From 99342696ea6fcb1b38edffc9cd450f252bbaf442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Scout=E5=BC=80=E5=8F=91=E8=80=85?= Date: Sun, 30 Aug 2026 22:10:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8F=AF=E5=A4=8D=E7=94=A8Go?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/scout-client/main.go | 197 +++++++------------- pkg/client/client.go | 382 ++++++++++++++++++++++++++++++++++++++ pkg/client/client_test.go | 116 ++++++++++++ 3 files changed, 566 insertions(+), 129 deletions(-) create mode 100644 pkg/client/client.go create mode 100644 pkg/client/client_test.go diff --git a/cmd/scout-client/main.go b/cmd/scout-client/main.go index d4308f9..f626d10 100644 --- a/cmd/scout-client/main.go +++ b/cmd/scout-client/main.go @@ -1,32 +1,17 @@ package main import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" + "context" "flag" "fmt" "io" - "net/http" "os" "path/filepath" "strings" - "scout/internal/registry" + "scout/pkg/client" ) -type packageInfo struct { - Name string `json:"name"` - Version string `json:"version"` - SHA256 string `json:"sha256"` - Size int64 `json:"size"` -} - -type updateEvent struct { - Package packageInfo `json:"package"` -} - func main() { if len(os.Args) < 2 { printUsage() @@ -37,6 +22,8 @@ func main() { checkCommand(os.Args[2:]) case "download": downloadCommand(os.Args[2:]) + case "upload": + uploadCommand(os.Args[2:]) case "watch": watchCommand(os.Args[2:]) default: @@ -52,9 +39,12 @@ func checkCommand(args []string) { current := flags.String("current", "", "当前版本") flags.Parse(args) requirePackage(*name) - var latest packageInfo - requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest) - if registry.CompareVersions(latest.Version, *current) > 0 { + api := newClient(*server, "") + latest, available, err := api.CheckUpdate(context.Background(), *name, *current) + if err != nil { + fatal("检查更新失败: %v", err) + } + if available { fmt.Printf("发现更新:%s %s -> %s\n", latest.Name, *current, latest.Version) return } @@ -69,41 +59,55 @@ func downloadCommand(args []string) { output := flags.String("output", "", "保存路径") flags.Parse(args) requirePackage(*name) - if *version == "" { - var latest packageInfo - requestJSON(http.MethodGet, strings.TrimRight(*server, "/")+"/api/v1/packages/"+*name, nil, "", &latest) - *version = latest.Version - } if *output == "" { - *output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, *version)) + resolvedVersion := *version + if resolvedVersion == "" { + resolvedVersion = "latest" + } + *output = filepath.Join(".", fmt.Sprintf("%s-%s.pkg", *name, resolvedVersion)) } - url := fmt.Sprintf("%s/api/v1/packages/%s/%s/download", strings.TrimRight(*server, "/"), *name, *version) - response, err := http.Get(url) + api := newClient(*server, "") + item, err := api.Download(context.Background(), *name, *version, *output) if err != nil { - fatal("请求下载失败: %v", err) + fatal("下载失败: %v", err) } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - fatalResponse(response) + fmt.Printf("下载完成:%s\n版本:%s\nSHA-256:%s\n", *output, item.Version, item.SHA256) +} + +func uploadCommand(args []string) { + flags := flag.NewFlagSet("upload", flag.ExitOnError) + server := flags.String("server", "http://localhost:8080", "服务端地址") + token := flags.String("token", "", "开发者令牌") + name := flags.String("package", "", "包名") + version := flags.String("version", "", "版本") + filePath := flags.String("file", "", "包文件路径") + description := flags.String("description", "", "包描述") + platform := flags.String("platform", "", "运行平台") + architecture := flags.String("architecture", "", "运行架构") + flags.Parse(args) + requirePackage(*name) + if *version == "" || *filePath == "" { + fatal("必须指定 --version 和 --file") } - file, err := os.Create(*output) + file, err := os.Open(*filePath) if err != nil { - fatal("创建输出文件失败: %v", err) + fatal("打开包文件失败: %v", err) } - _, copyErr := io.Copy(file, response.Body) - closeErr := file.Close() - if copyErr != nil || closeErr != nil { - fatal("保存包文件失败") - } - digest, err := fileSHA256(*output) + defer file.Close() + api := newClient(*server, *token) + item, err := api.Upload(context.Background(), client.UploadRequest{ + Name: *name, + Version: *version, + Description: *description, + Platform: *platform, + Architecture: *architecture, + FileName: filepath.Base(*filePath), + File: file, + }) if err != nil { - fatal("校验包文件失败: %v", err) + fatal("上传失败: %v", err) } - if expected := response.Header.Get("X-Scout-SHA256"); expected != "" && digest != expected { - _ = os.Remove(*output) - fatal("包文件校验失败,文件已删除") - } - fmt.Printf("下载完成:%s\nSHA-256:%s\n", *output, digest) + fmt.Printf("上传成功:%s %s\nSHA-256:%s\n", item.Name, item.Version, item.SHA256) } func watchCommand(args []string) { @@ -111,102 +115,34 @@ func watchCommand(args []string) { server := flags.String("server", "http://localhost:8080", "服务端地址") packages := flags.String("packages", "", "要监听的包名,多个包用逗号分隔") flags.Parse(args) - url := strings.TrimRight(*server, "/") + "/api/v1/updates/stream" - if *packages != "" { - url += "?packages=" + *packages - } - response, err := http.Get(url) + api := newClient(*server, "") + updates, err := api.WatchUpdates(context.Background(), splitPackages(*packages)...) if err != nil { fatal("连接更新通知失败: %v", err) } - defer response.Body.Close() - if response.StatusCode != http.StatusOK { - fatalResponse(response) - } fmt.Println("已连接更新通知,等待新包……") - readSSE(response.Body) + for update := range updates { + fmt.Printf("收到更新:%s %s\n", update.Package.Name, update.Package.Version) + } } -func requestJSON(method, url string, body io.Reader, token string, target any) { - request, err := http.NewRequest(method, url, body) +func newClient(server, token string) *client.Client { + api, err := client.New(server, client.WithDeveloperToken(token)) if err != nil { - fatal("创建请求失败: %v", err) - } - if token != "" { - request.Header.Set("Authorization", "Bearer "+token) - } - response, err := http.DefaultClient.Do(request) - if err != nil { - fatal("请求失败: %v", err) - } - defer response.Body.Close() - if response.StatusCode < 200 || response.StatusCode >= 300 { - fatalResponse(response) - } - if err := json.NewDecoder(response.Body).Decode(target); err != nil { - fatal("解析服务端响应失败: %v", err) + fatal("创建客户端失败: %v", err) } + return api } -func readSSE(reader io.Reader) { - dataLines := make([]string, 0, 1) - buffer := make([]byte, 4096) - pending := "" - for { - count, err := reader.Read(buffer) - if count > 0 { - pending += string(buffer[:count]) - for { - lineEnd := strings.IndexByte(pending, '\n') - if lineEnd < 0 { - break - } - line := strings.TrimSuffix(pending[:lineEnd], "\r") - pending = pending[lineEnd+1:] - if line == "" { - if len(dataLines) > 0 { - var event updateEvent - if json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &event) == nil { - fmt.Printf("收到更新:%s %s\n", event.Package.Name, event.Package.Version) - } - } - dataLines = dataLines[:0] - continue - } - if strings.HasPrefix(line, "data: ") { - dataLines = append(dataLines, strings.TrimPrefix(line, "data: ")) - } - } - } - if err != nil { - if !errors.Is(err, io.EOF) { - fatal("读取更新通知失败: %v", err) - } - return - } +func splitPackages(value string) []string { + if strings.TrimSpace(value) == "" { + return nil } -} - -func fileSHA256(path string) (string, error) { - file, err := os.Open(path) - if err != nil { - return "", err - } - defer file.Close() - hash := sha256.New() - if _, err := io.Copy(hash, file); err != nil { - return "", err - } - return hex.EncodeToString(hash.Sum(nil)), nil -} - -func fatalResponse(response *http.Response) { - body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) - fatal("服务端返回 %s:%s", response.Status, strings.TrimSpace(string(body))) + return strings.Split(value, ",") } func requirePackage(name string) { - if name == "" { + if strings.TrimSpace(name) == "" { fatal("必须指定 --package") } } @@ -218,5 +154,8 @@ func fatal(format string, args ...any) { func printUsage() { fmt.Println("Scout 轻量化包管理客户端") - fmt.Println("用法:scout-client [参数]") + fmt.Println("用法:scout-client [参数]") + fmt.Println("upload 用于开发者上传,check、download、watch 用于客户端更新流程") } + +var _ io.Reader diff --git a/pkg/client/client.go b/pkg/client/client.go new file mode 100644 index 0000000..7230cc5 --- /dev/null +++ b/pkg/client/client.go @@ -0,0 +1,382 @@ +package client + +import ( + "bufio" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" +) + +type Package struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description,omitempty"` + Platform string `json:"platform,omitempty"` + Architecture string `json:"architecture,omitempty"` + Size int64 `json:"size"` + SHA256 string `json:"sha256"` + UploadedAt string `json:"uploaded_at"` +} + +type Update struct { + Package Package `json:"package"` +} + +type UploadRequest struct { + Name string + Version string + Description string + Platform string + Architecture string + FileName string + File io.Reader +} + +type Option func(*Client) + +type Client struct { + baseURL string + httpClient *http.Client + token string +} + +func New(baseURL string, options ...Option) (*Client, error) { + baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") + if baseURL == "" { + return nil, errors.New("服务端地址不能为空") + } + if _, err := url.ParseRequestURI(baseURL); err != nil { + return nil, fmt.Errorf("服务端地址无效: %w", err) + } + client := &Client{baseURL: baseURL, httpClient: http.DefaultClient} + for _, option := range options { + option(client) + } + return client, nil +} + +func WithHTTPClient(httpClient *http.Client) Option { + return func(client *Client) { + if httpClient != nil { + client.httpClient = httpClient + } + } +} + +func WithDeveloperToken(token string) Option { + return func(client *Client) { + client.token = token + } +} + +func (c *Client) ListPackages(ctx context.Context) ([]Package, error) { + var response struct { + Packages []Package `json:"packages"` + } + if err := c.getJSON(ctx, "/api/v1/packages", &response); err != nil { + return nil, err + } + return response.Packages, nil +} + +func (c *Client) GetLatest(ctx context.Context, name string) (Package, error) { + var item Package + if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name), &item); err != nil { + return Package{}, err + } + return item, nil +} + +func (c *Client) ListVersions(ctx context.Context, name string) ([]Package, error) { + var response struct { + Name string `json:"name"` + Versions []Package `json:"versions"` + } + if err := c.getJSON(ctx, "/api/v1/packages/"+url.PathEscape(name)+"/versions", &response); err != nil { + return nil, err + } + return response.Versions, nil +} + +func (c *Client) CheckUpdate(ctx context.Context, name, currentVersion string) (Package, bool, error) { + latest, err := c.GetLatest(ctx, name) + if err != nil { + return Package{}, false, err + } + return latest, CompareVersions(latest.Version, currentVersion) > 0, nil +} + +func (c *Client) Download(ctx context.Context, name, version, destination string) (Package, error) { + if version == "" { + latest, err := c.GetLatest(ctx, name) + if err != nil { + return Package{}, err + } + version = latest.Version + } + path := "/api/v1/packages/" + url.PathEscape(name) + "/" + url.PathEscape(version) + "/download" + request, err := c.newRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return Package{}, err + } + response, err := c.httpClient.Do(request) + if err != nil { + return Package{}, fmt.Errorf("请求下载失败: %w", err) + } + defer response.Body.Close() + if err := checkResponse(response); err != nil { + return Package{}, err + } + if destination == "" { + return Package{}, errors.New("保存路径不能为空") + } + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + return Package{}, fmt.Errorf("创建保存目录失败: %w", err) + } + file, err := os.Create(destination) + if err != nil { + return Package{}, fmt.Errorf("创建保存文件失败: %w", err) + } + hash := sha256.New() + _, copyErr := io.Copy(io.MultiWriter(file, hash), response.Body) + closeErr := file.Close() + if copyErr != nil { + return Package{}, fmt.Errorf("保存包文件失败: %w", copyErr) + } + if closeErr != nil { + return Package{}, fmt.Errorf("关闭保存文件失败: %w", closeErr) + } + digest := hex.EncodeToString(hash.Sum(nil)) + expected := response.Header.Get("X-Scout-SHA256") + if expected != "" && !strings.EqualFold(expected, digest) { + return Package{}, fmt.Errorf("包文件校验失败: 期望 %s,实际 %s", expected, digest) + } + return Package{ + Name: name, + Version: version, + Size: response.ContentLength, + SHA256: digest, + }, nil +} + +func (c *Client) Upload(ctx context.Context, input UploadRequest) (Package, error) { + if c.token == "" { + return Package{}, errors.New("上传包需要开发者令牌") + } + if input.File == nil { + return Package{}, errors.New("包文件不能为空") + } + bodyReader, bodyWriter := io.Pipe() + multipartWriter := multipart.NewWriter(bodyWriter) + go func() { + err := writeUploadForm(multipartWriter, input) + if closeErr := multipartWriter.Close(); err == nil { + err = closeErr + } + _ = bodyWriter.CloseWithError(err) + }() + request, err := c.newRequest(ctx, http.MethodPost, "/api/v1/packages/upload", bodyReader) + if err != nil { + return Package{}, err + } + request.Header.Set("Content-Type", multipartWriter.FormDataContentType()) + request.Header.Set("Authorization", "Bearer "+c.token) + response, err := c.httpClient.Do(request) + if err != nil { + return Package{}, fmt.Errorf("请求上传失败: %w", err) + } + defer response.Body.Close() + if err := checkResponse(response); err != nil { + return Package{}, err + } + var item Package + if err := json.NewDecoder(response.Body).Decode(&item); err != nil { + return Package{}, fmt.Errorf("解析上传响应失败: %w", err) + } + return item, nil +} + +func (c *Client) WatchUpdates(ctx context.Context, packages ...string) (<-chan Update, error) { + query := url.Values{} + wanted := make([]string, 0, len(packages)) + for _, name := range packages { + if strings.TrimSpace(name) != "" { + wanted = append(wanted, name) + } + } + if len(wanted) > 0 { + query.Set("packages", strings.Join(wanted, ",")) + } + path := "/api/v1/updates/stream" + if encoded := query.Encode(); encoded != "" { + path += "?" + encoded + } + request, err := c.newRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return nil, err + } + response, err := c.httpClient.Do(request) + if err != nil { + return nil, fmt.Errorf("连接更新通知失败: %w", err) + } + if err := checkResponse(response); err != nil { + response.Body.Close() + return nil, err + } + updates := make(chan Update) + go readUpdates(ctx, response.Body, updates) + return updates, nil +} + +func (c *Client) getJSON(ctx context.Context, path string, target any) error { + request, err := c.newRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return err + } + response, err := c.httpClient.Do(request) + if err != nil { + return fmt.Errorf("请求失败: %w", err) + } + defer response.Body.Close() + if err := checkResponse(response); err != nil { + return err + } + if err := json.NewDecoder(response.Body).Decode(target); err != nil { + return fmt.Errorf("解析服务端响应失败: %w", err) + } + return nil +} + +func (c *Client) newRequest(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + request, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return nil, fmt.Errorf("创建请求失败: %w", err) + } + if c.token != "" { + request.Header.Set("Authorization", "Bearer "+c.token) + } + return request, nil +} + +func writeUploadForm(writer *multipart.Writer, input UploadRequest) error { + fields := map[string]string{ + "name": input.Name, + "version": input.Version, + "description": input.Description, + "platform": input.Platform, + "architecture": input.Architecture, + } + for name, value := range fields { + if err := writer.WriteField(name, value); err != nil { + return err + } + } + part, err := writer.CreateFormFile("file", input.FileName) + if err != nil { + return err + } + _, err = io.Copy(part, input.File) + return err +} + +func checkResponse(response *http.Response) error { + if response.StatusCode >= 200 && response.StatusCode < 300 { + return nil + } + body, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + message := strings.TrimSpace(string(body)) + if message == "" { + message = response.Status + } + return fmt.Errorf("服务端返回 %s: %s", response.Status, message) +} + +func readUpdates(ctx context.Context, body io.ReadCloser, updates chan<- Update) { + defer close(updates) + defer body.Close() + go func() { + <-ctx.Done() + _ = body.Close() + }() + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 4096), 4<<20) + dataLines := make([]string, 0, 1) + for scanner.Scan() { + line := strings.TrimSuffix(scanner.Text(), "\r") + if line == "" { + if len(dataLines) > 0 { + var update Update + if json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &update) == nil { + select { + case updates <- update: + case <-ctx.Done(): + return + } + } + } + dataLines = dataLines[:0] + continue + } + if strings.HasPrefix(line, "data:") { + dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:"))) + } + } +} + +func CompareVersions(left, right string) int { + leftParts := splitVersion(left) + rightParts := splitVersion(right) + for index := 0; index < len(leftParts) || index < len(rightParts); index++ { + if index >= len(leftParts) { + return -1 + } + if index >= len(rightParts) { + return 1 + } + leftNumber, leftOK := numberPart(leftParts[index]) + rightNumber, rightOK := numberPart(rightParts[index]) + if leftOK && rightOK && leftNumber != rightNumber { + if leftNumber > rightNumber { + return 1 + } + return -1 + } + if leftParts[index] != rightParts[index] { + return strings.Compare(leftParts[index], rightParts[index]) + } + } + return 0 +} + +func splitVersion(version string) []string { + version = strings.TrimPrefix(version, "v") + version = strings.SplitN(version, "+", 2)[0] + parts := strings.FieldsFunc(version, func(char rune) bool { + return char == '.' || char == '-' || char == '_' + }) + return parts +} + +func numberPart(value string) (int64, bool) { + if value == "" { + return 0, false + } + var number int64 + for _, char := range value { + if char < '0' || char > '9' { + return 0, false + } + number = number*10 + int64(char-'0') + } + return number, true +} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go new file mode 100644 index 0000000..dd7a7d1 --- /dev/null +++ b/pkg/client/client_test.go @@ -0,0 +1,116 @@ +package client + +import ( + "bytes" + "context" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "scout/internal/httpapi" + "scout/internal/registry" +) + +func TestClientUploadQueryCheckAndDownload(t *testing.T) { + store, err := registry.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer((&httpapi.Server{Registry: store, DeveloperToken: "测试令牌"}).Handler()) + defer server.Close() + api, err := New(server.URL, WithDeveloperToken("测试令牌")) + if err != nil { + t.Fatal(err) + } + ctx := context.Background() + uploaded, err := api.Upload(ctx, UploadRequest{ + Name: "client-demo", + Version: "1.0.0", + FileName: "demo.pkg", + File: bytes.NewBufferString("client package"), + }) + if err != nil { + t.Fatal(err) + } + if uploaded.SHA256 == "" { + t.Fatal("上传结果缺少 SHA-256") + } + packages, err := api.ListPackages(ctx) + if err != nil || len(packages) != 1 { + t.Fatalf("包列表错误: %v, %d", err, len(packages)) + } + latest, available, err := api.CheckUpdate(ctx, "client-demo", "0.9.0") + if err != nil || !available || latest.Version != "1.0.0" { + t.Fatalf("更新检查错误: %+v, %t, %v", latest, available, err) + } + versions, err := api.ListVersions(ctx, "client-demo") + if err != nil || len(versions) != 1 { + t.Fatalf("版本列表错误: %v, %d", err, len(versions)) + } + output := filepath.Join(t.TempDir(), "download.pkg") + downloaded, err := api.Download(ctx, "client-demo", "", output) + if err != nil { + t.Fatal(err) + } + if downloaded.SHA256 != uploaded.SHA256 { + t.Fatalf("下载校验值不一致: %s != %s", downloaded.SHA256, uploaded.SHA256) + } + content, err := os.ReadFile(output) + if err != nil || string(content) != "client package" { + t.Fatalf("下载内容错误: %q, %v", content, err) + } +} + +func TestClientWatchUpdates(t *testing.T) { + store, err := registry.New(t.TempDir()) + if err != nil { + t.Fatal(err) + } + server := httptest.NewServer((&httpapi.Server{Registry: store, DeveloperToken: "测试令牌"}).Handler()) + defer server.Close() + api, err := New(server.URL, WithDeveloperToken("测试令牌")) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + updates, err := api.WatchUpdates(ctx, "client-demo") + if err != nil { + t.Fatal(err) + } + go func() { + _, _ = api.Upload(context.Background(), UploadRequest{ + Name: "client-demo", + Version: "2.0.0", + FileName: "demo.pkg", + File: bytes.NewBufferString("new package"), + }) + }() + select { + case update := <-updates: + if update.Package.Name != "client-demo" || update.Package.Version != "2.0.0" { + t.Fatalf("通知内容错误: %+v", update) + } + case <-time.After(2 * time.Second): + t.Fatal("等待更新通知超时") + } +} + +func TestCompareVersions(t *testing.T) { + cases := []struct { + left string + right string + want int + }{ + {left: "1.10.0", right: "1.9.0", want: 1}, + {left: "v1.0.0", right: "1.0.0", want: 0}, + {left: "1.0.0", right: "2.0.0", want: -1}, + } + for _, item := range cases { + if got := CompareVersions(item.left, item.right); got != item.want { + t.Fatalf("CompareVersions(%q, %q) = %d, want %d", item.left, item.right, got, item.want) + } + } +}