From 9838e7626b01cb0978cc15511c750b478c25b1d8 Mon Sep 17 00:00:00 2001 From: Peanut Date: Sat, 23 May 2026 23:52:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=88=86=E6=9E=90=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E3=80=81=E6=8E=A5=E5=8F=A3=E7=AE=A1=E7=90=86=E5=8F=8A=E5=85=B6?= =?UTF-8?q?=E4=BB=96=E5=8A=9F=E8=83=BD=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: WebMvcConfig/拦截器/AnalyticsService/Mapper/测试优化,新增 Knife4jConfig、AnalyticsDictionary、数据库迁移脚本 - 前端: 分析仪表盘 UI 优化、接口管理列表及详情测试面板 - 小程序: analytics 服务优化、request 增强 - 文档: 分析模块中文标签设计文档、品牌重命名设计文档 - 部署: conf 配置优化、deploy.py 脚本更新 Co-Authored-By: Claude Opus 4.7 --- .../com/emotion/config/Knife4jConfig.java | 26 ++ .../java/com/emotion/config/WebMvcConfig.java | 17 +- .../emotion/interceptor/AuthInterceptor.java | 20 +- .../interceptor/JwtAuthInterceptor.java | 9 +- .../emotion/mapper/AnalyticsEventMapper.java | 7 +- .../analytics/AnalyticsDictionary.java | 222 ++++++++++++++++++ .../service/impl/AnalyticsServiceImpl.java | 40 ++-- .../V20260523__add_api_endpoint_tables.sql | 38 +++ .../emotion/service/AnalyticsServiceTest.java | 9 + conf/emotion-museum.conf | 20 +- deploy.py | 6 +- ...05-23-analytics-chinese-business-labels.md | 69 ++++++ ...nalytics-chinese-business-labels-design.md | 57 +++++ .../specs/2026-05-23-brand-rename-design.md | 174 ++++++++++++++ mini-program/src/services/analytics.js | 5 + mini-program/src/services/request.js | 76 ++++++ web-admin/src/api/analytics.ts | 9 + .../views/analytics/AnalyticsDashboard.vue | 153 +++++++++--- .../views/endpoint/EndpointDetailDialog.vue | 175 +++++++++++++- web-admin/src/views/endpoint/EndpointList.vue | 19 +- 20 files changed, 1073 insertions(+), 78 deletions(-) create mode 100644 backend-single/src/main/java/com/emotion/config/Knife4jConfig.java create mode 100644 backend-single/src/main/java/com/emotion/service/analytics/AnalyticsDictionary.java create mode 100644 backend-single/src/main/resources/db/migration/V20260523__add_api_endpoint_tables.sql create mode 100644 docs/superpowers/plans/2026-05-23-analytics-chinese-business-labels.md create mode 100644 docs/superpowers/specs/2026-05-23-analytics-chinese-business-labels-design.md create mode 100644 docs/superpowers/specs/2026-05-23-brand-rename-design.md diff --git a/backend-single/src/main/java/com/emotion/config/Knife4jConfig.java b/backend-single/src/main/java/com/emotion/config/Knife4jConfig.java new file mode 100644 index 0000000..c9d9337 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/config/Knife4jConfig.java @@ -0,0 +1,26 @@ +package com.emotion.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** + * Knife4j / OpenAPI 文档配置 + */ +@Configuration +public class Knife4jConfig { + + @Bean + public OpenAPI customOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("情绪博物馆 API 文档") + .version("1.0.0") + .description("情绪博物馆后端接口文档") + .contact(new Contact() + .name("emotion-museum") + .email(""))); + } +} diff --git a/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java b/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java index c3537c6..787201d 100644 --- a/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java +++ b/backend-single/src/main/java/com/emotion/config/WebMvcConfig.java @@ -34,6 +34,7 @@ public class WebMvcConfig implements WebMvcConfigurer { .order(1); // 优先级1,最先执行 // 普通用户拦截器 - 拦截除管理员路径外的所有请求 + // 注意: 由于 context-path=/api, excludePathPatterns 需要包含 /api 前缀的路径 registry.addInterceptor(jwtAuthInterceptor) .addPathPatterns("/**") .excludePathPatterns( @@ -47,10 +48,18 @@ public class WebMvcConfig implements WebMvcConfigurer { "/tts/audio/**", // Public generated TTS audio files "/health", // 健康检查接口 "/ws/**", // WebSocket接口 - "/swagger-ui/**", // Swagger UI - "/v3/api-docs/**", // API文档 - "/actuator/**", // 监控端点 - "/admin/**" // 排除管理员路径,由管理员拦截器处理 + "/swagger-ui", // Swagger UI + "/swagger-ui/**", // Swagger UI sub-paths + "/v3/api-docs", // API docs root + "/v3/api-docs/**", // API docs sub-paths + "/doc.html", // Knife4j entry + "/webjars", // Knife4j static resources + "/webjars/**", // Knife4j static resources sub-paths + "/swagger-resources", // Swagger resources root + "/swagger-resources/**", // Swagger resources sub-paths + "/actuator/**", // Actuator endpoints + "/admin/**", // 排除管理员路径,由管理员拦截器处理 + "/error" // Spring Boot error page ) .order(2); // 优先级2 } diff --git a/backend-single/src/main/java/com/emotion/interceptor/AuthInterceptor.java b/backend-single/src/main/java/com/emotion/interceptor/AuthInterceptor.java index c0c0f51..4cdfa71 100644 --- a/backend-single/src/main/java/com/emotion/interceptor/AuthInterceptor.java +++ b/backend-single/src/main/java/com/emotion/interceptor/AuthInterceptor.java @@ -115,10 +115,22 @@ public class AuthInterceptor implements HandlerInterceptor { return true; } - // Swagger文档 - if (requestURI.startsWith("/swagger-") || - requestURI.startsWith("/v2/api-docs") || - requestURI.startsWith("/webjars/")) { + // Swagger文档 / Knife4j / OpenAPI + // 注意:requestURI 包含 context-path /api,需同时支持两种路径格式 + if (requestURI.startsWith("/swagger-") || + requestURI.startsWith("/api/swagger-") || + requestURI.startsWith("/swagger-ui") || + requestURI.startsWith("/api/swagger-ui") || + requestURI.startsWith("/v2/api-docs") || + requestURI.startsWith("/api/v2/api-docs") || + requestURI.startsWith("/v3/api-docs") || + requestURI.startsWith("/api/v3/api-docs") || + requestURI.startsWith("/webjars/") || + requestURI.startsWith("/api/webjars/") || + requestURI.startsWith("/doc.html") || + requestURI.startsWith("/api/doc.html") || + requestURI.startsWith("/swagger-resources") || + requestURI.startsWith("/api/swagger-resources")) { return true; } diff --git a/backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java b/backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java index 6736e2e..fc69f87 100644 --- a/backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java +++ b/backend-single/src/main/java/com/emotion/interceptor/JwtAuthInterceptor.java @@ -101,10 +101,13 @@ public class JwtAuthInterceptor implements HandlerInterceptor { "/api/auth/register", "/api/auth/captcha", "/api/auth/refresh-token", + "/api/auth/resetPassword", "/api/health", - "/api/ws/chat", - "/swagger-ui", - "/v3/api-docs", + "/api/ws/", + "/api/swagger-ui", + "/api/v3/api-docs", + "/api/doc.html", + "/api/webjars/", "/actuator" }; diff --git a/backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java b/backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java index ddbea55..d4513d4 100644 --- a/backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java +++ b/backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java @@ -33,9 +33,12 @@ public interface AnalyticsEventMapper extends BaseMapper { @Select("SELECT COALESCE(AVG(duration_ms), 0) FROM t_analytics_event WHERE is_deleted = 0 AND event_name = 'page_leave' AND duration_ms IS NOT NULL AND duration_ms >= 0 AND occurred_at >= #{start} AND occurred_at < #{end}") Double avgStayMs(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end); - @Select("SELECT event_name AS eventName, event_type AS eventType, COUNT(*) AS count, COUNT(DISTINCT COALESCE(user_id, anonymous_id)) AS users " + + @Select("SELECT event_name AS eventName, event_type AS eventType, page_path AS pagePath, " + + "JSON_UNQUOTE(JSON_EXTRACT(properties, '$.api_path')) AS apiPath, " + + "COUNT(*) AS count, COUNT(DISTINCT COALESCE(user_id, anonymous_id)) AS users " + "FROM t_analytics_event WHERE is_deleted = 0 AND occurred_at >= #{start} AND occurred_at < #{end} " + - "GROUP BY event_name, event_type ORDER BY count DESC LIMIT #{limit}") + "GROUP BY event_name, event_type, page_path, JSON_UNQUOTE(JSON_EXTRACT(properties, '$.api_path')) " + + "ORDER BY count DESC LIMIT #{limit}") List selectTopEvents(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end, @Param("limit") int limit); diff --git a/backend-single/src/main/java/com/emotion/service/analytics/AnalyticsDictionary.java b/backend-single/src/main/java/com/emotion/service/analytics/AnalyticsDictionary.java new file mode 100644 index 0000000..cca647f --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/analytics/AnalyticsDictionary.java @@ -0,0 +1,222 @@ +package com.emotion.service.analytics; + +import org.springframework.util.StringUtils; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class AnalyticsDictionary { + + private static final Map EVENT_LABELS = mapOf( + "app_launch", "启动小程序", + "app_show", "打开小程序", + "app_hide", "离开小程序", + "page_view", "浏览页面", + "page_leave", "离开页面", + "script_home_view", "进入爽文生成页", + "script_my_scripts_click", "点击我的剧本", + "script_social_insights_click", "点击人生素材画像", + "script_social_import_entry_click", "点击导入社交数据", + "script_inspiration_select", "选择灵感推荐", + "script_inspiration_refresh", "刷新灵感推荐", + "script_voice_press_start", "开始按住说话", + "script_voice_press_end", "结束语音输入", + "script_voice_record_cancel", "取消语音输入", + "script_voice_recognize_success", "语音识别成功", + "script_voice_recognize_fail", "语音识别失败", + "script_wish_submit", "提交创作愿望", + "script_generation_progress_view", "查看生成进度", + "script_generate_success", "剧本生成成功", + "script_generate_fail", "剧本生成失败", + "script_generate_stream_fail", "流式生成失败", + "script_result_view", "查看生成结果", + "script_result_change_direction_click", "点击换个方向", + "script_result_not_like_me_click", "点击不像我", + "script_result_tts_click", "点击朗读结果", + "script_detail_view", "查看剧本详情", + "script_detail_tts_click", "点击详情朗读", + "script_tts_request", "请求生成朗读", + "script_tts_success", "朗读任务成功", + "script_tts_error", "朗读任务失败", + "script_tts_play", "播放朗读", + "script_tts_pause", "暂停朗读", + "script_tts_complete", "朗读播放完成", + "path_select", "选择实现路径", + "life_event_ai_assist", "点击 AI 辅助记录", + "life_event_create", "创建人生事件", + "life_event_update", "更新人生事件", + "life_event_favorite", "收藏人生事件", + "life_event_share", "分享人生事件", + "social_import_insights_click", "查看社交素材画像", + "social_import_manual_start", "开始手动导入", + "social_import_link_start", "开始链接导入", + "social_import_screenshot_start", "开始截图导入", + "social_import_submit_success", "社交数据导入成功", + "social_import_submit_fail", "社交数据导入失败", + "social_insights_view", "查看人生素材画像", + "social_insight_status_update", "更新素材状态", + "api_request_success", "接口调用成功", + "api_request_fail", "接口调用失败" + ); + + private static final Map EVENT_TYPE_LABELS = mapOf( + "app", "小程序", + "page", "页面行为", + "script", "剧本创作", + "tts", "朗读功能", + "life_event", "人生事件", + "social_import", "社交数据", + "api", "接口调用", + "custom", "自定义行为" + ); + + private static final Map PAGE_LABELS = mapOf( + "/pages/splash/index", "启动页", + "/pages/login/index", "登录页", + "/pages/onboarding/index", "引导页", + "/pages/main/index", "首页", + "/pages/main/ScriptView", "爽文生成页", + "/pages/main/RecordView", "人生轨迹页", + "/pages/main/MineView", "我的页面", + "/pages/main/ScriptDetailView", "剧本详情页", + "/pages/main/PathView", "实现路径页", + "/pages/life-event/form", "记录人生经历页", + "/pages/life-event/detail", "人生事件详情页", + "/pages/profile/index", "个人中心页", + "/pages/social-import/index", "社交数据导入页", + "/pages/social-import/preview", "导入预览页", + "/pages/social-import/insights", "人生素材画像页" + ); + + private static final Map DIMENSION_LABELS = mapOf( + "style", "剧本风格", + "length", "篇幅", + "source", "来源", + "tab", "所在栏目", + "platform", "平台", + "status", "状态", + "api_path", "接口" + ); + + private static final Map VALUE_LABELS = mapOf( + "script", "爽文生成", + "record", "人生轨迹", + "mine", "我的", + "career", "事业逆袭", + "romance", "情感成长", + "family", "亲情和解", + "growth", "自我成长", + "爽文", "爽文", + "short", "短篇", + "medium", "中篇", + "long", "长篇", + "text", "文本输入", + "home", "首页", + "recommendation", "推荐灵感", + "home_head", "首页头部", + "manual", "手动导入", + "link", "链接导入", + "screenshot", "截图导入", + "success", "成功", + "fail", "失败", + "failed", "失败", + "pending", "处理中", + "enabled", "已启用", + "disabled", "已停用" + ); + + private static final Map API_LABELS = mapOf( + "/analytics/events/batch", "上报行为埋点", + "/ai/runtime/stream", "AI 流式生成", + "/ai/runtime/invoke", "AI 通用调用", + "/asr/recognize", "语音识别", + "/tts/tasks", "创建朗读任务", + "/tts/tasks/by-source", "查询内容朗读任务", + "/lifeEvent/list", "查询人生事件", + "/lifeEvent/detail", "查看人生事件", + "/lifeEvent/create", "创建人生事件", + "/lifeEvent/update", "更新人生事件", + "/lifeEvent/delete", "删除人生事件", + "/lifePath/list", "查询实现路径", + "/lifePath/create", "创建实现路径", + "/lifePath/update", "更新实现路径", + "/epic-script/list", "查询我的剧本", + "/epic-script/detail", "查看剧本详情", + "/epic-script/create", "保存生成剧本", + "/epic-script/update", "更新剧本", + "/user-profile/create", "创建用户画像", + "/user-profile/update", "更新用户画像", + "/social-import", "社交数据导入", + "/auth/login", "登录", + "/auth/refresh", "刷新登录状态" + ); + + private AnalyticsDictionary() { + } + + public static String eventLabel(String eventName) { + return label(EVENT_LABELS, eventName); + } + + public static String eventTypeLabel(String eventType) { + return label(EVENT_TYPE_LABELS, eventType); + } + + public static String pageLabel(String pagePath) { + if (!StringUtils.hasText(pagePath)) { + return "未记录页面"; + } + String normalized = stripQuery(pagePath); + return PAGE_LABELS.getOrDefault(normalized, normalized); + } + + public static String dimensionLabel(String dimension) { + return label(DIMENSION_LABELS, dimension); + } + + public static String valueLabel(String dimension, String value) { + if (!StringUtils.hasText(value)) { + return "未记录"; + } + if ("api_path".equals(dimension)) { + return apiLabel(value); + } + return VALUE_LABELS.getOrDefault(value, value); + } + + public static String apiLabel(String apiPath) { + if (!StringUtils.hasText(apiPath)) { + return "未记录接口"; + } + String normalized = stripQuery(apiPath); + if (API_LABELS.containsKey(normalized)) { + return API_LABELS.get(normalized); + } + for (Map.Entry entry : API_LABELS.entrySet()) { + if (normalized.startsWith(entry.getKey() + "/")) { + return entry.getValue(); + } + } + return normalized; + } + + private static String label(Map labels, String key) { + if (!StringUtils.hasText(key)) { + return "未记录"; + } + return labels.getOrDefault(key, key); + } + + private static String stripQuery(String value) { + int queryIndex = value.indexOf('?'); + return queryIndex >= 0 ? value.substring(0, queryIndex) : value; + } + + private static Map mapOf(String... entries) { + Map map = new LinkedHashMap<>(); + for (int i = 0; i + 1 < entries.length; i += 2) { + map.put(entries[i], entries[i + 1]); + } + return map; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java index aa4b923..34223c8 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java @@ -15,6 +15,7 @@ import com.emotion.dto.response.analytics.AnalyticsUserItem; import com.emotion.entity.AnalyticsEvent; import com.emotion.mapper.AnalyticsEventMapper; import com.emotion.service.AnalyticsService; +import com.emotion.service.analytics.AnalyticsDictionary; import com.emotion.util.UserContextHolder; import org.springframework.stereotype.Service; import org.springframework.util.CollectionUtils; @@ -45,17 +46,7 @@ public class AnalyticsServiceImpl extends ServiceImpl FUNNEL_LABELS = Map.of( - "app_launch", "App launch", - "page_view", "Page view", - "script_inspiration_click", "Inspiration click", - "script_generate_start", "Generate start", - "script_generate_success", "Generate success", - "script_detail_view", "Script detail", - "path_select", "Path select", - "script_tts_play", "TTS play" - ); - private static final List PREFERENCE_DIMENSIONS = List.of("style", "length", "source", "tab"); + private static final List PREFERENCE_DIMENSIONS = List.of("style", "length", "source", "tab", "platform"); public static boolean isSafeEventName(String eventName) { return StringUtils.hasText(eventName) && SAFE_NAME.matcher(eventName).matches(); @@ -122,7 +113,9 @@ public class AnalyticsServiceImpl extends ServiceImpl getTrend(AnalyticsQueryRequest request) { DateRange range = resolveRange(request); - return baseMapper.selectTrend(range.start, range.end, resolveDateFormat(request)); + List result = baseMapper.selectTrend(range.start, range.end, resolveDateFormat(request)); + result.forEach(this::fillTrendLabels); + return result; } @Override @@ -143,7 +136,7 @@ public class AnalyticsServiceImpl extends ServiceImpl getTopEvents(AnalyticsQueryRequest request) { DateRange range = resolveRange(request); - return baseMapper.selectTopEvents(range.start, range.end, resolveLimit(request)); + List result = baseMapper.selectTopEvents(range.start, range.end, resolveLimit(request)); + result.forEach(this::fillTopEventLabels); + return result; } @Override @@ -260,6 +256,22 @@ public class AnalyticsServiceImpl extends ServiceImpl **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make the behavior analytics page display Chinese business descriptions for events, pages, buttons, preferences, and API calls. + +**Architecture:** Add a backend analytics dictionary that enriches aggregation DTOs with Chinese labels, add request-level API analytics in the mini program, and update web-admin to consume label fields and show an API call panel. + +**Tech Stack:** Spring Boot, MyBatis Plus, Vue 3, Element Plus, ECharts, uni-app mini program. + +--- + +### Task 1: Backend Analytics Dictionary + +**Files:** +- Create: `backend-single/src/main/java/com/emotion/service/analytics/AnalyticsDictionary.java` +- Modify: `backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTopEventItem.java` +- Modify: `backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTrendItem.java` +- Modify: `backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsPreferenceItem.java` + +- [x] Add event, event type, page, dimension, value, and API label maps. +- [x] Add DTO fields for Chinese labels while keeping original keys. +- [x] Add helper methods for page and API labels. + +### Task 2: Backend Aggregation Enrichment + +**Files:** +- Modify: `backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java` +- Modify: `backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java` + +- [x] Query `page_path` and `api_path` for top events. +- [x] Fill `eventLabel`, `eventTypeLabel`, `pageLabel`, `apiLabel` in top events. +- [x] Fill trend event labels and preference labels. +- [x] Replace funnel labels with Chinese labels. + +### Task 3: Mini Program API Analytics + +**Files:** +- Modify: `mini-program/src/services/request.js` + +- [x] Add API label map. +- [x] Track `api_request_success` and `api_request_fail`. +- [x] Include path, method, status, label, and duration. +- [x] Skip `/analytics/events/batch`. + +### Task 4: Admin Analytics Page + +**Files:** +- Modify: `web-admin/src/api/analytics.ts` +- Modify: `web-admin/src/views/analytics/AnalyticsDashboard.vue` + +- [x] Add TypeScript fields for backend labels. +- [x] Render chart legend with `eventLabel`. +- [x] Replace all visible analytics text with Chinese. +- [x] Add API call table using top events filtered by `api` type. +- [x] Show Chinese page and preference labels. + +### Task 5: Verification + +**Commands:** +- `cd backend-single && mvn test` +- `cd web-admin && npm run build` +- `cd mini-program && npm run build:mp-weixin` or available project build command +- `git diff --check` + +- [x] Backend tests pass. +- [x] web-admin build passes. +- [x] mini-program build/check passes. +- [x] Diff check has no new whitespace errors. diff --git a/docs/superpowers/specs/2026-05-23-analytics-chinese-business-labels-design.md b/docs/superpowers/specs/2026-05-23-analytics-chinese-business-labels-design.md new file mode 100644 index 0000000..3b7ac65 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-analytics-chinese-business-labels-design.md @@ -0,0 +1,57 @@ +# 行为分析中文业务标签设计 + +## 目标 + +行为分析页面必须用运营人员能理解的中文展示小程序用户行为,包括用户停留页面、点击操作、创作链路、偏好维度和调用接口。页面不再直接展示 `page_view`、`script_home_view`、`/pages/main/index`、`style/career` 等英文 key。 + +## 范围 + +- 后端聚合接口增加中文业务字段,保留原始 key 作为排查依据。 +- 小程序请求层补充 API 调用埋点,记录接口路径、中文接口名、成功/失败、耗时。 +- web-admin 行为分析页全部中文化,并优化表格信息结构。 +- 不迁移历史数据,不改变现有上报字段和存储表结构。 + +## 后端设计 + +新增分析字典类,集中维护: + +- 事件中文名:如 `page_view` → `浏览页面`、`script_generate_success` → `剧本生成成功`。 +- 事件类型中文名:如 `page` → `页面行为`、`script` → `剧本创作`、`api` → `接口调用`。 +- 页面中文名:如 `/pages/main/index` → `首页`、`/pages/main/ScriptView` → `爽文生成页`。 +- 偏好维度和值:如 `style` → `剧本风格`、`career` → `事业逆袭`。 +- 接口中文名:如 `/ai/runtime/stream` → `AI 流式生成`、`/tts/tasks` → `创建朗读任务`。 + +DTO 增加中文字段: + +- `AnalyticsTopEventItem`: `eventLabel`、`eventTypeLabel`、`pagePath`、`pageLabel`、`apiPath`、`apiLabel`。 +- `AnalyticsTrendItem`: `eventLabel`。 +- `AnalyticsPreferenceItem`: `dimensionLabel`、`valueLabel`。 +- `AnalyticsFunnelItem`: `label` 改为中文来源。 + +后端服务在聚合结果返回前统一填充这些字段。 + +## 小程序设计 + +在 `services/request.js` 中记录接口调用埋点: + +- 成功:`api_request_success` +- 失败:`api_request_fail` + +埋点属性包含 `api_path`、`api_label`、`method`、`status_code`、`duration_ms`。跳过 `/analytics/events/batch`,避免上报接口自身造成循环。 + +## 管理端设计 + +行为分析页保持现有暗色主题,优化为: + +- 指标卡使用中文:页面浏览量、访问用户数、行为事件数、活跃用户数、朗读请求数、平均停留时长。 +- 趋势图图例使用中文事件名。 +- 漏斗步骤使用中文业务动作。 +- 热门行为表展示中文行为、所在页面、类型、次数、用户。 +- 新增接口调用表,展示接口中文名、调用状态、次数、用户。 +- 偏好分布表展示中文维度和值。 + +## 验证 + +- 后端 `mvn test` 通过。 +- web-admin `npm run build` 通过。 +- mini-program 执行可用构建或类型检查命令,通过后确认无循环埋点。 diff --git a/docs/superpowers/specs/2026-05-23-brand-rename-design.md b/docs/superpowers/specs/2026-05-23-brand-rename-design.md new file mode 100644 index 0000000..1ec961c --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-brand-rename-design.md @@ -0,0 +1,174 @@ +--- +author: Peanut +created_at: 2026-05-23 +purpose: 全项目品牌重命名方案,从"情绪博物馆"到"开心星球" +--- + +# 情绪博物馆 → 开心星球 品牌重命名方案 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将项目中所有"情绪博物馆"相关文案(中文/英文/标识符)替换为"开心星球"对应名称,分层递进执行,保障数据安全和服务可用性。 + +**Architecture:** 分 4 步递进执行:文档层 → 用户可见层 → 配置层 → 底层标识。每步独立可验证,出问题可回退。 + +**Tech Stack:** grep/sed 批量替换、mysqldump 数据库迁移、git 版本控制、Spring Boot 配置切换 + +--- + +## 命名映射表 + +| 旧标识 | 新标识 | 使用场景 | +|---|---|---| +| `情绪博物馆` | `开心星球` | 中文文案(页面标题、提示语、文档) | +| `Emotion Museum` | `Happy Planet` | 英文标题 | +| `emotion-museum` | `happy-planet` | kebab-case(路径、文件名、service 名、jar 名) | +| `EmotionMuseum` | `HappyPlanet` | PascalCase(类名前缀) | +| `emotion_museum` | `happy_planet` | snake_case(数据库名、SQL) | +| `com.emotion` | `com.happyplanet` | Java 包名 | +| `emotion-museum.conf` | `happy-planet.conf` | nginx 配置名 | +| `emotion` | `happyplanet` | 包名前缀 | + +**保持不变的项**: +- URL 路径(如 `/emotion-museum-admin/`)— 保持兼容 +- 目录名(`web/`、`web-admin/`、`backend-single/`)— 避免构建断裂 +- 数据库表名前缀(如 `emotion_record` → 保持)— 避免 Entity/Table 映射断裂 + +## 第 1 步:文档层(无风险) + +**影响范围**:纯文档文件,不改任何代码或配置 + +**文件列表**(约 20 个): +- `CLAUDE.md` +- `README.md` +- `docs/superpowers/` 下所有 .md +- 各模块下的 `部署说明.md`、`技术方案.md`、`后端代码规范.md` 等 +- `backend-single/部署说明.md`、`backend-single/后端项目结构.md` 等 + +**操作**:批量 sed 替换中文 `情绪博物馆` → `开心星球`,英文 `Emotion Museum` → `Happy Planet`,`emotion-museum` → `happy-planet` + +**验证**:grep 确认无残留 + +## 第 2 步:用户可见层(低风险) + +**影响范围**:用户实际看到的页面文案和提示信息 + +**文件列表**: +- `web/src/` 下 Vue 组件中的中文标题 +- `web-admin/src/` 下 Vue 组件中的中文标题(登录页、布局、各页面) +- `mini-program/src/` 下页面标题和提示文案 +- `.env.development` 和 `.env.production` 中的 `VITE_APP_TITLE` +- `package.json` 中的 `name`/`description` +- 部署脚本中的部署完成提示 + +**操作**: +1. `web/src/` 和 `web-admin/src/` 中替换 `情绪博物馆` → `开心星球` +2. `mini-program/src/` 中替换 +3. `.env` 文件中 `VITE_APP_TITLE` 改为 `开心星球管理后台` / `开心星球` + +**验证**:启动前端开发服务器,浏览器验证各页面标题和文案 + +## 第 3 步:配置层(中等风险,需验证) + +**影响范围**:配置文件、部署脚本、nginx、systemd service + +### 3.1 应用配置 +- `backend-single/src/main/resources/application.yml` — `spring.application.name` +- `backend-single/src/main/resources/application-*.yml` — 同上 +- 日志路径、应用描述等配置中的 `emotion-museum` + +### 3.2 部署脚本 +- `deploy.sh`、`deploy.py` +- `backend-single/deploy.py`、`backend-single/deploy.sh` +- `web/deploy.sh`、`web/deploy.py` +- `web-admin/deploy.sh`、`web-admin/deploy.py` +- 各脚本中的路径引用、日志路径、提示文案 + +### 3.3 nginx 配置 +- `conf/emotion-museum.conf` → 重命名为 `happy-planet.conf` +- 内部路径 `/emotion-museum-admin/` 保持不变(URL 兼容) +- `emotion-museum` 路径引用在注释中的改为 `happy-planet` +- jar 文件名、日志路径引用中的 `emotion-museum` + +### 3.4 systemd Service +- `backend-single/asr-service/emotion-museum-asr.service` → `happy-planet-asr.service` +- `backend-single/tts-service/emotion-museum-tts.service` → `happy-planet-tts.service` +- 内部路径和描述中的 `emotion-museum` + +### 3.5 工具脚本 +- `tools/service_manager.py` +- `manage.py` +- `manage.conf.yaml` + +### 数据库迁移 + +**操作**: +1. `mysqldump -h ... -u ... -p emotion_museum > emotion_museum_backup.sql` +2. 创建新库:`CREATE DATABASE happy_planet DEFAULT CHARACTER SET utf8mb4` +3. `sed -i 's/emotion_museum/happy_planet/g' emotion_museum_backup.sql` +4. `mysql -h ... -u ... -p happy_planet < emotion_museum_backup.sql` +5. 更新 `application-prod.yml` 中的 `spring.datasource.url` 为 `happy_planet` +6. 重启后端服务,验证功能 +7. 保留旧库 `emotion_museum` 备份,确认无误后删除 + +**回退方案**:改回 `application-prod.yml` 中的数据库名即可恢复 + +## 第 4 步:底层标识(高风险,最后执行) + +### 4.1 Java 包名重命名 + +**操作**: +1. 移动目录:`backend-single/src/main/java/com/emotion/` → `backend-single/src/main/java/com/happyplanet/` +2. 更新 `pom.xml` 中的包名引用 +3. 用 sed 批量替换所有 `.java` 文件中 `package com.emotion` → `package com.happyplanet` +4. 用 sed 批量替换所有 `.java` 文件中 `import com.emotion` → `import com.happyplanet` +5. 编译验证:`mvn clean install -DskipTests` +6. 处理编译错误(可能有遗漏的引用) + +**测试目录**: +- `backend-single/src/test/java/com/emotion/` 同样需要迁移 +- `.java` 文件中的 `package` 和 `import` 语句 + +### 4.2 SQL 文件 + +**操作**: +- `sql/emotion_museum_init.sql` → `happy_planet_init.sql` +- `sql/emotion_museum_ddl.sql` → `happy_planet_ddl.sql` +- `sql/emotion_museum.sql` → `happy_planet.sql` +- 文件内部 `CREATE DATABASE emotion_museum` → `happy_planet` +- `USE emotion_museum` → `USE happy_planet` + +### 4.3 其他底层引用 +- `backend-single/create_api_tables.sql` 中的数据库名 +- `.gitignore` 中的路径引用(如有) + +**验证**: +1. `mvn clean install -DskipTests` 编译通过 +2. 启动后端服务,健康检查通过 +3. 前端页面可正常访问,API 调用正常 + +## 执行顺序与依赖 + +``` +步骤 1 (文档) → 步骤 2 (用户可见) → 步骤 3 (配置) → 步骤 4 (底层标识) + ↓ + 数据库迁移 (3.6) + 验证通过后再继续 4 +``` + +## 回退策略 + +- 每个步骤完成后立即 git commit +- 数据库迁移保留旧库 +- URL 路径全部不变,避免 404 +- 如有编译错误,git revert 最近 commit + +## 风险点与应对 + +| 风险 | 应对 | +|---|---| +| Java 包名迁移遗漏引用 | 分步编译,逐层修复 | +| 数据库迁移数据丢失 | 迁移前完整备份 | +| nginx 配置断裂 | 保留旧配置备份,先 cp 再改 | +| 部署脚本路径断裂 | 先本地 grep 验证,再上传 | +| Java 编译内存不足 | 临时增加 `-Xmx2048m` | diff --git a/mini-program/src/services/analytics.js b/mini-program/src/services/analytics.js index 0d6f675..22c5182 100644 --- a/mini-program/src/services/analytics.js +++ b/mini-program/src/services/analytics.js @@ -12,6 +12,8 @@ let pageEnterAt = {} let flushTimer = null let initialized = false +const runtimeRoot = typeof globalThis !== 'undefined' ? globalThis : {} + const now = () => Date.now() const uuid = (prefix) => `${prefix}_${now()}_${Math.random().toString(36).slice(2, 10)}` @@ -60,6 +62,9 @@ const safeProperties = (properties = {}) => { export const initAnalytics = () => { if (initialized) return initialized = true + runtimeRoot.__emotionAnalyticsTrack = (eventName, properties = {}, options = {}) => { + track(eventName, properties, options) + } sessionId = uuid('session') loadQueue() track('app_launch', {}, { eventType: 'app' }) diff --git a/mini-program/src/services/request.js b/mini-program/src/services/request.js index 5e5df14..0315a3a 100644 --- a/mini-program/src/services/request.js +++ b/mini-program/src/services/request.js @@ -3,9 +3,74 @@ import { getEnvValue, getConfig } from '../config/env.js' const API_BASE_URL = getEnvValue('API_BASE_URL') const AUTH_PATH_PREFIX = '/auth/' +const ANALYTICS_BATCH_PATH = '/analytics/events/batch' const isAuthPath = (path = '') => path.startsWith(AUTH_PATH_PREFIX) +const API_LABELS = { + '/auth/login': '登录', + '/auth/refresh': '刷新登录状态', + '/user-profile/create': '创建用户画像', + '/user-profile/update': '更新用户画像', + '/lifeEvent/list': '查询人生事件', + '/lifeEvent/detail': '查看人生事件', + '/lifeEvent/create': '创建人生事件', + '/lifeEvent/update': '更新人生事件', + '/lifeEvent/delete': '删除人生事件', + '/lifePath/list': '查询实现路径', + '/lifePath/create': '创建实现路径', + '/lifePath/update': '更新实现路径', + '/epic-script/list': '查询我的剧本', + '/epic-script/detail': '查看剧本详情', + '/epic-script/create': '保存生成剧本', + '/epic-script/update': '更新剧本', + '/ai/runtime/stream': 'AI 流式生成', + '/asr/recognize': '语音识别', + '/tts/tasks': '创建朗读任务', + '/tts/tasks/by-source': '查询内容朗读任务', + '/social-import': '社交数据导入' +} + +const normalizePath = (path = '') => { + const queryIndex = path.indexOf('?') + return queryIndex >= 0 ? path.slice(0, queryIndex) : path +} + +const apiLabel = (path = '') => { + const normalized = normalizePath(path) + if (API_LABELS[normalized]) return API_LABELS[normalized] + const matched = Object.keys(API_LABELS).find(key => normalized.startsWith(`${key}/`)) + return matched ? API_LABELS[matched] : normalized || '未知接口' +} + +const currentPagePath = () => { + try { + const pages = getCurrentPages?.() || [] + const current = pages[pages.length - 1] + return current?.route ? `/${current.route}` : '' + } catch (error) { + return '' + } +} + +const trackApiRequest = (eventName, options, meta = {}) => { + const normalized = normalizePath(options.url || '') + if (!normalized || normalized === ANALYTICS_BATCH_PATH) return + const hook = typeof globalThis !== 'undefined' ? globalThis.__emotionAnalyticsTrack : null + if (typeof hook !== 'function') return + hook(eventName, { + api_path: normalized, + api_label: apiLabel(normalized), + method: options.method || 'GET', + status_code: meta.statusCode || 0, + duration_ms: meta.durationMs || 0 + }, { + eventType: 'api', + pagePath: currentPagePath(), + durationMs: meta.durationMs + }) +} + const createRequestError = (message, meta = {}) => { const error = new Error(message || 'Request failed') Object.assign(error, meta) @@ -52,6 +117,7 @@ const request = (options) => { const method = options.method || 'GET' const fullUrl = `${API_BASE_URL}${options.url}` const forceLog = isAuthPath(options.url) + const startedAt = Date.now() logApi('log', 'request', { method, @@ -70,6 +136,11 @@ const request = (options) => { success: (res) => { const { data, statusCode } = res const success = statusCode >= 200 && statusCode < 300 && (data?.code === 200 || data?.code === 0) + const durationMs = Date.now() - startedAt + trackApiRequest(success ? 'api_request_success' : 'api_request_fail', { ...options, method }, { + statusCode, + durationMs + }) logApi('log', 'response', { path: options.url, @@ -106,6 +177,11 @@ const request = (options) => { })) }, fail: (err) => { + const durationMs = Date.now() - startedAt + trackApiRequest('api_request_fail', { ...options, method }, { + statusCode: 0, + durationMs + }) logApi('error', 'fail', { path: options.url, url: fullUrl, diff --git a/web-admin/src/api/analytics.ts b/web-admin/src/api/analytics.ts index 1461acc..f0cfe77 100644 --- a/web-admin/src/api/analytics.ts +++ b/web-admin/src/api/analytics.ts @@ -13,13 +13,20 @@ export interface AnalyticsOverview { export interface AnalyticsTrendItem { bucket: string eventName: string + eventLabel?: string count: number users: number } export interface AnalyticsTopEvent { eventName: string + eventLabel?: string eventType: string + eventTypeLabel?: string + pagePath?: string + pageLabel?: string + apiPath?: string + apiLabel?: string count: number users: number } @@ -33,7 +40,9 @@ export interface AnalyticsFunnelItem { export interface AnalyticsPreferenceItem { dimension: string + dimensionLabel?: string value: string + valueLabel?: string count: number users: number } diff --git a/web-admin/src/views/analytics/AnalyticsDashboard.vue b/web-admin/src/views/analytics/AnalyticsDashboard.vue index 8db70cc..6a0c1d4 100644 --- a/web-admin/src/views/analytics/AnalyticsDashboard.vue +++ b/web-admin/src/views/analytics/AnalyticsDashboard.vue @@ -3,44 +3,44 @@

行为分析

-

小程序访问、创作链路和朗读行为概览

+

查看小程序访问、页面停留、点击操作、创作链路和接口调用情况

- 刷新 + 刷新数据
- +
{{ card.value }}
{{ card.label }}
- +
- - + +
- - - + + + - + @@ -49,27 +49,53 @@
+ + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + - - - - - - - - + + +
+
+
{{ item.title }}
+
{{ item.desc }}
+
+
@@ -113,12 +139,30 @@ const trendChartRef = ref() let trendChart: echarts.ECharts | null = null const cards = computed(() => [ - { label: 'PV', value: overview.value.pv.toLocaleString() }, - { label: 'UV', value: overview.value.uv.toLocaleString() }, - { label: '事件数', value: overview.value.eventCount.toLocaleString() }, - { label: '活跃用户', value: overview.value.activeUsers.toLocaleString() }, - { label: '朗读请求', value: overview.value.ttsRequests.toLocaleString() }, - { label: '平均停留', value: `${Math.round((overview.value.avgStayMs || 0) / 1000)}s` } + { label: '页面浏览量', value: overview.value.pv.toLocaleString() }, + { label: '访问用户数', value: overview.value.uv.toLocaleString() }, + { label: '行为事件数', value: overview.value.eventCount.toLocaleString() }, + { label: '活跃用户数', value: overview.value.activeUsers.toLocaleString() }, + { label: '朗读请求数', value: overview.value.ttsRequests.toLocaleString() }, + { label: '平均停留时长', value: formatDuration(overview.value.avgStayMs || 0) } +]) + +const behaviorEvents = computed(() => topEvents.value.filter(item => item.eventType !== 'api')) +const apiEvents = computed(() => topEvents.value.filter(item => item.eventType === 'api' || item.eventName.startsWith('api_request_'))) + +const insightItems = computed(() => [ + { + title: '行为名称', + desc: '所有事件都显示为中文业务动作,例如“浏览页面”“提交创作愿望”“剧本生成成功”。' + }, + { + title: '所在页面', + desc: '页面路径统一转换为小程序中的中文页面名称,例如“首页”“爽文生成页”“剧本详情页”。' + }, + { + title: '接口说明', + desc: '接口调用按中文用途展示,例如“AI 流式生成”“创建朗读任务”“查询人生事件”。' + } ]) const queryParams = computed(() => { @@ -132,6 +176,14 @@ const queryParams = computed(() => { const formatPercent = (value: number) => `${Math.round((value || 0) * 100)}%` +const formatDuration = (ms: number) => { + const seconds = Math.round(ms / 1000) + if (seconds < 60) return `${seconds}秒` + const minutes = Math.floor(seconds / 60) + const rest = seconds % 60 + return rest ? `${minutes}分${rest}秒` : `${minutes}分钟` +} + const responseData = (response: unknown, fallback: T): T => { const payload = response as { data?: T } return payload.data ?? fallback @@ -143,8 +195,9 @@ const renderTrend = async () => { if (!trendChart) trendChart = echarts.init(trendChartRef.value) const buckets = [...new Set(trend.value.map(item => item.bucket))] const eventNames = [...new Set(trend.value.map(item => item.eventName))].slice(0, 5) + const labelByEvent = new Map(trend.value.map(item => [item.eventName, item.eventLabel || item.eventName])) const series = eventNames.map(eventName => ({ - name: eventName, + name: labelByEvent.get(eventName) || eventName, type: 'line', smooth: true, data: buckets.map(bucket => trend.value.find(item => item.bucket === bucket && item.eventName === eventName)?.count || 0) @@ -167,7 +220,7 @@ const fetchData = async () => { const [overviewRes, trendRes, topRes, funnelRes, preferenceRes] = await Promise.all([ getAnalyticsOverview(params), getAnalyticsTrend(params), - getAnalyticsTopEvents({ ...params, limit: 20 }), + getAnalyticsTopEvents({ ...params, limit: 60 }), getAnalyticsFunnel(params), getAnalyticsPreferences({ ...params, limit: 20 }) ]) @@ -233,6 +286,12 @@ onBeforeUnmount(() => { .stat-card { min-height: 96px; + padding: 18px 20px; + border: 1px solid var(--ls-glass-border); + border-radius: var(--ls-radius-lg); + background: rgba(15, 17, 26, 0.38); + box-shadow: var(--ls-shadow); + backdrop-filter: blur(20px) saturate(160%); } .stat-value { @@ -257,5 +316,31 @@ onBeforeUnmount(() => { height: 320px; width: 100%; } + + .insight-list { + display: grid; + gap: 12px; + min-height: 276px; + align-content: start; + } + + .insight-item { + padding: 14px 16px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: var(--ls-radius-md); + background: rgba(0, 0, 0, 0.16); + } + + .insight-title { + color: var(--ls-text); + font-weight: 700; + margin-bottom: 6px; + } + + .insight-desc { + color: rgba(226, 232, 240, 0.62); + font-size: 13px; + line-height: 1.7; + } } diff --git a/web-admin/src/views/endpoint/EndpointDetailDialog.vue b/web-admin/src/views/endpoint/EndpointDetailDialog.vue index f06b659..07b4c64 100644 --- a/web-admin/src/views/endpoint/EndpointDetailDialog.vue +++ b/web-admin/src/views/endpoint/EndpointDetailDialog.vue @@ -65,15 +65,69 @@ - - - + +
+ + 参数结构已预填充,修改值后点击"发送请求" +
+ + + +
+
+ Authorization: + {{ currentToken ? `Bearer ${currentToken.slice(0, 20)}...` : '未设置' }} +
+
+ Content-Type: + application/json +
+
+
+ 发送请求 @@ -98,11 +152,13 @@