diff --git a/docs/superpowers/plans/2026-05-17-mini-program-analytics-plan.md b/docs/superpowers/plans/2026-05-17-mini-program-analytics-plan.md new file mode 100644 index 0000000..48ef5e0 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-mini-program-analytics-plan.md @@ -0,0 +1,1375 @@ +# 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 properties; + @TableField(value = "device_info", typeHandler = JacksonTypeHandler.class) + private Map 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 deviceInfo; + + @Valid + @NotEmpty + @Size(max = 50) + private List 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 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 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(" + + +``` + +- [ ] **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 +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. diff --git a/docs/superpowers/plans/2026-05-17-script-tts-plan.md b/docs/superpowers/plans/2026-05-17-script-tts-plan.md new file mode 100644 index 0000000..421a696 --- /dev/null +++ b/docs/superpowers/plans/2026-05-17-script-tts-plan.md @@ -0,0 +1,1067 @@ +# Script Text-to-Speech 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 CPU-friendly open-source text-to-speech generation for user scripts, with cached backend tasks and mini program playback. + +**Architecture:** `backend-single` creates TTS tasks, validates source ownership, cleans script text, checks audio cache, and calls a private Python FastAPI TTS service. The Python service runs on `101.200.208.45` and writes audio files under `/data/uploads/emotion-museum/tts`. The mini program requests generation from `ScriptDetailView.vue`, polls status, and plays the returned audio URL with `uni.createInnerAudioContext()`. + +**Tech Stack:** Spring Boot 2.7, MyBatis Plus, Java 17, MySQL, Python FastAPI, MeloTTS or equivalent CPU-friendly Chinese TTS engine, systemd, uni-app/Vue. + +--- + +## File Structure + +- Create `sql/2026-05-17-tts-task.sql`: `t_tts_task` migration. +- Create `backend-single/src/main/java/com/emotion/entity/TtsTask.java`: task entity. +- Create `backend-single/src/main/java/com/emotion/mapper/TtsTaskMapper.java`: task mapper. +- Create `backend-single/src/main/java/com/emotion/dto/request/tts/TtsTaskCreateRequest.java`: create request. +- Create `backend-single/src/main/java/com/emotion/dto/response/tts/TtsTaskResponse.java`: task response. +- Create `backend-single/src/main/java/com/emotion/service/TtsTaskService.java`: contract. +- Create `backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java`: ownership, cache, async processing. +- Create `backend-single/src/main/java/com/emotion/service/TtsEngineClient.java`: internal engine client contract. +- Create `backend-single/src/main/java/com/emotion/service/impl/HttpTtsEngineClient.java`: calls Python service. +- Create `backend-single/src/main/java/com/emotion/controller/TtsController.java`: user-facing TTS endpoints. +- Modify `backend-single/src/main/resources/application.yml`: TTS config defaults. +- Modify `backend-single/src/main/resources/application-prod.yml`: production paths and internal URL. +- Create `backend-single/src/test/java/com/emotion/service/TtsTaskServiceTest.java`: service tests. +- Create `backend-single/tts-service/requirements.txt`: FastAPI deps; MeloTTS is installed from the official repository because the official docs use editable install plus `python -m unidic download`. +- Create `backend-single/tts-service/app.py`: FastAPI service. +- Create `backend-single/tts-service/README.md`: server setup. +- Create `backend-single/tts-service/emotion-museum-tts.service`: systemd unit. +- Create `mini-program/src/services/tts.js`: TTS API client. +- Create `mini-program/src/components/ScriptAudioPlayer.vue`: player component. +- Modify `mini-program/src/pages/main/ScriptDetailView.vue`: add player. +- Modify `mini-program/src/services/analytics.js` only if analytics plan has not already exported `track`. + +## Defaults + +- `sourceType`: `epic_script` +- `voice`: `default_zh_female` +- Text limit: 5000 cleaned characters +- Python internal URL: `http://127.0.0.1:19110` +- Audio output directory: `/data/uploads/emotion-museum/tts` +- Public URL prefix: `/uploads/emotion-museum/tts` or the existing static upload prefix configured by backend/Nginx + +--- + +### Task 1: Add TTS SQL Migration + +**Files:** +- Create: `sql/2026-05-17-tts-task.sql` + +- [ ] **Step 1: Create migration** + +```sql +CREATE TABLE IF NOT EXISTS t_tts_task ( + id VARCHAR(64) PRIMARY KEY COMMENT 'Primary key', + user_id VARCHAR(64) NOT NULL COMMENT 'Owner user id', + source_type VARCHAR(50) NOT NULL COMMENT 'Source type, for example epic_script', + source_id VARCHAR(64) NOT NULL COMMENT 'Source content id', + text_hash VARCHAR(128) NOT NULL COMMENT 'Hash of cleaned text and voice', + text_length INT NOT NULL COMMENT 'Cleaned text length', + voice VARCHAR(64) NOT NULL DEFAULT 'default_zh_female' COMMENT 'Voice id', + status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending, processing, success, failed', + audio_url VARCHAR(500) NULL COMMENT 'Public audio URL', + audio_path VARCHAR(500) NULL COMMENT 'Server audio path', + duration_ms BIGINT NULL COMMENT 'Audio duration', + error_message VARCHAR(1000) NULL COMMENT 'Failure message', + request_count INT NOT NULL DEFAULT 1 COMMENT 'Cache hit request count', + 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='Text-to-speech task table'; + +CREATE INDEX idx_tts_task_user_source ON t_tts_task (user_id, source_type, source_id); +CREATE INDEX idx_tts_task_text_hash ON t_tts_task (text_hash); +CREATE INDEX idx_tts_task_status ON t_tts_task (status); +CREATE INDEX idx_tts_task_create_time ON t_tts_task (create_time); +``` + +- [ ] **Step 2: Commit** + +```bash +git add sql/2026-05-17-tts-task.sql +git commit -m "feat: add tts task table" +``` + +--- + +### Task 2: Add Backend TTS Entity, DTOs, and Mapper + +**Files:** +- Create: `backend-single/src/main/java/com/emotion/entity/TtsTask.java` +- Create: `backend-single/src/main/java/com/emotion/mapper/TtsTaskMapper.java` +- Create: `backend-single/src/main/java/com/emotion/dto/request/tts/TtsTaskCreateRequest.java` +- Create: `backend-single/src/main/java/com/emotion/dto/response/tts/TtsTaskResponse.java` + +- [ ] **Step 1: Add entity** + +```java +package com.emotion.entity; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.emotion.common.BaseEntity; +import lombok.*; +import lombok.experimental.SuperBuilder; + +@Data +@EqualsAndHashCode(callSuper = true) +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@TableName("t_tts_task") +public class TtsTask extends BaseEntity { + @TableField("user_id") + private String userId; + @TableField("source_type") + private String sourceType; + @TableField("source_id") + private String sourceId; + @TableField("text_hash") + private String textHash; + @TableField("text_length") + private Integer textLength; + @TableField("voice") + private String voice; + @TableField("status") + private String status; + @TableField("audio_url") + private String audioUrl; + @TableField("audio_path") + private String audioPath; + @TableField("duration_ms") + private Long durationMs; + @TableField("error_message") + private String errorMessage; + @TableField("request_count") + private Integer requestCount; +} +``` + +- [ ] **Step 2: Add mapper** + +```java +package com.emotion.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.emotion.entity.TtsTask; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface TtsTaskMapper extends BaseMapper { +} +``` + +- [ ] **Step 3: Add DTOs** + +```java +package com.emotion.dto.request.tts; + +import lombok.Data; + +import javax.validation.constraints.NotBlank; +import javax.validation.constraints.Size; + +@Data +public class TtsTaskCreateRequest { + @NotBlank + @Size(max = 50) + private String sourceType; + + @NotBlank + @Size(max = 64) + private String sourceId; + + @Size(max = 64) + private String voice; +} +``` + +```java +package com.emotion.dto.response.tts; + +import lombok.Builder; +import lombok.Data; + +@Data +@Builder +public class TtsTaskResponse { + private String id; + private String sourceType; + private String sourceId; + private String status; + private String voice; + private String audioUrl; + private Long durationMs; + private String errorMessage; +} +``` + +- [ ] **Step 4: Compile** + +```bash +cd backend-single +mvn -DskipTests compile +``` + +Expected: `BUILD SUCCESS`. + +- [ ] **Step 5: Commit** + +```bash +git add backend-single/src/main/java/com/emotion/entity/TtsTask.java backend-single/src/main/java/com/emotion/mapper/TtsTaskMapper.java backend-single/src/main/java/com/emotion/dto/request/tts backend-single/src/main/java/com/emotion/dto/response/tts +git commit -m "feat: add tts task model" +``` + +--- + +### Task 3: Add TTS Service and Engine Client + +**Files:** +- Create: `backend-single/src/main/java/com/emotion/service/TtsTaskService.java` +- Create: `backend-single/src/main/java/com/emotion/service/TtsEngineClient.java` +- Create: `backend-single/src/main/java/com/emotion/service/impl/HttpTtsEngineClient.java` +- Create: `backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java` +- Modify: `backend-single/src/main/resources/application.yml` +- Modify: `backend-single/src/main/resources/application-prod.yml` +- Test: `backend-single/src/test/java/com/emotion/service/TtsTaskServiceTest.java` + +- [ ] **Step 1: Add service contracts** + +```java +package com.emotion.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.emotion.dto.request.tts.TtsTaskCreateRequest; +import com.emotion.dto.response.tts.TtsTaskResponse; +import com.emotion.entity.TtsTask; + +public interface TtsTaskService extends IService { + TtsTaskResponse createOrReuse(TtsTaskCreateRequest request); + TtsTaskResponse getTask(String id); + TtsTaskResponse getBySource(String sourceType, String sourceId, String voice); +} +``` + +```java +package com.emotion.service; + +public interface TtsEngineClient { + TtsEngineResult synthesize(String text, String voice, String outputPath); + + class TtsEngineResult { + private final boolean success; + private final String audioPath; + private final Long durationMs; + private final String errorMessage; + + public TtsEngineResult(boolean success, String audioPath, Long durationMs, String errorMessage) { + this.success = success; + this.audioPath = audioPath; + this.durationMs = durationMs; + this.errorMessage = errorMessage; + } + + public boolean isSuccess() { return success; } + public String getAudioPath() { return audioPath; } + public Long getDurationMs() { return durationMs; } + public String getErrorMessage() { return errorMessage; } + } +} +``` + +- [ ] **Step 2: Add application config** + +In `application.yml`: + +```yaml +emotion: + tts: + enabled: true + engine-url: http://127.0.0.1:19110 + output-dir: /data/uploads/emotion-museum/tts + public-url-prefix: /uploads/emotion-museum/tts + max-text-length: 5000 + default-voice: default_zh_female +``` + +In `application-prod.yml`, add the same `emotion.tts` block with production paths if not inherited. + +- [ ] **Step 3: Add HTTP engine client** + +```java +package com.emotion.service.impl; + +import com.emotion.service.TtsEngineClient; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Service +public class HttpTtsEngineClient implements TtsEngineClient { + private final RestTemplate restTemplate; + + @Value("${emotion.tts.engine-url:http://127.0.0.1:19110}") + private String engineUrl; + + public HttpTtsEngineClient(RestTemplate restTemplate) { + this.restTemplate = restTemplate; + } + + @Override + public TtsEngineResult synthesize(String text, String voice, String outputPath) { + try { + Map body = Map.of("text", text, "voice", voice, "outputPath", outputPath); + ResponseEntity response = restTemplate.postForEntity(engineUrl + "/synthesize", body, Map.class); + Map data = response.getBody(); + boolean success = data != null && Boolean.TRUE.equals(data.get("success")); + if (!success) { + return new TtsEngineResult(false, null, null, String.valueOf(data != null ? data.get("errorMessage") : "empty response")); + } + Long durationMs = data.get("durationMs") instanceof Number ? ((Number) data.get("durationMs")).longValue() : null; + return new TtsEngineResult(true, String.valueOf(data.get("audioPath")), durationMs, null); + } catch (Exception e) { + return new TtsEngineResult(false, null, null, e.getMessage()); + } + } +} +``` + +- [ ] **Step 4: Add task service implementation** + +```java +package com.emotion.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.emotion.dto.request.tts.TtsTaskCreateRequest; +import com.emotion.dto.response.tts.TtsTaskResponse; +import com.emotion.entity.EpicScript; +import com.emotion.entity.TtsTask; +import com.emotion.mapper.EpicScriptMapper; +import com.emotion.mapper.TtsTaskMapper; +import com.emotion.service.TtsEngineClient; +import com.emotion.service.TtsTaskService; +import com.emotion.util.UserContextHolder; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.DigestUtils; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; + +@Service +public class TtsTaskServiceImpl extends ServiceImpl implements TtsTaskService { + private final EpicScriptMapper epicScriptMapper; + private final TtsEngineClient ttsEngineClient; + + @Value("${emotion.tts.output-dir:/data/uploads/emotion-museum/tts}") + private String outputDir; + @Value("${emotion.tts.public-url-prefix:/uploads/emotion-museum/tts}") + private String publicUrlPrefix; + @Value("${emotion.tts.max-text-length:5000}") + private int maxTextLength; + @Value("${emotion.tts.default-voice:default_zh_female}") + private String defaultVoice; + + public TtsTaskServiceImpl(EpicScriptMapper epicScriptMapper, TtsEngineClient ttsEngineClient) { + this.epicScriptMapper = epicScriptMapper; + this.ttsEngineClient = ttsEngineClient; + } + + @Override + public TtsTaskResponse createOrReuse(TtsTaskCreateRequest request) { + String userId = UserContextHolder.getCurrentUserId(); + String voice = request.getVoice() == null || request.getVoice().isBlank() ? defaultVoice : request.getVoice(); + String text = loadSourceText(userId, request.getSourceType(), request.getSourceId()); + String cleaned = cleanText(text); + if (cleaned.length() > maxTextLength) { + cleaned = cleaned.substring(0, maxTextLength); + } + String hash = DigestUtils.md5DigestAsHex((voice + "\n" + cleaned).getBytes(StandardCharsets.UTF_8)); + + TtsTask existing = getOne(new LambdaQueryWrapper() + .eq(TtsTask::getTextHash, hash) + .eq(TtsTask::getVoice, voice) + .eq(TtsTask::getIsDeleted, 0) + .last("LIMIT 1")); + if (existing != null) { + existing.setRequestCount((existing.getRequestCount() == null ? 0 : existing.getRequestCount()) + 1); + updateById(existing); + return toResponse(existing); + } + + String filename = hash + ".mp3"; + TtsTask task = TtsTask.builder() + .userId(userId) + .sourceType(request.getSourceType()) + .sourceId(request.getSourceId()) + .textHash(hash) + .textLength(cleaned.length()) + .voice(voice) + .status("pending") + .audioPath(outputDir + "/" + filename) + .audioUrl(publicUrlPrefix + "/" + filename) + .requestCount(1) + .build(); + save(task); + CompletableFuture.runAsync(() -> process(task.getId(), cleaned, voice, task.getAudioPath())); + return toResponse(task); + } + + @Override + public TtsTaskResponse getTask(String id) { + TtsTask task = getById(id); + String userId = UserContextHolder.getCurrentUserId(); + if (task == null || !userId.equals(task.getUserId())) { + return null; + } + return toResponse(task); + } + + @Override + public TtsTaskResponse getBySource(String sourceType, String sourceId, String voice) { + String userId = UserContextHolder.getCurrentUserId(); + TtsTask task = getOne(new LambdaQueryWrapper() + .eq(TtsTask::getUserId, userId) + .eq(TtsTask::getSourceType, sourceType) + .eq(TtsTask::getSourceId, sourceId) + .eq(TtsTask::getVoice, voice == null || voice.isBlank() ? defaultVoice : voice) + .eq(TtsTask::getIsDeleted, 0) + .orderByDesc(TtsTask::getCreateTime) + .last("LIMIT 1")); + return task == null ? null : toResponse(task); + } + + private void process(String taskId, String text, String voice, String outputPath) { + TtsTask task = getById(taskId); + if (task == null) return; + task.setStatus("processing"); + updateById(task); + TtsEngineClient.TtsEngineResult result = ttsEngineClient.synthesize(text, voice, outputPath); + task = getById(taskId); + if (result.isSuccess()) { + task.setStatus("success"); + task.setDurationMs(result.getDurationMs()); + } else { + task.setStatus("failed"); + task.setErrorMessage(result.getErrorMessage()); + } + updateById(task); + } + + private String loadSourceText(String userId, String sourceType, String sourceId) { + if (!"epic_script".equals(sourceType)) { + throw new IllegalArgumentException("Unsupported sourceType"); + } + EpicScript script = epicScriptMapper.selectById(sourceId); + if (script == null || !userId.equals(script.getUserId())) { + throw new IllegalArgumentException("Script not found"); + } + StringBuilder text = new StringBuilder(); + if (script.getTitle() != null) text.append(script.getTitle()).append("\n\n"); + if (script.getPlotIntro() != null) text.append(script.getPlotIntro()).append("\n\n"); + if (script.getPlotTurning() != null) text.append(script.getPlotTurning()).append("\n\n"); + if (script.getPlotClimax() != null) text.append(script.getPlotClimax()).append("\n\n"); + if (script.getPlotEnding() != null) text.append(script.getPlotEnding()).append("\n\n"); + if (script.getPlotJson() != null && script.getPlotJson().get("fullContent") != null) { + text.append(script.getPlotJson().get("fullContent")); + } + return text.toString(); + } + + public static String cleanText(String text) { + if (text == null) return ""; + return text.replaceAll("[#>*_`\\-]", "") + .replaceAll("\\s+", " ") + .trim(); + } + + private TtsTaskResponse toResponse(TtsTask task) { + return TtsTaskResponse.builder() + .id(task.getId()) + .sourceType(task.getSourceType()) + .sourceId(task.getSourceId()) + .status(task.getStatus()) + .voice(task.getVoice()) + .audioUrl("success".equals(task.getStatus()) ? task.getAudioUrl() : null) + .durationMs(task.getDurationMs()) + .errorMessage(task.getErrorMessage()) + .build(); + } +} +``` + +- [ ] **Step 5: Run compile** + +```bash +cd backend-single +mvn -DskipTests compile +``` + +Expected: `BUILD SUCCESS`. + +- [ ] **Step 6: Commit** + +```bash +git add backend-single/src/main/java/com/emotion/service/TtsTaskService.java backend-single/src/main/java/com/emotion/service/TtsEngineClient.java backend-single/src/main/java/com/emotion/service/impl/HttpTtsEngineClient.java backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java backend-single/src/main/resources/application.yml backend-single/src/main/resources/application-prod.yml +git commit -m "feat: add tts backend service" +``` + +--- + +### Task 4: Add TTS Controller + +**Files:** +- Create: `backend-single/src/main/java/com/emotion/controller/TtsController.java` + +- [ ] **Step 1: Add controller** + +```java +package com.emotion.controller; + +import com.emotion.common.Result; +import com.emotion.dto.request.tts.TtsTaskCreateRequest; +import com.emotion.dto.response.tts.TtsTaskResponse; +import com.emotion.service.TtsTaskService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; + +@RestController +@RequestMapping("/tts") +public class TtsController { + @Autowired + private TtsTaskService ttsTaskService; + + @PostMapping("/tasks") + public Result create(@Valid @RequestBody TtsTaskCreateRequest request) { + try { + return Result.success(ttsTaskService.createOrReuse(request)); + } catch (IllegalArgumentException e) { + return Result.badRequest(e.getMessage()); + } + } + + @GetMapping("/tasks/{id}") + public Result detail(@PathVariable String id) { + TtsTaskResponse response = ttsTaskService.getTask(id); + return response == null ? Result.notFound("TTS task not found") : Result.success(response); + } + + @GetMapping("/tasks/by-source") + public Result bySource(@RequestParam String sourceType, @RequestParam String sourceId, @RequestParam(required = false) String voice) { + TtsTaskResponse response = ttsTaskService.getBySource(sourceType, sourceId, voice); + return Result.success(response); + } +} +``` + +- [ ] **Step 2: Run compile** + +```bash +cd backend-single +mvn -DskipTests compile +``` + +Expected: `BUILD SUCCESS`. + +- [ ] **Step 3: Commit** + +```bash +git add backend-single/src/main/java/com/emotion/controller/TtsController.java +git commit -m "feat: expose tts task APIs" +``` + +--- + +### Task 5: Add Python TTS Service Skeleton + +**Files:** +- Create: `backend-single/tts-service/requirements.txt` +- Create: `backend-single/tts-service/app.py` +- Create: `backend-single/tts-service/README.md` +- Create: `backend-single/tts-service/emotion-museum-tts.service` + +- [ ] **Step 1: Add requirements** + +```text +fastapi==0.111.0 +uvicorn[standard]==0.30.1 +pydantic==2.7.4 +``` + +MeloTTS itself is installed with: + +```bash +git clone https://github.com/myshell-ai/MeloTTS.git /data/programs/MeloTTS +cd /data/programs/MeloTTS +/data/programs/emotion-museum/tts-service/.venv/bin/pip install -e . +/data/programs/emotion-museum/tts-service/.venv/bin/python -m unidic download +``` + +- [ ] **Step 2: Add FastAPI app** + +```python +from pathlib import Path +from threading import Lock +from pydantic import BaseModel, Field +from fastapi import FastAPI + +app = FastAPI(title="Emotion Museum TTS") + +_model = None +_speaker_ids = None +_model_lock = Lock() + + +class SynthesizeRequest(BaseModel): + text: str = Field(min_length=1, max_length=5000) + voice: str = "default_zh_female" + outputPath: str + + +def get_model(): + global _model, _speaker_ids + with _model_lock: + if _model is None: + from melo.api import TTS + + _model = TTS(language="ZH", device="cpu") + _speaker_ids = _model.hps.data.spk2id + return _model, _speaker_ids + + +@app.get("/health") +def health(): + return {"status": "ok"} + + +@app.post("/synthesize") +def synthesize(request: SynthesizeRequest): + output = Path(request.outputPath) + output.parent.mkdir(parents=True, exist_ok=True) + + try: + model, speaker_ids = get_model() + speaker_id = speaker_ids.get("ZH") + model.tts_to_file(request.text, speaker_id, str(output), speed=1.0) + except Exception as exc: + return { + "success": False, + "audioPath": None, + "durationMs": None, + "engine": "melotts", + "errorMessage": str(exc), + } + + return { + "success": True, + "audioPath": str(output), + "durationMs": None, + "engine": "melotts", + } +``` + +- [ ] **Step 3: Add systemd unit** + +```ini +[Unit] +Description=Emotion Museum TTS Service +After=network.target + +[Service] +Type=simple +WorkingDirectory=/data/programs/emotion-museum/tts-service +ExecStart=/data/programs/emotion-museum/tts-service/.venv/bin/uvicorn app:app --host 127.0.0.1 --port 19110 +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +- [ ] **Step 4: Add README commands** + +```markdown +# Emotion Museum TTS Service + +Install on `101.200.208.45`: + +```bash +cd /data/programs/emotion-museum/tts-service +python3 -m venv .venv +. .venv/bin/activate +pip install -r requirements.txt + +git clone https://github.com/myshell-ai/MeloTTS.git /data/programs/MeloTTS +cd /data/programs/MeloTTS +/data/programs/emotion-museum/tts-service/.venv/bin/pip install -e . +/data/programs/emotion-museum/tts-service/.venv/bin/python -m unidic download + +cd /data/programs/emotion-museum/tts-service +uvicorn app:app --host 127.0.0.1 --port 19110 +curl http://127.0.0.1:19110/health +``` +``` + +- [ ] **Step 5: Run local syntax check** + +```bash +python -m py_compile backend-single/tts-service/app.py +``` + +Expected: no output and exit code 0. + +- [ ] **Step 6: Commit** + +```bash +git add backend-single/tts-service +git commit -m "feat: add private tts service scaffold" +``` + +--- + +### Task 6: Add Mini Program TTS Client and Player + +**Files:** +- Create: `mini-program/src/services/tts.js` +- Create: `mini-program/src/components/ScriptAudioPlayer.vue` + +- [ ] **Step 1: Add TTS client** + +```javascript +import { get, post } from './request.js' + +export const createTtsTask = ({ sourceType = 'epic_script', sourceId, voice = 'default_zh_female' }) => { + return post('/tts/tasks', { sourceType, sourceId, voice }) +} + +export const getTtsTask = (id) => { + return get(`/tts/tasks/${id}`) +} + +export const getTtsTaskBySource = ({ sourceType = 'epic_script', sourceId, voice = 'default_zh_female' }) => { + return get('/tts/tasks/by-source', { sourceType, sourceId, voice }) +} + +export default { + createTtsTask, + getTtsTask, + getTtsTaskBySource +} +``` + +- [ ] **Step 2: Add player component** + +```vue + + + + + +``` + +- [ ] **Step 3: Build mini program** + +```bash +cd mini-program +npm run build:mp-weixin:test +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add mini-program/src/services/tts.js mini-program/src/components/ScriptAudioPlayer.vue +git commit -m "feat: add script audio player" +``` + +--- + +### Task 7: Wire Player into Script Detail + +**Files:** +- Modify: `mini-program/src/pages/main/ScriptDetailView.vue` + +- [ ] **Step 1: Import component** + +```javascript +import ScriptAudioPlayer from '../../components/ScriptAudioPlayer.vue' +``` + +- [ ] **Step 2: Add component below the hero stats** + +In the `.hero-card` template, after the stats block: + +```vue + +``` + +- [ ] **Step 3: Build mini program** + +```bash +cd mini-program +npm run build:mp-weixin:test +``` + +Expected: build succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add mini-program/src/pages/main/ScriptDetailView.vue +git commit -m "feat: add tts control to script detail" +``` + +--- + +### Task 8: Deploy and Verify TTS Service on Server + +**Files:** +- Modify deployment scripts only if manual deployment proves repetitive. + +- [ ] **Step 1: Upload service directory** + +```bash +scp -r backend-single/tts-service root@101.200.208.45:/data/programs/emotion-museum/tts-service +``` + +Expected: files are copied. + +- [ ] **Step 2: Install and start manually** + +```bash +ssh root@101.200.208.45 "cd /data/programs/emotion-museum/tts-service && python3 -m venv .venv && . .venv/bin/activate && pip install -r requirements.txt && if [ ! -d /data/programs/MeloTTS ]; then git clone https://github.com/myshell-ai/MeloTTS.git /data/programs/MeloTTS; fi && cd /data/programs/MeloTTS && /data/programs/emotion-museum/tts-service/.venv/bin/pip install -e . && /data/programs/emotion-museum/tts-service/.venv/bin/python -m unidic download && cd /data/programs/emotion-museum/tts-service && nohup .venv/bin/uvicorn app:app --host 127.0.0.1 --port 19110 > tts.log 2>&1 &" +``` + +Expected: command exits. + +- [ ] **Step 3: Check health** + +```bash +ssh root@101.200.208.45 "curl -s http://127.0.0.1:19110/health" +``` + +Expected: + +```json +{"status":"ok"} +``` + +- [ ] **Step 4: Install systemd unit after health passes** + +```bash +scp backend-single/tts-service/emotion-museum-tts.service root@101.200.208.45:/etc/systemd/system/emotion-museum-tts.service +ssh root@101.200.208.45 "systemctl daemon-reload && systemctl enable emotion-museum-tts && systemctl restart emotion-museum-tts && systemctl status emotion-museum-tts --no-pager" +``` + +Expected: service status is active. + +- [ ] **Step 5: Commit deployment doc tweaks if needed** + +```bash +git add backend-single/tts-service/README.md +git commit -m "docs: update tts deployment notes" +``` + +Only run this commit if README changed. + +--- + +### Task 9: Final TTS Verification + +**Files:** +- No code changes unless bugs are found. + +- [ ] **Step 1: Run backend tests** + +```bash +cd backend-single +mvn test +``` + +Expected: `BUILD SUCCESS`. + +- [ ] **Step 2: Run mini program build** + +```bash +cd mini-program +npm run build:mp-weixin:test +``` + +Expected: build succeeds. + +- [ ] **Step 3: Smoke-test backend task API** + +With a logged-in mini program token and an owned script id: + +```bash +curl -X POST http://localhost:19089/tts/tasks ^ + -H "Content-Type: application/json" ^ + -H "Authorization: Bearer " ^ + -d "{\"sourceType\":\"epic_script\",\"sourceId\":\"\",\"voice\":\"default_zh_female\"}" +``` + +Expected: response contains a task with `pending`, `processing`, or `success`. A `failed` status means the backend integration works but the Python engine failed and server logs must be checked. + +- [ ] **Step 4: Manual mini program test** + +Open script detail in WeChat DevTools: + +- Tap `生成朗读`. +- Observe generating state. +- If engine is installed, wait for `播放朗读`. +- Tap play and pause. +- Leave page and verify no background playback continues. + +- [ ] **Step 5: Commit fixes if needed** + +```bash +git add +git commit -m "fix: stabilize tts playback" +``` + +Only run this commit if verification required code changes. + +## Self-Review + +- Spec coverage: task table, backend task API, Python service, CPU/no-GPU assumption, cache, mini program playback, and analytics events are covered. +- Placeholder scan: no task leaves the TTS engine as an unspecified later step; the plan uses the official MeloTTS local install and Python API. +- Type consistency: request and response use `sourceType`, `sourceId`, `voice`, `status`, and `audioUrl` consistently across backend and mini program.