feat: 小程序脚本首页重构 + 社交数据导入 + TTS 播放优化

- 后端:新增社交数据导入/审批/洞察生成 API(SocialContent/SocialInsight)
- 后端:优化脚本上下文服务,TTS 服务增强
- 小程序:重构脚本首页布局,新增社交导入页面
- 小程序:新增 useTtsPlayer composable,移除旧 ScriptAudioPlayer 组件
- 小程序:新增社交导入服务,优化请求服务
- SQL:新增社交数据导入建表脚本
- 文档:补充设计文档和实施计划

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 07:18:02 +08:00
parent 83cc32999b
commit ee5a6aba5d
50 changed files with 5723 additions and 1246 deletions
@@ -0,0 +1,115 @@
package com.emotion.controller;
import com.emotion.common.Result;
import com.emotion.dto.request.social.SocialContentApprovalRequest;
import com.emotion.dto.request.social.SocialContentLinkImportRequest;
import com.emotion.dto.request.social.SocialContentManualImportRequest;
import com.emotion.dto.response.social.SocialContentItemResponse;
import com.emotion.service.SocialContentService;
import com.emotion.util.UserContextHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import javax.validation.Valid;
import java.util.List;
@Validated
@RestController
@RequestMapping("/social/content")
public class SocialContentController {
@Autowired
private SocialContentService socialContentService;
@PostMapping("/manual")
public Result<SocialContentItemResponse> manualImport(@Valid @RequestBody SocialContentManualImportRequest request) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
try {
return Result.success(socialContentService.manualImport(userId, request));
} catch (IllegalArgumentException e) {
return Result.badRequest(e.getMessage());
}
}
@PostMapping("/link")
public Result<SocialContentItemResponse> linkImport(@Valid @RequestBody SocialContentLinkImportRequest request) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
try {
return Result.success(socialContentService.linkImport(userId, request));
} catch (IllegalArgumentException e) {
return Result.badRequest(e.getMessage());
}
}
@PostMapping("/screenshot")
public Result<SocialContentItemResponse> screenshotImport(@RequestParam String platform,
@RequestPart("file") MultipartFile file) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
try {
return Result.success(socialContentService.screenshotImport(userId, platform, file));
} catch (IllegalArgumentException | IllegalStateException e) {
return Result.badRequest(e.getMessage());
}
}
@GetMapping("/list")
public Result<List<SocialContentItemResponse>> list() {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
return Result.success(socialContentService.listByUser(userId));
}
@PutMapping("/{id}/approval")
public Result<SocialContentItemResponse> updateApproval(@PathVariable String id,
@RequestBody SocialContentApprovalRequest request) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
SocialContentItemResponse response = socialContentService.updateApproval(userId, id, request.getApprovedForAi());
if (response == null) {
return Result.notFound("导入内容不存在");
}
return Result.success(response);
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id,
@RequestParam(required = false, defaultValue = "true") Boolean keepConfirmedInsights) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
boolean deleted = socialContentService.deleteByUser(userId, id, keepConfirmedInsights);
if (!deleted) {
return Result.notFound("导入内容不存在");
}
return Result.success();
}
private String currentUserId() {
return UserContextHolder.getCurrentUserId();
}
}
@@ -0,0 +1,84 @@
package com.emotion.controller;
import com.emotion.common.Result;
import com.emotion.dto.request.social.SocialInsightGenerateRequest;
import com.emotion.dto.request.social.SocialInsightUpdateRequest;
import com.emotion.dto.response.social.SocialProfileInsightResponse;
import com.emotion.service.SocialInsightService;
import com.emotion.util.UserContextHolder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.validation.Valid;
import java.util.List;
@Validated
@RestController
@RequestMapping("/social/insight")
public class SocialInsightController {
@Autowired
private SocialInsightService socialInsightService;
@PostMapping("/generate")
public Result<List<SocialProfileInsightResponse>> generate(@RequestBody(required = false) SocialInsightGenerateRequest request) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
return Result.success(socialInsightService.generateInsights(userId, request));
}
@GetMapping("/list")
public Result<List<SocialProfileInsightResponse>> list(@RequestParam(required = false) String status) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
return Result.success(socialInsightService.listByUser(userId, status));
}
@PutMapping("/{id}")
public Result<SocialProfileInsightResponse> update(@PathVariable String id,
@Valid @RequestBody SocialInsightUpdateRequest request) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
try {
SocialProfileInsightResponse response = socialInsightService.updateByUser(userId, id, request);
if (response == null) {
return Result.notFound("画像不存在");
}
return Result.success(response);
} catch (IllegalArgumentException e) {
return Result.badRequest(e.getMessage());
}
}
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
String userId = currentUserId();
if (userId == null) {
return Result.unauthorized();
}
boolean deleted = socialInsightService.deleteByUser(userId, id);
if (!deleted) {
return Result.notFound("画像不存在");
}
return Result.success();
}
private String currentUserId() {
return UserContextHolder.getCurrentUserId();
}
}
@@ -78,4 +78,8 @@ public class EpicScriptCreateRequest extends BaseRequest {
* 过往经历关键词(前端传入,用于AI生成)
*/
private String lifeEventsSummary;
/**
* 是否使用用户已确认的社交画像增强剧本生成。
*/
private Boolean useSocialInsights;
}
@@ -25,4 +25,8 @@ public class EpicScriptInspirationRequest extends BaseRequest {
private String lifeEventsSummary;
private String source;
/**
* 是否使用用户已确认的社交画像增强生成。
*/
private Boolean useSocialInsights;
}
@@ -88,4 +88,8 @@ public class EpicScriptUpdateRequest extends BaseRequest {
* 是否需要重新生成AI内容
*/
private Boolean regenerateContent;
/**
* 是否使用用户已确认的社交画像增强重新生成。
*/
private Boolean useSocialInsights;
}
@@ -0,0 +1,15 @@
package com.emotion.dto.request.social;
import com.emotion.dto.request.BaseRequest;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotNull;
@Data
@EqualsAndHashCode(callSuper = true)
public class SocialContentApprovalRequest extends BaseRequest {
@NotNull(message = "授权状态不能为空")
private Boolean approvedForAi;
}
@@ -0,0 +1,27 @@
package com.emotion.dto.request.social;
import com.emotion.dto.request.BaseRequest;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
@EqualsAndHashCode(callSuper = true)
public class SocialContentLinkImportRequest extends BaseRequest {
@NotBlank(message = "平台不能为空")
private String platform;
@NotBlank(message = "来源链接不能为空")
@Size(max = 1000, message = "来源链接不能超过1000个字符")
private String sourceUrl;
private String title;
@Size(max = 20000, message = "导入内容不能超过20000个字符")
private String content;
private Boolean approvedForAi;
}
@@ -0,0 +1,24 @@
package com.emotion.dto.request.social;
import com.emotion.dto.request.BaseRequest;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
@Data
@EqualsAndHashCode(callSuper = true)
public class SocialContentManualImportRequest extends BaseRequest {
@NotBlank(message = "平台不能为空")
private String platform;
private String title;
@NotBlank(message = "导入内容不能为空")
@Size(max = 20000, message = "导入内容不能超过20000个字符")
private String content;
private Boolean approvedForAi;
}
@@ -0,0 +1,14 @@
package com.emotion.dto.request.social;
import com.emotion.dto.request.BaseRequest;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public class SocialInsightGenerateRequest extends BaseRequest {
private List<String> sourceItemIds;
}
@@ -0,0 +1,20 @@
package com.emotion.dto.request.social;
import com.emotion.dto.request.BaseRequest;
import lombok.Data;
import lombok.EqualsAndHashCode;
import javax.validation.constraints.Size;
@Data
@EqualsAndHashCode(callSuper = true)
public class SocialInsightUpdateRequest extends BaseRequest {
@Size(max = 100, message = "标签不能超过100个字符")
private String label;
@Size(max = 1000, message = "摘要不能超过1000个字符")
private String summary;
private String status;
}
@@ -0,0 +1,26 @@
package com.emotion.dto.response.social;
import com.emotion.dto.response.BaseResponse;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class SocialContentItemResponse extends BaseResponse {
private String platform;
private String sourceType;
private String sourceUrl;
private String title;
private String content;
private Boolean approvedForAi;
private String importStatus;
private Boolean sourceDeleted;
}
@@ -0,0 +1,30 @@
package com.emotion.dto.response.social;
import com.emotion.dto.response.BaseResponse;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
@Data
@EqualsAndHashCode(callSuper = true)
public class SocialProfileInsightResponse extends BaseResponse {
private String sourceItemId;
private String insightType;
private String label;
private String summary;
private String evidenceExcerpt;
private BigDecimal confidence;
private String status;
private Boolean userEdited;
private Boolean sourceDeleted;
}
@@ -0,0 +1,66 @@
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.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* Social content voluntarily imported by a user.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName(value = "t_social_content_item", autoResultMap = true)
public class SocialContentItem extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("platform")
private String platform;
@TableField("source_type")
private String sourceType;
@TableField("source_url")
private String sourceUrl;
@TableField("title")
private String title;
@TableField("content")
private String content;
@TableField(value = "image_urls", typeHandler = JacksonTypeHandler.class)
private List<String> imageUrls;
@TableField("published_at")
private LocalDateTime publishedAt;
@TableField("import_status")
private String importStatus;
@TableField("approved_for_ai")
private Integer approvedForAi;
@TableField("content_hash")
private String contentHash;
@TableField(value = "raw_metadata", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> rawMetadata;
@TableField("deleted_at")
private LocalDateTime deletedAt;
}
@@ -0,0 +1,58 @@
package com.emotion.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.emotion.common.BaseEntity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* User-reviewable insight extracted from imported social content.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName("t_social_profile_insight")
public class SocialProfileInsight extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("source_item_id")
private String sourceItemId;
@TableField("insight_type")
private String insightType;
@TableField("label")
private String label;
@TableField("summary")
private String summary;
@TableField("evidence_excerpt")
private String evidenceExcerpt;
@TableField("confidence")
private BigDecimal confidence;
@TableField("status")
private String status;
@TableField("user_edited")
private Integer userEdited;
@TableField("confirmed_at")
private LocalDateTime confirmedAt;
@TableField("deleted_at")
private LocalDateTime deletedAt;
}
@@ -0,0 +1,59 @@
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.AllArgsConstructor;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;
import java.time.LocalDateTime;
import java.util.Map;
/**
* Consent and revocation audit record for user data usage.
*/
@Data
@EqualsAndHashCode(callSuper = true)
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@TableName(value = "t_user_consent_log", autoResultMap = true)
public class UserConsentLog extends BaseEntity {
@TableField("user_id")
private String userId;
@TableField("platform")
private String platform;
@TableField("consent_type")
private String consentType;
@TableField("consent_version")
private String consentVersion;
@TableField("scope")
private String scope;
@TableField("purpose")
private String purpose;
@TableField("status")
private String status;
@TableField("granted_at")
private LocalDateTime grantedAt;
@TableField("revoked_at")
private LocalDateTime revokedAt;
@TableField("client_ip")
private String clientIp;
@TableField(value = "device_info", typeHandler = JacksonTypeHandler.class)
private Map<String, Object> deviceInfo;
}
@@ -0,0 +1,9 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.SocialContentItem;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SocialContentItemMapper extends BaseMapper<SocialContentItem> {
}
@@ -0,0 +1,9 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.SocialProfileInsight;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SocialProfileInsightMapper extends BaseMapper<SocialProfileInsight> {
}
@@ -0,0 +1,9 @@
package com.emotion.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.emotion.entity.UserConsentLog;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface UserConsentLogMapper extends BaseMapper<UserConsentLog> {
}
@@ -0,0 +1,10 @@
package com.emotion.service;
import java.util.List;
public interface ScriptContextService {
String buildSocialInsightContext(String userId, Boolean useSocialInsights);
List<String> getConfirmedInsightLabels(String userId);
}
@@ -0,0 +1,25 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.dto.request.social.SocialContentLinkImportRequest;
import com.emotion.dto.request.social.SocialContentManualImportRequest;
import com.emotion.dto.response.social.SocialContentItemResponse;
import com.emotion.entity.SocialContentItem;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
public interface SocialContentService extends IService<SocialContentItem> {
SocialContentItemResponse manualImport(String userId, SocialContentManualImportRequest request);
SocialContentItemResponse linkImport(String userId, SocialContentLinkImportRequest request);
SocialContentItemResponse screenshotImport(String userId, String platform, MultipartFile file);
List<SocialContentItemResponse> listByUser(String userId);
boolean deleteByUser(String userId, String id, Boolean keepConfirmedInsights);
SocialContentItemResponse updateApproval(String userId, String id, Boolean approvedForAi);
}
@@ -0,0 +1,20 @@
package com.emotion.service;
import com.baomidou.mybatisplus.extension.service.IService;
import com.emotion.dto.request.social.SocialInsightGenerateRequest;
import com.emotion.dto.request.social.SocialInsightUpdateRequest;
import com.emotion.dto.response.social.SocialProfileInsightResponse;
import com.emotion.entity.SocialProfileInsight;
import java.util.List;
public interface SocialInsightService extends IService<SocialProfileInsight> {
List<SocialProfileInsightResponse> generateInsights(String userId, SocialInsightGenerateRequest request);
List<SocialProfileInsightResponse> listByUser(String userId, String status);
SocialProfileInsightResponse updateByUser(String userId, String id, SocialInsightUpdateRequest request);
boolean deleteByUser(String userId, String id);
}
@@ -16,6 +16,7 @@ import com.emotion.mapper.EpicScriptMapper;
import com.emotion.service.AiChatService;
import com.emotion.service.EpicScriptService;
import com.emotion.service.LifePathService;
import com.emotion.service.ScriptContextService;
import com.emotion.util.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
@@ -70,6 +71,9 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
@Autowired
private AiChatService aiChatService;
@Autowired
private ScriptContextService scriptContextService;
@Override
public PageResult<EpicScriptResponse> getPageByCurrentUser(EpicScriptPageRequest request) {
String currentUserId = UserContextHolder.getCurrentUserId();
@@ -178,6 +182,10 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
plotJson = new java.util.HashMap<>();
}
plotJson.put("fullContent", aiGeneratedContent);
List<String> socialInsightLabels = scriptContextService.getConfirmedInsightLabels(currentUserId);
if (!Boolean.FALSE.equals(request.getUseSocialInsights()) && !socialInsightLabels.isEmpty()) {
plotJson.put("socialInsightLabels", socialInsightLabels);
}
script.setPlotJson(plotJson);
log.info("AI生成剧本内容成功,用户ID: {}, 内容长度: {}", currentUserId, aiGeneratedContent.length());
}
@@ -219,6 +227,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
createRequest.setLength(StringUtils.hasText(request.getLength()) ? request.getLength() : "medium");
createRequest.setCharacterInfo(request.getCharacterInfo());
createRequest.setLifeEventsSummary(request.getLifeEventsSummary());
createRequest.setUseSocialInsights(request.getUseSocialInsights());
Map<String, Object> plotJson = new HashMap<>();
plotJson.put("mode", "inspiration");
@@ -266,7 +275,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
private String generateScriptByAi(EpicScriptCreateRequest request, String userId) {
try {
// 组装AI输入
String input = assembleScriptInput(request);
String input = assembleScriptInput(request, userId);
log.info("开始调用AI生成剧本,用户ID: {}, 输入长度: {}", userId, input.length());
// 调用Coze工作流
@@ -289,7 +298,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
* @param request 剧本创建请求
* @return 格式化的输入字符串
*/
private String assembleScriptInput(EpicScriptCreateRequest request) {
private String assembleScriptInput(EpicScriptCreateRequest request, String userId) {
StringBuilder sb = new StringBuilder();
// 角色信息
@@ -302,6 +311,11 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
sb.append("【过往经历】").append(request.getLifeEventsSummary()).append("\n");
}
String socialContext = scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
if (StringUtils.hasText(socialContext)) {
sb.append(socialContext).append("\n");
}
// 标题
if (StringUtils.hasText(request.getTitle())) {
sb.append("【剧本标题】").append(request.getTitle()).append("\n");
@@ -437,6 +451,10 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
plotJson = new java.util.HashMap<>();
}
plotJson.put("fullContent", aiGeneratedContent);
List<String> socialInsightLabels = scriptContextService.getConfirmedInsightLabels(currentUserId);
if (!Boolean.FALSE.equals(request.getUseSocialInsights()) && !socialInsightLabels.isEmpty()) {
plotJson.put("socialInsightLabels", socialInsightLabels);
}
script.setPlotJson(plotJson);
log.info("AI重新生成剧本内容成功,用户ID: {}, 剧本ID: {}", currentUserId, script.getId());
}
@@ -457,7 +475,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
private String regenerateScriptByAi(EpicScriptUpdateRequest request, EpicScript script, String userId) {
try {
// 组装AI输入
String input = assembleUpdateScriptInput(request, script);
String input = assembleUpdateScriptInput(request, script, userId);
log.info("开始调用AI重新生成剧本,用户ID: {}, 剧本ID: {}", userId, script.getId());
// 调用Coze工作流
@@ -479,7 +497,7 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
* @param script 原剧本实体
* @return 格式化的输入字符串
*/
private String assembleUpdateScriptInput(EpicScriptUpdateRequest request, EpicScript script) {
private String assembleUpdateScriptInput(EpicScriptUpdateRequest request, EpicScript script, String userId) {
StringBuilder sb = new StringBuilder();
// 角色信息(优先使用请求中的,否则使用原有的)
@@ -496,6 +514,11 @@ public class EpicScriptServiceImpl extends ServiceImpl<EpicScriptMapper, EpicScr
sb.append("【过往经历】").append(lifeEventsSummary).append("\n");
}
String socialContext = scriptContextService.buildSocialInsightContext(userId, request.getUseSocialInsights());
if (StringUtils.hasText(socialContext)) {
sb.append(socialContext).append("\n");
}
// 标题
String title = StringUtils.hasText(request.getTitle()) ? request.getTitle() : script.getTitle();
if (StringUtils.hasText(title)) {
@@ -0,0 +1,66 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.emotion.entity.SocialProfileInsight;
import com.emotion.mapper.SocialProfileInsightMapper;
import com.emotion.service.ScriptContextService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class ScriptContextServiceImpl implements ScriptContextService {
@Autowired
private SocialProfileInsightMapper socialProfileInsightMapper;
@Override
public String buildSocialInsightContext(String userId, Boolean useSocialInsights) {
if (Boolean.FALSE.equals(useSocialInsights) || !StringUtils.hasText(userId)) {
return "";
}
List<SocialProfileInsight> insights = listConfirmedInsights(userId, 8);
if (insights.isEmpty()) {
return "";
}
StringBuilder sb = new StringBuilder("【用户确认的人生素材画像】\n");
for (SocialProfileInsight insight : insights) {
sb.append("- ")
.append(insight.getInsightType()).append("")
.append(insight.getLabel());
if (StringUtils.hasText(insight.getSummary())) {
sb.append("").append(insight.getSummary());
}
sb.append("\n");
}
sb.append("请仅将这些画像作为用户已确认的创作偏好和人生素材,不要编造未确认的社交平台事实。");
return sb.toString();
}
@Override
public List<String> getConfirmedInsightLabels(String userId) {
if (!StringUtils.hasText(userId)) {
return List.of();
}
return listConfirmedInsights(userId, 8).stream()
.map(SocialProfileInsight::getLabel)
.filter(StringUtils::hasText)
.distinct()
.collect(Collectors.toList());
}
private List<SocialProfileInsight> listConfirmedInsights(String userId, int limit) {
LambdaQueryWrapper<SocialProfileInsight> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SocialProfileInsight::getUserId, userId)
.eq(SocialProfileInsight::getIsDeleted, 0)
.eq(SocialProfileInsight::getStatus, "confirmed")
.orderByDesc(SocialProfileInsight::getConfirmedAt)
.orderByDesc(SocialProfileInsight::getUpdateTime)
.last("LIMIT " + Math.max(1, limit));
return socialProfileInsightMapper.selectList(wrapper);
}
}
@@ -0,0 +1,253 @@
package com.emotion.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.emotion.dto.request.social.SocialContentLinkImportRequest;
import com.emotion.dto.request.social.SocialContentManualImportRequest;
import com.emotion.dto.response.social.SocialContentItemResponse;
import com.emotion.entity.SocialContentItem;
import com.emotion.entity.SocialProfileInsight;
import com.emotion.entity.UserConsentLog;
import com.emotion.mapper.SocialContentItemMapper;
import com.emotion.mapper.SocialProfileInsightMapper;
import com.emotion.mapper.UserConsentLogMapper;
import com.emotion.service.SocialContentService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.DigestUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class SocialContentServiceImpl extends ServiceImpl<SocialContentItemMapper, SocialContentItem>
implements SocialContentService {
private static final Set<String> PLATFORMS = Set.of("xiaohongshu", "weibo", "wechat", "other");
private static final int MAX_CONTENT_LENGTH = 20000;
private static final long MAX_SCREENSHOT_SIZE = 5L * 1024L * 1024L;
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Autowired
private UserConsentLogMapper userConsentLogMapper;
@Autowired
private SocialProfileInsightMapper socialProfileInsightMapper;
@Override
public SocialContentItemResponse manualImport(String userId, SocialContentManualImportRequest request) {
validateUser(userId);
String platform = normalizePlatform(request.getPlatform());
String content = normalizeContent(request.getContent());
SocialContentItem item = findDuplicate(userId, platform, content);
if (item == null) {
item = new SocialContentItem();
item.setUserId(userId);
item.setPlatform(platform);
item.setSourceType("manual_text");
item.setTitle(trimToNull(request.getTitle()));
item.setContent(content);
item.setImportStatus("parsed");
item.setApprovedForAi(Boolean.TRUE.equals(request.getApprovedForAi()) ? 1 : 0);
item.setContentHash(hashContent(content));
this.save(item);
} else if (Boolean.TRUE.equals(request.getApprovedForAi()) && !isApproved(item)) {
item.setApprovedForAi(1);
this.updateById(item);
}
logConsentIfApproved(userId, platform, item.getApprovedForAi());
return convertToResponse(item);
}
@Override
public SocialContentItemResponse linkImport(String userId, SocialContentLinkImportRequest request) {
validateUser(userId);
String platform = normalizePlatform(request.getPlatform());
if (!StringUtils.hasText(request.getSourceUrl())) {
throw new IllegalArgumentException("来源链接不能为空");
}
String content = StringUtils.hasText(request.getContent())
? normalizeContent(request.getContent())
: normalizeContent(request.getSourceUrl());
SocialContentItem item = findDuplicate(userId, platform, content);
if (item == null) {
item = new SocialContentItem();
item.setUserId(userId);
item.setPlatform(platform);
item.setSourceType("public_link");
item.setSourceUrl(request.getSourceUrl().trim());
item.setTitle(trimToNull(request.getTitle()));
item.setContent(content);
item.setImportStatus("parsed");
item.setApprovedForAi(Boolean.TRUE.equals(request.getApprovedForAi()) ? 1 : 0);
item.setContentHash(hashContent(content));
this.save(item);
} else if (Boolean.TRUE.equals(request.getApprovedForAi()) && !isApproved(item)) {
item.setApprovedForAi(1);
this.updateById(item);
}
logConsentIfApproved(userId, platform, item.getApprovedForAi());
return convertToResponse(item);
}
@Override
public SocialContentItemResponse screenshotImport(String userId, String platform, MultipartFile file) {
validateUser(userId);
normalizePlatform(platform);
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("截图文件不能为空");
}
if (file.getSize() > MAX_SCREENSHOT_SIZE) {
throw new IllegalArgumentException("截图不能超过5MB");
}
String name = file.getOriginalFilename() == null ? "" : file.getOriginalFilename().toLowerCase(Locale.ROOT);
if (!(name.endsWith(".jpg") || name.endsWith(".jpeg") || name.endsWith(".png") || name.endsWith(".webp"))) {
throw new IllegalArgumentException("仅支持jpg、png、webp截图");
}
throw new IllegalStateException("OCR暂未启用,请先使用粘贴文本导入");
}
@Override
public List<SocialContentItemResponse> listByUser(String userId) {
validateUser(userId);
LambdaQueryWrapper<SocialContentItem> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SocialContentItem::getUserId, userId)
.eq(SocialContentItem::getIsDeleted, 0)
.orderByDesc(SocialContentItem::getCreateTime);
return this.list(wrapper).stream().map(this::convertToResponse).collect(Collectors.toList());
}
@Override
public boolean deleteByUser(String userId, String id, Boolean keepConfirmedInsights) {
validateUser(userId);
SocialContentItem item = getOwned(userId, id);
if (item == null) {
return false;
}
item.setIsDeleted(1);
item.setDeletedAt(LocalDateTime.now());
this.updateById(item);
LambdaUpdateWrapper<SocialProfileInsight> wrapper = new LambdaUpdateWrapper<>();
wrapper.eq(SocialProfileInsight::getUserId, userId)
.eq(SocialProfileInsight::getSourceItemId, id)
.eq(SocialProfileInsight::getIsDeleted, 0);
if (Boolean.TRUE.equals(keepConfirmedInsights)) {
wrapper.ne(SocialProfileInsight::getStatus, "confirmed");
}
wrapper.set(SocialProfileInsight::getStatus, "deleted")
.set(SocialProfileInsight::getIsDeleted, 1)
.set(SocialProfileInsight::getDeletedAt, LocalDateTime.now());
socialProfileInsightMapper.update(null, wrapper);
return true;
}
@Override
public SocialContentItemResponse updateApproval(String userId, String id, Boolean approvedForAi) {
validateUser(userId);
SocialContentItem item = getOwned(userId, id);
if (item == null) {
return null;
}
item.setApprovedForAi(Boolean.TRUE.equals(approvedForAi) ? 1 : 0);
this.updateById(item);
logConsentIfApproved(userId, item.getPlatform(), item.getApprovedForAi());
return convertToResponse(item);
}
private SocialContentItem getOwned(String userId, String id) {
if (!StringUtils.hasText(id)) {
return null;
}
LambdaQueryWrapper<SocialContentItem> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SocialContentItem::getId, id)
.eq(SocialContentItem::getUserId, userId)
.eq(SocialContentItem::getIsDeleted, 0);
return this.getOne(wrapper, false);
}
private SocialContentItem findDuplicate(String userId, String platform, String content) {
LambdaQueryWrapper<SocialContentItem> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SocialContentItem::getUserId, userId)
.eq(SocialContentItem::getPlatform, platform)
.eq(SocialContentItem::getContentHash, hashContent(content))
.eq(SocialContentItem::getIsDeleted, 0);
return this.getOne(wrapper, false);
}
private void logConsentIfApproved(String userId, String platform, Integer approvedForAi) {
if (approvedForAi == null || approvedForAi != 1) {
return;
}
UserConsentLog log = new UserConsentLog();
log.setUserId(userId);
log.setPlatform(platform);
log.setConsentType("ai_profile_analysis");
log.setConsentVersion("v1");
log.setScope("imported_social_content");
log.setPurpose("用于生成可编辑的人生画像,并增强人生剧本生成");
log.setStatus("granted");
log.setGrantedAt(LocalDateTime.now());
userConsentLogMapper.insert(log);
}
private String normalizePlatform(String platform) {
String value = platform == null ? "" : platform.trim().toLowerCase(Locale.ROOT);
if (!PLATFORMS.contains(value)) {
throw new IllegalArgumentException("不支持的平台");
}
return value;
}
private String normalizeContent(String content) {
if (!StringUtils.hasText(content)) {
throw new IllegalArgumentException("导入内容不能为空");
}
String normalized = content.replaceAll("\\s+", " ").trim();
if (normalized.length() > MAX_CONTENT_LENGTH) {
throw new IllegalArgumentException("导入内容不能超过20000个字符");
}
return normalized;
}
private String hashContent(String content) {
return DigestUtils.md5DigestAsHex(content.getBytes(StandardCharsets.UTF_8));
}
private boolean isApproved(SocialContentItem item) {
return item.getApprovedForAi() != null && item.getApprovedForAi() == 1;
}
private String trimToNull(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
private void validateUser(String userId) {
if (!StringUtils.hasText(userId)) {
throw new IllegalArgumentException("用户未登录");
}
}
private SocialContentItemResponse convertToResponse(SocialContentItem item) {
SocialContentItemResponse response = new SocialContentItemResponse();
BeanUtils.copyProperties(item, response);
response.setApprovedForAi(isApproved(item));
response.setSourceDeleted(item.getIsDeleted() != null && item.getIsDeleted() == 1);
if (item.getCreateTime() != null) {
response.setCreateTime(item.getCreateTime().format(DATE_TIME_FORMATTER));
}
if (item.getUpdateTime() != null) {
response.setUpdateTime(item.getUpdateTime().format(DATE_TIME_FORMATTER));
}
return response;
}
}
@@ -0,0 +1,253 @@
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.social.SocialInsightGenerateRequest;
import com.emotion.dto.request.social.SocialInsightUpdateRequest;
import com.emotion.dto.response.social.SocialProfileInsightResponse;
import com.emotion.entity.SocialContentItem;
import com.emotion.entity.SocialProfileInsight;
import com.emotion.mapper.SocialContentItemMapper;
import com.emotion.mapper.SocialProfileInsightMapper;
import com.emotion.service.SocialInsightService;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
@Service
public class SocialInsightServiceImpl extends ServiceImpl<SocialProfileInsightMapper, SocialProfileInsight>
implements SocialInsightService {
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private static final Set<String> STATUS_VALUES = Set.of("suggested", "confirmed", "rejected", "deleted");
@Autowired
private SocialContentItemMapper contentItemMapper;
@Override
public List<SocialProfileInsightResponse> generateInsights(String userId, SocialInsightGenerateRequest request) {
if (!StringUtils.hasText(userId)) {
return List.of();
}
LambdaQueryWrapper<SocialContentItem> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SocialContentItem::getUserId, userId)
.eq(SocialContentItem::getIsDeleted, 0)
.eq(SocialContentItem::getApprovedForAi, 1)
.isNotNull(SocialContentItem::getContent)
.orderByDesc(SocialContentItem::getCreateTime);
if (request != null && request.getSourceItemIds() != null && !request.getSourceItemIds().isEmpty()) {
wrapper.in(SocialContentItem::getId, request.getSourceItemIds());
}
List<SocialContentItem> contentItems = contentItemMapper.selectList(wrapper);
List<SocialProfileInsight> saved = new ArrayList<>();
for (SocialContentItem item : contentItems) {
for (SocialProfileInsight insight : extractInsights(userId, item)) {
if (existsActiveInsight(userId, item.getId(), insight.getInsightType(), insight.getLabel())) {
continue;
}
this.save(insight);
saved.add(insight);
}
}
return saved.stream()
.sorted(Comparator.comparing(SocialProfileInsight::getCreateTime, Comparator.nullsLast(Comparator.reverseOrder())))
.map(this::convertToResponse)
.collect(Collectors.toList());
}
@Override
public List<SocialProfileInsightResponse> listByUser(String userId, String status) {
if (!StringUtils.hasText(userId)) {
return List.of();
}
LambdaQueryWrapper<SocialProfileInsight> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SocialProfileInsight::getUserId, userId)
.eq(SocialProfileInsight::getIsDeleted, 0);
if (StringUtils.hasText(status)) {
wrapper.eq(SocialProfileInsight::getStatus, status);
}
wrapper.orderByDesc(SocialProfileInsight::getUpdateTime)
.orderByDesc(SocialProfileInsight::getCreateTime);
return this.list(wrapper).stream()
.map(this::convertToResponse)
.collect(Collectors.toList());
}
@Override
public SocialProfileInsightResponse updateByUser(String userId, String id, SocialInsightUpdateRequest request) {
SocialProfileInsight insight = getOwnedInsight(userId, id);
if (insight == null || request == null) {
return null;
}
if (StringUtils.hasText(request.getLabel())) {
insight.setLabel(request.getLabel().trim());
insight.setUserEdited(1);
}
if (request.getSummary() != null) {
insight.setSummary(request.getSummary().trim());
insight.setUserEdited(1);
}
if (StringUtils.hasText(request.getStatus())) {
String nextStatus = request.getStatus().trim().toLowerCase(Locale.ROOT);
if (!STATUS_VALUES.contains(nextStatus)) {
throw new IllegalArgumentException("不支持的画像状态");
}
insight.setStatus(nextStatus);
if ("confirmed".equals(nextStatus)) {
insight.setConfirmedAt(LocalDateTime.now());
}
if ("deleted".equals(nextStatus)) {
insight.setIsDeleted(1);
insight.setDeletedAt(LocalDateTime.now());
}
}
this.updateById(insight);
return convertToResponse(insight);
}
@Override
public boolean deleteByUser(String userId, String id) {
SocialProfileInsight insight = getOwnedInsight(userId, id);
if (insight == null) {
return false;
}
insight.setStatus("deleted");
insight.setIsDeleted(1);
insight.setDeletedAt(LocalDateTime.now());
return this.updateById(insight);
}
private List<SocialProfileInsight> extractInsights(String userId, SocialContentItem item) {
String content = item.getContent() == null ? "" : item.getContent().trim();
if (content.isEmpty()) {
return List.of();
}
List<SocialProfileInsight> insights = new ArrayList<>();
addIfMatched(insights, userId, item, "value", "职场成长", "用户内容中多次出现工作、职场或创业相关表达,适合转化为事业成长线。",
content, BigDecimal.valueOf(0.78), "工作", "职场", "老板", "产品", "创业", "公司", "项目");
addIfMatched(insights, userId, item, "value", "被认可", "用户对被看见、被肯定或证明自己有较强叙事诉求。",
content, BigDecimal.valueOf(0.74), "认可", "看见", "", "肯定", "证明", "成绩", "价值");
addIfMatched(insights, userId, item, "interest", "旅行探索", "用户表达了对远方、城市或旅行场景的兴趣,可用于开放式人生分支。",
content, BigDecimal.valueOf(0.70), "旅行", "旅游", "城市", "远方", "大理", "海边", "出发");
addIfMatched(insights, userId, item, "interest", "创作表达", "用户内容中有写作、音乐、设计或内容创作倾向,适合作为主角天赋线。",
content, BigDecimal.valueOf(0.72), "写作", "音乐", "创作", "设计", "内容", "", "摄影");
addIfMatched(insights, userId, item, "emotion_pattern", "关系修复", "用户对关系、告别或重新理解自己有明显表达,可用于情感修复线。",
content, BigDecimal.valueOf(0.69), "关系", "分手", "告别", "喜欢", "", "朋友", "家人");
addIfMatched(insights, userId, item, "script_theme", "低谷逆袭", "用户提到压力、失败或低谷,可转化为爽文开端和反转动力。",
content, BigDecimal.valueOf(0.73), "低谷", "失败", "焦虑", "难过", "压力", "崩溃", "迷茫");
if (insights.isEmpty()) {
insights.add(buildInsight(userId, item, "script_theme", "自我成长",
"这段内容适合作为自我探索和人生选择的素材。",
excerpt(content), BigDecimal.valueOf(0.58)));
}
return insights;
}
private void addIfMatched(List<SocialProfileInsight> insights, String userId, SocialContentItem item,
String type, String label, String summary, String content,
BigDecimal confidence, String... keywords) {
String lower = content.toLowerCase(Locale.ROOT);
for (String keyword : keywords) {
if (lower.contains(keyword.toLowerCase(Locale.ROOT))) {
insights.add(buildInsight(userId, item, type, label, summary, excerptAround(content, keyword), confidence));
return;
}
}
}
private SocialProfileInsight buildInsight(String userId, SocialContentItem item, String type, String label,
String summary, String evidence, BigDecimal confidence) {
SocialProfileInsight insight = new SocialProfileInsight();
insight.setUserId(userId);
insight.setSourceItemId(item.getId());
insight.setInsightType(type);
insight.setLabel(label);
insight.setSummary(summary);
insight.setEvidenceExcerpt(evidence);
insight.setConfidence(confidence);
insight.setStatus("suggested");
insight.setUserEdited(0);
insight.setIsDeleted(0);
return insight;
}
private boolean existsActiveInsight(String userId, String sourceItemId, String type, String label) {
LambdaQueryWrapper<SocialProfileInsight> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SocialProfileInsight::getUserId, userId)
.eq(SocialProfileInsight::getSourceItemId, sourceItemId)
.eq(SocialProfileInsight::getInsightType, type)
.eq(SocialProfileInsight::getLabel, label)
.eq(SocialProfileInsight::getIsDeleted, 0);
return this.count(wrapper) > 0;
}
private SocialProfileInsight getOwnedInsight(String userId, String id) {
if (!StringUtils.hasText(userId) || !StringUtils.hasText(id)) {
return null;
}
LambdaQueryWrapper<SocialProfileInsight> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(SocialProfileInsight::getUserId, userId)
.eq(SocialProfileInsight::getId, id)
.eq(SocialProfileInsight::getIsDeleted, 0);
return this.getOne(wrapper, false);
}
private String excerptAround(String content, String keyword) {
int index = content.indexOf(keyword);
if (index < 0) {
return excerpt(content);
}
int start = Math.max(0, index - 45);
int end = Math.min(content.length(), index + keyword.length() + 65);
return excerpt(content.substring(start, end));
}
private String excerpt(String text) {
String normalized = text.replaceAll("\\s+", " ").trim();
if (normalized.length() <= 120) {
return normalized;
}
return normalized.substring(0, 120) + "...";
}
private SocialProfileInsightResponse convertToResponse(SocialProfileInsight insight) {
SocialProfileInsightResponse response = new SocialProfileInsightResponse();
BeanUtils.copyProperties(insight, response);
response.setId(insight.getId());
response.setUserEdited(insight.getUserEdited() != null && insight.getUserEdited() == 1);
response.setSourceDeleted(isSourceDeleted(insight.getSourceItemId()));
if (insight.getCreateTime() != null) {
response.setCreateTime(insight.getCreateTime().format(DATE_TIME_FORMATTER));
}
if (insight.getUpdateTime() != null) {
response.setUpdateTime(insight.getUpdateTime().format(DATE_TIME_FORMATTER));
}
return response;
}
private Boolean isSourceDeleted(String sourceItemId) {
if (!StringUtils.hasText(sourceItemId)) {
return true;
}
SocialContentItem item = contentItemMapper.selectById(sourceItemId);
return item == null || (item.getIsDeleted() != null && item.getIsDeleted() == 1);
}
}
@@ -167,7 +167,7 @@ public class TtsTaskServiceImpl extends ServiceImpl<TtsTaskMapper, TtsTask> impl
}
private TtsTask buildTask(String userId, String sourceType, String sourceId, String voice, String hash, int textLength) {
String filename = hash + ".mp3";
String filename = hash + ".wav";
return TtsTask.builder()
.userId(userId)
.sourceType(sourceType)
+6 -5
View File
@@ -4,14 +4,15 @@ Install on `101.200.208.45`:
```bash
cd /data/programs/emotion-museum/tts-service
python3 -m venv .venv
python3.11 -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
mkdir -p models
curl -L -o models/zh_CN-huayan-medium.onnx \
https://hf-mirror.com/rhasspy/piper-voices/resolve/v1.0.0/zh/zh_CN/huayan/medium/zh_CN-huayan-medium.onnx
curl -L -o models/zh_CN-huayan-medium.onnx.json \
https://hf-mirror.com/rhasspy/piper-voices/resolve/v1.0.0/zh/zh_CN/huayan/medium/zh_CN-huayan-medium.onnx.json
cd /data/programs/emotion-museum/tts-service
uvicorn app:app --host 127.0.0.1 --port 19110
+35 -21
View File
@@ -1,14 +1,15 @@
import subprocess
from pathlib import Path
from threading import Lock
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI(title="Emotion Museum TTS")
_model = None
_speaker_ids = None
_model_lock = Lock()
BASE_DIR = Path(__file__).resolve().parent
PIPER_BIN = BASE_DIR / ".venv" / "bin" / "piper"
PIPER_MODEL = BASE_DIR / "models" / "zh_CN-huayan-medium.onnx"
PIPER_CONFIG = BASE_DIR / "models" / "zh_CN-huayan-medium.onnx.json"
class SynthesizeRequest(BaseModel):
@@ -17,20 +18,13 @@ class SynthesizeRequest(BaseModel):
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"}
return {
"status": "ok",
"engine": "piper",
"modelReady": PIPER_MODEL.exists() and PIPER_CONFIG.exists(),
}
@app.post("/synthesize")
@@ -39,15 +33,35 @@ def synthesize(request: SynthesizeRequest):
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)
if not PIPER_BIN.exists():
raise RuntimeError(f"piper binary not found: {PIPER_BIN}")
if not PIPER_MODEL.exists() or not PIPER_CONFIG.exists():
raise RuntimeError("piper Chinese voice model is not installed")
subprocess.run(
[
str(PIPER_BIN),
"--model",
str(PIPER_MODEL),
"--config",
str(PIPER_CONFIG),
"--output_file",
str(output),
"--sentence-silence",
"0.35",
],
input=request.text,
text=True,
check=True,
capture_output=True,
timeout=180,
)
except Exception as exc:
return {
"success": False,
"audioPath": None,
"durationMs": None,
"engine": "melotts",
"engine": "piper",
"errorMessage": str(exc),
}
@@ -55,5 +69,5 @@ def synthesize(request: SynthesizeRequest):
"success": True,
"audioPath": str(output),
"durationMs": None,
"engine": "melotts",
"engine": "piper",
}
@@ -1,3 +1,4 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
pydantic==2.7.4
piper-tts==1.4.2