package client import ( "bufio" "bytes" "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"` PackageType string `json:"package_type,omitempty"` Channel string `json:"channel,omitempty"` Status string `json:"status,omitempty"` FileName string `json:"file_name,omitempty"` Size int64 `json:"size"` SHA256 string `json:"sha256"` UploadedAt string `json:"uploaded_at"` Targets []Target `json:"targets,omitempty"` } type Target struct { ID string `json:"id"` Platform string `json:"platform"` Architecture string `json:"architecture"` PackageType string `json:"package_type"` FileName string `json:"file_name"` Size int64 `json:"size"` SHA256 string `json:"sha256"` ArtifactID string `json:"artifact_id"` Entrypoint string `json:"entrypoint,omitempty"` UpgradeFrom []string `json:"upgrade_from,omitempty"` } type Update struct { Package Package `json:"package"` } type UploadRequest struct { Name string Version string Description string Platform string Architecture string PackageType string Channel string Status string UpgradeFrom []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, "package_type": input.PackageType, "channel": input.Channel, "status": input.Status, "upgrade_from": strings.Join(input.UpgradeFrom, ","), } 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() stopCloser := make(chan struct{}) defer close(stopCloser) go func() { select { case <-ctx.Done(): _ = body.Close() case <-stopCloser: } }() 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 } type User struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` Role string `json:"role"` Active bool `json:"active"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type LoginResult struct { User User `json:"user"` Session string `json:"session"` ExpiresAt string `json:"expires_at"` } func WithSessionToken(token string) Option { return func(client *Client) { client.token = token } } func (c *Client) Register(ctx context.Context, email, name, password string) (User, error) { var result struct { User User `json:"user"` } if err := c.postJSON(ctx, "/api/v1/auth/register", map[string]string{"email": email, "name": name, "password": password}, &result); err != nil { return User{}, err } return result.User, nil } func (c *Client) Login(ctx context.Context, email, password string) (LoginResult, error) { var result LoginResult if err := c.postJSON(ctx, "/api/v1/auth/login", map[string]string{"email": email, "password": password}, &result); err != nil { return LoginResult{}, err } c.token = result.Session return result, nil } func (c *Client) Me(ctx context.Context) (User, error) { var result struct { User User `json:"user"` } if err := c.getJSON(ctx, "/api/v1/auth/me", &result); err != nil { return User{}, err } return result.User, nil } func (c *Client) Logout(ctx context.Context) error { var result map[string]string if err := c.postJSON(ctx, "/api/v1/auth/logout", map[string]string{}, &result); err != nil { return err } c.token = "" return nil } func (c *Client) RequestPasswordReset(ctx context.Context, email string) error { var result map[string]string return c.postJSON(ctx, "/api/v1/auth/password/request", map[string]string{"email": email}, &result) } func (c *Client) ResetPassword(ctx context.Context, token, newPassword string) error { var result map[string]string return c.postJSON(ctx, "/api/v1/auth/password/reset", map[string]string{"token": token, "new_password": newPassword}, &result) } func (c *Client) postJSON(ctx context.Context, path string, input any, target any) error { body, err := json.Marshal(input) if err != nil { return fmt.Errorf("编码请求失败: %w", err) } request, err := c.newRequest(ctx, http.MethodPost, path, bytes.NewReader(body)) if err != nil { return err } request.Header.Set("Content-Type", "application/json") 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 target == nil { return nil } if err := json.NewDecoder(response.Body).Decode(target); err != nil { return fmt.Errorf("解析服务端响应失败: %w", err) } return nil }