From 479364b97f6537162557841d533e6500e42719fd Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Mon, 28 Sep 2026 16:41:53 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BC=BA=E5=AE=9D=E5=81=A5=E5=BA=B7=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../accounting/controller/DietController.java | 134 +++++++ .../accounting/controller/FoodController.java | 54 +++ .../controller/WeightController.java | 110 ++++++ .../com/accounting/dto/DietAddRequest.java | 27 ++ .../com/accounting/dto/DietDayResponse.java | 44 +++ .../accounting/dto/DietRecordResponse.java | 41 +++ .../accounting/dto/FoodCategoryResponse.java | 7 + .../com/accounting/dto/FoodItemResponse.java | 42 +++ .../com/accounting/dto/FoodPortionItem.java | 10 + .../com/accounting/dto/WeightGoalRequest.java | 24 ++ .../accounting/dto/WeightRecordResponse.java | 21 ++ .../com/accounting/dto/WeightSaveRequest.java | 23 ++ .../com/accounting/entity/DietRecord.java | 59 ++++ .../com/accounting/entity/FoodNutrition.java | 54 +++ .../com/accounting/entity/WeightGoal.java | 46 +++ .../com/accounting/entity/WeightRecord.java | 39 ++ .../accounting/mapper/DietRecordMapper.java | 46 +++ .../mapper/FoodNutritionMapper.java | 48 +++ .../accounting/mapper/WeightGoalMapper.java | 9 + .../accounting/mapper/WeightRecordMapper.java | 37 ++ .../com/accounting/service/DietService.java | 255 ++++++++++++++ .../com/accounting/service/FoodService.java | 135 +++++++ .../com/accounting/service/WeightService.java | 332 ++++++++++++++++++ .../com/accounting/util/FoodPortionRules.java | 197 +++++++++++ .../com/accounting/util/NutritionUnit.java | 48 +++ .../accounting/util/WeightPlanCalculator.java | 94 +++++ .../resources/db/migration_add_health.sql | 92 +++++ src/main/resources/db/schema.sql | 50 +++ src/main/resources/static/admin/admin.js | 2 +- .../accounting/util/FoodPortionRulesTest.java | 106 ++++++ .../accounting/util/NutritionUnitTest.java | 63 ++++ .../util/WeightPlanCalculatorTest.java | 126 +++++++ 32 files changed, 2374 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/accounting/controller/DietController.java create mode 100644 src/main/java/com/accounting/controller/FoodController.java create mode 100644 src/main/java/com/accounting/controller/WeightController.java create mode 100644 src/main/java/com/accounting/dto/DietAddRequest.java create mode 100644 src/main/java/com/accounting/dto/DietDayResponse.java create mode 100644 src/main/java/com/accounting/dto/DietRecordResponse.java create mode 100644 src/main/java/com/accounting/dto/FoodCategoryResponse.java create mode 100644 src/main/java/com/accounting/dto/FoodItemResponse.java create mode 100644 src/main/java/com/accounting/dto/FoodPortionItem.java create mode 100644 src/main/java/com/accounting/dto/WeightGoalRequest.java create mode 100644 src/main/java/com/accounting/dto/WeightRecordResponse.java create mode 100644 src/main/java/com/accounting/dto/WeightSaveRequest.java create mode 100644 src/main/java/com/accounting/entity/DietRecord.java create mode 100644 src/main/java/com/accounting/entity/FoodNutrition.java create mode 100644 src/main/java/com/accounting/entity/WeightGoal.java create mode 100644 src/main/java/com/accounting/entity/WeightRecord.java create mode 100644 src/main/java/com/accounting/mapper/DietRecordMapper.java create mode 100644 src/main/java/com/accounting/mapper/FoodNutritionMapper.java create mode 100644 src/main/java/com/accounting/mapper/WeightGoalMapper.java create mode 100644 src/main/java/com/accounting/mapper/WeightRecordMapper.java create mode 100644 src/main/java/com/accounting/service/DietService.java create mode 100644 src/main/java/com/accounting/service/FoodService.java create mode 100644 src/main/java/com/accounting/service/WeightService.java create mode 100644 src/main/java/com/accounting/util/FoodPortionRules.java create mode 100644 src/main/java/com/accounting/util/NutritionUnit.java create mode 100644 src/main/java/com/accounting/util/WeightPlanCalculator.java create mode 100644 src/main/resources/db/migration_add_health.sql create mode 100644 src/test/java/com/accounting/util/FoodPortionRulesTest.java create mode 100644 src/test/java/com/accounting/util/NutritionUnitTest.java create mode 100644 src/test/java/com/accounting/util/WeightPlanCalculatorTest.java diff --git a/src/main/java/com/accounting/controller/DietController.java b/src/main/java/com/accounting/controller/DietController.java new file mode 100644 index 0000000..2a8e21b --- /dev/null +++ b/src/main/java/com/accounting/controller/DietController.java @@ -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; + +/** + * 饮食记录接口。 + * + *

路径前缀 {@code /api/health/**} —— 体重和饮食同属「强宝健康」模块, + * 但它们性质差得远(一个是标量趋势、一个是明细流水),所以 Service 分开, + * 只在 Controller 这一层用不同的子路径归到同一个模块下。

+ */ +@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> 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> 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 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> 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> trend(@RequestParam(defaultValue = "7") int days, + Authentication authentication) { + Long userId = getUserId(authentication); + List> points = dietService.energyTrend(userId, days); + return ResponseEntity.ok(Map.of("success", true, "points", points)); + } + + // ---------------------------------------------------------------- 内部 + + /** + * 参数校验失败直接抛 IllegalArgumentException,会被下面的 + * {@link #handleBadRequest} 接住转成 400 + 可读消息。 + * + *

本模块自带一个局部异常处理器,而不是加全局的 —— 全局的会改变 + * 所有既有接口的错误行为(现在它们返回 500),那是一次没有必要的回归面。

+ */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> 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().eq(User::getUsername, username) + ); + if (user == null) { + throw new IllegalArgumentException("用户不存在"); + } + return user.getId(); + } +} diff --git a/src/main/java/com/accounting/controller/FoodController.java b/src/main/java/com/accounting/controller/FoodController.java new file mode 100644 index 0000000..4970778 --- /dev/null +++ b/src/main/java/com/accounting/controller/FoodController.java @@ -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; + +/** + * 食物营养成分查询接口。 + * + *

数据源是 food_nutrition 表(《中国食物成分表》落库),全局共享、 + * 不按用户隔离 —— 食物营养值不是用户数据。

+ */ +@Tag(name = "强宝健康 · 食物库", description = "食物搜索、分类浏览") +@RestController +@RequestMapping("/api/health/food") +public class FoodController { + + @Autowired + private FoodService foodService; + + @Operation(summary = "按名称/别名搜索食物") + @GetMapping("/search") + public ResponseEntity> search(@RequestParam(required = false) String keyword) { + Map result = new HashMap<>(); + result.put("success", true); + result.put("list", foodService.search(keyword)); + return ResponseEntity.ok(result); + } + + @Operation(summary = "分类浏览:不传 category 给一级分类列表,传了给该分类下的食物") + @GetMapping("/browse") + public ResponseEntity> browse(@RequestParam(required = false) String category, + @RequestParam(required = false) String subCategory) { + Map 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> subCategories(@RequestParam String category) { + Map result = new HashMap<>(); + result.put("success", true); + result.put("list", foodService.subCategories(category)); + return ResponseEntity.ok(result); + } +} diff --git a/src/main/java/com/accounting/controller/WeightController.java b/src/main/java/com/accounting/controller/WeightController.java new file mode 100644 index 0000000..4ece8fe --- /dev/null +++ b/src/main/java/com/accounting/controller/WeightController.java @@ -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> 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> 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> 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。 + * + *

只作用于本 Controller,不影响既有接口 —— 给整个项目加全局处理器 + * 会改变其他模块现有的错误行为,那是没必要的回归面。

+ */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> 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().eq(User::getUsername, username) + ); + if (user == null) { + throw new IllegalArgumentException("用户不存在"); + } + return user.getId(); + } +} diff --git a/src/main/java/com/accounting/dto/DietAddRequest.java b/src/main/java/com/accounting/dto/DietAddRequest.java new file mode 100644 index 0000000..3e003ed --- /dev/null +++ b/src/main/java/com/accounting/dto/DietAddRequest.java @@ -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; +} diff --git a/src/main/java/com/accounting/dto/DietDayResponse.java b/src/main/java/com/accounting/dto/DietDayResponse.java new file mode 100644 index 0000000..215e9c3 --- /dev/null +++ b/src/main/java/com/accounting/dto/DietDayResponse.java @@ -0,0 +1,44 @@ +package com.accounting.dto; + +import lombok.Data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; + +/** + * 某一天的饮食情况:按餐次分组的明细 + 全天合计。 + * + *

合计是服务端 SUM 出来的,不是把明细加起来 —— 明细可能分页, + * 而合计必须是全天的。

+ */ +@Data +public class DietDayResponse { + + private LocalDate date; + + /** 全天合计 */ + private NutritionTotals totals; + + /** 按餐次分组,顺序固定为 早/午/晚/加餐(空餐次也保留,前端不用补位) */ + private List 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 items; + } +} diff --git a/src/main/java/com/accounting/dto/DietRecordResponse.java b/src/main/java/com/accounting/dto/DietRecordResponse.java new file mode 100644 index 0000000..4e4f979 --- /dev/null +++ b/src/main/java/com/accounting/dto/DietRecordResponse.java @@ -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; +} diff --git a/src/main/java/com/accounting/dto/FoodCategoryResponse.java b/src/main/java/com/accounting/dto/FoodCategoryResponse.java new file mode 100644 index 0000000..98e6f0a --- /dev/null +++ b/src/main/java/com/accounting/dto/FoodCategoryResponse.java @@ -0,0 +1,7 @@ +package com.accounting.dto; + +/** + * 食物分类 + 该分类下的食物数(分类浏览入口用) + */ +public record FoodCategoryResponse(String name, long count) { +} diff --git a/src/main/java/com/accounting/dto/FoodItemResponse.java b/src/main/java/com/accounting/dto/FoodItemResponse.java new file mode 100644 index 0000000..dcae160 --- /dev/null +++ b/src/main/java/com/accounting/dto/FoodItemResponse.java @@ -0,0 +1,42 @@ +package com.accounting.dto; + +import lombok.Data; + +/** + * 食物条目(含换算好的 kcal) + * + *

不暴露 {@code energyKj} —— 表里存的是千焦,但减肥场景大家都看大卡。 + * 换算统一在服务端做,前端拿到的就只有一个口径,不会出现两种单位混着显示。

+ */ +@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 portions; +} diff --git a/src/main/java/com/accounting/dto/FoodPortionItem.java b/src/main/java/com/accounting/dto/FoodPortionItem.java new file mode 100644 index 0000000..5124f30 --- /dev/null +++ b/src/main/java/com/accounting/dto/FoodPortionItem.java @@ -0,0 +1,10 @@ +package com.accounting.dto; + +/** + * 一个份量档位,如「一碗 150g」。 + * + *

用 record 而不是 @Data class:这是个不可变的值,没有 setter 可写, + * 也没必要为它生成 equals/hashCode 之外的一堆方法。

+ */ +public record FoodPortionItem(String label, int grams) { +} diff --git a/src/main/java/com/accounting/dto/WeightGoalRequest.java b/src/main/java/com/accounting/dto/WeightGoalRequest.java new file mode 100644 index 0000000..87a78b4 --- /dev/null +++ b/src/main/java/com/accounting/dto/WeightGoalRequest.java @@ -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; +} diff --git a/src/main/java/com/accounting/dto/WeightRecordResponse.java b/src/main/java/com/accounting/dto/WeightRecordResponse.java new file mode 100644 index 0000000..a2ec4f2 --- /dev/null +++ b/src/main/java/com/accounting/dto/WeightRecordResponse.java @@ -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; +} diff --git a/src/main/java/com/accounting/dto/WeightSaveRequest.java b/src/main/java/com/accounting/dto/WeightSaveRequest.java new file mode 100644 index 0000000..33a2a9f --- /dev/null +++ b/src/main/java/com/accounting/dto/WeightSaveRequest.java @@ -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; +} diff --git a/src/main/java/com/accounting/entity/DietRecord.java b/src/main/java/com/accounting/entity/DietRecord.java new file mode 100644 index 0000000..69bca9c --- /dev/null +++ b/src/main/java/com/accounting/entity/DietRecord.java @@ -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; + +/** + * 饮食记录 + * + *

营养值是快照:记录时按份量算好一份抄下来,而不是只存 + * food_id + 克数去实时关联。原因是 food_nutrition 将来重新抓取时数值 + * 可能被修正,而三个月前那顿饭吃进去多少是既成事实,不该被改写。

+ */ +@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; +} diff --git a/src/main/java/com/accounting/entity/FoodNutrition.java b/src/main/java/com/accounting/entity/FoodNutrition.java new file mode 100644 index 0000000..66f1e0d --- /dev/null +++ b/src/main/java/com/accounting/entity/FoodNutrition.java @@ -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 表的读取视图) + * + *

表里其实有 40 个字段,这里只映射用得到的 —— 饮食记录只关心 + * 能量和五大营养素,其余(维生素、脂肪酸构成等)现在用不上, + * 映射了也只会让实体变成一个巨大的字段堆。

+ * + *

单位提醒:所有值都是「每 100g 可食部」,且能量存的是 + * kJ(表源自中国食物成分表,习惯用千焦)。对外的 kcal 由 + * FoodService 换算,不要把 energyKj 直接透给前端。

+ */ +@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; +} diff --git a/src/main/java/com/accounting/entity/WeightGoal.java b/src/main/java/com/accounting/entity/WeightGoal.java new file mode 100644 index 0000000..791ff09 --- /dev/null +++ b/src/main/java/com/accounting/entity/WeightGoal.java @@ -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; + +/** + * 减重目标 + * + *

业务上「只有一个进行中的目标」:设置新目标时把旧的 {@code isActive} 置 0。 + * 旧行保留而不是删掉,是为了将来能做「历史战绩」—— 界面现在只读 + * isActive=1 那条,所以行为上等价于「单条覆盖」。

+ */ +@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; +} diff --git a/src/main/java/com/accounting/entity/WeightRecord.java b/src/main/java/com/accounting/entity/WeightRecord.java new file mode 100644 index 0000000..317da19 --- /dev/null +++ b/src/main/java/com/accounting/entity/WeightRecord.java @@ -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; + +/** + * 体重记录(一天一条) + * + *

「一天一条」靠 {@code uk_user_date} 唯一键 + Service 层的 + * 「先查再更新」实现,不是靠应用层去重 —— 并发下也能保证不出现两条。

+ */ +@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; +} diff --git a/src/main/java/com/accounting/mapper/DietRecordMapper.java b/src/main/java/com/accounting/mapper/DietRecordMapper.java new file mode 100644 index 0000000..fd7c99c --- /dev/null +++ b/src/main/java/com/accounting/mapper/DietRecordMapper.java @@ -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 { + + /** + * 某一天各餐次的营养合计。 + * + *

用 SUM 而不是把记录捞回来在 Java 里加:合计只需要 6 个数字, + * 没必要为了一句「今天吃了 1280 kcal」把几十行明细全查出来。

+ */ + @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> 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> dailyEnergyBetween(@Param("userId") Long userId, + @Param("from") LocalDate from, + @Param("to") LocalDate to); +} diff --git a/src/main/java/com/accounting/mapper/FoodNutritionMapper.java b/src/main/java/com/accounting/mapper/FoodNutritionMapper.java new file mode 100644 index 0000000..f16cc84 --- /dev/null +++ b/src/main/java/com/accounting/mapper/FoodNutritionMapper.java @@ -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 { + + /** + * 按名称模糊搜索。走 idx_name 索引,但前置通配符用不上索引 —— + * 1356 条的数据量下全表扫也就几毫秒,不值得为它上全文索引。 + * + *

排序把「名字更短的排前面」当作相关性近似:搜「苹果」时 + * 「苹果」要排在「苹果脯」前面。

+ */ + @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 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> 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> subCategorySummary(@Param("category") String category); +} diff --git a/src/main/java/com/accounting/mapper/WeightGoalMapper.java b/src/main/java/com/accounting/mapper/WeightGoalMapper.java new file mode 100644 index 0000000..21ad534 --- /dev/null +++ b/src/main/java/com/accounting/mapper/WeightGoalMapper.java @@ -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 { +} diff --git a/src/main/java/com/accounting/mapper/WeightRecordMapper.java b/src/main/java/com/accounting/mapper/WeightRecordMapper.java new file mode 100644 index 0000000..65c8e7d --- /dev/null +++ b/src/main/java/com/accounting/mapper/WeightRecordMapper.java @@ -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 { + + /** + * 按日期区间取记录,返回 (record_date, weight) 供画折线图。 + * + *

只取需要的两列,不把 note 也捞回来 —— 图表要的是整段趋势, + * 可能是几百个点,带上备注纯属浪费。

+ */ + @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> pointsBetween(@Param("userId") Long userId, + @Param("from") LocalDate from, + @Param("to") LocalDate to); + + /** + * 有记录以来的最早日期。 + * + *

用来决定折线图默认显示多长的时间窗 —— 没有它就只能拍一个 + * 「近 30 天」,而如果用户只记过 3 次、都在两个月前,图会是空的。

+ */ + @Select("SELECT MIN(record_date) FROM weight_record WHERE user_id = #{userId}") + LocalDate earliestDate(@Param("userId") Long userId); +} diff --git a/src/main/java/com/accounting/service/DietService.java b/src/main/java/com/accounting/service/DietService.java new file mode 100644 index 0000000..eb58981 --- /dev/null +++ b/src/main/java/com/accounting/service/DietService.java @@ -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 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; + + /** + * 加一条饮食记录。 + * + *

营养值在这里按份量算好存下来(快照),不是存 food_id 让前端去关联 —— + * 见 {@code DietRecord} 的类注释。

+ */ + @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); + } + + /** + * 改份量。营养快照要跟着重算 —— 改了克数热量却没变就是错的。 + * + *

重算用的是**原食物当前的数值**,不是当初存的那份。这点需要留意: + * 如果 food_nutrition 在这期间被重新抓取修正过,改份量会让这条记录 + * 顺带更新成新数值。这是刻意的取舍 —— 比起「改了份量却保留旧单位热量」 + * 导致的内部不一致,这个可接受。

+ */ + @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); + } + + /** + * 某一天的饮食:按餐次分组的明细 + 全天合计。 + * + *

合计用 SQL SUM 而不是把明细加起来 —— 明细将来可能做分页, + * 而合计必须是全天的。

+ */ + public DietDayResponse day(Long userId, LocalDate date) { + LocalDate safeDate = date == null ? LocalDate.now() : date; + + List all = dietMapper.selectList( + new LambdaQueryWrapper() + .eq(DietRecord::getUserId, userId) + .eq(DietRecord::getRecordDate, safeDate) + .orderByAsc(DietRecord::getId)); + + // 合计:数值直接由明细累加。这里的明细就是全天全量(没有分页), + // 所以和 SQL SUM 等价,且省一次查询。 + DietDayResponse.NutritionTotals totals = new DietDayResponse.NutritionTotals(); + Map> 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 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> 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); + } + + /** + * 今日热量合计(首页卡片用)。 + * + *

只走一次 SUM 查询,不返回明细 —— 首页卡片只要一个数字。

+ */ + public Map todaySummary(Long userId) { + DietDayResponse day = day(userId, LocalDate.now()); + Map 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; + } +} diff --git a/src/main/java/com/accounting/service/FoodService.java b/src/main/java/com/accounting/service/FoodService.java new file mode 100644 index 0000000..919f073 --- /dev/null +++ b/src/main/java/com/accounting/service/FoodService.java @@ -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; + +/** + * 食物营养成分查询。 + * + *

对外一律给 kcal,不给 kJ —— 换算在这里做掉(见 {@link NutritionUnit}), + * 前端就不会出现两种能量单位混用的情况。

+ */ +@Service +public class FoodService { + + /** 搜索最多返回多少条。够用了:搜不到精确名字应该去分类里翻,而不是翻 50 页。 */ + private static final int SEARCH_LIMIT = 40; + + /** 分类下的食物列表上限。品类最多的蔬菜类有 190 条,全量返回没有意义。 */ + private static final int CATEGORY_LIMIT = 200; + + @Autowired + private FoodNutritionMapper foodMapper; + + /** + * 按名称/别名模糊搜索。 + * + *

关键词为空时返回空列表而不是全表 —— 「没输入」和「搜所有」是两回事。

+ */ + public List search(String keyword) { + if (!StringUtils.hasText(keyword)) { + return List.of(); + } + return foodMapper.search(keyword.trim(), SEARCH_LIMIT) + .stream().map(this::toResponse).toList(); + } + + /** + * 分类浏览:不传分类给一级分类列表,传了给该分类下的食物。 + * + *

合成一个接口是因为前端就是一个页面:先看分类,点进去看该分类的食物。 + * 拆成两个接口只会让前端多写一套 loading 状态。

+ */ + 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 wrapper = + new LambdaQueryWrapper() + .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 subCategories(String category) { + if (!StringUtils.hasText(category)) { + return List.of(); + } + List result = new ArrayList<>(); + for (Map 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、附上份量档位。 + * + *

份量档位是算出来的不是查出来的(见 {@code FoodPortionRules}), + * 所以每条食物都有,不需要额外维护关联数据。

+ */ + 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(); + } +} diff --git a/src/main/java/com/accounting/service/WeightService.java b/src/main/java/com/accounting/service/WeightService.java new file mode 100644 index 0000000..e6b132f --- /dev/null +++ b/src/main/java/com/accounting/service/WeightService.java @@ -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; + + // ================================================================== + // 体重记录 + // ================================================================== + + /** + * 记一次体重。同一天再记就是覆盖。 + * + *

「先查再更新」而不是直接 insert + 唯一键冲突重试:这里是 + * 单用户的低频操作,不存在并发写同一天的可能,简单写法更清楚。

+ */ + @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() + .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); + } + + /** + * 折线图数据 + 当前体重 + 与上次的差值。 + * + *

points 里只放有记录的日子,不补零点 —— 断更的那几天应该让线断着, + * 补 0 会画出一条冲到谷底的假线。

+ */ + public Map 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() + .eq(WeightRecord::getUserId, userId) + .lt(WeightRecord::getRecordDate, from) + .orderByDesc(WeightRecord::getRecordDate) + .last("LIMIT 1")); + if (lastBefore != null) { + from = lastBefore.getRecordDate(); + } + } + + List> rows = recordMapper.pointsBetween(userId, from, today); + List> points = new ArrayList<>(); + for (Map row : rows) { + Map point = new HashMap<>(); + point.put("date", String.valueOf(row.get("date"))); + point.put("weight", row.get("weight")); + points.add(point); + } + + Map 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 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 history(Long userId) { + return recordMapper.selectList( + new LambdaQueryWrapper() + .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() + .eq(WeightGoal::getUserId, userId) + .eq(WeightGoal::getIsActive, 1) + .orderByDesc(WeightGoal::getId) + .last("LIMIT 1")); + } + + /** + * 设置减重目标,返回给前端展示的「每周该减多少」那一套。 + * + *

把旧的置为 inactive 而不是删掉(留作历史),新的插一条 —— + * 这样「单条进行中」和「保留历史」两个诉求同时满足。

+ */ + @Transactional + public Map 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() + .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); + } + + /** + * 目标详情:总周数、每周应减、理想体重曲线。 + * + *

理想曲线是给折线图当参照线的 —— 用户一眼就能看出自己 + * 在线的上方(落后)还是下方(超前)。

+ */ + public Map goalDetail(Long userId) { + WeightGoal goal = activeGoal(userId); + if (goal == null) { + return Map.of("hasGoal", false); + } + return goalPlan(userId, goal); + } + + private Map goalPlan(Long userId, WeightGoal goal) { + int weeks = WeightPlanCalculator.totalWeeks(goal.getStartDate(), goal.getTargetDate()); + BigDecimal weeklyLoss = WeightPlanCalculator.weeklyLoss( + goal.getStartWeight(), goal.getTargetWeight(), weeks); + + // 理想曲线:每周一个点,最后一点正好落在目标体重上 + List> idealCurve = new ArrayList<>(); + for (int i = 0; i <= weeks; i++) { + LocalDate weekDate = goal.getStartDate().plusWeeks(i); + Map 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 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() + .eq(WeightRecord::getUserId, userId) + .orderByDesc(WeightRecord::getRecordDate) + .last("LIMIT 1")); + return latest == null ? null : latest.getWeight(); + } + + // ================================================================== + + /** + * 体重合法性校验。 + * + *

范围给得很宽(20~300kg)—— 目的是拦住「65 打成 650」这种手滑, + * 而不是替用户判断他的体重正不正常。

+ */ + 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; + } +} diff --git a/src/main/java/com/accounting/util/FoodPortionRules.java b/src/main/java/com/accounting/util/FoodPortionRules.java new file mode 100644 index 0000000..9cae9f1 --- /dev/null +++ b/src/main/java/com/accounting/util/FoodPortionRules.java @@ -0,0 +1,197 @@ +package com.accounting.util; + +import com.accounting.dto.FoodPortionItem; + +import java.util.List; +import java.util.Map; + +/** + * 份量档位规则:把「一碗米饭」这种日常说法映射成克数。 + * + *

食物表全是「每 100g」的值,但用户记的是「一碗」「一个」「一杯」。 + * 让每次吃饭都去估克数是不现实的(也没人真的去称),所以这里给每种 + * 食物一组常用档位,用户点一下就行、也可以再改成具体克数。

+ * + *

为什么写在代码里而不是建一张表

+ *

这是一份「常识 + 少量精调」的静态映射,不是业务数据:它不随用户变化、 + * 不需要后台维护、也不会被查询条件筛选。建表意味着多一套 CRUD 和一次 + * 部署时的数据导入,换来的只是「改份量要重新打包」这个不便 —— 而实际 + * 上改它的频率大概是半年一次。真到了需要用户自定义份量的那天, + * 再加一张 user_food_portion 覆盖表也不迟。

+ * + *

两层匹配

+ *
    + *
  1. 按名称:最常吃的那些(米饭、鸡蛋、牛奶…)单独精修, + * 因为同一个大类里份量差异极大 —— 「谷类」既有一碗米饭也有一个馒头。
  2. + *
  3. 按一级分类:覆盖其余全部食物。粒度粗,但每条食物至少 + * 都有档位可选,不会出现「这条食物点进去是空的」。
  4. + *
+ */ +public final class FoodPortionRules { + + private FoodPortionRules() { + } + + /** 兜底:分类没匹配上时用(比如新抓进来的分类) */ + private static final List GENERIC = List.of( + new FoodPortionItem("一小份", 50), + new FoodPortionItem("一份", 100), + new FoodPortionItem("一大份", 200)); + + /** + * 按一级分类给的默认档位。 + * + *

数值取自中国居民膳食指南的常见份量概念(如「一把坚果约 25g」 + * 「一勺油约 10g」),不是精确的膳食调查数据 —— 目标是「八九不离十」, + * 精确到克对记饮食这件事没有意义,反而会让用户放弃记录。

+ */ + private static final Map> 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)))); + + /** + * 按名称精修的高频食物。 + * + *

键是「包含」匹配(食物名含这个子串就命中),所以「米饭」能覆盖 + * 「米饭(蒸)」「蒸米饭」这些变体。遍历顺序固定,先命中的先用 —— + * 所以更具体的词要放在更泛的词前面(比如「牛奶」在「牛」之前)。

+ */ + private static final List>> 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 portions(String categoryName, String foodName) { + final String name = foodName == null ? "" : foodName; + for (Map.Entry> entry : BY_NAME) { + if (name.contains(entry.getKey())) { + return entry.getValue(); + } + } + if (categoryName != null) { + List byCategory = BY_CATEGORY.get(categoryName); + if (byCategory != null) { + return byCategory; + } + } + return GENERIC; + } +} diff --git a/src/main/java/com/accounting/util/NutritionUnit.java b/src/main/java/com/accounting/util/NutritionUnit.java new file mode 100644 index 0000000..3c4e20a --- /dev/null +++ b/src/main/java/com/accounting/util/NutritionUnit.java @@ -0,0 +1,48 @@ +package com.accounting.util; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +/** + * 单位换算:食物表存的是千焦,界面看的是大卡。 + * + *

换算系数取 1 kcal = 4.184 kJ(热化学卡定义)。这是食品标签的通行口径, + * 和《中国食物成分表》本身用的系数一致。

+ */ +public final class NutritionUnit { + + private NutritionUnit() { + } + + private static final BigDecimal KJ_PER_KCAL = new BigDecimal("4.184"); + + /** + * kJ → kcal,保留 1 位小数。 + * + *

入参为 null(表里允许空值)时返回 null,让调用方自己决定退化成什么, + * 而不是在这里悄悄变成 0 —— 0 kcal 和「没测过」是两回事。

+ */ + public static BigDecimal kjToKcal(BigDecimal kj) { + if (kj == null) { + return null; + } + return kj.divide(KJ_PER_KCAL, 1, RoundingMode.HALF_UP); + } + + /** + * 按份量把「每 100g」的值换算成「这一份」的值。 + * + *

{@code grams} 为 0 或负数时返回 0 而不是抛异常:份量在界面里 + * 是个输入框,用户可能清空,这种输入应该得到「零热量」而不是 500。

+ */ + 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); + } +} diff --git a/src/main/java/com/accounting/util/WeightPlanCalculator.java b/src/main/java/com/accounting/util/WeightPlanCalculator.java new file mode 100644 index 0000000..73cb54c --- /dev/null +++ b/src/main/java/com/accounting/util/WeightPlanCalculator.java @@ -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 天」拆成「每周该减多少」。 + * + *

纯函数,不碰数据库 —— 目标是算术问题,单独放出来才好测边界。

+ */ +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 周)。 + * + *

向上取整的理由:3 天也应该算「1 周内完成」而不是 0 周 —— + * 0 周会让「每周应减」除零。同日(0 天)也归到 1 周。

+ */ + 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 位小数)。 + * + *

总减重为 0 或负数(目标比起始还大)时返回 0,不返回负数 —— + * 「增重计划」不是这个模块的场景,返回负数会让进度条算反。

+ */ + 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。 + * + *

不会低于目标体重 —— 计划结束时线应该平在目标值上, + * 而不是继续往下穿。

+ */ + 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),给进度条用。 + * + *

还没开始返回 0,已经超期返回 1.0(不是 >1)—— + * 进度条宽度是可变的 1 倍,超期再往下撑会溢出布局。

+ */ + 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); + } +} diff --git a/src/main/resources/db/migration_add_health.sql b/src/main/resources/db/migration_add_health.sql new file mode 100644 index 0000000..6ec5226 --- /dev/null +++ b/src/main/resources/db/migration_add_health.sql @@ -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'; diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 31b6880..2e4020e 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -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='饮食记录(营养值为记录时的快照)'; diff --git a/src/main/resources/static/admin/admin.js b/src/main/resources/static/admin/admin.js index 8079dc6..080f950 100644 --- a/src/main/resources/static/admin/admin.js +++ b/src/main/resources/static/admin/admin.js @@ -2210,7 +2210,6 @@ } else { doLogout(); } -})(); // ================================================================ AI 模型配置 // @@ -2395,3 +2394,4 @@ return raw ? parseInt(raw, 10) : null; } catch (e) { return null; } } +})(); diff --git a/src/test/java/com/accounting/util/FoodPortionRulesTest.java b/src/test/java/com/accounting/util/FoodPortionRulesTest.java new file mode 100644 index 0000000..40ce7b7 --- /dev/null +++ b/src/test/java/com/accounting/util/FoodPortionRulesTest.java @@ -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; + +/** + * 份量规则测试。 + * + *

这个函数的失败模式很隐蔽:匹配不上不会报错,只会静默退化成 + * 通用档位(「一小份/一份/一大份」)。对一条鱼来说「一份 100g」还算合理, + * 但对一碗米饭来说就很糟 —— 用户看到「米饭 一份」根本不知道是多少。 + * 所以这里重点测「该命中的有没有命中」。

+ */ +class FoodPortionRulesTest { + + @Test + @DisplayName("永远返回非空 —— 用户点进去不该看到空白") + void alwaysReturnsSomething() { + List portions = FoodPortionRules.portions(null, null); + assertFalse(portions.isEmpty()); + assertFalse(FoodPortionRules.portions("某个没见过的分类", "某种食物").isEmpty()); + } + + @Test + @DisplayName("按名称优先:米饭用碗、馒头用个 —— 同一个大类里份量差异极大") + void nameRuleBeatsCategory() { + List rice = FoodPortionRules.portions("谷类及制品", "米饭(蒸)"); + assertTrue(rice.stream().anyMatch(p -> p.label().equals("一碗"))); + assertEquals(150, rice.stream() + .filter(p -> p.label().equals("一碗")).findFirst().orElseThrow().grams()); + + List 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 portions = FoodPortionRules.portions(null, name); + assertTrue(portions.stream().anyMatch(p -> p.label().equals("一碗")), + name + " 应该命中米饭规则"); + } + } + + @Test + @DisplayName("名称匹配不上时退回分类档位") + void fallsBackToCategory() { + // 这个名字不在精修表里,但分类有规则 + List 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 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 categories = List.of( + "谷类及制品", "蔬菜类及制品", "水果类及制品", "畜肉类及制品", + "蛋类及制品", "乳类及制品", "油脂类", "坚果、种子类", + "鱼虾蟹贝类", "饮料类", "调味品类", "薯类、淀粉及制品"); + List 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 milk = FoodPortionRules.portions("乳类及制品", "牛乳(全脂)"); + // 「牛乳」不含「牛奶」这个子串,所以这里走的是分类规则 —— + // 断言的是「结果对」,不是「走了哪条路」 + assertTrue(milk.stream().anyMatch(p -> p.grams() == 200 || p.grams() == 250), + "牛奶应该有杯/盒这样的档位"); + + List literalMilk = FoodPortionRules.portions(null, "牛奶"); + assertEquals(200, literalMilk.stream() + .filter(p -> p.label().equals("一杯")).findFirst().orElseThrow().grams()); + } +} diff --git a/src/test/java/com/accounting/util/NutritionUnitTest.java b/src/test/java/com/accounting/util/NutritionUnitTest.java new file mode 100644 index 0000000..cf02f04 --- /dev/null +++ b/src/test/java/com/accounting/util/NutritionUnitTest.java @@ -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; + +/** + * 营养单位换算测试。 + * + *

kJ → kcal 是这次唯一的「数值口径」转换,算错的话所有热量都是错的, + * 而且错得不像 bug(比如全偏大 4 倍,看起来只是「热量好高」)。

+ */ +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)); + } +} diff --git a/src/test/java/com/accounting/util/WeightPlanCalculatorTest.java b/src/test/java/com/accounting/util/WeightPlanCalculatorTest.java new file mode 100644 index 0000000..0b4feb9 --- /dev/null +++ b/src/test/java/com/accounting/util/WeightPlanCalculatorTest.java @@ -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; + +/** + * 减重计划计算测试。 + * + *

这块是「设置目标 → 每周该减多少」的核心算术,也是最容易出现 + * 边界问题的地方:同日、超期、目标比起始还重、除零。所以边界用例 + * 写得比正常用例还多。

+ */ +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); + } +}