SpringSecurityOauth2系列學習(五):授權服務自定義異常處理


系列導航

SpringSecurity系列

SpringSecurityOauth2系列

授權服務異常分析

參考

代碼參考

在 Spring Security Oauth2中,異常是框架自行捕獲處理了,使用@RestControllerAdvice是不能統一處理的,因為這個注解是對controller層進行攔截。

我們先來看看Spring Security Oauth2是怎么處理異常的

OAuth2Exception

OAuth2Exception類就是Oauth2的異常類,繼承自RuntimeException

其定義了很多常量表示錯誤信息,基本上對應每個OAuth2Exception的子類。

// 錯誤 
	public static final String ERROR = "error";
	// 錯誤描述 
	public static final String DESCRIPTION = "error_description";
	// 錯誤的URI
	public static final String URI = "error_uri";
	// 無效的請求 InvalidRequestException
	public static final String INVALID_REQUEST = "invalid_request";
	// 無效客戶端
	public static final String INVALID_CLIENT = "invalid_client";
	// 無效授權 InvalidGrantException
	public static final String INVALID_GRANT = "invalid_grant";
	// 未經授權的客戶端
	public static final String UNAUTHORIZED_CLIENT = "unauthorized_client";
	// 不受支持的授權類型 UnsupportedGrantTypeException
	public static final String UNSUPPORTED_GRANT_TYPE = "unsupported_grant_type";
	// 無效授權范圍 InvalidScopeException
	public static final String INVALID_SCOPE = "invalid_scope";
	// 授權范圍不足
	public static final String INSUFFICIENT_SCOPE = "insufficient_scope";
	// 令牌無效 InvalidTokenException
	public static final String INVALID_TOKEN = "invalid_token";
	// 重定向uri不匹配 RedirectMismatchException
	public static final String REDIRECT_URI_MISMATCH ="redirect_uri_mismatch";
	// 不支持的響應類型 UnsupportedResponseTypeException
	public static final String UNSUPPORTED_RESPONSE_TYPE ="unsupported_response_type";
	// 拒絕訪問 UserDeniedAuthorizationException
	public static final String ACCESS_DENIED = "access_denied";

OAuth2Exception也定義了很多方法:

	// 添加額外的異常信息
	private Map<String, String> additionalInformation = null;
	// OAuth2 錯誤代碼
	public String getOAuth2ErrorCode() {
		return "invalid_request";
	}
	// 與此錯誤關聯的 HTTP 錯誤代碼
	public int getHttpErrorCode() {
		return 400;
	}
	// 根據定義好的錯誤代碼(常量),創建對應的OAuth2Exception子類
	public static OAuth2Exception create(String errorCode, String errorMessage) {
		if (errorMessage == null) {
			errorMessage = errorCode == null ? "OAuth Error" : errorCode;
		}
		if (INVALID_CLIENT.equals(errorCode)) {
			return new InvalidClientException(errorMessage);
		}
		// 省略.......
	}
	// 從 Map<String,String> 創建一個 {@link OAuth2Exception}。
	public static OAuth2Exception valueOf(Map<String, String> errorParams) {
		// 省略.......
		return ex;
	}
	

	/**
	 * @return 以逗號分隔的詳細信息列表(鍵值對)
	 */
	public String getSummary() {
		// 省略.......
		return builder.toString();
	}

異常處理源碼分析

我們以密碼模式,不傳入授權類型為例。

1. 端點校驗GrantType拋出異常

密碼模式訪問/oauth/token端點,在下面代碼中,不傳入GrantType,會拋出InvalidRequestException異常,這個異常的msg為Missing grant type

創建的異常,包含了下面這些信息。

2. 端點中的@ExceptionHandler統一處理異常

在端點類TokenEndpoint中,定義了多個@ExceptionHandler,所以只要是在這個端點中的異常,都會被捕獲處理。

    @ExceptionHandler({HttpRequestMethodNotSupportedException.class})
    public ResponseEntity<OAuth2Exception> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) throws Exception {
        if (this.logger.isInfoEnabled()) {
            this.logger.info("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
        }

        return this.getExceptionTranslator().translate(e);
    }

    @ExceptionHandler({Exception.class})
    public ResponseEntity<OAuth2Exception> handleException(Exception e) throws Exception {
        if (this.logger.isErrorEnabled()) {
            this.logger.error("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage(), e);
        }

        return this.getExceptionTranslator().translate(e);
    }

    @ExceptionHandler({ClientRegistrationException.class})
    public ResponseEntity<OAuth2Exception> handleClientRegistrationException(Exception e) throws Exception {
        if (this.logger.isWarnEnabled()) {
            this.logger.warn("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
        }

        return this.getExceptionTranslator().translate(new BadClientCredentialsException());
    }

    @ExceptionHandler({OAuth2Exception.class})
    public ResponseEntity<OAuth2Exception> handleException(OAuth2Exception e) throws Exception {
        if (this.logger.isWarnEnabled()) {
            this.logger.warn("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
        }

        return this.getExceptionTranslator().translate(e);
    }

1中拋出的InvalidRequestExceptionOAuth2Exception的子類,所以最終由下面這個ExceptionHandler處理。

	@ExceptionHandler(OAuth2Exception.class)
	public ResponseEntity<OAuth2Exception> handleException(OAuth2Exception e) throws Exception {
	// 打印WARN日志
		if (logger.isWarnEnabled()) {
			logger.warn("Handling error: " + e.getClass().getSimpleName() + ", " + e.getMessage());
		}
		// 調用異常翻譯器
		return getExceptionTranslator().translate(e);
	}

3.異常翻譯處理器

最終調用WebResponseExceptionTranslator的實現類,對異常進行翻譯封裝處理,最后由Spring MVC 返回ResponseEntity< OAuth2Exception> 對象。ResponseEntity實際是一個HttpEntity,是Spring WEB提供了一個封裝信息響應給請求的對象。

異常翻譯默認使用的是DefaultWebResponseExceptionTranslator類,最終進入其translate方法。

	@Override
	public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {

		// 1. 嘗試從堆棧跟蹤中提取 SpringSecurityException
		Throwable[] causeChain = throwableAnalyzer.determineCauseChain(e);
		Exception ase = (OAuth2Exception) throwableAnalyzer.getFirstThrowableOfType(OAuth2Exception.class, causeChain);
		// 2. 獲取OAuth2Exception
		if (ase != null) {
			// 3. 獲取到了OAuth2Exception,直接處理
			return handleOAuth2Exception((OAuth2Exception) ase);
		}
		
		ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class,
				causeChain);
		if (ase != null) {
			return handleOAuth2Exception(new UnauthorizedException(e.getMessage(), e));
		}

		ase = (AccessDeniedException) throwableAnalyzer
				.getFirstThrowableOfType(AccessDeniedException.class, causeChain);
		if (ase instanceof AccessDeniedException) {
			return handleOAuth2Exception(new ForbiddenException(ase.getMessage(), ase));
		}

		ase = (HttpRequestMethodNotSupportedException) throwableAnalyzer.getFirstThrowableOfType(
				HttpRequestMethodNotSupportedException.class, causeChain);
		if (ase instanceof HttpRequestMethodNotSupportedException) {
			return handleOAuth2Exception(new MethodNotAllowed(ase.getMessage(), ase));
		}

		return handleOAuth2Exception(new ServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), e));

	}

真正創建ResponseEntity的是handleOAuth2Exception方法。

	private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) throws IOException {
		// 獲取錯誤碼 eg:400 
		int status = e.getHttpErrorCode();
		// 設置響應消息頭,禁用緩存
		HttpHeaders headers = new HttpHeaders();
		headers.set("Cache-Control", "no-store");
		headers.set("Pragma", "no-cache");
		// 如果是401,或者是范圍不足異常,設置WWW-Authenticate 消息頭
		if (status == HttpStatus.UNAUTHORIZED.value() || (e instanceof InsufficientScopeException)) {
			headers.set("WWW-Authenticate", String.format("%s %s", OAuth2AccessToken.BEARER_TYPE, e.getSummary()));
		}
		// 將異常信息,塞到ResponseEntity的Body中
		ResponseEntity<OAuth2Exception> response = new ResponseEntity<OAuth2Exception>(e, headers,
				HttpStatus.valueOf(status));
		return response;
	}

4.序列化

最終ResponseEntity進行序列化,變成json字符串的時候,OAuth2Exception通過其定義的序列化器,進行json字符串的轉換

OAuth2Exception上標注了JsonSerialize 、JsonDeserialize注解,所以會進行序列化操作。主要是將OAuth2Exception中的異常進行序列化處理。

	@Override
	public void serialize(OAuth2Exception value, JsonGenerator jgen, SerializerProvider provider) throws IOException,
			JsonProcessingException {
        jgen.writeStartObject();
        // 序列化error
		jgen.writeStringField("error", value.getOAuth2ErrorCode());
		String errorMessage = value.getMessage();
		if (errorMessage != null) {
			errorMessage = HtmlUtils.htmlEscape(errorMessage);
		}
		// 序列化error_description
		jgen.writeStringField("error_description", errorMessage);
		// 序列化額外的附加信息AdditionalInformation
		if (value.getAdditionalInformation()!=null) {
			for (Entry<String, String> entry : 
			value.getAdditionalInformation().entrySet()) {
				String key = entry.getKey();
				String add = entry.getValue();
				jgen.writeStringField(key, add);				
			}
		}
        jgen.writeEndObject();
	}

5. 前端獲取錯誤信息

最終,OAuth2Exception經過拋出,ExceptionHandler捕獲,翻譯,封裝返回ResponseEntity,序列化處理,就展示給前端了。

自定義授權服務器異常信息

如果只需要改變有異常時,返回的json響應體,那么只需要自定義翻譯器即可,不需要自定義異常並添加序列化和反序列化。

但是在實際開放中,一般異常都是有固定格式的,OAuth2Exception直接返回,不是我們想要的,那么我們可以進行改造。

1.自定義異常

自定義一個異常,繼承OAuth2Exception,並添加序列化

/**
 * @author 硝酸銅
 * @date 2021/9/23
 */
@JsonSerialize(using = MyOauthExceptionJackson2Serializer.class)
@JsonDeserialize(using = MyOAuth2ExceptionJackson2Deserializer.class)
public class MyOAuth2Exception extends OAuth2Exception {
    public MyOAuth2Exception(String msg, Throwable t) {
        super(msg, t);
    }

    public MyOAuth2Exception(String msg) {
        super(msg);
    }
}

2.編寫序列化

參考OAuth2Exception的序列化,編寫我們自己的異常的序列化與反序列化類。

package com.cupricnitrate.authority.exception.serializer;

import com.cupricnitrate.authority.exception.MyOAuth2Exception;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

/**
 * 自定義Oauth2異常類序列化類
 * @author 硝酸銅
 * @date 2021/9/23
 */
public class MyOauthExceptionJackson2Serializer extends StdSerializer<MyOAuth2Exception> {


    public MyOauthExceptionJackson2Serializer() {
        super(MyOAuth2Exception.class);
    }


    @Override
    public void serialize(MyOAuth2Exception value, JsonGenerator jgen, SerializerProvider provider) throws IOException,
            JsonProcessingException {
        jgen.writeStartObject();

        Map<String ,String > content = new HashMap<>();

        //序列化error
        content.put(OAuth2Exception.ERROR,value.getOAuth2ErrorCode());
        //jgen.writeStringField(OAuth2Exception.ERROR,value.getOAuth2ErrorCode());

        //序列化error_description
        content.put(OAuth2Exception.DESCRIPTION,value.getMessage());
        //jgen.writeStringField(OAuth2Exception.DESCRIPTION,value.getMessage());

        //序列化額外的附加信息AdditionalInformation
        if (value.getAdditionalInformation()!=null) {
            for (Map.Entry<String, String> entry : value.getAdditionalInformation().entrySet()) {
                String key = entry.getKey();
                String add = entry.getValue();
                content.put(key,add);
                //jgen.writeStringField(key, add);
            }
        }
        jgen.writeFieldName("result");
        jgen.writeObject(content);
        jgen.writeFieldName("code");
        jgen.writeNumber(500);
        jgen.writeEndObject();
    }
}
package com.cupricnitrate.authority.exception.serializer;

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import org.springframework.security.oauth2.common.exceptions.*;
import org.springframework.security.oauth2.common.util.OAuth2Utils;

import java.io.IOException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

/**
 * 自定義Oauth2異常類反序列化類
 * @author 硝酸銅
 * @date 2021/9/23
 */
public class MyOAuth2ExceptionJackson2Deserializer extends StdDeserializer<OAuth2Exception> {

    public MyOAuth2ExceptionJackson2Deserializer() {
        super(OAuth2Exception.class);
    }

    @Override
    public OAuth2Exception deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException,
            JsonProcessingException {

        JsonToken t = jp.getCurrentToken();
        if (t == JsonToken.START_OBJECT) {
            t = jp.nextToken();
        }
        Map<String, Object> errorParams = new HashMap<String, Object>();
        for (; t == JsonToken.FIELD_NAME; t = jp.nextToken()) {
            // Must point to field name
            String fieldName = jp.getCurrentName();
            // And then the value...
            t = jp.nextToken();
            // Note: must handle null explicitly here; value deserializers won't
            Object value;
            if (t == JsonToken.VALUE_NULL) {
                value = null;
            }
            // 復雜結構
            else if (t == JsonToken.START_ARRAY) {
                value = jp.readValueAs(List.class);
            } else if (t == JsonToken.START_OBJECT) {
                value = jp.readValueAs(Map.class);
            } else {
                value = jp.getText();
            }
            errorParams.put(fieldName, value);
        }

        //讀取error與error_description字段
        Object errorCode = errorParams.get(OAuth2Exception.ERROR);
        String errorMessage = errorParams.get(OAuth2Exception.DESCRIPTION) != null ? errorParams.get(OAuth2Exception.DESCRIPTION).toString() : null;
        if (errorMessage == null) {
            errorMessage = errorCode == null ? "OAuth Error" : errorCode.toString();
        }

        //將讀取到的error與error_description字段,生成具體的OAuth2Exception實現類
        OAuth2Exception ex;
        if (OAuth2Exception.INVALID_CLIENT.equals(errorCode)) {
            ex = new InvalidClientException(errorMessage);
        } else if (OAuth2Exception.UNAUTHORIZED_CLIENT.equals(errorCode)) {
            ex = new UnauthorizedClientException(errorMessage);
        } else if (OAuth2Exception.INVALID_GRANT.equals(errorCode)) {
            if (errorMessage.toLowerCase().contains("redirect") && errorMessage.toLowerCase().contains("match")) {
                ex = new RedirectMismatchException(errorMessage);
            } else {
                ex = new InvalidGrantException(errorMessage);
            }
        } else if (OAuth2Exception.INVALID_SCOPE.equals(errorCode)) {
            ex = new InvalidScopeException(errorMessage);
        } else if (OAuth2Exception.INVALID_TOKEN.equals(errorCode)) {
            ex = new InvalidTokenException(errorMessage);
        } else if (OAuth2Exception.INVALID_REQUEST.equals(errorCode)) {
            ex = new InvalidRequestException(errorMessage);
        } else if (OAuth2Exception.REDIRECT_URI_MISMATCH.equals(errorCode)) {
            ex = new RedirectMismatchException(errorMessage);
        } else if (OAuth2Exception.UNSUPPORTED_GRANT_TYPE.equals(errorCode)) {
            ex = new UnsupportedGrantTypeException(errorMessage);
        } else if (OAuth2Exception.UNSUPPORTED_RESPONSE_TYPE.equals(errorCode)) {
            ex = new UnsupportedResponseTypeException(errorMessage);
        } else if (OAuth2Exception.INSUFFICIENT_SCOPE.equals(errorCode)) {
            ex = new InsufficientScopeException(errorMessage, OAuth2Utils.parseParameterList((String) errorParams
                    .get("scope")));
        } else if (OAuth2Exception.ACCESS_DENIED.equals(errorCode)) {
            ex = new UserDeniedAuthorizationException(errorMessage);
        } else {
            ex = new OAuth2Exception(errorMessage);
        }

        //將json中的其他字段添加到OAuth2Exception的附加信息中
        Set<Map.Entry<String, Object>> entries = errorParams.entrySet();
        for (Map.Entry<String, Object> entry : entries) {
            String key = entry.getKey();
            if (!"error".equals(key) && !"error_description".equals(key)) {
                Object value = entry.getValue();
                ex.addAdditionalInformation(key, value == null ? null : value.toString());
            }
        }
        return ex;
    }
}

自定義異常翻譯器

模仿框架編寫,主要的邏輯還是handleOAuth2Exception方法,將我們自定義的異常信息返回

import com.example.config.exception.MyOAuth2Exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.common.DefaultThrowableAnalyzer;
import org.springframework.security.oauth2.common.OAuth2AccessToken;
import org.springframework.security.oauth2.common.exceptions.InsufficientScopeException;
import org.springframework.security.oauth2.common.exceptions.OAuth2Exception;
import org.springframework.security.oauth2.provider.error.WebResponseExceptionTranslator;
import org.springframework.security.web.util.ThrowableAnalyzer;
import org.springframework.web.HttpRequestMethodNotSupportedException;

import java.io.IOException;

/**
 * 自定義異常翻譯器
 * @author 硝酸銅
 * @date 2021/9/17
 */
@Slf4j
public class AuthWebResponseExceptionTranslator implements WebResponseExceptionTranslator<OAuth2Exception> {

    private ThrowableAnalyzer throwableAnalyzer = new DefaultThrowableAnalyzer();

    @Override
    public ResponseEntity<OAuth2Exception> translate(Exception e) throws Exception {

        // Try to extract a SpringSecurityException from the stacktrace
        Throwable[] causeChain = throwableAnalyzer.determineCauseChain(e);
        Exception ase = (OAuth2Exception) throwableAnalyzer.getFirstThrowableOfType(OAuth2Exception.class, causeChain);

        if (ase != null) {
            return handleOAuth2Exception((OAuth2Exception) ase);
        }

        ase = (AuthenticationException) throwableAnalyzer.getFirstThrowableOfType(AuthenticationException.class,
                causeChain);
        if (ase != null) {
            return handleOAuth2Exception(new AuthWebResponseExceptionTranslator.UnauthorizedException(e.getMessage(), e));
        }

        ase = (AccessDeniedException) throwableAnalyzer
                .getFirstThrowableOfType(AccessDeniedException.class, causeChain);
        if (ase instanceof AccessDeniedException) {
            return handleOAuth2Exception(new AuthWebResponseExceptionTranslator.ForbiddenException(ase.getMessage(), ase));
        }

        ase = (HttpRequestMethodNotSupportedException) throwableAnalyzer.getFirstThrowableOfType(
                HttpRequestMethodNotSupportedException.class, causeChain);
        if (ase instanceof HttpRequestMethodNotSupportedException) {
            return handleOAuth2Exception(new AuthWebResponseExceptionTranslator.MethodNotAllowed(ase.getMessage(), ase));
        }

        return handleOAuth2Exception(new AuthWebResponseExceptionTranslator.ServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase(), e));

    }

    private ResponseEntity<OAuth2Exception> handleOAuth2Exception(OAuth2Exception e) throws IOException {

        //int status = e.getHttpErrorCode();

        // 這里使用http 200的響應碼,方便feign調用,feign調用收到400的http 響應碼會拋出FeignException$BadRequest異常
        // 返回體中有業務相關響應碼
        int status = 200;
        HttpHeaders headers = new HttpHeaders();
        headers.set("Cache-Control", "no-store");
        headers.set("Pragma", "no-cache");
        if (status == HttpStatus.UNAUTHORIZED.value() || (e instanceof InsufficientScopeException)) {
            headers.set("WWW-Authenticate", String.format("%s %s", OAuth2AccessToken.BEARER_TYPE, e.getSummary()));
        }

        //自定義異常信息
        MyOAuth2Exception myOAuth2Exception=new MyOAuth2Exception(e.getMessage());
        myOAuth2Exception.addAdditionalInformation("code", "401");
        myOAuth2Exception.addAdditionalInformation("result", "操作失敗");

        //將自定義的異常信息放入返回體中
        ResponseEntity<OAuth2Exception> response = new ResponseEntity<OAuth2Exception>(myOAuth2Exception, headers,
                HttpStatus.valueOf(status));

        return response;

    }

    public void setThrowableAnalyzer(ThrowableAnalyzer throwableAnalyzer) {
        this.throwableAnalyzer = throwableAnalyzer;
    }

    @SuppressWarnings("serial")
    private static class ForbiddenException extends OAuth2Exception {

        public ForbiddenException(String msg, Throwable t) {
            super(msg, t);
        }

        @Override
        public String getOAuth2ErrorCode() {
            return "access_denied";
        }

        @Override
        public int getHttpErrorCode() {
            return 403;
        }

    }

    @SuppressWarnings("serial")
    private static class ServerErrorException extends OAuth2Exception {

        public ServerErrorException(String msg, Throwable t) {
            super(msg, t);
        }

        @Override
        public String getOAuth2ErrorCode() {
            return "server_error";
        }

        @Override
        public int getHttpErrorCode() {
            return 500;
        }

    }

    @SuppressWarnings("serial")
    private static class UnauthorizedException extends OAuth2Exception {

        public UnauthorizedException(String msg, Throwable t) {
            super(msg, t);
        }

        @Override
        public String getOAuth2ErrorCode() {
            return "unauthorized";
        }

        @Override
        public int getHttpErrorCode() {
            return 401;
        }

    }

    @SuppressWarnings("serial")
    private static class MethodNotAllowed extends OAuth2Exception {

        public MethodNotAllowed(String msg, Throwable t) {
            super(msg, t);
        }

        @Override
        public String getOAuth2ErrorCode() {
            return "method_not_allowed";
        }

        @Override
        public int getHttpErrorCode() {
            return 405;
        }

    }
}

endpoints配置自定義異常翻譯器

在授權服務配置中,配置上自定義異常翻譯器,用戶處理OAuth2Exception

@EnableAuthorizationServer
@Configuration
public class AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {
...

    /**
     * 配置授權訪問的接入點
     * @param endpoints
     */
    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) {
        ...

        endpoints
                ...
                // 自定義異常翻譯器
                .exceptionTranslator(new AuthWebResponseExceptionTranslator());
    }
  ...
}

測試

授權服務故意輸錯密碼

feign調用故意輸錯密碼:

我們的feign調用返回的是泛型,所以異常信息也能接收到

		@PostMapping(value = "/oauth/login")
    <T> Result<T> login(@RequestBody LoginReq req);

總結

到這里,SpringSecurityOauth2的技術學習就到一段落了。

到目前為止,我們掌握的技術力可以去面對項目中的權限業務了。但是還是那句話,權限最難的是業務的設計而不是技術。如何設計好一個RBAC系統,這是有大學問的,建議小伙伴們多多去看一些權限相關的業務案例。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM