后端: - 审核结果拆分为 4 个独立维度 (法规合规/平台规则/品牌安全/Brief匹配度) - 卖点优先级从 required:bool 改为三级 (core/recommended/reference) - AI 语义匹配卖点覆盖 + AI 整体 Brief 匹配度分析 - BriefMatchDetail 评分详情 (覆盖率+亮点+问题点) - min_selling_points 代理商可配置最少卖点数 + Alembic 迁移 - AI 语境复核过滤误报 - Brief AI 解析 + 规则 AI 解析 - AI 未配置/异常时通知品牌方 - 种子数据更新 (新格式审核结果+brief_match_detail) 前端: - 三端审核页面展示四维度评分卡片 - 卖点编辑改为三级优先级选择器 - BriefMatchDetail 展示 (覆盖率进度条+亮点+问题) - min_selling_points 配置 UI - AI 配置页未配置时静默处理 - 文件预览/下载/签名 URL 优化 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
102 lines
2.5 KiB
TypeScript
102 lines
2.5 KiB
TypeScript
/**
|
||
* 获取私有桶文件的预签名访问 URL
|
||
*
|
||
* 用于展示/下载 TOS 私有桶中的文件。
|
||
* 自动缓存签名 URL,过期前 5 分钟刷新。
|
||
*/
|
||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||
import { api } from '@/lib/api'
|
||
import { USE_MOCK } from '@/contexts/AuthContext'
|
||
|
||
const urlCache = new Map<string, { signedUrl: string; expireAt: number }>()
|
||
|
||
export function useSignedUrl(originalUrl: string | undefined | null) {
|
||
const [signedUrl, setSignedUrl] = useState<string | null>(null)
|
||
const [loading, setLoading] = useState(false)
|
||
const mountedRef = useRef(true)
|
||
|
||
useEffect(() => {
|
||
mountedRef.current = true
|
||
return () => { mountedRef.current = false }
|
||
}, [])
|
||
|
||
const fetchSignedUrl = useCallback(async () => {
|
||
if (!originalUrl) {
|
||
setSignedUrl(null)
|
||
return
|
||
}
|
||
|
||
// Mock 模式直接返回原始 URL
|
||
if (USE_MOCK) {
|
||
setSignedUrl(originalUrl)
|
||
return
|
||
}
|
||
|
||
// 非 TOS URL(如外部链接)直接返回
|
||
if (!originalUrl.includes('tos-cn-') && !originalUrl.includes('volces.com') && !originalUrl.startsWith('uploads/')) {
|
||
setSignedUrl(originalUrl)
|
||
return
|
||
}
|
||
|
||
// 检查缓存(提前 5 分钟过期)
|
||
const cached = urlCache.get(originalUrl)
|
||
if (cached && cached.expireAt > Date.now() + 5 * 60 * 1000) {
|
||
setSignedUrl(cached.signedUrl)
|
||
return
|
||
}
|
||
|
||
setLoading(true)
|
||
try {
|
||
const expireSeconds = 3600
|
||
const url = await api.getSignedUrl(originalUrl)
|
||
if (mountedRef.current) {
|
||
setSignedUrl(url)
|
||
urlCache.set(originalUrl, {
|
||
signedUrl: url,
|
||
expireAt: Date.now() + expireSeconds * 1000,
|
||
})
|
||
}
|
||
} catch {
|
||
// 签名失败时回退到原始 URL
|
||
if (mountedRef.current) {
|
||
setSignedUrl(originalUrl)
|
||
}
|
||
} finally {
|
||
if (mountedRef.current) {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
}, [originalUrl])
|
||
|
||
useEffect(() => {
|
||
fetchSignedUrl()
|
||
}, [fetchSignedUrl])
|
||
|
||
return { signedUrl, loading, refresh: fetchSignedUrl }
|
||
}
|
||
|
||
/**
|
||
* 批量获取签名 URL 的工具函数
|
||
*/
|
||
export async function getSignedUrls(urls: string[]): Promise<Map<string, string>> {
|
||
const result = new Map<string, string>()
|
||
|
||
if (USE_MOCK) {
|
||
urls.forEach(u => result.set(u, u))
|
||
return result
|
||
}
|
||
|
||
await Promise.all(
|
||
urls.map(async (url) => {
|
||
try {
|
||
const signed = await api.getSignedUrl(url)
|
||
result.set(url, signed)
|
||
} catch {
|
||
result.set(url, url)
|
||
}
|
||
})
|
||
)
|
||
|
||
return result
|
||
}
|