# 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`. `server` 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 `server/src/main/java/com/emotion/entity/AnalyticsEvent.java`: MyBatis Plus entity.
- Create `server/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java`: mapper plus aggregation SQL.
- Create `server/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventBatchRequest.java`: mini program batch request.
- Create `server/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventRequest.java`: one event payload.
- Create `server/src/main/java/com/emotion/dto/request/analytics/AnalyticsQueryRequest.java`: admin query filters.
- Create `server/src/main/java/com/emotion/dto/response/analytics/AnalyticsBatchResponse.java`: accepted/rejected counts.
- Create `server/src/main/java/com/emotion/dto/response/analytics/AnalyticsOverviewResponse.java`: overview cards.
- Create `server/src/main/java/com/emotion/dto/response/analytics/AnalyticsTrendItem.java`: trend item.
- Create `server/src/main/java/com/emotion/dto/response/analytics/AnalyticsFunnelItem.java`: funnel item.
- Create `server/src/main/java/com/emotion/dto/response/analytics/AnalyticsPreferenceItem.java`: preference item.
- Create `server/src/main/java/com/emotion/dto/response/analytics/AnalyticsTopEventItem.java`: top event item.
- Create `server/src/main/java/com/emotion/dto/response/analytics/AnalyticsUserItem.java`: user ranking item.
- Create `server/src/main/java/com/emotion/service/AnalyticsService.java`: write and aggregate contract.
- Create `server/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java`: validation, persistence, aggregation.
- Create `server/src/main/java/com/emotion/controller/AnalyticsController.java`: mini program write endpoint.
- Create `server/src/main/java/com/emotion/controller/AdminAnalyticsController.java`: admin read endpoints.
- Modify `server/src/main/java/com/emotion/config/WebMvcConfig.java`: allow anonymous analytics batch endpoint while keeping admin analytics protected.
- Create `server/src/test/java/com/emotion/service/AnalyticsServiceTest.java`: service validation and aggregation tests.
- Create `server/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: `server/src/main/java/com/emotion/entity/AnalyticsEvent.java`
- Create: `server/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventBatchRequest.java`
- Create: `server/src/main/java/com/emotion/dto/request/analytics/AnalyticsEventRequest.java`
- Create: `server/src/main/java/com/emotion/dto/request/analytics/AnalyticsQueryRequest.java`
- Create: response DTO files under `server/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 server
mvn -DskipTests compile
```

Expected: `BUILD SUCCESS`.

- [ ] **Step 5: Commit**

```bash
git add server/src/main/java/com/emotion/entity/AnalyticsEvent.java server/src/main/java/com/emotion/dto/request/analytics server/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: `server/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java`
- Create: `server/src/main/java/com/emotion/service/AnalyticsService.java`
- Create: `server/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java`
- Test: `server/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 server
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 server
mvn -Dtest=AnalyticsServiceTest test
```

Expected: `BUILD SUCCESS`.

- [ ] **Step 7: Commit**

```bash
git add server/src/main/java/com/emotion/mapper/AnalyticsEventMapper.java server/src/main/java/com/emotion/service/AnalyticsService.java server/src/main/java/com/emotion/service/impl/AnalyticsServiceImpl.java server/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: `server/src/main/java/com/emotion/controller/AnalyticsController.java`
- Create: `server/src/main/java/com/emotion/controller/AdminAnalyticsController.java`
- Modify: `server/src/main/java/com/emotion/config/WebMvcConfig.java`
- Test: `server/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 server
mvn -DskipTests compile
```

Expected: `BUILD SUCCESS`.

- [ ] **Step 5: Commit**

```bash
git add server/src/main/java/com/emotion/controller/AnalyticsController.java server/src/main/java/com/emotion/controller/AdminAnalyticsController.java server/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 server
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.
