• JAVA,C# 大文件数据流分段输出,多段输出,支持视频图片等(Range,etag操作)


    为了防止直接请求文件而导致数据被采集,通过接口逻辑判断后再输出文件流的方式模拟完成直接请求文件的操作,支持大文件流操作

    JAVA代码:

    package lan.dk.podcastserver.utils.multipart;
    
    import lan.dk.podcastserver.utils.MimeTypeUtils;
    import org.apache.commons.lang3.StringUtils;
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    
    import javax.servlet.ServletOutputStream;
    import javax.servlet.http.HttpServletRequest;
    import javax.servlet.http.HttpServletResponse;
    import java.io.*;
    import java.nio.file.Files;
    import java.nio.file.Path;
    import java.nio.file.Paths;
    import java.nio.file.attribute.FileTime;
    import java.time.LocalDateTime;
    import java.time.ZoneId;
    import java.time.ZoneOffset;
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;
    
    /**
     * Created by kevin on 10/02/15.
     */
    public class MultipartFileSender {
    
        protected final Logger logger = LoggerFactory.getLogger(this.getClass());
        
        private static final int DEFAULT_BUFFER_SIZE = 20480; // ..bytes = 20KB.
        private static final long DEFAULT_EXPIRE_TIME = 604800000L; // ..ms = 1 week.
        private static final String MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES";
        
        Path filepath;
        HttpServletRequest request;
        HttpServletResponse response;
        
        public MultipartFileSender() {
        }
    
        public static MultipartFileSender fromPath(Path path) {
            return new MultipartFileSender().setFilepath(path);
        }
        
        public static MultipartFileSender fromFile(File file) {
            return new MultipartFileSender().setFilepath(file.toPath());
        }
    
        public static MultipartFileSender fromURIString(String uri) {
            return new MultipartFileSender().setFilepath(Paths.get(uri));
        }
    
        //** internal setter **//
        private MultipartFileSender setFilepath(Path filepath) {
            this.filepath = filepath;
            return this;
        }
        
        public MultipartFileSender with(HttpServletRequest httpRequest) {
            request = httpRequest;
            return this;
        }
        
        public MultipartFileSender with(HttpServletResponse httpResponse) {
            response = httpResponse;
            return this;
        }
        
        public void serveResource() throws Exception {
            if (response == null || request == null) {
                return;
            }
    
            if (!Files.exists(filepath)) {
                logger.error("File doesn't exist at URI : {}", filepath.toAbsolutePath().toString());
                response.sendError(HttpServletResponse.SC_NOT_FOUND);
                return;
            }
    
            Long length = Files.size(filepath);
            String fileName = filepath.getFileName().toString();
            FileTime lastModifiedObj = Files.getLastModifiedTime(filepath);
    
            if (StringUtils.isEmpty(fileName) || lastModifiedObj == null) {
                response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
                return;
            }
            long lastModified = LocalDateTime.ofInstant(lastModifiedObj.toInstant(), ZoneId.of(ZoneOffset.systemDefault().getId())).toEpochSecond(ZoneOffset.UTC);
            String contentType = MimeTypeUtils.probeContentType(filepath);
    
            // Validate request headers for caching ---------------------------------------------------
    
            // If-None-Match header should contain "*" or ETag. If so, then return 304.
            String ifNoneMatch = request.getHeader("If-None-Match");
            if (ifNoneMatch != null && HttpUtils.matches(ifNoneMatch, fileName)) {
                response.setHeader("ETag", fileName); // Required in 304.
                response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
                return;
            }
    
            // If-Modified-Since header should be greater than LastModified. If so, then return 304.
            // This header is ignored if any If-None-Match header is specified.
            long ifModifiedSince = request.getDateHeader("If-Modified-Since");
            if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified) {
                response.setHeader("ETag", fileName); // Required in 304.
                response.sendError(HttpServletResponse.SC_NOT_MODIFIED);
                return;
            }
    
            // Validate request headers for resume ----------------------------------------------------
    
            // If-Match header should contain "*" or ETag. If not, then return 412.
            String ifMatch = request.getHeader("If-Match");
            if (ifMatch != null && !HttpUtils.matches(ifMatch, fileName)) {
                response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
                return;
            }
    
            // If-Unmodified-Since header should be greater than LastModified. If not, then return 412.
            long ifUnmodifiedSince = request.getDateHeader("If-Unmodified-Since");
            if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified) {
                response.sendError(HttpServletResponse.SC_PRECONDITION_FAILED);
                return;
            }
    
            // Validate and process range -------------------------------------------------------------
    
            // Prepare some variables. The full Range represents the complete file.
            Range full = new Range(0, length - 1, length);
            List<Range> ranges = new ArrayList<>();
    
            // Validate and process Range and If-Range headers.
            String range = request.getHeader("Range");
            if (range != null) {
    
                // Range header should match format "bytes=n-n,n-n,n-n...". If not, then return 416.
                if (!range.matches("^bytes=\d*-\d*(,\d*-\d*)*$")) {
                    response.setHeader("Content-Range", "bytes */" + length); // Required in 416.
                    response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
                    return;
                }
    
                String ifRange = request.getHeader("If-Range");
                if (ifRange != null && !ifRange.equals(fileName)) {
                    try {
                        long ifRangeTime = request.getDateHeader("If-Range"); // Throws IAE if invalid.
                        if (ifRangeTime != -1) {
                            ranges.add(full);
                        }
                    } catch (IllegalArgumentException ignore) {
                        ranges.add(full);
                    }
                }
    
                // If any valid If-Range header, then process each part of byte range.
                if (ranges.isEmpty()) {
                    for (String part : range.substring(6).split(",")) {
                        // Assuming a file with length of 100, the following examples returns bytes at:
                        // 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100).
                        long start = Range.sublong(part, 0, part.indexOf("-"));
                        long end = Range.sublong(part, part.indexOf("-") + 1, part.length());
    
                        if (start == -1) {
                            start = length - end;
                            end = length - 1;
                        } else if (end == -1 || end > length - 1) {
                            end = length - 1;
                        }
    
                        // Check if Range is syntactically valid. If not, then return 416.
                        if (start > end) {
                            response.setHeader("Content-Range", "bytes */" + length); // Required in 416.
                            response.sendError(HttpServletResponse.SC_REQUESTED_RANGE_NOT_SATISFIABLE);
                            return;
                        }
    
                        // Add range.                    
                        ranges.add(new Range(start, end, length));
                    }
                }
            }
    
            // Prepare and initialize response --------------------------------------------------------
    
            // Get content type by file name and set content disposition.
            String disposition = "inline";
    
            // If content type is unknown, then set the default value.
            // For all content types, see: http://www.w3schools.com/media/media_mimeref.asp
            // To add new content types, add new mime-mapping entry in web.xml.
            if (contentType == null) {
                contentType = "application/octet-stream";
            } else if (!contentType.startsWith("image")) {
                // Else, expect for images, determine content disposition. If content type is supported by
                // the browser, then set to inline, else attachment which will pop a 'save as' dialogue.
                String accept = request.getHeader("Accept");
                disposition = accept != null && HttpUtils.accepts(accept, contentType) ? "inline" : "attachment";
            }
            logger.debug("Content-Type : {}", contentType);
            // Initialize response.
            response.reset();
            response.setBufferSize(DEFAULT_BUFFER_SIZE);
            response.setHeader("Content-Type", contentType);
            response.setHeader("Content-Disposition", disposition + ";filename="" + fileName + """);
            logger.debug("Content-Disposition : {}", disposition);
            response.setHeader("Accept-Ranges", "bytes");
            response.setHeader("ETag", fileName);
            response.setDateHeader("Last-Modified", lastModified);
            response.setDateHeader("Expires", System.currentTimeMillis() + DEFAULT_EXPIRE_TIME);
    
            // Send requested file (part(s)) to client ------------------------------------------------
    
            // Prepare streams.
            try (InputStream input = new BufferedInputStream(Files.newInputStream(filepath));
                 OutputStream output = response.getOutputStream()) {
    
                if (ranges.isEmpty() || ranges.get(0) == full) {
    
                    // Return full file.
                    logger.info("Return full file");
                    response.setContentType(contentType);
                    response.setHeader("Content-Range", "bytes " + full.start + "-" + full.end + "/" + full.total);
                    response.setHeader("Content-Length", String.valueOf(full.length));
                    Range.copy(input, output, length, full.start, full.length);
    
                } else if (ranges.size() == 1) {
    
                    // Return single part of file.
                    Range r = ranges.get(0);
                    logger.info("Return 1 part of file : from ({}) to ({})", r.start, r.end);
                    response.setContentType(contentType);
                    response.setHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total);
                    response.setHeader("Content-Length", String.valueOf(r.length));
                    response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206.
    
                    // Copy single part range.
                    Range.copy(input, output, length, r.start, r.length);
    
                } else {
    
                    // Return multiple parts of file.
                    response.setContentType("multipart/byteranges; boundary=" + MULTIPART_BOUNDARY);
                    response.setStatus(HttpServletResponse.SC_PARTIAL_CONTENT); // 206.
    
                    // Cast back to ServletOutputStream to get the easy println methods.
                    ServletOutputStream sos = (ServletOutputStream) output;
    
                    // Copy multi part range.
                    for (Range r : ranges) {
                        logger.info("Return multi part of file : from ({}) to ({})", r.start, r.end);
                        // Add multipart boundary and header fields for every range.
                        sos.println();
                        sos.println("--" + MULTIPART_BOUNDARY);
                        sos.println("Content-Type: " + contentType);
                        sos.println("Content-Range: bytes " + r.start + "-" + r.end + "/" + r.total);
    
                        // Copy single part range of multi part range.
                        Range.copy(input, output, length, r.start, r.length);
                    }
    
                    // End with multipart boundary.
                    sos.println();
                    sos.println("--" + MULTIPART_BOUNDARY + "--");
                }
            }
    
        }
    
        private static class Range {
            long start;
            long end;
            long length;
            long total;
    
            /**
             * Construct a byte range.
             * @param start Start of the byte range.
             * @param end End of the byte range.
             * @param total Total length of the byte source.
             */
            public Range(long start, long end, long total) {
                this.start = start;
                this.end = end;
                this.length = end - start + 1;
                this.total = total;
            }
    
            public static long sublong(String value, int beginIndex, int endIndex) {
                String substring = value.substring(beginIndex, endIndex);
                return (substring.length() > 0) ? Long.parseLong(substring) : -1;
            }
    
            private static void copy(InputStream input, OutputStream output, long inputSize, long start, long length) throws IOException {
                byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
                int read;
    
                if (inputSize == length) {
                    // Write full range.
                    while ((read = input.read(buffer)) > 0) {
                        output.write(buffer, 0, read);
                        output.flush();
                    }
                } else {
                    input.skip(start);
                    long toRead = length;
    
                    while ((read = input.read(buffer)) > 0) {
                        if ((toRead -= read) > 0) {
                            output.write(buffer, 0, read);
                            output.flush();
                        } else {
                            output.write(buffer, 0, (int) toRead + read);
                            output.flush();
                            break;
                        }
                    }
                }
            }
        }
        private static class HttpUtils {
    
            /**
             * Returns true if the given accept header accepts the given value.
             * @param acceptHeader The accept header.
             * @param toAccept The value to be accepted.
             * @return True if the given accept header accepts the given value.
             */
            public static boolean accepts(String acceptHeader, String toAccept) {
                String[] acceptValues = acceptHeader.split("\s*(,|;)\s*");
                Arrays.sort(acceptValues);
    
                return Arrays.binarySearch(acceptValues, toAccept) > -1
                        || Arrays.binarySearch(acceptValues, toAccept.replaceAll("/.*$", "/*")) > -1
                        || Arrays.binarySearch(acceptValues, "*/*") > -1;
            }
    
            /**
             * Returns true if the given match header matches the given value.
             * @param matchHeader The match header.
             * @param toMatch The value to be matched.
             * @return True if the given match header matches the given value.
             */
            public static boolean matches(String matchHeader, String toMatch) {
                String[] matchValues = matchHeader.split("\s*,\s*");
                Arrays.sort(matchValues);
                return Arrays.binarySearch(matchValues, toMatch) > -1
                        || Arrays.binarySearch(matchValues, "*") > -1;
            }
        }
    }

    C#代码

        /// <summary>
        /// 文件分段,多段输出
        /// </summary>
        public class MultipartFileSender
        {
            protected log4net.ILog logger = log4net.LogManager.GetLogger(typeof(MultipartFileSender));
            private const int DEFAULT_BUFFER_SIZE = 20480; // ..bytes = 20KB.
            private const string MULTIPART_BOUNDARY = "MULTIPART_BYTERANGES";
            string filepath;
            readonly HttpRequestBase request;
            private HttpResponseBase response;
            /// <summary>
            /// 初始化
            /// </summary>
            /// <param name="path">文件物理路径</param>
            public MultipartFileSender(string path, HttpRequestBase req, HttpResponseBase resp)
            {
                filepath = path;
                request = req;
                response = resp;
            }
            /// <summary>
            /// 输出
            /// </summary>
            public void ServeResource()
            {
                if (response == null || request == null)
                {
                    return;
                }
                if (filepath.IsNullOrWiteSpace() || !File.Exists(filepath))
                {
                    logger.Error($"请求文件不存在 : {filepath}");
                    response.StatusCode = 404;
                    response.Flush();
                    response.Close();
                    return;
                }
                FileInfo fileinfo = new FileInfo(filepath);
                long length = fileinfo.Length;
                string fileName = Path.GetFileName(filepath);
                string etag = fileName.GetMd5_16();
                DateTime lastModifiedTime = fileinfo.LastWriteTimeUtc;
                long lastModified = (long)(lastModifiedTime - new DateTime(1970, 1, 1, 0, 0, 0, 0)).TotalSeconds;
                string contentType = MimeMapping.GetMimeMapping(fileName);
    
                // Validate request headers for caching ---------------------------------------------------
    
                // If-None-Match header should contain "*" or ETag. If so, then return 304.
                string ifNoneMatch = request.Headers["If-None-Match"];
                if (ifNoneMatch != null && HttpMatches(ifNoneMatch, etag))
                {
                    response.AddHeader("ETag", etag); // Required in 304.
                    response.StatusCode = 304;
                    response.Flush();
                    response.Close();
    
                    return;
                }
    
                // If-Modified-Since header should be greater than LastModified. If so, then return 304.
                // This header is ignored if any If-None-Match header is specified.
                long ifModifiedSince = request.Headers["If-Modified-Since"]?.ToLong() ?? -1;
                if (ifNoneMatch == null && ifModifiedSince != -1 && ifModifiedSince + 1000 > lastModified)
                {
                    response.AddHeader("ETag", etag); // Required in 304.
                    response.StatusCode = 304;
                    response.Flush();
                    response.Close();
                    return;
                }
    
                // Validate request headers for resume ----------------------------------------------------
    
                // If-Match header should contain "*" or ETag. If not, then return 412.
                string ifMatch = request.Headers["If-Match"];
                if (ifMatch != null && !HttpMatches(ifMatch, etag))
                {
                    response.StatusCode = 412;
                    response.Flush();
                    response.Close();
                    return;
                }
    
                // If-Unmodified-Since header should be greater than LastModified. If not, then return 412.
                long ifUnmodifiedSince = request.Headers["If-Unmodified-Since"]?.ToLong() ?? -1;
                if (ifUnmodifiedSince != -1 && ifUnmodifiedSince + 1000 <= lastModified)
                {
                    response.StatusCode = 412;
                    response.Flush();
                    response.Close();
                    return;
                }
    
                // Validate and process range -------------------------------------------------------------
    
                // Prepare some variables. The full Range represents the complete file.
                HttpRange full = new HttpRange(0, length - 1, length);
                List<HttpRange> ranges = new List<HttpRange>();
    
                // Validate and process Range and If-Range headers.
                string range = request.Headers["Range"];
                if (range != null)
                {
                    // Range header should match format "bytes=n-n,n-n,n-n...". If not, then return 416.
                    if (!Regex.IsMatch(range, "^bytes=\d*-\d*(,\d*-\d*)*$"))
                    {
                        response.AddHeader("Content-Range", "bytes */" + length); // Required in 416.
                        response.StatusCode = 416;
                        response.Flush();
                        response.Close();
                        return;
                    }
    
                    string ifRange = request.Headers["If-Range"];
                    if (ifRange != null && ifRange != etag)
                    {
                        try
                        {
                            long ifRangeTime = ifRange.ToLong(); // Throws IAE if invalid.
                            if (ifRangeTime != -1)
                            {
                                ranges.Add(full);
                            }
                        }
                        catch (Exception ignore)
                        {
                            ranges.Add(full);
                        }
                    }
    
                    // If any valid If-Range header, then process each part of byte range.
                    if (ranges.Count == 0)
                    {
                        foreach (var part in range.Substring(6).Split(','))
                        {
    
                            // Assuming a file with length of 100, the following examples returns bytes at:
                            // 50-80 (50 to 80), 40- (40 to length=100), -20 (length-20=80 to length=100).
                            long start = HttpRange.Sublong(part, 0, part.IndexOf("-", StringComparison.Ordinal));
                            long end = HttpRange.Sublong(part, part.IndexOf("-", StringComparison.Ordinal) + 1, part.Length);
    
                            if (start == -1)
                            {
                                start = length - end;
                                end = length - 1;
                            }
                            else if (end == -1 || end > length - 1)
                            {
                                end = length - 1;
                            }
    
                            // Check if Range is syntactically valid. If not, then return 416.
                            if (start > end)
                            {
                                response.AddHeader("Content-Range", "bytes */" + length); // Required in 416.
                                response.StatusCode = 416;
                                response.Flush();
                                response.Close();
                                return;
                            }
                            // Add range.                    
                            ranges.Add(new HttpRange(start, end, length));
                        }
                    }
                }
                // Prepare and initialize response --------------------------------------------------------
    
                // Get content type by file name and set content disposition.
                string disposition = "inline";
    
                // If content type is unknown, then set the default value.
                // For all content types, see: http://www.w3schools.com/media/media_mimeref.asp
                // To add new content types, add new mime-mapping entry in web.xml.
                if (contentType == null)
                {
                    contentType = "application/octet-stream";
                }
                else if (!contentType.StartsWith("image"))
                {
                    // Else, expect for images, determine content disposition. If content type is supported by
                    // the browser, then set to inline, else attachment which will pop a 'save as' dialogue.
                    string accept = request.Headers["Accept"];
                    disposition = accept != null && HttpAccepts(accept, contentType) ? "inline" : "attachment";
                }
               
                // Initialize response.
                response.Clear();
                response.ClearContent();
                response.ClearHeaders();
    
                response.Buffer = true;
                response.AddHeader("Content-Type", contentType);
                response.AddHeader("Content-Disposition", disposition + ";filename="" + fileName + """);
    
               
    
                response.AddHeader("Accept-Ranges", "bytes");
                response.AddHeader("ETag", etag);
                response.Cache.SetLastModified(lastModifiedTime);
                response.Cache.SetExpires(DateTime.UtcNow.AddDays(7));
    
                // Send requested file (part(s)) to client ------------------------------------------------
    
                // Prepare streams.
                using (FileStream input = new FileStream(filepath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
                using (Stream output = response.OutputStream)
                {
    
                    if (ranges.Count == 0 || ranges[0] == full)
                    {
                        if (logger.IsDebugEnabled)
                            logger.Info($"Return full file,{filepath}");
                        response.ContentType = contentType;
                        response.AddHeader("Content-Range", "bytes " + full.start + "-" + full.end + "/" + full.total);
                        response.AddHeader("Content-Length", full.length.ToString());
                        HttpRange.Copy(input, output, length, full.start, full.length);
    
                    }
                    else if (ranges.Count == 1)
                    {
    
                        // Return single part of file.
                        HttpRange r = ranges[0];
                        logger.Info($"Return 1 part of file :{fileName}, from ({r.start}) to ({r.end})");
                        response.ContentType=contentType;
                        response.AddHeader("Content-Range", "bytes " + r.start + "-" + r.end + "/" + r.total);
                        response.AddHeader("Content-Length", r.length.ToString());
                        response.StatusCode = 206;
                        // Copy single part range.
                        HttpRange.Copy(input, output, length, r.start, r.length);
                    }
                    else
                    {
    
                        // Return multiple parts of file.
                        response.ContentType="multipart/byteranges; boundary=" + MULTIPART_BOUNDARY;
                        response.StatusCode = 206;
                        // Cast back to ServletOutputStream to get the easy println methods.
                      
                        // Copy multi part range.
                        foreach (HttpRange r in ranges)
                        {
                            if (logger.IsDebugEnabled)
                                logger.Info($"Return multi part of file :{fileName} from ({r.start}) to ({r.end})");
                            // Copy single part range of multi part range.
                            HttpRange.Copy(input, output, length, r.start, r.length);
                        }
                    }
                }
    
            }
            /**
            * Returns true if the given accept header accepts the given value.
            * @param acceptHeader The accept header.
            * @param toAccept The value to be accepted.
            * @return True if the given accept header accepts the given value.
            */
            public static bool HttpAccepts(string acceptHeader, string toAccept)
            {
                string[] acceptValues =
                acceptHeader.Split(new char[] { ',', '|', ';' }, StringSplitOptions.RemoveEmptyEntries);
    
                return acceptValues.Contains(toAccept)
                       || acceptValues.Contains(toAccept.Replace("/.*$", "/*"))
                       || acceptValues.Contains("*/*");
            }
    
            /**
             * Returns true if the given match header matches the given value.
             * @param matchHeader The match header.
             * @param toMatch The value to be matched.
             * @return True if the given match header matches the given value.
             */
            public static bool HttpMatches(string matchHeader, string toMatch)
            {
                string[] matchValues = matchHeader.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
                return matchValues.Contains(toMatch)
                       || matchValues.Contains("*");
            }
            class HttpRange
            {
                public long start;
                public long end;
                public long length;
                public long total;
    
                /**
                 * Construct a byte range.
                 * @param start Start of the byte range.
                 * @param end End of the byte range.
                 * @param total Total length of the byte source.
                 */
                public HttpRange(long start, long end, long total)
                {
                    this.start = start;
                    this.end = end;
                    this.length = end - start + 1;
                    this.total = total;
                }
    
                public static long Sublong(string value, int beginIndex, int endIndex)
                {
                    string substring = value.Substring(beginIndex, endIndex - beginIndex);
                    return (substring.Length > 0) ? substring.ToLong() : -1;
                }
    
                public static void Copy(Stream input, Stream output, long inputSize, long start, long length)
                {
                    byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
                    int read;
    
                    if (inputSize == length)
                    {
    
                        while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
                        {
                            output.Write(buffer, 0, read);
                            output.Flush();
                        }
                    }
                    else
                    {
                        input.Seek(start, SeekOrigin.Begin);
                        long toRead = length;
    
                        while ((read = input.Read(buffer, 0, buffer.Length)) > 0)
                        {
                            if ((toRead -= read) > 0)
                            {
                                output.Write(buffer, 0, read);
                                output.Flush();
                            }
                            else
                            {
                                output.Write(buffer, 0, (int)toRead + read);
                                output.Flush();
                                break;
                            }
                        }
                    }
                }
            }
        }
  • 相关阅读:
    ArtTmeplate模板+取结接口
    取接口
    ionic 基本布局
    angular通过路由实现跳转 resource加载数据
    总结
    JSON和JSONP
    js中sort()方法的用法,参数以及排序原理
    计算机语言的发展史
    Jquery+PHP实现简单的前后台数据交互实现注册登录,添加留言功能
    Jquery回调函数应用实例解析
  • 原文地址:https://www.cnblogs.com/daxiongblog/p/10613195.html
Copyright © 2020-2023  润新知