diff --git a/client.go b/client.go new file mode 100644 index 0000000..1859c0c --- /dev/null +++ b/client.go @@ -0,0 +1,378 @@ +// Package sdk_LinkedMall 是 LinkedMall OpenAPI 的 Go SDK。 +// 当前覆盖指定的 25 个接口,覆盖:采购方店铺、选品池、类目、搜索、 +// 采购单(渲染/拆单/创建/状态)、订单、物流、确认收货、售后、区域编码等。 +// +// 客户端基于阿里云通用 OpenAPI 客户端(darabonba-openapi/v2)发起 ROA 调用, +// ROA 签名由底层客户端自动完成。所有接口返回统一外层结构 CommonResponse, +// 必须判断 Success 字段为 true 才代表业务成功。 +package sdk_LinkedMall + +import ( + "context" + "encoding/json" + "fmt" + "io" + "reflect" + "strconv" + "strings" + + openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client" +) + +// 常量定义。 +const ( + // RegionID 为华北3(张家口)。 + RegionID = "cn-zhangjiakou" + // Endpoint 为网关访问地址。 + Endpoint = "linkedmall.cn-zhangjiakou.aliyuncs.com" + // APIVersion 为 API 版本号。 + APIVersion = "linkedmall/2023-09-30" + // BasePath 为 HTTP 基础路径前缀。 + BasePath = "/opensaas-s2b/opensaas-s2b-biz-trade/v2" +) + +// Client 是 LinkedMall OpenAPI 客户端。 +// +// 通过 RAM 子账号 AccessKey 初始化,禁止使用主账号 AccessKey; +// 签名类型为 ROA 签名,由底层 darabonba-openapi/v2 客户端自动处理,业务无需自行实现。 +type Client struct { + api *openapi.Client +} + +// NewClient 使用 RAM 子账号 AccessKeyId / AccessKeySecret 创建客户端。 +// +// 建议通过环境变量注入密钥,避免硬编码到代码中。 +func NewClient(accessKeyID, accessKeySecret string) (*Client, error) { + config := &openapi.Config{ + AccessKeyId: strPtr(accessKeyID), + AccessKeySecret: strPtr(accessKeySecret), + RegionId: strPtr(RegionID), + Endpoint: strPtr(Endpoint), + ReadTimeout: intPtr(30000), + ConnectTimeout: intPtr(10000), + } + api, err := openapi.NewClient(config) + if err != nil { + return nil, fmt.Errorf("create openapi client: %w", err) + } + return &Client{api: api}, nil +} + +// NewLinkedMallClient 是 NewClient 的别名,便于文档示例直接使用。 +func NewLinkedMallClient(accessKeyID, accessKeySecret string) (*Client, error) { + return NewClient(accessKeyID, accessKeySecret) +} + +// doRoa 发起一次 ROA 请求并解析统一的公共响应结构。 +// +// dataOut 用于接收业务 Data,传入对应响应数据类型的指针; +// 若为 nil,则 Data 保持为 json.RawMessage。 +func (c *Client) doRoa(ctx context.Context, method, path string, query map[string]*string, body interface{}, dataOut interface{}) (*CommonResponse, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + fullPath := BasePath + path + var bodyBytes []byte + var err error + if body != nil { + bodyBytes, err = MarshalJSON(body) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + } + resp, err := c.api.RoaRequest(strPtr(method), strPtr(fullPath), query, bodyBytes, nil) + if err != nil { + return nil, fmt.Errorf("roa request failed [%s %s]: %w", method, fullPath, err) + } + if resp == nil || resp.Body == nil { + return nil, fmt.Errorf("roa request returned empty response [%s %s]", method, fullPath) + } + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + var raw rawResponse + if err := json.Unmarshal(respBytes, &raw); err != nil { + return nil, fmt.Errorf("unmarshal response [%s %s] body=%s: %w", method, fullPath, string(respBytes), err) + } + out := &CommonResponse{ + RequestId: raw.RequestId, + Success: raw.Success, + Code: raw.Code, + Message: raw.Message, + SubCode: raw.SubCode, + SubMessage: raw.SubMessage, + } + if len(raw.Data) > 0 { + if dataOut != nil { + if err := json.Unmarshal(raw.Data, dataOut); err != nil { + return nil, fmt.Errorf("unmarshal response Data [%s %s]: %w", method, fullPath, err) + } + out.Data = dataOut + } else { + out.Data = raw.Data + } + } + return out, nil +} + +// structToQuery 将请求结构体转换为 ROA query 参数。 +// 仅序列化带 json tag 且非零值的字段;json:"-" 或空值字段被忽略。 +func structToQuery(v interface{}) map[string]*string { + query := make(map[string]*string) + if v == nil { + return query + } + rv := reflect.ValueOf(v) + if rv.Kind() == reflect.Ptr { + if rv.IsNil() { + return query + } + rv = rv.Elem() + } + if rv.Kind() != reflect.Struct { + return query + } + rt := rv.Type() + for i := 0; i < rv.NumField(); i++ { + sf := rt.Field(i) + if !sf.IsExported() { + continue + } + name := sf.Tag.Get("json") + if name == "" || name == "-" { + continue + } + if idx := strings.Index(name, ","); idx >= 0 { + name = name[:idx] + } + if name == "" { + continue + } + fv := rv.Field(i) + switch fv.Kind() { + case reflect.String: + if s := fv.String(); s != "" { + query[name] = strPtr(s) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if n := fv.Int(); n != 0 { + query[name] = strPtr(strconv.FormatInt(n, 10)) + } + case reflect.Bool: + if fv.Bool() { + query[name] = strPtr("true") + } + } + } + return query +} + +// buildPath 将路径中的 {Param} 占位符替换为实际值。 +func buildPath(path string, params map[string]string) string { + for k, v := range params { + path = strings.ReplaceAll(path, "{"+k+"}", v) + } + return path +} + +// ListPurchaserShops 分页获取采购方店铺列表。 +// +// 请求字段:PageNum(页码,默认1)、PageSize(每页条数,最大100)。 +// 返回 Data 为 *ListPurchaserShopsRespData。 +// 必须判断 resp.Success 为 true 才算业务成功。 +func (c *Client) ListPurchaserShops(ctx context.Context, req *ListPurchaserShopsRequest) (*CommonResponse, error) { + return c.doRoa(ctx, "GET", "/purchaser-shops", structToQuery(req), nil, &ListPurchaserShopsRespData{}) +} + +// GetPurchaserShop 获取单个采购店铺详情。 +// +// ShopId 为路径参数。返回 Data 为 *GetPurchaserShopRespData。 +func (c *Client) GetPurchaserShop(ctx context.Context, req *GetPurchaserShopRequest) (*CommonResponse, error) { + path := buildPath("/purchaser-shops/{ShopId}", map[string]string{"ShopId": req.ShopId}) + return c.doRoa(ctx, "GET", path, structToQuery(req), nil, &GetPurchaserShopRespData{}) +} + +// ListSelectionProducts 分页查询选品池商品列表。 +// +// 请求字段:PageNum、PageSize(最大100)、PurchaserId(必填)。 +// 返回 Data 为 *ListSelectionProductsRespData。 +func (c *Client) ListSelectionProducts(ctx context.Context, req *ListSelectionProductsRequest) (*CommonResponse, error) { + return c.doRoa(ctx, "GET", "/selection-products", structToQuery(req), nil, &ListSelectionProductsRespData{}) +} + +// GetSelectionProduct 查询选品池商品详情。 +// +// ProductId 为路径参数;支持传入五级乡镇 DivisionCode 校验该区域是否可售。 +// 返回 Data 为 *GetSelectionProductRespData。 +func (c *Client) GetSelectionProduct(ctx context.Context, req *GetSelectionProductRequest) (*CommonResponse, error) { + path := buildPath("/selection-products/{ProductId}", map[string]string{"ProductId": req.ProductId}) + return c.doRoa(ctx, "GET", path, structToQuery(req), nil, &GetSelectionProductRespData{}) +} + +// ListCategories 查询类目列表。 +// +// 不传 ParentCategoryId 时查询一级类目。返回 Data 为 *ListCategoriesRespData。 +func (c *Client) ListCategories(ctx context.Context, req *ListCategoriesRequest) (*CommonResponse, error) { + return c.doRoa(ctx, "GET", "/categories", structToQuery(req), nil, &ListCategoriesRespData{}) +} + +// SearchProducts 搜索选品池商品。 +// +// 返回 Data 为 *SearchProductsRespData。 +func (c *Client) SearchProducts(ctx context.Context, req *SearchProductsRequest) (*CommonResponse, error) { + return c.doRoa(ctx, "POST", "/selection-products:search", nil, req, &SearchProductsRespData{}) +} + +// SelectionGroupAddProduct 选品池商品入库。 +// +// 返回 Data 为 *SelectionGroupChangeProductRespData。 +func (c *Client) SelectionGroupAddProduct(ctx context.Context, req *SelectionGroupAddProductRequest) (*CommonResponse, error) { + return c.doRoa(ctx, "POST", "/selection-group/products:add", nil, req, &SelectionGroupChangeProductRespData{}) +} + +// SelectionGroupRemoveProduct 选品池商品出库。 +// +// 返回 Data 为 *SelectionGroupChangeProductRespData。 +func (c *Client) SelectionGroupRemoveProduct(ctx context.Context, req *SelectionGroupRemoveProductRequest) (*CommonResponse, error) { + return c.doRoa(ctx, "POST", "/selection-group/products:remove", nil, req, &SelectionGroupChangeProductRespData{}) +} + +// RenderPurchaseOrder 采购单渲染(下单预校验)。 +// +// 下单前必须调用,获取实时价格、校验商品是否可售。 +// 返回 Data 为 *RenderPurchaseOrderRespData。 +func (c *Client) RenderPurchaseOrder(ctx context.Context, req *RenderPurchaseOrderReq) (*CommonResponse, error) { + return c.doRoa(ctx, "POST", "/purchase-orders:render", nil, req, &RenderPurchaseOrderRespData{}) +} + +// SplitPurchaseOrder 采购单渲染并拆单。 +// +// 请求体结构与 RenderPurchaseOrderReq 完全一致。 +// 返回 Data 为 *SplitPurchaseOrderRespData,拆单结果用于创建采购单入参。 +func (c *Client) SplitPurchaseOrder(ctx context.Context, req *RenderPurchaseOrderReq) (*CommonResponse, error) { + return c.doRoa(ctx, "POST", "/purchase-orders:split-render", nil, req, &SplitPurchaseOrderRespData{}) +} + +// CreatePurchaseOrder 创建采购单【异步】。 +// +// 注意:接口只返回采购单号,不代表下单成功,必须等待回调通知后再查询订单接口。 +// 返回 Data 为 *CreatePurchaseOrderRespData(PurchaseOrderId)。 +func (c *Client) CreatePurchaseOrder(ctx context.Context, req *CreatePurchaseOrderReq) (*CommonResponse, error) { + return c.doRoa(ctx, "POST", "/purchase-orders", nil, req, &CreatePurchaseOrderRespData{}) +} + +// GetPurchaseOrderStatus 获取采购单状态。 +// +// PurchaseOrderId 为路径参数。返回 Data 为 *GetPurchaseOrderStatusRespData。 +// 状态枚举:INIT、PROCESS、SUCCESS、FAIL、CLOSED。 +func (c *Client) GetPurchaseOrderStatus(ctx context.Context, req *GetPurchaseOrderStatusRequest) (*CommonResponse, error) { + path := buildPath("/purchase-orders/{PurchaseOrderId}/status", map[string]string{"PurchaseOrderId": req.PurchaseOrderId}) + return c.doRoa(ctx, "GET", path, structToQuery(req), nil, &GetPurchaseOrderStatusRespData{}) +} + +// GetOrder 获取订单详情。 +// +// OrderId 为路径参数。返回 Data 为 *GetOrderRespData。 +func (c *Client) GetOrder(ctx context.Context, req *GetOrderRequest) (*CommonResponse, error) { + path := buildPath("/orders/{OrderId}", map[string]string{"OrderId": req.OrderId}) + return c.doRoa(ctx, "GET", path, structToQuery(req), nil, &GetOrderRespData{}) +} + +// QueryOrders 查询订单列表。 +// +// 返回 Data 为 *QueryOrdersRespData。 +func (c *Client) QueryOrders(ctx context.Context, req *QueryOrdersRequest) (*CommonResponse, error) { + return c.doRoa(ctx, "GET", "/orders", structToQuery(req), nil, &QueryOrdersRespData{}) +} + +// ListLogisticsOrders 查询订单物流信息。 +// +// OrderId 为路径参数。返回 Data 为 *ListLogisticsOrdersRespData。 +func (c *Client) ListLogisticsOrders(ctx context.Context, req *ListLogisticsOrdersRequest) (*CommonResponse, error) { + path := buildPath("/orders/{OrderId}/logistics", map[string]string{"OrderId": req.OrderId}) + return c.doRoa(ctx, "GET", path, structToQuery(req), nil, &ListLogisticsOrdersRespData{}) +} + +// ConfirmDisburse 确认收货。 +// +// OrderId 为路径参数,请求体为 {"PurchaserId":"xxx"}。 +func (c *Client) ConfirmDisburse(ctx context.Context, req *ConfirmDisburseRequest) (*CommonResponse, error) { + path := buildPath("/orders/{OrderId}:confirm-disburse", map[string]string{"OrderId": req.OrderId}) + body := map[string]string{"PurchaserId": req.PurchaserId} + return c.doRoa(ctx, "POST", path, nil, body, nil) +} + +// RenderRefundOrder 售后渲染预校验。 +// +// 返回 Data 为 *RenderRefundOrderRespData。 +func (c *Client) RenderRefundOrder(ctx context.Context, req *RenderRefundOrderReq) (*CommonResponse, error) { + return c.doRoa(ctx, "POST", "/refund-orders:render", nil, req, &RenderRefundOrderRespData{}) +} + +// CreateRefundOrder 创建售后单。 +// +// 返回 Data 为 *CreateRefundOrderRespData(RefundOrderId)。 +func (c *Client) CreateRefundOrder(ctx context.Context, req *CreateRefundOrderReq) (*CommonResponse, error) { + return c.doRoa(ctx, "POST", "/refund-orders", nil, req, &CreateRefundOrderRespData{}) +} + +// CancelRefundOrder 取消售后单。 +// +// RefundOrderId 为路径参数,请求体为 {"PurchaserId":"xxx"}。 +func (c *Client) CancelRefundOrder(ctx context.Context, req *CancelRefundOrderRequest) (*CommonResponse, error) { + path := buildPath("/refund-orders/{RefundOrderId}:cancel", map[string]string{"RefundOrderId": req.RefundOrderId}) + body := map[string]string{"PurchaserId": req.PurchaserId} + return c.doRoa(ctx, "POST", path, nil, body, nil) +} + +// GetRefundOrder 获取售后单详情。 +// +// RefundOrderId 为路径参数。返回 Data 为 *GetRefundOrderRespData。 +func (c *Client) GetRefundOrder(ctx context.Context, req *GetRefundOrderRequest) (*CommonResponse, error) { + path := buildPath("/refund-orders/{RefundOrderId}", map[string]string{"RefundOrderId": req.RefundOrderId}) + return c.doRoa(ctx, "GET", path, structToQuery(req), nil, &GetRefundOrderRespData{}) +} + +// CreateGoodsShippingNotice 回填退货物流运单。 +// +// RefundOrderId 为路径参数。 +func (c *Client) CreateGoodsShippingNotice(ctx context.Context, req *CreateGoodsShippingNoticeReq) (*CommonResponse, error) { + path := buildPath("/refund-orders/{RefundOrderId}:fill-logistics", map[string]string{"RefundOrderId": req.RefundOrderId}) + return c.doRoa(ctx, "POST", path, nil, req, nil) +} + +// QueryChildDivisionCode 查询子区域编码。 +// +// 不传 ParentDivisionCode 返回省级;传入省返回市,传入市返回区,传入区返回乡镇(五级)。 +// 下单、商品可售校验必须使用乡镇级 DivisionCode。 +// 返回 Data 为 *QueryChildDivisionCodeRespData。 +func (c *Client) QueryChildDivisionCode(ctx context.Context, req *QueryChildDivisionCodeRequest) (*CommonResponse, error) { + return c.doRoa(ctx, "GET", "/divisions:children", structToQuery(req), nil, &QueryChildDivisionCodeRespData{}) +} + +// ParseCallbackEvent 解析阿里云消息回调的请求体。 +// +// 收到回调后应返回 CallbackSuccessBody(),否则阿里云会重试推送。 +// 同一事件可能多次推送,业务侧回调处理需做好幂等。 +func ParseCallbackEvent(body []byte) (*CallbackEvent, error) { + var evt CallbackEvent + if err := json.Unmarshal(body, &evt); err != nil { + return nil, fmt.Errorf("parse callback event: %w", err) + } + return &evt, nil +} + +// CallbackSuccessBody 返回告知阿里云消费成功的响应体 {"success":true}。 +func CallbackSuccessBody() []byte { + return []byte(`{"success":true}`) +} + +// strPtr 返回 string 变量的指针。 +func strPtr(s string) *string { return &s } + +// intPtr 返回 int 变量的指针。 +func intPtr(i int) *int { return &i } +``` + +```go \ No newline at end of file