ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot个人健康信息管理系统:时序数据与策略模式实战

Spring Boot个人健康信息管理系统:时序数据与策略模式实战 简介面向计算机专业毕业生与课程设计学习者的一份个人健康信息管理系统设计与实现文档以 Java 语言结合 SSM 框架、MySQL 数据库与 JSP 页面技术完成从需求到测试的完整论述。文档围绕用户管理、健康信息管理、健康数据分析、健康建议四大模块展开依次给出可行性分析、功能与非功能需求、数据库与总体设计、编码实现及功能性测试并附中英文摘要与关键词可用于毕业设计选题参考、论文结构模仿与答辩准备。压缩包内共 1 个 docx 文件约 5.26MB正文含章节编号、用例说明与数据库表结构描述便于按目录快速定位所需内容。目前已有 301 人学习适合需要 Java Web 项目案例、SSM 技术落地思路或健康管理类系统设计素材的读者查阅与借鉴。1. 个人健康信息管理系统真正难的不是增删改查「个人健康信息管理系统」听起来像一份课程设计用户注册、录入血压、画个折线图、导出报告四个功能凑出两万字。真按这个思路写完第一版能跑第二版就崩——体征数据是按天甚至按小时往里灌的时序数据一个人一年就是几千条随便一个「最近三个月的血糖趋势」就能把全表扫一遍血压、血糖、BMI 的「正常 / 偏高 / 危险」判定规则还得随年龄、性别调整写死一串 if-else三个月后没人敢改用户还会提「给我生成上月健康报告」背后是批量查询加并行计算。用 Java 做这套系统选的不是语言而是工程结构Spring Boot 管生命周期MyBatis-Plus 管动态条件查询MySQL 存时序记录并靠联合索引扛住时间范围扫描Redis 缓存最近一次体征和阈值配置策略模式兜住判定规则的膨胀。它适合正在做毕设、接私活或者想把「java基础」重新串成一条线的人——数据建模、参数校验、线程等待、策略模式、索引优化这套系统能一次性全用上。2. 基于 Spring Boot 的个人健康信息管理系统选型与核心表设计2.1 为什么这套系统选 Spring Boot MyBatis-Plus 而不是 JPA健康类接口的查询条件天然是「可选项拼接」用户可能只查血压也可能查血压加时间范围也可能再加数据来源是手动还是设备同步。这种查询用 JPA 的 Criteria 或方法名派生写起来很别扭方法名会长到没法读MyBatis-Plus 的LambdaQueryWrapper支持条件式拼接一个.eq(condition, ...)就能表达「有值才拼进 WHERE」改需求时只动一行。另外健康数据表的字段迭代频繁今天加个「测量体位」明天加个「设备型号」JPA 的ddl-auto自动建表在生产环境是隐患用 SQL 脚本显式管理表结构更可控。依赖侧最小集合如下版本号以官方仓库当期稳定版为准不要照抄旧版本!-- pom.xml 片段 -- properties java.version17/java.version mybatis-plus.version3.5.x/mybatis-plus.version /properties dependencies !-- Web 层REST 接口与参数校验 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- 校验注解 Valid / NotNull 的提供方 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency !-- 数据访问只增强 MyBatis不替换它 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version${mybatis-plus.version}/version /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependencies这里三个选择值得说明。spring-boot-starter-validation单独引入是因为 Spring Boot 2.3 之后校验注解不再随 web 包传递漏引会在启动时才发现Valid不生效而参数校验恰好是健康数据的第一道闸门——血压填成 9999 这种脏数据进了库后面所有统计都是错的。mysql-connector-j用runtime作用域编译期不该出现驱动类引用。MyBatis-Plus 版本单独提成属性方便全工程统一升级。2.2 六张核心表的字段设计与建表 SQL个人健康信息管理系统的表结构别一上来就设计成「万能 EAV」中文互联网上很多毕设模板把所有指标塞进一张 key-value 表结果每次统计都要做行转列SQL 难写到没法维护。务实做法是主数据用固定列体征值用「指标编码 数值 测量时间」的窄表承载指标本身用字典表描述。核心六张表是用户表、指标字典表、体征记录表、阈值规则表、提醒任务表、报告表前四张最关键。-- 用户表只存必要档案敏感信息不落明文 CREATE TABLE t_user ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(32) NOT NULL COMMENT 登录名, password_hash CHAR(60) NOT NULL COMMENT BCrypt 哈希定长 60, gender TINYINT NOT NULL DEFAULT 0 COMMENT 0 未知 1 男 2 女, birth_date DATE DEFAULT NULL COMMENT 用于推导年龄段, height_cm DECIMAL(5,1) DEFAULT NULL COMMENT 身高BMI 计算依赖, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户档案; -- 体征记录表窄表设计靠 metric_code 区分指标 CREATE TABLE t_vital_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, metric_code VARCHAR(16) NOT NULL COMMENT SBP/DBP/GLU/WEIGHT/HR/SPO2, value_numeric DECIMAL(8,2) NOT NULL COMMENT 统一数值化便于聚合, measured_at DATETIME NOT NULL COMMENT 测量时间非入库时间, source TINYINT NOT NULL DEFAULT 1 COMMENT 1 手动 2 设备同步, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_metric_time (user_id, metric_code, measured_at), KEY idx_user_time (user_id, measured_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT体征明细;measured_at和created_at必须分开这是新手最容易埋的坑。用户补录三天前的血压created_at是今天measured_at是三天前趋势图必须按后者画。value_numeric统一用DECIMAL别一半存字符串一半存数字否则AVG直接失效。idx_user_metric_time这个联合索引的列顺序对应「等值列在前、范围列在后」的原则user_id和metric_code是等值条件measured_at是范围条件顺序颠倒会让范围扫描吃掉后面两列的过滤能力。表名核心作用关键字段索引要点t_user账号与基础档案username、height_cm用户名唯一索引t_metric_dict指标字典单位、参考方向metric_code、unit编码唯一t_vital_record体征明细measured_at、value_numeric联合索引覆盖查询t_rule_threshold阈值规则metric_code、age_min、level按指标人群建索引t_remind_task用药/测量提醒next_fire_time、status扫描待触发任务t_report生成的健康报告user_id、period用户周期唯一指标字典表虽小但要提前想清楚「方向」字段血糖是越高越危险血氧饱和度是越低越危险判定逻辑必须知道方向否则策略类里要写死一堆符号判断。2.3 java环境变量配置与本地最小可跑环境本机跑通只需要三样JDK 17、Maven、一个能连的 MySQL。java下载和安装本身没难度出问题基本都出在环境变量上——JAVA_HOME要指向 JDK 根目录而不是bin目录PATH里加的是$JAVA_HOME/bin。# 1. 先看系统里有没有现成的 JDK java -version # 2. 配置环境变量Linux/macOS写进 ~/.bashrc 或 ~/.zshrc 才永久生效 export JAVA_HOME/usr/lib/jvm/java-17-openjdk-amd64 export PATH$JAVA_HOME/bin:$PATH export MAVEN_HOME/opt/maven export PATH$MAVEN_HOME/bin:$PATH # 3. 验证三件套注意 javac 也要能跑通 echo $JAVA_HOME java -version javac -version mvn -v # 4. 用容器起 MySQL省掉本地装库和配 my.cnf docker run -d --name health-mysql \ -p 3306:3306 \ -e MYSQL_ROOT_PASSWORDhealth123 \ -e MYSQL_DATABASEhealth_db \ -v $PWD/mysql-data:/var/lib/mysql \ mysql:8.0 --character-set-serverutf8mb4 --collation-serverutf8mb4_general_ci第 4 步的参数逐个说明-e MYSQL_DATABASE让容器初始化时直接建库省去手动CREATE DATABASE-v把数据目录挂到宿主机容器删了数据还在调试期不用反复导数--character-set-serverutf8mb4是必须的健康数据里会出现中文备注和 emoji 形式的打卡记录用 latin1 或 utf8 存会直接报错或截断。启动后用docker logs health-mysql | tail -20看是否出现ready for connections再连库执行 2.2 节的建表脚本。注意JAVA_HOME配错时 Maven 报的错往往是「找不到 javac」而不是「JAVA_HOME 无效」排查时先echo $JAVA_HOME看路径末尾有没有多带一层bin。3. 体征数据的采集与查询校验、批量写入与索引取舍3.1 体征上报接口的参数校验与统一响应上报接口是数据入口也是脏数据的唯一防线。血压这种一次测量产生两个数值收缩压、舒张压的指标客户端应该按「一条上报 → 拆成多条记录」的约定提交服务端做拆分和校验。RestController RequestMapping(/api/vital) public class VitalController { private final VitalService vitalService; public VitalController(VitalService vitalService) { this.vitalService vitalService; } PostMapping(/report) public ResultVoid report(RequestBody Valid VitalReportVO vo, RequestHeader(X-User-Id) Long userId) { // 服务层负责拆分血压这类多值指标并补全 measured_at 的时区 vitalService.saveBatch(userId, vo.getItems()); return Result.ok(); } } public class VitalReportVO { NotEmpty(message 上报项不能为空) Size(max 50, message 单次上报不超过 50 条) private ListItem items; public static class Item { NotBlank(message 指标编码必填) private String metricCode; NotNull DecimalMin(value 0.1, message 数值过小) DecimalMax(value 9999.9, message 数值超出合理范围) private BigDecimal value; NotNull PastOrPresent(message 测量时间不能是未来) private LocalDateTime measuredAt; } }逻辑说明Valid触发对嵌套Item列表的级联校验前提是外层字段上标了注解且内层字段也有约束PastOrPresent拦住「提前录入明天血压」这类操作比在业务代码里写if更早失败Size(max 50)限制单次上报条数防止一次请求塞十万条把连接池打满。参数说明X-User-Id从请求头取是简化版做法正式项目应从登录态 token 解析避免用户伪造他人 ID。3.2 MyBatis-Plus 时间范围分页与按天聚合列表查询用LambdaQueryWrapper拼条件注意条件式方法的第一参数是布尔开关写错位置会导致把值当成开关用。public IPageVitalRecord pageByRange(Long userId, String metricCode, LocalDateTime from, LocalDateTime to, long current, long size) { LambdaQueryWrapperVitalRecord qw Wrappers.lambdaQuery(); qw.eq(VitalRecord::getUserId, userId) // 用户维度必带 .eq(StringUtils.hasText(metricCode), VitalRecord::getMetricCode, metricCode) .ge(from ! null, VitalRecord::getMeasuredAt, from) // 起始时间可选 .le(to ! null, VitalRecord::getMeasuredAt, to) // 结束时间可选 .orderByDesc(VitalRecord::getMeasuredAt); return vitalRecordMapper.selectPage(new Page(current, size), qw); }参数说明current从 1 开始计数不是 0这是 MyBatis-Plus 分页常踩的坑size要在 Controller 层做上限截断比如最大 200否则前端传size100000就是一次全表拉取。趋势图数据不走分页走按天聚合。!-- VitalRecordMapper.xml按天聚合给趋势图用 -- select idaggByDay resultTypecom.example.health.vo.DailyAggVO SELECT DATE(measured_at) AS day, ROUND(AVG(value_numeric),1) AS avgValue, MIN(value_numeric) AS minValue, MAX(value_numeric) AS maxValue, COUNT(*) AS sampleCount FROM t_vital_record WHERE user_id #{userId} AND metric_code #{metricCode} AND measured_at gt; #{from} AND measured_at lt; #{to} GROUP BY DATE(measured_at) ORDER BY day /select逻辑说明用半开区间 from AND to而不是BETWEEN避免月底 23:59:59 之后到次日零点之间的数据被漏掉或重复计入GROUP BY DATE(measured_at)会让索引退化无法完全走索引排序所以范围别开太大前端默认查 30 天、最多 90 天是比较稳的约定。3.3 联合索引怎么加三种典型查询的取舍索引不是越多越好体征表是写入频繁的表每多一个索引就多一次写入开销。按实际查询场景倒推通常三个索引够用。查询场景触发 SQL 特征建议索引说明单指标时间范围user_id metric_code measured_atidx_user_metric_time覆盖列表与聚合主路径全指标时间范围user_id measured_atidx_user_time健康报告页要拉全部指标按来源过滤source不单独建选择度极低建了也用不上判断索引有没有生效直接看执行计划重点看type和rowsEXPLAIN SELECT DATE(measured_at), AVG(value_numeric) FROM t_vital_record WHERE user_id 1001 AND metric_code SBP AND measured_at 2024-05-01 AND measured_at 2024-06-01 GROUP BY DATE(measured_at);type出现ref或range说明走索引了出现ALL就是全表扫rows数量级应该和你选的时间窗内实际记录数接近。如果type是range但rows大得离谱多半是measured_at的条件没传或者传成了字符串格式导致隐式转换。用EXPLAIN FORMATJSON还能看到是否用上了Using index condition这是联合索引生效的直接证据。4. 健康异常判定策略模式多种组合与并发批量计算4.1 用策略模式拆开血压、血糖、BMI 的判定逻辑判定逻辑的膨胀速度远超预期血压要区分收缩压和舒张压、血糖要区分空腹和餐后、BMI 要看年龄段。全塞在一个HealthJudgeService里两三千行很快就到。做法是把每个指标抽成一个策略实现由工厂按指标编码分发。public interface HealthRuleStrategy { /** 该策略负责的指标编码如 BP、GLU */ String metricCode(); /** 返回判定结果等级 命中的规则描述 */ Judgement judge(BigDecimal value, RuleContext ctx); } Component public class BloodPressureStrategy implements HealthRuleStrategy { Override public String metricCode() { return BP; } Override public Judgement judge(BigDecimal value, RuleContext ctx) { // ctx 携带年龄、性别、是否空腹等上下文规则从这里取值而不是写死 BigDecimal normalHigh ctx.threshold(BP_SYS_WARN); // 从规则表加载 BigDecimal dangerHigh ctx.threshold(BP_SYS_DANGER); if (value.compareTo(dangerHigh) 0) { return Judgement.of(DANGER, 收缩压达到危险区间); } if (value.compareTo(normalHigh) 0) { return Judgement.of(WARN, 收缩压偏高); } return Judgement.of(NORMAL, 收缩压正常); } } Component public class RuleStrategyFactory { private final MapString, HealthRuleStrategy strategyMap; public RuleStrategyFactory(ListHealthRuleStrategy strategies) { // 构造函数注入 ListSpring 会把所有实现类收集进来 this.strategyMap strategies.stream() .collect(Collectors.toMap(HealthRuleStrategy::metricCode, Function.identity())); } public HealthRuleStrategy get(String metricCode) { HealthRuleStrategy s strategyMap.get(metricCode); if (s null) { throw new IllegalArgumentException(未注册的指标: metricCode); } return s; } }这样写的好处有三点。新增一个指标只需要加一个实现类RuleStrategyFactory不用改这是策略模式相比 switch 的核心价值。用ListHealthRuleStrategy构造注入代替在工厂里手写if-else注册Spring 的集合注入会按类型自动收集所有 Bean漏注册在启动期就能通过缺 Bean 暴露出来。metricCode()与 Bean 名称解耦指标编码来自数据库字典改编码不用改类名。代价是策略类数量会随指标增长指标超过二十个时建议按业务域再分一层包结构。4.2 阈值规则存库还是硬编码规则必须存库但不要存成一段可执行的表达式字符串。存表达式看着灵活实际会带来两个问题一是表达式里混入用户可控内容就是注入风险二是规则调试极其痛苦改错了没人知道错在哪。用结构化的阈值行存一个指标一个年龄段一条记录。CREATE TABLE t_rule_threshold ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, metric_code VARCHAR(16) NOT NULL COMMENT 指标编码, gender TINYINT NOT NULL DEFAULT 0 COMMENT 0 通用 1 男 2 女, age_min INT NOT NULL DEFAULT 0, age_max INT NOT NULL DEFAULT 120, level VARCHAR(16) NOT NULL COMMENT NORMAL/WARN/DANGER, lower_bound DECIMAL(8,2) DEFAULT NULL COMMENT 下界含, upper_bound DECIMAL(8,2) DEFAULT NULL COMMENT 上界不含, PRIMARY KEY (id), KEY idx_metric_scope (metric_code, gender, age_min, age_max) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT阈值规则; -- 示例数据收缩压两条区间 INSERT INTO t_rule_threshold(metric_code, gender, age_min, age_max, level, lower_bound, upper_bound) VALUES (SBP, 0, 18, 120, NORMAL, 90, 140), (SBP, 0, 18, 120, WARN, 140, 160), (SBP, 0, 18, 120, DANGER, 160, 300);加载策略上规则表数据量很小几十到几百行适合启动时全量加载到本地缓存配一个Scheduled或者配置中心推送触发刷新。别在每次判定时查一次库批量判定一千条体征就是一千次查询。注意规则区间的边界要用统一的含舍约定左闭右开否则 140 这个值到底算正常还是偏高不同策略类会给出不同答案。4.3 java线程等待都完成CountDownLatch 与 CompletableFuture 在批量判定里的选择「生成健康报告」这类需求要跑几十上百个用户的判定和统计串行跑接口就超时了。java 面试题里高频出现的 CountDownLatch 和 CompletableFuture 在这里都会用到但适用场景不同。public MapLong, ListJudgement batchJudge(ListLong userIds) { ExecutorService pool Executors.newFixedThreadPool( Math.min(4, Runtime.getRuntime().availableProcessors())); CountDownLatch latch new CountDownLatch(userIds.size()); MapLong, ListJudgement result new ConcurrentHashMap(); for (Long uid : userIds) { pool.execute(() - { try { result.put(uid, judgeOne(uid)); // 单用户判定内部走策略工厂 } catch (Exception e) { // 单个用户失败不能拖垮整批记录后继续 log.warn(判定失败 userId{}, uid, e); } finally { latch.countDown(); // 无论成败都必须减一 } }); } try { // 超时兜底即使有任务卡住主线程也不会永久阻塞 if (!latch.await(3, TimeUnit.SECONDS)) { log.warn(批量判定超时已返回 {} 条结果, result.size()); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断标志别吞掉 } finally { pool.shutdown(); } return result; }逻辑说明latch.countDown()必须放在finally里任何一条任务抛异常没减计数主线程就会一直等接口直接挂死这是CountDownLatch用错最常见的形态await一定要带超时参数无参版本是线上事故的常见来源。参数说明线程池大小取min(4, 可用核心数)是个保守起点判定逻辑里有 IO读规则缓存、查体征也有计算纯 CPU 密集时取核心数就够别盲目开 32。如果只是「等全部完成再往下走」且需要拿每步的返回值做聚合用CompletableFuture更顺CompletableFutureVoid all CompletableFuture.allOf( userIds.stream() .map(uid - CompletableFuture.runAsync(() - { try { result.put(uid, judgeOne(uid)); } catch (Exception e) { log.warn(判定失败 userId{}, uid, e); } }, pool)) .toArray(CompletableFuture[]::new) // Stream 转数组注意传数组构造器 ); all.whenComplete((v, ex) - pool.shutdown()); all.join(); // 等待全部完成异常在这里统一暴露这里stream().toArray(CompletableFuture[]::new)的写法要注意必须传数组构造器直接toArray()得到的是Object[]allOf不接收。CompletableFuture的优势在于可以链式追加thenApply做后处理劣势是异常传播链更长、堆栈更难读CountDownLatch的优势是行为直观、超时控制明确。批量判定场景我一般用前者配超时只有当需要「部分完成就先返回」时才换CompletableFuture配anyOf。4.4 定时提醒与报告生成的任务编排提醒任务别用Scheduled(fixedRate)扫全表。健康提醒有明确的时间点每天早八点提醒测血压正确做法是把下次触发时间落库定时任务只捞「next_fire_time now且状态为待触发」的记录并把结果做批量发送。任务表上next_fire_time加索引每次扫描带上status条件避免全表扫。-- 捞取待触发提醒用主键游标分批避免 LIMIT 深分页 SELECT id, user_id, metric_code FROM t_remind_task WHERE status 0 AND next_fire_time NOW() ORDER BY next_fire_time LIMIT 500;执行时机上定时任务用单实例加分布式锁保护多实例部署时没有锁会出现同一条提醒发三次。这个锁不需要重组件一条带过期时间的SETNX就够失败时直接跳过本轮。任务发送完成后更新status和next_fire_time把「下次触发时间」按任务自身的周期往后推而不是简单加一天——按周提醒的任务只加一天用户会天天收到重复提醒。报告生成放在低峰时段跑先查最近一个周期的聚合结果再渲染模板最后写t_report表生成过程本身不阻塞用户请求。5. 容器化部署与健康数据导出的几个实用技巧5.1 用容器跑 java 服务注意时区和字符集本地mvn spring-boot:run跑通只是第一步。容器化部署有两个必踩的坑容器默认 UTC 时区measured_at存进去会和用户预期差八小时镜像基础层没带中文字体时若报告走 PDF 渲染会全是方块。# 构建产物后打镜像再用环境变量覆盖时区 mvn -DskipTests clean package docker build -t health-api:1.0 . docker run -d --name health-api \ -p 8080:8080 \ -e TZAsia/Shanghai \ -e SPRING_PROFILES_ACTIVEprod \ -e MYSQL_HOSThealth-mysql \ --network health-net \ health-api:1.0参数说明TZ影响 JVM 默认时区LocalDateTime的序列化结果由它决定--network让应用和数据库容器在同一自定义网络里用容器名当主机名解析比写死宿主 IP 稳定SPRING_PROFILES_ACTIVE切到生产配置把数据库地址、连接池大小从环境变量注入镜像本身不携带任何环境相关的配置。验证部署是否正常不要只看容器状态直接打健康接口curl -s http://localhost:8080/actuator/health curl -s -H X-User-Id: 1001 http://localhost:8080/api/vital/page?metricCodeSBPsize20第一条确认进程与依赖数据库、缓存都健康第二条确认鉴权头、分页参数和序列化时间格式都按预期工作。5.2 用流式查询导出 CSV别把全量数据读进内存导出是个人健康信息管理系统里最容易被忽视的性能点。用selectList拉三万个用户的全部体征记录堆内存直接翻倍GC一停顿接口就超时。正确做法是走 MyBatis 的游标查询或ResultHandler边读边写。public void exportCsv(Long userId, HttpServletResponse resp) throws IOException { resp.setContentType(text/csv;charsetUTF-8); resp.setHeader(Content-Disposition, attachment;filenamevitals.csv); try (PrintWriter w resp.getWriter()) { w.println(metricCode,value,measuredAt,source); // streamByUser 走 ResultHandler 游标不一次性装载结果集 vitalRecordMapper.streamByUser(userId, row - w.println(String.join(,, row.getMetricCode(), row.getValueNumeric().toPlainString(), // 用 toPlainString 避免科学计数法 row.getMeasuredAt().toString(), String.valueOf(row.getSource())))); } }逻辑说明PrintWriter放在 try-with-resources 里方法返回时自动 flush漏了这一步用户下载到的会是空文件toPlainString()必须用BigDecimal.toString()在特定精度下会输出1.4E2这种科学计数法CSV 里出现E会让 Excel 解析错列。分页游标配合fetchSize设置一个合理批次比如 500 行既能减少网络往返又不会把结果集全压在客户端内存。排查导出慢的问题重点看两处一是EXPLAIN确认是否走了idx_user_metric_time二是看应用日志里是否有大量Full GC。如果导出的是「全指标」优先按metric_code拆成多次游标查询每次只导一个指标内存占用可预测用户侧体验也更早拿到部分数据。本文还有配套的精品资源点击获取
返回列表