// 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>( 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编码,中文路径应该可以正常工作 // 如果遇到连接问题,可以考虑使用普通的标签配合代理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 }