当前位置: 首页 > news >正文

Apache Shiro 技术详解

Apache Shiro 技术详解

目录

  • 1. Apache Shiro 简介
  • 2. 架构流程图
    • 2.1 Shiro 整体架构
    • 2.2 认证流程
    • 2.3 授权流程
    • 2.4 CAS 单点登录认证成功流程
    • 2.5 CAS 认证成功后的 Principal 生成流程
  • 3. 核心类源码解析
  • 4. 重难点分析
  • 5. 结合 Spring Boot 使用
  • 6. 项目实战案例

1. Apache Shiro 简介

1.1 什么是 Apache Shiro

Apache Shiro 是一个功能强大且易于使用的 Java 安全框架,提供了认证、授权、加密和会话管理等功能。它被设计为直观和易用,同时提供强大的安全特性。

1.2 核心特性

  • 认证(Authentication):验证用户身份
  • 授权(Authorization):控制用户访问权限
  • 会话管理(Session Management):管理用户会话
  • 加密(Cryptography):提供加密解密功能
  • Web 支持:与 Web 应用无缝集成
  • 缓存支持:提供缓存机制提高性能

1.3 优势

  • 简单易用:API 设计直观,学习成本低
  • 功能完整:涵盖安全框架的所有核心功能
  • 灵活配置:支持多种配置方式
  • 性能优秀:轻量级框架,性能表现良好
  • 社区活跃:Apache 基金会维护,社区支持良好

2. 架构流程图

2.1 Shiro 整体架构

Subject 主体
SecurityManager 安全管理器
Authenticator 认证器
Authorizer 授权器
SessionManager 会话管理器
Cryptography 加密器
Realm 域
SessionDAO 会话数据访问对象
Hash 哈希
Cipher 密码
数据源
缓存

2.2 认证流程

用户SubjectSecurityManagerAuthenticatorRealm数据源1. 提交认证信息2. 调用 login()3. 执行认证4. 获取认证信息5. 查询用户数据6. 返回用户信息7. 返回认证结果8. 认证成功/失败9. 更新 Subject 状态10. 返回认证结果用户SubjectSecurityManagerAuthenticatorRealm数据源

2.3 授权流程

用户SubjectSecurityManagerAuthorizerRealm数据源1. 访问受保护资源2. 检查权限3. 执行授权检查4. 获取权限信息5. 查询用户权限6. 返回权限数据7. 返回权限信息8. 权限检查结果9. 允许/拒绝访问10. 返回访问结果用户SubjectSecurityManagerAuthorizerRealm数据源

2.4 CAS 单点登录认证成功流程

用户应用系统CAS服务器Shiro框架Redis缓存数据库用户首次访问受保护资源1. 访问受保护资源2. 检查用户认证状态3. subject.getPrincipal() == null4. 重定向到CAS登录页面CAS认证过程5. 在CAS页面输入凭据6. 验证用户凭据7. 携带ticket回调应用系统8. 验证ticket有效性9. 返回用户信息(loginName, userId)Shiro认证成功处理10. 调用ShiroAuthService.doAuthenticationInfo()11. 存储用户信息到Session12. 创建Principal对象13. 设置到Subject的PrincipalCollection14. 缓存用户信息到Redis15. 重定向到原始请求资源后续请求处理16. 访问原始资源17. 检查认证状态18. subject.getPrincipal() != null19. 认证通过,允许访问20. 返回资源内容会话恢复机制21. 携带SHIROJSESSIONID的后续请求22. 从Session中恢复Principal23. 验证会话有效性24. 重新设置Principal到Subject25. 认证成功,允许访问用户应用系统CAS服务器Shiro框架Redis缓存数据库

2.5 CAS 认证成功后的 Principal 生成流程

CAS认证成功回调
ShiroAuthService.doAuthenticationInfo
存储用户信息到Session
创建PrincipalCollection
添加用户ID到PrincipalCollection
添加登录名到PrincipalCollection
设置Realm名称
创建Subject对象
设置Principal到Subject
存储Principal到Session
缓存用户信息到Redis
重定向到原始请求

3. 核心类源码解析

3.1 Subject 接口

public interface Subject {// 获取用户身份Object getPrincipal();// 检查是否已认证boolean isAuthenticated();// 检查是否记住我boolean isRemembered();// 执行登录void login(AuthenticationToken token) throws AuthenticationException;// 执行登出void logout();// 检查权限boolean hasRole(String roleIdentifier);boolean isPermitted(String permission);boolean isPermitted(Permission permission);// 检查权限(多个)boolean[] hasRoles(List<String> roleIdentifiers);boolean[] isPermitted(String... permissions);boolean[] isPermitted(List<Permission> permissions);
}

3.2 SecurityManager 接口

public interface SecurityManager extends Authenticator, Authorizer, SessionManager {// 登录Subject login(Subject subject, AuthenticationToken authenticationToken) throws AuthenticationException;// 登出void logout(Subject subject);// 创建 SubjectSubject createSubject(SubjectContext context);// 获取 SubjectSubject getSubject(SubjectContext context);
}

3.3 DefaultWebSecurityManager 实现

public class DefaultWebSecurityManager extends DefaultSecurityManager {// 会话管理器private SessionManager sessionManager;// 会话存储private SessionStorageEvaluator sessionStorageEvaluator;// 会话模式private SessionMode sessionMode = SessionMode.HTTP;@Overrideprotected SubjectContext createSubjectContext() {WebSubjectContext wsc = new WebSubjectContext();wsc.setSessionMode(this.sessionMode);return wsc;}@Overrideprotected SubjectContext copy(SubjectContext subjectContext) {if (subjectContext instanceof WebSubjectContext) {return new WebSubjectContext(subjectContext);}return new WebSubjectContext(subjectContext);}
}

3.4 UserFilter 核心方法

public class UserFilter extends AccessControlFilter {// 访问控制核心方法protected boolean isAccessAllowed(ServletRequest request, ServletResponse response, Object mappedValue) {if (isLoginRequest(request, response)) {return true;  // 登录请求直接允许} else {Subject subject = getSubject(request, response);// 检查用户是否已认证return subject.getPrincipal() != null;}}// 访问被拒绝时的处理protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {saveRequestAndRedirectToLogin(request, response);return false;}// 检查是否为登录请求protected boolean isLoginRequest(ServletRequest request, ServletResponse response) {return pathsMatch(getLoginUrl(), request);}
}

3.5 Realm 接口

public interface Realm {// 获取 Realm 名称String getName();// 支持认证令牌boolean supports(AuthenticationToken token);// 获取认证信息AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException;
}

3.6 AuthorizingRealm 抽象类

public abstract class AuthorizingRealm extends AuthenticatingRealm implements Authorizer {// 授权缓存private Cache<Object, AuthorizationInfo> authorizationCache;// 获取授权信息protected abstract AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals);// 获取认证信息protected abstract AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException;@Overrideprotected AuthorizationInfo getAuthorizationInfo(PrincipalCollection principals) {if (principals == null) {return null;}AuthorizationInfo info = null;// 从缓存获取if (log.isTraceEnabled()) {log.trace("Retrieving AuthorizationInfo for principals [{}]", principals);}Cache<Object, AuthorizationInfo> cache = getAvailableAuthorizationCache();if (cache != null) {Object key = getAuthorizationCacheKey(principals);info = cache.get(key);if (log.isTraceEnabled()) {if (info == null) {log.trace("No AuthorizationInfo found in cache for principals [{}]", principals);} else {log.trace("AuthorizationInfo found in cache for principals [{}]", principals);}}}if (info == null) {// 从 Realm 获取info = doGetAuthorizationInfo(principals);if (info != null && cache != null) {Object key = getAuthorizationCacheKey(principals);cache.put(key, info);}}return info;}
}

4. 重难点分析

4.1 认证与授权的区别

特性认证(Authentication)授权(Authorization)
目的验证用户身份控制用户访问权限
时机用户登录时访问资源时
输入用户名/密码用户身份+资源信息
输出认证成功/失败允许/拒绝访问
实现doGetAuthenticationInfo()doGetAuthorizationInfo()

4.2 会话管理机制

4.2.1 会话生命周期
// 创建会话
Session session = subject.getSession();
session.setAttribute("key", "value");// 会话超时设置
session.setTimeout(1800000); // 30分钟// 会话销毁
session.stop();
4.2.2 会话存储策略
// 内存存储(默认)
DefaultSessionManager sessionManager = new DefaultSessionManager();// 数据库存储
JdbcSessionDAO sessionDAO = new JdbcSessionDAO();
sessionDAO.setDataSource(dataSource);
sessionManager.setSessionDAO(sessionDAO);// Redis 存储
RedisSessionDAO redisSessionDAO = new RedisSessionDAO();
redisSessionDAO.setRedisManager(redisManager);
sessionManager.setSessionDAO(redisSessionDAO);

4.3 缓存机制

4.3.1 认证缓存
// 启用认证缓存
realm.setAuthenticationCachingEnabled(true);
realm.setAuthenticationCacheName("authenticationCache");// 缓存配置
CacheManager cacheManager = new MemoryConstrainedCacheManager();
securityManager.setCacheManager(cacheManager);
4.3.2 授权缓存
// 启用授权缓存
realm.setAuthorizationCachingEnabled(true);
realm.setAuthorizationCacheName("authorizationCache");// 缓存失效
realm.clearCachedAuthorizationInfo(principals);

4.4 密码加密

4.4.1 哈希加密
// MD5 加密
Md5Hash md5Hash = new Md5Hash("password", "salt", 2);
String hashedPassword = md5Hash.toHex();// SHA-256 加密
Sha256Hash sha256Hash = new Sha256Hash("password", "salt", 2);
String hashedPassword = sha256Hash.toHex();// 自定义哈希
SimpleHash hash = new SimpleHash("SHA-256", "password", "salt", 2);
4.4.2 对称加密
// AES 加密
AesCipherService cipherService = new AesCipherService();
cipherService.setKeySize(128);byte[] key = cipherService.generateNewKey().getEncoded();
byte[] encrypted = cipherService.encrypt("plaintext".getBytes(), key).getBytes();
byte[] decrypted = cipherService.decrypt(encrypted, key).getBytes();

4.5 多 Realm 配置

// 配置多个 Realm
ModularRealmAuthenticator authenticator = new ModularRealmAuthenticator();
List<Realm> realms = new ArrayList<>();
realms.add(databaseRealm);
realms.add(ldapRealm);
realms.add(activeDirectoryRealm);authenticator.setRealms(realms);
securityManager.setAuthenticator(authenticator);// 认证策略
FirstSuccessfulStrategy strategy = new FirstSuccessfulStrategy();
authenticator.setAuthenticationStrategy(strategy);

5. 结合 Spring Boot 使用

5.1 依赖配置

<dependencies><!-- Shiro Spring Boot Starter --><dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-spring-boot-starter</artifactId><version>1.9.1</version></dependency><!-- Shiro Web --><dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-web</artifactId><version>1.9.1</version></dependency><!-- Shiro Ehcache --><dependency><groupId>org.apache.shiro</groupId><artifactId>shiro-ehcache</artifactId><version>1.9.1</version></dependency>
</dependencies>

5.2 配置文件

5.2.1 application.yml
shiro:web:enabled: trueurls:/login: anon/logout: logout/static/**: anon/**: authcsession-manager:session-id-cookie-enabled: truesession-id-url-rewriting-enabled: falsecache-manager:cache-manager: ehCacheManager
5.2.2 shiroFilter.properties
# 登录页面
/login=anon
# 登出
/logout=logout
# 静态资源
/static/**=anon
/**.js=anon
/**.css=anon
/**.png=anon
# API 文档
/swagger-ui/**=anon
/v2/api-docs=anon
# 所有其他路径需要认证
/**=authc

5.3 配置类

5.3.1 Shiro 配置类
@Configuration
public class ShiroConfig {@Beanpublic ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) {ShiroFilterFactoryBean shiroFilterFactoryBean = new ShiroFilterFactoryBean();shiroFilterFactoryBean.setSecurityManager(securityManager);// 设置登录页面shiroFilterFactoryBean.setLoginUrl("/login");// 设置登录成功页面shiroFilterFactoryBean.setSuccessUrl("/index");// 设置未授权页面shiroFilterFactoryBean.setUnauthorizedUrl("/403");// 配置过滤器链Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>();filterChainDefinitionMap.put("/login", "anon");filterChainDefinitionMap.put("/logout", "logout");filterChainDefinitionMap.put("/static/**", "anon");filterChainDefinitionMap.put("/swagger-ui/**", "anon");filterChainDefinitionMap.put("/**", "authc");shiroFilterFactoryBean.setFilterChainDefinitionMap(filterChainDefinitionMap);return shiroFilterFactoryBean;}@Beanpublic SecurityManager securityManager(Realm realm) {DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();securityManager.setRealm(realm);securityManager.setSessionManager(sessionManager());securityManager.setCacheManager(cacheManager());return securityManager;}@Beanpublic Realm realm() {CustomRealm realm = new CustomRealm();realm.setCredentialsMatcher(credentialsMatcher());realm.setCachingEnabled(true);realm.setAuthenticationCachingEnabled(true);realm.setAuthorizationCachingEnabled(true);return realm;}@Beanpublic CredentialsMatcher credentialsMatcher() {HashedCredentialsMatcher matcher = new HashedCredentialsMatcher();matcher.setHashAlgorithmName("SHA-256");matcher.setHashIterations(2);matcher.setStoredCredentialsHexEncoded(true);return matcher;}@Beanpublic SessionManager sessionManager() {DefaultWebSessionManager sessionManager = new DefaultWebSessionManager();sessionManager.setGlobalSessionTimeout(1800000); // 30分钟sessionManager.setDeleteInvalidSessions(true);sessionManager.setSessionValidationSchedulerEnabled(true);return sessionManager;}@Beanpublic CacheManager cacheManager() {return new MemoryConstrainedCacheManager();}
}
5.3.2 自定义 Realm
@Component
public class CustomRealm extends AuthorizingRealm {@Autowiredprivate UserService userService;@Overrideprotected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException {UsernamePasswordToken usernamePasswordToken = (UsernamePasswordToken) token;String username = usernamePasswordToken.getUsername();// 查询用户信息User user = userService.findByUsername(username);if (user == null) {throw new UnknownAccountException("用户不存在");}if (!user.isEnabled()) {throw new DisabledAccountException("用户已被禁用");}// 返回认证信息return new SimpleAuthenticationInfo(user.getUsername(),user.getPassword(),ByteSource.Util.bytes(user.getSalt()),getName());}@Overrideprotected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {String username = (String) principals.getPrimaryPrincipal();User user = userService.findByUsername(username);SimpleAuthorizationInfo authorizationInfo = new SimpleAuthorizationInfo();// 设置角色Set<String> roles = userService.getRolesByUsername(username);authorizationInfo.setRoles(roles);// 设置权限Set<String> permissions = userService.getPermissionsByUsername(username);authorizationInfo.setStringPermissions(permissions);return authorizationInfo;}
}

5.4 控制器示例

@RestController
@RequestMapping("/api")
public class UserController {@GetMapping("/user/info")public Result<UserInfo> getUserInfo() {Subject subject = SecurityUtils.getSubject();String username = (String) subject.getPrincipal();UserInfo userInfo = userService.getUserInfo(username);return Result.success(userInfo);}@PostMapping("/user/change-password")@RequiresAuthenticationpublic Result<String> changePassword(@RequestBody ChangePasswordRequest request) {Subject subject = SecurityUtils.getSubject();String username = (String) subject.getPrincipal();userService.changePassword(username, request.getOldPassword(), request.getNewPassword());return Result.success("密码修改成功");}@GetMapping("/admin/users")@RequiresRoles("admin")public Result<List<User>> getUsers() {List<User> users = userService.getAllUsers();return Result.success(users);}@PostMapping("/admin/user/{id}/enable")@RequiresPermissions("user:enable")public Result<String> enableUser(@PathVariable Long id) {userService.enableUser(id);return Result.success("用户启用成功");}
}

5.5 异常处理

@ControllerAdvice
public class ShiroExceptionHandler {@ExceptionHandler(UnauthenticatedException.class)public String handleUnauthenticatedException() {return "redirect:/login";}@ExceptionHandler(UnauthorizedException.class)public String handleUnauthorizedException() {return "redirect:/403";}@ExceptionHandler(ExpiredCredentialsException.class)public String handleExpiredCredentialsException() {return "redirect:/login?error=expired";}@ExceptionHandler(IncorrectCredentialsException.class)public String handleIncorrectCredentialsException() {return "redirect:/login?error=incorrect";}
}

6. 项目实战案例

6.1 项目结构

data-backend/
├── data-common/          # 公共模块
├── data-core/            # 核心模块
│   ├── config/
│   │   ├── ShiroConfig.java             # Shiro 配置
│   │   └── GlobalExceptionHandler.java  # 全局异常处理
│   ├── ecs/
│   │   └── MyEcsUserFilter.java         # 自定义用户过滤器
│   └── shiro/
│       ├── realm/
│       │   ├── ShiroAuthServiceImpl.java
│       │   └── ShiroRoleServiceImpl.java
│       └── filter/
└── data-web/             # Web 模块├── controller/├── service/└── resources/└── shiroFilter.properties       # 过滤器配置

6.2 核心配置实现

6.2.1 ShiroConfig.java
@Configuration
@EnableAutoConfiguration
public class ShiroConfig {@Beanpublic ShiroFilterFactoryBean shirFilter(DefaultWebSecurityManager securityManager) {ShiroFilterFactory shiroFilter = new ShiroFilterFactory();ShiroFilterFactoryBean shiroFilterFactoryBean = shiroFilter.shiroFilterFactoryBean(securityManager, shiroAuthService(), shiroRoleService());// 添加自定义过滤器Map<String, Filter> shiroFilterMap = shiroFilterFactoryBean.getFilters();shiroFilterMap.put("anyRoles", anyRolesAuthorizationFilter());shiroFilterMap.put("anyPerms", anyPermissionsAuthorizationFilter());shiroFilterMap.put("deny", new DenyAccessFilter());// 自定义用户过滤器Map<String, Filter> ecsFilterMap = shiroFilterFactoryBean.getFilters();MyEcsUserFilter myEcsUserFilter = new MyEcsUserFilter();ecsFilterMap.put("user", myEcsUserFilter);shiroFilterFactoryBean.setFilters(ecsFilterMap);return shiroFilterFactoryBean;}@Beanpublic ShiroAuthService shiroAuthService() {return new ShiroAuthServiceImpl();}@Beanpublic ShiroRoleService shiroRoleService() {return new ShiroRoleServiceImpl();}// 自定义角色过滤器public AnyRolesAuthorizationFilter anyRolesAuthorizationFilter() {return new AnyRolesAuthorizationFilter();}// 自定义权限过滤器public AnyPermissionsAuthorizationFilter anyPermissionsAuthorizationFilter() {return new AnyPermissionsAuthorizationFilter();}// 禁止访问过滤器public static class DenyAccessFilter implements Filter {@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException {HttpServletResponse httpResponse = (HttpServletResponse) response;httpResponse.setStatus(HttpServletResponse.SC_FORBIDDEN);httpResponse.setContentType("text/plain;charset=UTF-8");httpResponse.getWriter().write("Access Denied - 访问被拒绝");}}
}
6.2.2 MyEcsUserFilter.java
public class MyEcsUserFilter extends EcsUserFilter {@Overrideprotected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception {// 保存重定向 URLString initUrl = request.getParameter(Constants.LOGIN_SUCCESS_REDIRECT_PARAM);if (!StringUtils.isEmpty(initUrl)) {SecurityUtils.getSubject().getSession().setAttribute(Constants.LOGIN_SUCCESS_REDIRECT_PARAM, initUrl);}// 重定向到登录页面this.saveRequestAndRedirectToLogin(request, response);return false;}
}

6.3 权限控制实现

6.3.1 控制器权限控制
@RestController
@RequestMapping("/api/demo")
public class DemoController {@PostMapping("/V1")public Result<Res> V1(@RequestBody DTO dto) {// 获取当前用户信息UserInfoVO user = userService.getUserInfo();Assert.notNull(user, "用户未登录");// 角色权限检查if (!user.ifAreaManager()) {return Result.error("当前角色非区管,无权限");}// 执行业务逻辑Res result = AService.getSchoolCompliancePage(dto, user);return Result.success(result);}
}

6.4 配置文件

6.4.1 shiroFilter.properties
# Swagger 相关路径 - 禁止访问
/swagger-ui.html=deny
/swagger-ui/**=deny
/v2/api-docs=deny
/swagger-resources/**=deny
/webjars/**=deny# 包含 context-path 的 Swagger 路径
/data/swagger-ui.html=deny
/data/swagger-ui/**=deny
/data/v2/api-docs=deny
/data/swagger-resources/**=deny
/data-quality-monitor/webjars/**=deny# 监控页面 - 禁止访问
/druid/**=deny
/data/druid/**=deny
/actuator/**=deny
/data/actuator/**=deny
/management/**=deny
/data/management/**=deny# 所有其他路径需要用户认证
/**=user

6.5 最佳实践总结

6.5.1 安全配置最佳实践
  1. 最小权限原则:只授予必要的权限
  2. 分层权限控制:URL 级别 + 方法级别 + 数据级别
  3. 敏感路径保护:禁止访问监控和文档页面
  4. 会话安全:设置合理的会话超时时间
  5. 密码安全:使用强加密算法和盐值
6.5.2 性能优化建议
  1. 启用缓存:认证和授权信息缓存
  2. 会话存储:使用 Redis 等外部存储
  3. 连接池:数据库连接池优化
  4. 异步处理:耗时操作异步化
6.5.3 监控和日志
  1. 访问日志:记录用户访问行为
  2. 异常监控:监控认证和授权异常
  3. 性能监控:监控 Shiro 组件性能
  4. 安全审计:记录敏感操作日志

总结

Apache Shiro 是一个功能强大、易于使用的 Java 安全框架。通过本文的详细介绍,您应该能够:

  1. 理解 Shiro 的核心概念和架构
  2. 掌握核心类的源码实现
  3. 学会在 Spring Boot 中集成 Shiro
  4. 了解实际项目中的最佳实践

在实际项目中,建议根据具体需求选择合适的配置策略,并注重安全性和性能的平衡。同时,要定期更新 Shiro 版本,关注安全漏洞和性能优化。

http://www.dtcms.com/a/419787.html

相关文章:

  • 公众号授权网站莒县住房和城乡规划建设局网站
  • Day73 基本情报技术者 单词表08 操作系统进阶
  • [xboard]15 uboot加载内核启动分析
  • 从微分方程到FIR
  • 免费建立自己的网站代码一元夺宝网站怎么做
  • 网站备案前置审批表格做网站都注意哪些东西
  • 打开无忧管理后台网站装饰设计有限公司
  • Nginx 访问控制、用户认证、HTTPS配置实操手册
  • github repository 一个文件忘记添加到 .gitignore
  • 【STM32项目开源】基于STM32的智能语音分类垃圾桶
  • wordpress建站详细教程网页打不开视频怎么办
  • 【开题答辩全过程】以 基于Java的物流管理系统为例,包含答辩的问题和答案
  • BCEWithLogitsLoss
  • 在线设计网站大全网站建设方案推销
  • CUDA框架
  • 辽阳专业建设网站公司wordpress rss 爬取
  • TypeScript 简介与项目中配置
  • 南宁seo建站seo网站优化排名
  • 【每日一问】老化测试有什么作用?
  • 广州信科做网站dede 门户网站
  • 【JDBC】系列文章第一章,怎么在idea中连接数据库,并操作插入数据?
  • 企业的网站建设朔州网站建设收费
  • 外贸上哪个网站开发客户网站建设费可分摊几年
  • 8. mutable 的用法
  • 做网站 php j2ee做网站投注员挣钱吗
  • 试玩平台网站开发录入客户信息的软件
  • 网站建设谈单情景对话wordpress外网访问错误
  • 怎么学网站开发海阳网站制作
  • 肥东建设局网站家具设计师常去的网站
  • 查网站开通时间网站设计 职业