前后端分离 springboot+springSecurity+jpa权限控制

对于权限的设计可以分为菜单权限和数据权限两个部分
菜单权限


数据库表结构

关于这个表,我说如下几点:

1.hr表是用户表,存放了用户的基本信息。

2.role是角色表,name字段表示角色的英文名称,按照SpringSecurity的规范,将以ROLE_开始,nameZh字段表示角色的中文名称。

3.menu表是一个资源表,该表涉及到的字段有点多,由于我的前端采用了Vue来做,因此当用户登录成功之后,系统将根据用户的角色动态加载需要的模块,所有模块的信息将保存在menu表中,menu表中的path、component、iconCls、keepAlive、requireAuth等字段都是Vue-Router中需要的字段,也就是说menu中的数据到时候会以json的形式返回给前端,再由vue动态更新router,menu中还有一个字段url,表示一个url pattern,即路径匹配规则,假设有一个路径匹配规则为/admin/,那么当用户在客户端发起一个/admin/user的请求,将被/admin/拦截到,系统再去查看这个规则对应的角色是哪些,然后再去查看该用户是否具备相应的角色,进而判断该请求是否合法。

实体类
实体类采用多对多的关联关系


@Table(name = "user")
@Entity
public class User implements Serializable{
    
    private static final long serialVersionUID = 1L;
    /**
     * ID
     */
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "id")
    private Integer id;
   //用户角色
    @ManyToMany(cascade=CascadeType.REFRESH)
    @JoinTable(name="sys_role_user",
      inverseJoinColumns=@JoinColumn(name="role_id"),
      joinColumns=@JoinColumn(name="id"))
    private List<SysRole> sysRoles;
/**
*实体类角色 
*   @AUTO
*/
@Entity(name = "sys_role")
public class SysRole implements Serializable {

    private static final long serialVersionUID = 4395377670162987328L;
    
    // 角色ID
    @Id
    @Column(name="role_id",updatable=false)
    private String roleId;

    // 上级ID
    @Column(name="parent_id")
    private String parentId;

    // 角色名称
    @Column(name="role_name")
    private String roleName;

    // 角色描述
    @Column(name="role_desc")
    private String roleDesc;

    // 权限标识
    @Column(name="permission")
    private String permission;

//角色相关用户
    @ManyToMany(cascade=CascadeType.REFRESH,mappedBy="sysRoles",fetch = FetchType.LAZY)
      private List<User> users;

    //角色相关权限
    @ManyToMany(cascade=CascadeType.REFRESH)
    @JoinTable(name="sys_role_module",
      inverseJoinColumns=@JoinColumn(name="module_id"),
      joinColumns=@JoinColumn(name="role_id"))
    private List<SysModule> sysModules;
/**
*实体类模块 
*   @AUTO
*/
@Entity(name = "sys_module")
public class SysModule implements Serializable {

    private static final long serialVersionUID = 2416163897089599596L;
    
    // 模块ID
    @Id
    @Column(name="module_id",updatable=false)
    private String moduleId;

    // 模块名称
    @Column(name="module_name")
    private String moduleName;

    // 链接
    @Column(name="url")
    private String url;

    // 父节点ID
    @Column(name="parent_id")
    private String parentId;

    // 状态
    @Column(name="status")
    private Integer status;

    // 类型
    @Column(name="type")
    private Integer type;   
    
  //菜单相关角色
    @ManyToMany(cascade=CascadeType.REFRESH,mappedBy="sysModules",fetch = FetchType.LAZY)
      private List<SysRole> roles;

security相关接口实现
UserDetails接口默认有几个方法需要实现,这几个方法中,除了isEnabled返回了正常的enabled之外,其他的方法我都统一返回true,因为我这里的业务逻辑并不涉及到账户的锁定、密码的过期等等,只有账户是否被禁用,因此只处理了isEnabled方法,这一块小伙伴可以根据自己的实际情况来调整。另外,UserDetails中还有一个方法叫做getAuthorities,该方法用来获取当前用户所具有的角色,但是小伙伴也看到了,我的Hr中有一个roles属性用来描述当前用户的角色,因此我的getAuthorities方法的实现如下:

/**
 * UserRole定义类
 * 
 * 
 *
 */
@Component
public class UserRole implements Serializable {
    private static final long serialVersionUID = -6908168167010323563L; 
    
    @Autowired
    private UserService sysUserService;
    
    /**
     * 登录用户
     */
    String loginUserId;
    /**
     * 用户密码
     */
    String password;
    /**
     * 用户角色
     */
    List<String> role;
    
    public UserRole(){
        
    }
    
    public void setUser(String userName){
        sysUserService.getUserRole(this, userName);
        
    }
    
    
    public String getLoginUserId() {
        return loginUserId;
    }
    public void setLoginUserId(String loginUserId) {
        this.loginUserId = loginUserId;
    }
    public String getPassword() {
        return password;
    }
    public void setPassword(String password) {
        this.password = password;
    }

    public List<String> getRole() {
        return role;
    }
    
    public String[] getRoleStringArray(){
        String [] rslt = new String[role.size()];
        new ArrayList<String>(role).toArray(rslt);
        return rslt;
    }

    public void setRole(List<String> role) {
        this.role = role;
    }

}

这里最主要是实现了UserDetailsService接口中的loadUserByUsername方法,在执行登录的过程中,这个方法将根据用户名去查找用户,如果用户不存在,则抛出UsernameNotFoundException异常,否则直接将查到的Hr返回。HrMapper用来执行数据库的查询操作,这个不在本系列的介绍范围内,所有涉及到数据库的操作都将只介绍方法的作用。


/**
 * UserDetailService实现类
 * 
 * 
 *
 */
@Component
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserRole user;
    
    @Override
    public UserDetails loadUserByUsername(String userName) throws UsernameNotFoundException {
        user.setUser(userName);
        return new LoginUser(user);
    }

}

/**
 * User实体类
 * 加密等方法实现以外不能修正
 * 
 * 
 *
 */
class LoginUser
extends org.springframework.security.core.userdetails.User {

    private static final long serialVersionUID = -5628332201956992681L;

    private final UserRole userRole;

    public LoginUser(UserRole user) {       
        
        super(user.getLoginUserId(), user.getPassword(),
                AuthorityUtils.createAuthorityList(user.getRoleStringArray()));
        userRole = user;
    }

    public UserRole getUser() {
        return userRole;
    }
}

自定义FilterInvocationSecurityMetadataSource
FilterInvocationSecurityMetadataSource有一个默认的实现类DefaultFilterInvocationSecurityMetadataSource,该类的主要功能就是通过当前的请求地址,获取该地址需要的用户角色,我们照猫画虎,自己也定义一个FilterInvocationSecurityMetadataSource,如下:

@Component
public class UrlFilterInvocationSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
     @Autowired
        SysModuleService menuService;
        AntPathMatcher antPathMatcher = new AntPathMatcher();

        @Override
        public Collection<ConfigAttribute> getAttributes(Object o) throws IllegalArgumentException {
            //获取请求地址
            String requestUrl = ((FilterInvocation) o).getRequestUrl();
            if ("/login".equals(requestUrl)) {
                return SecurityConfig.createList("ROLE_LOGIN");
            }else{
                String[] values = new String[1];
                values[0] = requestUrl;
                return SecurityConfig.createList(values);
            }

        }

        @Override
        public Collection<ConfigAttribute> getAllConfigAttributes() {
            return null;
        }

        @Override
        public boolean supports(Class<?> aClass) {
            return FilterInvocation.class.isAssignableFrom(aClass);
        }
}

关于自定义这个类,我说如下几点:

1.一开始注入了MenuService,MenuService的作用是用来查询数据库中url pattern和role的对应关系,查询结果是一个List集合,集合中是Menu类,Menu类有两个核心属性,一个是url pattern,即匹配规则(比如/admin/**),还有一个是List,即这种规则的路径需要哪些角色才能访问。

2.我们可以从getAttributes(Object o)方法的参数o中提取出当前的请求url,然后将这个请求url和数据库中查询出来的所有url pattern一一对照,看符合哪一个url pattern,然后就获取到该url pattern所对应的角色,当然这个角色可能有多个,所以遍历角色,最后利用SecurityConfig.createList方法来创建一个角色集合。

3.第二步的操作中,涉及到一个优先级问题,比如我的地址是/employee/basic/hello,这个地址既能被/employee/匹配,也能被/employee/basic/匹配,这就要求我们从数据库查询的时候对数据进行排序,将/employee/basic/**类型的url pattern放在集合的前面去比较。

4.如果getAttributes(Object o)方法返回null的话,意味着当前这个请求不需要任何角色就能访问,甚至不需要登录。但是在我的整个业务中,并不存在这样的请求,我这里的要求是,所有未匹配到的路径,都是认证(登录)后可访问,因此我在这里返回一个ROLE_LOGIN的角色,这种角色在我的角色数据库中并不存在,因此我将在下一步的角色比对过程中特殊处理这种角色。

5.如果地址是/login_p,这个是登录页,不需要任何角色即可访问,直接返回null。

6.getAttributes(Object o)方法返回的集合最终会来到AccessDecisionManager类中,接下来我们再来看AccessDecisionManager类。

自定义AccessDecisionManager
自定义UrlAccessDecisionManager类实现AccessDecisionManager接口,如下:

@Component
public class UrlAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object o, Collection<ConfigAttribute> collection) throws AccessDeniedException, AuthenticationException {
        Iterator<ConfigAttribute> iterator = collection.iterator();
        while (iterator.hasNext()) {
            ConfigAttribute ca = iterator.next();
            //当前请求需要的权限
            String needRole = ca.getAttribute();
            if ("ROLE_LOGIN".equals(needRole)) {
                return;
            }else{
                if (authentication instanceof AnonymousAuthenticationToken) {
                  throw new BadCredentialsException("未登录");
              } else{                
                //当前用户所具有的权限
                Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
                for (GrantedAuthority authority : authorities) {
                    if (authority.getAuthority().equals(needRole)) {
                        return;
                    }
                }
              }
            }
            
        }
        throw new AccessDeniedException("权限不足!");
    }

    @Override
    public boolean supports(ConfigAttribute configAttribute) {
        return true;
    }

    @Override
    public boolean supports(Class<?> aClass) {
        return true;
    }
}

关于这个类,我说如下几点:

1.decide方法接收三个参数,其中第一个参数中保存了当前登录用户的角色信息,第三个参数则是UrlFilterInvocationSecurityMetadataSource中的getAttributes方法传来的,表示当前请求需要的角色(可能有多个)。

2.如果当前请求需要的权限为ROLE_LOGIN则表示登录即可访问,和角色没有关系,此时我需要判断authentication是不是AnonymousAuthenticationToken的一个实例,如果是,则表示当前用户没有登录,没有登录就抛一个BadCredentialsException异常,登录了就直接返回,则这个请求将被成功执行。

3.遍历collection,同时查看当前用户的角色列表中是否具备需要的权限,如果具备就直接返回,否则就抛异常。

4.这里涉及到一个all和any的问题:假设当前用户具备角色A、角色B,当前请求需要角色B、角色C,那么是要当前用户要包含所有请求角色才算授权成功还是只要包含一个就算授权成功?我这里采用了第二种方案,即只要包含一个即可。小伙伴可根据自己的实际情况调整decide方法中的逻辑。

自定义AccessDeniedHandler
通过自定义AccessDeniedHandler我们可以自定义403响应的内容,如下:
用户登录失败返回信息

@Component
public class UserAuthenticationFailureHandler implements AuthenticationFailureHandler {

     @Override
     public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
         httpServletResponse.setContentType("application/json;charset=utf-8");
         PrintWriter out = httpServletResponse.getWriter();
         StringBuffer sb = new StringBuffer();
         sb.append("{\"status\":\"error\",\"msg\":\"");
         if (e instanceof UsernameNotFoundException || e instanceof BadCredentialsException) {
             sb.append("用户名或密码输入错误,登录失败!");
         } else if (e instanceof DisabledException) {
             sb.append("账户被禁用,登录失败,请联系管理员!");
         } else {
             sb.append("登录失败!");
         }
         sb.append("\"}");
         out.write(sb.toString());
         out.flush();
         out.close();
     }

异常拦截

@Component
public class UnauthorizedEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        if(isAjaxRequest(request)){
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED,authException.getMessage());
        }else{
            response.setStatus(HttpServletResponse.SC_FORBIDDEN);
            response.setContentType("application/json;charset=UTF-8");
             PrintWriter out = response.getWriter();
             out.write("{\"status\":\"error\",\"msg\":\""+authException.getMessage()+"\"}");
             out.flush();
             out.close();
        }

    }

    public static boolean isAjaxRequest(HttpServletRequest request) {
        String ajaxFlag = request.getHeader("X-Requested-With");
        return ajaxFlag != null && "XMLHttpRequest".equals(ajaxFlag);
    }

}

登陆成功拦截器

@Component
public class LoginSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler {
//    @Autowired
//    SysLogService sysLogService;
    @Autowired
    UserService sysUserS;
    @Autowired
    SysGovService sysGovS;

    private static final String UNKNOWN = "unknown";

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
            Authentication authentication) throws IOException, ServletException {
        
        // 获得授权后可得到用户信息 可使用SUserService进行数据库操作
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
//        SysLog sysLog = new SysLog(); 
//        sysLog.setLogModule("1");
//        sysLog.setActionType("1");
//        sysLog.setLogAction("登录");
//        sysLogService.saveLog(sysLog,request);

        //获取用户信息
        User sysUser = sysUserS.findByLoginName(userDetails.getUsername());
//        List<SysRole> rolos = sysUser.getSysRoles();
//        List<String> rololist = new ArrayList<>();
//        for(SysRole sysRole:rolos){
//          rololist.add(sysRole.getPermission());
//        }
        //页面权限控制
//        request.getSession().setAttribute("auditType", sysUserS.getAllRole(sysUser.getUserId()));
        String orgId = sysUser.getDeptId();
        request.getSession().setAttribute("userId", sysUser.getId());
        request.getSession().setAttribute("userName", sysUser.getUserName());
        //该用户所属单位ID
        List<String> sysgovIds = new ArrayList<>();
        if(orgId!=null&&orgId!=""){
            request.getSession().setAttribute("userGovId", orgId);
            SysGov sysgov = sysGovS.findBySysGovId(orgId);
             //获取所有下属机构信息
            List<SysGov> sysgovs = sysGovS.recursionFindByParentIdAndStatus(new ArrayList<SysGov>(), orgId);
            for (SysGov sysGov2 : sysgovs) {
                sysgovIds.add(sysGov2.getGovId());
            }
            request.getSession().setAttribute("sysgovIds", sysgovIds);
            request.getSession().setAttribute("userGovCname", sysgov.getGovCnameall());
            request.getSession().setAttribute("userGovCname2", sysgov.getGovCname());
            
        }else{
            request.getSession().setAttribute("userGovId", "jusfoun");
            request.getSession().setAttribute("sysgovIds", "jusfoun");
            request.getSession().setAttribute("userGovCname", "jusfoun");
        }       
        response.setContentType("application/json;charset=utf-8");
        response.setHeader("Access-Control-Allow-Origin", "*");
        PrintWriter out = response.getWriter();
        ObjectMapper objectMapper = new ObjectMapper();
        @SuppressWarnings("unchecked")
        Map<String,String> map=objectMapper.readValue(objectMapper.writeValueAsString(SecurityContextHolder.getContext().getAuthentication().getPrincipal()),Map.class);
        map.remove("user");
        map.remove("password");
        String s = "{\"status\":\"success\",\"msg\":" + objectMapper.writeValueAsString(map) + "}";
        out.write(s);
        out.flush();
        out.close();
        super.onAuthenticationSuccess(request, response, authentication);
    }

配置WebSecurityConfig
最后在webSecurityConfig中完成简单的配置即可,如下:

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    private static final String ADMIN_AUTH = "ROLE_ADMIN";
    
    @Autowired
    UrlFilterInvocationSecurityMetadataSource urlFilterInvocationSecurityMetadataSource;
    @Autowired
    UrlAccessDecisionManager urlAccessDecisionManager;
    @Autowired
    UnauthorizedEntryPoint unauthorizedEntryPoint;
    @Autowired 
    UserAuthenticationFailureHandler userAuthenticationFailureHandler;
    

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth
            .inMemoryAuthentication()
                .withUser("user").password("password").roles("USER");
    }


    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers(
                            "/images/**",
                            "/image/**",
                            "/**/*.js",
                            "/**/*.css",
                            "/resource/img/**",
                            "/styleResource/img/**","/**");
    }
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .exceptionHandling().authenticationEntryPoint(unauthorizedEntryPoint).and()
            .csrf().disable()
            .authorizeRequests().withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                @Override
                public <O extends FilterSecurityInterceptor> O postProcess(O o) {
                    o.setSecurityMetadataSource(urlFilterInvocationSecurityMetadataSource);
                    o.setAccessDecisionManager(urlAccessDecisionManager);
                    return o;
                }
            })
                .antMatchers("/","/js/*", "/img/*").permitAll()
                .anyRequest().authenticated()
                .and()
            .formLogin()
//              .loginPage("/login").usernameParameter("username").passwordParameter("password")
//              .permitAll()
                .failureHandler(userAuthenticationFailureHandler)
                .successHandler(loginSuccessHandler())
                .and()
                .rememberMe()
                    .tokenValiditySeconds(3600)
                    .key("mykey");
    }
    

    @Configuration
    protected static class AuthenticationConfiguration
    extends GlobalAuthenticationConfigurerAdapter {
        @Autowired
        UserDetailsService userDetailsService;

        @Override
        public void init(AuthenticationManagerBuilder auth) throws Exception {
            auth.userDetailsService(userDetailsService);
        }
        
        
    }
    
    @Bean
    public LoginSuccessHandler loginSuccessHandler(){
        LoginSuccessHandler lsh = new LoginSuccessHandler();
        lsh.setDefaultTargetUrl("/index");
        return lsh;
    }

关于这个配置,我说如下几点:

1.在configure(HttpSecurity http)方法中,通过withObjectPostProcessor将刚刚创建的UrlFilterInvocationSecurityMetadataSource和UrlAccessDecisionManager注入进来。到时候,请求都会经过刚才的过滤器(除了configure(WebSecurity web)方法忽略的请求)。

2.successHandler中配置登录成功时返回的JSON,登录成功时返回当前用户的信息。

3.failureHandler表示登录失败,登录失败的原因可能有多种,我们根据不同的异常输出不同的错误提示即可。

OK,这些操作都完成之后,我们可以通过POSTMAN或者RESTClient来发起一个登录请求,看到如下结果则表示登录成功:

关于数据权限的设计
在每个表中加入所属部门的id
在执行查询时加入当前登陆用户的组织机构作为参数

        @SuppressWarnings("unchecked")
        List<String> sysgovIds = (List<String>) request.getSession().getAttribute("sysgovIds");
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,598评论 18 139
  • 22年12月更新:个人网站关停,如果仍旧对旧教程有兴趣参考 Github 的markdown内容[https://...
    tangyefei阅读 35,159评论 22 257
  • 文章大纲:1.spring security 基本配置介绍2.自定义角色和权限配置3.跟着源码走一遍页面请求流程 ...
    柠檬乌冬面阅读 17,798评论 1 17
  • 风残花落,梦境凋零。 曾扬起的帆,激起层层叠叠的浪花。 在春风中湮灭,在仲夏夜泛滥。 突然念起你的温柔,光阴也柔和...
    夜微语z阅读 255评论 0 0
  • 是否,有这样一些人,平时不怎么联系,有需要时,一通电话便陪伴身边。很幸运有这样的朋友。
    愉英阅读 262评论 2 0