spring cloud gateway实现全信道加解密

R-C.jpg

前言:近期在项目中需要对指定请求整个信道进行加解密(防篡改,反拦截),实现整个信道通讯安全,由于不确定后期那些接口可能需要类似处理,最后决定放在网关实现该功能(虽然稍微占用网关的性能,但是能够全局统一处理十分方便,性能方面可以增加配置来解决)。此过程中也踩了些坑,在此记录下来,以便后来人参考;

首先我们采用的是GlobalFilter,GlobalFilter是应用于所有路由的特殊过滤器。
GlobalFilter接口的实现类如下图所示:


4246179-211ffcc9d4633d54.png
4246179-a4b9a195c7713f46.png

当请求与路由匹配时,Web 处理程序会将所有的GlobalFilter和特定的GatewayFilter添加到过滤器链中。这个组合过滤器链是按org.springframework.core.Ordered接口排序的,也通过实现getOrder()方法来设置。
上代码:
整体流程是,前端按照我们约定的加密方式将数据进行加密传给后端,网关拦截后对其解密并请求转发,消费方返回结果后,网关统一处理请求,并对其加密返回前端
基于GlobalFilter 接口包装请求request和响应response,先列出关键代码,完整代码见文末
1、首先我们将我们接收到的请求参数进行实例化,并根据自己的加解密规则进行解密

byte[] bytes = new byte[dataBuffer.readableByteCount()];
String requestBody = new String(bytes, Charset.forName("UTF-8"));
//实例化请求体
SecurityRequest securityRequest = JSONObject.parseObject(requestBody, SecurityRequest.class);
if (securityRequest == null) {
       chain.filter(exchange);
}
//解密请求参数
String decryptedRequestStr = decryptParams(securityRequest);

2、将解密后的请求体写入新的request

ServerHttpRequestDecorator newRequest = new ServerHttpRequestDecorator(request) {
        @Override
        public HttpHeaders getHeaders() {
            HttpHeaders httpHeaders = new HttpHeaders();
            httpHeaders.putAll(super.getHeaders());
            httpHeaders.remove(HttpHeaders.CONTENT_LENGTH);
            httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
            return httpHeaders;
        }
        @Override
        public Flux<DataBuffer> getBody() {
            // 在这里对请求体进行修改
            byte[] bytes = decryptedRequestStr.getBytes(StandardCharsets.UTF_8);
            DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes);
            return Flux.just(buffer);
        }
    };

3、ServerHttpResponseDecorator处理请求返回,统一进行加密处理(这里的AES加密只是示例,可根据自己封装的加密方法进行加密,重在关注怎末读取返回,和处理后写回)

ServerHttpResponse decoratedResponse = new ServerHttpResponseDecorator(originalResponse) {
        @Override
        public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
            if (body instanceof Flux) {
                Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;
                return super.writeWith(fluxBody.buffer().map(dataBuffers -> {
                    DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
                    DataBuffer join = dataBufferFactory.join(dataBuffers);
                    byte[] byteArray = new byte[join.readableByteCount()];
                    join.read(byteArray);
                    String originalResponseBody = new String(byteArray, Charset.forName("UTF-8"));
                    //加密
                    byte[] encryptedByteArray = encryptResponse(originalResponseBody, SecurityRequest.getAesKey()).getBytes(StandardCharsets.UTF_8);
                    DataBuffer wrap = dataBufferFactory.wrap(encryptedByteArray);
                    DataBufferUtils.release(join);
                    return wrap;
                }));
            }
            return super.writeWith(body);
        }
        @Override
        public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
            return writeWith(Flux.from(body).flatMapSequential(p -> p));
        }
    };

4、对请求进行转发

ServerWebExchange newExchange = exchange.mutate().request(newRequest).response(decoratedResponse).build();

完整代码如下:

package com.xxx.gateway.filter.security;

import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.xxx.gateway.config.SecurityProperties;
import com.xxx.gateway.enums.BaseResponseEnum;
import com.xxx.gateway.security.ImpSatSecurity;
import com.xxx.gateway.util.AESCBCUtils;
import com.xxx.gateway.util.BizException;
import com.xxx.gateway.util.UrlPathUtils;
import com.xxx.gateway.vo.SecurityRequest;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferFactory;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.Set;


/**
 * @Description :
 * @Author : hsg
 * @Date: 2023/5/26  13:58
 */
@Component
@Slf4j
@Data
public class SecurityFilter implements GlobalFilter, Ordered {
    // 超时时间5分钟
    public static final long OVER_TIMES = 300000;

    protected Logger logger = LoggerFactory.getLogger(this.getClass());

    //开关配置
    @Autowired
    private SecurityProperties securityProperties;

    //加密工厂类
    @Autowired
    private ImpSatSecurity scurityFactory;


    /**
     * 是否需要安全验证
     *
     * @return
     */
    public boolean isNeedValidate(ServerHttpRequest request) {
        return isMatched(request, securityProperties.getNoExcludeUrls());
    }


    public int getOrder() {
        return -90;
    }


    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        // 获取请求体
        ServerHttpRequest request = exchange.getRequest();

        HttpMethod method = request.getMethod();
        //开启验证开关
        if("false".equals(securityProperties.getIsValidate())){
            logger.info("----no need security filter----");
            return chain.filter(exchange);
        }
        if (!isNeedValidate(request)) {
            logger.info("----no need security filter----");
            return chain.filter(exchange);
        }

        if (method == HttpMethod.POST || method == HttpMethod.PUT) {
            MediaType contentType = exchange.getRequest().getHeaders().getContentType();
            if (contentType != null && contentType.equals(MediaType.APPLICATION_JSON)) {
                return DataBufferUtils.join(exchange.getRequest().getBody())
                        .flatMap(dataBuffer -> {
                            try {
                                byte[] bytes = new byte[dataBuffer.readableByteCount()];
                                dataBuffer.read(bytes);
                                String requestBody = new String(bytes, Charset.forName("UTF-8"));
                                SecurityRequest securityRequest = JSONObject.parseObject(requestBody, SecurityRequest.class);

                                if (securityRequest == null) {
                                    chain.filter(exchange);
                                }
                                //解密请求参数
                                String decryptedRequestStr = decryptParams(securityRequest);
                                ServerHttpRequestDecorator newRequest = new ServerHttpRequestDecorator(request) {
                                    @Override
                                    public HttpHeaders getHeaders() {
                                        HttpHeaders httpHeaders = new HttpHeaders();
                                        httpHeaders.putAll(super.getHeaders());
                                        httpHeaders.remove(HttpHeaders.CONTENT_LENGTH);
                                        httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
                                        return httpHeaders;
                                    }
                                    @Override
                                    public Flux<DataBuffer> getBody() {
                                        // 在这里对请求体进行修改
                                        byte[] bytes = decryptedRequestStr.getBytes(StandardCharsets.UTF_8);
                                        DataBuffer buffer = new DefaultDataBufferFactory().wrap(bytes);
                                        return Flux.just(buffer);
                                    }
                                };
                                ServerHttpResponse originalResponse = exchange.getResponse();
                                HttpHeaders headers = new HttpHeaders();
                                headers.putAll(originalResponse.getHeaders());
                                headers.remove(HttpHeaders.CONTENT_LENGTH);
                                SecurityRequest SecurityRequest = securityRequest;
                                ServerHttpResponse decoratedResponse = new ServerHttpResponseDecorator(originalResponse) {
                                    @Override
                                    public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
                                        if (body instanceof Flux) {
                                            Flux<? extends DataBuffer> fluxBody = (Flux<? extends DataBuffer>) body;
                                            return super.writeWith(fluxBody.buffer().map(dataBuffers -> {
                                                DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
                                                DataBuffer join = dataBufferFactory.join(dataBuffers);
                                                byte[] byteArray = new byte[join.readableByteCount()];
                                                join.read(byteArray);
                                                String originalResponseBody = new String(byteArray, Charset.forName("UTF-8"));
                                                //加密
                                                byte[] encryptedByteArray = encryptResponse(originalResponseBody, SecurityRequest.getAesKey()).getBytes(StandardCharsets.UTF_8);
                                                DataBuffer wrap = dataBufferFactory.wrap(encryptedByteArray);
                                                DataBufferUtils.release(join);
                                                return wrap;
                                            }));
                                        }
                                        return super.writeWith(body);
                                    }
                                    @Override
                                    public Mono<Void> writeAndFlushWith(Publisher<? extends Publisher<? extends DataBuffer>> body) {
                                        return writeWith(Flux.from(body).flatMapSequential(p -> p));
                                    }
                                };
                                decoratedResponse.getHeaders().addAll(headers);
                                ServerWebExchange newExchange = exchange.mutate().request(newRequest).response(decoratedResponse).build();
                                return chain.filter(newExchange);
                            } catch (Exception e) {
                                log.error("请求加解密错误");
                                e.printStackTrace();
                            } finally {
                                DataBufferUtils.release(dataBuffer);
                            }
                            return Mono.empty();
                        });
            }
        }
        return chain.filter(exchange);
    }


    // 对参数进行解密
    private String decryptParams(SecurityRequest securityRequest) {
        //检查公共参数
        if (securityRequest.checkParameter()) {
            logger.error("IMP SAT GW securityRequest public parameter is null");
            throw new BizException(BaseResponseEnum.ILLEGAL_REQUEST.getCode(), BaseResponseEnum.ILLEGAL_REQUEST.getMsg());
        }
        //对requestData进行解密
        JSONObject requestData = scurityFactory.processParametersToJSONObj(securityRequest);
        logger.info("IMP SAT GW requestData is {}", JSONObject.toJSONString(requestData));
        return requestData.toJSONString();
    }

    private String encryptResponse(String responseContent, String aesKey) {
        //统一加密处理返回
        JSONObject jsonObject = JSONObject.parseObject(responseContent);
        if (jsonObject == null || "".equals(jsonObject.toString())) {
            jsonObject = new JSONObject();
        }
        String dataStr = JSONObject.toJSONString(jsonObject, SerializerFeature.DisableCircularReferenceDetect);
        logger.info("response data={}", StringUtils.abbreviate(dataStr, 500));
        JSONObject result = JSONObject.parseObject(dataStr);
        //此处要注意,加密只处理data,所以返回的数据结构体要是ResponseModel
        String resBodyStr = JSONObject.toJSONString(result.get("data"));
        //更改返回内容
        result.put("data", AESCBCUtils.encrypt(resBodyStr, aesKey));
        return result.toJSONString();

    }

    public static boolean isMatched(ServerHttpRequest request,
                                    Set<String> list) {
        if (CollectionUtils.isEmpty(list)) {
            return false;
        }
        String uri = request.getURI().getPath();
        for (String url : list) {
            if (UrlPathUtils.match(url, uri)) {
                return true;
            }
        }
        return false;
    }
}

总结:上述是本人在gateway实现接口加解密的整体实现过程,中间才到一个坑,ServerHttpRequestDecorator 在实例化时一定要实现getHeaders方法,写者在是实现时漏掉了这一步,导致下游一直等待报错:

@Override
    public HttpHeaders getHeaders() {
        HttpHeaders httpHeaders = new HttpHeaders();
        httpHeaders.putAll(super.getHeaders());
        httpHeaders.remove(HttpHeaders.CONTENT_LENGTH);
        httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
        return httpHeaders;
    }
023-05-30 14:34:39.710 ERROR 28536 --- [http-nio-48089-exec-2] [TID: N/A] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception

cn.hutool.core.io.IORuntimeException: ClientAbortException: java.net.SocketTimeoutException
    at cn.hutool.core.io.copy.StreamCopier.copy(StreamCopier.java:71)
    at cn.hutool.core.io.IoUtil.copy(IoUtil.java:162)
    at cn.hutool.core.io.IoUtil.copy(IoUtil.java:146)
    at cn.hutool.core.io.IoUtil.copy(IoUtil.java:132)
    at cn.hutool.core.io.IoUtil.copy(IoUtil.java:119)
    at cn.hutool.core.io.IoUtil.read(IoUtil.java:408)
    at cn.hutool.core.io.IoUtil.readBytes(IoUtil.java:495)
    at cn.hutool.core.io.IoUtil.readBytes(IoUtil.java:461)
    at cn.hutool.extra.servlet.ServletUtil.getBodyBytes(ServletUtil.java:117)
    at com.pharmaoryx.starter.web.core.filter.CacheRequestBodyWrapper.<init>(CacheRequestBodyWrapper.java:28)
    at com.pharmaoryx.starter.web.core.filter.CacheRequestBodyFilter.doFilterInternal(CacheRequestBodyFilter.java:22)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
    at com.pharmaoryx.starter.env.core.web.EnvWebFilter.doFilterInternal(EnvWebFilter.java:28)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
    at org.springframework.boot.actuate.metrics.web.servlet.WebMvcMetricsFilter.doFilterInternal(WebMvcMetricsFilter.java:96)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
    at com.pharmaoryx.starter.tracer.core.filter.TraceFilter.doFilterInternal(TraceFilter.java:30)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
    at org.springframework.web.filter.CorsFilter.doFilterInternal(CorsFilter.java:91)
    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117)
    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:189)
    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:162)
    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:177)
    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:97)
    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:541)
    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:135)
    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:92)
    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:78)
    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:360)
    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:399)
    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:65)
    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:891)
    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1784)
    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:49)
    at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191)
    at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659)
    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
    at java.lang.Thread.run(Thread.java:750)
Caused by: org.apache.catalina.connector.ClientAbortException: java.net.SocketTimeoutException
    at org.apache.catalina.connector.InputBuffer.realReadBytes(InputBuffer.java:321)
    at org.apache.catalina.connector.InputBuffer.checkByteBufferEof(InputBuffer.java:599)
    at org.apache.catalina.connector.InputBuffer.read(InputBuffer.java:339)
    at org.apache.catalina.connector.CoyoteInputStream.read(CoyoteInputStream.java:132)
    at cn.hutool.core.io.copy.StreamCopier.doCopy(StreamCopier.java:97)
    at cn.hutool.core.io.copy.StreamCopier.copy(StreamCopier.java:68)
    ... 49 common frames omitted
Caused by: java.net.SocketTimeoutException: null
    at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.fillReadBuffer(NioEndpoint.java:1310)
    at org.apache.tomcat.util.net.NioEndpoint$NioSocketWrapper.read(NioEndpoint.java:1227)
    at org.apache.coyote.http11.Http11InputBuffer.fill(Http11InputBuffer.java:805)
    at org.apache.coyote.http11.Http11InputBuffer.access$400(Http11InputBuffer.java:42)
    at org.apache.coyote.http11.Http11InputBuffer$SocketInputBuffer.doRead(Http11InputBuffer.java:1206)
    at org.apache.coyote.http11.filters.IdentityInputFilter.doRead(IdentityInputFilter.java:97)
    at org.apache.coyote.http11.Http11InputBuffer.doRead(Http11InputBuffer.java:249)
    at org.apache.coyote.Request.doRead(Request.java:640)
    at org.apache.catalina.connector.InputBuffer.realReadBytes(InputBuffer.java:316)
    ... 54 common frames omitted

排查许久,愿君无过,日拱一卒,功不唐捐!

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

推荐阅读更多精彩内容