新闻详情

新闻详情

首页 / 资讯中心 / 详情

SpringMVC拦截器实战:从登录校验到接口限流的完整指南

发布时间:2026/8/13 14:00:02
SpringMVC拦截器实战:从登录校验到接口限流的完整指南
1. 项目概述SpringMVC拦截器的核心价值在Web应用开发中我们经常遇到这样的场景用户登录后才能访问个人中心、管理员才能操作后台数据、或者对所有请求进行统一的日志记录和耗时统计。如果把这些逻辑分散在每个控制器方法里代码会变得臃肿不堪维护起来更是噩梦。SpringMVC拦截器Interceptor就是为了解决这类“横切关注点”而生的利器。它允许你在请求到达控制器之前、控制器处理之后、以及视图渲染之后这三个关键节点插入自定义的处理逻辑实现一种声明式的、非侵入式的功能增强。简单来说拦截器就像一道关卡或者一个尽职的“门卫”。当一个HTTP请求进入SpringMVC的领地时这个门卫会先检查你的“证件”比如Session里有没有登录信息决定是放行还是直接劝返。等请求在控制器里办完事准备返回时门卫可能还会进行一些“善后工作”比如记录一下这次访问。SpringMVC拦截器的设计完美契合了AOP面向切面编程的思想将那些与核心业务无关但又必须存在的公共逻辑剥离出来让我们的控制器代码保持清爽和纯粹。对于开发者而言掌握拦截器意味着你掌握了构建健壮、安全、可维护Web应用的又一把钥匙。无论是做权限校验、日志审计、性能监控还是处理全局异常、统一响应格式拦截器都是不可或缺的组件。接下来我们就从设计思路到实战细节彻底拆解SpringMVC拦截器。2. 拦截器整体设计与核心思路拆解2.1 拦截器与过滤器的本质区别很多刚接触的同学容易把拦截器和Servlet Filter过滤器搞混它们确实有相似之处但定位和粒度完全不同。理解这个区别是正确选用它们的前提。过滤器Filter是Servlet规范的一部分它的工作时机非常靠前。当一个请求到达Web容器如Tomcat时会先进入过滤器链。过滤器可以对请求和响应进行最原始的加工比如修改字符编码、压缩响应内容、实现CORS跨域支持等。过滤器的能力很强大但它对Spring的上下文ApplicationContext一无所知无法直接使用Spring管理的Bean如Service、Component。拦截器Interceptor是SpringMVC框架的一部分它的工作时机在DispatcherServlet接收到请求之后。此时Spring的IoC容器已经初始化完毕请求的映射关系也已确定即知道该由哪个Controller的哪个方法来处理。因此拦截器可以无缝地使用Spring容器中的任何Bean这是它最大的优势。它的拦截目标也更明确是针对Handler即控制器方法的。一个形象的比喻是过滤器是“城门守卫”在请求刚进入你的王国Web应用时就进行检查而拦截器是“宫殿侍卫”在请求已经进入宫殿DispatcherServlet准备面见国王Controller时再进行更细致的盘查和记录。2.2 拦截器的三大生命周期方法SpringMVC拦截器的核心是一个实现了HandlerInterceptor接口的类。这个接口定义了三个方法对应了请求处理流程的三个关键节点preHandle方法在控制器方法执行之前被调用。这是最常用、最核心的方法。通常在这里进行权限验证、登录检查等。其返回值是布尔类型true放行继续执行后续的拦截器和控制器方法。false中断流程后续的拦截器和控制器方法都不会执行。通常需要在此方法内通过HttpServletResponse直接返回响应如重定向到登录页。postHandle方法在控制器方法执行之后视图渲染之前被调用。此时控制器方法已经执行完毕你可以拿到控制器返回的ModelAndView对象并对其中的模型数据Model或视图View进行修改。这个方法在异步请求处理中可能不会被调用。postHandle方法在整个请求结束之后即视图渲染完毕之后被调用。这个方法主要用于资源清理工作比如记录请求完成日志、释放某些线程绑定资源等。特别注意这个方法无论控制器执行过程中是否抛出异常都会被调用类似于try-catch-finally中的finally块因此非常适合做最终的资源清理和统计。理解这三个方法的执行时机和职责是灵活运用拦截器的关键。一个典型的拦截器工作流程如下图所示此处以文字描述请求进入 -preHandle1-preHandle2- ... - 控制器执行 -postHandle2-postHandle1- 视图渲染 -afterCompletion1-afterCompletion2。2.3 拦截器的注册与配置方式定义了拦截器类之后你需要告诉SpringMVC在哪些请求路径上使用它。这通过在配置类中实现WebMvcConfigurer接口并重写addInterceptors方法来完成。配置的核心在于InterceptorRegistry对象它提供了链式调用的方法来添加和配置拦截器addInterceptor(Interceptor interceptor)注册一个拦截器实例。addPathPatterns(String... patterns)指定需要拦截的路径模式支持Ant风格如/admin/**和正则表达式。excludePathPatterns(String... patterns)指定需要排除的路径比如登录接口、静态资源等。这种配置方式非常灵活你可以为不同的拦截器指定不同的拦截路径构建出精细的拦截策略。3. 核心细节解析与实操要点3.1 如何实现一个基础的登录校验拦截器让我们从一个最经典的案例开始实现一个登录校验拦截器。假设我们的应用要求除了登录、注册和静态资源页面其他所有页面都需要用户登录后才能访问。首先创建拦截器类LoginInterceptorComponent // 交由Spring管理方便注入其他Bean public class LoginInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 判断请求是否针对Handler方法避免对静态资源等无效判断 if (!(handler instanceof HandlerMethod)) { return true; // 放行静态资源请求等 } // 2. 检查Session中是否存在用户登录标识 HttpSession session request.getSession(false); // false表示如果不存在则不创建新session if (session ! null session.getAttribute(currentUser) ! null) { // 用户已登录放行 return true; } // 3. 用户未登录根据请求类型返回相应响应 // 判断是否为Ajax请求通常通过请求头识别 String xRequestedWith request.getHeader(X-Requested-With); if (XMLHttpRequest.equalsIgnoreCase(xRequestedWith)) { // 对于Ajax请求返回JSON格式的未登录状态码 response.setContentType(application/json;charsetutf-8); response.setStatus(HttpStatus.UNAUTHORIZED.value()); // 401状态码 PrintWriter writer response.getWriter(); writer.write({\code\: 401, \msg\: \用户未登录或登录已过期\}); writer.flush(); } else { // 对于普通页面请求重定向到登录页 // 注意这里最好使用绝对路径或从配置中读取避免路径问题 String contextPath request.getContextPath(); response.sendRedirect(contextPath /login); } // 4. 中断后续执行 return false; } // postHandle和afterCompletion在本例中非必需可以不重写 }关键点解析与注意事项handler参数类型判断preHandle的handler参数可能是HandlerMethod对应控制器方法也可能是ResourceHttpRequestHandler对应静态资源。如果不加判断对静态资源的请求也会执行Session检查这通常是不必要且可能引发错误的。因此先判断类型是一个好习惯。Session获取方式request.getSession(false)是关键。如果传入true默认值当Session不存在时服务器会创建一个新的空Session。这会导致即使未登录的用户也会拥有一个Session ID可能被用于某些跟踪或攻击同时也浪费服务器资源。传入false则只在Session已存在时返回它否则返回null。区分请求类型现代前端应用大量使用Ajax。如果用户会话过期前端发起的Ajax请求如果被重定向到HTML登录页前端JavaScript通常无法正确处理。因此需要根据请求头X-Requested-With判断对Ajax请求返回结构化的JSON错误信息对普通请求进行重定向。这是一种非常友好的用户体验设计。路径处理在重定向时使用request.getContextPath()获取应用上下文路径再拼接目标路径可以避免在应用部署路径不是根路径/时产生404错误。3.2 配置拦截器并设置拦截/排除路径接下来在配置类中注册这个拦截器Configuration public class WebMvcConfig implements WebMvcConfigurer { Autowired private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) // 拦截所有请求 .excludePathPatterns( // 排除不需要拦截的路径 /, /login, /register, /api/login, // 登录接口 /api/register, // 注册接口 /css/**, // 静态资源 /js/**, /images/**, /favicon.ico ); // 可以继续添加其他拦截器 // registry.addInterceptor(new AnotherInterceptor()).addPathPatterns(/admin/**); } }配置要点拦截路径/**表示拦截所有请求这是最宽泛的配置。排除路径必须将登录、注册、静态资源等公开访问的路径明确排除否则会导致用户无法登录或者页面加载不了CSS/JS文件。路径顺序excludePathPatterns的优先级高于addPathPatterns。即一个请求如果匹配了排除模式即使它也匹配拦截模式也不会被拦截。静态资源处理在Spring Boot中静态资源通常有默认的映射路径如/static/**,/public/**。你需要根据你的实际资源存放位置来配置排除模式。一个更稳妥的方式是确保你的静态资源请求不会被DispatcherServlet处理例如通过配置spring.mvc.static-path-pattern这样它们根本不会进入拦截器链。3.3 拦截器链的执行顺序与优先级在实际项目中我们往往有多个拦截器比如一个负责日志一个负责权限一个负责防刷。它们的执行顺序至关重要。执行顺序由注册顺序决定。在addInterceptors方法中先注册的拦截器其preHandle方法会先执行但postHandle和afterCompletion方法会后执行。这形成了一个“栈”式的调用结构。假设我们注册了A、B两个拦截器A.preHandle-B.preHandle控制器方法执行B.postHandle-A.postHandle视图渲染B.afterCompletion-A.afterCompletionpreHandle的连锁效应如果A.preHandle返回false则请求被中断B.preHandle和控制器方法都不会执行。但已经执行了preHandle且返回true的拦截器其afterCompletion方法仍然会被调用。例如如果A放行B拦截那么执行流是A.preHandle(true) -B.preHandle(false) -A.afterCompletion。这一点在编写资源清理逻辑时需要特别注意。实操心得对于有依赖关系的拦截器一定要规划好注册顺序。例如一个需要记录用户操作日志的拦截器它可能需要依赖权限拦截器解析出的当前用户信息。那么权限拦截器就必须注册在日志拦截器之前确保在日志拦截器的preHandle中能拿到用户数据。4. 实操过程与核心环节实现4.1 实现一个全局请求日志与耗时统计拦截器除了权限日志是另一个拦截器的典型应用场景。我们希望记录每一个请求的详细信息包括IP、URL、参数、耗时以及响应状态这对于问题排查和系统监控非常有价值。Component Slf4j // 使用Lombok注解简化日志声明 public class RequestLogInterceptor implements HandlerInterceptor { // 使用ThreadLocal来保存请求开始时间保证线程安全 private static final ThreadLocalLong START_TIME_HOLDER new ThreadLocal(); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 记录请求开始时间 START_TIME_HOLDER.set(System.currentTimeMillis()); // 记录请求基本信息注意在生产环境打印包含敏感信息的参数需谨慎 if (log.isInfoEnabled()) { String queryString request.getQueryString(); String requestURI request.getRequestURI(); String clientIP getClientIpAddress(request); String method request.getMethod(); log.info(Request Start IP: [{}], Method: [{}], URI: [{}], Params: [{}], clientIP, method, requestURI, queryString); } return true; // 始终放行此拦截器仅用于记录 } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception { // 计算耗时 Long startTime START_TIME_HOLDER.get(); if (startTime ! null) { long duration System.currentTimeMillis() - startTime; int status response.getStatus(); String requestURI request.getRequestURI(); // 根据是否有异常和耗时长短选择不同的日志级别 if (ex ! null) { log.error(Request Error URI: [{}], Status: [{}], Duration: [{}ms], Exception: [{}], requestURI, status, duration, ex.getMessage(), ex); } else if (duration 1000) { log.warn(Request Slow URI: [{}], Status: [{}], Duration: [{}ms], requestURI, status, duration); } else { log.info(Request End URI: [{}], Status: [{}], Duration: [{}ms], requestURI, status, duration); } } // 务必清理ThreadLocal防止内存泄漏 START_TIME_HOLDER.remove(); } /** * 获取客户端真实IP考虑了代理情况 */ private String getClientIpAddress(HttpServletRequest request) { String ip request.getHeader(X-Forwarded-For); if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getHeader(Proxy-Client-IP); } if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getHeader(WL-Proxy-Client-IP); } if (ip null || ip.isEmpty() || unknown.equalsIgnoreCase(ip)) { ip request.getRemoteAddr(); } // 对于通过多个代理的情况第一个IP才是真实IP if (ip ! null ip.contains(,)) { ip ip.substring(0, ip.indexOf(,)).trim(); } return ip; } }配置这个拦截器时通常我们会把它放在链的最前面以确保它能记录最完整的耗时并排除对静态资源的记录以提升性能Override public void addInterceptors(InterceptorRegistry registry) { // 日志拦截器放在最前面 registry.addInterceptor(requestLogInterceptor) .addPathPatterns(/**) .excludePathPatterns(/css/**, /js/**, /images/**, /favicon.ico); // 其他拦截器如登录拦截器 registry.addInterceptor(loginInterceptor) .addPathPatterns(/**) .excludePathPatterns(...); // 排除路径 }4.2 利用拦截器实现接口防刷与限流在高并发场景下防止恶意用户或脚本频繁调用接口刷单、刷票、暴力破解是刚需。拦截器可以作为一个轻量级的限流闸口。下面实现一个基于内存使用Guava的RateLimiter或简单计数器的简易防刷拦截器。这里展示一个基于IP和URI的计数器方案Component public class RateLimitInterceptor implements HandlerInterceptor { // 使用ConcurrentHashMap存储计数器Key为IP:URI private final ConcurrentHashMapString, RequestCounter requestCountMap new ConcurrentHashMap(); // 清理过期计数器的时间间隔毫秒 private static final long CLEAN_UP_INTERVAL 60 * 1000L; // 最后一次清理时间 private volatile long lastCleanUpTime System.currentTimeMillis(); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 定期清理过期计数器避免内存无限增长 cleanUpExpiredCounters(); String clientIP getClientIpAddress(request); // 复用之前的获取IP方法 String requestURI request.getRequestURI(); String key clientIP : requestURI; // 获取或创建该IPURI的计数器 RequestCounter counter requestCountMap.computeIfAbsent(key, k - new RequestCounter()); // 定义规则例如每秒最多5次请求这里简化成时间窗口内计数 long currentTime System.currentTimeMillis(); if (counter.isAllowed(currentTime, 5, 1000L)) { // 1秒内最多5次 return true; } else { log.warn(触发限流IP{}, URI{}, clientIP, requestURI); response.setContentType(application/json;charsetutf-8); response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // 429状态码 response.getWriter().write({\code\: 429, \msg\: \请求过于频繁请稍后再试\}); return false; } } /** * 简单的计数器类 */ private static class RequestCounter { private final LinkedListLong timestamps new LinkedList(); synchronized boolean isAllowed(long currentTime, int maxRequests, long timeWindowMillis) { // 移除时间窗口之外的旧时间戳 while (!timestamps.isEmpty() currentTime - timestamps.peekFirst() timeWindowMillis) { timestamps.pollFirst(); } // 判断当前数量是否超过限制 if (timestamps.size() maxRequests) { timestamps.addLast(currentTime); return true; } return false; } } private void cleanUpExpiredCounters() { long now System.currentTimeMillis(); if (now - lastCleanUpTime CLEAN_UP_INTERVAL) { // 遍历map移除长时间未访问的key例如超过10分钟 long expiredTime now - 10 * 60 * 1000L; requestCountMap.entrySet().removeIf(entry - { // 这里简化处理如果计数器里最新的时间戳都很旧就移除 // 更严谨的做法需要维护计数器的最后访问时间 return entry.getValue().timestamps.isEmpty() || (now - entry.getValue().timestamps.peekLast() expiredTime); }); lastCleanUpTime now; } } }这个简易限流器的局限性单机内存限制此方案基于应用实例的内存在集群部署下无效。生产环境需要使用Redis等分布式缓存来实现集群限流。精度与平滑性滑动窗口算法比固定窗口更公平上述简易计数器可以演化为更精确的滑动窗口或令牌桶算法使用GuavaRateLimiter更简单。清理策略需要妥善设计过期数据的清理策略防止内存泄漏。尽管有局限但对于小型应用或作为第一道防线这样的拦截器实现简单且有效。配置时可以将其应用到特定的、需要保护的API路径上例如/api/order/**、/api/submit/**。5. 常见问题与排查技巧实录在实际使用拦截器时你可能会遇到一些“坑”。下面是我总结的一些典型问题及其解决方案。5.1 拦截器不生效的排查步骤这是最常见的问题。如果你的拦截器逻辑没有被执行请按以下顺序检查检查拦截器类是否被Spring管理确保你的拦截器类上有Component或其它Spring注解如Service或者在配置类中通过Bean方法手动创建。如果拦截器实例是通过new关键字创建的Spring无法对其进行依赖注入且其生命周期也不受管理。检查配置类是否被加载确保你的WebMvcConfig配置类位于Spring Boot的主应用类SpringBootApplication标注的类的子包或同级包下或者被ComponentScan显式扫描到。你可以通过在配置类的构造方法或某个方法上加PostConstruct打印日志来验证。检查拦截路径是否正确仔细核对addPathPatterns和excludePathPatterns。一个常见的错误是你试图拦截/api/user/profile但却排除了/api/**。记住排除模式的优先级更高。使用/**时要特别小心。检查静态资源路径如果你的请求是获取CSS、JS、图片等静态资源并且这些资源的请求路径没有被excludePathPatterns排除同时Spring MVC的静态资源处理机制没有生效例如你自定义了WebMvcConfigurer并重写了addResourceHandlers但配置有误那么这些请求可能会“意外地”走到控制器映射环节从而被拦截器处理。这通常不是期望的行为。确保静态资源被正确排除或由默认机制处理。查看拦截器链顺序如果你有多个拦截器并且前面的某个拦截器在preHandle中返回了false那么后面的拦截器就不会执行。检查日志或调试确认执行流在哪个环节被中断了。确认请求是否经过DispatcherServlet拦截器是SpringMVC框架的一部分只有映射到DispatcherServlet的请求才会经过拦截器链。如果你有一些通过Servlet原生API或其它框架处理的请求例如直接映射的Servlet、WebSocket端点这些请求是不会触发拦截器的。5.2 拦截器中获取请求Body内容的问题有时我们想在拦截器里记录或校验请求的Body内容比如JSON参数。但是HttpServletRequest的输入流getInputStream()或getReader()通常只能读取一次。如果在拦截器中读取了那么后续的控制器方法里就读取不到了会导致参数绑定失败。解决方案使用Spring提供的ContentCachingRequestWrapper来包装原始的HttpServletRequest。这个包装类会将请求Body内容缓存到内存中允许你多次读取。Component public class RequestBodyLogInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 包装请求使其支持多次读取Body if (request instanceof ContentCachingRequestWrapper) { // 已经包装过避免重复包装 logRequest((ContentCachingRequestWrapper) request); } else { // 通常我们会在过滤器中统一包装这里只是演示在拦截器中处理 // 更佳实践是在一个Filter中提前包装Request ContentCachingRequestWrapper wrapper new ContentCachingRequestWrapper(request); // 注意包装后需要“预读”一次才能将内容缓存起来 wrapper.getParameterMap(); // 触发缓存 // 此时可以读取缓存的body内容 byte[] content wrapper.getContentAsByteArray(); if (content.length 0) { String body new String(content, wrapper.getCharacterEncoding()); log.info(Request Body: {}, body); } // 注意这里无法替换Request对象所以此方案在拦截器中不完美 } return true; } }重要提示更标准、更可靠的做法是在一个过滤器Filter中最早地对HttpServletRequest进行包装然后将包装后的对象传递下去。这样无论是后续的过滤器、拦截器还是控制器都能安全地多次读取Body。在拦截器中做这件事时机可能有点晚且替换Request对象比较麻烦。5.3 异步请求Async与拦截器的特殊行为当控制器方法返回DeferredResult、Callable或使用ResponseBody配合异步Servlet时请求处理是异步的。这会影响到拦截器方法的执行postHandle方法可能不执行或提前执行对于异步请求postHandle会在控制器方法返回后、异步任务开始前就被调用此时异步任务的结果如DeferredResult.setResult还没有产生。因此在postHandle中你拿不到最终的响应数据。afterCompletion的执行时机afterCompletion会在异步请求完全结束后即异步任务执行完毕响应被发送才被调用。所以它仍然是资源清理和最终日志记录的安全位置。如何为异步请求定制逻辑Spring提供了AsyncHandlerInterceptor接口它继承了HandlerInterceptor并增加了一个方法afterConcurrentHandlingStarted(HttpServletRequest request, HttpServletResponse response, Object handler)该方法在异步处理开始时被调用即控制器方法返回Callable或DeferredResult之后。此时postHandle和afterCompletion属于当前线程都不会被调用了。你可以在这里做一些针对异步请求的初始清理或标记工作。如果你的拦截器需要感知异步处理可以实现这个接口。但大多数情况下我们只需要知道对于异步请求把关键的清理和日志记录逻辑放在afterCompletion中是安全的而postHandle中的逻辑可能对异步请求无效。5.4 拦截器中的异常处理在拦截器的preHandle、postHandle、afterCompletion中都有可能抛出异常。preHandle中抛出异常该异常会向上传播导致请求处理中断后续的拦截器和控制器都不会执行。这个异常最终会被Spring MVC的异常解析器HandlerExceptionResolver处理例如被ControllerAdvice标注的全局异常处理器捕获。但是已经成功执行了preHandle返回true的拦截器其afterCompletion方法仍然会被调用并且ex参数会携带这个异常对象。postHandle中抛出异常同样会被异常解析器处理。但注意如果postHandle抛出异常后续拦截器的postHandle如果存在将不会执行但所有拦截器的afterCompletion都会执行。afterCompletion中抛出异常这是一个比较棘手的情况。因为此时响应可能已经提交给了客户端再抛出异常很难处理通常只会被记录到日志中而无法改变HTTP响应。因此afterCompletion中的代码必须尽可能健壮避免抛出运行时异常。最佳实践在拦截器方法中尤其是afterCompletion使用try-catch块包裹可能出错的逻辑并记录错误日志而不是让异常抛出。Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { try { // 你的资源清理或日志记录逻辑 cleanUpResources(); } catch (Exception e) { log.error(拦截器afterCompletion方法执行清理时发生异常, e); // 不要再次抛出e } // 处理传入的ex控制器抛出的异常 if (ex ! null) { log.error(请求处理过程中发生异常, ex); } }5.5 拦截器性能优化考量拦截器在每个匹配的请求上都会执行因此其性能直接影响应用的整体吞吐量。避免在拦截器中执行重型操作如复杂的数据库查询、远程RPC调用等。拦截器的逻辑应尽可能轻量。例如登录校验拦截器应该只做Session或Token的验证而不应该每次请求都去数据库查询完整的用户信息。用户信息可以在登录时查询一次并缓存到Session或Token中。合理使用排除路径对于静态资源、健康检查端点如/actuator/health、公开API等务必在excludePathPatterns中排除避免不必要的拦截开销。注意ThreadLocal的清理如上文日志拦截器示例在afterCompletion中务必调用ThreadLocal.remove()防止内存泄漏。因为Tomcat等Web服务器使用线程池线程会被复用如果不清理旧的数据会残留导致数据错乱或内存无法释放。缓存重复计算的结果例如在防刷拦截器中解析客户端IP的getClientIpAddress方法可能会被频繁调用。可以考虑将解析结果缓存在请求属性request.setAttribute中供同一个请求的后续环节使用避免重复解析HTTP头。通过深入理解这些原理、细节和避坑指南你就能游刃有余地运用SpringMVC拦截器为你的Web应用构建起强大、灵活且高效的横切面功能层。记住好的工具用在合适的地方才能发挥最大价值。
网站建设 高端定制 企业官网