ARTICLE DETAIL

资讯详情

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

SpringBoot+MyBatis-Plus打造交通违章管理系统:毕设实战全流程

SpringBoot+MyBatis-Plus打造交通违章管理系统:毕设实战全流程 做毕设选了交通违章管理系统这个题目的同学十有八九是被Web 版车辆违章查询处理系统这个场景吸引来的。我自己当年就是从这套系统入手的 SpringBoot从数据库设计到前端页面再到最后的部署上线全程踩了无数坑熬夜修过 Bug也推倒重来过两版。这篇博客不灌水把整套系统的设计思路、核心实现、排错经验完整拉出来说清楚正在做毕业设计的同学可以直接照着搭想在 SpringBoot 项目里练手的小白也能找到自己想要的干货。SpringBoot 加持下的交通违章管理平台核心其实就是三件事违章数据的管理、违章处理流程的闭环、以及不同角色的权限划分。听起来像政府项目但落到毕设层面它本质上是一套标准的 Java Web 增删改查增强版系统难点不在于某个技术的深度而在于业务关系的设计以及对权限、统计报表、日志记录这类非核心功能的取舍。这套系统适合两类人一类是正在做计算机毕业设计、选题方向是 Java Web 系统开发的同学另一类是刚学完 SSM 基础、想用 SpringBootMyBatis-Plus 做完整项目练手的初级开发者。因为它的模块边界清晰、业务逻辑有代表性麻雀虽小五脏俱全RESTful 接口、ORM 映射、权限拦截、数据统计全都覆盖得到做完这一套你对整个 Java Web 开发链路的理解会系统很多。1. 项目整体设计与技术选型思路1.1 先把业务边界画清楚别一上来就写代码很多同学拿到课题第一反应是建工程、写实体类这是最大的坑。交通违章管理系统这个词看起来很具体但你仔细想一想里面包含了好几套完全不同的业务场景违章数据的录入和查询是一套逻辑违章处理确认、处罚、缴款、销案是一套流程车辆和驾驶员的档案管理是另一套数据再加上后台的统计分析和系统管理每一块都能拆出不少东西。我先给当年自己的设计做了一个减法把系统边界定为三条线管理端负责车辆信息、驾驶员信息、违章记录的维护处理端负责对违章记录做审核、处罚登记、处理状态变更查询端面向普通用户提供根据车牌号或驾驶证号的违章查询和详情查看。系统管理作为辅助模块负责用户账号、角色权限和基础数据字典的维护。这个边界的价值在于它天然形成了三种角色管理员、处理员、普通用户角色的权限差异直接决定了接口设计和菜单显示的逻辑。如果你不做角色拆分把所有功能堆在一个页面上答辩的时候老师第一个问题就是你的系统如何保证不同身份的人只能操作自己能操作的数据到那个时候再改权限模型成本就大了。1.2 为什么 SpringBoot 是毕设选题的默认答案技术选型上SpringBoot 几乎是这类毕设的标准答案原因很实在。第一它把 Spring 生态的配置成本压到了极低。以前用 SSM 做项目光 applicationContext.xml 就要写一大堆 Bean 定义SpringBoot 用自动配置全部接管了我可以把精力集中在业务代码上而不是反复调整 XML 配置。对我来说这节省了至少两天的时间。第二内置 Tomcat打包即部署。我做完之后直接mvn package打成 jar 包放到服务器上java -jar就启动了不用单独装 Tomcat 配置端口、发布 war 包这个体验对没有服务器运维经验的学生党非常友好。第三社区生态成熟遇到问题搜得到答案。SpringBoot 相关的博客、示例代码、踩坑记录全网到处都是毕设阶段你大概率会遇到各种连环报错如果一个技术搜不到解决方案那才是真的灾难。结合同学们的实际情况我建议技术栈固定为SpringBoot 2.x MyBatis-Plus MySQL 8.x Thymeleaf或 Vue后面我会细说前端选型。值得一提的是 MyBatis-Plus它让我省去了绝大部分手写 SQL 的活单表 CRUD 直接继承 BaseMapper 就能用这让代码量肉眼可见地降了下来非常适合时间紧任务重的毕设节奏。1.3 功能模块怎么拆才合理模块划分是项目结构的骨架我最终选的是按业务域分包而不是按技术层分包。按技术层分包是那种 controller/service/mapper 一刀切三层目录的做法乍一看规整但业务一多就会乱。我用的方式是每个功能域一个完整的分层包modules/user用户管理、登录认证、角色权限modules/vehicle车辆信息管理modules/driver驾驶员信息管理modules/violation违章记录、违章处理流程modules/system数据字典、系统配置modules/statistics统计报表这样写的最大好处是每个模块你都可以看作一个独立的小系统来开发业务内聚性很强。我做违章模块的时候只需要在 violation 包内部操作完全不需要去别的地方找代码调试时定位问题也顺手得多。后面如果你想要加功能比如增加一个事故记录管理直接新建一个 accident 包不动其他模块的代码。2. 数据库设计与实体建模2.1 六张核心表搞定全部业务数据库设计是整个系统里我会放最多心思的地方因为表结构一旦定下来后面改起来代价极高。我最早一版设计用了九张表后来发现有几张表其实可以合并自己做了一次重构最终稳定在下面这六张核心表上表名用途关键字段说明sys_user系统用户user_id, username, password, role_type, status三种角色用 role_type 区分vehicle_info车辆档案vehicle_id, plate_number, owner_name, owner_id_card, vehicle_type, register_date车牌号是业务主键做唯一索引driver_info驾驶员档案driver_id, name, license_number, license_type, phone, address驾驶证号唯一violation_record违章记录record_id, vehicle_id, driver_id, violation_type, violation_time, location, penalty_points, fine_amount, status核心表外键关联车辆和驾驶员violation_process违章处理记录process_id, record_id, handler_id, process_time, process_type, result, remark处理和违章记录是一对多关系sys_dict数据字典dict_id, dict_type, dict_code, dict_value, sort_order存违章类型、车辆类型等枚举这个设计的核心思路是把车辆的静态信息和违章的动态行为拆开开车的人驾驶员和管理对象车辆也分开。违章记录里同时存车辆 ID 和驾驶员 ID这样查一辆车的历史违章直接按车辆 ID 过滤就行查一个驾驶员的扣分情况按驾驶员 ID 分组求和就好查询路径非常清晰。业务主键这块我有话要说。车牌号和驾驶证号在现实业务里都是业务主键但在系统里我仍然建议用自增 ID 做物理主键车牌号只加唯一索引。原因很现实车牌号可能因为车辆过户等原因变更一旦用业务字段做物理主键改数据的时候会牵扯所有外键关联简直生不如死。自增 ID 是个无意义的纯技术标识永远不需要改这才是主键该有的样子。2.2 实体类设计从建表 SQL 到 Java 类的转换表结构确定后Java 实体类的设计要跟表字段一一对应这里 MyBatis-Plus 的约定优于配置帮了大忙。我只需要保证驼峰命名和数据库下划线命名的自动映射开好就完全不用手写 ResultMap。以违章记录表为例实体类的核心写法大概是这样的Data TableName(violation_record) public class ViolationRecord { TableId(type IdType.AUTO) private Long recordId; private Long vehicleId; private Long driverId; private String violationType; JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8) private LocalDateTime violationTime; private String location; private Integer penaltyPoints; private BigDecimal fineAmount; private Integer status; TableField(exist false) private String plateNumber; TableField(exist false) private String driverName; }这里有两个细节容易被忽略我特意标注出来。TableField(exist false)这个注解太重要了。上面代码里的plateNumber和driverName不是数据库表里的字段而是我为了前端展示方便做多表联查时手动填充的虚拟字段。如果不加这个注解MyBatis-Plus 在做插入和更新的时候会默认把这两个字段拼进 SQL直接报 Unknown column 错误。第一次遇到这个报错的人大概率会去改表结构加字段其实完全没必要一个注解就能解决。另一个细节是 LocalDateTime 的时间格式化。数据库存的是 datetime 类型Java 这边用 LocalDateTime 接收如果不对 JSON 序列化做处理前端拿到的是一长串数字时间戳。加上JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)后接口返回值会自动变成人类可读的时间字符串。这个我在第一个版本里没加结果前端页面时间显示全乱了后来才发现是这个注解的问题。2.3 时间字段与状态字段设计的四个关键细节建表时的字段类型选择看起来是小事实际影响很大。这里分享我总结出来的四个经验都是改过线上数据才记住的教训。第一时间字段一律用 datetime 类型不要用 timestamp。timestamp 的范围到 2038 年就结束了2038 年问题而且它依赖数据库服务器的时区设置一旦服务器时区配置不对你存进去的时间跟实际时间差八个小时。datetime 不受时区影响你用 MySQL 连接串里的 serverTimezone 参数来控制反而更可控。第二凡是涉及金额的字段一律用 Decimal不要用 float 或者 double。违章罚款金额虽然都是整数但如果以后扩展出滞纳金、折扣这类逻辑浮点类型会出现精度丢失问题0.10.2 不等于 0.3 这种事情在财务数据里是绝对不能出现的。Decimal(10, 2) 足够覆盖绝大部分业务金额场景。第三最重要的表都要带 status 字段而且定义一个全局统一的状态含义。比如违章记录表里我用 0 表示待处理、1 表示已处理、2 表示已撤销。这个状态字段是把系统的查询和统计串起来的关键后面做处理流程和首页统计都要靠它。第四每个表都要有create_time和update_time两个审计字段。MyBatis-Plus 有自动填充的机制实现MetaObjectHandler接口就能自动在插入和更新时填充这两个字段不用手动 set。花十分钟配置一次后面排查数据问题的时间能省好几个小时。3. 核心业务功能实现3.1 违章记录录入与多条件组合查询违章记录录入是整个系统使用频率最高的功能之一。处理员在录入界面选择车辆、选择驾驶员、选择违章类型、填写地点、选择时间系统自动带出该类型对应的罚款金额和扣分确认后保存到数据库。这里有个很实用的实现技巧违章类型不要硬编码在前端下拉框里而是从 sys_dict 数据字典表读取。我在系统管理里维护了一张字典表存违章类型的代码比如 speeding 对应超速和值对应 200 元和 3 分前端加载页面时调用字典接口动态渲染下拉选项。这样做的好处是以后违章类型要调整只需要在系统管理界面改字典数据代码一行都不用动。违章查询功能我用的是 MyBatis-Plus 的 LambdaQueryWrapper 做动态条件拼装这是它最让我省心的地方public PageViolationRecordVO queryViolationPage(ViolationQueryDTO dto) { LambdaQueryWrapperViolationRecord wrapper Wrappers.lambdaQuery(); wrapper.eq(StringUtils.hasText(dto.getPlateNumber()), ViolationRecord::getVehicleId, vehicleIdCache.get(dto.getPlateNumber())); wrapper.eq(dto.getStatus() ! null, ViolationRecord::getStatus, dto.getStatus()); wrapper.ge(dto.getStartTime() ! null, ViolationRecord::getViolationTime, dto.getStartTime()); wrapper.le(dto.getEndTime() ! null, ViolationRecord::getViolationTime, dto.getEndTime()); wrapper.orderByDesc(ViolationRecord::getViolationTime); return violationMapper.selectPage(new Page(dto.getPageNum(), dto.getPageSize()), wrapper); }注意我用的全部是 Lambda 表达式引用实体类的 getter 方法而不是字符串字段名。这么做的好处是如果实体类字段重命名编译期就能发现错误不会等到运行时报 SQL 异常。车牌号转 vehicleId 这块我用了一个缓存集合来避免每次查询都去查一次车辆表。这个优化其实很小但在数据量大、用户反复查询同一个车牌的场景下响应速度提升是能感知到的。当然这只是毕设级别的优化真实生产系统会用 Redis 缓存但毕设做这种程度已经能展现思考深度了。3.2 违章处理流程的状态机设计违章处理是这套系统的灵魂功能也是答辩时最能体现技术含量的一部分。我建议把它设计成一个简单的状态机违章记录在录入时是待处理状态处理员确认违章事实无误后进入已处理状态并生成处理记录如果录入时信息有误或者重复录入可以作废进入已撤销状态。实现状态机时我在 Service 层定义了清晰的状态流转方法而不是直接在 Controller 里改状态字段Service public class ViolationProcessService { Transactional(rollbackFor Exception.class) public void processViolation(ProcessRequestDTO dto) { ViolationRecord record violationMapper.selectById(dto.getRecordId()); if (record null || record.getStatus() ! 0) { throw new BusinessException(该违章记录不存在或已被处理); } // 更新违章记录状态 record.setStatus(1); violationMapper.updateById(record); // 插入处理记录 ViolationProcess process new ViolationProcess(); process.setRecordId(record.getRecordId()); process.setHandlerId(CurrentUserHolder.getUserId()); process.setProcessType(dto.getProcessType()); process.setResult(dto.getResult()); process.setRemark(dto.getRemark()); processMapper.insert(process); } }这里有两个关键点一个是事务一个是逻辑校验。Transactional(rollbackFor Exception.class)必须加上因为更新违章状态和插入处理记录是两个独立的数据库操作任何一个失败都不能留下半完成的脏数据。RollbackFor 默认只回滚 RuntimeExceptionrollbackFor Exception.class 是为了把受检异常也纳入回滚范围这是很多教程里没讲的细节。逻辑校验也很重要。用户传过来的 recordId 如果对应的记录不存在或者状态已经是已处理那就是非法请求必须抛异常拒绝掉。没做这层校验的第一个版本里我遇到过前端连续点击两次提交按钮导致同一条违章记录被处理了两遍的问题。那时候我才意识到除了后端校验前端按钮提交后还要置为 disabled 状态双重保险才靠得住。处理流程里的扣分和罚款逻辑建议在 service 层一并处理。处理员确认处罚后系统自动把 penaltyPoints 和 fineAmount 从违章记录表读取出来写入处理结果并形成汇总信息展示给用户。扣分累计超过 12 分的提示逻辑也可以做这会成为答辩时一个很好的加分亮点。3.3 车辆与驾驶员信息的联动管理车辆和驾驶员的管理模块技术上最核心的就是联动两个字。录入违章记录时要选择车辆选择了车辆之后系统要能自动带出这辆车所属的驾驶员信息。这个联动用 mybatis-plus 的条件查询很好实现。我的做法是车辆表和驾驶员表之间做一个映射字段 driver_id 放在车辆表上一辆车查询时连带把车主信息查出来public VehicleVO getVehicleDetail(Long vehicleId) { VehicleInfo vehicle vehicleMapper.selectById(vehicleId); DriverInfo driver driverMapper.selectById(vehicle.getDriverId()); VehicleVO vo new VehicleVO(); BeanUtils.copyProperties(vehicle, vo); vo.setDriverName(driver.getName()); vo.setDriverPhone(driver.getPhone()); return vo; }这属于典型的单表查询加代码组装比写复杂的多表 join 更好维护。我用这种方式做联查的经验是在数据量不大、查询逻辑不复杂的毕设项目里避免写长 SQL 是更明智的选择。因为一旦 SQL 写复杂了MyBatis 的 XML 映射里各种占位符拼接就会成为新的 bug 源。车辆和驾驶员的新增、修改、删除界面都有表单校验逻辑。车牌号要校验格式省份简称字母数字的组合、驾驶证号要校验位数、手机号要校验位数这些校验在前端做一次后端再校验一次。前端校验是为了用户体验后端校验是为了数据安全缺一不可。我说的安全不是指网络安全攻防而是指这条数据进入数据库之前必须保证有效性否则脏数据会污染所有关联的业务统计。删除功能这块我还要提醒一句车辆和驾驶员有关联的违章记录时不能直接物理删除。我的做法是在删除前检查违反记录是否有关联数据有关联就给出提示该车辆存在违章记录不能删除你可以选择做逻辑删除用一个 deleted 标志位顶替物理删除这是生产系统里最常见的做法答辩老师对这个设计通常是很认可的。3.4 统计报表的实现思路统计报表是交通违章管理系统里最能体现项目完成度的模块我建议无论如何都要做。答辩时老师看到一堆表格页面和一堆图表页面对项目的好感度会完全不一样。我的统计模块主要在管理端包括以下几个维度按月份统计违章数量趋势、按违章类型统计占比、按区域统计违章分布、按驾驶员统计扣分排行。用 SpringBoot 做聚合查询的思路其实很朴素在 Service 层写一个统计方法使用 MyBatis-Plus 提供的selectMaps返回 map 列表public ListMapString, Object countByMonth() { return violationMapper.selectMaps( new QueryWrapperViolationRecord() .select(DATE_FORMAT(violation_time, %Y-%m) as month, COUNT(*) as total) .groupBy(month) .orderByAsc(month) ); }前端拿到这个 list 之后用 ECharts 的折线图直接渲染出来就行。这块我的经验是后端只负责给出结构化的统计结果所有的图表样式、颜色、动画都属于前端的事后端不要做任何跟展示相关的处理职责边界要清晰。这里我要特别提一下selectMaps的这个用法。它的返回结果是ListMapString, Object因为 select 子句里的字段都不是实体类的属性MyBatis-Plus 会把它包装成 map。注意这里的 map 的 key 是 SQL 里起的别名month、total对外接口的字段命名讲究的是稳定所以我在 Controller 层把统计结果的 key 重命名成了count对应的英文这样前端不管怎么改只要接口字段约定不变就行。4. 前端页面与 API 接口设计4.1 Thymeleaf 还是 Vue前端选型实录前端方案是毕设里一个比较纠结的问题。如果你没有学过前端框架我建议直接选 Thymeleaf 模板引擎它跟 SpringBoot 天然集成后端 Controller 返回视图名模板里通过th:each循环渲染 list前端代码量小很多学习成本低一个人也能完得成。如果你学过 Vue 或者有一定前端基础选 Vue 拆前后端项目会让系统架构更现代答辩的时候也更好讲。但代价是你需要处理跨域问题要把前端的 8080 端口和后端的 8081 端口协调好还要做接口联调工作量至少多出百分之三十。我最终选的是 Thymeleaf 原生 JavaScript Bootstrap 的组合。选这个方案的原因很朴素我不需要额外安装 Node 环境、不用配置代理转发整个 SpringBoot 项目打包成一个可运行的 jar 就够了。我的心态是毕设的核心是把业务做完整前端够用就行实际用下来 Bootstrap 的栅格布局和表格样式让页面不至于太丑。不管是哪种方案有一个原则都要守住页面上所有的数据展示和操作结果都必须依赖后端接口返回的 JSON 数据不能通过直接拼接 SQL 的方式从页面上取数。页面只是视图模型必须由后端统一提供。4.2 接口路径与返回格式的统一约定接口设计直接影响前后端联调的效率。我最早做的时候接口路径很随意一会儿/getList一会儿/queryList维护起来非常混乱。后来我给自己定了一套 RESTful 风格的规范功能接口路径请求方式说明违章分页查询/api/violationsGET参数 pageNum, pageSize, 各种筛选条件违章详情/api/violations/{id}GET路径参数传违章记录 ID新增违章记录/api/violationsPOST请求体传 JSON 数据处理违章/api/violations/{id}/processPOST违章处理动作单独一个接口删除违章记录/api/violations/{id}DELETE逻辑删除返回格式上我统一使用一个 Result 类包装public class ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ... } public static T ResultT error(String message) { ... } }code 为 200 表示成功其他为失败配合 message 给前端展示错误提示。这套约定让前端的错误处理变得很简单只需要判断 code 是不是 200 就行不需要为每个接口单独写 try-catch。建议在做所有接口之前先把 Result 类写好后面每个 Controller 方法都直接复用。4.3 前后端联调最容易踩的三个坑第一个坑是日期格式不一致。后端默认返回的 LocalDateTime 格式如果你没做处理前端拿到的是时间戳或者带 T 的 ISO 格式字符串跟页面上显示的年月日不是一回事。这个在前面实体类设计时已经说过了加JsonFormat就能解决但要注意时区。第二个坑是分页参数对不上。MyBatis-Plus 的 Page 对象在 JSON 序列化时分页信息字段名是records、total、current、size前端如果要展示共 xx 条记录得从 total 字段拿。有的同学一开始写的分页接口返回的是整个 Page 对象前端不熟悉 MP 的字段命名直接找不到总数在哪儿白白来回传了好几趟。第三个坑是 POST 请求的 content-type。如果前端用原生 ajax POST 提交 form 格式的数据而后端 Controller 是用RequestBody接收 JSON 对象那就会报 415 Unsupported Media Type。我的经验是毕设项目里统一用 JSON 字符串交互前端提交前用JSON.stringify(data)请求头设Content-Type: application/json后端用RequestBody接收这套组合最省心。5. 部署上线与问题排查实录5.1 本地跑通的最小环境清单我整理一份能跑起整套系统的环境清单做毕设的同学装好这个就能开始开发了软件版本建议用途JDK1.8 或 11Java 运行环境Maven3.6依赖管理和打包MySQL8.0数据库IDEA2022 及以上开发工具Navicat 或 DBeaver均可数据库可视化工具导入项目的步骤我这里不展开了就说一个关键配置application.yml 里的数据库连接串必须带时区参数。我推荐serverTimezoneAsia/Shanghai如果不带这个参数MySQL 8.x 会报 The server time zone value 的异常这个错误几乎每个用 MySQL 8 的 SpringBoot 项目都会遇到。项目跑起来之后先做一次完整的连通性测试进管理员界面新建一个用户再录一条车辆信息、一条驾驶员信息、一条违章记录走一遍违章处理流程最后看一眼统计图表是否正常。这五个动作覆盖了系统所有核心表任何一个有问题都能及时暴露出然后集中排查修复。5.2 我遇到过的五个典型问题这里把我踩过的坑整理成一张速查表每一列都是从实际操作中记录的解决方案问题现象可能原因解决方案启动时报端口被占用上一次启动没有正常关闭找到占用 8080 端口的进程并 kill 掉数据库连接失败密码错误或 MySQL 服务未启动检查 application.yml 中的用户名密码配置表单提交报 400 错误JSON 参数类型不匹配检查前端传参的字段名和后端实体类是否一致列表页分页不生效没有配置 MyBatis-Plus 分页插件新建一个 MybatisPlusConfig 配置类添加分页拦截器时间显示差 8 小时MySQL 时区配置问题连接串加 serverTimezoneAsia/Shanghai前端展示时统一用后端格式化后的字符串分页不生效那个问题我要着重说一下。很多人继承 BaseMapper 后发现selectPage方法返回的记录数就是全部数据没有真正的分页效果原因就是漏了 MybatisPlusConfig 里的分页插件配置。SpirngBoot 3.x 和 2.x 的配置写法有差异你搜到的教程要确认跟你项目版本一致不然照抄了还是报错。5.3 让系统更稳的几个实用细节系统能跑通只是第一步稳定性和安全性是确保答辩顺利的关键。第一个细节是密码加密。用户表的密码不能明文存储SpringBoot 里最简单的方案是使用 BCryptPasswordEncoder 加密后入库登录时再匹配。这个类是 Spring Security 的一部分但单独引一个 spring-security-crypto 包就能用不需要引入整套 Security。第二个细节是登录拦截。没有登录的人不能直接访问管理端接口做法是写一个 HandlerInterceptor 拦截所有 /api/** 请求检查 session 里有没有用户信息没有就返回 401 错误码。第三个细节是全局异常处理。在 Controller 层加一个RestControllerAdvice统一捕获业务异常和系统异常把异常信息包装成 Result 格式返回给前端不要让堆栈信息直接暴露在前端页面。这个对你的代码可维护性和答辩观感都是加分项。第二个细节和第三个细节是配套的不写拦截器你的接口就是裸奔的不写全局异常处理你的报错页面可能就是一堆英文堆栈这两件事花半天时间做完整个系统给人的感觉完全不一样。这套系统做完之后我对 SpringBoot 的整个开发链路有了完整的认知。从前端的页面渲染逻辑到后端的接口处理和事务控制再到数据库的表结构设计每一个环节出了问题你都能在这个系统里找到对应的排查路径。最后再分享一个小经验也是我踩过几次坑之后学到的状态字段的取值含义一定要写注释宁可注释写在实体类的每个字段上也不要让后人包括一个月后的自己去猜 0 和 1 到底代表什么状态。我做第一版的时候没写注释中间隔了两周回去改需求自己都看不懂那个 status 字段什么意思了只好去翻数据库用例数据才猜出来。具体到交通违章管理系统你还可以在基础功能上继续扩展比如增加短信通知或邮件通知的违章提醒、增加微信小程序查询端、把统计报表升级成 ECharts 大屏展示这些都是答辩时很有冲击力的扩展点。先把这套核心系统做扎实后续的每一步扩展都能让你的项目上一个台阶。
返回列表