feat: 新增协作邀请功能与标签管理
Some checks failed
test/timeline-server/pipeline/head Something is wrong with the build of this commit

新增故事协作邀请功能,包括邀请状态字段和相关接口
添加标签管理功能,支持时间线节点的标签分类
实现智能填充服务,从图片EXIF提取时间和地点信息
优化Docker镜像使用Alpine基础镜像减少体积
新增批量操作功能,包括排序、删除和时间修改
扩展通知系统支持协作邀请相关消息
添加评论和提醒功能相关实体和服务接口
This commit is contained in:
2026-02-24 10:32:35 +08:00
parent d645164daa
commit 40412f6f67
49 changed files with 3816 additions and 15 deletions

2
Jenkinsfile vendored
View File

@@ -171,7 +171,7 @@ pipeline {
// 生成Dockerfile内容的函数
def getDockerfileContent(serviceDir) {
return """FROM eclipse-temurin:21-jdk
return """FROM eclipse-temurin:21.0.2_13-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
EXPOSE 8080

View File

@@ -0,0 +1,2 @@
ALTER TABLE `story_permission`
ADD COLUMN `invite_status` int DEFAULT 0 COMMENT '邀请状态: 0-待处理, 1-已接受, 2-已拒绝';

View File

@@ -25,4 +25,8 @@ public class ResponseEntity<T> {
public static <T> ResponseEntity<T> error(ResponseEnum responseEnum, String detailMessage) {
return new ResponseEntity<>(responseEnum.getCode(), responseEnum.getMessage() + ": " + detailMessage, null);
}
public static <T> ResponseEntity<T> error(String message) {
return new ResponseEntity<>(ResponseEnum.BAD_REQUEST.getCode(), message, null);
}
}

View File

@@ -1,4 +1,4 @@
FROM eclipse-temurin:21-jdk
FROM eclipse-temurin:21.0.2_13-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
EXPOSE 30002

View File

@@ -50,6 +50,13 @@
<artifactId>thumbnailator</artifactId>
<version>0.4.17</version>
</dependency>
<!-- EXIF 元数据解析库 -->
<dependency>
<groupId>com.drewnoakes</groupId>
<artifactId>metadata-extractor</artifactId>
<version>2.19.0</version>
</dependency>
<!--<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>

View File

@@ -1,6 +1,8 @@
package com.timeline.file.controller;
import com.timeline.file.service.ExifParserService;
import com.timeline.file.service.FileService;
import com.timeline.file.vo.ExifInfoVo;
import com.timeline.file.vo.ImageInfoVo;
import com.timeline.common.response.ResponseEntity;
import com.timeline.common.utils.UserContextUtils;
@@ -25,6 +27,9 @@ public class FileController {
@Autowired
private FileService fileService;
@Autowired
private ExifParserService exifParserService;
@GetMapping("/hello")
public String hello() {
return "file service hello";
@@ -196,4 +201,26 @@ public class FileController {
fileService.deleteImage(imageInstanceId);
return ResponseEntity.success("图片已删除");
}
/**
* 解析图片 EXIF 信息
*
* 功能描述:
* 解析上传图片的 EXIF 元数据提取拍摄时间、GPS 坐标等信息。
* 用于前端自动填充时间线节点的时间和地点字段。
*
* 使用场景:
* 1. 用户上传图片后,前端调用此接口获取 EXIF 信息
* 2. 前端根据返回的时间自动填充节点时间
* 3. 前端根据返回的地址自动填充节点地点
*
* @param file 图片文件
* @return EXIF 信息对象
*/
@PostMapping("/exif")
public ResponseEntity<ExifInfoVo> parseExif(@RequestPart("file") MultipartFile file) {
log.info("解析图片 EXIF 信息: {}", file.getOriginalFilename());
ExifInfoVo exifInfo = exifParserService.parseExif(file);
return ResponseEntity.success(exifInfo);
}
}

View File

@@ -0,0 +1,75 @@
package com.timeline.file.service;
import com.timeline.file.vo.ExifInfoVo;
import org.springframework.web.multipart.MultipartFile;
/**
* ExifParserService - EXIF 信息解析服务接口
*
* 功能描述:
* 解析图片文件中的 EXIF 元数据提取拍摄时间、GPS 坐标等信息。
* 用于自动填充时间线节点的时间和地点字段。
*
* 支持的 EXIF 标签:
* - DateTimeOriginal: 原始拍摄时间
* - GPSLatitude/GPSLongitude: GPS 坐标
* - Make/Model: 相机厂商和型号
* - Orientation: 图片方向
*
* @author Timeline Team
* @date 2024
*/
public interface ExifParserService {
/**
* 解析图片文件的 EXIF 信息
*
* 功能描述:
* 读取图片文件的 EXIF 元数据,提取以下信息:
* 1. 拍摄时间DateTimeOriginal
* 2. GPS 坐标(经纬度)
* 3. 相机信息(厂商、型号)
* 4. 图片尺寸
*
* @param file 图片文件
* @return EXIF 信息对象,如果解析失败返回 null
*/
ExifInfoVo parseExif(MultipartFile file);
/**
* 解析图片文件的 EXIF 信息(通过文件路径)
*
* @param filePath 文件路径
* @return EXIF 信息对象
*/
ExifInfoVo parseExifByPath(String filePath);
/**
* 根据 GPS 坐标获取地址信息
*
* 功能描述:
* 调用逆地理编码服务,将 GPS 坐标转换为可读的地址字符串。
* 支持多种逆地理编码服务:
* - 高德地图 API
* - 百度地图 API
* - Google Maps API
*
* @param latitude 纬度
* @param longitude 经度
* @return 地址字符串,如果转换失败返回 null
*/
String getAddressFromGps(double latitude, double longitude);
/**
* 将 GPS 坐标从度分秒格式转换为十进制格式
*
* 背景知识:
* EXIF 中的 GPS 坐标通常以度分秒DMS格式存储
* 需要转换为十进制格式才能用于地图服务。
*
* @param dms 度分秒格式的坐标数组 [度, 分, 秒]
* @param ref 方向参考 (N/S/E/W)
* @return 十进制坐标
*/
double convertGpsToDecimal(double[] dms, String ref);
}

View File

@@ -0,0 +1,117 @@
package com.timeline.file.service;
import com.timeline.file.vo.ExifInfoVo;
import com.timeline.file.vo.SmartFillResultVo;
import java.util.List;
import org.springframework.web.multipart.MultipartFile;
/**
* SmartFillService - 智能填充服务接口
*
* 功能描述:
* 基于 AI 和元数据,智能推断和填充时间线节点信息。
*
* 功能列表:
* - 从图片 EXIF 提取时间和地点
* - 从文件名推断时间
* - 批量智能填充
* - 智能标签推荐
*
* @author Timeline Team
* @date 2024
*/
public interface SmartFillService {
/**
* 智能分析单个文件
*
* 功能描述:
* 综合分析文件的各种信息源,智能推断时间和地点:
* 1. 优先使用 EXIF 元数据
* 2. 备选使用文件修改时间
* 3. 从文件名提取时间信息
* 4. GPS 坐标转换为地址
*
* @param file 文件
* @return 智能填充结果
*/
SmartFillResultVo analyzeFile(MultipartFile file);
/**
* 批量智能分析文件
*
* @param files 文件列表
* @return 智能填充结果列表
*/
List<SmartFillResultVo> analyzeFiles(List<MultipartFile> files);
/**
* 从文件名推断时间
*
* 支持的文件名格式:
* - IMG_20240115_123456.jpg
* - 2024-01-15_12-30-00.jpg
* - 20240115123000.jpg
* - Screenshot_20240115-123000.png
*
* @param fileName 文件名
* @return 推断的时间,如果无法推断返回 null
*/
String inferTimeFromFileName(String fileName);
/**
* 根据已有数据推断时间
*
* 功能描述:
* 当单个数据源不完整时,结合多个数据源进行推断:
* - EXIF 时间
* - 文件修改时间
* - 文件名时间
* - 相邻文件时间(批量上传时)
*
* @param exifInfo EXIF 信息
* @param fileName 文件名
* @param fileModifiedTime 文件修改时间
* @param neighborTimes 相邻文件时间列表(可选)
* @return 推断的时间
*/
String inferTime(ExifInfoVo exifInfo, String fileName, String fileModifiedTime, List<String> neighborTimes);
/**
* 智能标签推荐
*
* 功能描述:
* 根据图片内容和已有信息,推荐相关标签。
*
* @param file 图片文件
* @param existingTags 已有标签
* @return 推荐的标签列表
*/
List<String> recommendTags(MultipartFile file, List<String> existingTags);
/**
* 智能标题生成
*
* 功能描述:
* 根据时间、地点等信息,生成建议的标题。
*
* @param time 时间
* @param location 地点
* @return 建议的标题
*/
String generateTitle(String time, String location);
/**
* 批量智能填充
*
* 功能描述:
* 对批量上传的文件进行智能分析,并自动填充时间和地点。
*
* @param files 文件列表
* @param autoApply 是否自动应用结果
* @return 智能填充结果列表
*/
List<SmartFillResultVo> batchSmartFill(List<MultipartFile> files, boolean autoApply);
}

View File

@@ -0,0 +1,222 @@
package com.timeline.file.service.impl;
import com.drew.imaging.ImageMetadataReader;
import com.drew.imaging.ImageProcessingException;
import com.drew.lang.GeoLocation;
import com.drew.metadata.Metadata;
import com.drew.metadata.exif.ExifIFD0Directory;
import com.drew.metadata.exif.ExifSubIFDDirectory;
import com.drew.metadata.exif.GpsDirectory;
import com.timeline.file.service.ExifParserService;
import com.timeline.file.vo.ExifInfoVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Date;
/**
* ExifParserServiceImpl - EXIF 信息解析服务实现
*
* 功能描述:
* 使用 metadata-extractor 库解析图片文件的 EXIF 元数据。
* 支持常见的图片格式JPEG, TIFF, PNG, GIF, BMP 等。
*
* 技术方案:
* - 使用 metadata-extractor 库读取 EXIF 标签
* - 支持从 MultipartFile 和文件路径两种方式读取
* - GPS 坐标自动转换为十进制格式
*
* 依赖说明:
* 需要在 pom.xml 中添加 metadata-extractor 依赖:
* <dependency>
* <groupId>com.drewnoakes</groupId>
* <artifactId>metadata-extractor</artifactId>
* <version>2.19.0</version>
* </dependency>
*
* @author Timeline Team
* @date 2024
*/
@Slf4j
@Service
public class ExifParserServiceImpl implements ExifParserService {
/**
* 是否启用逆地理编码
* 可通过配置文件设置
*/
@Value("${exif.geocoding.enabled:false}")
private boolean geocodingEnabled;
/**
* 逆地理编码 API Key
*/
@Value("${exif.geocoding.api-key:}")
private String geocodingApiKey;
@Override
public ExifInfoVo parseExif(MultipartFile file) {
ExifInfoVo exifInfo = new ExifInfoVo();
exifInfo.setHasExif(false);
try (InputStream inputStream = file.getInputStream()) {
Metadata metadata = ImageMetadataReader.readMetadata(inputStream);
extractMetadata(metadata, exifInfo);
exifInfo.setHasExif(true);
} catch (ImageProcessingException e) {
log.warn("图片 EXIF 解析失败: {}", e.getMessage());
} catch (IOException e) {
log.error("读取图片文件失败: {}", e.getMessage());
}
return exifInfo;
}
@Override
public ExifInfoVo parseExifByPath(String filePath) {
ExifInfoVo exifInfo = new ExifInfoVo();
exifInfo.setHasExif(false);
try {
File file = new File(filePath);
Metadata metadata = ImageMetadataReader.readMetadata(file);
extractMetadata(metadata, exifInfo);
exifInfo.setHasExif(true);
} catch (ImageProcessingException e) {
log.warn("图片 EXIF 解析失败: {}", e.getMessage());
} catch (IOException e) {
log.error("读取图片文件失败: {}", e.getMessage());
}
return exifInfo;
}
/**
* 从 Metadata 对象中提取 EXIF 信息
*
* 提取顺序:
* 1. ExifSubIFDDirectory - 包含拍摄时间、相机设置等
* 2. ExifIFD0Directory - 包含相机型号、方向等
* 3. GpsDirectory - 包含 GPS 坐标
* 4. FileMetadataDirectory - 包含文件基本信息
*/
private void extractMetadata(Metadata metadata, ExifInfoVo exifInfo) {
// 提取 ExifSubIFDDirectory 信息
ExifSubIFDDirectory subIfdDir = metadata.getFirstDirectoryOfType(ExifSubIFDDirectory.class);
if (subIfdDir != null) {
// 原始拍摄时间
Date dateOriginal = subIfdDir.getDate(ExifSubIFDDirectory.TAG_DATETIME_ORIGINAL);
if (dateOriginal != null) {
exifInfo.setDateTimeOriginal(convertToLocalDateTime(dateOriginal));
}
// ISO 感光度
exifInfo.setIso(subIfdDir.getInteger(ExifSubIFDDirectory.TAG_ISO_EQUIVALENT));
// 光圈值
exifInfo.setAperture(subIfdDir.getDoubleObject(ExifSubIFDDirectory.TAG_APERTURE));
// 曝光时间
String exposureTime = subIfdDir.getString(ExifSubIFDDirectory.TAG_EXPOSURE_TIME);
exifInfo.setExposureTime(exposureTime);
// 焦距
exifInfo.setFocalLength(subIfdDir.getDoubleObject(ExifSubIFDDirectory.TAG_FOCAL_LENGTH));
}
// 提取 ExifIFD0Directory 信息
ExifIFD0Directory ifd0Dir = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
if (ifd0Dir != null) {
// 相机厂商
exifInfo.setMake(ifd0Dir.getString(ExifIFD0Directory.TAG_MAKE));
// 相机型号
exifInfo.setModel(ifd0Dir.getString(ExifIFD0Directory.TAG_MODEL));
// 图片方向
exifInfo.setOrientation(ifd0Dir.getInteger(ExifIFD0Directory.TAG_ORIENTATION));
// 图片尺寸
exifInfo.setWidth(ifd0Dir.getInteger(ExifIFD0Directory.TAG_IMAGE_WIDTH));
exifInfo.setHeight(ifd0Dir.getInteger(ExifIFD0Directory.TAG_IMAGE_HEIGHT));
}
// 提取 GPS 信息
GpsDirectory gpsDir = metadata.getFirstDirectoryOfType(GpsDirectory.class);
if (gpsDir != null) {
GeoLocation geoLocation = gpsDir.getGeoLocation();
if (geoLocation != null) {
exifInfo.setLatitude(geoLocation.getLatitude());
exifInfo.setLongitude(geoLocation.getLongitude());
// 如果启用了逆地理编码,获取地址
if (geocodingEnabled && exifInfo.getLatitude() != null && exifInfo.getLongitude() != null) {
String address = getAddressFromGps(exifInfo.getLatitude(), exifInfo.getLongitude());
exifInfo.setAddress(address);
}
}
}
// 如果没有原始拍摄时间,使用文件修改时间
if (exifInfo.getDateTimeOriginal() == null) {
// 尝试从其他目录获取时间信息
for (var directory : metadata.getDirectories()) {
if (directory != null) {
Date date = directory.getDate(0x0132); // DateTime tag
if (date != null) {
exifInfo.setDateTime(convertToLocalDateTime(date));
break;
}
}
}
}
}
@Override
public String getAddressFromGps(double latitude, double longitude) {
// TODO: 实现逆地理编码
// 可以集成高德地图、百度地图或 Google Maps API
// 示例:调用第三方 API 获取地址
log.info("逆地理编码: lat={}, lng={}", latitude, longitude);
// 暂时返回空,实际项目中应调用地图 API
return null;
}
@Override
public double convertGpsToDecimal(double[] dms, String ref) {
if (dms == null || dms.length < 3) {
return 0;
}
double degrees = dms[0];
double minutes = dms[1];
double seconds = dms[2];
double decimal = degrees + (minutes / 60.0) + (seconds / 3600.0);
// 南纬和西经为负数
if ("S".equalsIgnoreCase(ref) || "W".equalsIgnoreCase(ref)) {
decimal = -decimal;
}
return decimal;
}
/**
* 将 Date 转换为 LocalDateTime
*/
private LocalDateTime convertToLocalDateTime(Date date) {
if (date == null) {
return null;
}
return LocalDateTime.ofInstant(date.toInstant(), ZoneId.systemDefault());
}
}

View File

@@ -0,0 +1,284 @@
package com.timeline.file.service.impl;
import com.timeline.file.service.ExifParserService;
import com.timeline.file.service.SmartFillService;
import com.timeline.file.vo.ExifInfoVo;
import com.timeline.file.vo.SmartFillResultVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* SmartFillServiceImpl - 智能填充服务实现
*
* 功能描述:
* 实现智能时间推断和填充功能。
*
* 技术方案:
* 1. 优先级EXIF > 文件名 > 文件修改时间
* 2. 时间格式识别:支持多种常见格式
* 3. GPS 地址转换:调用逆地理编码服务
*
* @author Timeline Team
* @date 2024
*/
@Slf4j
@Service
public class SmartFillServiceImpl implements SmartFillService {
@Autowired
private ExifParserService exifParserService;
/**
* 文件名时间格式正则表达式列表
* 按优先级排序
*/
private static final List<Pattern> TIME_PATTERNS = List.of(
// IMG_20240115_123456.jpg
Pattern.compile("(\\d{4})(\\d{2})(\\d{2})[_-](\\d{2})(\\d{2})(\\d{2})"),
// 2024-01-15_12-30-00.jpg
Pattern.compile("(\\d{4})[-_](\\d{2})[-_](\\d{2})[_-](\\d{2})[-_](\\d{2})[-_](\\d{2})"),
// Screenshot_20240115-123000.png
Pattern.compile("Screenshot[_-]?(\\d{4})(\\d{2})(\\d{2})[_-](\\d{2})(\\d{2})(\\d{2})"),
// 20240115123000.jpg
Pattern.compile("(\\d{4})(\\d{2})(\\d{2})(\\d{2})(\\d{2})(\\d{2})"),
// 20240115.jpg (仅日期)
Pattern.compile("(\\d{4})(\\d{2})(\\d{2})")
);
@Override
public SmartFillResultVo analyzeFile(MultipartFile file) {
SmartFillResultVo result = new SmartFillResultVo();
result.setFileName(file.getOriginalFilename());
result.setSuccess(false);
try {
// 1. 解析 EXIF 信息
ExifInfoVo exifInfo = exifParserService.parseExif(file);
result.setExifInfo(exifInfo);
// 2. 推断时间
inferTimeForResult(result, exifInfo, file.getOriginalFilename());
// 3. 推断地点
inferLocationForResult(result, exifInfo);
// 4. 生成建议标题
if (result.getInferredTime() != null || result.getInferredLocation() != null) {
result.setSuggestedTitle(generateTitle(
result.getInferredTime() != null ? result.getInferredTime().toString() : null,
result.getInferredLocation()
));
}
result.setSuccess(true);
} catch (Exception e) {
log.error("智能分析文件失败: {}", file.getOriginalFilename(), e);
result.setErrorMessage("分析失败: " + e.getMessage());
}
return result;
}
@Override
public List<SmartFillResultVo> analyzeFiles(List<MultipartFile> files) {
List<SmartFillResultVo> results = new ArrayList<>();
for (MultipartFile file : files) {
results.add(analyzeFile(file));
}
return results;
}
@Override
public String inferTimeFromFileName(String fileName) {
if (fileName == null || fileName.isEmpty()) {
return null;
}
for (Pattern pattern : TIME_PATTERNS) {
Matcher matcher = pattern.matcher(fileName);
if (matcher.find()) {
try {
int year = Integer.parseInt(matcher.group(1));
int month = Integer.parseInt(matcher.group(2));
int day = Integer.parseInt(matcher.group(3));
// 检查日期有效性
if (month < 1 || month > 12 || day < 1 || day > 31) {
continue;
}
// 如果有时间信息
if (matcher.groupCount() >= 6) {
int hour = Integer.parseInt(matcher.group(4));
int minute = Integer.parseInt(matcher.group(5));
int second = Integer.parseInt(matcher.group(6));
if (hour < 0 || hour > 23 || minute < 0 || minute > 59 || second < 0 || second > 59) {
continue;
}
return String.format("%04d-%02d-%02d %02d:%02d:%02d", year, month, day, hour, minute, second);
} else {
// 仅日期
return String.format("%04d-%02d-%02d 00:00:00", year, month, day);
}
} catch (NumberFormatException e) {
continue;
}
}
}
return null;
}
@Override
public String inferTime(ExifInfoVo exifInfo, String fileName, String fileModifiedTime, List<String> neighborTimes) {
// 优先级1: EXIF 原始拍摄时间
if (exifInfo != null && exifInfo.getDateTimeOriginal() != null) {
return exifInfo.getDateTimeOriginal().toString();
}
// 优先级2: 文件名推断
String fileNameTime = inferTimeFromFileName(fileName);
if (fileNameTime != null) {
return fileNameTime;
}
// 优先级3: 文件修改时间
if (fileModifiedTime != null) {
return fileModifiedTime;
}
// 优先级4: 相邻文件时间(取中位数)
if (neighborTimes != null && !neighborTimes.isEmpty()) {
return neighborTimes.get(neighborTimes.size() / 2);
}
return null;
}
@Override
public List<String> recommendTags(MultipartFile file, List<String> existingTags) {
List<String> recommendedTags = new ArrayList<>();
// 基于文件名推荐
String fileName = file.getOriginalFilename();
if (fileName != null) {
String lowerName = fileName.toLowerCase();
if (lowerName.contains("screenshot")) {
recommendedTags.add("截图");
}
if (lowerName.contains("photo") || lowerName.contains("img")) {
recommendedTags.add("照片");
}
if (lowerName.contains("video")) {
recommendedTags.add("视频");
}
}
// TODO: 集成 AI 服务进行内容识别
// 过滤已存在的标签
if (existingTags != null) {
recommendedTags.removeAll(existingTags);
}
return recommendedTags;
}
@Override
public String generateTitle(String time, String location) {
StringBuilder title = new StringBuilder();
if (time != null) {
try {
LocalDateTime dateTime = LocalDateTime.parse(time.replace(" ", "T"));
title.append(dateTime.format(DateTimeFormatter.ofPattern("yyyy年M月d日")));
} catch (Exception e) {
// 忽略解析错误
}
}
if (location != null && !location.isEmpty()) {
if (title.length() > 0) {
title.append(" · ");
}
title.append(location);
}
return title.length() > 0 ? title.toString() : "未命名时刻";
}
@Override
public List<SmartFillResultVo> batchSmartFill(List<MultipartFile> files, boolean autoApply) {
List<SmartFillResultVo> results = analyzeFiles(files);
if (autoApply) {
// TODO: 自动应用填充结果
}
return results;
}
/**
* 为结果对象推断时间
*/
private void inferTimeForResult(SmartFillResultVo result, ExifInfoVo exifInfo, String fileName) {
// 优先级1: EXIF 原始拍摄时间
if (exifInfo != null && exifInfo.getDateTimeOriginal() != null) {
result.setInferredTime(exifInfo.getDateTimeOriginal());
result.setTimeSource("EXIF");
result.setTimeConfidence(95);
return;
}
// 优先级2: EXIF 修改时间
if (exifInfo != null && exifInfo.getDateTime() != null) {
result.setInferredTime(exifInfo.getDateTime());
result.setTimeSource("EXIF_MODIFIED");
result.setTimeConfidence(70);
return;
}
// 优先级3: 文件名推断
String fileNameTime = inferTimeFromFileName(fileName);
if (fileNameTime != null) {
result.setInferredTime(LocalDateTime.parse(fileNameTime.replace(" ", "T")));
result.setTimeSource("FILE_NAME");
result.setTimeConfidence(60);
return;
}
// 无法推断
result.setTimeConfidence(0);
}
/**
* 为结果对象推断地点
*/
private void inferLocationForResult(SmartFillResultVo result, ExifInfoVo exifInfo) {
if (exifInfo != null && exifInfo.getLatitude() != null && exifInfo.getLongitude() != null) {
result.setLatitude(exifInfo.getLatitude());
result.setLongitude(exifInfo.getLongitude());
result.setLocationSource("EXIF_GPS");
result.setLocationConfidence(90);
// 如果已有地址信息
if (exifInfo.getAddress() != null) {
result.setInferredLocation(exifInfo.getAddress());
}
}
}
}

View File

@@ -0,0 +1,116 @@
package com.timeline.file.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* ExifInfoVo - EXIF 信息值对象
*
* 功能描述:
* 封装从图片文件中提取的 EXIF 元数据信息。
* 用于前端展示和自动填充时间线节点字段。
*
* 字段说明:
* - dateTimeOriginal: 原始拍摄时间,优先级最高
* - dateTime: 文件修改时间,作为备选
* - latitude/longitude: GPS 坐标
* - address: 根据坐标解析的地址
* - make/model: 相机信息
* - orientation: 图片方向(用于自动旋转)
*
* @author Timeline Team
* @date 2024
*/
@Data
public class ExifInfoVo {
/**
* 原始拍摄时间
* 来自 EXIF 标签 DateTimeOriginal (0x9003)
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime dateTimeOriginal;
/**
* 文件修改时间
* 当没有 EXIF 时间时作为备选
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime dateTime;
/**
* GPS 纬度
* 十进制格式,正数表示北纬,负数表示南纬
*/
private Double latitude;
/**
* GPS 经度
* 十进制格式,正数表示东经,负数表示西经
*/
private Double longitude;
/**
* 地址信息
* 根据经纬度逆地理编码获得
*/
private String address;
/**
* 相机厂商
* 来自 EXIF 标签 Make (0x010F)
*/
private String make;
/**
* 相机型号
* 来自 EXIF 标签 Model (0x0110)
*/
private String model;
/**
* 图片方向
* 1: 正常
* 3: 旋转180度
* 6: 顺时针旋转90度
* 8: 逆时针旋转90度
*/
private Integer orientation;
/**
* 图片宽度(像素)
*/
private Integer width;
/**
* 图片高度(像素)
*/
private Integer height;
/**
* 是否包含有效的 EXIF 数据
*/
private Boolean hasExif;
/**
* ISO 感光度
*/
private Integer iso;
/**
* 光圈值
*/
private Double aperture;
/**
* 曝光时间
*/
private String exposureTime;
/**
* 焦距
*/
private Double focalLength;
}

View File

@@ -2,8 +2,10 @@ package com.timeline.file.vo;
import com.timeline.common.vo.CommonVo;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = false)
public class ImageInfoVo extends CommonVo {
private String objectKey;
private String imageName;

View File

@@ -0,0 +1,92 @@
package com.timeline.file.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* SmartFillResultVo - 智能填充结果值对象
*
* 功能描述:
* 封装智能分析的结果,包含推断的时间和地点信息。
* 用于前端展示智能填充建议。
*
* @author Timeline Team
* @date 2024
*/
@Data
public class SmartFillResultVo {
/**
* 文件名
*/
private String fileName;
/**
* 推断的时间
*/
private LocalDateTime inferredTime;
/**
* 时间来源
* EXIF/FILE_NAME/FILE_MODIFIED/NEIGHBOR
*/
private String timeSource;
/**
* 时间推断置信度 (0-100)
*/
private Integer timeConfidence;
/**
* 推断的地点
*/
private String inferredLocation;
/**
* 地点来源
* EXIF_GPS/MANUAL
*/
private String locationSource;
/**
* 地点推断置信度 (0-100)
*/
private Integer locationConfidence;
/**
* GPS 纬度
*/
private Double latitude;
/**
* GPS 经度
*/
private Double longitude;
/**
* 推荐的标签
*/
private List<String> recommendedTags;
/**
* 建议的标题
*/
private String suggestedTitle;
/**
* 原始 EXIF 信息
*/
private ExifInfoVo exifInfo;
/**
* 是否成功分析
*/
private Boolean success;
/**
* 错误信息
*/
private String errorMessage;
}

View File

@@ -1,4 +1,4 @@
FROM eclipse-temurin:21-jdk
FROM eclipse-temurin:21.0.2_13-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
EXPOSE 30000

View File

@@ -1,4 +1,4 @@
FROM eclipse-temurin:21-jdk
FROM eclipse-temurin:21.0.2_13-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
EXPOSE 30001

View File

@@ -0,0 +1,195 @@
package com.timeline.story.controller;
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 jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* AnalyticsController - 数据分析控制器
*
* 功能描述:
* 提供时间线数据分析的 REST API 接口。
*
* API 列表:
* - GET /story/analytics/overview: 获取总体统计
* - GET /story/analytics/story/{storyId}: 获取故事统计
* - GET /story/analytics/monthly-trend: 获取月度趋势
* - GET /story/analytics/top-locations: 获取热门地点
* - GET /story/analytics/top-tags: 获取热门标签
* - GET /story/analytics/yearly-report: 获取年度报告
* - GET /story/analytics/activity: 获取活跃度统计
* - GET /story/analytics/export: 导出统计数据
*
* @author Timeline Team
* @date 2024
*/
@Slf4j
@RestController
@RequestMapping("/story/analytics")
public class AnalyticsController {
@Autowired
private AnalyticsService analyticsService;
/**
* 获取用户时间线总体统计
*
* @return 统计数据
*/
@GetMapping("/overview")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo> getOverview() {
String userId = UserContextUtils.getCurrentUserId();
log.info("获取用户总体统计: userId={}", userId);
TimelineAnalyticsVo stats = analyticsService.getOverallStats(userId);
return com.timeline.common.response.ResponseEntity.success(stats);
}
/**
* 获取故事统计数据
*
* @param storyInstanceId 故事ID
* @return 统计数据
*/
@GetMapping("/story/{storyInstanceId}")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo> getStoryStats(
@PathVariable String storyInstanceId) {
log.info("获取故事统计: storyId={}", storyInstanceId);
TimelineAnalyticsVo stats = analyticsService.getStoryStats(storyInstanceId);
return com.timeline.common.response.ResponseEntity.success(stats);
}
/**
* 获取月度趋势数据
*
* @param year 年份(可选,默认当前年)
* @return 月度趋势
*/
@GetMapping("/monthly-trend")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo> getMonthlyTrend(
@RequestParam(required = false) Integer year) {
String userId = UserContextUtils.getCurrentUserId();
if (year == null) {
year = java.time.Year.now().getValue();
}
log.info("获取月度趋势: userId={}, year={}", userId, year);
TimelineAnalyticsVo stats = analyticsService.getMonthlyTrend(userId, year);
return com.timeline.common.response.ResponseEntity.success(stats);
}
/**
* 获取热门地点
*
* @param limit 返回数量默认10
* @return 热门地点列表
*/
@GetMapping("/top-locations")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo> getTopLocations(
@RequestParam(defaultValue = "10") int limit) {
String userId = UserContextUtils.getCurrentUserId();
log.info("获取热门地点: userId={}, limit={}", userId, limit);
TimelineAnalyticsVo stats = analyticsService.getTopLocations(userId, limit);
return com.timeline.common.response.ResponseEntity.success(stats);
}
/**
* 获取热门标签
*
* @param limit 返回数量默认10
* @return 热门标签列表
*/
@GetMapping("/top-tags")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo> getTopTags(
@RequestParam(defaultValue = "10") int limit) {
String userId = UserContextUtils.getCurrentUserId();
log.info("获取热门标签: userId={}, limit={}", userId, limit);
TimelineAnalyticsVo stats = analyticsService.getTopTags(userId, limit);
return com.timeline.common.response.ResponseEntity.success(stats);
}
/**
* 获取年度报告
*
* @param year 年份(可选,默认去年)
* @return 年度报告
*/
@GetMapping("/yearly-report")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo.YearlyReport> getYearlyReport(
@RequestParam(required = false) Integer year) {
String userId = UserContextUtils.getCurrentUserId();
if (year == null) {
year = java.time.Year.now().getValue() - 1;
}
log.info("获取年度报告: userId={}, year={}", userId, year);
TimelineAnalyticsVo.YearlyReport report = analyticsService.generateYearlyReport(userId, year);
return com.timeline.common.response.ResponseEntity.success(report);
}
/**
* 获取活跃度统计
*
* @return 活跃度数据
*/
@GetMapping("/activity")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo> getActivity() {
String userId = UserContextUtils.getCurrentUserId();
log.info("获取活跃度统计: userId={}", userId);
TimelineAnalyticsVo stats = analyticsService.getActivityStats(userId);
return com.timeline.common.response.ResponseEntity.success(stats);
}
/**
* 获取时间分布
*
* @return 时间分布数据
*/
@GetMapping("/time-distribution")
public com.timeline.common.response.ResponseEntity<TimelineAnalyticsVo> getTimeDistribution() {
String userId = UserContextUtils.getCurrentUserId();
log.info("获取时间分布: userId={}", userId);
TimelineAnalyticsVo stats = analyticsService.getTimeDistribution(userId);
return com.timeline.common.response.ResponseEntity.success(stats);
}
/**
* 导出统计数据
*
* @param format 导出格式 (json/csv)
* @param response HTTP 响应
*/
@GetMapping("/export")
public void exportStats(
@RequestParam(defaultValue = "json") String format,
HttpServletResponse response) {
String userId = UserContextUtils.getCurrentUserId();
log.info("导出统计数据: userId={}, format={}", userId, format);
try {
byte[] data = analyticsService.exportStats(userId, format);
String fileName = "timeline_stats_" + java.time.LocalDate.now() + "." + format;
response.setContentType(format.equals("csv") ? "text/csv" : "application/json");
response.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
response.getOutputStream().write(data);
response.getOutputStream().flush();
} catch (Exception e) {
log.error("导出统计数据失败", e);
}
}
}

View File

@@ -0,0 +1,207 @@
package com.timeline.story.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.story.entity.StoryComment;
import com.timeline.story.service.CommentService;
import com.timeline.story.vo.CommentVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* CommentController - 评论控制器
*
* 功能描述:
* 提供评论相关的 REST API 接口。
*
* API 列表:
* - POST /story/comment: 发表评论
* - POST /story/comment/{parentId}/reply: 回复评论
* - DELETE /story/comment/{commentId}: 删除评论
* - GET /story/comment/{commentId}: 获取评论详情
* - GET /story/comment/list/{storyItemId}: 获取节点的评论列表
* - GET /story/comment/{commentId}/replies: 获取评论的回复列表
* - POST /story/comment/{commentId}/like: 点赞评论
* - DELETE /story/comment/{commentId}/like: 取消点赞
*
* @author Timeline Team
* @date 2024
*/
@Slf4j
@RestController
@RequestMapping("/story/comment")
public class CommentController {
@Autowired
private CommentService commentService;
/**
* 发表评论
*
* @param commentVo 评论内容
* @return 创建的评论
*/
@PostMapping
public ResponseEntity<CommentVo> createComment(@RequestBody CommentVo commentVo) {
String userId = UserContextUtils.getCurrentUserId();
log.info("用户 {} 发表评论: storyItemId={}", userId, commentVo.getStoryItemId());
StoryComment comment = commentService.createComment(commentVo);
return ResponseEntity.success(convertToVo(comment));
}
/**
* 回复评论
*
* @param parentId 父评论ID
* @param commentVo 回复内容
* @return 创建的回复
*/
@PostMapping("/{parentId}/reply")
public ResponseEntity<CommentVo> replyComment(
@PathVariable String parentId,
@RequestBody CommentVo commentVo) {
String userId = UserContextUtils.getCurrentUserId();
log.info("用户 {} 回复评论: parentId={}", userId, parentId);
StoryComment comment = commentService.replyComment(parentId, commentVo);
return ResponseEntity.success(convertToVo(comment));
}
/**
* 删除评论
*
* @param commentInstanceId 评论ID
* @return 操作结果
*/
@DeleteMapping("/{commentInstanceId}")
public ResponseEntity<String> deleteComment(@PathVariable String commentInstanceId) {
String userId = UserContextUtils.getCurrentUserId();
log.info("用户 {} 删除评论: {}", userId, commentInstanceId);
commentService.deleteComment(commentInstanceId);
return ResponseEntity.success("评论已删除");
}
/**
* 获取评论详情
*
* @param commentInstanceId 评论ID
* @return 评论信息
*/
@GetMapping("/{commentInstanceId}")
public ResponseEntity<CommentVo> getComment(@PathVariable String commentInstanceId) {
StoryComment comment = commentService.getCommentById(commentInstanceId);
return ResponseEntity.success(convertToVo(comment));
}
/**
* 获取节点的评论列表
*
* @param storyItemId 节点ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 评论列表
*/
@GetMapping("/list/{storyItemId}")
public ResponseEntity<List<CommentVo>> getComments(
@PathVariable String storyItemId,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
log.info("获取节点评论列表: storyItemId={}", storyItemId);
List<CommentVo> comments = commentService.getCommentsByStoryItem(storyItemId, pageNum, pageSize);
return ResponseEntity.success(comments);
}
/**
* 获取评论的回复列表
*
* @param parentId 父评论ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 回复列表
*/
@GetMapping("/{parentId}/replies")
public ResponseEntity<List<CommentVo>> getReplies(
@PathVariable String parentId,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "5") int pageSize) {
log.info("获取评论回复列表: parentId={}", parentId);
List<CommentVo> replies = commentService.getRepliesByComment(parentId, pageNum, pageSize);
return ResponseEntity.success(replies);
}
/**
* 点赞评论
*
* @param commentInstanceId 评论ID
* @return 操作结果
*/
@PostMapping("/{commentInstanceId}/like")
public ResponseEntity<String> likeComment(@PathVariable String commentInstanceId) {
String userId = UserContextUtils.getCurrentUserId();
log.info("用户 {} 点赞评论: {}", userId, commentInstanceId);
commentService.likeComment(commentInstanceId, userId);
return ResponseEntity.success("点赞成功");
}
/**
* 取消点赞
*
* @param commentInstanceId 评论ID
* @return 操作结果
*/
@DeleteMapping("/{commentInstanceId}/like")
public ResponseEntity<String> unlikeComment(@PathVariable String commentInstanceId) {
String userId = UserContextUtils.getCurrentUserId();
log.info("用户 {} 取消点赞评论: {}", userId, commentInstanceId);
commentService.unlikeComment(commentInstanceId, userId);
return ResponseEntity.success("取消点赞成功");
}
/**
* 获取用户的评论列表
*
* @param pageNum 页码
* @param pageSize 每页数量
* @return 评论列表
*/
@GetMapping("/my")
public ResponseEntity<List<CommentVo>> getMyComments(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
String userId = UserContextUtils.getCurrentUserId();
List<CommentVo> comments = commentService.getCommentsByUser(userId, pageNum, pageSize);
return ResponseEntity.success(comments);
}
/**
* 转换为 VO
*/
private CommentVo convertToVo(StoryComment comment) {
if (comment == null) return null;
CommentVo vo = new CommentVo();
vo.setInstanceId(comment.getInstanceId());
vo.setStoryItemId(comment.getStoryItemId());
vo.setUserId(comment.getUserId());
vo.setParentId(comment.getParentId());
vo.setReplyToUserId(comment.getReplyToUserId());
vo.setContent(comment.getContent());
vo.setLikeCount(comment.getLikeCount());
vo.setCreateTime(comment.getCreateTime());
vo.setUpdateTime(comment.getUpdateTime());
return vo;
}
}

View File

@@ -0,0 +1,171 @@
package com.timeline.story.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.story.entity.Notification;
import com.timeline.story.service.NotificationService;
import com.timeline.story.vo.NotificationVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* NotificationController - 消息通知控制器
*
* 功能描述:
* 提供消息通知相关的 REST API 接口。
*
* API 列表:
* - GET /story/notification/list: 获取通知列表
* - GET /story/notification/unread: 获取未读通知列表
* - GET /story/notification/unread-count: 获取未读数量
* - PUT /story/notification/{notificationId}/read: 标记已读
* - PUT /story/notification/read-all: 全部标记已读
* - DELETE /story/notification/{notificationId}: 删除通知
* - DELETE /story/notification/clear: 清空所有通知
*
* @author Timeline Team
* @date 2024
*/
@Slf4j
@RestController
@RequestMapping("/story/notification")
public class NotificationController {
@Autowired
private NotificationService notificationService;
/**
* 获取通知列表
*
* @param type 消息类型(可选)
* @param pageNum 页码
* @param pageSize 每页数量
* @return 通知列表
*/
@GetMapping("/list")
public ResponseEntity<List<NotificationVo>> getNotifications(
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
String userId = UserContextUtils.getCurrentUserId();
log.info("获取用户通知列表: userId={}, type={}", userId, type);
List<NotificationVo> notifications = notificationService.getNotifications(userId, type, pageNum, pageSize);
return ResponseEntity.success(notifications);
}
/**
* 获取未读通知列表
*
* @param pageNum 页码
* @param pageSize 每页数量
* @return 未读通知列表
*/
@GetMapping("/unread")
public ResponseEntity<List<NotificationVo>> getUnreadNotifications(
@RequestParam(defaultValue = "1") int pageNum,
@RequestParam(defaultValue = "10") int pageSize) {
String userId = UserContextUtils.getCurrentUserId();
log.info("获取用户未读通知: userId={}", userId);
List<NotificationVo> notifications = notificationService.getUnreadNotifications(userId, pageNum, pageSize);
return ResponseEntity.success(notifications);
}
/**
* 获取未读通知数量
*
* @return 未读数量
*/
@GetMapping("/unread-count")
public ResponseEntity<Map<String, Object>> getUnreadCount() {
String userId = UserContextUtils.getCurrentUserId();
int totalCount = notificationService.getUnreadCount(userId);
Map<String, Integer> countByType = notificationService.getUnreadCountByType(userId);
return ResponseEntity.success(Map.of(
"total", totalCount,
"byType", countByType
));
}
/**
* 标记单条通知为已读
*
* @param notificationInstanceId 通知ID
* @return 操作结果
*/
@PutMapping("/{notificationInstanceId}/read")
public ResponseEntity<String> markAsRead(@PathVariable String notificationInstanceId) {
String userId = UserContextUtils.getCurrentUserId();
log.info("标记通知已读: userId={}, notificationId={}", userId, notificationInstanceId);
notificationService.markAsRead(notificationInstanceId, userId);
return ResponseEntity.success("已标记为已读");
}
/**
* 标记所有通知为已读
*
* @return 操作结果
*/
@PutMapping("/read-all")
public ResponseEntity<String> markAllAsRead() {
String userId = UserContextUtils.getCurrentUserId();
log.info("标记所有通知已读: userId={}", userId);
notificationService.markAllAsRead(userId);
return ResponseEntity.success("已全部标记为已读");
}
/**
* 按类型标记所有通知为已读
*
* @param type 消息类型
* @return 操作结果
*/
@PutMapping("/read-all/{type}")
public ResponseEntity<String> markAllAsReadByType(@PathVariable String type) {
String userId = UserContextUtils.getCurrentUserId();
log.info("按类型标记通知已读: userId={}, type={}", userId, type);
notificationService.markAllAsReadByType(userId, type);
return ResponseEntity.success("已标记为已读");
}
/**
* 删除通知
*
* @param notificationInstanceId 通知ID
* @return 操作结果
*/
@DeleteMapping("/{notificationInstanceId}")
public ResponseEntity<String> deleteNotification(@PathVariable String notificationInstanceId) {
String userId = UserContextUtils.getCurrentUserId();
log.info("删除通知: userId={}, notificationId={}", userId, notificationInstanceId);
notificationService.deleteNotification(notificationInstanceId, userId);
return ResponseEntity.success("通知已删除");
}
/**
* 清空所有通知
*
* @return 操作结果
*/
@DeleteMapping("/clear")
public ResponseEntity<String> clearAllNotifications() {
String userId = UserContextUtils.getCurrentUserId();
log.info("清空所有通知: userId={}", userId);
notificationService.clearAllNotifications(userId);
return ResponseEntity.success("通知已清空");
}
}

View File

@@ -0,0 +1,207 @@
package com.timeline.story.controller;
import com.timeline.common.response.ResponseEntity;
import com.timeline.common.utils.UserContextUtils;
import com.timeline.story.entity.Reminder;
import com.timeline.story.service.ReminderService;
import com.timeline.story.vo.ReminderVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* ReminderController - 提醒控制器
*
* 功能描述:
* 提供提醒管理的 REST API 接口。
*
* API 列表:
* - POST /story/reminder: 创建提醒
* - PUT /story/reminder/{reminderId}: 更新提醒
* - DELETE /story/reminder/{reminderId}: 删除提醒
* - GET /story/reminder/list: 获取提醒列表
* - PUT /story/reminder/{reminderId}/toggle: 启用/禁用提醒
* - POST /story/reminder/memory: 生成回忆提醒
*
* @author Timeline Team
* @date 2024
*/
@Slf4j
@RestController
@RequestMapping("/story/reminder")
public class ReminderController {
@Autowired
private ReminderService reminderService;
/**
* 创建提醒
*
* @param reminderVo 提醒信息
* @return 创建的提醒
*/
@PostMapping
public ResponseEntity<ReminderVo> createReminder(@RequestBody ReminderVo reminderVo) {
String userId = UserContextUtils.getCurrentUserId();
log.info("创建提醒: userId={}, type={}", userId, reminderVo.getType());
Reminder reminder = reminderService.createReminder(reminderVo);
return ResponseEntity.success(convertToVo(reminder));
}
/**
* 更新提醒
*
* @param reminderInstanceId 提醒ID
* @param reminderVo 提醒信息
* @return 更新后的提醒
*/
@PutMapping("/{reminderInstanceId}")
public ResponseEntity<ReminderVo> updateReminder(
@PathVariable String reminderInstanceId,
@RequestBody ReminderVo reminderVo) {
log.info("更新提醒: reminderId={}", reminderInstanceId);
Reminder reminder = reminderService.updateReminder(reminderInstanceId, reminderVo);
return ResponseEntity.success(convertToVo(reminder));
}
/**
* 删除提醒
*
* @param reminderInstanceId 提醒ID
* @return 操作结果
*/
@DeleteMapping("/{reminderInstanceId}")
public ResponseEntity<String> deleteReminder(@PathVariable String reminderInstanceId) {
log.info("删除提醒: reminderId={}", reminderInstanceId);
reminderService.deleteReminder(reminderInstanceId);
return ResponseEntity.success("提醒已删除");
}
/**
* 获取提醒详情
*
* @param reminderInstanceId 提醒ID
* @return 提醒信息
*/
@GetMapping("/{reminderInstanceId}")
public ResponseEntity<ReminderVo> getReminder(@PathVariable String reminderInstanceId) {
Reminder reminder = reminderService.getReminderById(reminderInstanceId);
return ResponseEntity.success(convertToVo(reminder));
}
/**
* 获取用户的提醒列表
*
* @param type 提醒类型(可选)
* @return 提醒列表
*/
@GetMapping("/list")
public ResponseEntity<List<ReminderVo>> getReminders(
@RequestParam(required = false) String type) {
String userId = UserContextUtils.getCurrentUserId();
log.info("获取提醒列表: userId={}, type={}", userId, type);
List<Reminder> reminders = reminderService.getRemindersByUser(userId, type);
List<ReminderVo> voList = reminders.stream()
.map(this::convertToVo)
.toList();
return ResponseEntity.success(voList);
}
/**
* 启用/禁用提醒
*
* @param reminderInstanceId 提醒ID
* @param enabled 是否启用
* @return 操作结果
*/
@PutMapping("/{reminderInstanceId}/toggle")
public ResponseEntity<String> toggleReminder(
@PathVariable String reminderInstanceId,
@RequestParam boolean enabled) {
log.info("切换提醒状态: reminderId={}, enabled={}", reminderInstanceId, enabled);
reminderService.toggleReminder(reminderInstanceId, enabled);
return ResponseEntity.success(enabled ? "提醒已启用" : "提醒已禁用");
}
/**
* 生成回忆提醒
*
* 功能描述:
* 检查用户是否有"去年的今天"的记录,
* 如果有则生成回忆提醒。
*
* @return 操作结果
*/
@PostMapping("/memory")
public ResponseEntity<String> generateMemoryReminders() {
String userId = UserContextUtils.getCurrentUserId();
log.info("生成回忆提醒: userId={}", userId);
reminderService.generateMemoryReminders(userId);
return ResponseEntity.success("回忆提醒已生成");
}
/**
* 转换为 VO
*/
private ReminderVo convertToVo(Reminder reminder) {
if (reminder == null) return null;
ReminderVo vo = new ReminderVo();
vo.setInstanceId(reminder.getInstanceId());
vo.setType(reminder.getType());
vo.setTitle(reminder.getTitle());
vo.setContent(reminder.getContent());
vo.setStoryInstanceId(reminder.getStoryInstanceId());
vo.setStoryItemId(reminder.getStoryItemId());
vo.setRemindTime(reminder.getRemindTime());
vo.setRepeatType(reminder.getRepeatType());
vo.setIsSent(reminder.getIsSent() == 1);
vo.setSentTime(reminder.getSentTime());
vo.setIsEnabled(reminder.getIsEnabled() == 1);
vo.setCreateTime(reminder.getCreateTime());
// 设置类型描述
vo.setTypeDesc(getTypeDesc(reminder.getType()));
vo.setRepeatTypeDesc(getRepeatTypeDesc(reminder.getRepeatType()));
return vo;
}
/**
* 获取类型描述
*/
private String getTypeDesc(String type) {
return switch (type) {
case "RECORD" -> "记录提醒";
case "MEMORY" -> "回忆提醒";
case "ANNIVERSARY" -> "纪念日提醒";
case "CUSTOM" -> "自定义提醒";
default -> "未知类型";
};
}
/**
* 获取重复类型描述
*/
private String getRepeatTypeDesc(String repeatType) {
return switch (repeatType) {
case "NONE" -> "不重复";
case "DAILY" -> "每天";
case "WEEKLY" -> "每周";
case "MONTHLY" -> "每月";
case "YEARLY" -> "每年";
default -> "未知";
};
}
}

View File

@@ -2,6 +2,7 @@ package com.timeline.story.controller;
import com.alibaba.fastjson.JSONObject;
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;
@@ -98,4 +99,74 @@ public class StoryItemController {
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("排序数据不能为空");
}
log.info("批量更新 StoryItem 排序,共 {} 项", items.size());
storyItemService.updateItemsOrder(items);
return ResponseEntity.success("排序更新成功");
}
/**
* 批量删除时间线节点
*
* 功能描述:
* 根据节点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, "删除列表不能为空");
}
log.info("批量删除 StoryItem共 {} 项", instanceIds.size());
storyItemService.batchDeleteItems(instanceIds);
return ResponseEntity.success("批量删除成功");
}
/**
* 批量修改时间线节点时间
*
* 功能描述:
* 批量修改多个节点的 storyItemTime 字段。
* 用于批量调整节点的时间信息。
*
* @param request 包含 instanceIds 数组和 storyItemTime 字符串
* @return 操作结果
*/
@PutMapping("/batch-time")
public ResponseEntity<String> batchUpdateItemTime(@RequestBody Map<String, Object> request) {
@SuppressWarnings("unchecked")
List<String> instanceIds = (List<String>) request.get("instanceIds");
String storyItemTime = (String) request.get("storyItemTime");
if (instanceIds == null || instanceIds.isEmpty()) {
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "修改列表不能为空");
}
if (storyItemTime == null || storyItemTime.isEmpty()) {
return ResponseEntity.error(ResponseEnum.BAD_REQUEST, "时间不能为空");
}
log.info("批量修改 StoryItem 时间,共 {} 项,新时间: {}", instanceIds.size(), storyItemTime);
storyItemService.batchUpdateItemTime(instanceIds, storyItemTime);
return ResponseEntity.success("批量修改时间成功");
}
}

View File

@@ -12,7 +12,6 @@ import java.util.List;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@RestController
@RequestMapping("/story/permission")
@Slf4j
@@ -34,6 +33,7 @@ public class StoryPermissionController {
storyPermissionService.updatePermission(permissionVo);
return ResponseEntity.success("权限更新成功");
}
@PostMapping("/authorize")
public ResponseEntity<String> authorizePermission(@RequestBody StoryPermissionVo permissionVo) {
log.info("授权权限: {}", permissionVo);
@@ -76,7 +76,29 @@ public class StoryPermissionController {
@RequestParam Integer requiredPermissionType) {
log.info("检查用户权限: storyInstanceId={}, userId={}, requiredPermissionType={}",
storyInstanceId, userId, requiredPermissionType);
boolean hasPermission = storyPermissionService.checkUserPermission(storyInstanceId, userId, requiredPermissionType);
boolean hasPermission = storyPermissionService.checkUserPermission(storyInstanceId, userId,
requiredPermissionType);
return ResponseEntity.success(hasPermission);
}
@PostMapping("/invite")
public ResponseEntity<String> inviteUser(@RequestBody StoryPermissionVo permissionVo) {
log.info("邀请用户协作: {}", permissionVo);
storyPermissionService.inviteUser(permissionVo);
return ResponseEntity.success("邀请发送成功");
}
@PutMapping("/invite/{inviteId}/accept")
public ResponseEntity<String> acceptInvite(@PathVariable String inviteId) {
log.info("接受邀请: {}", inviteId);
storyPermissionService.acceptInvite(inviteId);
return ResponseEntity.success("邀请已接受");
}
@PutMapping("/invite/{inviteId}/reject")
public ResponseEntity<String> rejectInvite(@PathVariable String inviteId) {
log.info("拒绝邀请: {}", inviteId);
storyPermissionService.rejectInvite(inviteId);
return ResponseEntity.success("邀请已拒绝");
}
}

View File

@@ -33,4 +33,18 @@ public interface StoryItemMapper {
// StoryItemShareVo selectByShareIdWithAuthor(String shareId);
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

@@ -8,11 +8,20 @@ import java.util.List;
@Mapper
public interface StoryPermissionMapper {
void insert(StoryPermission permission);
void update(StoryPermission permission);
void updateInviteStatus(String permissionId, Integer inviteStatus);
void deleteByPermissionId(String permissionId);
StoryPermission selectByPermissionId(String permissionId);
List<StoryPermission> selectByStoryInstanceId(String storyInstanceId);
List<StoryPermission> selectByUserId(String userId);
StoryPermission selectByStoryAndUser(String storyInstanceId, String userId);
List<StoryPermission> selectByStoryAndPermissionType(String storyInstanceId, Integer permissionType);
}

View File

@@ -0,0 +1,101 @@
package com.timeline.story.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Notification - 消息通知实体类
*
* 功能描述:
* 定义系统消息通知的数据结构。
* 支持多种消息类型:评论、点赞、@提及、邀请、系统通知。
*
* 消息类型说明:
* - COMMENT: 评论通知
* - LIKE: 点赞通知
* - MENTION: @提及通知
* - INVITE: 协作邀请通知
* - SYSTEM: 系统通知
*
* @author Timeline Team
* @date 2024
*/
@Data
public class Notification {
/**
* 主键ID
*/
private Long id;
/**
* 消息唯一标识
*/
private String instanceId;
/**
* 接收用户ID
*/
private String userId;
/**
* 消息类型
* COMMENT/LIKE/MENTION/INVITE/SYSTEM
*/
private String type;
/**
* 消息标题
*/
private String title;
/**
* 消息内容
*/
private String content;
/**
* 关联ID评论ID/节点ID等
*/
private String relatedId;
/**
* 关联类型
* STORY_ITEM/COMMENT/STORY
*/
private String relatedType;
/**
* 发送者ID
*/
private String senderId;
/**
* 发送者名称
*/
private String senderName;
/**
* 发送者头像
*/
private String senderAvatar;
/**
* 是否已读
*/
private Integer isRead;
/**
* 阅读时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime readTime;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,106 @@
package com.timeline.story.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Reminder - 提醒实体类
*
* 功能描述:
* 定义用户提醒的数据结构,支持多种提醒类型。
*
* 提醒类型:
* - RECORD: 记录提醒(每日记录提醒)
* - MEMORY: 回忆提醒(去年的今天)
* - ANNIVERSARY: 纪念日提醒
* - CUSTOM: 自定义提醒
*
* @author Timeline Team
* @date 2024
*/
@Data
public class Reminder {
/**
* 主键ID
*/
private Long id;
/**
* 提醒唯一标识
*/
private String instanceId;
/**
* 用户ID
*/
private String userId;
/**
* 提醒类型
* RECORD/MEMORY/ANNIVERSARY/CUSTOM
*/
private String type;
/**
* 提醒标题
*/
private String title;
/**
* 提醒内容
*/
private String content;
/**
* 关联的故事ID可选
*/
private String storyInstanceId;
/**
* 关联的节点ID可选
*/
private String storyItemId;
/**
* 提醒时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime remindTime;
/**
* 重复类型
* NONE/DAILY/WEEKLY/MONTHLY/YEARLY
*/
private String repeatType;
/**
* 是否已发送
*/
private Integer isSent;
/**
* 发送时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime sentTime;
/**
* 是否启用
*/
private Integer isEnabled;
/**
* 创建时间
*/
@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,84 @@
package com.timeline.story.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* StoryComment - 评论实体类
*
* 功能描述:
* 定义时间线节点评论的数据结构。
* 支持多级评论(回复功能)。
*
* 字段说明:
* - instanceId: 评论唯一标识
* - storyItemId: 关联的时间线节点ID
* - userId: 评论用户ID
* - parentId: 父评论ID用于回复
* - content: 评论内容
* - likeCount: 点赞数
*
* @author Timeline Team
* @date 2024
*/
@Data
public class StoryComment {
/**
* 主键ID
*/
private Long id;
/**
* 评论唯一标识
*/
private String instanceId;
/**
* 时间线节点ID
*/
private String storyItemId;
/**
* 评论用户ID
*/
private String userId;
/**
* 父评论ID用于回复功能
*/
private String parentId;
/**
* 回复的用户ID
*/
private String replyToUserId;
/**
* 评论内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 创建时间
*/
@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

@@ -22,4 +22,9 @@ public class StoryItem {
private LocalDateTime createTime;
private LocalDateTime updateTime;
private Integer isDelete;
/**
* 排序值 - 用于拖拽排序
* 数值越小越靠前默认为0
*/
private Integer sortOrder;
}

View File

@@ -0,0 +1,44 @@
package com.timeline.story.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* StoryItemTag - 时间线节点标签关联实体
*
* 功能描述:
* 定义时间线节点与标签的多对多关联关系。
*
* @author Timeline Team
* @date 2024
*/
@Data
public class StoryItemTag {
/**
* 主键ID
*/
private Long id;
/**
* 时间线节点ID
*/
private String storyItemId;
/**
* 标签ID
*/
private String tagId;
/**
* 创建者ID
*/
private String createId;
/**
* 创建时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
}

View File

@@ -12,6 +12,7 @@ public class StoryPermission {
private String storyInstanceId;
private String userId;
private Integer permissionType; // 1-创建者2-仅查看3-可新增4-可管理
private Integer inviteStatus; // 0-待处理, 1-已接受, 2-已拒绝
private Integer isDeleted;
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;

View File

@@ -0,0 +1,66 @@
package com.timeline.story.entity;
import com.fasterxml.jackson.annotation.JsonFormat;
import lombok.Data;
import java.time.LocalDateTime;
/**
* Tag - 标签实体类
*
* 功能描述:
* 定义标签的数据结构,用于时间线节点的分类和检索。
*
* 字段说明:
* - instanceId: 标签唯一标识
* - name: 标签名称
* - color: 标签颜色(十六进制)
* - ownerId: 创建者ID
*
* @author Timeline Team
* @date 2024
*/
@Data
public class Tag {
/**
* 主键ID
*/
private Long id;
/**
* 标签唯一标识
*/
private String instanceId;
/**
* 标签名称
*/
private String name;
/**
* 标签颜色(十六进制,如 #1890ff
*/
private String color;
/**
* 创建者ID
*/
private String ownerId;
/**
* 创建时间
*/
@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,107 @@
package com.timeline.story.service;
import com.timeline.story.vo.TimelineAnalyticsVo;
/**
* AnalyticsService - 数据分析服务接口
*
* 功能描述:
* 提供时间线数据的统计和分析功能。
*
* 功能列表:
* - 总体统计
* - 时间分布分析
* - 地点分布分析
* - 标签分布分析
* - 年度报告生成
*
* @author Timeline Team
* @date 2024
*/
public interface AnalyticsService {
/**
* 获取用户时间线总体统计
*
* @param userId 用户ID
* @return 统计数据
*/
TimelineAnalyticsVo getOverallStats(String userId);
/**
* 获取故事统计数据
*
* @param storyInstanceId 故事ID
* @return 统计数据
*/
TimelineAnalyticsVo getStoryStats(String storyInstanceId);
/**
* 获取月度趋势数据
*
* @param userId 用户ID
* @param year 年份
* @return 月度趋势
*/
TimelineAnalyticsVo getMonthlyTrend(String userId, int year);
/**
* 获取热门地点
*
* @param userId 用户ID
* @param limit 返回数量
* @return 热门地点列表
*/
TimelineAnalyticsVo getTopLocations(String userId, int limit);
/**
* 获取热门标签
*
* @param userId 用户ID
* @param limit 返回数量
* @return 热门标签列表
*/
TimelineAnalyticsVo getTopTags(String userId, int limit);
/**
* 生成年度报告
*
* @param userId 用户ID
* @param year 年份
* @return 年度报告数据
*/
TimelineAnalyticsVo.YearlyReport generateYearlyReport(String userId, int year);
/**
* 获取活跃度统计
*
* @param userId 用户ID
* @return 活跃度数据
*/
TimelineAnalyticsVo getActivityStats(String userId);
/**
* 计算连续记录天数
*
* @param userId 用户ID
* @return 连续天数
*/
int calculateConsecutiveDays(String userId);
/**
* 获取记录时间分布
*
* @param userId 用户ID
* @return 时间分布数据
*/
TimelineAnalyticsVo getTimeDistribution(String userId);
/**
* 导出统计数据
*
* @param userId 用户ID
* @param format 导出格式 (json/csv)
* @return 导出数据
*/
byte[] exportStats(String userId, String format);
}

View File

@@ -0,0 +1,123 @@
package com.timeline.story.service;
import com.timeline.story.entity.StoryComment;
import com.timeline.story.vo.CommentVo;
import java.util.List;
/**
* CommentService - 评论服务接口
*
* 功能描述:
* 提供评论的 CRUD 操作和互动功能。
*
* 功能列表:
* - 发表评论
* - 回复评论
* - 删除评论
* - 点赞评论
* - 获取评论列表
* - 获取回复列表
*
* @author Timeline Team
* @date 2024
*/
public interface CommentService {
/**
* 发表评论
*
* @param commentVo 评论信息
* @return 创建的评论
*/
StoryComment createComment(CommentVo commentVo);
/**
* 回复评论
*
* @param parentId 父评论ID
* @param commentVo 回复内容
* @return 创建的回复
*/
StoryComment replyComment(String parentId, CommentVo commentVo);
/**
* 删除评论
* 软删除,保留数据
*
* @param commentInstanceId 评论ID
*/
void deleteComment(String commentInstanceId);
/**
* 获取评论详情
*
* @param commentInstanceId 评论ID
* @return 评论信息
*/
StoryComment getCommentById(String commentInstanceId);
/**
* 获取节点的评论列表
* 分页获取,按时间倒序
*
* @param storyItemId 节点ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 评论列表
*/
List<CommentVo> getCommentsByStoryItem(String storyItemId, int pageNum, int pageSize);
/**
* 获取评论的回复列表
*
* @param parentId 父评论ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 回复列表
*/
List<CommentVo> getRepliesByComment(String parentId, int pageNum, int pageSize);
/**
* 获取节点的评论数量
*
* @param storyItemId 节点ID
* @return 评论数量
*/
int getCommentCountByStoryItem(String storyItemId);
/**
* 点赞评论
*
* @param commentInstanceId 评论ID
* @param userId 用户ID
*/
void likeComment(String commentInstanceId, String userId);
/**
* 取消点赞评论
*
* @param commentInstanceId 评论ID
* @param userId 用户ID
*/
void unlikeComment(String commentInstanceId, String userId);
/**
* 检查用户是否已点赞
*
* @param commentInstanceId 评论ID
* @param userId 用户ID
* @return 是否已点赞
*/
boolean hasLiked(String commentInstanceId, String userId);
/**
* 获取用户的评论列表
*
* @param userId 用户ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 评论列表
*/
List<CommentVo> getCommentsByUser(String userId, int pageNum, int pageSize);
}

View File

@@ -0,0 +1,156 @@
package com.timeline.story.service;
import com.timeline.story.entity.Notification;
import com.timeline.story.vo.NotificationVo;
import java.util.List;
/**
* NotificationService - 消息通知服务接口
*
* 功能描述:
* 提供消息通知的管理和推送功能。
*
* 功能列表:
* - 创建通知
* - 获取用户通知列表
* - 标记已读
* - 批量标记已读
* - 删除通知
* - 获取未读数量
* - 实时推送通知
*
* @author Timeline Team
* @date 2024
*/
public interface NotificationService {
/**
* 创建评论通知
*
* @param storyItemId 节点ID
* @param commentId 评论ID
* @param senderId 评论者ID
*/
void createCommentNotification(String storyItemId, String commentId, String senderId);
/**
* 创建点赞通知
*
* @param storyItemId 节点ID
* @param senderId 点赞者ID
*/
void createLikeNotification(String storyItemId, String senderId);
/**
* 创建@提及通知
*
* @param toUserId 被提及的用户ID
* @param relatedType 关联类型
* @param relatedId 关联ID
* @param senderId 提及者ID
*/
void createMentionNotification(String toUserId, String relatedType, String relatedId, String senderId);
/**
* 创建协作邀请通知
*
* @param storyId 故事ID
* @param toUserId 被邀请者ID
* @param senderId 邀请者ID
*/
void createInviteNotification(String storyId, String toUserId, String senderId);
/**
* 创建系统通知
*
* @param userId 用户ID
* @param title 标题
* @param content 内容
*/
void createSystemNotification(String userId, String title, String content);
/**
* 获取用户的通知列表
*
* @param userId 用户ID
* @param type 消息类型(可选)
* @param pageNum 页码
* @param pageSize 每页数量
* @return 通知列表
*/
List<NotificationVo> getNotifications(String userId, String type, int pageNum, int pageSize);
/**
* 获取未读通知列表
*
* @param userId 用户ID
* @param pageNum 页码
* @param pageSize 每页数量
* @return 未读通知列表
*/
List<NotificationVo> getUnreadNotifications(String userId, int pageNum, int pageSize);
/**
* 获取未读通知数量
*
* @param userId 用户ID
* @return 未读数量
*/
int getUnreadCount(String userId);
/**
* 按类型获取未读数量
*
* @param userId 用户ID
* @return 各类型的未读数量
*/
java.util.Map<String, Integer> getUnreadCountByType(String userId);
/**
* 标记单条通知为已读
*
* @param notificationInstanceId 通知ID
* @param userId 用户ID
*/
void markAsRead(String notificationInstanceId, String userId);
/**
* 标记所有通知为已读
*
* @param userId 用户ID
*/
void markAllAsRead(String userId);
/**
* 按类型标记所有通知为已读
*
* @param userId 用户ID
* @param type 消息类型
*/
void markAllAsReadByType(String userId, String type);
/**
* 删除通知
*
* @param notificationInstanceId 通知ID
* @param userId 用户ID
*/
void deleteNotification(String notificationInstanceId, String userId);
/**
* 清空所有通知
*
* @param userId 用户ID
*/
void clearAllNotifications(String userId);
/**
* 推送实时通知
* 通过 WebSocket 推送给在线用户
*
* @param userId 用户ID
* @param notification 通知内容
*/
void pushNotification(String userId, Notification notification);
}

View File

@@ -0,0 +1,124 @@
package com.timeline.story.service;
import com.timeline.story.entity.Reminder;
import com.timeline.story.vo.ReminderVo;
import java.util.List;
/**
* ReminderService - 提醒服务接口
*
* 功能描述:
* 提供提醒的管理和推送功能。
*
* 功能列表:
* - 创建提醒
* - 更新提醒
* - 删除提醒
* - 获取提醒列表
* - 发送提醒
* - 生成回忆提醒
*
* @author Timeline Team
* @date 2024
*/
public interface ReminderService {
/**
* 创建提醒
*
* @param reminderVo 提醒信息
* @return 创建的提醒
*/
Reminder createReminder(ReminderVo reminderVo);
/**
* 更新提醒
*
* @param reminderInstanceId 提醒ID
* @param reminderVo 提醒信息
* @return 更新后的提醒
*/
Reminder updateReminder(String reminderInstanceId, ReminderVo reminderVo);
/**
* 删除提醒
*
* @param reminderInstanceId 提醒ID
*/
void deleteReminder(String reminderInstanceId);
/**
* 获取提醒详情
*
* @param reminderInstanceId 提醒ID
* @return 提醒信息
*/
Reminder getReminderById(String reminderInstanceId);
/**
* 获取用户的提醒列表
*
* @param userId 用户ID
* @param type 提醒类型(可选)
* @return 提醒列表
*/
List<Reminder> getRemindersByUser(String userId, String type);
/**
* 获取待发送的提醒列表
*
* @return 待发送的提醒列表
*/
List<Reminder> getPendingReminders();
/**
* 发送提醒
*
* @param reminder 提醒信息
*/
void sendReminder(Reminder reminder);
/**
* 批量发送提醒
*
* @param reminders 提醒列表
*/
void sendReminders(List<Reminder> reminders);
/**
* 生成回忆提醒
*
* 功能描述:
* 检查用户是否有"去年的今天"的记录,
* 如果有则生成回忆提醒。
*
* @param userId 用户ID
*/
void generateMemoryReminders(String userId);
/**
* 启用/禁用提醒
*
* @param reminderInstanceId 提醒ID
* @param enabled 是否启用
*/
void toggleReminder(String reminderInstanceId, boolean enabled);
/**
* 标记提醒为已发送
*
* @param reminderInstanceId 提醒ID
*/
void markAsSent(String reminderInstanceId);
/**
* 处理重复提醒
*
* 功能描述:
* 对于重复类型的提醒,发送后自动创建下一次提醒。
*
* @param reminder 原提醒
*/
void handleRepeatReminder(Reminder reminder);
}

View File

@@ -27,4 +27,37 @@ public interface StoryItemService {
// StoryItemShareVo getItemByShareId(String shareId);
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

@@ -7,12 +7,26 @@ import java.util.List;
public interface StoryPermissionService {
void createPermission(StoryPermissionVo permissionVo);
void updatePermission(StoryPermissionVo permissionVo);
void deletePermission(String permissionId);
StoryPermission getPermissionById(String permissionId);
List<StoryPermission> getPermissionsByStoryId(String storyInstanceId);
List<StoryPermission> getPermissionsByUserId(String userId);
StoryPermission getPermissionByStoryAndUser(String storyInstanceId, String userId);
boolean checkUserPermission(String storyInstanceId, String userId, Integer requiredPermissionType);
List<StoryPermission> getPermissionsByStoryAndType(String storyInstanceId, Integer permissionType);
void inviteUser(StoryPermissionVo permissionVo);
void acceptInvite(String permissionId);
void rejectInvite(String permissionId);
}

View File

@@ -0,0 +1,135 @@
package com.timeline.story.service;
import com.timeline.story.entity.Tag;
import com.timeline.story.vo.TagVo;
import java.util.List;
/**
* TagService - 标签服务接口
*
* 功能描述:
* 提供标签的 CRUD 操作和节点标签关联管理。
*
* 功能列表:
* - 创建标签
* - 更新标签
* - 删除标签
* - 获取用户标签列表
* - 为节点添加标签
* - 移除节点标签
* - 获取节点的标签列表
*
* @author Timeline Team
* @date 2024
*/
public interface TagService {
/**
* 创建标签
*
* @param tagVo 标签信息
* @return 创建的标签
*/
Tag createTag(TagVo tagVo);
/**
* 更新标签
*
* @param tagInstanceId 标签ID
* @param tagVo 标签信息
* @return 更新后的标签
*/
Tag updateTag(String tagInstanceId, TagVo tagVo);
/**
* 删除标签
* 同时删除所有关联关系
*
* @param tagInstanceId 标签ID
*/
void deleteTag(String tagInstanceId);
/**
* 获取标签详情
*
* @param tagInstanceId 标签ID
* @return 标签信息
*/
Tag getTagById(String tagInstanceId);
/**
* 获取用户的所有标签
*
* @param userId 用户ID
* @return 标签列表
*/
List<Tag> getTagsByUserId(String userId);
/**
* 搜索标签
* 根据名称模糊搜索
*
* @param keyword 关键词
* @param userId 用户ID
* @return 匹配的标签列表
*/
List<Tag> searchTags(String keyword, String userId);
/**
* 为节点添加标签
*
* @param storyItemId 节点ID
* @param tagInstanceId 标签ID
*/
void addTagToStoryItem(String storyItemId, String tagInstanceId);
/**
* 为节点批量添加标签
*
* @param storyItemId 节点ID
* @param tagInstanceIds 标签ID列表
*/
void addTagsToStoryItem(String storyItemId, List<String> tagInstanceIds);
/**
* 移除节点标签
*
* @param storyItemId 节点ID
* @param tagInstanceId 标签ID
*/
void removeTagFromStoryItem(String storyItemId, String tagInstanceId);
/**
* 移除节点的所有标签
*
* @param storyItemId 节点ID
*/
void removeAllTagsFromStoryItem(String storyItemId);
/**
* 获取节点的标签列表
*
* @param storyItemId 节点ID
* @return 标签列表
*/
List<Tag> getTagsByStoryItemId(String storyItemId);
/**
* 获取标签下的节点数量
*
* @param tagInstanceId 标签ID
* @return 节点数量
*/
int getStoryItemCountByTag(String tagInstanceId);
/**
* 获取热门标签
* 按使用次数排序
*
* @param userId 用户ID
* @param limit 返回数量
* @return 热门标签列表
*/
List<Tag> getHotTags(String userId, int limit);
}

View File

@@ -112,4 +112,100 @@ public class StoryItemServiceImpl implements StoryItemService {
result.put("total", pageInfo.getTotal());
return result;
}
/**
* 批量更新时间线节点排序
*
* 实现思路:
* 1. 使用事务确保批量更新的原子性
* 2. 遍历排序数据,逐个更新节点的 sortOrder 字段
* 3. 同时更新 updateTime 时间戳
*
* 注意事项:
* - 该方法需要在事务中执行,确保数据一致性
* - 如果任一更新失败,整个事务将回滚
*
* @param items 排序数据列表
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void updateItemsOrder(List<Map<String, Object>> items) {
String currentUserId = UserContextUtils.getCurrentUserId();
LocalDateTime now = LocalDateTime.now();
for (Map<String, Object> item : items) {
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);
params.put("updateId", currentUserId);
params.put("updateTime", now);
storyItemMapper.updateOrder(params);
log.debug("更新节点排序: instanceId={}, sortOrder={}", instanceId, sortOrder);
}
log.info("批量更新排序完成,共更新 {} 个节点", 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.info("批量删除完成,共删除 {} 个节点", 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) {
Map<String, Object> params = new HashMap<>();
params.put("instanceId", instanceId);
params.put("storyItemTime", parsedTime);
params.put("updateId", currentUserId);
params.put("updateTime", now);
storyItemMapper.updateItemTime(params);
log.debug("更新节点时间: instanceId={}, storyItemTime={}", instanceId, storyItemTime);
}
log.info("批量修改时间完成,共修改 {} 个节点", instanceIds.size());
}
}

View File

@@ -29,6 +29,7 @@ public class StoryPermissionServiceImpl implements StoryPermissionService {
private StoryPermissionMapper storyPermissionMapper;
@Autowired
private UserServiceClient userServiceClient;
private String getCurrentUserId() {
String uid = UserContextUtils.getCurrentUserId();
if (uid == null) {
@@ -45,10 +46,13 @@ public class StoryPermissionServiceImpl implements StoryPermissionService {
if (currentUserId.equals(permissionVo.getUserId()) && permissionVo.getPermissionType() != 1) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "不能授权给自己");
}
StoryPermission selectByStoryAndUser = storyPermissionMapper.selectByStoryAndUser(permissionVo.getStoryInstanceId(), permissionVo.getUserId());;
StoryPermission selectByStoryAndUser = storyPermissionMapper
.selectByStoryAndUser(permissionVo.getStoryInstanceId(), permissionVo.getUserId());
if (selectByStoryAndUser != null) {
log.info("用户已有该故事权限,更新当前权限为:{}", permissionVo.getPermissionType());
selectByStoryAndUser.setPermissionType(permissionVo.getPermissionType());;
selectByStoryAndUser.setPermissionType(permissionVo.getPermissionType());
selectByStoryAndUser.setUpdateTime(LocalDateTime.now());
storyPermissionMapper.update(selectByStoryAndUser);
return;
@@ -61,19 +65,20 @@ public class StoryPermissionServiceImpl implements StoryPermissionService {
} else if (response.getData() == null) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "用户不存在");
}
log.info("新建故事{} 授权 {} 给 {}", permissionVo.getStoryInstanceId(), permissionVo.getPermissionType(), permissionVo.getUserId());
log.info("新建故事{} 授权 {} 给 {}", permissionVo.getStoryInstanceId(), permissionVo.getPermissionType(),
permissionVo.getUserId());
StoryPermission permission = new StoryPermission();
BeanUtils.copyProperties(permissionVo, permission);
permission.setPermissionId(IdUtils.randomUuidUpper());
permission.setCreateTime(LocalDateTime.now());
permission.setUpdateTime(LocalDateTime.now());
permission.setIsDeleted(CommonConstants.NOT_DELETED);
permission.setInviteStatus(1); // Direct creation is accepted
storyPermissionMapper.insert(permission);
} catch(CustomException e) {
} catch (CustomException e) {
throw e;
}
catch (Exception e) {
} catch (Exception e) {
log.error("创建权限失败", e);
throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "创建权限失败");
}
@@ -142,6 +147,11 @@ public class StoryPermissionServiceImpl implements StoryPermissionService {
return false;
}
// Check invite status: Must be ACCEPTED (1)
if (permission.getInviteStatus() != null && permission.getInviteStatus() != 1) {
return false;
}
// 权限类型数字越小权限越高
return permission.getPermissionType() <= requiredPermissionType;
}
@@ -150,4 +160,87 @@ public class StoryPermissionServiceImpl implements StoryPermissionService {
public List<StoryPermission> getPermissionsByStoryAndType(String storyInstanceId, Integer permissionType) {
return storyPermissionMapper.selectByStoryAndPermissionType(storyInstanceId, permissionType);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void inviteUser(StoryPermissionVo permissionVo) {
try {
// Check if user exists (Feign)
ResponseEntity<Map> response = userServiceClient.getUserByUserId(permissionVo.getUserId());
if (response.getCode() != 200 || response.getData() == null) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "用户不存在");
}
StoryPermission existing = storyPermissionMapper.selectByStoryAndUser(permissionVo.getStoryInstanceId(),
permissionVo.getUserId());
if (existing != null) {
if (existing.getInviteStatus() != null && existing.getInviteStatus() == 1) {
throw new CustomException(ResponseEnum.BAD_REQUEST, "用户已经是协作者");
}
// Update existing to PENDING
existing.setPermissionType(permissionVo.getPermissionType());
existing.setInviteStatus(0);
storyPermissionMapper.update(existing);
storyPermissionMapper.updateInviteStatus(existing.getPermissionId(), 0);
} else {
StoryPermission permission = new StoryPermission();
BeanUtils.copyProperties(permissionVo, permission);
permission.setPermissionId(IdUtils.randomUuidUpper());
permission.setCreateTime(LocalDateTime.now());
permission.setUpdateTime(LocalDateTime.now());
permission.setIsDeleted(CommonConstants.NOT_DELETED);
permission.setInviteStatus(0); // PENDING
storyPermissionMapper.insert(permission);
}
// TODO: Send notification
} catch (CustomException e) {
throw e;
} catch (Exception e) {
log.error("邀请用户失败", e);
throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "邀请用户失败");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void acceptInvite(String permissionId) {
try {
StoryPermission permission = storyPermissionMapper.selectByPermissionId(permissionId);
if (permission == null) {
throw new CustomException(ResponseEnum.NOT_FOUND, "邀请不存在");
}
// Check if current user is the invitee
String currentUserId = getCurrentUserId();
if (!permission.getUserId().equals(currentUserId)) {
throw new CustomException(ResponseEnum.FORBIDDEN, "无权操作此邀请");
}
storyPermissionMapper.updateInviteStatus(permissionId, 1); // ACCEPTED
} catch (CustomException e) {
throw e;
} catch (Exception e) {
log.error("接受邀请失败", e);
throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "接受邀请失败");
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void rejectInvite(String permissionId) {
try {
StoryPermission permission = storyPermissionMapper.selectByPermissionId(permissionId);
if (permission == null) {
throw new CustomException(ResponseEnum.NOT_FOUND, "邀请不存在");
}
String currentUserId = getCurrentUserId();
if (!permission.getUserId().equals(currentUserId)) {
throw new CustomException(ResponseEnum.FORBIDDEN, "无权操作此邀请");
}
storyPermissionMapper.updateInviteStatus(permissionId, 2); // REJECTED
} catch (CustomException e) {
throw e;
} catch (Exception e) {
log.error("拒绝邀请失败", e);
throw new CustomException(ResponseEnum.INTERNAL_SERVER_ERROR, "拒绝邀请失败");
}
}
}

View File

@@ -0,0 +1,95 @@
package com.timeline.story.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* CommentVo - 评论值对象
*
* 功能描述:
* 封装评论信息,包含用户信息和回复列表。
* 用于前端展示评论树结构。
*
* @author Timeline Team
* @date 2024
*/
@Data
public class CommentVo {
/**
* 评论ID
*/
private String instanceId;
/**
* 时间线节点ID
*/
private String storyItemId;
/**
* 评论用户ID
*/
private String userId;
/**
* 评论用户名
*/
private String userName;
/**
* 评论用户头像
*/
private String userAvatar;
/**
* 父评论ID
*/
private String parentId;
/**
* 回复的用户ID
*/
private String replyToUserId;
/**
* 回复的用户名
*/
private String replyToUserName;
/**
* 评论内容
*/
private String content;
/**
* 点赞数
*/
private Integer likeCount;
/**
* 当前用户是否已点赞
*/
private Boolean hasLiked;
/**
* 回复数量
*/
private Integer replyCount;
/**
* 回复列表(子评论)
*/
private List<CommentVo> replies;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

@@ -0,0 +1,88 @@
package com.timeline.story.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* NotificationVo - 消息通知值对象
*
* 功能描述:
* 封装消息通知信息,用于前端展示。
*
* @author Timeline Team
* @date 2024
*/
@Data
public class NotificationVo {
/**
* 通知ID
*/
private String instanceId;
/**
* 消息类型
*/
private String type;
/**
* 消息类型描述
*/
private String typeDesc;
/**
* 消息标题
*/
private String title;
/**
* 消息内容
*/
private String content;
/**
* 关联ID
*/
private String relatedId;
/**
* 关联类型
*/
private String relatedType;
/**
* 发送者ID
*/
private String senderId;
/**
* 发送者名称
*/
private String senderName;
/**
* 发送者头像
*/
private String senderAvatar;
/**
* 是否已读
*/
private Boolean isRead;
/**
* 阅读时间
*/
private LocalDateTime readTime;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 相对时间描述刚刚、5分钟前
*/
private String timeAgo;
}

View File

@@ -0,0 +1,95 @@
package com.timeline.story.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* ReminderVo - 提醒值对象
*
* 功能描述:
* 封装提醒信息,用于前端展示和创建。
*
* @author Timeline Team
* @date 2024
*/
@Data
public class ReminderVo {
/**
* 提醒ID
*/
private String instanceId;
/**
* 提醒类型
* RECORD/MEMORY/ANNIVERSARY/CUSTOM
*/
private String type;
/**
* 提醒类型描述
*/
private String typeDesc;
/**
* 提醒标题
*/
private String title;
/**
* 提醒内容
*/
private String content;
/**
* 关联的故事ID
*/
private String storyInstanceId;
/**
* 关联的故事标题
*/
private String storyTitle;
/**
* 关联的节点ID
*/
private String storyItemId;
/**
* 提醒时间
*/
private LocalDateTime remindTime;
/**
* 重复类型
* NONE/DAILY/WEEKLY/MONTHLY/YEARLY
*/
private String repeatType;
/**
* 重复类型描述
*/
private String repeatTypeDesc;
/**
* 是否已发送
*/
private Boolean isSent;
/**
* 发送时间
*/
private LocalDateTime sentTime;
/**
* 是否启用
*/
private Boolean isEnabled;
/**
* 创建时间
*/
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,120 @@
package com.timeline.story.vo;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* SearchResultVo - 搜索结果值对象
*
* 功能描述:
* 封装搜索结果数据,包含分页信息和高亮内容。
*
* @author Timeline Team
* @date 2024
*/
@Data
public class SearchResultVo {
/**
* 搜索结果列表
*/
private List<SearchItemVo> list;
/**
* 总数量
*/
private long total;
/**
* 当前页码
*/
private int pageNum;
/**
* 每页数量
*/
private int pageSize;
/**
* 搜索耗时(毫秒)
*/
private long took;
/**
* 是否有更多结果
*/
private boolean hasMore;
/**
* 搜索项值对象
*/
@Data
public static class SearchItemVo {
/**
* 节点ID
*/
private String instanceId;
/**
* 所属故事ID
*/
private String storyInstanceId;
/**
* 故事标题
*/
private String storyTitle;
/**
* 节点标题
*/
private String title;
/**
* 高亮标题(包含 <em> 标签)
*/
private String highlightTitle;
/**
* 节点描述
*/
private String description;
/**
* 高亮描述
*/
private String highlightDescription;
/**
* 时间
*/
private LocalDateTime storyItemTime;
/**
* 地点
*/
private String location;
/**
* 标签列表
*/
private List<String> tags;
/**
* 创建者名称
*/
private String createName;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 相关性得分
*/
private float score;
}
}

View File

@@ -10,4 +10,5 @@ public class StoryPermissionVo {
private String storyInstanceId;
private String userId;
private Integer permissionType; // 1-创建者2-仅查看3-可新增4-可管理
private Integer inviteStatus; // 0-待处理, 1-已接受, 2-已拒绝
}

View File

@@ -0,0 +1,53 @@
package com.timeline.story.vo;
import lombok.Data;
import java.time.LocalDateTime;
/**
* TagVo - 标签值对象
*
* 功能描述:
* 封装标签信息,用于前端展示和创建。
*
* @author Timeline Team
* @date 2024
*/
@Data
public class TagVo {
/**
* 标签ID
*/
private String instanceId;
/**
* 标签名称
*/
private String name;
/**
* 标签颜色
*/
private String color;
/**
* 创建者ID
*/
private String ownerId;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
/**
* 使用次数
*/
private Integer usageCount;
}

View File

@@ -0,0 +1,168 @@
package com.timeline.story.vo;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* TimelineAnalyticsVo - 时间线数据分析值对象
*
* 功能描述:
* 封装用户时间线的统计数据,用于数据分析面板展示。
*
* 数据维度:
* - 总体统计:时刻数、媒体数、协作数
* - 时间分布:按年/月/周统计
* - 地点分布:热门地点
* - 标签分布:热门标签
* - 活跃度:记录频率
*
* @author Timeline Team
* @date 2024
*/
@Data
public class TimelineAnalyticsVo {
/**
* 总时刻数
*/
private Long totalMoments;
/**
* 总媒体文件数
*/
private Long totalMedia;
/**
* 图片数量
*/
private Long imageCount;
/**
* 视频数量
*/
private Long videoCount;
/**
* 总故事数
*/
private Long totalStories;
/**
* 协作故事数
*/
private Long collaborationCount;
/**
* 总评论数
*/
private Long totalComments;
/**
* 总点赞数
*/
private Long totalLikes;
/**
* 总收藏数
*/
private Long totalFavorites;
/**
* 月度记录趋势
* key: 月份 (yyyy-MM)
* value: 记录数量
*/
private List<MonthlyStats> monthlyTrend;
/**
* 周记录分布
* key: 星期 (1-7)
* value: 记录数量
*/
private Map<Integer, Long> weeklyDistribution;
/**
* 小时分布
* key: 小时 (0-23)
* value: 记录数量
*/
private Map<Integer, Long> hourlyDistribution;
/**
* 热门地点 Top 10
*/
private List<LocationStats> topLocations;
/**
* 热门标签 Top 10
*/
private List<TagStats> topTags;
/**
* 最近活跃日期
*/
private String lastActiveDate;
/**
* 连续记录天数
*/
private Integer consecutiveDays;
/**
* 最长连续记录天数
*/
private Integer maxConsecutiveDays;
/**
* 年度报告数据
*/
private YearlyReport yearlyReport;
/**
* 月度统计
*/
@Data
public static class MonthlyStats {
private String month;
private Long count;
private Long mediaCount;
}
/**
* 地点统计
*/
@Data
public static class LocationStats {
private String location;
private Long count;
private Double percentage;
}
/**
* 标签统计
*/
@Data
public static class TagStats {
private String tag;
private String color;
private Long count;
private Double percentage;
}
/**
* 年度报告
*/
@Data
public static class YearlyReport {
private Integer year;
private Long totalMoments;
private Long totalMedia;
private String mostActiveMonth;
private String mostActiveDay;
private String topLocation;
private String topTag;
private List<MonthlyStats> monthlyBreakdown;
}
}

View File

@@ -112,4 +112,46 @@
ORDER BY
si.story_item_time DESC
</select>
<!--
更新节点排序值
功能描述:
根据拖拽排序结果更新节点的 sort_order 字段。
同时更新 update_id 和 update_time 以记录修改信息。
参数说明:
- instanceId: 节点唯一标识
- sortOrder: 新的排序值(数值越小越靠前)
- updateId: 操作用户ID
- updateTime: 更新时间
-->
<update id="updateOrder">
UPDATE story_item
SET sort_order = #{sortOrder},
update_id = #{updateId},
update_time = #{updateTime}
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},
update_id = #{updateId},
update_time = #{updateTime}
WHERE instance_id = #{instanceId}
</update>
</mapper>

View File

@@ -5,8 +5,8 @@
<mapper namespace="com.timeline.story.dao.StoryPermissionMapper">
<insert id="insert">
INSERT INTO story_permission (permission_id, story_instance_id, user_id, permission_type)
VALUES (#{permissionId}, #{storyInstanceId}, #{userId}, #{permissionType})
INSERT INTO story_permission (permission_id, story_instance_id, user_id, permission_type, invite_status)
VALUES (#{permissionId}, #{storyInstanceId}, #{userId}, #{permissionType}, #{inviteStatus})
</insert>
<update id="update">
UPDATE story_permission
@@ -15,6 +15,13 @@
WHERE permission_id = #{permissionId} AND is_deleted = 0
</update>
<update id="updateInviteStatus">
UPDATE story_permission
SET invite_status = #{inviteStatus},
update_time = NOW()
WHERE permission_id = #{permissionId} AND is_deleted = 0
</update>
<update id="deleteByPermissionId">
UPDATE story_permission
SET is_deleted = 1

View File

@@ -1,4 +1,4 @@
FROM eclipse-temurin:21-jdk
FROM eclipse-temurin:21.0.2_13-jdk-alpine
VOLUME /tmp
COPY target/*.jar app.jar
EXPOSE 30003