Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
npx skills add Pippit-dev/cli --skill xyq-marketing-skill
```

需要 Node.js 16+,使用 Skill 内自包含脚本,无额外 npm 依赖。脚本从当前进程读取 `XYQ_ACCESS_KEY`;API 与 CLI 可使用同一 Access Key,但 CLI 浏览器登录不会设置此环境变量。密钥在本机安全配置,不写入请求 JSON 或命令参数。
需要 Node.js 16+ 和支持 `marketing` 命令的 CLI。Skill 脚本通过原生 `marketing` 命令复用 CLI 登录态;先运行 `pippit-tool-cli status`,未登录时执行 `pippit-tool-cli login` 完成浏览器授权,无需用户提供 access_token 或 Access Key。生成请求通过 stdin 传入原生 CLI,凭据仅在 CLI 内用于鉴权。

```bash
# 从仓库根目录执行;营销请求字段见接口契约,默认只预览
Expand Down
144 changes: 144 additions & 0 deletions cmd/marketing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"io"
"mime"
"os"
"path/filepath"
"strings"
"time"

"github.com/Pippit-dev/pippit-cli/internal/common"
"github.com/Pippit-dev/pippit-cli/internal/config"
"github.com/spf13/cobra"
)

var marketingPaths = map[string]string{
"generate": "/api/biz/v1/agent/submit_marketing_run",
"query": "/api/biz/v1/agent/query_generate_video_result",
"upload": config.UploadFilePath,
"balance": config.GetCreditBalancePath,
}

// Marketing uses the same AuthManager as login/status and all other commands.
// Only fixed public endpoints are exposed; credentials never leave the CLI.
func newMarketingCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
root := &cobra.Command{Use: "marketing", Short: "Marketing API using the shared CLI login"}
root.SetOut(stdout)
root.SetErr(stderr)
for _, action := range []string{"generate", "query", "upload", "balance"} {
root.AddCommand(newMarketingAction(action, stdout, stderr, runner))
}
return root
}

func newMarketingAction(action string, stdout, stderr io.Writer, runner *common.Runner) *cobra.Command {
var requestFile, file, threadID, runID string
var execute bool
var timeout time.Duration
command := &cobra.Command{Use: action, Args: cobra.NoArgs, Short: "Call marketing " + action}
command.SetOut(stdout)
command.SetErr(stderr)
command.Flags().DurationVar(&timeout, "timeout", 60*time.Second, "request deadline (e.g. 60s)")
switch action {
case "generate":
command.Flags().StringVar(&requestFile, "request", "", "request JSON file, or - for stdin")
command.Flags().BoolVar(&execute, "execute", false, "submit generation; otherwise preview only")
case "query":
command.Flags().StringVar(&threadID, "thread-id", "", "marketing thread ID")
command.Flags().StringVar(&runID, "run-id", "", "marketing run ID")
case "upload":
command.Flags().StringVar(&file, "file", "", "local media file")
}
command.RunE = func(cmd *cobra.Command, _ []string) error {
if timeout <= 0 || timeout > 30*time.Minute {
return fmt.Errorf("timeout 必须大于 0 且不超过 30m")
}
var body any = map[string]any{}
switch action {
case "generate":
if requestFile == "" {
return fmt.Errorf("缺少必填参数 --request")
}
reader := cmd.InOrStdin()
if requestFile != "-" {
f, err := os.Open(requestFile)
if err != nil {
return err
}
defer f.Close()
reader = f
}
var value map[string]json.RawMessage
decoder := json.NewDecoder(io.LimitReader(reader, 8*1024*1024+1))
if err := decoder.Decode(&value); err != nil {
return fmt.Errorf("请求 JSON 无效: %w", err)
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
return fmt.Errorf("请求只能包含一个 JSON 对象")
}
for key := range value {
if key != "message" && key != "asset_ids" && key != "thread_id" && key != "general_agent_settings" {
return fmt.Errorf("未支持的营销请求字段: %s", key)
}
}
var message string
var settings struct {
VideoModel string `json:"video_model"`
}
if json.Unmarshal(value["message"], &message) != nil || strings.TrimSpace(message) == "" {
return fmt.Errorf("message 必须为非空字符串")
}
if json.Unmarshal(value["general_agent_settings"], &settings) != nil || strings.TrimSpace(settings.VideoModel) == "" {
return fmt.Errorf("general_agent_settings.video_model 必填")
}
body = value
if !execute {
return common.WriteJSON(stdout, map[string]any{"dry_run": true, "url": config.DefaultBaseURL + marketingPaths[action], "body": body})
}
case "query":
if strings.TrimSpace(threadID) == "" || strings.TrimSpace(runID) == "" {
return fmt.Errorf("query 需要 --thread-id 和 --run-id")
}
body = map[string]string{"thread_id": threadID, "run_id": runID}
case "upload":
if err := validateMediaUpload(file); err != nil {
return err
}
info, err := os.Stat(file)
if err != nil {
return err
}
if info.Size() == 0 {
return fmt.Errorf("上传需要非空文件")
}
}
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel()
client := common.NewNonRedirectingHTTPClient(runner.Config.BaseURL, timeout, newRunnerAuthorizer(runner))
var result map[string]json.RawMessage
var err error
if action == "upload" {
contentType := mime.TypeByExtension(strings.ToLower(filepath.Ext(file)))
err = client.SendMultipartRequest(ctx, marketingPaths[action], nil, common.MultipartFile{FieldName: "file", Path: file, ContentType: contentType}, &result)
} else {
err = client.SendRequest(ctx, marketingPaths[action], body, &result)
}
if err != nil {
return err
}
ret := strings.TrimSpace(string(result["ret"]))
if ret != `"0"` && ret != "0" {
var message, logID string
_ = json.Unmarshal(result["errmsg"], &message)
_ = json.Unmarshal(result["log_id"], &logID)
return common.NewLogIDError(fmt.Sprintf("营销 API 失败: ret=%s errmsg=%s", ret, message), logID)
}
return common.WriteJSON(stdout, result)
}
return command
}
179 changes: 179 additions & 0 deletions cmd/marketing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package cmd

import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/Pippit-dev/pippit-cli/internal/auth"
"github.com/Pippit-dev/pippit-cli/internal/config"
)

type marketingCredentialStore struct {
auth.CredentialStore
credential *auth.Credential
loads int
}

func (s *marketingCredentialStore) Load(context.Context) (*auth.Credential, error) {
s.loads++
if s.credential == nil {
return nil, auth.ErrCredentialNotFound
}
return s.credential, nil
}

const marketingRequest = `{"message":"make an ad","general_agent_settings":{"video_model":"chosen-model","show_subtitle":false}}`

func TestMarketingUsesSharedBrowserAuth(t *testing.T) {
for _, action := range []string{"generate", "query", "upload", "balance"} {
t.Run(action, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Header.Get("Authorization") != "Bearer browser-secret" {
t.Error("browser login not reused")
}
if r.URL.Path != marketingPaths[action] || r.Method != "POST" {
t.Error("wrong endpoint")
}
data, _ := io.ReadAll(r.Body)
if bytes.Contains(data, []byte("browser-secret")) {
t.Error("credential leaked into payload")
}
if action == "upload" && !bytes.Contains(data, []byte(`name="file"`)) {
t.Error("missing multipart file")
}
fmt.Fprint(w, `{"ret":"0","log_id":"log-test","data":{"thread_id":"thread","run_id":"run"}}`)
}))
defer server.Close()
cfg := config.Load()
cfg.BaseURL = server.URL
cfg.AccessKey = ""
store := &marketingCredentialStore{credential: &auth.Credential{AccessKey: "browser-secret", UID: "user", DeviceID: "device", ExpiredAt: time.Now().Add(time.Hour).Unix()}}
runner := newRootRunner(cfg)
runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
var output bytes.Buffer
root := newRootCommand(&output, io.Discard, runner)
args := []string{"marketing", action}
switch action {
case "generate":
args = append(args, "--request", "-", "--execute")
root.SetIn(strings.NewReader(marketingRequest))
case "query":
args = append(args, "--thread-id", "thread", "--run-id", "run")
case "upload":
file := filepath.Join(t.TempDir(), "product.png")
if err := os.WriteFile(file, []byte("image"), 0600); err != nil {
t.Fatal(err)
}
args = append(args, "--file", file)
}
root.SetArgs(args)
if err := root.Execute(); err != nil {
t.Fatal(err)
}
if calls != 1 || store.loads != 1 {
t.Fatalf("HTTP calls=%d credential loads=%d", calls, store.loads)
}
if strings.Contains(output.String(), "browser-secret") || !strings.Contains(output.String(), "log-test") {
t.Fatal("raw result or credential boundary broken")
}
})
}
}

func TestMarketingAuthFailureAndEnvironmentPrecedence(t *testing.T) {
for _, scenario := range []string{"missing", "expired", "override"} {
t.Run(scenario, func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
if r.Header.Get("Authorization") != "Bearer explicit-key" {
t.Error("environment override not preserved")
}
fmt.Fprint(w, `{"ret":"0","data":{}}`)
}))
defer server.Close()
cfg := config.Load()
cfg.BaseURL = server.URL
cfg.AccessKey = ""
store := &marketingCredentialStore{}
if scenario == "expired" {
store.credential = &auth.Credential{AccessKey: "expired", ExpiredAt: 1}
}
if scenario == "override" {
cfg.AccessKey = "explicit-key"
}
runner := newRootRunner(cfg)
runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
root := newRootCommand(io.Discard, io.Discard, runner)
root.SetArgs([]string{"marketing", "balance"})
err := root.Execute()
if scenario == "override" {
if err != nil || calls != 1 || store.loads != 0 {
t.Fatalf("override: err=%v calls=%d loads=%d", err, calls, store.loads)
}
} else if err == nil || !strings.Contains(err.Error(), "pippit-tool-cli login") || calls != 0 {
t.Fatalf("missing/expired: err=%v calls=%d", err, calls)
}
})
}
}

func TestMarketingPreviewAndHelpDoNotReadCredentials(t *testing.T) {
for _, args := range [][]string{{"marketing", "--help"}, {"marketing", "generate", "--request", "-"}, {"marketing", "query"}} {
cfg := config.Load()
cfg.AccessKey = ""
store := &marketingCredentialStore{}
runner := newRootRunner(cfg)
runner.Auth = auth.NewManager(cfg, auth.WithCredentialStore(store))
root := newRootCommand(io.Discard, io.Discard, runner)
root.SetIn(strings.NewReader(marketingRequest))
root.SetArgs(args)
err := root.Execute()
if args[1] != "query" && err != nil {
t.Fatal(err)
}
if args[1] == "query" && err == nil {
t.Fatal("missing IDs accepted")
}
if store.loads != 0 {
t.Fatal("offline operation accessed credentials")
}
}
}

func TestMarketingDoesNotReplaySubmission(t *testing.T) {
for _, status := range []int{302, 307, 504} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
calls := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls++
w.Header().Set("Location", "/unexpected")
w.WriteHeader(status)
}))
defer server.Close()
cfg := config.Load()
cfg.BaseURL = server.URL
cfg.AccessKey = "test-key"
root := newRootCommand(io.Discard, io.Discard, newRootRunner(cfg))
root.SetIn(strings.NewReader(marketingRequest))
root.SetArgs([]string{"marketing", "generate", "--request", "-", "--execute"})
if err := root.Execute(); err == nil {
t.Fatal("failed/redirected request succeeded")
}
if calls != 1 {
t.Fatalf("submission replayed %d times", calls)
}
})
}
}
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func newRootCommand(stdout, stderr io.Writer, runner *common.Runner) *cobra.Comm
root.AddCommand(newDownloadResultCommand(stdout, stderr, runner))
root.AddCommand(newGetCreditBalanceCommand(stdout, stderr, runner))
root.AddCommand(newModelCommand(stdout, stderr, runner))
root.AddCommand(newMarketingCommand(stdout, stderr, runner))
root.AddCommand(newGetThreadCommand(stdout, stderr, runner))
root.AddCommand(newSubmitRunCommand(stdout, stderr, runner))
root.AddCommand(newUploadFileCommand(stdout, stderr, runner))
Expand Down
12 changes: 11 additions & 1 deletion internal/common/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,17 @@ func NewHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuth
return newHTTPClient(baseURL, timeout, authorizer)
}

func newHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client {
// NewNonRedirectingHTTPClient keeps one-shot submissions from being replayed.
// Authentication and request handling still use the shared CLI client.
func NewNonRedirectingHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) Client {
client := newHTTPClient(baseURL, timeout, authorizer)
client.httpClient.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return fmt.Errorf("拒绝营销 API 重定向;请求未重试")
}
return client
}

func newHTTPClient(baseURL string, timeout time.Duration, authorizer RequestAuthorizer) *httpClient {
client := &httpClient{
baseURL: strings.TrimRight(baseURL, "/"),
headers: make(http.Header),
Expand Down
Loading
Loading