新增: 云OSS存储集成(七牛云+阿里云)+多桶导航+GBK编码自动转换
This commit is contained in:
137
app.go
137
app.go
@@ -18,6 +18,7 @@ import (
|
|||||||
"u-desk/internal/api"
|
"u-desk/internal/api"
|
||||||
"u-desk/internal/common"
|
"u-desk/internal/common"
|
||||||
"u-desk/internal/filesystem"
|
"u-desk/internal/filesystem"
|
||||||
|
osssvc "u-desk/internal/ossdrv"
|
||||||
"u-desk/internal/service"
|
"u-desk/internal/service"
|
||||||
"u-desk/internal/sftp"
|
"u-desk/internal/sftp"
|
||||||
"u-desk/internal/storage"
|
"u-desk/internal/storage"
|
||||||
@@ -38,6 +39,7 @@ type App struct {
|
|||||||
pdfAPI *api.PdfAPI
|
pdfAPI *api.PdfAPI
|
||||||
filesystem *filesystem.FileSystemService
|
filesystem *filesystem.FileSystemService
|
||||||
sftpService *sftp.Service
|
sftpService *sftp.Service
|
||||||
|
ossService *osssvc.Service
|
||||||
profileSvc *service.ProfileService
|
profileSvc *service.ProfileService
|
||||||
isAlwaysOnTop bool
|
isAlwaysOnTop bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -217,6 +219,9 @@ func (a *App) ServiceShutdown() error {
|
|||||||
}
|
}
|
||||||
sftp.CleanupTempFiles()
|
sftp.CleanupTempFiles()
|
||||||
|
|
||||||
|
// 关闭所有 OSS 连接
|
||||||
|
osssvc.GetManager().Shutdown()
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,6 +977,104 @@ func (a *App) SftpGetSystemInfo(connID string) (map[string]interface{}, error) {
|
|||||||
return a.ensureSftpService().GetSystemInfo(connID)
|
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 持久化) ---
|
// --- 连接配置 CRUD (SQLite 持久化) ---
|
||||||
|
|
||||||
type SaveProfileRequest struct {
|
type SaveProfileRequest struct {
|
||||||
@@ -984,6 +1087,11 @@ type SaveProfileRequest struct {
|
|||||||
KeyPath string `json:"key_path"`
|
KeyPath string `json:"key_path"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Token string `json:"token"`
|
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"`
|
LastConnected *int64 `json:"last_connected"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1002,17 +1110,22 @@ func (a *App) LoadConnectionProfiles() ([]map[string]interface{}, error) {
|
|||||||
result := make([]map[string]interface{}, len(list))
|
result := make([]map[string]interface{}, len(list))
|
||||||
for i, p := range list {
|
for i, p := range list {
|
||||||
result[i] = map[string]interface{}{
|
result[i] = map[string]interface{}{
|
||||||
"id": float64(p.ID),
|
"id": float64(p.ID),
|
||||||
"name": p.Name,
|
"name": p.Name,
|
||||||
"host": p.Host,
|
"host": p.Host,
|
||||||
"port": p.Port,
|
"port": p.Port,
|
||||||
"username": p.Username,
|
"username": p.Username,
|
||||||
"password": p.Password,
|
"password": p.Password,
|
||||||
"keyPath": p.KeyPath,
|
"keyPath": p.KeyPath,
|
||||||
"type": p.Type,
|
"type": p.Type,
|
||||||
"token": p.Token,
|
"token": p.Token,
|
||||||
"lastConnected": p.LastConnected,
|
"accessKey": p.AccessKey,
|
||||||
"sortOrder": float64(p.SortOrder),
|
"secretKey": p.SecretKey,
|
||||||
|
"bucket": p.Bucket,
|
||||||
|
"region": p.Region,
|
||||||
|
"endpoint": p.Endpoint,
|
||||||
|
"lastConnected": p.LastConnected,
|
||||||
|
"sortOrder": float64(p.SortOrder),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result, nil
|
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,
|
Name: req.Name, Host: req.Host, Port: req.Port,
|
||||||
Username: req.Username, Password: req.Password,
|
Username: req.Username, Password: req.Password,
|
||||||
KeyPath: req.KeyPath, Type: req.Type, Token: req.Token,
|
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 {
|
if req.LastConnected != nil {
|
||||||
t := time.Unix(*req.LastConnected, 0)
|
t := time.Unix(*req.LastConnected, 0)
|
||||||
|
|||||||
@@ -302,6 +302,115 @@ export function OpenPath(path: string): $CancellablePromise<void> {
|
|||||||
return $Call.ByID(1591734570, path);
|
return $Call.ByID(1591734570, path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function OssConnect(req: $models.OssConnectRequest): $CancellablePromise<string> {
|
||||||
|
return $Call.ByID(3667022538, req);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssCreateDir OSS 创建目录
|
||||||
|
*/
|
||||||
|
export function OssCreateDir(connID: string, dirPath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||||
|
return $Call.ByID(605668951, connID, dirPath).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssCreateFile OSS 创建文件
|
||||||
|
*/
|
||||||
|
export function OssCreateFile(connID: string, filePath: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||||
|
return $Call.ByID(4148593430, connID, filePath).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssDeletePath OSS 删除
|
||||||
|
*/
|
||||||
|
export function OssDeletePath(connID: string, key: string): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||||
|
return $Call.ByID(4285234744, connID, key).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssDisconnect 断开 OSS 连接
|
||||||
|
*/
|
||||||
|
export function OssDisconnect(connID: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(3427288622, connID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssDownloadToTemp OSS 下载到临时文件
|
||||||
|
*/
|
||||||
|
export function OssDownloadToTemp(connID: string, key: string): $CancellablePromise<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
return $Call.ByID(1629576606, connID, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssRenamePath OSS 重命名
|
||||||
|
*/
|
||||||
|
export function OssRenamePath(req: $models.OssRenamePathRequest): $CancellablePromise<filesystem$0.FileOperationResult | null> {
|
||||||
|
return $Call.ByID(4218061693, req).then(($result: any) => {
|
||||||
|
return $$createType2($result);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssWriteBase64File OSS 写入 base64 编码文件
|
||||||
|
*/
|
||||||
|
export function OssWriteBase64File(connID: string, key: string, base64Content: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(1772140162, connID, key, base64Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssWriteFile OSS 写入文件
|
||||||
|
*/
|
||||||
|
export function OssWriteFile(connID: string, key: string, content: string): $CancellablePromise<void> {
|
||||||
|
return $Call.ByID(39773277, connID, key, content);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ReadFile 读取文件
|
* ReadFile 读取文件
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
OssConnectRequest,
|
||||||
|
OssRenamePathRequest,
|
||||||
RenamePathRequest,
|
RenamePathRequest,
|
||||||
SaveAppConfigRequest,
|
SaveAppConfigRequest,
|
||||||
SaveBase64FileRequest,
|
SaveBase64FileRequest,
|
||||||
|
|||||||
@@ -9,6 +9,71 @@ import { Create as $Create } from "@wailsio/runtime";
|
|||||||
// @ts-ignore: Unused imports
|
// @ts-ignore: Unused imports
|
||||||
import * as api$0 from "./internal/api/models.js";
|
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<OssConnectRequest> = {}) {
|
||||||
|
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<OssConnectRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OssRenamePathRequest OSS 重命名请求
|
||||||
|
*/
|
||||||
|
export class OssRenamePathRequest {
|
||||||
|
"conn_id": string;
|
||||||
|
"old_path": string;
|
||||||
|
"new_path": string;
|
||||||
|
|
||||||
|
/** Creates a new OssRenamePathRequest instance. */
|
||||||
|
constructor($$source: Partial<OssRenamePathRequest> = {}) {
|
||||||
|
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<OssRenamePathRequest>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RenamePathRequest 重命名文件或目录请求结构体
|
* RenamePathRequest 重命名文件或目录请求结构体
|
||||||
*/
|
*/
|
||||||
@@ -119,6 +184,11 @@ export class SaveProfileRequest {
|
|||||||
"key_path": string;
|
"key_path": string;
|
||||||
"type": string;
|
"type": string;
|
||||||
"token": string;
|
"token": string;
|
||||||
|
"access_key": string;
|
||||||
|
"secret_key": string;
|
||||||
|
"bucket": string;
|
||||||
|
"region": string;
|
||||||
|
"endpoint": string;
|
||||||
"last_connected": number | null;
|
"last_connected": number | null;
|
||||||
|
|
||||||
/** Creates a new SaveProfileRequest instance. */
|
/** Creates a new SaveProfileRequest instance. */
|
||||||
@@ -150,6 +220,21 @@ export class SaveProfileRequest {
|
|||||||
if (!("token" in $$source)) {
|
if (!("token" in $$source)) {
|
||||||
this["token"] = "";
|
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)) {
|
if (!("last_connected" in $$source)) {
|
||||||
this["last_connected"] = null;
|
this["last_connected"] = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ import type { FsTransport } from './transport'
|
|||||||
import { WailsTransport } from './wails-transport'
|
import { WailsTransport } from './wails-transport'
|
||||||
import { HttpTransport } from './http-transport'
|
import { HttpTransport } from './http-transport'
|
||||||
import { SftpTransport } from './sftp-transport'
|
import { SftpTransport } from './sftp-transport'
|
||||||
|
import { OssTransport } from './oss-transport'
|
||||||
import { getFileServerBaseURL } from './file-server'
|
import { getFileServerBaseURL } from './file-server'
|
||||||
import {
|
import {
|
||||||
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
|
LoadConnectionProfiles, SaveConnectionProfile, DeleteConnectionProfile,
|
||||||
SftpGetSystemInfo, GetLocalSystemInfo,
|
SftpGetSystemInfo, GetLocalSystemInfo,
|
||||||
} from '@bindings/u-desk/app'
|
} from '@bindings/u-desk/app'
|
||||||
|
|
||||||
export type ConnectionType = 'local' | 'remote' | 'sftp'
|
export type ConnectionType = 'local' | 'remote' | 'sftp' | 'qiniu' | 'aliyun'
|
||||||
|
|
||||||
export interface ConnectionProfile {
|
export interface ConnectionProfile {
|
||||||
id: string | number
|
id: string | number
|
||||||
@@ -25,6 +26,11 @@ export interface ConnectionProfile {
|
|||||||
username?: string
|
username?: string
|
||||||
password?: string
|
password?: string
|
||||||
keyPath?: string
|
keyPath?: string
|
||||||
|
accessKey?: string
|
||||||
|
secretKey?: string
|
||||||
|
bucket?: string
|
||||||
|
region?: string
|
||||||
|
endpoint?: string
|
||||||
lastConnected?: number
|
lastConnected?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,6 +130,11 @@ class ConnectionManagerImpl {
|
|||||||
keyPath: profile.keyPath || '',
|
keyPath: profile.keyPath || '',
|
||||||
type: profile.type,
|
type: profile.type,
|
||||||
token: profile.token || '',
|
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,
|
lastConnected: profile.lastConnected ? Math.floor(profile.lastConnected / 1000) : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -189,7 +200,7 @@ class ConnectionManagerImpl {
|
|||||||
|
|
||||||
isRemote(): boolean {
|
isRemote(): boolean {
|
||||||
const t = this.activeProfile?.type
|
const t = this.activeProfile?.type
|
||||||
return t === 'remote' || t === 'sftp'
|
return t === 'remote' || t === 'sftp' || t === 'qiniu' || t === 'aliyun'
|
||||||
}
|
}
|
||||||
|
|
||||||
getSystemInfo(profileId: string): SystemInfo | undefined {
|
getSystemInfo(profileId: string): SystemInfo | undefined {
|
||||||
@@ -210,6 +221,11 @@ class ConnectionManagerImpl {
|
|||||||
const data = await SftpGetSystemInfo(t.sessionId)
|
const data = await SftpGetSystemInfo(t.sessionId)
|
||||||
if (data) Object.assign(info, snakeToCamel(data))
|
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') {
|
} else if (profile.type === 'remote') {
|
||||||
const t = this.getTransportFor(profileId)
|
const t = this.getTransportFor(profileId)
|
||||||
if (t instanceof HttpTransport) {
|
if (t instanceof HttpTransport) {
|
||||||
@@ -243,16 +259,16 @@ class ConnectionManagerImpl {
|
|||||||
const profile = this._profiles.find(p => p.id === profileId)
|
const profile = this._profiles.find(p => p.id === profileId)
|
||||||
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
|
if (!profile) return Promise.reject(new Error(`连接配置不存在: ${profileId}`))
|
||||||
|
|
||||||
this._activeId = profileId
|
|
||||||
|
|
||||||
// 池中已有,直接复用
|
// 池中已有,直接复用
|
||||||
if (this._pool.has(profileId)) {
|
if (this._pool.has(profileId)) {
|
||||||
|
this._activeId = profileId
|
||||||
this.setState('connected')
|
this.setState('connected')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新建连接并入池
|
// 新建连接并入池(成功后再设 activeId)
|
||||||
await this.buildAndPool(profileId, profile)
|
await this.buildAndPool(profileId, profile)
|
||||||
|
this._activeId = profileId
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 断开指定 profile 并从池移除 */
|
/** 断开指定 profile 并从池移除 */
|
||||||
@@ -260,14 +276,15 @@ class ConnectionManagerImpl {
|
|||||||
if (profileId === 'local-default') return
|
if (profileId === 'local-default') return
|
||||||
const t = this._pool.get(profileId)
|
const t = this._pool.get(profileId)
|
||||||
if (t instanceof SftpTransport) { t.disconnect() }
|
if (t instanceof SftpTransport) { t.disconnect() }
|
||||||
|
if (t instanceof OssTransport) { t.disconnect() }
|
||||||
this._pool.delete(profileId)
|
this._pool.delete(profileId)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 断开所有远程连接(保留 local) */
|
/** 断开所有远程连接(保留 local) */
|
||||||
disconnectAll(): void {
|
disconnectAll(): void {
|
||||||
for (const [id, t] of this._pool) {
|
for (const [id, t] of this._pool) {
|
||||||
if (id !== 'local-default' && t instanceof SftpTransport) {
|
if (id !== 'local-default') {
|
||||||
t.disconnect()
|
if (t instanceof SftpTransport || t instanceof OssTransport) t.disconnect()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._pool.clear()
|
this._pool.clear()
|
||||||
@@ -294,7 +311,7 @@ class ConnectionManagerImpl {
|
|||||||
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
this._profiles[idx] = { ...this._profiles[idx], ...updates }
|
||||||
await this.persistProfile(this._profiles[idx])
|
await this.persistProfile(this._profiles[idx])
|
||||||
// 连接参数变更 → 淘汰旧连接,下次 connect 时重建
|
// 连接参数变更 → 淘汰旧连接,下次 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)) {
|
if (EVICT_KEYS.some(k => k in updates)) {
|
||||||
this.disconnectProfile(id)
|
this.disconnectProfile(id)
|
||||||
if (id === this._activeId) {
|
if (id === this._activeId) {
|
||||||
@@ -346,6 +363,23 @@ class ConnectionManagerImpl {
|
|||||||
return
|
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
|
// remote / http agent
|
||||||
this.setState('connecting')
|
this.setState('connecting')
|
||||||
try {
|
try {
|
||||||
|
|||||||
194
frontend/src/api/oss-transport.ts
Normal file
194
frontend/src/api/oss-transport.ts
Normal file
@@ -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<string, string>()
|
||||||
|
private previewOrder: string[] = []
|
||||||
|
|
||||||
|
constructor(profile: ConnectionProfile) {
|
||||||
|
this.profile = profile
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(): Promise<string> {
|
||||||
|
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<void> {
|
||||||
|
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<FileItem[]> {
|
||||||
|
return transformFileList(await OssListDir(this.requireConn(), path))
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileInfo(path: string): Promise<Record<string, any>> {
|
||||||
|
return OssGetFileInfo(this.requireConn(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 文件读写 ======
|
||||||
|
|
||||||
|
async readFile(path: string): Promise<string> {
|
||||||
|
return OssReadFile(this.requireConn(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeFile(path: string, content: string): Promise<void> {
|
||||||
|
await OssWriteFile(this.requireConn(), path, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveBase64File(path: string, content: string): Promise<void> {
|
||||||
|
if (!content) throw new Error('无效的 base64 内容')
|
||||||
|
await OssWriteBase64File(this.requireConn(), path, content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 文件操作 ======
|
||||||
|
|
||||||
|
async createFile(dirPath: string, filename: string): Promise<FileOperationResult> {
|
||||||
|
const fullPath = dirPath.replace(/\/$/, '') + '/' + filename
|
||||||
|
return OssCreateFile(this.requireConn(), fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDir(parentPath: string, dirname: string): Promise<FileOperationResult> {
|
||||||
|
const fullPath = parentPath.replace(/\/$/, '') + '/' + dirname
|
||||||
|
return OssCreateDir(this.requireConn(), fullPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePath(path: string): Promise<FileOperationResult> {
|
||||||
|
return OssDeletePath(this.requireConn(), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async renamePath(oldPath: string, newPath: string): Promise<FileOperationResult> {
|
||||||
|
return OssRenamePath({
|
||||||
|
conn_id: this.requireConn(),
|
||||||
|
old_path: oldPath,
|
||||||
|
new_path: newPath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== ZIP 操作(不支持)======
|
||||||
|
|
||||||
|
async listZipContents(_zipPath: string): Promise<FileItem[]> {
|
||||||
|
throw new Error('ZIP 操作在 OSS 模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractFileFromZip(_zipPath: string, _filePath: string): Promise<string> {
|
||||||
|
throw new Error('ZIP 操作在 OSS 模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async extractFileFromZipToTemp(_zipPath: string, _filePath: string): Promise<string> {
|
||||||
|
throw new Error('ZIP 操作在 OSS 模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getZipFileInfo(_zipPath: string, _filePath: string): Promise<FileItem> {
|
||||||
|
throw new Error('ZIP 操作在 OSS 模式暂未实现')
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 系统操作 ======
|
||||||
|
|
||||||
|
async openPath(_path: string): Promise<void> {
|
||||||
|
throw new Error('OSS 模式不支持打开本地路径')
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFileServerURL(): Promise<string> {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
getPreviewToken(): string {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async resolveShortcut(_lnkPath: string): Promise<any> {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
async detectFileTypeByContent(_path: string): Promise<DetectTypeResult> {
|
||||||
|
return { extension: '', category: 'unknown', mime_type: '', confidence: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCommonPaths(): Promise<Record<string, string>> {
|
||||||
|
return OssGetCommonPaths(this.requireConn())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====== 回收站(无)======
|
||||||
|
|
||||||
|
async getRecycleBinEntries(): Promise<any[]> {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
async restoreFromRecycleBin(_path: string): Promise<void> {}
|
||||||
|
|
||||||
|
async deletePermanently(_path: string): Promise<void> {}
|
||||||
|
|
||||||
|
async emptyRecycleBin(): Promise<void> {}
|
||||||
|
|
||||||
|
// ====== 预览辅助 ======
|
||||||
|
|
||||||
|
async downloadForPreview(remotePath: string): Promise<string> {
|
||||||
|
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<string> {
|
||||||
|
return OssGetSignedURL(this.requireConn(), key)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,18 @@
|
|||||||
<!-- 连接类型 -->
|
<!-- 连接类型 -->
|
||||||
<div style="display: flex; align-items: center; gap: 8px">
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">类型</label>
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">类型</label>
|
||||||
<a-radio-group v-model="form.type" type="button" size="small">
|
<a-radio-group v-model="category" type="button" size="small">
|
||||||
<a-radio value="sftp">SFTP</a-radio>
|
<a-radio value="sftp">SFTP</a-radio>
|
||||||
<a-radio value="remote">HTTP Agent</a-radio>
|
<a-radio value="remote">HTTP Agent</a-radio>
|
||||||
|
<a-radio value="oss">云OSS</a-radio>
|
||||||
|
</a-radio-group>
|
||||||
|
</div>
|
||||||
|
<!-- OSS 厂商(表单行,仅在选中云OSS时显示) -->
|
||||||
|
<div v-if="category === 'oss'" style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">厂商</label>
|
||||||
|
<a-radio-group v-model="form.type" type="button" size="small">
|
||||||
|
<a-radio value="qiniu">七牛云</a-radio>
|
||||||
|
<a-radio value="aliyun">阿里云</a-radio>
|
||||||
</a-radio-group>
|
</a-radio-group>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -16,13 +25,26 @@
|
|||||||
<a-input v-model="form.name" placeholder="如:生产服务器" style="flex: 1" />
|
<a-input v-model="form.name" placeholder="如:生产服务器" style="flex: 1" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 地址 -->
|
<!-- 地址(SFTP / HTTP Agent) -->
|
||||||
<div style="display: flex; align-items: center; gap: 8px">
|
<div v-if="form.type === 'sftp' || form.type === 'remote'" style="display: flex; align-items: center; gap: 8px">
|
||||||
<label style="font-size: 13px; width: 36px; flex-shrink: 0">地址</label>
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">地址</label>
|
||||||
<a-input v-model="form.host" :placeholder="form.type === 'sftp' ? '192.168.1.100' : '192.168.1.100'" style="flex: 1" />
|
<a-input v-model="form.host" :placeholder="form.type === 'sftp' ? '192.168.1.100' : '192.168.1.100'" style="flex: 1" />
|
||||||
<a-input-number v-model="form.port" :min="1" :max="65535" :placeholder="form.type === 'sftp' ? '22' : '9876'" style="width: 90px" hide-button />
|
<a-input-number v-model="form.port" :min="1" :max="65535" :placeholder="form.type === 'sftp' ? '22' : '9876'" style="width: 90px" hide-button />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- OSS 认证字段 -->
|
||||||
|
<template v-if="form.type === 'qiniu' || form.type === 'aliyun'">
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">AK</label>
|
||||||
|
<a-input v-model="form.accessKey" placeholder="AccessKey" style="flex: 1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
|
<label style="font-size: 13px; width: 36px; flex-shrink: 0">SK</label>
|
||||||
|
<a-input v-model="form.secretKey" type="password" placeholder="SecretKey" allow-clear style="flex: 1" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- SFTP 认证字段 -->
|
<!-- SFTP 认证字段 -->
|
||||||
<template v-if="form.type === 'sftp'">
|
<template v-if="form.type === 'sftp'">
|
||||||
<div style="display: flex; align-items: center; gap: 8px">
|
<div style="display: flex; align-items: center; gap: 8px">
|
||||||
@@ -66,7 +88,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref, watch, onMounted } from 'vue'
|
import { reactive, ref, computed, watch, onMounted } from 'vue'
|
||||||
import { Message } from '@arco-design/web-vue'
|
import { Message } from '@arco-design/web-vue'
|
||||||
import { Dialogs } from '@wailsio/runtime'
|
import { Dialogs } from '@wailsio/runtime'
|
||||||
import { GetEnvVars } from '@bindings/u-desk/app'
|
import { GetEnvVars } from '@bindings/u-desk/app'
|
||||||
@@ -94,6 +116,22 @@ const form = reactive({
|
|||||||
username: 'root',
|
username: 'root',
|
||||||
password: '',
|
password: '',
|
||||||
keyPath: '',
|
keyPath: '',
|
||||||
|
accessKey: '',
|
||||||
|
secretKey: '',
|
||||||
|
bucket: '',
|
||||||
|
region: '',
|
||||||
|
endpoint: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const category = computed({
|
||||||
|
get: () => {
|
||||||
|
if (form.type === 'qiniu' || form.type === 'aliyun') return 'oss'
|
||||||
|
return form.type
|
||||||
|
},
|
||||||
|
set: (v: string) => {
|
||||||
|
if (v === 'oss') form.type = 'qiniu'
|
||||||
|
else form.type = v as ConnectionType
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.visible, (val) => {
|
watch(() => props.visible, (val) => {
|
||||||
@@ -103,16 +141,25 @@ watch(() => props.visible, (val) => {
|
|||||||
name: '', host: '', port: 22, token: '',
|
name: '', host: '', port: 22, token: '',
|
||||||
type: 'sftp' as ConnectionType,
|
type: 'sftp' as ConnectionType,
|
||||||
username: 'root', password: '', keyPath: '',
|
username: 'root', password: '', keyPath: '',
|
||||||
|
accessKey: '', secretKey: '', bucket: '', region: '', endpoint: '',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => form.type, (t) => {
|
watch(() => form.type, (t) => {
|
||||||
form.port = t === 'sftp' ? 22 : 9876
|
if (t === 'sftp') form.port = 22
|
||||||
|
else if (t === 'remote') form.port = 9876
|
||||||
|
else form.port = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
async function handleOk(): Promise<boolean> {
|
async function handleOk(): Promise<boolean> {
|
||||||
if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
|
if (!form.name?.trim()) { Message.warning('请输入名称'); return false }
|
||||||
if (!form.host?.trim()) { Message.warning('请输入地址'); return false }
|
const isOss = form.type === 'qiniu' || form.type === 'aliyun'
|
||||||
|
if (isOss) {
|
||||||
|
if (!form.accessKey?.trim()) { Message.warning('请输入 AccessKey'); return false }
|
||||||
|
if (!form.secretKey?.trim()) { Message.warning('请输入 SecretKey'); return false }
|
||||||
|
} else {
|
||||||
|
if (!form.host?.trim()) { Message.warning('请输入地址'); return false }
|
||||||
|
}
|
||||||
|
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
@@ -156,6 +203,11 @@ function editProfile(id: string) {
|
|||||||
username: profile.username || 'root',
|
username: profile.username || 'root',
|
||||||
password: profile.password || '',
|
password: profile.password || '',
|
||||||
keyPath: profile.keyPath || '',
|
keyPath: profile.keyPath || '',
|
||||||
|
accessKey: profile.accessKey || '',
|
||||||
|
secretKey: profile.secretKey || '',
|
||||||
|
bucket: profile.bucket || '',
|
||||||
|
region: profile.region || '',
|
||||||
|
endpoint: profile.endpoint || '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ function handleDelete(p: { id: string; name: string }) {
|
|||||||
function dotClass(p: { type: string }): string {
|
function dotClass(p: { type: string }): string {
|
||||||
if (p.type === 'sftp') return 'sftp'
|
if (p.type === 'sftp') return 'sftp'
|
||||||
if (p.type === 'remote') return 'remote'
|
if (p.type === 'remote') return 'remote'
|
||||||
|
if (p.type === 'qiniu' || p.type === 'aliyun') return 'oss'
|
||||||
return 'local'
|
return 'local'
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -175,6 +176,7 @@ function dotClass(p: { type: string }): string {
|
|||||||
.dot.local { background: var(--color-text-3); }
|
.dot.local { background: var(--color-text-3); }
|
||||||
.dot.remote { background: var(--color-primary-6); }
|
.dot.remote { background: var(--color-primary-6); }
|
||||||
.dot.sftp { background: #7c3aed; }
|
.dot.sftp { background: #7c3aed; }
|
||||||
|
.dot.oss { background: #ff7d00; }
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
max-width: 70px;
|
max-width: 70px;
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
|
|
||||||
<!-- 视频预览 -->
|
<!-- 视频预览 -->
|
||||||
<div v-else-if="config.isVideoView" class="media-preview">
|
<div v-else-if="config.isVideoView" class="media-preview">
|
||||||
<video :src="config.previewUrl" controls class="preview-video" @error="handleMediaError('视频')"></video>
|
<video :src="config.previewUrl" controls class="preview-video" @error="handleMediaError('视频')" @canplay="mediaErrorMsg = ''"></video>
|
||||||
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<a-tag color="arcoblue">🎬 视频</a-tag>
|
<a-tag color="arcoblue">🎬 视频</a-tag>
|
||||||
@@ -83,7 +83,7 @@
|
|||||||
|
|
||||||
<!-- 音频预览 -->
|
<!-- 音频预览 -->
|
||||||
<div v-else-if="config.isAudioView" class="media-preview">
|
<div v-else-if="config.isAudioView" class="media-preview">
|
||||||
<audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')"></audio>
|
<audio :src="config.previewUrl" controls class="preview-audio" @error="handleMediaError('音频')" @canplay="mediaErrorMsg = ''"></audio>
|
||||||
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
<div v-if="mediaErrorMsg" class="media-error">{{ mediaErrorMsg }}</div>
|
||||||
<div class="media-meta">
|
<div class="media-meta">
|
||||||
<a-tag color="green">🎵 音频</a-tag>
|
<a-tag color="green">🎵 音频</a-tag>
|
||||||
@@ -566,6 +566,7 @@ const handleImageError = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaErrorMsg = ref('')
|
const mediaErrorMsg = ref('')
|
||||||
|
watch(() => props.config.previewUrl, () => { mediaErrorMsg.value = '' })
|
||||||
const handleMediaError = (type: string) => {
|
const handleMediaError = (type: string) => {
|
||||||
mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限`
|
mediaErrorMsg.value = `${type}文件加载失败,请检查网络连接或文件权限`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { normalizeFilePath, getExt } from '@/utils/fileUtils'
|
|||||||
import { detectFileTypeByContent } from '@/api/system'
|
import { detectFileTypeByContent } from '@/api/system'
|
||||||
import { connectionManager } from '@/api/connection-manager'
|
import { connectionManager } from '@/api/connection-manager'
|
||||||
import { SftpTransport } from '@/api/sftp-transport'
|
import { SftpTransport } from '@/api/sftp-transport'
|
||||||
|
import { OssTransport } from '@/api/oss-transport'
|
||||||
import { getFileServerBaseURL } from '@/api/file-server'
|
import { getFileServerBaseURL } from '@/api/file-server'
|
||||||
import {
|
import {
|
||||||
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
isImageFile, isVideoFile, isAudioFile, isPdfFile,
|
||||||
@@ -106,22 +107,25 @@ export function useFilePreview(options: UseFilePreviewOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新预览 URL(SFTP 模式会先下载到本地临时目录)
|
* 更新预览 URL
|
||||||
|
* SFTP/OSS:下载到本地临时目录后用本地文件服务器预览
|
||||||
*/
|
*/
|
||||||
const updatePreviewUrl = async (path: string) => {
|
const updatePreviewUrl = async (path: string) => {
|
||||||
if (!path) { previewUrl.value = ''; return }
|
if (!path) { previewUrl.value = ''; return }
|
||||||
|
const transport = connectionManager.getTransport()
|
||||||
|
|
||||||
// SFTP 模式:下载到本地临时目录后用本地文件服务器预览
|
// SFTP / OSS:下载到本地临时目录后用本地文件服务器预览
|
||||||
if (connectionManager.isSftp()) {
|
if (transport instanceof SftpTransport || transport instanceof OssTransport) {
|
||||||
const transport = connectionManager.getTransport()
|
try {
|
||||||
if (transport instanceof SftpTransport) {
|
const tempPath = await transport.downloadForPreview(path)
|
||||||
try {
|
// 临时文件通过本地文件服务器提供,始终用 localfs 路径
|
||||||
const tempPath = await transport.downloadForPreview(path)
|
const base = getLocalServerURL()
|
||||||
previewUrl.value = getPreviewUrl(tempPath)
|
const normalized = normalizeFilePath(tempPath, true)
|
||||||
return
|
const sep = base.endsWith('/') ? '' : '/'
|
||||||
} catch {
|
previewUrl.value = `${base}${sep}localfs/${normalized}`
|
||||||
// 下载失败,回退显示原始路径(会无法预览但不会崩溃)
|
return
|
||||||
}
|
} catch {
|
||||||
|
// 下载失败,回退
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1371,7 +1371,9 @@ onMounted(async () => {
|
|||||||
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
// 初始化加载:远程模式强制用根路径,避免 localStorage 残留 Windows 路径
|
||||||
const startPath = connectionManager.isRemote() ? '/'
|
const startPath = connectionManager.isRemote() ? '/'
|
||||||
: (commonPaths.value.length > 0 ? commonPaths.value[0].path : 'C:/')
|
: (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)
|
await loadDirectory(filePath.value)
|
||||||
} else {
|
} else {
|
||||||
filePath.value = startPath
|
filePath.value = startPath
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ export const useConnectionStore = defineStore('connection', () => {
|
|||||||
const isRemote = computed(() => connectionManager.isRemote())
|
const isRemote = computed(() => connectionManager.isRemote())
|
||||||
const fileServerBaseURL = computed(() => connectionManager.getFileServerBaseURL())
|
const fileServerBaseURL = computed(() => connectionManager.getFileServerBaseURL())
|
||||||
|
|
||||||
function connect(id: string) {
|
async function connect(id: string) {
|
||||||
connectionManager.connect(id)
|
try {
|
||||||
|
await connectionManager.connect(id)
|
||||||
|
} catch {
|
||||||
|
// 连接失败,回退到本地
|
||||||
|
connectionManager.disconnect()
|
||||||
|
}
|
||||||
activeProfile.value = connectionManager.activeProfile
|
activeProfile.value = connectionManager.activeProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,9 @@ export const FILE_ICONS = {
|
|||||||
// 文件夹
|
// 文件夹
|
||||||
FOLDER: '📁',
|
FOLDER: '📁',
|
||||||
|
|
||||||
|
// OSS 桶
|
||||||
|
BUCKET: '🪣',
|
||||||
|
|
||||||
// 默认文件
|
// 默认文件
|
||||||
FILE: '📄',
|
FILE: '📄',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ export function getFileName(path) {
|
|||||||
export function getFileIcon(fileInfo) {
|
export function getFileIcon(fileInfo) {
|
||||||
if (!fileInfo) return FILE_ICONS.FILE
|
if (!fileInfo) return FILE_ICONS.FILE
|
||||||
|
|
||||||
|
// OSS 桶
|
||||||
|
if (fileInfo.is_bucket) {
|
||||||
|
return FILE_ICONS.BUCKET
|
||||||
|
}
|
||||||
|
|
||||||
// 如果是目录
|
// 如果是目录
|
||||||
if (fileInfo.is_dir) {
|
if (fileInfo.is_dir) {
|
||||||
return FILE_ICONS.FOLDER
|
return FILE_ICONS.FOLDER
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -13,6 +13,7 @@ require (
|
|||||||
github.com/yuin/goldmark v1.7.16
|
github.com/yuin/goldmark v1.7.16
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
|
golang.org/x/text v0.33.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/gorm v1.31.1
|
gorm.io/gorm v1.31.1
|
||||||
)
|
)
|
||||||
@@ -75,7 +76,6 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
|
||||||
golang.org/x/net v0.49.0 // 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
|
golang.org/x/time v0.14.0 // indirect
|
||||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
modernc.org/libc v1.67.6 // indirect
|
||||||
|
|||||||
@@ -218,9 +218,10 @@ func handleLocalFileRequest(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
log.Printf("[LocalFileHandler] 最终路径: %s", filePath)
|
||||||
|
|
||||||
// 🔒 文件类型白名单检查
|
// 🔒 文件类型白名单检查(临时目录文件放行,用于 OSS/SFTP 预览)
|
||||||
ext := strings.ToLower(filepath.Ext(filePath))
|
ext := strings.ToLower(filepath.Ext(filePath))
|
||||||
if !isAllowedFileType(ext) {
|
isTemp := strings.HasPrefix(filePath, os.TempDir())
|
||||||
|
if !isTemp && !isAllowedFileType(ext) {
|
||||||
log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext)
|
log.Printf("[LocalFileHandler] 不允许的文件类型: %s", ext)
|
||||||
http.Error(w, fmt.Sprintf("Forbidden file type: %s", ext), http.StatusForbidden)
|
http.Error(w, fmt.Sprintf("Forbidden file type: %s", ext), http.StatusForbidden)
|
||||||
return
|
return
|
||||||
|
|||||||
24
internal/filesystem/encoding.go
Normal file
24
internal/filesystem/encoding.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -142,7 +142,7 @@ func (s *FileSystemService) ReadFile(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s.logRead(path, int64(len(data)), nil)
|
s.logRead(path, int64(len(data)), nil)
|
||||||
return string(data), nil
|
return BytesToString(data), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write 写入文件内容(实现 FileService 接口)
|
// Write 写入文件内容(实现 FileService 接口)
|
||||||
|
|||||||
73
internal/oss/aliyun/bucket.go
Normal file
73
internal/oss/aliyun/bucket.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
572
internal/oss/aliyun/client.go
Normal file
572
internal/oss/aliyun/client.go
Normal file
@@ -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: 每个 <Contents> 直接包含 Key/Size 等字段,无 <Object> 包裹
|
||||||
|
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"`
|
||||||
|
}
|
||||||
521
internal/oss/aliyun/lifecycle.go
Normal file
521
internal/oss/aliyun/lifecycle.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
584
internal/oss/aliyun/multipart.go
Normal file
584
internal/oss/aliyun/multipart.go
Normal file
@@ -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 格式:
|
||||||
|
// <?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
// <CompleteMultipartUpload>
|
||||||
|
// <Part>
|
||||||
|
// <PartNumber>1</PartNumber>
|
||||||
|
// <ETag>"etag"</ETag> <!-- 注意:ETag 需要带引号 -->
|
||||||
|
// </Part>
|
||||||
|
// ...
|
||||||
|
// </CompleteMultipartUpload>
|
||||||
|
var xmlBuilder strings.Builder
|
||||||
|
xmlBuilder.WriteString("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n")
|
||||||
|
xmlBuilder.WriteString("<CompleteMultipartUpload>\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(" <Part>\n <PartNumber>%d</PartNumber>\n <ETag>%s</ETag>\n </Part>\n",
|
||||||
|
part.PartNumber, etag))
|
||||||
|
}
|
||||||
|
xmlBuilder.WriteString("</CompleteMultipartUpload>")
|
||||||
|
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)
|
||||||
|
}
|
||||||
42
internal/oss/errors.go
Normal file
42
internal/oss/errors.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/oss/interface.go
Normal file
43
internal/oss/interface.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
299
internal/oss/qiniu/bucket.go
Normal file
299
internal/oss/qiniu/bucket.go
Normal file
@@ -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: 错误信息
|
||||||
|
//
|
||||||
|
// 注意:
|
||||||
|
// - 返回的域名包括七牛云提供的默认域名和用户绑定的自定义域名
|
||||||
|
// - 默认域名格式: <bucket>.<region>.qiniudns.com 或 <bucket>.<region>.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=<bucketName>
|
||||||
|
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=<bucketName>&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))
|
||||||
|
}
|
||||||
570
internal/oss/qiniu/client.go
Normal file
570
internal/oss/qiniu/client.go
Normal file
@@ -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-<region>.qiniup.com 或 upload-<region>.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
|
||||||
|
}
|
||||||
235
internal/oss/qiniu/lifecycle.go
Normal file
235
internal/oss/qiniu/lifecycle.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
427
internal/oss/qiniu/multipart_v2.go
Normal file
427
internal/oss/qiniu/multipart_v2.go
Normal file
@@ -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/<BucketName>/objects/<EncodedObjectName>/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/<BucketName>/objects/<EncodedObjectName>/uploads/<UploadId>/<PartNumber>
|
||||||
|
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/<BucketName>/objects/<EncodedObjectName>/uploads/<UploadId>
|
||||||
|
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/<BucketName>/objects/<EncodedObjectName>/uploads/<UploadId>
|
||||||
|
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/<BucketName>/objects/<EncodedObjectName>/uploads/<UploadId>?partNumberMarker=<Marker>&maxParts=<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)
|
||||||
|
}
|
||||||
235
internal/oss/qiniu/upload.go
Normal file
235
internal/oss/qiniu/upload.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
60
internal/oss/types.go
Normal file
60
internal/oss/types.go
Normal file
@@ -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 // 失败的文件
|
||||||
|
}
|
||||||
652
internal/ossdrv/service.go
Normal file
652
internal/ossdrv/service.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
221
internal/ossdrv/service_test.go
Normal file
221
internal/ossdrv/service_test.go
Normal file
@@ -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("账户凭据未被清除")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,7 +102,7 @@ func (s *Service) ReadFile(connID string, filePath string) (string, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("读取文件失败: %w", err)
|
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 {
|
func (s *Service) WriteFile(connID string, filePath string, content string) error {
|
||||||
|
|||||||
@@ -11,8 +11,13 @@ type ConnectionProfile struct {
|
|||||||
Username string `gorm:"type:varchar(100);default:root" json:"username"`
|
Username string `gorm:"type:varchar(100);default:root" json:"username"`
|
||||||
Password string `gorm:"type:text" json:"password"`
|
Password string `gorm:"type:text" json:"password"`
|
||||||
KeyPath string `gorm:"type:text" json:"key_path"`
|
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"`
|
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"`
|
LastConnected *time.Time `json:"last_connected"`
|
||||||
SortOrder int `gorm:"default:0" json:"sort_order"`
|
SortOrder int `gorm:"default:0" json:"sort_order"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|||||||
Reference in New Issue
Block a user