简介:面向高校毕业设计与课程设计场景的 Spring Boot 竞赛管理系统项目,整合 Spring Boot、Spring Security、JWT、Vue.js、Element UI、axios 与 MyBatis Plus,覆盖用户认证、权限控制、竞赛信息管理等典型业务模块,适合计算机相关专业学生参考或二次开发。压缩包共 53 个文件,约 230KB,以 31 个 Vue 组件页面、11 个 JavaScript 逻辑文件为主,配合 3 个 CSS、3 个 JSON、1 个 SCSS 样式与配置,以及 README 说明,前端代码分层清晰,便于快速定位入口与接口调用。已有 85 人学习浏览,印证其在校园项目实践中的参考价值。通过这套项目,可掌握前后端分离开发中 JWT 鉴权、Spring Security 过滤链配置、Vue 路由与状态管理、axios 封装等关键实现,同时从完整目录结构中理解毕业设计常见模块划分与代码组织方式,适合作为课程设计报告撰写或系统演示的起点。
1. 用SpringBoot + Spring Security给竞赛系统先立权限墙
一个大学生竞赛管理系统,业务上绕不开这几件事:学生报名、上传作品、教师评审、管理员发布赛项。如果只把CRUD写完,上线后第一个月就会出乱子——学生能翻到别人的作品附件,教师能改不属于自己赛项的分数,学生能直接调接口把自己状态改成“已通过”。这些问题不是业务逻辑没写好,而是认证和授权从根上就没立住。
SpringBoot负责把竞赛业务的开发速度提起来,Spring Security负责把所有请求挡在权限边界之外。这个组合的典型落地方式是:SpringBoot提供接口,Spring Security处理登录认证、会话维护、接口授权,再配合RBAC模型把“谁能干什么”变成数据库里的几行记录。这篇顺着从理论到实现的路径,把整个系统的安全体系拆开讲清楚。适合正在做竞赛管理类系统的开发者,也适合想从SSH老项目迁到SpringBoot的人。
2. 竞赛系统的RBAC权限模型:表结构、角色边界与选型理由
2.1 三种角色的权限边界与“超管”陷阱
竞赛管理系统里最常见的角色划分是学生、教师、管理员。学生能报名、上传作品、查看自己的成绩;教师能创建赛项(如果学校允许)、评审作品、录入分数;管理员管用户、管赛项配置、管全局参数。
这里有一个常见的认知陷阱:以为“管理员”应该拥有一切权限,于是代码里到处写if (user.getRole() == ADMIN)。等系统跑起来才发现,管理员误操作改掉评审分数,比学生越权更可怕。正确的做法是把管理员也看作一个普通角色,只是它被分配了“用户管理”“赛项管理”等具体权限点。权限的最小单位为操作,角色只是权限的集合,而不是硬编码的超级身份。
2.2 五张核心表与一段可直接执行的建表SQL
RBAC的标准落法是五张表:用户表、角色表、用户角色关联表、权限菜单表、角色权限关联表。竞赛业务表(赛项表、报名表)通过teacher_id或student_id与用户表外键关联,权限判断时再联合角色表查询。
CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), email VARCHAR(100), enabled TINYINT(1) DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE sys_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_code VARCHAR(50) NOT NULL UNIQUE, role_name VARCHAR(50), -- 角色描述,如:学生、评审教师、校级管理员 description VARCHAR(200) ); CREATE TABLE sys_user_role ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, role_id BIGINT NOT NULL ); CREATE TABLE sys_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, perm_code VARCHAR(100) NOT NULL UNIQUE, menu_name VARCHAR(50), parent_id BIGINT DEFAULT 0 ); CREATE TABLE sys_role_menu ( id BIGINT PRIMARY KEY AUTO_INCREMENT, role_id BIGINT NOT NULL, menu_id BIGINT NOT NULL );这段SQL里,sys_menu.perm_code存的是像competition:create、score:update这样的权限标识字符串,而不是前端菜单路径。把权限标识和菜单解耦之后,后端校验可以直接用hasAuthority("competition:create")判断,前端菜单的增删不影响后端权限规则。enabled字段用来做账号禁用,这是竞赛系统里很实用的一个开关,比如学生毕业或教师调离后,不删账号只禁用。
2.3 为什么这里选RBAC而不是在代码里写if判断
| 对比项 | 硬编码角色判断 | RBAC模型 |
|---|---|---|
| 新增角色 | 改代码、重新部署 | 数据库插一行记录 |
| 调整权限 | 逐个找if分支 | 改角色与权限的关联记录 |
| 审计追溯 | 无法回答“谁有这个权限” | 一次关联查询即可 |
竞赛系统的角色数量虽然少(通常只有3到4类),但每个角色的权限点会随业务演进持续调整。比如第一版学生只能上传作品,第二版允许学生修改已提交的作品,这个变化如果走硬编码,就要去代码里加判断;走RBAC,给“学生”角色加一条entry:update权限记录即可。Spring Security的hasAuthority天然支持这种权限点校验,配合@PreAuthorize注解就能在方法级别做控制。
3. Spring Security登录认证落地:过滤器链、UserDetailsService与自定义登录接口
3.1 从过滤器链认识Spring Security的认证入口
Spring Security不是一个独立运行的框架,它寄生在Servlet过滤器链上。一个HTTP请求进入SpringBoot应用后,会先经过Tomcat的Filter链,再进入DispatcherServlet。Spring Security通过FilterChainProxy在这条链上注册了一批过滤器,按顺序处理认证逻辑。
SecurityContextPersistenceFilter先从Session里取出已保存的SecurityContext,没有就新建一个空的;UsernamePasswordAuthenticationFilter负责拦截登录请求,从请求体里取出用户名和密码,封装成Authentication对象后交给AuthenticationManager做校验;校验成功后,SecurityContext会被放回SecurityContextHolder,同时写进Session。后续请求进来,SecurityContextHolder里已经有认证信息,就不需要再走登录逻辑。
理解这条链的意义在于,写代码时要知道每个配置项最终落在了哪个过滤器上。比如sessionManagement配置控制的是SessionManagementFilter,csrf配置控制的是CsrfFilter。出了问题,看启动日志里打出的过滤器链顺序就能定位到是哪一环没配对。
3.2 SecurityFilterChain:一份能跑起来的SecurityConfig
Spring Security 5.7版本之后,官方推荐用SecurityFilterChain的Bean方式替换掉曾经的WebSecurityConfigurerAdapter继承写法。竞赛系统前后端分离,登录走JSON接口,所以表单登录和HTTP Basic都要关掉。
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @Configuration public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { // BCrypt每次生成的hash都不同,但matches能正确比对,适合存库 return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/competitions/public").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/teacher/**").hasAnyRole("TEACHER", "ADMIN") .requestMatchers("/api/student/**").authenticated() .anyRequest().authenticated()); return http.build(); } }这段配置的逻辑分几层看:csrf.disable()是因为前后端分离后没有页面表单,CSRF Token的传递成本高于收益;SessionCreationPolicy.IF_REQUIRED表示只有需要时才创建Session,避免每个匿名请求都产生服务端Session。requestMatchers的匹配规则从上往下生效,先声明/api/teacher/**允许TEACHER和ADMIN,那么ADMIN访问教师接口时就放行,不需要在/api/admin/**里再重复配。注意hasRole会自动给传入的值加ROLE_前缀,数据库里存的角色标识要写成ROLE_ADMIN。
3.3 UserDetailsService实现与密码加密选型
Spring Security只认UserDetailsService接口——你给它用户名,它返回一个UserDetails对象。这个对象里封装了密码和权限列表,AuthenticationManager拿到之后和用户提交的密码做比对。
import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; @Service public class UserDetailsServiceImpl implements UserDetailsService { private final UserMapper userMapper; public UserDetailsServiceImpl(UserMapper userMapper) { this.userMapper = userMapper; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { SysUser user = userMapper.selectByUsername(username); if (user == null) { throw new UsernameNotFoundException("用户不存在: " + username); } org.springframework.security.core.userdetails.User.UserBuilder builder = org.springframework.security.core.userdetails.User.builder(); return builder .username(user.getUsername()) .password(user.getPassword()) .roles(userMapper.selectRoleCodesByUserId(user.getId())) .disabled(!user.getEnabled()) .build(); } }这段代码里有三个关键点。第一,密码是从库里查出来的BCrypt密文,绝不能拿明文做比对。第二,roles()方法接收的是不带ROLE_前缀的角色代码,Spring会帮你拼接。第三,disabled(!user.getEnabled())把sys_user.enabled字段映射到了Spring Security的账号状态判断上,管理员在后台禁用账号后,该账号立刻无法登录。密码加密用BCryptPasswordEncoder,它的特点是同一密码每次加密结果不同,但matches(rawPassword, encodedPassword)能正确校验。建表时password字段长度至少留到60,BCrypt的hash长度是60个字符。
3.4 自定义登录接口与AuthenticationManager装配
Spring Boot的自动装配会把AuthenticationManager准备好,但不会直接作为Bean暴露。从AuthenticationConfiguration里取出来,再配合自定义的登录接口使用。
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/api/auth") public class AuthController { private final AuthenticationManager authenticationManager; public AuthController(AuthenticationManager authenticationManager) { this.authenticationManager = authenticationManager; } @PostMapping("/login") public String login(@RequestBody LoginRequest loginRequest) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken( loginRequest.getUsername(), loginRequest.getPassword()); // 这一步会触发UserDetailsService + PasswordEncoder的校验流程 Authentication authentication = authenticationManager.authenticate(authToken); SecurityContextHolder.getContext().setAuthentication(authentication); return "登录成功"; } }AuthenticationManager.authenticate()内部会依次调用DaoAuthenticationProvider、UserDetailsServiceImpl和PasswordEncoder.matches(),任何一个环节失败都会抛出AuthenticationException。登录成功后,把Authentication放进SecurityContextHolder,后续请求会通过SecurityContextPersistenceFilter从Session里恢复这个上下文。这里没有手写Token,默认走的是Session机制,JSESSIONID由服务端通过Set-Cookie下发。
需要单独配置一个AuthenticationManager的Bean,否则无法注入:
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.context.annotation.Bean; @Bean public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); }异常处理方面,登录接口要捕获BadCredentialsException(密码错误)和DisabledException(账号禁用),分别返回“用户名或密码错误”和“账号已被禁用”,不要把Spring Security的原始异常直接抛给前端。
4. 接口级权限控制:URL规则、@PreAuthorize与数据级权限
4.1 URL规则管粗粒度,方法注解管细粒度
在第3章的SecurityFilterChain配置里,requestMatchers已经处理了“谁可以访问哪个URL前缀”的问题。URL规则适合做粗粒度的拦截——/api/admin/**只能管理员进,/api/student/**登录用户都能进。但竞赛系统里很多权限判断依赖请求参数,比如“修改作品”要求修改者是作品的主人,“录入分数”要求评分人是该赛项的指定评委。URL匹配做不到这种粒度,需要在方法上做二次校验。
方法级权限控制的开关是@EnableMethodSecurity。Spring Security 5.6之前叫@EnableGlobalMethodSecurity,新版本已经废弃了旧注解,直接用新的即可。
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.context.annotation.Configuration; @Configuration @EnableMethodSecurity public class MethodSecurityConfig { }开启之后,@PreAuthorize注解就生效了,它在方法执行之前先做SpEL表达式判断,表达式结果为false直接抛出AccessDeniedException,方法体不会执行。
4.2 在竞赛管理中使用@PreAuthorize的三个实例
看三个竞赛系统里最典型的场景。第一个是管理员发布赛项,只有ADMIN角色能做:
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @PostMapping("/api/admin/competitions") @PreAuthorize("hasAuthority('competition:create')") public String createCompetition(@RequestBody Competition competition) { competitionService.save(competition); return "创建成功"; }这里用了hasAuthority而不是hasRole,对应sys_menu.perm_code里的competition:create。区别在于hasRole强制要求ROLE_前缀,hasAuthority则是精确匹配权限字符串。如果初始化了角色和权限的关联数据,推荐用hasAuthority做细粒度控制,角色与权限点可以自由组合。
第二个是删除作品附件,只有作品所属者或者管理员能操作:
import org.springframework.security.access.prepost.PreAuthorize; @DeleteMapping("/api/student/entries/{id}") @PreAuthorize("hasRole('ADMIN') or @entryPermissionChecker.canDelete(authentication.principal, #id)") public String deleteEntry(@PathVariable Long id) { entryService.deleteById(id); return "删除成功"; }SpEL里用@beanName.method(...)可以调用已注册的Spring Bean。这里把判断逻辑抽到EntryPermissionChecker里,避免在注解里写复杂的逻辑组合。authentication.principal拿到的是之前UserDetailsService里构造的User对象,#id对应方法参数。
第三个是评分接口,限定只有该赛项的指定评委才能打分:
@PutMapping("/api/teacher/scores/{entryId}") @PreAuthorize("@competitionPermissionChecker.isReviewer(authentication.principal, #entryId)") public String scoreEntry(@PathVariable Long entryId, @RequestBody ScoreRequest score) { scoreService.updateScore(entryId, score.getScore()); return "评分成功"; }isReviewer的典型实现是查competition表里的reviewer_id字段,和当前登录用户的ID比对。这样配置之后,“教师角色能访问评分接口”和“该教师能评这个赛项”被拆成了两层:前者由URL规则把关,后者由方法注解结合业务数据把关。
4.3 数据级权限:教师只能看自己赛项的做法与边界
方法注解能挡“不该访问的人”,但挡不住“访问了不该访问的数据”。考虑这样一个接口——查询赛项详情,返回结果包含全部参赛作品列表。教师A访问赛项ID为10的接口,但ID为10的赛项是教师B负责的。@PreAuthorize如果只判断“当前用户是TEACHER角色”,那教师A就拿到了教师B的赛项数据。
常见做法是分两步。第一步在查询层过滤数据,把teacher_id作为SQL查询条件:
public Competition getCompetitionForTeacher(Long teacherId, Long competitionId) { return competitionMapper.selectByIdAndTeacherId(competitionId, teacherId); }SELECT * FROM competition WHERE id = #{competitionId} AND teacher_id = #{teacherId}第二步用@PostAuthorize兜底,防止查询结果意外返回给无权用户:
import org.springframework.security.access.prepost.PostAuthorize; @PostAuthorize("returnObject == null or returnObject.teacherId == authentication.principal.id") public Competition getCompetitionDetail(Long competitionId) { return competitionMapper.selectById(competitionId); }注意@PostAuthorize的局限:它在方法执行之后才拦截,数据已经从数据库查出来了,如果表达式校验失败,虽然响应会被拦截掉,但方法内部的日志、统计等副作用已经发生了。所以在数据敏感的场景里,第一道防线必须是SQL层面的数据隔离,@PostAuthorize只做兜底。
4.4 常用权限表达式速查表
| 表达式 | 含义 | 竞赛系统场景 |
|---|---|---|
hasRole('ADMIN') | 当前用户拥有ROLE_ADMIN角色 | 管理员操作赛项配置 |
hasAnyRole('TEACHER','ADMIN') | 拥有其中任一角色即通过 | 教师可进评分接口,管理员兜底 |
hasAuthority('score:update') | 拥有score:update权限点 | 精确控制评分权限,与角色解耦 |
isAuthenticated() | 已登录即可访问 | 学生查看自己已有报名记录 |
#id == authentication.principal.id | 方法参数与登录用户ID一致 | 学生只能修改自己的作品 |
@beanName.check(...) | 自定义Bean校验逻辑 | 判断当前用户是否为赛项指定评委 |
denyAll() | 拒绝所有访问 | 临时关闭某个高危接口时使用 |
5. 落地阶段的配置排错:版本差异、CORS、CSRF与403定位
5.1 Spring Boot 2.7到3.x:WebSecurityConfigurerAdapter过时与javax换jakarta
很多人跟着旧教程写代码,会遇到“springboot版本太高”导致的编译失败。Spring Security 5.7开始,WebSecurityConfigurerAdapter被标记为过时,Spring Security 6.0直接移除了这个类。如果你新建的SpringBoot项目是2.7.x以上,configure(HttpSecurity http)的继承写法会提示无法覆盖父类方法。
3.x版本还有另一个坑:Servlet API从javax.servlet换成了jakarta.servlet。意味着OncePerRequestFilter、Filter等类的import路径全要改,旧代码迁移时这一条最容易漏,报错信息通常是ClassNotFoundException: javax.servlet.Filter。
我一般会在项目初始化时直接定下版本基线:SpringBoot 2.7.x对应Spring Security 5.8,SpringBoot 3.x对应Spring Security 6.x。代码里统一用SecurityFilterChain加Lambda表达式配置,不碰已经过时的实现类。
5.2 前后端分离的CORS与CSRF配置
竞赛系统的前端如果跑在Vue开发服务器(比如localhost:5173),后端接口在localhost:8080,浏览器会拦截跨域请求。Spring Security的过滤链默认会把不带正确CORS头的跨域请求挡掉,不能只靠@CrossOrigin注解。
import org.springframework.context.annotation.Bean; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import java.util.List; @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOrigins(List.of("http://localhost:5173")); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", configuration); return source; }然后在SecurityFilterChain里加上:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));setAllowCredentials(true)是关键,它允许前端请求携带Cookie(JSESSIONID)。但注意setAllowedOrigins不能和AllowCredentials同时使用*,必须显式写清来源地址。CSRF和CORS的搭配容易糊涂:如果你连csrf.disable()都写了,那么CSRF就不是跨域问题的来源;如果没写,跨域请求会因为没有CSRF Token被拒绝,报403。很多前后端分离项目里的403其实是CSRF校验失败,而不是权限不足。
5.3 403不等于没权限:三条快速定位路径
遇到403,先别急着改权限表达式。比赛系统里最常见的几种403成因:
第一,请求带上了跨域预检请求(OPTIONS),但CORS配置没放行。检查CorsConfigurationSource里的setAllowedMethods是否包含OPTIONS,不包含的话浏览器预检直接失败,控制台会提示CORS error。
第二,CSRF没关且请求未带Token。确认SecurityFilterChain里是否执行了csrf.disable(),如果没执行且走的是自定义登录接口,就要在登录请求头里追加X-CSRF-TOKEN,或者直接关掉。
第三,角色前缀对不上。hasRole("ADMIN")要求数据库或UserDetails里必须存在ROLE_ADMIN这个授权标识。如果UserDetailsService里用了authorities()而不是roles(),返回的权限列表里没有ROLE_前缀,那hasRole永远返回false。
排查时把Security的日志开到DEBUG级别,直接看过滤器链和处理结果是最高效的办法:
logging: level: org.springframework.security: DEBUG日志里会逐条打出访问的URL命中了哪些匹配规则、最终走的哪个过滤器、异常原因是什么。看到AccessDeniedException出现在AuthorizationFilter之前,说明是认证阶段出了问题;出现在AuthorizationFilter,说明是授权规则拒绝;如果请求都没进到SecurityFilterChain就被拦,那就是CORS层或Tomcat层的错误。
6. 生产化收尾:Redis会话共享、OAuth2.0扩展与安全加固
6.1 Spring Session + Redis处理多实例部署
竞赛管理系统上线后通常不止跑一个实例,两台服务器轮询负载均衡时,Session默认存在各自的内存里。用户在第一台服务器登录成功,第二次请求被分发到第二台,服务端查不到Session,直接返回未登录。解决方案是Spring Session加Redis,把Session从本地内存搬到集中存储。
引入依赖后配置spring.session.store-type=redis即可,SpringBoot会自动替换掉HttpSession的实现,所有getSession()的调用透明切换到Redis存储。
<dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session-data-redis</artifactId> </dependency>要注意的是Redis里的Session数据有序列化格式,排查问题时别直接用redis-cli get去看,用Redis Desktop Manager或redis-cli --scan --pattern "spring:session:*"确认key是否存在。Session失效时间在server.servlet.session.timeout里设置,竞赛系统建议设30分钟,评审教师经常填一半分数去查资料,时间太短会丢评分内容。
6.2 预留OAuth2.0统一身份认证的接入位
不少高校有统一身份认证平台,竞赛系统最终要对接学校账号体系。Spring Security对OAuth2.0 Client的支持已经非常成熟,只需要在配置里加依赖和认证服务器参数,不用自己实现授权码流程。实际接入时,application.yml里要配spring.security.oauth2.client.registration和provider两段信息,需向校信息中心申请client-id和client-secret。改造的时候,建议保留原有用户名密码登录方式作为备用通道,等统一认证稳定了再切换默认登录入口。
6.3 加固与验收,安全边界才有意义
落地到这个阶段,还差两类工作。一类是防护SpringBoot常见漏洞——springboot heapdump 敏感信息泄露漏洞要重点处理,Spring Actuator的/actuator/heapdump端点如果暴露在公网,攻击者可以直接下载JVM堆内存文件,从中提取密码、Token等敏感信息,生产环境要把management.endpoints.web.exposure.include配置为只暴露health和info,并把Actuator端口限定在内网访问。另一类是审计验收,逐个确认关键接口的权限和用户禁用后的状态同步。
最终验证建议用无痕窗口开三个角色账号,分别登录后访问同一个受保护接口,确认返回码分别是200和403,再禁用其中一个账号验证enabled字段的即时生效。这套检查跑完,权限墙才算真正立住了。
本文还有配套的精品资源,点击获取