水印相机后端+建表语句

食物营养成分表数据
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
+29
View File
@@ -0,0 +1,29 @@
# 2026-09-28
## 食物营养成分表建表(中国食物成分表)
- 新增 `src/main/resources/db/migration_add_food_nutrition.sql`(表 `food_nutrition`),并按项目惯例同步追加到 `db/schema.sql`。
- 数据来源:`https://nlc.chinanutri.cn/fq/`(中国疾控中心营养与健康所)。
- 列表接口:`POST /fq/FoodInfoQueryAction!queryFoodInfoList.do`
- 参数:`categoryOne` / `categoryTwo` / `foodName` / `pageNum` / `field` / `flag`
- 返回:`{currentPage, totalPages, list: [[...36 元素...]]}`
- 列定义来源:`/fq/foodlist_0_0_0_0_0_1.htm` 里 `th.ysq_code_*` 的 class 名(页面为 UTF-8)。
- 数组下标 = 表头序号 + 2([0]=食物ID、[1]=图片、[2]=食物名称,之后 34 个营养素)。
- 关键字段名:`huifen`=灰分、`tshhw`=碳水化合物、`qianwei`=总膳食纤维、`afte`=α-TE(维生素E)、
`las`=硫胺素、`hhs`=核黄素、`yanSuan`=烟酸、`wssC`=维生素C;末尾 4 个是 SFA/MUFA/PUFA 百分比与合计。
- 接口空值:`""`=无数据、`"—"`=未检出,入库统一 NULL;值自带单位后缀(`12.7g`/`5.40μg`/`1478kJ`/`100%`)。
- 分类为两级:categoryOne 1..28(大类,如 1=谷类及制品),categoryTwo 30..128(子类,如 30=小麦)。
- 校验方法:碳水 = 100 - 水分 - 蛋白质 - 脂肪 - 灰分(小麦粉各行完全吻合),据此确认了 `huifen`=灰分为第 11 位。
- 修正(同上):数组共 36 元素,[0]ID [1]图片 [2]名称 [3]别名 [4]英文名 → [5]~[35] 共 **31 个**营养素,不是 34 个。
## 抓取全量数据 + 生成 INSERT(同日完成)
- 新增 `tools/fetch_food_nutrition.py`(抓取 + 生成 SQL,带磁盘缓存、可断点续跑、支持 `--offline` 重生成)、
`tools/verify_sql.py`(结构/列序/逐值一致性校验)。`tools/_cache/` 为 557 个原始 JSON 响应的缓存,已加入 `.gitignore`。
- 产物 `src/main/resources/db/data_food_nutrition.sql`:**1356 条**食物,0.68MB,7 条批量 INSERT(200 行/批,41 列)。
- 抓取策略:先用 `categoryOne=0&categoryTwo=0` 拿全量(即全集,170 页 × 8 条/页),
再遍历 20 个大类 + 91 个子类补分类标签;首页共 111 个分类链接,已全部覆盖。
- 数据结论:`Tr`(微量)513 处,是唯一无法转数字的 token;只有 `枸杞子(1571)`、`菊花(1613)` 在源站没有分类归属(留 NULL)。
- 校验结果:1356 条 × 41 字段全部正常;DDL 列序 == INSERT 列序;31 个营养素逐值与接口原始数组完全一致;`raw_values` JSON 合法。
- 脚本关键点:`to_num()` 剥离单位后缀并统一 `""`/`—`/`Tr` → NULL;`sql_str()` 转义 `\` 与 `'`;
`verify_sql.py` 里按「单引号字符串 + 括号嵌套」扫描切分元组,切分前必须先剥掉最外层圆括号。
+3
View File
@@ -35,6 +35,9 @@ build/
### 上传文件目录 ###
uploads/
### 食物成分表抓取缓存(可重新抓取, 不入库) ###
tools/_cache/
@@ -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>
+210
View File
@@ -0,0 +1,210 @@
# -*- coding: utf-8 -*-
"""抓取中国食物成分表(nlc.chinanutri.cn/fq)全部食物, 生成 MySQL INSERT 脚本.
python tools/fetch_food_nutrition.py # 联网抓取(带缓存,可续跑)
python tools/fetch_food_nutrition.py --offline # 只用缓存重新生成 SQL
产物: tools/_cache/ 接口原始响应缓存
src/main/resources/db/data_food_nutrition.sql
前置: 先执行 db/migration_add_food_nutrition.sql 建表.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import re
import ssl
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor, as_completed
BASE = "https://nlc.chinanutri.cn/fq/"
QUERY_URL = BASE + "FoodInfoQueryAction!queryFoodInfoList.do"
ROOT = pathlib.Path(__file__).resolve().parent.parent
CACHE = ROOT / "tools" / "_cache"
OUT_SQL = ROOT / "src" / "main" / "resources" / "db" / "data_food_nutrition.sql"
CTX = ssl.create_default_context()
CTX.check_hostname = False
CTX.verify_mode = ssl.CERT_NONE
WORKERS, RETRY, BATCH = 4, 4, 200
# 数组下标 -> 列名 (下标 = 列表页表头序号 + 2)
NUTRIENTS = [
(5, "edible_percent"), (6, "water"), (7, "energy_kj"), (8, "protein"),
(9, "fat"), (10, "cholesterol"), (11, "ash"), (12, "carbohydrate"),
(13, "dietary_fiber"), (14, "carotene"), (15, "retinol"), (16, "vitamin_e"),
(17, "thiamin"), (18, "riboflavin"), (19, "niacin"), (20, "vitamin_c"),
(21, "calcium"), (22, "phosphorus"), (23, "potassium"), (24, "sodium"),
(25, "magnesium"), (26, "iron"), (27, "zinc"), (28, "selenium"),
(29, "copper"), (30, "manganese"), (31, "iodine"), (32, "sfa_percent"),
(33, "mufa_percent"), (34, "pufa_percent"), (35, "fa_total_percent"),
]
COLUMNS = ["food_id", "name", "alias", "english_name", "image_path",
"category_id", "category_name", "sub_category_id", "sub_category_name"] \
+ [c for _, c in NUTRIENTS] + ["raw_values"]
NUM_RE = re.compile(r"^([+-]?\d+(?:\.\d+)?)\s*(?:g|mg|μg|µg|ug|kJ|kj|%)?$")
NULL_TOKENS = {"", "—", "–", "-", "…", "tr", "微量", "未检出", "未测"}
def to_num(raw) -> str | None:
s = "" if raw is None else str(raw).strip()
if s.lower() in NULL_TOKENS:
return None
m = NUM_RE.match(s)
return m.group(1) if m else None
def sql_str(v) -> str:
if v is None or str(v) == "":
return "NULL"
return "'" + str(v).replace("\\", "\\\\").replace("'", "''") + "'"
def post_form(pairs: dict) -> str:
req = urllib.request.Request(
QUERY_URL, data=urllib.parse.urlencode(pairs).encode(),
headers={"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"X-Requested-With": "XMLHttpRequest",
"Referer": BASE + "foodlist_0_0_0_0_0_1.htm",
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"})
with urllib.request.urlopen(req, timeout=30, context=CTX) as r:
return r.read().decode("utf-8", errors="replace")
def page_json(c1: int, c2: int, page: int, offline: bool) -> dict:
path = CACHE / f"cat_{c1}_{c2}_p{page}.json"
if path.exists():
return json.loads(path.read_text(encoding="utf-8"))
if offline:
raise RuntimeError(f"离线模式缺缓存 {path.name}")
err = None
for i in range(1, RETRY + 1):
try:
data = json.loads(post_form({"categoryOne": str(c1), "categoryTwo": str(c2),
"foodName": "", "pageNum": str(page),
"field": "", "flag": ""}))
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
return data
except Exception as e: # noqa: BLE001
err = e
time.sleep(1.2 * i)
raise RuntimeError(f"抓取 {c1}/{c2} p{page} 失败: {err}")
def fetch_group(c1: int, c2: int, offline: bool, label: str = "") -> list:
first = page_json(c1, c2, 1, offline)
total = int(first.get("totalPages") or 1)
pages = [first] + [None] * (total - 1)
if total > 1:
with ThreadPoolExecutor(max_workers=WORKERS) as ex:
futs = {ex.submit(page_json, c1, c2, p, offline): p for p in range(2, total + 1)}
for n, fut in enumerate(as_completed(futs), 1):
pages[futs[fut] - 1] = fut.result()
if n % 20 == 0 or n == len(futs):
print(f" {n}/{len(futs)} 页", flush=True)
rows = [r for pg in pages for r in (pg.get("list") or [])]
print(f" {label or f'{c1}/{c2}'}: {total} 页 -> {len(rows)} 条")
return rows
def load_categories(offline: bool) -> list:
f = CACHE / "index.htm"
if not f.exists():
if offline:
raise RuntimeError("离线模式缺 index.htm")
req = urllib.request.Request(BASE, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=30, context=CTX) as r:
f.write_bytes(r.read())
html = f.read_bytes().decode("utf-8", errors="replace")
out = []
for m in re.finditer(r'<a href="foodlist_0_\d+_\d+_0_0_1\.htm"([^>]*)>([^<]*)</a>', html):
attrs, name = m.group(1), m.group(2).strip()
sid = re.search(r'data_id="(\d+)"', attrs)
pid = re.search(r'data_pid="(\d+)"', attrs)
if sid and pid:
out.append((int(pid.group(1)), int(sid.group(1)), name))
return out
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--offline", action="store_true")
args = ap.parse_args()
CACHE.mkdir(parents=True, exist_ok=True)
cats = load_categories(args.offline)
tops = {i: n for p, i, n in cats if p == 0}
subs = [(p, i, n) for p, i, n in cats if p != 0]
print(f"分类: {len(tops)} 个大类, {len(subs)} 个子类")
print("[1/3] 抓取全量食物 ...")
master = {}
for row in fetch_group(0, 0, args.offline, "全量"):
master[int(row[0])] = row
print(f" 全量去重后 {len(master)} 条")
print("[2/3] 抓取分类归属 ...")
cat_of: dict[int, tuple] = {}
for pid, sid, name in subs:
for row in fetch_group(pid, sid, args.offline, f"{tops.get(pid,'')} > {name}"):
fid = int(row[0])
master.setdefault(fid, row)
cat_of[fid] = (pid, tops.get(pid, ""), sid, name)
for tid, tname in tops.items():
for row in fetch_group(tid, 0, args.offline, f"{tname}(大类)"):
fid = int(row[0])
master.setdefault(fid, row)
cat_of.setdefault(fid, (tid, tname, None, None))
print("[3/3] 生成 SQL ...")
records = []
no_cat, bad = [], 0
for fid in sorted(master):
row = master[fid]
name = (row[2] or "").strip()
if not name:
bad += 1
continue
c1, c1n, c2, c2n = cat_of.get(fid, (None, None, None, None))
if c1 is None:
no_cat.append(fid)
vals = [str(fid), sql_str(name), sql_str(row[3] if len(row) > 3 else ""),
sql_str(row[4] if len(row) > 4 else ""), sql_str(row[1] if len(row) > 1 else "")]
vals += [str(c1) if c1 else "NULL", sql_str(c1n),
str(c2) if c2 else "NULL", sql_str(c2n)]
vals += ["NULL" if (n := to_num(row[i] if len(row) > i else None)) is None else n
for i, _ in NUTRIENTS]
vals.append(sql_str(json.dumps(row, ensure_ascii=False)))
records.append("(" + ",".join(vals) + ")")
cols = ",".join(f"`{c}`" for c in COLUMNS)
lines = [
"-- 食物营养成分数据 (自动生成, 请勿手工编辑)",
f"-- 来源: {BASE} | 接口: FoodInfoQueryAction!queryFoodInfoList.do",
f"-- 生成时间: {time.strftime('%Y-%m-%d %H:%M:%S')}",
f"-- 共 {len(records)} 条; 无分类归属 {len(no_cat)} 条; 跳过无名 {bad} 条",
"-- 前置: 先执行 db/migration_add_food_nutrition.sql 建表",
"-- 注意: 本脚本会先清空 food_nutrition 再全量写入",
"",
"SET NAMES utf8mb4;",
"DELETE FROM `food_nutrition`;",
"",
]
for i in range(0, len(records), BATCH):
lines.append(f"INSERT INTO `food_nutrition` ({cols}) VALUES")
lines.append(",\n".join(records[i:i + BATCH]) + ";")
lines.append("")
OUT_SQL.write_text("\n".join(lines), encoding="utf-8")
size_mb = OUT_SQL.stat().st_size / 1024 / 1024
print(f"\n完成: {OUT_SQL}")
print(f" {len(records)} 条记录, 文件 {size_mb:.2f} MB")
if no_cat:
print(f" 未归属分类的 food_id (前20): {no_cat[:20]}")
if __name__ == "__main__":
main()
+116
View File
@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
"""校验生成的 INSERT 脚本: 元组数量/字段个数/列序/与缓存数据一致性。用完可删。"""
import json
import pathlib
import re
import sys
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
from fetch_food_nutrition import to_num # noqa: E402
ROOT = pathlib.Path(__file__).resolve().parent.parent
SQL = ROOT / "src" / "main" / "resources" / "db" / "data_food_nutrition.sql"
DDL = ROOT / "src" / "main" / "resources" / "db" / "migration_add_food_nutrition.sql"
CACHE = ROOT / "tools" / "_cache"
N_META, N_NUT, EXPECT = 9, 31, 41 # 9 元数据 + 31 营养素 + raw_values
def split_top(text):
"""按最外层逗号/分号切分, 尊重单引号字符串(含 '' 转义)与括号嵌套。"""
out, cur, depth, in_str, i = [], [], 0, False, 0
while i < len(text):
ch = text[i]
if in_str:
if ch == "'":
if i + 1 < len(text) and text[i + 1] == "'":
cur.append("''")
i += 2
continue
in_str = False
cur.append(ch)
elif ch == "'":
in_str = True
cur.append(ch)
elif ch in "([{":
depth += 1
cur.append(ch)
elif ch in ")]}":
depth -= 1
cur.append(ch)
elif depth == 0 and ch == ",":
out.append("".join(cur).strip())
cur = []
else:
cur.append(ch)
i += 1
if "".join(cur).strip():
out.append("".join(cur).strip())
return out
sql = SQL.read_text(encoding="utf-8")
body = "\n".join(l for l in sql.splitlines() if not l.lstrip().startswith("--"))
stmts = [s for s in body.split(";") if "INSERT INTO" in s]
ins_cols = re.findall(r"`(\w+)`", stmts[0][:stmts[0].index("VALUES")])[1:] # 去掉表名
print(f"INSERT 语句 {len(stmts)} 条, 列数 {len(ins_cols)}")
# 1) DDL 列序 vs INSERT 列序(排除 id/create_time/update_time)
ddl = DDL.read_text(encoding="utf-8")
block = ddl[ddl.index("CREATE TABLE"):ddl.index("ENGINE=InnoDB")]
ddl_cols = [c for c in re.findall(r"^\s*`(\w+)`\s+(?:BIGINT|INT|VARCHAR|DECIMAL|JSON|DATETIME)",
block, re.M)
if c not in ("id", "create_time", "update_time")]
print(f"DDL 列数 {len(ddl_cols)}; 列序与 INSERT 一致: {ddl_cols == ins_cols}")
if ddl_cols != ins_cols:
for a, b in zip(ddl_cols, ins_cols):
if a != b:
print(f" 首个差异: DDL={a} INSERT={b}")
break
# 2) 元组解析
rows, bad = [], []
for st in stmts:
for tup in split_top(st[st.index("VALUES") + 6:]):
t = tup.strip()
t = t[1:-1] if t.startswith("(") and t.endswith(")") else t
f = split_top(t)
if len(f) != EXPECT:
bad.append((f[0] if f else "?", len(f)))
else:
rows.append(f)
print(f"解析记录 {len(rows)} 条; 字段数非 {EXPECT} 的 {len(bad)} {bad[:3]}")
ids = [r[0] for r in rows]
print(f"food_id 唯一 {len(set(ids))}; 升序 {ids == sorted(ids, key=int)}; "
f"范围 {min(map(int, ids))}~{max(map(int, ids))}")
# 3) 与接口缓存比对
master = {}
for f in CACHE.glob("cat_0_0_p*.json"):
for row in json.loads(f.read_text(encoding="utf-8")).get("list") or []:
master[int(row[0])] = row
print(f"缓存 {len(master)} 条; ID 集合一致: {set(map(int, ids)) == set(master)}")
mis = badj = 0
for r in rows:
src = master[int(r[0])]
got = r[N_META:N_META + N_NUT]
for n, idx in enumerate(range(5, 36)):
exp = to_num(src[idx])
if (exp is None and got[n] != "NULL") or (exp is not None and got[n] != exp):
mis += 1
if mis <= 5:
print(f" 值不一致 id={r[0]} idx={idx} 期望 {exp} 实际 {got[n]}")
if json.loads(r[N_META + N_NUT].strip()[1:-1].replace("''", "'")) != src:
badj += 1
print(f"31 个营养素逐值比对: {'全部一致' if mis == 0 else f'{mis} 处不一致'}")
print(f"raw_values 为合法 JSON 且与原数组一致: {badj == 0} (异常 {badj})")
print("\n样例:")
for r in rows:
if r[1][:2] in ("小麦", "鸡肝", "花生") or r[1] == "小麦粉(标准粉)":
print(" " + " | ".join(r[:N_META + 8]))
if r[1].startswith("小麦粉(标准粉)"):
break
print(" 首行: " + " | ".join(rows[0][:N_META + 8]))
print(" raw_values(首行前 60 字): " + rows[0][N_META + N_NUT][:60])