diff --git a/README.md b/README.md index 70b8764..cd24997 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,13 @@ tier0 api /openapi/v1/uns/read --body '{"topics":["demo"]}' # 浏览命名空间 tier0 api /openapi/v1/uns/browse --body '{"path":"/"}' + +# 上传 / 查询 UNS 附件 +tier0 uns attachments upload --uns-id 10001 --file manual.pdf +tier0 uns attachments list --uns-id 10001 + +# 绑定 UNS 节点到 SourceFlow +tier0 uns bind-flow --uns-id 10001 --flow-id 20001 ``` ### 查看当前 API Key @@ -126,6 +133,9 @@ tier0 flow create --name "alert-handler" --event # 更新 Flow(名称、描述、收藏) tier0 flow update --id 1 --name "new-name" --favorite +# 绑定 UNS 节点到 SourceFlow(flow-id 使用上面列表里的业务主键 ID) +tier0 uns bind-flow --uns-id 10001 --flow-id 1 + # 删除 Flow(支持多个 ID) tier0 flow delete --id 1 --id 2 tier0 flow delete 1,2,3 @@ -259,7 +269,7 @@ npx skills remove FREEZONEX/Tier0-skill | Version | Date | Notes | |---------|------|-------| -| Unreleased | — | 新增 `tier0 auth whoami` 和 `tier0 flow nodes` | +| Unreleased | — | 新增 `tier0 auth whoami`、`tier0 flow nodes`、`tier0 uns attachments`、`tier0 uns bind-flow`,`tier0 uns create` 支持 `--persistence` | | [v0.4.11](https://github.com/FREEZONEX/Tier0-cli/releases/tag/v0.4.11) | 2026-05-26 | 修复写操作忽略后端业务错误的问题 | | [v0.4.10](https://github.com/FREEZONEX/Tier0-cli/releases/tag/v0.4.10) | 2026-05-26 | 新增 `tier0 config --api-key` 直接设置 API Key | | [v0.4.9](https://github.com/FREEZONEX/Tier0-cli/releases/tag/v0.4.9) | 2026-05-26 | 新增 `tier0 uninstall`;修复安装版本错误(直接用 npm 包版本);修复 release.sh JSON 400 | diff --git a/cmd/uns.go b/cmd/uns.go index 5cbeb41..6214c09 100644 --- a/cmd/uns.go +++ b/cmd/uns.go @@ -24,4 +24,6 @@ func init() { unsCmd.AddCommand(unsSearchCmd) unsCmd.AddCommand(unsHistoryCmd) unsCmd.AddCommand(unsRestoreCmd) + unsCmd.AddCommand(unsAttachmentsCmd) + unsCmd.AddCommand(unsBindFlowCmd) } diff --git a/cmd/uns_attachments.go b/cmd/uns_attachments.go new file mode 100644 index 0000000..6046624 --- /dev/null +++ b/cmd/uns_attachments.go @@ -0,0 +1,176 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/url" + "strconv" + "strings" + + "github.com/FREEZONEX/Tier0-cli/internal/cmdutil" + "github.com/FREEZONEX/Tier0-cli/internal/i18n" + "github.com/FREEZONEX/Tier0-cli/internal/notice" + "github.com/spf13/cobra" +) + +var unsAttachmentsCmd = &cobra.Command{ + Use: "attachments", + Aliases: []string{"attachment", "files"}, + Short: i18n.T("Manage UNS attachments", "管理 UNS 附件"), + Long: i18n.T( + "Upload and list files bound to a UNS node by unsId.", + "按 unsId 上传和查询绑定到 UNS 节点的附件。", + ), +} + +var unsAttachmentUploadCmd = &cobra.Command{ + Use: "upload", + Short: i18n.T("Upload a file attachment to a UNS node", "上传 UNS 节点附件"), + RunE: runUnsAttachmentUpload, +} + +var unsAttachmentListCmd = &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: i18n.T("List attachments of a UNS node", "查询 UNS 节点附件"), + RunE: runUnsAttachmentList, +} + +func init() { + unsAttachmentsCmd.AddCommand(unsAttachmentUploadCmd) + unsAttachmentsCmd.AddCommand(unsAttachmentListCmd) + + unsAttachmentUploadCmd.Flags().Int64("uns-id", 0, + i18n.T("UNS node ID (required)", "UNS 节点 ID(必填)")) + unsAttachmentUploadCmd.Flags().StringP("file", "f", "", + i18n.T("Local file path to upload (required)", "要上传的本地文件路径(必填)")) + unsAttachmentUploadCmd.Flags().String("file-name", "", + i18n.T("Override attachment fileName", "覆盖附件 fileName")) + unsAttachmentUploadCmd.Flags().String("sha256", "", + i18n.T("Optional client-side sha256", "可选的客户端 sha256")) + unsAttachmentUploadCmd.MarkFlagRequired("uns-id") + unsAttachmentUploadCmd.MarkFlagRequired("file") + + unsAttachmentListCmd.Flags().Int64("uns-id", 0, + i18n.T("UNS node ID (required)", "UNS 节点 ID(必填)")) + unsAttachmentListCmd.Flags().Int("page-no", 1, + i18n.T("Page number", "页码")) + unsAttachmentListCmd.Flags().Int("page-size", 20, + i18n.T("Page size", "每页条数")) + unsAttachmentListCmd.Flags().Bool("include-file-url", true, + i18n.T("Include downloadable fileUrl", "返回可下载 fileUrl")) + unsAttachmentListCmd.MarkFlagRequired("uns-id") +} + +func runUnsAttachmentUpload(cmd *cobra.Command, args []string) error { + checker := notice.Start() + jsonMode, _ := cmd.Flags().GetBool("json") + debug, _ := cmd.Flags().GetBool("debug") + unsID, _ := cmd.Flags().GetInt64("uns-id") + filePath, _ := cmd.Flags().GetString("file") + fileName, _ := cmd.Flags().GetString("file-name") + sha256, _ := cmd.Flags().GetString("sha256") + + if unsID <= 0 { + return fmt.Errorf(i18n.T("uns-id is required", "uns-id 为必填")) + } + if strings.TrimSpace(filePath) == "" { + return fmt.Errorf(i18n.T("file is required", "file 为必填")) + } + + endpoint := fmt.Sprintf("/openapi/v1/uns/%d/attachments", unsID) + fields := map[string]string{ + "fileName": fileName, + "sha256": sha256, + } + resp, err := cmdutil.DoMultipart(cmd.Context(), endpoint, "file", filePath, fileName, fields, debug) + if err != nil { + return cmdutil.HandleCommandError(cmd.ErrOrStderr(), err, jsonMode) + } + if err := cmdutil.CheckOK(resp); err != nil { + return cmdutil.HandleCommandError(cmd.ErrOrStderr(), err, jsonMode) + } + + stdout := cmd.OutOrStdout() + if jsonMode { + checker.Emit(resp, true, stdout, cmd.ErrOrStderr()) + return nil + } + var result struct { + UnsID int64 `json:"unsId"` + FileName string `json:"fileName"` + FilePath string `json:"filePath"` + FileURL string `json:"fileUrl"` + } + if err := json.Unmarshal([]byte(cmdutil.ExtractData(resp)), &result); err != nil { + fmt.Fprintln(stdout, resp) + checker.Emit("", false, stdout, cmd.ErrOrStderr()) + return nil + } + fmt.Fprintf(stdout, i18n.T("✓ Attachment uploaded: %s\n", "✓ 附件上传成功: %s\n"), result.FileName) + fmt.Fprintf(stdout, "unsId: %d\nfilePath: %s\n", result.UnsID, result.FilePath) + if result.FileURL != "" { + fmt.Fprintf(stdout, "fileUrl: %s\n", result.FileURL) + } + checker.Emit("", false, stdout, cmd.ErrOrStderr()) + return nil +} + +func runUnsAttachmentList(cmd *cobra.Command, args []string) error { + checker := notice.Start() + jsonMode, _ := cmd.Flags().GetBool("json") + debug, _ := cmd.Flags().GetBool("debug") + unsID, _ := cmd.Flags().GetInt64("uns-id") + pageNo, _ := cmd.Flags().GetInt("page-no") + pageSize, _ := cmd.Flags().GetInt("page-size") + includeFileURL, _ := cmd.Flags().GetBool("include-file-url") + + if unsID <= 0 { + return fmt.Errorf(i18n.T("uns-id is required", "uns-id 为必填")) + } + q := url.Values{} + q.Set("pageNo", strconv.Itoa(pageNo)) + q.Set("pageSize", strconv.Itoa(pageSize)) + q.Set("includeFileUrl", strconv.FormatBool(includeFileURL)) + endpoint := fmt.Sprintf("/openapi/v1/uns/%d/attachments/list?%s", unsID, q.Encode()) + + resp, err := cmdutil.DoAPI(cmd.Context(), endpoint, "POST", "", debug) + if err != nil { + return cmdutil.HandleCommandError(cmd.ErrOrStderr(), err, jsonMode) + } + if err := cmdutil.CheckOK(resp); err != nil { + return cmdutil.HandleCommandError(cmd.ErrOrStderr(), err, jsonMode) + } + + stdout := cmd.OutOrStdout() + if jsonMode { + checker.Emit(resp, true, stdout, cmd.ErrOrStderr()) + return nil + } + var result struct { + List []struct { + FileName string `json:"fileName"` + FilePath string `json:"filePath"` + FileURL string `json:"fileUrl"` + } `json:"list"` + Total int64 `json:"total"` + } + if err := json.Unmarshal([]byte(cmdutil.ExtractData(resp)), &result); err != nil { + fmt.Fprintln(stdout, resp) + checker.Emit("", false, stdout, cmd.ErrOrStderr()) + return nil + } + if len(result.List) == 0 { + checker.Emit("", false, stdout, cmd.ErrOrStderr()) + fmt.Fprintln(stdout, i18n.T("No attachments found.", "暂无附件。")) + return nil + } + fmt.Fprintf(stdout, "%-30s %-48s %s\n", i18n.T("FileName", "文件名"), "FilePath", "FileUrl") + fmt.Fprintln(stdout, strings.Repeat("-", 120)) + for _, item := range result.List { + fmt.Fprintf(stdout, "%-30s %-48s %s\n", item.FileName, item.FilePath, item.FileURL) + } + fmt.Fprintf(stdout, i18n.T("Total: %d\n", "总数: %d\n"), result.Total) + checker.Emit("", false, stdout, cmd.ErrOrStderr()) + return nil +} diff --git a/cmd/uns_bind_flow.go b/cmd/uns_bind_flow.go new file mode 100644 index 0000000..d4bdf3f --- /dev/null +++ b/cmd/uns_bind_flow.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/FREEZONEX/Tier0-cli/internal/cmdutil" + "github.com/FREEZONEX/Tier0-cli/internal/i18n" + "github.com/FREEZONEX/Tier0-cli/internal/notice" + "github.com/spf13/cobra" +) + +var unsBindFlowCmd = &cobra.Command{ + Use: "bind-flow", + Short: i18n.T("Bind a UNS node to a SourceFlow", "绑定 UNS 节点到 SourceFlow"), + Long: i18n.T( + "Bind a UNS node to a SourceFlow using unsId and flow business ID.", + "使用 unsId 和 Flow 业务主键 ID 关联 UNS 节点与 SourceFlow。", + ), + RunE: runUnsBindFlow, +} + +func init() { + unsBindFlowCmd.Flags().Int64("uns-id", 0, + i18n.T("UNS node ID (required)", "UNS 节点 ID(必填)")) + unsBindFlowCmd.Flags().Int64("flow-id", 0, + i18n.T("Flow business ID (required)", "Flow 业务主键 ID(必填)")) + unsBindFlowCmd.MarkFlagRequired("uns-id") + unsBindFlowCmd.MarkFlagRequired("flow-id") +} + +func runUnsBindFlow(cmd *cobra.Command, args []string) error { + checker := notice.Start() + jsonMode, _ := cmd.Flags().GetBool("json") + debug, _ := cmd.Flags().GetBool("debug") + unsID, _ := cmd.Flags().GetInt64("uns-id") + flowID, _ := cmd.Flags().GetInt64("flow-id") + + if unsID == 0 && len(args) > 0 { + unsID, _ = strconv.ParseInt(args[0], 10, 64) + } + if flowID == 0 && len(args) > 1 { + flowID, _ = strconv.ParseInt(args[1], 10, 64) + } + if unsID <= 0 || flowID <= 0 { + return fmt.Errorf(i18n.T( + "specify --uns-id and --flow-id ", + "请指定 --uns-id 和 --flow-id ", + )) + } + + body, _ := json.Marshal(map[string]int64{ + "unsId": unsID, + "flowId": flowID, + }) + resp, err := cmdutil.DoAPI(cmd.Context(), "/openapi/v1/uns/unsBindFlow", "POST", string(body), debug) + if err != nil { + return cmdutil.HandleCommandError(cmd.ErrOrStderr(), err, jsonMode) + } + if err := cmdutil.CheckOK(resp); err != nil { + return cmdutil.HandleCommandError(cmd.ErrOrStderr(), err, jsonMode) + } + + stdout := cmd.OutOrStdout() + if jsonMode { + checker.Emit(resp, true, stdout, cmd.ErrOrStderr()) + return nil + } + fmt.Fprintf(stdout, i18n.T("✓ UNS %d bound to Flow %d\n", "✓ UNS %d 已绑定到 Flow %d\n"), unsID, flowID) + checker.Emit("", false, stdout, cmd.ErrOrStderr()) + return nil +} diff --git a/cmd/uns_create.go b/cmd/uns_create.go index 68a876c..626256d 100644 --- a/cmd/uns_create.go +++ b/cmd/uns_create.go @@ -38,6 +38,8 @@ func init() { i18n.T("Topic type", "Topic 类型")) unsCreateCmd.Flags().String("fields", "", i18n.T("Schema fields JSON array (e.g. '[{\"name\":\"temp\",\"type\":\"float\"}]')", "Schema 字段 JSON 数组")) + unsCreateCmd.Flags().Bool("persistence", false, + i18n.T("Persist topic values to history storage", "持久化保存点位历史数据")) } func runUnsCreate(cmd *cobra.Command, args []string) error { @@ -52,6 +54,7 @@ func runUnsCreate(cmd *cobra.Command, args []string) error { file, _ := cmd.Flags().GetString("file") topicType, _ := cmd.Flags().GetString("topic-type") fields, _ := cmd.Flags().GetString("fields") + persistence, _ := cmd.Flags().GetBool("persistence") var namespace []any @@ -93,6 +96,9 @@ func runUnsCreate(cmd *cobra.Command, args []string) error { } node["fields"] = fieldList } + if persistence { + node["persistence"] = true + } namespace = []any{node} } diff --git a/internal/auth/setup.go b/internal/auth/setup.go index f6b4071..c0bf10c 100644 --- a/internal/auth/setup.go +++ b/internal/auth/setup.go @@ -90,7 +90,7 @@ func PollSetupCheck(ctx context.Context, baseURL, setupCode string, onPoll func( APIKey string `json:"apiKey"` WorkspaceID string `json:"workspaceID"` WorkspaceName string `json:"workspaceName"` - ExpiresAt string `json:"expiresAt"` + ExpiresAt any `json:"expiresAt"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { @@ -103,7 +103,7 @@ func PollSetupCheck(ctx context.Context, baseURL, setupCode string, onPoll func( } resp.Body.Close() - if result.Code != 200 { + if result.Code != 0 && result.Code != 200 { err := fmt.Errorf("setup-check failed: %s", result.Msg) if onPoll != nil { onPoll(i, maxPollCount, false, err) diff --git a/internal/client/client.go b/internal/client/client.go index 2c97e5b..0c8ec4b 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -6,8 +6,10 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "os" + "path/filepath" "regexp" "strings" "time" @@ -100,6 +102,95 @@ func (c *Client) DoAPI(ctx context.Context, endpoint, method, body string, debug return string(respBody), nil } +// DoMultipart uploads one file plus optional form fields to an API endpoint. +func (c *Client) DoMultipart(ctx context.Context, endpoint, fileField, filePath, fileName string, fields map[string]string, debug bool) (string, error) { + if fileField == "" { + fileField = "file" + } + file, err := os.Open(filePath) + if err != nil { + return "", fmt.Errorf("打开文件失败: %w", err) + } + defer file.Close() + + if strings.TrimSpace(fileName) == "" { + fileName = filepath.Base(filePath) + } + + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + for key, value := range fields { + if value == "" { + continue + } + if err := writer.WriteField(key, value); err != nil { + return "", fmt.Errorf("写入表单字段失败: %w", err) + } + } + part, err := writer.CreateFormFile(fileField, fileName) + if err != nil { + return "", fmt.Errorf("创建文件表单失败: %w", err) + } + if _, err := io.Copy(part, file); err != nil { + return "", fmt.Errorf("读取文件失败: %w", err) + } + if err := writer.Close(); err != nil { + return "", fmt.Errorf("关闭 multipart writer 失败: %w", err) + } + + url := c.baseURL + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, &buf) + if err != nil { + return "", fmt.Errorf("构建请求失败: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + if c.apiKey != "" { + req.Header.Set("x-api-key", c.apiKey) + } + + if debug { + fmt.Fprintf(os.Stderr, "[debug] ---------- HTTP Request ----------\n") + fmt.Fprintf(os.Stderr, "[debug] %s %s\n", req.Method, req.URL.String()) + for key, values := range req.Header { + for _, v := range values { + if strings.EqualFold(key, "x-api-key") { + v = v[:min(len(v), 8)] + "..." + } + fmt.Fprintf(os.Stderr, "[debug] %s: %s\n", key, v) + } + } + fmt.Fprintf(os.Stderr, "[debug] Multipart file: field=%s path=%s name=%s\n", fileField, filePath, fileName) + fmt.Fprintf(os.Stderr, "[debug] ----------------------------------\n") + } + + resp, err := c.client.Do(req) + if err != nil { + return "", fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("读取响应失败: %w", err) + } + + if debug { + fmt.Fprintf(os.Stderr, "[debug] ---------- HTTP Response ---------\n") + fmt.Fprintf(os.Stderr, "[debug] Status: %d %s\n", resp.StatusCode, resp.Status) + if len(respBody) > 4096 { + fmt.Fprintf(os.Stderr, "[debug] Body: %s... (%d bytes truncated)\n", string(respBody[:4096]), len(respBody)) + } else { + fmt.Fprintf(os.Stderr, "[debug] Body: %s\n", string(respBody)) + } + fmt.Fprintf(os.Stderr, "[debug] ----------------------------------\n") + } + + if resp.StatusCode >= 400 { + return "", apierr.New(resp.StatusCode, string(respBody)) + } + return string(respBody), nil +} + // fixJSON 尝试修复 PowerShell 等环境导致的 JSON 引号丢失 // 例如 {path:/} → {"path":"/"} func fixJSON(body string) string { diff --git a/internal/cmdutil/helpers.go b/internal/cmdutil/helpers.go index 4321046..9bf09ee 100644 --- a/internal/cmdutil/helpers.go +++ b/internal/cmdutil/helpers.go @@ -29,6 +29,19 @@ func DoAPI(ctx context.Context, endpoint, method, body string, debug bool) (stri return c.DoAPI(ctx, endpoint, method, body, debug) } +// DoMultipart loads the profile, creates a client, and uploads a multipart file. +func DoMultipart(ctx context.Context, endpoint, fileField, filePath, fileName string, fields map[string]string, debug bool) (string, error) { + profile, err := config.LoadProfile() + if err != nil { + return "", fmt.Errorf(i18n.T("failed to load config: %w", "加载配置失败: %w"), err) + } + if profile.APIKey == "" { + return "", apierr.New(401, `{"code":401,"msg":"API Key not found"}`) + } + c := client.New(profile.BaseURL, profile.APIKey) + return c.DoMultipart(ctx, endpoint, fileField, filePath, fileName, fields, debug) +} + // ResolveBaseURL resolves the effective base URL from arg, env, or config. func ResolveBaseURL(baseURLArg string) string { if baseURLArg != "" { @@ -90,6 +103,7 @@ func JSONString(v any) string { // - code 200 → success // - code 0 → field absent / zero-valued, treat as success // - anything else (400, 500, …) → failure +// - data.success false → bulk operation has failed items // // HTTP-level errors (4xx/5xx status) are already handled by DoAPI; this catches // the cases where the server responds HTTP 200 but embeds an error in the body. @@ -97,6 +111,10 @@ func CheckOK(resp string) error { var rv struct { Code int `json:"code"` Msg string `json:"msg"` + Data struct { + Success *bool `json:"success"` + Results []bulkResultItem `json:"results"` + } `json:"data"` } if err := json.Unmarshal([]byte(resp), &rv); err != nil { return nil // not a standard envelope, assume OK @@ -104,9 +122,45 @@ func CheckOK(resp string) error { if rv.Code != 0 && rv.Code != 200 { return apierr.New(rv.Code, resp) } + if rv.Data.Success != nil && !*rv.Data.Success { + code, msg := firstBulkError(rv.Data.Results) + if msg == "" { + msg = "batch operation failed" + } + if code == 0 { + code = 400 + } + return apierr.New(code, JSONString(map[string]any{"code": code, "msg": msg})) + } return nil } +type bulkResultItem struct { + Success *bool `json:"success"` + Topic string `json:"topic"` + Error *struct { + Code int `json:"code"` + Message string `json:"message"` + } `json:"error,omitempty"` +} + +func firstBulkError(results []bulkResultItem) (int, string) { + for _, item := range results { + if item.Success != nil && *item.Success { + continue + } + if item.Error == nil { + continue + } + msg := item.Error.Message + if item.Topic != "" && msg != "" { + msg = item.Topic + ": " + msg + } + return item.Error.Code, msg + } + return 0, "" +} + // ExtractData unwraps the standard backend envelope {"code":N,"msg":"...","data":{...}} // and returns the raw JSON of the "data" field. // If the response has no "data" field the original string is returned unchanged,