水印相机后端+建表语句

食物营养成分表数据
This commit is contained in:
2026-09-28 09:58:38 +08:00
parent 2338ff5885
commit 0129cb1290
16 changed files with 2498 additions and 215 deletions
@@ -0,0 +1,71 @@
package com.accounting.controller;
import com.accounting.dto.PhotoResponse;
import com.accounting.entity.User;
import com.accounting.mapper.UserMapper;
import com.accounting.service.PhotoService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.math.BigDecimal;
/**
* 照片存档接口(水印相机模块)
*
* <p>App 拍完照把**原图**传上来存档;水印在 App 本地合成,与这里无关。</p>
*
* <p>路径不在 SecurityConfig 的白名单里,所以自动走 {@code anyRequest().authenticated()}
* —— 需要 JWT,防止变成一个公开的图床。</p>
*/
@Tag(name = "照片", description = "水印相机模块的照片存档接口")
@RestController
@RequestMapping("/api/photos")
public class PhotoController {
@Autowired
private PhotoService photoService;
@Autowired
private UserMapper userMapper;
@Operation(summary = "上传照片原图")
@PostMapping
public ResponseEntity<PhotoResponse> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(required = false) BigDecimal latitude,
@RequestParam(required = false) BigDecimal longitude,
@RequestParam(required = false) String address,
@RequestParam(required = false) String weather,
@RequestParam(required = false) String remark,
@RequestParam(required = false) String takenTime,
@RequestParam(required = false) String originalName,
Authentication authentication) throws Exception {
Long userId = getUserId(authentication);
PhotoResponse response = photoService.create(
userId, file, latitude, longitude, address, weather, remark, takenTime, originalName);
return ResponseEntity.ok(response);
}
private Long getUserId(Authentication authentication) {
UserDetails userDetails = (UserDetails) authentication.getPrincipal();
String username = userDetails.getUsername();
User user = userMapper.selectOne(
new LambdaQueryWrapper<User>().eq(User::getUsername, username)
);
if (user == null) {
throw new IllegalArgumentException("用户不存在");
}
return user.getId();
}
}
@@ -0,0 +1,39 @@
package com.accounting.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 照片响应
*
* <p>字段给得比较全(不只是上传时用得上的),是为了以后加「我的照片」列表接口时
* 能直接复用这个 DTO,不用再建一个几乎一样的类。</p>
*/
@Data
public class PhotoResponse {
private Long id;
/** 可直接访问的地址,如 /uploads/photo/2026/09/28/xxx.jpg */
private String url;
private Long fileSize;
private String originalName;
private BigDecimal latitude;
private BigDecimal longitude;
private String address;
private String weather;
private String remark;
private LocalDateTime takenTime;
private LocalDateTime createTime;
}
@@ -0,0 +1,63 @@
package com.accounting.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableLogic;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 照片(水印相机模块)
*
* <p>存的是 App 上传的**原图**(未加水印)。水印由 App 本地合成后直接存手机相册,
* 服务器这份留作原始素材,以后想换水印样式还能重做。</p>
*
* <p>deleted 字段与 application.yml 中的
* {@code mybatis-plus.global-config.db-config.logic-delete-field=deleted} 对应,
* 查询会自动追加 {@code deleted = 0},删除会自动变成 UPDATE。</p>
*/
@Data
@TableName("photo")
public class Photo {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
/** 相对路径,如 photo/2026/09/28/xxx.jpg(不含 /uploads 前缀) */
private String filePath;
/** 文件大小(字节) */
private Long fileSize;
/** 原始文件名 */
private String originalName;
/** 纬度(WGS84),未授权定位时为空 */
private BigDecimal latitude;
/** 经度(WGS84),未授权定位时为空 */
private BigDecimal longitude;
/** 文字地址(逆地理编码结果) */
private String address;
/** 天气+温度,如「晴 25℃」 */
private String weather;
private String remark;
/** 拍摄时间(客户端传来的本地时间) */
private LocalDateTime takenTime;
@TableLogic
private Integer deleted;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,9 @@
package com.accounting.mapper;
import com.accounting.entity.Photo;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PhotoMapper extends BaseMapper<Photo> {
}
@@ -0,0 +1,138 @@
package com.accounting.service;
import com.accounting.dto.PhotoResponse;
import com.accounting.entity.Photo;
import com.accounting.mapper.PhotoMapper;
import com.accounting.util.FileUtil;
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.IOException;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* 照片服务
*
* <p>只负责「存原图 + 落一条记录」。水印是 App 本地合成的,服务器这份永远是原图。</p>
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class PhotoService {
/**
* 照片大小上限。
*
* <p>比笔记附件的 5MB 宽松得多 —— 手机原图 12MP 常见 3~8MB,
* 用 5MB 会直接把它们挡在门外。</p>
*/
private static final long MAX_PHOTO_SIZE = 20 * 1024 * 1024;
/** 独立子目录,和笔记附件(直接落 yyyy/MM/dd/)分开,方便备份/清理 */
private static final String PHOTO_SUB_DIR = "photo";
private static final int MAX_ADDRESS = 500;
private static final int MAX_REMARK = 500;
private static final int MAX_ORIGINAL_NAME = 255;
private static final DateTimeFormatter TAKEN_TIME_FORMAT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final PhotoMapper photoMapper;
private final FileUtil fileUtil;
/**
* 保存一张照片。
*
* <p>先落盘、再写库。写库失败时把刚存的文件删掉 —— 否则会留下一个
* 谁也引用不到的孤儿文件,时间长了把磁盘堆满。</p>
*/
@Transactional
public PhotoResponse create(Long userId,
MultipartFile file,
BigDecimal latitude,
BigDecimal longitude,
String address,
String weather,
String remark,
String takenTime,
String originalName) throws IOException {
String relativePath = fileUtil.saveFile(file, MAX_PHOTO_SIZE, PHOTO_SUB_DIR);
Photo photo = new Photo();
photo.setUserId(userId);
photo.setFilePath(relativePath);
photo.setFileSize(file.getSize());
photo.setOriginalName(trimTo(
originalName != null ? originalName : file.getOriginalFilename(), MAX_ORIGINAL_NAME));
photo.setLatitude(latitude);
photo.setLongitude(longitude);
photo.setAddress(trimTo(address, MAX_ADDRESS));
photo.setWeather(trimTo(weather, 50));
photo.setRemark(trimTo(remark, MAX_REMARK));
photo.setTakenTime(parseTakenTime(takenTime));
try {
photoMapper.insert(photo);
} catch (RuntimeException e) {
// 写库失败 → 把刚落的文件删掉,别留孤儿
if (!fileUtil.deleteFile(relativePath)) {
log.warn("照片写库失败后清理文件也失败,残留: {}", relativePath);
}
throw e;
}
log.info("照片已保存: userId={}, id={}, size={}B, path={}",
userId, photo.getId(), photo.getFileSize(), relativePath);
return toResponse(photo);
}
/**
* 解析客户端传来的拍摄时间。
*
* <p>宽容处理:格式不对就存 null,不让一个时间字段把整次上传搞失败 ——
* 照片本身已经存好了,比时间重要得多。</p>
*/
private LocalDateTime parseTakenTime(String takenTime) {
if (takenTime == null || takenTime.isBlank()) {
return null;
}
try {
return LocalDateTime.parse(takenTime.trim(), TAKEN_TIME_FORMAT);
} catch (Exception e) {
log.warn("拍摄时间格式无法解析,已忽略: {}", takenTime);
return null;
}
}
/** 按数据库列宽截断,避免超长直接把插入打回 */
private String trimTo(String value, int max) {
if (value == null) {
return "";
}
String trimmed = value.trim();
return trimmed.length() <= max ? trimmed : trimmed.substring(0, max);
}
private PhotoResponse toResponse(Photo photo) {
PhotoResponse response = new PhotoResponse();
response.setId(photo.getId());
response.setUrl("/uploads/" + photo.getFilePath());
response.setFileSize(photo.getFileSize());
response.setOriginalName(photo.getOriginalName());
response.setLatitude(photo.getLatitude());
response.setLongitude(photo.getLongitude());
response.setAddress(photo.getAddress());
response.setWeather(photo.getWeather());
response.setRemark(photo.getRemark());
response.setTakenTime(photo.getTakenTime());
response.setCreateTime(photo.getCreateTime());
return response;
}
}
+41 -36
View File
@@ -15,28 +15,43 @@ import java.util.UUID;
@Component
public class FileUtil {
@Value("${file.upload.path}")
private String uploadPath;
private static final String[] ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".bmp"};
private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
/**
* 保存上传的文件
* 保存上传的文件(笔记附件等通用场景)
*
* <p>上限 5MB,按 yyyy/MM/dd 分目录。行为与拆分前完全一致。</p>
*/
public String saveFile(MultipartFile file) throws IOException {
// 验证文件
validateFile(file);
return saveFile(file, MAX_FILE_SIZE, null);
}
/**
* 保存上传的文件(可指定大小上限与子目录)
*
* <p>拆出这个重载是为了「照片」这种大文件场景:手机原图动辄 3~8MB,用 5MB
* 会被直接挡掉;而且照片和笔记附件分开放,方便单独备份/清理。</p>
*
* @param maxSize 单文件大小上限(字节)
* @param subDir 子目录名;null 或空表示直接落在 yyyy/MM/dd 下
* @return 相对路径,如 {@code photo/2026/09/28/xxx.jpg}
*/
public String saveFile(MultipartFile file, long maxSize, String subDir) throws IOException {
validateFile(file, maxSize);
// 创建上传目录(按日期分类)
String dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String fullPath = uploadPath + dateDir;
Path path = Paths.get(fullPath);
if (!Files.exists(path)) {
Files.createDirectories(path);
String relativeDir = (subDir == null || subDir.isBlank()) ? dateDir : subDir + "/" + dateDir;
Path dir = Paths.get(uploadPath + relativeDir);
if (!Files.exists(dir)) {
Files.createDirectories(dir);
}
// 生成文件名
String originalFilename = file.getOriginalFilename();
String extension = "";
@@ -44,32 +59,31 @@ public class FileUtil {
extension = originalFilename.substring(originalFilename.lastIndexOf("."));
}
String filename = UUID.randomUUID().toString() + extension;
// 保存文件
Path filePath = path.resolve(filename);
file.transferTo(filePath.toFile());
file.transferTo(dir.resolve(filename).toFile());
// 返回相对路径(用于数据库存储)
return dateDir + "/" + filename;
return relativeDir + "/" + filename;
}
/**
* 验证文件
*/
private void validateFile(MultipartFile file) {
private void validateFile(MultipartFile file, long maxSize) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("文件不能为空");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new IllegalArgumentException("文件大小不能超过5MB");
if (file.getSize() > maxSize) {
throw new IllegalArgumentException("文件大小不能超过" + (maxSize / 1024 / 1024) + "MB");
}
String originalFilename = file.getOriginalFilename();
if (originalFilename == null) {
throw new IllegalArgumentException("文件名不能为空");
}
String extension = originalFilename.substring(originalFilename.lastIndexOf(".")).toLowerCase();
boolean allowed = false;
for (String allowedExt : ALLOWED_EXTENSIONS) {
@@ -78,19 +92,19 @@ public class FileUtil {
break;
}
}
if (!allowed) {
throw new IllegalArgumentException("不支持的文件格式,仅支持: " + String.join(", ", ALLOWED_EXTENSIONS));
}
}
/**
* 获取文件的完整路径
*/
public String getFullPath(String relativePath) {
return uploadPath + relativePath;
}
/**
* 删除文件
*/
@@ -103,12 +117,3 @@ public class FileUtil {
}
}
}