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);
}
主键可以是多种类型:
- 数字主键:
Integer、Long(自增 id) - 字符串主键:
String、UUID 字符串 - 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 次,不能多次删除同名记录
流程演示:
- 插入
张三,0→ 删除 →张三,1 - 新增
张三,0✅ 成功 - 再次删除这条新记录 → 更新为
张三,1 - 此时表里两条记录:
(张三,1)、(张三,1) - ❌ 联合索引检测到组合重复,
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));
优点:
- 有效数据强制 name 唯一;
- 同一个名字可以无限次逻辑删除、新增;
- 不新增额外列,表结构干净。
限制: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 逻辑删除开发建议
- 业务允许名字仅删除一次:可以临时用
UNIQUE(name,deleted); - 业务存在反复删除新增同名:
- MySQL 8.0:优先选择表达式索引;
- MySQL 5.7:使用生成列+唯一索引;
- 代码层兜底:新增/修改前先查询,判断有效数据是否重名;
- 全局异常捕获
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'
}
})
守卫的判断顺序是有讲究的:
- 先处理「已登录访问白名单」→ 回首页(防止登录后又进登录页);
- 再处理「未登录访问受保护页」→ 去登录;
- 白名单页必须显式放行,否则下面的路径鉴权会把
/login也拦截,形成死循环; - 权限未加载时先
await拉取——覆盖「直接刷新/user」场景:刷新后 Pinia 虽有 persist 缓存,但routesLoaded逻辑保证数据是最新的; - 最后才做路径鉴权,拦截回
/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
})
}
两个必须知道的细节:
onActivated首次挂载也会触发。项目实际代码里 setup 顶层还保留了一次loadData(),因此首次进入页面会请求两次表格数据——不影响功能,但严格来说顶层的重复调用可以删掉,只留onActivated一处即可。- 下拉和表格是两个函数,要一起放进钩子。之前只放
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,是改动最小、体验最好的做法。
经验总结
- keep-alive 改变了生命周期模型:
setup/onMounted只在首次创建时执行;切回缓存页面走的是「激活」而不是「重新挂载」。任何「进页面就要拉最新数据」的逻辑,在 keep-alive 项目里都应该放进onActivated。 - 判断口诀:
- 页面内部增删改查后要刷新 → 操作成功回调里手动调
loadData()(本来就在做); - 其他页面改了数据、切回来要看到 → 加
onActivated重新拉取。
- 排查思路:遇到「刷新浏览器才正常,页面切换就不对」,先看
<router-view>是否包了keep-alive,再看数据加载是不是只在 setup 顶层执行了一次。 - 连带注意:下拉框数据(老人、护工、护理项目这类「选项」)最容易踩坑,因为它们通常在
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 按「源」隔离,相安无事。部署到同一域名后就是同一个源,于是:
- 登录后台 →
localStorage['token']= 后台管理员的 token - 打开前台 → 前台从
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 元素动画...
}
两个必须知道的知识点
- 为什么必须 DOMPurify:AI 输出属于「不可信内容」。如果模型被提示词注入诱导输出了
<script>或<img onerror=...>,直接v-html就是存储型 XSS 漏洞。DOMPurify.sanitize会剥掉危险标签/属性,只保留安全 HTML。 - 为什么样式要
:deep():Vue scoped 样式靠给节点加data-v-xxx属性实现,而v-html插入的 DOM 不带这个属性,普通 scoped 选择器匹配不到,必须:deep()穿透。 - 为什么用户消息不用渲染:用户输入的是自己打的纯文本,没有 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) |
| 密钥明文 | 配置的值混进了代码 | 配置与代码分离,环境变量注入 |
| 越权看任务 | 只有功能权限,没有数据权限 | 前端隐藏是体验,后端过滤才是安全 |