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:
@@ -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;
|
||||
}
|
||||
|
||||
+15
@@ -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;
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
+20
@@ -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;
|
||||
}
|
||||
+26
@@ -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;
|
||||
}
|
||||
+30
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user