@@ -33,6 +33,9 @@ export type UserOSSSetting = {
region : string ;
endpoint : string ;
cdnBaseUrl : string ;
cdnAuthMode : "" | "public" | "qiniu" | string ;
requireCDN : boolean ;
allowPrivateProxy : boolean ;
bucket : string ;
accessKeyId : string ;
hasAccessKeySecret : boolean ;
@@ -52,6 +55,9 @@ export type UserOSSSetting = {
export type UserOSSSettingInput = Pick < UserOSSSetting , "enabled" | "provider" | "s3Preset" | "region" | "endpoint" | "cdnBaseUrl" | "bucket" | "accessKeyId" | "pathPrefix" | "pathStyle" > & {
accessKeySecret? : string ;
sessionToken? : string ;
cdnAuthMode ? : "" | "public" | "qiniu" | string ;
requireCDN? : boolean ;
allowPrivateProxy? : boolean ;
} ;
export type AccountFileStorageUsage = {
@@ -96,9 +102,23 @@ export class ResourceUploadError extends Error {
const resourceCache = new Map < string , RemoteResource > ( ) ;
const resourceRequests = new Map < string , Promise < RemoteResource > > ( ) ;
const missingResourceIds = new Set < string > ( ) ;
const ossUrlCache = new Map < string , { url : string ; expiresAt : number } > ( ) ;
const ossUrlRequests = new Map < string , Promise < string > > ( ) ;
const OSS_URL_CACHE_TTL_MS = 4 * 60 * 1000 ;
export type ResourceAccessPurpose = "display" | "copy" | "download" | "browser-process" | "provider-input" ;
export type ResourceAccessVariant = "original" | "playback" ;
export type ResourceAccess = {
resourceId : string ;
requestedVariant : ResourceAccessVariant ;
actualVariant : ResourceAccessVariant ;
url : string ;
delivery : "cdn" | "origin" | "platform-local" | "platform-proxy" ;
issuedAt : string ;
expiresAt? : string ;
refreshAt : string ;
revision : string ;
fallbackReason? : string ;
} ;
const accessCache = new Map < string , { value : ResourceAccess ; expiresAt : number } > ( ) ;
const accessRequests = new Map < string , Promise < ResourceAccess > > ( ) ;
export function resourceStorageKey ( id : string ) {
return ` resource: ${ id } ` ;
@@ -271,42 +291,61 @@ export function refreshResource(id: string): Promise<RemoteResource> {
} ) ;
}
export async function getResourceOSSUrl ( storageKey? : string ) {
export async function getResourceAccess ( storageKey : string | undefined , purpose : ResourceAccessPurpose = "display" , variant : ResourceAccessVariant = "original" ) {
const id = resourceIdFromStorageKey ( storageKey ) ;
if ( ! id ) throw new Error ( "当前媒体尚未上传到后端资源存储" ) ;
const cached = ossUrlCache . get ( resourceCacheKey ( id ) ) ;
if ( cached && c ached . expiresAt > Date . now ( ) ) return cached . url ;
const pending = ossUrlRequests . get ( resourceCacheKey ( id ) ) ;
const key = ` ${ resourceCacheKey ( id ) } : ${ purpose } : ${ variant } ` ;
const cached = accessC ache. get ( key ) ;
if ( cached && cached . expiresAt > Date . now ( ) ) return cached . value ;
const pending = accessRequests . get ( key ) ;
if ( pending ) return pending ;
const request = ( async ( ) = > {
try {
const data = await http . ge t< { url : string } > ( ` /resources/ ${ encodeURIComponent ( id ) } /oss-url ` ) ;
if ( ! data . url ) throw new Error ( "后端未返回对象存储地址" ) ;
ossUrlCache . set ( resourceCacheKey ( id ) , { url : data.url , expiresAt : Date.now ( ) + OSS_URL_CACHE_TTL_MS } ) ;
return data . url ;
const data = await http . pos t< { items : Array < { resourceId : string ; access? : ResourceAccess ; error ? : { msg? : string } } > } > ( "/resources/access" , [ { resourceId : id , purpose , variant } ] ) ;
const item = data . items ? . [ 0 ] ;
if ( ! item ? . access ? . url ) throw new Error ( item ? . error ? . msg || "后端未返回资源访问地址" ) ;
const value = item . access ;
const ttl = value . expiresAt ? Math . max ( 10 _000 , new Date ( value . expiresAt ) . getTime ( ) - Date . now ( ) - 15 _000 ) : 5 * 60 _000 ;
accessCache . set ( key , { value , expiresAt : Date.now ( ) + ttl } ) ;
return value ;
} catch ( error ) {
if ( error instanceof ApiError ) throw new Error ( error . message || "获取对象存储地址失败" ) ;
throw error ;
} finally {
ossUrl Requests. delete ( resourceCacheKey ( id ) ) ;
access Requests. delete ( key ) ;
}
} ) ( ) ;
ossUrl Requests. set ( resourceCacheKey ( id ) , request ) ;
access Requests. set ( key , request ) ;
return request ;
}
/** 模型上游读取资源使用更长 TTL, 但仍走统一资源访问合同。 */
export async function getResourceInputURL ( storageKey? : string ) {
return ( await getResourceAccess ( storageKey , "provider-input" ) ) . url ;
}
/**
* Resolve a platform delivery URL against the configured API origin.
*
* Cloud deliveries are already absolute CDN/origin URLs. Local/proxy
* deliveries are intentionally returned by the backend as controlled API
* paths; when the frontend talks to a separate backend origin, resolving the
* path here prevents a Blob read from accidentally targeting the web origin.
*/
export function resolveResourceAccessURL ( url : string ) {
if ( ! url || /^(?:[a-z][a-z\d+.-]*:|\/\/)/i . test ( url ) ) return url ;
const base = String ( apiBaseURL ) . trim ( ) ;
if ( ! /^https?:\/\//i . test ( base ) ) return url ;
return new URL ( url , ` ${ base . replace ( /\/+$/ , "" ) } / ` ) . toString ( ) ;
}
function resourceCacheKey ( id : string ) {
return ` ${ getActiveUserScope ( ) } : ${ id } ` ;
}
export function resourceFileUrl ( id : string ) {
const base = String ( apiBaseURL ) . replace ( /\/+$/ , "" ) ;
return ` ${ base } /resources/ ${ encodeURIComponent ( id ) } /file?direct=1 ` ;
}
function resourceProxyFileUrl ( id : string ) {
const base = String ( apiBaseURL ) . replace ( /\/+$/ , "" ) ;
return ` ${ base } /resources/ ${ encodeURIComponent ( id ) } /file?proxy=1 ` ;
return ` ${ base } /resources/ ${ encodeURIComponent ( id ) } /file ` ;
}
export function resolveResourceUrl ( storageKey? : string , fallback = "" ) {
@@ -320,25 +359,21 @@ export function resolveResourceUrl(storageKey?: string, fallback = "") {
// 副本就绪前由后端回退原件,调用方再按需降级。
export function playbackVariantUrl ( id : string ) {
const base = String ( apiBaseURL ) . replace ( /\/+$/ , "" ) ;
return ` ${ base } /resources/ ${ encodeURIComponent ( id ) } /file?variant=playback&direct=1 ` ;
return ` ${ base } /resources/ ${ encodeURIComponent ( id ) } /file?variant=playback ` ;
}
export async function getResourceBlob ( storageKey : string , options ? : { allowProxyFallback? : boolean } ) {
export async function getResourceBlob ( storageKey : string ) {
const id = resourceIdFromStorageKey ( storageKey ) ;
if ( ! id ) return null ;
// A direct OSS response needs CORS to be readable as a Blob. Native <img>/<video>
// can still display it without CORS, so background cache fills must not proxy it.
try {
const ossUrl = await getResourceOSSUrl ( storageKey ) ;
const response = await fetch ( ossUrl , { credentials : "omit" , mode : "cors" } ) ;
if ( response . ok ) return response . blob ( ) ;
if ( ! options ? . allowProxyFallback || response . status === 401 || response . status === 403 || response . status === 404 ) return null ;
} catch ( error ) {
if ( ! options ? . allowProxyFallback ) throw error ;
}
if ( ! options ? . allowProxyFallback ) return null ;
const response = await fetch ( resourceProxyFileUrl ( id ) , { credentials : "include" } ) ;
return response . ok ? response . blob ( ) : null ;
const access = await getResourceAccess ( storageKey , "browser-process" ) ;
// CDN/origin URLs carry their own authorization and must not receive the
// application session cookie. Local/proxy delivery is intentionally
// session-bound, so a Blob read must include it even when the URL is
// relative to the platform origin.
const credentials = access . delivery === "platform-local" || access . delivery === "platform-proxy" ? "include" : "omit" ;
const response = await fetch ( resolveResourceAccessURL ( access . url ) , { c redentials , mode : "cors" } ) ;
if ( ! response . ok ) throw new Error ( ` 资源读取失败( ${ response . status } ) ` ) ;
return response . blob ( ) ;
}
function extensionFromMime ( mimeType : string , kind : string ) {