跳到内容
当前位置:首页>下载方法>下载大文件又把服务器给撑爆了!

下载大文件又把服务器给撑爆了!

  • 2026-11-04 15:52:38
下载大文件又把服务器给撑爆了!

观众姥爷们好!

咱们好像很久没有更新内容了,更新必须要货真价实!本次更新一套优质代码!

前言

干货满满,欢迎收藏保存!

就一个文件下载,大部分程序员一般考虑不到以下常见问题,也大概是最容易被低估的接口。实际上只要文件过大、并发变高、客户端变杂,那么就会开始逐个引爆:

1. 内存型问题,最常见也最致命: 把文件整个读进 byte[] 再写出,十个人写下载接口八个这么写。平时没症状,文件一大就是陡峭的堆尖刺,几个人同时下载直接 OOM。

2. 磁盘型问题,退一步的坑: 知道不能进内存的人,往往改成先落临时文件再回读。堆确实安全了,但磁盘一写一读、首字节延迟拉长,高并发下磁盘 IO 又成了新瓶颈。

3. Content-Type 全靠猜: 按后缀硬编码 if-else,没见过的后缀一律 octet-stream;前端拿到图片不能预览、视频不能播放,还得自己再维护一套映射表。

4. 中文文件名必乱码: URLEncoder.encode 把空格变成加号、各浏览器解析规则不一,安卓 WebView 下载出来的文件名惨不忍睹。RFC 5987 早就是标准答案,但很少被用对。

5. 安全裸奔: 图省事开放对象存储的匿名直链,等于把存储端点暴露给全网扫描器;上传口不校验文件真实内容,一个伪装成 jpg 的 exe 就能投毒进系统。

6. 断点续传缺失: 不支持 Range:500MB 的文件下到 99% 断网,只能从头再来;视频播放器拖不动进度条,或者一拖就卡死。

下面我针对上述问题,对下载功能完整的写了一些工具类,供收藏保存:

文件类型嗅探

文件类型嗅探的核心是:不信后缀,只认内容。几乎所有文件格式在头部或固定偏移处都埋着约定的字节序列(魔数),比如 JPEG 开头是 FFD8FF、ZIP 是 504B0304、MP4 在第 4 字节处是 ftyp、DICOM 则在第 128 字节处才是 DICM——所以只要流式读出文件最前面的 262 字节「探测窗」,与预置的魔数库做深度倒序匹配(先比偏移深的,避免浅特征误判),再对 ZIP、ftyp 这类通用容器格式结合内部结构与文件名细分出 docx、mp4 等具体类型,就能在只读千分之一文件的情况下得到真实类型。这个真值不仅用来确定准确的 Content-Type(不信上游标的 octet-stream),还可以支撑安全策略:魔数与后缀身份矛盾时告警审计,避免投毒。本工具类中strict 模式下白名单大类之外的可执行类一律 403。由于探测窗的 262 字节判定后照常写出,一次流式打开即可完成「识别 + 传输」,不为嗅探多花任何往返。

package com.feigo.common.utils.file;import lombok.Getter;import java.util.ArrayList;import java.util.Collections;import java.util.Comparator;import java.util.HashMap;import java.util.HexFormat;import java.util.LinkedHashSet;import java.util.List;import java.util.Locale;import java.util.Map;import java.util.Objects;import java.util.Set;/** * 文件类型枚举 —— 「后缀名 + 魔数 + MIME」三合一的文件类型知识库。 * <p> * {@link FileTypeParser} 与 {@link FileDownloader} 的所有类型判断全部由本枚举驱动: * <ul> *   <li><b>魔数探测</b>:{@link #matchMagic(byte[])} —— 按文件头字节识别真实类型,不依赖后缀;</li> *   <li><b>后缀反查</b>:{@link #byFilename(String)} / {@link #byExtension(String)} —— 后缀 → 类型;</li> *   <li><b>MIME 协商</b>:{@link #resolveMimeType(FileType, String)} —— 魔数与后缀综合裁决 Content-Type;</li> *   <li><b>伪装检测</b>:{@link #accepts(FileType)} —— 判断「后缀身份」与「魔数事实」是否自洽。</li> * </ul> * * <h3>魔数语法</h3> * 每个类型可声明多条魔数,统一语法 {@code "hex[@offset]"}: * <ul> *   <li>{@code "ffd8ff"} —— 前缀魔数(文件必须以这串字节开头);</li> *   <li>{@code "4449434d@128"} —— 深偏移魔数(DICOM:128 字节前导之后跟 {@code "DICM"});</li> *   <li>{@code "57415645@8"} —— RIFF 家族的 form type 魔数(offset 8 处为 {@code "WAVE"})。</li> * </ul> * 匹配顺序按「魔数总深度(offset + 字节长度)<b>倒序</b>」:越深越具体的先比 * ({@code WAV@8} 先于 {@code RIFF@0},{@code heic@8} 先于 {@code ftyp@4}), * 浅层容器头天然兜底,从机制上避免短前缀截胡长魔数。 * * <h3>容器与 magicRoot</h3> * OLE2 / ZIP / RIFF / MP4(ISOBMFF)/ MKV(EBML)/ WMV(ASF)这类「一头顶多身」的容器, * 魔数只能识别到容器级。细分类型通过 {@code magicRoot} 声明自己挂在哪个容器下: * <ul> *   <li>魔数命中容器(如 ZIP)+ 后缀指向细分类型(如 docx)→ {@link #accepts} 判定自洽, *       {@link #resolveMimeType} 按细分类型取 MIME;</li> *   <li>SVG 这类「内容常以 {@code <?xml} 开头」的特例同样靠 magicRoot 声明(svg 后缀 + XML 魔数 → 自洽)。</li> * </ul> * * <h3>设计说明:</h3> * <ul> *   <li>魔数在类加载时一次性解析为 {@code byte[]},运行期匹配是<b>纯字节比较</b>: *       无 hex 字符串转换、无装箱、零临时对象,比字符串 {@code startsWith} 更快;</li> *   <li>魔数表、后缀索引在 {@code <clinit>} 构建一次后只读,无锁无竞争,不会被运行期篡改;</li> *   <li>探测深度 {@link #PROBE_BYTES} 由表内最深魔数自动推得(当前为 TAR 的 *       {@code ustar@257},即 262 字节),<b>仍是一次顺序读</b>: *       1KB 与 1TB 文件耗时相同(微秒级),不做 {@code readAllBytes}、不 mmap,零大内存分配;</li> *   <li>枚举自身不依赖 Spring / Servlet,可独立复用于任意模块。</li> * </ul> * * <h3>已知边界(魔数探测的天然局限)</h3> * <ul> *   <li>无固定文件头的纯文本(txt / csv / json / md 等)探测为 {@link #UNKNOWN},靠后缀反查兜底;</li> *   <li>不带 ID3v2 的裸 MP3 帧流靠位级帧同步(0xFFEx),不能写成字节前缀魔数,探测不到;</li> *   <li>容器内部格式(如 doc 与 xls 同为 OLE2)不做无依据的臆断细分,交给后缀 / 二次解析。</li> * </ul> * */@GetterpublicenumFileType {    // =====================================================================================    // 容器根类型:被下方细分类型通过 magicRoot 引用,必须声明在最前(枚举禁止前向引用)    // =====================================================================================    /** OLE2 复合文档容器:doc / xls / ppt / msi / mdb / pst 共用同一文件头,需结合后缀或 CLSID 细分 */    OLE2(Category.OTHER, octet(), null, magic("d0cf11e0a1b11ae1")),    /** RIFF 容器:avi / wav / webp / aiff 共用文件头,需看 offset 8 的 form type 细分 */    RIFF(Category.OTHER, octet(), null, magic("52494646")),    /** ZIP 容器:docx / xlsx / pptx / odt / epub / jar / apk 共用文件头,需结合后缀或读 [Content_Types].xml 细分 */    ZIP(Category.ARCHIVE, "application/zip", null, magic("504b0304"), "zip"),    /** MP4(ISOBMFF)容器:mov / m4a / 3gp 同源,offset 4 固定为 ftyp,offset 8 为品牌(isom/mp42/M4A/3gp4/heic…) */    MP4(Category.VIDEO, "video/mp4", null, magic("66747970@4"), "mp4", "m4v", "f4v"),    /** MKV(EBML / Matroska)容器:webm 同头,需结合后缀细分 */    MKV(Category.VIDEO, "video/x-matroska", null, magic("1a45dfa3"), "mkv"),    /** WMV(ASF)容器:wma 同头,需结合后缀细分 */    WMV(Category.VIDEO, "video/x-ms-wmv", null, magic("3026b2758e66cf11"), "wmv", "asf"),    /** XML:SVG 常以 &lt;?xml 开头,声明在前供 SVG.magicRoot 引用 */    XML(Category.TEXT, "application/xml; charset=UTF-8", null, magic("3c3f786d6c"), "xml", "xsd", "xsl"),    // =====================================================================================    // 图片    // =====================================================================================    /** JPEG(jpg):ffd8ffe0 / e1 / e2 等变体全靠 ffd8ff 前缀覆盖 */    JPEG(Category.IMAGE, "image/jpeg", null, magic("ffd8ff"), "jpg", "jpeg", "jpe"),    /** PNG(png):含 apng */    PNG(Category.IMAGE, "image/png", null, magic("89504e470d0a1a0a"), "png", "apng"),    /** GIF(gif):GIF87a / GIF89a */    GIF(Category.IMAGE, "image/gif", null, magic("47494638"), "gif"),    /** BMP(bmp) */    BMP(Category.IMAGE, "image/bmp", null, magic("424d"), "bmp", "dib"),    /** TIFF(tif):小端 49492a00 / 大端 4d4d002a 双字节序 */    TIFF(Category.IMAGE, "image/tiff", null, magic("49492a00", "4d4d002a"), "tif", "tiff"),    /** WEBP(webp):RIFF 容器,offset 8 处为 "WEBP" */    WEBP(Category.IMAGE, "image/webp", RIFF, magic("57454250@8"), "webp"),    /** ICO(ico):reserved=0 + type=1 */    ICO(Category.IMAGE, "image/x-icon", null, magic("00000100"), "ico", "cur"),    /** PSD(psd):"8BPS" */    PSD(Category.IMAGE, "image/vnd.adobe.photoshop", null, magic("38425053"), "psd", "pdd"),    /** SVG(svg):矢量图。无 &lt;?xml 前缀、直接 &lt;svg 开头的可命中;带前缀的会先命中 XML,靠 magicRoot 声明自洽 */    SVG(Category.IMAGE, "image/svg+xml", XML, magic("3c737667"), "svg"),    /** HEIC / HEIF(heic):苹果系照片格式,ftyp 品牌 heic / mif1;其余 HEIF 变体品牌会落回 MP4 容器,靠 magicRoot 兜住 */    HEIC(Category.IMAGE, "image/heic", MP4, magic("68656963@8", "6d696631@8"), "heic", "heif"),    // =====================================================================================    // 视频    // =====================================================================================    /** MOV(mov):老 QuickTime 变体直接 moov 开头;新变体是 ftyp 容器,靠 magicRoot=MP4 兜住 */    MOV(Category.VIDEO, "video/quicktime", MP4, magic("6d6f6f76"), "mov", "qt"),    /** AVI(avi):RIFF 容器,offset 8 处为 "AVI " */    AVI(Category.VIDEO, "video/x-msvideo", RIFF, magic("41564920@8"), "avi"),    /** WEBM(webm):与 MKV 共用 EBML 头 */    WEBM(Category.VIDEO, "video/webm", MKV, noMagic(), "webm"),    /** FLV(flv) */    FLV(Category.VIDEO, "video/x-flv", null, magic("464c5601"), "flv"),    /** MPG / MPEG(mpg):MPEG-PS(ba)与 MPEG-Video(b3)两种序列头 */    MPG(Category.VIDEO, "video/mpeg", null, magic("000001ba", "000001b3"), "mpg", "mpeg", "mpe", "m2v"),    /** 3GP(3gp):ftyp 容器,品牌 3gp4 / 3gp5 / 3gp6 与 MP4 无从字节级区分,靠 magicRoot=MP4 */    THREE_GP(Category.VIDEO, "video/3gpp", MP4, noMagic(), "3gp", "3g2"),    // =====================================================================================    // 音频    // =====================================================================================    /** MP3(mp3):含 ID3v2 头;裸帧 MP3 的 0xFFEx 是位级同步,无法写成字节前缀魔数 */    MP3(Category.AUDIO, "audio/mpeg", null, magic("494433"), "mp3"),    /** WAV(wav):RIFF 容器,offset 8 处为 "WAVE" */    WAV(Category.AUDIO, "audio/wav", RIFF, magic("57415645@8"), "wav"),    /** FLAC(flac):"fLaC" */    FLAC(Category.AUDIO, "audio/flac", null, magic("664c6143"), "flac"),    /** OGG(ogg):Vorbis / Opus 共用 "OggS" 页头 */    OGG(Category.AUDIO, "audio/ogg", null, magic("4f676753"), "ogg", "oga", "opus"),    /** AAC(aac):ADTS 裸流头 FF F1(无 CRC)/ FF F9(有 CRC) */    AAC(Category.AUDIO, "audio/aac", null, magic("fff1", "fff9"), "aac"),    /** WMA(wma):与 WMV 共用 ASF 头,靠 magicRoot=WMV */    WMA(Category.AUDIO, "audio/x-ms-wma", WMV, noMagic(), "wma"),    /** M4A(m4a):ftyp 品牌为 M4A / M4B,字节级与 MP4 无异,靠 magicRoot=MP4 */    M4A(Category.AUDIO, "audio/mp4", MP4, noMagic(), "m4a", "m4b"),    /** AIFF(aiff):FORM 容器,offset 8 处为 "AIFF" / "AIFC" */    AIFF(Category.AUDIO, "audio/aiff", RIFF, magic("41494646@8", "41494643@8"), "aiff", "aif", "aifc"),    /** AMR(amr):"#!AMR" */    AMR(Category.AUDIO, "audio/amr", null, magic("2321414d52"), "amr"),    // =====================================================================================    // 文档    // =====================================================================================    /** PDF(pdf):"%PDF-" */    PDF(Category.DOCUMENT, "application/pdf", null, magic("255044462d"), "pdf"),    /** DOC(doc):OLE2 容器 */    DOC(Category.DOCUMENT, "application/msword", OLE2, noMagic(), "doc", "dot"),    /** XLS(xls):OLE2 容器 */    XLS(Category.DOCUMENT, "application/vnd.ms-excel", OLE2, noMagic(), "xls", "xla", "xlt"),    /** PPT(ppt):OLE2 容器 */    PPT(Category.DOCUMENT, "application/vnd.ms-powerpoint", OLE2, noMagic(), "ppt", "pps", "pot"),    /** DOCX(docx):OOXML,ZIP 容器 */    DOCX(Category.DOCUMENT, "application/vnd.openxmlformats-officedocument.wordprocessingml.document",            ZIP, noMagic(), "docx", "docm"),    /** XLSX(xlsx):OOXML,ZIP 容器 */    XLSX(Category.DOCUMENT, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",            ZIP, noMagic(), "xlsx", "xlsm"),    /** PPTX(pptx):OOXML,ZIP 容器 */    PPTX(Category.DOCUMENT, "application/vnd.openxmlformats-officedocument.presentationml.presentation",            ZIP, noMagic(), "pptx", "pptm"),    /** ODT(odt):OpenDocument 文本,ZIP 容器 */    ODT(Category.DOCUMENT, "application/vnd.oasis.opendocument.text", ZIP, noMagic(), "odt"),    /** ODS(ods):OpenDocument 表格,ZIP 容器 */    ODS(Category.DOCUMENT, "application/vnd.oasis.opendocument.spreadsheet", ZIP, noMagic(), "ods"),    /** ODP(odp):OpenDocument 演示,ZIP 容器 */    ODP(Category.DOCUMENT, "application/vnd.oasis.opendocument.presentation", ZIP, noMagic(), "odp"),    /** EPUB(epub):ZIP 容器 */    EPUB(Category.DOCUMENT, "application/epub+zip", ZIP, noMagic(), "epub"),    /** WPS 族(wps/et/dps):OLE2 容器,WPS 无稳定公开的标准 MIME,统一二进制流强制下载 */    WPS(Category.DOCUMENT, octet(), OLE2, noMagic(), "wps", "et", "dps"),    /** RTF(rtf):"{\rtf" */    RTF(Category.DOCUMENT, "application/rtf", null, magic("7b5c727466"), "rtf"),    /** EML(eml):邮件。特征是弱魔数 "From:",仅按文件头判断,误报概率极低但非强特征 */    EML(Category.DOCUMENT, "message/rfc822", null, magic("46726f6d3a"), "eml"),    /** MOBI(mobi):"BOOKMOBI" 固定出现在 offset 60 */    MOBI(Category.DOCUMENT, "application/x-mobipocket-ebook", null,            magic("424f4f4b4d4f4249@60"), "mobi", "azw", "azw3"),    // =====================================================================================    // 文本 / 前端(无魔数的纯文本靠后缀反查)    // =====================================================================================    /** TXT(txt):无魔数 */    TXT(Category.TEXT, "text/plain; charset=UTF-8", null, noMagic(),            "txt", "log", "ini", "cfg", "conf", "properties"),    /** CSV(csv):无魔数 */    CSV(Category.TEXT, "text/csv; charset=UTF-8", null, noMagic(), "csv"),    /** JSON(json):无魔数 */    JSON(Category.TEXT, "application/json; charset=UTF-8", null, noMagic(), "json"),    /** HTML(html):&lt;!DOCTYPE html&gt; 大小写两种 + 无声明的 &lt;html&gt; 直开变体 */    HTML(Category.TEXT, "text/html; charset=UTF-8", null,            magic("3c21444f435459504520", "3c21646f637479706520", "3c68746d6c"), "html", "htm"),    /** Markdown(md):无魔数 */    MD(Category.TEXT, "text/markdown; charset=UTF-8", null, noMagic(), "md", "markdown"),    /** JavaScript(js) */    JS(Category.TEXT, "application/javascript; charset=UTF-8", null, noMagic(), "js", "mjs", "cjs"),    /** TypeScript(ts) */    TS(Category.TEXT, "application/typescript; charset=UTF-8", null, noMagic(), "ts", "tsx"),    /** CSS(css) */    CSS(Category.TEXT, "text/css; charset=UTF-8", null, noMagic(), "css"),    /** YAML(yml):RFC 9512 注册的 application/yaml */    YAML(Category.TEXT, "application/yaml; charset=UTF-8", null, noMagic(), "yml", "yaml"),    /** SQL(sql):application/sql 为社区惯用值(IANA 未正式注册) */    SQL(Category.TEXT, "application/sql; charset=UTF-8", null, noMagic(), "sql"),    // =====================================================================================    // 压缩 / 归档    // =====================================================================================    /** RAR(rar):v4 头 7 字节、v5 头 8 字节(多一个 00),互为前缀,v5 在前防截胡,都归 RAR */    RAR(Category.ARCHIVE, "application/vnd.rar", null,            magic("526172211a070100", "526172211a0700"), "rar"),    /** 7Z(7z):分卷等变体同头 */    SEVEN_Z(Category.ARCHIVE, "application/x-7z-compressed", null, magic("377abcaf271c"), "7z"),    /** GZ(gz):gzip 头 1f 8b 08(CM=deflate),tgz / tar.gz 取后缀最后一段为 gz */    GZ(Category.ARCHIVE, "application/gzip", null, magic("1f8b08"), "gz", "tgz"),    /** BZ2(bz2):"BZh" + 级别数字 */    BZ2(Category.ARCHIVE, "application/x-bzip2", null, magic("425a68"), "bz2", "tbz2"),    /** XZ(xz) */    XZ(Category.ARCHIVE, "application/x-xz", null, magic("fd377a585a00"), "xz", "txz"),    /** TAR(tar):"ustar" 固定出现在 offset 257(POSIX / GNU tar 均有),magic 表内最深的魔数 */    TAR(Category.ARCHIVE, "application/x-tar", null, magic("7573746172@257"), "tar"),    /** ZSTD(zst):帧魔数 28 B5 2F FD */    ZSTD(Category.ARCHIVE, "application/zstd", null, magic("28b52ffd"), "zst"),    /** LZ4(lz4):帧魔数 04 22 4D 18,无正式 MIME */    LZ4(Category.ARCHIVE, octet(), null, magic("04224d18"), "lz4"),    /** CAB(cab):"MSCF" */    CAB(Category.ARCHIVE, "application/vnd.ms-cab-compressed", null, magic("4d534346"), "cab"),    /** ISO(iso):卷描述符在 offset 8001 太深,不做魔数探测,靠后缀 */    ISO(Category.ARCHIVE, "application/x-iso9660-image", null, noMagic(), "iso"),    // =====================================================================================    // 可执行 / 脚本(防投毒的重点拦截对象)    // =====================================================================================    /** Windows PE(exe):"MZ",dll / sys / 屏保等同头 */    EXE(Category.EXECUTABLE, "application/vnd.microsoft.portable-executable", null,            magic("4d5a"), "exe", "dll", "sys", "scr", "ocx", "cpl"),    /** Linux ELF(elf):so / bin 同头 */    ELF(Category.EXECUTABLE, octet(), null, magic("7f454c46"), "elf", "so", "bin"),    /** Java class 文件:CA FE BA BE */    CLASS(Category.EXECUTABLE, "application/java-vm", null, magic("cafebabe"), "class"),    /** macOS Mach-O:32 / 64 位及 fat 大小端四种魔数 */    MACHO(Category.EXECUTABLE, octet(), null,            magic("feedface", "feedfacf", "cefaedfe", "cffaedfe"), "dylib"),    /** JAR(jar):可执行 Java 归档,ZIP 容器 */    JAR(Category.EXECUTABLE, "application/java-archive", ZIP, noMagic(), "jar", "war", "ear"),    /** APK(apk):Android 安装包,ZIP 容器 */    APK(Category.EXECUTABLE, "application/vnd.android.package-archive", ZIP, noMagic(), "apk"),    /** MSI(msi):Windows 安装包,OLE2 容器 */    MSI(Category.EXECUTABLE, "application/x-msi", OLE2, noMagic(), "msi"),    /** Shell 脚本(sh):shebang(#!)是所有脚本共用的弱特征,不作为魔数,靠后缀识别 */    SH(Category.EXECUTABLE, "application/x-sh; charset=UTF-8", null, noMagic(), "sh", "bash", "zsh"),    /** Windows 批处理(bat):application/bat 为历史惯用值 */    BAT(Category.EXECUTABLE, "application/bat; charset=UTF-8", null, noMagic(), "bat", "cmd"),    /** PowerShell 脚本(ps1):纯文本无魔数 */    PS1(Category.EXECUTABLE, octet(), null, noMagic(), "ps1"),    // =====================================================================================    // 字体    // =====================================================================================    /** TrueType(ttf):00 01 00 00,ttc 合集同头 */    TTF(Category.FONT, "font/ttf", null, magic("00010000"), "ttf", "ttc"),    /** OpenType(otf):"OTTO" */    OTF(Category.FONT, "font/otf", null, magic("4f54544f"), "otf"),    /** WOFF(woff):"wOFF" */    WOFF(Category.FONT, "font/woff", null, magic("774f4646"), "woff"),    /** WOFF2(woff2):"wOF2" */    WOFF2(Category.FONT, "font/woff2", null, magic("774f4632"), "woff2"),    /** EOT(eot):嵌入式私有格式,无简洁魔数 */    EOT(Category.FONT, octet(), null, noMagic(), "eot"),    // =====================================================================================    // 数据 / 数据库(DICOM 为医院影像场景核心格式)    // =====================================================================================    /** DICOM(dcm):128 字节前导(通常全 0)之后固定为 "DICM" */    DICOM(Category.DATA, "application/dicom", null, magic("4449434d@128"), "dcm", "dicom"),    /** SQLite(db):文件头 "SQLite format 3\0" */    SQLITE(Category.DATA, "application/x-sqlite3", null, magic("53514c697465"),            "db", "sqlite", "sqlite3", "db3"),    /** Parquet(parquet):"PAR1",大数据列存 */    PARQUET(Category.DATA, "application/vnd.apache.parquet", null, magic("50415231"), "parquet"),    /** Avro(avro):"Obj\x01" 容器头 */    AVRO(Category.DATA, octet(), null, magic("4f626a01"), "avro"),    /** 抓包文件(pcap):pcap 大小端两种 + pcapng */    PCAP(Category.DATA, "application/vnd.tcpdump.pcap", null,            magic("a1b2c3d4", "d4c3b2a1", "0a0d0d0a"), "pcap", "pcapng", "cap"),    /** Access(mdb):OLE2 容器;accdb 亦同头 */    MDB(Category.DATA, "application/x-msaccess", OLE2, noMagic(), "mdb", "accdb"),    /** Outlook PST(pst):OLE2 容器 */    PST(Category.DATA, "application/vnd.ms-outlook", OLE2, noMagic(), "pst"),    // =====================================================================================    // 兜底    // =====================================================================================    /** 无法识别:魔数与后缀都查不到时兜底,MIME 固定二进制流(强制浏览器下载而非内联预览) */    UNKNOWN(Category.UNKNOWN, octet(), null, noMagic());    // ================================ 实例字段 ================================    /** 分类,strict 白名单与 isXxx() 便捷判断的基础 */    privatefinal Category category;    /** 标准 MIME(文本类自带 charset) */    privatefinal String mimeType;    /** 底层容器根(细分类型声明自己挂在哪个容器下),可空 */    privatefinal FileType magicRoot;    /** 原始魔数声明("hex" / "hex@offset"),类加载时解析为字节 */    privatefinal String[] magicSpecs;    /** 后缀集合(小写、按声明序保序),首个为 defaultExtension() */    privatefinal Set<String> extensions;    /** 兜底二进制流 MIME */    privatestaticfinal String MIME_OCTET = "application/octet-stream";    /** 魔数表:全部类型的魔数汇总,按总深度倒序(越深越具体越先比) */    privatestaticfinal List<Magic> MAGICS;    /** 后缀 → 类型 反查索引(小写) */    privatestaticfinal Map<String, FileType> EXT_INDEX;    /** 魔数探测需要读到的最大字节数:表内最深魔数自动推得(当前为 ustar@257 的 262 字节) */    publicstaticfinalint PROBE_BYTES;    static {        List<Magic> magics = new ArrayList<>();        Map<String, FileType> extIndex = new HashMap<>();        int deepest = 16;        for(FileType type : values()) {            for(String spec : type.magicSpecs) {                Magic m = Magic.parse(spec, type);                magics.add(m);                deepest = Math.max(deepest, m.depth);            }            for(String ext : type.extensions) {                FileType prev = extIndex.putIfAbsent(ext, type);                if(prev != null) {                    // 启动期暴雷优于运行期静默错路由                    thrownew IllegalStateException(                            "duplicate extension [%s]: %s vs %s".formatted(ext, prev, type));                }            }        }        // 总深度倒序:WAV@8 先于 RIFF@0、heic@8 先于 ftyp@4、ustar@257 最先——长且深的魔数优先命中        magics.sort(Comparator.comparingInt((Magic m) -> m.depth).reversed());        MAGICS = Collections.unmodifiableList(magics);        EXT_INDEX = Map.copyOf(extIndex);        PROBE_BYTES = deepest;    }    /**     * @param category    分类     * @param mimeType    标准 MIME     * @param magicRoot   底层容器根(细分类型用;容器根自身传 null)     * @param magicSpecs  魔数声明("hex" / "hex@offset"),可为空数组     * @param extensions  后缀集合(自动转小写),可空     */    FileType(Category category, String mimeType, FileType magicRoot,             String[] magicSpecs, String... extensions) {        this.category = Objects.requireNonNull(category, "category");        this.mimeType = Objects.requireNonNull(mimeType, "mimeType");        this.magicRoot = magicRoot;        this.magicSpecs = magicSpecs.clone();        Set<String> ext = new LinkedHashSet<>(Math.max(4, extensions.length * 2));        for(String e : extensions) {            ext.add(e.toLowerCase(Locale.ROOT));        }        this.extensions = Collections.unmodifiableSet(ext);    }    // ================================ 静态查询 API ================================    /**     * 按文件头字节匹配真实类型(魔数探测核心)。     * <p>     * 只做前缀 / 深偏移的字节级比对,不读文件、不碰 IO —— IO 由 {@link FileTypeParser} 负责。     * 调用方需保证 {@code head} 至少读到 {@link #PROBE_BYTES} 字节(流提前 EOF 则以实际长度为准)。     *     * @param head 文件头字节(可为短于 PROBE_BYTES 的截断头)     * @return 命中的类型;null / 空 / 无命中 → {@link #UNKNOWN}     */    publicstatic FileType matchMagic(byte[] head){        if(head == null || head.length == 0) {            return UNKNOWN;        }        for(Magic m : MAGICS) {            if(m.matches(head)) {                return m.type;            }        }        return UNKNOWN;    }    /**     * 按后缀反查类型(大小写不敏感)。     *     * @param ext 后缀(不含点),null / 空 / 未收录 → null     */    publicstatic FileType byExtension(String ext){        if(ext == null || ext.isEmpty()) {            return null;        }        return EXT_INDEX.get(ext.toLowerCase(Locale.ROOT));    }    /**     * 按文件名反查类型:取最后一个点之后的部分。     * <p>隐藏文件(如 {@code .gitignore})、以点结尾的名字 → null。     *     * @param fileName 文件名(可含路径,只取最后一段的点后部分)     */    publicstatic FileType byFilename(String fileName){        if(fileName == null) {            return null;        }        int dot = fileName.lastIndexOf('.');        if(dot < 0 || dot == fileName.length() - 1) {            return null;        }        return byExtension(fileName.substring(dot + 1));    }    /**     * 魔数事实与后缀身份综合裁决 MIME(下载 / 响应头专用协商策略)。     * <p>     * 优先级从高到低:     * <ol>     *   <li><b>后缀身份获魔数背书</b>:后缀指向的类型与魔数命中类型自洽     *       (相等或后缀类型挂在该容器下,如 docx 后缀 + ZIP 魔数、mov 后缀 + MP4 魔数)     *       → 按后缀类型取 MIME(比容器头精确:docx ≠ application/zip);</li>     *   <li><b>魔数权威</b>:魔数命中(非 UNKNOWN)→ 按魔数类型取 MIME     *       (例:{@code .exe} 挂羊头卖 JPEG 内容 → 给 image/jpeg,让浏览器按真实内容处理);</li>     *   <li><b>后缀兜底</b>:魔数认不出(txt / csv / md 等无头文本)→ 按后缀取 MIME;</li>     *   <li><b>二进制流</b>:两者都无信息 → application/octet-stream,强制下载。</li>     * </ol>     *     * @param magicType 魔数探测结果(可为 {@code null} / {@link #UNKNOWN})     * @param fileName 文件名(用于后缀反查,可为 null)     * @return 永不为 null 的 MIME 字符串     */    publicstatic String resolveMimeType(FileType magicType, String fileName){        FileType byExt = byFilename(fileName);        if(byExt != null && byExt.accepts(magicType)) {            return byExt.mimeType;        }        if(magicType != null && magicType != UNKNOWN) {            return magicType.mimeType;        }        if(byExt != null) {            return byExt.mimeType;        }        return MIME_OCTET;    }    // ================================ 实例判断 API ================================    /**     * 「后缀身份」与「魔数事实」是否自洽(伪装 / 投毒检测)。     * <ul>     *   <li>相等 → 自洽;</li>     *   <li>魔数命中自己声明的容器根(docx 后缀 + ZIP 魔数 / svg 后缀 + XML 魔数)→ 自洽;</li>     *   <li>双方都是文本类(txt 挂 json 头、log 挂 xml 头等无魔数文本互改)→ 视为无害,自洽;</li>     *   <li>其余(如 jpg 后缀 + PNG 魔数、mp4 后缀 + EXE 魔数)→ 不自洽,典型改后缀投毒特征。</li>     * </ul>     *     * @param magicType 魔数探测结果,null → false(无信息不判自洽)     */    public boolean accepts(FileType magicType){        if(magicType == null) {            returnfalse;        }        if(this == magicType || this.magicRoot == magicType) {            returntrue;        }        // 纯文本互改后缀无安全意义(log→txt 查看很常见),不告警        returnthis.category == Category.TEXT && magicType.category == Category.TEXT;    }    /** 兜底后缀建议:取声明序第一个;无后缀的类型返回空串 */    public String defaultExtension(){        return extensions.isEmpty() ? "" : extensions.iterator().next();    }    // ================================ 分类便捷判断 ================================    /** 是否图片(svg 矢量图也算) */    public boolean isImage(){        return category == Category.IMAGE;    }    /** 是否视频 */    public boolean isVideo(){        return category == Category.VIDEO;    }    /** 是否音频(WAV / M4A 等独立成类型后不再依赖 RIFF 容器误报) */    public boolean isAudio(){        return category == Category.AUDIO;    }    /** 是否文档(office / openoffice / pdf / rtf / eml 等) */    public boolean isDocument(){        return category == Category.DOCUMENT;    }    /** 是否纯文本 */    public boolean isText(){        return category == Category.TEXT;    }    /** 是否压缩归档 */    public boolean isArchive(){        return category == Category.ARCHIVE;    }    /** 是否可执行 / 脚本(防投毒重点对象) */    public boolean isExecutable(){        return category == Category.EXECUTABLE;    }    // ================================ 私有工厂 / 内部结构 ================================    /** 魔数声明工厂:{@code magic("ffd8ff", "4449434d@128")} */    privatestatic String[] magic(String... specs) {        return specs;    }    /** 无魔数声明(txt / docx 等靠后缀或容器根识别的类型) */    privatestatic String[] noMagic() {        returnnew String[0];    }    /** 二进制流 MIME(以方法而非常量提供,规避枚举常量构造期的静态前向引用问题) */    privatestatic String octet(){        return MIME_OCTET;    }    /**     * 已解析的魔数:前缀或深偏移的字节序列。     *     * @param bytes  字节序列(小端 hex 解析)     * @param offset 文件内偏移,0 = 前缀魔数     * @param depth  总深度 = offset + bytes.length,倒序排序键     * @param type   命中后归属的类型     */    private record Magic(byte[] bytes, int offset, int depth, FileType type){        /** 解析 {@code "hex"} / {@code "hex@offset"} 声明 */        static Magic parse(String spec, FileType type){            int at = spec.indexOf('@');            String hex = at < 0 ? spec : spec.substring(0, at);            int offset = at < 0 ? 0 : Integer.parseInt(spec.substring(at + 1).trim());            byte[] bytes = HexFormat.of().parseHex(hex);            if(bytes.length == 0) {                thrownew IllegalArgumentException("empty magic spec: " + spec);            }            returnnew Magic(bytes, offset, offset + bytes.length, type);        }        /** 头部长度不足直接 false;逐字节比对,无临时对象 */        boolean matches(byte[] head){            if(head.length < depth) {                returnfalse;            }            for(int i = 0; i < bytes.length; i++) {                if(head[offset + i] != bytes[i]) {                    returnfalse;                }            }            returntrue;        }    }    /**     * 类型大类。strict 模式白名单与便捷判断的基础。     * <p>容器根(OLE2 / RIFF)单列 {@link #OTHER}:单凭容器头无法判断具体内容,     * 严格模式下不放行,由调用方结合后缀 / 二次解析决定。     */    publicenumCategory {        /** 图片 */        IMAGE,        /** 视频 */        VIDEO,        /** 音频 */        AUDIO,        /** 文档:office / openoffice / pdf / rtf / eml / epub / mobi */        DOCUMENT,        /** 纯文本:txt / csv / json / xml / html / md / 代码配置 */        TEXT,        /** 压缩归档 */        ARCHIVE,        /** 字体 */        FONT,        /** 结构化数据:DICOM / SQLite / Parquet / pcap / Access 等 */        DATA,        /** 可执行与脚本:exe / elf / class / jar / apk / msi / sh / bat / ps1 */        EXECUTABLE,        /** 容器根或其他无法归类的格式 */        OTHER,        /** 无法识别 */        UNKNOWN    }}

文件类型解析

根据上述文件类型魔数库枚举,我们就可以动态解析每个文件的类型了。

package com.feigo.common.utils.file;import lombok.extern.slf4j.Slf4j;import org.springframework.util.StringUtils;import java.io.BufferedInputStream;import java.io.File;import java.io.FileInputStream;import java.io.IOException;import java.io.InputStream;import java.nio.file.Files;import java.nio.file.Path;import java.util.HexFormat;/** * 文件类型解析器(IO 编排层)。 * <p> * 通过「文件头魔数(Magic Number)」判断真实类型,<b>不依赖文件名后缀</b>。 * 类型知识(后缀 / 魔数 / MIME)全部集中在 {@link FileType} 枚举,本类只负责: * 打开流 → 严格读满头部 → 调 {@link FileType#matchMagic(byte[])} → 异常兜底。 * <p> * 设计原则: * <ul> *   <li><b>只读头部 {@link FileType#PROBE_BYTES} 字节</b>:探测深度由枚举魔数表自动推得 *       (当前 262 字节,为 TAR 的 {@code ustar@257})。1KB 与 100GB 文件耗时相同, *       约 <b>一次</b>顺序读 + 微秒级字节比对,不 {@code length()}、不 {@code mmap}、 *       不 {@code readAllBytes},零大内存分配;</li> *   <li><b>字节级匹配</b>:枚举在类加载时把魔数解析为 {@code byte[]},运行期纯字节比对, *       无 hex 字符串转换、无装箱、零临时对象;</li> *   <li><b>严格 {@code readFully}</b>:规避 {@code InputStream.read(byte[])} 不保证读满 *       导致魔数错位;</li> *   <li><b>资源一律 try-with-resources</b>:不会泄漏文件描述符;</li> *   <li><b>只回答「头部像什么」,不回答「文件是否完整」</b>:OLE2 / ZIP / RIFF 这类 *       一头顶多身的容器统一报容器级类型,不臆断 doc / docx,细分交由 *       {@link FileType#resolveMimeType(FileType, String)} 结合后缀裁决。</li> * </ul> * <p> * 典型用法: * <pre>{@code *     // 1) 上传先落盘,再检测(大文件唯一推荐姿势) * *     Path tmp = Files.createTempFile("upload-", ".tmp"); *     Files.copy(request.getInputStream(), tmp, StandardCopyOption.REPLACE_EXISTING); *     FileType type = FileTypeParser.detect(tmp); * *     if (type.isImage()) { ... } *     if (type == FileType.DICOM) { *         // 影像归档入库 ... *     } *     if (type == FileType.OLE2) { *         // 需结合后缀 / 二次解析才能区分 doc / xls / ppt *     } * *     // 2) 已有输入流:务必 mark/reset,否则头部被吃掉 * *     BufferedInputStream bis = new BufferedInputStream(in, 8192); *     bis.mark(FileType.PROBE_BYTES + 1); *     FileType t = FileTypeParser.detect(bis); *     bis.reset(); * }</pre> * */@Slf4jpublicfinalclassFileTypeParser {    /**     * 魔数探测需要读到的最大字节数,由 {@link FileType} 魔数表自动推得。     * <p>给流式调用方 mark/reset 用:{@code bis.mark(FileTypeParser.MAGIC_MAX_BYTES + 1)}。     */    publicstaticfinalint MAGIC_MAX_BYTES = FileType.PROBE_BYTES;    /** 禁止实例化 */    privateFileTypeParser(){        thrownew UnsupportedOperationException("FileTypeParser is util class");    }    // ================================ 对外 API ================================    /**     * 按路径判断类型。     *     * @param path 文件路径;null / 空 / 不存在 / 是目录 → {@link FileType#UNKNOWN}     */    publicstatic FileType detect(String path){        if (!StringUtils.hasText(path)) {            return FileType.UNKNOWN;        }        return detect(Path.of(path));    }    /**     * 按 File 判断类型。     *     * @param file 文件;null → {@link FileType#UNKNOWN}     */    publicstatic FileType detect(File file){        return file == null ? FileType.UNKNOWN : detect(file.toPath());    }    /**     * 按 Path 判断类型。     *     * @param path 文件 Path;null / 不存在 / 是目录 → {@link FileType#UNKNOWN}     */    publicstatic FileType detect(Path path){        if (path == null || !Files.exists(path) || Files.isDirectory(path)) {            return FileType.UNKNOWN;        }        try (InputStream in = Files.newInputStream(path)) {            return detect(in);        } catch (IOException e) {            log.debug("read file header failed: {}", path, e);            return FileType.UNKNOWN;        }    }    /**     * 按输入流判断类型。     * <p>     * <b>⚠️ 会消费流的前 {@link FileType#PROBE_BYTES} 字节。</b> 若调用方后续还要读这个流     * (如上传解析、Multipart 处理),请先用 {@link BufferedInputStream} 包一层,     * 并在调用前 {@code mark(MAGIC_MAX_BYTES + 1)}、调用后 {@code reset()}。     * <p>     * 本方法<b>不会关闭</b>传入的流。     *     * @param in 输入流;null → {@link FileType#UNKNOWN}     */    publicstatic FileType detect(InputStream in){        if (in == null) {            return FileType.UNKNOWN;        }        byte[] head = readFully(in, MAGIC_MAX_BYTES);        if (head.length == 0) {            return FileType.UNKNOWN;        }        return match(head);    }    /**     * 返回文件头 hex 快照,用于排查 / 审计日志。     *     * @param path     文件     * @param maxBytes 最多读多少字节,≤0 按 {@link #MAGIC_MAX_BYTES} 处理     */    publicstatic String dumpHead(Path path, int maxBytes) throws IOException {        int n = maxBytes <= 0 ? MAGIC_MAX_BYTES : maxBytes;        try (InputStream in = Files.newInputStream(path)) {            return bytesToHex(readFully(in, n));        }    }    // ================================ 匹配核心 ================================    /**     * 字节级匹配魔数表,委托 {@link FileType#matchMagic(byte[])}。     * <p>包级可见:测试与同包工具直连字节匹配用,不碰 IO。     */    static FileType match(byte[] head){        return FileType.matchMagic(head);    }    // ================================ 字节 / 十六进制 ================================    /**     * 严格读满 {@code len} 字节;流提前结束则返回已读部分(长度 &lt; len)。     * <p>     * 为什么不直接 {@code in.read(buf)}:该方法<b>不保证读满</b>,网络流 / 分块流 / 压缩流     * 经常一次只给几个字节,直接拿去比魔数会整体错位。这里循环补齐,语义等同     * {@code DataInputStream#readFully(byte[])},但不抛 {@code EOFException}(更友好)。     */    static byte[] readFully(InputStream in, int len) {        if (len <= 0) {            returnnew byte[0];        }        byte[] buf = new byte[len];        int off = 0;        try {            while (off < len) {                int n = in.read(buf, off, len - off);                if (n == -1) {                    break; // 正常 EOF(文件比预期短,返回截断部分交给匹配器判未知)                }                off += n;            }        } catch (IOException e) {            log.debug("read header interrupted at {}/{}", off, len, e);        }        return off == len ? buf : trim(buf, off);    }    /** 截取有效部分,避免返回带尾随 0 的脏数组。 */    privatestatic byte[] trim(byte[] buf, int len) {        byte[] r = new byte[len];        System.arraycopy(buf, 0, r, 0, len);        return r;    }    /**     * 字节数组 → 小写十六进制字符串,无分隔符。     * <p>     * 例:{@code [0xFF, 0xD8, 0xFF]} → {@code "ffd8ff"}     * <p>     * 使用 JDK 17 {@link HexFormat}:查表实现,零临时 {@code StringBuilder} 逐字节拼接,GC 友好。     */    publicstatic String bytesToHex(byte[] src){        if (src == null || src.length == 0) {            return"";        }        return HexFormat.of().withLowerCase().formatHex(src);    }    // ================================ 可选:校验和(审计用) ================================    /**     * 计算 CRC32,用于秒传 / 完整性对账。     * <p>流式计算,固定内存占用,10GB 文件也无压力。下载链路默认不调用,按需主动调。     */    publicstaticlongcrc32(File file) throws IOException {        java.util.zip.CRC32 crc = new java.util.zip.CRC32();        byte[] buf = new byte[8 * 1024];        try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {            int len;            while ((len = in.read(buf)) != -1) {                crc.update(buf, 0, len);            }        }        return crc.getValue();    }}

文件下载

package com.feigo.common.utils.file;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import lombok.extern.slf4j.Slf4j;import org.springframework.util.StringUtils;import org.springframework.web.util.UriUtils;import java.io.BufferedOutputStream;import java.io.File;import java.io.IOException;import java.io.InputStream;import java.io.RandomAccessFile;import java.nio.charset.StandardCharsets;import java.util.EnumSet;import java.util.Set;importstatic jakarta.servlet.http.HttpServletResponse.*;/** * 文件下载器(HTTP Range 断点续传 / 分片下载 + 文件头魔数校验)。 * <p> * 支持两类数据源: * <ul> *   <li><b>本地文件</b>({@code download(File,...)}):魔数嗅探 + strict 白名单 + *       {@link RandomAccessFile#seek} 断点续传;</li> *   <li><b>远端流式源</b>({@code download(RemoteSource,...)},C1 直通): *       区间透传给上游,Java 仅 8KB 搬运,零落盘、零全量进堆; *       整文件请求时对上游开 262B 探测窗做魔数嗅探(探测字节随后照常写出), *       分片请求不探测,Content-Type 依据元数据后缀。</li> * </ul> * 两种形态共享:Range 解析(0-499 / 500- / -500)、416/206 语义、RFC 5987 附件名、 * 客户端断连静默、X-File-Size / X-File-Name 扩展头。 * <p> * 使用示例: * <pre>{@code * // 1) 本地文件(原有用法不变) * FileDownloader.download(new File("/data/a.jpg"), request, response); * * // 2) 远端直通:总长已知(推荐,零额外往返) * RemoteSource src = HttpSource.of(openUrl, totalSize, "视频.mp4"); * FileDownloader.download(src, request, response);          // 整文件 + 可选探测 * // 浏览器断点续传时自动变为 open(pos, last) 透传,无需任何额外代码 * * // 3) URL 一行接入:总长自动探测(失败自动降级 chunked) * FileDownloader.download(openUrl, "视频.mp4", request, response); * }</pre> * */@Slf4jpublicclassFileDownloader {    /** 读取缓冲:8KB。高并发大文件可提到 64KB,单机低并发保持 8KB 更省 */    privatestaticfinalint BUFFER_SIZE = 8 * 1024;    /** Range 协议前缀 */    privatestaticfinal String RANGE_BYTES = "bytes=";    /** 默认兜底类型:强制浏览器下载而非内联预览 */    privatestaticfinal String DEFAULT_CONTENT_TYPE = "application/octet-stream";    /**     * strict 模式(上传防投毒)放行的类型大类白名单。     * <p>     * 图片 / 音视频 / 文档(含 pdf、doc/docx、rtf 等)/ 压缩包 / 数据文件(DICOM 影像、     * SQLite、Parquet 等)放行;以下一律 403:     * <ul>     *   <li>{@link FileType.Category#EXECUTABLE}:exe / elf / class / jar / apk / msi / sh / bat / ps1;</li>     *   <li>{@link FileType.Category#TEXT}:无魔数文本及 html(防钓鱼页);</li>     *   <li>{@link FileType.Category#FONT} 与 {@link FileType.Category#OTHER}(OLE2 / RIFF 裸容器);</li>     *   <li>魔数无法识别的 {@link FileType#UNKNOWN}。</li>     * </ul>     */    privatestaticfinal Set<FileType.Category> STRICT_ALLOWED_CATEGORIES = EnumSet.of(            FileType.Category.IMAGE,            FileType.Category.VIDEO,            FileType.Category.AUDIO,            FileType.Category.DOCUMENT,            FileType.Category.ARCHIVE,            FileType.Category.DATA);    /** 私有构造,工具类禁止实例化 */    privateFileDownloader(){        thrownew UnsupportedOperationException("FileDownloader is util class");    }    // ================================ 对外 API:本地文件(原签名全保留) ================================    /**     * 下载文件,使用文件本身名作为附件名。     * <p>非严格模式:只修正 Content-Type,不拦截任何文件(公共下载接口用这个)。     *     * @param file     待下载文件(必须存在且为普通文件)     * @param request  请求,用于解析 Range 头     * @param response 响应,直接写出文件流     */    publicstaticvoiddownload(File file,                                HttpServletRequest request,                                HttpServletResponse response) throws IOException {        download(file, null, request, response, false);    }    /**     * 下载文件,可指定附件名(建议:磁盘名是 uuid 时传原始文件名)。     * <p>非严格模式。     *     * @param file        待下载文件     * @param attachName  附件名,null 则取 {@code file.getName()}     * @param request     请求     * @param response    响应     */    publicstaticvoiddownload(File file,                                String attachName,                                HttpServletRequest request,                                HttpServletResponse response) throws IOException {        download(file, attachName, request, response, false);    }    /**     * 带「类型校验开关」的本地文件下载入口(原行为不变)。     *     * @param file        待下载文件     * @param attachName  附件名,null 取磁盘名     * @param request     请求     * @param response    响应     * @param strict      true  = 严格模式,魔数不在白名单大类直接 403(上传防投毒)     *                    false = 只修正 Content-Type,识别不出也不拦(公共下载)     */    publicstaticvoiddownload(File file,                                 String attachName,                                 HttpServletRequest request,                                 HttpServletResponse response,                                 boolean strict) throws IOException {        if (file == null || !file.exists() || file.isDirectory()) {            log.warn("download failed, file not exist or is directory: {}", file);            response.sendError(SC_NOT_FOUND);            return;        }        downloadInternal(LocalSource.of(file),                StringUtils.hasText(attachName) ? attachName : file.getName(), strict,                request, response);    }    // ================================ 对外 API:远端直通(C1) ================================    /**     * 远端流式直通下载(整文件;附件名取 {@link RemoteSource#name()})。     * <p>内存 O(8KB),磁盘 0;Range 请求区间透传上游。适合 OSS / 内网 HTTP 资源。     */    publicstaticvoiddownload(RemoteSource source,                                 HttpServletRequest request,                                 HttpServletResponse response) throws IOException {        download(source, null, request, response, false);    }    /**     * 远端流式直通下载(整文件,可指定附件名)。     */    publicstaticvoiddownload(RemoteSource source,                                 String attachName,                                 HttpServletRequest request,                                 HttpServletResponse response) throws IOException {        download(source, attachName, request, response, false);    }    /**     * 远端流式直通下载(C1),唯一真正干活的远端入口。     * <p>     * 流程:size 越界/缺失校验 → 头部探测(仅整文件请求,262B 窗)→ 严格白名单 →     * Content-Type / 附件名 / Range 响应头 → 打开上游区间流直通写出。     * <ul>     *   <li>{@code size() < 0}:无 Range 支持的降级形态——无 Range 头时整文件直通;     *       带 Range 头时忽略(退整文件),Content-Length 省略(chunked);</li>     *   <li>上游 {@code open(pos,last)} 抛 IOException(含 from 越界):按 416 处理;</li>     *   <li>strict 拦截:魔数已知且不在白名单大类,或未知类型 → 403;分片请求因无法     *       探测,strict 下按元数据后缀白名单判定(后缀不可识别时 403)。</li>     * </ul>     */    publicstaticvoiddownload(RemoteSource source,                                 String attachName,                                 HttpServletRequest request,                                 HttpServletResponse response,                                 boolean strict) throws IOException {        if (source == null) {            log.warn("download failed, remote source is null");            response.sendError(SC_NOT_FOUND);            return;        }        String fileName = StringUtils.hasText(attachName) ? attachName : source.name();        downloadInternal(source, fileName, strict, request, response);    }    // ================================ 对外 API:URL 直通(业务侧一行接入) ================================    /**     * URL 直通下载(最简形态):附件名取 URL 末段,总长自动探测,非严格模式。     * <p>业务侧最常用的一行接入:     * <pre>FileDownloader.download(openUrl, request, response);</pre>     */    publicstaticvoiddownload(String url,                                 HttpServletRequest request,                                 HttpServletResponse response) throws IOException {        download(url, null, request, response, false);    }    /**     * URL 直通下载(可指定附件名),非严格模式。     */    publicstaticvoiddownload(String url,                                 String attachName,                                 HttpServletRequest request,                                 HttpServletResponse response) throws IOException {        download(url, attachName, request, response, false);    }    /**     * URL 直通下载(C1):内部包 {@link HttpSource},总长惰性探测,Range 区间透传,     * 内存 O(8KB)、零落盘。探测失败自动降级 chunked(无 Content-Length、忽略 Range)。     *     * @param url        上游 URL(http/https)     * @param attachName  附件名;空则取 URL 末段     * @param strict      true = 严格模式(魔数不在白名单大类直接 403)     */    publicstaticvoiddownload(String url,                                 String attachName,                                 HttpServletRequest request,                                 HttpServletResponse response,                                 boolean strict) throws IOException {        if (!StringUtils.hasText(url)) {            log.warn("download failed, url is blank");            response.sendError(SC_NOT_FOUND);            return;        }        String fileName = StringUtils.hasText(attachName) ? attachName : HttpSource.urlLastName(url);        download(HttpSource.of(url, fileName), fileName, request, response, strict);    }    // ================================ 内核(两种数据源共用) ================================    privatestaticvoiddownloadInternal(RemoteSource source,                                          String fileName,                                          boolean strict,                                          HttpServletRequest request,                                          HttpServletResponse response) throws IOException {        long fileSize = source.size();        // ---------- Range 解析(size<0 时强制整文件形态) ----------        Range range = fileSize > 0                ? parseRange(request, fileSize)                : new Range(0, -1, false);        boolean fullRequest = !range.partial() && range.pos() == 0;        // ---------- 魔数嗅探(仅整文件请求;分片无文件头不探测);        //             探测用的同一个流直接续接写出 —— 对上游只发一次 open ----------        Probe probe = fullRequest ? openProbe(source) : Probe.EMPTY;        FileType magicType = FileTypeParser.match(probe.bytes());        // ---------- strict / Content-Type / 审计;失败关闭探测流并返回 ----------        sniffAndEnrich(magicType, fileName, response, strict);        if (response.isCommitted() || response.getStatus() >= 400) {            probe.close();            return;        }        // ---------- 响应头(416 提前 return,需先关探测流) ----------        setHeaders(response, fileName, Math.max(fileSize, 0), range);        if (response.getStatus() == SC_REQUESTED_RANGE_NOT_SATISFIABLE) {            probe.close();            return;        }        // ---------- 直通写出 ----------        writeRange(source, range, probe, response);    }    /** 探测窗结果:魔数字节快照 + 仍打开的上游流(供写出阶段续用,避免二次 open) */    private record Probe(byte[] bytes, InputStream stream){        staticfinal Probe EMPTY = new Probe(new byte[0], null);        voidclose(){            if (stream != null) {                try {                    stream.close();                } catch (IOException ignored) {                }            }        }    }    /**     * 整文件请求:打开 [0, size-1],读满 262B 探测窗。     * <p>流不关闭,供后续写出续用 —— 一次 open 完成“探测 + 直通”。     * 上游不可读时返回 EMPTY(退化后缀判型 + 写出阶段重新 open)。     */    privatestatic Probe openProbe(RemoteSource source){        if (source.size() == 0) {            return Probe.EMPTY;        }        InputStream in;        try {            in = source.size() > 0                    ? source.open(0, source.size() - 1)                    : source.open(0, -1);        } catch (IOException e) {            log.debug("probe open failed, degrade by extension: {}", source.name(), e);            return Probe.EMPTY;        }        if (in == null) {            return Probe.EMPTY;        }        byte[] head = FileTypeParser.readFully(in, RemoteSource.PROBE_SIZE);        returnnew Probe(head, in);    }    /**     * 策略层:strict 裁决(403)+ Content-Type 协商 + 伪装审计。     * <p>只消费已判定的魔数结果,不碰数据源;任何 IO 都已由上层处理。     */    privatestaticvoidsniffAndEnrich(FileType magicType,                                       String fileName,                                       HttpServletResponse response,                                       boolean strict) throws IOException {        // —— strict 裁决(整文件请求按魔数;分片/探测失败时 magicType 为 UNKNOWN,退化为后缀白名单) ——        if (strict) {            FileType verdict = magicType;            if (verdict == FileType.UNKNOWN) {                verdict = FileType.byFilename(fileName);            }            if (verdict == null || !isStrictAllowed(verdict)) {                log.warn("strict reject: magic={}, file={}", magicType, fileName);                response.sendError(SC_FORBIDDEN, "file type not allowed");                return;            }        }        // —— Content-Type:魔数与后缀综合裁决,永不 null ——        response.setContentType(FileType.resolveMimeType(magicType, fileName));        // —— 安全审计:魔数事实 vs 后缀身份 不自洽(改后缀投毒特征,只告警不阻断) ——        if (isMismatch(magicType, fileName)) {            FileType byExt = FileType.byFilename(fileName);            log.warn("magic/extension mismatch: magic={}, extType={}, name={}",                    magicType, byExt, fileName);        }    }    /**     * 按区间写出:远端 = 区间透传;本地 File = seek+read。     * <p>统一在 {@link RemoteSource#open(long, long)} 契约上;     * 8KB 循环缓冲,零全量、零落盘;断连静默。     * <p>416 / 无效区间:不写 body(提前 return)。     * size 未知(size<0)时 chunked 整文件直通。     * <p>整文件请求时优先续用探测阶段已打开的流(单次上游请求);     * 探测失败(EMPTY)时写出阶段重新 open。     */    privatestaticvoidwriteRange(RemoteSource source,                                   Range range,                                   Probe probed,                                   HttpServletResponse response) throws IOException {        long remain;        boolean unknownSize;        if (range.last() < 0) {            // size 未知:chunked 整文件            remain = -1;            unknownSize = true;        } else {            if (range.last() < range.pos()) {                return;                       // 416 已处理,无 body            }            remain = range.last() - range.pos() + 1;            if (remain <= 0) {                return;            }            remain -= probed.bytes().length;            if (remain < 0) {                remain = 0;            }            unknownSize = false;        }        InputStream in = probed.stream();        if (in == null) {            // 分片请求 / 探测失败:写出阶段新开区间流            in = unknownSize                    ? source.open(0, -1)                    : source.open(range.pos() + probed.bytes().length, range.last());        }        if (in == null) {            log.warn("remote source returned null stream: {}", source.name());            return;        }        try (InputStream ignored = in;             BufferedOutputStream out = new BufferedOutputStream(                     response.getOutputStream(), BUFFER_SIZE)) {            if (probed.bytes().length > 0) {                out.write(probed.bytes(), 0, probed.bytes().length);                out.flush();            }            byte[] buf = new byte[BUFFER_SIZE];            int len;            while (unknownSize || remain > 0) {                int want = unknownSize ? buf.length : (int) Math.min(buf.length, remain);                if ((len = in.read(buf, 0, want)) == -1) {                    break;                }                out.write(buf, 0, len);                if (!unknownSize) {                    remain -= len;                }                out.flush();            }            log.debug("download done, range={}-{}, src={}", range.pos(), range.last(), source.name());        } catch (IOException e) {            if (isClientAbort(e)) {                log.debug("client aborted, range={}-{}, src={}",                        range.pos(), range.last(), source.name());            } else {                throw e;            }        }    }    // ================================ strict / 伪装判定(原逻辑保留) ================================    /** strict 白名单判定:UNKNOWN 或大类不在白名单即不放行 */    privatestatic boolean isStrictAllowed(FileType type){        return type != FileType.UNKNOWN                && STRICT_ALLOWED_CATEGORIES.contains(type.getCategory());    }    /**     * 魔数事实与后缀身份是否矛盾。     * <p>例:{@code .exe} 但魔数是 MP4,或 {@code .mp4} 魔数是 EXE —— 典型投毒特征。     */    privatestatic boolean isMismatch(FileType magicType, String fileName){        if (magicType == null || magicType == FileType.UNKNOWN) {            returnfalse;        }        FileType byExt = FileType.byFilename(fileName);        return byExt != null && !byExt.accepts(magicType);    }    // ================================ Range 解析(原逻辑保留) ================================    /**     * 解析 {@code Range} 头,兼容三种写法:     * <ul>     *   <li>{@code bytes=0-499}   → 前缀 500 字节</li>     *   <li>{@code bytes=500-}    → 从 500 到末尾</li>     *   <li>{@code bytes=-500}    → 末尾 500 字节</li>     * </ul>     * 无 Range 头或格式非法 → 降级为整文件下载,不中断请求。     */    privatestatic Range parseRange(HttpServletRequest request, long fileSize){        long pos = 0;        long last = fileSize - 1;        String rangeHeader = request.getHeader("Range");        if (!StringUtils.hasText(rangeHeader) || !rangeHeader.startsWith(RANGE_BYTES)) {            returnnew Range(pos, last, false);        }        String numRange = rangeHeader.substring(RANGE_BYTES.length()).trim();        String[] parts = numRange.split("-", -1); // 保留尾部空串,区分 "500-" 和 "500"        boolean ok = true;        try {            if (parts.length == 2 && !parts[0].isEmpty()) {                // bytes=start-end  /  bytes=start-                pos = Long.parseLong(parts[0].trim());                if (!parts[1].isEmpty()) {                    last = Long.parseLong(parts[1].trim());                }            } elseif (numRange.startsWith("-")) {                // bytes=-500 → 末尾 500 字节                long suffix = Long.parseLong(parts[parts.length - 1].trim());                pos = Math.max(0, fileSize - suffix);            } else {                // 格式不对,降级整文件                ok = false;                pos = 0;                last = fileSize - 1;            }        } catch (NumberFormatException e) {            log.debug("invalid Range header: {}", rangeHeader, e);            ok = false;            pos = 0;            last = fileSize - 1;        }        // 边界保护:end 越界截断到末尾;start 越界交由调用方返回 416        if (pos < 0) {            pos = 0;        }        if (last > fileSize - 1) {            last = fileSize - 1;        }        // start 越界 → 416 标记:partial 仍为 true,setHeaders 会处理        if (pos >= fileSize) {            returnnew Range(pos, last, true);        }        returnnew Range(pos, last, ok);    }    // ================================ 响应头(原逻辑保留) ================================    /**     * 设置下载响应头。     * <ul>     *   <li>Content-Type 已存在则不覆盖(交给 {@link #sniffAndEnrich} 设的真值)</li>     *   <li>附件名用 RFC 5987 的 {@code filename*=UTF-8''...},中文 / 空格不乱码</li>     *   <li>声明 {@code Accept-Ranges: bytes},下载器据此开启断点续传</li>     *   <li>分片 → 206 + Content-Range;整文件 → 200</li>     *   <li>size 未知(<0)时不发 Content-Length(chunked),避免骗浏览器</li>     * </ul>     */    privatestaticvoidsetHeaders(HttpServletResponse response,                                   String fileName,                                   long fileSize,                                   Range range) {        if (response.getContentType() == null) {            response.setContentType(DEFAULT_CONTENT_TYPE);        }        // UriUtils 不会像 URLEncoder 那样把空格变 '+',更稳        String encodedName = UriUtils.encode(fileName, StandardCharsets.UTF_8);        response.setHeader("Content-Disposition",                "attachment; filename*=UTF-8''" + encodedName);        response.setHeader("Accept-Ranges", "bytes");        if (fileSize > 0) {            response.setHeader("X-File-Size", String.valueOf(fileSize));        }        response.setHeader("X-File-Name", encodedName);        if (fileSize <= 0) {            // size 未知:chunked 形态,不谎报 Content-Length / 不碰 416 / 200            response.setStatus(SC_OK);            return;        }        long contentLen = range.last() - range.pos() + 1;        if (range.partial()) {            if (contentLen <= 0 || range.pos() >= fileSize) {                // 416:起始越界,不写 body                response.setStatus(SC_REQUESTED_RANGE_NOT_SATISFIABLE);                response.setHeader("Content-Range", "bytes */" + fileSize);                return;            }            // 206:区间数据            response.setStatus(SC_PARTIAL_CONTENT);            response.setHeader("Content-Range",                    "bytes %d-%d/%d".formatted(range.pos(), range.last(), fileSize));        }        response.setHeader("Content-Length", String.valueOf(contentLen));    }    // ================================ 断连判定(原逻辑保留) ================================    /**     * 判断是否为客户端主动中断。     * <p>     * Tomcat 抛 {@code ClientAbortException};其他容器(Jetty / Undertow / WebLogic)     * 多是 {@code "Broken pipe"} / {@code "Connection reset"} 消息,统一收敛。     */    privatestatic boolean isClientAbort(IOException e){        if (e instanceof org.apache.catalina.connector.ClientAbortException) {            returntrue;        }        String msg = e.getMessage();        if (msg == null) {            returnfalse;        }        return msg.contains("Broken pipe")                || msg.contains("Connection reset")                || msg.contains("client closed")                || msg.contains("closed by remote host");    }    // ================================ 本地 File 适配(行为与旧版一致) ================================    /**     * 本地文件适配为 {@link RemoteSource}:旧公开 API 的内部桥。     * <p>size = length();open(from,to) = RandomAccessFile.seek + 读;     * 魔数探测等效旧版(前 262B)。     */    staticfinalclassLocalSourceimplementsRemoteSource {        privatefinal File file;        privateLocalSource(File file){            this.file = file;        }        static LocalSource of(File file){            returnnew LocalSource(file);        }        @Override        publiclongsize(){            return file.length();        }        @Override        public InputStream open(long from, long to) throws IOException {            RandomAccessFile raf = new RandomAccessFile(file, "r");            raf.seek(from);            returnnew InputStream() {                long remain = to < 0 ? Long.MAX_VALUE : to - from + 1;                @Override                publicintread() throws IOException {                    byte[] b = new byte[1];                    int n = read(b, 0, 1);                    return n == -1 ? -1 : (b[0] & 0xff);                }                @Override                publicintread(byte[] b, int off, int len) throws IOException {                    if (remain <= 0) {                        return-1;                    }                    int want = (int) Math.min(len, remain);                    int n = raf.read(b, off, want);                    if (n > 0) {                        remain -= n;                    }                    return n;                }                @Override                publicvoidclose() throws IOException {                    raf.close();                }            };        }        @Override        public String name(){            return file.getName();        }    }    // ================================ 区间值对象(record 化) ================================    /**     * 本次下载的区间。     *     * @param pos      起始字节(含)     * @param last     结束字节(含);-1 = 未知大小(chunked 整文件)     * @param partial  true = 分片请求,回 206;false = 整文件,回 200     */    private record Range(long pos, long last, boolean partial){}}
package com.feigo.common.utils.file;import lombok.extern.slf4j.Slf4j;import java.io.IOException;import java.io.InputStream;import java.net.URI;import java.net.URLDecoder;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;import java.nio.charset.StandardCharsets;import java.time.Duration;/** * HTTP 远端数据源({@link RemoteSource} 的现成实现)。 * <p> * 每次区间请求都会向上游发起<b>新的</b> HTTP 连接并透传 {@code Range: bytes=pos-last}: * <ul> *   <li>上游 2xx:返回响应体流(由 {@link FileDownloader} 消费并关闭);</li> *   <li>上游 4xx/5xx:抛 {@link IOException}(带状态码),下载器上层按需转 416/502。</li> * </ul> * 零落盘、零全量缓冲(8KB 搬运),适合「内网静态资源 / nginx 代理路径 / OSS 开放端点」。 * <p> * 总长(size)的三种形态: * <ul> *   <li>{@link #of(String, long, String)} 且 size &gt;= 0:调用方已知总长(库表 fileSize 等), *       零额外往返,推荐形态;</li> *   <li>{@link #of(String, long, String)} 且 size = -1:显式「未知」,不探测,下载时 chunked 降级;</li> *   <li>{@link #of(String, String)}:总长未知,首次 {@link #size()} 时向上游<b>惰性探测</b>一次 *       (GET + {@code Range: bytes=0-0}:206 时解析 Content-Range 尾段,200 时解析 Content-Length, *       兼容「忽略 Range 的上游」),探测失败静默降级 -1。业务侧无需自己发探测请求, *       也能拿到完整 Content-Length / Range / 416 语义。</li> * </ul> * 使用示例: * <pre>{@code * // 1) 总长已知(推荐,零额外往返) * RemoteSource src = HttpSource.of(openUrl, fileSize, "视频.mp4"); * FileDownloader.download(src, "视频.mp4", request, response); * * // 2) 一行接入:总长自动探测(失败自动降级 chunked) * FileDownloader.download(openUrl, "视频.mp4", request, response); * }</pre> * */@Slf4jpublicfinalclassHttpSourceimplementsRemoteSource {    /** 进程级共享 HttpClient:JDK17 起 HttpClient 线程安全,连接池复用 */    privatestaticfinal HttpClient SHARED = HttpClient.newBuilder()            .connectTimeout(Duration.ofSeconds(10))            .followRedirects(HttpClient.Redirect.NORMAL)            .build();    privatefinal URI uri;    privatefinallong size;    privatefinal String name;    /** true = size&lt;0 时由 {@link #size()} 惰性探测并缓存 */    privatefinal boolean allowProbe;    /** 惰性探测缓存(并发首次调用最多多探测一次,幂等无害) */    privatevolatile Long probedSize;    privateHttpSource(URI uri, long size, String name, boolean allowProbe){        this.uri = uri;        this.size = size;        this.name = name == null || name.isBlank() ? "download.bin" : name;        this.allowProbe = allowProbe;    }    /**     * @param url  上游 URL(http/https)     * @param size 总字节数;不知道传 -1(不探测,下载时 chunked 降级)     * @param name 附件名兜底     */    publicstatic HttpSource of(String url, long size, String name){        returnnew HttpSource(URI.create(url), size, name, false);    }    /**     * 总长未知版:首次 {@link #size()} 时向上游惰性探测一次     * (GET + Range: bytes=0-0,206 取 Content-Range 尾段,200 取 Content-Length,     * 兼容忽略 Range 的上游);探测失败降级 -1(下载时 chunked)。     */    publicstatic HttpSource of(String url, String name){        returnnew HttpSource(URI.create(url), -1, name, true);    }    @Override    publiclongsize(){        if (size >= 0) {            return size;        }        if (!allowProbe) {            return-1;        }        Long v = probedSize;        if (v == null) {            v = probeSize();            probedSize = v;        }        return v;    }    @Override    public InputStream open(long from, long to) throws IOException {        HttpRequest.Builder b = HttpRequest.newBuilder(uri)                .timeout(Duration.ofSeconds(60))                .GET();        if (from > 0 || to >= 0) {            // to=-1 且 from>0 不能表达(HTTP Range 无「从 N 到无穷」的省略式),            // from=0 & to=-1(整文件)也不需要 Range 头            if (to >= 0) {                b.header("Range", "bytes=%d-%d".formatted(from, to));            } elseif (from > 0) {                b.header("Range", "bytes=%d-".formatted(from));            }        }        HttpResponse<InputStream> resp;        try {            resp = SHARED.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());        } catch (InterruptedException e) {            Thread.currentThread().interrupt();            thrownew IOException("http source interrupted: " + uri, e);        }        if (resp.statusCode() / 100 != 2) {            try (InputStream ignored = resp.body()) {                thrownew IOException("http source not 2xx: "                        + resp.statusCode() + " " + uri);            }        }        return resp.body();    }    @Override    public String name(){        return name;    }    // ================================ 头部探测(供魔数探测 / 元数据展示等场景) ================================    /**     * 只读文件头部字节快照,附带尽力解析的文件总长,零落盘、零全量缓冲。     * <p>     * 上游支持 Range 时仅取头部区间(206);忽略 Range 的上游拿 200 头部后立即断流,     * 两种形态均只发生一次轻量往返。     *     * @param url      上游 URL     * @param maxBytes 读取的头部字节数(&lt;=0 时只解析响应头,不读 body)     * @return head = 头部字节快照;totalSize = 文件总长(尽力解析,未知为 -1)     */    publicstatic HttpHead probe(String url, int maxBytes) throws IOException {        URI u = URI.create(url);        HttpRequest.Builder b = HttpRequest.newBuilder(u)                .timeout(Duration.ofSeconds(60))                .GET();        if (maxBytes > 0) {            b.header("Range", "bytes=0-" + (maxBytes - 1));        }        HttpResponse<InputStream> resp;        try {            resp = SHARED.send(b.build(), HttpResponse.BodyHandlers.ofInputStream());        } catch (InterruptedException e) {            Thread.currentThread().interrupt();            thrownew IOException("http probe interrupted: " + u, e);        }        if (resp.statusCode() / 100 != 2) {            try (InputStream ignored = resp.body()) {                thrownew IOException("http probe not 2xx: "                        + resp.statusCode() + " " + u);            }        }        long total = parseTotalSize(resp);        try (InputStream in = resp.body()) {            returnnew HttpHead(maxBytes > 0 ? in.readNBytes(maxBytes) : new byte[0], total);        }    }    /** {@link #probe(String, int)} 的结果:头部字节快照 + 文件总长(未知为 -1) */    public record HttpHead(byte[] head, long totalSize){}    /**     * URL 末段作为兜底附件名(UTF-8 解码;解码失败退回原始段;空路径用 download.bin)。     * 供 {@link FileDownloader#download(String, jakarta.servlet.http.HttpServletRequest,     * jakarta.servlet.http.HttpServletResponse)} 等无附件名入参的场景。     */    publicstatic String urlLastName(String url){        String path;        try {            path = URI.create(url).getPath();        } catch (IllegalArgumentException e) {            return"download.bin";        }        if (path == null || path.isEmpty()) {            return"download.bin";        }        int slash = path.lastIndexOf('/');        String seg = slash >= 0 ? path.substring(slash + 1) : path;        if (seg.isEmpty()) {            return"download.bin";        }        try {            return URLDecoder.decode(seg, StandardCharsets.UTF_8);        } catch (IllegalArgumentException e) {            return seg;        }    }    // ================================ 内部 ================================    /** 惰性探测一次总长:2xx 才解析;失败(含网络异常)返回 -1 走 chunked 降级 */    privatelongprobeSize(){        try {            HttpResponse<InputStream> resp = SHARED.send(                    HttpRequest.newBuilder(uri)                            .timeout(Duration.ofSeconds(30))                            .header("Range", "bytes=0-0")                            .GET().build(),                    HttpResponse.BodyHandlers.ofInputStream());            try (InputStream ignored = resp.body()) {                if (resp.statusCode() / 100 != 2) {                    return-1;                }                return parseTotalSize(resp);            }        } catch (IOException e) {            log.debug("probe size failed, degrade to chunked: {}", uri, e);            return-1;        } catch (InterruptedException e) {            Thread.currentThread().interrupt();            return-1;        }    }    /**     * 文件总长解析:优先 Content-Range 尾段(206 形态 {@code bytes 0-0/N}),     * 其次 Content-Length(200 全量形态);均缺失返回 -1。     */    privatestaticlongparseTotalSize(HttpResponse<?> resp){        String contentRange = resp.headers().firstValue("Content-Range").orElse(null);        if (contentRange != null) {            int slash = contentRange.lastIndexOf('/');            if (slash >= 0 && slash + 1 < contentRange.length()) {                try {                    return Long.parseLong(contentRange.substring(slash + 1).trim());                } catch (NumberFormatException ignored) {                    // fall through 到 Content-Length                }            }        }        return resp.headers().firstValue("Content-Length")                .map(v -> {                    try {                        return Long.parseLong(v);                    } catch (NumberFormatException e) {                        return-1L;                    }                })                .orElse(-1L);    }}
package com.feigo.common.utils.file;import java.io.IOException;import java.io.InputStream;/** * 远端数据源抽象:让 {@link FileDownloader} 以「流式直通」形态下载非本地文件。 * <p> * 设计动机:下载器原有的随机读能力({@code RandomAccessFile.seek})只对本地磁盘文件成立。 * 对象存储(MinIO/S3)、内网 HTTP 资源等远端数据源的断点续传<b>不需要本地 seek</b>—— * 每个 HTTP Range 请求都是一条新连接,把区间 <b>透传</b>给上游即可: * <ul> *   <li>上游承担随机读(S3 Range GET),Java 只搬运,零落盘、零全量缓冲;</li> *   <li>整文件请求时下载器会先 {@link #open(long, long) open(0, size-1)}, *       借 {@link #PROBE_SIZE} 探测窗做魔数嗅探(262B 常量,见 {@link FileTypeParser});</li> *   <li>分片请求({@code bytes=500-})没有文件头,不做魔数探测,Content-Type 依赖元数据后缀。</li> * </ul> * 实现方只需回答三个问题:多大、怎么打开指定区间、叫什么名字(附件名兜底)。 * <p> * 使用示例(C1 直通,见 OSS 方案文档 §6.4): * <pre>{@code * // 1) upload 服务内已有的 S3RangeSource(可复用 OssClient.downloadWithRange 的产物) * FileDownloader.download(new S3RangeSource(bucket, key, totalSize, client), *                         originalName, request, response, strict); * * // 2) 任意 HTTP 资源(nginx 开放端点 / 内网静态资源) * FileDownloader.download(HttpSource.of("http://171.14.0.182:20000/prod-api/openaccess/....jpg", *                                      "宣传图.jpg", 63864), *                         "宣传图.jpg", request, response); * }</pre> * */public interface RemoteSource {    /** 魔数探测窗大小:与 {@link FileTypeParser#MAGIC_MAX_BYTES} 同源(ustar@257 → 262B) */    int PROBE_SIZE = FileType.PROBE_BYTES;    /**     * 数据源总字节数。     * <p>下载器用它解析 / 截断 Range 与计算 416 / Content-Range / Content-Length。     * 上游不提供时返回 -1(此时 Range 降级为整文件、传输退化为 chunked)。     */    longsize();    /**     * 打开 [from, to] 闭区间的字节流(to = -1 表示读到末尾)。     * <p>     * 约定:     * <ul>     *   <li>对本实现的每次调用都应发起<b>新的</b>上游请求(不复用旧流);</li>     *   <li>返回的流由下载器负责关闭;</li>     *   <li>from 越界时抛 {@link IOException},下载器上层转为 416。</li>     * </ul>     * 下载器保证:from 无条件为 0 当且仅当请求为整文件(探测窗场景)。     */    InputStream open(long from, long to) throws IOException;    /**     * 附件名兜底(调用方显式传了 attachName 时不会被用到)。     * <p>远端源的“文件名”往往在元数据里,实现方给一个有语义的值。     */    String name();}

最后

核心代码全在上面了,欢迎大家收藏保存!

END

基本 文件 流程 错误 SQL 调试
  1. 请求信息 : 2026-11-04 17:56:23 HTTP/1.1 GET : http://yeyulingfeng.com/a/499026.html
  2. 运行时间 : 0.076461s [ 吞吐率:13.08req/s ] 内存消耗:4,756.74kb 文件加载:140
  3. 缓存信息 : 0 reads,0 writes
  4. 会话信息 : SESSION_ID=9a4e2f4017551fd5a42eddb24611818d
  1. /www/wwwroot/yeyulingfeng.com/public/index.php ( 0.79 KB )
  2. /www/wwwroot/yeyulingfeng.com/vendor/autoload.php ( 0.17 KB )
  3. /www/wwwroot/yeyulingfeng.com/vendor/composer/autoload_real.php ( 2.49 KB )
  4. /www/wwwroot/yeyulingfeng.com/vendor/composer/platform_check.php ( 0.90 KB )
  5. /www/wwwroot/yeyulingfeng.com/vendor/composer/ClassLoader.php ( 14.03 KB )
  6. /www/wwwroot/yeyulingfeng.com/vendor/composer/autoload_static.php ( 4.90 KB )
  7. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-helper/src/helper.php ( 8.34 KB )
  8. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-validate/src/helper.php ( 2.19 KB )
  9. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/helper.php ( 1.47 KB )
  10. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/stubs/load_stubs.php ( 0.16 KB )
  11. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Exception.php ( 1.69 KB )
  12. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-container/src/Facade.php ( 2.71 KB )
  13. /www/wwwroot/yeyulingfeng.com/vendor/symfony/deprecation-contracts/function.php ( 0.99 KB )
  14. /www/wwwroot/yeyulingfeng.com/vendor/symfony/polyfill-mbstring/bootstrap.php ( 8.26 KB )
  15. /www/wwwroot/yeyulingfeng.com/vendor/symfony/polyfill-mbstring/bootstrap80.php ( 9.78 KB )
  16. /www/wwwroot/yeyulingfeng.com/vendor/symfony/var-dumper/Resources/functions/dump.php ( 1.49 KB )
  17. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-dumper/src/helper.php ( 0.18 KB )
  18. /www/wwwroot/yeyulingfeng.com/vendor/symfony/var-dumper/VarDumper.php ( 4.30 KB )
  19. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/App.php ( 15.30 KB )
  20. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-container/src/Container.php ( 15.76 KB )
  21. /www/wwwroot/yeyulingfeng.com/vendor/psr/container/src/ContainerInterface.php ( 1.02 KB )
  22. /www/wwwroot/yeyulingfeng.com/app/provider.php ( 0.19 KB )
  23. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Http.php ( 6.04 KB )
  24. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-helper/src/helper/Str.php ( 7.29 KB )
  25. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Env.php ( 4.68 KB )
  26. /www/wwwroot/yeyulingfeng.com/app/common.php ( 0.03 KB )
  27. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/helper.php ( 18.78 KB )
  28. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Config.php ( 5.54 KB )
  29. /www/wwwroot/yeyulingfeng.com/config/app.php ( 0.95 KB )
  30. /www/wwwroot/yeyulingfeng.com/config/cache.php ( 0.78 KB )
  31. /www/wwwroot/yeyulingfeng.com/config/console.php ( 0.23 KB )
  32. /www/wwwroot/yeyulingfeng.com/config/cookie.php ( 0.56 KB )
  33. /www/wwwroot/yeyulingfeng.com/config/database.php ( 2.50 KB )
  34. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/facade/Env.php ( 1.67 KB )
  35. /www/wwwroot/yeyulingfeng.com/config/filesystem.php ( 0.61 KB )
  36. /www/wwwroot/yeyulingfeng.com/config/lang.php ( 0.91 KB )
  37. /www/wwwroot/yeyulingfeng.com/config/log.php ( 1.35 KB )
  38. /www/wwwroot/yeyulingfeng.com/config/middleware.php ( 0.19 KB )
  39. /www/wwwroot/yeyulingfeng.com/config/route.php ( 1.89 KB )
  40. /www/wwwroot/yeyulingfeng.com/config/session.php ( 0.57 KB )
  41. /www/wwwroot/yeyulingfeng.com/config/trace.php ( 0.34 KB )
  42. /www/wwwroot/yeyulingfeng.com/config/view.php ( 0.82 KB )
  43. /www/wwwroot/yeyulingfeng.com/app/event.php ( 0.25 KB )
  44. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Event.php ( 7.67 KB )
  45. /www/wwwroot/yeyulingfeng.com/app/service.php ( 0.13 KB )
  46. /www/wwwroot/yeyulingfeng.com/app/AppService.php ( 0.26 KB )
  47. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Service.php ( 1.64 KB )
  48. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Lang.php ( 7.35 KB )
  49. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/lang/zh-cn.php ( 13.70 KB )
  50. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/Error.php ( 3.31 KB )
  51. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/RegisterService.php ( 1.33 KB )
  52. /www/wwwroot/yeyulingfeng.com/vendor/services.php ( 0.14 KB )
  53. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/service/PaginatorService.php ( 1.52 KB )
  54. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/service/ValidateService.php ( 0.99 KB )
  55. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/service/ModelService.php ( 2.04 KB )
  56. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-trace/src/Service.php ( 0.77 KB )
  57. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Middleware.php ( 6.72 KB )
  58. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/initializer/BootService.php ( 0.77 KB )
  59. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/Paginator.php ( 11.86 KB )
  60. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-validate/src/Validate.php ( 63.20 KB )
  61. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/Model.php ( 23.55 KB )
  62. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/Attribute.php ( 21.05 KB )
  63. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/AutoWriteData.php ( 4.21 KB )
  64. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/Conversion.php ( 6.44 KB )
  65. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/DbConnect.php ( 5.16 KB )
  66. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/ModelEvent.php ( 2.33 KB )
  67. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/model/concern/RelationShip.php ( 28.29 KB )
  68. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-helper/src/contract/Arrayable.php ( 0.09 KB )
  69. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-helper/src/contract/Jsonable.php ( 0.13 KB )
  70. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/model/contract/Modelable.php ( 0.09 KB )
  71. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Db.php ( 2.88 KB )
  72. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/DbManager.php ( 8.52 KB )
  73. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Log.php ( 6.28 KB )
  74. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Manager.php ( 3.92 KB )
  75. /www/wwwroot/yeyulingfeng.com/vendor/psr/log/src/LoggerTrait.php ( 2.69 KB )
  76. /www/wwwroot/yeyulingfeng.com/vendor/psr/log/src/LoggerInterface.php ( 2.71 KB )
  77. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Cache.php ( 4.92 KB )
  78. /www/wwwroot/yeyulingfeng.com/vendor/psr/simple-cache/src/CacheInterface.php ( 4.71 KB )
  79. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-helper/src/helper/Arr.php ( 16.63 KB )
  80. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/cache/driver/File.php ( 7.84 KB )
  81. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/cache/Driver.php ( 9.03 KB )
  82. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/contract/CacheHandlerInterface.php ( 1.99 KB )
  83. /www/wwwroot/yeyulingfeng.com/app/Request.php ( 0.09 KB )
  84. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Request.php ( 55.78 KB )
  85. /www/wwwroot/yeyulingfeng.com/app/middleware.php ( 0.25 KB )
  86. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Pipeline.php ( 2.61 KB )
  87. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-trace/src/TraceDebug.php ( 3.40 KB )
  88. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/middleware/SessionInit.php ( 1.94 KB )
  89. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Session.php ( 1.80 KB )
  90. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/session/driver/File.php ( 6.27 KB )
  91. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/contract/SessionHandlerInterface.php ( 0.87 KB )
  92. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/session/Store.php ( 7.12 KB )
  93. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Route.php ( 23.73 KB )
  94. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleName.php ( 5.75 KB )
  95. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/route/Domain.php ( 2.53 KB )
  96. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleGroup.php ( 22.43 KB )
  97. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/route/Rule.php ( 26.95 KB )
  98. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/route/RuleItem.php ( 9.78 KB )
  99. /www/wwwroot/yeyulingfeng.com/route/app.php ( 1.72 KB )
  100. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/facade/Route.php ( 4.70 KB )
  101. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/route/dispatch/Controller.php ( 4.74 KB )
  102. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/route/Dispatch.php ( 10.44 KB )
  103. /www/wwwroot/yeyulingfeng.com/app/controller/Index.php ( 5.22 KB )
  104. /www/wwwroot/yeyulingfeng.com/app/BaseController.php ( 2.05 KB )
  105. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/facade/Db.php ( 0.93 KB )
  106. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/connector/Mysql.php ( 5.44 KB )
  107. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/PDOConnection.php ( 52.47 KB )
  108. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/Connection.php ( 8.39 KB )
  109. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/ConnectionInterface.php ( 4.57 KB )
  110. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/builder/Mysql.php ( 16.58 KB )
  111. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/Builder.php ( 24.06 KB )
  112. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/BaseBuilder.php ( 27.50 KB )
  113. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/Query.php ( 15.71 KB )
  114. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/BaseQuery.php ( 45.13 KB )
  115. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/TimeFieldQuery.php ( 7.43 KB )
  116. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/AggregateQuery.php ( 3.26 KB )
  117. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ModelRelationQuery.php ( 20.07 KB )
  118. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ParamsBind.php ( 3.66 KB )
  119. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/ResultOperation.php ( 7.01 KB )
  120. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/WhereQuery.php ( 19.37 KB )
  121. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/JoinAndViewQuery.php ( 7.11 KB )
  122. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/TableFieldInfo.php ( 2.63 KB )
  123. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-orm/src/db/concern/Transaction.php ( 2.77 KB )
  124. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/log/driver/File.php ( 5.96 KB )
  125. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/contract/LogHandlerInterface.php ( 0.86 KB )
  126. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/log/Channel.php ( 3.89 KB )
  127. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/event/LogRecord.php ( 1.02 KB )
  128. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-helper/src/Collection.php ( 16.47 KB )
  129. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/facade/View.php ( 1.70 KB )
  130. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/View.php ( 4.39 KB )
  131. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Response.php ( 8.81 KB )
  132. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/response/View.php ( 3.29 KB )
  133. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/Cookie.php ( 6.06 KB )
  134. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-view/src/Think.php ( 8.38 KB )
  135. /www/wwwroot/yeyulingfeng.com/vendor/topthink/framework/src/think/contract/TemplateHandlerInterface.php ( 1.60 KB )
  136. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-template/src/Template.php ( 46.61 KB )
  137. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-template/src/template/driver/File.php ( 2.41 KB )
  138. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-template/src/template/contract/DriverInterface.php ( 0.86 KB )
  139. /www/wwwroot/yeyulingfeng.com/runtime/temp/44b959ca79ad33496f044db2c231b11d.php ( 8.29 KB )
  140. /www/wwwroot/yeyulingfeng.com/vendor/topthink/think-trace/src/Html.php ( 4.42 KB )
  1. CONNECT:[ UseTime:0.000658s ] mysql:host=172.18.0.4;port=3306;dbname=no_yeyulingfeng;charset=utf8mb4
  2. SHOW FULL COLUMNS FROM `fenlei` [ RunTime:0.000718s ]
  3. SELECT * FROM `fenlei` WHERE `fid` = 0 [ RunTime:0.000390s ]
  4. SELECT * FROM `fenlei` WHERE `fid` = 63 [ RunTime:0.000323s ]
  5. SHOW FULL COLUMNS FROM `set` [ RunTime:0.000562s ]
  6. SELECT * FROM `set` [ RunTime:0.000323s ]
  7. SHOW FULL COLUMNS FROM `article` [ RunTime:0.000546s ]
  8. SELECT * FROM `article` WHERE `id` = 499026 LIMIT 1 [ RunTime:0.000642s ]
  9. UPDATE `article` SET `lasttime` = 1793786183 WHERE `id` = 499026 [ RunTime:0.003475s ]
  10. SELECT * FROM `fenlei` WHERE `id` = 64 LIMIT 1 [ RunTime:0.000412s ]
  11. SELECT * FROM `article` WHERE `id` < 499026 ORDER BY `id` DESC LIMIT 1 [ RunTime:0.000465s ]
  12. SELECT * FROM `article` WHERE `id` > 499026 ORDER BY `id` ASC LIMIT 1 [ RunTime:0.000403s ]
  13. SELECT * FROM `article` WHERE `id` < 499026 ORDER BY `id` DESC LIMIT 10 [ RunTime:0.000716s ]
  14. SELECT * FROM `article` WHERE `id` < 499026 ORDER BY `id` DESC LIMIT 10,10 [ RunTime:0.000630s ]
  15. SELECT * FROM `article` WHERE `id` < 499026 ORDER BY `id` DESC LIMIT 20,10 [ RunTime:0.000679s ]
0.087708s