强宝健康模块
This commit is contained in:
@@ -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';
|
||||
@@ -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='饮食记录(营养值为记录时的快照)';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user