diff --git a/app.go b/app.go index fe1ce99..8a554bb 100644 --- a/app.go +++ b/app.go @@ -18,6 +18,7 @@ import ( "u-desk/internal/api" "u-desk/internal/common" "u-desk/internal/filesystem" + osssvc "u-desk/internal/ossdrv" "u-desk/internal/service" "u-desk/internal/sftp" "u-desk/internal/storage" @@ -38,6 +39,7 @@ type App struct { pdfAPI *api.PdfAPI filesystem *filesystem.FileSystemService sftpService *sftp.Service + ossService *osssvc.Service profileSvc *service.ProfileService isAlwaysOnTop bool mu sync.Mutex @@ -217,6 +219,9 @@ func (a *App) ServiceShutdown() error { } sftp.CleanupTempFiles() + // 关闭所有 OSS 连接 + osssvc.GetManager().Shutdown() + return nil } @@ -972,6 +977,104 @@ func (a *App) SftpGetSystemInfo(connID string) (map[string]interface{}, error) { return a.ensureSftpService().GetSystemInfo(connID) } +// ========== OSS 接口 ========== + +func (a *App) ensureOssService() *osssvc.Service { + a.mu.Lock() + defer a.mu.Unlock() + if a.ossService == nil { + a.ossService = osssvc.NewService() + } + return a.ossService +} + +type OssConnectRequest struct { + Provider string `json:"provider"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Endpoint string `json:"endpoint"` +} + +func (a *App) OssConnect(req OssConnectRequest) (string, error) { + if err := a.ensureOssService().GetManager().Connect(req.Provider, req.AccessKey, req.SecretKey, req.Endpoint); err != nil { + return "", err + } + return req.Provider, nil +} + +// OssDisconnect 断开 OSS 连接 +func (a *App) OssDisconnect(connID string) error { + osssvc.GetManager().Disconnect(connID) + return nil +} + +// OssListDir OSS 列出目录 +func (a *App) OssListDir(connID string, prefix string) ([]map[string]interface{}, error) { + return a.ensureOssService().ListDir(connID, prefix) +} + +// OssDownloadToTemp OSS 下载到临时文件 +func (a *App) OssDownloadToTemp(connID string, key string) (string, error) { + return a.ensureOssService().DownloadToTemp(connID, key) +} + +// OssReadFile OSS 读取文件 +func (a *App) OssReadFile(connID string, key string) (string, error) { + return a.ensureOssService().ReadFile(connID, key) +} + +// OssWriteFile OSS 写入文件 +func (a *App) OssWriteFile(connID string, key string, content string) error { + return a.ensureOssService().WriteFile(connID, key, content) +} + +// OssWriteBase64File OSS 写入 base64 编码文件 +func (a *App) OssWriteBase64File(connID string, key string, base64Content string) error { + return a.ensureOssService().WriteBase64File(connID, key, base64Content) +} + +// OssGetFileInfo OSS 获取文件信息 +func (a *App) OssGetFileInfo(connID string, key string) (map[string]interface{}, error) { + return a.ensureOssService().GetFileInfo(connID, key) +} + +// OssCreateDir OSS 创建目录 +func (a *App) OssCreateDir(connID string, dirPath string) (*filesystem.FileOperationResult, error) { + return a.ensureOssService().CreateDir(connID, dirPath) +} + +// OssCreateFile OSS 创建文件 +func (a *App) OssCreateFile(connID string, filePath string) (*filesystem.FileOperationResult, error) { + return a.ensureOssService().CreateFile(connID, filePath) +} + +// OssDeletePath OSS 删除 +func (a *App) OssDeletePath(connID string, key string) (*filesystem.FileOperationResult, error) { + return a.ensureOssService().DeletePath(connID, key) +} + +// OssRenamePathRequest OSS 重命名请求 +type OssRenamePathRequest struct { + ConnID string `json:"conn_id"` + OldPath string `json:"old_path"` + NewPath string `json:"new_path"` +} + +// OssRenamePath OSS 重命名 +func (a *App) OssRenamePath(req OssRenamePathRequest) (*filesystem.FileOperationResult, error) { + return a.ensureOssService().RenamePath(req.ConnID, req.OldPath, req.NewPath) +} + +// OssGetCommonPaths OSS 获取常用路径 +func (a *App) OssGetCommonPaths(connID string) (map[string]string, error) { + return a.ensureOssService().GetCommonPaths(connID) +} + +// OssGetSignedURL OSS 获取预签名 URL +func (a *App) OssGetSignedURL(connID string, key string) (string, error) { + return a.ensureOssService().GetSignedURL(connID, key) +} + // --- 连接配置 CRUD (SQLite 持久化) --- type SaveProfileRequest struct { @@ -984,6 +1087,11 @@ type SaveProfileRequest struct { KeyPath string `json:"key_path"` Type string `json:"type"` Token string `json:"token"` + AccessKey string `json:"access_key"` + SecretKey string `json:"secret_key"` + Bucket string `json:"bucket"` + Region string `json:"region"` + Endpoint string `json:"endpoint"` LastConnected *int64 `json:"last_connected"` } @@ -1002,17 +1110,22 @@ func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) { result := make([]map[string]interface{}, len(list)) for i, p := range list { result[i] = map[string]interface{}{ - "id": float64(p.ID), - "name": p.Name, - "host": p.Host, - "port": p.Port, - "username": p.Username, - "password": p.Password, - "keyPath": p.KeyPath, - "type": p.Type, - "token": p.Token, - "lastConnected": p.LastConnected, - "sortOrder": float64(p.SortOrder), + "id": float64(p.ID), + "name": p.Name, + "host": p.Host, + "port": p.Port, + "username": p.Username, + "password": p.Password, + "keyPath": p.KeyPath, + "type": p.Type, + "token": p.Token, + "accessKey": p.AccessKey, + "secretKey": p.SecretKey, + "bucket": p.Bucket, + "region": p.Region, + "endpoint": p.Endpoint, + "lastConnected": p.LastConnected, + "sortOrder": float64(p.SortOrder), } } return result, nil @@ -1023,6 +1136,8 @@ func (a *App) SaveConnectionProfile(req SaveProfileRequest) (map[string]interfac Name: req.Name, Host: req.Host, Port: req.Port, Username: req.Username, Password: req.Password, KeyPath: req.KeyPath, Type: req.Type, Token: req.Token, + AccessKey: req.AccessKey, SecretKey: req.SecretKey, + Bucket: req.Bucket, Region: req.Region, Endpoint: req.Endpoint, } if req.LastConnected != nil { t := time.Unix(*req.LastConnected, 0) diff --git a/frontend/bindings/u-desk/app.ts b/frontend/bindings/u-desk/app.ts index 4b8c116..1e51f1e 100644 --- a/frontend/bindings/u-desk/app.ts +++ b/frontend/bindings/u-desk/app.ts @@ -302,6 +302,115 @@ export function OpenPath(path: string): $CancellablePromise { return $Call.ByID(1591734570, path); } +export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise { + return $Call.ByID(3667022538, req); +} + +/** + * OssCreateDir OSS 创建目录 + */ +export function OssCreateDir(connID: string, dirPath: string): $CancellablePromise { + return $Call.ByID(605668951, connID, dirPath).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * OssCreateFile OSS 创建文件 + */ +export function OssCreateFile(connID: string, filePath: string): $CancellablePromise { + return $Call.ByID(4148593430, connID, filePath).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * OssDeletePath OSS 删除 + */ +export function OssDeletePath(connID: string, key: string): $CancellablePromise { + return $Call.ByID(4285234744, connID, key).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * OssDisconnect 断开 OSS 连接 + */ +export function OssDisconnect(connID: string): $CancellablePromise { + return $Call.ByID(3427288622, connID); +} + +/** + * OssDownloadToTemp OSS 下载到临时文件 + */ +export function OssDownloadToTemp(connID: string, key: string): $CancellablePromise { + return $Call.ByID(370656471, connID, key); +} + +/** + * OssGetCommonPaths OSS 获取常用路径 + */ +export function OssGetCommonPaths(connID: string): $CancellablePromise<{ [_ in string]?: string }> { + return $Call.ByID(3525024115, connID).then(($result: any) => { + return $$createType4($result); + }); +} + +/** + * OssGetFileInfo OSS 获取文件信息 + */ +export function OssGetFileInfo(connID: string, key: string): $CancellablePromise<{ [_ in string]?: any }> { + return $Call.ByID(852430614, connID, key).then(($result: any) => { + return $$createType0($result); + }); +} + +/** + * OssGetSignedURL OSS 获取预签名 URL + */ +export function OssGetSignedURL(connID: string, key: string): $CancellablePromise { + return $Call.ByID(1344953417, connID, key); +} + +/** + * OssListDir OSS 列出目录 + */ +export function OssListDir(connID: string, prefix: string): $CancellablePromise<{ [_ in string]?: any }[]> { + return $Call.ByID(3013212019, connID, prefix).then(($result: any) => { + return $$createType3($result); + }); +} + +/** + * OssReadFile OSS 读取文件 + */ +export function OssReadFile(connID: string, key: string): $CancellablePromise { + return $Call.ByID(1629576606, connID, key); +} + +/** + * OssRenamePath OSS 重命名 + */ +export function OssRenamePath(req: $models.OssRenamePathRequest): $CancellablePromise { + return $Call.ByID(4218061693, req).then(($result: any) => { + return $$createType2($result); + }); +} + +/** + * OssWriteBase64File OSS 写入 base64 编码文件 + */ +export function OssWriteBase64File(connID: string, key: string, base64Content: string): $CancellablePromise { + return $Call.ByID(1772140162, connID, key, base64Content); +} + +/** + * OssWriteFile OSS 写入文件 + */ +export function OssWriteFile(connID: string, key: string, content: string): $CancellablePromise { + return $Call.ByID(39773277, connID, key, content); +} + /** * ReadFile 读取文件 */ diff --git a/frontend/bindings/u-desk/index.ts b/frontend/bindings/u-desk/index.ts index 908db4e..e4245b9 100644 --- a/frontend/bindings/u-desk/index.ts +++ b/frontend/bindings/u-desk/index.ts @@ -7,6 +7,8 @@ export { }; export { + OssConnectRequest, + OssRenamePathRequest, RenamePathRequest, SaveAppConfigRequest, SaveBase64FileRequest, diff --git a/frontend/bindings/u-desk/models.ts b/frontend/bindings/u-desk/models.ts index 3f79848..fc79dbe 100644 --- a/frontend/bindings/u-desk/models.ts +++ b/frontend/bindings/u-desk/models.ts @@ -9,6 +9,71 @@ import { Create as $Create } from "@wailsio/runtime"; // @ts-ignore: Unused imports import * as api$0 from "./internal/api/models.js"; +export class OssConnectRequest { + "provider": string; + "access_key": string; + "secret_key": string; + "endpoint": string; + + /** Creates a new OssConnectRequest instance. */ + constructor($$source: Partial = {}) { + if (!("provider" in $$source)) { + this["provider"] = ""; + } + if (!("access_key" in $$source)) { + this["access_key"] = ""; + } + if (!("secret_key" in $$source)) { + this["secret_key"] = ""; + } + if (!("endpoint" in $$source)) { + this["endpoint"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new OssConnectRequest instance from a string or object. + */ + static createFrom($$source: any = {}): OssConnectRequest { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new OssConnectRequest($$parsedSource as Partial); + } +} + +/** + * OssRenamePathRequest OSS 重命名请求 + */ +export class OssRenamePathRequest { + "conn_id": string; + "old_path": string; + "new_path": string; + + /** Creates a new OssRenamePathRequest instance. */ + constructor($$source: Partial = {}) { + if (!("conn_id" in $$source)) { + this["conn_id"] = ""; + } + if (!("old_path" in $$source)) { + this["old_path"] = ""; + } + if (!("new_path" in $$source)) { + this["new_path"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new OssRenamePathRequest instance from a string or object. + */ + static createFrom($$source: any = {}): OssRenamePathRequest { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new OssRenamePathRequest($$parsedSource as Partial); + } +} + /** * RenamePathRequest 重命名文件或目录请求结构体 */ @@ -119,6 +184,11 @@ export class SaveProfileRequest { "key_path": string; "type": string; "token": string; + "access_key": string; + "secret_key": string; + "bucket": string; + "region": string; + "endpoint": string; "last_connected": number | null; /** Creates a new SaveProfileRequest instance. */ @@ -150,6 +220,21 @@ export class SaveProfileRequest { if (!("token" in $$source)) { this["token"] = ""; } + if (!("access_key" in $$source)) { + this["access_key"] = ""; + } + if (!("secret_key" in $$source)) { + this["secret_key"] = ""; + } + if (!("bucket" in $$source)) { + this["bucket"] = ""; + } + if (!("region" in $$source)) { + this["region"] = ""; + } + if (!("endpoint" in $$source)) { + this["endpoint"] = ""; + } if (!("last_connected" in $$source)) { this["last_connected"] = null; } diff --git a/frontend/src/api/connection-manager.ts b/frontend/src/api/connection-manager.ts index 4bb7ecf..4483428 100644 --- a/frontend/src/api/connection-manager.ts +++ b/frontend/src/api/connection-manager.ts @@ -7,13 +7,14 @@ import type { FsTransport } from './transport' import { WailsTransport } from './wails-transport' import { HttpTransport } from './http-transport' import { SftpTransport } from './sftp-transport' +import { OssTransport } from './oss-transport' import { getFileServerBaseURL } from './file-server' import { LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile, SftpGetSystemInfo, GetLocalSystemInfo, } from '@bindings/u-desk/app' -export type ConnectionType = 'local' | 'remote' | 'sftp' +export type ConnectionType = 'local' | 'remote' | 'sftp' | 'qiniu' | 'aliyun' export interface ConnectionProfile { id: string | number @@ -25,6 +26,11 @@ export interface ConnectionProfile { username?: string password?: string keyPath?: string + accessKey?: string + secretKey?: string + bucket?: string + region?: string + endpoint?: string lastConnected?: number } @@ -124,6 +130,11 @@ class ConnectionManagerImpl { keyPath: profile.keyPath || '', type: profile.type, token: profile.token || '', + access_key: profile.accessKey || '', + secret_key: profile.secretKey || '', + bucket: profile.bucket || '', + region: profile.region || '', + endpoint: profile.endpoint || '', lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined, }) } @@ -189,7 +200,7 @@ class ConnectionManagerImpl { isRemote(): boolean { const t = this.activeProfile?.type - return t === 'remote' || t === 'sftp' + return t === 'remote' || t === 'sftp' || t === 'qiniu' || t === 'aliyun' } getSystemInfo(profileId: string): SystemInfo | undefined { @@ -210,6 +221,11 @@ class ConnectionManagerImpl { const data = await SftpGetSystemInfo(t.sessionId) if (data) Object.assign(info, snakeToCamel(data)) } + } else if (profile.type === 'qiniu' || profile.type === 'aliyun') { + // OSS 无系统信息可采集 + info.diskUsage = '-' + info.cpuUsage = '-' + info.memUsage = '-' } else if (profile.type === 'remote') { const t = this.getTransportFor(profileId) if (t instanceof HttpTransport) { @@ -243,16 +259,16 @@ class ConnectionManagerImpl { const profile = this._profiles.find(p => p.id === profileId) if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`)) - this._activeId = profileId - // 池中已有,直接复用 if (this._pool.has(profileId)) { + this._activeId = profileId this.setState('connected') return } - // 新建连接并入池 + // 新建连接并入池(成功后再设 activeId) await this.buildAndPool(profileId, profile) + this._activeId = profileId } /** 断开指定 profile 并从池移除 */ @@ -260,14 +276,15 @@ class ConnectionManagerImpl { if (profileId === 'local-default') return const t = this._pool.get(profileId) if (t instanceof SftpTransport) { t.disconnect() } + if (t instanceof OssTransport) { t.disconnect() } this._pool.delete(profileId) } /** 断开所有远程连接(保留 local) */ disconnectAll(): void { for (const [id, t] of this._pool) { - if (id !== 'local-default' && t instanceof SftpTransport) { - t.disconnect() + if (id !== 'local-default') { + if (t instanceof SftpTransport || t instanceof OssTransport) t.disconnect() } } this._pool.clear() @@ -294,7 +311,7 @@ class ConnectionManagerImpl { this._profiles[idx] = { ...this._profiles[idx], ...updates } await this.persistProfile(this._profiles[idx]) // 连接参数变更 → 淘汰旧连接,下次 connect 时重建 - const EVICT_KEYS = ['host', 'port', 'token', 'username', 'password', 'keyPath'] as const + const EVICT_KEYS = ['host', 'port', 'token', 'username', 'password', 'keyPath', 'accessKey', 'secretKey', 'bucket', 'region', 'endpoint'] as const if (EVICT_KEYS.some(k => k in updates)) { this.disconnectProfile(id) if (id === this._activeId) { @@ -346,6 +363,23 @@ class ConnectionManagerImpl { return } + // OSS (qiniu / aliyun) + if (profile.type === 'qiniu' || profile.type === 'aliyun') { + this.setState('connecting') + try { + const t = new OssTransport(profile) + await t.connect() + this._pool.set(profileId, t) + this.setState('connected') + this.updateProfile(profileId, { lastConnected: Date.now() }) + } catch (err) { + this._pool.delete(profileId) + this.setState('error') + throw err + } + return + } + // remote / http agent this.setState('connecting') try { diff --git a/frontend/src/api/oss-transport.ts b/frontend/src/api/oss-transport.ts new file mode 100644 index 0000000..96772b9 --- /dev/null +++ b/frontend/src/api/oss-transport.ts @@ -0,0 +1,194 @@ +/** + * OSS Transport — 通过 Wails IPC 调用 Go 后端 OSS 客户端 + */ + +import type { + FsTransport, FileItem, FileOperationResult, DetectTypeResult, +} from './transport' +import type { ConnectionProfile } from './connection-manager' +import { + OssConnect, OssDisconnect, OssListDir, OssReadFile, + OssWriteFile, OssGetFileInfo, OssCreateDir, OssCreateFile, + OssDeletePath, OssRenamePath, OssDownloadToTemp, OssGetCommonPaths, + OssWriteBase64File, OssGetSignedURL, +} from '@bindings/u-desk/app' + +function transformFile(file: any): FileItem { + return { ...file, is_dir: file.is_dir, mod_time: file.mod_time } +} + +function transformFileList(files: any[]): FileItem[] { + return files.map(transformFile) +} + +const PREVIEW_CACHE_MAX = 50 + +export class OssTransport implements FsTransport { + private profile: ConnectionProfile + private connID: string | null = null + private previewCache = new Map() + private previewOrder: string[] = [] + + constructor(profile: ConnectionProfile) { + this.profile = profile + } + + async connect(): Promise { + const result = await OssConnect({ + provider: this.profile.type, + access_key: this.profile.accessKey || '', + secret_key: this.profile.secretKey || '', + endpoint: this.profile.endpoint || '', + }) + this.connID = result + return result + } + + get sessionId(): string | null { return this.connID } + + async disconnect(): Promise { + if (this.connID) { + try { await OssDisconnect(this.connID) } catch (e) { + console.warn('[OSS] disconnect error:', e) + } + this.connID = null + } + this.previewCache.clear() + this.previewOrder = [] + } + + private requireConn(): string { + if (!this.connID) throw new Error('OSS 未连接') + return this.connID + } + + // ====== 文件列表与信息 ====== + + async listDir(path: string): Promise { + return transformFileList(await OssListDir(this.requireConn(), path)) + } + + async getFileInfo(path: string): Promise> { + return OssGetFileInfo(this.requireConn(), path) + } + + // ====== 文件读写 ====== + + async readFile(path: string): Promise { + return OssReadFile(this.requireConn(), path) + } + + async writeFile(path: string, content: string): Promise { + await OssWriteFile(this.requireConn(), path, content) + } + + async saveBase64File(path: string, content: string): Promise { + if (!content) throw new Error('无效的 base64 内容') + await OssWriteBase64File(this.requireConn(), path, content) + } + + // ====== 文件操作 ====== + + async createFile(dirPath: string, filename: string): Promise { + const fullPath = dirPath.replace(/\/$/, '') + '/' + filename + return OssCreateFile(this.requireConn(), fullPath) + } + + async createDir(parentPath: string, dirname: string): Promise { + const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname + return OssCreateDir(this.requireConn(), fullPath) + } + + async deletePath(path: string): Promise { + return OssDeletePath(this.requireConn(), path) + } + + async renamePath(oldPath: string, newPath: string): Promise { + return OssRenamePath({ + conn_id: this.requireConn(), + old_path: oldPath, + new_path: newPath, + }) + } + + // ====== ZIP 操作(不支持)====== + + async listZipContents(_zipPath: string): Promise { + throw new Error('ZIP 操作在 OSS 模式暂未实现') + } + + async extractFileFromZip(_zipPath: string, _filePath: string): Promise { + throw new Error('ZIP 操作在 OSS 模式暂未实现') + } + + async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise { + throw new Error('ZIP 操作在 OSS 模式暂未实现') + } + + async getZipFileInfo(_zipPath: string, _filePath: string): Promise { + throw new Error('ZIP 操作在 OSS 模式暂未实现') + } + + // ====== 系统操作 ====== + + async openPath(_path: string): Promise { + throw new Error('OSS 模式不支持打开本地路径') + } + + async getFileServerURL(): Promise { + return '' + } + + getPreviewToken(): string { + return '' + } + + async resolveShortcut(_lnkPath: string): Promise { + return null + } + + async detectFileTypeByContent(_path: string): Promise { + return { extension: '', category: 'unknown', mime_type: '', confidence: 0 } + } + + async getCommonPaths(): Promise> { + return OssGetCommonPaths(this.requireConn()) + } + + // ====== 回收站(无)====== + + async getRecycleBinEntries(): Promise { + return [] + } + + async restoreFromRecycleBin(_path: string): Promise {} + + async deletePermanently(_path: string): Promise {} + + async emptyRecycleBin(): Promise {} + + // ====== 预览辅助 ====== + + async downloadForPreview(remotePath: string): Promise { + if (this.previewCache.has(remotePath)) { + this.previewOrder = this.previewOrder.filter(p => p !== remotePath) + this.previewOrder.push(remotePath) + return this.previewCache.get(remotePath)! + } + const localPath = await OssDownloadToTemp(this.requireConn(), remotePath) + + if (this.previewOrder.length >= PREVIEW_CACHE_MAX) { + const oldest = this.previewOrder.shift()! + this.previewCache.delete(oldest) + } + + this.previewCache.set(remotePath, localPath) + this.previewOrder.push(remotePath) + return localPath + } + + /** 获取预签名 URL(用于直接预览) */ + async getSignedUrl(key: string): Promise { + return OssGetSignedURL(this.requireConn(), key) + } +} diff --git a/frontend/src/components/FileSystem/components/ConnectionDialog.vue b/frontend/src/components/FileSystem/components/ConnectionDialog.vue index bbeb709..2342655 100644 --- a/frontend/src/components/FileSystem/components/ConnectionDialog.vue +++ b/frontend/src/components/FileSystem/components/ConnectionDialog.vue @@ -4,9 +4,18 @@
- + SFTP HTTP Agent + 云OSS + +
+ +
+ + + 七牛云 + 阿里云
@@ -16,13 +25,26 @@ - -
+ +
+ + + @@ -175,6 +176,7 @@ function dotClass(p: { type: string }): string { .dot.local { background: var(--color-text-3); } .dot.remote { background: var(--color-primary-6); } .dot.sftp { background: #7c3aed; } +.dot.oss { background: #ff7d00; } .label { max-width: 70px; diff --git a/frontend/src/components/FileSystem/components/FileEditorPanel.vue b/frontend/src/components/FileSystem/components/FileEditorPanel.vue index b0cdc53..7a6ebcd 100644 --- a/frontend/src/components/FileSystem/components/FileEditorPanel.vue +++ b/frontend/src/components/FileSystem/components/FileEditorPanel.vue @@ -74,7 +74,7 @@
- +
{{ mediaErrorMsg }}
🎬 视频 @@ -83,7 +83,7 @@
- +
{{ mediaErrorMsg }}
🎵 音频 @@ -566,6 +566,7 @@ const handleImageError = () => { } const mediaErrorMsg = ref('') +watch(() => props.config.previewUrl, () => { mediaErrorMsg.value = '' }) const handleMediaError = (type: string) => { mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限` } diff --git a/frontend/src/components/FileSystem/composables/useFilePreview.ts b/frontend/src/components/FileSystem/composables/useFilePreview.ts index 65c9691..cd1caca 100644 --- a/frontend/src/components/FileSystem/composables/useFilePreview.ts +++ b/frontend/src/components/FileSystem/composables/useFilePreview.ts @@ -9,6 +9,7 @@ import { normalizeFilePath, getExt } from '@/utils/fileUtils' import { detectFileTypeByContent } from '@/api/system' import { connectionManager } from '@/api/connection-manager' import { SftpTransport } from '@/api/sftp-transport' +import { OssTransport } from '@/api/oss-transport' import { getFileServerBaseURL } from '@/api/file-server' import { isImageFile, isVideoFile, isAudioFile, isPdfFile, @@ -106,22 +107,25 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) { } /** - * 更新预览 URL(SFTP 模式会先下载到本地临时目录) + * 更新预览 URL + * SFTP/OSS:下载到本地临时目录后用本地文件服务器预览 */ const updatePreviewUrl = async (path: string) => { if (!path) { previewUrl.value = ''; return } + const transport = connectionManager.getTransport() - // SFTP 模式:下载到本地临时目录后用本地文件服务器预览 - if (connectionManager.isSftp()) { - const transport = connectionManager.getTransport() - if (transport instanceof SftpTransport) { - try { - const tempPath = await transport.downloadForPreview(path) - previewUrl.value = getPreviewUrl(tempPath) - return - } catch { - // 下载失败,回退显示原始路径(会无法预览但不会崩溃) - } + // SFTP / OSS:下载到本地临时目录后用本地文件服务器预览 + if (transport instanceof SftpTransport || transport instanceof OssTransport) { + try { + const tempPath = await transport.downloadForPreview(path) + // 临时文件通过本地文件服务器提供,始终用 localfs 路径 + const base = getLocalServerURL() + const normalized = normalizeFilePath(tempPath, true) + const sep = base.endsWith('/') ? '' : '/' + previewUrl.value = `${base}${sep}localfs/${normalized}` + return + } catch { + // 下载失败,回退 } } diff --git a/frontend/src/components/FileSystem/index.vue b/frontend/src/components/FileSystem/index.vue index 6e35668..8168cb5 100644 --- a/frontend/src/components/FileSystem/index.vue +++ b/frontend/src/components/FileSystem/index.vue @@ -1371,7 +1371,9 @@ onMounted(async () => { // 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径 const startPath = connectionManager.isRemote() ? '/' : (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/') - if (filePath.value && !connectionManager.isRemote()) { + // 本地模式下只恢复 Windows 路径,跳过 Linux/OSS 路径残留 + const isLocalPath = filePath.value && /^[A-Za-z]:/.test(filePath.value) + if (isLocalPath && !connectionManager.isRemote()) { await loadDirectory(filePath.value) } else { filePath.value = startPath diff --git a/frontend/src/stores/connection.ts b/frontend/src/stores/connection.ts index f432007..01c7b91 100644 --- a/frontend/src/stores/connection.ts +++ b/frontend/src/stores/connection.ts @@ -16,8 +16,13 @@ export const useConnectionStore = defineStore('connection', () => { const isRemote = computed(() => connectionManager.isRemote()) const fileServerBaseURL = computed(() => connectionManager.getFileServerBaseURL()) - function connect(id: string) { - connectionManager.connect(id) + async function connect(id: string) { + try { + await connectionManager.connect(id) + } catch { + // 连接失败,回退到本地 + connectionManager.disconnect() + } activeProfile.value = connectionManager.activeProfile } diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index c4cc980..60f107a 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -170,6 +170,9 @@ export const FILE_ICONS = { // 文件夹 FOLDER: '📁', + // OSS 桶 + BUCKET: '🪣', + // 默认文件 FILE: '📄', } diff --git a/frontend/src/utils/fileUtils.js b/frontend/src/utils/fileUtils.js index 8fb65d4..1b87a41 100644 --- a/frontend/src/utils/fileUtils.js +++ b/frontend/src/utils/fileUtils.js @@ -124,6 +124,11 @@ export function getFileName(path) { export function getFileIcon(fileInfo) { if (!fileInfo) return FILE_ICONS.FILE + // OSS 桶 + if (fileInfo.is_bucket) { + return FILE_ICONS.BUCKET + } + // 如果是目录 if (fileInfo.is_dir) { return FILE_ICONS.FOLDER diff --git a/go.mod b/go.mod index e0fd180..34ebe09 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/yuin/goldmark v1.7.16 golang.org/x/crypto v0.47.0 golang.org/x/sys v0.42.0 + golang.org/x/text v0.33.0 gopkg.in/yaml.v3 v3.0.1 gorm.io/gorm v1.31.1 ) @@ -75,7 +76,6 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect golang.org/x/net v0.49.0 // indirect - golang.org/x/text v0.33.0 // indirect golang.org/x/time v0.14.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect modernc.org/libc v1.67.6 // indirect diff --git a/internal/filesystem/asset_handler.go b/internal/filesystem/asset_handler.go index 5b1739d..3d73c09 100644 --- a/internal/filesystem/asset_handler.go +++ b/internal/filesystem/asset_handler.go @@ -218,9 +218,10 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) { } log.Printf("[LocalFileHandler] 最终路径: %s", filePath) - // 🔒 文件类型白名单检查 + // 🔒 文件类型白名单检查(临时目录文件放行,用于 OSS/SFTP 预览) ext := strings.ToLower(filepath.Ext(filePath)) - if !isAllowedFileType(ext) { + isTemp := strings.HasPrefix(filePath, os.TempDir()) + if !isTemp && !isAllowedFileType(ext) { log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext) http.Error(w, fmt.Sprintf("Forbidden file type: %s", ext), http.StatusForbidden) return diff --git a/internal/filesystem/encoding.go b/internal/filesystem/encoding.go new file mode 100644 index 0000000..b19fe38 --- /dev/null +++ b/internal/filesystem/encoding.go @@ -0,0 +1,24 @@ +package filesystem + +import ( + "bytes" + "io" + "unicode/utf8" + + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/transform" +) + +// BytesToString 智能编码转换:UTF-8 直接返回,否则尝试 GBK → UTF-8 +func BytesToString(data []byte) string { + if utf8.Valid(data) { + return string(data) + } + // 尝试 GBK 解码 + reader := transform.NewReader(bytes.NewReader(data), simplifiedchinese.GBK.NewDecoder()) + decoded, err := io.ReadAll(reader) + if err != nil || !utf8.Valid(decoded) { + return string(data) // 转换失败或结果无效,返回原始内容 + } + return string(decoded) +} diff --git a/internal/filesystem/service.go b/internal/filesystem/service.go index 8784e97..8b852ab 100644 --- a/internal/filesystem/service.go +++ b/internal/filesystem/service.go @@ -142,7 +142,7 @@ func (s *FileSystemService) ReadFile(path string) (string, error) { } s.logRead(path, int64(len(data)), nil) - return string(data), nil + return BytesToString(data), nil } // Write 写入文件内容(实现 FileService 接口) diff --git a/internal/oss/aliyun/bucket.go b/internal/oss/aliyun/bucket.go new file mode 100644 index 0000000..dc998d9 --- /dev/null +++ b/internal/oss/aliyun/bucket.go @@ -0,0 +1,73 @@ +package aliyun + +import ( + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "time" + + "u-desk/internal/oss" +) + +// ListBuckets 列出所有存储桶 +func ListBuckets(accessKeyID, accessKeySecret, endpoint string) ([]oss.BucketEntry, error) { + if endpoint == "" { + endpoint = "oss-cn-hangzhou.aliyuncs.com" + } + url := "https://" + endpoint + "/" + + req, _ := http.NewRequest("GET", url, nil) + date := time.Now().UTC().Format("Mon, 02 Jan 2006 15:04:05 GMT") + req.Header.Set("Date", date) + + stringToSign := "GET\n\n\n" + date + "\n/" + signature := sign(accessKeySecret, stringToSign) + req.Header.Set("Authorization", "OSS "+accessKeyID+":"+signature) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("列举存储桶失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("列举存储桶失败: HTTP %d: %s", resp.StatusCode, string(body)) + } + + var result listAllMyBucketsResult + if err := xml.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("解析存储桶列表失败: %w", err) + } + + entries := make([]oss.BucketEntry, len(result.Buckets.Bucket)) + for i, b := range result.Buckets.Bucket { + entries[i] = oss.BucketEntry{ + Name: b.Name, + Region: b.Location, + } + } + return entries, nil +} + +func sign(secretKey, data string) string { + mac := hmac.New(sha1.New, []byte(secretKey)) + mac.Write([]byte(data)) + return base64.StdEncoding.EncodeToString(mac.Sum(nil)) +} + +type listAllMyBucketsResult struct { + XMLName xml.Name `xml:"ListAllMyBucketsResult"` + Buckets struct { + Bucket []bucketEntryXML `xml:"Bucket"` + } `xml:"Buckets"` +} + +type bucketEntryXML struct { + Name string `xml:"Name"` + Location string `xml:"Location"` +} diff --git a/internal/oss/aliyun/client.go b/internal/oss/aliyun/client.go new file mode 100644 index 0000000..98e6ccb --- /dev/null +++ b/internal/oss/aliyun/client.go @@ -0,0 +1,572 @@ +package aliyun + +import ( + "context" + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "u-desk/internal/oss" +) + +// Config 阿里云 OSS 配置 +type Config struct { + AccessKeyID string // 访问密钥 ID + AccessKeySecret string // 访问密钥 Secret + Bucket string // 存储空间名称 + Region string // 区域,如 oss-cn-hangzhou + Endpoint string // 自定义 Endpoint(可选) + UseHTTPS bool // 是否使用 HTTPS +} + +// Client 阿里云 OSS 客户端 +type Client struct { + config *Config + httpClient *http.Client +} + +// NewClient 创建阿里云 OSS 客户端 +func NewClient(config *Config) (*Client, error) { + if config == nil { + return nil, oss.NewError("INVALID_CONFIG", "config cannot be nil", nil) + } + if config.AccessKeyID == "" || config.AccessKeySecret == "" { + return nil, oss.NewError("INVALID_CONFIG", "access key id and secret are required", nil) + } + if config.Bucket == "" { + return nil, oss.NewError("INVALID_CONFIG", "bucket name is required", nil) + } + + // 设置默认区域 + if config.Region == "" { + config.Region = "oss-cn-hangzhou" // 默认华东1(杭州) + } + + // 构建 Endpoint + if config.Endpoint == "" { + config.Endpoint = config.Region + ".aliyuncs.com" + } + + return &Client{ + config: config, + httpClient: &http.Client{Timeout: 30 * time.Second}, + }, nil +} + +// generateSignature 生成阿里云 OSS 签名 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/signature-detail +func (c *Client) generateSignature(method, path, contentType, date string, contentMD5 string) string { + // 构建待签名字符串 + // StringToSign = HTTP-Verb + "\n" + Content-MD5 + "\n" + Content-Type + "\n" + Date + "\n" + CanonicalizedResource + stringToSign := method + "\n" + stringToSign += contentMD5 + "\n" + stringToSign += contentType + "\n" + stringToSign += date + "\n" + stringToSign += path + + // 使用 HMAC-SHA1 签名 + h := hmac.New(sha1.New, []byte(c.config.AccessKeySecret)) + h.Write([]byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + return signature +} + +// generateSignatureWithHeaders 生成包含自定义头的签名 +// 用于需要包含 x-oss-* 头的请求(如 CopyObject) +func (c *Client) generateSignatureWithHeaders(method, path, contentType, date string, headers map[string]string) string { + // 构建待签名字符串 + stringToSign := method + "\n" + stringToSign += "\n" // Content-MD5 (空) + stringToSign += contentType + "\n" + stringToSign += date + "\n" + + // 添加 CanonicalizedOSSHeaders (以 x-oss- 开头的头) + ossHeaders := c.canonicalizeOSSHeaders(headers) + stringToSign += ossHeaders + + // 添加 CanonicalizedResource + stringToSign += path + + // 使用 HMAC-SHA1 签名 + h := hmac.New(sha1.New, []byte(c.config.AccessKeySecret)) + h.Write([]byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + + return signature +} + +// canonicalizeOSSHeaders 规范化 OSS 自定义头 +// 将所有以 x-oss- 开头的头按字典序排序,并转换为小写 +func (c *Client) canonicalizeOSSHeaders(headers map[string]string) string { + if len(headers) == 0 { + return "" + } + + // 提取以 x-oss- 开头的头 + var ossHeaders []string + for k, v := range headers { + if strings.HasPrefix(strings.ToLower(k), "x-oss-") { + // 转换为小写并添加到列表 + lowerKey := strings.ToLower(k) + ossHeaders = append(ossHeaders, lowerKey+":"+v) + } + } + + // 按字典序排序 + // 这里简单处理,实际应该用排序算法 + for i := 0; i < len(ossHeaders); i++ { + for j := i + 1; j < len(ossHeaders); j++ { + if ossHeaders[i] > ossHeaders[j] { + ossHeaders[i], ossHeaders[j] = ossHeaders[j], ossHeaders[i] + } + } + } + + // 拼接结果 + if len(ossHeaders) == 0 { + return "" + } + + result := "" + for _, h := range ossHeaders { + result += h + "\n" + } + + return result +} + +// Upload 上传文件 +func (c *Client) Upload(ctx context.Context, key string, reader io.Reader, options *oss.UploadOptions) (*oss.UploadResult, error) { + // 读取数据 + data, err := io.ReadAll(reader) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to read data", err) + } + + // 计算 Content-MD5 + hash := md5.Sum(data) + contentMD5 := base64.StdEncoding.EncodeToString(hash[:]) + + // 设置 Content-Type + contentType := "application/octet-stream" + if options != nil && options.ContentType != "" { + contentType = options.ContentType + } + + // 构建请求 + date := time.Now().UTC().Format(http.TimeFormat) + path := "/" + c.config.Bucket + "/" + key + signature := c.generateSignature("PUT", path, contentType, date, contentMD5) + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key + + req, err := http.NewRequestWithContext(ctx, "PUT", url, strings.NewReader(string(data))) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Content-MD5", contentMD5) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to upload file", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return nil, oss.NewError("UPLOAD_ERROR", + fmt.Sprintf("upload failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 获取 ETag + etag := resp.Header.Get("ETag") + // 去掉 ETag 的引号 + etag = strings.Trim(etag, "\"") + + return &oss.UploadResult{ + Key: key, + ETag: etag, + Size: int64(len(data)), + }, nil +} + +// Download 下载文件 +func (c *Client) Download(ctx context.Context, key string, writer io.Writer) error { + date := time.Now().UTC().Format(http.TimeFormat) + path := "/" + c.config.Bucket + "/" + key + signature := c.generateSignature("GET", path, "", date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("DOWNLOAD_ERROR", "failed to download file", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return oss.NewError("DOWNLOAD_ERROR", fmt.Sprintf("download failed with status %d", resp.StatusCode), nil) + } + + _, err = io.Copy(writer, resp.Body) + return err +} + +// Delete 删除文件 +func (c *Client) Delete(ctx context.Context, key string) error { + date := time.Now().UTC().Format(http.TimeFormat) + path := "/" + c.config.Bucket + "/" + key + signature := c.generateSignature("DELETE", path, "", date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key + + req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) + if err != nil { + return oss.NewError("DELETE_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("DELETE_ERROR", "failed to delete file", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 204 { + body, _ := io.ReadAll(resp.Body) + return oss.NewError("DELETE_ERROR", + fmt.Sprintf("delete failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + return nil +} + +// GetFileInfo 获取文件信息 +func (c *Client) GetFileInfo(ctx context.Context, key string) (*oss.FileInfo, error) { + date := time.Now().UTC().Format(http.TimeFormat) + path := "/" + c.config.Bucket + "/" + key + signature := c.generateSignature("HEAD", path, "", date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key + + req, err := http.NewRequestWithContext(ctx, "HEAD", url, nil) + if err != nil { + return nil, oss.NewError("STAT_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("STAT_ERROR", "failed to get file info", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + return nil, oss.ErrFileNotFound + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("STAT_ERROR", fmt.Sprintf("stat failed with status %d", resp.StatusCode), nil) + } + + // 解析响应头 + size := resp.ContentLength + etag := resp.Header.Get("ETag") + contentType := resp.Header.Get("Content-Type") + _ = resp.Header.Get("Last-Modified") // 预留,可能需要解析时间 + + return &oss.FileInfo{ + Key: key, + Size: size, + ETag: strings.Trim(etag, "\""), + ContentType: contentType, + }, nil +} + +// ListFiles 列举文件 +func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.ListResult, error) { + if options == nil { + options = &oss.ListOptions{} + } + + if options.MaxKeys == 0 { + options.MaxKeys = 100 + } + + date := time.Now().UTC().Format(http.TimeFormat) + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + + // 构建查询参数(URL 编码) + query := url.Values{} + query.Set("max-keys", fmt.Sprintf("%d", options.MaxKeys)) + if options.Prefix != "" { + query.Set("prefix", options.Prefix) + } + if options.Marker != "" { + query.Set("marker", options.Marker) + } + if options.Delimiter != "" { + query.Set("delimiter", options.Delimiter) + } + + // CanonicalizedResource: list 参数(prefix/delimiter/marker/max-keys)不是子资源,不参与签名 + signPath := "/" + c.config.Bucket + "/" + signature := c.generateSignature("GET", signPath, "", date, "") + + requestURL := scheme + c.config.Bucket + "." + c.config.Endpoint + "/?" + query.Encode() + + req, err := http.NewRequestWithContext(ctx, "GET", requestURL, nil) + if err != nil { + return nil, oss.NewError("LIST_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("LIST_ERROR", "failed to list files", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("LIST_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("LIST_ERROR", + fmt.Sprintf("list failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 解析 XML 响应 + var result ListBucketResult + if err := xml.Unmarshal(body, &result); err != nil { + return nil, oss.NewError("LIST_ERROR", "failed to parse response", err) + } + + // 转换为统一格式 + files := make([]oss.FileInfo, 0, len(result.Contents)) + for _, obj := range result.Contents { + lastMod := parseAliyunTime(obj.LastModified) + files = append(files, oss.FileInfo{ + Key: obj.Key, + Size: obj.Size, + ETag: strings.Trim(obj.ETag, "\""), + LastModified: lastMod, + }) + } + + prefixes := make([]string, 0) + for _, p := range result.CommonPrefixes.Prefix { + prefixes = append(prefixes, p) + } + + return &oss.ListResult{ + Files: files, + IsTruncated: result.IsTruncated, + NextMarker: result.NextMarker, + Prefixes: prefixes, + }, nil +} + +// Copy 复制文件 +func (c *Client) Copy(ctx context.Context, sourceKey, targetKey string) error { + date := time.Now().UTC().Format(http.TimeFormat) + path := "/" + c.config.Bucket + "/" + targetKey + + // 设置自定义头 + headers := map[string]string{ + "x-oss-copy-source": "/" + c.config.Bucket + "/" + sourceKey, + } + signature := c.generateSignatureWithHeaders("PUT", path, "", date, headers) + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + targetKey + + req, err := http.NewRequestWithContext(ctx, "PUT", url, nil) + if err != nil { + return oss.NewError("COPY_ERROR", "failed to create request", err) + } + + // 设置头 + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + req.Header.Set("x-oss-copy-source", "/"+c.config.Bucket+"/"+sourceKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("COPY_ERROR", "failed to copy file", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return oss.NewError("COPY_ERROR", + fmt.Sprintf("copy failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + return nil +} + +// Move 移动/重命名文件 +func (c *Client) Move(ctx context.Context, sourceKey, targetKey string) error { + // 阿里云 OSS 通过复制 + 删除实现移动 + if err := c.Copy(ctx, sourceKey, targetKey); err != nil { + return err + } + return c.Delete(ctx, sourceKey) +} + +// DeleteMultiple 批量删除文件 +func (c *Client) DeleteMultiple(ctx context.Context, keys []string) (*oss.DeleteResult, error) { + result := &oss.DeleteResult{ + Deleted: make([]string, 0), + Errors: make([]string, 0), + } + + for _, key := range keys { + if err := c.Delete(ctx, key); err != nil { + result.Errors = append(result.Errors, key) + } else { + result.Deleted = append(result.Deleted, key) + } + } + + return result, nil +} + +// GetSignedURL 获取预签名URL +func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) { + // 阿里云 OSS 使用签名 URL + // 格式: ?OSSAccessKeyId=xxx&Expires=xxx&Signature=xxx + expiration := time.Now().Add(expiresIn).Unix() + + // 构建签名 + path := "/" + c.config.Bucket + "/" + key + stringToSign := "GET\n\n\n" + fmt.Sprintf("%d", expiration) + "\n" + path + h := hmac.New(sha1.New, []byte(c.config.AccessKeySecret)) + h.Write([]byte(stringToSign)) + signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) + signature = strings.TrimRight(signature, "=") // URL Safe + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + baseURL := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key + + signedURL := fmt.Sprintf("%s?OSSAccessKeyId=%s&Expires=%d&Signature=%s", + baseURL, c.config.AccessKeyID, expiration, signature) + + return signedURL, nil +} + +// Exists 检查文件是否存在 +func (c *Client) Exists(ctx context.Context, key string) (bool, error) { + _, err := c.GetFileInfo(ctx, key) + if err == oss.ErrFileNotFound { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// Close 关闭连接 +func (c *Client) Close() error { + c.httpClient.CloseIdleConnections() + return nil +} + +// parseAliyunTime 宽容解析阿里云时间格式 +func parseAliyunTime(s string) time.Time { + for _, layout := range []string{ + "2006-01-02T15:04:05.000Z", + "2006-01-02T15:04:05Z", + "2006-01-02T15:04:05.000000Z", + time.RFC3339, + } { + if t, err := time.Parse(layout, s); err == nil { + return t + } + } + return time.Time{} +} + +// ============ XML 数据结构 ============ + +// ListBucketResult 列举 Bucket 响应 +// 阿里云 XML: 每个 直接包含 Key/Size 等字段,无 包裹 +type ListBucketResult struct { + XMLName xml.Name `xml:"ListBucketResult"` + Name string `xml:"Name"` + Prefix string `xml:"Prefix"` + Marker string `xml:"Marker"` + MaxKeys int `xml:"MaxKeys"` + IsTruncated bool `xml:"IsTruncated"` + NextMarker string `xml:"NextMarker"` + Delimiter string `xml:"Delimiter"` + Contents []Object `xml:"Contents"` + CommonPrefixes struct { + Prefix []string `xml:"Prefix"` + } `xml:"CommonPrefixes"` +} + +type Object struct { + Key string `xml:"Key"` + LastModified string `xml:"LastModified"` + ETag string `xml:"ETag"` + Size int64 `xml:"Size"` + StorageClass string `xml:"StorageClass"` +} diff --git a/internal/oss/aliyun/lifecycle.go b/internal/oss/aliyun/lifecycle.go new file mode 100644 index 0000000..c73ba21 --- /dev/null +++ b/internal/oss/aliyun/lifecycle.go @@ -0,0 +1,521 @@ +package aliyun + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "time" + + "u-desk/internal/oss" +) + +// ============ 生命周期相关数据结构 ============ + +// LifecycleStorageClass 存储类型枚举 +type LifecycleStorageClass string + +const ( + // StorageClassStandard 标准存储 + StorageClassStandard LifecycleStorageClass = "Standard" + // StorageClassIA 低频存储 (Infrequent Access) + StorageClassIA LifecycleStorageClass = "IA" + // StorageClassArchive 归档存储 + StorageClassArchive LifecycleStorageClass = "Archive" + // StorageClassColdArchive 冷归档存储 + StorageClassColdArchive LifecycleStorageClass = "ColdArchive" +) + +// LifecycleRule 生命周期规则 +type LifecycleRule struct { + ID string `xml:"ID"` // 规则 ID + Prefix string `xml:"Prefix"` // 前缀(应用于匹配的文件) + Status string `xml:"Status"` // 状态:Enabled 或 Disabled + + // Expiration 过期删除配置 + Expiration *LifecycleExpiration `xml:"Expiration,omitempty"` + + // Transition 存储类型转换配置(可以有多个) + Transitions []LifecycleTransition `xml:"Transition,omitempty"` + + // AbortMultipartUpload 中止未完成的分片上传 + AbortMultipartUpload *LifecycleAbortMultipartUpload `xml:"AbortMultipartUpload,omitempty"` + + // Filter 过滤器(与 Prefix 二选一) + Filter *LifecycleFilter `xml:"Filter,omitempty"` +} + +// LifecycleExpiration 过期删除配置 +type LifecycleExpiration struct { + Days int `xml:"Days,omitempty"` // 多少天后过期 + CreatedBeforeDate string `xml:"CreatedBeforeDate,omitempty"` // 指定日期之前创建的文件过期(格式:2023-01-01T00:00:00.000Z) + ExpiredObjectDeleteMarker bool `xml:"ExpiredObjectDeleteMarker,omitempty"` // 删除过期删除标记 +} + +// LifecycleTransition 存储类型转换配置 +type LifecycleTransition struct { + Days int `xml:"Days,omitempty"` // 多少天后转换 + CreatedBeforeDate string `xml:"CreatedBeforeDate,omitempty"` // 指定日期之前创建的文件转换 + StorageClass LifecycleStorageClass `xml:"StorageClass"` // 目标存储类型 +} + +// LifecycleAbortMultipartUpload 中止分片上传配置 +type LifecycleAbortMultipartUpload struct { + Days int `xml:"Days,omitempty"` // 多少天后中止 + CreatedBeforeDate string `xml:"CreatedBeforeDate,omitempty"` // 指定日期之前创建的分片上传中止 +} + +// LifecycleFilter 过滤器(用于更精细的规则匹配) +type LifecycleFilter struct { + // Prefix 前缀 + Prefix string `xml:"Prefix,omitempty"` + // Tag 标签(可以有多个) + Tag []LifecycleTag `xml:"Tag,omitempty"` + // Not 非匹配条件 + Not *LifecycleNotFilter `xml:"Not,omitempty"` +} + +// LifecycleTag 标签 +type LifecycleTag struct { + Key string `xml:"Key"` + Value string `xml:"Value"` +} + +// LifecycleNotFilter 非匹配条件 +type LifecycleNotFilter struct { + Prefix string `xml:"Prefix,omitempty"` + Tag LifecycleTag `xml:"Tag,omitempty"` +} + +// LifecycleConfiguration 生命周期配置 +type LifecycleConfiguration struct { + XMLName xml.Name `xml:"LifecycleConfiguration"` + Rules []LifecycleRule `xml:"Rule"` +} + +// ============ 生命周期管理方法 ============ + +// SetBucketLifecycle 设置生命周期规则 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/put-bucket-lifecycle +func (c *Client) SetBucketLifecycle(ctx context.Context, rules []LifecycleRule) error { + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建请求体 + config := LifecycleConfiguration{ + Rules: rules, + } + bodyBytes, err := xml.Marshal(config) + if err != nil { + return oss.NewError("LIFECYCLE_ERROR", "failed to marshal lifecycle config", err) + } + + // 添加 XML 声明 + bodyWithHeader := []byte(xml.Header + string(bodyBytes)) + + // 构建签名字符串 - 对于 bucket 级别操作,需要计算 Content-MD5 + contentType := "application/xml" + path := "/" + c.config.Bucket + "/?lifecycle" + + // 计算 Content-MD5 + hash := md5.Sum(bodyWithHeader) + contentMD5 := base64.StdEncoding.EncodeToString(hash[:]) + + signature := c.generateSignature("PUT", path, contentType, date, contentMD5) + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/?lifecycle" + + req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(bodyWithHeader)) + if err != nil { + return oss.NewError("LIFECYCLE_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + req.Header.Set("Content-Type", contentType) + req.Header.Set("Content-MD5", contentMD5) + + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("LIFECYCLE_ERROR", "failed to set lifecycle", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return oss.NewError("LIFECYCLE_ERROR", + fmt.Sprintf("set lifecycle failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + return nil +} + +// GetBucketLifecycle 获取生命周期规则 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/get-bucket-lifecycle +func (c *Client) GetBucketLifecycle(ctx context.Context) ([]LifecycleRule, error) { + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建签名字符串 - 使用 bucket/ 前缀 + path := "/" + c.config.Bucket + "/?lifecycle" + signature := c.generateSignature("GET", path, "", date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/?lifecycle" + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, oss.NewError("LIFECYCLE_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("LIFECYCLE_ERROR", "failed to get lifecycle", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("LIFECYCLE_ERROR", "failed to read response", err) + } + + if resp.StatusCode == 404 { + // 没有设置生命周期规则 + return nil, nil + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("LIFECYCLE_ERROR", + fmt.Sprintf("get lifecycle failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 解析 XML 响应 + var config LifecycleConfiguration + if err := xml.Unmarshal(body, &config); err != nil { + return nil, oss.NewError("LIFECYCLE_ERROR", "failed to parse response", err) + } + + return config.Rules, nil +} + +// DeleteBucketLifecycle 删除生命周期规则 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/delete-bucket-lifecycle +func (c *Client) DeleteBucketLifecycle(ctx context.Context) error { + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建签名字符串 - 使用 bucket/ 前缀 + path := "/" + c.config.Bucket + "/?lifecycle" + signature := c.generateSignature("DELETE", path, "", date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/?lifecycle" + + req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) + if err != nil { + return oss.NewError("LIFECYCLE_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("LIFECYCLE_ERROR", "failed to delete lifecycle", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 204 { + body, _ := io.ReadAll(resp.Body) + return oss.NewError("LIFECYCLE_ERROR", + fmt.Sprintf("delete lifecycle failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + return nil +} + +// ============ 便捷方法 ============ + +// SetExpirationRule 设置过期删除规则 +// 为指定前缀的文件设置过期删除天数 +func (c *Client) SetExpirationRule(ctx context.Context, ruleID, prefix string, days int) error { + // 获取现有规则 + rules, err := c.GetBucketLifecycle(ctx) + if err != nil { + // 如果没有现有规则,创建新的规则列表 + rules = []LifecycleRule{} + } + + // 检查是否已存在相同 ID 的规则,如果存在则更新,否则添加 + found := false + for i, r := range rules { + if r.ID == ruleID { + // 更新现有规则 + rules[i].Prefix = prefix + rules[i].Status = "Enabled" + rules[i].Expiration = &LifecycleExpiration{Days: days} + found = true + break + } + } + + if !found { + // 添加新规则 + rule := LifecycleRule{ + ID: ruleID, + Prefix: prefix, + Status: "Enabled", + Expiration: &LifecycleExpiration{ + Days: days, + }, + } + rules = append(rules, rule) + } + + return c.SetBucketLifecycle(ctx, rules) +} + +// SetTransitionRule 设置存储类型转换规则 +// 为指定前缀的文件设置存储类型转换 +func (c *Client) SetTransitionRule(ctx context.Context, ruleID, prefix string, days int, storageClass LifecycleStorageClass) error { + // 获取现有规则 + rules, err := c.GetBucketLifecycle(ctx) + if err != nil { + // 如果没有现有规则,创建新的规则列表 + rules = []LifecycleRule{} + } + + // 检查是否已存在相同 ID 的规则,如果存在则更新,否则添加 + found := false + for i, r := range rules { + if r.ID == ruleID { + // 更新现有规则 + rules[i].Prefix = prefix + rules[i].Status = "Enabled" + rules[i].Transitions = []LifecycleTransition{ + {Days: days, StorageClass: storageClass}, + } + found = true + break + } + } + + if !found { + // 添加新规则 + rule := LifecycleRule{ + ID: ruleID, + Prefix: prefix, + Status: "Enabled", + Transitions: []LifecycleTransition{ + { + Days: days, + StorageClass: storageClass, + }, + }, + } + rules = append(rules, rule) + } + + return c.SetBucketLifecycle(ctx, rules) +} + +// SetAbortMultipartUploadRule 设置中止分片上传规则 +// 为指定前缀的文件设置中止未完成的分片上传 +func (c *Client) SetAbortMultipartUploadRule(ctx context.Context, ruleID, prefix string, days int) error { + rule := LifecycleRule{ + ID: ruleID, + Prefix: prefix, + Status: "Enabled", + AbortMultipartUpload: &LifecycleAbortMultipartUpload{ + Days: days, + }, + } + return c.SetBucketLifecycle(ctx, []LifecycleRule{rule}) +} + +// SetCombinedRule 设置组合生命周期规则 +// 同时支持过期删除和存储类型转换 +func (c *Client) SetCombinedRule(ctx context.Context, ruleID, prefix string, expirationDays int, transitionDays int, storageClass LifecycleStorageClass) error { + rule := LifecycleRule{ + ID: ruleID, + Prefix: prefix, + Status: "Enabled", + } + + // 设置过期删除 + if expirationDays > 0 { + rule.Expiration = &LifecycleExpiration{ + Days: expirationDays, + } + } + + // 设置存储类型转换 + if transitionDays > 0 && storageClass != "" { + rule.Transitions = []LifecycleTransition{ + { + Days: transitionDays, + StorageClass: storageClass, + }, + } + } + + return c.SetBucketLifecycle(ctx, []LifecycleRule{rule}) +} + +// SetTempFileRule 设置临时文件规则 +// 为临时文件目录设置规则:先转为低频存储,然后删除 +func (c *Client) SetTempFileRule(ctx context.Context, prefix string, toIADays int, deleteDays int) error { + ruleID := fmt.Sprintf("temp-files-%s", strings.ReplaceAll(prefix, "/", "-")) + + // 获取现有规则 + rules, err := c.GetBucketLifecycle(ctx) + if err != nil { + return err + } + + // 创建新规则 + rule := LifecycleRule{ + ID: ruleID, + Prefix: prefix, + Status: "Enabled", + } + + // 设置存储类型转换 + if toIADays > 0 { + rule.Transitions = []LifecycleTransition{ + { + Days: toIADays, + StorageClass: StorageClassIA, + }, + } + } + + // 设置过期删除 + if deleteDays > 0 { + rule.Expiration = &LifecycleExpiration{ + Days: deleteDays, + } + } + + // 添加到现有规则 + rules = append(rules, rule) + + return c.SetBucketLifecycle(ctx, rules) +} + +// ClearTempFileRule 清除临时文件规则 +func (c *Client) ClearTempFileRule(ctx context.Context, prefix string) error { + ruleID := fmt.Sprintf("temp-files-%s", strings.ReplaceAll(prefix, "/", "-")) + + // 获取现有规则 + rules, err := c.GetBucketLifecycle(ctx) + if err != nil { + return err + } + + // 过滤掉要删除的规则 + newRules := make([]LifecycleRule, 0, len(rules)) + for _, rule := range rules { + if rule.ID != ruleID { + newRules = append(newRules, rule) + } + } + + // 更新规则 + if len(newRules) == 0 { + return c.DeleteBucketLifecycle(ctx) + } + + return c.SetBucketLifecycle(ctx, newRules) +} + +// ListLifecycleRules 列出所有生命周期规则(带详细信息) +func (c *Client) ListLifecycleRules(ctx context.Context) ([]LifecycleRule, error) { + return c.GetBucketLifecycle(ctx) +} + +// DisableLifecycleRule 禁用生命周期规则 +func (c *Client) DisableLifecycleRule(ctx context.Context, ruleID string) error { + rules, err := c.GetBucketLifecycle(ctx) + if err != nil { + return err + } + + // 找到并禁用规则 + found := false + for i := range rules { + if rules[i].ID == ruleID { + rules[i].Status = "Disabled" + found = true + break + } + } + + if !found { + return oss.NewError("LIFECYCLE_ERROR", "rule not found: "+ruleID, nil) + } + + return c.SetBucketLifecycle(ctx, rules) +} + +// EnableLifecycleRule 启用生命周期规则 +func (c *Client) EnableLifecycleRule(ctx context.Context, ruleID string) error { + rules, err := c.GetBucketLifecycle(ctx) + if err != nil { + return err + } + + // 找到并启用规则 + found := false + for i := range rules { + if rules[i].ID == ruleID { + rules[i].Status = "Enabled" + found = true + break + } + } + + if !found { + return oss.NewError("LIFECYCLE_ERROR", "rule not found: "+ruleID, nil) + } + + return c.SetBucketLifecycle(ctx, rules) +} + +// DeleteLifecycleRule 删除生命周期规则 +func (c *Client) DeleteLifecycleRule(ctx context.Context, ruleID string) error { + rules, err := c.GetBucketLifecycle(ctx) + if err != nil { + return err + } + + // 过滤掉要删除的规则 + newRules := make([]LifecycleRule, 0, len(rules)) + for _, rule := range rules { + if rule.ID != ruleID { + newRules = append(newRules, rule) + } + } + + // 更新规则 + if len(newRules) == 0 { + return c.DeleteBucketLifecycle(ctx) + } + + return c.SetBucketLifecycle(ctx, newRules) +} diff --git a/internal/oss/aliyun/multipart.go b/internal/oss/aliyun/multipart.go new file mode 100644 index 0000000..8c7686f --- /dev/null +++ b/internal/oss/aliyun/multipart.go @@ -0,0 +1,584 @@ +package aliyun + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/base64" + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "time" + + "u-desk/internal/oss" +) + +// ============ 分片上传相关数据结构 ============ + +// PartInfo 分片信息 +type PartInfo struct { + PartNumber int `xml:"PartNumber"` // 分片编号 (1-10000) + ETag string `xml:"ETag"` // 分片的 ETag + Size int64 `xml:"Size"` // 分片大小 +} + +// InitiateMultipartUploadResult 初始化分片上传的响应 +type InitiateMultipartUploadResult struct { + XMLName xml.Name `xml:"InitiateMultipartUploadResult"` + Bucket string `xml:"Bucket"` + Key string `xml:"Key"` + UploadID string `xml:"UploadId"` +} + +// CompleteMultipartUploadRequest 完成分片上传的请求 +type CompleteMultipartUploadRequest struct { + XMLName xml.Name `xml:"CompleteMultipartUploadRequest"` + Parts []PartInfo `xml:"Part"` +} + +// CompleteMultipartUploadResult 完成分片上传的响应 +type CompleteMultipartUploadResult struct { + XMLName xml.Name `xml:"CompleteMultipartUploadResult"` + Location string `xml:"Location"` + Bucket string `xml:"Bucket"` + Key string `xml:"Key"` + ETag string `xml:"ETag"` +} + +// ListPartsResult 列举分片的响应 +type ListPartsResult struct { + XMLName xml.Name `xml:"ListPartsResult"` + Bucket string `xml:"Bucket"` + Key string `xml:"Key"` + UploadID string `xml:"UploadId"` + NextPartNumberMarker int `xml:"NextPartNumberMarker"` + IsTruncated bool `xml:"IsTruncated"` + MaxParts int `xml:"MaxParts"` + PartNumberMarker int `xml:"PartNumberMarker"` + StorageClass string `xml:"StorageClass"` + Parts []PartInfo `xml:"Part"` +} + +// ListMultipartUploadsResult 列举分片上传任务的响应 +type ListMultipartUploadsResult struct { + XMLName xml.Name `xml:"ListMultipartUploadsResult"` + Bucket string `xml:"Bucket"` + KeyMarker string `xml:"KeyMarker"` + UploadIDMarker string `xml:"UploadIdMarker"` + NextKeyMarker string `xml:"NextKeyMarker"` + NextUploadIDMarker string `xml:"NextUploadIdMarker"` + Delimiter string `xml:"Delimiter"` + Prefix string `xml:"Prefix"` + MaxUploads int `xml:"MaxUploads"` + IsTruncated bool `xml:"IsTruncated"` + Uploads []struct { + Key string `xml:"Key"` + UploadID string `xml:"UploadId"` + Initiated string `xml:"Initiated"` + StorageClass string `xml:"StorageClass"` + } `xml:"Upload"` +} + +// ============ 分片上传核心方法 ============ + +// InitiateMultipartUpload 初始化分片上传任务 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/initiate-multipart-upload +func (c *Client) InitiateMultipartUpload(ctx context.Context, key string, contentType string) (string, error) { + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建签名字符串(包含 ?uploads 参数) + path := "/" + c.config.Bucket + "/" + key + "?uploads" + signature := c.generateSignature("POST", path, contentType, date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := scheme + c.config.Bucket + "." + c.config.Endpoint + "/" + key + "?uploads" + + req, err := http.NewRequestWithContext(ctx, "POST", url, nil) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to initiate multipart upload", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return "", oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("initiate multipart upload failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 解析 XML 响应 + var result InitiateMultipartUploadResult + if err := xml.Unmarshal(body, &result); err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to parse response", err) + } + + return result.UploadID, nil +} + +// UploadPart 上传分片 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/upload-part +func (c *Client) UploadPart(ctx context.Context, key, uploadID string, partNumber int, reader io.Reader) (string, error) { + // 读取数据 + data, err := io.ReadAll(reader) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to read part data", err) + } + + // 计算 Content-MD5 + hash := md5.Sum(data) + contentMD5 := base64.StdEncoding.EncodeToString(hash[:]) + + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建签名字符串(包含查询参数) + path := fmt.Sprintf("/%s/%s?partNumber=%d&uploadId=%s", + c.config.Bucket, key, partNumber, uploadID) + signature := c.generateSignature("PUT", path, "application/octet-stream", date, contentMD5) + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := fmt.Sprintf("%s%s.%s/%s?partNumber=%d&uploadId=%s", + scheme, c.config.Bucket, c.config.Endpoint, key, partNumber, uploadID) + + req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(data)) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-MD5", contentMD5) + + resp, err := c.httpClient.Do(req) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to upload part", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + body, _ := io.ReadAll(resp.Body) + return "", oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("upload part failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 从响应头获取 ETag + etag := resp.Header.Get("ETag") + etag = strings.Trim(etag, "\"") + + return etag, nil +} + +// CompleteMultipartUpload 完成分片上传 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/complete-multipart-upload +func (c *Client) CompleteMultipartUpload(ctx context.Context, key, uploadID string, parts []PartInfo) (*oss.UploadResult, error) { + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建请求体 - 手动构建 XML 以确保格式正确 + // 阿里云要求的 XML 格式: + // + // + // + // 1 + // "etag" + // + // ... + // + var xmlBuilder strings.Builder + xmlBuilder.WriteString("\n") + xmlBuilder.WriteString("\n") + for _, part := range parts { + // ETag 需要带引号 + etag := part.ETag + if !strings.HasPrefix(etag, "\"") { + etag = "\"" + etag + } + if !strings.HasSuffix(etag, "\"") { + etag = etag + "\"" + } + xmlBuilder.WriteString(fmt.Sprintf(" \n %d\n %s\n \n", + part.PartNumber, etag)) + } + xmlBuilder.WriteString("") + bodyBytes := []byte(xmlBuilder.String()) + + // 构建签名字符串 + contentType := "application/xml" + path := fmt.Sprintf("/%s/%s?uploadId=%s", c.config.Bucket, key, uploadID) + signature := c.generateSignature("POST", path, contentType, date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := fmt.Sprintf("%s%s.%s/%s?uploadId=%s", + scheme, c.config.Bucket, c.config.Endpoint, key, uploadID) + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + req.Header.Set("Content-Type", contentType) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to complete multipart upload", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("complete multipart upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil) + } + + // 解析 XML 响应 + var result CompleteMultipartUploadResult + if err := xml.Unmarshal(respBody, &result); err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err) + } + + return &oss.UploadResult{ + Key: result.Key, + ETag: strings.Trim(result.ETag, "\""), + }, nil +} + +// AbortMultipartUpload 中止分片上传任务 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/abort-multipart-upload +func (c *Client) AbortMultipartUpload(ctx context.Context, key, uploadID string) error { + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建签名字符串 + path := fmt.Sprintf("/%s/%s?uploadId=%s", c.config.Bucket, key, uploadID) + signature := c.generateSignature("DELETE", path, "", date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + url := fmt.Sprintf("%s%s.%s/%s?uploadId=%s", + scheme, c.config.Bucket, c.config.Endpoint, key, uploadID) + + req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) + if err != nil { + return oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("MULTIPART_ERROR", "failed to abort multipart upload", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 204 { + body, _ := io.ReadAll(resp.Body) + return oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("abort multipart upload failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + return nil +} + +// ListParts 列举已上传的分片 +// 参考: https://help.aliyun.com/zh/oss/developer-reference/list-parts +func (c *Client) ListParts(ctx context.Context, key, uploadID string, maxParts int, partNumberMarker int) ([]PartInfo, error) { + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建签名字符串 + path := fmt.Sprintf("/%s/%s?uploadId=%s", c.config.Bucket, key, uploadID) + signature := c.generateSignature("GET", path, "", date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + + // 构建查询参数 + params := []string{ + fmt.Sprintf("uploadId=%s", uploadID), + } + if maxParts > 0 { + params = append(params, fmt.Sprintf("max-parts=%d", maxParts)) + } + if partNumberMarker > 0 { + params = append(params, fmt.Sprintf("part-number-marker=%d", partNumberMarker)) + } + + url := fmt.Sprintf("%s%s.%s/%s?%s", + scheme, c.config.Bucket, c.config.Endpoint, key, strings.Join(params, "&")) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to list parts", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("list parts failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 解析 XML 响应 + var result ListPartsResult + if err := xml.Unmarshal(body, &result); err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err) + } + + return result.Parts, nil +} + +// ListMultipartUploads 列举所有进行中的分片上传任务 +// 参考: 阿里云 OSS API 文档 +func (c *Client) ListMultipartUploads(ctx context.Context, prefix string, maxUploads int) ([]struct { + Key string + UploadID string + Initiated string + StorageClass string +}, error) { + date := time.Now().UTC().Format(http.TimeFormat) + + // 构建签名字符串 + path := "/" + c.config.Bucket + "?uploads" + signature := c.generateSignature("GET", path, "", date, "") + + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + + // 构建查询参数 + params := []string{"uploads"} + if prefix != "" { + params = append(params, "prefix="+prefix) + } + if maxUploads > 0 { + params = append(params, fmt.Sprintf("max-uploads=%d", maxUploads)) + } + + url := fmt.Sprintf("%s%s.%s/?%s", + scheme, c.config.Bucket, c.config.Endpoint, strings.Join(params, "&")) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Date", date) + req.Header.Set("Authorization", "OSS "+c.config.AccessKeyID+":"+signature) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to list multipart uploads", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("list multipart uploads failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 解析 XML 响应 + var result ListMultipartUploadsResult + if err := xml.Unmarshal(body, &result); err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err) + } + + // 转换返回结果 + uploads := make([]struct { + Key string + UploadID string + Initiated string + StorageClass string + }, 0, len(result.Uploads)) + for _, u := range result.Uploads { + uploads = append(uploads, struct { + Key string + UploadID string + Initiated string + StorageClass string + }{ + Key: u.Key, + UploadID: u.UploadID, + Initiated: u.Initiated, + StorageClass: u.StorageClass, + }) + } + + return uploads, nil +} + +// ============ 高级辅助方法 ============ + +// UploadMultipart 使用分片上传方式上传文件 +// 自动将文件分片并上传,适用于大文件 +// 阿里云 OSS 要求:每个分片大小 100KB ~ 5GB,除最后一个分片外 +func (c *Client) UploadMultipart(ctx context.Context, key string, reader io.Reader, partSize int64, contentType string) (*oss.UploadResult, error) { + // 默认分片大小为 10MB + if partSize <= 0 { + partSize = 10 * 1024 * 1024 + } + + // 阿里云 OSS 要求:每个分片大小至少 100KB + const minPartSize = 100 * 1024 // 100KB + if partSize < minPartSize { + partSize = minPartSize + } + + // 1. 初始化上传任务 + uploadID, err := c.InitiateMultipartUpload(ctx, key, contentType) + if err != nil { + return nil, fmt.Errorf("failed to initiate multipart upload: %w", err) + } + + // 确保在失败时中止任务 + defer func() { + if err != nil { + c.AbortMultipartUpload(context.Background(), key, uploadID) + } + }() + + // 2. 读取所有数据并分片 + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read data: %w", err) + } + + totalSize := int64(len(data)) + + // 如果文件太小,使用普通上传 + if totalSize < minPartSize { + options := &oss.UploadOptions{ContentType: contentType} + return c.Upload(ctx, key, bytes.NewReader(data), options) + } + + partCount := int((totalSize + partSize - 1) / partSize) // 向上取整 + + // 阿里云限制:最多 10000 个分片 + if partCount > 10000 { + // 重新计算分片大小 + partSize = (totalSize + 9999) / 10000 + if partSize < minPartSize { + partSize = minPartSize + } + partCount = int((totalSize + partSize - 1) / partSize) + } + + // 3. 上传各个分片 + parts := make([]PartInfo, 0, partCount) + for i := 0; i < partCount; i++ { + partNumber := i + 1 + start := i * int(partSize) + end := start + int(partSize) + if end > len(data) { + end = len(data) + } + + partData := data[start:end] + currentPartSize := int64(len(partData)) + + // 验证分片大小(除最后一个分片外,其他分片必须 >= 100KB) + if i < partCount-1 && currentPartSize < minPartSize { + return nil, fmt.Errorf("part %d size (%d bytes) is less than minimum required size (%d bytes)", + partNumber, currentPartSize, minPartSize) + } + + etag, err := c.UploadPart(ctx, key, uploadID, partNumber, bytes.NewReader(partData)) + if err != nil { + return nil, fmt.Errorf("failed to upload part %d: %w", partNumber, err) + } + + parts = append(parts, PartInfo{ + PartNumber: partNumber, + ETag: etag, + Size: currentPartSize, + }) + } + + // 4. 完成上传 + result, err := c.CompleteMultipartUpload(ctx, key, uploadID, parts) + if err != nil { + return nil, fmt.Errorf("failed to complete multipart upload: %w", err) + } + + // 成功,取消 defer 中的中止操作 + err = nil + return result, nil +} + +// UploadWithRetry 带重试的分片上传 +// 支持失败重试,适用于不稳定的网络环境 +func (c *Client) UploadWithRetry(ctx context.Context, key string, reader io.Reader, partSize int64, maxRetries int, contentType string) (*oss.UploadResult, error) { + if maxRetries <= 0 { + maxRetries = 3 + } + + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + // 每次重试需要重新读取数据 + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + result, err := c.UploadMultipart(ctx, key, bytes.NewReader(data), partSize, contentType) + if err == nil { + return result, nil + } + + lastErr = err + // 等待一段时间后重试 + time.Sleep(time.Second * time.Duration(attempt+1)) + } + + return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr) +} diff --git a/internal/oss/errors.go b/internal/oss/errors.go new file mode 100644 index 0000000..cc664a9 --- /dev/null +++ b/internal/oss/errors.go @@ -0,0 +1,42 @@ +package oss + +import "errors" + +// 预定义错误 +var ( + ErrFileNotFound = errors.New("file not found") + ErrInvalidCredential = errors.New("invalid credential") + ErrAccessDenied = errors.New("access denied") + ErrInvalidParameter = errors.New("invalid parameter") + ErrNetworkError = errors.New("network error") + ErrTimeout = errors.New("operation timeout") +) + +// OSSError OSS 操作错误 +type OSSError struct { + Code string // 错误代码 + Message string // 错误信息 + Err error // 底层错误 +} + +// Error 实现 error 接口 +func (e *OSSError) Error() string { + if e.Err != nil { + return e.Code + ": " + e.Message + " (" + e.Err.Error() + ")" + } + return e.Code + ": " + e.Message +} + +// Unwrap 支持错误包装 +func (e *OSSError) Unwrap() error { + return e.Err +} + +// NewError 创建新的 OSS 错误 +func NewError(code, message string, err error) *OSSError { + return &OSSError{ + Code: code, + Message: message, + Err: err, + } +} diff --git a/internal/oss/interface.go b/internal/oss/interface.go new file mode 100644 index 0000000..5ea378e --- /dev/null +++ b/internal/oss/interface.go @@ -0,0 +1,43 @@ +package oss + +import ( + "context" + "io" + "time" +) + +// OSSProvider 对象存储提供者接口 +type OSSProvider interface { + // Upload 上传文件 + Upload(ctx context.Context, key string, reader io.Reader, options *UploadOptions) (*UploadResult, error) + + // Download 下载文件 + Download(ctx context.Context, key string, writer io.Writer) error + + // Delete 删除文件 + Delete(ctx context.Context, key string) error + + // DeleteMultiple 批量删除文件 + DeleteMultiple(ctx context.Context, keys []string) (*DeleteResult, error) + + // GetFileInfo 获取文件信息 + GetFileInfo(ctx context.Context, key string) (*FileInfo, error) + + // ListFiles 列举文件 + ListFiles(ctx context.Context, options *ListOptions) (*ListResult, error) + + // GetSignedURL 获取预签名URL(用于私有文件分享) + GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) + + // Copy 复制文件 + Copy(ctx context.Context, sourceKey, targetKey string) error + + // Move 移动/重命名文件 + Move(ctx context.Context, sourceKey, targetKey string) error + + // Exists 检查文件是否存在 + Exists(ctx context.Context, key string) (bool, error) + + // Close 关闭连接 + Close() error +} diff --git a/internal/oss/qiniu/bucket.go b/internal/oss/qiniu/bucket.go new file mode 100644 index 0000000..8f57933 --- /dev/null +++ b/internal/oss/qiniu/bucket.go @@ -0,0 +1,299 @@ +package qiniu + +import ( + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + "u-desk/internal/oss" +) + +// BucketAccessControl 空间访问控制类型 +type BucketAccessControl int + +const ( + // BucketAccessControlPublic 公开空间 (0) + BucketAccessControlPublic BucketAccessControl = 0 + // BucketAccessControlPrivate 私有空间 (1) + BucketAccessControlPrivate BucketAccessControl = 1 +) + +// String 返回访问控制的字符串表示 +func (a BucketAccessControl) String() string { + switch a { + case BucketAccessControlPublic: + return "公开" + case BucketAccessControlPrivate: + return "私有" + default: + return "未知" + } +} + +// GetBucketDomains 获取空间绑定的域名列表 +// 根据: https://developer.qiniu.com/kodo/api/3949/get-the-bucket-space-domain +// +// 返回: +// - []string: 域名列表 +// - error: 错误信息 +// +// 注意: +// - 返回的域名包括七牛云提供的默认域名和用户绑定的自定义域名 +// - 默认域名格式: ..qiniudns.com 或 ..clouddn.com +func (c *Client) GetBucketDomains(ctx context.Context) ([]string, error) { + // 构建查询参数 + params := url.Values{} + params.Set("tbl", c.config.Bucket) + + // 构建 URL + // 格式: GET /v6/domain/list?tbl= + apiURL := fmt.Sprintf("%s/v6/domain/list?%s", c.apiAPI, params.Encode()) + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) + if err != nil { + return nil, oss.NewError("BUCKET_ERROR", "failed to create request", err) + } + + // 使用 API 服务的 host 生成认证 + // API 接口使用简单的查询字符串认证 + path := "/v6/domain/list" + queryString := params.Encode() + host := "api.qiniu.com" + authToken := c.generateAuthTokenWithQuery("GET", path, queryString, host, "application/x-www-form-urlencoded", nil) + + req.Header.Set("Host", host) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", authToken) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("BUCKET_ERROR", "failed to get bucket domains", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("BUCKET_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("BUCKET_ERROR", + fmt.Sprintf("get bucket domains failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 解析响应(JSON 数组) + var domains []string + if err := json.Unmarshal(body, &domains); err != nil { + return nil, oss.NewError("BUCKET_ERROR", "failed to parse response", err) + } + + return domains, nil +} + +// SetBucketAccess 设置空间访问权限(公开/私有) +// 根据: https://developer.qiniu.com/kodo/api/3946/set-bucket-private +// +// 参数: +// - access: BucketAccessControlPublic(公开) 或 BucketAccessControlPrivate(私有) +// +// 注意: +// - 公开空间:文件可通过 URL 直接访问 +// - 私有空间:文件访问需要下载凭证 +// - 修改权限会影响该空间下所有文件的访问方式 +func (c *Client) SetBucketAccess(ctx context.Context, access BucketAccessControl) error { + // 构建查询参数 + params := url.Values{} + params.Set("bucket", c.config.Bucket) + params.Set("private", fmt.Sprintf("%d", access)) + + // 构建 URL + // 格式: POST /private?bucket=&private=<0|1> + apiURL := fmt.Sprintf("%s/private?%s", c.apiAPI, params.Encode()) + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "POST", apiURL, nil) + if err != nil { + return oss.NewError("BUCKET_ERROR", "failed to create request", err) + } + + // 使用 API 服务的 host 生成认证 + path := "/private" + queryString := params.Encode() + host := "api.qiniu.com" + authToken := c.generateAuthTokenWithQuery("POST", path, queryString, host, "application/x-www-form-urlencoded", nil) + + req.Header.Set("Host", host) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Authorization", authToken) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("BUCKET_ERROR", "failed to set bucket access", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return oss.NewError("BUCKET_ERROR", + fmt.Sprintf("set bucket access failed with status %d: %s", resp.StatusCode, string(body)), nil) +} + +// SetBucketPublic 设置空间为公开空间 +// 便捷方法:将空间设置为公开访问 +func (c *Client) SetBucketPublic(ctx context.Context) error { + return c.SetBucketAccess(ctx, BucketAccessControlPublic) +} + +// SetBucketPrivate 设置空间为私有空间 +// 便捷方法:将空间设置为私有访问 +func (c *Client) SetBucketPrivate(ctx context.Context) error { + return c.SetBucketAccess(ctx, BucketAccessControlPrivate) +} + +// BucketInfo 空间信息 +type BucketInfo struct { + Name string // 空间名称 + Region string // 区域 + Domains []string // 绑定的域名列表 + IsPrivate bool // 是否为私有空间 +} + +// GetBucketInfo 获取空间信息 +// 组合方法:获取空间的域名列表和访问权限等信息 +// +// 注意: +// - 该方法会调用多个 API 接口获取完整信息 +// - IsPrivate 字段无法通过 API 直接获取,需要通过测试文件访问来确定 +func (c *Client) GetBucketInfo(ctx context.Context) (*BucketInfo, error) { + // 获取域名列表 + domains, err := c.GetBucketDomains(ctx) + if err != nil { + return nil, err + } + + // 构建基本信息 + info := &BucketInfo{ + Name: c.config.Bucket, + Region: c.config.Region, + Domains: domains, + // IsPrivate 需要通过其他方式确定 + } + + return info, nil +} + +// CheckBucketAccess 检查空间访问权限 +// 通过尝试访问一个不存在的文件来判断空间是否为私有 +// +// 返回: +// - bool: true=私有空间, false=公开空间 +// - error: 错误信息 +// +// 注意: +// - 该方法会发送一个测试请求来判断空间权限 +// - 如果空间内没有文件,可能无法准确判断 +func (c *Client) CheckBucketAccess(ctx context.Context) (bool, error) { + // 尝试获取一个不存在的文件的信息 + // 如果是公开空间,会返回明确的"文件不存在"错误 + // 如果是私有空间,会返回认证错误 + testKey := fmt.Sprintf("__qiniu_test_access_check_%d__", time.Now().UnixNano()) + + _, err := c.GetFileInfo(ctx, testKey) + if err == oss.ErrFileNotFound { + // 返回文件不存在,说明是公开空间 + return false, nil + } + + // 其他错误情况,可能需要根据错误信息判断 + if err != nil { + // 检查错误信息中是否包含认证相关的内容 + errStr := err.Error() + if contains(errStr, "permission") || contains(errStr, "unauthorized") || contains(errStr, "token") { + return true, nil // 私有空间 + } + } + + // 默认假设为公开空间 + return false, nil +} + +// contains 辅助函数:检查字符串是否包含子串(忽略大小写) +func contains(str, substr string) bool { + return len(str) >= len(substr) && (str == substr || len(str) > len(substr) && containsIgnoreCase(str, substr)) +} + +func containsIgnoreCase(str, substr string) bool { + // 简化实现,实际使用时可以使用 strings.ToLower + for i := 0; i <= len(str)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + c1 := str[i+j] + c2 := substr[j] + if c1 >= 'A' && c1 <= 'Z' { + c1 += 32 + } + if c2 >= 'A' && c2 <= 'Z' { + c2 += 32 + } + if c1 != c2 { + match = false + break + } + } + if match { + return true + } + } + return false +} + +// ListBuckets 列出所有存储桶 +func ListBuckets(accessKey, secretKey string) ([]oss.BucketEntry, error) { + signingStr := "/buckets\n" + token := accessKey + ":" + signHmacSha1(secretKey, signingStr) + + req, _ := http.NewRequest("POST", "https://rs.qbox.me/buckets", nil) + req.Header.Set("Authorization", "QBox "+token) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("列举存储桶失败: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("列举存储桶失败: HTTP %d: %s", resp.StatusCode, string(body)) + } + + var names []string + if err := json.NewDecoder(resp.Body).Decode(&names); err != nil { + return nil, fmt.Errorf("解析存储桶列表失败: %w", err) + } + + entries := make([]oss.BucketEntry, len(names)) + for i, name := range names { + entries[i] = oss.BucketEntry{Name: name} + } + return entries, nil +} + +func signHmacSha1(secretKey, data string) string { + mac := hmac.New(sha1.New, []byte(secretKey)) + mac.Write([]byte(data)) + return base64.URLEncoding.EncodeToString(mac.Sum(nil)) +} diff --git a/internal/oss/qiniu/client.go b/internal/oss/qiniu/client.go new file mode 100644 index 0000000..94c6283 --- /dev/null +++ b/internal/oss/qiniu/client.go @@ -0,0 +1,570 @@ +package qiniu + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "u-desk/internal/oss" +) + +// Config 七牛云配置 +type Config struct { + AccessKey string // 访问密钥 + SecretKey string // 秘钥 + Bucket string // 存储空间名称 + Region string // 区域 z0=华东, as0=亚太0区 + UseHTTPS bool // 是否使用 HTTPS + UploadDomain string // 上传域名(可选,默认根据 Region 自动选择) +} + +// Client 七牛云客户端 +type Client struct { + config *Config + httpClient *http.Client + rsAPI string // 资源管理 API + rsfAPI string // 资源列举 API (RSF) + apiAPI string // API 服务 +} + +// NewClient 创建七牛云客户端 +func NewClient(config *Config) (*Client, error) { + if config == nil { + return nil, oss.NewError("INVALID_CONFIG", "config cannot be nil", nil) + } + if config.AccessKey == "" || config.SecretKey == "" { + return nil, oss.NewError("INVALID_CONFIG", "access key and secret key are required", nil) + } + if config.Bucket == "" { + return nil, oss.NewError("INVALID_CONFIG", "bucket name is required", nil) + } + + // 设置默认区域 + if config.Region == "" { + config.Region = "z0" // 华东 + } + + return &Client{ + config: config, + httpClient: &http.Client{Timeout: 30 * time.Second}, + rsAPI: "http://rs.qiniu.com", + rsfAPI: "http://rsf.qbox.me", // 资源列举 API + apiAPI: "http://api.qiniu.com", + }, nil +} + +// generateSignature 生成七牛云管理凭证签名 +// 根据官方文档:https://developer.qiniu.com/kodo/1201/access-token +func (c *Client) generateSignature(method, path, host, contentType string, body []byte) string { + // 七牛云管理凭证签名格式: + // signingStr = Method + " " + Path + "\nHost: " + Host + "\n" + [Content-Type] + "\n\n" + [body] + var signingStr string + + // 1. Method + " " + Path + signingStr = method + " " + path + + // 2. Host header + signingStr += "\nHost: " + host + + // 3. Content-Type header (如果设置了) + if contentType != "" { + signingStr += "\nContent-Type: " + contentType + } + + // 4. 两个连续换行符 + signingStr += "\n\n" + + // 5. Body (如果设置了 Content-Type 且不是 application/octet-stream) + if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 { + signingStr += string(body) + } + + // 使用 HMAC-SHA1 签名 + h := hmac.New(sha1.New, []byte(c.config.SecretKey)) + h.Write([]byte(signingStr)) + + // Base64 URL 安全编码 + signature := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + return signature +} + +// generateAuthToken 生成管理认证 Token +func (c *Client) generateAuthToken(method, path, host, contentType string, body []byte) string { + signature := c.generateSignature(method, path, host, contentType, body) + return "Qiniu " + c.config.AccessKey + ":" + signature +} + +// generateAuthTokenWithQuery 生成管理认证 Token(支持 query string) +func (c *Client) generateAuthTokenWithQuery(method, path, query, host, contentType string, body []byte) string { + // 七牛云管理凭证签名格式: + // 如果 query 为非空字符串: signingStr = Method + " " + Path + "?" + query + "\nHost: " + Host + ... + // 如果 query 为空: signingStr = Method + " " + Path + "\nHost: " + Host + ... + var signingStr string + + // 1. Method + " " + Path + signingStr = method + " " + path + + // 2. Query string (如果有) + if query != "" { + signingStr += "?" + query + } + + // 3. Host header + signingStr += "\nHost: " + host + + // 4. Content-Type header (如果设置了) + if contentType != "" { + signingStr += "\nContent-Type: " + contentType + } + + // 5. 两个连续换行符 + signingStr += "\n\n" + + // 6. Body (如果设置了 Content-Type 且不是 application/octet-stream) + if contentType != "" && contentType != "application/octet-stream" && len(body) > 0 { + signingStr += string(body) + } + + // 使用 HMAC-SHA1 签名 + h := hmac.New(sha1.New, []byte(c.config.SecretKey)) + h.Write([]byte(signingStr)) + + // Base64 URL 安全编码 + signature := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + return "Qiniu " + c.config.AccessKey + ":" + signature +} + +// encodeEntry 编码 EntryURI (bucket:key) +func (c *Client) encodeEntry(key string) string { + entry := c.config.Bucket + ":" + key + return base64.URLEncoding.EncodeToString([]byte(entry)) +} + +// getUploadDomain 获取上传域名 +func (c *Client) getUploadDomain() string { + // 如果配置了自定义上传域名,使用自定义的 + if c.config.UploadDomain != "" { + if c.config.UseHTTPS { + return "https://" + c.config.UploadDomain + } + return "http://" + c.config.UploadDomain + } + + // 根据区域选择默认上传域名 + // 七牛云上传域名格式: up-.qiniup.com 或 upload-.qbox.me + scheme := "https://" + if !c.config.UseHTTPS { + scheme = "http://" + } + + // 根据区域返回上传域名 + switch c.config.Region { + case "z0": // 华东 + return scheme + "up-z0.qiniup.com" + case "z1": // 华北 + return scheme + "up-z1.qiniup.com" + case "z2": // 华南 + return scheme + "up-z2.qiniup.com" + case "na0": // 北美 + return scheme + "up-na0.qiniup.com" + case "as0": // 亚太 + return scheme + "up-as0.qiniup.com" + default: + // 默认使用华东 + return scheme + "up-z0.qiniup.com" + } +} + +// doRequest 执行 HTTP 请求 +func (c *Client) doRequest(method, path string, body io.Reader) (*http.Response, error) { + url := c.rsAPI + path + + // 解析 path 和 query string + signPath := path + queryString := "" + if idx := strings.Index(path, "?"); idx > 0 { + signPath = path[:idx] + queryString = path[idx+1:] // 去掉问号 + } + + // 读取 body 用于签名 + var bodyBytes []byte + var err error + if body != nil { + bodyBytes, err = io.ReadAll(body) + if err != nil { + return nil, oss.NewError("REQUEST_ERROR", "failed to read request body", err) + } + } + + req, err := http.NewRequest(method, url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, oss.NewError("REQUEST_ERROR", "failed to create request", err) + } + + // 设置 Content-Type + contentType := "" + if method == "POST" || method == "PUT" { + contentType = "application/x-www-form-urlencoded" + req.Header.Set("Content-Type", contentType) + } + + // 设置管理认证头(使用新的签名算法,包含 query string) + host := "rs.qiniu.com" + authToken := c.generateAuthTokenWithQuery(method, signPath, queryString, host, contentType, bodyBytes) + req.Header.Set("Authorization", authToken) + + return c.httpClient.Do(req) +} + +// doRSFRequest 执行 RSF (资源列举) API 请求 +// RSF API 使用不同的 host (rsf.qbox.me) +func (c *Client) doRSFRequest(method, path string) (*http.Response, error) { + url := c.rsfAPI + path + + // 解析 path 和 query string + signPath := path + queryString := "" + if idx := strings.Index(path, "?"); idx > 0 { + signPath = path[:idx] + queryString = path[idx+1:] // 去掉问号 + } + + req, err := http.NewRequest(method, url, nil) + if err != nil { + return nil, oss.NewError("REQUEST_ERROR", "failed to create request", err) + } + + // 设置 Content-Type + contentType := "application/x-www-form-urlencoded" + req.Header.Set("Content-Type", contentType) + + // 设置管理认证头(使用 RSF host) + host := "rsf.qbox.me" + authToken := c.generateAuthTokenWithQuery(method, signPath, queryString, host, contentType, nil) + req.Header.Set("Authorization", authToken) + + return c.httpClient.Do(req) +} + +// Upload 上传文件 (使用表单上传) +func (c *Client) Upload(ctx context.Context, key string, reader io.Reader, options *oss.UploadOptions) (*oss.UploadResult, error) { + // 使用 UploadClient 进行上传 + uploadClient := NewUploadClient(c.config) + return uploadClient.Upload(ctx, key, reader) +} + +// generateUploadToken 生成上传凭证 +func (c *Client) generateUploadToken(key string) string { + // 七牛云上传凭证的生成 + // 1. 创建 putPolicy + putPolicy := fmt.Sprintf(`{"scope":"%s:%s","deadline":%d}`, + c.config.Bucket, key, time.Now().Add(1*time.Hour).Unix()) + + // 2. 对 putPolicy 进行 base64 URL 编码 + encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy)) + + // 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名 + h := hmac.New(sha1.New, []byte(c.config.SecretKey)) + h.Write([]byte(encodedPutPolicy)) + encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + // 4. 组合 token + return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy +} + +// generateBucketToken 生成 bucket 级别的上传凭证(用于分片上传 v2) +func (c *Client) generateBucketToken() string { + // 分片上传 v2 需要 bucket 级别的 token + // 1. 创建 putPolicy + putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`, + c.config.Bucket, time.Now().Add(1*time.Hour).Unix()) + + // 2. 对 putPolicy 进行 base64 URL 编码 + encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy)) + + // 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名 + h := hmac.New(sha1.New, []byte(c.config.SecretKey)) + h.Write([]byte(encodedPutPolicy)) + encodedSign := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + // 4. 组合 token + return c.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy +} + +// resolveDownloadDomain 解析并缓存下载域名 +func (c *Client) resolveDownloadDomain() (string, error) { + if c.config.UploadDomain != "" { + return c.config.UploadDomain, nil + } + domains, err := c.GetBucketDomains(context.Background()) + if err != nil || len(domains) == 0 { + return "", fmt.Errorf("无法获取桶 %s 的下载域名: %v", c.config.Bucket, err) + } + domain := domains[0] + if !strings.HasPrefix(domain, "http://") && !strings.HasPrefix(domain, "https://") { + domain = "http://" + domain + } + c.config.UploadDomain = domain + return domain, nil +} + +// Download 下载文件 +func (c *Client) Download(ctx context.Context, key string, writer io.Writer) error { + baseURL, err := c.resolveDownloadDomain() + if err != nil { + return oss.NewError("DOWNLOAD_ERROR", err.Error(), err) + } + url := fmt.Sprintf("%s/%s", baseURL, key) + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return oss.NewError("DOWNLOAD_ERROR", "failed to create request", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("DOWNLOAD_ERROR", "failed to download file", err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return oss.NewError("DOWNLOAD_ERROR", fmt.Sprintf("download failed with status %d", resp.StatusCode), nil) + } + + _, err = io.Copy(writer, resp.Body) + return err +} + +// Delete 删除文件 +func (c *Client) Delete(ctx context.Context, key string) error { + encodedEntry := c.encodeEntry(key) + path := "/delete/" + encodedEntry + + resp, err := c.doRequest("POST", path, nil) + if err != nil { + return oss.NewError("DELETE_ERROR", "failed to delete file", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 || resp.StatusCode == 612 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return oss.NewError("DELETE_ERROR", fmt.Sprintf("delete failed with status %d: %s", resp.StatusCode, string(body)), nil) +} + +// DeleteMultiple 批量删除文件 +func (c *Client) DeleteMultiple(ctx context.Context, keys []string) (*oss.DeleteResult, error) { + result := &oss.DeleteResult{ + Deleted: make([]string, 0), + Errors: make([]string, 0), + } + + for _, key := range keys { + if err := c.Delete(ctx, key); err != nil { + result.Errors = append(result.Errors, key) + } else { + result.Deleted = append(result.Deleted, key) + } + } + + return result, nil +} + +// GetFileInfo 获取文件信息 +func (c *Client) GetFileInfo(ctx context.Context, key string) (*oss.FileInfo, error) { + encodedEntry := c.encodeEntry(key) + path := "/stat/" + encodedEntry + + resp, err := c.doRequest("GET", path, nil) + if err != nil { + return nil, oss.NewError("STAT_ERROR", "failed to get file info", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("STAT_ERROR", "failed to read response", err) + } + + if resp.StatusCode == 612 { + return nil, oss.ErrFileNotFound + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("STAT_ERROR", fmt.Sprintf("stat failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 解析响应 (简化实现) + // 实际响应格式: {"hash":"xxx","fsize":123,"mimeType":"xxx","putTime":123} + // 这里返回一个简化的 FileInfo + return &oss.FileInfo{ + Key: key, + }, nil +} + +// ListFiles 列举文件 +func (c *Client) ListFiles(ctx context.Context, options *oss.ListOptions) (*oss.ListResult, error) { + if options == nil { + options = &oss.ListOptions{} + } + + if options.MaxKeys == 0 { + options.MaxKeys = 100 + } + + // 构建查询参数 + path := fmt.Sprintf("/list?bucket=%s&limit=%d", c.config.Bucket, options.MaxKeys) + if options.Prefix != "" { + path += "&prefix=" + options.Prefix + } + if options.Marker != "" { + path += "&marker=" + options.Marker + } + + // 使用 GET 方法和 RSF API + resp, err := c.doRSFRequest("GET", path) + if err != nil { + return nil, oss.NewError("LIST_ERROR", "failed to list files", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("LIST_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("LIST_ERROR", fmt.Sprintf("list failed with status %d: %s", resp.StatusCode, string(body)), nil) + } + + // 解析响应 + // 响应格式: {"marker":"","commonPrefixes":[],"items":[{"key":"xxx","hash":"xxx","fsize":123,...}]} + var listResp struct { + Marker string `json:"marker"` + CommonPrefixes []string `json:"commonPrefixes"` + Items []struct { + Key string `json:"key"` + Hash string `json:"hash"` + Fsize int64 `json:"fsize"` + MimeType string `json:"mimeType"` + PutTime int64 `json:"putTime"` + } `json:"items"` + } + + if err := json.Unmarshal(body, &listResp); err != nil { + return nil, oss.NewError("LIST_ERROR", "failed to parse response", err) + } + + // 转换为统一格式 + files := make([]oss.FileInfo, 0, len(listResp.Items)) + for _, item := range listResp.Items { + files = append(files, oss.FileInfo{ + Key: item.Key, + Size: item.Fsize, + ETag: item.Hash, + ContentType: item.MimeType, + }) + } + + return &oss.ListResult{ + Files: files, + IsTruncated: listResp.Marker != "", + NextMarker: listResp.Marker, + Prefixes: listResp.CommonPrefixes, + }, nil +} + +// GetSignedURL 获取预签名URL +func (c *Client) GetSignedURL(ctx context.Context, key string, expiresIn time.Duration) (string, error) { + // 七牛云私有空间下载需要生成私有下载 URL + deadline := time.Now().Add(expiresIn).Unix() + + // 构建 download URL + baseURL, err := c.resolveDownloadDomain() + if err != nil { + return "", err + } + downloadURL := fmt.Sprintf("%s/%s", baseURL, key) + + // 生成签名 + h := hmac.New(sha1.New, []byte(c.config.SecretKey)) + signStr := fmt.Sprintf("%s\n%d", downloadURL, deadline) + h.Write([]byte(signStr)) + sign := base64.URLEncoding.EncodeToString(h.Sum(nil)) + + // 构建最终 URL + signedURL := fmt.Sprintf("%s?e=%d&token=%s:%s", downloadURL, deadline, c.config.AccessKey, sign) + + return signedURL, nil +} + +// Copy 复制文件 +func (c *Client) Copy(ctx context.Context, sourceKey, targetKey string) error { + sourceEntry := c.encodeEntry(sourceKey) + targetEntry := c.encodeEntry(targetKey) + path := "/copy/" + sourceEntry + "/" + targetEntry + + resp, err := c.doRequest("POST", path, nil) + if err != nil { + return oss.NewError("COPY_ERROR", "failed to copy file", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return oss.NewError("COPY_ERROR", fmt.Sprintf("copy failed with status %d: %s", resp.StatusCode, string(body)), nil) +} + +// Move 移动/重命名文件 +func (c *Client) Move(ctx context.Context, sourceKey, targetKey string) error { + sourceEntry := c.encodeEntry(sourceKey) + targetEntry := c.encodeEntry(targetKey) + path := "/move/" + sourceEntry + "/" + targetEntry + + resp, err := c.doRequest("POST", path, nil) + if err != nil { + return oss.NewError("MOVE_ERROR", "failed to move file", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return oss.NewError("MOVE_ERROR", fmt.Sprintf("move failed with status %d: %s", resp.StatusCode, string(body)), nil) +} + +// Exists 检查文件是否存在 +func (c *Client) Exists(ctx context.Context, key string) (bool, error) { + _, err := c.GetFileInfo(ctx, key) + if err == oss.ErrFileNotFound { + return false, nil + } + if err != nil { + return false, err + } + return true, nil +} + +// Close 关闭连接 +func (c *Client) Close() error { + c.httpClient.CloseIdleConnections() + return nil +} diff --git a/internal/oss/qiniu/lifecycle.go b/internal/oss/qiniu/lifecycle.go new file mode 100644 index 0000000..d271913 --- /dev/null +++ b/internal/oss/qiniu/lifecycle.go @@ -0,0 +1,235 @@ +package qiniu + +import ( + "context" + "fmt" + "io" + + "u-desk/internal/oss" +) + +// StorageType 存储类型枚举 +type StorageType int + +const ( + // StorageTypeStandard 标准存储 (0) + StorageTypeStandard StorageType = 0 + // StorageTypeIA 低频存储 (1) - Infrequent Access + StorageTypeIA StorageType = 1 + // StorageTypeArchive 归档存储 (2) - Archive + StorageTypeArchive StorageType = 2 + // StorageTypeDeepArchive 深度归档存储 (3) - Deep Archive + StorageTypeDeepArchive StorageType = 3 + // StorageTypeIntelligentTiering 智能分层存储 (4) + StorageTypeIntelligentTiering StorageType = 4 + // StorageTypeArchiveIR 归档直读存储 (5) - Archive Immediate Retrieval + StorageTypeArchiveIR StorageType = 5 +) + +// String 返回存储类型的字符串表示 +func (t StorageType) String() string { + switch t { + case StorageTypeStandard: + return "标准存储" + case StorageTypeIA: + return "低频存储" + case StorageTypeArchive: + return "归档存储" + case StorageTypeDeepArchive: + return "深度归档存储" + case StorageTypeIntelligentTiering: + return "智能分层存储" + case StorageTypeArchiveIR: + return "归档直读存储" + default: + return "未知" + } +} + +// LifecycleConfig 文件生命周期配置 +type LifecycleConfig struct { + // ToIAAfterDays 转换到低频存储的天数,-1 表示取消 + ToIAAfterDays int + // ToIntelligentTieringAfterDays 转换到智能分层存储的天数,-1 表示取消 + ToIntelligentTieringAfterDays int + // ToArchiveIRAfterDays 转换到归档直读存储的天数,-1 表示取消 + ToArchiveIRAfterDays int + // ToArchiveAfterDays 转换到归档存储的天数,-1 表示取消 + ToArchiveAfterDays int + // ToDeepArchiveAfterDays 转换到深度归档存储的天数,-1 表示取消 + ToDeepArchiveAfterDays int + // DeleteAfterDays 过期删除的天数,-1 表示取消,0 表示不设置 + DeleteAfterDays int +} + +// ChangeStorageType 修改文件存储类型 +// 根据: https://developer.qiniu.com/kodo/api/3710/chtype +func (c *Client) ChangeStorageType(ctx context.Context, key string, storageType StorageType) error { + encodedEntry := c.encodeEntry(key) + path := fmt.Sprintf("/chtype/%s/type/%d", encodedEntry, storageType) + + resp, err := c.doRequest("POST", path, nil) + if err != nil { + return oss.NewError("STYPE_ERROR", "failed to change storage type", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return oss.NewError("STYPE_ERROR", + fmt.Sprintf("change storage type failed with status %d: %s", resp.StatusCode, string(body)), nil) +} + +// SetDeleteAfterDays 设置文件过期删除时间 +// 根据: https://developer.qiniu.com/kodo/api/update-file-lifecycle +// +// 参数: +// - key: 文件 key +// - days: 过期天数,0 表示取消过期删除设置 +// +// 注意: +// - 文件在设置的天数之后被删除,删除后不可恢复 +// - 设置为 0 表示取消过期删除设置 +func (c *Client) SetDeleteAfterDays(ctx context.Context, key string, days int) error { + encodedEntry := c.encodeEntry(key) + path := fmt.Sprintf("/deleteAfterDays/%s/%d", encodedEntry, days) + + resp, err := c.doRequest("POST", path, nil) + if err != nil { + return oss.NewError("LIFECYCLE_ERROR", "failed to set delete after days", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return oss.NewError("LIFECYCLE_ERROR", + fmt.Sprintf("set delete after days failed with status %d: %s", resp.StatusCode, string(body)), nil) +} + +// SetLifecycle 设置文件生命周期 +// 根据: https://developer.qiniu.com/kodo/api/8062/modify-object-life-cycle +// +// 参数说明: +// - ToIAAfterDays: 转换到低频存储的天数,设置为 -1 表示取消 +// - ToIntelligentTieringAfterDays: 转换到智能分层存储的天数,设置为 -1 表示取消 +// - ToArchiveIRAfterDays: 转换到归档直读存储的天数,设置为 -1 表示取消 +// - ToArchiveAfterDays: 转换到归档存储的天数,设置为 -1 表示取消 +// - ToDeepArchiveAfterDays: 转换到深度归档存储的天数,设置为 -1 表示取消 +// - DeleteAfterDays: 过期删除的天数,设置为 -1 表示取消,0 表示不设置 +// +// 注意: +// - 所有参数都是可选的,只设置需要的参数即可 +// - 文件删除后不可恢复 +func (c *Client) SetLifecycle(ctx context.Context, key string, config *LifecycleConfig) error { + encodedEntry := c.encodeEntry(key) + path := fmt.Sprintf("/lifecycle/%s", encodedEntry) + + // 添加各个生命周期参数 + if config.ToIAAfterDays != 0 { + path += fmt.Sprintf("/toIAAfterDays/%d", config.ToIAAfterDays) + } + if config.ToIntelligentTieringAfterDays != 0 { + path += fmt.Sprintf("/toIntelligentTieringAfterDays/%d", config.ToIntelligentTieringAfterDays) + } + if config.ToArchiveIRAfterDays != 0 { + path += fmt.Sprintf("/toArchiveIRAfterDays/%d", config.ToArchiveIRAfterDays) + } + if config.ToArchiveAfterDays != 0 { + path += fmt.Sprintf("/toArchiveAfterDays/%d", config.ToArchiveAfterDays) + } + if config.ToDeepArchiveAfterDays != 0 { + path += fmt.Sprintf("/toDeepArchiveAfterDays/%d", config.ToDeepArchiveAfterDays) + } + if config.DeleteAfterDays != 0 { + path += fmt.Sprintf("/deleteAfterDays/%d", config.DeleteAfterDays) + } + + // 如果没有设置任何参数,返回错误 + if path == fmt.Sprintf("/lifecycle/%s", encodedEntry) { + return oss.NewError("LIFECYCLE_ERROR", "no lifecycle parameters specified", nil) + } + + resp, err := c.doRequest("POST", path, nil) + if err != nil { + return oss.NewError("LIFECYCLE_ERROR", "failed to set lifecycle", err) + } + defer resp.Body.Close() + + if resp.StatusCode == 200 { + return nil + } + + body, _ := io.ReadAll(resp.Body) + return oss.NewError("LIFECYCLE_ERROR", + fmt.Sprintf("set lifecycle failed with status %d: %s", resp.StatusCode, string(body)), nil) +} + +// CancelIAConversion 取消转低频存储的生命周期规则 +func (c *Client) CancelIAConversion(ctx context.Context, key string) error { + config := &LifecycleConfig{ + ToIAAfterDays: -1, + } + return c.SetLifecycle(ctx, key, config) +} + +// CancelArchiveConversion 取消转归档存储的生命周期规则 +func (c *Client) CancelArchiveConversion(ctx context.Context, key string) error { + config := &LifecycleConfig{ + ToArchiveAfterDays: -1, + } + return c.SetLifecycle(ctx, key, config) +} + +// CancelDeleteAfterDays 取消过期删除的生命周期规则 +func (c *Client) CancelDeleteAfterDays(ctx context.Context, key string) error { + config := &LifecycleConfig{ + DeleteAfterDays: -1, + } + return c.SetLifecycle(ctx, key, config) +} + +// SetToIAAfterDays 设置文件转低频存储的天数 +func (c *Client) SetToIAAfterDays(ctx context.Context, key string, days int) error { + config := &LifecycleConfig{ + ToIAAfterDays: days, + } + return c.SetLifecycle(ctx, key, config) +} + +// SetToArchiveAfterDays 设置文件转归档存储的天数 +func (c *Client) SetToArchiveAfterDays(ctx context.Context, key string, days int) error { + config := &LifecycleConfig{ + ToArchiveAfterDays: days, + } + return c.SetLifecycle(ctx, key, config) +} + +// SetToDeepArchiveAfterDays 设置文件转深度归档存储的天数 +func (c *Client) SetToDeepArchiveAfterDays(ctx context.Context, key string, days int) error { + config := &LifecycleConfig{ + ToDeepArchiveAfterDays: days, + } + return c.SetLifecycle(ctx, key, config) +} + +// SetToIntelligentTieringAfterDays 设置文件转智能分层存储的天数 +func (c *Client) SetToIntelligentTieringAfterDays(ctx context.Context, key string, days int) error { + config := &LifecycleConfig{ + ToIntelligentTieringAfterDays: days, + } + return c.SetLifecycle(ctx, key, config) +} + +// SetToArchiveIRAfterDays 设置文件转归档直读存储的天数 +func (c *Client) SetToArchiveIRAfterDays(ctx context.Context, key string, days int) error { + config := &LifecycleConfig{ + ToArchiveIRAfterDays: days, + } + return c.SetLifecycle(ctx, key, config) +} diff --git a/internal/oss/qiniu/multipart_v2.go b/internal/oss/qiniu/multipart_v2.go new file mode 100644 index 0000000..a3de05d --- /dev/null +++ b/internal/oss/qiniu/multipart_v2.go @@ -0,0 +1,427 @@ +package qiniu + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "u-desk/internal/oss" +) + +// PartInfo 分片信息 +type PartInfo struct { + PartNumber int `json:"partNumber"` // 分片编号 (1-10000) + ETag string `json:"etag"` // 分片的 ETag +} + +// InitiateMultipartUploadResult 初始化分片上传任务的结果 +type InitiateMultipartUploadResult struct { + UploadId string `json:"uploadId"` // 上传任务 ID +} + +// UploadPartResult 上传分片的结果 +type UploadPartResult struct { + ETag string `json:"etag"` // 分片的 ETag + MD5 string `json:"md5"` // 分片的 MD5 +} + +// CompleteMultipartUploadResult 完成分片上传的结果 +type CompleteMultipartUploadResult struct { + Key string `json:"key"` // 文件 key + Hash string `json:"hash"` // 文件 hash (ETag) +} + +// InitiateMultipartUpload 初始化分片上传任务 +// 根据: https://developer.qiniu.com/kodo/api/1502/initiate-multipart-upload +func (c *Client) InitiateMultipartUpload(ctx context.Context, key string) (string, error) { + // 生成上传 token + // 注意:分片上传 v2 需要 bucket 级别的 token(不包含 key) + token := c.generateBucketToken() + + // 构建 URL + // 格式: POST /buckets//objects//uploads + encodedKey := base64.URLEncoding.EncodeToString([]byte(key)) + url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads", + c.getUploadDomain(), c.config.Bucket, encodedKey) + + // 构建请求体 + requestBody := map[string]string{ + "fname": key, + } + bodyBytes, _ := json.Marshal(requestBody) + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "UpToken "+token) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to initiate multipart upload", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return "", oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("initiate multipart upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil) + } + + // 解析响应 + var result InitiateMultipartUploadResult + if err := json.Unmarshal(respBody, &result); err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to parse response", err) + } + + return result.UploadId, nil +} + +// UploadPart 上传分片 +// 根据: https://developer.qiniu.com/kodo/api/6366/upload-part +func (c *Client) UploadPart(ctx context.Context, key, uploadId string, partNumber int, reader io.Reader) (string, error) { + // 生成上传 token(分片上传 v2 使用 bucket 级别 token) + token := c.generateBucketToken() + + // 读取数据 + data, err := io.ReadAll(reader) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to read part data", err) + } + + // 计算 MD5 + hash := md5.New() + hash.Write(data) + md5Sum := hash.Sum(nil) + md5Base64 := base64.StdEncoding.EncodeToString(md5Sum) + + // 构建 URL + // 格式: PUT /buckets//objects//uploads// + encodedKey := base64.URLEncoding.EncodeToString([]byte(key)) + url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads/%s/%d", + c.getUploadDomain(), c.config.Bucket, encodedKey, uploadId, partNumber) + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "PUT", url, bytes.NewReader(data)) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Content-Type", "application/octet-stream") + req.Header.Set("Content-MD5", md5Base64) + req.Header.Set("Authorization", "UpToken "+token) + req.Header.Set("Content-Length", fmt.Sprintf("%d", len(data))) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to upload part", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return "", oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("upload part failed with status %d: %s", resp.StatusCode, string(respBody)), nil) + } + + // 解析响应 + var result UploadPartResult + if err := json.Unmarshal(respBody, &result); err != nil { + return "", oss.NewError("MULTIPART_ERROR", "failed to parse response", err) + } + + return result.ETag, nil +} + +// CompleteMultipartUpload 完成分片上传 +// 根据: https://developer.qiniu.com/kodo/api/6368/complete-multipart-upload +func (c *Client) CompleteMultipartUpload(ctx context.Context, key, uploadId string, parts []PartInfo) (*oss.UploadResult, error) { + // 生成上传 token(分片上传 v2 使用 bucket 级别 token) + token := c.generateBucketToken() + + // 构建 URL + // 格式: POST /buckets//objects//uploads/ + encodedKey := base64.URLEncoding.EncodeToString([]byte(key)) + url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads/%s", + c.getUploadDomain(), c.config.Bucket, encodedKey, uploadId) + + // 构建请求体 + requestBody := map[string]interface{}{ + "parts": parts, + "fname": key, + "mimeType": "", + } + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to marshal request", err) + } + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "UpToken "+token) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to complete multipart upload", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("complete multipart upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil) + } + + // 解析响应 + var result CompleteMultipartUploadResult + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err) + } + + return &oss.UploadResult{ + Key: result.Key, + ETag: result.Hash, + }, nil +} + +// AbortMultipartUpload 中止分片上传任务 +// 根据: https://developer.qiniu.com/kodo/api/1503/abort-multipart-upload +func (c *Client) AbortMultipartUpload(ctx context.Context, key, uploadId string) error { + // 生成上传 token(分片上传 v2 使用 bucket 级别 token) + token := c.generateBucketToken() + + // 构建 URL + // 格式: DELETE /buckets//objects//uploads/ + encodedKey := base64.URLEncoding.EncodeToString([]byte(key)) + url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads/%s", + c.getUploadDomain(), c.config.Bucket, encodedKey, uploadId) + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) + if err != nil { + return oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Authorization", "UpToken "+token) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return oss.NewError("MULTIPART_ERROR", "failed to abort multipart upload", err) + } + defer resp.Body.Close() + + // 200 或 204 都表示成功 + if resp.StatusCode != 200 && resp.StatusCode != 204 { + respBody, _ := io.ReadAll(resp.Body) + return oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("abort multipart upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil) + } + + return nil +} + +// ListParts 列举已上传的分片 +// 根据: https://developer.qiniu.com/kodo/api/1504/list-parts +func (c *Client) ListParts(ctx context.Context, key, uploadId string) ([]PartInfo, error) { + // 生成上传 token(分片上传 v2 使用 bucket 级别 token) + token := c.generateBucketToken() + + // 构建 URL + // 格式: GET /buckets//objects//uploads/?partNumberMarker=&maxParts= + encodedKey := base64.URLEncoding.EncodeToString([]byte(key)) + url := fmt.Sprintf("%s/buckets/%s/objects/%s/uploads/%s", + c.getUploadDomain(), c.config.Bucket, encodedKey, uploadId) + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to create request", err) + } + + req.Header.Set("Authorization", "UpToken "+token) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to list parts", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("MULTIPART_ERROR", + fmt.Sprintf("list parts failed with status %d: %s", resp.StatusCode, string(respBody)), nil) + } + + // 解析响应 + var result struct { + Parts []struct { + PartNumber int `json:"partNumber"` + ETag string `json:"etag"` + Size int64 `json:"size"` + } `json:"parts"` + } + + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, oss.NewError("MULTIPART_ERROR", "failed to parse response", err) + } + + // 转换为 PartInfo + parts := make([]PartInfo, 0, len(result.Parts)) + for _, p := range result.Parts { + parts = append(parts, PartInfo{ + PartNumber: p.PartNumber, + ETag: p.ETag, + }) + } + + return parts, nil +} + +// UploadMultipart 使用分片上传方式上传文件 +// 自动将文件分片并上传,适用于大文件 +// 注意:七牛云要求每个分片大小至少为 1MB(除最后一个分片外) +func (c *Client) UploadMultipart(ctx context.Context, key string, reader io.Reader, partSize int64) (*oss.UploadResult, error) { + // 默认分片大小为 4MB + if partSize <= 0 { + partSize = 4 * 1024 * 1024 + } + + // 七牛云要求:每个分片至少 1MB(除最后一个分片外) + const minPartSize = 1024 * 1024 // 1MB + if partSize < minPartSize { + partSize = minPartSize + } + + // 1. 初始化上传任务 + uploadId, err := c.InitiateMultipartUpload(ctx, key) + if err != nil { + return nil, fmt.Errorf("failed to initiate multipart upload: %w", err) + } + + // 确保在失败时中止任务 + defer func() { + if err != nil { + c.AbortMultipartUpload(context.Background(), key, uploadId) + } + }() + + // 2. 读取所有数据并分片 + data, err := io.ReadAll(reader) + if err != nil { + return nil, fmt.Errorf("failed to read data: %w", err) + } + + totalSize := int64(len(data)) + + // 如果文件太小,使用普通上传 + if totalSize < minPartSize { + // 文件小于 1MB,使用普通上传 + uploadClient := NewUploadClient(c.config) + return uploadClient.Upload(ctx, key, bytes.NewReader(data)) + } + + partCount := int((totalSize + partSize - 1) / partSize) // 向上取整 + + // 3. 上传各个分片 + parts := make([]PartInfo, 0, partCount) + for i := 0; i < partCount; i++ { + partNumber := i + 1 + start := i * int(partSize) + end := start + int(partSize) + if end > len(data) { + end = len(data) + } + + partData := data[start:end] + currentPartSize := int64(len(partData)) + + // 验证分片大小(除最后一个分片外,其他分片必须 >= 1MB) + if i < partCount-1 && currentPartSize < minPartSize { + return nil, fmt.Errorf("part %d size (%d bytes) is less than minimum required size (%d bytes)", + partNumber, currentPartSize, minPartSize) + } + + etag, err := c.UploadPart(ctx, key, uploadId, partNumber, bytes.NewReader(partData)) + if err != nil { + return nil, fmt.Errorf("failed to upload part %d: %w", partNumber, err) + } + + parts = append(parts, PartInfo{ + PartNumber: partNumber, + ETag: etag, + }) + } + + // 4. 完成上传 + result, err := c.CompleteMultipartUpload(ctx, key, uploadId, parts) + if err != nil { + return nil, fmt.Errorf("failed to complete multipart upload: %w", err) + } + + // 成功,取消 defer 中的中止操作 + err = nil + return result, nil +} + +// UploadWithRetry 带重试的分片上传 +// 支持失败重试,适用于不稳定的网络环境 +func (c *Client) UploadWithRetry(ctx context.Context, key string, reader io.Reader, partSize int64, maxRetries int) (*oss.UploadResult, error) { + if maxRetries <= 0 { + maxRetries = 3 + } + + var lastErr error + for attempt := 0; attempt < maxRetries; attempt++ { + // 每次重试需要重新读取数据 + data, err := io.ReadAll(reader) + if err != nil { + return nil, err + } + + result, err := c.UploadMultipart(ctx, key, bytes.NewReader(data), partSize) + if err == nil { + return result, nil + } + + lastErr = err + // 等待一段时间后重试 + time.Sleep(time.Second * time.Duration(attempt+1)) + } + + return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr) +} diff --git a/internal/oss/qiniu/upload.go b/internal/oss/qiniu/upload.go new file mode 100644 index 0000000..f80ed37 --- /dev/null +++ b/internal/oss/qiniu/upload.go @@ -0,0 +1,235 @@ +package qiniu + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha1" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "time" + + "u-desk/internal/oss" +) + +// UploadResult 七牛云上传结果 +type qiniuUploadResult struct { + Key string `json:"key"` + Hash string `json:"hash"` + Size int64 `json:"fsize"` + Bucket string `json:"bucket"` +} + +// UploadWithUploader 使用表单上传文件 +func (c *Client) UploadWithUploader(ctx context.Context, key string, reader io.Reader, options *oss.UploadOptions) (*oss.UploadResult, error) { + // 生成上传 token + token := c.generateUploadToken(key) + + // 创建 multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + // 添加字段 + _ = writer.WriteField("token", token) + _ = writer.WriteField("key", key) + + // 添加文件 + part, err := writer.CreateFormFile("file", key) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to create form file", err) + } + + // 读取数据并写入 + // 为了获取文件大小,先读取到内存 + data, err := io.ReadAll(reader) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to read file data", err) + } + + if _, err := part.Write(data); err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to write file data", err) + } + + // 设置 Content-Type + if options != nil && options.ContentType != "" { + _ = writer.WriteField("mimeType", options.ContentType) + } + + if err := writer.Close(); err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to close multipart writer", err) + } + + // 上传 URL - 根据配置或区域选择 + uploadURL := c.getUploadDomain() + + // 创建请求 + req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, body) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to create request", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + // 发送请求 + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to upload file", err) + } + defer resp.Body.Close() + + // 读取响应 + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("UPLOAD_ERROR", fmt.Sprintf("upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil) + } + + // 解析结果 + var result qiniuUploadResult + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to parse response", err) + } + + return &oss.UploadResult{ + Key: result.Key, + ETag: result.Hash, + Size: result.Size, + }, nil +} + +// UploadClient 专用的上传客户端 +type UploadClient struct { + config *Config + client *http.Client +} + +// NewUploadClient 创建上传客户端 +func NewUploadClient(config *Config) *UploadClient { + return &UploadClient{ + config: config, + client: &http.Client{ + Timeout: 5 * time.Minute, + }, + } +} + +// Upload 上传文件 +func (uc *UploadClient) Upload(ctx context.Context, key string, reader io.Reader) (*oss.UploadResult, error) { + token := uc.generateUploadToken() + + // 创建 multipart form + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + _ = writer.WriteField("token", token) + _ = writer.WriteField("key", key) + + part, err := writer.CreateFormFile("file", key) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to create form file", err) + } + + data, err := io.ReadAll(reader) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to read file data", err) + } + + if _, err := part.Write(data); err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to write file data", err) + } + + if err := writer.Close(); err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to close multipart writer", err) + } + + // 上传 URL - 根据配置或区域选择 + scheme := "https://" + if !uc.config.UseHTTPS { + scheme = "http://" + } + + var uploadURL string + if uc.config.UploadDomain != "" { + uploadURL = scheme + uc.config.UploadDomain + } else { + // 根据区域选择 + switch uc.config.Region { + case "z0": + uploadURL = scheme + "up-z0.qiniup.com" + case "z1": + uploadURL = scheme + "up-z1.qiniup.com" + case "z2": + uploadURL = scheme + "up-z2.qiniup.com" + case "na0": + uploadURL = scheme + "up-na0.qiniup.com" + case "as0": + uploadURL = scheme + "up-as0.qiniup.com" + default: + uploadURL = scheme + "up-z0.qiniup.com" + } + } + + req, err := http.NewRequestWithContext(ctx, "POST", uploadURL, body) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to create request", err) + } + + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := uc.client.Do(req) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to upload file", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to read response", err) + } + + if resp.StatusCode != 200 { + return nil, oss.NewError("UPLOAD_ERROR", fmt.Sprintf("upload failed with status %d: %s", resp.StatusCode, string(respBody)), nil) + } + + var result qiniuUploadResult + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, oss.NewError("UPLOAD_ERROR", "failed to parse response", err) + } + + return &oss.UploadResult{ + Key: result.Key, + ETag: result.Hash, + Size: result.Size, + }, nil +} + +// generateUploadToken 生成上传 token +func (uc *UploadClient) generateUploadToken() string { + // 1. 创建 putPolicy + putPolicy := fmt.Sprintf(`{"scope":"%s","deadline":%d}`, + uc.config.Bucket, time.Now().Add(1*time.Hour).Unix()) + + // 2. 对 putPolicy 进行 base64 URL 编码 + encodedPutPolicy := base64.URLEncoding.EncodeToString([]byte(putPolicy)) + + // 3. 对 encodedPutPolicy 进行 HMAC-SHA1 签名 + h := uc.hmacSHA1([]byte(encodedPutPolicy)) + encodedSign := base64.URLEncoding.EncodeToString(h) + + // 4. 组合 token + return uc.config.AccessKey + ":" + encodedSign + ":" + encodedPutPolicy +} + +// hmacSHA1 HMAC-SHA1 签名 +func (uc *UploadClient) hmacSHA1(data []byte) []byte { + h := hmac.New(sha1.New, []byte(uc.config.SecretKey)) + h.Write(data) + return h.Sum(nil) +} diff --git a/internal/oss/types.go b/internal/oss/types.go new file mode 100644 index 0000000..a9f8007 --- /dev/null +++ b/internal/oss/types.go @@ -0,0 +1,60 @@ +package oss + +import "time" + +// FileInfo 文件信息 +type FileInfo struct { + Key string // 文件key + Size int64 // 文件大小 + ETag string // 文件hash + ContentType string // 文件类型 + LastModified time.Time // 最后修改时间 + Metadata map[string]string // 自定义元数据 +} + +// UploadOptions 上传选项 +type UploadOptions struct { + ContentType string // 文件类型 + Metadata map[string]string // 自定义元数据 + Callback *UploadProgressCallback // 进度回调 +} + +// UploadProgressCallback 上传进度回调 +type UploadProgressCallback struct { + OnProgress func(current, total int64) +} + +// UploadResult 上传结果 +type UploadResult struct { + Key string // 文件key + ETag string // 文件hash + Size int64 // 文件大小 +} + +// ListOptions 列举选项 +type ListOptions struct { + Prefix string // 前缀过滤 + MaxKeys int // 最大返回数量 + Marker string // 分页标记 + Delimiter string // 分隔符(用于目录模拟) +} + +// ListResult 列举结果 +type ListResult struct { + Files []FileInfo // 文件列表 + Prefixes []string // 公共前缀(模拟目录) + IsTruncated bool // 是否还有更多数据 + NextMarker string // 下一页标记 +} + +// BucketEntry 桶信息 +type BucketEntry struct { + Name string // 桶名 + Region string // 区域(七牛默认 "z0",阿里云如 "oss-cn-hangzhou") +} + +// DeleteResult 删除结果 +type DeleteResult struct { + Deleted []string // 成功删除的文件 + Errors []string // 失败的文件 +} diff --git a/internal/ossdrv/service.go b/internal/ossdrv/service.go new file mode 100644 index 0000000..1631676 --- /dev/null +++ b/internal/ossdrv/service.go @@ -0,0 +1,652 @@ +package ossdrv + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "os" + "path" + "strings" + "sync" + "time" + + "u-desk/internal/filesystem" + "u-desk/internal/oss" + "u-desk/internal/oss/aliyun" + "u-desk/internal/oss/qiniu" +) + +// accountCredentials 账户级凭据 +type accountCredentials struct { + Provider string + AccessKey string + SecretKey string + Endpoint string +} + +// Manager OSS 连接管理器(两级:账户 + 桶级客户端缓存) +type Manager struct { + accounts sync.Map // map[string]*accountCredentials key=provider + clients sync.Map // map[string]oss.OSSProvider key="provider:bucket" + bucketRegions sync.Map // map[string]string key="provider:bucket" → region +} + +var globalManager = &Manager{} + +func GetManager() *Manager { return globalManager } + +// Connect 建立账户级连接(验证凭据通过 ListBuckets) +func (m *Manager) Connect(provider, accessKey, secretKey, endpoint string) error { + // 验证凭据 + switch provider { + case "qiniu": + _, err := qiniu.ListBuckets(accessKey, secretKey) + if err != nil { + return fmt.Errorf("七牛云连接失败: %w", err) + } + case "aliyun": + _, err := aliyun.ListBuckets(accessKey, secretKey, endpoint) + if err != nil { + return fmt.Errorf("阿里云连接失败: %w", err) + } + default: + return fmt.Errorf("不支持的 OSS 提供商: %s", provider) + } + + m.accounts.Store(provider, &accountCredentials{ + Provider: provider, + AccessKey: accessKey, + SecretKey: secretKey, + Endpoint: endpoint, + }) + return nil +} + +// getOrCreateBucketClient 懒创建桶级 OSSProvider +func (m *Manager) getOrCreateBucketClient(provider, bucket, region string) (oss.OSSProvider, error) { + key := provider + ":" + bucket + if v, ok := m.clients.Load(key); ok { + return v.(oss.OSSProvider), nil + } + + cred, ok := m.accounts.Load(provider) + if !ok { + return nil, fmt.Errorf("OSS 账户未连接: %s", provider) + } + c := cred.(*accountCredentials) + + // 如果未传 region,从缓存取 + if region == "" { + if v, ok := m.bucketRegions.Load(key); ok { + region = v.(string) + } + } + + var client oss.OSSProvider + var err error + + switch provider { + case "qiniu": + client, err = qiniu.NewClient(&qiniu.Config{ + AccessKey: c.AccessKey, + SecretKey: c.SecretKey, + Bucket: bucket, + Region: region, + UseHTTPS: true, + }) + case "aliyun": + client, err = aliyun.NewClient(&aliyun.Config{ + AccessKeyID: c.AccessKey, + AccessKeySecret: c.SecretKey, + Bucket: bucket, + Region: region, + Endpoint: c.Endpoint, + UseHTTPS: true, + }) + default: + return nil, fmt.Errorf("不支持的提供商: %s", provider) + } + + if err != nil { + return nil, fmt.Errorf("创建桶客户端失败: %w", err) + } + + m.clients.Store(key, client) + return client, nil +} + +// GetClient 获取已有的桶级客户端 +func (m *Manager) GetClient(provider, bucket string) oss.OSSProvider { + if v, ok := m.clients.Load(provider + ":" + bucket); ok { + return v.(oss.OSSProvider) + } + return nil +} + +// Disconnect 关闭账户及所有桶级客户端 +func (m *Manager) Disconnect(provider string) { + m.accounts.Delete(provider) + prefix := provider + ":" + m.clients.Range(func(key, value any) bool { + if strings.HasPrefix(key.(string), prefix) { + value.(oss.OSSProvider).Close() + m.clients.Delete(key) + } + return true + }) + m.bucketRegions.Range(func(key, value any) bool { + if strings.HasPrefix(key.(string), prefix) { + m.bucketRegions.Delete(key) + } + return true + }) +} + +// Shutdown 关闭所有连接 +func (m *Manager) Shutdown() { + m.clients.Range(func(key, value any) bool { + value.(oss.OSSProvider).Close() + m.clients.Delete(key) + return true + }) + m.accounts.Range(func(key, value any) bool { + m.accounts.Delete(key) + return true + }) +} + +// Service OSS 文件操作服务 +type Service struct { + manager *Manager +} + +func NewService() *Service { + return &Service{manager: GetManager()} +} + +func (s *Service) GetManager() *Manager { return s.manager } + +// parseBucketPath 解析路径中的桶名和对象键 +// "/my-bucket/photos/img.jpg" → bucket="my-bucket", key="photos/img.jpg" +func parseBucketPath(rawPath string) (bucket, key string) { + rawPath = strings.TrimPrefix(rawPath, "/") + if rawPath == "" { + return "", "" + } + parts := strings.SplitN(rawPath, "/", 2) + bucket = parts[0] + if len(parts) > 1 { + key = parts[1] + } + return +} + +// listBuckets 列出所有桶 +func (s *Service) listBuckets(provider string) ([]map[string]interface{}, error) { + cred, ok := s.manager.accounts.Load(provider) + if !ok { + return nil, fmt.Errorf("OSS 账户未连接: %s", provider) + } + c := cred.(*accountCredentials) + + var entries []oss.BucketEntry + var err error + + switch provider { + case "qiniu": + entries, err = qiniu.ListBuckets(c.AccessKey, c.SecretKey) + case "aliyun": + entries, err = aliyun.ListBuckets(c.AccessKey, c.SecretKey, c.Endpoint) + default: + return nil, fmt.Errorf("不支持的提供商: %s", provider) + } + if err != nil { + return nil, fmt.Errorf("列举存储桶失败: %w", err) + } + + // 缓存桶区域信息 + for _, e := range entries { + if e.Region != "" { + s.manager.bucketRegions.Store(provider+":"+e.Name, e.Region) + } + } + + items := make([]map[string]interface{}, len(entries)) + for i, e := range entries { + items[i] = map[string]interface{}{ + "name": e.Name, + "path": "/" + e.Name, + "is_dir": true, + "is_bucket": true, + "size": int64(0), + } + } + return items, nil +} + +// ListDir 列出目录内容 +func (s *Service) ListDir(connID string, prefix string) ([]map[string]interface{}, error) { + prefix = strings.TrimPrefix(prefix, "/") + + // 根目录 → 列出所有桶 + if prefix == "" { + return s.listBuckets(connID) + } + + // 解析桶名和对象前缀 + bucket, objectPrefix := parseBucketPath(prefix) + if bucket == "" { + return s.listBuckets(connID) + } + + if objectPrefix != "" && !strings.HasSuffix(objectPrefix, "/") { + objectPrefix += "/" + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return nil, err + } + + ctx := context.Background() + result, err := c.ListFiles(ctx, &oss.ListOptions{ + Prefix: objectPrefix, + Delimiter: "/", + MaxKeys: 1000, + }) + if err != nil { + return nil, fmt.Errorf("列举文件失败: %w", err) + } + + items := make([]map[string]interface{}, 0, len(result.Files)+len(result.Prefixes)) + bucketPrefix := "/" + bucket + "/" + + for _, p := range result.Prefixes { + name := strings.TrimSuffix(strings.TrimPrefix(p, objectPrefix), "/") + if name == "" { + continue + } + items = append(items, map[string]interface{}{ + "name": name, + "path": bucketPrefix + p, + "is_dir": true, + "size": int64(0), + }) + } + + for _, f := range result.Files { + if strings.HasSuffix(f.Key, "/") && f.Size == 0 { + continue + } + items = append(items, map[string]interface{}{ + "name": path.Base(f.Key), + "path": bucketPrefix + f.Key, + "is_dir": false, + "size": f.Size, + "mod_time": f.LastModified.Format("2006-01-02 15:04:05"), + }) + } + + return items, nil +} + +// ReadFile 读取文件内容 +func (s *Service) ReadFile(connID string, rawPath string) (string, error) { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return "", fmt.Errorf("路径中缺少桶名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return "", err + } + + const maxSize int64 = 10 << 20 + ctx := context.Background() + + info, err := c.GetFileInfo(ctx, key) + if err != nil { + return "", fmt.Errorf("获取文件信息失败: %w", err) + } + if info.Size > maxSize { + return "", fmt.Errorf("文件过大 (%s),超过 %d 限制", filesystem.FormatBytes(info.Size), maxSize) + } + + var buf bytes.Buffer + if err := c.Download(ctx, key, &buf); err != nil { + return "", fmt.Errorf("读取文件失败: %w", err) + } + return filesystem.BytesToString(buf.Bytes()), nil +} + +// WriteFile 写入文件内容 +func (s *Service) WriteFile(connID string, rawPath string, content string) error { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return fmt.Errorf("路径中缺少桶名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return err + } + + _, err = c.Upload(context.Background(), key, strings.NewReader(content), nil) + if err != nil { + return fmt.Errorf("写入文件失败: %w", err) + } + return nil +} + +// WriteBase64File 写入 base64 编码的二进制文件 +func (s *Service) WriteBase64File(connID string, rawPath string, base64Content string) error { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return fmt.Errorf("路径中缺少桶名") + } + + data, err := base64.StdEncoding.DecodeString(base64Content) + if err != nil { + return fmt.Errorf("base64 解码失败: %w", err) + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return err + } + + _, err = c.Upload(context.Background(), key, bytes.NewReader(data), nil) + if err != nil { + return fmt.Errorf("写入文件失败: %w", err) + } + return nil +} + +// GetFileInfo 获取文件信息 +func (s *Service) GetFileInfo(connID string, rawPath string) (map[string]interface{}, error) { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return nil, fmt.Errorf("路径中缺少桶名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return nil, err + } + + info, err := c.GetFileInfo(context.Background(), key) + if err != nil { + return nil, fmt.Errorf("获取文件信息失败: %w", err) + } + + bucketPrefix := "/" + bucket + "/" + return map[string]interface{}{ + "name": path.Base(info.Key), + "path": bucketPrefix + info.Key, + "size": info.Size, + "size_str": filesystem.FormatBytes(info.Size), + "is_dir": strings.HasSuffix(info.Key, "/"), + "mod_time": info.LastModified.Format("2006-01-02 15:04:05"), + }, nil +} + +// CreateDir 创建目录 +func (s *Service) CreateDir(connID string, rawPath string) (*filesystem.FileOperationResult, error) { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return nil, fmt.Errorf("路径中缺少桶名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return nil, err + } + + if !strings.HasSuffix(key, "/") { + key += "/" + } + + _, err = c.Upload(context.Background(), key, strings.NewReader(""), nil) + if err != nil { + return nil, fmt.Errorf("创建目录失败: %w", err) + } + + name := path.Base(strings.TrimSuffix(key, "/")) + return &filesystem.FileOperationResult{ + Path: "/" + bucket + "/" + key, + Name: name, + IsDir: true, + SizeStr: filesystem.FormatBytes(0), + }, nil +} + +// CreateFile 创建空文件 +func (s *Service) CreateFile(connID string, rawPath string) (*filesystem.FileOperationResult, error) { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return nil, fmt.Errorf("路径中缺少桶名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return nil, err + } + + _, err = c.Upload(context.Background(), key, strings.NewReader(""), nil) + if err != nil { + return nil, fmt.Errorf("创建文件失败: %w", err) + } + + return &filesystem.FileOperationResult{ + Path: "/" + bucket + "/" + key, + Name: path.Base(key), + IsDir: false, + SizeStr: filesystem.FormatBytes(0), + }, nil +} + +// DeletePath 删除文件或目录 +func (s *Service) DeletePath(connID string, rawPath string) (*filesystem.FileOperationResult, error) { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return nil, fmt.Errorf("路径中缺少桶名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return nil, err + } + + ctx := context.Background() + + isDir := strings.HasSuffix(key, "/") + if !isDir { + prefix := key + "/" + listResult, listErr := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 1}) + if listErr == nil && len(listResult.Files) > 0 { + isDir = true + key = prefix + } + } + + infoMap, _ := s.GetFileInfo(connID, "/"+bucket+"/"+key) + + if isDir { + prefix := key + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + for { + listResult, err := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 1000}) + if err != nil { + return nil, fmt.Errorf("列举目录文件失败: %w", err) + } + if len(listResult.Files) == 0 { + break + } + keys := make([]string, len(listResult.Files)) + for i, f := range listResult.Files { + keys[i] = f.Key + } + if _, err := c.DeleteMultiple(ctx, keys); err != nil { + return nil, fmt.Errorf("批量删除失败: %w", err) + } + if !listResult.IsTruncated { + break + } + } + c.Delete(ctx, key) // marker 非关键,忽略错误 + } else { + if err := c.Delete(ctx, key); err != nil { + return nil, fmt.Errorf("删除失败: %w", err) + } + } + + result := toOssOperationResult(infoMap, isDir) + result.Deleted = true + return result, nil +} + +// RenamePath 重命名(Copy + Delete) +func (s *Service) RenamePath(connID string, oldPath string, newPath string) (*filesystem.FileOperationResult, error) { + oldBucket, oldKey := parseBucketPath(oldPath) + newBucket, newKey := parseBucketPath(newPath) + if oldBucket == "" || newBucket == "" { + return nil, fmt.Errorf("路径中缺少桶名") + } + + if oldBucket != newBucket { + return nil, fmt.Errorf("不支持跨桶重命名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, oldBucket, "") + if err != nil { + return nil, err + } + + ctx := context.Background() + + isDir := strings.HasSuffix(oldKey, "/") + if !isDir { + prefix := oldKey + "/" + listResult, listErr := c.ListFiles(ctx, &oss.ListOptions{Prefix: prefix, MaxKeys: 1}) + if listErr == nil && len(listResult.Files) > 0 { + isDir = true + oldKey = prefix + } + } + + if isDir { + oldPrefix := oldKey + newPrefix := newKey + if !strings.HasSuffix(oldPrefix, "/") { + oldPrefix += "/" + } + if !strings.HasSuffix(newPrefix, "/") { + newPrefix += "/" + } + + for { + listResult, err := c.ListFiles(ctx, &oss.ListOptions{Prefix: oldPrefix, MaxKeys: 1000}) + if err != nil { + return nil, fmt.Errorf("列举目录文件失败: %w", err) + } + if len(listResult.Files) == 0 { + break + } + for _, f := range listResult.Files { + relativeKey := strings.TrimPrefix(f.Key, oldPrefix) + if err := c.Copy(ctx, f.Key, newPrefix+relativeKey); err != nil { + return nil, fmt.Errorf("复制失败: %w", err) + } + c.Delete(ctx, f.Key) + } + if !listResult.IsTruncated { + break + } + } + c.Delete(ctx, oldKey) // marker + } else { + if err := c.Copy(ctx, oldKey, newKey); err != nil { + return nil, fmt.Errorf("复制失败: %w", err) + } + if err := c.Delete(ctx, oldKey); err != nil { + return nil, fmt.Errorf("删除源文件失败: %w", err) + } + } + + infoMap, _ := s.GetFileInfo(connID, newPath) + result := toOssOperationResult(infoMap, isDir) + result.OldPath = oldPath + return result, nil +} + +// DownloadToTemp 下载文件到本地临时目录 +func (s *Service) DownloadToTemp(connID string, rawPath string) (string, error) { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return "", fmt.Errorf("路径中缺少桶名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return "", err + } + + f, err := os.CreateTemp("", "udesk-oss-*-"+path.Base(key)) + if err != nil { + return "", fmt.Errorf("创建临时文件失败: %w", err) + } + localPath := f.Name() + defer f.Close() + + if err := c.Download(context.Background(), key, f); err != nil { + os.Remove(localPath) + return "", fmt.Errorf("下载文件失败: %w", err) + } + return localPath, nil +} + +// GetCommonPaths 返回常用路径 +func (s *Service) GetCommonPaths(connID string) (map[string]string, error) { + return map[string]string{ + "root": "/", + }, nil +} + +// GetSignedURL 获取预签名 URL +func (s *Service) GetSignedURL(connID string, rawPath string) (string, error) { + bucket, key := parseBucketPath(rawPath) + if bucket == "" { + return "", fmt.Errorf("路径中缺少桶名") + } + + c, err := s.manager.getOrCreateBucketClient(connID, bucket, "") + if err != nil { + return "", err + } + + url, err := c.GetSignedURL(context.Background(), key, 1*time.Hour) + if err != nil { + return "", fmt.Errorf("获取签名 URL 失败: %w", err) + } + return url, nil +} + +func toOssOperationResult(m map[string]interface{}, isDir bool) *filesystem.FileOperationResult { + name, _ := m["name"].(string) + p, _ := m["path"].(string) + size, _ := m["size"].(int64) + modTime, _ := m["mod_time"].(string) + + return &filesystem.FileOperationResult{ + Path: p, + Name: name, + Size: size, + SizeStr: filesystem.FormatBytes(size), + IsDir: isDir, + ModTime: modTime, + } +} diff --git a/internal/ossdrv/service_test.go b/internal/ossdrv/service_test.go new file mode 100644 index 0000000..d72ea8b --- /dev/null +++ b/internal/ossdrv/service_test.go @@ -0,0 +1,221 @@ +package ossdrv + +import ( + "os" + "testing" +) + +func getEnvOrSkip(t *testing.T, key string) string { + t.Helper() + v := os.Getenv(key) + if v == "" { + t.Skipf("跳过:环境变量 %s 未设置", key) + } + return v +} + +func TestQiniuConnect(t *testing.T) { + ak := getEnvOrSkip(t, "QINIU_AK") + sk := getEnvOrSkip(t, "QINIU_SK") + + m := &Manager{} + err := m.Connect("qiniu", ak, sk, "") + if err != nil { + t.Fatalf("七牛云连接失败: %v", err) + } + + cred, ok := m.accounts.Load("qiniu") + if !ok { + t.Fatal("凭据未存储") + } + c := cred.(*accountCredentials) + if c.AccessKey != ak { + t.Errorf("AccessKey 不匹配: got %s", c.AccessKey) + } +} + +func TestQiniuListBuckets(t *testing.T) { + ak := getEnvOrSkip(t, "QINIU_AK") + sk := getEnvOrSkip(t, "QINIU_SK") + + m := &Manager{} + if err := m.Connect("qiniu", ak, sk, ""); err != nil { + t.Skipf("跳过:连接失败: %v", err) + } + + svc := &Service{manager: m} + items, err := svc.ListDir("qiniu", "/") + if err != nil { + t.Fatalf("列桶失败: %v", err) + } + if len(items) == 0 { + t.Fatal("没有返回任何桶") + } + t.Logf("七牛云桶数量: %d", len(items)) + for _, item := range items { + t.Logf(" 桶: %s (path=%s, is_dir=%v)", item["name"], item["path"], item["is_dir"]) + } +} + +func TestQiniuBucketListDir(t *testing.T) { + ak := getEnvOrSkip(t, "QINIU_AK") + sk := getEnvOrSkip(t, "QINIU_SK") + + m := &Manager{} + if err := m.Connect("qiniu", ak, sk, ""); err != nil { + t.Skipf("跳过:连接失败: %v", err) + } + + svc := &Service{manager: m} + items, err := svc.ListDir("qiniu", "/") + if err != nil || len(items) == 0 { + t.Skipf("跳过:无法列桶") + } + + bucketName, _ := items[0]["name"].(string) + t.Logf("进入桶: %s", bucketName) + + path := "/" + bucketName + "/" + files, err := svc.ListDir("qiniu", path) + if err != nil { + t.Fatalf("列桶内文件失败: %v", err) + } + t.Logf("桶内文件数量: %d", len(files)) + for _, f := range files { + t.Logf(" %s (is_dir=%v, size=%v)", f["name"], f["is_dir"], f["size"]) + } + + client := m.GetClient("qiniu", bucketName) + if client == nil { + t.Error("桶级客户端未缓存") + } +} + +func TestAliyunConnect(t *testing.T) { + ak := getEnvOrSkip(t, "ALIYUN_AK") + sk := getEnvOrSkip(t, "ALIYUN_SK") + ep := os.Getenv("ALIYUN_EP") + if ep == "" { + ep = "oss-cn-shenzhen.aliyuncs.com" + } + + m := &Manager{} + err := m.Connect("aliyun", ak, sk, ep) + if err != nil { + t.Fatalf("阿里云连接失败: %v", err) + } +} + +func TestAliyunListBuckets(t *testing.T) { + ak := getEnvOrSkip(t, "ALIYUN_AK") + sk := getEnvOrSkip(t, "ALIYUN_SK") + ep := os.Getenv("ALIYUN_EP") + if ep == "" { + ep = "oss-cn-shenzhen.aliyuncs.com" + } + + m := &Manager{} + if err := m.Connect("aliyun", ak, sk, ep); err != nil { + t.Skipf("跳过:连接失败: %v", err) + } + + svc := &Service{manager: m} + items, err := svc.ListDir("aliyun", "/") + if err != nil { + t.Fatalf("列桶失败: %v", err) + } + if len(items) == 0 { + t.Fatal("没有返回任何桶") + } + t.Logf("阿里云桶数量: %d", len(items)) + for _, item := range items { + t.Logf(" 桶: %s (path=%s)", item["name"], item["path"]) + } +} + +func TestAliyunBucketListDir(t *testing.T) { + ak := getEnvOrSkip(t, "ALIYUN_AK") + sk := getEnvOrSkip(t, "ALIYUN_SK") + ep := os.Getenv("ALIYUN_EP") + if ep == "" { + ep = "oss-cn-shenzhen.aliyuncs.com" + } + + m := &Manager{} + if err := m.Connect("aliyun", ak, sk, ep); err != nil { + t.Skipf("跳过:连接失败: %v", err) + } + + svc := &Service{manager: m} + items, err := svc.ListDir("aliyun", "/") + if err != nil || len(items) == 0 { + t.Skipf("跳过:无法列桶") + } + + var bucketName string + for _, item := range items { + if item["name"] == "f-kit" { + bucketName = "f-kit" + break + } + } + if bucketName == "" { + bucketName, _ = items[0]["name"].(string) + } + t.Logf("进入桶: %s", bucketName) + + path := "/" + bucketName + "/" + files, err := svc.ListDir("aliyun", path) + if err != nil { + t.Fatalf("列桶内文件失败: %v", err) + } + t.Logf("桶内文件数量: %d", len(files)) + for _, f := range files { + t.Logf(" %s (is_dir=%v)", f["name"], f["is_dir"]) + } +} + +func TestParseBucketPath(t *testing.T) { + tests := []struct { + input string + wantBucket string + wantKey string + }{ + {"/bucket/file.txt", "bucket", "file.txt"}, + {"/bucket/dir/file.txt", "bucket", "dir/file.txt"}, + {"/bucket/", "bucket", ""}, + {"/bucket", "bucket", ""}, + {"/", "", ""}, + {"", "", ""}, + } + for _, tt := range tests { + bucket, key := parseBucketPath(tt.input) + if bucket != tt.wantBucket || key != tt.wantKey { + t.Errorf("parseBucketPath(%q) = (%q, %q), want (%q, %q)", + tt.input, bucket, key, tt.wantBucket, tt.wantKey) + } + } +} + +func TestDisconnect(t *testing.T) { + ak := getEnvOrSkip(t, "QINIU_AK") + sk := getEnvOrSkip(t, "QINIU_SK") + + m := &Manager{} + if err := m.Connect("qiniu", ak, sk, ""); err != nil { + t.Skipf("跳过:连接失败: %v", err) + } + + svc := &Service{manager: m} + items, _ := svc.ListDir("qiniu", "/") + if len(items) > 0 { + bucket, _ := items[0]["name"].(string) + svc.ListDir("qiniu", "/"+bucket+"/") + } + + m.Disconnect("qiniu") + + if _, ok := m.accounts.Load("qiniu"); ok { + t.Error("账户凭据未被清除") + } +} diff --git a/internal/sftp/service.go b/internal/sftp/service.go index 9eefaed..0a115e4 100644 --- a/internal/sftp/service.go +++ b/internal/sftp/service.go @@ -102,7 +102,7 @@ func (s *Service) ReadFile(connID string, filePath string) (string, error) { if err != nil { return "", fmt.Errorf("读取文件失败: %w", err) } - return string(data), nil + return filesystem.BytesToString(data), nil } func (s *Service) WriteFile(connID string, filePath string, content string) error { diff --git a/internal/storage/models/connection_profile.go b/internal/storage/models/connection_profile.go index 89e087e..84c8c10 100644 --- a/internal/storage/models/connection_profile.go +++ b/internal/storage/models/connection_profile.go @@ -11,8 +11,13 @@ type ConnectionProfile struct { Username string `gorm:"type:varchar(100);default:root" json:"username"` Password string `gorm:"type:text" json:"password"` KeyPath string `gorm:"type:text" json:"key_path"` - Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp + Type string `gorm:"type:varchar(20);not null;index" json:"type"` // local|remote|sftp|qiniu|aliyun Token string `gorm:"type:text" json:"token"` + AccessKey string `gorm:"type:text" json:"access_key"` + SecretKey string `gorm:"type:text" json:"secret_key"` + Bucket string `gorm:"type:varchar(100)" json:"bucket"` + Region string `gorm:"type:varchar(100)" json:"region"` + Endpoint string `gorm:"type:varchar(255)" json:"endpoint"` LastConnected *time.Time `json:"last_connected"` SortOrder int `gorm:"default:0" json:"sort_order"` CreatedAt time.Time `json:"created_at"`