初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
/**
* Token 缓存和并发请求防重工具类
* 1. 确保同一个 key 的并发请求只会真正执行一次
* 2. 缓存 token 并管理过期时间
*/
import { MAX_TOKEN_CACHE_DURATION } from './adapter/base'
interface CacheEntry<T> {
data: T
expirationTime: number
}
export default class OssSingleton {
private static pendingRequests: Map<string, Promise<any>> = new Map()
private static cache: Map<string, CacheEntry<any>> = new Map()
/**
* 获取或创建带缓存的请求
* @param key 请求的唯一标识
* @param callback 实际的请求函数
* @param getCacheDuration 计算缓存时长的函数,接收 callback 返回的数据,返回缓存时长(毫秒)
* @returns Promise<T>
*/
public static async getCachedData<T>(
key: string,
callback: () => Promise<T>,
getCacheDuration?: number | ((data: T) => number),
): Promise<T> {
const currentTime = Date.now()
// 检查缓存是否有效
const cachedEntry = OssSingleton.cache.get(key)
if (cachedEntry && currentTime < cachedEntry.expirationTime) {
return cachedEntry.data as T
}
// 如果已经有相同的请求在进行中,直接返回该 Promise
if (OssSingleton.pendingRequests.has(key)) {
return OssSingleton.pendingRequests.get(key)! as Promise<T>
}
// 创建新的请求 Promise
const promise = callback()
.then(data => {
// 计算缓存时长
let cacheDuration: number
if (typeof getCacheDuration === 'function') {
cacheDuration = getCacheDuration(data)
} else if (typeof getCacheDuration === 'number') {
cacheDuration = getCacheDuration
} else {
cacheDuration = MAX_TOKEN_CACHE_DURATION
}
// 缓存数据并设置过期时间
OssSingleton.cache.set(key, {
data,
expirationTime: Date.now() + cacheDuration,
})
return data
})
.finally(() => {
// 请求完成后清理 pending 状态
OssSingleton.pendingRequests.delete(key)
})
OssSingleton.pendingRequests.set(key, promise)
return promise
}
/**
* 检查缓存是否已过期
* @param key 缓存键
* @returns 如果缓存不存在或已过期返回 true否则返回 false
*/
public static isCacheExpired(key: string): boolean {
const cachedEntry = OssSingleton.cache.get(key)
if (!cachedEntry) {
return true
}
return Date.now() >= cachedEntry.expirationTime
}
}

View File

@@ -0,0 +1,115 @@
import type OSS from 'ali-oss'
import {
getSTSToken,
getOssSignatureUrl,
checkOssSignatureUrlIsExist,
} from '@apis/oss'
import type { STSTokenResponse, UploadParams, UploadResult } from '../type'
import { OSSBucketType } from '../type'
import { StorageAdapter } from './base'
// 常量定义
const OSS_REGION = 'oss-cn-hangzhou'
export class OSSClient extends StorageAdapter<OSS> {
private static instance: OSSClient
// 获取默认实例键值
protected getDefaultInstanceKey(): string {
return 'bty-oss-token'
}
// 获取提供商前缀
protected getProviderPrefix(): string {
return 'custom-oss'
}
// 默认获取STS Token的方法
protected async defaultGetSTSToken(): Promise<STSTokenResponse> {
return getSTSToken()
}
// 获取默认OSSClient单例
public static getInstance(): OSSClient {
if (!OSSClient.instance) {
OSSClient.instance = new OSSClient()
}
return OSSClient.instance
}
// 创建自定义OSSClient实例
public static createCustomInstance(
getSTSTokenFn: () => Promise<STSTokenResponse>,
): OSSClient {
return new OSSClient(getSTSTokenFn)
}
// 刷新OSS客户端
protected async refreshClient(): Promise<void> {
const token = await this.getSTSToken()
const aliOssModule: any = await import('ali-oss')
const OSSSdk: typeof OSS = aliOssModule.default || (aliOssModule as any)
this.client = new OSSSdk({
region: token.region || OSS_REGION,
accessKeyId: token.access_key_id,
accessKeySecret: token.access_key_secret,
stsToken: token.security_token,
secure: true,
...(Reflect.has(token, 'secure') ? { secure: token.secure } : {}),
})
this.bucketMap = {
[OSSBucketType.PRIVATE]: token.data_bucket,
[OSSBucketType.PUBLIC]: token.resource_bucket,
} as Record<OSSBucketType, string>
}
// 上传文件
public async upload(params: UploadParams): Promise<UploadResult> {
try {
const client = await this.getClient()
const bucketName =
params.custom_bucket ||
this.getBucketName(params.bucketType || OSSBucketType.PRIVATE)
client!.useBucket(bucketName)
const result = await client.put(
params.filePath,
params.file,
params.options || {},
)
const url = (result.res as any)?.requestUrls?.[0]?.split('?')?.[0]
return { url, result }
} catch (error) {
console.error('File upload failed:', error)
throw new Error(`File upload failed: ${(error as Error).message}`)
}
}
public async multipartUpload(params: UploadParams): Promise<UploadResult> {
try {
const client = await this.getClient()
const bucketName =
params.custom_bucket ||
this.getBucketName(params.bucketType || OSSBucketType.PRIVATE)
client!.useBucket(bucketName)
const result = await client.multipartUpload(
params.filePath,
params.file,
params.options || {},
)
const url = (result.res as any)?.requestUrls?.[0]?.split('?')?.[0]
return { url, result }
} catch (error) {
console.error('File upload failed:', error)
throw new Error(`File upload failed: ${(error as Error).message}`)
}
}
public async getOssSignatureUrl(key: string, params?: Record<string, any>) {
return getOssSignatureUrl(key, params)
}
public async checkOssSignatureUrlIsExist(url: string) {
return checkOssSignatureUrlIsExist(url)
}
}

View File

@@ -0,0 +1,128 @@
import OssSingleton from '../OssSingleton'
import type {
STSTokenResponse,
OSSBucketType,
UploadParams,
UploadResult,
} from '../type'
// 常量定义
// 最大缓存时长:无论 token 实际有效期多久,最多只缓存 10 分钟
export const MAX_TOKEN_CACHE_DURATION = 10 * 60 * 1000 // 10 分钟
/**
* 根据 token 的 expiration 字段计算缓存时长
* @param tokenResponse STS Token 响应
* @returns 缓存时长(毫秒)
*/
export function calculateTokenCacheDuration(
tokenResponse: STSTokenResponse,
): number {
// 如果没有 expiration 字段,使用最大缓存时长
if (!tokenResponse.expiration) {
return MAX_TOKEN_CACHE_DURATION
}
try {
const expirationTime = new Date(tokenResponse.expiration).getTime()
const remainingTime = expirationTime - Date.now()
// token 已过期,不缓存
if (remainingTime <= 0) {
return 0
}
// 取服务端返回的时间和最大缓存时长中的较小值
const cacheDuration = Math.min(remainingTime, MAX_TOKEN_CACHE_DURATION)
console.log('[Token Cache] Cache duration:', cacheDuration, 'ms')
return cacheDuration
} catch (error) {
console.warn('[Token Cache] Parse error, using max duration:', error)
return MAX_TOKEN_CACHE_DURATION
}
}
// 存储适配器抽象基类
export abstract class StorageAdapter<C = any> {
protected client: C | null = null
protected instanceKey: string | null = null
protected bucketMap: Record<OSSBucketType, string> = {} as Record<
OSSBucketType,
string
>
protected getSTSTokenFn: () => Promise<STSTokenResponse>
constructor(getSTSTokenFn?: () => Promise<STSTokenResponse>) {
if (getSTSTokenFn) {
this.getSTSTokenFn = getSTSTokenFn
this.instanceKey = this.generateInstanceKey(getSTSTokenFn)
} else {
this.getSTSTokenFn = this.defaultGetSTSToken
this.instanceKey = this.getDefaultInstanceKey()
}
}
// 生成实例唯一键值
protected generateInstanceKey(fn: (...args: any[]) => any): string {
const fnString = fn.toString()
let hash = 0
for (let i = 0; i < fnString.length; i++) {
const char = fnString.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
return `${this.getProviderPrefix()}-token-${hash}`
}
// 获取默认实例键值
protected abstract getDefaultInstanceKey(): string
// 获取提供商前缀
protected abstract getProviderPrefix(): string
// 默认获取STS Token的方法
protected abstract defaultGetSTSToken(): Promise<STSTokenResponse>
// 获取并可能刷新STS Token
protected async getSTSToken(): Promise<STSTokenResponse> {
return OssSingleton.getCachedData<STSTokenResponse>(
this.instanceKey!,
this.getSTSTokenFn,
// 传入函数来动态计算缓存时长
calculateTokenCacheDuration,
)
}
// 刷新客户端
protected abstract refreshClient(): Promise<void>
// 获取客户端
public async getClient(): Promise<C> {
// 检查 token 缓存是否过期
const tokenExpired = OssSingleton.isCacheExpired(this.instanceKey!)
// 如果 client 不存在或 token 已过期,则刷新 client
if (!this.client || tokenExpired) {
console.log('[Client] Refreshing client, token expired:', tokenExpired)
await this.refreshClient()
}
return this.client!
}
// 获取存储桶名称
protected getBucketName(bucketType: OSSBucketType): string {
return this.bucketMap[bucketType]
}
// 抽象方法,子类必须实现
public abstract upload(params: UploadParams): Promise<UploadResult>
public abstract multipartUpload(params: UploadParams): Promise<UploadResult>
public abstract getOssSignatureUrl(
key: string,
params?: Record<string, any>,
): Promise<string>
public abstract checkOssSignatureUrlIsExist(url: string): Promise<boolean>
}

View File

@@ -0,0 +1,207 @@
import {
S3Client,
PutObjectCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
CompleteMultipartUploadCommand,
} from '@aws-sdk/client-s3'
import {
getOssSignatureUrl as apiGetOssSignatureUrl,
checkOssSignatureUrlIsExist as apiCheckOssSignatureUrlIsExist,
getSTSToken,
} from '@apis/oss'
import type { STSTokenResponse, UploadParams, UploadResult } from '../type'
import { OSSBucketType } from '../type'
import { StorageAdapter } from './base'
// 添加常量
const PART_SIZE = 5 * 1024 * 1024 // 5MB
export class MinIOAdapter extends StorageAdapter<S3Client> {
private static instance: MinIOAdapter
private config: Record<string, any> = {}
// 获取默认实例键值
protected getDefaultInstanceKey(): string {
return 'minio-sts-token'
}
// 获取提供商前缀
protected getProviderPrefix(): string {
return 'custom-minio'
}
// 默认获取STS Token的方法
protected async defaultGetSTSToken(): Promise<STSTokenResponse> {
const token = await getSTSToken()
return token
}
public static getInstance(): MinIOAdapter {
if (!MinIOAdapter.instance) {
MinIOAdapter.instance = new MinIOAdapter()
}
return MinIOAdapter.instance
}
// 添加创建自定义实例的静态方法
public static createCustomInstance(
getSTSTokenFn: () => Promise<STSTokenResponse>,
): MinIOAdapter {
return new MinIOAdapter(getSTSTokenFn)
}
// 刷新S3客户端
protected async refreshClient(): Promise<void> {
try {
// 获取最新的token
const token = await this.getSTSToken()
this.config = {
...token,
endpoint: token.server_endpoint,
}
this.client = new S3Client({
region: token.region,
endpoint: token.server_endpoint,
credentials: {
accessKeyId: token.access_key_id,
secretAccessKey: token.access_key_secret,
...(token.security_token
? { sessionToken: token.security_token }
: {}),
},
forcePathStyle: true,
})
this.bucketMap = {
[OSSBucketType.PRIVATE]: token.data_bucket!,
[OSSBucketType.PUBLIC]: token.resource_bucket!,
}
} catch (error) {
throw new Error(`MinIO客户端初始化失败: ${(error as Error).message}`)
}
}
// 优化上传方法,提取公共逻辑
private getUploadBucketAndUrl(params: UploadParams): {
bucketName: string
urlBase: string
} {
const bucketName =
params.custom_bucket ||
this.getBucketName(params.bucketType || OSSBucketType.PRIVATE)
const urlBase = `${this.config.endpoint}/${bucketName}/${params.filePath}`
return { bucketName, urlBase }
}
async upload(params: UploadParams): Promise<UploadResult> {
try {
const client = await this.getClient()
const { bucketName, urlBase } = this.getUploadBucketAndUrl(params)
const body = new Uint8Array(await params.file.arrayBuffer())
const command = new PutObjectCommand({
Bucket: bucketName,
Key: params.filePath,
Body: body,
ContentType: params.file.type,
...params.options,
})
const result = await client.send(command)
const url = urlBase
return {
url,
result: {
res: {
requestUrls: [url],
...result,
},
},
}
} catch (error) {
console.error('文件上传失败:', error)
throw new Error(`文件上传失败: ${(error as Error).message}`)
}
}
async multipartUpload(params: UploadParams): Promise<UploadResult> {
try {
const client = await this.getClient()
const { bucketName, urlBase } = this.getUploadBucketAndUrl(params)
const createCommand = new CreateMultipartUploadCommand({
Bucket: bucketName,
Key: params.filePath,
ContentType: params.file.type,
...params.options,
})
const createResponse = await client.send(createCommand)
const uploadId = createResponse.UploadId
if (!uploadId) {
throw new Error('初始化分片上传失败')
}
const fileBuffer = await params.file.arrayBuffer()
const totalParts = Math.ceil(fileBuffer.byteLength / PART_SIZE)
const uploadPromises = []
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
const start = (partNumber - 1) * PART_SIZE
const end = Math.min(start + PART_SIZE, fileBuffer.byteLength)
const partBuffer = fileBuffer.slice(start, end)
const uploadPartCommand = new UploadPartCommand({
Bucket: bucketName,
Key: params.filePath,
PartNumber: partNumber,
UploadId: uploadId,
Body: new Uint8Array(partBuffer),
})
uploadPromises.push(client.send(uploadPartCommand))
}
const uploadResults = await Promise.all(uploadPromises)
const completeCommand = new CompleteMultipartUploadCommand({
Bucket: bucketName,
Key: params.filePath,
UploadId: uploadId,
MultipartUpload: {
Parts: uploadResults.map((result, index) => ({
ETag: result.ETag,
PartNumber: index + 1,
})),
},
})
const completeResponse = await client.send(completeCommand)
const url = urlBase
return {
url,
result: {
res: {
requestUrls: [url],
...completeResponse,
},
},
}
} catch (error) {
console.error('分片上传失败:', error)
throw new Error(`分片上传失败: ${(error as Error).message}`)
}
}
async getOssSignatureUrl(
key: string,
params?: Record<string, any>,
): Promise<string> {
return apiGetOssSignatureUrl(key, params)
}
async checkOssSignatureUrlIsExist(): Promise<boolean> {
return apiCheckOssSignatureUrlIsExist()
}
}

View File

@@ -0,0 +1,53 @@
import { PrivateOSSType } from '@bty/constant'
import { OSSClient } from './adapter/alioss'
import { MinIOAdapter } from './adapter/minio'
import type { STSTokenResponse } from './type'
export * from './type'
const StorageType = {
AliOSS: PrivateOSSType.ALIYUN,
MinIO: PrivateOSSType.MINIO,
} as const
type StorageType = (typeof StorageType)[keyof typeof StorageType]
function checkIsMinIOVersion() {
try {
return false
} catch {
return false
}
}
const isMinIOVersion = checkIsMinIOVersion()
const STORAGE_TYPE: StorageType = isMinIOVersion
? StorageType.MinIO
: StorageType.AliOSS
// 根据环境变量选择存储适配器
function getAdapterClass() {
switch (STORAGE_TYPE) {
case StorageType.MinIO:
return MinIOAdapter
case StorageType.AliOSS:
default:
return OSSClient
}
}
// 获取适配器类
const AdapterClass = getAdapterClass()
// 导出工厂方法,根据环境变量创建相应的适配器实例
export function createCustomOSSUploader(
tokenFn?: () => Promise<STSTokenResponse>,
) {
return tokenFn
? AdapterClass.createCustomInstance(tokenFn)
: AdapterClass.getInstance()
}
// 默认导出实例,根据环境变量选择适当的实现
export const ossUploader = AdapterClass.getInstance()

View File

@@ -0,0 +1,35 @@
// 存储桶类型
export const OSSBucketType = {
PRIVATE: 'PRIVATE',
PUBLIC: 'PUBLIC',
} as const
export type OSSBucketType = (typeof OSSBucketType)[keyof typeof OSSBucketType]
// 统一的上传参数接口
export interface UploadParams {
file: File
filePath: string
bucketType?: OSSBucketType
options?: Record<string, any>
custom_bucket?: string
}
// 统一的上传结果接口
export interface UploadResult {
url: string
result: any
}
export interface STSTokenResponse {
server_endpoint?: string | undefined
access_key_id: string
access_key_secret: string
security_token?: string
expiration?: string
data_bucket?: string
resource_bucket?: string
region?: string
secure?: boolean
endpoint?: string
}