☰
学生勤工俭学系统开发:Spring Boot + Vue全栈实现与避坑指南
2026/10/13 7:15:00 网站建设 项目流程

简介:毕设学生勤工俭学系统.zip 是一份面向高校毕业设计环节的完整Web应用项目,适合计算机相关专业学生用于课程设计或毕业课题参考。项目围绕高校勤工俭学管理场景,实现岗位发布、学生申请、工时记录、工资计算与双向评价等核心功能,可用于理解校园服务类信息系统的完整开发流程,涵盖需求分析、系统设计、编码实现、测试与文档编写等软件开发全过程要素。资源包共638个文件,约6.6MB,以gif动图、js脚本、html/jsp页面、java/class代码、jar依赖库为主,另含css样式、db/mdf数据库文件、doc说明文档及辅助脚本,基本覆盖从源码到运行环境的典型文件类型。目前已有1086人学习下载。压缩包同时提供asp、php等多语言文件与完整项目结构,便于读者对照JSP实现方式进行比较学习,对掌握MVC分层、Servlet处理请求、JDBC数据库交互及前端布局调试均有实际参考价值。

1. 为什么毕设圈的勤工俭学系统,值得你认真做一遍

手上拿到「毕设 学生勤工俭学系统.zip」这个压缩包时,很多人第一反应是解压看代码。但我建议你先别急着拆,想清楚一个问题:这套系统凭什么能当毕设题目。勤工俭学系统本质是典型的管理信息系统,学生、用工部门、管理员三种角色串起一条完整业务链:岗位发布、在线申请、工时填报、薪酬结算。它不像算法题那样拼模型效果,评审更在意流程是否闭环、表设计是否合理、权限边界是否讲得清。选这个题的人,大多是想用一套代码把 Web 全链路走通,顺便在答辩时把每个表、每个状态机说透。这篇笔记我就按这个目标拆开讲。

2. 把需求拆成实体:用户、岗位、工时与结算的六张核心表

2.1 三种角色与权限边界

勤工俭学系统的业务闭环比图书管理多一层「结算」,比宿舍管理多一层「审核」。先用角色把权限边界划清楚,后面写接口才不会乱。常见做法是分三种角色:学生负责浏览岗位、提交申请、填报工时、查看工资单;用工部门负责发布岗位、审核申请、确认工时;管理员负责用户管理、岗位兜底、月度结算和统计报表。

权限边界在代码里怎么落?毕设规模不需要引入重型权限框架,用用户表的角色字段加前端路由守卫就够了。后端接口按角色校验,前端按角色隐藏按钮,两层都做才能算完整的权限闭环。我在模拟项目X里见过只做了前端隐藏、后端接口裸奔的翻车案例,答辩时被追问一句「直接调接口怎么办」就卡住了。

有人会纠结要不要拆成 user、student、department 三张表。我建议合成一张用户表加 role 字段,理由有三:登录逻辑统一、关联查询少一次 join、答辩讲 RBAC 概念更直观。部门名称等属性作为冗余字段放在用户表里,只有管理员能看到角色为「用工部门」的用户记录。

2.2 六张核心表的字段设计与关联

表数量控制在六张以内,既覆盖业务又不显得堆砌。这是我给这个题固定下来的清单,字段只列关键项,其余按常规补充即可。

表名关键字段说明
sys_userid, username, password, real_name, role, dept_name, statusrole 用 0/1/2 分别表示学生、部门、管理员
job_positionid, title, description, dept_id, hourly_wage, headcount, recruited, status状态分招聘中、已满、已截止
apply_recordid, student_id, job_id, status, apply_time状态分待审核、通过、拒绝
work_logid, student_id, job_id, work_date, hours, status, confirm_time状态分待确认、已确认、驳回
salary_settlementid, student_id, month, total_hours, total_amount, status状态分未发放、已发放
announcementid, title, content, create_time公告表,用于丰富页面和答辩素材

几个字段设计的细节必须提前定好:密码字段不能存明文,用 BCrypt 加密,长度给 60;工时按 0.5 小时为最小颗粒度,用 DECIMAL(4,1) 而不是 FLOAT,免得 0.1 的精度问题让你在结算时抓狂;金额统一 DECIMAL(10,2),所有涉及钱的字段都不许用 double。外键我建议只做逻辑关联,不加物理外键,理由在避坑章里细说。

2.3 工时与薪酬的冗余字段:结算口径在答辩前就要统一

薪酬结算是这个系统的灵魂,评审几乎必问:工资是怎么算出来的。这里要定一条口径并在答辩时咬死:某个结算月内,学生所有「已确认」工时的汇总 — 每个岗位的时薪取该月最后一条确认记录的岗位时薪,按岗位分别计算后求和。写成公式就是 月工资 = Σ(岗位时薪 × 该岗位当月已确认工时)。

salary_settlement 表里冗余 total_hours 和 total_amount 两个快照字段,不是多此一举。岗位的 hourly_wage 随时可能被部门修改,如果不做快照,下个月查历史工资单时数值就漂移了,这在财务上是不可接受的。同理,核算结束并标记「已发放」后,前端要把当月的工时填报入口锁掉,不然数据越改越乱。

这个冗余设计还能引出另一个答辩亮点:结算时读取的是快照字段,需要追溯明细时再按 month 关联 work_log 和 job_position。所以我在设计表时故意不加外键,就是为了让快照能脱离岗位表独立存活。你把这个理由讲出来,比单纯说「我建了外键保证一致性」要有说服力得多。

3. 用 Spring Boot 把登录和岗位申请跑通:JWT 与状态机

3.1 工程结构与配置文件

后端我选 Spring Boot 加 MyBatis-Plus,原因很简单:MyBatis-Plus 的单表 CRUD 不用写 SQL,能把精力省给状态机和事务这些真正的核心逻辑。Spring Security 这个阶段没必要上,一个拦截器加一个 JWT 工具类就能把权限说清楚,答辩时也更容易对着代码讲。

工程结构按职责分层,别把所有类堆在一个包下。我一般会分成 controller、service、mapper、entity、common 五个包,common 下放统一返回结果、JwtUtil、异常处理和拦截器。pom 里最关键的依赖就四个:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.1</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency>

jjwt 0.9.1 是毕设圈最常见的版本,网上资料多,遇到问题好搜。MyBatis-Plus 版本别追新,3.5.x 足够稳定。配置文件里除了数据源,要把 JWT 的几个参数外置,方便答辩时现场调过期时间。

server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/workstudy?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 你的密码 mybatis-plus: configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0 jwt: secret: 一串长度超过32位的随机字符串 expire-hours: 2 upload: path: /data/workstudy/files

参数说明:数据库连接串必须带 characterEncoding=utf8 和 serverTimezone,这两个不加,中文乱码和时区报错会各坑你一次。jwt.secret 至少 32 位,太短会报签名异常。upload.path 我直接指向项目外部目录,理由在避坑章第 4 条展开。

3.2 JWT 登录与拦截器:三个类的核心代码

登录接口走「校验用户名密码 → 生成 token → 返回用户信息」三步。密码校验用 BCrypt,数据库里存的必须是加密后的密文,绝不能明文。JwtUtil 是核心,生成 token 时把 userId 和 role 塞进 claim,后续接口全靠这两个字段做身份识别。

@Component public class JwtUtil { @Value("${jwt.secret}") private String secret; @Value("${jwt.expire-hours}") private int expireHours; public String generateToken(Long userId, Integer role) { return Jwts.builder() .setSubject(String.valueOf(userId)) .claim("role", role) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expireHours * 3600 * 1000L)) .signWith(SignatureAlgorithm.HS256, secret) .compact(); } public Claims parseToken(String token) { return Jwts.parser().setSigningKey(secret).parseClaimsJws(token).getBody(); } }

逻辑说明:setSubject 存用户 ID,claim 里带角色,这样拦截器解析一次 token 就能同时拿到身份和权限,不用再查一次数据库。过期时间单位换算成毫秒,别漏了 *1000L,否则两个小时就变成两秒钟,这是最常见的翻车点。

登录 Controller 写出来很短,但有个细节值得注意:返回前把密码字段置空,前端拿到用户信息里不该有密码密文,这是安全习惯,也是评审可能挑刺的点。

@RestController @RequestMapping("/api/auth") public class AuthController { @Autowired private UserService userService; @Autowired private JwtUtil jwtUtil; @PostMapping("/login") public Result login(@RequestBody LoginDTO dto) { User user = userService.findByUsername(dto.getUsername()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } if (user.getStatus() == 0) { return Result.error("账号已被禁用"); } String token = jwtUtil.generateToken(user.getId(), user.getRole()); user.setPassword(null); return Result.success(new LoginVO(token, user)); } }

逻辑说明:先查用户再验证密码,两个条件合并判断,避免用户名不存在和密码错误两种提示不一致导致信息泄露。status 字段做启用禁用,这个字段在答辩演示禁用账号时很出效果。

拦截器的核心是解析 token 并塞进 request 上下文,后续接口从 request attribute 里取 userId 和 role。注意排除登录接口,否则会出现「登录接口也要先登录」的死循环。

@Component public class JwtInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { writeUnauthorized(response); return false; } try { Claims claims = jwtUtil.parseToken(token.substring(7)); request.setAttribute("userId", Long.parseLong(claims.getSubject())); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { writeUnauthorized(response); return false; } } }

拦截器里先处理 OPTIONS 预检请求,这是前后端分离部署时最容易忘的一步。解析失败统一返回 401,前端 axios 拦截到 401 就跳转登录页。注册拦截器时注意路径匹配,/api/auth/login 和 /api/auth/register 这类匿名接口必须排除。

3.3 岗位申请与审核状态机的接口设计

岗位申请是并发压力最集中的地方,也是答辩时最容易暴露问题的环节。业务规则有三条:岗位状态必须是招聘中、已录用人数不能超过总人数、同一学生不能重复申请同一岗位。三条规则看着简单,并发一上来就出事,原因后面专门写。

申请接口的事务逻辑要这样写:先查岗位状态和人数,再查重复申请记录,校验通过后插入申请记录,最后执行一条原子更新的 SQL 去累计已招人数。

@Transactional public Result applyJob(Long studentId, Long jobId) { JobPosition job = jobPositionMapper.selectById(jobId); if (job == null || job.getStatus() != 0) { return Result.error("岗位不存在或已停止招聘"); } Long count = applyRecordMapper.selectCount( new LambdaQueryWrapper<ApplyRecord>() .eq(ApplyRecord::getStudentId, studentId) .eq(ApplyRecord::getJobId, jobId) .ne(ApplyRecord::getStatus, 2)); if (count > 0) { return Result.error("您已申请过该岗位"); } int rows = jobPositionMapper.increaseRecruited(jobId, job.getHeadcount()); if (rows == 0) { return Result.error("岗位名额已满"); } ApplyRecord record = new ApplyRecord(); record.setStudentId(studentId); record.setJobId(jobId); record.setStatus(0); applyRecordMapper.insert(record); return Result.success("申请成功,等待部门审核"); }

关键在 increaseRecruited 这条自定义 SQL,它把「检查人数」和「增加人数」合并成一条原子操作。触发的条件用 recruited < headcount 写在 where 子句里,靠数据库行锁挡住并发,而不是靠应用层的 if 判断。

<update id="increaseRecruited"> UPDATE job_position SET recruited = recruited + 1 WHERE id = #{jobId} AND recruited &lt; #{headcount} </update>

参数说明:影响行数 rows 是关键信号。rows 为 0 说明 where 条件不满足,即名额已满,事务要抛异常回滚,确保不会出现「申请记录插进去了但人数没加上」的脏数据。被拒绝的申请记录在重复判断里要排除,否则学生被拒绝后就永远无法再申请,这属于业务逻辑的隐藏缺陷。

审核接口的规则相对简单,但状态流转要写清楚:申请通过后,对应的岗位若 recruited 等于 headcount,则应把岗位状态自动改为已满;审核拒绝后,学生在重复申请条件中不再受限制。这两条规则都建议写在同一个事务里,避免状态不一致。

4. 用 Vue 搭起学生端与部门端:路由守卫、申请与工时填报

4.1 路由守卫与 axios 拦截器

前端用 Vue 3 加 Element Plus 是比较省力的组合。Route 的 meta 里标注 roles,守卫里判断当前用户角色是否在允许列表内,不在就重定向到对应首页。token 存在 localStorage,用户信息里的 role 字段是路由判断的唯一依据。

// router/index.js router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (!token && to.path !== '/login') { next({ path: '/login' }) return } const role = parseInt(localStorage.getItem('role')) if (to.meta.roles && !to.meta.roles.includes(role)) { next(role === 1 ? '/department' : '/student') return } next() })

逻辑说明:第一个判断拦截未登录访问,第二个判断拦截角色越权。role 存的是数字,从 localStorage 取出来是字符串,务必 parseInt 转换,这个 bug 会让所有角色判断失效,属于经典低级错误。

axios 封装是前端工程的地基。请求拦截器统一加 Authorization 头,响应拦截器统一处理 401 和业务错误码。后端返回结构统一用 { code, message, data },前端就能用一套逻辑消化全部异常。

// request.js const service = axios.create({ baseURL: '/api', timeout: 15000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers['Authorization'] = 'Bearer ' + token } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res }, error => { if (error.response && error.response.status === 401) { localStorage.clear() router.push('/login') } ElMessage.error(error.message || '网络异常') return Promise.reject(error) } )

参数说明:timeout 设 15 秒,防止后端某个接口卡死时前端白屏无反馈。401 时清空本地存储并跳登录,对应后端 JWT 过期场景。业务错误码统一在响应拦截器弹提示,业务代码里就不用每个接口都写一遍错误分支,这是能省下大量重复代码的习惯。

4.2 岗位列表与申请按钮的边界条件

岗位列表页是学生的入口,需要体现三样东西:分页搜索、岗位状态映射、申请按钮的禁用逻辑。申请按钮的禁用条件不是单一的,要同时看登录状态、岗位状态、人数是否已满、当前用户是否已申请,这四种状态组合起来才是完整的边界控制。

<template> <el-table :data="list" v-loading="loading"> <el-table-column prop="title" label="岗位名称" /> <el-table-column label="时薪"> <template #default="{ row }"> {{ row.hourlyWage }} 元/小时 </template> </el-table-column> <el-table-column label="名额"> <template #default="{ row }"> {{ row.recruited }} / {{ row.headcount }} </template> </el-table-column> <el-table-column label="状态"> <template #default="{ row }"> <el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag> </template> </el-table-column> <el-table-column label="操作"> <template #default="{ row }"> <el-button type="primary" size="small" :disabled="!canApply(row)" @click="handleApply(row)"> {{ appliedIds.includes(row.id) ? '已申请' : '申请' }} </el-button> </template> </el-table-column> </el-table> </template>

canApply 方法建议写成独立函数,不要揉在模板里。它要做四层判断:岗位状态是招聘中、已招人数小于总人数、当前用户没申请过、当前用户角色是学生。这四层判断同时满足才能点申请。把逻辑抽出来还有一个额外好处:后端接口的校验规则和前端完全对齐,答辩时可以说「前端控制体验,后端保证安全」。

申请按钮点击后,用返回结果决定是否刷新列表。常见错误是点击后直接给成功提示,不管后端返回什么。后端返回「名额已满」时前端仍然提示成功,这就属于前后端状态不同步,非常掉价。

4.3 工时填报与部门确认:状态流转的双端协作

工时填报是学生端另一个核心功能,表单需要日期和时长两个关键输入。日期限制在当月范围内,已经确认过的日期不能重复填报;时长的步进设为 0.5,范围从 0.5 到 8 小时。提交时把 student_id 和 job_id 塞进隐藏字段,因为后端要从 token 里取用户身份,学生 ID 不能信任前端传值。

部门端的确认列表结构是另一个维度:按「待确认」工时记录来展示,标题用学生的姓名和所属岗位拼接。每条记录两个操作:确认和驳回。驳回时可以要求填理由,这个理由在答辩演示中很容易被问到,建议做进去。确认操作后,工时记录状态变为已确认,不可再次修改。

学生填报工时(待确认) → 部门确认(已确认) → 结算时纳入统计 ↘ 部门驳回 → 学生可查看驳回原因后重新填报

这个状态流转图虽然简单,却是薪酬结算的数据基础。我在模拟项目X里见过一种做法:部门确认时把金额算好存进工时表,导致一个月后岗位时薪变化,历史工资金额跟着漂移,结算完全对不上。正确做法是工时表只存小时数和状态,金额在结算时按岗位时薪快照计算,不要在确认环节就把钱定死。

前端角色控制还有个细节:按钮的显示用 v-if 判断当前角色,而不是用 disabled 隐藏。学生端根本看不到「确认工时」按钮,部门端根本看不到「申请岗位」按钮,这样才符合权限隔离的直觉。把 role 判断封装成自定义指令会让模板干净很多,答辩时提一句「封装了权限指令」,印象分会不一样。

5. 勤工俭学系统开发避坑:5 个高频踩坑点

5.1 现象:登录成功后页面不到两分钟就掉线

表现是页面操作到一半,突然跳回登录页,输入账号密码又能进来,但过一会儿又掉了。原因几乎都是 JWT 过期时间设置有问题:有人把过期时间写成秒数却当作毫秒用,有人是刷新页面时把 token 一起清掉了,还有人是后端拦截器把每个请求都当成新会话,token 根本没存住。

解决:先确认 jwt.expire-hours 在配置里的值,生成 token 的时间单位是小时乘以 3600 再乘以 1000,换算成毫秒。前端刷新页面不清 token,localStorage 里的 token 要等 401 时才清除。如果你做了记住登录状态,可以加一层 refreshToken 机制,但毕设阶段把 accessToken 有效期设成 2 小时足够演示,别过度设计。

5.2 现象:部门审核通过后,岗位详情里的「已招人数」纹丝不动

表现是审核列表状态正常更新,但回到岗位列表,招募人数还是审核前的数字。原因大概率是审核接口只更新了申请记录的状态,没有调用岗位表的人数增加逻辑。申请通过和名额占要是两件事,代码里只做了一件。

解决:审核通过的业务方法里,必须同时执行「更新申请状态为通过」和「recruited 字段加一」。把两步放在同一个事务方法里,任何一个失败都整体回滚。推荐在 Service 层写一个 auditPass 方法封装这两个操作,并且要复用申请时的原子更新 SQL,不能另写一条不带条件的 update。

5.3 现象:月度工资汇总和 Excel 手工核算结果差几分钱

表现是系统里显示总金额是 1000.00,手工拿计算器按却是 999.98。原因几乎可以断定是用了 double 或 FLOAT 存金额和工时,浮点数在累加时会产生误差。0.1 加 0.2 不等于 0.3,后端的 double 求和也会积累这种误差,越汇总越明显。

解决:从数据库到 Java 代码全链路用定点数。表字段 DECIMAL(10,2),实体属性用 BigDecimal,Java 求和用 BigDecimal.add,不要用 + 号。工时字段虽然允许一位小数,但录入时按 0.5 的倍数约束,避免出现 1.1 小时这种无法用二进制精确表示的值。这个坑属于「知道一次就不再犯」的类型,答辩时主动讲出来反而是加分项。

5.4 现象:并发申请最后一个名额,系统录进去了 5 个人

表现是岗位只招 5 人,压测时 10 个请求同时打进来,apply_record 表里多了 10 条待审核记录,recruited 也涨到了 10。原因就是应用层的 check-then-act 竞态:先查人数再插入,两条请求同时查到人数为 4,都认为自己还有名额,然后都执行了插入。这是经典并发问题,不是玄学。

解决:用数据库的原子更新做名额扣减,也就是第 3 章里的 increaseRecruited,把「判断名额」和「名额加一」合并到一条 UPDATE 语句里。WHERE 条件 recruited < headcount 在数据库层面加锁,并发请求只有一个能更新成功,其余返回影响行数为 0,事务回滚。如果不想写自定义 SQL,也可以用 SELECT ... FOR UPDATE 做悲观锁,但毕设阶段原子更新是更优雅的方案,也更好讲。

5.5 现象:导出 Excel 文件名是乱码,打开内容也乱

表现是部署到服务器后,下载的工资明细文件名显示为 %E5%B7%A5%E8%B5%84%……,用 Excel 打开后中文内容变成问号。原因是响应头的 Content-Disposition 里直接拼了中文文件名,不同浏览器对非 ASCII 字符的处理方式不同。Tomcat 8 以上版本对 URL 编码的兼容性比直接传中文好。

解决:文件名用 URLEncoder 编码后,拼接进 Content-Disposition,并加上 filename* 参数。同时确保 POI 生成的 xlsx 内容本身用了 UTF-8,服务端向浏览器输出时指定字符集。代码层面有个小技巧:attachment; filename*=UTF-8'' + 编码后的文件名,这种写法在现代浏览器里全部兼容。这个坑解决后,把格式写在单元测试里固定下来,防止日后被改回去。

6. 答辩前值得做的三件事:造数据、写测试、压一把接口

第一件事是造数据。空库跑系统的演示效果非常单薄,分页只有一页、报表只有一行、图表拉不出曲线。我用一个 SQL 脚本批量生成数据,比一条条 insert 高效得多:300 个学生、30 个岗位、500 条申请记录、1000 条工时记录、12 个月的结算单。生成时注意两点:工时要跨月份分布,才能演示结算的月度筛选;申请记录的状态要覆盖待审核、通过、拒绝三种,审核列表才有内容可看。

第二件事是给核心业务写接口测试,别怕花时间。覆盖四个主流程:登录成功与失败、岗位申请成功与名额已满、审核通过后人数回写、月度结算金额计算。用 MockMvc 模拟请求,断言返回码和数据库记录变化。这四类测试跑通,答辩时被问到边界情况可以直接说「这块有测试覆盖」,评审的追问方向会明显收敛。

@Test @Transactional void applyWhenFull_shouldFail() { // 构造一个 headcount=1 且已满员的岗位 JobPosition job = new JobPosition(); job.setHeadcount(1); job.setRecruited(1); job.setStatus(0); jobPositionMapper.insert(job); Result result = workStudyService.applyJob(2L, job.getId()); assertTrue(result.isSuccess() == false); assertEquals("岗位名额已满", result.getMessage()); }

第三件事是压一把接口,重点压岗位申请。不需要全面性能测试,单测一个接口的手工并发即可:开 20 个线程同时申请最后一个名额,断言申请记录只有一条成功、recruited 不超过 headcount。这一步直接验证第 5 章的防超录方案,也让你在答辩演示时心里有底。评审如果问「并发怎么办」,现场展示这条测试比任何口头解释都可信。

我从这类项目里得到的最大教训是:毕设系统的代码量不大,但完整性要求很高。把登录、申请、审核、结算、角色控制串成一条干净的闭环,再把测试和边界情况的答案准备好,这个题目就立住了。至于那些漂亮花哨的动画页面,优先级远低于数据一致性和权限安全,别把力气用反方向。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询