水印相机后端+建表语句

食物营养成分表数据
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 {
}
}
}
+4 -2
View File
@@ -8,8 +8,10 @@ spring:
password: mysql_jP65Fc
servlet:
multipart:
max-file-size: 5MB
max-request-size: 10MB
# 20MB:手机原图(12MP 常见 3~8MB)要能传上来,5MB 会直接拒。
# 各接口自己还有更细的上限校验(见 FileUtil):笔记附件 5MB、照片 20MB。
max-file-size: 20MB
max-request-size: 30MB
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: GMT+8
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
-- 食物营养成分表(2026-09-28)
--
-- 用途:把《中国食物成分表》的查询结果落库,供「饮食 / 营养」相关功能查询。
-- 数据来源:中国疾病预防控制中心营养与健康所 · 中国营养学会
-- 食物营养成分查询平台 https://nlc.chinanutri.cn/fq/
-- 列表接口:POST /fq/FoodInfoQueryAction!queryFoodInfoList.do
-- data: categoryOne=&categoryTwo=&foodName=&pageNum=&field=&flag=
-- 返回:{ currentPage, totalPages, list: [ [ ...36 个元素... ], ... ] }
--
-- list 每行是一个数组,下标含义(= 列表页表头序号 + 2):
-- [0] 食物ID [1] 图片文件名 [2] 食物名称
-- [3] 别名或俗名 [4] 英文名称
-- [5] 食部 % [6] 水分 g [7] 能量 kJ
-- [8] 蛋白质 g [9] 脂肪 g [10] 胆固醇 mg
-- [11] 灰分 g [12] 碳水化合物 g [13] 总膳食纤维 g
-- [14] 胡萝卜素 μg [15] 视黄醇 μg [16] α-TE(维生素E) mg
-- [17] 硫胺素 mg [18] 核黄素 mg [19] 烟酸 mg
-- [20] 维生素C mg [21] 钙 mg [22] 磷 mg
-- [23] 钾 mg [24] 钠 mg [25] 镁 mg
-- [26] 铁 mg [27] 锌 mg [28] 硒 μg
-- [29] 铜 mg [30] 锰 mg [31] 碘 μg
-- [32] 饱和脂肪酸 % [33] 单不饱和脂肪酸 % [34] 多不饱和脂肪酸 %
-- [35] 脂肪酸合计 %
-- (字段名取自列表页 th 的 class:ysq_code_shibu / shuifen / nliang ... ,
-- 其中 huifen=灰分、tshhw=碳水化合物、qianwei=总膳食纤维、afte=α-TE)
--
-- 单位:所有营养素均为「每 100g 可食部」的值,接口返回值自带单位后缀
-- (如 "12.7g" / "5.40μg" / "1478kJ" / "100%"),入库时需剥离。
-- 空值:接口用空串 "" 表示无数据、用 "—" 表示未检出/未测定,入库统一转 NULL。
--
-- 分类:站点为两级结构(categoryOne / categoryTwo),首页 20 个大类,
-- ID 与名称见 https://nlc.chinanutri.cn/fq/ 的「食物分类」,如
-- 1=谷类及制品、30=小麦;12=蔬菜类及制品、45=根菜类。
-- 故按子类分页抓取时,把循环里的两个分类 ID / 名称一并写入。
--
-- 执行方式(在 231 上):
-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \
-- -uroot -p密码 accounting_db < migration_add_food_nutrition.sql
CREATE TABLE IF NOT EXISTS `food_nutrition` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`food_id` INT NOT NULL COMMENT '来源食物ID(详情页 /fq/foodinfo/<food_id>.html)',
`name` VARCHAR(200) NOT NULL COMMENT '食物名称',
`alias` VARCHAR(200) NULL COMMENT '别名或俗名',
`english_name` VARCHAR(200) NULL COMMENT '英文名称',
`image_path` VARCHAR(300) NULL COMMENT '图片文件名(前缀 upload/images/small/)',
`category_id` INT NULL COMMENT '一级分类ID(接口 categoryOne),如 1=谷类及制品',
`category_name` VARCHAR(50) NULL COMMENT '一级分类名称',
`sub_category_id` INT NULL COMMENT '二级分类ID(接口 categoryTwo),如 30=小麦',
`sub_category_name` VARCHAR(50) NULL COMMENT '二级分类名称',
-- ==== 一般营养成分(每 100g 可食部)====
`edible_percent` DECIMAL(5,1) NULL COMMENT '食部 %',
`water` DECIMAL(6,2) NULL COMMENT '水分 g',
`energy_kj` DECIMAL(8,1) NULL COMMENT '能量 kJ',
`protein` DECIMAL(6,2) NULL COMMENT '蛋白质 g',
`fat` DECIMAL(6,2) NULL COMMENT '脂肪 g',
`cholesterol` DECIMAL(8,1) NULL COMMENT '胆固醇 mg',
`ash` DECIMAL(6,2) NULL COMMENT '灰分 g',
`carbohydrate` DECIMAL(6,2) NULL COMMENT '碳水化合物 g',
`dietary_fiber` DECIMAL(6,2) NULL COMMENT '总膳食纤维 g',
`carotene` DECIMAL(10,1) NULL COMMENT '胡萝卜素 μg',
`retinol` DECIMAL(9,1) NULL COMMENT '视黄醇 μg',
`vitamin_e` DECIMAL(7,2) NULL COMMENT 'α-生育酚当量(维生素E)mg',
`thiamin` DECIMAL(7,3) NULL COMMENT '硫胺素(维生素B1)mg',
`riboflavin` DECIMAL(7,3) NULL COMMENT '核黄素(维生素B2)mg',
`niacin` DECIMAL(7,2) NULL COMMENT '烟酸(尼克酸)mg',
`vitamin_c` DECIMAL(8,2) NULL COMMENT '维生素C mg',
`calcium` DECIMAL(8,1) NULL COMMENT '钙 mg',
`phosphorus` DECIMAL(8,1) NULL COMMENT '磷 mg',
`potassium` DECIMAL(8,1) NULL COMMENT '钾 mg',
`sodium` DECIMAL(9,1) NULL COMMENT '钠 mg(食盐可达 39311)',
`magnesium` DECIMAL(8,1) NULL COMMENT '镁 mg',
`iron` DECIMAL(8,2) NULL COMMENT '铁 mg',
`zinc` DECIMAL(8,2) NULL COMMENT '锌 mg',
`selenium` DECIMAL(8,2) NULL COMMENT '硒 μg',
`copper` DECIMAL(8,3) NULL COMMENT '铜 mg',
`manganese` DECIMAL(8,3) NULL COMMENT '锰 mg',
`iodine` DECIMAL(10,1) NULL COMMENT '碘 μg(干海带可达 36240)',
-- ==== 脂肪酸构成(占总脂肪酸的百分比,SFA+MUFA+PUFA ≈ 合计)====
`sfa_percent` DECIMAL(5,1) NULL COMMENT '饱和脂肪酸 SFA %',
`mufa_percent` DECIMAL(5,1) NULL COMMENT '单不饱和脂肪酸 MUFA %',
`pufa_percent` DECIMAL(5,1) NULL COMMENT '多不饱和脂肪酸 PUFA %',
`fa_total_percent` DECIMAL(5,1) NULL COMMENT '脂肪酸合计 %',
`raw_values` JSON NULL COMMENT '接口原始数组(保留 ""/"—" 等原始形态,便于回溯;不需要可删)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_food_id` (`food_id`) COMMENT '来源ID唯一,重复抓取用它幂等覆盖',
INDEX `idx_category` (`category_id`, `sub_category_id`) COMMENT '按分类浏览',
INDEX `idx_name` (`name`) COMMENT '按名称查询'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物营养成分表(每100g可食部,数据来源:中国食物成分表)';
-- 核对:跑完应该能看到 food_nutrition 表
-- SHOW TABLES LIKE 'food_nutrition';
@@ -0,0 +1,37 @@
-- 照片表(水印相机模块,2026-09-28)
--
-- 用途:App 拍完照后把**原图**上传到此存档,一条记录对应一个文件。
-- 水印是 App 本地合成的,服务器存的始终是未加水印的原图 —— 这样以后
-- 想重新生成水印(改了样式/颜色)也还有原始素材。
--
-- 上传接口:POST /api/photos
-- 文件落盘位置:${file.upload.path}/photo/yyyy/MM/dd/<uuid>.<ext>
-- 访问地址:/uploads/photo/yyyy/MM/dd/<uuid>.<ext>(走已有的 /uploads/** 静态映射)
--
-- 执行方式(在 231 上):
-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \
-- -uroot -p密码 accounting_db < migration_add_photo.sql
CREATE TABLE IF NOT EXISTS `photo` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '照片ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`file_path` VARCHAR(500) NOT NULL COMMENT '相对路径,如 photo/2026/09/28/xxx.jpg',
`file_size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
`original_name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
`latitude` DECIMAL(10,7) NULL COMMENT '纬度(WGS84),未授权定位时为空',
`longitude` DECIMAL(10,7) NULL COMMENT '经度(WGS84),未授权定位时为空',
`address` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '文字地址(逆地理编码结果)',
`weather` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '天气+温度,如「晴 25℃」',
`remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
`taken_time` DATETIME NULL COMMENT '拍摄时间(客户端传来的本地时间)',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
-- 列表查询(按时间倒序翻页)走这个索引
INDEX `idx_user_created` (`user_id`, `deleted`, `create_time`),
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='照片表';
-- 核对:跑完应该能看到 photo 表
-- SHOW TABLES LIKE 'photo';
+79
View File
@@ -208,3 +208,82 @@ CREATE TABLE IF NOT EXISTS `chat_message` (
INDEX `idx_session_created` (`session_id`, `deleted`, `create_time`),
FOREIGN KEY (`session_id`) REFERENCES `chat_session` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI消息表';
-- 照片表(水印相机模块)
-- 存 App 上传的**原图**(未加水印);水印由 App 本地合成后直接存手机相册。
-- 文件落在 ${file.upload.path}/photo/yyyy/MM/dd/,通过 /uploads/** 访问。
CREATE TABLE IF NOT EXISTS `photo` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '照片ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`file_path` VARCHAR(500) NOT NULL COMMENT '相对路径,如 photo/2026/09/28/xxx.jpg',
`file_size` BIGINT NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
`original_name` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
`latitude` DECIMAL(10,7) NULL COMMENT '纬度(WGS84),未授权定位时为空',
`longitude` DECIMAL(10,7) NULL COMMENT '经度(WGS84),未授权定位时为空',
`address` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '文字地址(逆地理编码结果)',
`weather` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '天气+温度,如「晴 25℃」',
`remark` VARCHAR(500) NOT NULL DEFAULT '' COMMENT '备注',
`taken_time` DATETIME NULL COMMENT '拍摄时间(客户端传来的本地时间)',
`deleted` TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0正常 1已删除',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
INDEX `idx_user_created` (`user_id`, `deleted`, `create_time`),
FOREIGN KEY (`user_id`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='照片表';
-- 食物营养成分表(数据来源:中国食物成分表 · https://nlc.chinanutri.cn/fq/ )
-- 与业务数据无关,是全局参考表,所以没有 user_id / 逻辑删除。
-- 字段下标与接口数组的对应关系见 migration_add_food_nutrition.sql 头部注释。
CREATE TABLE IF NOT EXISTS `food_nutrition` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`food_id` INT NOT NULL COMMENT '来源食物ID(详情页 /fq/foodinfo/<food_id>.html)',
`name` VARCHAR(200) NOT NULL COMMENT '食物名称',
`alias` VARCHAR(200) NULL COMMENT '别名或俗名',
`english_name` VARCHAR(200) NULL COMMENT '英文名称',
`image_path` VARCHAR(300) NULL COMMENT '图片文件名(前缀 upload/images/small/)',
`category_id` INT NULL COMMENT '一级分类ID(接口 categoryOne),如 1=谷类及制品',
`category_name` VARCHAR(50) NULL COMMENT '一级分类名称',
`sub_category_id` INT NULL COMMENT '二级分类ID(接口 categoryTwo),如 30=小麦',
`sub_category_name` VARCHAR(50) NULL COMMENT '二级分类名称',
-- 一般营养成分(每 100g 可食部)
`edible_percent` DECIMAL(5,1) NULL COMMENT '食部 %',
`water` DECIMAL(6,2) NULL COMMENT '水分 g',
`energy_kj` DECIMAL(8,1) NULL COMMENT '能量 kJ',
`protein` DECIMAL(6,2) NULL COMMENT '蛋白质 g',
`fat` DECIMAL(6,2) NULL COMMENT '脂肪 g',
`cholesterol` DECIMAL(8,1) NULL COMMENT '胆固醇 mg',
`ash` DECIMAL(6,2) NULL COMMENT '灰分 g',
`carbohydrate` DECIMAL(6,2) NULL COMMENT '碳水化合物 g',
`dietary_fiber` DECIMAL(6,2) NULL COMMENT '总膳食纤维 g',
`carotene` DECIMAL(10,1) NULL COMMENT '胡萝卜素 μg',
`retinol` DECIMAL(9,1) NULL COMMENT '视黄醇 μg',
`vitamin_e` DECIMAL(7,2) NULL COMMENT 'α-生育酚当量(维生素E)mg',
`thiamin` DECIMAL(7,3) NULL COMMENT '硫胺素(维生素B1)mg',
`riboflavin` DECIMAL(7,3) NULL COMMENT '核黄素(维生素B2)mg',
`niacin` DECIMAL(7,2) NULL COMMENT '烟酸(尼克酸)mg',
`vitamin_c` DECIMAL(8,2) NULL COMMENT '维生素C mg',
`calcium` DECIMAL(8,1) NULL COMMENT '钙 mg',
`phosphorus` DECIMAL(8,1) NULL COMMENT '磷 mg',
`potassium` DECIMAL(8,1) NULL COMMENT '钾 mg',
`sodium` DECIMAL(9,1) NULL COMMENT '钠 mg(食盐可达 39311)',
`magnesium` DECIMAL(8,1) NULL COMMENT '镁 mg',
`iron` DECIMAL(8,2) NULL COMMENT '铁 mg',
`zinc` DECIMAL(8,2) NULL COMMENT '锌 mg',
`selenium` DECIMAL(8,2) NULL COMMENT '硒 μg',
`copper` DECIMAL(8,3) NULL COMMENT '铜 mg',
`manganese` DECIMAL(8,3) NULL COMMENT '锰 mg',
`iodine` DECIMAL(10,1) NULL COMMENT '碘 μg(干海带可达 36240)',
-- 脂肪酸构成(占总脂肪酸的百分比)
`sfa_percent` DECIMAL(5,1) NULL COMMENT '饱和脂肪酸 SFA %',
`mufa_percent` DECIMAL(5,1) NULL COMMENT '单不饱和脂肪酸 MUFA %',
`pufa_percent` DECIMAL(5,1) NULL COMMENT '多不饱和脂肪酸 PUFA %',
`fa_total_percent` DECIMAL(5,1) NULL COMMENT '脂肪酸合计 %',
`raw_values` JSON NULL COMMENT '接口原始数组(保留 ""/"—" 等原始形态)',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_food_id` (`food_id`),
INDEX `idx_category` (`category_id`, `sub_category_id`),
INDEX `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物营养成分表(每100g可食部,数据来源:中国食物成分表)';
+182 -177
View File
@@ -1,11 +1,11 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>强宝小助手</title>
<link rel="stylesheet" href="/admin/vendor/vditor/dist/index.css">
<link rel="stylesheet" href="/admin/admin.css">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>强宝小助手</title>
<link rel="stylesheet" href="/admin/vendor/vditor/dist/index.css">
<link rel="stylesheet" href="/admin/admin.css">
</head>
<body>
@@ -15,41 +15,42 @@
-->
<div id="login-view">
<!-- 背景装饰圆球(手机端是带缓动的,这里用 CSS 动画近似) -->
<div class="login-balls" aria-hidden="true">
<span class="ball b1"></span>
<span class="ball b2"></span>
<span class="ball b3"></span>
</div>
<div class="login-wrap">
<div class="login-header">
<div class="login-logo">
<img src="/admin/logo.png" alt="强宝小助手">
</div>
<h1 class="login-title">
<i class="deco"></i><span>强宝小助手</span><i class="deco"></i>
</h1>
<p class="login-sub">智能记账,轻松管理</p>
<!-- 背景装饰圆球(手机端是带缓动的,这里用 CSS 动画近似) -->
<div class="login-balls" aria-hidden="true">
<span class="ball b1"></span>
<span class="ball b2"></span>
<span class="ball b3"></span>
</div>
<div class="login-box">
<div class="login-field">
<span class="ico"><img class="ci" src="/admin/icons/user.svg" width="18" height="18" alt=""></span>
<input id="login-username" autocomplete="username" maxlength="20" placeholder="请输入用户名">
</div>
<div class="login-field">
<span class="ico"><img class="ci" src="/admin/icons/lock.svg" width="18" height="18" alt=""></span>
<input id="login-password" type="password" autocomplete="current-password" maxlength="20" placeholder="请输入密码">
<button type="button" class="eye" id="login-eye" title="显示密码">
<img class="ci" src="/admin/icons/eye.svg" width="18" height="18" alt="">
</button>
</div>
<button class="login-btn" id="login-btn">登 录</button>
</div>
<div class="login-wrap">
</div>
<div class="login-header">
<div class="login-logo">
<img src="/admin/logo.png" alt="强宝小助手">
</div>
<h1 class="login-title">
<i class="deco"></i><span>强宝小助手</span><i class="deco"></i>
</h1>
<p class="login-sub">智能记账,轻松管理</p>
</div>
<div class="login-box">
<div class="login-field">
<span class="ico"><img class="ci" src="/admin/icons/user.svg" width="18" height="18" alt=""></span>
<input id="login-username" autocomplete="username" maxlength="20" placeholder="请输入用户名">
</div>
<div class="login-field">
<span class="ico"><img class="ci" src="/admin/icons/lock.svg" width="18" height="18" alt=""></span>
<input id="login-password" type="password" autocomplete="current-password" maxlength="20"
placeholder="请输入密码">
<button type="button" class="eye" id="login-eye" title="显示密码">
<img class="ci" src="/admin/icons/eye.svg" width="18" height="18" alt="">
</button>
</div>
<button class="login-btn" id="login-btn">登 录</button>
</div>
</div>
</div>
<!-- ==================== 主界面 ====================
@@ -60,178 +61,182 @@
-->
<div id="app-view" style="display:none">
<header class="topbar">
<div class="brand"><img class="brand-logo" src="/admin/logo.png" alt="">强宝小助手</div>
<div class="spacer"></div>
<span class="who" id="who"></span>
<button id="logout-btn">退出</button>
</header>
<div class="console">
<nav class="module-nav">
<button class="module-item active" data-module="notes">
<span class="mi-icon"><img class="ci" src="/admin/icons/note.svg" width="18" height="18" alt=""></span>
<span class="mi-text">笔记管理</span>
</button>
<button class="module-item" data-module="finance">
<span class="mi-icon"><img class="ci" src="/admin/icons/bill.svg" width="18" height="18" alt=""></span>
<span class="mi-text">财务管理</span>
</button>
<button class="module-item" data-module="ai">
<span class="mi-icon"><img class="ci" src="/admin/icons/assistant.svg" width="18" height="18" alt=""></span>
<span class="mi-text">AI 助手</span>
</button>
<!-- 以后的模块(数据看板…)往这里加:这里加一个 button,
下面加一个 data-module-panel 的 section,再在 admin.js 的
switchModule 里按需做初始化就行 -->
</nav>
<section class="module-panel" data-module-panel="notes">
<div class="toolbar">
<input id="search" placeholder="搜索标题和正文…">
<select id="scope" title="搜索范围">
<option value="current" selected>当前目录</option>
<option value="all">全部笔记</option>
</select>
<select id="sort" title="排序方式">
<option value="updated">最近更新</option>
<option value="created">创建时间</option>
<option value="title">标题</option>
</select>
<header class="topbar">
<div class="brand"><img class="brand-logo" src="/admin/logo.png" alt="">强宝小助手</div>
<div class="spacer"></div>
<button id="new-folder-btn">+ 新建目录</button>
<button id="new-btn" class="primary">+ 新建笔记</button>
</div>
<span class="who" id="who"></span>
<button id="logout-btn">退出</button>
</header>
<div class="module-body">
<div class="console">
<aside class="sidebar">
<div id="folder-tree"></div>
<div class="side-divider"></div>
<div class="side-title">标签</div>
<div id="tag-list"></div>
</aside>
<nav class="module-nav">
<button class="module-item active" data-module="notes">
<span class="mi-icon"><img class="ci" src="/admin/icons/note.svg" width="18" height="18" alt=""></span>
<span class="mi-text">笔记管理</span>
</button>
<button class="module-item" data-module="finance">
<span class="mi-icon"><img class="ci" src="/admin/icons/bill.svg" width="18" height="18" alt=""></span>
<span class="mi-text">财务管理</span>
</button>
<button class="module-item" data-module="ai">
<span class="mi-icon"><img class="ci" src="/admin/icons/assistant.svg" width="18" height="18"
alt=""></span>
<span class="mi-text">AI 助手</span>
</button>
<!-- 以后的模块(数据看板…)往这里加:这里加一个 button,
下面加一个 data-module-panel 的 section,再在 admin.js 的
switchModule 里按需做初始化就行 -->
</nav>
<main class="content">
<div class="breadcrumb" id="breadcrumb"></div>
<section class="module-panel" data-module-panel="notes">
<div id="dropzone">
把 <b>.md</b> 文件拖到这里批量导入<span id="dropzone-hint"></span>,或 <a href="#" id="pick-files">点击选择文件</a>
<input type="file" id="file-input" accept=".md,.markdown,.txt" multiple style="display:none">
</div>
<div class="toolbar">
<input id="search" placeholder="搜索标题和正文…">
<select id="scope" title="搜索范围">
<option value="current" selected>当前目录</option>
<option value="all">全部笔记</option>
</select>
<select id="sort" title="排序方式">
<option value="updated">最近更新</option>
<option value="created">创建时间</option>
<option value="title">标题</option>
</select>
<div class="spacer"></div>
<button id="new-folder-btn">+ 新建目录</button>
<button id="new-btn" class="primary">+ 新建笔记</button>
</div>
<div id="note-list"></div>
<button id="loadmore" style="display:none">加载更多</button>
</main>
<div class="module-body">
</div>
</section>
<aside class="sidebar">
<div id="folder-tree"></div>
<div class="side-divider"></div>
<div class="side-title">标签</div>
<div id="tag-list"></div>
</aside>
<!-- ==================== 财务管理模块 ====================
纯展示:月度汇总 / 预算进度 / 分类占比 / 账单明细。
记账和改账在 App 上做,这里只读 —— 数据全部来自现成接口
(/statistics/monthly、/bills、/budget、/account)。 -->
<section class="module-panel" data-module-panel="finance" style="display:none">
<main class="content">
<div class="breadcrumb" id="breadcrumb"></div>
<div class="toolbar">
<button class="icon-btn" id="fin-prev" title="上一月">‹</button>
<span class="fin-month" id="fin-month">—</span>
<button class="icon-btn" id="fin-next" title="下一月">›</button>
<div class="toolbar-sep"></div>
<select id="fin-type" title="按类型筛选">
<option value="">全部类型</option>
<option value="1">只看支出</option>
<option value="2">只看收入</option>
</select>
<select id="fin-cat" title="按分类筛选"><option value="">全部分类</option></select>
<div class="spacer"></div>
<span class="toolbar-note" id="fin-note"></span>
</div>
<div id="dropzone">
把 <b>.md</b> 文件拖到这里批量导入<span id="dropzone-hint"></span>,或 <a href="#"
id="pick-files">点击选择文件</a>
<input type="file" id="file-input" accept=".md,.markdown,.txt" multiple style="display:none">
</div>
<div class="module-body">
<div id="note-list"></div>
<button id="loadmore" style="display:none">加载更多</button>
</main>
<aside class="sidebar">
<div class="side-title">本月概览</div>
<div id="fin-summary"></div>
<div class="side-divider"></div>
<div class="side-title">分类(点一下筛选)</div>
<div id="fin-cats"></div>
</aside>
</div>
</section>
<main class="content">
<div id="fin-bills"></div>
</main>
<!-- ==================== 财务管理模块 ====================
纯展示:月度汇总 / 预算进度 / 分类占比 / 账单明细。
记账和改账在 App 上做,这里只读 —— 数据全部来自现成接口
(/statistics/monthly、/bills、/budget、/account)。 -->
<section class="module-panel" data-module-panel="finance" style="display:none">
</div>
</section>
<div class="toolbar">
<button class="icon-btn" id="fin-prev" title="上一月">‹</button>
<span class="fin-month" id="fin-month">—</span>
<button class="icon-btn" id="fin-next" title="下一月">›</button>
<div class="toolbar-sep"></div>
<select id="fin-type" title="按类型筛选">
<option value="">全部类型</option>
<option value="1">只看支出</option>
<option value="2">只看收入</option>
</select>
<select id="fin-cat" title="按分类筛选">
<option value="">全部分类</option>
</select>
<div class="spacer"></div>
<span class="toolbar-note" id="fin-note"></span>
</div>
<!-- ==================== AI 助手模块 ====================
和 App 端共用同一套后端接口(/api/ai/**),历史存在服务端的
chat_session / chat_message 里 —— 所以两边看到的会话是同一批。 -->
<section class="module-panel" data-module-panel="ai" style="display:none">
<div class="module-body">
<div class="toolbar">
<span class="toolbar-title">AI 助手</span>
<span class="toolbar-note" id="ai-hint"></span>
<div class="spacer"></div>
<button id="ai-new">+ 新对话</button>
</div>
<aside class="sidebar">
<div class="side-title">本月概览</div>
<div id="fin-summary"></div>
<div class="side-divider"></div>
<div class="side-title">分类(点一下筛选)</div>
<div id="fin-cats"></div>
</aside>
<div class="module-body">
<main class="content">
<div id="fin-bills"></div>
</main>
<aside class="sidebar">
<div class="side-title">历史对话</div>
<div id="ai-session-list"></div>
</aside>
</div>
</section>
<main class="ai-main">
<div id="ai-messages" class="ai-messages"></div>
<!-- 工具调用提示(「正在查账单…」),没有在查东西时整行隐藏 -->
<div class="ai-status" id="ai-status" style="display:none"></div>
<div class="ai-composer">
<!-- ==================== AI 助手模块 ====================
和 App 端共用同一套后端接口(/api/ai/**),历史存在服务端的
chat_session / chat_message 里 —— 所以两边看到的会话是同一批。 -->
<section class="module-panel" data-module-panel="ai" style="display:none">
<div class="toolbar">
<span class="toolbar-title">AI 助手</span>
<span class="toolbar-note" id="ai-hint"></span>
<div class="spacer"></div>
<button id="ai-new">+ 新对话</button>
</div>
<div class="module-body">
<aside class="sidebar">
<div class="side-title">历史对话</div>
<div id="ai-session-list"></div>
</aside>
<main class="ai-main">
<div id="ai-messages" class="ai-messages"></div>
<!-- 工具调用提示(「正在查账单…」),没有在查东西时整行隐藏 -->
<div class="ai-status" id="ai-status" style="display:none"></div>
<div class="ai-composer">
<textarea id="ai-input" rows="1"
placeholder="问点什么,或者说一句…(Enter 发送,Shift+Enter 换行)"></textarea>
<button class="primary" id="ai-send">发送</button>
</div>
</main>
<button class="primary" id="ai-send">发送</button>
</div>
</main>
</div>
</section>
</div>
</section>
</div>
</div>
</div>
<!-- ==================== 编辑器 ==================== -->
<div id="editor">
<div class="editor-box">
<div class="editor-head">
<input class="title" id="ed-title" placeholder="标题(留空则自动取正文首行)">
<input class="tags" id="ed-tags" placeholder="标签,逗号分隔">
<div class="editor-box">
<div class="editor-head">
<input class="title" id="ed-title" placeholder="标题(留空则自动取正文首行)">
<input class="tags" id="ed-tags" placeholder="标签,逗号分隔">
</div>
<div class="editor-body">
<div id="vditor"></div>
</div>
<div class="editor-foot">
<span class="hint" id="ed-hint"></span>
<button id="ed-cancel">取消</button>
<button class="primary" id="ed-save">保存 (Ctrl+S)</button>
</div>
</div>
<div class="editor-body">
<div id="vditor"></div>
</div>
<div class="editor-foot">
<span class="hint" id="ed-hint"></span>
<button id="ed-cancel">取消</button>
<button class="primary" id="ed-save">保存 (Ctrl+S)</button>
</div>
</div>
</div>
<!-- ==================== 目录名输入 / 目录选择弹窗 ==================== -->
<div id="dialog">
<div class="dialog-box">
<h3 id="dialog-title">新建目录</h3>
<input id="dialog-input" placeholder="目录名">
<select id="dialog-select" style="display:none"></select>
<div class="dialog-foot">
<button id="dialog-cancel">取消</button>
<button class="primary" id="dialog-ok">确定</button>
<div class="dialog-box">
<h3 id="dialog-title">新建目录</h3>
<input id="dialog-input" placeholder="目录名">
<select id="dialog-select" style="display:none"></select>
<div class="dialog-foot">
<button id="dialog-cancel">取消</button>
<button class="primary" id="dialog-ok">确定</button>
</div>
</div>
</div>
</div>
<div id="toast"></div>