42 KiB
Mini Program Analytics 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: Add mini program behavior analytics collection, backend event storage and aggregation, and a web-admin behavior analytics dashboard.
Architecture: The mini program queues analytics events locally and flushes them to POST /analytics/events/batch. backend-single stores raw events in t_analytics_event and exposes admin-only aggregation endpoints under /admin/analytics. web-admin renders overview, trend, funnel, preference, and top-event stats using Element Plus and ECharts.
Tech Stack: Spring Boot 2.7, MyBatis Plus, MySQL JSON, Java 17, uni-app/Vue, Vue 3, Element Plus, ECharts.
File Structure
- Create
sql/2026-05-17-analytics-event.sql: migration fort_analytics_event. - Create
backend-single/src/main/java/com/emotion/entity/AnalyticsEvent.java: MyBatis Plus entity. - Create
backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java: mapper plus aggregation SQL. - Create
backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventBatchRequest.java: mini program batch request. - Create
backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventRequest.java: one event payload. - Create
backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsQueryRequest.java: admin query filters. - Create
backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsBatchResponse.java: accepted/rejected counts. - Create
backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsOverviewResponse.java: overview cards. - Create
backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTrendItem.java: trend item. - Create
backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsFunnelItem.java: funnel item. - Create
backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsPreferenceItem.java: preference item. - Create
backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsTopEventItem.java: top event item. - Create
backend-single/src/main/java/com/emotion/dto/response/analytics/AnalyticsUserItem.java: user ranking item. - Create
backend-single/src/main/java/com/emotion/service/AnalyticsService.java: write and aggregate contract. - Create
backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java: validation, persistence, aggregation. - Create
backend-single/src/main/java/com/emotion/controller/AnalyticsController.java: mini program write endpoint. - Create
backend-single/src/main/java/com/emotion/controller/AdminAnalyticsController.java: admin read endpoints. - Modify
backend-single/src/main/java/com/emotion/config/WebMvcConfig.java: allow anonymous analytics batch endpoint while keeping admin analytics protected. - Create
backend-single/src/test/java/com/emotion/service/AnalyticsServiceTest.java: service validation and aggregation tests. - Create
backend-single/src/test/java/com/emotion/controller/AnalyticsControllerTest.java: endpoint tests. - Create
mini-program/src/services/analytics.js: client SDK. - Modify
mini-program/src/App.vue: initialize and flush analytics. - Modify
mini-program/src/pages/main/index.vue: track main tab/page activity if this page owns tab switching. - Modify
mini-program/src/pages/main/ScriptView.vue: track script list, inspiration, generation events. - Modify
mini-program/src/pages/main/ScriptDetailView.vue: track detail view and path mapping. - Modify
mini-program/src/pages/life-event/form.vue: track life event create/update and AI assist. - Modify
mini-program/src/pages/life-event/detail.vue: track detail, favorite, share. - Create
web-admin/src/api/analytics.ts: admin analytics API client and types. - Create
web-admin/src/views/analytics/AnalyticsDashboard.vue: dashboard page. - Modify
web-admin/src/router/index.ts: add/analytics. - Modify
web-admin/src/config/menu.ts: add menu item.
Event Naming Contract
Use lowercase snake case only.
app_launch,app_show,app_hidepage_view,page_leaveprofile_completelife_event_create,life_event_update,life_event_ai_assist,life_event_favorite,life_event_sharescript_inspiration_view,script_inspiration_click,script_generate_start,script_generate_success,script_generate_failscript_detail_view,path_selectscript_tts_request,script_tts_play,script_tts_pause,script_tts_complete,script_tts_error
Do not put full user-created text in properties.
Task 1: Add Analytics SQL Migration
Files:
-
Create:
sql/2026-05-17-analytics-event.sql -
Step 1: Create the migration
CREATE TABLE IF NOT EXISTS t_analytics_event (
id VARCHAR(64) PRIMARY KEY COMMENT 'Primary key',
user_id VARCHAR(64) NULL COMMENT 'Logged-in user id',
anonymous_id VARCHAR(128) NULL COMMENT 'Anonymous client id',
session_id VARCHAR(128) NOT NULL COMMENT 'Client session id',
event_name VARCHAR(100) NOT NULL COMMENT 'Event name',
event_type VARCHAR(50) NOT NULL COMMENT 'Event category',
page_path VARCHAR(255) NULL COMMENT 'Current page path',
referrer_path VARCHAR(255) NULL COMMENT 'Referrer page path',
properties JSON NULL COMMENT 'Business metadata',
device_info JSON NULL COMMENT 'Device metadata',
duration_ms BIGINT NULL COMMENT 'Duration in milliseconds',
occurred_at DATETIME NOT NULL COMMENT 'Client occurrence time',
server_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'Server receive time',
create_by VARCHAR(64) NULL COMMENT 'Creator',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT 'Create time',
update_by VARCHAR(64) NULL COMMENT 'Updater',
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 'Update time',
is_deleted TINYINT DEFAULT 0 COMMENT 'Logic delete flag',
remarks VARCHAR(500) NULL COMMENT 'Remarks'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Analytics event table';
CREATE INDEX idx_analytics_event_name ON t_analytics_event (event_name);
CREATE INDEX idx_analytics_event_type ON t_analytics_event (event_type);
CREATE INDEX idx_analytics_event_user_id ON t_analytics_event (user_id);
CREATE INDEX idx_analytics_event_anonymous_id ON t_analytics_event (anonymous_id);
CREATE INDEX idx_analytics_event_occurred_at ON t_analytics_event (occurred_at);
CREATE INDEX idx_analytics_event_name_time ON t_analytics_event (event_name, occurred_at);
CREATE INDEX idx_analytics_event_user_time ON t_analytics_event (user_id, occurred_at);
- Step 2: Verify SQL syntax locally
Run:
mysql --version
Expected: MySQL client version prints. If no client is installed, skip execution and rely on review plus deployment migration.
- Step 3: Commit
git add sql/2026-05-17-analytics-event.sql
git commit -m "feat: add analytics event table"
Task 2: Add Backend DTOs and Entity
Files:
-
Create:
backend-single/src/main/java/com/emotion/entity/AnalyticsEvent.java -
Create:
backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventBatchRequest.java -
Create:
backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventRequest.java -
Create:
backend-single/src/main/java/com/emotion/dto/request/analytics/AnalyticsQueryRequest.java -
Create: response DTO files under
backend-single/src/main/java/com/emotion/dto/response/analytics/ -
Step 1: Add the entity
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
import com.emotion.common.BaseEntity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.LocalDateTime;
import java.util.Map;
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName(value = "t_analytics_event", autoResultMap = true)
public class AnalyticsEvent extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("anonymous_id")
private String anonymousId;
@TableField("session_id")
private String sessionId;
@TableField("event_name")
private String eventName;
@TableField("event_type")
private String eventType;
@TableField("page_path")
private String pagePath;
@TableField("referrer_path")
private String referrerPath;
@TableField(value = "properties", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> properties;
@TableField(value = "device_info", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> deviceInfo;
@TableField("duration_ms")
private Long durationMs;
@TableField("occurred_at")
private LocalDateTime occurredAt;
@TableField("server_time")
private LocalDateTime serverTime;
}
- Step 2: Add request DTOs
package com.emotion.dto.request.analytics;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.Size;
import java.util.List;
import java.util.Map;
@Data
public class AnalyticsEventBatchRequest {
@Size(max = 128)
private String anonymousId;
@NotBlank
@Size(max = 128)
private String sessionId;
private Map<String, Object> deviceInfo;
@Valid
@NotEmpty
@Size(max = 50)
private List<AnalyticsEventRequest> events;
}
package com.emotion.dto.request.analytics;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
import java.util.Map;
@Data
public class AnalyticsEventRequest {
@NotBlank
@Size(max = 100)
private String eventName;
@NotBlank
@Size(max = 50)
private String eventType;
@Size(max = 255)
private String pagePath;
@Size(max = 255)
private String referrerPath;
private Map<String, Object> properties;
private Long durationMs;
private LocalDateTime occurredAt;
}
package com.emotion.dto.request.analytics;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
import java.util.List;
@Data
public class AnalyticsQueryRequest {
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
private String granularity;
private List<String> eventNames;
private String eventType;
private Integer limit;
}
- Step 3: Add response DTOs
package com.emotion.dto.response.analytics;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AnalyticsBatchResponse {
private int accepted;
private int rejected;
}
package com.emotion.dto.response.analytics;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AnalyticsOverviewResponse {
private long pv;
private long uv;
private long eventCount;
private long activeUsers;
private long ttsRequests;
private long ttsPlays;
private double avgStayMs;
}
package com.emotion.dto.response.analytics;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AnalyticsTrendItem {
private String bucket;
private String eventName;
private long count;
private long users;
}
package com.emotion.dto.response.analytics;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AnalyticsFunnelItem {
private String eventName;
private String label;
private long users;
private double conversionRate;
}
package com.emotion.dto.response.analytics;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AnalyticsPreferenceItem {
private String dimension;
private String value;
private long count;
private long users;
}
package com.emotion.dto.response.analytics;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class AnalyticsTopEventItem {
private String eventName;
private String eventType;
private long count;
private long users;
}
package com.emotion.dto.response.analytics;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@Builder
public class AnalyticsUserItem {
private String userId;
private String anonymousId;
private long eventCount;
private LocalDateTime lastActiveTime;
}
- Step 4: Run compile
Run:
cd backend-single
mvn -DskipTests compile
Expected: BUILD SUCCESS.
- Step 5: Commit
git add backend-single/src/main/java/com/emotion/entity/AnalyticsEvent.java backend-single/src/main/java/com/emotion/dto/request/analytics backend-single/src/main/java/com/emotion/dto/response/analytics
git commit -m "feat: add analytics DTOs and entity"
Task 3: Add Analytics Mapper and Service
Files:
-
Create:
backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java -
Create:
backend-single/src/main/java/com/emotion/service/AnalyticsService.java -
Create:
backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java -
Test:
backend-single/src/test/java/com/emotion/service/AnalyticsServiceTest.java -
Step 1: Write service tests
package com.emotion.service;
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
import com.emotion.dto.request.analytics.AnalyticsEventRequest;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
public class AnalyticsServiceTest {
@Test
void safeEventNameAllowsSnakeCase() {
assertTrue(com.emotion.service.impl.AnalyticsServiceImpl.isSafeEventName("script_generate_success"));
}
@Test
void safeEventNameRejectsUnsafeCharacters() {
assertFalse(com.emotion.service.impl.AnalyticsServiceImpl.isSafeEventName("script.generate"));
assertFalse(com.emotion.service.impl.AnalyticsServiceImpl.isSafeEventName("<script>"));
}
@Test
void batchRequestShapeCanHoldValidEvent() {
AnalyticsEventRequest event = new AnalyticsEventRequest();
event.setEventName("page_view");
event.setEventType("page");
event.setPagePath("/pages/main/index");
event.setOccurredAt(LocalDateTime.now());
event.setProperties(Map.of("tab", "script"));
AnalyticsEventBatchRequest request = new AnalyticsEventBatchRequest();
request.setAnonymousId("anon_test");
request.setSessionId("session_test");
request.setEvents(List.of(event));
assertEquals("session_test", request.getSessionId());
assertEquals(1, request.getEvents().size());
}
}
- Step 2: Run tests and verify compile fails before implementation
Run:
cd backend-single
mvn -Dtest=AnalyticsServiceTest test
Expected: FAIL because AnalyticsServiceImpl does not exist.
- Step 3: Add mapper
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.dto.response.analytics.*;
import com.emotion.entity.AnalyticsEvent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface AnalyticsEventMapper extends BaseMapper<AnalyticsEvent> {
@Select("SELECT COUNT(*) FROM t_analytics_event WHERE is_deleted = 0 AND event_name = #{eventName} AND occurred_at >= #{start} AND occurred_at < #{end}")
Long countEvent(@Param("eventName") String eventName, @Param("start") LocalDateTime start, @Param("end") LocalDateTime end);
@Select("SELECT COUNT(DISTINCT COALESCE(user_id, anonymous_id)) FROM t_analytics_event WHERE is_deleted = 0 AND occurred_at >= #{start} AND occurred_at < #{end}")
Long countUniqueVisitors(@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 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}")
List<AnalyticsTopEventItem> selectTopEvents(@Param("start") LocalDateTime start, @Param("end") LocalDateTime end, @Param("limit") int limit);
}
- Step 4: Add service contract
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
import com.emotion.dto.request.analytics.AnalyticsQueryRequest;
import com.emotion.dto.response.analytics.*;
import com.emotion.entity.AnalyticsEvent;
import java.util.List;
public interface AnalyticsService extends IService<AnalyticsEvent> {
AnalyticsBatchResponse ingestBatch(AnalyticsEventBatchRequest request);
AnalyticsOverviewResponse getOverview(AnalyticsQueryRequest request);
List<AnalyticsTrendItem> getTrend(AnalyticsQueryRequest request);
List<AnalyticsFunnelItem> getFunnel(AnalyticsQueryRequest request);
List<AnalyticsPreferenceItem> getPreferences(AnalyticsQueryRequest request);
List<AnalyticsTopEventItem> getTopEvents(AnalyticsQueryRequest request);
List<AnalyticsUserItem> getUsers(AnalyticsQueryRequest request);
}
- Step 5: Add minimal service implementation
package com.emotion.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.dto.request.analytics.*;
import com.emotion.dto.response.analytics.*;
import com.emotion.entity.AnalyticsEvent;
import com.emotion.mapper.AnalyticsEventMapper;
import com.emotion.service.AnalyticsService;
import com.emotion.util.UserContextHolder;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
@Service
public class AnalyticsServiceImpl extends ServiceImpl<AnalyticsEventMapper, AnalyticsEvent> implements AnalyticsService {
private static final Pattern EVENT_NAME = Pattern.compile("^[a-z][a-z0-9_]{1,99}$");
private static final int JSON_LIMIT_BYTES = 4096;
public static boolean isSafeEventName(String eventName) {
return eventName != null && EVENT_NAME.matcher(eventName).matches();
}
@Override
public AnalyticsBatchResponse ingestBatch(AnalyticsEventBatchRequest request) {
int accepted = 0;
int rejected = 0;
List<AnalyticsEvent> entities = new ArrayList<>();
String userId = UserContextHolder.getCurrentUserId();
for (AnalyticsEventRequest event : request.getEvents()) {
if (!isSafeEventName(event.getEventName()) || tooLarge(event.getProperties()) || tooLarge(request.getDeviceInfo())) {
rejected++;
continue;
}
entities.add(AnalyticsEvent.builder()
.userId(userId)
.anonymousId(request.getAnonymousId())
.sessionId(request.getSessionId())
.eventName(event.getEventName())
.eventType(event.getEventType())
.pagePath(event.getPagePath())
.referrerPath(event.getReferrerPath())
.properties(event.getProperties())
.deviceInfo(request.getDeviceInfo())
.durationMs(event.getDurationMs())
.occurredAt(event.getOccurredAt() != null ? event.getOccurredAt() : LocalDateTime.now())
.serverTime(LocalDateTime.now())
.build());
accepted++;
}
if (!entities.isEmpty()) {
saveBatch(entities);
}
return AnalyticsBatchResponse.builder().accepted(accepted).rejected(rejected).build();
}
@Override
public AnalyticsOverviewResponse getOverview(AnalyticsQueryRequest request) {
DateRange range = range(request);
long pv = countByEvent("page_view", range);
long eventCount = count(new LambdaQueryWrapper<AnalyticsEvent>().eq(AnalyticsEvent::getIsDeleted, 0).ge(AnalyticsEvent::getOccurredAt, range.start).lt(AnalyticsEvent::getOccurredAt, range.end));
long uv = safeLong(baseMapper.countUniqueVisitors(range.start, range.end));
long ttsRequests = countByEvent("script_tts_request", range);
long ttsPlays = countByEvent("script_tts_play", range);
Double avgStay = list(new LambdaQueryWrapper<AnalyticsEvent>().eq(AnalyticsEvent::getEventName, "page_leave").ge(AnalyticsEvent::getOccurredAt, range.start).lt(AnalyticsEvent::getOccurredAt, range.end))
.stream().filter(e -> e.getDurationMs() != null).mapToLong(AnalyticsEvent::getDurationMs).average().orElse(0);
return AnalyticsOverviewResponse.builder().pv(pv).uv(uv).eventCount(eventCount).activeUsers(uv).ttsRequests(ttsRequests).ttsPlays(ttsPlays).avgStayMs(avgStay).build();
}
@Override
public List<AnalyticsTrendItem> getTrend(AnalyticsQueryRequest request) {
return List.of();
}
@Override
public List<AnalyticsFunnelItem> getFunnel(AnalyticsQueryRequest request) {
DateRange range = range(request);
String[] events = {"app_launch", "profile_complete", "life_event_create", "script_generate_success", "path_select"};
String[] labels = {"启动", "完善资料", "创建人生事件", "生成剧本", "映射路径"};
List<AnalyticsFunnelItem> items = new ArrayList<>();
long first = Math.max(1, countByEvent(events[0], range));
for (int i = 0; i < events.length; i++) {
long users = countByEvent(events[i], range);
items.add(AnalyticsFunnelItem.builder().eventName(events[i]).label(labels[i]).users(users).conversionRate(users * 1.0 / first).build());
}
return items;
}
@Override
public List<AnalyticsPreferenceItem> getPreferences(AnalyticsQueryRequest request) {
return List.of();
}
@Override
public List<AnalyticsTopEventItem> getTopEvents(AnalyticsQueryRequest request) {
DateRange range = range(request);
return baseMapper.selectTopEvents(range.start, range.end, limit(request));
}
@Override
public List<AnalyticsUserItem> getUsers(AnalyticsQueryRequest request) {
return List.of();
}
private long countByEvent(String eventName, DateRange range) {
return safeLong(baseMapper.countEvent(eventName, range.start, range.end));
}
private boolean tooLarge(Map<String, Object> json) {
return json != null && JSON.toJSONBytes(json).length > JSON_LIMIT_BYTES;
}
private long safeLong(Long value) {
return value == null ? 0L : value;
}
private int limit(AnalyticsQueryRequest request) {
if (request == null || request.getLimit() == null) return 20;
return Math.max(1, Math.min(200, request.getLimit()));
}
private DateRange range(AnalyticsQueryRequest request) {
LocalDate endDate = request != null && request.getEndDate() != null ? request.getEndDate() : LocalDate.now();
LocalDate startDate = request != null && request.getStartDate() != null ? request.getStartDate() : endDate.minusDays(6);
if (startDate.isBefore(endDate.minusDays(89))) {
startDate = endDate.minusDays(89);
}
return new DateRange(startDate.atStartOfDay(), endDate.plusDays(1).atStartOfDay());
}
private static class DateRange {
final LocalDateTime start;
final LocalDateTime end;
DateRange(LocalDateTime start, LocalDateTime end) {
this.start = start;
this.end = end;
}
}
}
- Step 6: Run tests
Run:
cd backend-single
mvn -Dtest=AnalyticsServiceTest test
Expected: BUILD SUCCESS.
- Step 7: Commit
git add backend-single/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java backend-single/src/main/java/com/emotion/service/AnalyticsService.java backend-single/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java backend-single/src/test/java/com/emotion/service/AnalyticsServiceTest.java
git commit -m "feat: add analytics service"
Task 4: Add Analytics Controllers and Auth Exclusion
Files:
-
Create:
backend-single/src/main/java/com/emotion/controller/AnalyticsController.java -
Create:
backend-single/src/main/java/com/emotion/controller/AdminAnalyticsController.java -
Modify:
backend-single/src/main/java/com/emotion/config/WebMvcConfig.java -
Test:
backend-single/src/test/java/com/emotion/controller/AnalyticsControllerTest.java -
Step 1: Add write controller
package com.emotion.controller;
import com.emotion.common.Result;
import com.emotion.dto.request.analytics.AnalyticsEventBatchRequest;
import com.emotion.dto.response.analytics.AnalyticsBatchResponse;
import com.emotion.service.AnalyticsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
@RestController
@RequestMapping("/analytics")
public class AnalyticsController {
@Autowired
private AnalyticsService analyticsService;
@PostMapping("/events/batch")
public Result<AnalyticsBatchResponse> batch(@Valid @RequestBody AnalyticsEventBatchRequest request) {
return Result.success(analyticsService.ingestBatch(request));
}
}
- Step 2: Add admin controller
package com.emotion.controller;
import com.emotion.common.Result;
import com.emotion.dto.request.analytics.AnalyticsQueryRequest;
import com.emotion.dto.response.analytics.*;
import com.emotion.service.AnalyticsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/admin/analytics")
public class AdminAnalyticsController {
@Autowired
private AnalyticsService analyticsService;
@GetMapping("/overview")
public Result<AnalyticsOverviewResponse> overview(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getOverview(request));
}
@GetMapping("/trend")
public Result<List<AnalyticsTrendItem>> trend(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getTrend(request));
}
@GetMapping("/funnel")
public Result<List<AnalyticsFunnelItem>> funnel(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getFunnel(request));
}
@GetMapping("/preferences")
public Result<List<AnalyticsPreferenceItem>> preferences(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getPreferences(request));
}
@GetMapping("/top-events")
public Result<List<AnalyticsTopEventItem>> topEvents(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getTopEvents(request));
}
@GetMapping("/users")
public Result<List<AnalyticsUserItem>> users(@Validated AnalyticsQueryRequest request) {
return Result.success(analyticsService.getUsers(request));
}
}
- Step 3: Exclude only the mini program write endpoint from login
In WebMvcConfig, add this exact pattern to the jwtAuthInterceptor.excludePathPatterns(...) list:
"/analytics/events/batch",
Do not exclude /admin/analytics/**.
- Step 4: Run backend compile
Run:
cd backend-single
mvn -DskipTests compile
Expected: BUILD SUCCESS.
- Step 5: Commit
git add backend-single/src/main/java/com/emotion/controller/AnalyticsController.java backend-single/src/main/java/com/emotion/controller/AdminAnalyticsController.java backend-single/src/main/java/com/emotion/config/WebMvcConfig.java
git commit -m "feat: expose analytics APIs"
Task 5: Add Mini Program Analytics SDK
Files:
-
Create:
mini-program/src/services/analytics.js -
Step 1: Add SDK implementation
import { post } from './request.js'
const QUEUE_KEY = 'analytics_queue'
const ANON_KEY = 'analytics_anonymous_id'
const MAX_QUEUE = 100
const FLUSH_SIZE = 20
const FLUSH_INTERVAL = 10000
let queue = []
let sessionId = ''
let pageEnterAt = {}
let flushTimer = null
const now = () => Date.now()
const uuid = (prefix) => `${prefix}_${now()}_${Math.random().toString(36).slice(2, 10)}`
export const getAnonymousId = () => {
let id = uni.getStorageSync(ANON_KEY)
if (!id) {
id = uuid('anon')
uni.setStorageSync(ANON_KEY, id)
}
return id
}
const loadQueue = () => {
const saved = uni.getStorageSync(QUEUE_KEY)
queue = Array.isArray(saved) ? saved.slice(-MAX_QUEUE) : []
}
const saveQueue = () => {
uni.setStorageSync(QUEUE_KEY, queue.slice(-MAX_QUEUE))
}
const getDeviceInfo = () => {
try {
const system = uni.getSystemInfoSync()
return {
platform: system.platform,
model: system.model,
system: system.system,
version: system.version,
SDKVersion: system.SDKVersion
}
} catch (error) {
return { platform: 'unknown' }
}
}
const safeProperties = (properties = {}) => {
const blocked = ['token', 'access_token', 'refresh_token', 'phone', 'smsCode', 'content', 'fullContent']
return Object.keys(properties).reduce((acc, key) => {
if (!blocked.includes(key)) acc[key] = properties[key]
return acc
}, {})
}
export const initAnalytics = () => {
sessionId = uuid('session')
loadQueue()
track('app_launch', {})
if (!flushTimer) {
flushTimer = setInterval(() => flush(), FLUSH_INTERVAL)
}
}
export const track = (eventName, properties = {}, options = {}) => {
if (!eventName) return
queue.push({
eventName,
eventType: options.eventType || eventName.split('_')[0] || 'custom',
pagePath: options.pagePath || '',
referrerPath: options.referrerPath || '',
properties: safeProperties(properties),
durationMs: options.durationMs,
occurredAt: new Date().toISOString().slice(0, 19).replace('T', ' ')
})
queue = queue.slice(-MAX_QUEUE)
saveQueue()
if (queue.length >= FLUSH_SIZE) {
flush()
}
}
export const trackPageView = (pagePath, properties = {}) => {
pageEnterAt[pagePath] = now()
track('page_view', properties, { eventType: 'page', pagePath })
}
export const trackPageLeave = (pagePath, properties = {}) => {
const durationMs = pageEnterAt[pagePath] ? now() - pageEnterAt[pagePath] : undefined
delete pageEnterAt[pagePath]
track('page_leave', properties, { eventType: 'page', pagePath, durationMs })
}
export const flush = async () => {
if (!queue.length || !sessionId) return
const sending = queue.slice(0, 50)
try {
await post('/analytics/events/batch', {
anonymousId: getAnonymousId(),
sessionId,
deviceInfo: getDeviceInfo(),
events: sending
})
queue = queue.slice(sending.length)
saveQueue()
} catch (error) {
saveQueue()
}
}
export default {
initAnalytics,
track,
trackPageView,
trackPageLeave,
flush,
getAnonymousId
}
- Step 2: Build mini program
Run:
cd mini-program
npm run build:mp-weixin:test
Expected: build completes without module resolution errors.
- Step 3: Commit
git add mini-program/src/services/analytics.js
git commit -m "feat: add mini program analytics sdk"
Task 6: Wire Mini Program Events
Files:
-
Modify:
mini-program/src/App.vue -
Modify:
mini-program/src/pages/main/ScriptView.vue -
Modify:
mini-program/src/pages/main/ScriptDetailView.vue -
Modify:
mini-program/src/pages/life-event/form.vue -
Modify:
mini-program/src/pages/life-event/detail.vue -
Step 1: Initialize analytics in
App.vue
Add:
import analytics from './services/analytics.js'
In app lifecycle hooks:
onLaunch(() => {
analytics.initAnalytics()
})
onShow(() => {
analytics.track('app_show', {}, { eventType: 'app' })
})
onHide(() => {
analytics.track('app_hide', {}, { eventType: 'app' })
analytics.flush()
})
- Step 2: Track script detail page
In ScriptDetailView.vue, import:
import analytics from '../../services/analytics.js'
In onMounted after scriptId.value is set:
analytics.trackPageView('/pages/main/ScriptDetailView', { script_id: scriptId.value })
analytics.track('script_detail_view', {
script_id: scriptId.value,
style: script.value?.style || '',
length: script.value?.length || '',
word_count: script.value?.wordCount || 0
}, { eventType: 'script', pagePath: '/pages/main/ScriptDetailView' })
Before navigating to path in selectCurrent:
analytics.track('path_select', {
script_id: script.value.id,
style: script.value.style || '',
length: script.value.length || ''
}, { eventType: 'script', pagePath: '/pages/main/ScriptDetailView' })
Add onUnmounted:
onUnmounted(() => {
analytics.trackPageLeave('/pages/main/ScriptDetailView', { script_id: scriptId.value })
})
- Step 3: Track script generation in
ScriptView.vue
Import analytics and wrap the existing generation call:
analytics.track('script_generate_start', {
style: selectedStyle.value,
length: selectedLength.value,
source: 'inspiration'
}, { eventType: 'script', pagePath: '/pages/main/ScriptView' })
On success:
analytics.track('script_generate_success', {
style: selectedStyle.value,
length: selectedLength.value
}, { eventType: 'script', pagePath: '/pages/main/ScriptView' })
On failure:
analytics.track('script_generate_fail', {
style: selectedStyle.value,
length: selectedLength.value,
error: error?.message || 'unknown'
}, { eventType: 'script', pagePath: '/pages/main/ScriptView' })
- Step 4: Track life event form
In life-event/form.vue, track successful create/update:
analytics.track(isEdit.value ? 'life_event_update' : 'life_event_create', {
tag_count: Array.isArray(form.tags) ? form.tags.length : 0,
has_time: Boolean(form.time || form.date)
}, { eventType: 'life_event', pagePath: '/pages/life-event/form' })
Track AI assist:
analytics.track('life_event_ai_assist', {
tag_count: Array.isArray(form.tags) ? form.tags.length : 0
}, { eventType: 'life_event', pagePath: '/pages/life-event/form' })
- Step 5: Build mini program
Run:
cd mini-program
npm run build:mp-weixin:test
Expected: build succeeds.
- Step 6: Commit
git add mini-program/src/App.vue mini-program/src/pages/main/ScriptView.vue mini-program/src/pages/main/ScriptDetailView.vue mini-program/src/pages/life-event/form.vue mini-program/src/pages/life-event/detail.vue
git commit -m "feat: track mini program analytics events"
Task 7: Add Web Admin Analytics Page
Files:
-
Create:
web-admin/src/api/analytics.ts -
Create:
web-admin/src/views/analytics/AnalyticsDashboard.vue -
Modify:
web-admin/src/router/index.ts -
Modify:
web-admin/src/config/menu.ts -
Step 1: Add API client
import request from '@/utils/request'
export interface AnalyticsOverview {
pv: number
uv: number
eventCount: number
activeUsers: number
ttsRequests: number
ttsPlays: number
avgStayMs: number
}
export interface AnalyticsTopEvent {
eventName: string
eventType: string
count: number
users: number
}
export interface AnalyticsFunnelItem {
eventName: string
label: string
users: number
conversionRate: number
}
export function getAnalyticsOverview(params = {}) {
return request<AnalyticsOverview>({ url: '/admin/analytics/overview', method: 'get', params })
}
export function getAnalyticsTopEvents(params = {}) {
return request<AnalyticsTopEvent[]>({ url: '/admin/analytics/top-events', method: 'get', params })
}
export function getAnalyticsFunnel(params = {}) {
return request<AnalyticsFunnelItem[]>({ url: '/admin/analytics/funnel', method: 'get', params })
}
- Step 2: Add dashboard page
<template>
<div class="analytics-dashboard">
<div class="dashboard-header">
<h2 class="page-title">行为分析</h2>
<el-button type="primary" :loading="loading" @click="fetchData">刷新</el-button>
</div>
<el-row :gutter="20" class="stats-row" v-loading="loading">
<el-col :span="4" v-for="card in cards" :key="card.label">
<el-card class="stat-card">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-label">{{ card.label }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-card>
<template #header>关键漏斗</template>
<el-table :data="funnel" size="small">
<el-table-column prop="label" label="步骤" />
<el-table-column prop="users" label="用户数" width="100" />
<el-table-column label="转化率" width="120">
<template #default="{ row }">{{ Math.round(row.conversionRate * 100) }}%</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
<el-col :span="12">
<el-card>
<template #header>热门事件</template>
<el-table :data="topEvents" size="small">
<el-table-column prop="eventName" label="事件" min-width="160" />
<el-table-column prop="eventType" label="类型" width="100" />
<el-table-column prop="count" label="次数" width="100" />
<el-table-column prop="users" label="用户" width="100" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getAnalyticsOverview, getAnalyticsTopEvents, getAnalyticsFunnel, type AnalyticsOverview, type AnalyticsTopEvent, type AnalyticsFunnelItem } from '@/api/analytics'
const loading = ref(false)
const overview = ref<AnalyticsOverview>({ pv: 0, uv: 0, eventCount: 0, activeUsers: 0, ttsRequests: 0, ttsPlays: 0, avgStayMs: 0 })
const topEvents = ref<AnalyticsTopEvent[]>([])
const funnel = ref<AnalyticsFunnelItem[]>([])
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: overview.value.ttsPlays.toLocaleString() }
])
const fetchData = async () => {
loading.value = true
try {
const [overviewRes, topRes, funnelRes] = await Promise.all([
getAnalyticsOverview(),
getAnalyticsTopEvents({ limit: 20 }),
getAnalyticsFunnel()
])
overview.value = overviewRes.data || overview.value
topEvents.value = topRes.data || []
funnel.value = funnelRes.data || []
} catch (error) {
ElMessage.error('行为分析数据加载失败')
} finally {
loading.value = false
}
}
onMounted(fetchData)
</script>
<style scoped lang="scss">
.analytics-dashboard {
.dashboard-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
}
.page-title {
margin: 0;
font-size: 24px;
color: var(--ls-text);
}
.stats-row {
margin-bottom: 20px;
}
.stat-card {
.stat-value {
font-size: 26px;
font-weight: 700;
color: var(--ls-text);
}
.stat-label {
margin-top: 8px;
color: rgba(226, 232, 240, 0.65);
}
}
}
</style>
- Step 3: Add route
Add under the Layout children group in web-admin/src/router/index.ts:
{
path: 'analytics',
name: 'AnalyticsDashboard',
component: () => import('@/views/analytics/AnalyticsDashboard.vue'),
meta: { title: '行为分析', icon: 'TrendCharts' }
}
- Step 4: Add menu
Add to menuConfig:
{
path: '/analytics',
title: '行为分析',
icon: 'TrendCharts'
}
- Step 5: Build admin
Run:
cd web-admin
npm run build
Expected: vue-tsc and Vite build succeed.
- Step 6: Commit
git add web-admin/src/api/analytics.ts web-admin/src/views/analytics/AnalyticsDashboard.vue web-admin/src/router/index.ts web-admin/src/config/menu.ts
git commit -m "feat: add analytics admin dashboard"
Task 8: Final Verification for Analytics
Files:
-
No code changes unless verification finds bugs.
-
Step 1: Run backend tests
cd backend-single
mvn test
Expected: BUILD SUCCESS.
- Step 2: Run admin build
cd web-admin
npm run build
Expected: build succeeds.
- Step 3: Run mini program build
cd mini-program
npm run build:mp-weixin:test
Expected: build succeeds.
- Step 4: Manual API smoke test
With backend running locally, send:
curl -X POST http://localhost:19089/analytics/events/batch ^
-H "Content-Type: application/json" ^
-d "{\"anonymousId\":\"anon_smoke\",\"sessionId\":\"session_smoke\",\"deviceInfo\":{\"platform\":\"dev\"},\"events\":[{\"eventName\":\"page_view\",\"eventType\":\"page\",\"pagePath\":\"/pages/main/index\",\"occurredAt\":\"2026-05-17 10:00:00\"}]}"
Expected response includes "accepted":1.
- Step 5: Commit fixes if needed
git add <changed-files>
git commit -m "fix: stabilize analytics verification"
Only run this commit if verification required changes.
Self-Review
- Spec coverage: event storage, anonymous reporting, admin aggregation, mini program SDK, privacy filtering, and dashboard are covered.
- Placeholder scan: no task uses TBD/TODO as an instruction.
- Type consistency: event field names use
eventName,eventType,durationMs, andoccurredAtconsistently across mini program and backend.