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:
2026-05-23 23:52:39 +08:00
parent d1a0018d1b
commit 9838e7626b
20 changed files with 1073 additions and 78 deletions
@@ -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,最先执行
// 普通用户拦截器 - 拦截除管理员路径外的所有请求
// 注意: 由于 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
}
@@ -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;
}
@@ -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"
};
@@ -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}")
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<AnalyticsTopEventItem> selectTopEvents(@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@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.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<AnalyticsEventMapper, Anal
"app_launch", "page_view", "script_inspiration_click", "script_generate_start",
"script_generate_success", "script_detail_view", "path_select", "script_tts_play"
);
private static final Map<String, String> 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<String> PREFERENCE_DIMENSIONS = List.of("style", "length", "source", "tab");
private static final List<String> 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<AnalyticsEventMapper, Anal
@Override
public List<AnalyticsTrendItem> getTrend(AnalyticsQueryRequest 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
@@ -143,7 +136,7 @@ public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, Anal
double conversionRate = firstUsers == 0L ? 0D : (double) users / firstUsers;
result.add(AnalyticsFunnelItem.builder()
.eventName(eventName)
.label(FUNNEL_LABELS.getOrDefault(eventName, eventName))
.label(AnalyticsDictionary.eventLabel(eventName))
.users(users)
.conversionRate(conversionRate)
.build());
@@ -159,13 +152,16 @@ public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, Anal
for (String dimension : PREFERENCE_DIMENSIONS) {
result.addAll(baseMapper.selectPreference(dimension, range.start, range.end, perDimensionLimit));
}
result.forEach(this::fillPreferenceLabels);
return result;
}
@Override
public List<AnalyticsTopEventItem> getTopEvents(AnalyticsQueryRequest 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
@@ -260,6 +256,22 @@ public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, Anal
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) {
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;