水印优化+定时任务删除+照片管理

This commit is contained in:
2026-09-28 11:40:38 +08:00
parent 0129cb1290
commit dbb0178918
10 changed files with 613 additions and 0 deletions
@@ -1,5 +1,6 @@
package com.accounting.controller;
import com.accounting.dto.PhotoPageResponse;
import com.accounting.dto.PhotoResponse;
import com.accounting.entity.User;
import com.accounting.mapper.UserMapper;
@@ -11,6 +12,9 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -57,6 +61,25 @@ public class PhotoController {
return ResponseEntity.ok(response);
}
@Operation(summary = "分页查询照片,最新的在前")
@GetMapping
public ResponseEntity<PhotoPageResponse> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "30") int size,
Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(photoService.list(userId, page, size));
}
@Operation(summary = "删除照片(连同磁盘上的原图和缩略图)")
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id, Authentication authentication) {
Long userId = getUserId(authentication);
photoService.delete(userId, id);
return ResponseEntity.ok().build();
}
private Long getUserId(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String username = userDetails.getUsername();
@@ -0,0 +1,25 @@
package com.accounting.dto;
import lombok.Data;
import java.util.List;
/**
* 照片分页响应
*
* <p>字段和 {@code NotePageResponse} 对齐(list / total / page / size / hasMore),
* App 端两边的分页处理可以长得一样。</p>
*/
@Data
public class PhotoPageResponse {
private List<PhotoResponse> list;
private long total;
private int page;
private int size;
private boolean hasMore;
}
@@ -19,6 +19,14 @@ public class PhotoResponse {
/** 可直接访问的地址,如 /uploads/photo/2026/09/28/xxx.jpg */
private String url;
/**
* 缩略图地址,如 /uploads/photo/2026/09/28/xxx.thumb.jpg
*
* <p>列表页只加载它 —— 原图一张 3~8MB,一屏加载原图要上百 MB。
* 为 null 表示缩略图没生成成功,客户端可以回退用原图。</p>
*/
private String thumbUrl;
private Long fileSize;
private String originalName;
@@ -3,9 +3,22 @@ package com.accounting.schedule;
import com.accounting.service.GoldPriceService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import jakarta.annotation.PostConstruct;
/**
* 金价定时抓取。
*
* <p>⚠️ 2026-09-28 起**默认关闭**({@code gold-price.scheduler.enabled=false}):
* App 端没用这个功能,而这个第三方接口后来改过返回格式(多了 msgid 字段,
* 导致 K780Response 反序列化失败),于是每 15 分钟往日志里刷一次 ERROR,纯噪音。</p>
*
* <p>代码保留、接口 {@code /api/gold-price/**} 照常可用 —— 想恢复只需在服务器
* {@code .env} 里加 {@code GOLD_PRICE_SCHEDULER_ENABLED=true} 再重启,不用改代码。</p>
*/
@Slf4j
@Component
@RequiredArgsConstructor
@@ -15,8 +28,25 @@ public class GoldPriceScheduler {
private final GoldPriceService goldPriceService;
@Value("${gold-price.scheduler.enabled:false}")
private boolean enabled;
@PostConstruct
public void logStatus() {
if (enabled) {
log.info("金价定时抓取已启用(每 15 分钟)");
} else {
log.info("金价定时抓取已关闭(gold-price.scheduler.enabled=false)");
}
}
@Scheduled(cron = "0 */15 * * * ?", zone = "Asia/Shanghai")
public void fetchPeriodicPrice() {
// 开关关着就直接返回。不用 @ConditionalOnProperty 是因为那要作用在 Bean 上,
// 而这里只想停掉这个「任务」,不想让整个 SchedulerConfig 的线程池也变样。
if (!enabled) {
return;
}
runJob("periodic");
}
@@ -1,19 +1,26 @@
package com.accounting.service;
import com.accounting.dto.PhotoPageResponse;
import com.accounting.dto.PhotoResponse;
import com.accounting.entity.Photo;
import com.accounting.mapper.PhotoMapper;
import com.accounting.util.FileUtil;
import com.accounting.util.ImageThumbUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
/**
* 照片服务
@@ -40,11 +47,15 @@ public class PhotoService {
private static final int MAX_REMARK = 500;
private static final int MAX_ORIGINAL_NAME = 255;
/** 列表单页上限,防止客户端要一个巨大的 size */
private static final int MAX_PAGE_SIZE = 60;
private static final DateTimeFormatter TAKEN_TIME_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final PhotoMapper photoMapper;
private final FileUtil fileUtil;
private final ImageThumbUtil imageThumbUtil;
/**
* 保存一张照片。
@@ -88,11 +99,107 @@ public class PhotoService {
throw e;
}
// 顺手生成缩略图。失败也无所谓(原图已经存好了),列表页会再补一次
ensureThumbnail(relativePath);
log.info("照片已保存: userId={}, id={}, size={}B, path={}",
userId, photo.getId(), photo.getFileSize(), relativePath);
return toResponse(photo);
}
/**
* 分页列表,最新的在前。
*
* <p>和 {@code NoteService} 一样手写 LIMIT —— 项目里没配 MyBatis-Plus 的
* 分页插件,{@code selectPage} 不会真的生成 LIMIT。</p>
*
* <p>顺带补齐缩略图:这次改动之前上传的照片没有缩略图,借列表请求补上,
* 不用手工刷数据。只有第一次会慢一点,之后都是现成的文件。</p>
*/
public PhotoPageResponse list(Long userId, int page, int size) {
int safePage = Math.max(page, 1);
int safeSize = Math.min(Math.max(size, 1), MAX_PAGE_SIZE);
long total = photoMapper.selectCount(
new LambdaQueryWrapper<Photo>().eq(Photo::getUserId, userId));
List<Photo> rows;
if (total == 0) {
rows = new ArrayList<>();
} else {
int offset = (safePage - 1) * safeSize;
rows = photoMapper.selectList(
new LambdaQueryWrapper<Photo>()
.eq(Photo::getUserId, userId)
.orderByDesc(Photo::getId)
.last("LIMIT " + offset + "," + safeSize));
}
PhotoPageResponse response = new PhotoPageResponse();
response.setList(rows.stream().map(this::toResponse).collect(Collectors.toList()));
response.setTotal(total);
response.setPage(safePage);
response.setSize(safeSize);
response.setHasMore((long) safePage * safeSize < total);
return response;
}
/**
* 删除一张照片:先逻辑删记录,再把磁盘上的原图和缩略图都删掉。
*
* <p>磁盘删除失败**不回滚**事务 —— 记录已经从用户的列表里消失了,
* 留个删不掉的文件只是浪费几 MB,而抛异常会让用户以为「删除失败」、
* 反复重试。所以失败只记日志。</p>
*/
@Transactional
public void delete(Long userId, Long id) {
Photo photo = photoMapper.selectOne(
new LambdaQueryWrapper<Photo>()
.eq(Photo::getId, id)
.eq(Photo::getUserId, userId));
if (photo == null) {
throw new IllegalArgumentException("照片不存在");
}
photoMapper.deleteById(photo.getId());
String filePath = photo.getFilePath();
if (!fileUtil.deleteFile(filePath)) {
log.warn("照片记录已删,但文件删不掉(可能已不存在): {}", filePath);
}
String thumbPath = thumbRelativePath(filePath);
if (!fileUtil.deleteFile(thumbPath)) {
log.warn("缩略图删不掉(可能本来就没生成过): {}", thumbPath);
}
log.info("照片已删除: userId={}, id={}, path={}", userId, id, filePath);
}
/**
* 确保缩略图存在,返回它的相对路径;生成不出来返回 null。
*
* <p>叫「ensure」不叫「generate」是因为它是补齐语义:上传时调一次,
* 列表接口对老照片再调一次 —— 已经有了就直接返回,不重复干活。</p>
*/
private String ensureThumbnail(String relativePath) {
String thumbPath = thumbRelativePath(relativePath);
File thumbFile = new File(fileUtil.getFullPath(thumbPath));
if (thumbFile.exists() && thumbFile.length() > 0) {
return thumbPath;
}
File source = new File(fileUtil.getFullPath(relativePath));
if (!source.exists()) {
return null;
}
return imageThumbUtil.generate(source, thumbFile) ? thumbPath : null;
}
/** photo/2026/09/28/xxx.jpg → photo/2026/09/28/xxx.thumb.jpg */
private String thumbRelativePath(String filePath) {
int dot = filePath.lastIndexOf('.');
return dot < 0 ? filePath + ".thumb.jpg" : filePath.substring(0, dot) + ".thumb.jpg";
}
/**
* 解析客户端传来的拍摄时间。
*
@@ -124,6 +231,12 @@ public class PhotoService {
PhotoResponse response = new PhotoResponse();
response.setId(photo.getId());
response.setUrl("/uploads/" + photo.getFilePath());
// 列表页只加载缩略图:原图一张 3~8MB,一屏就能吃掉上百 MB。
// 这里做的是「没有就补」—— 老照片第一次列表时会被补上。
String thumb = ensureThumbnail(photo.getFilePath());
response.setThumbUrl(thumb == null ? null : "/uploads/" + thumb);
response.setFileSize(photo.getFileSize());
response.setOriginalName(photo.getOriginalName());
response.setLatitude(photo.getLatitude());
@@ -0,0 +1,105 @@
package com.accounting.util;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.imageio.IIOImage;
import javax.imageio.ImageIO;
import javax.imageio.ImageWriteParam;
import javax.imageio.ImageWriter;
import javax.imageio.stream.ImageOutputStream;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.Iterator;
/**
* 缩略图生成。
*
* <p>照片列表页不可能直接加载原图 —— 手机拍的 12MP 照片一张 3~8MB,
* 一屏 20 张就是上百 MB。所以上传时(以及翻到旧图时)顺手生成一张
* 最长边 {@value #MAX_WIDTH} 的小图,列表只加载它。</p>
*
* <p>用 JDK 自带的 Java2D({@code javax.imageio} + {@code Graphics2D}),
* 不引入任何新依赖 —— 和项目「能不加包就不加」的取向一致。</p>
*/
@Slf4j
@Component
public class ImageThumbUtil {
/** 缩略图最长边(像素)。400 在手机上显示 3 列网格足够了 */
private static final int MAX_WIDTH = 400;
/** JPEG 质量。缩略图不需要太精细 */
private static final float QUALITY = 0.82f;
/**
* 生成缩略图。
*
* <p>失败不抛异常,只返回 false —— 缩略图是「锦上添花」,生成不出来
* 不该把上传整体搞失败(原图已经存好了)。</p>
*
* @return 是否生成成功
*/
public boolean generate(File source, File target) {
try {
BufferedImage original = ImageIO.read(source);
if (original == null) {
log.warn("缩略图跳过:读不出图片内容 {}", source.getPath());
return false;
}
int srcW = original.getWidth();
int srcH = original.getHeight();
if (srcW <= 0 || srcH <= 0) {
return false;
}
int width = Math.min(srcW, MAX_WIDTH);
int height = Math.max(1, (int) Math.round(srcH * (width / (double) srcW)));
// 统一转成 RGB:JPEG 不支持透明通道,源图可能是 PNG
BufferedImage scaled = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
Graphics2D g = scaled.createGraphics();
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY);
g.drawImage(original, 0, 0, width, height, null);
g.dispose();
File parent = target.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
log.warn("缩略图目录创建失败: {}", parent.getPath());
return false;
}
writeJpeg(scaled, target);
return true;
} catch (Exception e) {
log.warn("缩略图生成失败: {} -> {}", source.getPath(), e.getMessage());
return false;
}
}
/** ImageIO 默认写出来的 JPEG 质量偏低,要显式设压缩参数 */
private void writeJpeg(BufferedImage image, File target) throws Exception {
Iterator<ImageWriter> writers = ImageIO.getImageWritersByFormatName("jpeg");
if (!writers.hasNext()) {
throw new IllegalStateException("没有可用的 JPEG 编码器");
}
ImageWriter writer = writers.next();
try {
ImageWriteParam param = writer.getDefaultWriteParam();
param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT);
param.setCompressionQuality(QUALITY);
try (ImageOutputStream out = ImageIO.createImageOutputStream(target)) {
writer.setOutput(out);
writer.write(null, new IIOImage(image, null, null), param);
}
} finally {
writer.dispose();
}
}
}