feat: 分析模块、接口管理及其他功能优化
- 后端: WebMvcConfig/拦截器/AnalyticsService/Mapper/测试优化,新增 Knife4jConfig、AnalyticsDictionary、数据库迁移脚本 - 前端: 分析仪表盘 UI 优化、接口管理列表及详情测试面板 - 小程序: analytics 服务优化、request 增强 - 文档: 分析模块中文标签设计文档、品牌重命名设计文档 - 部署: conf 配置优化、deploy.py 脚本更新 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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("")));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
.order(1); // 优先级1,最先执行
|
.order(1); // 优先级1,最先执行
|
||||||
|
|
||||||
// 普通用户拦截器 - 拦截除管理员路径外的所有请求
|
// 普通用户拦截器 - 拦截除管理员路径外的所有请求
|
||||||
|
// 注意: 由于 context-path=/api, excludePathPatterns 需要包含 /api 前缀的路径
|
||||||
registry.addInterceptor(jwtAuthInterceptor)
|
registry.addInterceptor(jwtAuthInterceptor)
|
||||||
.addPathPatterns("/**")
|
.addPathPatterns("/**")
|
||||||
.excludePathPatterns(
|
.excludePathPatterns(
|
||||||
@@ -47,10 +48,18 @@ public class WebMvcConfig implements WebMvcConfigurer {
|
|||||||
"/tts/audio/**", // Public generated TTS audio files
|
"/tts/audio/**", // Public generated TTS audio files
|
||||||
"/health", // 健康检查接口
|
"/health", // 健康检查接口
|
||||||
"/ws/**", // WebSocket接口
|
"/ws/**", // WebSocket接口
|
||||||
"/swagger-ui/**", // Swagger UI
|
"/swagger-ui", // Swagger UI
|
||||||
"/v3/api-docs/**", // API文档
|
"/swagger-ui/**", // Swagger UI sub-paths
|
||||||
"/actuator/**", // 监控端点
|
"/v3/api-docs", // API docs root
|
||||||
"/admin/**" // 排除管理员路径,由管理员拦截器处理
|
"/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
|
.order(2); // 优先级2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,10 +115,22 @@ public class AuthInterceptor implements HandlerInterceptor {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Swagger文档
|
// Swagger文档 / Knife4j / OpenAPI
|
||||||
|
// 注意:requestURI 包含 context-path /api,需同时支持两种路径格式
|
||||||
if (requestURI.startsWith("/swagger-") ||
|
if (requestURI.startsWith("/swagger-") ||
|
||||||
|
requestURI.startsWith("/api/swagger-") ||
|
||||||
|
requestURI.startsWith("/swagger-ui") ||
|
||||||
|
requestURI.startsWith("/api/swagger-ui") ||
|
||||||
requestURI.startsWith("/v2/api-docs") ||
|
requestURI.startsWith("/v2/api-docs") ||
|
||||||
requestURI.startsWith("/webjars/")) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,10 +101,13 @@ public class JwtAuthInterceptor implements HandlerInterceptor {
|
|||||||
"/api/auth/register",
|
"/api/auth/register",
|
||||||
"/api/auth/captcha",
|
"/api/auth/captcha",
|
||||||
"/api/auth/refresh-token",
|
"/api/auth/refresh-token",
|
||||||
|
"/api/auth/resetPassword",
|
||||||
"/api/health",
|
"/api/health",
|
||||||
"/api/ws/chat",
|
"/api/ws/",
|
||||||
"/swagger-ui",
|
"/api/swagger-ui",
|
||||||
"/v3/api-docs",
|
"/api/v3/api-docs",
|
||||||
|
"/api/doc.html",
|
||||||
|
"/api/webjars/",
|
||||||
"/actuator"
|
"/actuator"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -33,9 +33,12 @@ public interface AnalyticsEventMapper extends BaseMapper<AnalyticsEvent> {
|
|||||||
@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}")
|
@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);
|
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} " +
|
"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<AnalyticsTopEventItem> selectTopEvents(@Param("start") LocalDateTime start,
|
List<AnalyticsTopEventItem> selectTopEvents(@Param("start") LocalDateTime start,
|
||||||
@Param("end") LocalDateTime end,
|
@Param("end") LocalDateTime end,
|
||||||
@Param("limit") int limit);
|
@Param("limit") int limit);
|
||||||
|
|||||||
@@ -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<String, String> 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<String, String> EVENT_TYPE_LABELS = mapOf(
|
||||||
|
"app", "小程序",
|
||||||
|
"page", "页面行为",
|
||||||
|
"script", "剧本创作",
|
||||||
|
"tts", "朗读功能",
|
||||||
|
"life_event", "人生事件",
|
||||||
|
"social_import", "社交数据",
|
||||||
|
"api", "接口调用",
|
||||||
|
"custom", "自定义行为"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final Map<String, String> 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<String, String> DIMENSION_LABELS = mapOf(
|
||||||
|
"style", "剧本风格",
|
||||||
|
"length", "篇幅",
|
||||||
|
"source", "来源",
|
||||||
|
"tab", "所在栏目",
|
||||||
|
"platform", "平台",
|
||||||
|
"status", "状态",
|
||||||
|
"api_path", "接口"
|
||||||
|
);
|
||||||
|
|
||||||
|
private static final Map<String, String> 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<String, String> 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<String, String> entry : API_LABELS.entrySet()) {
|
||||||
|
if (normalized.startsWith(entry.getKey() + "/")) {
|
||||||
|
return entry.getValue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String label(Map<String, String> 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<String, String> mapOf(String... entries) {
|
||||||
|
Map<String, String> map = new LinkedHashMap<>();
|
||||||
|
for (int i = 0; i + 1 < entries.length; i += 2) {
|
||||||
|
map.put(entries[i], entries[i + 1]);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.emotion.dto.response.analytics.AnalyticsUserItem;
|
|||||||
import com.emotion.entity.AnalyticsEvent;
|
import com.emotion.entity.AnalyticsEvent;
|
||||||
import com.emotion.mapper.AnalyticsEventMapper;
|
import com.emotion.mapper.AnalyticsEventMapper;
|
||||||
import com.emotion.service.AnalyticsService;
|
import com.emotion.service.AnalyticsService;
|
||||||
|
import com.emotion.service.analytics.AnalyticsDictionary;
|
||||||
import com.emotion.util.UserContextHolder;
|
import com.emotion.util.UserContextHolder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
@@ -45,17 +46,7 @@ public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, Anal
|
|||||||
"app_launch", "page_view", "script_inspiration_click", "script_generate_start",
|
"app_launch", "page_view", "script_inspiration_click", "script_generate_start",
|
||||||
"script_generate_success", "script_detail_view", "path_select", "script_tts_play"
|
"script_generate_success", "script_detail_view", "path_select", "script_tts_play"
|
||||||
);
|
);
|
||||||
private static final Map<String, String> FUNNEL_LABELS = Map.of(
|
private static final List<String> PREFERENCE_DIMENSIONS = List.of("style", "length", "source", "tab", "platform");
|
||||||
"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<String> PREFERENCE_DIMENSIONS = List.of("style", "length", "source", "tab");
|
|
||||||
|
|
||||||
public static boolean isSafeEventName(String eventName) {
|
public static boolean isSafeEventName(String eventName) {
|
||||||
return StringUtils.hasText(eventName) && SAFE_NAME.matcher(eventName).matches();
|
return StringUtils.hasText(eventName) && SAFE_NAME.matcher(eventName).matches();
|
||||||
@@ -122,7 +113,9 @@ public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, Anal
|
|||||||
@Override
|
@Override
|
||||||
public List<AnalyticsTrendItem> getTrend(AnalyticsQueryRequest request) {
|
public List<AnalyticsTrendItem> getTrend(AnalyticsQueryRequest request) {
|
||||||
DateRange range = resolveRange(request);
|
DateRange range = resolveRange(request);
|
||||||
return baseMapper.selectTrend(range.start, range.end, resolveDateFormat(request));
|
List<AnalyticsTrendItem> result = baseMapper.selectTrend(range.start, range.end, resolveDateFormat(request));
|
||||||
|
result.forEach(this::fillTrendLabels);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -143,7 +136,7 @@ public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, Anal
|
|||||||
double conversionRate = firstUsers == 0L ? 0D : (double) users / firstUsers;
|
double conversionRate = firstUsers == 0L ? 0D : (double) users / firstUsers;
|
||||||
result.add(AnalyticsFunnelItem.builder()
|
result.add(AnalyticsFunnelItem.builder()
|
||||||
.eventName(eventName)
|
.eventName(eventName)
|
||||||
.label(FUNNEL_LABELS.getOrDefault(eventName, eventName))
|
.label(AnalyticsDictionary.eventLabel(eventName))
|
||||||
.users(users)
|
.users(users)
|
||||||
.conversionRate(conversionRate)
|
.conversionRate(conversionRate)
|
||||||
.build());
|
.build());
|
||||||
@@ -159,13 +152,16 @@ public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, Anal
|
|||||||
for (String dimension : PREFERENCE_DIMENSIONS) {
|
for (String dimension : PREFERENCE_DIMENSIONS) {
|
||||||
result.addAll(baseMapper.selectPreference(dimension, range.start, range.end, perDimensionLimit));
|
result.addAll(baseMapper.selectPreference(dimension, range.start, range.end, perDimensionLimit));
|
||||||
}
|
}
|
||||||
|
result.forEach(this::fillPreferenceLabels);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<AnalyticsTopEventItem> getTopEvents(AnalyticsQueryRequest request) {
|
public List<AnalyticsTopEventItem> getTopEvents(AnalyticsQueryRequest request) {
|
||||||
DateRange range = resolveRange(request);
|
DateRange range = resolveRange(request);
|
||||||
return baseMapper.selectTopEvents(range.start, range.end, resolveLimit(request));
|
List<AnalyticsTopEventItem> result = baseMapper.selectTopEvents(range.start, range.end, resolveLimit(request));
|
||||||
|
result.forEach(this::fillTopEventLabels);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -260,6 +256,22 @@ public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, Anal
|
|||||||
return Math.max(1, Math.min(MAX_LIMIT, limit));
|
return Math.max(1, Math.min(MAX_LIMIT, limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void fillTopEventLabels(AnalyticsTopEventItem item) {
|
||||||
|
item.setEventLabel(AnalyticsDictionary.eventLabel(item.getEventName()));
|
||||||
|
item.setEventTypeLabel(AnalyticsDictionary.eventTypeLabel(item.getEventType()));
|
||||||
|
item.setPageLabel(AnalyticsDictionary.pageLabel(item.getPagePath()));
|
||||||
|
item.setApiLabel(AnalyticsDictionary.apiLabel(item.getApiPath()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillTrendLabels(AnalyticsTrendItem item) {
|
||||||
|
item.setEventLabel(AnalyticsDictionary.eventLabel(item.getEventName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void fillPreferenceLabels(AnalyticsPreferenceItem item) {
|
||||||
|
item.setDimensionLabel(AnalyticsDictionary.dimensionLabel(item.getDimension()));
|
||||||
|
item.setValueLabel(AnalyticsDictionary.valueLabel(item.getDimension(), item.getValue()));
|
||||||
|
}
|
||||||
|
|
||||||
private long defaultLong(Long value) {
|
private long defaultLong(Long value) {
|
||||||
return value == null ? 0L : value;
|
return value == null ? 0L : value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
-- 接口端点表
|
||||||
|
CREATE TABLE IF NOT EXISTS api_endpoint (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
path VARCHAR(500) NOT NULL,
|
||||||
|
method VARCHAR(10) NOT NULL,
|
||||||
|
operation_id VARCHAR(200),
|
||||||
|
summary VARCHAR(500),
|
||||||
|
description TEXT,
|
||||||
|
tags VARCHAR(500),
|
||||||
|
deprecated TINYINT(1) DEFAULT 0,
|
||||||
|
request_schema JSON,
|
||||||
|
response_schema JSON,
|
||||||
|
create_by VARCHAR(64),
|
||||||
|
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
update_by VARCHAR(64),
|
||||||
|
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
is_deleted TINYINT(1) DEFAULT 0,
|
||||||
|
remarks VARCHAR(500),
|
||||||
|
|
||||||
|
UNIQUE INDEX idx_operation_id (operation_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- 接口参数表
|
||||||
|
CREATE TABLE IF NOT EXISTS api_param (
|
||||||
|
id VARCHAR(64) PRIMARY KEY,
|
||||||
|
endpoint_id VARCHAR(64) NOT NULL,
|
||||||
|
param_type VARCHAR(20) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
required TINYINT(1) DEFAULT 0,
|
||||||
|
param_type_def VARCHAR(50),
|
||||||
|
description VARCHAR(500),
|
||||||
|
default_value VARCHAR(200),
|
||||||
|
enum_values JSON,
|
||||||
|
example VARCHAR(500),
|
||||||
|
|
||||||
|
INDEX idx_endpoint (endpoint_id),
|
||||||
|
FOREIGN KEY (endpoint_id) REFERENCES api_endpoint(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -2,6 +2,7 @@ package com.emotion.service;
|
|||||||
|
|
||||||
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
|
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
|
||||||
import com.emotion.dto.request.analytics.AnalyticsEventRequest;
|
import com.emotion.dto.request.analytics.AnalyticsEventRequest;
|
||||||
|
import com.emotion.service.analytics.AnalyticsDictionary;
|
||||||
import com.emotion.service.impl.AnalyticsServiceImpl;
|
import com.emotion.service.impl.AnalyticsServiceImpl;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
@@ -43,4 +44,12 @@ public class AnalyticsServiceTest {
|
|||||||
assertEquals("session_test", request.getSessionId());
|
assertEquals("session_test", request.getSessionId());
|
||||||
assertEquals(1, request.getEvents().size());
|
assertEquals(1, request.getEvents().size());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void dictionaryReturnsChineseBusinessLabels() {
|
||||||
|
assertEquals("浏览页面", AnalyticsDictionary.eventLabel("page_view"));
|
||||||
|
assertEquals("首页", AnalyticsDictionary.pageLabel("/pages/main/index?tab=script"));
|
||||||
|
assertEquals("AI 流式生成", AnalyticsDictionary.apiLabel("/ai/runtime/stream"));
|
||||||
|
assertEquals("爽文生成", AnalyticsDictionary.valueLabel("tab", "script"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,8 +131,9 @@ server {
|
|||||||
proxy_read_timeout 60s;
|
proxy_read_timeout 60s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Swagger UI / Knife4j API 文档代理(防止被前端 try_files 拦截)
|
# Swagger UI / Knife4j API 文档代理
|
||||||
location /swagger-ui {
|
# 使用 ^~ 前缀匹配,防止被 / 的 try_files 拦截
|
||||||
|
location ^~ /swagger-ui {
|
||||||
proxy_pass http://127.0.0.1:19089;
|
proxy_pass http://127.0.0.1:19089;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -140,7 +141,7 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /v3/api-docs {
|
location ^~ /v3/api-docs {
|
||||||
proxy_pass http://127.0.0.1:19089;
|
proxy_pass http://127.0.0.1:19089;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -148,7 +149,7 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /doc.html {
|
location = /doc.html {
|
||||||
proxy_pass http://127.0.0.1:19089;
|
proxy_pass http://127.0.0.1:19089;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
@@ -156,7 +157,16 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /webjars {
|
location ^~ /webjars {
|
||||||
|
proxy_pass http://127.0.0.1:19089;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Knife4j 4.x 额外需要的路径(swagger-resources 等)
|
||||||
|
location ^~ /swagger-resources {
|
||||||
proxy_pass http://127.0.0.1:19089;
|
proxy_pass http://127.0.0.1:19089;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
@@ -409,9 +409,9 @@ def main():
|
|||||||
log_info(f"Life-Script: https://{DOMAIN}/life-script/")
|
log_info(f"Life-Script: https://{DOMAIN}/life-script/")
|
||||||
log_info(f"API 地址: https://{DOMAIN}/api")
|
log_info(f"API 地址: https://{DOMAIN}/api")
|
||||||
log_info(f"WebSocket: wss://{DOMAIN}/ws")
|
log_info(f"WebSocket: wss://{DOMAIN}/ws")
|
||||||
log_info(f"API 文档: https://{DOMAIN}/doc.html")
|
log_info(f"API 文档: https://{DOMAIN}/api/doc.html")
|
||||||
log_info(f"Swagger UI: https://{DOMAIN}/swagger-ui/index.html")
|
log_info(f"Swagger UI: https://{DOMAIN}/api/swagger-ui/index.html")
|
||||||
log_info(f"API Specs: https://{DOMAIN}/v3/api-docs")
|
log_info(f"API Specs: https://{DOMAIN}/api/v3/api-docs")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Analytics Chinese Business Labels Implementation Plan
|
||||||
|
|
||||||
|
> **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.
|
||||||
@@ -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 执行可用构建或类型检查命令,通过后确认无循环埋点。
|
||||||
@@ -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` |
|
||||||
@@ -12,6 +12,8 @@ let pageEnterAt = {}
|
|||||||
let flushTimer = null
|
let flushTimer = null
|
||||||
let initialized = false
|
let initialized = false
|
||||||
|
|
||||||
|
const runtimeRoot = typeof globalThis !== 'undefined' ? globalThis : {}
|
||||||
|
|
||||||
const now = () => Date.now()
|
const now = () => Date.now()
|
||||||
|
|
||||||
const uuid = (prefix) => `${prefix}_${now()}_${Math.random().toString(36).slice(2, 10)}`
|
const uuid = (prefix) => `${prefix}_${now()}_${Math.random().toString(36).slice(2, 10)}`
|
||||||
@@ -60,6 +62,9 @@ const safeProperties = (properties = {}) => {
|
|||||||
export const initAnalytics = () => {
|
export const initAnalytics = () => {
|
||||||
if (initialized) return
|
if (initialized) return
|
||||||
initialized = true
|
initialized = true
|
||||||
|
runtimeRoot.__emotionAnalyticsTrack = (eventName, properties = {}, options = {}) => {
|
||||||
|
track(eventName, properties, options)
|
||||||
|
}
|
||||||
sessionId = uuid('session')
|
sessionId = uuid('session')
|
||||||
loadQueue()
|
loadQueue()
|
||||||
track('app_launch', {}, { eventType: 'app' })
|
track('app_launch', {}, { eventType: 'app' })
|
||||||
|
|||||||
@@ -3,9 +3,74 @@ import { getEnvValue, getConfig } from '../config/env.js'
|
|||||||
const API_BASE_URL = getEnvValue('API_BASE_URL')
|
const API_BASE_URL = getEnvValue('API_BASE_URL')
|
||||||
|
|
||||||
const AUTH_PATH_PREFIX = '/auth/'
|
const AUTH_PATH_PREFIX = '/auth/'
|
||||||
|
const ANALYTICS_BATCH_PATH = '/analytics/events/batch'
|
||||||
|
|
||||||
const isAuthPath = (path = '') => path.startsWith(AUTH_PATH_PREFIX)
|
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 createRequestError = (message, meta = {}) => {
|
||||||
const error = new Error(message || 'Request failed')
|
const error = new Error(message || 'Request failed')
|
||||||
Object.assign(error, meta)
|
Object.assign(error, meta)
|
||||||
@@ -52,6 +117,7 @@ const request = (options) => {
|
|||||||
const method = options.method || 'GET'
|
const method = options.method || 'GET'
|
||||||
const fullUrl = `${API_BASE_URL}${options.url}`
|
const fullUrl = `${API_BASE_URL}${options.url}`
|
||||||
const forceLog = isAuthPath(options.url)
|
const forceLog = isAuthPath(options.url)
|
||||||
|
const startedAt = Date.now()
|
||||||
|
|
||||||
logApi('log', 'request', {
|
logApi('log', 'request', {
|
||||||
method,
|
method,
|
||||||
@@ -70,6 +136,11 @@ const request = (options) => {
|
|||||||
success: (res) => {
|
success: (res) => {
|
||||||
const { data, statusCode } = res
|
const { data, statusCode } = res
|
||||||
const success = statusCode >= 200 && statusCode < 300 && (data?.code === 200 || data?.code === 0)
|
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', {
|
logApi('log', 'response', {
|
||||||
path: options.url,
|
path: options.url,
|
||||||
@@ -106,6 +177,11 @@ const request = (options) => {
|
|||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
fail: (err) => {
|
fail: (err) => {
|
||||||
|
const durationMs = Date.now() - startedAt
|
||||||
|
trackApiRequest('api_request_fail', { ...options, method }, {
|
||||||
|
statusCode: 0,
|
||||||
|
durationMs
|
||||||
|
})
|
||||||
logApi('error', 'fail', {
|
logApi('error', 'fail', {
|
||||||
path: options.url,
|
path: options.url,
|
||||||
url: fullUrl,
|
url: fullUrl,
|
||||||
|
|||||||
@@ -13,13 +13,20 @@ export interface AnalyticsOverview {
|
|||||||
export interface AnalyticsTrendItem {
|
export interface AnalyticsTrendItem {
|
||||||
bucket: string
|
bucket: string
|
||||||
eventName: string
|
eventName: string
|
||||||
|
eventLabel?: string
|
||||||
count: number
|
count: number
|
||||||
users: number
|
users: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AnalyticsTopEvent {
|
export interface AnalyticsTopEvent {
|
||||||
eventName: string
|
eventName: string
|
||||||
|
eventLabel?: string
|
||||||
eventType: string
|
eventType: string
|
||||||
|
eventTypeLabel?: string
|
||||||
|
pagePath?: string
|
||||||
|
pageLabel?: string
|
||||||
|
apiPath?: string
|
||||||
|
apiLabel?: string
|
||||||
count: number
|
count: number
|
||||||
users: number
|
users: number
|
||||||
}
|
}
|
||||||
@@ -33,7 +40,9 @@ export interface AnalyticsFunnelItem {
|
|||||||
|
|
||||||
export interface AnalyticsPreferenceItem {
|
export interface AnalyticsPreferenceItem {
|
||||||
dimension: string
|
dimension: string
|
||||||
|
dimensionLabel?: string
|
||||||
value: string
|
value: string
|
||||||
|
valueLabel?: string
|
||||||
count: number
|
count: number
|
||||||
users: number
|
users: number
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,44 +3,44 @@
|
|||||||
<div class="dashboard-header">
|
<div class="dashboard-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">行为分析</h2>
|
<h2 class="page-title">行为分析</h2>
|
||||||
<p class="page-subtitle">小程序访问、创作链路和朗读行为概览</p>
|
<p class="page-subtitle">查看小程序访问、页面停留、点击操作、创作链路和接口调用情况</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
v-model="dateRange"
|
v-model="dateRange"
|
||||||
type="daterange"
|
type="daterange"
|
||||||
range-separator="-"
|
range-separator="至"
|
||||||
start-placeholder="开始日期"
|
start-placeholder="开始日期"
|
||||||
end-placeholder="结束日期"
|
end-placeholder="结束日期"
|
||||||
value-format="YYYY-MM-DD"
|
value-format="YYYY-MM-DD"
|
||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
<el-button type="primary" size="small" :loading="loading" @click="fetchData">刷新</el-button>
|
<el-button type="primary" size="small" :loading="loading" @click="fetchData">刷新数据</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-row :gutter="16" class="stats-row" v-loading="loading">
|
<el-row :gutter="16" class="stats-row" v-loading="loading">
|
||||||
<el-col v-for="card in cards" :key="card.label" :xs="12" :sm="8" :md="4">
|
<el-col v-for="card in cards" :key="card.label" :xs="12" :sm="8" :md="4">
|
||||||
<el-card class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-value">{{ card.value }}</div>
|
<div class="stat-value">{{ card.value }}</div>
|
||||||
<div class="stat-label">{{ card.label }}</div>
|
<div class="stat-label">{{ card.label }}</div>
|
||||||
</el-card>
|
</div>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
<el-row :gutter="16" class="panel-row">
|
<el-row :gutter="16" class="panel-row">
|
||||||
<el-col :xs="24" :md="14">
|
<el-col :xs="24" :md="14">
|
||||||
<el-card class="panel-card">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>事件趋势</template>
|
<template #header>行为趋势</template>
|
||||||
<div ref="trendChartRef" class="chart"></div>
|
<div ref="trendChartRef" class="chart"></div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :md="10">
|
<el-col :xs="24" :md="10">
|
||||||
<el-card class="panel-card">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>关键漏斗</template>
|
<template #header>创作关键漏斗</template>
|
||||||
<el-table :data="funnel" size="small" height="320">
|
<el-table :data="funnel" size="small" height="320" empty-text="暂无漏斗数据">
|
||||||
<el-table-column prop="label" label="步骤" min-width="150" show-overflow-tooltip />
|
<el-table-column prop="label" label="步骤" min-width="150" show-overflow-tooltip />
|
||||||
<el-table-column prop="users" label="用户" width="90" />
|
<el-table-column prop="users" label="用户数" width="90" />
|
||||||
<el-table-column label="转化率" width="100">
|
<el-table-column label="转化率" width="100">
|
||||||
<template #default="{ row }">{{ formatPercent(row.conversionRate) }}</template>
|
<template #default="{ row }">{{ formatPercent(row.conversionRate) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -49,27 +49,53 @@
|
|||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="16" class="panel-row">
|
||||||
|
<el-col :xs="24" :md="14">
|
||||||
|
<el-card class="panel-card" shadow="never">
|
||||||
|
<template #header>热门用户行为</template>
|
||||||
|
<el-table :data="behaviorEvents" size="small" height="340" empty-text="暂无行为数据">
|
||||||
|
<el-table-column prop="eventLabel" label="行为名称" min-width="170" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="pageLabel" label="所在页面" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="eventTypeLabel" label="行为类型" width="110" />
|
||||||
|
<el-table-column prop="count" label="次数" width="90" />
|
||||||
|
<el-table-column prop="users" label="用户数" width="90" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
<el-col :xs="24" :md="10">
|
||||||
|
<el-card class="panel-card" shadow="never">
|
||||||
|
<template #header>接口调用情况</template>
|
||||||
|
<el-table :data="apiEvents" size="small" height="340" empty-text="暂无接口调用数据">
|
||||||
|
<el-table-column prop="apiLabel" label="接口说明" min-width="160" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="eventLabel" label="调用结果" width="120" />
|
||||||
|
<el-table-column prop="count" label="次数" width="90" />
|
||||||
|
<el-table-column prop="users" label="用户数" width="90" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
<el-row :gutter="16" class="panel-row">
|
<el-row :gutter="16" class="panel-row">
|
||||||
<el-col :xs="24" :md="12">
|
<el-col :xs="24" :md="12">
|
||||||
<el-card class="panel-card">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>热门事件</template>
|
<template #header>偏好分布</template>
|
||||||
<el-table :data="topEvents" size="small" height="320">
|
<el-table :data="preferences" size="small" height="320" empty-text="暂无偏好数据">
|
||||||
<el-table-column prop="eventName" label="事件" min-width="170" show-overflow-tooltip />
|
<el-table-column prop="dimensionLabel" label="维度" width="130" show-overflow-tooltip />
|
||||||
<el-table-column prop="eventType" label="类型" width="110" />
|
<el-table-column prop="valueLabel" label="偏好值" min-width="170" show-overflow-tooltip />
|
||||||
<el-table-column prop="count" label="次数" width="95" />
|
<el-table-column prop="count" label="次数" width="90" />
|
||||||
<el-table-column prop="users" label="用户" width="95" />
|
<el-table-column prop="users" label="用户数" width="90" />
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
<el-col :xs="24" :md="12">
|
<el-col :xs="24" :md="12">
|
||||||
<el-card class="panel-card">
|
<el-card class="panel-card" shadow="never">
|
||||||
<template #header>偏好分布</template>
|
<template #header>页面与操作说明</template>
|
||||||
<el-table :data="preferences" size="small" height="320">
|
<div class="insight-list">
|
||||||
<el-table-column prop="dimension" label="维度" width="110" show-overflow-tooltip />
|
<div v-for="item in insightItems" :key="item.title" class="insight-item">
|
||||||
<el-table-column prop="value" label="值" min-width="160" show-overflow-tooltip />
|
<div class="insight-title">{{ item.title }}</div>
|
||||||
<el-table-column prop="count" label="次数" width="95" />
|
<div class="insight-desc">{{ item.desc }}</div>
|
||||||
<el-table-column prop="users" label="用户" width="95" />
|
</div>
|
||||||
</el-table>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-col>
|
</el-col>
|
||||||
</el-row>
|
</el-row>
|
||||||
@@ -113,12 +139,30 @@ const trendChartRef = ref<HTMLDivElement>()
|
|||||||
let trendChart: echarts.ECharts | null = null
|
let trendChart: echarts.ECharts | null = null
|
||||||
|
|
||||||
const cards = computed(() => [
|
const cards = computed(() => [
|
||||||
{ label: 'PV', value: overview.value.pv.toLocaleString() },
|
{ label: '页面浏览量', value: overview.value.pv.toLocaleString() },
|
||||||
{ label: 'UV', value: overview.value.uv.toLocaleString() },
|
{ label: '访问用户数', value: overview.value.uv.toLocaleString() },
|
||||||
{ label: '事件数', value: overview.value.eventCount.toLocaleString() },
|
{ label: '行为事件数', value: overview.value.eventCount.toLocaleString() },
|
||||||
{ label: '活跃用户', value: overview.value.activeUsers.toLocaleString() },
|
{ label: '活跃用户数', value: overview.value.activeUsers.toLocaleString() },
|
||||||
{ label: '朗读请求', value: overview.value.ttsRequests.toLocaleString() },
|
{ label: '朗读请求数', value: overview.value.ttsRequests.toLocaleString() },
|
||||||
{ label: '平均停留', value: `${Math.round((overview.value.avgStayMs || 0) / 1000)}s` }
|
{ 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<AnalyticsQuery>(() => {
|
const queryParams = computed<AnalyticsQuery>(() => {
|
||||||
@@ -132,6 +176,14 @@ const queryParams = computed<AnalyticsQuery>(() => {
|
|||||||
|
|
||||||
const formatPercent = (value: number) => `${Math.round((value || 0) * 100)}%`
|
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 = <T,>(response: unknown, fallback: T): T => {
|
const responseData = <T,>(response: unknown, fallback: T): T => {
|
||||||
const payload = response as { data?: T }
|
const payload = response as { data?: T }
|
||||||
return payload.data ?? fallback
|
return payload.data ?? fallback
|
||||||
@@ -143,8 +195,9 @@ const renderTrend = async () => {
|
|||||||
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
|
if (!trendChart) trendChart = echarts.init(trendChartRef.value)
|
||||||
const buckets = [...new Set(trend.value.map(item => item.bucket))]
|
const buckets = [...new Set(trend.value.map(item => item.bucket))]
|
||||||
const eventNames = [...new Set(trend.value.map(item => item.eventName))].slice(0, 5)
|
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 => ({
|
const series = eventNames.map(eventName => ({
|
||||||
name: eventName,
|
name: labelByEvent.get(eventName) || eventName,
|
||||||
type: 'line',
|
type: 'line',
|
||||||
smooth: true,
|
smooth: true,
|
||||||
data: buckets.map(bucket => trend.value.find(item => item.bucket === bucket && item.eventName === eventName)?.count || 0)
|
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([
|
const [overviewRes, trendRes, topRes, funnelRes, preferenceRes] = await Promise.all([
|
||||||
getAnalyticsOverview(params),
|
getAnalyticsOverview(params),
|
||||||
getAnalyticsTrend(params),
|
getAnalyticsTrend(params),
|
||||||
getAnalyticsTopEvents({ ...params, limit: 20 }),
|
getAnalyticsTopEvents({ ...params, limit: 60 }),
|
||||||
getAnalyticsFunnel(params),
|
getAnalyticsFunnel(params),
|
||||||
getAnalyticsPreferences({ ...params, limit: 20 })
|
getAnalyticsPreferences({ ...params, limit: 20 })
|
||||||
])
|
])
|
||||||
@@ -233,6 +286,12 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
min-height: 96px;
|
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 {
|
.stat-value {
|
||||||
@@ -257,5 +316,31 @@ onBeforeUnmount(() => {
|
|||||||
height: 320px;
|
height: 320px;
|
||||||
width: 100%;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -65,15 +65,69 @@
|
|||||||
<el-form-item label="请求方法">
|
<el-form-item label="请求方法">
|
||||||
<el-input v-model="testForm.method" disabled />
|
<el-input v-model="testForm.method" disabled />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-for="param in (detail?.params || [])" :key="param.name" :label="param.name">
|
<template v-for="param in queryParams" :key="param.name">
|
||||||
<el-input v-model="testForm.params[param.name]" :placeholder="param.description || param.example || ''" />
|
<el-form-item :label="param.name">
|
||||||
|
<!-- 枚举类型下拉 -->
|
||||||
|
<el-select
|
||||||
|
v-if="getParamInputType(param) === 'select'"
|
||||||
|
v-model="testForm.params[param.name]"
|
||||||
|
:placeholder="param.description || param.example || ''"
|
||||||
|
clearable
|
||||||
|
>
|
||||||
|
<el-option
|
||||||
|
v-for="opt in getEnumOptions(param)"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:value="opt.value"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
<!-- 布尔类型开关 -->
|
||||||
|
<el-switch
|
||||||
|
v-else-if="getParamInputType(param) === 'switch'"
|
||||||
|
v-model="testForm.params[param.name]"
|
||||||
|
active-value="true"
|
||||||
|
inactive-value="false"
|
||||||
|
/>
|
||||||
|
<!-- 数字类型 -->
|
||||||
|
<el-input
|
||||||
|
v-else-if="getParamInputType(param) === 'number'"
|
||||||
|
v-model="testForm.params[param.name]"
|
||||||
|
type="number"
|
||||||
|
:placeholder="param.description || param.example || ''"
|
||||||
|
/>
|
||||||
|
<!-- 默认文本 -->
|
||||||
|
<el-input
|
||||||
|
v-else
|
||||||
|
v-model="testForm.params[param.name]"
|
||||||
|
:placeholder="param.description || param.example || ''"
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</template>
|
||||||
<el-form-item v-if="['POST', 'PUT', 'PATCH'].includes(testForm.method)" label="请求体">
|
<el-form-item v-if="['POST', 'PUT', 'PATCH'].includes(testForm.method)" label="请求体">
|
||||||
<el-input v-model="testForm.body" type="textarea" :rows="5" placeholder="JSON 格式" />
|
<el-input v-model="testForm.body" type="textarea" :rows="5" placeholder="JSON 格式" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div v-if="testForm.body" class="body-hint">
|
||||||
|
<el-icon><InfoFilled /></el-icon>
|
||||||
|
参数结构已预填充,修改值后点击"发送请求"
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
|
<!-- 请求头 -->
|
||||||
|
<el-card class="headers-card" style="margin-bottom: 16px">
|
||||||
|
<template #header>请求头</template>
|
||||||
|
<div class="headers-list">
|
||||||
|
<div class="header-item">
|
||||||
|
<span class="header-key">Authorization:</span>
|
||||||
|
<span class="header-value">{{ currentToken ? `Bearer ${currentToken.slice(0, 20)}...` : '未设置' }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="['POST', 'PUT', 'PATCH'].includes(testForm.method) && testForm.body" class="header-item">
|
||||||
|
<span class="header-key">Content-Type:</span>
|
||||||
|
<span class="header-value">application/json</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
<el-button type="primary" @click="handleTest" :loading="testing">发送请求</el-button>
|
<el-button type="primary" @click="handleTest" :loading="testing">发送请求</el-button>
|
||||||
|
|
||||||
<!-- 响应结果 -->
|
<!-- 响应结果 -->
|
||||||
@@ -98,11 +152,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { InfoFilled } from '@element-plus/icons-vue'
|
||||||
import { testEndpoint, type ApiEndpointDetail } from '@/api/endpoint'
|
import { testEndpoint, type ApiEndpointDetail } from '@/api/endpoint'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: boolean
|
modelValue: boolean
|
||||||
detail: ApiEndpointDetail | null
|
detail: ApiEndpointDetail | null
|
||||||
|
defaultTab?: 'detail' | 'test'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -127,6 +183,76 @@ const testForm = ref({
|
|||||||
body: ''
|
body: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const queryParams = computed(() => {
|
||||||
|
return (props.detail?.params || []).filter(p => p.paramType === 'query' || p.paramType === 'path')
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentToken = computed(() => {
|
||||||
|
if (tokenSource.value === 'admin') {
|
||||||
|
return localStorage.getItem('adminToken') || ''
|
||||||
|
}
|
||||||
|
return manualToken.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const getParamInputType = (param: any): string => {
|
||||||
|
if (param.enumValues) return 'select'
|
||||||
|
const type = (param.paramTypeDef || '').toLowerCase()
|
||||||
|
if (type === 'boolean') return 'switch'
|
||||||
|
if (type === 'integer' || type === 'number') return 'number'
|
||||||
|
return 'text'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEnumOptions = (param: any): { label: string; value: string }[] => {
|
||||||
|
if (!param.enumValues) return []
|
||||||
|
return param.enumValues.split(',').map((v: string) => ({ label: v.trim(), value: v.trim() }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateJsonTemplate = (schema: any): any => {
|
||||||
|
if (!schema || !schema.properties) return {}
|
||||||
|
const result: any = {}
|
||||||
|
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||||
|
const p = prop as any
|
||||||
|
if (p.example && p.example !== '' && p.example !== 'null') {
|
||||||
|
try { result[key] = JSON.parse(p.example) }
|
||||||
|
catch { result[key] = p.example }
|
||||||
|
} else if (p.type === 'string') {
|
||||||
|
result[key] = ''
|
||||||
|
} else if (p.type === 'integer') {
|
||||||
|
result[key] = 0
|
||||||
|
} else if (p.type === 'number') {
|
||||||
|
result[key] = 0
|
||||||
|
} else if (p.type === 'boolean') {
|
||||||
|
result[key] = false
|
||||||
|
} else if (p.type === 'array') {
|
||||||
|
result[key] = []
|
||||||
|
} else if (p.type === 'object' && p.properties) {
|
||||||
|
result[key] = generateJsonTemplate(p)
|
||||||
|
} else {
|
||||||
|
result[key] = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const required = schema.required || []
|
||||||
|
for (const k of Object.keys(result)) {
|
||||||
|
if (!required.includes(k) && (result[k] === null || result[k] === '' || result[k] === 0 || result[k] === false || (Array.isArray(result[k]) && result[k].length === 0))) {
|
||||||
|
delete result[k]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateRequestBody = () => {
|
||||||
|
if (!props.detail?.requestSchema || props.detail.requestSchema === '{}') {
|
||||||
|
testForm.value.body = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const schema = JSON.parse(props.detail.requestSchema)
|
||||||
|
testForm.value.body = JSON.stringify(generateJsonTemplate(schema), null, 2)
|
||||||
|
} catch {
|
||||||
|
testForm.value.body = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(() => props.detail, (ep) => {
|
watch(() => props.detail, (ep) => {
|
||||||
if (ep) {
|
if (ep) {
|
||||||
testForm.value.path = ep.path
|
testForm.value.path = ep.path
|
||||||
@@ -134,7 +260,8 @@ watch(() => props.detail, (ep) => {
|
|||||||
testForm.value.params = {}
|
testForm.value.params = {}
|
||||||
testForm.value.body = ''
|
testForm.value.body = ''
|
||||||
testResult.value = null
|
testResult.value = null
|
||||||
activeTab.value = 'detail'
|
generateRequestBody()
|
||||||
|
activeTab.value = props.defaultTab || 'detail'
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
@@ -168,9 +295,9 @@ const handleTest = async () => {
|
|||||||
headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`
|
headers['Authorization'] = token.startsWith('Bearer ') ? token : `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryParams: Record<string, string> = {}
|
const params: Record<string, string> = {}
|
||||||
for (const [key, value] of Object.entries(testForm.value.params)) {
|
for (const [key, value] of Object.entries(testForm.value.params)) {
|
||||||
if (value) queryParams[key] = value
|
if (value) params[key] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
const res: any = await testEndpoint({
|
const res: any = await testEndpoint({
|
||||||
@@ -178,7 +305,7 @@ const handleTest = async () => {
|
|||||||
path: '/api' + testForm.value.path,
|
path: '/api' + testForm.value.path,
|
||||||
body: testForm.value.body || undefined,
|
body: testForm.value.body || undefined,
|
||||||
headers,
|
headers,
|
||||||
params: Object.keys(queryParams).length > 0 ? queryParams : undefined,
|
params: Object.keys(params).length > 0 ? params : undefined,
|
||||||
timeoutSeconds: 30
|
timeoutSeconds: 30
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -232,4 +359,38 @@ const handleTest = async () => {
|
|||||||
font-family: 'Menlo', 'Monaco', monospace;
|
font-family: 'Menlo', 'Monaco', monospace;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.body-hint {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headers-card {
|
||||||
|
:deep(.el-card__header) {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.headers-list {
|
||||||
|
.header-item {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'Menlo', 'Monaco', monospace;
|
||||||
|
|
||||||
|
.header-key {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-value {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -53,9 +53,10 @@
|
|||||||
<el-tag v-if="row.deprecated === 1" type="danger" size="small">废弃</el-tag>
|
<el-tag v-if="row.deprecated === 1" type="danger" size="small">废弃</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="100" align="center">
|
<el-table-column label="操作" width="150" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
|
<el-button type="primary" link size="small" @click="showDetail(row)">详情</el-button>
|
||||||
|
<el-button type="success" link size="small" @click="showTest(row)">测试</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -74,7 +75,7 @@
|
|||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 详情弹窗 -->
|
<!-- 详情弹窗 -->
|
||||||
<EndpointDetailDialog v-model="detailVisible" :detail="selectedDetail" />
|
<EndpointDetailDialog v-model="detailVisible" :detail="selectedDetail" :default-tab="defaultTab" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ const loading = ref(false)
|
|||||||
const syncing = ref(false)
|
const syncing = ref(false)
|
||||||
const detailVisible = ref(false)
|
const detailVisible = ref(false)
|
||||||
const selectedDetail = ref<ApiEndpointDetail | null>(null)
|
const selectedDetail = ref<ApiEndpointDetail | null>(null)
|
||||||
|
const defaultTab = ref<'detail' | 'test'>('detail')
|
||||||
|
|
||||||
const searchForm = reactive({
|
const searchForm = reactive({
|
||||||
keyword: '',
|
keyword: '',
|
||||||
@@ -149,6 +151,7 @@ const handleSync = async () => {
|
|||||||
|
|
||||||
const showDetail = async (row: ApiEndpointItem) => {
|
const showDetail = async (row: ApiEndpointItem) => {
|
||||||
selectedDetail.value = null
|
selectedDetail.value = null
|
||||||
|
defaultTab.value = 'detail'
|
||||||
detailVisible.value = true
|
detailVisible.value = true
|
||||||
try {
|
try {
|
||||||
const res: any = await getEndpointDetail(row.operationId)
|
const res: any = await getEndpointDetail(row.operationId)
|
||||||
@@ -158,6 +161,18 @@ const showDetail = async (row: ApiEndpointItem) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showTest = async (row: ApiEndpointItem) => {
|
||||||
|
selectedDetail.value = null
|
||||||
|
detailVisible.value = true
|
||||||
|
defaultTab.value = 'test'
|
||||||
|
try {
|
||||||
|
const res: any = await getEndpointDetail(row.operationId)
|
||||||
|
selectedDetail.value = res.data
|
||||||
|
} catch {
|
||||||
|
ElMessage.error('加载详情失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getMethodType = (method: string): string => {
|
const getMethodType = (method: string): string => {
|
||||||
const types: Record<string, string> = {
|
const types: Record<string, string> = {
|
||||||
GET: 'success',
|
GET: 'success',
|
||||||
|
|||||||
Reference in New Issue
Block a user