flnxi-Wu/lib/url-rewrite.ts
WuYongJie 542c7bcc49 feat: 优化自定义上传模板系统
- 修复内置模板验证问题(builtin-video-material等)
- 添加模板验证工具 lib/template-validator.ts
- 创建模板系统优化需求文档(30个详细需求)
- 优化上传API的模板验证逻辑
- 改进错误提示信息的清晰度
- 添加字段池和分类管理相关需求
- 完善自定义字段的创建、验证、存储逻辑
- 清理过期的 .specstory 和 .trae 文件
2025-12-13 14:26:43 +08:00

144 lines
3.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 存储 URL 重写工具
*
* 目标:即使数据库里存的是 http://localhost:9000/... 这样的地址,
* 也要在「返回给前端之前」自动替换为实际可访问的 MinIO 地址。
*/
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
function getSafeMinioBase(): { 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'
// 1) 端口优先取 S3_PUBLIC_DOMAIN 上的端口(如果有)
if (publicDomain) {
try {
const url = new URL(publicDomain)
if (url.port) {
port = url.port
}
} catch {
// ignore
}
}
// 2) 主机优先使用 SERVER_IP
if (serverIp && !LOCAL_HOSTS.has(serverIp)) {
host = serverIp
}
// 3) 如果没有 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
}
} catch {
// ignore
}
}
// 4) 如果 publicDomain 本身就是非 localhost 的,也可以作为兜底 host
if (!host && publicDomain) {
try {
const url = new URL(publicDomain)
if (!LOCAL_HOSTS.has(url.hostname)) {
host = url.hostname
}
} catch {
// ignore
}
}
if (!host) {
return null
}
return {
protocol: 'http:',
host,
port: port || '9000',
}
}
/**
* 将存储相关的 URL 中的 localhost/127.0.0.1 替换为 SERVER_IP:MINIO_API_PORT
* - 仅在 URL 为 http/https 且 host 为本机时生效
* - 其他情况(如相对路径、第三方 CDN)保持不变
*/
export function rewriteStorageUrlForClient(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
}
const minioBase = getSafeMinioBase()
if (!minioBase) {
// 没有 SERVER_IP 配置时,不做任何修改,避免返回错误地址
return trimmed
}
try {
const parsed = new URL(trimmed)
if (!LOCAL_HOSTS.has(parsed.hostname)) {
// 不是 localhost / 127.0.0.1,直接返回
return trimmed
}
// 将 localhost:9000 替换为 SERVER_IP:MINIO_API_PORT
parsed.protocol = minioBase.protocol
parsed.hostname = minioBase.host
parsed.port = minioBase.port
return parsed.toString()
} catch {
// URL 解析失败时保持原样,避免引入新的错误
return trimmed
}
}
/**
* 批量重写素材对象中的所有存储 URL 字段
*/
export function rewriteMaterialStorageUrlsForClient<T extends Record<string, any>>(material: T): T {
if (!material || typeof material !== 'object') return material
const cloned: any = { ...material }
const urlFields = [
'previewUrl',
'videoCoverUrl',
'videoPreview540Url',
'videoPreviewClipUrl',
] as const
urlFields.forEach((field) => {
if (field in cloned) {
cloned[field] = rewriteStorageUrlForClient(cloned[field])
}
})
// displayImages: string[]
if (Array.isArray(cloned.displayImages)) {
cloned.displayImages = cloned.displayImages.map((item: any) =>
typeof item === 'string' ? rewriteStorageUrlForClient(item) : item
)
}
return cloned as T
}