From 52a34e2cb0f53644db56c19b4a4c456e1e6c655a Mon Sep 17 00:00:00 2001 From: dailz Date: Thu, 16 Apr 2026 13:24:21 +0800 Subject: [PATCH] feat(client): add CLI client entry point --- cmd/client/main.go | 662 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 662 insertions(+) create mode 100644 cmd/client/main.go diff --git a/cmd/client/main.go b/cmd/client/main.go new file mode 100644 index 0000000..6a3e5bd --- /dev/null +++ b/cmd/client/main.go @@ -0,0 +1,662 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" +) + +// ── API response types ─────────────────────────────────────────────── + +type apiResponse struct { + Success bool `json:"success"` + Data json.RawMessage `json:"data"` + Error string `json:"error,omitempty"` +} + +type sessionResponse struct { + ID int64 `json:"id"` + FileName string `json:"file_name"` + FileSize int64 `json:"file_size"` + ChunkSize int64 `json:"chunk_size"` + TotalChunks int `json:"total_chunks"` + SHA256 string `json:"sha256"` + Status string `json:"status"` + UploadedChunks []int `json:"uploaded_chunks"` +} + +type fileResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mime_type"` + SHA256 string `json:"sha256"` + CreatedAt string `json:"created_at"` +} + +type folderResponse struct { + ID int64 `json:"id"` + Name string `json:"name"` + ParentID *int64 `json:"parent_id,omitempty"` + Path string `json:"path"` + CreatedAt string `json:"created_at"` +} + +type listFilesResponse struct { + Files []fileResponse `json:"files"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// ── Helpers ────────────────────────────────────────────────────────── + +func formatSize(b int64) string { + const ( + KB = 1024 + MB = KB * 1024 + GB = MB * 1024 + ) + switch { + case b >= GB: + return fmt.Sprintf("%.2f GB", float64(b)/float64(GB)) + case b >= MB: + return fmt.Sprintf("%.2f MB", float64(b)/float64(MB)) + case b >= KB: + return fmt.Sprintf("%.2f KB", float64(b)/float64(KB)) + default: + return fmt.Sprintf("%d B", b) + } +} + +func doRequest(server, method, path string, body io.Reader, contentType string) (*apiResponse, error) { + url := server + path + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("sending request: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response: %w", err) + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw)) + } + + var apiResp apiResponse + if err := json.Unmarshal(raw, &apiResp); err != nil { + return nil, fmt.Errorf("parsing response: %w\nbody: %s", err, string(raw)) + } + + if !apiResp.Success { + return &apiResp, fmt.Errorf("API error: %s", apiResp.Error) + } + + return &apiResp, nil +} + +func computeSHA256(path string) (string, error) { + f, err := os.Open(path) + if err != nil { + return "", err + } + defer f.Close() + + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return hex.EncodeToString(h.Sum(nil)), nil +} + +// ── Commands ───────────────────────────────────────────────────────── + +func cmdMkdir(server string, args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Usage: client -server mkdir [-parent ]") + os.Exit(1) + } + + name := args[0] + var parentID *int64 + for i := 1; i+1 < len(args); i += 2 { + if args[i] == "-parent" { + v, err := strconv.ParseInt(args[i+1], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid parent id: %s\n", args[i+1]) + os.Exit(1) + } + parentID = &v + } + } + + payload := map[string]interface{}{"name": name} + if parentID != nil { + payload["parent_id"] = *parentID + } + + body, _ := json.Marshal(payload) + resp, err := doRequest(server, http.MethodPost, "/api/v1/files/folders", bytes.NewReader(body), "application/json") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + var folder folderResponse + if err := json.Unmarshal(resp.Data, &folder); err != nil { + fmt.Fprintf(os.Stderr, "parsing folder response: %v\n", err) + os.Exit(1) + } + + fmt.Printf("Folder created: id=%d name=%q path=%q\n", folder.ID, folder.Name, folder.Path) +} + +func cmdListFolders(server string, args []string) { + path := "/api/v1/files/folders?" + for i := 0; i+1 < len(args); i += 2 { + if args[i] == "-parent" { + path += "parent_id=" + args[i+1] + "&" + } + } + + resp, err := doRequest(server, http.MethodGet, path, nil, "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + var folders []folderResponse + if err := json.Unmarshal(resp.Data, &folders); err != nil { + fmt.Fprintf(os.Stderr, "parsing folders response: %v\n", err) + os.Exit(1) + } + + if len(folders) == 0 { + fmt.Println("No folders found.") + return + } + + fmt.Printf("%-8s %-30s %-10s %s\n", "ID", "Name", "ParentID", "Path") + fmt.Println(strings.Repeat("-", 80)) + for _, f := range folders { + pid := "" + if f.ParentID != nil { + pid = strconv.FormatInt(*f.ParentID, 10) + } + fmt.Printf("%-8d %-30s %-10s %s\n", f.ID, f.Name, pid, f.Path) + } +} + +func cmdDeleteFolder(server string, args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Usage: client -server delete-folder ") + os.Exit(1) + } + id := args[0] + + _, err := doRequest(server, http.MethodDelete, "/api/v1/files/folders/"+id, nil, "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Printf("Folder %s deleted.\n", id) +} + +func cmdUpload(server string, args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Usage: client -server upload [-folder ] [-chunk-size ]") + os.Exit(1) + } + + filePath := args[0] + var folderID *int64 + chunkSize := int64(8 * 1024 * 1024) // 8 MB default + + for i := 1; i+1 < len(args); i += 2 { + switch args[i] { + case "-folder": + v, err := strconv.ParseInt(args[i+1], 10, 64) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid folder id: %s\n", args[i+1]) + os.Exit(1) + } + folderID = &v + case "-chunk-size": + v, err := strconv.ParseInt(args[i+1], 10, 64) + if err != nil || v <= 0 { + fmt.Fprintf(os.Stderr, "invalid chunk size: %s\n", args[i+1]) + os.Exit(1) + } + chunkSize = v + } + } + + start := time.Now() + + // Open file and get size + f, err := os.Open(filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot open file: %v\n", err) + os.Exit(1) + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + fmt.Fprintf(os.Stderr, "cannot stat file: %v\n", err) + os.Exit(1) + } + fileSize := fi.Size() + fileName := filepath.Base(filePath) + + // Compute SHA256 + fmt.Printf("Computing SHA256 for %s (%s)...\n", fileName, formatSize(fileSize)) + sha, err := computeSHA256(filePath) + if err != nil { + fmt.Fprintf(os.Stderr, "sha256 error: %v\n", err) + os.Exit(1) + } + fmt.Printf("SHA256: %s\n", sha) + + // Init upload + payload := map[string]interface{}{ + "file_name": fileName, + "file_size": fileSize, + "sha256": sha, + "chunk_size": chunkSize, + } + if folderID != nil { + payload["folder_id"] = *folderID + } + + body, _ := json.Marshal(payload) + resp, err := doRequest(server, http.MethodPost, "/api/v1/files/uploads", bytes.NewReader(body), "application/json") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + // Check for dedup (秒传) — response is a file, not a session + var sess sessionResponse + if err := json.Unmarshal(resp.Data, &sess); err == nil && sess.Status != "" && sess.TotalChunks > 0 { + // It's a session, proceed with chunk uploads + } else { + // Try to parse as file (dedup hit) + var fr fileResponse + if err := json.Unmarshal(resp.Data, &fr); err == nil && fr.Name != "" { + elapsed := time.Since(start) + fmt.Printf("⚡ 秒传 (instant upload)! File already exists.\n") + fmt.Printf(" ID: %d\n", fr.ID) + fmt.Printf(" Name: %s\n", fr.Name) + fmt.Printf(" Size: %s\n", formatSize(fr.Size)) + fmt.Printf(" SHA256: %s\n", fr.SHA256) + fmt.Printf(" Elapsed: %s\n", elapsed.Truncate(time.Millisecond)) + return + } + // If neither, just try the session path anyway + if err := json.Unmarshal(resp.Data, &sess); err != nil { + fmt.Fprintf(os.Stderr, "unexpected response: %s\n", string(resp.Data)) + os.Exit(1) + } + } + + sessionID := sess.ID + totalChunks := sess.TotalChunks + fmt.Printf("Upload session created: id=%d total_chunks=%d chunk_size=%s\n", + sessionID, totalChunks, formatSize(chunkSize)) + + // Upload chunks (4 concurrent workers) + const uploadWorkers = 4 + + type chunkResult struct { + index int + err error + } + + work := make(chan int, totalChunks) + results := make(chan chunkResult, totalChunks) + + for i := 0; i < totalChunks; i++ { + work <- i + } + close(work) + + var uploaded int64 // atomic counter for progress + + var wg sync.WaitGroup + for w := 0; w < uploadWorkers; w++ { + wg.Add(1) + go func() { + defer wg.Done() + for idx := range work { + offset := int64(idx) * chunkSize + remaining := fileSize - offset + thisChunkSize := chunkSize + if remaining < chunkSize { + thisChunkSize = remaining + } + sectionReader := io.NewSectionReader(f, offset, thisChunkSize) + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("chunk", fileName) + if err != nil { + results <- chunkResult{index: idx, err: err} + continue + } + if _, err := io.Copy(part, sectionReader); err != nil { + results <- chunkResult{index: idx, err: err} + continue + } + writer.Close() + + chunkURL := fmt.Sprintf("%s/api/v1/files/uploads/%d/chunks/%d", server, sessionID, idx) + chunkReq, err := http.NewRequest(http.MethodPut, chunkURL, &buf) + if err != nil { + results <- chunkResult{index: idx, err: err} + continue + } + chunkReq.Header.Set("Content-Type", writer.FormDataContentType()) + + chunkResp, err := http.DefaultClient.Do(chunkReq) + if err != nil { + results <- chunkResult{index: idx, err: err} + continue + } + raw, _ := io.ReadAll(chunkResp.Body) + chunkResp.Body.Close() + + if chunkResp.StatusCode >= 400 { + results <- chunkResult{index: idx, err: fmt.Errorf("HTTP %d: %s", chunkResp.StatusCode, string(raw))} + continue + } + var chunkAPIResp apiResponse + if err := json.Unmarshal(raw, &chunkAPIResp); err == nil && !chunkAPIResp.Success { + results <- chunkResult{index: idx, err: fmt.Errorf("%s", chunkAPIResp.Error)} + continue + } + + results <- chunkResult{index: idx, err: nil} + } + }() + } + + go func() { + wg.Wait() + close(results) + }() + + var firstErr error + for res := range results { + if res.err != nil { + if firstErr == nil { + firstErr = res.err + } + fmt.Fprintf(os.Stderr, "chunk %d failed: %v\n", res.index, res.err) + } + done := atomic.AddInt64(&uploaded, 1) + pct := float64(done) / float64(totalChunks) * 100 + doneBytes := done * chunkSize + if doneBytes > fileSize { + doneBytes = fileSize + } + fmt.Printf("\rUploading: %.1f%% (%s / %s)", pct, formatSize(doneBytes), formatSize(fileSize)) + } + fmt.Println() + + if firstErr != nil { + fmt.Fprintf(os.Stderr, "upload failed: %v\n", firstErr) + os.Exit(1) + } + + // Complete upload + completeURL := fmt.Sprintf("/api/v1/files/uploads/%d/complete", sessionID) + resp, err = doRequest(server, http.MethodPost, completeURL, nil, "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + var result fileResponse + if err := json.Unmarshal(resp.Data, &result); err != nil { + fmt.Fprintf(os.Stderr, "parsing complete response: %v\n", err) + os.Exit(1) + } + + elapsed := time.Since(start) + speed := float64(fileSize) / elapsed.Seconds() + fmt.Println("✓ Upload complete!") + fmt.Printf(" ID: %d\n", result.ID) + fmt.Printf(" Name: %s\n", result.Name) + fmt.Printf(" Size: %s\n", formatSize(result.Size)) + fmt.Printf(" SHA256: %s\n", result.SHA256) + fmt.Printf(" Elapsed: %s\n", elapsed.Truncate(time.Millisecond)) + fmt.Printf(" Speed: %s/s\n", formatSize(int64(speed))) +} + +func cmdDownload(server string, args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Usage: client -server download [-o ]") + os.Exit(1) + } + + fileID := args[0] + var output string + for i := 1; i+1 < len(args); i += 2 { + if args[i] == "-o" { + output = args[i+1] + } + } + + start := time.Now() + + url := server + "/api/v1/files/" + fileID + "/download" + resp, err := http.Get(url) + if err != nil { + fmt.Fprintf(os.Stderr, "download error: %v\n", err) + os.Exit(1) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) + fmt.Fprintf(os.Stderr, "download failed (HTTP %d): %s\n", resp.StatusCode, string(body)) + os.Exit(1) + } + + // Determine output filename + if output == "" { + // Try Content-Disposition header + if cd := resp.Header.Get("Content-Disposition"); cd != "" { + if idx := strings.Index(cd, "filename="); idx != -1 { + output = strings.Trim(cd[idx+9:], `"`) + } + } + if output == "" { + output = "download_" + fileID + } + } + + outFile, err := os.Create(output) + if err != nil { + fmt.Fprintf(os.Stderr, "cannot create output file: %v\n", err) + os.Exit(1) + } + defer outFile.Close() + + written, err := io.Copy(outFile, resp.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "download write error: %v\n", err) + os.Exit(1) + } + + elapsed := time.Since(start) + speed := float64(written) / elapsed.Seconds() + fmt.Println("✓ Download complete!") + fmt.Printf(" File: %s\n", output) + fmt.Printf(" Size: %s\n", formatSize(written)) + fmt.Printf(" Elapsed: %s\n", elapsed.Truncate(time.Millisecond)) + fmt.Printf(" Speed: %s/s\n", formatSize(int64(speed))) +} + +func cmdListFiles(server string, args []string) { + var folderID, page, pageSize string + for i := 0; i+1 < len(args); i += 2 { + switch args[i] { + case "-folder": + folderID = args[i+1] + case "-page": + page = args[i+1] + case "-page-size": + pageSize = args[i+1] + } + } + + path := "/api/v1/files?" + if folderID != "" { + path += "folder_id=" + folderID + "&" + } + if page != "" { + path += "page=" + page + "&" + } + if pageSize != "" { + path += "page_size=" + pageSize + "&" + } + + resp, err := doRequest(server, http.MethodGet, path, nil, "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + var list listFilesResponse + if err := json.Unmarshal(resp.Data, &list); err != nil { + fmt.Fprintf(os.Stderr, "parsing files response: %v\n", err) + os.Exit(1) + } + + if len(list.Files) == 0 { + fmt.Println("No files found.") + return + } + + fmt.Printf("%-8s %-30s %-12s %-10s %s\n", "ID", "Name", "Size", "MIME", "Created") + fmt.Println(strings.Repeat("-", 90)) + for _, f := range list.Files { + name := f.Name + if len(name) > 28 { + name = name[:25] + "..." + } + fmt.Printf("%-8d %-30s %-12s %-10s %s\n", f.ID, name, formatSize(f.Size), f.MimeType, f.CreatedAt) + } + fmt.Printf("\nTotal: %d Page: %d PageSize: %d\n", list.Total, list.Page, list.PageSize) +} + +func cmdDeleteFile(server string, args []string) { + if len(args) == 0 { + fmt.Fprintln(os.Stderr, "Usage: client -server delete-file ") + os.Exit(1) + } + fileID := args[0] + + _, err := doRequest(server, http.MethodDelete, "/api/v1/files/"+fileID, nil, "") + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + + fmt.Printf("File %s deleted.\n", fileID) +} + +// ── Main ───────────────────────────────────────────────────────────── + +func usage() { + fmt.Fprintf(os.Stderr, `HPC File Storage Client + +Usage: client -server [args] + +Commands: + mkdir [-parent ] 创建文件夹 + ls-folders [-parent ] 列出文件夹 + delete-folder 删除文件夹 + upload [-folder ] [-chunk-size ] 上传文件(分片) + download [-o ] 下载文件 + ls-files [-folder ] [-page ] [-page-size ] 列出文件 + delete-file 删除文件 + +Global flags: + -server 服务器地址 (默认 http://localhost:8080) +`) +} + +func main() { + server := "http://localhost:8080" + var command string + var cmdArgs []string + + args := os.Args[1:] + i := 0 + for i < len(args) { + if args[i] == "-server" && i+1 < len(args) { + server = args[i+1] + i += 2 + continue + } + if command == "" { + command = args[i] + } else { + cmdArgs = append(cmdArgs, args[i]) + } + i++ + } + + if command == "" { + usage() + os.Exit(1) + } + + switch command { + case "mkdir": + cmdMkdir(server, cmdArgs) + case "ls-folders": + cmdListFolders(server, cmdArgs) + case "delete-folder": + cmdDeleteFolder(server, cmdArgs) + case "upload": + cmdUpload(server, cmdArgs) + case "download": + cmdDownload(server, cmdArgs) + case "ls-files": + cmdListFiles(server, cmdArgs) + case "delete-file": + cmdDeleteFile(server, cmdArgs) + default: + fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command) + usage() + os.Exit(1) + } +}