- 修复内置模板验证问题(builtin-video-material等) - 添加模板验证工具 lib/template-validator.ts - 创建模板系统优化需求文档(30个详细需求) - 优化上传API的模板验证逻辑 - 改进错误提示信息的清晰度 - 添加字段池和分类管理相关需求 - 完善自定义字段的创建、验证、存储逻辑 - 清理过期的 .specstory 和 .trae 文件
144 lines
3.6 KiB
TypeScript
144 lines
3.6 KiB
TypeScript
/**
|
||
* 存储 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
|
||
}
|
||
|
||
|