必读经典项目,web开发框架renren-fast项目解读

基本信息

renren-fast是一个轻量级的Spring Boot2.1快速开发平台,其设计目标是开发迅速、学习简单、轻量级、易扩展;使用Spring Boot、Shiro、MyBatis、Redis、Bootstrap、Vue2.x等框架,包含:管理员列表、角色管理、菜单管理、定时任务、参数管理、代码生成器、日志管理、云存储、API模块(APP接口开发利器)、前后端分离等。

  • git链接:https://gitee.com/renrenio/renren-fast.git

  • 项目作者:人人开源

  • 官方文档:https://www.renren.io/guide

技术栈

  • 前端 vue.js、element-ui

  • 后端 spring boot、mybatis plus、shiro、swagger2、redis、mysql、jwt、多数据源

功能大纲

后台管理系统的基本功能!

  • 管理员列表

  • 角色管理

  • 菜单管理

  • SQL监控

  • 定时任务

  • 参数管理

  • 文件上传

  • 系统日志

学习重点(目的)

  • Hibernate Validator数据校验

  • google开源的的验证码生成器

  • jwt机制

  • 多数据源模块

  • 预防CSFR、XSS攻击

  • quartz定时任务

项目启动步骤

前端

由于前端使用vue开发,因此需要安装node.js环境。node.js安装教程:http://nodejs.cn/download/ 下载msi版本安装。

安装之后,命令行窗口,表示安装成功。

必读经典项目,web开发框架renren-fast项目解读_第1张图片

启动步骤:

# 克隆项目
git clone https://github.com/daxiongYang/renren-fast-vue.git
# 切换到项目目录根目录renren-fast-vue里面
# 1、安装淘宝镜像依赖
npm install -g cnpm --registry=https://registry.npm.taobao.org
# 2、安装项目依赖
cnpm install
# 启动服务
npm run dev

后端

导入sql文件,修改配置文件的数据库账号密码,启动main方法即可。

项目截图

登录页

必读经典项目,web开发框架renren-fast项目解读_第2张图片

后台页面

必读经典项目,web开发框架renren-fast项目解读_第3张图片

项目特点

  • 友好的代码结构及注释,便于阅读及二次开发

  • 实现前后端分离,通过token进行数据交互,前端再也不用关注后端技术

  • 灵活的权限控制,可控制到页面或按钮,满足绝大部分的权限需求

  • 页面交互使用Vue2.x,极大的提高了开发效率

  • 完善的代码生成机制,可在线生成entity、xml、dao、service、vue、sql代码,减少70%以上的开发任务

  • 引入quartz定时任务,可动态完成任务的添加、修改、删除、暂停、恢复及日志查看等功能

  • 引入API模板,根据token作为登录令牌,极大的方便了APP接口开发

  • 引入Hibernate Validator校验框架,轻松实现后端校验

  • 引入云存储服务,已支持:七牛云、阿里云、腾讯云等

  • 引入swagger文档支持,方便编写API接口文档

数据校验

后端实体效验使用的是Hibernate Validator校验框架。且自定义ValidatorUtils工具类,用来效验数据。

  • io.renren.common.validator.ValidatorUtils(校验工具)

  • io.renren.common.validator.Assert(自定义的断言,用于抛出自定义的RRException)

必读经典项目,web开发框架renren-fast项目解读_第4张图片

通过上面的实体类代码,我们来理解Hibernate Validator校验框架的使用。其中,username属性,表示保存或修改用户时,都会效验username属性;而password属 性,表示只有保存用户时,才会效验password属性,也就是说,修改用户时,password可以 不填写,允许为空。

如果不指定属性的groups,则默认属于javax.validation.groups.Default.class分组,可以通 过ValidatorUtils.validateEntity(user)进行效验。

校验分组

  • io.renren.common.validator.group.AddGroup

  • io.renren.common.validator.group.AliyunGroup

  • io.renren.common.validator.group.Group

  • io.renren.common.validator.group.QcloudGroup

  • io.renren.common.validator.group.QiniuGroup

  • io.renren.common.validator.group.UpdateGroup

项目逻辑

  • 1、首先定义分组,根据实际情况,可以分为添加组AddGroup和修改组UpdateGroup等。

  • 2、在实体上添加hibernate.validator规则注解@NotBlank、@Email等,并分组。

  • 3、编写规则校验工具类ValidatorUtils。校验出有不符合规则的内容抛出自定义异常RRException

  • 4、再保存、更新等操作中使用ValidatorUtils.validateEntity(user, AddGroup.class);校验实体规则情况。

常用注解

Bean Validation 中内置的 constraint
@Null   被注释的元素必须为 null
@NotNull    被注释的元素必须不为 null
@AssertTrue     被注释的元素必须为 true
@AssertFalse    被注释的元素必须为 false
@Min(value)     被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@Max(value)     被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@DecimalMin(value)  被注释的元素必须是一个数字,其值必须大于等于指定的最小值
@DecimalMax(value)  被注释的元素必须是一个数字,其值必须小于等于指定的最大值
@Size(max=, min=)   被注释的元素的大小必须在指定的范围内
@Digits (integer, fraction)     被注释的元素必须是一个数字,其值必须在可接受的范围内
@Past   被注释的元素必须是一个过去的日期
@Future     被注释的元素必须是一个将来的日期
@Pattern(regex=,flag=)  被注释的元素必须符合指定的正则表达式
Hibernate Validator 附加的 constraint
@NotBlank(message =)   验证字符串非null,且长度必须大于0
@Email  被注释的元素必须是电子邮箱地址
@Length(min=,max=)  被注释的字符串的大小必须在指定的范围内
@NotEmpty   被注释的字符串的必须非空
@Range(min=,max=,message=)  被注释的元素必须在合适的范围内

登录验证码逻辑

后台的登录需要用到验证码,那么验证的生成逻辑是怎么样的呢?

这里用到了一个google开源的的验证码生成器kaptcha,我们来看下代码。

第一步:导入相关jar包


   com.github.axet
   kaptcha
   0.0.9

第二步:配置kaptcha验证码的生成规则,也就是配置一下验证码的样式,复杂程度等。

  • io.renren.config.KaptchaConfig

/**
 * 生成验证码配置
 *
 * @author Mark [email protected]
 */
@Configuration
public class KaptchaConfig {
    @Bean
    public DefaultKaptcha producer() {
        Properties properties = new Properties();
        properties.put("kaptcha.border", "no");
        properties.put("kaptcha.textproducer.font.color", "black");
        properties.put("kaptcha.textproducer.char.space", "5");
        properties.put("kaptcha.textproducer.font.names", "Arial,Courier,cmr10,宋体,楷体,微软雅黑");
        Config config = new Config(properties);
        DefaultKaptcha defaultKaptcha = new DefaultKaptcha();
        defaultKaptcha.setConfig(config);
        return defaultKaptcha;
    }
}

上面的代码中,有很多可以自定义的样式,比如是否需要边框kaptcha.border等。第三步:生成验证码

  • io.renren.modules.sys.controller.SysLoginController

/**
 * 验证码
 */
@GetMapping("captcha.jpg")
public void captcha(HttpServletResponse response, String uuid)throws IOException {
   response.setHeader("Cache-Control", "no-store, no-cache");
   response.setContentType("image/jpeg");
   //获取图片验证码
   BufferedImage image = sysCaptchaService.getCaptcha(uuid);
   ServletOutputStream out = response.getOutputStream();
   ImageIO.write(image, "jpg", out);
   IOUtils.closeQuietly(out);
}

我们来分析一下,首先客户端传来一个uuid,然后得到图片验证,客户端访问是这样的:

必读经典项目,web开发框架renren-fast项目解读_第5张图片

http://localhost:8080/renren-fast/captcha.jpg?uuid=8bca6b8c-00d1-4a80-88ad-dad986c07b99

uuid是哪里来的呢,我看了客户端的源码,原来是前端生成的!

  • /src/views/common/login.vue

  • /src/views/utils/index.js

// 获取验证码
getCaptcha () {
  this.dataForm.uuid = getUUID()
  this.captchaPath = this.$http.adornUrl(`/captcha.jpg?uuid=${this.dataForm.uuid}`)
}
--
/**
 * 获取uuid
 */
export function getUUID () {
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
    return (c === 'x' ? (Math.random() * 16 | 0) : ('r&0x3' | '0x8')).toString(16)
  })
}

uuid得到之后,就开始生成BufferedImage,我们看下里面的逻辑:

  • io.renren.modules.sys.service.impl.SysCaptchaServiceImpl

@Override
public BufferedImage getCaptcha(String uuid) {
    if(StringUtils.isBlank(uuid)){
        throw new RRException("uuid不能为空");
    }
    //生成文字验证码
    String code = producer.createText();
    SysCaptchaEntity captchaEntity = new SysCaptchaEntity();
    captchaEntity.setUuid(uuid);
    captchaEntity.setCode(code);
    //5分钟后过期
    captchaEntity.setExpireTime(DateUtils.addDateMinutes(new Date(), 5));
    this.save(captchaEntity);
    return producer.createImage(code);
}

producer就是google的验证码生成器,spring注入进来的

@Autowired
private Producer producer;

producer.createText()生成了文字验证码,然后producer.createImage(code)生成图片,过程中吧code和uuid关联起来存入数据库中,所以验证验证码是否正确,就直接登录表单中提交uuid和输入的验证码作为条件查库即可,查出记录表示验证码是正确的。

  • io.renren.modules.sys.service.impl.SysCaptchaServiceImpl

@Override
public boolean validate(String uuid, String code) {
    SysCaptchaEntity captchaEntity = this.getOne(new QueryWrapper().eq("uuid", uuid));
    if(captchaEntity == null){
        return false;
    }
    //删除验证码
    this.removeById(uuid);
    if(captchaEntity.getCode().equalsIgnoreCase(code) && captchaEntity.getExpireTime().getTime() >= System.currentTimeMillis()){
        return true;
    }
    return false;
}

预防CSRF

CSRF(Cross-site request forgery):跨站请求伪造。

用户是网站A的注册用户,且登录进去,于是网站A就给用户下发cookie。

从上图可以看出,要完成一次CSRF攻击,受害者必须满足两个必要的条件:

(1)登录受信任网站A,并在本地生成Cookie。(如果用户没有登录网站A,那么网站B在诱导的时候,请求网站A的api接口时,会提示你登录)

(2)在不登出A的情况下,访问危险网站B(其实是利用了网站A的漏洞)。

我们在讲CSRF时,一定要把上面的两点说清楚。

温馨提示一下,cookie保证了用户可以处于登录状态,但网站B其实拿不到 cookie。

预防CSRF攻击

跨站请求攻击,是攻击者通过一些技术手段欺骗用户的浏览器去访问一个自己曾经认证过的网站并执行一定的操作。因为浏览器认证过,所以网站会认为是真正的用户在操作。

例子

假如一家银行转账操作的URL地址如下: 

http://www.examplebank.com/withdraw?account=AccoutName&amount=1000&for=PayeeName 

那么,一个另外的网站可以放置如下代码 

 

如果用户之前刚访问过银行网站,登陆信息尚未过期,再次访问恶意网站点击了这个图片,那么就会损失1000元。事实上,这些地址还可以放在论坛、博客等地方,这种恶意访问的形式更加隐蔽,如果服务端没有相应措施,很容易受到威胁。

防御措施
  • 检查referer字段

HTTP头中有一个Referer字段,这个字段是用来标明请求来源于哪一个网址。通常来说,Referer字段应和请求的地址是在同一个域名下的。服务器可以通过判断Referer字段来判断请求的来源。 这种方法简单易行,但也有其局限性。http协议无法保证来访的浏览器的具体实现,可以通过篡改Referer字段的方式来进行攻击

  • Token 验证

(1)服务器发送给客户端一个token;

(2)客户端提交的表单中带着这个token。

(3)如果这个 token 不合法,那么服务器拒绝这个请求。

预防XSS

XSS(Cross Site Scripting):跨域脚本攻击。

XSS攻击的核心原理是:不需要你做任何的登录认证,它会通过合法的操作(比如在url中输入、在评论框中输入),向你的页面注入脚本(可能是js、hmtl代码块等)。

最后导致的结果可能是:

  • 盗用Cookie

  • 破坏页面的正常结构,插入广告等恶意内容

  • D-doss攻击

1、百度百科的解释: XSS又叫CSS (Cross Site Script) ,跨站脚本攻击。它指的是恶意攻击者往Web页面里插入恶意html代码,当用户浏览该页之时,嵌入其中Web里面的html代码会被执行,从而达到恶意用户的特殊目的。

2、它与SQL注入攻击类似,SQL注入攻击中以SQL语句作为用户输入,从而达到查询/修改/删除数据的目的,而在xss攻击中,通过插入恶意脚本,实现对用户游览器的控制,获取用户的一些信息。

攻击方式

1、反射型

发出请求时,XSS代码出现在url中,作为输入提交到服务器端,服务器端解析后响应,XSS代码随响应内容一起传回给浏览器,最后浏览器解析执行XSS代码。这个过程像一次反射,所以叫反射型XSS。

2、存储型

存储型XSS和反射型XSS的差别在于,提交的代码会存储在服务器端(数据库、内存、文件系统等),下次请求时目标页面时不用再提交XSS代码。

项目运用

Filter过滤器

Filter过滤器是一种比较实用的东西,可以过滤不良信息,对提交来的信息进行处理。是Request和Response之间的传输纽带。

HttpServletRequestWrapper

Filter是这样一种Java对象,它能能在request到达servlet的服务方法之前拦截HttpServletRequest对象,而在服务方 法转移控制后又能拦截HttpServletResponse对象。

你可以使用filter来实现特定的任务,比如验证用户输入,以及压缩web内容。但HttpServletRequest对象的参数是不可改变的,这极大地缩减了filter的应用范围。至少在一半的时间里,你希望可以改变准备传送给 filter的对象。

幸运的是,尽管你不能改变不变对象本身,但你却可以通过使用装饰模式来改变其状态。 

所以:在Filter中修改后台Controller中获取到的HttpServletRequest中的参数,只需要在Filter中自定义一个类继承于HttpServletRequestWrapper,并复写getParameterNames、getParameter、getParameterValues等方法即可

方法:

要创建HttpServletRequest的装饰类,你需要继承HttpServletRequestWrapper并且覆盖你希望改变的方法。

必读经典项目,web开发框架renren-fast项目解读_第6张图片

  • ServletRequest  抽象组件

  • HttpServletRequest  抽象组件的一个子类,它的实例被称作"被装饰者"

  • ServletRequestWrapper  一个基本的装饰类,这里是非抽象的

  • HttpServletRequestWrapper  一个具体的装饰者,当然这里也继承了HttpServletRequest这个接口,是为了获取一些在ServletRequest中没有的方法

  • ModifyParametersWrapper  同样是 一个具体的装饰者(PS:我自定义的一个类)

防XSS注入流程:

  • 自定义包装类XssHttpServletRequestWrapper,继承HttpServletRequestWrapper,重写getInputStream(),getParameter(String name),getParameterValues(String name)等方法。

  • 自定义过滤器XssFilter,过滤所有链接,这样所有方法中request.getParameter();就能调用自定义包装类里面重写的方法,进行xss过滤。

JWT的代码实现

配置步骤

第一步、导入maven坐标


   io.jsonwebtoken
   jjwt
   0.9.0#renren-fast用的是0.7.0版本

第二步、封装一个util工具类统一头部和载荷部分的信息,应包含生成jwt和校验jwt。io.renren.modules.app.utils.JwtUtils

@ConfigurationProperties(prefix = "renren.jwt")
@Component
public class JwtUtils {
    private Logger logger = LoggerFactory.getLogger(getClass());
    private String secret;
    private long expire;
    private String header;
    /**
     * 生成jwt token
     */
    public String generateToken(long userId) {
        Date nowDate = new Date();
        //过期时间
        Date expireDate = new Date(nowDate.getTime() + expire * 1000);
        return Jwts.builder()
                .setHeaderParam("typ", "JWT")
                .setSubject(userId+"")
                .setIssuedAt(nowDate)
                .setExpiration(expireDate)
                .signWith(SignatureAlgorithm.HS512, secret)
                .compact();
    }
    public Claims getClaimByToken(String token) {
        try {
            return Jwts.parser()
                    .setSigningKey(secret)
                    .parseClaimsJws(token)
                    .getBody();
        }catch (Exception e){
            logger.debug("validate is token error ", e);
            return null;
        }
    }
    /**
     * token是否过期
     * @return  true:过期
     */
    public boolean isTokenExpired(Date expiration) {
        return expiration.before(new Date());
    }
    ....getter、setter
}

第三步、为了区分需要拦截和不需要拦截的资源,项目添加了一个@login注解

/**
 * app登录效验
 * @author chenshun
 * @email [email protected]
 * @date 2017/9/23 14:30
 */
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Login {
}

第四步、登录成功后,生成一个jwt的 token,用于返回给前段。

/**
 * 登录
 */
@PostMapping("login")
@ApiOperation("登录")
public R login(@RequestBody LoginForm form){
    //表单校验
    ValidatorUtils.validateEntity(form);
    //用户登录
    long userId = userService.login(form);
    //生成token
    String token = jwtUtils.generateToken(userId);
    Map map = new HashMap<>();
    map.put("token", token);
    map.put("expire", jwtUtils.getExpire());
    return R.ok(map);
}

第五步、编写一个拦截器,拦截所有需要校验的资源模块的url(有加了@login注解的),访问前校验jwt是否合法。

/**
 * 权限(Token)验证
 * @author chenshun
 * @email [email protected]
 * @date 2017-03-23 15:38
 */
@Component
public class AuthorizationInterceptor extends HandlerInterceptorAdapter {
    @Autowired
    private JwtUtils jwtUtils;
    public static final String USER_KEY = "userId";
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        Login annotation;
        if(handler instanceof HandlerMethod) {
            annotation = ((HandlerMethod) handler).getMethodAnnotation(Login.class);
        }else{
            return true;
        }
        if(annotation == null){
            return true;
        }
        //获取用户凭证
        String token = request.getHeader(jwtUtils.getHeader());
        if(StringUtils.isBlank(token)){
            token = request.getParameter(jwtUtils.getHeader());
        }
        //凭证为空
        if(StringUtils.isBlank(token)){
            throw new RRException(jwtUtils.getHeader() + "不能为空", HttpStatus.UNAUTHORIZED.value());
        }
        Claims claims = jwtUtils.getClaimByToken(token);
        if(claims == null || jwtUtils.isTokenExpired(claims.getExpiration())){
            throw new RRException(jwtUtils.getHeader() + "失效,请重新登录", HttpStatus.UNAUTHORIZED.value());
        }
        //设置userId到request里,后续根据userId,获取用户信息
        request.setAttribute(USER_KEY, Long.parseLong(claims.getSubject()));
        return true;
    }
}

获取用户信息

项目用了一个一种特殊的方法来获取用户信息,一般我们再baseController中获取用户信息,但renren-fast使用了注解的形式,@loginUser

/**
 * 登录用户信息
 *
 * @author chenshun
 * @email [email protected]
 * @date 2017-03-23 20:39
 */
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface LoginUser {
}

运用是这样的,参数中添加@LoginUser UserEntity user作为参数:

@RestController
@RequestMapping("/app")
@Api("APP测试接口")
public class AppTestController {
    @Login
    @GetMapping("userInfo")
    @ApiOperation("获取用户信息")
    public R userInfo(@LoginUser UserEntity user){
        return R.ok().put("user", user);
    }
}

然后写一个全局解析注解的类:其中HandlerMethodArgumentResolver是用来为处理器解析参数的。HandlerMethodArgumentResolver的接口定义如下:

(1)supportsParameter 用于判断是否支持对某种参数的解析

(2)resolveArgument  将请求中的参数值解析为某种对象

/**
 * 有@LoginUser注解的方法参数,注入当前登录用户
 * @author chenshun
 * @email [email protected]
 * @date 2017-03-23 22:02
 */
@Component
public class LoginUserHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
    @Autowired
    private UserService userService;
    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.getParameterType().isAssignableFrom(UserEntity.class) && parameter.hasParameterAnnotation(LoginUser.class);
    }
    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container,
                                  NativeWebRequest request, WebDataBinderFactory factory) throws Exception {
        //获取用户ID
        Object object = request.getAttribute(AuthorizationInterceptor.USER_KEY, RequestAttributes.SCOPE_REQUEST);
        if(object == null){
            return null;
        }
        //获取用户信息
        UserEntity user = userService.selectById((Long)object);
        return user;
    }
}

最后在mvc配置中添加这个解析器

/**
 * MVC配置
 *
 * @author chenshun
 * @email [email protected]
 * @date 2017-04-20 22:30
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    @Autowired
    private LoginUserHandlerMethodArgumentResolver loginUserHandlerMethodArgumentResolver;
    @Override
    public void addArgumentResolvers(List argumentResolvers) {
        argumentResolvers.add(loginUserHandlerMethodArgumentResolver);
    }
}

多数据源模块

预备知识-ThreadLocal

ThreadLocal是线程局部变量,所谓的线程局部变量,就是仅仅只能被本线程访问,不能在线程之间进行共享访问的变量。

关键抽象类-AbstractRoutingDataSource

官方注释如下:

* Abstract {@link javax.sql.DataSource} implementation that routes {@link #getConnection()}
* calls to one of various target DataSources based on a lookup key. The latter is usually
* (but not necessarily) determined through some thread-bound transaction context.

大概意思是:就是getConnection()根据查找lookup key键对不同目标数据源的调用,通常是通过(但不一定)某些线程绑定的事物上下文来实现。

通过这我们知道可以实现:

  • 多数据源的动态切换,在程序运行时,把数据源数据源动态织入到程序中,灵活的进行数据源切换。

  • 基于多数据源的动态切换,我们可以实现读写分离,这么做缺点也很明显,无法动态的增加数据源。

逻辑思路

  • DynamicDataSource继承AbstractRoutingDataSource类,并实现了determineCurrentLookupKey()方法。

  • 我们配置的多个数据源会放在AbstractRoutingDataSource的 targetDataSources和defaultTargetDataSource中,然后通过afterPropertiesSet()方法将数据源分别进行复制到resolvedDataSources和resolvedDefaultDataSource中。

  • AbstractRoutingDataSource的getConnection()的方法的时候,先调用determineTargetDataSource()方法返回DataSource在进行getConnection()。

实现多数据源

步骤1,在spring boot中,增加多数据源的配置

步骤2,扩展Spring的AbstractRoutingDataSource抽象类,

AbstractRoutingDataSource中的抽象方法determineCurrentLookupKey是实现多数据

源的核心,并对该方法进行Override

步骤3,配置DataSource,指定数据源的信息

步骤4,通过注解,实现多数据源

步骤5、配置加上(exclude={DataSourceAutoConfiguration.class})

必读经典项目,web开发框架renren-fast项目解读_第7张图片

关于事务

只支持单库事务,也就是说切换数据源要在开启事务之前执行。spring DataSourceTransactionManager进行事务管理,开启事务,会将数据源缓存到DataSourceTransactionObject对象中进行后续的commit rollback等事务操作。

必读经典项目,web开发框架renren-fast项目解读_第8张图片

必读经典项目,web开发框架renren-fast项目解读_第9张图片

使用经验

出现多数据源动态切换失败的原因是因为在事务开启后,数据源就不能再进行随意切换了,也就是说,一个事务对应一个数据源。那么传统的Spring管理事务是放在Service业务层操作的,所以更换数据源的操作要放在这个操作之前进行。也就是切换数据源操作放在Controller层,可是这样操作会造成Controller层代码混乱的结果。故而想到的解决方案是将事务管理在数据持久 (Dao层) 开启,切换数据源的操作放在业务层进行操作,就可在事务开启之前顺利进行数据源切换,不会再出现切换失败了。

(完)

【推荐阅读】

感受lambda之美,推荐收藏,需要时查阅

给你一份SpringBoot知识清单

全面了解 Nginx 到底能做什么

Git使用教程:最详细、最傻瓜、最浅显、真正手把手教!

你可能感兴趣的:(必读经典项目,web开发框架renren-fast项目解读)