From 306a1bb179d9d3ea6ef1c3ce62408e0619acebd7 Mon Sep 17 00:00:00 2001 From: sa0ChunLuyu Date: Sun, 2 Aug 2026 11:03:42 +0800 Subject: [PATCH] go upload --- Laravel/.env.example | 3 + Laravel/app/Filesystem/HttpGoAdapter.php | 388 ++++++++++++++++++ .../Http/Controllers/API/UpLoadController.php | 39 ++ Laravel/app/Providers/AppServiceProvider.php | 10 +- Laravel/config/filesystems.php | 10 +- Laravel/routes/api.php | 3 + Laravel/routes/web.php | 17 +- upload/config.yaml.example | 7 +- upload/config/config.go | 21 +- upload/router/router.go | 191 ++++++++- 10 files changed, 676 insertions(+), 13 deletions(-) create mode 100644 Laravel/app/Filesystem/HttpGoAdapter.php diff --git a/Laravel/.env.example b/Laravel/.env.example index b7becba..cfd0621 100644 --- a/Laravel/.env.example +++ b/Laravel/.env.example @@ -22,6 +22,9 @@ QUEUE_CONNECTION=sync SESSION_DRIVER=file SESSION_LIFETIME=120 +# Go 文件上传服务地址 +GO_UPLOAD_SERVICE=http://127.0.0.1:8852 + MEMCACHED_HOST=127.0.0.1 REDIS_HOST=127.0.0.1 diff --git a/Laravel/app/Filesystem/HttpGoAdapter.php b/Laravel/app/Filesystem/HttpGoAdapter.php new file mode 100644 index 0000000..9ef9c0c --- /dev/null +++ b/Laravel/app/Filesystem/HttpGoAdapter.php @@ -0,0 +1,388 @@ +baseUri = rtrim($baseUri, '/'); + $this->client = new Client([ + 'base_uri' => $this->baseUri . '/', + 'timeout' => 30, + 'http_errors' => false, + ]); + } + + /** + * 写入文件(base64 方式) + */ + public function write($path, $contents, Config $config): array|false + { + $fullPath = $this->applyPathPrefix($path); + $dir = dirname($fullPath); + $filename = basename($fullPath); + + try { + $response = $this->client->post('api/upload/base64', [ + 'json' => [ + 'file' => base64_encode($contents), + 'path' => $dir, + 'filename' => $filename, + ], + ]); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $body = json_decode($response->getBody(), true); + if (($body['code'] ?? 0) !== 200) { + return false; + } + + return $this->normalizeMetadata($fullPath, $config); + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 写入文件流(multipart 方式) + */ + public function writeStream($path, $resource, Config $config): array|false + { + $fullPath = $this->applyPathPrefix($path); + $dir = dirname($fullPath); + $filename = basename($fullPath); + + // 将流转换为临时文件 + $tmpFile = tmpfile(); + stream_copy_to_stream($resource, $tmpFile); + $meta = stream_get_meta_data($tmpFile); + + try { + $response = $this->client->post('api/upload/file', [ + 'multipart' => [ + [ + 'name' => 'file', + 'contents' => fopen($meta['uri'], 'r'), + 'filename' => $filename, + ], + [ + 'name' => 'path', + 'contents' => $dir, + ], + ], + ]); + + fclose($tmpFile); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $body = json_decode($response->getBody(), true); + if (($body['code'] ?? 0) !== 200) { + return false; + } + + return $this->normalizeMetadata($fullPath, $config); + } catch (GuzzleException $e) { + fclose($tmpFile); + return false; + } + } + + /** + * 更新文件(委托给 write) + */ + public function update($path, $contents, Config $config): array|false + { + return $this->write($path, $contents, $config); + } + + /** + * 更新文件流(委托给 writeStream) + */ + public function updateStream($path, $resource, Config $config): array|false + { + return $this->writeStream($path, $resource, $config); + } + + /** + * 读取文件内容 + */ + public function read($path): array|false + { + $fullPath = $this->applyPathPrefix($path); + + try { + $response = $this->client->get('files/' . ltrim($fullPath, '/')); + + if ($response->getStatusCode() !== 200) { + return false; + } + + return ['contents' => $response->getBody()->getContents()]; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 读取文件流 + */ + public function readStream($path): array|false + { + $fullPath = $this->applyPathPrefix($path); + + try { + $response = $this->client->get('files/' . ltrim($fullPath, '/'), [ + 'stream' => true, + ]); + + if ($response->getStatusCode() !== 200) { + return false; + } + + // 返回一个临时流 + $tmpFile = tmpfile(); + $stream = $response->getBody()->detach(); + if ($stream === null) { + fclose($tmpFile); + return false; + } + stream_copy_to_stream($stream, $tmpFile); + fclose($stream); + rewind($tmpFile); + + return ['stream' => $tmpFile]; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 检查文件是否存在 + */ + public function has($path): bool + { + $fullPath = $this->applyPathPrefix($path); + + try { + $response = $this->client->head('files/' . ltrim($fullPath, '/')); + return $response->getStatusCode() === 200; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 删除文件 + */ + public function delete($path): bool + { + $fullPath = $this->applyPathPrefix($path); + + try { + $response = $this->client->delete('files/' . ltrim($fullPath, '/')); + return $response->getStatusCode() === 200; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 删除目录 + */ + public function deleteDir($dirname): bool + { + $fullPath = $this->applyPathPrefix($dirname); + + try { + $response = $this->client->delete('files/' . ltrim($fullPath, '/')); + return $response->getStatusCode() === 200; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 创建目录(Go 服务隐式创建,直接返回成功) + */ + public function createDir($dirname, Config $config): array|false + { + return ['path' => $dirname, 'type' => 'dir']; + } + + /** + * 设置可见性(始终 public) + */ + public function setVisibility($path, $visibility): array|false + { + return ['visibility' => $visibility, 'path' => $path]; + } + + /** + * 获取可见性 + */ + public function getVisibility($path): array|false + { + return ['visibility' => 'public', 'path' => $path]; + } + + /** + * 获取 MIME 类型 + */ + public function getMimetype($path): array|false + { + $fullPath = $this->applyPathPrefix($path); + + try { + $response = $this->client->head('files/' . ltrim($fullPath, '/')); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $contentType = $response->getHeader('Content-Type')[0] ?? 'application/octet-stream'; + return ['mimetype' => $contentType]; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 获取文件大小 + */ + public function getSize($path): array|false + { + $fullPath = $this->applyPathPrefix($path); + + try { + $response = $this->client->head('files/' . ltrim($fullPath, '/')); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $size = (int)($response->getHeader('Content-Length')[0] ?? 0); + return ['size' => $size]; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 获取文件修改时间 + */ + public function getTimestamp($path): array|false + { + $fullPath = $this->applyPathPrefix($path); + + try { + $response = $this->client->head('files/' . ltrim($fullPath, '/')); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $lastModified = $response->getHeader('Last-Modified')[0] ?? ''; + $timestamp = $lastModified ? strtotime($lastModified) : time(); + return ['timestamp' => $timestamp]; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 获取文件元信息 + */ + public function getMetadata($path): array|false + { + $fullPath = $this->applyPathPrefix($path); + + try { + $response = $this->client->head('files/' . ltrim($fullPath, '/')); + + if ($response->getStatusCode() !== 200) { + return false; + } + + $contentType = $response->getHeader('Content-Type')[0] ?? 'application/octet-stream'; + $size = (int)($response->getHeader('Content-Length')[0] ?? 0); + $lastModified = $response->getHeader('Last-Modified')[0] ?? ''; + $timestamp = $lastModified ? strtotime($lastModified) : time(); + + return [ + 'type' => 'file', + 'path' => $fullPath, + 'timestamp' => $timestamp, + 'size' => $size, + 'mimetype' => $contentType, + ]; + } catch (GuzzleException $e) { + return false; + } + } + + /** + * 列出目录内容(简化实现,返回空数组) + */ + public function listContents($directory = '', $recursive = false): array + { + return []; + } + + /** + * 重命名文件 + */ + public function rename($path, $newpath): bool + { + $contents = $this->read($path); + if ($contents === false) { + return false; + } + + if ($this->write($newpath, $contents['contents'], new Config()) === false) { + return false; + } + + return $this->delete($path); + } + + /** + * 复制文件 + */ + public function copy($path, $newpath): bool + { + $contents = $this->read($path); + if ($contents === false) { + return false; + } + + return $this->write($newpath, $contents['contents'], new Config()) !== false; + } + + /** + * 标准化元数据 + */ + protected function normalizeMetadata(string $path, Config $config): array + { + return [ + 'type' => 'file', + 'path' => $path, + 'timestamp' => time(), + 'size' => 0, + ]; + } +} \ No newline at end of file diff --git a/Laravel/app/Http/Controllers/API/UpLoadController.php b/Laravel/app/Http/Controllers/API/UpLoadController.php index 18e9376..adfb66b 100644 --- a/Laravel/app/Http/Controllers/API/UpLoadController.php +++ b/Laravel/app/Http/Controllers/API/UpLoadController.php @@ -38,4 +38,43 @@ public function UpFileBase64() return \Yz::Return(true,'上传成功',['fileurl' =>'/storage/H5Upload/'.$date.'/'.$filename]); } + + /** + * 测试 network_drive 上传链路 + * 上传文件到 Go 服务,返回可访问的完整 URL + * 入参: file (必填), disk_name (可选, 默认 network_drive_test) + */ + public function testUploadNetworkDrive() + { + $file = request('file'); + $diskName = request('disk_name', 'network_drive_test'); + + if (!$file || !$file->isValid()) { + return \Yz::echoError1('获取文件失败'); + } + + // 验证文件类型 + $mime = $file->getMimeType(); + $allowed = ['application/pdf', 'image/png', 'image/jpeg', 'image/jpg']; + if (!in_array($mime, $allowed)) { + return \Yz::echoError1('不支持的文件格式,仅支持 PDF/PNG/JPG'); + } + + // 生成存储路径 + $date = date('Ymd'); + $filename = uniqid() . '_' . mt_rand(0, 999999) . '.' . $file->extension(); + $filePath = '/phpUpload/test/' . $date . '/' . $filename; + + // 根据传入的磁盘名称选择对应磁盘 + $disk = Storage::disk($diskName); + $contents = file_get_contents($file->getRealPath()); + $savePath = $disk->put($filePath, $contents); + $url = $disk->url($filePath); + + return \Yz::Return(true, '上传成功', [ + 'disk' => $diskName, + 'path' => $savePath, + 'url' => $url, + ]); + } } diff --git a/Laravel/app/Providers/AppServiceProvider.php b/Laravel/app/Providers/AppServiceProvider.php index ee8ca5b..2887e90 100644 --- a/Laravel/app/Providers/AppServiceProvider.php +++ b/Laravel/app/Providers/AppServiceProvider.php @@ -2,7 +2,10 @@ namespace App\Providers; +use Illuminate\Support\Facades\Storage; use Illuminate\Support\ServiceProvider; +use League\Flysystem\Filesystem; +use App\Filesystem\HttpGoAdapter; class AppServiceProvider extends ServiceProvider { @@ -23,6 +26,11 @@ public function register() */ public function boot() { - // + Storage::extend('http_go', function($app, $config) { + $adapter = new HttpGoAdapter($config['base_uri']); + $filesystem = new Filesystem($adapter); + // 直接返回 FilesystemAdapter 并传入 config,确保 url() 方法能读取配置 + return new \Illuminate\Filesystem\FilesystemAdapter($filesystem, $config); + }); } } diff --git a/Laravel/config/filesystems.php b/Laravel/config/filesystems.php index 65b6cf8..2b12fef 100644 --- a/Laravel/config/filesystems.php +++ b/Laravel/config/filesystems.php @@ -53,8 +53,14 @@ 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), ], 'network_drive' => [ - 'driver' => 'local', - 'root' => 'D:/aa', // Windows 映射的盘符路径 + 'driver' => 'http_go', + 'base_uri' => env('GO_UPLOAD_SERVICE', 'http://127.0.0.1:8852'), + 'url' => env('APP_URL').'/phpstorage', + 'visibility' => 'public', + ], + 'network_drive_test' => [ + 'driver' => 'http_go', + 'base_uri' => env('GO_UPLOAD_SERVICE', 'http://127.0.0.1:8852'), 'url' => env('APP_URL').'/phpstorage', 'visibility' => 'public', ], diff --git a/Laravel/routes/api.php b/Laravel/routes/api.php index 3e37549..5106473 100644 --- a/Laravel/routes/api.php +++ b/Laravel/routes/api.php @@ -128,6 +128,9 @@ Route::post('healthCard/createInfo','App\Http\Controllers\API\Internal\HealthCertificateController@CreatInfo')->middleware('log'); Route::post('healthCard/uploadPdf','App\Http\Controllers\API\Internal\HealthCertificateController@uploadPdf')->middleware('log'); +// 测试接口:验证 network_drive 上传链路 +Route::post('testUploadNetworkDrive','App\Http\Controllers\API\UpLoadController@testUploadNetworkDrive'); + Route::post('test','App\Http\Controllers\API\Admin\LoginController@test'); Route::any('XTSignNotify','App\Http\Controllers\API\XTSignController@Notify'); diff --git a/Laravel/routes/web.php b/Laravel/routes/web.php index 507ee12..1251ca6 100644 --- a/Laravel/routes/web.php +++ b/Laravel/routes/web.php @@ -17,14 +17,21 @@ return view('welcome'); }); Route::get('/phpstorage/{path}', function ($path) { - // 拼接A服务器的实际路径 - $storagePath = 'Z:/' . $path; + $disk = \Illuminate\Support\Facades\Storage::disk('network_drive'); - if (!file_exists($storagePath)) { + try { + if (!$disk->exists($path)) { + abort(404); + } + + $contents = $disk->get($path); + $mimeType = $disk->mimeType($path); + + return response($contents, 200) + ->header('Content-Type', $mimeType ?: 'application/octet-stream'); + } catch (\Exception $e) { abort(404); } - // 返回A服务器的图片 - return response()->file($storagePath); })->where('path', '.*'); //微信登录授权获取code diff --git a/upload/config.yaml.example b/upload/config.yaml.example index ff17e23..6d2f50e 100755 --- a/upload/config.yaml.example +++ b/upload/config.yaml.example @@ -4,4 +4,9 @@ # Gin 框架配置 gin: # 服务器监听端口 - port: "8850" \ No newline at end of file + port: "8850" + +# 文件存储配置 +storage: + # 存储根目录,文件将存储在此目录下 + root: "D:/aa" \ No newline at end of file diff --git a/upload/config/config.go b/upload/config/config.go index 311b498..25067f5 100755 --- a/upload/config/config.go +++ b/upload/config/config.go @@ -9,7 +9,8 @@ import ( // Config 应用配置 type Config struct { - Gin GinConfig `json:"gin" yaml:"gin"` + Gin GinConfig `json:"gin" yaml:"gin"` + Storage StorageConfig `json:"storage" yaml:"storage"` } // GinConfig Gin配置 @@ -17,12 +18,20 @@ type GinConfig struct { Port string `json:"port" yaml:"port"` } +// StorageConfig 存储配置 +type StorageConfig struct { + Root string `json:"root" yaml:"root"` +} + // getDefaultConfig 获取默认配置 func getDefaultConfig() *Config { return &Config{ Gin: GinConfig{ Port: "8850", }, + Storage: StorageConfig{ + Root: "D:/aa", + }, } } @@ -53,10 +62,15 @@ func loadFromFile() (*Config, error) { // loadFromEnv 从环境变量加载配置 func loadFromEnv(config *Config) { - // 加载 Gin 配置 + // 加载 Gin 配置 if port := os.Getenv("GIN_PORT"); port != "" { config.Gin.Port = port } + + // 加载 Storage 配置 + if root := os.Getenv("STORAGE_ROOT"); root != "" { + config.Storage.Root = root + } } // GetConfig 获取配置 @@ -70,6 +84,9 @@ func GetConfig() *Config { if fileConfig.Gin.Port != "" { config.Gin.Port = fileConfig.Gin.Port } + if fileConfig.Storage.Root != "" { + config.Storage.Root = fileConfig.Storage.Root + } } // 最后从环境变量加载 diff --git a/upload/router/router.go b/upload/router/router.go index d73964f..586e528 100755 --- a/upload/router/router.go +++ b/upload/router/router.go @@ -1,22 +1,67 @@ package router import ( + "encoding/base64" "fmt" + "net/http" + "os" + "path/filepath" "strings" "time" + "ziqian/config" u "ziqian/utils" "github.com/gin-gonic/gin" ) +// getMimeType 根据文件扩展名获取 MIME 类型 +func getMimeType(filename string) string { + ext := strings.ToLower(filepath.Ext(filename)) + mimeMap := map[string]string{ + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + ".bmp": "image/bmp", + ".svg": "image/svg+xml", + ".pdf": "application/pdf", + ".txt": "text/plain", + ".html": "text/html", + ".json": "application/json", + ".zip": "application/zip", + ".doc": "application/msword", + ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ".xls": "application/vnd.ms-excel", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + } + if mime, ok := mimeMap[ext]; ok { + return mime + } + return "application/octet-stream" +} + +// fileExists 检查文件是否存在 +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// resolveFilePath 将请求路径转换为存储服务器上的绝对路径 +func resolveFilePath(storageRoot, requestPath string) string { + // 去除开头的斜杠,避免 filepath.Join 处理异常 + cleanPath := strings.TrimPrefix(requestPath, "/") + return filepath.Join(storageRoot, cleanPath) +} + func SetupRouter() *gin.Engine { r := gin.Default() + cfg := config.GetConfig() r.LoadHTMLFiles("templates/index.html") // 只在开发环境中配置静态文件服务 if gin.Mode() == gin.DebugMode { - // 配置静态文件服务 r.Static("/static", "./static") fmt.Println("Development mode: Static file services enabled") } else { @@ -117,7 +162,149 @@ func SetupRouter() *gin.Engine { u.Zi.Echo(c, apiRoutes) }) + + // 上传文件 - base64 + apiGroup.POST("/upload/base64", func(c *gin.Context) { + var req struct { + File string `json:"file"` + Path string `json:"path"` + Filename string `json:"filename"` + } + if err := c.ShouldBindJSON(&req); err != nil { + u.Zi.Error(c, "请求参数错误") + return + } + + if req.File == "" || req.Filename == "" { + u.Zi.Error(c, "文件内容或文件名不能为空") + return + } + + // 解码 base64 + decoded, err := base64.StdEncoding.DecodeString(req.File) + if err != nil { + u.Zi.Error(c, "base64 解码失败") + return + } + + // 构建完整路径 + dirPath := resolveFilePath(cfg.Storage.Root, req.Path) + fullPath := filepath.Join(dirPath, req.Filename) + + // 创建目录 + if err := os.MkdirAll(dirPath, 0755); err != nil { + u.Zi.Error(c, "创建目录失败: "+err.Error()) + return + } + + // 写入文件 + if err := os.WriteFile(fullPath, decoded, 0644); err != nil { + u.Zi.Error(c, "写入文件失败: "+err.Error()) + return + } + + // 构建返回 URL + relPath := strings.TrimPrefix(req.Path, "/") + url := fmt.Sprintf("/files/%s/%s", relPath, req.Filename) + + u.Zi.Echo(c, gin.H{ + "url": url, + }) + }) + + // 上传文件 - multipart + apiGroup.POST("/upload/file", func(c *gin.Context) { + file, err := c.FormFile("file") + if err != nil { + u.Zi.Error(c, "获取上传文件失败") + return + } + + path := c.PostForm("path") + if path == "" { + u.Zi.Error(c, "路径不能为空") + return + } + + // 构建完整路径 + dirPath := resolveFilePath(cfg.Storage.Root, path) + fullPath := filepath.Join(dirPath, file.Filename) + + // 创建目录 + if err := os.MkdirAll(dirPath, 0755); err != nil { + u.Zi.Error(c, "创建目录失败: "+err.Error()) + return + } + + // 保存文件 + if err := c.SaveUploadedFile(file, fullPath); err != nil { + u.Zi.Error(c, "保存文件失败: "+err.Error()) + return + } + + // 构建返回 URL + relPath := strings.TrimPrefix(path, "/") + url := fmt.Sprintf("/files/%s/%s", relPath, file.Filename) + + u.Zi.Echo(c, gin.H{ + "url": url, + }) + }) } + // 文件服务路由 - 处理 GET/HEAD/DELETE + r.Any("/files/*filepath", func(c *gin.Context) { + filePath := c.Param("filepath") + + // 安全保护:不允许空路径或根路径操作 + if filePath == "" || filePath == "/" { + c.Status(http.StatusBadRequest) + return + } + + fullPath := resolveFilePath(cfg.Storage.Root, filePath) + + // 安全保护:确保解析后的路径仍在存储根目录下 + cleanRoot := filepath.Clean(cfg.Storage.Root) + cleanFull := filepath.Clean(fullPath) + if !strings.HasPrefix(cleanFull, cleanRoot) { + c.Status(http.StatusForbidden) + return + } + + switch c.Request.Method { + case http.MethodGet: + if !fileExists(fullPath) { + c.Status(http.StatusNotFound) + return + } + c.File(fullPath) + + case http.MethodHead: + info, err := os.Stat(fullPath) + if err != nil { + c.Status(http.StatusNotFound) + return + } + c.Header("Content-Type", getMimeType(filePath)) + c.Header("Content-Length", fmt.Sprintf("%d", info.Size())) + c.Header("Last-Modified", info.ModTime().Format(http.TimeFormat)) + c.Status(http.StatusOK) + + case http.MethodDelete: + if err := os.RemoveAll(fullPath); err != nil { + c.Status(http.StatusInternalServerError) + return + } + c.Status(http.StatusOK) + + default: + c.Status(http.StatusMethodNotAllowed) + } + }) + + // 确保文件上传中间件中不包含日志记录(已移除) + // 如果需要将上传的文件写入到磁盘中的指定路径,请使用上述接口 + return r -} +} \ No newline at end of file