强宝健康模块

This commit is contained in:
2026-09-28 16:41:53 +08:00
parent f0056441f6
commit 479364b97f
32 changed files with 2374 additions and 1 deletions
@@ -0,0 +1,134 @@
package com.accounting.controller;
import com.accounting.dto.DietAddRequest;
import com.accounting.entity.User;
import com.accounting.mapper.UserMapper;
import com.accounting.service.DietService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
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.*;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 饮食记录接口。
*
* <p>路径前缀 {@code /api/health/**} —— 体重和饮食同属「强宝健康」模块,
* 但它们性质差得远(一个是标量趋势、一个是明细流水),所以 Service 分开,
* 只在 Controller 这一层用不同的子路径归到同一个模块下。</p>
*/
@Tag(name = "强宝健康 · 饮食", description = "饮食记录、每日营养汇总")
@RestController
@RequestMapping("/api/health/diet")
public class DietController {
@Autowired
private DietService dietService;
@Autowired
private UserMapper userMapper;
@Operation(summary = "某天的饮食明细 + 全天营养合计(date 不传 = 今天)")
@GetMapping("/day")
public ResponseEntity<Map<String, Object>> day(@RequestParam(required = false) String date,
Authentication authentication) {
Long userId = getUserId(authentication);
LocalDate day = parseDate(date);
return ResponseEntity.ok(Map.of("success", true, "data", dietService.day(userId, day)));
}
@Operation(summary = "今日热量合计(首页卡片用,只返回一个数字)")
@GetMapping("/today")
public ResponseEntity<Map<String, Object>> today(Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(Map.of("success", true, "data", dietService.todaySummary(userId)));
}
@Operation(summary = "加一条饮食记录")
@PostMapping
public ResponseEntity<?> add(@Valid @RequestBody DietAddRequest request,
Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(dietService.add(userId, request.getRecordDate(),
request.getMealType(), request.getFoodId(), request.getAmountGrams()));
}
@Operation(summary = "改份量(营养值会跟着重算)")
@PutMapping("/{id}")
public ResponseEntity<?> updateAmount(@PathVariable Long id,
@RequestBody Map<String, BigDecimal> body,
Authentication authentication) {
Long userId = getUserId(authentication);
BigDecimal grams = body.get("amountGrams");
if (grams == null) {
throw new IllegalArgumentException("份量不能为空");
}
return ResponseEntity.ok(dietService.updateAmount(userId, id, grams));
}
@Operation(summary = "删除一条饮食记录")
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id,
Authentication authentication) {
Long userId = getUserId(authentication);
dietService.delete(userId, id);
return ResponseEntity.ok(Map.of("success", true));
}
@Operation(summary = "最近 N 天的每日热量,给趋势图用")
@GetMapping("/trend")
public ResponseEntity<Map<String, Object>> trend(@RequestParam(defaultValue = "7") int days,
Authentication authentication) {
Long userId = getUserId(authentication);
List<Map<String, Object>> points = dietService.energyTrend(userId, days);
return ResponseEntity.ok(Map.of("success", true, "points", points));
}
// ---------------------------------------------------------------- 内部
/**
* 参数校验失败直接抛 IllegalArgumentException,会被下面的
* {@link #handleBadRequest} 接住转成 400 + 可读消息。
*
* <p>本模块自带一个局部异常处理器,而不是加全局的 —— 全局的会改变
* 所有既有接口的错误行为(现在它们返回 500),那是一次没有必要的回归面。</p>
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "message", e.getMessage()));
}
/** yyyy-MM-dd;解析不了就按今天算,不让一个格式错误把整个请求打回。 */
private LocalDate parseDate(String raw) {
if (raw == null || raw.isBlank()) {
return LocalDate.now();
}
try {
return LocalDate.parse(raw.trim());
} catch (Exception e) {
return LocalDate.now();
}
}
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,54 @@
package com.accounting.controller;
import com.accounting.service.FoodService;
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.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* 食物营养成分查询接口。
*
* <p>数据源是 food_nutrition 表(《中国食物成分表》落库),全局共享、
* 不按用户隔离 —— 食物营养值不是用户数据。</p>
*/
@Tag(name = "强宝健康 · 食物库", description = "食物搜索、分类浏览")
@RestController
@RequestMapping("/api/health/food")
public class FoodController {
@Autowired
private FoodService foodService;
@Operation(summary = "按名称/别名搜索食物")
@GetMapping("/search")
public ResponseEntity<Map<String, Object>> search(@RequestParam(required = false) String keyword) {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("list", foodService.search(keyword));
return ResponseEntity.ok(result);
}
@Operation(summary = "分类浏览:不传 category 给一级分类列表,传了给该分类下的食物")
@GetMapping("/browse")
public ResponseEntity<Map<String, Object>> browse(@RequestParam(required = false) String category,
@RequestParam(required = false) String subCategory) {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("list", foodService.browse(category, subCategory));
return ResponseEntity.ok(result);
}
@Operation(summary = "某个一级分类下的二级分类")
@GetMapping("/sub-categories")
public ResponseEntity<Map<String, Object>> subCategories(@RequestParam String category) {
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("list", foodService.subCategories(category));
return ResponseEntity.ok(result);
}
}
@@ -0,0 +1,110 @@
package com.accounting.controller;
import com.accounting.dto.WeightGoalRequest;
import com.accounting.dto.WeightSaveRequest;
import com.accounting.entity.User;
import com.accounting.mapper.UserMapper;
import com.accounting.service.WeightService;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
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.*;
import java.util.Map;
/**
* 体重记录 + 减重目标接口
*/
@Tag(name = "强宝健康 · 体重", description = "体重记录、折线图、减重目标")
@RestController
@RequestMapping("/api/health/weight")
public class WeightController {
@Autowired
private WeightService weightService;
@Autowired
private UserMapper userMapper;
@Operation(summary = "记一次体重(同一天再记就是覆盖)")
@PostMapping
public ResponseEntity<?> save(@Valid @RequestBody WeightSaveRequest request,
Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(weightService.save(userId, request.getRecordDate(),
request.getWeight(), request.getNote()));
}
@Operation(summary = "折线图数据 + 当前体重 + 与上次的差值")
@GetMapping("/chart")
public ResponseEntity<Map<String, Object>> chart(@RequestParam(defaultValue = "90") int days,
Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(weightService.chart(userId, days));
}
@Operation(summary = "历史记录(新的在前,最多 100 条)")
@GetMapping("/history")
public ResponseEntity<?> history(Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(weightService.history(userId));
}
@Operation(summary = "删除一条体重记录")
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> delete(@PathVariable Long id,
Authentication authentication) {
Long userId = getUserId(authentication);
weightService.deleteRecord(userId, id);
return ResponseEntity.ok(Map.of("success", true));
}
// ---------------------------------------------------------------- 减重目标
@Operation(summary = "当前减重目标 + 每周应减 + 理想体重曲线(没设过时 hasGoal=false)")
@GetMapping("/goal")
public ResponseEntity<Map<String, Object>> goal(Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(weightService.goalDetail(userId));
}
@Operation(summary = "设置减重目标(覆盖当前进行中的目标)")
@PostMapping("/goal")
public ResponseEntity<?> saveGoal(@Valid @RequestBody WeightGoalRequest request,
Authentication authentication) {
Long userId = getUserId(authentication);
return ResponseEntity.ok(weightService.saveGoal(userId, request.getStartWeight(),
request.getTargetWeight(), request.getStartDate(), request.getTargetDate()));
}
// ---------------------------------------------------------------- 内部
/**
* 本模块的局部异常处理:校验失败返回 400 + 可读消息,而不是 500。
*
* <p>只作用于本 Controller,不影响既有接口 —— 给整个项目加全局处理器
* 会改变其他模块现有的错误行为,那是没必要的回归面。</p>
*/
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest()
.body(Map.of("success", false, "message", e.getMessage()));
}
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,27 @@
package com.accounting.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 加一条饮食记录。营养值由服务端按 foodId + amountGrams 算出来,
* 客户端不要传 —— 那些是结果不是输入。
*/
@Data
public class DietAddRequest {
@NotNull(message = "食物不能为空")
private Long foodId;
@NotNull(message = "份量不能为空")
private BigDecimal amountGrams;
/** breakfast / lunch / dinner / snack;不传按 snack 算 */
private String mealType;
/** 不传 = 今天 */
private LocalDate recordDate;
}
@@ -0,0 +1,44 @@
package com.accounting.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/**
* 某一天的饮食情况:按餐次分组的明细 + 全天合计。
*
* <p>合计是服务端 SUM 出来的,不是把明细加起来 —— 明细可能分页,
* 而合计必须是全天的。</p>
*/
@Data
public class DietDayResponse {
private LocalDate date;
/** 全天合计 */
private NutritionTotals totals;
/** 按餐次分组,顺序固定为 早/午/晚/加餐(空餐次也保留,前端不用补位) */
private List<MealGroup> meals;
@Data
public static class NutritionTotals {
private BigDecimal energyKcal = BigDecimal.ZERO;
private BigDecimal protein = BigDecimal.ZERO;
private BigDecimal fat = BigDecimal.ZERO;
private BigDecimal carbohydrate = BigDecimal.ZERO;
private BigDecimal dietaryFiber = BigDecimal.ZERO;
private BigDecimal sodium = BigDecimal.ZERO;
private int itemCount;
}
@Data
public static class MealGroup {
/** breakfast / lunch / dinner / snack */
private String mealType;
private NutritionTotals totals;
private List<DietRecordResponse> items;
}
}
@@ -0,0 +1,41 @@
package com.accounting.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 一条饮食记录
*/
@Data
public class DietRecordResponse {
private Long id;
private LocalDate recordDate;
/** breakfast / lunch / dinner / snack */
private String mealType;
private Long foodId;
private String foodName;
private BigDecimal amountGrams;
private BigDecimal energyKcal;
private BigDecimal protein;
private BigDecimal fat;
private BigDecimal carbohydrate;
private BigDecimal dietaryFiber;
private BigDecimal sodium;
private LocalDateTime createTime;
}
@@ -0,0 +1,7 @@
package com.accounting.dto;
/**
* 食物分类 + 该分类下的食物数(分类浏览入口用)
*/
public record FoodCategoryResponse(String name, long count) {
}
@@ -0,0 +1,42 @@
package com.accounting.dto;
import lombok.Data;
/**
* 食物条目(含换算好的 kcal)
*
* <p>不暴露 {@code energyKj} —— 表里存的是千焦,但减肥场景大家都看大卡。
* 换算统一在服务端做,前端拿到的就只有一个口径,不会出现两种单位混着显示。</p>
*/
@Data
public class FoodItemResponse {
private Long id;
private String name;
private String alias;
private String categoryName;
private String subCategoryName;
/** 食部 %。展示用参考值,不参与热量换算(详见 FoodService 注释)。 */
private Double ediblePercent;
/** 每 100g 的能量 kcal */
private Double energyKcal;
private Double protein;
private Double fat;
private Double carbohydrate;
private Double dietaryFiber;
private Double sodium;
/** 推荐的份量档位,由 FoodPortionRules 按分类/名称推出 */
private java.util.List<FoodPortionItem> portions;
}
@@ -0,0 +1,10 @@
package com.accounting.dto;
/**
* 一个份量档位,如「一碗 150g」。
*
* <p>用 record 而不是 @Data class:这是个不可变的值,没有 setter 可写,
* 也没必要为它生成 equals/hashCode 之外的一堆方法。</p>
*/
public record FoodPortionItem(String label, int grams) {
}
@@ -0,0 +1,24 @@
package com.accounting.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 设置减重目标。设置会覆盖当前进行中的目标(旧的保留为历史)。
*/
@Data
public class WeightGoalRequest {
@NotNull(message = "起始体重不能为空")
private BigDecimal startWeight;
@NotNull(message = "目标体重不能为空")
private BigDecimal targetWeight;
private LocalDate startDate;
private LocalDate targetDate;
}
@@ -0,0 +1,21 @@
package com.accounting.dto;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 一条体重记录
*/
@Data
public class WeightRecordResponse {
private Long id;
private LocalDate recordDate;
private BigDecimal weight;
private String note;
}
@@ -0,0 +1,23 @@
package com.accounting.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 记一次体重。recordDate 不传按今天算,同一天再记就是覆盖。
*/
@Data
public class WeightSaveRequest {
@NotNull(message = "体重不能为空")
private BigDecimal weight;
/** 不传 = 今天 */
private LocalDate recordDate;
/** 备注;不传(null)= 不改动已有备注,传空串 = 清空 */
private String note;
}
@@ -0,0 +1,59 @@
package com.accounting.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 饮食记录
*
* <p><b>营养值是快照</b>:记录时按份量算好一份抄下来,而不是只存
* food_id + 克数去实时关联。原因是 food_nutrition 将来重新抓取时数值
* 可能被修正,而三个月前那顿饭吃进去多少是既成事实,不该被改写。</p>
*/
@Data
@TableName("diet_record")
public class DietRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
private LocalDate recordDate;
/** breakfast / lunch / dinner / snack */
private String mealType;
/** food_nutrition.id */
private Long foodId;
/** 食物名(冗余,食物被删也能显示) */
private String foodName;
/** 份量 g */
private BigDecimal amountGrams;
// ---- 营养快照(按 amountGrams 换算后的实际值,不是每 100g)----
private BigDecimal energyKcal;
private BigDecimal protein;
private BigDecimal fat;
private BigDecimal carbohydrate;
private BigDecimal dietaryFiber;
private BigDecimal sodium;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,54 @@
package com.accounting.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
/**
* 食物营养(food_nutrition 表的读取视图)
*
* <p>表里其实有 40 个字段,这里只映射用得到的 —— 饮食记录只关心
* 能量和五大营养素,其余(维生素、脂肪酸构成等)现在用不上,
* 映射了也只会让实体变成一个巨大的字段堆。</p>
*
* <p><b>单位提醒</b>:所有值都是「每 100g 可食部」,且能量存的是
* <b>kJ</b>(表源自中国食物成分表,习惯用千焦)。对外的 kcal 由
* FoodService 换算,不要把 energyKj 直接透给前端。</p>
*/
@Data
@TableName("food_nutrition")
public class FoodNutrition {
@TableId(type = IdType.AUTO)
private Long id;
/** 来源食物ID(详情页用),与主键 id 不是一回事 */
private Integer foodId;
private String name;
private String alias;
private String categoryName;
private String subCategoryName;
/** 食部 %(鸡蛋 88 表示壳占 12%)。只做展示,不参与热量换算。 */
private BigDecimal ediblePercent;
/** 能量 kJ(每 100g) */
private BigDecimal energyKj;
private BigDecimal protein;
private BigDecimal fat;
private BigDecimal carbohydrate;
private BigDecimal dietaryFiber;
private BigDecimal sodium;
}
@@ -0,0 +1,46 @@
package com.accounting.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 减重目标
*
* <p>业务上「只有一个进行中的目标」:设置新目标时把旧的 {@code isActive} 置 0。
* 旧行保留而不是删掉,是为了将来能做「历史战绩」—— 界面现在只读
* isActive=1 那条,所以行为上等价于「单条覆盖」。</p>
*/
@Data
@TableName("weight_goal")
public class WeightGoal {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
/** 起始体重 kg */
private BigDecimal startWeight;
/** 目标体重 kg */
private BigDecimal targetWeight;
/** 开始日期 */
private LocalDate startDate;
/** 目标完成日期 */
private LocalDate targetDate;
/** 是否进行中:0否 1是 */
private Integer isActive;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,39 @@
package com.accounting.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 体重记录(一天一条)
*
* <p>「一天一条」靠 {@code uk_user_date} 唯一键 + Service 层的
* 「先查再更新」实现,不是靠应用层去重 —— 并发下也能保证不出现两条。</p>
*/
@Data
@TableName("weight_record")
public class WeightRecord {
@TableId(type = IdType.AUTO)
private Long id;
private Long userId;
/** 记录日期,一天一条 */
private LocalDate recordDate;
/** 体重 kg */
private BigDecimal weight;
/** 备注,比如「早上空腹」 */
private String note;
private LocalDateTime createTime;
private LocalDateTime updateTime;
}
@@ -0,0 +1,46 @@
package com.accounting.mapper;
import com.accounting.entity.DietRecord;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@Mapper
public interface DietRecordMapper extends BaseMapper<DietRecord> {
/**
* 某一天各餐次的营养合计。
*
* <p>用 SUM 而不是把记录捞回来在 Java 里加:合计只需要 6 个数字,
* 没必要为了一句「今天吃了 1280 kcal」把几十行明细全查出来。</p>
*/
@Select("SELECT meal_type AS mealType, "
+ " COUNT(*) AS itemCount, "
+ " SUM(energy_kcal) AS energyKcal, "
+ " SUM(protein) AS protein, "
+ " SUM(fat) AS fat, "
+ " SUM(carbohydrate) AS carbohydrate, "
+ " SUM(dietary_fiber) AS dietaryFiber, "
+ " SUM(sodium) AS sodium "
+ "FROM diet_record "
+ "WHERE user_id = #{userId} AND record_date = #{date} "
+ "GROUP BY meal_type")
List<Map<String, Object>> sumByMeal(@Param("userId") Long userId,
@Param("date") LocalDate date);
/**
* 最近 N 天的每日热量合计,给「本周热量趋势」用。
*/
@Select("SELECT record_date AS date, SUM(energy_kcal) AS energyKcal "
+ "FROM diet_record "
+ "WHERE user_id = #{userId} AND record_date BETWEEN #{from} AND #{to} "
+ "GROUP BY record_date ORDER BY record_date")
List<Map<String, Object>> dailyEnergyBetween(@Param("userId") Long userId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
}
@@ -0,0 +1,48 @@
package com.accounting.mapper;
import com.accounting.entity.FoodNutrition;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
import java.util.Map;
@Mapper
public interface FoodNutritionMapper extends BaseMapper<FoodNutrition> {
/**
* 按名称模糊搜索。走 idx_name 索引,但前置通配符用不上索引 ——
* 1356 条的数据量下全表扫也就几毫秒,不值得为它上全文索引。
*
* <p>排序把「名字更短的排前面」当作相关性近似:搜「苹果」时
* 「苹果」要排在「苹果脯」前面。</p>
*/
@Select("SELECT id, food_id, name, alias, category_name, sub_category_name, "
+ " edible_percent, energy_kj, protein, fat, carbohydrate, "
+ " dietary_fiber, sodium "
+ "FROM food_nutrition "
+ "WHERE name LIKE CONCAT('%', #{keyword}, '%') "
+ " OR alias LIKE CONCAT('%', #{keyword}, '%') "
+ "ORDER BY CHAR_LENGTH(name), id "
+ "LIMIT #{limit}")
List<FoodNutrition> search(@Param("keyword") String keyword, @Param("limit") int limit);
/**
* 一级分类列表 + 各自的食物数,给分类浏览的入口用。
*/
@Select("SELECT category_name AS name, COUNT(*) AS cnt "
+ "FROM food_nutrition WHERE category_name IS NOT NULL "
+ "GROUP BY category_name ORDER BY cnt DESC")
List<Map<String, Object>> categorySummary();
/**
* 某个一级分类下的二级分类列表。
*/
@Select("SELECT sub_category_name AS name, COUNT(*) AS cnt "
+ "FROM food_nutrition "
+ "WHERE category_name = #{category} AND sub_category_name IS NOT NULL "
+ "GROUP BY sub_category_name ORDER BY cnt DESC")
List<Map<String, Object>> subCategorySummary(@Param("category") String category);
}
@@ -0,0 +1,9 @@
package com.accounting.mapper;
import com.accounting.entity.WeightGoal;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface WeightGoalMapper extends BaseMapper<WeightGoal> {
}
@@ -0,0 +1,37 @@
package com.accounting.mapper;
import com.accounting.entity.WeightRecord;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
@Mapper
public interface WeightRecordMapper extends BaseMapper<WeightRecord> {
/**
* 按日期区间取记录,返回 (record_date, weight) 供画折线图。
*
* <p>只取需要的两列,不把 note 也捞回来 —— 图表要的是整段趋势,
* 可能是几百个点,带上备注纯属浪费。</p>
*/
@Select("SELECT record_date AS date, weight FROM weight_record "
+ "WHERE user_id = #{userId} AND record_date BETWEEN #{from} AND #{to} "
+ "ORDER BY record_date")
List<Map<String, Object>> pointsBetween(@Param("userId") Long userId,
@Param("from") LocalDate from,
@Param("to") LocalDate to);
/**
* 有记录以来的最早日期。
*
* <p>用来决定折线图默认显示多长的时间窗 —— 没有它就只能拍一个
* 「近 30 天」,而如果用户只记过 3 次、都在两个月前,图会是空的。</p>
*/
@Select("SELECT MIN(record_date) FROM weight_record WHERE user_id = #{userId}")
LocalDate earliestDate(@Param("userId") Long userId);
}
@@ -0,0 +1,255 @@
package com.accounting.service;
import com.accounting.dto.DietDayResponse;
import com.accounting.dto.DietRecordResponse;
import com.accounting.entity.DietRecord;
import com.accounting.entity.FoodNutrition;
import com.accounting.mapper.DietRecordMapper;
import com.accounting.util.NutritionUnit;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 饮食记录。
*/
@Service
public class DietService {
/** 合法餐次,顺序 = 界面上的显示顺序 */
private static final List<String> MEAL_TYPES =
List.of("breakfast", "lunch", "dinner", "snack");
/** 单次份量上限。拦住「150 打成 1500」这种手滑。 */
private static final BigDecimal MAX_GRAMS = new BigDecimal("5000");
@Autowired
private DietRecordMapper dietMapper;
@Autowired
private FoodService foodService;
/**
* 加一条饮食记录。
*
* <p>营养值在这里按份量算好存下来(快照),不是存 food_id 让前端去关联 ——
* 见 {@code DietRecord} 的类注释。</p>
*/
@Transactional
public DietRecordResponse add(Long userId, LocalDate date, String mealType,
Long foodId, BigDecimal grams) {
String safeMeal = normalizeMeal(mealType);
LocalDate safeDate = date == null ? LocalDate.now() : date;
BigDecimal safeGrams = validateGrams(grams);
FoodNutrition food = foodService.findById(foodId);
if (food == null) {
throw new IllegalArgumentException("食物不存在");
}
DietRecord record = new DietRecord();
record.setUserId(userId);
record.setRecordDate(safeDate);
record.setMealType(safeMeal);
record.setFoodId(food.getId());
record.setFoodName(food.getName());
record.setAmountGrams(safeGrams);
// 快照:按份量把「每 100g」换算成「这一份」
record.setEnergyKcal(NutritionUnit.per100gToAmount(
NutritionUnit.kjToKcal(food.getEnergyKj()), safeGrams));
record.setProtein(NutritionUnit.per100gToAmount(food.getProtein(), safeGrams));
record.setFat(NutritionUnit.per100gToAmount(food.getFat(), safeGrams));
record.setCarbohydrate(NutritionUnit.per100gToAmount(food.getCarbohydrate(), safeGrams));
record.setDietaryFiber(NutritionUnit.per100gToAmount(food.getDietaryFiber(), safeGrams));
record.setSodium(NutritionUnit.per100gToAmount(food.getSodium(), safeGrams));
dietMapper.insert(record);
return toResponse(record);
}
/**
* 改份量。营养快照要跟着重算 —— 改了克数热量却没变就是错的。
*
* <p>重算用的是**原食物当前的数值**,不是当初存的那份。这点需要留意:
* 如果 food_nutrition 在这期间被重新抓取修正过,改份量会让这条记录
* 顺带更新成新数值。这是刻意的取舍 —— 比起「改了份量却保留旧单位热量」
* 导致的内部不一致,这个可接受。</p>
*/
@Transactional
public DietRecordResponse updateAmount(Long userId, Long id, BigDecimal grams) {
DietRecord record = requireOwn(userId, id);
BigDecimal safeGrams = validateGrams(grams);
FoodNutrition food = foodService.findById(record.getFoodId());
if (food == null) {
throw new IllegalArgumentException("原食物已不存在,请删除后重新记录");
}
record.setAmountGrams(safeGrams);
record.setEnergyKcal(NutritionUnit.per100gToAmount(
NutritionUnit.kjToKcal(food.getEnergyKj()), safeGrams));
record.setProtein(NutritionUnit.per100gToAmount(food.getProtein(), safeGrams));
record.setFat(NutritionUnit.per100gToAmount(food.getFat(), safeGrams));
record.setCarbohydrate(NutritionUnit.per100gToAmount(food.getCarbohydrate(), safeGrams));
record.setDietaryFiber(NutritionUnit.per100gToAmount(food.getDietaryFiber(), safeGrams));
record.setSodium(NutritionUnit.per100gToAmount(food.getSodium(), safeGrams));
dietMapper.updateById(record);
return toResponse(record);
}
@Transactional
public void delete(Long userId, Long id) {
requireOwn(userId, id);
dietMapper.deleteById(id);
}
/**
* 某一天的饮食:按餐次分组的明细 + 全天合计。
*
* <p>合计用 SQL SUM 而不是把明细加起来 —— 明细将来可能做分页,
* 而合计必须是全天的。</p>
*/
public DietDayResponse day(Long userId, LocalDate date) {
LocalDate safeDate = date == null ? LocalDate.now() : date;
List<DietRecord> all = dietMapper.selectList(
new LambdaQueryWrapper<DietRecord>()
.eq(DietRecord::getUserId, userId)
.eq(DietRecord::getRecordDate, safeDate)
.orderByAsc(DietRecord::getId));
// 合计:数值直接由明细累加。这里的明细就是全天全量(没有分页),
// 所以和 SQL SUM 等价,且省一次查询。
DietDayResponse.NutritionTotals totals = new DietDayResponse.NutritionTotals();
Map<String, List<DietRecord>> byMeal = new LinkedHashMap<>();
for (String meal : MEAL_TYPES) {
byMeal.put(meal, new ArrayList<>());
}
for (DietRecord record : all) {
byMeal.computeIfAbsent(record.getMealType(), k -> new ArrayList<>()).add(record);
totals.setEnergyKcal(totals.getEnergyKcal().add(nz(record.getEnergyKcal())));
totals.setProtein(totals.getProtein().add(nz(record.getProtein())));
totals.setFat(totals.getFat().add(nz(record.getFat())));
totals.setCarbohydrate(totals.getCarbohydrate().add(nz(record.getCarbohydrate())));
totals.setDietaryFiber(totals.getDietaryFiber().add(nz(record.getDietaryFiber())));
totals.setSodium(totals.getSodium().add(nz(record.getSodium())));
}
totals.setItemCount(all.size());
List<DietDayResponse.MealGroup> meals = new ArrayList<>();
byMeal.forEach((meal, items) -> {
DietDayResponse.MealGroup group = new DietDayResponse.MealGroup();
group.setMealType(meal);
group.setItems(items.stream().map(this::toResponse).toList());
// 每个餐次也给小计,界面上「午餐 650 kcal」要靠它
DietDayResponse.NutritionTotals mealTotals = new DietDayResponse.NutritionTotals();
for (DietRecord record : items) {
mealTotals.setEnergyKcal(mealTotals.getEnergyKcal().add(nz(record.getEnergyKcal())));
mealTotals.setProtein(mealTotals.getProtein().add(nz(record.getProtein())));
mealTotals.setFat(mealTotals.getFat().add(nz(record.getFat())));
mealTotals.setCarbohydrate(
mealTotals.getCarbohydrate().add(nz(record.getCarbohydrate())));
mealTotals.setDietaryFiber(
mealTotals.getDietaryFiber().add(nz(record.getDietaryFiber())));
mealTotals.setSodium(mealTotals.getSodium().add(nz(record.getSodium())));
}
mealTotals.setItemCount(items.size());
group.setTotals(mealTotals);
meals.add(group);
});
DietDayResponse response = new DietDayResponse();
response.setDate(safeDate);
response.setTotals(totals);
response.setMeals(meals);
return response;
}
/** 最近 N 天的每日热量,给趋势图用 */
public List<Map<String, Object>> energyTrend(Long userId, int days) {
int safeDays = Math.min(Math.max(days, 1), 365);
LocalDate today = LocalDate.now();
return dietMapper.dailyEnergyBetween(userId, today.minusDays(safeDays - 1L), today);
}
/**
* 今日热量合计(首页卡片用)。
*
* <p>只走一次 SUM 查询,不返回明细 —— 首页卡片只要一个数字。</p>
*/
public Map<String, Object> todaySummary(Long userId) {
DietDayResponse day = day(userId, LocalDate.now());
Map<String, Object> result = new java.util.HashMap<>();
result.put("energyKcal", day.getTotals().getEnergyKcal());
result.put("itemCount", day.getTotals().getItemCount());
result.put("date", day.getDate().toString());
return result;
}
// ==================================================================
private DietRecord requireOwn(Long userId, Long id) {
DietRecord record = dietMapper.selectById(id);
if (record == null || !record.getUserId().equals(userId)) {
throw new IllegalArgumentException("记录不存在");
}
return record;
}
private String normalizeMeal(String mealType) {
if (!StringUtils.hasText(mealType)) {
return "snack";
}
String value = mealType.trim().toLowerCase();
if (!MEAL_TYPES.contains(value)) {
throw new IllegalArgumentException("餐次不合法");
}
return value;
}
private BigDecimal validateGrams(BigDecimal grams) {
if (grams == null || grams.signum() <= 0) {
throw new IllegalArgumentException("份量必须大于 0");
}
if (grams.compareTo(MAX_GRAMS) > 0) {
throw new IllegalArgumentException("份量不能超过 " + MAX_GRAMS.intValue() + " g");
}
return grams.setScale(1, java.math.RoundingMode.HALF_UP);
}
private BigDecimal nz(BigDecimal value) {
return value == null ? BigDecimal.ZERO : value;
}
private DietRecordResponse toResponse(DietRecord record) {
DietRecordResponse response = new DietRecordResponse();
response.setId(record.getId());
response.setRecordDate(record.getRecordDate());
response.setMealType(record.getMealType());
response.setFoodId(record.getFoodId());
response.setFoodName(record.getFoodName());
response.setAmountGrams(record.getAmountGrams());
response.setEnergyKcal(record.getEnergyKcal());
response.setProtein(record.getProtein());
response.setFat(record.getFat());
response.setCarbohydrate(record.getCarbohydrate());
response.setDietaryFiber(record.getDietaryFiber());
response.setSodium(record.getSodium());
response.setCreateTime(record.getCreateTime());
return response;
}
}
@@ -0,0 +1,135 @@
package com.accounting.service;
import com.accounting.dto.FoodCategoryResponse;
import com.accounting.dto.FoodItemResponse;
import com.accounting.entity.FoodNutrition;
import com.accounting.mapper.FoodNutritionMapper;
import com.accounting.util.FoodPortionRules;
import com.accounting.util.NutritionUnit;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 食物营养成分查询。
*
* <p>对外一律给 kcal,不给 kJ —— 换算在这里做掉(见 {@link NutritionUnit}),
* 前端就不会出现两种能量单位混用的情况。</p>
*/
@Service
public class FoodService {
/** 搜索最多返回多少条。够用了:搜不到精确名字应该去分类里翻,而不是翻 50 页。 */
private static final int SEARCH_LIMIT = 40;
/** 分类下的食物列表上限。品类最多的蔬菜类有 190 条,全量返回没有意义。 */
private static final int CATEGORY_LIMIT = 200;
@Autowired
private FoodNutritionMapper foodMapper;
/**
* 按名称/别名模糊搜索。
*
* <p>关键词为空时返回空列表而不是全表 —— 「没输入」和「搜所有」是两回事。</p>
*/
public List<FoodItemResponse> search(String keyword) {
if (!StringUtils.hasText(keyword)) {
return List.of();
}
return foodMapper.search(keyword.trim(), SEARCH_LIMIT)
.stream().map(this::toResponse).toList();
}
/**
* 分类浏览:不传分类给一级分类列表,传了给该分类下的食物。
*
* <p>合成一个接口是因为前端就是一个页面:先看分类,点进去看该分类的食物。
* 拆成两个接口只会让前端多写一套 loading 状态。</p>
*/
public List<?> browse(String category, String subCategory) {
if (!StringUtils.hasText(category)) {
return foodMapper.categorySummary().stream()
.map(row -> new FoodCategoryResponse(
(String) row.get("name"),
row.get("cnt") == null ? 0L : ((Number) row.get("cnt")).longValue()))
.toList();
}
LambdaQueryWrapper<FoodNutrition> wrapper =
new LambdaQueryWrapper<FoodNutrition>()
.select(FoodNutrition::getId, FoodNutrition::getFoodId,
FoodNutrition::getName, FoodNutrition::getAlias,
FoodNutrition::getCategoryName, FoodNutrition::getSubCategoryName,
FoodNutrition::getEdiblePercent,
FoodNutrition::getEnergyKj, FoodNutrition::getProtein,
FoodNutrition::getFat, FoodNutrition::getCarbohydrate,
FoodNutrition::getDietaryFiber, FoodNutrition::getSodium)
.eq(FoodNutrition::getCategoryName, category.trim());
if (StringUtils.hasText(subCategory)) {
wrapper.eq(FoodNutrition::getSubCategoryName, subCategory.trim());
}
wrapper.orderByAsc(FoodNutrition::getId).last("LIMIT " + CATEGORY_LIMIT);
return foodMapper.selectList(wrapper).stream().map(this::toResponse).toList();
}
/** 某个一级分类下的二级分类 */
public List<FoodCategoryResponse> subCategories(String category) {
if (!StringUtils.hasText(category)) {
return List.of();
}
List<FoodCategoryResponse> result = new ArrayList<>();
for (Map<String, Object> row : foodMapper.subCategorySummary(category.trim())) {
Object cnt = row.get("cnt");
result.add(new FoodCategoryResponse(
(String) row.get("name"),
cnt == null ? 0L : ((Number) cnt).longValue()));
}
return result;
}
/** 按主键取一条(记饮食时用它拿营养值) */
public FoodNutrition findById(Long id) {
return foodMapper.selectById(id);
}
/**
* 实体 → 响应,负责两件事:kJ 换 kcal、附上份量档位。
*
* <p>份量档位是算出来的不是查出来的(见 {@code FoodPortionRules}),
* 所以每条食物都有,不需要额外维护关联数据。</p>
*/
public FoodItemResponse toResponse(FoodNutrition food) {
FoodItemResponse response = new FoodItemResponse();
response.setId(food.getId());
response.setName(food.getName());
response.setAlias(food.getAlias());
response.setCategoryName(food.getCategoryName());
response.setSubCategoryName(food.getSubCategoryName());
response.setEdiblePercent(toDouble(food.getEdiblePercent()));
BigDecimal kcal = NutritionUnit.kjToKcal(food.getEnergyKj());
response.setEnergyKcal(kcal == null ? null : kcal.doubleValue());
response.setProtein(toDouble(food.getProtein()));
response.setFat(toDouble(food.getFat()));
response.setCarbohydrate(toDouble(food.getCarbohydrate()));
response.setDietaryFiber(toDouble(food.getDietaryFiber()));
response.setSodium(toDouble(food.getSodium()));
response.setPortions(FoodPortionRules.portions(food.getCategoryName(), food.getName()));
return response;
}
private Double toDouble(BigDecimal value) {
return value == null ? null : value.doubleValue();
}
}
@@ -0,0 +1,332 @@
package com.accounting.service;
import com.accounting.dto.WeightRecordResponse;
import com.accounting.entity.WeightGoal;
import com.accounting.entity.WeightRecord;
import com.accounting.mapper.WeightGoalMapper;
import com.accounting.mapper.WeightRecordMapper;
import com.accounting.util.WeightPlanCalculator;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 体重记录 + 减重目标。
*/
@Service
public class WeightService {
/** 折线图最多返回多少个点。再多屏幕上也是一团糊。 */
private static final int CHART_LIMIT = 180;
/** 历史列表最多返回多少条 */
private static final int HISTORY_LIMIT = 100;
/** 单次体重合理区间。防的是误输入(65 打成 650),不是防真实体重。 */
private static final BigDecimal MIN_WEIGHT = new BigDecimal("20");
private static final BigDecimal MAX_WEIGHT = new BigDecimal("300");
@Autowired
private WeightRecordMapper recordMapper;
@Autowired
private WeightGoalMapper goalMapper;
// ==================================================================
// 体重记录
// ==================================================================
/**
* 记一次体重。同一天再记就是覆盖。
*
* <p>「先查再更新」而不是直接 insert + 唯一键冲突重试:这里是
* 单用户的低频操作,不存在并发写同一天的可能,简单写法更清楚。</p>
*/
@Transactional
public WeightRecordResponse save(Long userId, LocalDate date, BigDecimal weight, String note) {
BigDecimal safeWeight = validateWeight(weight);
LocalDate safeDate = date == null ? LocalDate.now() : date;
WeightRecord existing = recordMapper.selectOne(
new LambdaQueryWrapper<WeightRecord>()
.eq(WeightRecord::getUserId, userId)
.eq(WeightRecord::getRecordDate, safeDate));
if (existing == null) {
WeightRecord record = new WeightRecord();
record.setUserId(userId);
record.setRecordDate(safeDate);
record.setWeight(safeWeight);
record.setNote(note);
recordMapper.insert(record);
return toResponse(record);
}
existing.setWeight(safeWeight);
// note 传 null 表示「不改备注」;传空串才表示「清空备注」
if (note != null) {
existing.setNote(note);
}
recordMapper.updateById(existing);
return toResponse(existing);
}
/**
* 折线图数据 + 当前体重 + 与上次的差值。
*
* <p>points 里只放有记录的日子,不补零点 —— 断更的那几天应该让线断着,
* 补 0 会画出一条冲到谷底的假线。</p>
*/
public Map<String, Object> chart(Long userId, int days) {
int safeDays = Math.min(Math.max(days, 7), 3650);
LocalDate today = LocalDate.now();
LocalDate from = today.minusDays(safeDays - 1L);
// 记过的第一天可能早于窗口(用户记了半年、只想看最近30天),
// 这时线的起点会显得突兀,所以额外把窗口前最近的一个点带进来
LocalDate earliest = recordMapper.earliestDate(userId);
if (earliest != null && earliest.isBefore(from)) {
WeightRecord lastBefore = recordMapper.selectOne(
new LambdaQueryWrapper<WeightRecord>()
.eq(WeightRecord::getUserId, userId)
.lt(WeightRecord::getRecordDate, from)
.orderByDesc(WeightRecord::getRecordDate)
.last("LIMIT 1"));
if (lastBefore != null) {
from = lastBefore.getRecordDate();
}
}
List<Map<String, Object>> rows = recordMapper.pointsBetween(userId, from, today);
List<Map<String, Object>> points = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> point = new HashMap<>();
point.put("date", String.valueOf(row.get("date")));
point.put("weight", row.get("weight"));
points.add(point);
}
Map<String, Object> result = new HashMap<>();
result.put("points", points);
result.put("currentWeight", points.isEmpty() ? null
: points.get(points.size() - 1).get("weight"));
// 与上一次记录的差值(正数 = 涨了)。只有一个点时没有「上一次」。
if (points.size() >= 2) {
BigDecimal last = new BigDecimal(points.get(points.size() - 1).get("weight").toString());
BigDecimal prev = new BigDecimal(points.get(points.size() - 2).get("weight").toString());
result.put("delta", last.subtract(prev).setScale(1, RoundingMode.HALF_UP));
} else {
result.put("delta", null);
}
// 窗口内的最高/最低,给图表算 Y 轴范围用(前端拿它避免线贴边)
if (!points.isEmpty()) {
BigDecimal max = null;
BigDecimal min = null;
for (Map<String, Object> point : points) {
BigDecimal w = new BigDecimal(point.get("weight").toString());
if (max == null || w.compareTo(max) > 0) max = w;
if (min == null || w.compareTo(min) < 0) min = w;
}
// 把目标体重也纳入范围,否则理想线可能画在可见区外
WeightGoal goal = activeGoal(userId);
if (goal != null) {
BigDecimal t = goal.getTargetWeight();
if (max == null || t.compareTo(max) > 0) max = t;
if (min == null || t.compareTo(min) < 0) min = t;
}
result.put("maxWeight", max);
result.put("minWeight", min);
}
return result;
}
/** 历史记录,新的在前 */
public List<WeightRecordResponse> history(Long userId) {
return recordMapper.selectList(
new LambdaQueryWrapper<WeightRecord>()
.eq(WeightRecord::getUserId, userId)
.orderByDesc(WeightRecord::getRecordDate)
.last("LIMIT " + HISTORY_LIMIT))
.stream().map(this::toResponse).toList();
}
@Transactional
public void deleteRecord(Long userId, Long id) {
WeightRecord record = recordMapper.selectById(id);
// 带 userId 校验:不能删别人的记录
if (record == null || !record.getUserId().equals(userId)) {
throw new IllegalArgumentException("记录不存在");
}
recordMapper.deleteById(id);
}
// ==================================================================
// 减重目标
// ==================================================================
/** 当前进行中的目标,没设过返回 null */
public WeightGoal activeGoal(Long userId) {
return goalMapper.selectOne(
new LambdaQueryWrapper<WeightGoal>()
.eq(WeightGoal::getUserId, userId)
.eq(WeightGoal::getIsActive, 1)
.orderByDesc(WeightGoal::getId)
.last("LIMIT 1"));
}
/**
* 设置减重目标,返回给前端展示的「每周该减多少」那一套。
*
* <p>把旧的置为 inactive 而不是删掉(留作历史),新的插一条 ——
* 这样「单条进行中」和「保留历史」两个诉求同时满足。</p>
*/
@Transactional
public Map<String, Object> saveGoal(Long userId, BigDecimal startWeight,
BigDecimal targetWeight, LocalDate startDate,
LocalDate targetDate) {
BigDecimal safeStart = validateWeight(startWeight);
BigDecimal safeTarget = validateWeight(targetWeight);
LocalDate safeStartDate = startDate == null ? LocalDate.now() : startDate;
LocalDate safeTargetDate = targetDate == null ? safeStartDate.plusMonths(3) : targetDate;
if (!safeTargetDate.isAfter(safeStartDate)) {
throw new IllegalArgumentException("目标日期必须晚于开始日期");
}
if (safeTarget.compareTo(safeStart) > 0) {
throw new IllegalArgumentException("目标体重不能高于起始体重");
}
goalMapper.update(null, new LambdaUpdateWrapper<WeightGoal>()
.eq(WeightGoal::getUserId, userId)
.eq(WeightGoal::getIsActive, 1)
.set(WeightGoal::getIsActive, 0));
WeightGoal goal = new WeightGoal();
goal.setUserId(userId);
goal.setStartWeight(safeStart);
goal.setTargetWeight(safeTarget);
goal.setStartDate(safeStartDate);
goal.setTargetDate(safeTargetDate);
goal.setIsActive(1);
goalMapper.insert(goal);
return goalPlan(userId, goal);
}
/**
* 目标详情:总周数、每周应减、理想体重曲线。
*
* <p>理想曲线是给折线图当参照线的 —— 用户一眼就能看出自己
* 在线的上方(落后)还是下方(超前)。</p>
*/
public Map<String, Object> goalDetail(Long userId) {
WeightGoal goal = activeGoal(userId);
if (goal == null) {
return Map.of("hasGoal", false);
}
return goalPlan(userId, goal);
}
private Map<String, Object> goalPlan(Long userId, WeightGoal goal) {
int weeks = WeightPlanCalculator.totalWeeks(goal.getStartDate(), goal.getTargetDate());
BigDecimal weeklyLoss = WeightPlanCalculator.weeklyLoss(
goal.getStartWeight(), goal.getTargetWeight(), weeks);
// 理想曲线:每周一个点,最后一点正好落在目标体重上
List<Map<String, Object>> idealCurve = new ArrayList<>();
for (int i = 0; i <= weeks; i++) {
LocalDate weekDate = goal.getStartDate().plusWeeks(i);
Map<String, Object> point = new HashMap<>();
point.put("date", weekDate.toString());
point.put("weight", WeightPlanCalculator.idealWeightAt(
goal.getStartWeight(), goal.getTargetWeight(), weeklyLoss, i));
idealCurve.add(point);
}
BigDecimal latest = latestWeight(userId);
BigDecimal lost = latest == null ? null
: goal.getStartWeight().subtract(latest).setScale(1, RoundingMode.HALF_UP);
BigDecimal remain = latest == null ? null
: latest.subtract(goal.getTargetWeight()).setScale(1, RoundingMode.HALF_UP);
// 体重进度:已减 / 总目标(0~1)。没记过体重时是 0,不是算出来的 NaN。
double weightProgress = 0.0;
BigDecimal total = goal.getStartWeight().subtract(goal.getTargetWeight());
if (latest != null && total.signum() > 0) {
double ratio = goal.getStartWeight().subtract(latest)
.divide(total, 4, RoundingMode.HALF_UP).doubleValue();
weightProgress = Math.min(1.0, Math.max(0.0, ratio));
}
Map<String, Object> result = new HashMap<>();
result.put("hasGoal", true);
result.put("id", goal.getId());
result.put("startWeight", goal.getStartWeight());
result.put("targetWeight", goal.getTargetWeight());
result.put("startDate", goal.getStartDate().toString());
result.put("targetDate", goal.getTargetDate().toString());
result.put("totalWeeks", weeks);
result.put("weeklyLoss", weeklyLoss);
// 每周掉超过 1kg 通常是掉肌肉和水,界面上要提示
result.put("weeklyLossUnhealthy",
weeklyLoss.compareTo(WeightPlanCalculator.HEALTHY_WEEKLY_LOSS_KG) > 0);
result.put("idealCurve", idealCurve);
result.put("currentWeight", latest);
result.put("lostWeight", lost);
result.put("remainWeight", remain);
result.put("weightProgress", weightProgress);
result.put("timeProgress", WeightPlanCalculator.timeProgress(
goal.getStartDate(), goal.getTargetDate(), LocalDate.now()));
return result;
}
/** 最近一次体重(不限于今天) */
private BigDecimal latestWeight(Long userId) {
WeightRecord latest = recordMapper.selectOne(
new LambdaQueryWrapper<WeightRecord>()
.eq(WeightRecord::getUserId, userId)
.orderByDesc(WeightRecord::getRecordDate)
.last("LIMIT 1"));
return latest == null ? null : latest.getWeight();
}
// ==================================================================
/**
* 体重合法性校验。
*
* <p>范围给得很宽(20~300kg)—— 目的是拦住「65 打成 650」这种手滑,
* 而不是替用户判断他的体重正不正常。</p>
*/
private BigDecimal validateWeight(BigDecimal weight) {
if (weight == null) {
throw new IllegalArgumentException("体重不能为空");
}
if (weight.compareTo(MIN_WEIGHT) < 0 || weight.compareTo(MAX_WEIGHT) > 0) {
throw new IllegalArgumentException("体重需在 20 ~ 300 kg 之间");
}
return weight.setScale(1, RoundingMode.HALF_UP);
}
private WeightRecordResponse toResponse(WeightRecord record) {
WeightRecordResponse response = new WeightRecordResponse();
response.setId(record.getId());
response.setRecordDate(record.getRecordDate());
response.setWeight(record.getWeight());
response.setNote(record.getNote());
return response;
}
}
@@ -0,0 +1,197 @@
package com.accounting.util;
import com.accounting.dto.FoodPortionItem;
import java.util.List;
import java.util.Map;
/**
* 份量档位规则:把「一碗米饭」这种日常说法映射成克数。
*
* <p>食物表全是「每 100g」的值,但用户记的是「一碗」「一个」「一杯」。
* 让每次吃饭都去估克数是不现实的(也没人真的去称),所以这里给每种
* 食物一组常用档位,用户点一下就行、也可以再改成具体克数。</p>
*
* <h3>为什么写在代码里而不是建一张表</h3>
* <p>这是一份「常识 + 少量精调」的静态映射,不是业务数据:它不随用户变化、
* 不需要后台维护、也不会被查询条件筛选。建表意味着多一套 CRUD 和一次
* 部署时的数据导入,换来的只是「改份量要重新打包」这个不便 —— 而实际
* 上改它的频率大概是半年一次。真到了需要用户自定义份量的那天,
* 再加一张 user_food_portion 覆盖表也不迟。</p>
*
* <h3>两层匹配</h3>
* <ol>
* <li><b>按名称</b>:最常吃的那些(米饭、鸡蛋、牛奶…)单独精修,
* 因为同一个大类里份量差异极大 —— 「谷类」既有一碗米饭也有一个馒头。</li>
* <li><b>按一级分类</b>:覆盖其余全部食物。粒度粗,但每条食物至少
* 都有档位可选,不会出现「这条食物点进去是空的」。</li>
* </ol>
*/
public final class FoodPortionRules {
private FoodPortionRules() {
}
/** 兜底:分类没匹配上时用(比如新抓进来的分类) */
private static final List<FoodPortionItem> GENERIC = List.of(
new FoodPortionItem("一小份", 50),
new FoodPortionItem("一份", 100),
new FoodPortionItem("一大份", 200));
/**
* 按一级分类给的默认档位。
*
* <p>数值取自中国居民膳食指南的常见份量概念(如「一把坚果约 25g」
* 「一勺油约 10g」),不是精确的膳食调查数据 —— 目标是「八九不离十」,
* 精确到克对记饮食这件事没有意义,反而会让用户放弃记录。</p>
*/
private static final Map<String, List<FoodPortionItem>> BY_CATEGORY = Map.ofEntries(
Map.entry("谷类及制品", List.of(
new FoodPortionItem("小碗", 100),
new FoodPortionItem("一碗", 150),
new FoodPortionItem("大碗", 250))),
Map.entry("薯类、淀粉及制品", List.of(
new FoodPortionItem("一个", 150),
new FoodPortionItem("一份", 100))),
Map.entry("干豆类及制品", List.of(
new FoodPortionItem("一把", 30),
new FoodPortionItem("一份", 50))),
Map.entry("蔬菜类及制品", List.of(
new FoodPortionItem("一小把", 50),
new FoodPortionItem("一份", 100),
new FoodPortionItem("一盘", 200))),
Map.entry("菌藻类", List.of(
new FoodPortionItem("一份(干)", 20),
new FoodPortionItem("一份", 100))),
Map.entry("水果类及制品", List.of(
new FoodPortionItem("一小个", 100),
new FoodPortionItem("一个", 200),
new FoodPortionItem("一大个", 300))),
Map.entry("坚果、种子类", List.of(
new FoodPortionItem("一小把", 15),
new FoodPortionItem("一把", 25))),
Map.entry("畜肉类及制品", List.of(
new FoodPortionItem("一小块", 50),
new FoodPortionItem("一块", 80),
new FoodPortionItem("一份", 100))),
Map.entry("禽肉类及制品", List.of(
new FoodPortionItem("一小块", 50),
new FoodPortionItem("一块", 80),
new FoodPortionItem("一只鸡腿", 100))),
Map.entry("鱼虾蟹贝类", List.of(
new FoodPortionItem("一只虾", 15),
new FoodPortionItem("一份", 100),
new FoodPortionItem("一条", 150))),
Map.entry("蛋类及制品", List.of(
new FoodPortionItem("一个", 50),
new FoodPortionItem("两个", 100))),
Map.entry("乳类及制品", List.of(
new FoodPortionItem("一小杯", 150),
new FoodPortionItem("一杯", 200),
new FoodPortionItem("一盒", 250))),
Map.entry("饮料类", List.of(
new FoodPortionItem("一罐", 330),
new FoodPortionItem("一杯", 250),
new FoodPortionItem("一瓶", 500))),
Map.entry("含酒精饮料", List.of(
new FoodPortionItem("一小杯", 50),
new FoodPortionItem("一杯", 100),
new FoodPortionItem("一瓶", 500))),
Map.entry("油脂类", List.of(
new FoodPortionItem("一茶匙", 5),
new FoodPortionItem("一勺", 10),
new FoodPortionItem("一份", 15))),
Map.entry("糖、果脯和蜜饯、蜂蜜", List.of(
new FoodPortionItem("一块", 10),
new FoodPortionItem("一勺", 15),
new FoodPortionItem("一份", 30))),
Map.entry("调味品类", List.of(
new FoodPortionItem("一茶匙", 5),
new FoodPortionItem("一勺", 10))),
Map.entry("小吃、甜饼", List.of(
new FoodPortionItem("一个", 50),
new FoodPortionItem("一块", 80),
new FoodPortionItem("一份", 100))),
Map.entry("速食食品", List.of(
new FoodPortionItem("一小包", 50),
new FoodPortionItem("一包", 100),
new FoodPortionItem("一个", 200))),
Map.entry("婴幼儿食品", List.of(
new FoodPortionItem("一勺", 30),
new FoodPortionItem("一份", 100))));
/**
* 按名称精修的高频食物。
*
* <p>键是「包含」匹配(食物名含这个子串就命中),所以「米饭」能覆盖
* 「米饭(蒸)」「蒸米饭」这些变体。遍历顺序固定,先命中的先用 ——
* 所以更具体的词要放在更泛的词前面(比如「牛奶」在「牛」之前)。</p>
*/
private static final List<Map.Entry<String, List<FoodPortionItem>>> BY_NAME = List.of(
Map.entry("米饭", List.of(
new FoodPortionItem("小碗", 100),
new FoodPortionItem("一碗", 150),
new FoodPortionItem("大碗", 250))),
Map.entry("粥", List.of(
new FoodPortionItem("一小碗", 200),
new FoodPortionItem("一碗", 300))),
Map.entry("面条", List.of(
new FoodPortionItem("一小碗", 150),
new FoodPortionItem("一碗", 250))),
Map.entry("馒头", List.of(
new FoodPortionItem("半个", 50),
new FoodPortionItem("一个", 100))),
Map.entry("包子", List.of(
new FoodPortionItem("一个", 80),
new FoodPortionItem("两个", 160))),
Map.entry("饺子", List.of(
new FoodPortionItem("一个", 15),
new FoodPortionItem("十个", 150))),
Map.entry("面包", List.of(
new FoodPortionItem("一片", 35),
new FoodPortionItem("一个", 80))),
Map.entry("鸡蛋", List.of(
new FoodPortionItem("一个", 50),
new FoodPortionItem("两个", 100))),
Map.entry("牛奶", List.of(
new FoodPortionItem("一杯", 200),
new FoodPortionItem("一盒", 250))),
Map.entry("酸奶", List.of(
new FoodPortionItem("一小杯", 100),
new FoodPortionItem("一杯", 150))),
Map.entry("豆浆", List.of(
new FoodPortionItem("一杯", 250))),
Map.entry("苹果", List.of(
new FoodPortionItem("半个", 100),
new FoodPortionItem("一个", 200))),
Map.entry("香蕉", List.of(
new FoodPortionItem("一根", 120))),
Map.entry("西瓜", List.of(
new FoodPortionItem("一块", 300))),
Map.entry("鸡胸", List.of(
new FoodPortionItem("一块", 100))),
Map.entry("鸡腿", List.of(
new FoodPortionItem("一只", 100))),
Map.entry("可乐", List.of(
new FoodPortionItem("一罐", 330),
new FoodPortionItem("一瓶", 500))));
/**
* 取这条食物的份量档位。永远返回非空列表 —— 用户点进去不该看到空白。
*/
public static List<FoodPortionItem> portions(String categoryName, String foodName) {
final String name = foodName == null ? "" : foodName;
for (Map.Entry<String, List<FoodPortionItem>> entry : BY_NAME) {
if (name.contains(entry.getKey())) {
return entry.getValue();
}
}
if (categoryName != null) {
List<FoodPortionItem> byCategory = BY_CATEGORY.get(categoryName);
if (byCategory != null) {
return byCategory;
}
}
return GENERIC;
}
}
@@ -0,0 +1,48 @@
package com.accounting.util;
import java.math.BigDecimal;
import java.math.RoundingMode;
/**
* 单位换算:食物表存的是千焦,界面看的是大卡。
*
* <p>换算系数取 1 kcal = 4.184 kJ(热化学卡定义)。这是食品标签的通行口径,
* 和《中国食物成分表》本身用的系数一致。</p>
*/
public final class NutritionUnit {
private NutritionUnit() {
}
private static final BigDecimal KJ_PER_KCAL = new BigDecimal("4.184");
/**
* kJ → kcal,保留 1 位小数。
*
* <p>入参为 null(表里允许空值)时返回 null,让调用方自己决定退化成什么,
* 而不是在这里悄悄变成 0 —— 0 kcal 和「没测过」是两回事。</p>
*/
public static BigDecimal kjToKcal(BigDecimal kj) {
if (kj == null) {
return null;
}
return kj.divide(KJ_PER_KCAL, 1, RoundingMode.HALF_UP);
}
/**
* 按份量把「每 100g」的值换算成「这一份」的值。
*
* <p>{@code grams} 为 0 或负数时返回 0 而不是抛异常:份量在界面里
* 是个输入框,用户可能清空,这种输入应该得到「零热量」而不是 500。</p>
*/
public static BigDecimal per100gToAmount(BigDecimal per100g, BigDecimal grams) {
if (per100g == null || grams == null) {
return BigDecimal.ZERO;
}
if (grams.signum() <= 0) {
return BigDecimal.ZERO;
}
return per100g.multiply(grams)
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
}
}
@@ -0,0 +1,94 @@
package com.accounting.util;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
/**
* 减重计划的计算:把「从 A 减到 B,用 N 天」拆成「每周该减多少」。
*
* <p>纯函数,不碰数据库 —— 目标是算术问题,单独放出来才好测边界。</p>
*/
public final class WeightPlanCalculator {
private WeightPlanCalculator() {
}
/** 每周减重超过这个值就提示不健康(掉的多半是肌肉和水) */
public static final BigDecimal HEALTHY_WEEKLY_LOSS_KG = BigDecimal.ONE;
/** 一周按 7 天算 */
private static final BigDecimal DAYS_PER_WEEK = new BigDecimal("7");
/**
* 总周数(向上取整,至少 1 周)。
*
* <p>向上取整的理由:3 天也应该算「1 周内完成」而不是 0 周 ——
* 0 周会让「每周应减」除零。同日(0 天)也归到 1 周。</p>
*/
public static int totalWeeks(LocalDate from, LocalDate to) {
long days = ChronoUnit.DAYS.between(from, to);
if (days <= 0) {
return 1;
}
return (int) Math.ceil(days / 7.0);
}
/**
* 每周应减重 kg(保留 2 位小数)。
*
* <p>总减重为 0 或负数(目标比起始还大)时返回 0,不返回负数 ——
* 「增重计划」不是这个模块的场景,返回负数会让进度条算反。</p>
*/
public static BigDecimal weeklyLoss(BigDecimal startWeight, BigDecimal targetWeight,
int weeks) {
if (startWeight == null || targetWeight == null) {
return BigDecimal.ZERO;
}
BigDecimal total = startWeight.subtract(targetWeight);
if (total.signum() <= 0) {
return BigDecimal.ZERO;
}
int safeWeeks = Math.max(weeks, 1);
return total.divide(new BigDecimal(safeWeeks), 2, RoundingMode.HALF_UP);
}
/**
* 第 N 周末的理想体重 = 起始体重 - 每周应减 × N。
*
* <p>不会低于目标体重 —— 计划结束时线应该平在目标值上,
* 而不是继续往下穿。</p>
*/
public static BigDecimal idealWeightAt(BigDecimal startWeight, BigDecimal targetWeight,
BigDecimal weeklyLoss, int week) {
if (startWeight == null || targetWeight == null) {
return BigDecimal.ZERO;
}
BigDecimal ideal = startWeight.subtract(
weeklyLoss.multiply(new BigDecimal(Math.max(week, 0))));
// 已经低于目标就钉在目标上
if (ideal.compareTo(targetWeight) < 0) {
return targetWeight;
}
return ideal.setScale(1, RoundingMode.HALF_UP);
}
/**
* 已过天数占总天数的比例(0.0 ~ 1.0),给进度条用。
*
* <p>还没开始返回 0,已经超期返回 1.0(不是 >1)——
* 进度条宽度是可变的 1 倍,超期再往下撑会溢出布局。</p>
*/
public static double timeProgress(LocalDate start, LocalDate target, LocalDate today) {
long total = ChronoUnit.DAYS.between(start, target);
if (total <= 0) {
return 1.0;
}
long passed = ChronoUnit.DAYS.between(start, today);
if (passed <= 0) {
return 0.0;
}
return Math.min(1.0, (double) passed / total);
}
}
@@ -0,0 +1,92 @@
-- 「强宝健康」模块建表(2026-09-28)
--
-- 三张表:
-- weight_record 体重记录(一天一条,同日覆盖)
-- weight_goal 减重目标(单条覆盖:只保留一个进行中的目标)
-- diet_record 饮食记录(营养值冗余存快照)
--
-- 依赖:food_nutrition 表(见 migration_add_food_nutrition.sql),
-- diet_record.food_id 关联的是 food_nutrition.id(主键),不是来源 food_id。
--
-- 执行方式(在 231 上):
-- docker exec -i mysql-8.0.30 mysql --default-character-set=utf8mb4 \
-- -uroot -p密码 accounting_db < migration_add_health.sql
-- ==================================================================
-- 体重记录
--
-- 一天一条:unique(user_id, record_date) + Service 层「先查再更新」。
-- 早上称一次晚上再称一次的情况,后记的覆盖前面的 —— 图表就干净了。
-- ==================================================================
CREATE TABLE IF NOT EXISTS `weight_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`record_date` DATE NOT NULL COMMENT '记录日期(一天一条)',
`weight` DECIMAL(5,1) NOT NULL COMMENT '体重 kg',
`note` VARCHAR(200) 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_user_date` (`user_id`, `record_date`) COMMENT '一天一条,重复记录覆盖',
INDEX `idx_user_date` (`user_id`, `record_date` DESC) COMMENT '按日期倒序取最近记录'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='体重记录(一天一条)';
-- ==================================================================
-- 减重目标
--
-- is_active 只有一条为 1:设置新目标时把旧的置 0。
-- 保留历史行而不是物理删除,是为了将来能做「历史战绩」——
-- 现在界面只读 is_active=1 那条,所以行为上等价于「单条覆盖」。
-- ==================================================================
CREATE TABLE IF NOT EXISTS `weight_goal` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`start_weight` DECIMAL(5,1) NOT NULL COMMENT '起始体重 kg',
`target_weight` DECIMAL(5,1) NOT NULL COMMENT '目标体重 kg',
`start_date` DATE NOT NULL COMMENT '开始日期',
`target_date` DATE NOT NULL COMMENT '目标完成日期',
`is_active` TINYINT NOT NULL DEFAULT 1 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_active` (`user_id`, `is_active`) COMMENT '取当前进行中的目标'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='减重目标(is_active=1 为当前目标)';
-- ==================================================================
-- 饮食记录
--
-- 【为什么冗余存营养值】food_nutrition 是《中国食物成分表》的落库快照,
-- 将来重新抓取时数值可能被修正。如果只存 food_id + 克数,那么食物表
-- 一更新,三个月前那顿饭的热量就跟着变了 —— 那是既成事实,不该被改写。
-- 所以记录时就把算好的值抄一份存下来(快照语义)。
--
-- meal_type 取值:breakfast / lunch / dinner / snack
-- ==================================================================
CREATE TABLE IF NOT EXISTS `diet_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`record_date` DATE NOT NULL COMMENT '记录日期',
`meal_type` VARCHAR(20) NOT NULL COMMENT '餐次:breakfast/lunch/dinner/snack',
`food_id` BIGINT NOT NULL COMMENT '食物ID(food_nutrition.id)',
`food_name` VARCHAR(200) NOT NULL COMMENT '食物名(冗余,食物被删也能显示)',
`amount_grams` DECIMAL(7,1) NOT NULL COMMENT '份量 g',
-- ==== 营养快照(按 amount_grams 换算后的实际值,不是每 100g)====
`energy_kcal` DECIMAL(8,1) NOT NULL DEFAULT 0 COMMENT '能量 kcal',
`protein` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '蛋白质 g',
`fat` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '脂肪 g',
`carbohydrate` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '碳水化合物 g',
`dietary_fiber` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '膳食纤维 g',
`sodium` DECIMAL(9,1) NOT NULL DEFAULT 0 COMMENT '钠 mg',
`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_date` (`user_id`, `record_date`) COMMENT '按天查饮食列表'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='饮食记录(营养值为记录时的快照)';
-- 核对:跑完应该能看到三张表
-- SHOW TABLES LIKE 'weight_%'; SHOW TABLES LIKE 'diet_record';
+50
View File
@@ -287,3 +287,53 @@ CREATE TABLE IF NOT EXISTS `food_nutrition` (
INDEX `idx_category` (`category_id`, `sub_category_id`),
INDEX `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='食物营养成分表(每100g可食部,数据来源:中国食物成分表)';
-- ==================== 强宝健康 ====================
-- 与 db/migration_add_health.sql 保持一致。已有环境跑迁移脚本,全新环境跑本文件。
CREATE TABLE IF NOT EXISTS `weight_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`record_date` DATE NOT NULL COMMENT '记录日期(一天一条)',
`weight` DECIMAL(5,1) NOT NULL COMMENT '体重 kg',
`note` VARCHAR(200) 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_user_date` (`user_id`, `record_date`),
INDEX `idx_user_date` (`user_id`, `record_date` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='体重记录(一天一条)';
CREATE TABLE IF NOT EXISTS `weight_goal` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`start_weight` DECIMAL(5,1) NOT NULL COMMENT '起始体重 kg',
`target_weight` DECIMAL(5,1) NOT NULL COMMENT '目标体重 kg',
`start_date` DATE NOT NULL COMMENT '开始日期',
`target_date` DATE NOT NULL COMMENT '目标完成日期',
`is_active` TINYINT NOT NULL DEFAULT 1 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_active` (`user_id`, `is_active`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='减重目标(is_active=1 为当前目标)';
CREATE TABLE IF NOT EXISTS `diet_record` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`record_date` DATE NOT NULL COMMENT '记录日期',
`meal_type` VARCHAR(20) NOT NULL COMMENT '餐次:breakfast/lunch/dinner/snack',
`food_id` BIGINT NOT NULL COMMENT '食物ID(food_nutrition.id)',
`food_name` VARCHAR(200) NOT NULL COMMENT '食物名(冗余)',
`amount_grams` DECIMAL(7,1) NOT NULL COMMENT '份量 g',
`energy_kcal` DECIMAL(8,1) NOT NULL DEFAULT 0 COMMENT '能量 kcal',
`protein` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '蛋白质 g',
`fat` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '脂肪 g',
`carbohydrate` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '碳水化合物 g',
`dietary_fiber` DECIMAL(7,2) NOT NULL DEFAULT 0 COMMENT '膳食纤维 g',
`sodium` DECIMAL(9,1) NOT NULL DEFAULT 0 COMMENT '钠 mg',
`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_date` (`user_id`, `record_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='饮食记录(营养值为记录时的快照)';
+1 -1
View File
@@ -2210,7 +2210,6 @@
} else {
doLogout();
}
})();
// ================================================================ AI 模型配置
//
@@ -2395,3 +2394,4 @@
return raw ? parseInt(raw, 10) : null;
} catch (e) { return null; }
}
})();
@@ -0,0 +1,106 @@
package com.accounting.util;
import com.accounting.dto.FoodPortionItem;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 份量规则测试。
*
* <p>这个函数的失败模式很隐蔽:匹配不上不会报错,只会静默退化成
* 通用档位(「一小份/一份/一大份」)。对一条鱼来说「一份 100g」还算合理,
* 但对一碗米饭来说就很糟 —— 用户看到「米饭 一份」根本不知道是多少。
* 所以这里重点测「该命中的有没有命中」。</p>
*/
class FoodPortionRulesTest {
@Test
@DisplayName("永远返回非空 —— 用户点进去不该看到空白")
void alwaysReturnsSomething() {
List<FoodPortionItem> portions = FoodPortionRules.portions(null, null);
assertFalse(portions.isEmpty());
assertFalse(FoodPortionRules.portions("某个没见过的分类", "某种食物").isEmpty());
}
@Test
@DisplayName("按名称优先:米饭用碗、馒头用个 —— 同一个大类里份量差异极大")
void nameRuleBeatsCategory() {
List<FoodPortionItem> rice = FoodPortionRules.portions("谷类及制品", "米饭(蒸)");
assertTrue(rice.stream().anyMatch(p -> p.label().equals("一碗")));
assertEquals(150, rice.stream()
.filter(p -> p.label().equals("一碗")).findFirst().orElseThrow().grams());
List<FoodPortionItem> bun = FoodPortionRules.portions("谷类及制品", "馒头(标准粉)");
assertTrue(bun.stream().anyMatch(p -> p.label().equals("一个")));
assertEquals(100, bun.stream()
.filter(p -> p.label().equals("一个")).findFirst().orElseThrow().grams());
}
@Test
@DisplayName("名称匹配变体:名字里含「米饭」就能命中,不用穷举完整名")
void matchesNameSubstring() {
for (String name : List.of("米饭(蒸)", "米饭(标准米)", "蒸米饭", "米饭")) {
List<FoodPortionItem> portions = FoodPortionRules.portions(null, name);
assertTrue(portions.stream().anyMatch(p -> p.label().equals("一碗")),
name + " 应该命中米饭规则");
}
}
@Test
@DisplayName("名称匹配不上时退回分类档位")
void fallsBackToCategory() {
// 这个名字不在精修表里,但分类有规则
List<FoodPortionItem> fish = FoodPortionRules.portions("鱼虾蟹贝类", "草鱼");
assertTrue(fish.stream().anyMatch(p -> p.label().equals("一条")));
assertEquals(150, fish.stream()
.filter(p -> p.label().equals("一条")).findFirst().orElseThrow().grams());
}
@Test
@DisplayName("分类也没规则时用通用档位兜底")
void fallsBackToGeneric() {
List<FoodPortionItem> generic = FoodPortionRules.portions("未收录的分类", "未收录的食物");
assertEquals(3, generic.size());
assertEquals(100, generic.stream()
.filter(p -> p.label().equals("一份")).findFirst().orElseThrow().grams());
}
@Test
@DisplayName("每个档位的克数都大于 0 —— 否则换算出来全是零热量")
void allPortionsHavePositiveGrams() {
List<String> categories = List.of(
"谷类及制品", "蔬菜类及制品", "水果类及制品", "畜肉类及制品",
"蛋类及制品", "乳类及制品", "油脂类", "坚果、种子类",
"鱼虾蟹贝类", "饮料类", "调味品类", "薯类、淀粉及制品");
List<String> foods = List.of("米饭(蒸)", "馒头", "草鱼", "鸡蛋", "牛奶", "苹果", "花生");
for (String category : categories) {
for (String food : foods) {
for (FoodPortionItem portion : FoodPortionRules.portions(category, food)) {
assertTrue(portion.grams() > 0,
category + "/" + food + " 的「" + portion.label() + "」克数应为正");
}
}
}
}
@Test
@DisplayName("牛奶要命中 200/250 而不是被「牛」类规则抢走")
void milkMatchesItsOwnRule() {
List<FoodPortionItem> milk = FoodPortionRules.portions("乳类及制品", "牛乳(全脂)");
// 「牛乳」不含「牛奶」这个子串,所以这里走的是分类规则 ——
// 断言的是「结果对」,不是「走了哪条路」
assertTrue(milk.stream().anyMatch(p -> p.grams() == 200 || p.grams() == 250),
"牛奶应该有杯/盒这样的档位");
List<FoodPortionItem> literalMilk = FoodPortionRules.portions(null, "牛奶");
assertEquals(200, literalMilk.stream()
.filter(p -> p.label().equals("一杯")).findFirst().orElseThrow().grams());
}
}
@@ -0,0 +1,63 @@
package com.accounting.util;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* 营养单位换算测试。
*
* <p>kJ → kcal 是这次唯一的「数值口径」转换,算错的话所有热量都是错的,
* 而且错得不像 bug(比如全偏大 4 倍,看起来只是「热量好高」)。</p>
*/
class NutritionUnitTest {
@Test
@DisplayName("1478 kJ = 353.3 kcal(小麦粉标准粉)")
void convertsKjToKcal() {
assertEquals(new BigDecimal("353.3"),
NutritionUnit.kjToKcal(new BigDecimal("1478")));
}
@Test
@DisplayName("0 kJ = 0 kcal")
void convertsZero() {
assertEquals(new BigDecimal("0.0"),
NutritionUnit.kjToKcal(BigDecimal.ZERO));
}
@Test
@DisplayName("null 进 null 出 —— 不能悄悄变成 0,「没测过」和「零热量」是两回事")
void nullStaysNull() {
assertNull(NutritionUnit.kjToKcal(null));
}
@Test
@DisplayName("按份量换算:每 100g 有 353.3 kcal,吃 150g 就是 530 kcal")
void scalesByAmount() {
assertEquals(new BigDecimal("529.95"),
NutritionUnit.per100gToAmount(new BigDecimal("353.3"), new BigDecimal("150")));
}
@Test
@DisplayName("份量为 0 或负数时返回 0,不抛异常 —— 输入框可能被清空")
void handlesZeroAndNegativeGrams() {
assertEquals(BigDecimal.ZERO, NutritionUnit.per100gToAmount(
new BigDecimal("353.3"), BigDecimal.ZERO));
assertEquals(BigDecimal.ZERO, NutritionUnit.per100gToAmount(
new BigDecimal("353.3"), new BigDecimal("-10")));
}
@Test
@DisplayName("任一入参为 null 都返回 0(表的字段允许为空)")
void handlesNulls() {
assertEquals(BigDecimal.ZERO,
NutritionUnit.per100gToAmount(null, new BigDecimal("100")));
assertEquals(BigDecimal.ZERO,
NutritionUnit.per100gToAmount(new BigDecimal("100"), null));
}
}
@@ -0,0 +1,126 @@
package com.accounting.util;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* 减重计划计算测试。
*
* <p>这块是「设置目标 → 每周该减多少」的核心算术,也是最容易出现
* 边界问题的地方:同日、超期、目标比起始还重、除零。所以边界用例
* 写得比正常用例还多。</p>
*/
class WeightPlanCalculatorTest {
@Test
@DisplayName("三个月 65→58kg:总减 7kg 分到 13 周,每周 0.54kg")
void weeklyLossForThreeMonths() {
LocalDate start = LocalDate.of(2026, 1, 1);
LocalDate target = LocalDate.of(2026, 4, 1); // 90 天
int weeks = WeightPlanCalculator.totalWeeks(start, target);
assertEquals(13, weeks);
BigDecimal weekly = WeightPlanCalculator.weeklyLoss(
new BigDecimal("65"), new BigDecimal("58"), weeks);
assertEquals(new BigDecimal("0.54"), weekly);
}
@Test
@DisplayName("总天数除不尽 7 时向上取整 —— 少算一周会让每周目标偏轻")
void roundsUpPartialWeek() {
// 8 天 = 1 周 + 1 天,应该算 2 周而不是 1 周
assertEquals(2, WeightPlanCalculator.totalWeeks(
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 9)));
// 6 天算 1 周
assertEquals(1, WeightPlanCalculator.totalWeeks(
LocalDate.of(2026, 1, 1), LocalDate.of(2026, 1, 7)));
}
@Test
@DisplayName("同日或倒填日期都算 1 周,不除零")
void sameDayFallsBackToOneWeek() {
LocalDate day = LocalDate.of(2026, 1, 1);
assertEquals(1, WeightPlanCalculator.totalWeeks(day, day));
// 目标日期早于开始日期也不该崩
assertEquals(1, WeightPlanCalculator.totalWeeks(day, day.minusDays(5)));
// 1 周减 7kg 是个荒谬的值,但必须是个数、不能是异常
assertEquals(new BigDecimal("7.00"), WeightPlanCalculator.weeklyLoss(
new BigDecimal("65"), new BigDecimal("58"), 1));
}
@Test
@DisplayName("目标比起始还重时每周应减为 0,不能是负数")
void gainingWeightGivesZeroNotNegative() {
// 负数会让进度条宽度算反、也会让「每周应减」显示成 -0.5kg
assertEquals(BigDecimal.ZERO, WeightPlanCalculator.weeklyLoss(
new BigDecimal("58"), new BigDecimal("65"), 13));
assertEquals(BigDecimal.ZERO, WeightPlanCalculator.weeklyLoss(
new BigDecimal("60"), new BigDecimal("60"), 13));
}
@Test
@DisplayName("理想体重曲线:第 0 周是起始体重,最后一周落在目标上")
void idealCurveEndsAtTarget() {
BigDecimal start = new BigDecimal("65");
BigDecimal target = new BigDecimal("58");
BigDecimal weekly = new BigDecimal("0.54");
assertEquals(new BigDecimal("65.0"),
WeightPlanCalculator.idealWeightAt(start, target, weekly, 0));
// 第 13 周:65 - 0.54*13 = 57.98,低于目标 58 → 应该钉在 58
assertEquals(new BigDecimal("58"),
WeightPlanCalculator.idealWeightAt(start, target, weekly, 13));
}
@Test
@DisplayName("理想体重不会跌破目标 —— 否则参照线会穿到目标下方")
void idealWeightNeverGoesBelowTarget() {
BigDecimal target = new BigDecimal("58");
// 刻意给一个过大的每周减重
BigDecimal ideal = WeightPlanCalculator.idealWeightAt(
new BigDecimal("65"), target, new BigDecimal("2.0"), 10);
assertEquals(target, ideal);
}
@Test
@DisplayName("时间进度:未开始 0、进行中在 0~1、超期封顶 1.0")
void timeProgressIsClamped() {
LocalDate start = LocalDate.of(2026, 1, 1);
LocalDate target = LocalDate.of(2026, 1, 11); // 10 天
// 还没开始
assertEquals(0.0, WeightPlanCalculator.timeProgress(start, target,
LocalDate.of(2025, 12, 20)));
// 正中间
assertEquals(0.5, WeightPlanCalculator.timeProgress(start, target,
LocalDate.of(2026, 1, 6)), 0.001);
// 已超期:封顶 1.0,不是 1.5 —— 进度条宽度不能溢出
assertEquals(1.0, WeightPlanCalculator.timeProgress(start, target,
LocalDate.of(2026, 3, 1)));
}
@Test
@DisplayName("每周超过 1kg 会被标记成不健康")
void flagsUnhealthyPace() {
// 65→58 只用 3 周 = 每周 2.33kg
BigDecimal weekly = WeightPlanCalculator.weeklyLoss(
new BigDecimal("65"), new BigDecimal("58"), 3);
assertTrue(weekly.compareTo(WeightPlanCalculator.HEALTHY_WEEKLY_LOSS_KG) > 0);
// 正常节奏不该被标
BigDecimal healthy = WeightPlanCalculator.weeklyLoss(
new BigDecimal("65"), new BigDecimal("58"), 13);
assertFalse(healthy.compareTo(WeightPlanCalculator.HEALTHY_WEEKLY_LOSS_KG) > 0);
}
}