diff --git a/.superpowers/brainstorm/manual-20260517091308/.server-stopped b/.superpowers/brainstorm/manual-20260517091308/.server-stopped new file mode 100644 index 0000000..053c066 --- /dev/null +++ b/.superpowers/brainstorm/manual-20260517091308/.server-stopped @@ -0,0 +1 @@ +{"reason":"owner process exited","timestamp":1778980449957} diff --git a/backend-single/src/main/java/com/emotion/controller/SocialContentController.java b/backend-single/src/main/java/com/emotion/controller/SocialContentController.java new file mode 100644 index 0000000..6ef99a8 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/SocialContentController.java @@ -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 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 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 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() { + String userId = currentUserId(); + if (userId == null) { + return Result.unauthorized(); + } + return Result.success(socialContentService.listByUser(userId)); + } + + @PutMapping("/{id}/approval") + public Result 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 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(); + } +} diff --git a/backend-single/src/main/java/com/emotion/controller/SocialInsightController.java b/backend-single/src/main/java/com/emotion/controller/SocialInsightController.java new file mode 100644 index 0000000..e91b36a --- /dev/null +++ b/backend-single/src/main/java/com/emotion/controller/SocialInsightController.java @@ -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> 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(@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 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 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(); + } +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptCreateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptCreateRequest.java index 1bf4a60..450d5ca 100644 --- a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptCreateRequest.java +++ b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptCreateRequest.java @@ -78,4 +78,8 @@ public class EpicScriptCreateRequest extends BaseRequest { * 过往经历关键词(前端传入,用于AI生成) */ private String lifeEventsSummary; + /** + * 是否使用用户已确认的社交画像增强剧本生成。 + */ + private Boolean useSocialInsights; } diff --git a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptInspirationRequest.java b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptInspirationRequest.java index 03d6f40..86733aa 100644 --- a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptInspirationRequest.java +++ b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptInspirationRequest.java @@ -25,4 +25,8 @@ public class EpicScriptInspirationRequest extends BaseRequest { private String lifeEventsSummary; private String source; + /** + * 是否使用用户已确认的社交画像增强生成。 + */ + private Boolean useSocialInsights; } diff --git a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptUpdateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptUpdateRequest.java index 18c88f1..6ca8ee7 100644 --- a/backend-single/src/main/java/com/emotion/dto/request/EpicScriptUpdateRequest.java +++ b/backend-single/src/main/java/com/emotion/dto/request/EpicScriptUpdateRequest.java @@ -88,4 +88,8 @@ public class EpicScriptUpdateRequest extends BaseRequest { * 是否需要重新生成AI内容 */ private Boolean regenerateContent; + /** + * 是否使用用户已确认的社交画像增强重新生成。 + */ + private Boolean useSocialInsights; } diff --git a/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentApprovalRequest.java b/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentApprovalRequest.java new file mode 100644 index 0000000..e241d57 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentApprovalRequest.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentLinkImportRequest.java b/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentLinkImportRequest.java new file mode 100644 index 0000000..f94b3d1 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentLinkImportRequest.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentManualImportRequest.java b/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentManualImportRequest.java new file mode 100644 index 0000000..548bf63 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/social/SocialContentManualImportRequest.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/social/SocialInsightGenerateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/social/SocialInsightGenerateRequest.java new file mode 100644 index 0000000..78347d5 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/social/SocialInsightGenerateRequest.java @@ -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 sourceItemIds; +} diff --git a/backend-single/src/main/java/com/emotion/dto/request/social/SocialInsightUpdateRequest.java b/backend-single/src/main/java/com/emotion/dto/request/social/SocialInsightUpdateRequest.java new file mode 100644 index 0000000..9a24c2f --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/request/social/SocialInsightUpdateRequest.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/social/SocialContentItemResponse.java b/backend-single/src/main/java/com/emotion/dto/response/social/SocialContentItemResponse.java new file mode 100644 index 0000000..8396100 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/social/SocialContentItemResponse.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/dto/response/social/SocialProfileInsightResponse.java b/backend-single/src/main/java/com/emotion/dto/response/social/SocialProfileInsightResponse.java new file mode 100644 index 0000000..214c591 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/dto/response/social/SocialProfileInsightResponse.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/entity/SocialContentItem.java b/backend-single/src/main/java/com/emotion/entity/SocialContentItem.java new file mode 100644 index 0000000..d1849ba --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/SocialContentItem.java @@ -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 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 rawMetadata; + + @TableField("deleted_at") + private LocalDateTime deletedAt; +} diff --git a/backend-single/src/main/java/com/emotion/entity/SocialProfileInsight.java b/backend-single/src/main/java/com/emotion/entity/SocialProfileInsight.java new file mode 100644 index 0000000..e09d0dd --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/SocialProfileInsight.java @@ -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; +} diff --git a/backend-single/src/main/java/com/emotion/entity/UserConsentLog.java b/backend-single/src/main/java/com/emotion/entity/UserConsentLog.java new file mode 100644 index 0000000..e0b1b41 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/entity/UserConsentLog.java @@ -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 deviceInfo; +} diff --git a/backend-single/src/main/java/com/emotion/mapper/SocialContentItemMapper.java b/backend-single/src/main/java/com/emotion/mapper/SocialContentItemMapper.java new file mode 100644 index 0000000..1bb5498 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/SocialContentItemMapper.java @@ -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 { +} diff --git a/backend-single/src/main/java/com/emotion/mapper/SocialProfileInsightMapper.java b/backend-single/src/main/java/com/emotion/mapper/SocialProfileInsightMapper.java new file mode 100644 index 0000000..3a788ef --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/SocialProfileInsightMapper.java @@ -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 { +} diff --git a/backend-single/src/main/java/com/emotion/mapper/UserConsentLogMapper.java b/backend-single/src/main/java/com/emotion/mapper/UserConsentLogMapper.java new file mode 100644 index 0000000..177dee4 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/mapper/UserConsentLogMapper.java @@ -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 { +} diff --git a/backend-single/src/main/java/com/emotion/service/ScriptContextService.java b/backend-single/src/main/java/com/emotion/service/ScriptContextService.java new file mode 100644 index 0000000..ad66c87 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/ScriptContextService.java @@ -0,0 +1,10 @@ +package com.emotion.service; + +import java.util.List; + +public interface ScriptContextService { + + String buildSocialInsightContext(String userId, Boolean useSocialInsights); + + List getConfirmedInsightLabels(String userId); +} diff --git a/backend-single/src/main/java/com/emotion/service/SocialContentService.java b/backend-single/src/main/java/com/emotion/service/SocialContentService.java new file mode 100644 index 0000000..b5352d1 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/SocialContentService.java @@ -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 { + + SocialContentItemResponse manualImport(String userId, SocialContentManualImportRequest request); + + SocialContentItemResponse linkImport(String userId, SocialContentLinkImportRequest request); + + SocialContentItemResponse screenshotImport(String userId, String platform, MultipartFile file); + + List listByUser(String userId); + + boolean deleteByUser(String userId, String id, Boolean keepConfirmedInsights); + + SocialContentItemResponse updateApproval(String userId, String id, Boolean approvedForAi); +} diff --git a/backend-single/src/main/java/com/emotion/service/SocialInsightService.java b/backend-single/src/main/java/com/emotion/service/SocialInsightService.java new file mode 100644 index 0000000..31cf7a6 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/SocialInsightService.java @@ -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 { + + List generateInsights(String userId, SocialInsightGenerateRequest request); + + List listByUser(String userId, String status); + + SocialProfileInsightResponse updateByUser(String userId, String id, SocialInsightUpdateRequest request); + + boolean deleteByUser(String userId, String id); +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java index b07ed6e..97cbc2f 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/EpicScriptServiceImpl.java @@ -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 getPageByCurrentUser(EpicScriptPageRequest request) { String currentUserId = UserContextHolder.getCurrentUserId(); @@ -178,6 +182,10 @@ public class EpicScriptServiceImpl extends ServiceImpl(); } plotJson.put("fullContent", aiGeneratedContent); + List 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 plotJson = new HashMap<>(); plotJson.put("mode", "inspiration"); @@ -266,7 +275,7 @@ public class EpicScriptServiceImpl extends ServiceImpl(); } plotJson.put("fullContent", aiGeneratedContent); + List 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 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 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 listConfirmedInsights(String userId, int limit) { + LambdaQueryWrapper 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); + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/SocialContentServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/SocialContentServiceImpl.java new file mode 100644 index 0000000..825a4b1 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/SocialContentServiceImpl.java @@ -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 + implements SocialContentService { + + private static final Set 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 listByUser(String userId) { + validateUser(userId); + LambdaQueryWrapper 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 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 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 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; + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/SocialInsightServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/SocialInsightServiceImpl.java new file mode 100644 index 0000000..515f344 --- /dev/null +++ b/backend-single/src/main/java/com/emotion/service/impl/SocialInsightServiceImpl.java @@ -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 + implements SocialInsightService { + + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final Set STATUS_VALUES = Set.of("suggested", "confirmed", "rejected", "deleted"); + + @Autowired + private SocialContentItemMapper contentItemMapper; + + @Override + public List generateInsights(String userId, SocialInsightGenerateRequest request) { + if (!StringUtils.hasText(userId)) { + return List.of(); + } + + LambdaQueryWrapper 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 contentItems = contentItemMapper.selectList(wrapper); + List 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 listByUser(String userId, String status) { + if (!StringUtils.hasText(userId)) { + return List.of(); + } + LambdaQueryWrapper 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 extractInsights(String userId, SocialContentItem item) { + String content = item.getContent() == null ? "" : item.getContent().trim(); + if (content.isEmpty()) { + return List.of(); + } + + List 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 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 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 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); + } +} diff --git a/backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java b/backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java index eab1653..5cd0c5a 100644 --- a/backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java +++ b/backend-single/src/main/java/com/emotion/service/impl/TtsTaskServiceImpl.java @@ -167,7 +167,7 @@ public class TtsTaskServiceImpl extends ServiceImpl 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) diff --git a/backend-single/tts-service/README.md b/backend-single/tts-service/README.md index 34d486c..9f8033e 100644 --- a/backend-single/tts-service/README.md +++ b/backend-single/tts-service/README.md @@ -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 diff --git a/backend-single/tts-service/app.py b/backend-single/tts-service/app.py index 38d93df..e6af167 100644 --- a/backend-single/tts-service/app.py +++ b/backend-single/tts-service/app.py @@ -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", } diff --git a/backend-single/tts-service/requirements.txt b/backend-single/tts-service/requirements.txt index a6d2796..e3c80e2 100644 --- a/backend-single/tts-service/requirements.txt +++ b/backend-single/tts-service/requirements.txt @@ -1,3 +1,4 @@ fastapi==0.111.0 uvicorn[standard]==0.30.1 pydantic==2.7.4 +piper-tts==1.4.2 diff --git a/docs/0517-UI设计 更新.md b/docs/0517-UI设计 更新.md index e599b39..e6c7fd7 100644 --- a/docs/0517-UI设计 更新.md +++ b/docs/0517-UI设计 更新.md @@ -8,13 +8,13 @@ 图⽚ -![](_page_1_Picture_2.jpeg) +![](<0517-UI设计 更新/_page_1_Picture_2.jpeg>) # 今天有什么心愿想实现,想实现 -![](_page_1_Picture_4.jpeg) +![](<0517-UI设计 更新/_page_1_Picture_4.jpeg>) -![](_page_1_Picture_5.jpeg) +![](<0517-UI设计 更新/_page_1_Picture_5.jpeg>) 按住说话,即刻如愿 @@ -1491,7 +1491,7 @@ box-shadow: 1 如果老板今天突然夸我 15:11 ~ -![](_page_24_Picture_10.jpeg) +![](<0517-UI设计 更新/_page_24_Picture_10.jpeg>) 心愿实现中…… 15:11 @@ -1521,7 +1521,7 @@ box-shadow: 1 这三个字,像一道光,照亮了你整个下午。 -![](_page_25_Figure_0.jpeg) +![](<0517-UI设计 更新/_page_25_Figure_0.jpeg>) # vue代码 diff --git a/docs/superpowers/plans/2026-05-18-mini-program-script-home-redesign-plan.md b/docs/superpowers/plans/2026-05-18-mini-program-script-home-redesign-plan.md new file mode 100644 index 0000000..da9af14 --- /dev/null +++ b/docs/superpowers/plans/2026-05-18-mini-program-script-home-redesign-plan.md @@ -0,0 +1,771 @@ +# Mini Program Script Home Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `爽文生成` the mini program home experience and rebuild the wish-to-script flow from the 0517 UI requirement. + +**Architecture:** Keep backend APIs and store contracts mostly unchanged. Rework the mini program shell default tab and rewrite `ScriptView.vue` into a state-driven experience with home, generating, and result states, reusing existing inspiration, script generation, analytics, and TTS components where possible. + +**Tech Stack:** uni-app, Vue 3 ` - - diff --git a/mini-program/src/composables/useTtsPlayer.js b/mini-program/src/composables/useTtsPlayer.js new file mode 100644 index 0000000..7086292 --- /dev/null +++ b/mini-program/src/composables/useTtsPlayer.js @@ -0,0 +1,213 @@ +import { computed, onUnmounted, ref } from 'vue' +import { createTtsTask, getTtsTask, getTtsTaskBySource } from '../services/tts.js' +import analytics from '../services/analytics.js' + +const readResponseData = (response) => response?.data ?? response ?? null + +export const useTtsPlayer = ({ pagePath = '', sourceType = 'epic_script' } = {}) => { + const task = ref(null) + const loading = ref(false) + const playing = ref(false) + const statusText = ref('') + let audio = null + let timer = null + + const buttonText = computed(() => { + if (loading.value) return '正在生成朗读' + if (task.value?.status === 'success') return playing.value ? '暂停朗读' : '播放朗读' + if (task.value?.status === 'failed') return '重试朗读' + return '语音播放' + }) + + const clearTimer = () => { + if (timer) clearInterval(timer) + timer = null + } + + const stopAudio = () => { + if (!audio) return + audio.stop() + audio.destroy() + audio = null + playing.value = false + } + + const setTask = (nextTask) => { + task.value = nextTask + if (audio && nextTask?.audioUrl && audio.src !== nextTask.audioUrl) { + stopAudio() + } + } + + const track = (eventName, sourceId, payload = {}) => { + analytics.track(eventName, { + source_id: sourceId || '', + task_id: task.value?.id || '', + ...payload + }, { eventType: 'tts', pagePath }) + } + + const markFailed = (message) => { + loading.value = false + statusText.value = message || '朗读暂时不可用' + uni.showToast({ title: statusText.value, icon: 'none' }) + } + + const pollTask = (taskId, sourceId) => { + clearTimer() + timer = setInterval(async () => { + try { + const response = await getTtsTask(taskId) + const nextTask = readResponseData(response) + setTask(nextTask) + + if (nextTask?.status === 'success') { + loading.value = false + statusText.value = '' + clearTimer() + track('script_tts_success', sourceId, { task_id: nextTask.id }) + play(sourceId) + return + } + + if (nextTask?.status === 'failed') { + clearTimer() + track('script_tts_error', sourceId, { error: nextTask.errorMessage || '' }) + markFailed(nextTask.errorMessage || '朗读生成失败') + } + } catch (error) { + clearTimer() + track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'poll failed' }) + markFailed('朗读状态获取失败') + } + }, 2500) + } + + const play = (sourceId) => { + if (!task.value?.audioUrl) return + + if (!audio) { + audio = uni.createInnerAudioContext() + audio.src = task.value.audioUrl + audio.autoplay = false + + audio.onPlay(() => { + playing.value = true + track('script_tts_play', sourceId) + }) + + audio.onPause(() => { + playing.value = false + track('script_tts_pause', sourceId) + }) + + audio.onEnded(() => { + playing.value = false + track('script_tts_complete', sourceId) + }) + + audio.onError((error) => { + playing.value = false + track('script_tts_error', sourceId, { error: error?.errMsg || 'play failed' }) + markFailed('音频播放失败') + }) + } + + if (playing.value) { + audio.pause() + } else { + audio.play() + } + } + + const createAndPoll = async (sourceId) => { + loading.value = true + statusText.value = '' + track('script_tts_request', sourceId) + + try { + const response = await createTtsTask({ sourceType, sourceId }) + const nextTask = readResponseData(response) + setTask(nextTask) + + if (nextTask?.status === 'success') { + loading.value = false + track('script_tts_success', sourceId, { task_id: nextTask.id }) + play(sourceId) + return + } + + if (nextTask?.status === 'failed') { + track('script_tts_error', sourceId, { error: nextTask.errorMessage || '' }) + markFailed(nextTask.errorMessage || '朗读生成失败') + return + } + + if (nextTask?.id) { + pollTask(nextTask.id, sourceId) + return + } + + markFailed('朗读任务创建失败') + } catch (error) { + track('script_tts_error', sourceId, { error: error?.message || error?.errMsg || 'create failed' }) + markFailed(error?.message || '朗读任务创建失败') + } + } + + const playSource = async (sourceId) => { + if (!sourceId) { + uni.showToast({ title: '生成保存后可播放', icon: 'none' }) + return + } + if (loading.value) return + + if (task.value?.status === 'success') { + play(sourceId) + return + } + + try { + const response = await getTtsTaskBySource({ sourceType, sourceId }) + const existingTask = readResponseData(response) + setTask(existingTask) + + if (existingTask?.status === 'success') { + play(sourceId) + return + } + + if (existingTask?.status === 'pending' || existingTask?.status === 'processing') { + loading.value = true + pollTask(existingTask.id, sourceId) + return + } + } catch (error) { + // Missing existing task should fall through and create a new one. + } + + await createAndPoll(sourceId) + } + + const reset = () => { + clearTimer() + stopAudio() + setTask(null) + statusText.value = '' + loading.value = false + } + + onUnmounted(reset) + + return { + task, + loading, + playing, + statusText, + buttonText, + playSource, + reset + } +} + +export default useTtsPlayer diff --git a/mini-program/src/pages.json b/mini-program/src/pages.json index 5ba972f..70b5d1e 100644 --- a/mini-program/src/pages.json +++ b/mini-program/src/pages.json @@ -59,6 +59,28 @@ "navigationBarTitleText": "个人中心" } } + , + { + "path": "pages/social-import/index", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "社交数据导入" + } + }, + { + "path": "pages/social-import/preview", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "导入预览" + } + }, + { + "path": "pages/social-import/insights", + "style": { + "navigationStyle": "custom", + "navigationBarTitleText": "人生素材画像" + } + } ], "globalStyle": { "navigationBarTextStyle": "white", diff --git a/mini-program/src/pages/main/MineView.vue b/mini-program/src/pages/main/MineView.vue index 5cda8a7..c57abc2 100644 --- a/mini-program/src/pages/main/MineView.vue +++ b/mini-program/src/pages/main/MineView.vue @@ -19,6 +19,14 @@ + + + 社交数据导入 + 生成并确认人生素材画像,让爽文更像你 + + + + { uni.$emit('switchTab', 'script') } +const openSocialImport = () => { + uni.navigateTo({ url: '/pages/social-import/index' }) +} + const openSearch = () => { uni.showModal({ title: '搜索剧本', @@ -592,6 +604,41 @@ const mapScript = async (script) => { background: rgba(130, 48, 220, 0.28); } +.profile-entry { + min-height: 92rpx; + padding: 22rpx 24rpx; + border-radius: 24rpx; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18rpx; + border: 1rpx solid rgba(105, 79, 210, 0.34); + background: rgba(9, 12, 42, 0.72); +} + +.profile-entry-title, +.profile-entry-copy { + display: block; +} + +.profile-entry-title { + color: #fff; + font-size: 29rpx; + font-weight: 900; +} + +.profile-entry-copy { + margin-top: 8rpx; + color: rgba(226, 215, 246, 0.68); + font-size: 22rpx; + line-height: 1.42; +} + +.profile-entry-arrow { + color: rgba(226, 215, 246, 0.7); + font-size: 46rpx; +} + .script-card { display: grid; grid-template-columns: 150rpx 1fr; diff --git a/mini-program/src/pages/main/ScriptDetailView.vue b/mini-program/src/pages/main/ScriptDetailView.vue index 78469ce..bcb27fd 100644 --- a/mini-program/src/pages/main/ScriptDetailView.vue +++ b/mini-program/src/pages/main/ScriptDetailView.vue @@ -28,7 +28,10 @@ 字数 - + + + {{ detailTtsButtonText }} + @@ -63,7 +66,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue' import { useAppStore } from '../../stores/app.js' import Markdown from '../../components/Markdown.vue' import analytics from '../../services/analytics.js' -import ScriptAudioPlayer from '../../components/ScriptAudioPlayer.vue' +import { useTtsPlayer } from '../../composables/useTtsPlayer.js' const store = useAppStore() const statusBarHeight = ref(20) @@ -71,6 +74,7 @@ const activeTab = ref('content') const scriptId = ref('') const script = ref(null) const pagePath = '/pages/main/ScriptDetailView' +const ttsPlayer = useTtsPlayer({ pagePath }) const fullContent = computed(() => script.value?.content || '暂无正文内容。') const lengthText = computed(() => { @@ -78,6 +82,11 @@ const lengthText = computed(() => { return map[script.value?.length] || script.value?.length || '中篇' }) +const detailTtsButtonText = computed(() => { + if (!script.value?.id) return '生成保存后可语音播放' + return ttsPlayer.buttonText.value +}) + const outline = computed(() => { const text = fullContent.value const parts = text.split(/\n{2,}/).filter(Boolean) @@ -102,6 +111,7 @@ const outline = computed(() => { const loadScript = async () => { if (!scriptId.value) return + ttsPlayer.reset() script.value = store.getScriptById(scriptId.value) if (!script.value) { await store.fetchScripts() @@ -124,6 +134,13 @@ const selectCurrent = async () => { uni.navigateTo({ url: '/pages/main/PathView' }) } +const trackTtsClick = () => { + analytics.track('script_detail_tts_click', { + script_id: script.value?.id || '' + }, { eventType: 'tts', pagePath }) + ttsPlayer.playSource(script.value?.id || '') +} + const goBack = () => { uni.navigateBack() } @@ -143,6 +160,7 @@ onMounted(async () => { }) onUnmounted(() => { + ttsPlayer.reset() analytics.trackPageLeave(pagePath, { script_id: scriptId.value }) }) @@ -254,6 +272,41 @@ onUnmounted(() => { margin-top: 28rpx; } +.audio-inline { + height: 76rpx; + margin-top: 26rpx; + border-radius: 999rpx; + padding: 0 26rpx; + display: flex; + align-items: center; + justify-content: center; + gap: 14rpx; + color: #fff; + font-size: 25rpx; + font-weight: 800; + line-height: 1; + background: linear-gradient(135deg, #24c6dc, #7f5af0); + box-shadow: 0 12rpx 30rpx rgba(36, 198, 220, 0.22); +} + +.audio-inline-icon { + width: 34rpx; + height: 34rpx; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(5, 6, 21, 0.86); + font-size: 18rpx; + font-weight: 900; + background: rgba(255, 255, 255, 0.86); +} + +.audio-inline-text { + line-height: 1.2; + text-align: center; +} + .stat { padding: 18rpx 10rpx; border-radius: 20rpx; diff --git a/mini-program/src/pages/main/ScriptView.vue b/mini-program/src/pages/main/ScriptView.vue index 910ad1b..fa05f3e 100644 --- a/mini-program/src/pages/main/ScriptView.vue +++ b/mini-program/src/pages/main/ScriptView.vue @@ -1,1048 +1,937 @@