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