feat(分享): 新增故事分享反馈功能
All checks were successful
test/timeline-server/pipeline/head This commit looks good

实现故事分享的评论和浏览统计功能,包括:
1. 新增StoryShareFeedbackService接口及相关实现
2. 添加StoryShareComment、StoryShareMetric实体类
3. 创建story_share_comment和story_share_metric表
4. 在StoryDetailVo中增加shareConfigured和sharePublished字段
5. 扩展StoryShareConfigVo包含反馈统计数据
6. 新增公共API端点处理反馈请求
7. 实现模板样式选择功能
This commit is contained in:
2026-03-18 14:05:42 +08:00
parent 427f66bce5
commit 7e3e1f66f1
26 changed files with 562 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS `story_share` (
`share_title` varchar(255) DEFAULT NULL COMMENT 'Curated public title',
`share_description` text COMMENT 'Curated public description',
`share_quote` text COMMENT 'Curated story note',
`template_style` varchar(32) DEFAULT 'editorial' COMMENT 'Public template style',
`featured_moment_ids` text COMMENT 'Comma separated featured moment IDs',
`create_id` varchar(32) DEFAULT NULL,
`update_id` varchar(32) DEFAULT NULL,

View File

@@ -0,0 +1,24 @@
CREATE TABLE IF NOT EXISTS `story_share_metric` (
`id` bigint NOT NULL AUTO_INCREMENT,
`share_id` varchar(64) NOT NULL,
`view_count` bigint NOT NULL DEFAULT '0',
`comment_count` bigint NOT NULL DEFAULT '0',
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_story_share_metric_share_id` (`share_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE IF NOT EXISTS `story_share_comment` (
`id` bigint NOT NULL AUTO_INCREMENT,
`instance_id` varchar(64) NOT NULL,
`share_id` varchar(64) NOT NULL,
`visitor_name` varchar(40) NOT NULL,
`content` varchar(280) NOT NULL,
`create_time` datetime DEFAULT CURRENT_TIMESTAMP,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`is_delete` tinyint(1) DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_story_share_comment_instance_id` (`instance_id`),
KEY `idx_story_share_comment_share_id` (`share_id`, `is_delete`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -0,0 +1,24 @@
SET @story_share_table_exists = (
SELECT COUNT(*)
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'story_share'
);
SET @story_share_template_exists = (
SELECT COUNT(*)
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'story_share'
AND COLUMN_NAME = 'template_style'
);
SET @story_share_template_stmt = IF(
@story_share_table_exists = 1 AND @story_share_template_exists = 0,
"ALTER TABLE story_share ADD COLUMN template_style varchar(32) DEFAULT 'editorial' COMMENT 'Public template style' AFTER share_quote",
'SELECT 1'
);
PREPARE story_share_template_stmt FROM @story_share_template_stmt;
EXECUTE story_share_template_stmt;
DEALLOCATE PREPARE story_share_template_stmt;

View File

@@ -2,12 +2,18 @@ package com.timeline.story.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.story.service.StoryItemService;
import com.timeline.story.service.StoryShareFeedbackService;
import com.timeline.story.vo.StoryItemShareVo;
import com.timeline.story.vo.StoryShareCommentRequest;
import com.timeline.story.vo.StoryShareFeedbackVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
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.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@@ -18,10 +24,38 @@ public class StoryPublicController {
@Autowired
private StoryItemService storyItemService;
@Autowired
private StoryShareFeedbackService storyShareFeedbackService;
@GetMapping("/{shareId}")
public ResponseEntity<StoryItemShareVo> getStoryItemByShareId(@PathVariable String shareId) {
log.info("Get public story by shareId: {}", shareId);
StoryItemShareVo storyItem = storyItemService.getItemByShareId(shareId);
return ResponseEntity.success(storyItem);
}
}
@GetMapping("/{shareId}/feedback")
public ResponseEntity<StoryShareFeedbackVo> getShareFeedback(
@PathVariable String shareId,
@RequestParam(defaultValue = "8") int limit) {
log.info("Get public story feedback: shareId={}, limit={}", shareId, limit);
return ResponseEntity.success(storyShareFeedbackService.getFeedback(shareId, limit));
}
@PostMapping("/{shareId}/feedback/view")
public ResponseEntity<StoryShareFeedbackVo> recordShareView(
@PathVariable String shareId,
@RequestParam(defaultValue = "8") int limit) {
log.info("Record public story view: shareId={}, limit={}", shareId, limit);
return ResponseEntity.success(storyShareFeedbackService.recordView(shareId, limit));
}
@PostMapping("/{shareId}/feedback/comment")
public ResponseEntity<StoryShareFeedbackVo> createShareComment(
@PathVariable String shareId,
@RequestBody StoryShareCommentRequest request,
@RequestParam(defaultValue = "8") int limit) {
log.info("Create public story comment: shareId={}, limit={}", shareId, limit);
return ResponseEntity.success(storyShareFeedbackService.createComment(shareId, request, limit));
}
}

View File

@@ -0,0 +1,26 @@
package com.timeline.story.dao;
import com.timeline.story.entity.StoryShareComment;
import com.timeline.story.entity.StoryShareMetric;
import com.timeline.story.vo.StoryShareCommentVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface StoryShareFeedbackMapper {
void ensureMetricRow(@Param("shareId") String shareId);
void incrementViewCount(@Param("shareId") String shareId);
void incrementCommentCount(@Param("shareId") String shareId);
StoryShareMetric selectMetricByShareId(@Param("shareId") String shareId);
void insertComment(StoryShareComment comment);
List<StoryShareCommentVo> selectRecentComments(
@Param("shareId") String shareId,
@Param("limit") int limit);
}

View File

@@ -13,6 +13,7 @@ public class StoryShare {
private String shareTitle;
private String shareDescription;
private String shareQuote;
private String templateStyle;
private String featuredMomentIds;
private String createId;
private String updateId;

View File

@@ -0,0 +1,20 @@
package com.timeline.story.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StoryShareComment {
private Long id;
private String instanceId;
private String shareId;
private String visitorName;
private String content;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
private Integer isDelete;
}

View File

@@ -0,0 +1,18 @@
package com.timeline.story.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StoryShareMetric {
private Long id;
private String shareId;
private Long viewCount;
private Long commentCount;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,12 @@
package com.timeline.story.service;
import com.timeline.story.vo.StoryShareCommentRequest;
import com.timeline.story.vo.StoryShareFeedbackVo;
public interface StoryShareFeedbackService {
StoryShareFeedbackVo getFeedback(String shareId, int limit);
StoryShareFeedbackVo recordView(String shareId, int limit);
StoryShareFeedbackVo createComment(String shareId, StoryShareCommentRequest request, int limit);
}

View File

@@ -314,7 +314,7 @@ public class AnalyticsServiceImpl implements AnalyticsService {
return new ArchiveDataset(
storyData.size(),
(int) storyData.stream().filter(data -> StringUtils.hasText(data.story().getShareId())).count(),
(int) storyData.stream().filter(data -> isShareReady(data.story())).count(),
(int) itemData.stream().filter(data -> hasVideo(data.item())).count(),
sortArchiveBuckets(locationBuckets),
sortArchiveBuckets(tagBuckets),
@@ -405,7 +405,9 @@ public class AnalyticsServiceImpl implements AnalyticsService {
? item.getInstanceId()
: (story.getInstanceId() + "-" + Math.abs((item.getTitle() == null ? "" : item.getTitle()).hashCode())));
moment.setStoryInstanceId(story.getInstanceId());
moment.setStoryShareId(story.getShareId());
moment.setStoryShareId(resolvePublicShareId(story));
moment.setShareConfigured(isShareReady(story));
moment.setSharePublished(isSharePublished(story));
moment.setStoryTitle(StringUtils.hasText(story.getTitle()) ? story.getTitle() : "Untitled story");
moment.setStoryTime(formatArchiveDateTime(itemData.eventTime()));
moment.setItemInstanceId(item.getInstanceId());
@@ -438,6 +440,8 @@ public class AnalyticsServiceImpl implements AnalyticsService {
bucket.setSubtitle(moment.getStoryTitle());
bucket.setStoryInstanceId(moment.getStoryInstanceId());
bucket.setStoryShareId(moment.getStoryShareId());
bucket.setShareConfigured(moment.getShareConfigured());
bucket.setSharePublished(moment.getSharePublished());
bucket.setCoverInstanceId(moment.getCoverInstanceId());
bucket.setCoverSrc(moment.getCoverSrc());
bucket.setSampleMomentTitle(moment.getItemTitle());
@@ -451,7 +455,9 @@ public class AnalyticsServiceImpl implements AnalyticsService {
if (momentSortValue >= bucketSortValue) {
bucket.setSubtitle(moment.getStoryTitle());
bucket.setStoryInstanceId(story.getInstanceId());
bucket.setStoryShareId(story.getShareId());
bucket.setStoryShareId(resolvePublicShareId(story));
bucket.setShareConfigured(isShareReady(story));
bucket.setSharePublished(isSharePublished(story));
bucket.setCoverInstanceId(moment.getCoverInstanceId());
bucket.setCoverSrc(moment.getCoverSrc());
bucket.setSampleMomentTitle(moment.getItemTitle());
@@ -459,6 +465,21 @@ public class AnalyticsServiceImpl implements AnalyticsService {
}
}
private String resolvePublicShareId(StoryDetailVo story) {
if (story == null) {
return null;
}
return StringUtils.hasText(story.getPublicShareId()) ? story.getPublicShareId() : story.getShareId();
}
private boolean isSharePublished(StoryDetailVo story) {
return story != null && (Boolean.TRUE.equals(story.getSharePublished()) || StringUtils.hasText(resolvePublicShareId(story)));
}
private boolean isShareReady(StoryDetailVo story) {
return story != null && (Boolean.TRUE.equals(story.getShareConfigured()) || isSharePublished(story));
}
private List<TimelineArchiveBucketVo> sortArchiveBuckets(Map<String, TimelineArchiveBucketVo> bucketMap) {
return bucketMap.values().stream()
.sorted((left, right) -> {

View File

@@ -9,14 +9,17 @@ import com.timeline.common.utils.IdUtils;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.story.dao.StoryMapper;
import com.timeline.story.dao.StoryItemMapper;
import com.timeline.story.dao.StoryShareFeedbackMapper;
import com.timeline.story.dao.StoryShareMapper;
import com.timeline.story.entity.StoryItem;
import com.timeline.story.entity.StoryShare;
import com.timeline.story.entity.StoryShareMetric;
import com.timeline.story.service.StoryPermissionService;
import com.timeline.story.service.StoryItemService;
import com.timeline.story.vo.StoryDetailVo;
import com.timeline.story.vo.StoryItemAddVo;
import com.timeline.story.vo.StoryItemShareVo;
import com.timeline.story.vo.StoryShareCommentVo;
import com.timeline.story.vo.StoryItemVo;
import com.timeline.story.vo.StoryShareConfigVo;
import com.timeline.story.vo.StoryShareMomentVo;
@@ -43,6 +46,8 @@ import java.util.Set;
@Service
public class StoryItemServiceImpl implements StoryItemService {
private static final String DEFAULT_TEMPLATE_STYLE = "editorial";
@Autowired
private StoryItemMapper storyItemMapper;
@@ -55,6 +60,9 @@ public class StoryItemServiceImpl implements StoryItemService {
@Autowired
private StoryShareMapper storyShareMapper;
@Autowired
private StoryShareFeedbackMapper storyShareFeedbackMapper;
private String getCurrentUserId() {
String currentUserId = UserContextUtils.getCurrentUserId();
if (!StringUtils.hasText(currentUserId)) {
@@ -162,11 +170,15 @@ public class StoryItemServiceImpl implements StoryItemService {
shareVo.setShareTitle(trimToNull(storyShare.getShareTitle()));
shareVo.setShareDescription(trimToNull(storyShare.getShareDescription()));
shareVo.setShareQuote(trimToNull(storyShare.getShareQuote()));
shareVo.setTemplateStyle(normalizeTemplateStyle(storyShare.getTemplateStyle()));
shareVo.setHeroMomentId(storyShare.getHeroMomentId());
List<String> featuredMomentIds = parseFeaturedMomentIds(storyShare.getFeaturedMomentIds());
shareVo.setFeaturedMomentIds(featuredMomentIds);
shareVo.setFeaturedMoments(loadFeaturedMoments(shareVo.getStoryInstanceId(), featuredMomentIds));
}
if (!StringUtils.hasText(shareVo.getTemplateStyle())) {
shareVo.setTemplateStyle(DEFAULT_TEMPLATE_STYLE);
}
if ((shareVo.getFeaturedMoments() == null || shareVo.getFeaturedMoments().isEmpty())
&& StringUtils.hasText(shareVo.getInstanceId())) {
@@ -187,21 +199,31 @@ public class StoryItemServiceImpl implements StoryItemService {
StoryDetailVo story = getAccessibleStory(storyId, currentUserId);
StoryShare storyShare = storyShareMapper.selectLatestByStoryId(storyId);
StoryItem publishedItem = storyItemMapper.selectPublishedByStoryId(storyId);
boolean published = StringUtils.hasText(story.getShareId());
String publicShareId = published ? story.getShareId() : null;
String persistedShareId = StringUtils.hasText(publicShareId)
? publicShareId
: storyShare != null && StringUtils.hasText(storyShare.getShareId())
? storyShare.getShareId()
: publishedItem == null ? null : publishedItem.getShareId();
StoryShareConfigVo config = new StoryShareConfigVo();
config.setStoryId(storyId);
config.setShareId(StringUtils.hasText(story.getShareId()) ? story.getShareId() : storyShare == null ? null : storyShare.getShareId());
config.setShareId(persistedShareId);
config.setPublicShareId(publicShareId);
config.setHeroMomentId(storyShare != null && StringUtils.hasText(storyShare.getHeroMomentId())
? storyShare.getHeroMomentId()
: publishedItem == null ? null : publishedItem.getInstanceId());
config.setTitle(storyShare == null ? null : trimToNull(storyShare.getShareTitle()));
config.setDescription(storyShare == null ? null : trimToNull(storyShare.getShareDescription()));
config.setQuote(storyShare == null ? null : trimToNull(storyShare.getShareQuote()));
config.setTemplateStyle(storyShare == null ? DEFAULT_TEMPLATE_STYLE : normalizeTemplateStyle(storyShare.getTemplateStyle()));
config.setFeaturedMomentIds(storyShare == null
? Collections.emptyList()
: parseFeaturedMomentIds(storyShare.getFeaturedMomentIds()));
config.setPublished(StringUtils.hasText(story.getShareId()));
config.setPublished(published);
config.setUpdatedAt(storyShare == null ? null : storyShare.getUpdateTime());
hydrateShareFeedback(config, persistedShareId);
return config;
}
@@ -249,6 +271,7 @@ public class StoryItemServiceImpl implements StoryItemService {
result.setStoryId(story.getInstanceId());
result.setShareId(shareId);
result.setHeroMomentId(heroItem.getInstanceId());
result.setTemplateStyle(normalizeTemplateStyle(request.getTemplateStyle()));
result.setPublicPath("/share/" + shareId);
result.setPublishedAt(now);
return result;
@@ -266,7 +289,6 @@ public class StoryItemServiceImpl implements StoryItemService {
LocalDateTime now = LocalDateTime.now();
storyItemMapper.clearShareIdByStoryId(storyId, currentUserId, now);
storyShareMapper.softDeleteByStoryId(storyId, currentUserId, now);
storyMapper.touchUpdate(storyId, currentUserId);
}
@@ -392,6 +414,7 @@ public class StoryItemServiceImpl implements StoryItemService {
storyShare.setShareTitle(trimToNull(request.getTitle()));
storyShare.setShareDescription(trimToNull(request.getDescription()));
storyShare.setShareQuote(trimToNull(request.getQuote()));
storyShare.setTemplateStyle(normalizeTemplateStyle(request.getTemplateStyle()));
storyShare.setFeaturedMomentIds(serializeFeaturedMomentIds(request.getFeaturedMomentIds(), heroMomentId));
storyShare.setUpdateId(currentUserId);
storyShare.setUpdateTime(now);
@@ -465,4 +488,32 @@ public class StoryItemServiceImpl implements StoryItemService {
}
return value.trim();
}
private String normalizeTemplateStyle(String templateStyle) {
if (!StringUtils.hasText(templateStyle)) {
return DEFAULT_TEMPLATE_STYLE;
}
String normalized = templateStyle.trim().toLowerCase();
return switch (normalized) {
case "cinematic", "scrapbook", "editorial" -> normalized;
default -> DEFAULT_TEMPLATE_STYLE;
};
}
private void hydrateShareFeedback(StoryShareConfigVo config, String shareId) {
if (config == null || !StringUtils.hasText(shareId)) {
if (config != null) {
config.setViewCount(0L);
config.setCommentCount(0L);
config.setRecentComments(Collections.emptyList());
}
return;
}
StoryShareMetric metric = storyShareFeedbackMapper.selectMetricByShareId(shareId);
List<StoryShareCommentVo> recentComments = storyShareFeedbackMapper.selectRecentComments(shareId, 5);
config.setViewCount(metric == null || metric.getViewCount() == null ? 0L : metric.getViewCount());
config.setCommentCount(metric == null || metric.getCommentCount() == null ? 0L : metric.getCommentCount());
config.setRecentComments(recentComments == null ? Collections.emptyList() : recentComments);
}
}

View File

@@ -176,6 +176,7 @@ public class StoryServiceImpl implements StoryService {
result.setItems(items);
result.setInstanceId(story.getInstanceId());
result.setShareId(story.getShareId());
result.setPublicShareId(story.getPublicShareId());
result.setOwnerId(story.getOwnerId());
result.setUpdateId(story.getUpdateId());
result.setTitle(story.getTitle());
@@ -190,6 +191,8 @@ public class StoryServiceImpl implements StoryService {
result.setUpdateName(story.getUpdateName());
result.setPermissionType(story.getPermissionType());
result.setItemCount(story.getItemCount());
result.setShareConfigured(story.getShareConfigured());
result.setSharePublished(story.getSharePublished());
return result;
}
@@ -213,4 +216,4 @@ public class StoryServiceImpl implements StoryService {
throw new CustomException(500, "Query stories failed");
}
}
}
}

View File

@@ -0,0 +1,128 @@
package com.timeline.story.service.impl;
import com.timeline.common.exception.CustomException;
import com.timeline.common.response.ResponseEnum;
import com.timeline.common.utils.IdUtils;
import com.timeline.story.dao.StoryItemMapper;
import com.timeline.story.dao.StoryShareFeedbackMapper;
import com.timeline.story.entity.StoryItem;
import com.timeline.story.entity.StoryShareComment;
import com.timeline.story.entity.StoryShareMetric;
import com.timeline.story.service.StoryShareFeedbackService;
import com.timeline.story.vo.StoryShareCommentRequest;
import com.timeline.story.vo.StoryShareCommentVo;
import com.timeline.story.vo.StoryShareFeedbackVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.Collections;
import java.util.List;
@Slf4j
@Service
public class StoryShareFeedbackServiceImpl implements StoryShareFeedbackService {
private static final int DEFAULT_LIMIT = 8;
private static final int MAX_LIMIT = 20;
private static final int MAX_VISITOR_NAME_LENGTH = 40;
private static final int MAX_COMMENT_LENGTH = 280;
@Autowired
private StoryItemMapper storyItemMapper;
@Autowired
private StoryShareFeedbackMapper storyShareFeedbackMapper;
@Override
public StoryShareFeedbackVo getFeedback(String shareId, int limit) {
validatePublicShare(shareId);
storyShareFeedbackMapper.ensureMetricRow(shareId);
return buildFeedbackVo(shareId, limit);
}
@Override
@Transactional(rollbackFor = Exception.class)
public StoryShareFeedbackVo recordView(String shareId, int limit) {
validatePublicShare(shareId);
storyShareFeedbackMapper.ensureMetricRow(shareId);
storyShareFeedbackMapper.incrementViewCount(shareId);
return buildFeedbackVo(shareId, limit);
}
@Override
@Transactional(rollbackFor = Exception.class)
public StoryShareFeedbackVo createComment(String shareId, StoryShareCommentRequest request, int limit) {
validatePublicShare(shareId);
String visitorName = normalizeVisitorName(request == null ? null : request.getVisitorName());
String content = normalizeContent(request == null ? null : request.getContent());
StoryShareComment comment = new StoryShareComment();
comment.setInstanceId(IdUtils.randomUuid());
comment.setShareId(shareId);
comment.setVisitorName(visitorName);
comment.setContent(content);
comment.setCreateTime(LocalDateTime.now());
comment.setUpdateTime(comment.getCreateTime());
comment.setIsDelete(0);
storyShareFeedbackMapper.ensureMetricRow(shareId);
storyShareFeedbackMapper.insertComment(comment);
storyShareFeedbackMapper.incrementCommentCount(shareId);
return buildFeedbackVo(shareId, limit);
}
private StoryShareFeedbackVo buildFeedbackVo(String shareId, int limit) {
StoryShareMetric metric = storyShareFeedbackMapper.selectMetricByShareId(shareId);
List<StoryShareCommentVo> comments = storyShareFeedbackMapper.selectRecentComments(shareId, normalizeLimit(limit));
StoryShareFeedbackVo feedback = new StoryShareFeedbackVo();
feedback.setShareId(shareId);
feedback.setViewCount(metric == null || metric.getViewCount() == null ? 0L : metric.getViewCount());
feedback.setCommentCount(metric == null || metric.getCommentCount() == null ? 0L : metric.getCommentCount());
feedback.setComments(comments == null ? Collections.emptyList() : comments);
return feedback;
}
private void validatePublicShare(String shareId) {
if (!StringUtils.hasText(shareId)) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "Share id is required");
}
StoryItem item = storyItemMapper.selectByShareId(shareId);
if (item == null || !StringUtils.hasText(item.getShareId())) {
throw new CustomException(ResponseEnum.NOT_FOUND, "Public share not found");
}
}
private int normalizeLimit(int limit) {
if (limit <= 0) {
return DEFAULT_LIMIT;
}
return Math.min(limit, MAX_LIMIT);
}
private String normalizeVisitorName(String visitorName) {
String normalized = StringUtils.hasText(visitorName) ? visitorName.trim() : "Guest";
if (normalized.length() > MAX_VISITOR_NAME_LENGTH) {
normalized = normalized.substring(0, MAX_VISITOR_NAME_LENGTH);
}
return normalized;
}
private String normalizeContent(String content) {
if (!StringUtils.hasText(content)) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "Comment content is required");
}
String normalized = content.trim();
if (normalized.length() > MAX_COMMENT_LENGTH) {
normalized = normalized.substring(0, MAX_COMMENT_LENGTH);
}
return normalized;
}
}

View File

@@ -13,4 +13,7 @@ public class StoryDetailVo extends Story {
// 新增字段:故事项数量
private Integer itemCount;
private Integer permissionType;
private String publicShareId;
private Boolean shareConfigured;
private Boolean sharePublished;
}

View File

@@ -17,6 +17,7 @@ public class StoryItemShareVo extends StoryItem {
private String shareTitle;
private String shareDescription;
private String shareQuote;
private String templateStyle;
private String heroMomentId;
private List<String> images;
private List<String> relatedImageInstanceIds;

View File

@@ -0,0 +1,9 @@
package com.timeline.story.vo;
import lombok.Data;
@Data
public class StoryShareCommentRequest {
private String visitorName;
private String content;
}

View File

@@ -0,0 +1,14 @@
package com.timeline.story.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StoryShareCommentVo {
private String instanceId;
private String shareId;
private String visitorName;
private String content;
private LocalDateTime createTime;
}

View File

@@ -8,12 +8,17 @@ import java.util.List;
@Data
public class StoryShareConfigVo {
private String shareId;
private String publicShareId;
private String storyId;
private String heroMomentId;
private String title;
private String description;
private String quote;
private String templateStyle;
private List<String> featuredMomentIds;
private Boolean published;
private LocalDateTime updatedAt;
private Long viewCount;
private Long commentCount;
private List<StoryShareCommentVo> recentComments;
}

View File

@@ -0,0 +1,13 @@
package com.timeline.story.vo;
import lombok.Data;
import java.util.List;
@Data
public class StoryShareFeedbackVo {
private String shareId;
private Long viewCount;
private Long commentCount;
private List<StoryShareCommentVo> comments;
}

View File

@@ -11,5 +11,6 @@ public class StorySharePublishRequest {
private String title;
private String description;
private String quote;
private String templateStyle;
private List<String> featuredMomentIds;
}

View File

@@ -9,6 +9,7 @@ public class StorySharePublishVo {
private String storyId;
private String shareId;
private String heroMomentId;
private String templateStyle;
private String publicPath;
private LocalDateTime publishedAt;
}

View File

@@ -10,6 +10,8 @@ public class TimelineArchiveBucketVo {
private String subtitle;
private String storyInstanceId;
private String storyShareId;
private Boolean shareConfigured;
private Boolean sharePublished;
private String coverInstanceId;
private String coverSrc;
private String sampleMomentTitle;

View File

@@ -9,6 +9,8 @@ public class TimelineArchiveMomentVo {
private String key;
private String storyInstanceId;
private String storyShareId;
private Boolean shareConfigured;
private Boolean sharePublished;
private String storyTitle;
private String storyTime;
private String itemInstanceId;

View File

@@ -40,6 +40,40 @@
ORDER BY si.update_time DESC, si.create_time DESC
LIMIT 1
) AS share_id,
(
SELECT si.share_id
FROM story_item si
WHERE si.story_instance_id = s.instance_id
AND si.share_id IS NOT NULL
AND si.is_delete = 0
ORDER BY si.update_time DESC, si.create_time DESC
LIMIT 1
) AS public_share_id,
CASE
WHEN EXISTS (
SELECT 1
FROM story_item si
WHERE si.story_instance_id = s.instance_id
AND si.share_id IS NOT NULL
AND si.is_delete = 0
) THEN 1
ELSE 0
END AS share_published,
CASE
WHEN EXISTS (
SELECT 1
FROM story_share ss
WHERE ss.story_instance_id = s.instance_id
AND ss.is_delete = 0
) OR EXISTS (
SELECT 1
FROM story_item si
WHERE si.story_instance_id = s.instance_id
AND si.share_id IS NOT NULL
AND si.is_delete = 0
) THEN 1
ELSE 0
END AS share_configured,
u1.username AS owner_name,
u2.username AS update_name,
sp.permission_type AS permission_type,
@@ -70,6 +104,40 @@
ORDER BY si.update_time DESC, si.create_time DESC
LIMIT 1
) AS share_id,
(
SELECT si.share_id
FROM story_item si
WHERE si.story_instance_id = s.instance_id
AND si.share_id IS NOT NULL
AND si.is_delete = 0
ORDER BY si.update_time DESC, si.create_time DESC
LIMIT 1
) AS public_share_id,
CASE
WHEN EXISTS (
SELECT 1
FROM story_item si
WHERE si.story_instance_id = s.instance_id
AND si.share_id IS NOT NULL
AND si.is_delete = 0
) THEN 1
ELSE 0
END AS share_published,
CASE
WHEN EXISTS (
SELECT 1
FROM story_share ss
WHERE ss.story_instance_id = s.instance_id
AND ss.is_delete = 0
) OR EXISTS (
SELECT 1
FROM story_item si
WHERE si.story_instance_id = s.instance_id
AND si.share_id IS NOT NULL
AND si.is_delete = 0
) THEN 1
ELSE 0
END AS share_configured,
u1.username AS owner_name,
u2.username AS update_name,
sp.permission_type AS permission_type,
@@ -99,4 +167,4 @@
WHERE instance_id = #{instanceId}
</update>
</mapper>
</mapper>

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.timeline.story.dao.StoryShareFeedbackMapper">
<insert id="ensureMetricRow">
INSERT INTO story_share_metric (share_id, view_count, comment_count, create_time, update_time)
VALUES (#{shareId}, 0, 0, NOW(), NOW())
ON DUPLICATE KEY UPDATE update_time = VALUES(update_time)
</insert>
<update id="incrementViewCount">
UPDATE story_share_metric
SET view_count = view_count + 1,
update_time = NOW()
WHERE share_id = #{shareId}
</update>
<update id="incrementCommentCount">
UPDATE story_share_metric
SET comment_count = comment_count + 1,
update_time = NOW()
WHERE share_id = #{shareId}
</update>
<select id="selectMetricByShareId" resultType="com.timeline.story.entity.StoryShareMetric">
SELECT *
FROM story_share_metric
WHERE share_id = #{shareId}
LIMIT 1
</select>
<insert id="insertComment">
INSERT INTO story_share_comment (
instance_id,
share_id,
visitor_name,
content,
create_time,
update_time,
is_delete
) VALUES (
#{instanceId},
#{shareId},
#{visitorName},
#{content},
#{createTime},
#{updateTime},
#{isDelete}
)
</insert>
<select id="selectRecentComments" resultType="com.timeline.story.vo.StoryShareCommentVo">
SELECT
instance_id,
share_id,
visitor_name,
content,
create_time
FROM story_share_comment
WHERE share_id = #{shareId}
AND is_delete = 0
ORDER BY create_time DESC, id DESC
LIMIT #{limit}
</select>
</mapper>

View File

@@ -37,6 +37,7 @@
share_title,
share_description,
share_quote,
template_style,
featured_moment_ids,
create_id,
update_id,
@@ -50,6 +51,7 @@
#{shareTitle},
#{shareDescription},
#{shareQuote},
#{templateStyle},
#{featuredMomentIds},
#{createId},
#{updateId},
@@ -66,6 +68,7 @@
share_title = #{shareTitle},
share_description = #{shareDescription},
share_quote = #{shareQuote},
template_style = #{templateStyle},
featured_moment_ids = #{featuredMomentIds},
update_id = #{updateId},
update_time = #{updateTime},