Elder 开发手册 · 问题笔记

Elder 开发手册 · 问题笔记

记录养老项目(Spring Boot 3 + MyBatis-Plus + Vue3 + Element Plus)开发中遇到的真实问题,统一按「现象 → 原因 → 解决方案 → 要点」整理,代码均来自项目实际实现。

目录

序号 问题 关键词
1 前端环境配置 npm / Vue3 / Pinia / Element Plus
2 服务端 Long 精度丢失 Jackson 雪花ID → String
3 时间范围查询查不到数据 时区偏移 / axios toISOString
4 前台 String 向后台传 Date Converter 转换器
5 为什么要用 <template> 包裹 Vue 作用域插槽
6 Pinia 刷新页面数据丢失 persist 持久化插件
7 MyBatis-Plus 参数为什么是 Serializable 标记接口 / 主键兼容
8 逻辑删除后同名无法新增 唯一索引 4 种方案
9 直接输入 URL 跳过权限拦截 路由级鉴权
10 切换页面后数据不更新 keep-alive / onActivated
11 哪些类不能交给 Spring 管理 有状态 / 生命周期
12 同域名部署 localStorage key 冲突 persist 自定义 key
13 AI 回复 Markdown 原样显示 marked + DOMPurify
14 API Key 明文写在代码里 环境变量注入
15 护工能看到其他护工的任务 数据权限 / 行级隔离
16 三个安全类问题的共性总结 面试复盘

1. 前端环境配置

# 创建最新版 Vue3 项目脚手架,交互式选择项目配置(TS、路由、Pinia 等)
npm create vue@latest

# 安装 package.json 中记录的全部项目依赖(初始化项目后执行)
npm install

# 安装 Pinia,Vue3 全局状态管理库(生产依赖,项目运行需要)
npm install pinia

# 安装 axios,用于发送 http 网络请求,调用后端接口(生产依赖)
npm install axios

# 安装 vue-router,Vue 路由,实现页面跳转(i 是 install 简写,生产依赖)
npm i vue-router

# 安装 Element Plus UI 组件库,后台管理页面组件;--save 新版 npm 可省略,保存到生产依赖
npm install element-plus --save

# 安装 sass,scss 样式编译器;-D = --save-dev,开发依赖,仅开发阶段使用,打包不会带入线上
npm install -D sass

依赖分类记忆:不带参数 / --save → 生产依赖(dependencies,线上运行需要);-D / --save-dev → 开发依赖(devDependencies,只有开发构建阶段用,如 sass 编译器)。


2. 服务端 Long 类型的 id,前端精度丢失

现象

后端数据库里的雪花 ID 是 19 位 Long 类型,数据本身正常。但前端拿到 JSON 后 id 末几位变成 0,按 id 查询/删除时对不上记录。

原因

JSON 字符串转 JS 对象时,Long 类型被接收为 Number,而 JS 的 Number 遵循 IEEE 754 双精度标准,能精确表示的整数上限是 Number.MAX_SAFE_INTEGER = 2^53 – 1(约 9×10^15,16 位数字)。雪花 ID 是 19 位,超出安全范围的位会被舍入,导致精度丢失:

数据库真实 id:  1932764851294781441   (19 位,Long 精确)
前端拿到的 id:  1932764851294781400   (末 3 位被舍入,对不上记录)

解决方案:配置 Jackson 全局序列化,Long → String

src/main/java/com/situ/elder/config/JacksonConfig.java(项目实际代码):

package com.situ.elder.config;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;

@Configuration
public class JacksonConfig {

    @Bean
    @Primary
    @ConditionalOnMissingBean(ObjectMapper.class)
    public ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
        ObjectMapper objectMapper = builder.createXmlMapper(false).build();

        // 全局配置序列化返回 JSON 处理
        SimpleModule simpleModule = new SimpleModule();
        // JSON 序列化时 Long ==> String,前端收到的是字符串,精度不丢
        simpleModule.addSerializer(Long.class, ToStringSerializer.instance);
        objectMapper.registerModule(simpleModule);
        return objectMapper;
    }
}

为什么这样写(逐行原因):

  • ToStringSerializer.instance:把 Long 序列化成 JSON 字符串("id": "1932764851294781441")。JS 的 String 没有位数限制,精度天然不丢。
  • @Primary:Spring 容器里可能存在多个 ObjectMapper(Spring Boot 默认自动配置了一个),标注优先使用这一个,所有 @RestController 的 JSON 响应都走它。
  • @ConditionalOnMissingBean(ObjectMapper.class):如果开发者已经自己定义了 ObjectMapper Bean,这个配置就不再生效,避免冲突覆盖。
  • 注册的是 Long.class(包装类型)——MyBatis-Plus 实体主键默认用包装类型 Long,因此覆盖了绝大多数场景;若实体里有原生 long 字段,还需再注册一次。
  • 反向方向不用配:前端把 id 按字符串传回后端时,Jackson 反序列化字符串 → Long 是自动兼容的。

要点

  • 该配置只影响 JSON 响应体;id 出现在 URL 路径或 query 参数时本来就是字符串,不受影响。
  • 前端拿到的 id 变成字符串后,== 比较要改成 === 或统一 toString 再比,避免隐式转换坑。

3. user 表按时间范围筛选查不到数据,时间向前偏移了 16 小时

现象

user 表按时间范围筛选,选「2026-08-26 到 2026-08-28」查不到任何数据。后端解析没有正确识别 Z,导致时间向前偏移了约 16 小时。

根本原因

axios 请求时对 Date 参数自动调 toISOString() 转成 UTC 带 Z 的字符串(比如本地 8-26 00:00 会变成 2026-08-25T16:00:00.000Z)。后端收到后解析时把 Z 忽略、按本地时间当成了 2026-08-25 16:00,JDBC 转 SQL 字符串时又偏一次,最终实际查询时间比选的提前了约 16 小时。

「前端 Date → 后端 query 参数」这条链路的时区表示不一致:

① el-date-picker(daterange) 返回 Date 对象(本地 2026-08-26 00:00 东八区)
② axios 序列化参数时对 Date 调 toISOString() → UTC 带 Z 的 ISO 串
   "2026-08-25T16:00:00.000Z"(本地时间减 8 小时的 UTC 表示)
③ 后端 Spring 绑定带 Z 的串时没按 UTC 处理,生成偏早 8 小时的 Date
④ 结果:SQL 里的实际时间比用户选的早了 8~16 小时 → 范围里没数据

关键点:

  • 前端发的「格式」是标准的(UTC ISO 无歧义),错在后端解析没有正确识别 Z。
  • 这条链路不归 Jackson 管(Jackson 只管 JSON 请求/响应体,yml 里的 time-zone: GMT+8、date-format 管不到 query 参数),也不归 JDBC 管(serverTimezone=Asia/Shanghai 管的是 Date→SQL 字符串那一步,那步本身是对的)。
  • 空格格式 2026-08-26 00:00:00 没有时区歧义,后端按东八区本地时间解析 → 正确。这就是修复的突破口。

解决方案:统一让前端发「本地字符串」,不带时区歧义

① 新增 ui/ui-admin/src/utils/date.js(项目实际代码):

// 把 Date 转成 "YYYY-MM-DD HH:mm:ss" 本地格式字符串。
// 原因:axios 会把 Date 参数自动转成 UTC 的 ISO 串(如 2026-08-25T16:00:00.000Z),
// 后端按本地时间解析、JDBC 又按 UTC 转字符串,双重偏移导致实际查询时间比选的偏早,时间范围查不到数据。
// 传本地字符串后,后端直接按 "yyyy-MM-dd HH:mm:ss" 绑定即可。
export const formatDate = (date) => {
  if (!date) return undefined
  const pad = (n) => String(n).padStart(2, '0')
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
         `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}

// 结束时间补到当天 23:59:59,避免 between 闭区间把当天 00:00 之后的数据过滤掉
export const endOfDay = (date) => {
  if (!date) return undefined
  const d = new Date(date)
  d.setHours(23, 59, 59, 999)
  return formatDate(d)
}

② 页面 loadData 里使用(ui/ui-admin/src/views/user/User.vue 实际代码):

import {formatDate, endOfDay} from '@/utils/date.js'

//日期范围选择器(type="daterange"),v-model 绑定 Date 数组
const CreateChangeTime = ref([])

//加载表格数据:带上日期范围筛选后请求列表接口
const loadData = () => {
  userQuery.value.begintime = formatDate(CreateChangeTime.value?.[0]);
  userQuery.value.endtime = endOfDay(CreateChangeTime.value?.[1]);

  userApi.list(userQuery.value).then(result => {
    list.value = result.data.records
    total.value = result.data.total
  })
}

③ 后端 application.yml 里 Jackson 的时区与格式配置(管 JSON 体,与上一节 Converter 互补):

spring:
  jackson:
    time-zone: GMT+8
    date-format: yyyy-MM-dd HH:mm:ss

一次解决两个问题:

  • 时区偏移:发本地字符串,后端按东八区解析,和库里时间一致。
  • 当天边界:结束时间补到 23:59:59,between 闭区间也能查全「选的那一天」——否则选 8-26 到 8-28 实际查的是 8-26 00:00 ~ 8-28 00:00,28 号当天的数据全被漏掉。

4. 前台 String 向后台传递 Date 日期格式(String → Date)

问题

前端通过 query 参数/表单提交的时间是字符串,Spring MVC 默认的时间转换格式有限,需要配置转换器统一支持项目里用到的几种格式。

解决方案:配置 Converter 转换器

src/main/java/com/situ/elder/config/WebConfig.java(项目实际代码,该类同时负责登录拦截器注册):

package com.situ.elder.config;

import com.situ.elder.interceptor.LoginInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.convert.converter.Converter;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Arrays;
import java.util.Date;
import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        // 拦截所有请求
        registry.addInterceptor(new LoginInterceptor())
                .addPathPatterns("/**")
                .excludePathPatterns("/admin/users/login","/app/elders/login","/admin/users/register", "/users/forgetPassword",
                        // 预约模块公开接口:免登录自助提交预约(落入待确认计划)、预约项目下拉
                        "/admin/appointment-plan/submit", "/admin/appointment-item/public-list",
                        // 体检模块公开接口:免登录查看上架套餐及详情(前台预约体检)
                        "/app/exam-package", "/app/exam-package/*");
    }

    //把前台String改成Date类型
    @Override
    public void addFormatters(FormatterRegistry registry) {
        String DATETIME_FORMAT = "yyyy-MM-dd HH:mm:ss";
        List<String> patterns = Arrays.asList(
                "yyyy-MM-dd HH:mm:ss",       // 普通格式:2026-08-26 12:30:00
                "yyyy-MM-dd",                // 普通格式:2026-08-26
                "yyyy-MM-dd'T'HH:mm:ss.SSSX",// ISO 格式:2025-08-25T09:21:40.922Z
                "yyyy-MM-dd'T'HH:mm:ssX"     // ISO 格式:2025-08-25T09:21:40Z
        );

        registry.addConverter(new Converter<String, Date>() {
            @Override
            public Date convert(String source) {
                for (String pattern : patterns) {
                    try {
                        return new SimpleDateFormat(pattern).parse(source);
                    } catch (ParseException ignored) {
                        // 当前格式不匹配,静默尝试下一种
                    }
                }
                throw new RuntimeException("时间转换失败:" + source);
            }
        });
    }
}

为什么这样写(逐点原因):

  • Converter 管的是 query 参数/表单绑定(@RequestParam、实体属性绑定等非 JSON 请求体),Jackson yml 配置管的是 JSON 请求/响应体——两者互补,都要配,缺一个对应链路就断。
  • patterns 依次尝试,兼容前端各种传参习惯:日期选择器发 yyyy-MM-dd HH:mm:ss,axios 发 ISO 带 Z 格式;全都不匹配时抛出带原始值的明确异常,方便排查。
  • ISO 格式里的 X 表示时区偏移(Z 或 +08:00),带 SSS 的是毫秒版本,两种都要注册。

同时在 yml 中配置(管 JSON 体),规范时区和日期格式:

spring:
  jackson:
    time-zone: GMT+8
    date-format: yyyy-MM-dd HH:mm:ss

要点

  • 第 3 节的时区偏移问题最终靠前端只发空格格式(无时区歧义)解决,这里的 ISO pattern 只是兜底兼容,不代表带 Z 的串能被”正确”按 UTC 解析——SimpleDateFormat 不感知 Z 语义。

5. 为什么要用 <template> 包裹(作用域插槽)

问题

el-table 自定义列内容(状态开关、头像等)时,为什么要用 <template #default="{row}"> 包一层?

项目实际代码(ui/ui-admin/src/views/user/User.vue 状态列)

<el-table-column prop="status" label="状态" width="80">
  <template #default="{row}">
    <el-switch
        v-model="row.status"
        style="--el-switch-on-color: #13ce66; --el-switch-off-color: #ff4949"
        :active-value="1"
        :inactive-value="0"
        inline-prompt
        active-text="正常"
        inactive-text="禁用"
        @change="handleSwitchChange(row)"
    />
  </template>
</el-table-column>

四种等价写法

<!-- 写法1:完整简写(项目采用) -->
<template #default="{row}"> ... </template>

<!-- 写法2:省略插槽名 default(#default 的 default 是默认插槽名,可省略) -->
<template #="{row}"> ... </template>

<!-- 写法3:完整 v-slot 指令 -->
<template v-slot:default="{row}"> ... </template>

<!-- 写法4:v-slot 省略 default -->
<template v-slot="{row}"> ... </template>

原理说明

这里使用的是 Vue 作用域插槽。# 是 v-slot: 的缩写,#default 即默认插槽;该指令只能写在 <template> 标签上(写在普通组件上会编译报错)。

{row} 是子组件 el-table-column 通过作用域插槽向上传递的行数据——el-table 内部渲染每一列时才知道当前是第几行,父组件普通写法拿不到这个数据,作用域插槽就是官方提供的「子传父、渲染权在父」通道。只有在 template 插槽作用域内,内部组件才能访问 row 对象。

<template> 本身不渲染真实 DOM,仅作为插槽容器,用来接收插槽传过来的数据;去掉它直接把 #default 写在 el-switch 上,指令不生效且多渲染一层无意义节点。


6. Pinia 刷新页面数据丢失问题

问题

默认情况下 Pinia 是内存存储,刷新页面时 JS 重新执行、内存清空,Pinia 中的数据(token、用户信息等)会全部丢失——表现为刷新后直接被踢回登录页。

解决方案:persist 持久化插件

persist 插件支持将 Pinia 中的数据持久化到 sessionStorage 和 localStorage 中,页面加载时自动恢复。

① 安装插件

npm i pinia-plugin-persistedstate

② 在 ui/ui-admin/src/main.js 中引入持久化插件(项目实际代码):

import { createApp } from 'vue'
import App from './App.vue'

import router from './router'
import {createPinia} from 'pinia'
const pinia = createPinia()
import ElementPlus from 'element-plus' //导入element-plus
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import 'element-plus/dist/index.css' //导入element-plus样式
//引入持久化插件
import piniaPluginPersistedstate from "pinia-plugin-persistedstate"
const app = createApp(App)
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

// 全局挂载和注册 element-plus 的所有 icon
app.config.globalProperties.$icons = []
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
    app.config.globalProperties.$icons.push(key)
    app.component(key, component)
}

//使用持久化插件
pinia.use(piniaPluginPersistedstate)

app.use(router).use(pinia).use(ElementPlus, {locale: zhCn }).mount('#app')

③ 在需要持久化的 store 模块下添加 persist 配置

ui/ui-admin/src/store/token.js:

import {defineStore} from 'pinia'

export const useTokenStore = defineStore('token', {
    //存储数据地方
    state() {
        return {
            token: ''
        }
    },
    //actions里面放的是一个一个方法
    actions: {
        setToken(newToken) {
            this.token = newToken
        },
        removeToken(value) {
            this.token = ''
        }
    },
    persist: {
        enabled: true,           // 开启缓存  默认会存储在本地localstorage
        key: 'elder-admin-token' // 独立key,见第12节:同域名部署时避免与手机端互相覆盖
    },
})

要点

  • 默认持久化到 localStorage,key 就是 store 的 id('token')——由此引出第 12 节的同域名 key 冲突问题,所以项目里显式指定了独立 key。
  • 修改 state 会自动同步到 localStorage,无需手动调用保存方法;刷新后插件自动把存储值灌回 state。

7. MyBatis-Plus 的方法参数为什么是 Serializable 类型

什么是 Serializable

Serializable 是标记接口,没有任何方法;实现这个接口代表该类支持 Java 序列化(对象转字节流)。

Integer、Long、String、UUID 全部都实现了 Serializable。

为什么 MP 设计参数是 Serializable

MP 源码里 BaseMapper 的主键相关方法一律声明为 Serializable:

public interface BaseMapper<T> extends Mapper<T> {
    // MP 源码签名:主键参数不写具体类型,统一声明为 Serializable
    int deleteById(Serializable id);
    T selectById(Serializable id);
    int deleteBatchIds(Collection<? extends Serializable> idList);
}

主键可以是多种类型:

  1. 数字主键:Integer、Long(自增 id)
  2. 字符串主键:String、UUID 字符串
  3. UUID 对象主键

这些类型全部都实现了 Serializable。MP 作为通用框架不知道每张表的主键是什么类型,就把方法参数定义为它们的公共父接口 Serializable——任何合法主键类型都能传进来,这就是「面向接口/父类编程」的典型应用。

补充(易错点):想用 updateById 屏蔽密码字段不更新,setPassword("") 是不可行的——MP 的更新语句只忽略值为 null 的字段(按 null 不拼进 SET 子句),空字符串 "" 是有效值,会把密码真的更新为空。正确做法是 user.setPassword(null)(或查出实体后不动该字段)。


8. 逻辑删除后同名无法新增(唯一约束设计)

场景:MyBatis-Plus 逻辑删除,is_deleted/deleted:0=未删除,1=已逻辑删除;业务字段 name 需要唯一。

逻辑删除本质:不会真正 DELETE,而是执行 UPDATE,把删除标记置为 1,数据物理保留在表中。

项目 application.yml 的逻辑删除配置:

mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleted     # 逻辑删除字段名(实体上加 @TableLogic)
  #   logic-delete-value: 1           # 逻辑已删除值(默认为 1)
  #   logic-not-delete-value: 0       # 逻辑未删除值(默认为 0)

方案1:仅业务字段唯一索引 UNIQUE(name)(错误方案)

  • 表存在记录:name="张三",deleted=0
  • 执行逻辑删除:UPDATE user SET deleted=1 WHERE id=?
  • 此时数据库真实存在:name="张三",deleted=1
  • 尝试新增 name="张三",deleted=0
  • ❌ 报错:唯一索引冲突,无法新增

原因:唯一索引只看 name 字段,不识别逻辑删除标记,只要数据物理存在,就占用该名称,即使已经逻辑删除。

方案2:联合唯一索引 UNIQUE(name, deleted)(临时方案,有坑)

原理

唯一索引判重粒度变为:name + deleted 两个字段组合整体唯一。

行 name deleted 索引组合值
旧逻辑删除记录 张三 1 (张三,1)
待新增记录 张三 0 (张三,0)
  • (张三,1) 和 (张三,0) 组合不相同,可以插入成功。
  • 业务约束:deleted=0 为有效数据,(张三,0) 只能存在 1 条,保证活跃数据 name 不重复。

致命缺陷(面试高频考点)

同一个业务名称,最多只能逻辑删除 1 次,不能多次删除同名记录

流程演示:

  1. 插入 张三,0 → 删除 → 张三,1
  2. 新增 张三,0 ✅ 成功
  3. 再次删除这条新记录 → 更新为 张三,1
  4. 此时表里两条记录:(张三,1)、(张三,1)
  5. ❌ 联合索引检测到组合重复,update 直接抛出唯一键冲突报错。

根源:多条同名记录全部逻辑删除后,name+deleted 组合完全一样,触发唯一约束。

✅ 适合场景:业务几乎不会反复删除、复用同一个名字;❌ 不适合:业务会反复删除-新增同名数据。

方案3:MySQL 8.0+ 表达式唯一索引(推荐,无次数限制)

底层原理:MySQL 唯一索引中,NULL 不参与唯一性校验,多条 NULL 可以共存。

  • deleted=0:返回业务名称,做唯一校验
  • deleted=1:返回 NULL,不占用索引槽位
-- 删除旧联合索引
ALTER TABLE `user` DROP INDEX uk_user_name_deleted;

-- 创建表达式唯一索引,外层两层括号不可省略:
-- 内层括号是表达式,外层括号是 CREATE INDEX 的语法要求
CREATE UNIQUE INDEX uk_user_name ON `user` ((CASE WHEN deleted = 0 THEN name ELSE NULL END));

优点:

  1. 有效数据强制 name 唯一;
  2. 同一个名字可以无限次逻辑删除、新增;
  3. 不新增额外列,表结构干净。

限制:MySQL 8.0 及以上版本支持,MySQL 5.7 不支持表达式索引。

方案4:生成列 + 唯一索引(兼容 MySQL 5.7 / 8.0,通用方案)

MySQL 5.7 不支持表达式索引,使用虚拟生成列(Generated Column),数据库自动计算,业务代码无需操作该列。

-- 删除旧联合索引
ALTER TABLE `user` DROP INDEX uk_user_name_deleted;

-- VIRTUAL:虚拟列,内存计算,不占用磁盘存储
ALTER TABLE `user`
ADD COLUMN uk_name_gen VARCHAR(255) GENERATED ALWAYS AS (
    CASE WHEN deleted = 0 THEN name ELSE NULL END
) VIRTUAL;

-- 给生成列建立唯一索引
CREATE UNIQUE INDEX uk_user_name_gen ON `user`(uk_name_gen);
  • deleted=0:生成列赋值为 name,唯一索引校验重复;
  • deleted=1:生成列赋值为 NULL,允许多条 NULL,无冲突;
  • 业务代码完全不需要感知、操作 uk_name_gen,MySQL 自动计算。

VIRTUAL:虚拟列,不占磁盘;STORED:真实存储数据,占用磁盘,一般不用。

四种方案对比表

方案 SQL 写法 MySQL 版本 优点 缺点
方案1 UNIQUE(name) 全部 简单 逻辑删除后同名永远无法新增,废弃
方案2 UNIQUE(name,deleted) 全部 实现简单,快速解决问题 同一名称最多只能删除 1 次,多次删除同名报错
方案3 CASE 表达式唯一索引 8.0+ 无额外列,可无限次删除复用 5.7 不支持
方案4 生成列+唯一索引 5.7/8.0 全版本兼容,无删除次数限制 多一个虚拟生成列

MyBatis-Plus 逻辑删除开发建议

  1. 业务允许名字仅删除一次:可以临时用 UNIQUE(name,deleted);
  2. 业务存在反复删除新增同名:
  • MySQL 8.0:优先选择表达式索引;
  • MySQL 5.7:使用生成列+唯一索引;
  1. 代码层兜底:新增/修改前先查询,判断有效数据是否重名;
  2. 全局异常捕获 DuplicateKeyException,返回前端友好提示「该名称已存在」:
// 全局异常兜底示例
@ExceptionHandler(DuplicateKeyException.class)
public Result<?> handleDuplicateKey(DuplicateKeyException e) {
    return Result.error("该业务名称已存在,请更换");
}

面试口述总结

逻辑删除只是修改标记,数据物理上仍然保存在数据库。如果直接对业务字段建立唯一索引,逻辑删除后的记录依然占用索引,导致同名新数据无法插入。使用业务字段+删除标记的联合唯一索引,把判重粒度改为字段组合,删除状态 1 和有效状态 0 组合值不同,可以实现删除后新增同名。但是该方案存在缺陷:同一个业务名称最多只能逻辑删除一次,多条同名记录同时删除会报索引冲突。MySQL 8.0 推荐表达式唯一索引,利用 NULL 不参与唯一校验的特性,只对有效数据做唯一性约束,支持无限次删除复用;MySQL 5.7 环境使用生成列+唯一索引实现相同效果。


9. 权限管理系统可以直接输入 URL 跳过拦截 → 路由级鉴权

需求

之前只有「菜单级」+「按钮级」权限,路由守卫只校验登录 → 登录后直接输 /user 就能绕过,看到无权页面。

方案

把「当前用户可见菜单路径」存入 Pinia,守卫在放行前校验目标路径是否在权限集合内,不在则拦截回首页。首页与个人中心(/chart、/userCenter、/resetPassword)不属于权限菜单,任何登录用户都放行。

store 增加菜单与鉴权(ui/ui-admin/src/store/userInfo.js,项目实际代码)

import {defineStore} from 'pinia'

export const useUserInfoStore = defineStore('userInfo', {
    state() {
        return {
            user: {},
            btnList: [],
            // 当前用户拥有的角色编码(如 HUGONG=护工),用于页面上的身份判断
            roleCodes: [],
            // 当前用户可见菜单(树),来自后端 routerList,用于路由级鉴权
            routerList: [],
            // 是否已从后端加载过权限数据(避免每次路由跳转都重复请求)
            routesLoaded: false
        }
    },
    getters: {
        // 当前用户允许访问的路由路径集合
        // 首页/个人中心等不属于权限菜单,任何登录用户都能访问,故固定放行
        allowedPaths(state) {
            const acc = ['/chart', '/userCenter', '/resetPassword']
            const walk = (list) => {
                (list || []).forEach(item => {
                    if (item.path) acc.push(item.path)
                    if (item.children?.length) walk(item.children)
                })
            }
            walk(state.routerList)
            return acc
        }
    },
    actions: {
        setUser(user) {
            this.user = user
        },
        removesetUser(value) {
            this.user = {}
        },
        setBtnList(btnList) {
            this.btnList = btnList
        },
        removeBtnList() {
            this.btnList = []
        },
        setRouterList(routerList) {
            this.routerList = routerList || []
            this.routesLoaded = true
        },
        setRoleCodes(roleCodes) {
            this.roleCodes = roleCodes || []
        },
        removeRouterList() {
            this.routerList = []
            this.routesLoaded = false
        },
        // 退出登录时清空用户及权限数据,防止下一账号沿用上一账号的权限(越权)
        resetAll() {
            this.user = {}
            this.btnList = []
            this.routerList = []
            this.roleCodes = []
            this.routesLoaded = false
        }
    },
    persist: {
        enabled: true,          // 开启缓存  默认会存储在本地localstorage
        key: 'elder-admin-user' // 独立key,避免与手机端(elder-app-user)在同一域名下互相覆盖
    },
})

设计原因:

  • allowedPaths 用 getter 递归展开菜单树(type=1 的目录 + type=2 以下的菜单节点都带 path),把树拍平成路径数组,守卫 O(1) 查询。
  • routesLoaded 标志位:避免每次路由跳转都重复请求 /userInfo——只在第一次(或退出后)拉取。
  • resetAll 必须清空全部权限字段:否则 A 管理员退出、B 护工登录的瞬间,B 会短暂沿用 A 的权限缓存(越权窗口)。

Index.vue 侧(登录加载 + 退出清权限,项目实际代码)

//登录后拉取用户信息,存储菜单树(供侧边栏渲染 + 路由守卫鉴权)
const getUserInfo = () => {
  userApi.userInfo().then(result => {
    if (result.code == 1) {
      //数据存储在pinia中
      UserInfoStore.setUser(result.data.user)
      //首页固定在最前,不依赖角色权限(脱离权限系统);其余菜单由后端 routerList 按权限动态生成
      menuData.value = [
        {name: '首页', icon: 'TrendCharts', path: '/chart'},
        ...result.data.routerList
      ]
      UserInfoStore.setBtnList(result.data.btnList)
      // 存下菜单树,供路由守卫做"路径级"鉴权,防止直接输 URL 绕过权限
      UserInfoStore.setRouterList(result.data.routerList)
    }
  })
}
getUserInfo()

const handleCommand = (command) => {
  if (command === 'logout') {
    tokenStore.removeToken();
    // 清除本地缓存的用户与权限数据,避免换账号登录后沿用旧权限
    UserInfoStore.resetAll();
    // 跳转到登录
    router.push('/login')
  } else if (command === 'info') {
    router.push('/userCenter')
  } else if (command === 'resetPassword') {
    router.push('/resetPassword')
  } else {
    //路由
    router.push('/user/' + command)
  }
}

路由守卫(ui/ui-admin/src/router/index.js,项目实际代码)

//路由守卫
//全局前置守卫
import {useTokenStore} from '@/store/token.js'
import {useUserInfoStore} from '@/store/userInfo.js'
import userApi from '@/api/user/user.js'
import {ElMessage} from "element-plus";
let whiteList = ['/login', '/register', '/forget']; // 白名单:未登录也能访问(含注册页)
router.beforeEach(async (to) => {
    const tokenStore = useTokenStore()
    const userInfoStore = useUserInfoStore()
    const token = tokenStore.token;

    // 已登录访问登录/忘记密码页时,跳转到首页(公开预约页 /booking 任何状态都可访问)
    if (to.path !== '/booking' && whiteList.includes(to.path) && token) {
        ElMessage.success("已成功登录")
        return '/'
    }
    // 未登录访问非白名单页面时,跳转到登录页
    if (!whiteList.includes(to.path) && !token) {
        return '/login'
    }
    // 白名单页(/login、/forget)直接放行,不参与鉴权
    if (whiteList.includes(to.path)) {
        return true
    }
    // ——已登录进入受保护区域——
    // 若权限尚未加载(如直接刷新 /user 或刚登录跳转),先从后端拉取菜单,才能判断有无权限
    if (!userInfoStore.routesLoaded) {
        try {
            const result = await userApi.userInfo()
            if (result.code === 1) {
                userInfoStore.setUser(result.data.user)
                userInfoStore.setBtnList(result.data.btnList)
                userInfoStore.setRouterList(result.data.routerList)
                userInfoStore.setRoleCodes(result.data.roleCodes)
            }
        } catch (e) {
            // 拉取失败保持空权限,未授权路径一律被拦截
        }
    }
    // 路径级鉴权:目标路由不在允许列表内则拦截回首页,防止直接输 URL 绕过权限系统
    if (!userInfoStore.allowedPaths.includes(to.path)) {
        ElMessage.warning('无权访问该页面')
        return '/chart'
    }
})

守卫的判断顺序是有讲究的:

  1. 先处理「已登录访问白名单」→ 回首页(防止登录后又进登录页);
  2. 再处理「未登录访问受保护页」→ 去登录;
  3. 白名单页必须显式放行,否则下面的路径鉴权会把 /login 也拦截,形成死循环;
  4. 权限未加载时先 await 拉取——覆盖「直接刷新 /user」场景:刷新后 Pinia 虽有 persist 缓存,但 routesLoaded 逻辑保证数据是最新的;
  5. 最后才做路径鉴权,拦截回 /chart(首页任何登录用户都可访问,保证一定放行成功)。

后端返回结构(UserController.userInfo,项目实际代码)

@GetMapping("userInfo")
public Result userInfo(@RequestHeader(name = "Authorization") String token){

    Map<String,Object> map = JwtUtil.parseToken(token);
    Integer id = (Integer)map.get("id");
    User user = iUserService.getById(id);
    user.setPassword(null);   //密码不能返回给前端,置null后Jackson不会输出该字段

    Map<String,Object> PermissionMap = iPermissionService.selectUserPermissionInfoById(user.getId());

    Map<String,Object> resultMap = new HashMap<>();
    resultMap.put("user",user);
    resultMap.put("routerList",PermissionMap.get("routerList"));
    resultMap.put("btnList",PermissionMap.get("btnList"));
    //返回角色编码列表,前端据此区分身份(如护工 HUGONG 只能看自己的任务)
    resultMap.put("roleCodes",iUserService.getRoleCodes(user.getId()));

    return Result.ok(resultMap);
}

返回 JSON 结构示例:

{
  "user": {"id": "1", "name": "admin", "...": "..."},
  "routerList": [{"id": "41", "parentId": "40", "name": "护理项目", "type": 1, "path": "/careItem", "children": []}],
  "btnList": ["careItem:add", "careItem:update"],
  "roleCodes": ["ADMIN"]
}

routerList 由 PermissionServiceImpl.selectUserPermissionInfoById() 构建(项目实际代码):

public Map<String, Object> selectUserPermissionInfoById(Long id) {

    //根据用户id 查询这个用户所有权限
    List<Permission> permissionList = permissionMapper.selectPermissionByUserId(id);
    List<String> btnList = new ArrayList<>();
    List<PermissionVo> permissionVoList = new ArrayList<>();
    permissionList.forEach(permission -> {
        if (permission.getType() == 2) {
            //按钮权限的"权限值"在 permission_value 字段(如 user:add),
            //用它拼 btnList 才能和前端 hasBtnPermission 对得上
            btnList.add(permission.getPermissionValue());
        } else {
            PermissionVo permissionVo = new PermissionVo();
            BeanUtils.copyProperties(permission, permissionVo);
            permissionVoList.add(permissionVo);
        }
    });
    return Map.of("routerList", buildTree(permissionVoList), "btnList", btnList);
}

重要安全边界

前端拦截是「看不见 / 点不进没授权页面」的第一道防线,不能替代后端校验——浏览器里直接调接口就能绕过一切前端限制。真正的数据安全靠后端每个接口的 token + 权限校验(见第 15 节的数据权限)。权限调整后前端可能因 persist 缓存暂未刷新,属可接受取舍;如需强一致,可在守卫里每次强制拉取。


10. 页面添加完后,刷新其他页面才显示出添加的信息 → keep-alive 与 onActivated

根因分析:项目用了 keep-alive 缓存页面

ui/ui-admin/src/views/home/Index.vue 的内容区(项目实际代码):

<!-- 中间区域 -->
<el-main>
  <!-- 页面内容 -->
  <router-view v-slot="{ Component }">
    <keep-alive>
      <component :is="Component" :key="route.fullPath"/>
    </keep-alive>
  </router-view>
</el-main>

keep-alive 的作用:组件切走时不销毁,而是缓存在内存里;切回来直接复用缓存实例。好处是保留页面状态(表单填了一半的内容、滚动位置、分页条件都不丢),多标签页后台系统基本都用它。

代价:setup(含数据加载)只执行一次

Vue 组件的生命周期只有一次「挂载」:

首次进入「护理任务」页:
  setup 执行 → loadOptions() + loadData() 拉取下拉和表格 ✅

切去「护理计划」页添加计划:
  护理任务组件 → 被 deactivate(失活),但没销毁,还在缓存里

切回「护理任务」页:
  命中缓存 → 复用旧实例
  ❌ setup 不会再执行
  ❌ onMounted 也不会再触发
  → 页面上还是离开前的旧数据

而页面里的增删改查操作后都会手动调 loadData() 刷新本页面,所以单页面内部操作看不出问题——问题只出现在跨页面的数据联动上(如在护理计划页新增计划生成了任务,切回护理任务页看不到)。

解决方案:onActivated 钩子

Vue 专门为 keep-alive 提供了两个专属生命周期钩子:

钩子 触发时机
onActivated 缓存组件被激活时(首次挂载后也会触发一次,以及每次切回来)
onDeactivated 缓存组件被失活时(切走)

ui/ui-admin/src/views/care/CareTask.vue(项目实际代码):

import {ref, computed, onActivated} from 'vue'

//下拉数据
const elderList = ref([])
const carePlanList = ref([])
const userList = ref([])

//加载下拉框数据(老人、护理计划、护工)
const loadOptions = () => {
  elderApi.list({page: 1, limit: 1000}).then(result => {
    elderList.value = result.data.records
  })
  carePlanApi.list({page: 1, limit: 1000}).then(result => {
    carePlanList.value = result.data.records
  })
  // ...护工列表同理
}

//首次挂载和每次被keep-alive重新激活时都会执行,重新加载下拉数据和任务表格
//(在护理计划页新增计划生成的任务,切回本页就能看到,无需刷新浏览器)
onActivated(() => {
  loadOptions()
  loadData()
})

//加载护理任务表格数据
const loadData = () => {
  careTaskQuery.value.begintime = formatDate(CreateChangeTime.value?.[0]);
  careTaskQuery.value.endtime = endOfDay(CreateChangeTime.value?.[1]);
  careTaskApi.list(careTaskQuery.value).then(result => {
    list.value = result.data.records
    total.value = result.data.total
  })
}

两个必须知道的细节:

  1. onActivated 首次挂载也会触发。项目实际代码里 setup 顶层还保留了一次 loadData(),因此首次进入页面会请求两次表格数据——不影响功能,但严格来说顶层的重复调用可以删掉,只留 onActivated 一处即可。
  2. 下拉和表格是两个函数,要一起放进钩子。之前只放 loadOptions() 修好了下拉,但任务表格还是旧的——因为表格数据是 loadData() 拉的,两个都得放进激活钩子。

项目中的实际修改(4 个页面)

页面 onActivated 内容 解决的问题
CareTask.vue 护理任务 onActivated(() => { loadOptions(); loadData() }) 护理计划新增后,任务表格 + 下拉都更新
AppointmentTask.vue 预约任务 onActivated(() => { ... }) 预约计划新增后,任务表格 + 下拉都更新
CarePlan.vue 护理计划 onActivated(loadOptions) 老人/护工/护理等级/护理项目下拉更新
AppointmentPlan.vue 预约计划 onActivated(loadOptions) 老人/护工/预约项目下拉更新

其他可选方案对比

方案 做法 优点 缺点
onActivated(本项目采用) 页面里加激活钩子重新拉数据 精准、改动小、保留 keep-alive 的状态保留优势 每个需要跨页数据的页面都要加一行
去掉 keep-alive <router-view/> 不包 keep-alive 一劳永逸,每次进页面都重新挂载加载 丢失所有页面状态:表单内容、搜索条件、分页位置切走就重置,体验差
watch 路由 watch(() => route.path, () => loadData()) 也能实现 组件首次挂载不触发(要加 immediate),且路由参数变化也会触发,不如 onActivated 语义清晰
路由后置守卫统一刷新 router.afterEach 里通知当前页面刷新 全局一处配置 需要事件总线/暴露刷新方法,耦合复杂,过度设计

结论:后台管理系统中保留 keep-alive + 在需要的页面加 onActivated,是改动最小、体验最好的做法。

经验总结

  1. keep-alive 改变了生命周期模型:setup / onMounted 只在首次创建时执行;切回缓存页面走的是「激活」而不是「重新挂载」。任何「进页面就要拉最新数据」的逻辑,在 keep-alive 项目里都应该放进 onActivated。
  2. 判断口诀:
  • 页面内部增删改查后要刷新 → 操作成功回调里手动调 loadData()(本来就在做);
  • 其他页面改了数据、切回来要看到 → 加 onActivated 重新拉取。
  1. 排查思路:遇到「刷新浏览器才正常,页面切换就不对」,先看 <router-view> 是否包了 keep-alive,再看数据加载是不是只在 setup 顶层执行了一次。
  2. 连带注意:下拉框数据(老人、护工、护理项目这类「选项」)最容易踩坑,因为它们通常在 loadOptions() 里只加载一次;表格数据反而常被本页操作顺带刷新。新写页面时直接把「进页加载」统一写进 onActivated 即可避免。

11. 为什么有些类不能 @Component 纳入 Spring 管理、不能 @Autowired,只能从调用方传参数

核心结论

Spring 容器默认采用单例(Singleton)模式管理 Bean,天然适合无状态、可全局复用的对象;凡是违背「无状态、生命周期匹配、依赖可注入」原则的类,都不适合纳入 Spring 容器管理,强行纳入会引发并发安全、业务逻辑错乱、框架兼容等问题。

四类不适合纳入 Spring 管理的典型场景

1. 有状态对象:单例会引发并发安全事故

这是最核心的原因。

  • 判定标准:类中包含请求级/用户级/任务级的可变成员变量,每次调用、每个任务对应的变量值都不同。
  • 典型案例:
  • ElderTools 中的 elderId:每个对话、每个老人对应不同的 ID,是动态上下文数据。
  • EasyExcel 读取监听器:内部维护读取行数、批次缓存、解析上下文等任务状态。
  • 问题后果:单例模式下所有并发请求共享同一个对象,成员变量会被互相覆盖,出现数据串号、隐私泄露、结果错乱等严重业务 Bug。

2. 生命周期不匹配:任务级对象 vs 应用级单例

  • 判定标准:类的生命周期应该和单次任务/单次对话绑定,任务完成就该销毁释放,而不是和应用同生命周期。
  • 典型案例:
  • Excel 导入监听器:生命周期 = 单次 Excel 文件解析过程。
  • 会话绑定的 AI 工具:生命周期 = 单次用户对话过程。
  • 问题后果:单例 Bean 会长期驻留内存,上一次任务的状态数据会残留,污染下一次任务的执行,出现数据重复、状态异常等隐性问题。

3. 框架设计规范:要求每次使用必须新建实例

  • 判定标准:第三方框架的官方设计约定,明确要求每次调用传入全新实例,不支持对象复用。
  • 典型案例:
  • EasyExcel 官方规范:EasyExcel.read() 每次必须传入新的 AnalysisEventListener 实例。
  • Spring AI 有状态工具:需要绑定单次对话的上下文,通过 tools(new XxxTools()) 手动传入实例。
  • 问题后果:强行复用单例实例不符合框架设计约定,会出现解析异常、上下文错乱、功能不兼容等问题。

4. 依赖场景不匹配:需要动态传入运行时参数

  • 判定标准:类的构造不仅需要容器中的 Bean 依赖,还需要每次调用时动态生成的上下文参数(这些参数不是 Spring 管理的 Bean)。
  • 典型案例:
  • ElderTools 需要同时注入业务 Service + 动态传入当前老人 ID。
  • 任务处理器需要同时注入通用依赖 + 动态传入任务 ID、业务参数。
  • 问题后果:Spring 单例只能注入容器内的固定 Bean,无法动态传入每次不同的运行时参数;原型模式虽技术可行,但写法繁琐,不如直接 new 清晰高效。

快速判断原则

特征 适合纳入 Spring 管理 不适合纳入 Spring 管理
状态 完全无状态,无可变成员 包含任务级/用户级可变状态
生命周期 和应用同周期,长期存活 和单次任务绑定,用完即毁
依赖 仅依赖容器内的 Bean 需要动态传入运行时参数
复用性 全局通用,可反复复用 每次使用都是独立上下文

补充:技术上都可以通过 @Component + @Scope("prototype") 强行纳入容器,但绝大多数场景下,手动 new 实例 + 构造方法传参,是更清晰、更安全、更符合框架约定的做法。


12. 部署到同一域名后 token 重复,登录信息串号(localStorage key 冲突)

现象

前后台两个项目部署到同一域名后,登录信息重复出现 / 串号。

原因

两个项目的 store 都叫 token 和 userInfo。pinia-plugin-persistedstate 持久化到 localStorage 时,默认 key 就是 store 的 id——即两边都往 localStorage['token'] 和 localStorage['userInfo'] 里写。

本地开发时两个项目端口不同(5173/5174),localStorage 按「源」隔离,相安无事。部署到同一域名后就是同一个源,于是:

  1. 登录后台 → localStorage['token'] = 后台管理员的 token
  2. 打开前台 → 前台从 localStorage['token'] 读到的是管理员的 token,请求头 Authorization 带出去 → 后端解析出管理员的 id → 返回管理员的信息(反之亦然)

这就是「信息重复/串号」的根源。

解决方式:显式指定不同的 key(项目实际代码)

插件的 persist 配置支持自定义 key,给两边各起独立的名字:

// ui/ui-admin/src/store/token.js
persist: {
    enabled: true,            // 开启缓存  默认会存储在本地localstorage
    key: 'elder-admin-token', // 指定独立的key:前后台部署在同一域名下时,
                              // 避免与手机端(elder-app-token)互相覆盖
}

// ui/ui-app/src/store/token.js
persist: {
    enabled: true,          // 开启缓存  默认会存储在本地localstorage
    key: 'elder-app-token', // 指定独立的key:前后台部署在同一域名下时,
                            // 避免与管理后台(elder-admin-token)互相覆盖
}

userInfo store 同理:后台 key: 'elder-admin-user',前台 key: 'elder-app-user'。

两个键互不相干,同域名下也能共存:

localStorage = {
  'elder-admin-token': '"后台管理员的token"',
  'elder-app-token':   '"老人的token"',
  'elder-admin-user':  '{"user":{...}}',
  'elder-app-user':    '{"elder":{...}}'
}

要点

  • localStorage 按「源(协议+域名+端口)」隔离,端口不同 = 不同源,本地开发发现不了这个问题,只有同域名部署后才暴露——这是典型的「开发环境掩盖线上问题」。
  • 排查口诀:前端「串号/串数据」问题,先打开 DevTools → Application → Local Storage 看两边的 key 是否撞名。

13. AI 助手输出的是 Markdown 格式,页面显示原始符号

现象

康养小智的回复里 加粗、- 列表、### 标题 等符号原样显示在聊天气泡里,没有任何排版效果。

原因分析

  • 大模型(DeepSeek)返回的内容默认就是 Markdown 源文本——这是 LLM 的输出习惯,无法从接口层关闭;
  • 前端 Chat.vue 的气泡用的是 Vue 插值:
<!-- 原来的写法:纯文本插值,不解析任何格式 -->
<div class="bubble">{{ msg.content }}</div>
  • Vue 的 {{ }} 插值只做纯文本输出(这是安全特性,防止注入),Markdown 语法符号自然被当成普通字符渲染出来。

解决方案

引入 marked(Markdown → HTML)+ DOMPurify(消毒),只对 AI 消息启用渲染,用户消息保持纯文本。

npm install marked dompurify

完整代码(ui/ui-app/src/views/Chat.vue,项目实际代码)

① script 部分

import {ref, nextTick, onUnmounted} from 'vue'
import {useRouter} from 'vue-router'
import {marked} from 'marked'
import DOMPurify from 'dompurify'
import chatApi from '@/api/chat.js'

//AI回复是Markdown格式:marked渲染成HTML;DOMPurify消毒防XSS(AI输出属于不可信内容,禁止直接v-html)
//breaks:true 让单个换行也断行,更符合聊天场景
marked.setOptions({breaks: true})
const renderMd = (text) => DOMPurify.sanitize(marked.parse(text || ''))

② 模板部分

<div class="bubble">
  <!--等待首段回复时显示打字动画-->
  <span v-if="msg.streaming && !msg.content" class="dotting"><i></i><i></i><i></i></span>
  <template v-else>
    <!--AI消息渲染Markdown(已消毒),用户消息保持纯文本-->
    <div v-if="msg.role === 'ai'" class="md-body" v-html="renderMd(msg.content)"></div>
    <template v-else>{{ msg.content }}</template>
    <span v-if="msg.streaming" class="cursor"></span>
  </template>
</div>

③ 样式部分(关键:必须用 :deep() 穿透)

.bubble {
  // ...气泡基础样式不变...
  white-space: pre-wrap; // 只对用户消息生效(保留手动换行)

  //AI消息的 Markdown 渲染区:
  //v-html 插入的子节点没有 scoped 标记,样式必须用 :deep() 穿透
  .md-body {
    white-space: normal; //HTML的<p>等已自带换行,关掉pre-wrap防止双重换行

    :deep(p) { margin: 4px 0; }
    :deep(h1), :deep(h2), :deep(h3), :deep(h4) { margin: 10px 0 4px; font-size: 15px; }
    :deep(ul), :deep(ol) { margin: 4px 0; padding-left: 20px; }
    :deep(code) {
      padding: 1px 5px; border-radius: 4px;
      background: rgba(46, 53, 47, 0.08); font-size: 13px;
    }
    :deep(pre) {
      margin: 6px 0; padding: 8px 10px; border-radius: 8px;
      background: rgba(46, 53, 47, 0.9); overflow-x: auto;
    }
    :deep(pre code) { padding: 0; background: none; color: #e8e5da; }
    :deep(blockquote) {
      margin: 6px 0; padding: 2px 10px;
      border-left: 3px solid #2e7ce8; color: #6b756c;
    }
    :deep(table) { margin: 6px 0; border-collapse: collapse; }
    :deep(th), :deep(td) { padding: 4px 8px; border: 1px solid #e8e5da; font-size: 12px; }
    :deep(a) { color: #2e7ce8; }

    //消息末尾最后一个元素不留多余下边距
    :deep(>:last-child) { margin-bottom: 0; }
  }
}

//等待回复时的三点跳动动画
.dotting {
  display: inline-flex;
  gap: 4px;
  // ...i 元素动画...
}

两个必须知道的知识点

  1. 为什么必须 DOMPurify:AI 输出属于「不可信内容」。如果模型被提示词注入诱导输出了 <script> 或 <img onerror=...>,直接 v-html 就是存储型 XSS 漏洞。DOMPurify.sanitize 会剥掉危险标签/属性,只保留安全 HTML。
  2. 为什么样式要 :deep():Vue scoped 样式靠给节点加 data-v-xxx 属性实现,而 v-html 插入的 DOM 不带这个属性,普通 scoped 选择器匹配不到,必须 :deep() 穿透。
  3. 为什么用户消息不用渲染:用户输入的是自己打的纯文本,没有 Markdown 语义,{{ }} 插值即可;且对用户输入做 v-html 同样有 XSS 风险。

14. DeepSeek API Key、阿里云 AccessKey 明文写在代码里

现象

  • application.yml 里 DeepSeek 的 sk-xxxx 完整明文;
  • AliOSSUtil.java 里 ACCESS_KEY_ID = "LTAI5t..." 硬编码;
  • 这些文件随 git 提交,密钥进了仓库历史——即使后来删掉,历史提交里仍能翻到,等于永久泄露。

原因分析

开发时把「配置的结构」和「配置的值」混在了一起。正确做法是:代码里只留配置的结构(key 名),值由部署环境注入。且要认识到:明文提交过的密钥无法靠删除文件挽回,只能去云平台轮换。

解决方案:配置与代码分离

① application.yml:改占位符(项目实际代码)

# 阿里云OSS配置(从环境变量注入,代码里不留明文;冒号后默认值留空表示必须配环境变量)
oss:
  endpoint: https://oss-cn-beijing.aliyuncs.com
  bucket-name: ellder
  access-key-id: ${ACCESS_KEY_ID:}
  access-key-secret: ${ACCESS_KEY_SECRET:}
spring:
  ai:
    deepseek:
      api-key: ${DEEPSEEK_API_KEY:} # 建议用环境变量注入,不要明文写死
      base-url: https://api.deepseek.com # 默认就是该地址,可省略

占位符语法:${环境变量名:兜底默认值},冒号后留空表示「必须配环境变量,否则为空」,避免把默认密钥又写回代码。

② AliOSSUtil:静态工具类改造成 Spring Bean,@Value 注入(src/main/java/com/situ/elder/utils/AliOSSUtil.java,项目实际代码)

原来密钥是 private static final String 硬编码——静态工具类在容器启动前就加载,拿不到 Spring 的配置注入,所以先加 @Component 改造成 Bean:

package com.situ.elder.utils;

import com.aliyun.oss.OSS;
import com.aliyun.oss.OSSClientBuilder;
import com.aliyun.oss.model.PutObjectRequest;
import com.aliyun.oss.model.ObjectMetadata;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.InputStream;
import java.io.OutputStream;

/**
 * 阿里云 OSS 工具(Spring Bean)
 * endpoint / bucket / 密钥全部来自 application.yml 的 oss.* 配置,
 * 密钥不在代码里留明文,由环境变量 ACCESS_KEY_ID / ACCESS_KEY_SECRET 注入
 */
@Component
public class AliOSSUtil {

    @Value("${oss.endpoint}")
    private String endpoint;

    @Value("${oss.bucket-name}")
    private String bucketName;

    @Value("${oss.access-key-id}")
    private String accessKeyId;

    @Value("${oss.access-key-secret}")
    private String accessKeySecret;

    public String uploadFile(String objectName, InputStream inputStream) throws Exception {
        // 创建OSSClient实例。
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);

        //公网访问地址
        String url = "";
        try {
            // 对象名带后缀时按后缀指定 Content-Type,让浏览器内联展示(如 PDF),
            // 否则 OSS 默认 octet-stream 导致下载
            ObjectMetadata metadata = new ObjectMetadata();
            String contentType = guessContentType(objectName);
            if (contentType != null) {
                metadata.setContentType(contentType);
            }
            // 创建PutObjectRequest对象并上传。
            PutObjectRequest putObjectRequest =
                    new PutObjectRequest(bucketName, objectName, inputStream, metadata);
            ossClient.putObject(putObjectRequest);
            //url组成:https://{bucket名称}.{区域节点}/{objectName}
            //如 https://ellder.oss-cn-beijing.aliyuncs.com/1.jpg
            url = "https://" + bucketName + "."
                    + endpoint.substring(endpoint.lastIndexOf("/") + 1) + "/" + objectName;
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();   //释放连接资源,防止泄漏
            }
        }
        return url;
    }

    //根据对象名后缀推断 Content-Type,便于浏览器内联预览
    private String guessContentType(String objectName) {
        String name = objectName.toLowerCase();
        if (name.endsWith(".pdf")) return "application/pdf";
        if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg";
        if (name.endsWith(".png")) return "image/png";
        if (name.endsWith(".gif")) return "image/gif";
        if (name.endsWith(".webp")) return "image/webp";
        if (name.endsWith(".bmp")) return "image/bmp";
        if (name.endsWith(".txt")) return "text/plain";
        if (name.endsWith(".html") || name.endsWith(".htm")) return "text/html";
        if (name.endsWith(".doc")) return "application/msword";
        if (name.endsWith(".docx")) return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
        if (name.endsWith(".xls")) return "application/vnd.ms-excel";
        if (name.endsWith(".xlsx")) return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
        return null; //未知类型交给 OSS 默认处理
    }

    //按对象名读取 OSS 文件并流式写回响应(Content-Type/inline 由后端指定,供浏览器内联预览)
    public void downloadFile(String url, jakarta.servlet.http.HttpServletResponse response) throws Exception {
        //从完整 URL 中截取对象名:https://bucket.region/xxx.pdf -> xxx.pdf
        String objectName = new java.net.URL(url).getPath();
        if (objectName.startsWith("/")) {
            objectName = objectName.substring(1);
        }
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        try {
            com.aliyun.oss.model.OSSObject ossObject = ossClient.getObject(bucketName, objectName);
            String contentType = guessContentType(objectName);
            response.setContentType(contentType != null ? contentType : "application/octet-stream");
            response.setHeader("Content-Disposition", "inline");
            try (InputStream in = ossObject.getObjectContent();
                 OutputStream out = response.getOutputStream()) {
                byte[] buf = new byte[8192];
                int len;
                while ((len = in.read(buf)) > 0) {
                    out.write(buf, 0, len);
                }
            }
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
    }

    public void deleteFile(String url) {
        OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
        try {
            //按 URL 截取对象名后删除
            ossClient.deleteObject(bucketName, url.substring(url.lastIndexOf("/") + 1));
        } finally {
            if (ossClient != null) {
                ossClient.shutdown();
            }
        }
    }
}

③ 调用处:静态调用改为注入调用(src/main/java/com/situ/elder/controller/common/UploadController.java,项目实际代码)

@RestController
public class UploadController {

    @Autowired
    private AliOSSUtil aliOSSUtil;   // 注入Bean(原来是静态调用 AliOSSUtil.uploadFile(...))

    //MultipartFile file封装了所有图片上传信息
    @PostMapping("/upload")
    public Result upload(MultipartFile file) {
        //f7a9f3e6805a4e81b5d27245c6c30070
        String uuid = UUID.randomUUID().toString().replace("-", "");
        //a.png
        String filename = file.getOriginalFilename();
        //.png
        String entension = filename.substring(filename.lastIndexOf("."));
        //f7a9f3e6805a4e81b5d27245c6c30070.png
        String newFilename = uuid + entension;
        String url = "";
        try {
            url = aliOSSUtil.uploadFile(newFilename, file.getInputStream());
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
        return Result.ok("上传成功", url);
    }
}
// OssProxyController(/admin/oss) 与 AppOssProxyController(/app/oss) 同样注入调用

文件名用 UUID 重命名的原因:防止用户上传同名文件互相覆盖,也避免中文/特殊字符文件名在 URL 中出问题。

④ 环境变量怎么配

场景 做法
本地 IDEA Edit Configurations → Environment variables:ACCESS_KEY_ID=xxx;ACCESS_KEY_SECRET=xxx;DEEPSEEK_API_KEY=sk-xxx
Windows 系统级 setx ACCESS_KEY_ID "xxx"(需重启 IDEA 生效)
Linux 启动时 DEEPSEEK_API_KEY=sk-xxx ACCESS_KEY_ID=xxx ACCESS_KEY_SECRET=xxx nohup java -jar elder.jar &
Linux 永久 echo 'export ACCESS_KEY_ID=xxx' >> /etc/profile && source /etc/profile
宝塔 Java 项目 项目设置里的「环境变量」输入框

最后一步:去云平台轮换已泄露的 key(DeepSeek 后台重置 sk-xxx、阿里云 RAM 禁用旧 AccessKey 并新建)——删代码不等于作废。

收获点

  • 配置与代码分离;${ENV:default} 占位符机制;
  • 静态工具类要用 Spring 配置,需改造成 Bean(或用静态字段 setter 桥接,但 Bean 是标准做法);
  • 进过 git 的密钥必须轮换,且应清理 git 历史(filter-repo)或视为已泄露。

15. 权限系统里,护工角色能看到其他护工的任务

现象

护工登录管理端,「预约任务」列表能看到所有护工的任务,而不是只看分配给自己的。

原因分析

项目的权限体系只做到了功能权限(RBAC:控制菜单可见性 + 按钮禁用),没有做数据权限(行级隔离):

  • 任务列表接口 /admin/appointment-task 只按前端传的查询条件分页(selectTaskVoPage),后端不知道「当前登录的是护工」,也没按 user_id 过滤;
  • 登录 token 里明明有用户 id(JwtUtil.createToken({id, name})),但任务查询完全没用它;
  • 前端菜单隐藏制造了「有权限控制」的假象——接口层数据是全量的,手工调接口或换个角色登录就能看到一切。

核心认知:功能权限 ≠ 数据权限。前端隐藏只是体验,数据安全必须落在后端 SQL 条件上。

解决方案:服务端强制数据过滤(不信任前端传参)

原则:数据权限必须在服务端强制。解析 token 拿当前用户 → 是护工就强制覆盖(而非采纳)userId 查询条件 → 前端传什么都无效。同时补齐详情/执行两个接口的越权校验。

① IUserService 增加角色判断(复用已有的 getRoleCodes 查询,项目实际代码)

// IUserService.java
//判断用户是否拥有指定角色编码(如护工 HUGONG),供后端做数据权限判断
boolean hasRole(Long userId, String roleCode);

// UserServiceImpl.java(完整实现)
//查询指定用户拥有的全部角色编码:不用join,拆成两次Lambda单表查询,内存中关联
@Override
public List<String> getRoleCodes(Long userId) {
    //第一步:查user_role表,拿到该用户的所有角色id
    LambdaQueryWrapper<UserRole> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(UserRole::getUserId, userId);
    List<Long> roleIds = userRoleMapper.selectList(wrapper).stream()
            .map(UserRole::getRoleId)
            .collect(Collectors.toList());
    //角色id为空直接返回,避免in空集合
    if (roleIds.isEmpty()) {
        return List.of();
    }
    //第二步:按角色id批量查role表取编码(Role的deleted带@TableLogic,已删除的角色自动被过滤)
    return roleMapper.selectBatchIds(roleIds).stream()
            .map(Role::getCode)
            .collect(Collectors.toList());
}

//判断用户是否拥有指定角色编码
@Override
public boolean hasRole(Long userId, String roleCode) {
    return getRoleCodes(userId).contains(roleCode);
}

② AppointmentTaskQuery 增加过滤字段(项目实际代码)

@Data
public class AppointmentTaskQuery {
    //老人姓名(冗余列,模糊查询)
    private String elderName;
    private Integer status;
    //负责护工(护工角色登录时由服务端强制设为当前用户,只查自己的任务)
    private Long userId;
    private Long appointmentItemId;
    //预约日期(精确到天)
    private Date appointDate;
    private Integer page;
    private Integer limit;
    private Date begintime;
    private Date endtime;
}

③ AppointmentTaskController:接口加数据权限校验(项目实际代码)

@RestController
@RequestMapping("/admin/appointment-task")
public class AppointmentTaskController {

    @Autowired
    private IAppointmentTaskService iAppointmentTaskService;

    @Autowired
    private IUserService iUserService;

    //条件分页查询(护工角色登录时,服务端强制只查分配给自己的任务)
    @GetMapping
    public Result<IPage<AppointmentTaskVo>> selectAll(AppointmentTaskQuery appointmentTaskQuery,
                                                      @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")) {
            appointmentTaskQuery.setUserId(currentUserId);
        }
        IPage<AppointmentTaskVo> page = iAppointmentTaskService.list(appointmentTaskQuery);
        return Result.ok(page);
    }

    //根据id查询
    @GetMapping("/{id}")
    public Result<AppointmentTask> selectById(@PathVariable("id") Long id,
                                              @RequestHeader(name = "Authorization") String token) {
        AppointmentTask appointmentTask = iAppointmentTaskService.getById(id);
        //护工只能查看自己的任务详情,防止拿任务id越权查看
        if (appointmentTask != null && isCaregiver(token)
                && !appointmentTask.getUserId().equals(currentUserId(token))) {
            return Result.error("无权查看该任务");
        }
        return Result.ok(appointmentTask);
    }

    //护工打卡执行(更新状态、实际完成时间、执行结果、打卡照片、备注)
    @PutMapping("/execute/{id}")
    public Result execute(@PathVariable Long id, @RequestBody AppointmentTask appointmentTask,
                          @RequestHeader(name = "Authorization") String token) {
        //护工只能执行分配给自己的任务
        if (isCaregiver(token)) {
            AppointmentTask dbTask = iAppointmentTaskService.getById(id);
            if (dbTask == null || !dbTask.getUserId().equals(currentUserId(token))) {
                return Result.error("无权执行该任务");
            }
        }
        iAppointmentTaskService.execute(id, appointmentTask);
        return Result.ok("执行成功");
    }

    //解析token拿到当前登录用户id
    private Long currentUserId(String token) {
        return ((Integer) JwtUtil.parseToken(token).get("id")).longValue();
    }

    //判断当前登录用户是否为护工角色
    private boolean isCaregiver(String token) {
        return iUserService.hasRole(currentUserId(token), "HUGONG");
    }
}

④ AppointmentTaskMapper.xml:查询条件接上 userId(项目实际代码)

<select id="selectTaskVoPage" resultType="com.situ.elder.pojo.vo.AppointmentTaskVo">
    SELECT t.id,
           t.elder_id,
           t.elder_name,
           t.appointment_plan_id,
           t.appointment_item_id,
           t.appointment_item_name,
           t.user_id,
           t.plan_execute_date,
           t.plan_execute_time,
           t.status,
           t.actual_execute_time,
           t.execute_result,
           t.execute_img,
           t.remark,
           t.create_time,
           u.name AS user_name
    FROM appointment_task t
             LEFT JOIN `user` u ON u.id = t.user_id
    <!-- t.deleted=0:实体上有@TableLogic逻辑删除,但MP只给内置方法自动加该条件,
         手写XML必须自己带上,否则已删除的任务仍然会查出来 -->
    <where>
        t.deleted = 0
        <if test="query.elderName != null and query.elderName != ''"> AND t.elder_name LIKE CONCAT('%', #{query.elderName}, '%') </if>
        <if test="query.status != null"> AND t.status = #{query.status} </if>
        <if test="query.userId != null"> AND t.user_id = #{query.userId} </if>
        <if test="query.appointmentItemId != null"> AND t.appointment_item_id = #{query.appointmentItemId} </if>
        <if test="query.appointDate != null"> AND t.plan_execute_date = #{query.appointDate} </if>
        <if test="query.begintime != null and query.endtime != null"> AND t.create_time BETWEEN #{query.begintime} AND #{query.endtime} </if>
    </where>
    ORDER BY t.create_time DESC
</select>

防御链条说明(为什么这样设计)

攻击方式 防御点
护工登录后直接看列表 列表接口强制 userId=自己,SQL 层过滤
护工改前端参数传别人的 userId 服务端覆盖而非采纳前端值
护工跳过列表,直接猜任务 id 调详情接口 selectById 校验归属
护工拿别人任务 id 打卡执行 execute 校验归属

同类问题排查与改进方向

  • 同样模式需要排查:care_task(护理任务)、exam_appointment(体检记录)等管理端列表——都存在全量可见问题(CareTaskMapper.xml 里 query.userId 条件已具备,接入同样的 Controller 校验即可);
  • 手机端接口是安全的:所有 app 接口都从 token 解 elderId 查询,天然按本人隔离;
  • 更规范的改进(可作为面试「改进计划」):自定义 @DataScope 注解 + AOP 统一织入数据权限,避免每个接口手写;或引入 Spring Security 做方法级鉴权。

16. 三个安全类问题的共性总结

问题 本质 一句话原则
Markdown 原样显示 输出格式与渲染方式不匹配 不可信内容渲染必须消毒(DOMPurify)
密钥明文 配置的值混进了代码 配置与代码分离,环境变量注入
越权看任务 只有功能权限,没有数据权限 前端隐藏是体验,后端过滤才是安全
上一篇
下一篇