feat(故事分享): 新增故事分享功能及相关接口
All checks were successful
test/timeline-server/pipeline/head This commit looks good

实现故事分享功能,包括分享配置、发布、取消发布及公开访问接口
新增故事分享相关VO类及数据库表结构
扩展故事和故事项实体类以支持分享功能
添加故事分享Mapper及XML映射文件
更新故事服务和控制器以支持分享操作
This commit is contained in:
2026-03-16 19:31:41 +08:00
parent ff84ab4dd1
commit 427f66bce5
27 changed files with 1629 additions and 426 deletions

View File

@@ -0,0 +1,18 @@
CREATE TABLE IF NOT EXISTS `story_share` (
`id` bigint NOT NULL AUTO_INCREMENT,
`share_id` varchar(64) NOT NULL COMMENT 'Public share ID',
`story_instance_id` varchar(32) NOT NULL COMMENT 'Story instance ID',
`hero_moment_id` varchar(32) DEFAULT NULL COMMENT 'Primary public moment ID',
`share_title` varchar(255) DEFAULT NULL COMMENT 'Curated public title',
`share_description` text COMMENT 'Curated public description',
`share_quote` text COMMENT 'Curated story note',
`featured_moment_ids` text COMMENT 'Comma separated featured moment IDs',
`create_id` varchar(32) DEFAULT NULL,
`update_id` varchar(32) DEFAULT 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_share_id` (`share_id`),
KEY `idx_story_share_story_id` (`story_instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

View File

@@ -4,6 +4,8 @@ import com.timeline.common.response.ResponseEntity;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.story.service.AnalyticsService;
import com.timeline.story.vo.TimelineAnalyticsVo;
import com.timeline.story.vo.TimelineArchiveExploreVo;
import com.timeline.story.vo.TimelineArchiveSummaryVo;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
@@ -124,6 +126,33 @@ public class AnalyticsController {
* @param year 年份(可选,默认去年)
* @return 年度报告
*/
@GetMapping("/archive/summary")
public ResponseEntity<TimelineArchiveSummaryVo> getArchiveSummary(
@RequestParam(defaultValue = "8") int locationLimit,
@RequestParam(defaultValue = "12") int tagLimit) {
String userId = UserContextUtils.getCurrentUserId();
log.info(
"Get archive summary: userId={}, locationLimit={}, tagLimit={}",
userId,
locationLimit,
tagLimit);
TimelineArchiveSummaryVo summary = analyticsService.getArchiveSummary(userId, locationLimit, tagLimit);
return ResponseEntity.success(summary);
}
@GetMapping("/archive/explore")
public ResponseEntity<TimelineArchiveExploreVo> getArchiveExplore(
@RequestParam String type,
@RequestParam(required = false) String value,
@RequestParam(defaultValue = "12") int limit) {
String userId = UserContextUtils.getCurrentUserId();
log.info("Get archive explore: userId={}, type={}, value={}, limit={}", userId, type, value, limit);
TimelineArchiveExploreVo explore = analyticsService.getArchiveExplore(userId, type, value, limit);
return ResponseEntity.success(explore);
}
@GetMapping("/yearly-report")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo.YearlyReport> getYearlyReport(
@RequestParam(required = false) Integer year) {

View File

@@ -5,12 +5,12 @@ import com.timeline.common.response.ResponseEntity;
import com.timeline.common.response.ResponseEnum;
import com.timeline.story.entity.StoryItem;
import com.timeline.story.service.StoryItemService;
import com.timeline.story.service.StoryService;
import com.timeline.story.vo.StoryItemAddVo;
import com.timeline.story.vo.StoryItemShareVo;
import com.timeline.story.vo.StoryItemVo;
import com.timeline.story.vo.StoryVo;
import com.timeline.story.vo.StoryShareConfigVo;
import com.timeline.story.vo.StorySharePublishRequest;
import com.timeline.story.vo.StorySharePublishVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.openfeign.SpringQueryMap;
@@ -29,129 +29,126 @@ public class StoryItemController {
private StoryItemService storyItemService;
@PostMapping()
public ResponseEntity<String> createItem(@RequestParam("storyItem") String storyItemVoString,
public ResponseEntity<String> createItem(
@RequestParam("storyItem") String storyItemVoString,
@RequestParam(value = "images", required = false) List<MultipartFile> images) {
log.info("创建 StoryItem{}", storyItemVoString);
log.info("Create StoryItem: {}", storyItemVoString);
storyItemService.createStoryItem(JSONObject.parseObject(storyItemVoString, StoryItemAddVo.class), images);
return ResponseEntity.success("StoryItem 创建成功");
return ResponseEntity.success("StoryItem created");
}
@PutMapping("")
public ResponseEntity<String> updateItem(@RequestParam("storyItem") String storyItemVoString,
public ResponseEntity<String> updateItem(
@RequestParam("storyItem") String storyItemVoString,
@RequestParam(value = "images", required = false) List<MultipartFile> images) {
log.info("更新 StoryItem: {}", storyItemVoString);
log.info("Update StoryItem: {}", storyItemVoString);
storyItemService.updateItem(JSONObject.parseObject(storyItemVoString, StoryItemAddVo.class), images);
return ResponseEntity.success("StoryItem 更新成功");
return ResponseEntity.success("StoryItem updated");
}
@DeleteMapping("/{itemId}")
public ResponseEntity<String> deleteItem(@PathVariable String itemId) {
log.info("删除 StoryItem: {}", itemId);
log.info("Delete StoryItem: {}", itemId);
storyItemService.deleteItem(itemId);
return ResponseEntity.success("StoryItem 删除成功");
return ResponseEntity.success("StoryItem deleted");
}
@GetMapping("/{itemId}")
public ResponseEntity<StoryItem> getItemById(@PathVariable String itemId) {
log.info("获取 StoryItem 详情: {}", itemId);
log.info("Get StoryItem detail: {}", itemId);
StoryItem item = storyItemService.getItemById(itemId);
return ResponseEntity.success(item);
}
@GetMapping("/list")
public ResponseEntity<Map> getItemsByMasterItem(@SpringQueryMap StoryItemVo storyItemVo) {
log.info("查询 StoryItem 列表storyInstanceId: {}, current: {}, pageSize:{}", storyItemVo.getStoryInstanceId(),
storyItemVo.getCurrent(), storyItemVo.getPageSize());
log.info(
"Query StoryItem list: storyInstanceId={}, current={}, pageSize={}",
storyItemVo.getStoryInstanceId(),
storyItemVo.getCurrent(),
storyItemVo.getPageSize());
Map items = storyItemService.getItemsByMasterItem(storyItemVo);
return ResponseEntity.success(items);
}
@GetMapping("/images/{itemId}")
public ResponseEntity<List<String>> getStoryItemImages(@PathVariable String itemId) {
log.info("获取 StoryItem 图片列表,itemId: {}", itemId);
log.info("Get StoryItem images: itemId={}", itemId);
List<String> images = storyItemService.getStoryItemImages(itemId);
return ResponseEntity.success(images);
}
@GetMapping("/count/{storyInstanceId}")
public ResponseEntity<Integer> getStoryItemCount(@PathVariable String storyInstanceId) {
log.info("获取 StoryItem 子项数量,storyInstanceId: {}", storyInstanceId);
log.info("Get StoryItem count: storyInstanceId={}", storyInstanceId);
Integer count = storyItemService.getStoryItemCount(storyInstanceId);
return ResponseEntity.success(count);
}
/*
* @GetMapping("/public/story/item/{shareId}")
* public ResponseEntity<StoryItemShareVo> getStoryItemByShareId(@PathVariable
* String shareId) {
* log.info("获取分享的 StoryItemshareId: {}", shareId);
* StoryItemShareVo item = storyItemService.getItemByShareId(shareId);
* return ResponseEntity.success(item);
* }
*/
@GetMapping("/public/story/item/{shareId}")
public ResponseEntity<StoryItemShareVo> getStoryItemByShareId(@PathVariable String shareId) {
log.info("Get public StoryItem by shareId: {}", shareId);
StoryItemShareVo item = storyItemService.getItemByShareId(shareId);
return ResponseEntity.success(item);
}
@GetMapping("/share/{storyId}")
public ResponseEntity<StoryShareConfigVo> getStoryShareConfig(@PathVariable String storyId) {
log.info("Get Story share config: storyId={}", storyId);
StoryShareConfigVo config = storyItemService.getStoryShareConfig(storyId);
return ResponseEntity.success(config);
}
@PostMapping("/share/publish")
public ResponseEntity<StorySharePublishVo> publishStoryShare(@RequestBody StorySharePublishRequest request) {
log.info(
"Publish Story share: storyId={}, heroMomentId={}",
request.getStoryId(),
request.getHeroMomentId());
StorySharePublishVo result = storyItemService.publishStoryShare(request);
return ResponseEntity.success(result);
}
@DeleteMapping("/share/{storyId}")
public ResponseEntity<String> unpublishStoryShare(@PathVariable String storyId) {
log.info("Unpublish Story share: storyId={}", storyId);
storyItemService.unpublishStoryShare(storyId);
return ResponseEntity.success("Story share unpublished");
}
@GetMapping("/search")
public ResponseEntity<Map> searchItems(@RequestParam String keyword,
public ResponseEntity<Map> searchItems(
@RequestParam String keyword,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
log.info("搜索 StoryItemkeyword: {}, pageNum: {}, pageSize: {}", keyword, pageNum, pageSize);
log.info("Search StoryItem: keyword={}, pageNum={}, pageSize={}", keyword, pageNum, pageSize);
Map result = storyItemService.searchItems(keyword, pageNum, pageSize);
return ResponseEntity.success(result);
}
/**
* 批量更新时间线节点排序
*
* 功能描述:
* 接收前端拖拽排序后的节点顺序,批量更新各节点的 sortOrder 字段。
* 用于保存用户手动调整的时间线节点顺序。
*
* @param request 包含 items 数组,每个元素有 instanceId 和 sortOrder
* @return 操作结果
*/
@PutMapping("/order")
public ResponseEntity<String> updateItemsOrder(@RequestBody Map<String, List<Map<String, Object>>> request) {
List<Map<String, Object>> items = request.get("items");
if (items == null || items.isEmpty()) {
return ResponseEntity.error("排序数据不能为空");
return ResponseEntity.error("Sort payload cannot be empty");
}
log.info("批量更新 StoryItem 排序,共 {} 项", items.size());
log.info("Update StoryItem order: {} items", items.size());
storyItemService.updateItemsOrder(items);
return ResponseEntity.success("排序更新成功");
return ResponseEntity.success("Order updated");
}
/**
* 批量删除时间线节点
*
* 功能描述:
* 根据节点ID列表批量软删除时间线节点。
* 软删除仅标记 is_delete 字段,数据可恢复。
*
* @param request 包含 instanceIds 数组
* @return 操作结果
*/
@PostMapping("/batch-delete")
public ResponseEntity<String> batchDeleteItems(@RequestBody Map<String, List<String>> request) {
List<String> instanceIds = request.get("instanceIds");
if (instanceIds == null || instanceIds.isEmpty()) {
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "删除列表不能为空");
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "Delete list cannot be empty");
}
log.info("批量删除 StoryItem,共 {} ", instanceIds.size());
log.info("Batch delete StoryItem: {} items", instanceIds.size());
storyItemService.batchDeleteItems(instanceIds);
return ResponseEntity.success("批量删除成功");
return ResponseEntity.success("Batch delete completed");
}
/**
* 批量修改时间线节点时间
*
* 功能描述:
* 批量修改多个节点的 storyItemTime 字段。
* 用于批量调整节点的时间信息。
*
* @param request 包含 instanceIds 数组和 storyItemTime 字符串
* @return 操作结果
*/
@PutMapping("/batch-time")
public ResponseEntity<String> batchUpdateItemTime(@RequestBody Map<String, Object> request) {
@SuppressWarnings("unchecked")
@@ -159,14 +156,14 @@ public class StoryItemController {
String storyItemTime = (String) request.get("storyItemTime");
if (instanceIds == null || instanceIds.isEmpty()) {
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "修改列表不能为空");
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "Update list cannot be empty");
}
if (storyItemTime == null || storyItemTime.isEmpty()) {
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "时间不能为空");
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "Time cannot be empty");
}
log.info("批量修改 StoryItem 时间,共 {} 项,新时间: {}", instanceIds.size(), storyItemTime);
log.info("Batch update StoryItem time: {} items, newTime={}", instanceIds.size(), storyItemTime);
storyItemService.batchUpdateItemTime(instanceIds, storyItemTime);
return ResponseEntity.success("批量修改时间成功");
return ResponseEntity.success("Batch time update completed");
}
}

View File

@@ -1,8 +1,8 @@
package com.timeline.story.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.story.entity.StoryItem;
import com.timeline.story.service.StoryItemService;
import com.timeline.story.vo.StoryItemShareVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -18,13 +18,10 @@ public class StoryPublicController {
@Autowired
private StoryItemService storyItemService;
/*
* @GetMapping("/{shareId}")
* public ResponseEntity<StoryItem> getStoryItemByShareId(@PathVariable String
* shareId) {
* log.info("根据 shareId 获取 StoryItem: {}", shareId);
* StoryItem storyItem = storyItemService.getItemByShareId(shareId);
* return ResponseEntity.success(storyItem);
* }
*/
}
@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);
}
}

View File

@@ -3,10 +3,10 @@ package com.timeline.story.dao;
import com.timeline.story.entity.StoryItem;
import com.timeline.story.vo.StoryItemShareVo;
import com.timeline.story.vo.StoryItemVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
@@ -16,35 +16,38 @@ public interface StoryItemMapper {
void update(StoryItem storyItem);
void deleteByItemId(String itemId);
void deleteByItemId(@Param("instanceId") String itemId);
StoryItem selectById(String itemId);
StoryItem selectById(@Param("instanceId") String itemId);
List<StoryItem> selectByMasterItem(String masterItemId);
List<StoryItem> selectByMasterItem(@Param("masterItemId") String masterItemId);
List<String> selectImagesByItemId(String itemId);
List<String> selectImagesByItemId(@Param("instanceId") String itemId);
List<StoryItemVo> selectStoryItemByStoryInstanceId(Map map);
int countByStoryId(String storyInstanceId);
int countByStoryId(@Param("storyInstanceId") String storyInstanceId);
// StoryItem selectByShareId(String shareId);
StoryItem selectByShareId(@Param("shareId") String shareId);
// StoryItemShareVo selectByShareIdWithAuthor(String shareId);
StoryItemShareVo selectByShareIdWithAuthor(@Param("shareId") String shareId);
StoryItem selectPublishedByStoryId(@Param("storyInstanceId") String storyInstanceId);
void clearShareIdByStoryId(
@Param("storyInstanceId") String storyInstanceId,
@Param("updateId") String updateId,
@Param("updateTime") LocalDateTime updateTime);
void updateShareId(
@Param("instanceId") String instanceId,
@Param("shareId") String shareId,
@Param("updateId") String updateId,
@Param("updateTime") LocalDateTime updateTime);
List<StoryItemVo> searchItems(@Param("keyword") String keyword);
/**
* 更新节点排序值
*
* @param params 包含 instanceId, sortOrder, updateId, updateTime
*/
void updateOrder(Map<String, Object> params);
/**
* 更新节点时间
*
* @param params 包含 instanceId, storyItemTime, updateId, updateTime
*/
void updateItemTime(Map<String, Object> params);
}

View File

@@ -3,15 +3,21 @@ package com.timeline.story.dao;
import com.timeline.story.entity.Story;
import com.timeline.story.vo.StoryDetailVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface StoryMapper {
void insert(Story story);
void update(Story story);
void deleteByInstanceId(String instanceId);
StoryDetailVo selectByInstanceId(String instanceId, String userId);
List<StoryDetailVo> selectByOwnerId(String ownerId);
void touchUpdate(String instanceId, String updateId);
}
void deleteByInstanceId(@Param("instanceId") String instanceId);
StoryDetailVo selectByInstanceId(@Param("instanceId") String instanceId, @Param("userId") String userId);
List<StoryDetailVo> selectByOwnerId(@Param("ownerId") String ownerId);
void touchUpdate(@Param("instanceId") String instanceId, @Param("updateId") String updateId);
}

View File

@@ -0,0 +1,25 @@
package com.timeline.story.dao;
import com.timeline.story.entity.StoryShare;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
@Mapper
public interface StoryShareMapper {
StoryShare selectActiveByStoryId(@Param("storyInstanceId") String storyInstanceId);
StoryShare selectLatestByStoryId(@Param("storyInstanceId") String storyInstanceId);
StoryShare selectByShareId(@Param("shareId") String shareId);
void insert(StoryShare storyShare);
void update(StoryShare storyShare);
void softDeleteByStoryId(
@Param("storyInstanceId") String storyInstanceId,
@Param("updateId") String updateId,
@Param("updateTime") LocalDateTime updateTime);
}

View File

@@ -9,6 +9,7 @@ import java.time.LocalDateTime;
@Data
public class Story {
private String instanceId;
private String shareId;
private String title;
private String description;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@@ -17,9 +18,9 @@ public class Story {
private LocalDate storyTime;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime updateTime;
private String updateId;
private String updateId;
private Integer isDelete;
private String ownerId;
private String status;
private String logo;
}
}

View File

@@ -10,6 +10,7 @@ public class StoryItem {
private String instanceId;
private String storyInstanceId;
private String masterItemId;
private String shareId;
private String title;
private String description;
private String location;
@@ -22,9 +23,5 @@ public class StoryItem {
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Integer isDelete;
/**
* 排序值 - 用于拖拽排序
* 数值越小越靠前默认为0
*/
private Integer sortOrder;
}
}

View File

@@ -0,0 +1,22 @@
package com.timeline.story.entity;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StoryShare {
private Long id;
private String shareId;
private String storyInstanceId;
private String heroMomentId;
private String shareTitle;
private String shareDescription;
private String shareQuote;
private String featuredMomentIds;
private String createId;
private String updateId;
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Integer isDelete;
}

View File

@@ -1,6 +1,8 @@
package com.timeline.story.service;
import com.timeline.story.vo.TimelineAnalyticsVo;
import com.timeline.story.vo.TimelineArchiveExploreVo;
import com.timeline.story.vo.TimelineArchiveSummaryVo;
/**
* AnalyticsService - 数据分析服务接口
@@ -63,6 +65,10 @@ public interface AnalyticsService {
*/
TimelineAnalyticsVo getTopTags(String userId, int limit);
TimelineArchiveSummaryVo getArchiveSummary(String userId, int locationLimit, int tagLimit);
TimelineArchiveExploreVo getArchiveExplore(String userId, String type, String value, int limit);
/**
* 生成年度报告
*

View File

@@ -4,6 +4,9 @@ import com.timeline.story.entity.StoryItem;
import com.timeline.story.vo.StoryItemAddVo;
import com.timeline.story.vo.StoryItemShareVo;
import com.timeline.story.vo.StoryItemVo;
import com.timeline.story.vo.StoryShareConfigVo;
import com.timeline.story.vo.StorySharePublishRequest;
import com.timeline.story.vo.StorySharePublishVo;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@@ -24,40 +27,19 @@ public interface StoryItemService {
Integer getStoryItemCount(String storyInstanceId);
// StoryItemShareVo getItemByShareId(String shareId);
StoryItemShareVo getItemByShareId(String shareId);
StoryShareConfigVo getStoryShareConfig(String storyId);
StorySharePublishVo publishStoryShare(StorySharePublishRequest request);
void unpublishStoryShare(String storyId);
Map<String, Object> searchItems(String keyword, Integer pageNum, Integer pageSize);
/**
* 批量更新时间线节点排序
*
* 功能描述:
* 根据前端传递的排序数据,批量更新各节点的 sortOrder 字段。
* 该方法支持事务处理,确保所有排序更新原子性完成。
*
* @param items 排序数据列表,每个元素包含 instanceId 和 sortOrder
*/
void updateItemsOrder(List<Map<String, Object>> items);
/**
* 批量删除时间线节点
*
* 功能描述:
* 根据节点ID列表批量软删除时间线节点。
* 软删除仅标记 is_delete 字段,数据可恢复。
*
* @param instanceIds 要删除的节点ID列表
*/
void batchDeleteItems(List<String> instanceIds);
/**
* 批量修改时间线节点时间
*
* 功能描述:
* 批量修改多个节点的 storyItemTime 字段。
*
* @param instanceIds 要修改的节点ID列表
* @param storyItemTime 新的时间值
*/
void batchUpdateItemTime(List<String> instanceIds, String storyItemTime);
}

View File

@@ -1,99 +1,538 @@
package com.timeline.story.service.impl;
import com.timeline.story.dao.StoryMapper;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.timeline.story.dao.StoryItemMapper;
import com.timeline.story.dao.StoryMapper;
import com.timeline.story.service.AnalyticsService;
import com.timeline.story.vo.StoryDetailVo;
import com.timeline.story.vo.TimelineArchiveBucketVo;
import com.timeline.story.vo.TimelineArchiveExploreVo;
import com.timeline.story.vo.TimelineArchiveMomentVo;
import com.timeline.story.vo.TimelineArchiveSummaryVo;
import com.timeline.story.vo.StoryItemVo;
import com.timeline.story.vo.TimelineAnalyticsVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.Year;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* AnalyticsServiceImpl - 数据分析服务实现类
*/
@Slf4j
@Service
public class AnalyticsServiceImpl implements AnalyticsService {
private static final Pattern HASHTAG_PATTERN = Pattern.compile("#([\\p{L}\\p{N}_-]+)");
private static final String[] TAG_COLORS = {
"blue", "green", "gold", "magenta", "purple", "cyan", "orange", "volcano"
};
@Autowired
private StoryMapper storyMapper;
@Autowired
private StoryItemMapper storyItemMapper;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public TimelineAnalyticsVo getOverallStats(String userId) {
log.info("获取用户总体统计: userId={}", userId);
log.info("Get overall timeline stats: userId={}", userId);
List<StoryData> storyData = loadStoryData(userId);
List<ItemData> itemData = flattenItemData(storyData);
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
// 基础统计数据 - 使用现有方法查询
List<StoryDetailVo> stories = storyMapper.selectByOwnerId(userId);
vo.setTotalStories((long) (stories != null ? stories.size() : 0));
// 统计所有故事中的时刻数量
long totalMoments = 0;
long imageCount = 0;
long videoCount = 0;
if (stories != null) {
for (StoryDetailVo story : stories) {
Map<String, Object> params = new HashMap<>();
params.put("storyInstanceId", story.getInstanceId());
List<StoryItemVo> items = storyItemMapper.selectStoryItemByStoryInstanceId(params);
if (items != null) {
totalMoments += items.size();
for (StoryItemVo item : items) {
// 根据 video_url 判断类型:有 video_url 是视频,否则是图片
if (item.getVideoUrl() != null && !item.getVideoUrl().isEmpty()) {
videoCount++;
} else {
imageCount++;
}
}
}
}
}
vo.setTotalMoments(totalMoments);
vo.setTotalMedia(totalMoments);
vo.setImageCount(imageCount);
vo.setVideoCount(videoCount);
vo.setCollaborationCount(0L);
vo.setTotalStories((long) storyData.size());
vo.setTotalMoments((long) itemData.size());
vo.setTotalMedia(itemData.stream().mapToLong(ItemData::mediaCount).sum());
vo.setImageCount(itemData.stream().mapToLong(ItemData::imageCount).sum());
vo.setVideoCount(itemData.stream().mapToLong(item -> hasVideo(item.item()) ? 1 : 0).sum());
vo.setCollaborationCount(
storyData.stream().filter(data -> data.story().getPermissionType() != null && data.story().getPermissionType() > 1).count());
vo.setTotalComments(0L);
vo.setTotalLikes(0L);
vo.setTotalFavorites(0L);
vo.setConsecutiveDays(0);
vo.setMaxConsecutiveDays(0);
vo.setMonthlyTrend(buildMonthlyTrend(itemData, Year.now().getValue()));
vo.setTopLocations(buildTopLocations(itemData, 10));
vo.setTopTags(buildTopTags(itemData, 10));
vo.setWeeklyDistribution(buildWeeklyDistribution(itemData));
vo.setHourlyDistribution(buildHourlyDistribution(itemData));
ActivitySummary activitySummary = buildActivitySummary(itemData);
vo.setLastActiveDate(activitySummary.lastActiveDate());
vo.setConsecutiveDays(activitySummary.currentStreak());
vo.setMaxConsecutiveDays(activitySummary.maxStreak());
return vo;
}
@Override
public TimelineAnalyticsVo getStoryStats(String storyInstanceId) {
log.info("获取故事统计: storyInstanceId={}", storyInstanceId);
log.info("Get story stats: storyInstanceId={}", storyInstanceId);
StoryDetailVo story = storyMapper.selectByInstanceId(storyInstanceId, null);
List<StoryItemVo> items = loadItemsForStory(storyInstanceId);
List<ItemData> itemData = new ArrayList<>();
for (StoryItemVo item : items) {
itemData.add(buildItemData(story, item));
}
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
// TODO: 实现故事统计逻辑
vo.setTotalMoments(0L);
vo.setTotalMedia(0L);
vo.setTotalStories(1L);
vo.setTotalMoments((long) itemData.size());
vo.setTotalMedia(itemData.stream().mapToLong(ItemData::mediaCount).sum());
vo.setImageCount(itemData.stream().mapToLong(ItemData::imageCount).sum());
vo.setVideoCount(itemData.stream().mapToLong(item -> hasVideo(item.item()) ? 1 : 0).sum());
vo.setTopLocations(buildTopLocations(itemData, 5));
vo.setTopTags(buildTopTags(itemData, 5));
vo.setWeeklyDistribution(buildWeeklyDistribution(itemData));
vo.setHourlyDistribution(buildHourlyDistribution(itemData));
vo.setMonthlyTrend(buildMonthlyTrend(itemData, resolveReportYear(itemData, Year.now().getValue())));
return vo;
}
@Override
public TimelineAnalyticsVo getMonthlyTrend(String userId, int year) {
log.info("获取月度趋势: userId={}, year={}", userId, year);
log.info("Get monthly trend: userId={}, year={}", userId, year);
List<ItemData> itemData = flattenItemData(loadStoryData(userId));
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
List<TimelineAnalyticsVo.MonthlyStats> monthlyTrend = new ArrayList<>();
vo.setMonthlyTrend(buildMonthlyTrend(itemData, year));
return vo;
}
// 生成12个月的默认数据
@Override
public TimelineAnalyticsVo getTopLocations(String userId, int limit) {
log.info("Get top locations: userId={}, limit={}", userId, limit);
List<ItemData> itemData = flattenItemData(loadStoryData(userId));
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
vo.setTopLocations(buildTopLocations(itemData, limit));
return vo;
}
@Override
public TimelineAnalyticsVo getTopTags(String userId, int limit) {
log.info("Get top tags: userId={}, limit={}", userId, limit);
List<ItemData> itemData = flattenItemData(loadStoryData(userId));
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
vo.setTopTags(buildTopTags(itemData, limit));
return vo;
}
@Override
public TimelineArchiveSummaryVo getArchiveSummary(String userId, int locationLimit, int tagLimit) {
log.info("Get archive summary: userId={}, locationLimit={}, tagLimit={}", userId, locationLimit, tagLimit);
ArchiveDataset dataset = buildArchiveDataset(userId);
TimelineArchiveSummaryVo summary = new TimelineArchiveSummaryVo();
summary.setStoriesIndexed(dataset.storyCount());
summary.setShareableStories(dataset.shareableStoryCount());
summary.setVideoMoments(dataset.videoMomentCount());
summary.setLocations(limitBuckets(dataset.locationBuckets(), locationLimit));
summary.setTags(limitBuckets(dataset.tagBuckets(), tagLimit));
return summary;
}
@Override
public TimelineArchiveExploreVo getArchiveExplore(String userId, String type, String value, int limit) {
log.info("Get archive explore: userId={}, type={}, value={}, limit={}", userId, type, value, limit);
ArchiveDataset dataset = buildArchiveDataset(userId);
String resolvedType = resolveArchiveType(type, dataset);
List<TimelineArchiveBucketVo> buckets =
"tag".equals(resolvedType) ? dataset.tagBuckets() : dataset.locationBuckets();
TimelineArchiveBucketVo bucket = findArchiveBucket(buckets, value);
List<TimelineArchiveMomentVo> matchedMoments = bucket == null
? Collections.emptyList()
: dataset.moments().stream()
.filter(moment -> matchesBucket(moment, resolvedType, bucket.getKey()))
.sorted((left, right) -> Long.compare(
right.getSortValue() == null ? 0L : right.getSortValue(),
left.getSortValue() == null ? 0L : left.getSortValue()))
.limit(limit)
.toList();
TimelineArchiveExploreVo explore = new TimelineArchiveExploreVo();
explore.setType(resolvedType);
explore.setValue(bucket == null ? null : bucket.getKey());
explore.setBucket(bucket);
explore.setMoments(matchedMoments);
return explore;
}
@Override
public TimelineAnalyticsVo.YearlyReport generateYearlyReport(String userId, int year) {
log.info("Generate yearly report: userId={}, year={}", userId, year);
List<ItemData> itemData = flattenItemData(loadStoryData(userId));
List<ItemData> inYear = itemData.stream()
.filter(item -> item.eventTime() != null && item.eventTime().getYear() == year)
.toList();
TimelineAnalyticsVo.YearlyReport report = new TimelineAnalyticsVo.YearlyReport();
report.setYear(year);
report.setTotalMoments((long) inYear.size());
report.setTotalMedia(inYear.stream().mapToLong(ItemData::mediaCount).sum());
report.setMonthlyBreakdown(buildMonthlyTrend(inYear, year));
report.setMostActiveMonth(pickMostActiveMonth(report.getMonthlyBreakdown()));
report.setMostActiveDay(pickMostActiveDay(inYear));
List<TimelineAnalyticsVo.LocationStats> topLocations = buildTopLocations(inYear, 1);
report.setTopLocation(topLocations.isEmpty() ? "-" : topLocations.get(0).getLocation());
List<TimelineAnalyticsVo.TagStats> topTags = buildTopTags(inYear, 1);
report.setTopTag(topTags.isEmpty() ? "-" : topTags.get(0).getTag());
return report;
}
@Override
public TimelineAnalyticsVo getActivityStats(String userId) {
log.info("Get activity stats: userId={}", userId);
List<ItemData> itemData = flattenItemData(loadStoryData(userId));
ActivitySummary summary = buildActivitySummary(itemData);
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
vo.setConsecutiveDays(summary.currentStreak());
vo.setMaxConsecutiveDays(summary.maxStreak());
vo.setLastActiveDate(summary.lastActiveDate());
return vo;
}
@Override
public int calculateConsecutiveDays(String userId) {
log.info("Calculate consecutive days: userId={}", userId);
List<ItemData> itemData = flattenItemData(loadStoryData(userId));
return buildActivitySummary(itemData).currentStreak();
}
@Override
public TimelineAnalyticsVo getTimeDistribution(String userId) {
log.info("Get time distribution: userId={}", userId);
List<ItemData> itemData = flattenItemData(loadStoryData(userId));
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
vo.setWeeklyDistribution(buildWeeklyDistribution(itemData));
vo.setHourlyDistribution(buildHourlyDistribution(itemData));
return vo;
}
@Override
public byte[] exportStats(String userId, String format) {
log.info("Export stats: userId={}, format={}", userId, format);
TimelineAnalyticsVo overview = getOverallStats(userId);
TimelineAnalyticsVo.YearlyReport report = generateYearlyReport(userId, Year.now().getValue());
if ("csv".equalsIgnoreCase(format)) {
StringBuilder builder = new StringBuilder();
builder.append("metric,value\n");
builder.append("totalStories,").append(valueOrZero(overview.getTotalStories())).append("\n");
builder.append("totalMoments,").append(valueOrZero(overview.getTotalMoments())).append("\n");
builder.append("totalMedia,").append(valueOrZero(overview.getTotalMedia())).append("\n");
builder.append("imageCount,").append(valueOrZero(overview.getImageCount())).append("\n");
builder.append("videoCount,").append(valueOrZero(overview.getVideoCount())).append("\n");
builder.append("consecutiveDays,").append(valueOrZero(overview.getConsecutiveDays())).append("\n");
builder.append("maxConsecutiveDays,").append(valueOrZero(overview.getMaxConsecutiveDays())).append("\n");
builder.append("lastActiveDate,").append(orDash(overview.getLastActiveDate())).append("\n");
builder.append("yearlyReportYear,").append(valueOrZero(report.getYear())).append("\n");
builder.append("yearlyReportMoments,").append(valueOrZero(report.getTotalMoments())).append("\n");
builder.append("yearlyReportMedia,").append(valueOrZero(report.getTotalMedia())).append("\n");
builder.append("topLocation,").append(orDash(report.getTopLocation())).append("\n");
builder.append("topTag,").append(orDash(report.getTopTag())).append("\n");
return builder.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
Map<String, Object> export = new LinkedHashMap<>();
export.put("overview", overview);
export.put("yearlyReport", report);
try {
return objectMapper.writeValueAsBytes(export);
} catch (JsonProcessingException exception) {
log.error("Failed to export stats as JSON", exception);
return "{}".getBytes(java.nio.charset.StandardCharsets.UTF_8);
}
}
private List<StoryData> loadStoryData(String userId) {
List<StoryDetailVo> stories = storyMapper.selectByOwnerId(userId);
if (stories == null || stories.isEmpty()) {
return Collections.emptyList();
}
List<StoryData> data = new ArrayList<>();
for (StoryDetailVo story : stories) {
data.add(new StoryData(story, loadItemsForStory(story.getInstanceId())));
}
return data;
}
private List<StoryItemVo> loadItemsForStory(String storyInstanceId) {
Map<String, Object> params = new HashMap<>();
params.put("storyInstanceId", storyInstanceId);
List<StoryItemVo> items = storyItemMapper.selectStoryItemByStoryInstanceId(params);
return items == null ? Collections.emptyList() : items;
}
private List<ItemData> flattenItemData(List<StoryData> storyData) {
List<ItemData> itemData = new ArrayList<>();
for (StoryData data : storyData) {
for (StoryItemVo item : data.items()) {
itemData.add(buildItemData(data.story(), item));
}
}
return itemData;
}
private ArchiveDataset buildArchiveDataset(String userId) {
List<StoryData> storyData = loadStoryData(userId);
List<ItemData> itemData = flattenItemData(storyData);
Map<String, TimelineArchiveBucketVo> locationBuckets = new LinkedHashMap<>();
Map<String, TimelineArchiveBucketVo> tagBuckets = new LinkedHashMap<>();
List<TimelineArchiveMomentVo> moments = new ArrayList<>();
for (ItemData item : itemData) {
TimelineArchiveMomentVo moment = buildArchiveMoment(item);
moments.add(moment);
if (StringUtils.hasText(moment.getLocation())) {
mergeArchiveBucket(locationBuckets, moment.getLocation(), moment, item.story());
}
for (String tag : item.tags()) {
mergeArchiveBucket(tagBuckets, tag, moment, item.story());
}
}
return new ArchiveDataset(
storyData.size(),
(int) storyData.stream().filter(data -> StringUtils.hasText(data.story().getShareId())).count(),
(int) itemData.stream().filter(data -> hasVideo(data.item())).count(),
sortArchiveBuckets(locationBuckets),
sortArchiveBuckets(tagBuckets),
moments);
}
private ItemData buildItemData(StoryDetailVo story, StoryItemVo item) {
List<String> images = item.getInstanceId() == null
? Collections.emptyList()
: safeImages(storyItemMapper.selectImagesByItemId(item.getInstanceId()));
long imageCount = images.size();
long mediaCount = imageCount + (hasVideo(item) ? 1 : 0);
LocalDateTime eventTime = resolveEventTime(story, item);
List<String> tags = inferTags(item);
return new ItemData(story, item, images, imageCount, mediaCount, tags, eventTime);
}
private List<String> safeImages(List<String> images) {
return images == null ? Collections.emptyList() : images;
}
private LocalDateTime resolveEventTime(StoryDetailVo story, StoryItemVo item) {
if (item.getStoryItemTime() != null) {
return item.getStoryItemTime();
}
if (story != null && story.getStoryTime() != null) {
return story.getStoryTime().atStartOfDay();
}
if (story != null && story.getUpdateTime() != null) {
return story.getUpdateTime();
}
return story != null ? story.getCreateTime() : null;
}
private boolean hasVideo(StoryItemVo item) {
return StringUtils.hasText(item.getVideoUrl());
}
private List<String> inferTags(StoryItemVo item) {
Set<String> tags = new LinkedHashSet<>();
String raw = ((item.getTitle() == null ? "" : item.getTitle()) + " " + (item.getDescription() == null ? "" : item.getDescription())).trim();
String text = raw.toLowerCase();
Matcher matcher = HASHTAG_PATTERN.matcher(raw);
while (matcher.find()) {
tags.add(matcher.group(1));
}
if (containsAny(text, "travel", "trip", "journey", "\u65c5\u884c", "\u51fa\u6e38")) {
tags.add("Travel");
}
if (containsAny(text, "family", "home", "\u5bb6", "\u7236\u6bcd", "\u5b69\u5b50")) {
tags.add("Family");
}
if (containsAny(text, "friend", "party", "gathering", "\u670b\u53cb", "\u805a\u4f1a")) {
tags.add("Friends");
}
if (containsAny(text, "birthday", "festival", "celebration", "\u751f\u65e5", "\u8282\u65e5", "\u65b0\u5e74")) {
tags.add("Celebration");
}
if (containsAny(text, "work", "project", "office", "\u5de5\u4f5c", "\u9879\u76ee")) {
tags.add("Work");
}
if (hasVideo(item)) {
tags.add("Video");
}
if (tags.isEmpty()) {
tags.add("Everyday");
}
return new ArrayList<>(tags);
}
private boolean containsAny(String text, String... keywords) {
for (String keyword : keywords) {
if (text.contains(keyword.toLowerCase())) {
return true;
}
}
return false;
}
private TimelineArchiveMomentVo buildArchiveMoment(ItemData itemData) {
StoryDetailVo story = itemData.story();
StoryItemVo item = itemData.item();
TimelineArchiveMomentVo moment = new TimelineArchiveMomentVo();
moment.setKey(StringUtils.hasText(item.getInstanceId())
? item.getInstanceId()
: (story.getInstanceId() + "-" + Math.abs((item.getTitle() == null ? "" : item.getTitle()).hashCode())));
moment.setStoryInstanceId(story.getInstanceId());
moment.setStoryShareId(story.getShareId());
moment.setStoryTitle(StringUtils.hasText(story.getTitle()) ? story.getTitle() : "Untitled story");
moment.setStoryTime(formatArchiveDateTime(itemData.eventTime()));
moment.setItemInstanceId(item.getInstanceId());
moment.setItemTitle(StringUtils.hasText(item.getTitle()) ? item.getTitle() : "Untitled moment");
moment.setItemDescription(StringUtils.hasText(item.getDescription())
? item.getDescription()
: "Add one clear sentence here and this memory becomes much easier to revisit.");
moment.setItemTime(formatArchiveDateTime(itemData.eventTime()));
moment.setLocation(item.getLocation());
moment.setTags(itemData.tags());
moment.setCoverInstanceId(resolveCoverInstanceId(itemData));
moment.setCoverSrc(resolveCoverSrc(item));
moment.setMediaCount((int) itemData.mediaCount());
moment.setHasVideo(hasVideo(item));
moment.setSortValue(itemData.eventTime() == null ? 0L : itemData.eventTime().atZone(java.time.ZoneId.systemDefault()).toInstant().toEpochMilli());
return moment;
}
private void mergeArchiveBucket(
Map<String, TimelineArchiveBucketVo> buckets,
String bucketKey,
TimelineArchiveMomentVo moment,
StoryDetailVo story) {
TimelineArchiveBucketVo bucket = buckets.get(bucketKey);
if (bucket == null) {
bucket = new TimelineArchiveBucketVo();
bucket.setKey(bucketKey);
bucket.setTitle(bucketKey);
bucket.setCount(0L);
bucket.setSubtitle(moment.getStoryTitle());
bucket.setStoryInstanceId(moment.getStoryInstanceId());
bucket.setStoryShareId(moment.getStoryShareId());
bucket.setCoverInstanceId(moment.getCoverInstanceId());
bucket.setCoverSrc(moment.getCoverSrc());
bucket.setSampleMomentTitle(moment.getItemTitle());
bucket.setSortValue(moment.getSortValue());
buckets.put(bucketKey, bucket);
}
bucket.setCount((bucket.getCount() == null ? 0L : bucket.getCount()) + 1L);
long bucketSortValue = bucket.getSortValue() == null ? 0L : bucket.getSortValue();
long momentSortValue = moment.getSortValue() == null ? 0L : moment.getSortValue();
if (momentSortValue >= bucketSortValue) {
bucket.setSubtitle(moment.getStoryTitle());
bucket.setStoryInstanceId(story.getInstanceId());
bucket.setStoryShareId(story.getShareId());
bucket.setCoverInstanceId(moment.getCoverInstanceId());
bucket.setCoverSrc(moment.getCoverSrc());
bucket.setSampleMomentTitle(moment.getItemTitle());
bucket.setSortValue(moment.getSortValue());
}
}
private List<TimelineArchiveBucketVo> sortArchiveBuckets(Map<String, TimelineArchiveBucketVo> bucketMap) {
return bucketMap.values().stream()
.sorted((left, right) -> {
int countCompare = Long.compare(
right.getCount() == null ? 0L : right.getCount(),
left.getCount() == null ? 0L : left.getCount());
if (countCompare != 0) {
return countCompare;
}
return Long.compare(
right.getSortValue() == null ? 0L : right.getSortValue(),
left.getSortValue() == null ? 0L : left.getSortValue());
})
.toList();
}
private List<TimelineArchiveBucketVo> limitBuckets(List<TimelineArchiveBucketVo> buckets, int limit) {
return buckets.stream().limit(Math.max(limit, 0)).toList();
}
private String resolveArchiveType(String requestedType, ArchiveDataset dataset) {
if ("tag".equalsIgnoreCase(requestedType) && !dataset.tagBuckets().isEmpty()) {
return "tag";
}
if (!dataset.locationBuckets().isEmpty()) {
return "location";
}
return dataset.tagBuckets().isEmpty() ? "location" : "tag";
}
private TimelineArchiveBucketVo findArchiveBucket(List<TimelineArchiveBucketVo> buckets, String value) {
if (buckets.isEmpty()) {
return null;
}
if (StringUtils.hasText(value)) {
for (TimelineArchiveBucketVo bucket : buckets) {
if (value.equals(bucket.getKey())) {
return bucket;
}
}
}
return buckets.get(0);
}
private boolean matchesBucket(TimelineArchiveMomentVo moment, String type, String key) {
if (!StringUtils.hasText(key)) {
return false;
}
if ("tag".equals(type)) {
return moment.getTags() != null && moment.getTags().contains(key);
}
return key.equals(moment.getLocation());
}
private String resolveCoverInstanceId(ItemData itemData) {
if (!itemData.images().isEmpty()) {
return itemData.images().get(0);
}
return null;
}
private String resolveCoverSrc(StoryItemVo item) {
return StringUtils.hasText(item.getThumbnailUrl()) ? item.getThumbnailUrl() : null;
}
private String formatArchiveDateTime(LocalDateTime value) {
if (value == null) {
return "Pending date";
}
return value.format(DateTimeFormatter.ofPattern("yyyy.MM.dd HH:mm"));
}
private List<TimelineAnalyticsVo.MonthlyStats> buildMonthlyTrend(List<ItemData> itemData, int year) {
List<TimelineAnalyticsVo.MonthlyStats> monthlyTrend = new ArrayList<>();
for (int month = 1; month <= 12; month++) {
TimelineAnalyticsVo.MonthlyStats stats = new TimelineAnalyticsVo.MonthlyStats();
stats.setMonth(String.format("%d-%02d", year, month));
@@ -102,83 +541,213 @@ public class AnalyticsServiceImpl implements AnalyticsService {
monthlyTrend.add(stats);
}
vo.setMonthlyTrend(monthlyTrend);
return vo;
for (ItemData item : itemData) {
if (item.eventTime() == null || item.eventTime().getYear() != year) {
continue;
}
TimelineAnalyticsVo.MonthlyStats stats = monthlyTrend.get(item.eventTime().getMonthValue() - 1);
stats.setCount(stats.getCount() + 1);
stats.setMediaCount(stats.getMediaCount() + item.mediaCount());
}
return monthlyTrend;
}
@Override
public TimelineAnalyticsVo getTopLocations(String userId, int limit) {
log.info("获取热门地点: userId={}, limit={}", userId, limit);
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
vo.setTopLocations(new ArrayList<>());
return vo;
private List<TimelineAnalyticsVo.LocationStats> buildTopLocations(List<ItemData> itemData, int limit) {
Map<String, Long> counts = new HashMap<>();
long total = 0L;
for (ItemData item : itemData) {
String location = item.item().getLocation();
if (!StringUtils.hasText(location)) {
continue;
}
counts.merge(location, 1L, Long::sum);
total++;
}
List<TimelineAnalyticsVo.LocationStats> result = new ArrayList<>();
final long totalCount = total;
counts.entrySet().stream()
.sorted((left, right) -> Long.compare(right.getValue(), left.getValue()))
.limit(limit)
.forEach(entry -> {
TimelineAnalyticsVo.LocationStats stats = new TimelineAnalyticsVo.LocationStats();
stats.setLocation(entry.getKey());
stats.setCount(entry.getValue());
stats.setPercentage(totalCount == 0 ? 0D : roundPercentage(entry.getValue(), totalCount));
result.add(stats);
});
return result;
}
@Override
public TimelineAnalyticsVo getTopTags(String userId, int limit) {
log.info("获取热门标签: userId={}, limit={}", userId, limit);
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
vo.setTopTags(new ArrayList<>());
return vo;
private List<TimelineAnalyticsVo.TagStats> buildTopTags(List<ItemData> itemData, int limit) {
Map<String, Long> counts = new HashMap<>();
long total = 0L;
for (ItemData item : itemData) {
for (String tag : item.tags()) {
counts.merge(tag, 1L, Long::sum);
total++;
}
}
List<TimelineAnalyticsVo.TagStats> result = new ArrayList<>();
final long totalCount = total;
counts.entrySet().stream()
.sorted((left, right) -> Long.compare(right.getValue(), left.getValue()))
.limit(limit)
.forEach(entry -> {
TimelineAnalyticsVo.TagStats stats = new TimelineAnalyticsVo.TagStats();
stats.setTag(entry.getKey());
stats.setColor(TAG_COLORS[Math.abs(entry.getKey().hashCode()) % TAG_COLORS.length]);
stats.setCount(entry.getValue());
stats.setPercentage(totalCount == 0 ? 0D : roundPercentage(entry.getValue(), totalCount));
result.add(stats);
});
return result;
}
@Override
public TimelineAnalyticsVo.YearlyReport generateYearlyReport(String userId, int year) {
log.info("生成年度报告: userId={}, year={}", userId, year);
TimelineAnalyticsVo.YearlyReport report = new TimelineAnalyticsVo.YearlyReport();
report.setYear(year);
report.setTotalMoments(0L);
report.setTotalMedia(0L);
report.setMostActiveMonth("-");
report.setMostActiveDay("-");
report.setTopLocation("-");
report.setTopTag("-");
report.setMonthlyBreakdown(new ArrayList<>());
return report;
}
@Override
public TimelineAnalyticsVo getActivityStats(String userId) {
log.info("获取活跃度统计: userId={}", userId);
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
vo.setConsecutiveDays(0);
vo.setMaxConsecutiveDays(0);
vo.setLastActiveDate(LocalDate.now().format(DateTimeFormatter.ISO_DATE));
return vo;
}
@Override
public int calculateConsecutiveDays(String userId) {
log.info("计算连续记录天数: userId={}", userId);
return 0;
}
@Override
public TimelineAnalyticsVo getTimeDistribution(String userId) {
log.info("获取时间分布: userId={}", userId);
TimelineAnalyticsVo vo = new TimelineAnalyticsVo();
// 初始化周分布
Map<Integer, Long> weeklyDistribution = new HashMap<>();
private Map<Integer, Long> buildWeeklyDistribution(List<ItemData> itemData) {
Map<Integer, Long> weeklyDistribution = new LinkedHashMap<>();
for (int i = 1; i <= 7; i++) {
weeklyDistribution.put(i, 0L);
}
vo.setWeeklyDistribution(weeklyDistribution);
// 初始化小时分布
Map<Integer, Long> hourlyDistribution = new HashMap<>();
for (ItemData item : itemData) {
if (item.eventTime() == null) {
continue;
}
int day = item.eventTime().getDayOfWeek().getValue();
weeklyDistribution.put(day, weeklyDistribution.get(day) + 1);
}
return weeklyDistribution;
}
private Map<Integer, Long> buildHourlyDistribution(List<ItemData> itemData) {
Map<Integer, Long> hourlyDistribution = new LinkedHashMap<>();
for (int i = 0; i < 24; i++) {
hourlyDistribution.put(i, 0L);
}
vo.setHourlyDistribution(hourlyDistribution);
return vo;
for (ItemData item : itemData) {
if (item.eventTime() == null) {
continue;
}
int hour = item.eventTime().getHour();
hourlyDistribution.put(hour, hourlyDistribution.get(hour) + 1);
}
return hourlyDistribution;
}
@Override
public byte[] exportStats(String userId, String format) {
log.info("导出统计数据: userId={}, format={}", userId, format);
// 返回空数据
return new byte[0];
private ActivitySummary buildActivitySummary(List<ItemData> itemData) {
Set<LocalDate> distinctDates = new LinkedHashSet<>();
for (ItemData item : itemData) {
if (item.eventTime() != null) {
distinctDates.add(item.eventTime().toLocalDate());
}
}
List<LocalDate> sortedDates = new ArrayList<>(distinctDates);
sortedDates.sort(Comparator.reverseOrder());
if (sortedDates.isEmpty()) {
return new ActivitySummary(null, 0, 0);
}
int currentStreak = 1;
for (int i = 1; i < sortedDates.size(); i++) {
if (sortedDates.get(i - 1).minusDays(1).equals(sortedDates.get(i))) {
currentStreak++;
} else {
break;
}
}
List<LocalDate> ascending = new ArrayList<>(sortedDates);
ascending.sort(Comparator.naturalOrder());
int maxStreak = 1;
int runningStreak = 1;
for (int i = 1; i < ascending.size(); i++) {
if (ascending.get(i - 1).plusDays(1).equals(ascending.get(i))) {
runningStreak++;
maxStreak = Math.max(maxStreak, runningStreak);
} else {
runningStreak = 1;
}
}
return new ActivitySummary(sortedDates.get(0).format(DateTimeFormatter.ISO_DATE), currentStreak, maxStreak);
}
private int resolveReportYear(List<ItemData> itemData, int fallbackYear) {
return itemData.stream()
.map(ItemData::eventTime)
.filter(java.util.Objects::nonNull)
.map(LocalDateTime::getYear)
.max(Integer::compareTo)
.orElse(fallbackYear);
}
private String pickMostActiveMonth(List<TimelineAnalyticsVo.MonthlyStats> monthlyBreakdown) {
return monthlyBreakdown.stream()
.max(Comparator.comparingLong(stat -> stat.getCount() == null ? 0L : stat.getCount()))
.filter(stat -> stat.getCount() != null && stat.getCount() > 0)
.map(TimelineAnalyticsVo.MonthlyStats::getMonth)
.orElse("-");
}
private String pickMostActiveDay(List<ItemData> itemData) {
Map<String, Long> dailyCounts = new HashMap<>();
for (ItemData item : itemData) {
if (item.eventTime() == null) {
continue;
}
dailyCounts.merge(item.eventTime().toLocalDate().format(DateTimeFormatter.ISO_DATE), 1L, Long::sum);
}
return dailyCounts.entrySet().stream()
.max(Map.Entry.comparingByValue())
.map(Map.Entry::getKey)
.orElse("-");
}
private double roundPercentage(long count, long total) {
if (total == 0) {
return 0D;
}
return Math.round((count * 10000.0) / total) / 100.0;
}
private String orDash(String value) {
return StringUtils.hasText(value) ? value : "-";
}
private long valueOrZero(Number value) {
return value == null ? 0L : value.longValue();
}
private record StoryData(StoryDetailVo story, List<StoryItemVo> items) {
}
private record ItemData(
StoryDetailVo story,
StoryItemVo item,
List<String> images,
long imageCount,
long mediaCount,
List<String> tags,
LocalDateTime eventTime) {
}
private record ArchiveDataset(
int storyCount,
int shareableStoryCount,
int videoMomentCount,
List<TimelineArchiveBucketVo> locationBuckets,
List<TimelineArchiveBucketVo> tagBuckets,
List<TimelineArchiveMomentVo> moments) {
}
private record ActivitySummary(String lastActiveDate, int currentStreak, int maxStreak) {
}
}

View File

@@ -2,25 +2,42 @@ package com.timeline.story.service.impl;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.timeline.common.constants.CommonConstants;
import com.timeline.common.exception.CustomException;
import com.timeline.common.response.ResponseEnum;
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.StoryShareMapper;
import com.timeline.story.entity.StoryItem;
import com.timeline.story.entity.StoryShare;
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.StoryItemVo;
import com.timeline.story.vo.StoryShareConfigVo;
import com.timeline.story.vo.StoryShareMomentVo;
import com.timeline.story.vo.StorySharePublishRequest;
import com.timeline.story.vo.StorySharePublishVo;
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 org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
@Service
@@ -29,15 +46,51 @@ public class StoryItemServiceImpl implements StoryItemService {
@Autowired
private StoryItemMapper storyItemMapper;
@Autowired
private StoryMapper storyMapper;
@Autowired
private StoryPermissionService storyPermissionService;
@Autowired
private StoryShareMapper storyShareMapper;
private String getCurrentUserId() {
String currentUserId = UserContextUtils.getCurrentUserId();
if (!StringUtils.hasText(currentUserId)) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "User context is missing");
}
return currentUserId;
}
private StoryDetailVo getAccessibleStory(String storyId, String currentUserId) {
StoryDetailVo story = storyMapper.selectByInstanceId(storyId, currentUserId);
if (story == null) {
throw new CustomException(ResponseEnum.NOT_FOUND, "Story not found");
}
if (!storyPermissionService.checkUserPermission(
storyId,
currentUserId,
CommonConstants.STORY_PERMISSION_TYPE_WRITE)) {
throw new CustomException(ResponseEnum.FORBIDDEN, "No permission to publish this story");
}
return story;
}
@Override
public Map<String, Object> getItemsByMasterItem(StoryItemVo storyItemVo) {
PageHelper.startPage(storyItemVo.getCurrent() != null ? storyItemVo.getCurrent() : 1,
PageHelper.startPage(
storyItemVo.getCurrent() != null ? storyItemVo.getCurrent() : 1,
storyItemVo.getPageSize() != null ? storyItemVo.getPageSize() : 10);
Map<String, Object> params = new HashMap<>();
params.put("storyInstanceId", storyItemVo.getStoryInstanceId());
if (storyItemVo.getAfterTime() != null) {
params.put("afterTime", storyItemVo.getAfterTime());
}
if (storyItemVo.getBeforeTime() != null) {
params.put("beforeTime", storyItemVo.getBeforeTime());
}
List<StoryItemVo> list = storyItemMapper.selectStoryItemByStoryInstanceId(params);
PageInfo<StoryItemVo> pageInfo = new PageInfo<>(list);
@@ -61,7 +114,7 @@ public class StoryItemServiceImpl implements StoryItemService {
}
storyItemMapper.insert(storyItemAddVo);
// Images handling to be implemented
// Images handling to be implemented separately.
}
@Override
@@ -70,7 +123,7 @@ public class StoryItemServiceImpl implements StoryItemService {
storyItemAddVo.setUpdateId(UserContextUtils.getCurrentUserId());
storyItemAddVo.setUpdateTime(LocalDateTime.now());
storyItemMapper.update(storyItemAddVo);
// Images handling to be implemented
// Images handling to be implemented separately.
}
@Override
@@ -86,7 +139,8 @@ public class StoryItemServiceImpl implements StoryItemService {
@Override
public List<String> getStoryItemImages(String itemId) {
return storyItemMapper.selectImagesByItemId(itemId);
List<String> images = storyItemMapper.selectImagesByItemId(itemId);
return images == null ? Collections.emptyList() : images;
}
@Override
@@ -94,12 +148,127 @@ public class StoryItemServiceImpl implements StoryItemService {
return storyItemMapper.countByStoryId(storyInstanceId);
}
/*
* @Override
* public StoryItemShareVo getItemByShareId(String shareId) {
* return storyItemMapper.selectByShareIdWithAuthor(shareId);
* }
*/
@Override
public StoryItemShareVo getItemByShareId(String shareId) {
StoryItemShareVo shareVo = storyItemMapper.selectByShareIdWithAuthor(shareId);
if (shareVo == null) {
throw new CustomException(ResponseEnum.NOT_FOUND, "Public share not found");
}
hydrateShareItem(shareVo);
StoryShare storyShare = storyShareMapper.selectByShareId(shareId);
if (storyShare != null) {
shareVo.setShareTitle(trimToNull(storyShare.getShareTitle()));
shareVo.setShareDescription(trimToNull(storyShare.getShareDescription()));
shareVo.setShareQuote(trimToNull(storyShare.getShareQuote()));
shareVo.setHeroMomentId(storyShare.getHeroMomentId());
List<String> featuredMomentIds = parseFeaturedMomentIds(storyShare.getFeaturedMomentIds());
shareVo.setFeaturedMomentIds(featuredMomentIds);
shareVo.setFeaturedMoments(loadFeaturedMoments(shareVo.getStoryInstanceId(), featuredMomentIds));
}
if ((shareVo.getFeaturedMoments() == null || shareVo.getFeaturedMoments().isEmpty())
&& StringUtils.hasText(shareVo.getInstanceId())) {
shareVo.setFeaturedMomentIds(Collections.singletonList(shareVo.getInstanceId()));
shareVo.setFeaturedMoments(Collections.singletonList(buildShareMomentVo(shareVo)));
}
return shareVo;
}
@Override
public StoryShareConfigVo getStoryShareConfig(String storyId) {
if (!StringUtils.hasText(storyId)) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "Story id is required");
}
String currentUserId = getCurrentUserId();
StoryDetailVo story = getAccessibleStory(storyId, currentUserId);
StoryShare storyShare = storyShareMapper.selectLatestByStoryId(storyId);
StoryItem publishedItem = storyItemMapper.selectPublishedByStoryId(storyId);
StoryShareConfigVo config = new StoryShareConfigVo();
config.setStoryId(storyId);
config.setShareId(StringUtils.hasText(story.getShareId()) ? story.getShareId() : storyShare == null ? null : storyShare.getShareId());
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.setFeaturedMomentIds(storyShare == null
? Collections.emptyList()
: parseFeaturedMomentIds(storyShare.getFeaturedMomentIds()));
config.setPublished(StringUtils.hasText(story.getShareId()));
config.setUpdatedAt(storyShare == null ? null : storyShare.getUpdateTime());
return config;
}
@Override
@Transactional(rollbackFor = Exception.class)
public StorySharePublishVo publishStoryShare(StorySharePublishRequest request) {
if (request == null || !StringUtils.hasText(request.getStoryId())) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "Story id is required");
}
if (!StringUtils.hasText(request.getHeroMomentId())) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "Hero moment id is required");
}
String currentUserId = getCurrentUserId();
StoryDetailVo story = getAccessibleStory(request.getStoryId(), currentUserId);
StoryItem heroItem = storyItemMapper.selectById(request.getHeroMomentId());
if (heroItem == null || (heroItem.getIsDelete() != null && heroItem.getIsDelete() == 1)) {
throw new CustomException(ResponseEnum.NOT_FOUND, "Hero moment not found");
}
if (!request.getStoryId().equals(heroItem.getStoryInstanceId())) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "Hero moment does not belong to this story");
}
StoryItem existingPublished = storyItemMapper.selectPublishedByStoryId(request.getStoryId());
StoryShare existingShareConfig = storyShareMapper.selectLatestByStoryId(request.getStoryId());
String shareId = existingPublished != null && StringUtils.hasText(existingPublished.getShareId())
? existingPublished.getShareId()
: existingShareConfig != null && StringUtils.hasText(existingShareConfig.getShareId())
? existingShareConfig.getShareId()
: IdUtils.randomUuid();
LocalDateTime now = LocalDateTime.now();
storyItemMapper.clearShareIdByStoryId(request.getStoryId(), currentUserId, now);
storyItemMapper.updateShareId(heroItem.getInstanceId(), shareId, currentUserId, now);
upsertStoryShareConfig(
existingShareConfig,
request,
shareId,
currentUserId,
now,
heroItem.getInstanceId());
storyMapper.touchUpdate(request.getStoryId(), currentUserId);
StorySharePublishVo result = new StorySharePublishVo();
result.setStoryId(story.getInstanceId());
result.setShareId(shareId);
result.setHeroMomentId(heroItem.getInstanceId());
result.setPublicPath("/share/" + shareId);
result.setPublishedAt(now);
return result;
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unpublishStoryShare(String storyId) {
if (!StringUtils.hasText(storyId)) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "Story id is required");
}
String currentUserId = getCurrentUserId();
getAccessibleStory(storyId, currentUserId);
LocalDateTime now = LocalDateTime.now();
storyItemMapper.clearShareIdByStoryId(storyId, currentUserId, now);
storyShareMapper.softDeleteByStoryId(storyId, currentUserId, now);
storyMapper.touchUpdate(storyId, currentUserId);
}
@Override
public Map<String, Object> searchItems(String keyword, Integer pageNum, Integer pageSize) {
@@ -113,20 +282,6 @@ public class StoryItemServiceImpl implements StoryItemService {
return result;
}
/**
* 批量更新时间线节点排序
*
* 实现思路:
* 1. 使用事务确保批量更新的原子性
* 2. 遍历排序数据,逐个更新节点的 sortOrder 字段
* 3. 同时更新 updateTime 时间戳
*
* 注意事项:
* - 该方法需要在事务中执行,确保数据一致性
* - 如果任一更新失败,整个事务将回滚
*
* @param items 排序数据列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void updateItemsOrder(List<Map<String, Object>> items) {
@@ -137,7 +292,6 @@ public class StoryItemServiceImpl implements StoryItemService {
String instanceId = (String) item.get("instanceId");
Integer sortOrder = ((Number) item.get("sortOrder")).intValue();
// 构建更新参数
Map<String, Object> params = new HashMap<>();
params.put("instanceId", instanceId);
params.put("sortOrder", sortOrder);
@@ -145,54 +299,27 @@ public class StoryItemServiceImpl implements StoryItemService {
params.put("updateTime", now);
storyItemMapper.updateOrder(params);
log.debug("更新节点排序: instanceId={}, sortOrder={}", instanceId, sortOrder);
log.debug("Updated story item order: instanceId={}, sortOrder={}", instanceId, sortOrder);
}
log.info("批量更新排序完成,共更新 {} 个节点", items.size());
log.info("Updated order for {} story items", items.size());
}
/**
* 批量删除时间线节点
*
* 实现思路:
* 1. 使用事务确保批量删除的原子性
* 2. 遍历节点ID列表逐个执行软删除
* 3. 软删除仅标记 is_delete = 1数据可恢复
*
* 注意事项:
* - 该方法需要在事务中执行
* - 如果任一删除失败,整个事务将回滚
*
* @param instanceIds 要删除的节点ID列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void batchDeleteItems(List<String> instanceIds) {
for (String instanceId : instanceIds) {
storyItemMapper.deleteByItemId(instanceId);
log.debug("软删除节点: instanceId={}", instanceId);
log.debug("Soft deleted story item: {}", instanceId);
}
log.info("批量删除完成,共删除 {} 个节点", instanceIds.size());
log.info("Soft deleted {} story items", instanceIds.size());
}
/**
* 批量修改时间线节点时间
*
* 实现思路:
* 1. 使用事务确保批量修改的原子性
* 2. 遍历节点ID列表逐个更新时间字段
* 3. 同时更新 updateTime 时间戳
*
* @param instanceIds 要修改的节点ID列表
* @param storyItemTime 新的时间值
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void batchUpdateItemTime(List<String> instanceIds, String storyItemTime) {
String currentUserId = UserContextUtils.getCurrentUserId();
LocalDateTime now = LocalDateTime.now();
// 解析时间字符串为 LocalDateTime
LocalDateTime parsedTime = LocalDateTime.parse(storyItemTime.replace(" ", "T"));
for (String instanceId : instanceIds) {
@@ -203,9 +330,139 @@ public class StoryItemServiceImpl implements StoryItemService {
params.put("updateTime", now);
storyItemMapper.updateItemTime(params);
log.debug("更新节点时间: instanceId={}, storyItemTime={}", instanceId, storyItemTime);
log.debug("Updated story item time: instanceId={}, storyItemTime={}", instanceId, storyItemTime);
}
log.info("批量修改时间完成,共修改 {} 个节点", instanceIds.size());
log.info("Updated time for {} story items", instanceIds.size());
}
private void hydrateShareItem(StoryItemShareVo shareVo) {
List<String> images = getStoryItemImages(shareVo.getInstanceId());
shareVo.setImages(images);
shareVo.setRelatedImageInstanceIds(images);
if (!StringUtils.hasText(shareVo.getCoverInstanceId()) && !images.isEmpty()) {
shareVo.setCoverInstanceId(images.get(0));
}
if (!StringUtils.hasText(shareVo.getThumbnailInstanceId())) {
shareVo.setThumbnailInstanceId(shareVo.getThumbnailUrl());
}
if (!StringUtils.hasText(shareVo.getOwnerName())) {
shareVo.setOwnerName(shareVo.getAuthorName());
}
}
private List<String> parseFeaturedMomentIds(String featuredMomentIds) {
if (!StringUtils.hasText(featuredMomentIds)) {
return Collections.emptyList();
}
return Arrays.stream(featuredMomentIds.split(","))
.map(String::trim)
.filter(StringUtils::hasText)
.distinct()
.toList();
}
private String serializeFeaturedMomentIds(List<String> featuredMomentIds, String heroMomentId) {
Set<String> result = new LinkedHashSet<>();
if (StringUtils.hasText(heroMomentId)) {
result.add(heroMomentId);
}
if (featuredMomentIds != null) {
for (String featuredMomentId : featuredMomentIds) {
if (StringUtils.hasText(featuredMomentId) && result.size() < 6) {
result.add(featuredMomentId.trim());
}
}
}
return String.join(",", result);
}
private void upsertStoryShareConfig(
StoryShare existingShareConfig,
StorySharePublishRequest request,
String shareId,
String currentUserId,
LocalDateTime now,
String heroMomentId) {
StoryShare storyShare = existingShareConfig == null ? new StoryShare() : existingShareConfig;
storyShare.setShareId(shareId);
storyShare.setStoryInstanceId(request.getStoryId());
storyShare.setHeroMomentId(heroMomentId);
storyShare.setShareTitle(trimToNull(request.getTitle()));
storyShare.setShareDescription(trimToNull(request.getDescription()));
storyShare.setShareQuote(trimToNull(request.getQuote()));
storyShare.setFeaturedMomentIds(serializeFeaturedMomentIds(request.getFeaturedMomentIds(), heroMomentId));
storyShare.setUpdateId(currentUserId);
storyShare.setUpdateTime(now);
storyShare.setIsDelete(0);
if (storyShare.getId() == null) {
storyShare.setCreateId(currentUserId);
storyShare.setCreateTime(now);
storyShareMapper.insert(storyShare);
return;
}
storyShareMapper.update(storyShare);
}
private List<StoryShareMomentVo> loadFeaturedMoments(String storyId, List<String> featuredMomentIds) {
if (!StringUtils.hasText(storyId) || featuredMomentIds == null || featuredMomentIds.isEmpty()) {
return Collections.emptyList();
}
List<StoryShareMomentVo> moments = new ArrayList<>();
for (String featuredMomentId : featuredMomentIds) {
StoryItem item = storyItemMapper.selectById(featuredMomentId);
if (item == null || (item.getIsDelete() != null && item.getIsDelete() == 1)) {
continue;
}
if (!storyId.equals(item.getStoryInstanceId())) {
continue;
}
moments.add(buildShareMomentVo(item));
}
return moments;
}
private StoryShareMomentVo buildShareMomentVo(StoryItem item) {
StoryShareMomentVo moment = new StoryShareMomentVo();
moment.setId(item.getId());
moment.setInstanceId(item.getInstanceId());
moment.setStoryInstanceId(item.getStoryInstanceId());
moment.setMasterItemId(item.getMasterItemId());
moment.setShareId(item.getShareId());
moment.setTitle(item.getTitle());
moment.setDescription(item.getDescription());
moment.setLocation(item.getLocation());
moment.setStoryItemTime(item.getStoryItemTime());
moment.setVideoUrl(item.getVideoUrl());
moment.setDuration(item.getDuration());
moment.setThumbnailUrl(item.getThumbnailUrl());
moment.setCreateId(item.getCreateId());
moment.setUpdateId(item.getUpdateId());
moment.setCreateTime(item.getCreateTime());
moment.setUpdateTime(item.getUpdateTime());
moment.setIsDelete(item.getIsDelete());
moment.setSortOrder(item.getSortOrder());
List<String> images = getStoryItemImages(item.getInstanceId());
moment.setImages(images);
moment.setRelatedImageInstanceIds(images);
if (!images.isEmpty()) {
moment.setCoverInstanceId(images.get(0));
}
if (!StringUtils.hasText(moment.getThumbnailInstanceId())) {
moment.setThumbnailInstanceId(moment.getThumbnailUrl());
}
return moment;
}
private String trimToNull(String value) {
if (!StringUtils.hasText(value)) {
return null;
}
return value.trim();
}
}

View File

@@ -3,29 +3,28 @@ package com.timeline.story.service.impl;
import com.timeline.common.constants.CommonConstants;
import com.timeline.common.exception.CustomException;
import com.timeline.common.response.ResponseEnum;
import com.timeline.common.utils.IdUtils;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.story.dao.StoryMapper;
import com.timeline.story.entity.Story;
import com.timeline.story.entity.StoryActivity;
import com.timeline.story.entity.StoryItem;
import com.timeline.story.dao.StoryMapper;
import com.timeline.story.mq.ActivityLogProducer;
import com.timeline.story.service.StoryItemService;
import com.timeline.story.service.StoryPermissionService;
import com.timeline.story.service.StoryService;
import com.timeline.story.mq.ActivityLogProducer;
import com.timeline.story.vo.StoryDetailVo;
import com.timeline.story.vo.StoryDetailWithItemsVo;
import com.timeline.story.vo.StoryItemVo;
import com.timeline.story.vo.StoryPermissionVo;
import com.timeline.story.vo.StoryVo;
import com.timeline.common.utils.IdUtils;
import com.timeline.common.utils.UserContextUtils;
import lombok.extern.slf4j.Slf4j;
import lombok.val;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@@ -48,7 +47,7 @@ public class StoryServiceImpl implements StoryService {
private String getCurrentUserId() {
String currentUserId = UserContextUtils.getCurrentUserId();
if (currentUserId == null || currentUserId.isEmpty()) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份");
throw new CustomException(ResponseEnum.UNAUTHORIZED, "User context is missing");
}
return currentUserId;
}
@@ -57,10 +56,8 @@ public class StoryServiceImpl implements StoryService {
@Transactional(rollbackFor = Exception.class)
public void createStory(StoryVo storyVo) {
try {
String currentUserId = UserContextUtils.getCurrentUserId();
if (currentUserId == null || currentUserId.isEmpty()) {
throw new CustomException(ResponseEnum.UNAUTHORIZED, "未获取到用户身份");
}
String currentUserId = getCurrentUserId();
Story story = new Story();
story.setOwnerId(currentUserId);
story.setUpdateId(currentUserId);
@@ -74,11 +71,10 @@ public class StoryServiceImpl implements StoryService {
story.setLogo(storyVo.getLogo());
story.setIsDelete(0);
storyMapper.insert(story);
// 自动添加创建者权限
StoryPermissionVo permissionVo = new StoryPermissionVo();
permissionVo.setStoryInstanceId(story.getInstanceId());
permissionVo.setUserId(currentUserId);
// 创建者权限
permissionVo.setPermissionType(CommonConstants.STORY_PERMISSION_TYPE_OWNER);
storyPermissionService.createPermission(permissionVo);
@@ -88,9 +84,9 @@ public class StoryServiceImpl implements StoryService {
activity.setStoryInstanceId(story.getInstanceId());
activity.setRemark(CommonConstants.ACTION_REMARK_STORY_CREATE);
activityLogProducer.sendLog("story.activity.create", activity);
} catch (Exception e) {
log.error("创建故事失败", e);
throw new CustomException(500, "创建故事失败: " + e.toString());
} catch (Exception exception) {
log.error("Create story failed", exception);
throw new CustomException(500, "Create story failed: " + exception.getMessage());
}
}
@@ -103,17 +99,19 @@ public class StoryServiceImpl implements StoryService {
if (story == null) {
throw new CustomException(ResponseEnum.NOT_FOUND);
}
if (!storyPermissionService.checkUserPermission(storyId, currentUserId,
if (!storyPermissionService.checkUserPermission(
storyId,
currentUserId,
CommonConstants.STORY_PERMISSION_TYPE_WRITE)) {
throw new CustomException(ResponseEnum.FORBIDDEN, "无权限修改故事");
throw new CustomException(ResponseEnum.FORBIDDEN, "No permission to update story");
}
story.setTitle(storyVo.getTitle());
story.setDescription(storyVo.getDescription());
story.setStatus(storyVo.getStatus());
story.setStoryTime(storyVo.getStoryTime());
story.setUpdateTime(LocalDateTime.now());
story.setLogo(storyVo.getLogo());
storyMapper.update(story);
StoryActivity activity = new StoryActivity();
@@ -122,7 +120,6 @@ public class StoryServiceImpl implements StoryService {
activity.setStoryInstanceId(storyId);
activity.setRemark(CommonConstants.ACTION_REMARK_STORY_UPDATE);
activityLogProducer.sendLog("story.activity.update", activity);
}
@Override
@@ -133,15 +130,16 @@ public class StoryServiceImpl implements StoryService {
if (story == null) {
throw new CustomException(ResponseEnum.NOT_FOUND);
}
if (!storyPermissionService.checkUserPermission(storyId, currentUserId,
if (!storyPermissionService.checkUserPermission(
storyId,
currentUserId,
CommonConstants.STORY_PERMISSION_TYPE_ADMIN)) {
throw new CustomException(ResponseEnum.FORBIDDEN, "无权限删除故事");
throw new CustomException(ResponseEnum.FORBIDDEN, "No permission to delete story");
}
// delete story
storyMapper.deleteByInstanceId(storyId);
// delete permission
storyPermissionService.deletePermission(storyId);
// delete activity
StoryActivity activity = new StoryActivity();
activity.setActorId(currentUserId);
activity.setAction(CommonConstants.ACTION_TYPE_STORY_DELETE);
@@ -152,21 +150,32 @@ public class StoryServiceImpl implements StoryService {
@Override
public StoryDetailWithItemsVo getStoryByInstanceId(String storyId) {
val userId = getCurrentUserId();
String userId = getCurrentUserId();
StoryDetailVo story = storyMapper.selectByInstanceId(storyId, userId);
if (story == null) {
throw new CustomException(ResponseEnum.NOT_FOUND);
}
StoryItemVo storyItemVo = new StoryItemVo();
storyItemVo.setStoryInstanceId(storyId);
storyItemVo.setCurrent(1);
storyItemVo.setPageSize(10);
Map itemsMap = storyItemService.getItemsByMasterItem(storyItemVo);
List<StoryItem> items = (List<StoryItem>) itemsMap.get("list");
Map<String, Object> itemsMap = storyItemService.getItemsByMasterItem(storyItemVo);
List<StoryItem> items = new ArrayList<>();
Object rawItems = itemsMap.get("list");
if (rawItems instanceof List<?> rawList) {
for (Object item : rawList) {
if (item instanceof StoryItem storyItem) {
items.add(storyItem);
}
}
}
StoryDetailWithItemsVo result = new StoryDetailWithItemsVo();
result.setItems(items);
result.setInstanceId(story.getInstanceId());
result.setShareId(story.getShareId());
result.setOwnerId(story.getOwnerId());
result.setUpdateId(story.getUpdateId());
result.setTitle(story.getTitle());
@@ -181,7 +190,6 @@ public class StoryServiceImpl implements StoryService {
result.setUpdateName(story.getUpdateName());
result.setPermissionType(story.getPermissionType());
result.setItemCount(story.getItemCount());
return result;
}
@@ -189,9 +197,9 @@ public class StoryServiceImpl implements StoryService {
public List<StoryDetailVo> getStoriesByOwnerId(String ownerId) {
try {
return storyMapper.selectByOwnerId(ownerId);
} catch (Exception e) {
log.error("查询用户故事列表失败", e);
throw new CustomException(500, "查询用户故事列表失败");
} catch (Exception exception) {
log.error("Query user stories failed", exception);
throw new CustomException(500, "Query user stories failed");
}
}
@@ -200,9 +208,9 @@ public class StoryServiceImpl implements StoryService {
try {
String currentUserId = getCurrentUserId();
return storyMapper.selectByOwnerId(currentUserId);
} catch (Exception e) {
log.error("查询用户故事列表失败", e);
throw new CustomException(500, "查询用户故事列表失败");
} catch (Exception exception) {
log.error("Query stories failed", exception);
throw new CustomException(500, "Query stories failed");
}
}
}
}

View File

@@ -4,9 +4,22 @@ import com.timeline.story.entity.StoryItem;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public class StoryItemShareVo extends StoryItem {
private String authorName;
private String authorAvatar;
private String ownerName;
private String coverInstanceId;
private String thumbnailInstanceId;
private String shareTitle;
private String shareDescription;
private String shareQuote;
private String heroMomentId;
private List<String> images;
private List<String> relatedImageInstanceIds;
private List<String> featuredMomentIds;
private List<StoryShareMomentVo> featuredMoments;
}

View File

@@ -0,0 +1,19 @@
package com.timeline.story.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class StoryShareConfigVo {
private String shareId;
private String storyId;
private String heroMomentId;
private String title;
private String description;
private String quote;
private List<String> featuredMomentIds;
private Boolean published;
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,16 @@
package com.timeline.story.vo;
import com.timeline.story.entity.StoryItem;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
@Data
@EqualsAndHashCode(callSuper = true)
public class StoryShareMomentVo extends StoryItem {
private String coverInstanceId;
private String thumbnailInstanceId;
private List<String> images;
private List<String> relatedImageInstanceIds;
}

View File

@@ -0,0 +1,15 @@
package com.timeline.story.vo;
import lombok.Data;
import java.util.List;
@Data
public class StorySharePublishRequest {
private String storyId;
private String heroMomentId;
private String title;
private String description;
private String quote;
private List<String> featuredMomentIds;
}

View File

@@ -0,0 +1,14 @@
package com.timeline.story.vo;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StorySharePublishVo {
private String storyId;
private String shareId;
private String heroMomentId;
private String publicPath;
private LocalDateTime publishedAt;
}

View File

@@ -0,0 +1,17 @@
package com.timeline.story.vo;
import lombok.Data;
@Data
public class TimelineArchiveBucketVo {
private String key;
private String title;
private Long count;
private String subtitle;
private String storyInstanceId;
private String storyShareId;
private String coverInstanceId;
private String coverSrc;
private String sampleMomentTitle;
private Long sortValue;
}

View File

@@ -0,0 +1,13 @@
package com.timeline.story.vo;
import lombok.Data;
import java.util.List;
@Data
public class TimelineArchiveExploreVo {
private String type;
private String value;
private TimelineArchiveBucketVo bucket;
private List<TimelineArchiveMomentVo> moments;
}

View File

@@ -0,0 +1,25 @@
package com.timeline.story.vo;
import lombok.Data;
import java.util.List;
@Data
public class TimelineArchiveMomentVo {
private String key;
private String storyInstanceId;
private String storyShareId;
private String storyTitle;
private String storyTime;
private String itemInstanceId;
private String itemTitle;
private String itemDescription;
private String itemTime;
private String location;
private List<String> tags;
private String coverInstanceId;
private String coverSrc;
private Integer mediaCount;
private Boolean hasVideo;
private Long sortValue;
}

View File

@@ -0,0 +1,14 @@
package com.timeline.story.vo;
import lombok.Data;
import java.util.List;
@Data
public class TimelineArchiveSummaryVo {
private Integer storiesIndexed;
private Integer shareableStories;
private Integer videoMoments;
private List<TimelineArchiveBucketVo> locations;
private List<TimelineArchiveBucketVo> tags;
}

View File

@@ -6,7 +6,7 @@
<insert id="insert">
INSERT INTO story_item (instance_id, master_item_id, description, location, title, create_id, story_instance_id, is_delete, story_item_time, update_id, video_url, duration, thumbnail_url)
VALUES (#{instanceId}, #{masterItemId}, #{description}, #{location}, #{title},#{createId}, #{storyInstanceId}, #{isDelete}, #{storyItemTime}, #{updateId}, #{videoUrl}, #{duration}, #{thumbnailUrl})
VALUES (#{instanceId}, #{masterItemId}, #{description}, #{location}, #{title}, #{createId}, #{storyInstanceId}, #{isDelete}, #{storyItemTime}, #{updateId}, #{videoUrl}, #{duration}, #{thumbnailUrl})
</insert>
<update id="update">
@@ -30,16 +30,26 @@
</delete>
<select id="selectById" resultType="com.timeline.story.entity.StoryItem">
SELECT * FROM story_item WHERE instance_id = #{instanceId}
SELECT *
FROM story_item
WHERE instance_id = #{instanceId}
</select>
<select id="selectByMasterItem" resultType="com.timeline.story.entity.StoryItem">
SELECT * FROM story_item WHERE master_item_id = #{masterItemId} AND is_delete = 0
SELECT *
FROM story_item
WHERE master_item_id = #{masterItemId}
AND is_delete = 0
</select>
<select id="selectImagesByItemId" resultType="java.lang.String">
SELECT sub_rela_id FROM common_relation WHERE rela_id = #{instanceId} AND rela_type = 5 AND is_delete = 0
SELECT sub_rela_id
FROM common_relation
WHERE rela_id = #{instanceId}
AND rela_type = 5
AND is_delete = 0
</select>
<select id="selectStoryItemByStoryInstanceId" resultType="com.timeline.story.vo.StoryItemVo">
SELECT
si.id,
@@ -50,52 +60,71 @@
si.story_instance_id,
si.master_item_id,
si.is_delete,
si.story_item_time as story_item_time,
si.story_item_time AS story_item_time,
si.video_url,
si.duration,
si.thumbnail_url,
si.share_id,
si.create_id AS create_id,
si.create_time AS create_time,
u1.username AS create_name,
si.update_id AS update_id,
si.update_time AS update_time,
u2.username AS update_name
FROM
story_item si
LEFT JOIN user u1 ON si.create_id = u1.user_id
LEFT JOIN `user` u2 ON si.update_id = u2.user_id
WHERE
story_instance_id = #{storyInstanceId}
AND is_delete = 0
<if test="afterTime != null">
AND story_item_time > #{afterTime}
</if>
ORDER BY
story_item_time DESC
FROM story_item si
LEFT JOIN user u1 ON si.create_id = u1.user_id
LEFT JOIN user u2 ON si.update_id = u2.user_id
WHERE si.story_instance_id = #{storyInstanceId}
AND si.is_delete = 0
<if test="afterTime != null">
AND si.story_item_time &gt; #{afterTime}
</if>
<if test="beforeTime != null">
AND si.story_item_time &lt; #{beforeTime}
</if>
ORDER BY si.story_item_time DESC, si.update_time DESC
</select>
<select id="countByStoryId" resultType="int">
SELECT COUNT(*) FROM story_item WHERE story_instance_id = #{storyInstanceId} AND is_delete = 0
SELECT COUNT(*)
FROM story_item
WHERE story_instance_id = #{storyInstanceId}
AND is_delete = 0
</select>
<!--
<select id="selectByShareId" resultType="com.timeline.story.entity.StoryItem">
SELECT * FROM story_item WHERE share_id = #{shareId} AND is_delete = 0
SELECT *
FROM story_item
WHERE share_id = #{shareId}
AND is_delete = 0
LIMIT 1
</select>
<select id="selectByShareIdWithAuthor" resultType="com.timeline.story.vo.StoryItemShareVo">
SELECT
si.*,
u.username AS authorName,
u.avatar AS authorAvatar
FROM
story_item si
LEFT JOIN
user u ON si.create_id = u.user_id
WHERE
si.share_id = #{shareId} AND si.is_delete = 0
author.username AS author_name,
author.avatar AS author_avatar,
owner.username AS owner_name
FROM story_item si
LEFT JOIN story s ON si.story_instance_id = s.instance_id AND s.is_delete = 0
LEFT JOIN user author ON si.create_id = author.user_id AND author.is_deleted = 0
LEFT JOIN user owner ON s.owner_id = owner.user_id AND owner.is_deleted = 0
WHERE si.share_id = #{shareId}
AND si.is_delete = 0
LIMIT 1
</select>
<select id="selectPublishedByStoryId" resultType="com.timeline.story.entity.StoryItem">
SELECT *
FROM story_item
WHERE story_instance_id = #{storyInstanceId}
AND share_id IS NOT NULL
AND share_id != ''
AND is_delete = 0
ORDER BY update_time DESC, create_time DESC
LIMIT 1
</select>
-->
<select id="searchItems" resultType="com.timeline.story.vo.StoryItemVo">
SELECT
@@ -103,29 +132,38 @@
si.story_instance_id,
si.title,
si.description,
si.story_item_time
FROM
story_item si
WHERE
si.is_delete = 0
AND (si.title LIKE CONCAT('%', #{keyword}, '%') OR si.description LIKE CONCAT('%', #{keyword}, '%'))
ORDER BY
si.story_item_time DESC
si.story_item_time,
si.location,
si.video_url,
si.share_id
FROM story_item si
WHERE si.is_delete = 0
AND (
si.title LIKE CONCAT('%', #{keyword}, '%')
OR si.description LIKE CONCAT('%', #{keyword}, '%')
)
ORDER BY si.story_item_time DESC, si.update_time DESC
</select>
<!--
更新节点排序值
功能描述:
根据拖拽排序结果更新节点的 sort_order 字段。
同时更新 update_id 和 update_time 以记录修改信息。
参数说明:
- instanceId: 节点唯一标识
- sortOrder: 新的排序值(数值越小越靠前)
- updateId: 操作用户ID
- updateTime: 更新时间
-->
<update id="clearShareIdByStoryId">
UPDATE story_item
SET share_id = NULL,
update_id = #{updateId},
update_time = #{updateTime}
WHERE story_instance_id = #{storyInstanceId}
AND is_delete = 0
AND share_id IS NOT NULL
</update>
<update id="updateShareId">
UPDATE story_item
SET share_id = #{shareId},
update_id = #{updateId},
update_time = #{updateTime}
WHERE instance_id = #{instanceId}
AND is_delete = 0
</update>
<update id="updateOrder">
UPDATE story_item
SET sort_order = #{sortOrder},
@@ -134,19 +172,6 @@
WHERE instance_id = #{instanceId}
</update>
<!--
更新节点时间
功能描述:
批量修改节点时间时使用,更新 story_item_time 字段。
同时更新 update_id 和 update_time 以记录修改信息。
参数说明:
- instanceId: 节点唯一标识
- storyItemTime: 新的时间值
- updateId: 操作用户ID
- updateTime: 更新时间
-->
<update id="updateItemTime">
UPDATE story_item
SET story_item_time = #{storyItemTime},

View File

@@ -22,43 +22,74 @@
</update>
<delete id="deleteByInstanceId">
UPDATE story SET story.is_delete = 1, update_time = NOW() WHERE instance_id = #{instanceId}
UPDATE story
SET is_delete = 1,
update_time = NOW()
WHERE instance_id = #{instanceId}
</delete>
<select id="selectByInstanceId" resultType="com.timeline.story.vo.StoryDetailVo">
SELECT
s.*,
u1.username as owner_name,
u2.username as update_name,
sp.permission_type as permission_type,
(SELECT COUNT(*) FROM story_item si WHERE si.story_instance_id = s.instance_id AND si.is_delete = 0) as item_count
(
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 share_id,
u1.username AS owner_name,
u2.username AS update_name,
sp.permission_type AS permission_type,
(
SELECT COUNT(*)
FROM story_item si
WHERE si.story_instance_id = s.instance_id
AND si.is_delete = 0
) AS item_count
FROM story s
LEFT JOIN user u1 ON s.owner_id = u1.user_id AND u1.is_deleted = 0
LEFT JOIN user u2 ON s.update_id = u2.user_id AND u2.is_deleted = 0
LEFT JOIN story_permission sp on sp.story_instance_id = s.instance_id and sp.user_id = #{userId}
LEFT JOIN user u1 ON s.owner_id = u1.user_id AND u1.is_deleted = 0
LEFT JOIN user u2 ON s.update_id = u2.user_id AND u2.is_deleted = 0
LEFT JOIN story_permission sp ON sp.story_instance_id = s.instance_id AND sp.user_id = #{userId}
WHERE s.instance_id = #{instanceId}
AND s.is_delete = 0
LIMIT 1
</select>
<select id="selectByOwnerId" resultType="com.timeline.story.vo.StoryDetailVo">
SELECT
s.*,
(
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 share_id,
u1.username AS owner_name,
u2.username AS update_name,
sp.permission_type AS permission_type,
( SELECT COUNT(*) FROM story_item si WHERE si.story_instance_id = s.instance_id AND si.is_delete = 0 ) AS item_count
FROM
story s
LEFT JOIN user u1 ON s.owner_id = u1.user_id
AND u1.is_deleted = 0
LEFT JOIN user u2 ON s.update_id = u2.user_id
AND u2.is_deleted = 0
LEFT JOIN story_permission sp ON s.instance_id = sp.story_instance_id AND sp.user_id = #{owerId}
WHERE
s.instance_id IN ( SELECT story_instance_id FROM story_permission WHERE user_id = #{owerId} )
AND s.is_delete = 0
(
SELECT COUNT(*)
FROM story_item si
WHERE si.story_instance_id = s.instance_id
AND si.is_delete = 0
) AS item_count
FROM story s
LEFT JOIN user u1 ON s.owner_id = u1.user_id AND u1.is_deleted = 0
LEFT JOIN user u2 ON s.update_id = u2.user_id AND u2.is_deleted = 0
LEFT JOIN story_permission sp ON s.instance_id = sp.story_instance_id AND sp.user_id = #{ownerId}
WHERE s.instance_id IN (
SELECT story_instance_id
FROM story_permission
WHERE user_id = #{ownerId}
)
AND s.is_delete = 0
ORDER BY s.update_time DESC, s.create_time DESC
</select>
<update id="touchUpdate">
@@ -68,4 +99,4 @@
WHERE instance_id = #{instanceId}
</update>
</mapper>
</mapper>

View File

@@ -0,0 +1,84 @@
<?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.StoryShareMapper">
<select id="selectActiveByStoryId" resultType="com.timeline.story.entity.StoryShare">
SELECT *
FROM story_share
WHERE story_instance_id = #{storyInstanceId}
AND is_delete = 0
ORDER BY update_time DESC, create_time DESC
LIMIT 1
</select>
<select id="selectLatestByStoryId" resultType="com.timeline.story.entity.StoryShare">
SELECT *
FROM story_share
WHERE story_instance_id = #{storyInstanceId}
ORDER BY update_time DESC, create_time DESC
LIMIT 1
</select>
<select id="selectByShareId" resultType="com.timeline.story.entity.StoryShare">
SELECT *
FROM story_share
WHERE share_id = #{shareId}
AND is_delete = 0
LIMIT 1
</select>
<insert id="insert">
INSERT INTO story_share (
share_id,
story_instance_id,
hero_moment_id,
share_title,
share_description,
share_quote,
featured_moment_ids,
create_id,
update_id,
create_time,
update_time,
is_delete
) VALUES (
#{shareId},
#{storyInstanceId},
#{heroMomentId},
#{shareTitle},
#{shareDescription},
#{shareQuote},
#{featuredMomentIds},
#{createId},
#{updateId},
#{createTime},
#{updateTime},
#{isDelete}
)
</insert>
<update id="update">
UPDATE story_share
SET share_id = #{shareId},
hero_moment_id = #{heroMomentId},
share_title = #{shareTitle},
share_description = #{shareDescription},
share_quote = #{shareQuote},
featured_moment_ids = #{featuredMomentIds},
update_id = #{updateId},
update_time = #{updateTime},
is_delete = #{isDelete}
WHERE id = #{id}
</update>
<update id="softDeleteByStoryId">
UPDATE story_share
SET is_delete = 1,
update_id = #{updateId},
update_time = #{updateTime}
WHERE story_instance_id = #{storyInstanceId}
AND is_delete = 0
</update>
</mapper>