水印相机后端+建表语句
食物营养成分表数据
This commit is contained in:
@@ -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` 里按「单引号字符串 + 括号嵌套」扫描切分元组,切分前必须先剥掉最外层圆括号。
|
||||||
@@ -35,6 +35,9 @@ build/
|
|||||||
### 上传文件目录 ###
|
### 上传文件目录 ###
|
||||||
uploads/
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,18 +23,33 @@ public class FileUtil {
|
|||||||
private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存上传的文件
|
* 保存上传的文件(笔记附件等通用场景)
|
||||||
|
*
|
||||||
|
* <p>上限 5MB,按 yyyy/MM/dd 分目录。行为与拆分前完全一致。</p>
|
||||||
*/
|
*/
|
||||||
public String saveFile(MultipartFile file) throws IOException {
|
public String saveFile(MultipartFile file) throws IOException {
|
||||||
// 验证文件
|
return saveFile(file, MAX_FILE_SIZE, null);
|
||||||
validateFile(file);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存上传的文件(可指定大小上限与子目录)
|
||||||
|
*
|
||||||
|
* <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 dateDir = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
|
||||||
String fullPath = uploadPath + dateDir;
|
String relativeDir = (subDir == null || subDir.isBlank()) ? dateDir : subDir + "/" + dateDir;
|
||||||
Path path = Paths.get(fullPath);
|
Path dir = Paths.get(uploadPath + relativeDir);
|
||||||
if (!Files.exists(path)) {
|
if (!Files.exists(dir)) {
|
||||||
Files.createDirectories(path);
|
Files.createDirectories(dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成文件名
|
// 生成文件名
|
||||||
@@ -46,23 +61,22 @@ public class FileUtil {
|
|||||||
String filename = UUID.randomUUID().toString() + extension;
|
String filename = UUID.randomUUID().toString() + extension;
|
||||||
|
|
||||||
// 保存文件
|
// 保存文件
|
||||||
Path filePath = path.resolve(filename);
|
file.transferTo(dir.resolve(filename).toFile());
|
||||||
file.transferTo(filePath.toFile());
|
|
||||||
|
|
||||||
// 返回相对路径(用于数据库存储)
|
// 返回相对路径(用于数据库存储)
|
||||||
return dateDir + "/" + filename;
|
return relativeDir + "/" + filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证文件
|
* 验证文件
|
||||||
*/
|
*/
|
||||||
private void validateFile(MultipartFile file) {
|
private void validateFile(MultipartFile file, long maxSize) {
|
||||||
if (file == null || file.isEmpty()) {
|
if (file == null || file.isEmpty()) {
|
||||||
throw new IllegalArgumentException("文件不能为空");
|
throw new IllegalArgumentException("文件不能为空");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (file.getSize() > MAX_FILE_SIZE) {
|
if (file.getSize() > maxSize) {
|
||||||
throw new IllegalArgumentException("文件大小不能超过5MB");
|
throw new IllegalArgumentException("文件大小不能超过" + (maxSize / 1024 / 1024) + "MB");
|
||||||
}
|
}
|
||||||
|
|
||||||
String originalFilename = file.getOriginalFilename();
|
String originalFilename = file.getOriginalFilename();
|
||||||
@@ -103,12 +117,3 @@ public class FileUtil {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ spring:
|
|||||||
password: mysql_jP65Fc
|
password: mysql_jP65Fc
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 5MB
|
# 20MB:手机原图(12MP 常见 3~8MB)要能传上来,5MB 会直接拒。
|
||||||
max-request-size: 10MB
|
# 各接口自己还有更细的上限校验(见 FileUtil):笔记附件 5MB、照片 20MB。
|
||||||
|
max-file-size: 20MB
|
||||||
|
max-request-size: 30MB
|
||||||
jackson:
|
jackson:
|
||||||
date-format: yyyy-MM-dd HH:mm:ss
|
date-format: yyyy-MM-dd HH:mm:ss
|
||||||
time-zone: GMT+8
|
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';
|
||||||
@@ -208,3 +208,82 @@ CREATE TABLE IF NOT EXISTS `chat_message` (
|
|||||||
INDEX `idx_session_created` (`session_id`, `deleted`, `create_time`),
|
INDEX `idx_session_created` (`session_id`, `deleted`, `create_time`),
|
||||||
FOREIGN KEY (`session_id`) REFERENCES `chat_session` (`id`) ON DELETE CASCADE
|
FOREIGN KEY (`session_id`) REFERENCES `chat_session` (`id`) ON DELETE CASCADE
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI消息表';
|
) 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可食部,数据来源:中国食物成分表)';
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>强宝小助手</title>
|
<title>强宝小助手</title>
|
||||||
<link rel="stylesheet" href="/admin/vendor/vditor/dist/index.css">
|
<link rel="stylesheet" href="/admin/vendor/vditor/dist/index.css">
|
||||||
<link rel="stylesheet" href="/admin/admin.css">
|
<link rel="stylesheet" href="/admin/admin.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
@@ -41,7 +41,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="login-field">
|
<div class="login-field">
|
||||||
<span class="ico"><img class="ci" src="/admin/icons/lock.svg" width="18" height="18" alt=""></span>
|
<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="请输入密码">
|
<input id="login-password" type="password" autocomplete="current-password" maxlength="20"
|
||||||
|
placeholder="请输入密码">
|
||||||
<button type="button" class="eye" id="login-eye" title="显示密码">
|
<button type="button" class="eye" id="login-eye" title="显示密码">
|
||||||
<img class="ci" src="/admin/icons/eye.svg" width="18" height="18" alt="">
|
<img class="ci" src="/admin/icons/eye.svg" width="18" height="18" alt="">
|
||||||
</button>
|
</button>
|
||||||
@@ -79,7 +80,8 @@
|
|||||||
<span class="mi-text">财务管理</span>
|
<span class="mi-text">财务管理</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="module-item" data-module="ai">
|
<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-icon"><img class="ci" src="/admin/icons/assistant.svg" width="18" height="18"
|
||||||
|
alt=""></span>
|
||||||
<span class="mi-text">AI 助手</span>
|
<span class="mi-text">AI 助手</span>
|
||||||
</button>
|
</button>
|
||||||
<!-- 以后的模块(数据看板…)往这里加:这里加一个 button,
|
<!-- 以后的模块(数据看板…)往这里加:这里加一个 button,
|
||||||
@@ -118,7 +120,8 @@
|
|||||||
<div class="breadcrumb" id="breadcrumb"></div>
|
<div class="breadcrumb" id="breadcrumb"></div>
|
||||||
|
|
||||||
<div id="dropzone">
|
<div id="dropzone">
|
||||||
把 <b>.md</b> 文件拖到这里批量导入<span id="dropzone-hint"></span>,或 <a href="#" id="pick-files">点击选择文件</a>
|
把 <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">
|
<input type="file" id="file-input" accept=".md,.markdown,.txt" multiple style="display:none">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -145,7 +148,9 @@
|
|||||||
<option value="1">只看支出</option>
|
<option value="1">只看支出</option>
|
||||||
<option value="2">只看收入</option>
|
<option value="2">只看收入</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="fin-cat" title="按分类筛选"><option value="">全部分类</option></select>
|
<select id="fin-cat" title="按分类筛选">
|
||||||
|
<option value="">全部分类</option>
|
||||||
|
</select>
|
||||||
<div class="spacer"></div>
|
<div class="spacer"></div>
|
||||||
<span class="toolbar-note" id="fin-note"></span>
|
<span class="toolbar-note" id="fin-note"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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])
|
||||||
Reference in New Issue
Block a user