下载大文件又把服务器给撑爆了!
- 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 常以 <?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):矢量图。无 <?xml 前缀、直接 <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):<!DOCTYPE html> 大小写两种 + 无声明的 <html> 直开变体 */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} 字节;流提前结束则返回已读部分(长度 < 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 越界交由调用方返回 416if (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 / 200response.setStatus(SC_OK);return;}long contentLen = range.last() - range.pos() + 1;if (range.partial()) {if (contentLen <= 0 || range.pos() >= fileSize) {// 416:起始越界,不写 bodyresponse.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);}@Overridepubliclongsize(){return file.length();}@Overridepublic 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;@Overridepublicintread() throws IOException {byte[] b = new byte[1];int n = read(b, 0, 1);return n == -1 ? -1 : (b[0] & 0xff);}@Overridepublicintread(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;}@Overridepublicvoidclose() throws IOException {raf.close();}};}@Overridepublic 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 >= 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<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);}@Overridepubliclongsize(){if (size >= 0) {return size;}if (!allowProbe) {return-1;}Long v = probedSize;if (v == null) {v = probeSize();probedSize = v;}return v;}@Overridepublic 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();}@Overridepublic String name(){return name;}// ================================ 头部探测(供魔数探测 / 元数据展示等场景) ================================/*** 只读文件头部字节快照,附带尽力解析的文件总长,零落盘、零全量缓冲。* <p>* 上游支持 Range 时仅取头部区间(206);忽略 Range 的上游拿 200 头部后立即断流,* 两种形态均只发生一次轻量往返。** @param url 上游 URL* @param maxBytes 读取的头部字节数(<=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