Elder 开发手册 · 模块功能

Elder 开发手册 · 模块功能

本手册按业务模块整理养老项目(Spring Boot 3.4 + MyBatis-Plus 3.5 + Vue3 + Element Plus / Vant4 + Spring AI)的实现,每个模块包含:表设计 → 接口清单 → 核心代码 → 业务闭环 → 后续扩展功能(含完整代码)。

通用技术点(日期搜索、上传、分页、表单校验等)见《elder开发手册组件功能.md》;JWT/RBAC/数据权限见《elder开发手册权限功能.md》。

目录

章节 模块 关键词
1 项目总览与开发规约 分层架构 / Result / Query 模式 / 冗余快照
2 用户模块 注册 / 登录 / 密码 / 分配角色 / Excel
3 老人与标签模块 老人档案 / 多对多标签 / 多标签筛选
4 客户与合同模块 客户-老人绑定 / 合同状态机 / 附件预览
5 护理模块 计划-任务展开算法 / 护工打卡 / 数据权限
6 预约模块 免登录自助提交 / 分配护工确认 / 与护理同构
7 体检模块 套餐穿梭框 / 结果自动判异常 / 报告
8 AI 助手模块 Spring AI / 工具调用 / 会话记忆 / SSE 流式
9 手机端(app)模块 统一预约 / 合同 / 报告 / SSE 解析
10 首页统计模块 ECharts 三图 / 统计卡
11 后续功能扩展 定时任务 / 操作日志 / 接口级鉴权 / BCrypt / 健康趋势

1. 项目总览与开发规约

1.1 分层架构

┌─────────────── 浏览器 / 手机 ───────────────┐
│  ui-admin (5173, /api/admin)   ui-app (5174, /api/app)  │
└──────────────────┬──────────────────────────┘
                   │ Vite 代理: /api → localhost:8080(重写掉 /api)
┌──────────────────▼──────────────────────────┐
│ Spring Boot 单体后端                          │
│  controller/admin/**  管理端接口(/admin 前缀)│
│  controller/app/**    手机端接口(/app 前缀)  │
│  controller/common    通用接口(/upload)      │
│  interceptor          LoginInterceptor(JWT)   │
│  service/**           业务逻辑(接口+impl)     │
│  mapper/**            MyBatis-Plus + 手写XML   │
│  config/ tools/ utils/ exception/ listener/   │
└─────────────────────────────────────────────┘
  • 前后端通过 Vite 代理联调:ui-admin 的 axios baseURL = '/api/admin',ui-app 的 baseURL = '/api/app',代理把 /api 剥掉转发到 8080。
  • 后端接口按端分组:/admin/ 管理端、/app/ 手机端、免登录公开接口在 WebConfig 白名单里逐条登记。

1.2 统一返回 Result 与全局异常

utils/Result.java:{code, msg, data},code=1 成功、code=0 失败,静态工厂 ok()/error(msg)。

exception/GlobalExceptionHandler.java:

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
    //业务异常:代码里主动抛出,msg 就是给用户看的提示
    @ExceptionHandler(ServiceException.class)
    public Result serviceException(ServiceException e) {
        log.warn("业务异常: {}", e.getMessage());
        return Result.error(e.getMessage());
    }
    //兜底:意料之外的异常,详细信息只记日志,不暴露给前端
    @ExceptionHandler(Exception.class)
    public Result exception(Exception e) {
        log.error("系统异常", e);
        return Result.error("系统繁忙,请稍后重试");
    }
}

ServiceException 是业务校验不通过时在 Service 层主动抛出的预期内异常,message 直接作为给用户看的提示。

1.3 Query 设计模式

pojo/query/ 下每个模块一个平铺 POJO(@Data + 全包装类型),字段命名高度一致:

  • 分页:Integer page / limit(页码从 1 开始);
  • 时间范围:Date begintime / endtime → create_time BETWEEN;
  • 条件字段按模块定制(如 ElderQuery 独有 List<Long> tagIds)。

用法:Controller 方法签名直接以 Query 接收 Spring 参数绑定 → Service 里 new Page<>(query.getPage(), query.getLimit()) + LambdaQueryWrapper 条件可选拼接(!ObjectUtils.isEmpty(...))。

1.4 全项目开发规约(从各模块归纳)

  1. Controller 统一骨架:分页 GET / 详情 GET /{id} / 插入 POST / 修改 PUT /{id} / 单删 DELETE /{id} / 批删 DELETE `(body Long[] ids),返回统一 Result`。
  2. VO 继承实体:列表/详情 VO 继承实体扩展 join 字段(CareTaskVo、ContractVo…),详情类 VO 再组合子表列表。
  3. 冗余快照字段:CareTask.careItemName、ExamAppointment.price、ExamAppointmentItem.itemName、AppointmentTask.elderName —— 统一注释为”防止改名后历史记录变动”。
  4. 多对多分配统一”先删后插全量覆盖”:老人-标签、用户-角色、套餐-项目、客户-老人四处一致。
  5. 手写 XML 必须自带 deleted = 0:MP 的 @TableLogic 逻辑删除只作用于内置方法,不会追加到手写 SQL(AppointmentPlanMapper.xml 注释原话记录了这个坑)。
  6. 免登录公开接口不信任前端:submit/register 一律强制覆盖 id/status/source/userId 等敏感字段。
  7. 代码生成:新表用 MPGenerator.java 生成 entity/mapper/service/controller(配置表名、逻辑删除列、create_time/update_time 自动填充列后执行 main)。

2. 用户模块

2.1 表设计

user:id / name(登录名)/ password / phone / email / avatar / status(0停用,1正常) / deleted(@TableLogic)/ create_time / update_time。 role:id / name / code(角色编码,如 HUGONG) / description。 user_role:user_id + role_id 中间表(权限功能见《elder开发手册权限功能.md》)。

2.2 接口清单(UserController → /admin/users)

方法 路径 说明
POST /login 登录(四重校验 + JWT,见权限手册)
POST /register 注册(免登录开放接口)
PUT /forgetPassword 忘记密码(按手机号找回)
PUT /resetPassword 重置密码(校验旧密码 + token 身份)
GET /userInfo 用户信息 + 权限 + 角色编码
GET /selectAssingnedRole/{userId} / POST /assignRole 分配角色(先删后插)
GET /listByRole?roleCode= 按角色编码查用户(如护工下拉)
GET /exportExcel / POST /importExcel Excel 导出/导入
GET/POST/PUT/DELETE ` 与 /{id}` 标准 CRUD

2.3 核心代码

注册(免登录接口的安全姿势):

//register 逻辑:用户名/密码/手机号必填校验 → 手机号唯一校验
user.setId(null); user.setAvatar(null); user.setStatus(1);
//"不信任前端传来的id/头像/状态,新账号一律默认启用"
iUserService.add(user);   // add 内部做用户名查重,重复抛 ServiceException

重置密码:

@PutMapping("resetPassword")
public Result resetPassword(@RequestBody UserPasswordDTO dto, @RequestHeader(name="Authorization") String token) {
    Integer id = (Integer) JwtUtil.parseToken(token).get("id");
    User user = iUserService.getById(id);
    if (!user.getPassword().equals(dto.getOldPassword())) {
        return Result.error("密码不正确");
    }
    user.setPassword(dto.getNewPassword());
    iUserService.updateById(user);
    return Result.ok("修改成功");
}

忘记密码:按 phone 查用户 → 校验注销/禁用 → 直接更新 newPassword(UserForgetPassword{phone, email, newPassword})。

角色编码查询(两次单表查询内存关联,避免 join):

//第一步:查user_role表拿到角色id(空集合直接返回,避免in空集合)
//第二步:按角色id批量查role表取编码(@TableLogic自动过滤已删除角色)
public List<String> getRoleCodes(Long userId) { ... }

//被 care / appointment 任务控制器用作护工数据权限判断
public boolean hasRole(Long userId, String roleCode) {
    return getRoleCodes(userId).contains(roleCode);
}

Excel 导入导出:导出查全表 → UserExcelVO(字段带 @ExcelProperty("中文列名"))→ ExcelUtil.exportExcel(response, list, UserExcelVO.class, "用户信息表");导入 EasyExcel.read(file.getInputStream(), UserExcelVO.class, new UserExcelListener(userMapper)).sheet().doRead(),监听器逐行 BeanUtils.copyProperties + setId(null) + insert(监听器不是 Spring Bean,用构造器传 Mapper,不能用 @Autowired)。

2.4 业务闭环

注册(默认启用、无角色)→ 登录发 JWT → userInfo 返回用户+权限+角色编码 → 管理员维护用户/分配角色 → 忘记/重置密码 → Excel 批量导入导出 → 角色编码(HUGONG)驱动护理/预约任务模块的服务端数据权限。


3. 老人与标签模块

3.1 表设计

elder:id / name / password(老人也能登录手机端)/ phone / idCardNo / birthday / address / avatar / customerId(所属客户,签约家属) / remark(wangEditor 富文本 HTML)/ status(0禁用 1启用 2请假 3退住中 4入住中 5已退住) / deleted / 时间字段。 tag:id / name / …;elder_tag:elder_id + tag_id 中间表(多对多)。

3.2 核心代码

老人分页 + 多标签 AND 筛选(ElderServiceImpl.list):

LambdaQueryWrapper<Elder> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.like(... 名字/手机号 ...)
        .between(... 时间 ...)
        .orderByDesc(Elder::getCreateTime);

//======== ① 标签筛选 ========
List<Long> queryTagIds = elderQuery.getTagIds();
if (queryTagIds != null && !queryTagIds.isEmpty()) {
    //①-1 查中间表:所有拥有「这些标签」的绑定记录
    List<ElderTag> rows = elderTagMapper.selectList(
            new LambdaQueryWrapper<ElderTag>().in(ElderTag::getTagId, queryTagIds));
    //①-2 按老人分组收集标签id集合
    List<Long> matchedElderIds = rows.stream()
            .collect(Collectors.groupingBy(ElderTag::getElderId,
                    Collectors.mapping(ElderTag::getTagId, Collectors.toSet())))
            .entrySet().stream()
            //①-3 containsAll 实现「必须同时拥有所有选中标签」(AND 语义)
            .filter(entry -> entry.getValue().containsAll(queryTagIds))
            .map(Map.Entry::getKey).collect(Collectors.toList());
    if (matchedElderIds.isEmpty()) { return page; }
    lambdaQueryWrapper.in(Elder::getId, matchedElderIds);
}
IPage<Elder> result = elderMapper.selectPage(page, lambdaQueryWrapper);

//======== ② 标签回显(批量组装,避免 N+1)========
//一次查出本页所有绑定 + 一次查出所有标签名,按老人分组后塞回 records
records.forEach(elder -> elder.setTagList(elderTagMap.getOrDefault(elder.getId(), new ArrayList<>())));

分配标签(先删后插全量覆盖):

@Override
public void assignTag(Long elderId, Long[] tagIds) {
    elderTagMapper.delete(new LambdaQueryWrapper<ElderTag>().eq(ElderTag::getElderId, elderId));
    for (Long tagId : tagIds) {
        ElderTag elderTag = new ElderTag();
        elderTag.setElderId(elderId);
        elderTag.setTagId(tagId);
        elderTagMapper.insert(elderTag);
    }
}

回显接口 selectAssingnedTag/{elderId} 返回 ElderTagVo{tagList 全部标签, assignedTagIdList 已选id},前端 el-checkbox-group 勾选。

3.3 业务闭环

标签维护 → 老人档案 CRUD(录入时选所属客户、wangEditor 备注、头像 OSS 上传)→ 老人独立 JWT 登录手机端 → 打标签(回显+先删后插)→ 列表多标签 AND 筛选 → Excel 导入导出 → 「查看合同」跳 /contract?elderId=&elderName=。

编辑老人时密码不回显:elder.value.password = '',提交前 if (!payload.password) delete payload.password,留空即不修改。


4. 客户与合同模块

4.1 表设计

customer:id / name / phone / idCardNo / address / source(0电话 1上门 2转介绍 3广告) / status(1意向 2已签约 3流失)。 contract:id / contractNo(HT202608280001) / elder_id / customer_id(由老人自动带出)/ contractType(1入住 2续签 3补充 4退住) / status(1草稿 2待签署 3生效中 4已到期 5已终止 6已作废) / guardian_name、guardian_phone、guardian_relation、guardian_id_card_no(签署时快照)/ monthly_fee、deposit / sign_date、start_date、end_date、terminate_date、terminate_reason。 contract_attachment:contract_id / file_name / file_url / file_type(1扫描件 2身份证复印件 3体检报告 4其他)。

4.2 核心代码

客户删除保护(防孤儿数据):

// 流程:录入客户(意向) → 关联/登记老人 → 签订合同 → 合同生效(入住)
public void removeCustomer(Long id) {
    //名下还有老人或合同时不允许删除,避免客户删了、老人和合同变成"孤儿"数据
    Long elderCount = elderMapper.selectCount(new LambdaQueryWrapper<Elder>().eq(Elder::getCustomerId, id));
    if (elderCount > 0) { throw new ServiceException("该客户名下还有" + elderCount + "位老人,无法删除"); }
    Long contractCount = contractMapper.selectCount(...);
    if (contractCount > 0) { throw new ServiceException("该客户名下还有" + contractCount + "份合同,无法删除"); }
    removeById(id);
}

客户-老人绑定(操作的是 elder.customer_id,不是中间表):

@Transactional(rollbackFor = Exception.class)
public void assignElders(Long customerId, Long[] elderIds) {
    List<Long> ids = elderIds == null ? List.of() : Arrays.asList(elderIds);
    //1. 清空原有关联:该客户名下、这次没勾选的老人,customer_id 置空
    LambdaUpdateWrapper<Elder> clearWrapper = new LambdaUpdateWrapper<Elder>()
            .eq(Elder::getCustomerId, customerId)
            .set(Elder::getCustomerId, null);
    if (!ids.isEmpty()) { clearWrapper.notIn(Elder::getId, ids); }
    elderMapper.update(null, clearWrapper);
    //2. 建立新的关联:勾选的老人全部挂到该客户下
    if (!ids.isEmpty()) {
        elderMapper.update(null, new LambdaUpdateWrapper<Elder>()
                .in(Elder::getId, ids).set(Elder::getCustomerId, customerId));
    }
}

合同保存时客户由老人唯一确定:

/**
 * 客户由老人唯一确定:选了老人就按老人档案带出所属客户,
 * 避免前端传值和老人档案不一致
 */
private void fillCustomerFromElder(Contract contract) {
    if (contract.getElderId() == null) { return; }
    Elder elder = elderMapper.selectById(contract.getElderId());
    if (elder != null) { contract.setCustomerId(elder.getCustomerId()); }
}

前端配套的级联逻辑(Contract.vue):选客户 → 老人下拉只显示该客户名下的老人,并把客户信息带入担保人字段快照;选老人 → 反向把 elder.customerId 同步到表单;从老人页跳入(route.query.elderId)时自动带出客户。

附件两段式保存与预览:

//1) el-upload 直传 /api/upload 拿到 OSS URL,暂存 pendingAttachments
//2) 保存合同成功(后端返回新合同id回填)后批量写附件表
Promise.all(pendingAttachments.value.map(att => {
    att.contractId = contract.value.id
    return attachmentApi.add(att)
}))

//3) 预览:后端代理流式写回(绕过 OSS 匿名强制下载),前端 blob 新窗口内联打开
contractApi.getOss(list[0].fileUrl).then(res => {   // responseType: 'blob'
    window.open(URL.createObjectURL(res.data), '_blank')
})

后端代理 GET /admin/oss/get?url= / GET /app/oss/get?url= → aliOSSUtil.downloadFile(url, response),设置 Content-Disposition: inline。

4.3 业务闭环

录入客户(来源/意向)→ 关联老人(全量替换 elder.customer_id,且不抢其他客户的老人:下拉只列「无客户 + 本客户」)→ 为老人签合同(customer_id 自动带出)→ 合同状态机(草稿→待签署→生效中→到期/终止/作废)→ 附件上传挂接与内联预览 → 删除客户强校验。


5. 护理模块

5.1 表设计(计划-明细-任务三级)

表 关键字段
care_item 护理项目 name / price / requirement(护理要求)/ status
care_level 护理等级 name / price / description
care_plan 护理计划 elder_id / user_id(护工)/ care_level_id / start_date、end_date / status(0结束 1开始)
care_plan_item 计划明细 care_plan_id / care_item_id / execute_time(HH:mm:ss) / execute_cycle(0每天 1每周 2每月) / execute_frequency(每周期任务条数)
care_task 任务与打卡 elder_id / care_plan_id / care_item_id / care_item_name(冗余快照) / user_id / plan_execute_date / plan_execute_time / status(0待执行 1已完成 2已跳过) / actual_execute_time / execute_result / execute_img(多张逗号隔开)

5.2 核心:任务生成算法(CarePlanServiceImpl.generateTasks)

/**
 * 根据计划(开始/结束日期)与项目子表(执行周期、频次、时间)生成护理任务。
 * 执行周期:0每天 1每周 2每月;执行频次:每个周期内生成的任务条数。
 */
private void generateTasks(CarePlan plan, List<CarePlanItem> items) {
    if (plan.getStartDate() == null || items == null || items.isEmpty()) { return; }
    LocalDate start = plan.getStartDate().toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
    LocalDate end   = plan.getEndDate() == null ? start : ...;

    for (CarePlanItem item : items) {
        String itemName = getItemName(item.getCareItemId()); // 冗余项目名快照
        int cycle = item.getExecuteCycle() == null ? 0 : item.getExecuteCycle();
        int frequency = item.getExecuteFrequency() == null ? 1 : item.getExecuteFrequency();
        for (LocalDate d = start; !d.isAfter(end); d = d.plusDays(1)) {  // 逐日展开
            if (!isExecuteDay(start, d, cycle)) { continue; }
            for (int f = 0; f < frequency; f++) {          // 每执行日生成 frequency 条
                CareTask task = new CareTask();
                task.setElderId(plan.getElderId());
                task.setCarePlanId(plan.getId());
                task.setCareItemName(itemName);            // 项目名快照
                task.setUserId(plan.getUserId());
                task.setPlanExecuteDate(Date.valueOf(d));
                task.setStatus(0);                         // 初始待执行
                careTaskMapper.insert(task);
            }
        }
    }
}

/** 判断某个日期是否属于执行日期 */
private boolean isExecuteDay(LocalDate start, LocalDate d, int cycle) {
    switch (cycle) {
        case 1:  return ChronoUnit.DAYS.between(start, d) % 7 == 0; // 每周:同一星期几
        case 2:  return d.getDayOfMonth() == start.getDayOfMonth(); // 每月:同一日号
        default: return true;                                       // 每天
    }
}

保存/修改/删除计划都在 @Transactional 中级联处理:修改 = 删旧明细+旧任务 → 重建 → 重新生成;删除 = 计划+明细+任务一起删。

5.3 核心:服务端数据权限(护工只能看/执行自己的任务)

//条件分页查询(护工角色登录时,服务端强制只查分配给自己的任务)
@GetMapping
public Result<IPage<CareTaskVo>> selectAll(CareTaskQuery careTaskQuery,
                                           @RequestHeader(name = "Authorization") String token) {
    //数据权限必须在服务端强制:解析token拿到当前用户,若是护工则覆盖userId条件,
    //前端无论传什么userId都无效,防止手工调接口越权查看他人任务
    Integer id = (Integer) JwtUtil.parseToken(token).get("id");
    Long currentUserId = id.longValue();
    if (iUserService.hasRole(currentUserId, "HUGONG")) {
        careTaskQuery.setUserId(currentUserId);
    }
    ...
}

详情与执行(PUT /execute/{id})接口同样先 getById 校验 task.getUserId() 是当前护工,防越权。这是全项目权限体系的第二道、也是真正可靠的防线(详见《elder开发手册权限功能.md》第 9 节)。

5.4 业务闭环

维护护理项目/等级 → 为老人建计划(老人+护工+等级+日期区间)→ 计划里配项目子表(时间/周期/频次)→ 保存时按「日期区间 × 执行日规则 × 频次」展开生成每日任务 → 护工端只查自己的任务 → 打卡执行(状态 0→1/2,录结果 + 现场照片)→ 改计划级联重建任务。

前端 CarePlan.vue 是主子表行内编辑范式:弹窗内 el-table :data="plan.itemList" 行内 el-select/el-time-picker/el-input-number,addPlanItem() push 空行、removePlanItem($index) splice,整份 plan(含 itemList)一次提交。护工下拉用 userApi.listByRole('HUGONG'),且 onActivated(loadOptions) 在 keep-alive 激活时刷新下拉。


6. 预约模块(上门服务)

6.1 与护理模块的同构关系

appointment_item / appointment_plan / appointment_plan_item / appointment_task 与 care 四表结构、generateTasks/isExecuteDay 算法完全同构,差别在于多了两种来源的入口:

字段 说明
plan.source 0管理员创建,1自助提交
plan.status 0待确认 → 1进行中 → 2结束 / 3已取消

6.2 核心:免登录自助提交(不信任前端)

// POST /admin/appointment-plan/submit(WebConfig 白名单放行,无需 token)
@Transactional(rollbackFor = Exception.class)
public void submit(AppointmentPlan plan) {
    //公开提交:不信任前端传来的状态/来源/护工/老人ID,强制覆盖
    plan.setId(null); plan.setElderId(null); plan.setUserId(null);
    plan.setStatus(0);    // 待确认
    plan.setSource(1);    // 自助提交
    //必填校验:老人姓名、联系人、联系电话、预约日期、预约项目
    //自助预约为单次预约:开始=结束=预约日期;计划名自动生成
    plan.setEndDate(plan.getStartDate());
    if (ObjectUtils.isEmpty(plan.getName())) { plan.setName(plan.getElderName() + "-预约"); }
    save(plan);
    //写一条明细子表(执行时间取前端提交的预约时间,cycle=0,frequency=1)
    //待确认状态不生成任务,等管理员分配护工确认后再生成
}

老人端登录后提交(elderSubmit):强制 elderId/elderName = 登录者,联系人/电话为空时回填老人档案;多选项目逐条写明细。

6.3 核心:分配护工确认(任务生成的触发点)

@Transactional(rollbackFor = Exception.class)
public void assignWorker(Long id, Long userId) {
    AppointmentPlan plan = getById(id);
    if (plan == null) { throw new ServiceException("预约计划不存在"); }
    if (plan.getStatus() == null || plan.getStatus() != 0) {
        throw new ServiceException("当前状态不允许分配护工");   // 仅待确认(0)允许
    }
    if (userId == null) { throw new ServiceException("请选择护工"); }
    plan.setUserId(userId);
    plan.setStatus(1);     // 分配护工后确认,状态改为进行中
    updateById(plan);
    //确认后按计划生成预约任务
    generateTasks(plan, appointmentPlanItemMapper.selectItemsByPlanId(id));
}

管理员创建时选了护工则一步到位(status=1 + 立即生成任务);未选护工则 status=0,稍后在列表里分配。取消(0/1→3)会删除已生成的任务。

6.4 接口补充

GET /admin/appointment-item/public-list:公开接口,查启用预约项目,供免登录预约表单下拉(同样在 WebConfig 白名单)。 AppointmentTaskController 与 CareTaskController 同构:分页护工数据权限、详情/执行越权校验。

6.5 业务闭环

维护预约项目(含公开下拉)→ 免登录/老人端自助提交(落计划,status=0,不生成任务)→ 管理员分配护工(0→1,生成任务)或创建时选护工一步到位 → 取消(0/1→3,删任务)→ 护工端查/执行自己的任务。


7. 体检模块

7.1 表设计

表 关键字段
exam_item 项目 name / price / unit / resultType(0文本 1数值) / referenceMin、referenceMax(参考范围) / referenceUnit
exam_package 套餐 name / price(由所含项目自动汇总)/ image / status(0下架 1上架)
exam_package_item package_id + exam_item_id + sort
exam_appointment 体检记录 elder_id / package_id / appointment_date、time / price(套餐价格快照) / status(0待体检 1体检中 2已完成 3已取消 4已过期)
exam_appointment_item 结果明细 appointment_id / exam_item_id / item_name(快照) / resultValue(数值)/ resultText(文本)/ status(0待检查 1正常 2异常 3未完成) / abnormal(0/1)

7.2 核心:套餐-项目分配(删旧→批量插→价格回写)

//给套餐分配体检项目:删旧关联 → 批量插入新关联 → 套餐价格自动更新为所含项目价格之和
@Transactional
public void assignItems(Long packageId, List<Long> itemIds) {
    examPackageItemMapper.delete(...eq(ExamPackageItem::getPackageId, packageId)); //全量覆盖
    if (!ObjectUtils.isEmpty(itemIds)) {
        examPackageItemMapper.insertBatch(packageId, itemIds); //XML foreach 批插,sort 用下标
    }
    //价格汇总回写(移除全部项目时价格为 0)
    BigDecimal price = ObjectUtils.isEmpty(itemIds) ? BigDecimal.ZERO
            : examItemMapper.sumPriceByIds(itemIds);   //XML: IFNULL(SUM(price),0)
    ExamPackage examPackage = new ExamPackage();
    examPackage.setId(packageId);
    examPackage.setPrice(price);
    updateById(examPackage);   //updateById 只更非空字段 → 局部更新
}

前端用 el-transfer 穿梭框:左侧候选 [{key, label}](label 拼 名称(¥价格)),右侧回显 getItemIds,确认后提交 id 数组。套餐编辑弹窗里价格只读展示(”价格由分配体检项自动计算,避免人工填写对不上”)。

7.3 核心:体检全流程状态机

手机端提交预约 ──▶ 0待体检 ──checkIn──▶ 1体检中 ──complete──▶ 2已完成
                     │                    │
                     └──cancel──▶ 3已取消 ◀┘(仅 0 可取消)
                                        │
                     complete 时未录入明细 → 置 3 未完成

(a) 提交预约(价格快照 + 明细快照):

@Transactional(rollbackFor = Exception.class)
public void submit(Long elderId, AppAppointmentForm form) {
    //必填校验 → 套餐必须存在且上架(status==1)
    ExamPackage examPackage = iExamPackageService.getById(form.getPackageId());
    if (examPackage == null || examPackage.getStatus() != 1) {
        throw new ServiceException("体检套餐不存在或已下架");
    }
    appointment.setPrice(examPackage.getPrice());  //价格取套餐当前价做快照
    appointment.setStatus(0);
    save(appointment);
    //按套餐包含的项目生成明细快照(项目名、状态待检查0)
    List<ExamItem> examItems = iExamPackageService.selectItemsByPackageId(examPackage.getId());
    ...
}

(b) 逐项结果保存(数值型自动判异常,核心):

@Transactional
public void saveItemResults(Long id, List<ExamAppointmentItem> items) {
    for (ExamAppointmentItem item : items) {
        if (item.getId() == null) { continue; }
        //数值型项目:有结果值时依据参考范围自动判定异常,覆盖前端选择
        if (item.getExamItemId() != null && item.getResultValue() != null) {
            ExamItem examItem = examItemMapper.selectById(item.getExamItemId());
            if (examItem != null && examItem.getResultType() == 1) {
                boolean abnormal = isOutOfRange(item.getResultValue(),
                        examItem.getReferenceMin(), examItem.getReferenceMax());
                item.setAbnormal(abnormal ? 1 : 0);
            }
        }
        //明细状态:未检查0 → 异常2 / 正常1
        if (item.getAbnormal() != null && item.getAbnormal() == 1) { item.setStatus(2); }
        else if (item.getResultValue() != null || !ObjectUtils.isEmpty(item.getResultText())) { item.setStatus(1); }
        examAppointmentItemMapper.updateById(item);
    }
}

//数值结果是否超出参考范围(无参考范围时不判定为异常)
private boolean isOutOfRange(BigDecimal value, BigDecimal min, BigDecimal max) {
    if (min != null && value.compareTo(min) < 0) { return true; }
    return max != null && value.compareTo(max) > 0;
}

(c) 报告与健康数据:adminDetail 主记录+老人/套餐+明细(XML join exam_item 带出参考范围供录入弹窗展示);elderReport(elderId, id) 本人 + 已完成双重校验,否则返回 null;latestResult(elderId) 手机端首页健康数据 —— 取最近一次已完成体检(orderByDesc(appointmentDate).last("limit 1")),逐项拼结果文本。

7.4 业务闭环

维护体检项目(数值型带参考范围)→ 组套餐(穿梭框、价格自动汇总)→ 手机端选套餐提交预约(快照)→ 管理端签到(0→1) → 逐项录入(自动判异常)→ 完成(1→2,漏录项置3) → 老人端看报告/首页健康数据。


8. AI 助手模块(Spring AI + DeepSeek)

8.1 技术选型与配置

后端用 spring-ai-starter-model-deepseek 1.1.0(OpenAI 兼容协议)+ spring-ai-autoconfigure-model-chat-memory(默认内存 ChatMemory)。

spring:
  ai:
    deepseek:
      api-key: ${DEEPSEEK_API_KEY:}   # 环境变量注入,不要明文写死
      base-url: https://api.deepseek.com
      chat:
        options:
          model: deepseek-v4-flash    # 可选 deepseek-chat / deepseek-reasoner
          temperature: 0.7
          max-tokens: 2048

8.2 AgentConfig:系统提示词 + 记忆 Advisor

@Configuration
public class AgentConfig {

    /** "康养小智"的角色设定(系统提示词),统一配置在 ChatClient 上,避免每次调用重复传入 */
    public static final String SYSTEM_PROMPT = """
            你是"康养小智",一名智慧养老社区AI健康助手,……
            【角色】你是健康管理与陪伴助手,不是医生,不替代医生诊断、处方或治疗……
            【紧急风险】如出现胸痛、严重呼吸困难、意识不清……立即进入紧急处理模式……
            【核心原则】安全第一;不确定时不猜测;不把可能性说成确定结论;不替代医生……
            """;   // 全文约 40 条规则,覆盖沟通/数据/用药/跌倒/慢病/心理/隐私等

    @Bean
    public ChatClient chatClient(ChatClient.Builder chatClientBuilder, ChatMemory chatMemory) {
        return chatClientBuilder
                .defaultSystem(SYSTEM_PROMPT)          // 智能体角色设定
                .defaultAdvisors(
                        new SimpleLoggerAdvisor(),     // 打印每次聊天的输入和响应(debug)
                        // 上下文记忆:会话按 conversationId(老人id)隔离,
                        // 调用方需通过 advisors 参数传入,否则不同用户会互相串记忆
                        PromptChatMemoryAdvisor.builder(chatMemory).build())
                .build();
    }
}

系统提示词工程要点:给大模型定角色、划红线(不是医生、不调药)、给紧急情况的固定话术、要求”区分可以申请和已成功完成”(防止工具没真正执行就说办好了)。

8.3 ChatController / ChatServiceImpl:同步 + SSE 流式

@RestController
@RequestMapping("/app/chat")
public class ChatController {
    @PostMapping("/chat")
    public Result<String> chat(String message, @RequestHeader("Authorization") String token) {
        //以老人id作为会话id,隔离不同用户的聊天记忆
        Integer conversationId = (Integer) JwtUtil.parseToken(token).get("id");
        return Result.ok("聊天成功", chatService.chat(message, conversationId));
    }

    //处理流式聊天请求,返回服务器发送事件(SSE)格式的响应流
    @PostMapping(value = "/chatStream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatStream(String message, @RequestHeader("Authorization") String token) {
        Integer conversationId = (Integer) JwtUtil.parseToken(token).get("id");
        return chatService.chatStream(message, conversationId);
    }
}
@Service
public class ChatServiceImpl implements IChatService {
    private static final String STREAM_END_MARK = "[END]";   // 与前端约定的流结束标记

    /** 按当前老人组装本次对话可用的 AI 工具:
        工具实例持有 elderId(来自登录token),模型只能查到本人的档案和体检数据 */
    private Object[] buildTools(Integer elderId) {
        return new Object[]{
                new ElderTools(elderId, elderService),
                new ExamReportTools(elderId, examAppointmentService, examPackageService)
        };
    }

    @Override
    public String chat(String message, Integer conversationId) {
        if (ObjectUtils.isEmpty(message)) { return EMPTY_INPUT_REPLY; }
        try {
            return chatClient.prompt()
                    .user(message)
                    .tools(buildTools(conversationId))       // 注册AI工具
                    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) // 会话记忆隔离
                    .call().content();
        } catch (Exception e) {
            log.error("AI调用失败, conversationId: {}", conversationId, e);
            return ERROR_REPLY;
        }
    }

    @Override
    public Flux<String> chatStream(String message, Integer conversationId) {
        if (ObjectUtils.isEmpty(message)) { return Flux.just(EMPTY_INPUT_REPLY, STREAM_END_MARK); }
        return chatClient.prompt()
                .user(message)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
                .tools(buildTools(conversationId))
                .stream().content()
                //模型调用失败时也要发出提示和结束标记,避免前端一直处于等待状态
                .onErrorResume(e -> Flux.just(ERROR_REPLY, STREAM_END_MARK))
                .concatWith(Flux.just(STREAM_END_MARK));     // 流尾追加结束标记
    }
}

8.4 工具调用(Tool Calling / Function Calling)

/**
 * 工作机制:
 * 1. @Tool 注解的方法会被 Spring AI 扫描,把方法名和 description 告诉大模型
 * 2. 大模型根据用户问题自主判断:需不需要调工具、传什么参数
 * 3. 框架通过反射调用对应方法,把返回值转成 JSON 回传给模型
 * 4. 模型拿到数据后,再组织成自然语言回答用户
 * 整个过程对用户透明:用户只问"我今年多大",模型自己去查库再回答。
 */
public class ElderTools {          // 注意:不是 @Component,按请求 new 的有状态实例

    private Integer elderId;       // 来自当前登录老人的 token → 数据权限
    private IElderService elderService;

    @Tool(description = "查询当前老人自己的档案信息,包括姓名、年龄、手机号、出生日期、家庭住址")
    public Elder getElderInfo() { return elderService.getById(elderId); }
}

public class ExamReportTools {
    @Tool(description = "查询当前老人最近一次已完成体检的完整报告……老人询问体检结果、健康指标是否正常时优先调用此方法")
    public HealthDataVo getLatestReport() { return examAppointmentService.latestResult(elderId.longValue()); }

    @Tool(description = "查询当前老人的历史体检记录列表(仅已完成的体检,最多10条)……如需详情,再用返回的体检记录ID调用getReportDetail")
    public List<ReportBrief> getReportHistory() { ... }

    @Tool(description = "按体检记录ID查询某次体检的完整报告明细……只能查询当前老人本人已完成体检的记录")
    public ExamAppointmentDetailVo getReportDetail(Long appointmentId) {
        //elderReport 内部校验了"本人+已完成",防止查到别人的报告
        return examAppointmentService.elderReport(elderId.longValue(), appointmentId);
    }
}

安全设计三层:① 工具实例按请求 new,elderId 来自 token;② 工具 description 明确限定”当前老人本人”;③ 底层 elderReport 再做本人+已完成校验。

8.5 业务闭环

老人手机端进入 Chat → 输入问题 → POST /app/chat/chatStream(SSE)→ 后端带系统提示词+按老人隔离的记忆+按需工具查库 → 流式吐字 → 前端 [END] 结束(前端解析见 9.3)。


9. 手机端(app)模块

9.1 接口总表(controller/app/**)

路径 方法 鉴权 说明
/app/elders/login POST 免登录 老人登录,返回 JWT
/app/elders/userInfo GET token 本人信息 + 签约家属对象
/app/elders/profile PUT token 只挑 avatar/phone/birthday/address 四个字段,id 强制取自 token,手机号正则校验
/app/elders/password PUT token 改密码(验旧密码,防 token 泄露被改密)
/app/contract GET token 我的合同(按 elder_id 查,倒序)
/app/contract/attachments GET token 合同附件(先校验合同属于当前老人,防越权)
/app/appointment POST / GET token 提交体检预约 / 统一预约列表(体检 EXAM + 服务 SERVICE 三段合并)
/app/appointment/{id}/cancel PUT token 取消(SERVICE 仅本人 status==0 待确认计划)
/app/appointment/{id}/report GET token 体检报告(本人+已完成)
/app/exam-package、/{id} GET 免登录 上架套餐列表/详情
/app/health/latest GET token 最近一次已完成体检数据
/app/service/item GET token 可预约上门服务目录
/app/service/appointment POST token 自助提交服务预约(多选项目)
/app/chat/chat、/chatStream POST token AI 同步 / 流式对话

9.2 核心:统一预约列表(两类来源拼一个列表)

GET /app/appointment 把 体检预约(EXAM)、已确认服务任务(SERVICE,带护工名,价格从 AppointmentItem 取)、待确认服务计划(SERVICE,status=5 表示”待确认”,subTitle=”待管理员确认”) 三段合并后按日期倒序(nullsLast reverseOrder)返回。前端据此渲染两种状态映射:

// serviceStatus 里 5 是前端虚拟状态:待确认计划来自 appointment_plan,
// 已确认任务来自 appointment_task,两张表自增id会重叠,
// 所以 v-for 的 key 要带上 type+status 区分,避免 Vue 重复 key 渲染错乱
const serviceStatus = [
    {value: 0, label: '待执行', color: '#e6a23c'},
    {value: 1, label: '已完成', color: '#2e7ce8'},
    {value: 2, label: '已取消', color: '#a4aea4'},
    {value: 5, label: '待确认', color: '#2aa876'}
]

9.3 核心:手机端 SSE 流式解析(api/chat.js)

不是 EventSource(它只支持 GET),而是 fetch POST + response.body.getReader() 手写解析 SSE。

const END_MARK = '[END]'

async chatStream(message, {onChunk, onEnd, onError, signal} = {}) {
    const tokenStore = useTokenStore()
    const response = await fetch(baseURL + '/chat/chatStream', {
        method: 'POST',
        headers: {
            //后端 message 是普通参数(非@RequestBody),用表单格式提交
            'Content-Type': 'application/x-www-form-urlencoded',
            Authorization: tokenStore.token
        },
        body: 'message=' + encodeURIComponent(message),
        signal                            // AbortController:离开页面时中断
    })
    if (response.status === 401) { showToast('请先登录'); router.push('/login'); return }
    const reader = response.body.getReader()
    const decoder = new TextDecoder('utf-8')
    let buffer = ''
    while (true) {
        const {done, value} = await reader.read()
        if (done) break
        buffer += decoder.decode(value, {stream: true})
        //SSE事件以空行(\n\n)分隔,按事件逐个解析
        let index
        while ((index = buffer.indexOf('\n\n')) !== -1) {
            const event = buffer.slice(0, index)
            buffer = buffer.slice(index + 2)
            //同一段内容可能被拆成多个 data: 行,按规范用换行拼回
            const text = event.split('\n').filter(l => l.startsWith('data:'))
                .map(l => l.slice(5)).join('\n')
            if (!text) continue
            if (text === END_MARK) { onEnd && onEnd(); return }
            onChunk && onChunk(text)
        }
    }
    onEnd && onEnd()
}

Chat.vue 打字机效果 = 先 push 一条 streaming: true 的空 AI 消息,onChunk 里 reply.content += chunk 触发视图更新,onEnd 关掉 streaming;Markdown 渲染用 marked.parse + DOMPurify.sanitize(AI 输出属于不可信内容,禁止直接 v-html);onUnmounted(() => controller.abort()) 中断未完成的流。

9.4 手机端其它实现要点

  • 登录:elderApi.login → 存 token → 再拉 userInfo 存 store → 跳 /。
  • 退出:tokenStore.removeToken() + userInfoStore.removeUserInfo() + appointmentStore.reset()(防换账号串数据)。
  • 纯前端配色缩略图:后端没有图片字段,utils/tint.js 提供 4 组暖色色板,按列表下标 tintAt(index) 取色,显示名称前两个字符。
  • 头像上传:van-uploader :after-read → FormData 直接 axios.post('/api/upload')(upload 不在 /app 前缀下)→ 回填 URL,点「保存资料」才入库。
  • 合同附件预览:contractApi.getOss(url)(blob)→ URL.createObjectURL → van-popup 内 iframe 内联预览。

10. 首页统计模块(ui-admin Chart.vue)

  • 统计卡四张:老人总数 / 护理计划数(limit=1 只拿 total)、待执行/已完成任务(按 status 各查一次 total)。
  • 近 7 日趋势:循环生成最近 7 天,每天用 formatDate(0点) / endOfDay 拼区间查两次 total(新增数、完成数),画 ECharts 面积图 + 柱状图。
  • 任务状态环形图:中心用 graphic.text 显示总数。
  • 生命周期:onMounted 里 await nextTick() 后 echarts.init(避免拿到空 DOM);onUnmounted 里 removeEventListener + 三个 dispose(),resize 监听统一 handleResize。
  • 各数据块互相独立 loadCounts().catch(() => {}),单个失败不影响其它。

11. 后续功能扩展(含完整代码)

以下是按现有架构顺手可加的功能,代码风格与项目保持一致(MyBatis-Plus + Result + ServiceException)。

11.1 合同到期提醒与状态自动流转(Spring Task 定时任务)

问题:合同 status=3 生效中 到了 end_date 不会自己变「4 已到期」,也没有到期前提醒。

① 启动定时任务(启动类或配置类上加注解):

// ElderApplication.java
@SpringBootApplication
@EnableScheduling        // 开启 Spring 定时任务
public class ElderApplication {
    public static void main(String[] args) { SpringApplication.run(ElderApplication.class, args); }
}

② 定时任务类(新建 task/ContractTask.java):

package com.situ.elder.task;

import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.situ.elder.mapper.customer.ContractMapper;
import com.situ.elder.pojo.entity.customer.Contract;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.time.ZoneId;
import java.util.Date;

/**
 * 合同定时任务:
 * 1. 每天凌晨把「生效中」但已过 end_date 的合同自动置为「4 已到期」
 * 2. 统计 30 天内即将到期的合同数,供首页/工作台提醒
 */
@Slf4j
@Component
public class ContractTask {

    private final ContractMapper contractMapper;

    public ContractTask(ContractMapper contractMapper) {
        this.contractMapper = contractMapper;
    }

    /**
     * cron 含义:秒 分 时 日 月 周 —— 每天凌晨 1:30 执行一次。
     * 只更新状态字段:update(null, wrapper) 不整行覆盖,且 @TableLogic 的
     * deleted=0 条件 MP 会自动追加,不会误伤已删除合同。
     */
    @Scheduled(cron = "0 30 1 * * ?")
    public void expireContracts() {
        LambdaUpdateWrapper<Contract> wrapper = new LambdaUpdateWrapper<Contract>()
                .eq(Contract::getStatus, 3)                       // 3 生效中
                .lt(Contract::getEndDate, new Date())             // end_date 已过
                .set(Contract::getStatus, 4);                     // 4 已到期
        int rows = contractMapper.update(null, wrapper);
        if (rows > 0) { log.info("合同到期自动流转:{} 份合同置为已到期", rows); }
    }

    /** 30 天内即将到期的生效中合同数(首页红点用) */
    public long expiringSoonCount() {
        LocalDate deadline = LocalDate.now().plusDays(30);
        Date end = Date.from(deadline.atStartOfDay(ZoneId.systemDefault()).toInstant());
        return contractMapper.selectCount(new LambdaUpdateWrapper<Contract>()
                .eq(Contract::getStatus, 3)
                .between(Contract::getEndDate, new Date(), end));
    }
}

③ 首页提醒接口(ContractController 追加):

@GetMapping("/expiringSoon")
public Result<Long> expiringSoon() {
    //30天内即将到期的生效中合同数量,首页角标提示
    return Result.ok(contractTask.expiringSoonCount());
}

④ 前端:Chart.vue 统计卡加一张「即将到期合同」,onMounted 里 contractApi.expiringSoon().then(...);或用 el-badge 挂在菜单上。

要点:状态流转交给定时任务后,管理端「合同管理」列表里的 statusMap(1草稿…4已到期)无需任何改动;@Scheduled 默认单线程调度,多任务并存时建议配 TaskScheduler 池。

11.2 体检预约过期自动处理

与 11.1 同一套路,新建 task/ExamAppointmentTask.java:

@Slf4j
@Component
public class ExamAppointmentTask {

    private final ExamAppointmentMapper examAppointmentMapper;

    public ExamAppointmentTask(ExamAppointmentMapper examAppointmentMapper) {
        this.examAppointmentMapper = examAppointmentMapper;
    }

    /**
     * 每天凌晨 2 点:把「0待体检」但预约日期已经过了的记录置为「4已过期」,
     * 与实体 status 注释(0待体检 1体检中 2已完成 3已取消 4已过期)对齐,
     * 手机端"我的预约"列表的状态映射不用改。
     */
    @Scheduled(cron = "0 0 2 * * ?")
    public void expireAppointments() {
        LambdaUpdateWrapper<ExamAppointment> wrapper = new LambdaUpdateWrapper<ExamAppointment>()
                .eq(ExamAppointment::getStatus, 0)
                .lt(ExamAppointment::getAppointmentDate, new Date())
                .set(ExamAppointment::getStatus, 4);
        int rows = examAppointmentMapper.update(null, wrapper);
        if (rows > 0) { log.info("体检预约过期处理:{} 条置为已过期", rows); }
    }
}

同理护理/预约任务也可以加一个「把 plan_execute_date 已过且仍 0 待执行的任务」标逾期(可加 5 状态或只统计),看业务需要。

11.3 操作日志(自定义注解 + AOP 切面 + 日志表)

问题:现在只有 logback 文件日志,查不到「谁在什么时候改了什么数据」。

① 建表:

CREATE TABLE oper_log (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id      BIGINT        COMMENT '操作人id',
  user_name    VARCHAR(50)   COMMENT '操作人用户名',
  module       VARCHAR(50)   COMMENT '模块名',
  operation    VARCHAR(100)  COMMENT '操作描述',
  method       VARCHAR(200)  COMMENT '请求方法(类.方法)',
  url          VARCHAR(200)  COMMENT '请求地址',
  cost_ms      BIGINT        COMMENT '耗时(毫秒)',
  success      TINYINT       COMMENT '1成功 0失败',
  error_msg    VARCHAR(500)  COMMENT '异常信息',
  create_time  DATETIME,
  update_time  DATETIME
) COMMENT '操作日志表';

② 注解(annotation/OperLog.java):

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OperLog {
    String module();      // 模块名,如 "用户管理"
    String operation();   // 动作描述,如 "新增用户"
}

③ 切面(aspect/OperLogAspect.java):

package com.situ.elder.aspect;

import com.situ.elder.annotation.OperLog;
import com.situ.elder.pojo.entity.user.User;
import com.situ.elder.service.user.IUserService;
import com.situ.elder.utils.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

@Slf4j
@Aspect
@Component
public class OperLogAspect {

    private final OperLogMapper operLogMapper;
    private final IUserService iUserService;

    public OperLogAspect(OperLogMapper operLogMapper, IUserService iUserService) {
        this.operLogMapper = operLogMapper;
        this.iUserService = iUserService;
    }

    /** 环绕通知:拦下所有标了 @OperLog 的方法,记录耗时/结果/操作人 */
    @Around("@annotation(operLog)")
    public Object around(ProceedingJoinPoint joinPoint, OperLog operLog) throws Throwable {
        long start = System.currentTimeMillis();
        OperLogEntity logEntity = new OperLogEntity();
        logEntity.setModule(operLog.module());
        logEntity.setOperation(operLog.operation());
        logEntity.setMethod(joinPoint.getSignature().toShortString());

        //从当前请求拿 URL 和 token,解析操作人(拿不到 token 也记匿名,不阻断业务)
        ServletRequestAttributes attrs =
                (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        if (attrs != null) {
            HttpServletRequest request = attrs.getRequest();
            logEntity.setUrl(request.getRequestURI());
            try {
                Integer id = (Integer) JwtUtil.parseToken(request.getHeader("Authorization")).get("id");
                logEntity.setUserId(id.longValue());
                User user = iUserService.getById(id.longValue());
                if (user != null) { logEntity.setUserName(user.getName()); }
            } catch (Exception ignored) { }
        }

        try {
            Object result = joinPoint.proceed();          // 执行原方法
            logEntity.setSuccess(1);
            return result;
        } catch (Throwable e) {
            logEntity.setSuccess(0);
            logEntity.setErrorMsg(e.getMessage());
            throw e;                                       // 异常继续往外抛,交给全局异常处理器
        } finally {
            logEntity.setCostMs(System.currentTimeMillis() - start);
            try { operLogMapper.insert(logEntity); }       // 日志失败不影响主业务
            catch (Exception ex) { log.error("保存操作日志失败", ex); }
        }
    }
}

(OperLogEntity/OperLogMapper 用 MPGenerator 按上面的表生成即可。)

④ 使用:在需要审计的 Controller 方法上加注解,一行接入:

@OperLog(module = "用户管理", operation = "删除用户")
@DeleteMapping("/{id}")
public Result deleteById(@PathVariable Long id) { ... }

要点:@Around("@annotation(operLog)") 直接把注解参数注入切面方法,不用反射再取;finally 里落库保证失败也记录;量大时可改为异步(@Async 或消息队列)。

11.4 后端接口级权限校验(@RequirePermission + 拦截器)

问题:目前按钮级权限(hasBtnPermission)在前端,LoginInterceptor 只验证”是否登录”。知道接口地址的人拿自己的 token 就能调任何接口(如删除用户)。给接口加一层权限值校验即可闭环。

① 注解(annotation/RequirePermission.java):

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequirePermission {
    String value();   // 权限值,与 permission 表 permission_value 一致,如 "user:deleteById"
}

② 拦截器(interceptor/PermissionInterceptor.java):

package com.situ.elder.interceptor;

import com.situ.elder.annotation.RequirePermission;
import com.situ.elder.mapper.permission.PermissionMapper;
import com.situ.elder.utils.JwtUtil;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

/**
 * 接口级权限拦截器:在 LoginInterceptor 之后执行(顺序见 WebConfig)。
 * Controller 方法/类上标了 @RequirePermission 的,校验当前用户是否拥有该权限值。
 * 权限值来自 user_role → role_permission → permission 三表联查(与前端 btnList 同源)。
 */
@Component
public class PermissionInterceptor implements HandlerInterceptor {

    private final PermissionMapper permissionMapper;

    public PermissionInterceptor(PermissionMapper permissionMapper) {
        this.permissionMapper = permissionMapper;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        //只拦 Controller 方法,静态资源等直接放行
        if (!(handler instanceof HandlerMethod handlerMethod)) { return true; }

        //方法上的注解优先,其次类上的(Controller 整类共用一个权限值时用)
        RequirePermission require = handlerMethod.getMethodAnnotation(RequirePermission.class);
        if (require == null) { require = handlerMethod.getBeanType().getAnnotation(RequirePermission.class); }
        if (require == null) { return true; }            // 没标注解的接口不校验

        //解析当前用户,查出他的全部权限值集合
        Map<String, Object> claims = JwtUtil.parseToken(request.getHeader("Authorization"));
        Long userId = ((Integer) claims.get("id")).longValue();
        List<String> permissionValues = permissionMapper.selectPermissionByUserId(userId).stream()
                .filter(p -> p.getType() == 2)           // 只要按钮权限值
                .map(p -> p.getPermissionValue())
                .collect(Collectors.toList());

        if (permissionValues.contains(require.value())) { return true; }
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);   // 403 没有权限
        return false;
    }
}

③ 注册(WebConfig,注意顺序:先登录校验,再权限校验):

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(new LoginInterceptor())
            .addPathPatterns("/**")
            .excludePathPatterns(... /* 原白名单不变 */);
    //权限拦截器注册为 order=1(LoginInterceptor 默认 0),登录校验通过后才轮到它
    registry.addInterceptor(permissionInterceptor)
            .addPathPatterns("/admin/**")
            .order(1);
}

④ 使用:

@RequirePermission("user:deleteById")
@DeleteMapping("/{id}")
public Result deleteById(@PathVariable Long id) { ... }

要点:权限值与前端 permission:add 等按钮权限同源同值,配置一次两端生效;前端拦截是”看不见”,这个拦截器才是”调不动”,二者互补。返回 403 后前端 request.js 响应拦截器可加一个分支提示「没有操作权限」。

11.5 BCrypt 密码加密改造

问题:当前登录是明文比对(dbUser.getPassword().equals(user.getPassword())),数据库泄露即全量泄密。只需 spring-security-crypto 单包(不引入整套 Spring Security 过滤器链)。

① pom.xml:

<dependency>
    <groupId>org.springframework.security</groupId>
    <artifactId>spring-security-crypto</artifactId>
    <version>6.4.5</version>
</dependency>

② 工具 Bean(config/PasswordConfig.java):

@Configuration
public class PasswordConfig {
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();   // 每次加密自动加随机盐,同一明文密文都不同
    }
}

③ 注册时加密:

//register / add 用户:把明文换成哈希再入库
user.setPassword(passwordEncoder.encode(user.getPassword()));

④ 登录改比对方式(matches(明文, 密文),不能再用 equals):

//旧:if (!dbUser.getPassword().equals(user.getPassword())) { return Result.error("密码错误"); }
if (!passwordEncoder.matches(user.getPassword(), dbUser.getPassword())) {
    return Result.error("密码错误");
}

⑤ 存量数据迁移(一次性跑一遍):

@Component
public class PasswordMigrateRunner implements ApplicationRunner {
    //启动时把历史明文密码全部重新加密;已加密的($2a$/$2b$ 开头)跳过,幂等可重复执行
    @Override
    public void run(ApplicationArguments args) {
        for (User user : iUserService.list()) {
            String pwd = user.getPassword();
            if (pwd != null && !pwd.startsWith("$2")) {
                User u = new User();
                u.setId(user.getId());
                u.setPassword(passwordEncoder.encode(pwd));
                iUserService.updateById(u);
            }
        }
    }
}

要点:resetPassword(改密时验旧密码)、forgetPassword、Elder 登录(/app/elders/login)三处同步换成 matches;Elder 实体同理。BCrypt 每次结果不同是特性不是 bug。

11.6 健康趋势折线图(app 端)

后端:AppHealthController 追加——取该老人最近 N 次已完成体检里某一指标的历史值:

/**
 * 健康趋势:按体检项目名汇总最近6次已完成体检的数值结果,
 * 返回 [{date, value}],供手机端画折线图(如血压/血糖趋势)
 */
@GetMapping("/trend")
public Result<List<Map<String, Object>>> trend(@RequestParam String itemName) {
    Long elderId = currentElderId(token);
    //1. 最近6次已完成体检(升序,折线从左到右就是时间顺序)
    List<ExamAppointment> list = iExamAppointmentService.lambdaQuery()
            .eq(ExamAppointment::getElderId, elderId)
            .eq(ExamAppointment::getStatus, 2)
            .orderByDesc(ExamAppointment::getAppointmentDate)
            .last("limit 6")
            .list();
    Collections.reverse(list);
    //2. 在这几次体检的明细里找指定项目
    List<Map<String, Object>> trend = new ArrayList<>();
    for (ExamAppointment appointment : list) {
        ExamAppointmentItem item = iExamAppointmentItemService.lambdaQuery()
                .eq(ExamAppointmentItem::getAppointmentId, appointment.getId())
                .eq(ExamAppointmentItem::getItemName, itemName)
                .isNotNull(ExamAppointmentItem::getResultValue)
                .one();
        if (item != null) {
            trend.add(Map.of(
                    "date", DateUtil.format(appointment.getAppointmentDate(), "MM-dd"),
                    "value", item.getResultValue()));
        }
    }
    return Result.ok(trend);
}

前端(Report.vue 或首页卡片,用 echarts):

import * as echarts from 'echarts'

const trendRef = ref(null)
onMounted(() => {
  healthApi.trend('收缩压').then(result => {
    if (result.code !== 1) return
    const chart = echarts.init(trendRef.value)
    chart.setOption({
      xAxis: {type: 'category', data: result.data.map(d => d.date)},
      yAxis: {type: 'value', scale: true},        // scale: 不从0起,波动更明显
      series: [{type: 'line', smooth: true, data: result.data.map(d => d.value),
                areaStyle: {opacity: 0.15}}]
    })
  })
})

要点:项目名用 itemName 快照匹配(明细表冗余字段),不怕体检项目后来改名;数值型才有折线,文本型项目(resultType=0)天然不适合。


附:模块速查表

模块 主表 关键状态字段 核心算法/机制
用户 user / role / user_role user.status RBAC、JWT、Excel
老人标签 elder / tag / elder_tag elder.status(0~5) 多对多先删后插、containsAll AND 筛选
客户合同 customer / contract / contract_attachment contract.status(1~6) 绑定回路(customer_id 反向维护)、附件代理预览
护理 care_item/level/plan/plan_item/task task.status(0~2) generateTasks 逐日展开、护工数据权限
预约 appointment_item/plan/plan_item/task plan.status(0~3)、source(0/1) 免登录提交→分配护工→生成任务
体检 exam_item/package/package_item/appointment/appointment_item appointment.status(0~4) 价格自动汇总、参考范围自动判异常
AI 助手 (无新表) — ChatClient + 系统提示词 + @Tool + 会话记忆 + SSE
手机端 复用以上各表 前端虚拟 status=5(待确认) 统一预约列表三段合并、fetch 手写 SSE 解析
上一篇
下一篇