feat: 初始化shop-crm-agent项目,实现基础CLI与CRM操作能力

本提交初始化完整的shop-crm-agent项目:
1. 创建基础项目结构与配置文件(.gitignore、go.mod/go.sum)
2. 添加项目许可协议与文档(README.md、AGENTS.md、SKILL.md)
3. 实现核心CLI入口与命令框架
4. 封装Protobuf API客户端与完整的命令实现
5. 添加单元测试覆盖核心功能与边界情况
6. 提供构建脚本与版本管理能力
This commit is contained in:
2026-08-01 01:36:37 +08:00
commit 3466e19d13
15 changed files with 8554 additions and 0 deletions
+166
View File
@@ -0,0 +1,166 @@
package client
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
api "code.zhecent.com/open/shop-crm-agent/internal/api"
"google.golang.org/protobuf/proto"
)
const (
apiPath = "/api/shop/crm-agent/v1"
maxResponseSize = 16 << 20
)
type Client struct {
baseURL string
token string
httpClient *http.Client
}
func New(baseURL, token string, httpClient *http.Client) (*Client, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("LIGHTCORE_API_BASE_URL 必须是有效的 http/https URL")
}
if strings.TrimSpace(token) == "" {
return nil, errors.New("LIGHTCORE_SHOP_CRM_AGENT_TOKEN 不能为空")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 30 * time.Second}
}
return &Client{baseURL: baseURL, token: strings.TrimSpace(token), httpClient: httpClient}, nil
}
type responseWithHeader interface {
proto.Message
GetHeader() *api.ResponseHeader
}
func doRequest[Req proto.Message, Resp responseWithHeader](ctx context.Context, client *Client, path string, request Req, response Resp) (Resp, error) {
data, err := proto.Marshal(request)
if err != nil {
return response, fmt.Errorf("编码 Protobuf 请求失败: %w", err)
}
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+apiPath+path, bytes.NewReader(data))
if err != nil {
return response, err
}
httpRequest.Header.Set("Content-Type", "application/x-protobuf")
httpRequest.Header.Set("Accept", "application/x-protobuf")
httpRequest.Header.Set("Authorization", "Bearer "+client.token)
httpResponse, err := client.httpClient.Do(httpRequest)
if err != nil {
return response, err
}
defer httpResponse.Body.Close()
body, err := io.ReadAll(io.LimitReader(httpResponse.Body, maxResponseSize+1))
if err != nil {
return response, err
}
if len(body) > maxResponseSize {
return response, errors.New("CRM Agent API 响应超过16 MiB限制")
}
if err := proto.Unmarshal(body, response); err != nil {
return response, fmt.Errorf("解析 Protobuf 响应失败(HTTP %d: %w", httpResponse.StatusCode, err)
}
header := response.GetHeader()
if httpResponse.StatusCode < 200 || httpResponse.StatusCode >= 300 {
if header != nil && header.Message != "" {
return response, fmt.Errorf("CRM Agent API HTTP %d: %s", httpResponse.StatusCode, header.Message)
}
return response, fmt.Errorf("CRM Agent API HTTP %d", httpResponse.StatusCode)
}
if header == nil {
return response, errors.New("CRM Agent API 响应缺少 ResponseHeader")
}
if header.Code != 200 {
return response, fmt.Errorf("CRM Agent API 错误 %d: %s", header.Code, header.Message)
}
return response, nil
}
func (c *Client) CustomerList(ctx context.Context, req *api.CustomerListRequest) (*api.CustomerListResponse, error) {
return doRequest(ctx, c, "/customers/list", req, &api.CustomerListResponse{})
}
func (c *Client) ProjectList(ctx context.Context, req *api.ProjectListRequest) (*api.ProjectListResponse, error) {
return doRequest(ctx, c, "/projects/list", req, &api.ProjectListResponse{})
}
func (c *Client) ProjectGet(ctx context.Context, req *api.ProjectGetRequest) (*api.ProjectResponse, error) {
return doRequest(ctx, c, "/projects/get", req, &api.ProjectResponse{})
}
func (c *Client) ProjectCreate(ctx context.Context, req *api.ProjectCreateRequest) (*api.ProjectResponse, error) {
return doRequest(ctx, c, "/projects/create", req, &api.ProjectResponse{})
}
func (c *Client) ProjectUpdate(ctx context.Context, req *api.ProjectUpdateRequest) (*api.ProjectResponse, error) {
return doRequest(ctx, c, "/projects/update", req, &api.ProjectResponse{})
}
func (c *Client) ProjectStatus(ctx context.Context, req *api.ProjectStatusRequest) (*api.ProjectResponse, error) {
return doRequest(ctx, c, "/projects/status", req, &api.ProjectResponse{})
}
func (c *Client) ProjectDeletePreview(ctx context.Context, req *api.ProjectDeletePreviewRequest) (*api.DeletePreviewResponse, error) {
return doRequest(ctx, c, "/projects/delete/preview", req, &api.DeletePreviewResponse{})
}
func (c *Client) ProjectDelete(ctx context.Context, req *api.ProjectDeleteRequest) (*api.DeletePreviewResponse, error) {
return doRequest(ctx, c, "/projects/delete", req, &api.DeletePreviewResponse{})
}
func (c *Client) QuotationList(ctx context.Context, req *api.QuotationListRequest) (*api.QuotationListResponse, error) {
return doRequest(ctx, c, "/quotations/list", req, &api.QuotationListResponse{})
}
func (c *Client) QuotationGet(ctx context.Context, req *api.QuotationGetRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/get", req, &api.QuotationResponse{})
}
func (c *Client) QuotationCreate(ctx context.Context, req *api.QuotationCreateRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/create", req, &api.QuotationResponse{})
}
func (c *Client) QuotationCopy(ctx context.Context, req *api.QuotationCopyRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/copy", req, &api.QuotationResponse{})
}
func (c *Client) QuotationTitle(ctx context.Context, req *api.QuotationTitleRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/title", req, &api.QuotationResponse{})
}
func (c *Client) QuotationStatus(ctx context.Context, req *api.QuotationStatusRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/status", req, &api.QuotationResponse{})
}
func (c *Client) QuotationLock(ctx context.Context, req *api.QuotationLockRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/lock", req, &api.QuotationResponse{})
}
func (c *Client) QuotationUnlock(ctx context.Context, req *api.QuotationLockRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotations/unlock", req, &api.QuotationResponse{})
}
func (c *Client) QuotationDeletePreview(ctx context.Context, req *api.QuotationDeletePreviewRequest) (*api.DeletePreviewResponse, error) {
return doRequest(ctx, c, "/quotations/delete/preview", req, &api.DeletePreviewResponse{})
}
func (c *Client) QuotationDelete(ctx context.Context, req *api.QuotationDeleteRequest) (*api.DeletePreviewResponse, error) {
return doRequest(ctx, c, "/quotations/delete", req, &api.DeletePreviewResponse{})
}
func (c *Client) CatalogSearch(ctx context.Context, req *api.CatalogSearchRequest) (*api.CatalogSearchResponse, error) {
return doRequest(ctx, c, "/catalog/search", req, &api.CatalogSearchResponse{})
}
func (c *Client) EditBegin(ctx context.Context, req *api.QuotationEditBeginRequest) (*api.EditSessionResponse, error) {
return doRequest(ctx, c, "/quotation/edit/begin", req, &api.EditSessionResponse{})
}
func (c *Client) EditCommand(ctx context.Context, req *api.QuotationEditCommandRequest) (*api.QuotationEditCommandResponse, error) {
return doRequest(ctx, c, "/quotation/edit/command", req, &api.QuotationEditCommandResponse{})
}
func (c *Client) EditPreview(ctx context.Context, req *api.QuotationEditPreviewRequest) (*api.QuotationEditPreviewResponse, error) {
return doRequest(ctx, c, "/quotation/edit/preview", req, &api.QuotationEditPreviewResponse{})
}
func (c *Client) EditCommit(ctx context.Context, req *api.QuotationEditCommitRequest) (*api.QuotationResponse, error) {
return doRequest(ctx, c, "/quotation/edit/commit", req, &api.QuotationResponse{})
}
func (c *Client) EditDiscard(ctx context.Context, req *api.QuotationEditDiscardRequest) (*api.EditSessionResponse, error) {
return doRequest(ctx, c, "/quotation/edit/discard", req, &api.EditSessionResponse{})
}
+156
View File
@@ -0,0 +1,156 @@
package client
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
api "code.zhecent.com/open/shop-crm-agent/internal/api"
"google.golang.org/protobuf/proto"
)
func TestClientSendsBearerProtobufAndChecksBusinessHeader(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.URL.Path != apiPath+"/projects/get" {
t.Errorf("path = %s", request.URL.Path)
}
if request.Header.Get("Authorization") != "Bearer test-token" {
t.Errorf("authorization = %q", request.Header.Get("Authorization"))
}
if request.Header.Get("Content-Type") != "application/x-protobuf" {
t.Errorf("content type = %q", request.Header.Get("Content-Type"))
}
data, _ := proto.Marshal(&api.ProjectResponse{Header: &api.ResponseHeader{Code: 200, Message: "success"}, Project: &api.Project{Id: 9}})
writer.Header().Set("Content-Type", "application/x-protobuf")
_, _ = writer.Write(data)
}))
defer server.Close()
client, err := New(server.URL, "test-token", server.Client())
if err != nil {
t.Fatalf("new client: %v", err)
}
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{ProjectId: 9})
if err != nil {
t.Fatalf("project get: %v", err)
}
if response.Project.GetId() != 9 {
t.Fatalf("project id = %d", response.Project.GetId())
}
}
func TestClientReportsHTTPProtocolAndSizeFailures(t *testing.T) {
t.Run("http error preserves protobuf header", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
writer.WriteHeader(http.StatusUnauthorized)
data, _ := proto.Marshal(&api.ProjectResponse{Header: &api.ResponseHeader{Code: 401, Message: "unauthorized"}})
_, _ = writer.Write(data)
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
if err == nil || !strings.Contains(err.Error(), "HTTP 401: unauthorized") {
t.Fatalf("error = %v", err)
}
if response.GetHeader().GetCode() != 401 {
t.Fatalf("header = %#v", response.GetHeader())
}
})
t.Run("malformed protobuf", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write([]byte{0xff})
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
if _, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{}); err == nil || !strings.Contains(err.Error(), "解析 Protobuf 响应失败") {
t.Fatalf("error = %v", err)
}
})
t.Run("response size limit", func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
_, _ = writer.Write(make([]byte, maxResponseSize+1))
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
if _, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{}); err == nil || !strings.Contains(err.Error(), "16 MiB") {
t.Fatalf("error = %v", err)
}
})
}
func TestClientHonorsHTTPClientTimeout(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
time.Sleep(50 * time.Millisecond)
}))
defer server.Close()
httpClient := server.Client()
httpClient.Timeout = time.Millisecond
client, _ := New(server.URL, "token", httpClient)
_, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
if err == nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v", err)
}
}
func TestClientRepresentativeOperationRoutes(t *testing.T) {
var paths []string
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
paths = append(paths, request.URL.Path)
var response proto.Message
switch request.URL.Path {
case apiPath + "/projects/create":
response = &api.ProjectResponse{Header: &api.ResponseHeader{Code: 200}}
case apiPath + "/quotations/delete/preview":
response = &api.DeletePreviewResponse{Header: &api.ResponseHeader{Code: 200}}
case apiPath + "/quotation/edit/command":
response = &api.QuotationEditCommandResponse{Header: &api.ResponseHeader{Code: 200}}
default:
t.Fatalf("unexpected path %q", request.URL.Path)
}
data, _ := proto.Marshal(response)
_, _ = writer.Write(data)
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
if _, err := client.ProjectCreate(context.Background(), &api.ProjectCreateRequest{}); err != nil {
t.Fatal(err)
}
if _, err := client.QuotationDeletePreview(context.Background(), &api.QuotationDeletePreviewRequest{}); err != nil {
t.Fatal(err)
}
if _, err := client.EditCommand(context.Background(), &api.QuotationEditCommandRequest{}); err != nil {
t.Fatal(err)
}
want := []string{apiPath + "/projects/create", apiPath + "/quotations/delete/preview", apiPath + "/quotation/edit/command"}
if strings.Join(paths, "|") != strings.Join(want, "|") {
t.Fatalf("paths = %v", paths)
}
}
func TestClientRejectsMissingConfigurationAndBusinessError(t *testing.T) {
if _, err := New("", "token", nil); err == nil {
t.Fatal("empty base URL accepted")
}
if _, err := New("https://example.com", "", nil); err == nil {
t.Fatal("empty token accepted")
}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
data, _ := proto.Marshal(&api.CommonResponse{Header: &api.ResponseHeader{Code: 400, Message: "bad request", RequestId: "request-123", Replayed: true}})
_, _ = writer.Write(data)
}))
defer server.Close()
client, _ := New(server.URL, "token", server.Client())
response, err := client.ProjectGet(context.Background(), &api.ProjectGetRequest{})
if err == nil {
t.Fatal("business error was accepted")
}
if response.GetHeader() == nil || !response.GetHeader().Replayed || response.GetHeader().RequestId != "request-123" {
t.Fatalf("business error response = %#v", response.GetHeader())
}
}