1376 lines
42 KiB
Markdown
1376 lines
42 KiB
Markdown
# 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 for `t_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_hide`
|
|
- `page_view`, `page_leave`
|
|
- `profile_complete`
|
|
- `life_event_create`, `life_event_update`, `life_event_ai_assist`, `life_event_favorite`, `life_event_share`
|
|
- `script_inspiration_view`, `script_inspiration_click`, `script_generate_start`, `script_generate_success`, `script_generate_fail`
|
|
- `script_detail_view`, `path_select`
|
|
- `script_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**
|
|
|
|
```sql
|
|
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:
|
|
|
|
```bash
|
|
mysql --version
|
|
```
|
|
|
|
Expected: MySQL client version prints. If no client is installed, skip execution and rely on review plus deployment migration.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```java
|
|
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**
|
|
|
|
```java
|
|
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;
|
|
}
|
|
```
|
|
|
|
```java
|
|
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;
|
|
}
|
|
```
|
|
|
|
```java
|
|
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**
|
|
|
|
```java
|
|
package com.emotion.dto.response.analytics;
|
|
|
|
import lombok.Builder;
|
|
import lombok.Data;
|
|
|
|
@Data
|
|
@Builder
|
|
public class AnalyticsBatchResponse {
|
|
private int accepted;
|
|
private int rejected;
|
|
}
|
|
```
|
|
|
|
```java
|
|
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;
|
|
}
|
|
```
|
|
|
|
```java
|
|
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;
|
|
}
|
|
```
|
|
|
|
```java
|
|
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;
|
|
}
|
|
```
|
|
|
|
```java
|
|
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;
|
|
}
|
|
```
|
|
|
|
```java
|
|
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;
|
|
}
|
|
```
|
|
|
|
```java
|
|
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:
|
|
|
|
```bash
|
|
cd backend-single
|
|
mvn -DskipTests compile
|
|
```
|
|
|
|
Expected: `BUILD SUCCESS`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```java
|
|
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:
|
|
|
|
```bash
|
|
cd backend-single
|
|
mvn -Dtest=AnalyticsServiceTest test
|
|
```
|
|
|
|
Expected: FAIL because `AnalyticsServiceImpl` does not exist.
|
|
|
|
- [ ] **Step 3: Add mapper**
|
|
|
|
```java
|
|
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**
|
|
|
|
```java
|
|
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**
|
|
|
|
```java
|
|
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:
|
|
|
|
```bash
|
|
cd backend-single
|
|
mvn -Dtest=AnalyticsServiceTest test
|
|
```
|
|
|
|
Expected: `BUILD SUCCESS`.
|
|
|
|
- [ ] **Step 7: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```java
|
|
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**
|
|
|
|
```java
|
|
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:
|
|
|
|
```java
|
|
"/analytics/events/batch",
|
|
```
|
|
|
|
Do not exclude `/admin/analytics/**`.
|
|
|
|
- [ ] **Step 4: Run backend compile**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd backend-single
|
|
mvn -DskipTests compile
|
|
```
|
|
|
|
Expected: `BUILD SUCCESS`.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```bash
|
|
cd mini-program
|
|
npm run build:mp-weixin:test
|
|
```
|
|
|
|
Expected: build completes without module resolution errors.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```javascript
|
|
import analytics from './services/analytics.js'
|
|
```
|
|
|
|
In app lifecycle hooks:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
import analytics from '../../services/analytics.js'
|
|
```
|
|
|
|
In `onMounted` after `scriptId.value` is set:
|
|
|
|
```javascript
|
|
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`:
|
|
|
|
```javascript
|
|
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`:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
analytics.track('script_generate_start', {
|
|
style: selectedStyle.value,
|
|
length: selectedLength.value,
|
|
source: 'inspiration'
|
|
}, { eventType: 'script', pagePath: '/pages/main/ScriptView' })
|
|
```
|
|
|
|
On success:
|
|
|
|
```javascript
|
|
analytics.track('script_generate_success', {
|
|
style: selectedStyle.value,
|
|
length: selectedLength.value
|
|
}, { eventType: 'script', pagePath: '/pages/main/ScriptView' })
|
|
```
|
|
|
|
On failure:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```javascript
|
|
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:
|
|
|
|
```bash
|
|
cd mini-program
|
|
npm run build:mp-weixin:test
|
|
```
|
|
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```vue
|
|
<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`:
|
|
|
|
```typescript
|
|
{
|
|
path: 'analytics',
|
|
name: 'AnalyticsDashboard',
|
|
component: () => import('@/views/analytics/AnalyticsDashboard.vue'),
|
|
meta: { title: '行为分析', icon: 'TrendCharts' }
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Add menu**
|
|
|
|
Add to `menuConfig`:
|
|
|
|
```typescript
|
|
{
|
|
path: '/analytics',
|
|
title: '行为分析',
|
|
icon: 'TrendCharts'
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 5: Build admin**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
cd web-admin
|
|
npm run build
|
|
```
|
|
|
|
Expected: `vue-tsc` and Vite build succeed.
|
|
|
|
- [ ] **Step 6: Commit**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
cd backend-single
|
|
mvn test
|
|
```
|
|
|
|
Expected: `BUILD SUCCESS`.
|
|
|
|
- [ ] **Step 2: Run admin build**
|
|
|
|
```bash
|
|
cd web-admin
|
|
npm run build
|
|
```
|
|
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 3: Run mini program build**
|
|
|
|
```bash
|
|
cd mini-program
|
|
npm run build:mp-weixin:test
|
|
```
|
|
|
|
Expected: build succeeds.
|
|
|
|
- [ ] **Step 4: Manual API smoke test**
|
|
|
|
With backend running locally, send:
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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`, and `occurredAt` consistently across mini program and backend.
|