646 lines
24 KiB
TypeScript
646 lines
24 KiB
TypeScript
// LEGACY_READ_ONLY: 旧域名仅用于历史链接展示与下载兼容。
|
||
/**
|
||
* 存储 URL 重写工具
|
||
*
|
||
* 目标:即使数据库里存的是 http://localhost:9000/... 这样的地址,
|
||
* 也要在「返回给前端之前」自动替换为实际可访问的 MinIO 地址。
|
||
*
|
||
* 注意:这里处理的是资源文件(图片、视频等)的存储地址,不是应用访问地址。
|
||
* - 应用访问地址:https://sc.jiansun.vip/(当前主站)
|
||
* - 旧域名:https://3d.yydd.ai/(当前仅保留跳转)
|
||
* - 资源存储地址:https://s3.jiansun.vip(当前主入口)
|
||
*
|
||
* 智能选择策略(针对资源文件地址):
|
||
* - 内网用户:使用内网IP地址(如 http://192.168.58.20:9000)
|
||
* - 外网用户:使用公网域名(如 https://s3.jiansun.vip)
|
||
*/
|
||
|
||
import { NextRequest } from 'next/server'
|
||
import { detectIsIntranet } from './network-detection'
|
||
|
||
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
|
||
|
||
/**
|
||
* 获取 MinIO 基础地址(根据用户网络位置智能选择)
|
||
* @param isIntranet 用户是否在内网
|
||
* @returns MinIO 基础地址配置
|
||
*/
|
||
function getSafeMinioBase(isIntranet: boolean = false): { protocol: string; host: string; port: string } | null {
|
||
const publicDomain = (process.env.S3_PUBLIC_DOMAIN || '').trim()
|
||
const serverIp = (process.env.SERVER_IP || '').trim()
|
||
const minioPort = (process.env.MINIO_API_PORT || '9000').trim()
|
||
const appUrl = (process.env.APP_PUBLIC_URL || process.env.NEXTAUTH_URL || '').trim()
|
||
|
||
let host: string | null = null
|
||
let port: string = minioPort || '9000'
|
||
let protocol: string = 'http:'
|
||
|
||
// 智能选择策略:
|
||
// - 内网用户:优先使用 SERVER_IP(内网地址),走内网网络(最高优先级)
|
||
// - 外网用户:使用 S3_PUBLIC_DOMAIN(公网域名),走公网网络
|
||
// 注意:内网用户的判断必须优先,避免内网用户被错误地使用公网域名
|
||
if (isIntranet) {
|
||
// 内网用户:优先使用 SERVER_IP(内网地址)
|
||
if (serverIp && !LOCAL_HOSTS.has(serverIp)) {
|
||
// 有配置SERVER_IP,使用内网IP地址(最高优先级,直接返回,不继续执行)
|
||
host = serverIp
|
||
protocol = 'http:'
|
||
port = minioPort || '9000'
|
||
return {
|
||
protocol,
|
||
host,
|
||
port,
|
||
}
|
||
}
|
||
// 如果没有SERVER_IP,尝试从S3_ENDPOINT推断内网地址
|
||
const s3Endpoint = (process.env.S3_ENDPOINT || '').trim()
|
||
if (s3Endpoint) {
|
||
try {
|
||
const endpointUrl = new URL(s3Endpoint)
|
||
if (endpointUrl.hostname && !LOCAL_HOSTS.has(endpointUrl.hostname)) {
|
||
// 使用S3_ENDPOINT的内网地址
|
||
host = endpointUrl.hostname
|
||
protocol = endpointUrl.protocol || 'http:'
|
||
port = endpointUrl.port || minioPort || '9000'
|
||
return {
|
||
protocol,
|
||
host,
|
||
port,
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
// 外网用户或未配置内网IP:使用公网域名
|
||
if (publicDomain) {
|
||
try {
|
||
// 兼容未带协议的配置,默认视为 https,避免浏览器 https 页面出现 mixed-content
|
||
const normalized = publicDomain.match(/^https?:\/\//i) ? publicDomain : `https://${publicDomain}`
|
||
const url = new URL(normalized)
|
||
host = url.hostname
|
||
protocol = url.protocol
|
||
// 如果配置了公网域名且非本地主机,使用公网域名
|
||
if (host && !LOCAL_HOSTS.has(host)) {
|
||
// 公网域名必须使用 HTTPS,强制升级协议和端口,避免混合内容错误
|
||
protocol = 'https:'
|
||
port = '443' // 公网域名强制使用443端口,忽略配置中的端口(如果有的话)
|
||
return {
|
||
protocol,
|
||
host,
|
||
port,
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
// 如果既没有公网域名,也没有内网IP,尝试使用 SERVER_IP 作为后备
|
||
if (serverIp && !LOCAL_HOSTS.has(serverIp)) {
|
||
host = serverIp
|
||
protocol = 'http:'
|
||
port = minioPort || '9000'
|
||
}
|
||
|
||
// 如果没有 SERVER_IP,再从 APP_PUBLIC_URL / NEXTAUTH_URL 推断
|
||
if (!host && appUrl) {
|
||
try {
|
||
const url = new URL(appUrl)
|
||
if (!LOCAL_HOSTS.has(url.hostname)) {
|
||
host = url.hostname
|
||
protocol = url.protocol
|
||
// 如果appUrl是https,但S3可能是http,这里保持http
|
||
// 因为MinIO通常使用http
|
||
if (host === serverIp) {
|
||
protocol = 'http:'
|
||
port = minioPort || '9000'
|
||
} else {
|
||
port = url.port || (protocol === 'https:' ? '443' : '80')
|
||
}
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
}
|
||
|
||
if (!host) {
|
||
return null
|
||
}
|
||
|
||
return {
|
||
protocol,
|
||
host,
|
||
port,
|
||
}
|
||
}
|
||
|
||
// 公网S3域名列表(包含当前主域名和少量历史兼容域名)
|
||
const PUBLIC_S3_DOMAINS = [
|
||
's3.yydd.ai',
|
||
's3.jiansun.vip',
|
||
's3.1080p.hk',
|
||
]
|
||
|
||
/**
|
||
* 将存储相关的 URL 中的 localhost/127.0.0.1/内网IP 或公网S3域名替换为可访问的地址
|
||
* - 内网用户:使用 SERVER_IP:MINIO_API_PORT(内网地址),即使URL是公网域名也转换为内网地址
|
||
* - 外网用户:使用 S3_PUBLIC_DOMAIN(公网域名)
|
||
* - 仅在 URL 为 http/https 且 host 为本机、内网IP或公网S3域名时生效
|
||
* - 其他情况(如相对路径、第三方 CDN)保持不变
|
||
*
|
||
* @param url 要重写的 URL
|
||
* @param request 可选的 NextRequest 对象,用于检测用户网络位置
|
||
* @returns 重写后的 URL
|
||
*/
|
||
export function rewriteStorageUrlForClient(url?: string | null, request?: NextRequest): string | null {
|
||
// 确保只处理字符串类型,其他类型(如布尔值 true)直接返回 null
|
||
if (!url || typeof url !== 'string') return null
|
||
|
||
const trimmed = url.trim()
|
||
if (!trimmed) return null
|
||
|
||
// 非 http(s) 直接返回(可能是相对路径或 data: URL)
|
||
if (!/^https?:\/\//i.test(trimmed)) {
|
||
return trimmed
|
||
}
|
||
|
||
// 检测用户是否在内网(如果提供了 request 对象)
|
||
const isIntranet = request ? detectIsIntranet(request) : false
|
||
|
||
const minioBase = getSafeMinioBase(isIntranet)
|
||
if (!minioBase) {
|
||
// 没有配置时,不做任何修改,避免返回错误地址
|
||
console.warn('[URL Rewrite] getSafeMinioBase returned null, isIntranet:', isIntranet, 'SERVER_IP:', process.env.SERVER_IP)
|
||
return trimmed
|
||
}
|
||
|
||
// 调试日志:检查内网用户是否使用了正确的内网地址
|
||
if (isIntranet && minioBase.host !== process.env.SERVER_IP) {
|
||
console.warn('[URL Rewrite] 内网用户但使用了非内网地址:', {
|
||
isIntranet,
|
||
minioBaseHost: minioBase.host,
|
||
expectedServerIp: process.env.SERVER_IP,
|
||
url: trimmed
|
||
})
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(trimmed)
|
||
const hostname = parsed.hostname.toLowerCase()
|
||
const isLocalhost = LOCAL_HOSTS.has(hostname)
|
||
const isInternalIP = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname)
|
||
|
||
// 检查是否为公网S3域名
|
||
const isPublicS3Domain = PUBLIC_S3_DOMAINS.some(domain =>
|
||
hostname === domain || hostname.endsWith(`.${domain}`)
|
||
)
|
||
|
||
// 检查URL是否指向旧MinIO服务器(s3.1080p.hk)且使用旧bucket(jsxiangche)
|
||
// 旧MinIO服务器和新MinIO服务器是不同的服务器,新MinIO服务器没有jsxiangche bucket
|
||
// 所以对于旧MinIO服务器的URL,即使内网用户也应该保持原始URL(通过代理访问)
|
||
const isOldMinIOServer = hostname === 's3.1080p.hk' || hostname.endsWith('.s3.1080p.hk')
|
||
const isOldBucket = parsed.pathname.startsWith('/jsxiangche/')
|
||
const shouldKeepOriginalUrl = isOldMinIOServer && isOldBucket
|
||
|
||
// 注意:不要自动替换bucket名称
|
||
// 旧素材的文件在 jsxiangche bucket中,新素材在 gongxiangpan bucket中
|
||
// 应该保持原始URL不变,让MinIO根据实际bucket提供服务
|
||
// 如果确实需要迁移,应该在数据迁移层面处理,而不是在URL重写层面
|
||
|
||
// 如果是 localhost/127.0.0.1/内网IP,需要重写
|
||
// 如果是内网用户且URL是公网S3域名,也需要转换为内网MinIO地址
|
||
// 但如果是旧MinIO服务器的URL(s3.1080p.hk + jsxiangche),保持原始URL
|
||
if ((isLocalhost || isInternalIP || (isIntranet && isPublicS3Domain)) && !shouldKeepOriginalUrl) {
|
||
// 根据用户网络位置智能选择地址
|
||
parsed.protocol = minioBase.protocol
|
||
parsed.hostname = minioBase.host
|
||
parsed.port = minioBase.port
|
||
|
||
// 重要:MinIO使用path-style访问,路径格式为 bucket/key
|
||
// 对于内网MinIO地址,路径中的中文字符应该保持未编码状态
|
||
// MinIO服务器会自动处理URL编码,或者浏览器在请求时会自动编码
|
||
// 如果手动编码,可能导致MinIO无法正确解析路径,返回404错误
|
||
try {
|
||
// 检查路径是否已经编码过(包含%符号)
|
||
const isAlreadyEncoded = /%[0-9A-Fa-f]{2}/.test(parsed.pathname)
|
||
|
||
if (isAlreadyEncoded) {
|
||
// 路径已经编码过,先解码(MinIO期望未编码的路径)
|
||
try {
|
||
const decodedPath = decodeURIComponent(parsed.pathname)
|
||
// 手动构建URL字符串,避免toString()自动编码
|
||
const decodedUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${decodedPath}${parsed.search || ''}${parsed.hash || ''}`
|
||
return decodedUrl
|
||
} catch {
|
||
// 解码失败,保持原路径(可能部分编码)
|
||
console.warn('[URL Rewrite] Failed to decode already-encoded path:', parsed.pathname)
|
||
// 即使解码失败,也要手动构建URL,避免toString()重新编码
|
||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${parsed.pathname}${parsed.search || ''}${parsed.hash || ''}`
|
||
}
|
||
}
|
||
|
||
// 对于内网MinIO地址,不进行编码,保持原始路径
|
||
// 手动构建URL字符串,避免toString()自动编码
|
||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${parsed.pathname}${parsed.search || ''}${parsed.hash || ''}`
|
||
} catch (error) {
|
||
// 处理失败,保持原路径(但记录警告)
|
||
console.warn('[URL Rewrite] Failed to process URL path for MinIO:', error, 'pathname:', parsed.pathname)
|
||
// 即使处理失败,也要手动构建URL,避免toString()重新编码
|
||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${parsed.pathname}${parsed.search || ''}${parsed.hash || ''}`
|
||
}
|
||
}
|
||
|
||
// 对于公网S3域名:
|
||
// - 外网用户统一改写到当前配置的 S3_PUBLIC_DOMAIN,避免历史数据继续返回旧域名
|
||
// - 再处理路径编码,确保中文 key 可以正常访问
|
||
// 如果路径已经编码过,先解码(MinIO期望未编码的路径)
|
||
// 浏览器在发送HTTP请求时会自动编码,但MinIO服务器需要未编码的路径才能正确解析
|
||
if (isPublicS3Domain) {
|
||
if (!isIntranet && !shouldKeepOriginalUrl) {
|
||
parsed.protocol = minioBase.protocol
|
||
parsed.hostname = minioBase.host
|
||
parsed.port = minioBase.port
|
||
}
|
||
|
||
// 使用已经创建的parsed对象,不要重复创建
|
||
// 检查路径是否已经编码过(包含%符号)
|
||
const isAlreadyEncoded = /%[0-9A-Fa-f]{2}/.test(parsed.pathname)
|
||
|
||
if (isAlreadyEncoded) {
|
||
// 路径已经编码过,先解码(MinIO期望未编码的路径)
|
||
try {
|
||
const decodedPath = decodeURIComponent(parsed.pathname)
|
||
// 手动构建URL字符串,避免toString()自动编码
|
||
// 格式:protocol://hostname:port/pathname?search#hash
|
||
const decodedUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${decodedPath}${parsed.search || ''}${parsed.hash || ''}`
|
||
return decodedUrl
|
||
} catch {
|
||
// 解码失败,保持原路径
|
||
console.warn('[URL Rewrite] Failed to decode already-encoded path for public S3:', parsed.pathname)
|
||
// 即使解码失败,也要手动构建URL,避免toString()重新编码
|
||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${parsed.pathname}${parsed.search || ''}${parsed.hash || ''}`
|
||
}
|
||
}
|
||
|
||
// 路径未编码,手动构建URL字符串,避免toString()自动编码
|
||
return `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ''}${parsed.pathname}${parsed.search || ''}${parsed.hash || ''}`
|
||
}
|
||
|
||
// 不是 localhost/内网IP/公网S3域名,检查URL路径是否包含中文字符,如果有则编码
|
||
// 浏览器可能无法正确处理URL中的中文字符,需要手动编码路径部分
|
||
// 重要:如果路径已经编码过(包含%),先解码再重新编码,避免双重编码
|
||
try {
|
||
// 检查路径是否已经编码过(包含%符号)
|
||
const isAlreadyEncoded = /%[0-9A-Fa-f]{2}/.test(parsed.pathname)
|
||
|
||
if (isAlreadyEncoded) {
|
||
// 路径已经编码过,先解码
|
||
try {
|
||
parsed.pathname = decodeURIComponent(parsed.pathname)
|
||
} catch {
|
||
// 解码失败,保持原路径(可能部分编码)
|
||
console.warn('[URL Rewrite] Failed to decode already-encoded path:', parsed.pathname)
|
||
}
|
||
}
|
||
|
||
// 检查路径是否包含非ASCII字符(包括中文)
|
||
if (/[^\x00-\x7F]/.test(parsed.pathname)) {
|
||
// 路径包含非ASCII字符,需要编码
|
||
// 将路径分割成多个部分,分别编码每个部分(保留斜杠)
|
||
const pathParts = parsed.pathname.split('/')
|
||
const encodedPath = pathParts.map(part => {
|
||
if (!part) return part // 空字符串(开头的斜杠)
|
||
return encodeURIComponent(part)
|
||
}).join('/')
|
||
parsed.pathname = encodedPath
|
||
return parsed.toString()
|
||
}
|
||
} catch (encodeError) {
|
||
// 编码失败,返回原始URL
|
||
console.warn('[URL Rewrite] Failed to encode URL path:', encodeError)
|
||
}
|
||
|
||
// 没有中文字符或编码失败,直接返回
|
||
return trimmed
|
||
} catch {
|
||
// URL 解析失败时保持原样,避免引入新的错误
|
||
return trimmed
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 批量重写素材对象中的所有存储 URL 字段
|
||
*
|
||
* @param material 素材对象
|
||
* @param request 可选的 NextRequest 对象,用于检测用户网络位置
|
||
* @returns 重写后的素材对象
|
||
*/
|
||
export function rewriteMaterialStorageUrlsForClient<T extends Record<string, any>>(
|
||
material: T,
|
||
request?: NextRequest
|
||
): T {
|
||
if (!material || typeof material !== 'object') return material
|
||
|
||
const cloned: any = { ...material }
|
||
|
||
const urlFields = [
|
||
'previewUrl',
|
||
'videoCoverUrl',
|
||
'videoPreview540Url',
|
||
'videoPreviewClipUrl',
|
||
'videoSourceUrl',
|
||
] as const
|
||
|
||
urlFields.forEach((field) => {
|
||
if (field in cloned) {
|
||
let url = rewriteStorageUrlForClient(cloned[field], request)
|
||
// 对于s3.yydd.ai和s3.1080p.hk的URL,直接返回原始URL
|
||
// Next.js Image组件无法正确处理相对路径的代理URL,所以直接使用原始URL
|
||
// 浏览器会自动处理URL编码,中文路径应该可以正常工作
|
||
// 如果遇到连接问题,可以考虑使用普通的<img>标签配合代理URL
|
||
cloned[field] = url
|
||
}
|
||
})
|
||
|
||
// displayImages: string[]
|
||
if (Array.isArray(cloned.displayImages)) {
|
||
cloned.displayImages = cloned.displayImages.map((item: any) => {
|
||
if (typeof item === 'string') {
|
||
let url = rewriteStorageUrlForClient(item, request)
|
||
// 对于s3.yydd.ai和s3.1080p.hk的URL,直接返回原始URL
|
||
// Next.js Image组件无法正确处理相对路径的代理URL
|
||
return url
|
||
}
|
||
return item
|
||
})
|
||
}
|
||
|
||
return cloned as T
|
||
}
|
||
|
||
/**
|
||
* 将 HTTP/HTTPS URL 转换为通过代理的 URL(解决混合内容和连接问题)
|
||
* 对于 s3.yydd.ai 和 s3.1080p.hk,无论 HTTP 还是 HTTPS,都通过代理提供
|
||
* 这样可以避免 ERR_CONNECTION_CLOSED 错误(FRP穿透连接不稳定)
|
||
*/
|
||
export function proxyHttpUrl(url?: string | null): string | null {
|
||
if (!url || typeof url !== 'string') return url ?? null
|
||
|
||
const trimmed = url.trim()
|
||
if (!trimmed) return null
|
||
|
||
// 非 http(s) 直接返回(可能是相对路径或 data: URL)
|
||
if (!/^https?:\/\//i.test(trimmed)) {
|
||
return trimmed
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(trimmed)
|
||
const hostname = parsed.hostname.toLowerCase()
|
||
|
||
// s3.jiansun.vip 入口已经稳定,并且前面有独立缓存层。
|
||
// 这里优先直连,避免所有预览流量继续压在主站 /api/proxy-media 上。
|
||
if (hostname === 's3.jiansun.vip') {
|
||
if (parsed.protocol !== 'https:') {
|
||
parsed.protocol = 'https:'
|
||
if (parsed.port === '80') {
|
||
parsed.port = ''
|
||
}
|
||
}
|
||
return parsed.toString()
|
||
}
|
||
|
||
// 检查是否为内网MinIO地址(内网地址不应该通过代理)
|
||
const isIntranetMinIO = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname) ||
|
||
hostname === 'localhost' || hostname === '127.0.0.1'
|
||
if (isIntranetMinIO) {
|
||
// 内网MinIO地址直接返回,不通过代理(代理API不允许内网地址)
|
||
return trimmed
|
||
}
|
||
|
||
// 遗留公网域名列表(仍保留代理兜底)
|
||
// 检查是否为公网域名
|
||
const isPublicDomain = PUBLIC_S3_DOMAINS.some(domain =>
|
||
hostname === domain || hostname.endsWith(`.${domain}`)
|
||
)
|
||
|
||
// 如果是公网域名,无论 HTTP 还是 HTTPS,都通过代理提供
|
||
// 这样可以避免 ERR_CONNECTION_CLOSED 错误
|
||
if (isPublicDomain) {
|
||
// 使用相对路径的代理 API,避免硬编码域名
|
||
return `/api/proxy-media?url=${encodeURIComponent(trimmed)}`
|
||
}
|
||
|
||
// 只处理 HTTP 协议的其他资源(避免混合内容问题)
|
||
if (parsed.protocol === 'http:') {
|
||
// 其他 HTTP 资源也通过代理(如果需要)
|
||
return `/api/proxy-media?url=${encodeURIComponent(trimmed)}`
|
||
}
|
||
|
||
// 其他 HTTPS 资源直接返回
|
||
return trimmed
|
||
} catch {
|
||
// URL 解析失败时保持原样
|
||
return trimmed
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 确保 URL 使用 HTTPS 协议(避免混合内容问题)
|
||
* 对于公网域名(如 s3.1080p.hk),强制使用 HTTPS
|
||
*/
|
||
export function ensureHttpsUrl(url?: string | null): string | null {
|
||
if (!url || typeof url !== 'string') return url ?? null
|
||
|
||
const trimmed = url.trim()
|
||
if (!trimmed) return null
|
||
|
||
// 非 http(s) 直接返回(可能是相对路径或 data: URL)
|
||
if (!/^https?:\/\//i.test(trimmed)) {
|
||
return trimmed
|
||
}
|
||
|
||
try {
|
||
const parsed = new URL(trimmed)
|
||
|
||
// 如果是 HTTP 协议,检查是否为公网域名
|
||
if (parsed.protocol === 'http:') {
|
||
const hostname = parsed.hostname.toLowerCase()
|
||
|
||
// 公网域名列表(需要强制使用 HTTPS)
|
||
const publicDomains = [
|
||
's3.1080p.hk',
|
||
's3.yydd.ai',
|
||
's3.jiansun.vip',
|
||
'3d.yydd.ai',
|
||
'sc.jiansun.vip',
|
||
]
|
||
|
||
// 检查是否为公网域名或非内网地址
|
||
const isPublicDomain = publicDomains.some(domain => hostname === domain || hostname.endsWith(`.${domain}`))
|
||
const isLocalhost = LOCAL_HOSTS.has(hostname)
|
||
const isInternalIP = /^192\.168\.|^10\.|^172\.(1[6-9]|2[0-9]|3[01])\./.test(hostname)
|
||
|
||
// 如果是公网域名或非内网地址,强制升级为 HTTPS
|
||
if (isPublicDomain || (!isLocalhost && !isInternalIP)) {
|
||
parsed.protocol = 'https:'
|
||
// 如果原来是 http 且没有指定端口,移除端口(使用默认 443)
|
||
if (parsed.port === '80') {
|
||
parsed.port = ''
|
||
}
|
||
return parsed.toString()
|
||
}
|
||
}
|
||
|
||
// 已经是 HTTPS 或内网地址,直接返回
|
||
return trimmed
|
||
} catch {
|
||
// URL 解析失败时保持原样
|
||
return trimmed
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 根据URL路径估算文件大小(用于决定是否需要代理)
|
||
* 基于文件类型和路径特征进行估算,避免额外的HEAD请求
|
||
*
|
||
* @param url 文件URL
|
||
* @returns 估算的文件大小(字节)
|
||
*/
|
||
function estimateFileSize(url: string): number {
|
||
const lowerUrl = url.toLowerCase()
|
||
|
||
// 视频文件:通常 >5MB
|
||
// 注意:video-cover 是封面图(小文件),不应该匹配这里
|
||
if ((lowerUrl.includes('video-preview') ||
|
||
lowerUrl.includes('video-clip') ||
|
||
lowerUrl.includes('video-540')) &&
|
||
!lowerUrl.includes('video-cover')) {
|
||
return 10 * 1024 * 1024 // 10MB
|
||
}
|
||
|
||
// .mp4 文件:通常是视频文件
|
||
if (lowerUrl.includes('.mp4') && !lowerUrl.includes('video-cover')) {
|
||
return 10 * 1024 * 1024 // 10MB
|
||
}
|
||
|
||
// 音频文件:通常 >2MB
|
||
if (lowerUrl.includes('audio-source') ||
|
||
(lowerUrl.includes('audio-') && !lowerUrl.includes('audio-cover'))) {
|
||
return 5 * 1024 * 1024 // 5MB
|
||
}
|
||
|
||
// 音频文件扩展名
|
||
if (lowerUrl.includes('.mp3') ||
|
||
lowerUrl.includes('.wav') ||
|
||
lowerUrl.includes('.flac')) {
|
||
return 5 * 1024 * 1024 // 5MB
|
||
}
|
||
|
||
// 展示图:可能较大(1-2MB)
|
||
if (lowerUrl.includes('display-') ||
|
||
lowerUrl.includes('model-') ||
|
||
lowerUrl.includes('detail-')) {
|
||
return 1.5 * 1024 * 1024 // 1.5MB
|
||
}
|
||
|
||
// 预览图:通常 <500KB
|
||
// 视频封面:通常 <500KB(video-cover 应该在这里)
|
||
return 300 * 1024 // 300KB
|
||
}
|
||
|
||
/**
|
||
* 统一的媒体URL重写规则(优化版:内网用户不走代理)
|
||
*
|
||
* 规则:
|
||
* - 内网用户:直接使用内网MinIO地址(不走代理,最快)
|
||
* - 所有文件都直接访问内网MinIO,不走代理
|
||
* - 外网用户:
|
||
* - < 1MB:直接访问S3(小文件,影响不大)
|
||
* - ≥ 1MB:通过代理(大文件,需要优化Range请求)
|
||
*
|
||
* @param url 原始URL
|
||
* @param request NextRequest对象(用于检测用户网络位置)
|
||
* @param fileType 可选的文件类型提示:'image' | 'video' | 'audio' | 'source'
|
||
* @returns 重写后的URL
|
||
*/
|
||
export function rewriteMediaUrlForClient(
|
||
url: string | null | undefined,
|
||
request?: NextRequest,
|
||
fileType?: 'image' | 'video' | 'audio' | 'source'
|
||
): string | null {
|
||
// 确保只处理字符串类型,其他类型(如布尔值 true)直接返回 null
|
||
if (!url || typeof url !== 'string') return null
|
||
|
||
// 如果URL已经是代理URL(/api/proxy-media?url=...),需要先提取原始URL
|
||
// 这种情况不应该发生,但如果发生了,我们需要处理它
|
||
if (url.startsWith('/api/proxy-media?url=')) {
|
||
try {
|
||
const urlParam = url.replace('/api/proxy-media?url=', '')
|
||
const decodedUrl = decodeURIComponent(urlParam)
|
||
// 递归调用,使用提取的原始URL
|
||
return rewriteMediaUrlForClient(decodedUrl, request, fileType)
|
||
} catch (e) {
|
||
// 如果解析失败,返回原始URL
|
||
console.warn('[URL Rewrite] 无法解析代理URL:', url, e)
|
||
return url
|
||
}
|
||
}
|
||
|
||
// 检测用户网络位置
|
||
const isIntranet = request ? detectIsIntranet(request) : false
|
||
|
||
// 先重写存储URL(内网用户使用内网地址,外网用户使用公网地址)
|
||
const rewrittenUrl = rewriteStorageUrlForClient(url, request)
|
||
if (!rewrittenUrl) return null
|
||
|
||
// 内网用户:直接访问内网MinIO,不走代理(最快)
|
||
if (isIntranet) {
|
||
// 调试日志:检查内网用户的URL是否正确重写为内网地址
|
||
if (
|
||
rewrittenUrl.includes('s3.yydd.ai') ||
|
||
rewrittenUrl.includes('s3.jiansun.vip') ||
|
||
rewrittenUrl.includes('s3.1080p.hk')
|
||
) {
|
||
console.error('[URL Rewrite] 内网用户但URL仍包含公网S3域名:', {
|
||
originalUrl: url,
|
||
rewrittenUrl,
|
||
isIntranet,
|
||
serverIp: process.env.SERVER_IP,
|
||
fileType
|
||
})
|
||
}
|
||
return rewrittenUrl
|
||
}
|
||
|
||
// 公网图片优先走统一 URL 策略:
|
||
// - s3.jiansun.vip:现在优先直连,命中入口缓存
|
||
// - 其它遗留公网域名:继续通过代理兜底
|
||
if (fileType === 'image') {
|
||
return proxyHttpUrl(rewrittenUrl)
|
||
}
|
||
|
||
// 外网用户:按文件大小阈值决定是否走代理
|
||
// 注意:不要将内网MinIO地址转换为代理URL(代理API不允许内网地址)
|
||
const isIntranetMinIO = rewrittenUrl.includes('192.168.') || rewrittenUrl.includes('localhost') || rewrittenUrl.includes('127.0.0.1')
|
||
if (isIntranetMinIO) {
|
||
// 如果是内网MinIO地址,直接返回(不应该通过代理)
|
||
return rewrittenUrl
|
||
}
|
||
|
||
const PROXY_THRESHOLD = 1 * 1024 * 1024 // 1MB
|
||
const estimatedSize = estimateFileSize(url)
|
||
|
||
// 如果估算大小 >= 1MB,通过代理(优化Range请求处理)
|
||
if (estimatedSize >= PROXY_THRESHOLD) {
|
||
return proxyHttpUrl(rewrittenUrl)
|
||
}
|
||
|
||
// 小文件:直接访问S3
|
||
return rewrittenUrl
|
||
}
|
||
|