From efdea027f8313e70c32f447d4637074960e16fce Mon Sep 17 00:00:00 2001 From: renzhiyuan <465386466@qq.com> Date: Mon, 17 Aug 2026 17:04:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=96=87=E4=BB=B6:=20intelli?= =?UTF-8?q?gence=5Ffinance=5Fv1=5Fga/client.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- intelligence_finance_v1_ga/client.go | 351 +++++++++++++++++++++++++++ 1 file changed, 351 insertions(+) create mode 100644 intelligence_finance_v1_ga/client.go diff --git a/intelligence_finance_v1_ga/client.go b/intelligence_finance_v1_ga/client.go new file mode 100644 index 0000000..76a3937 --- /dev/null +++ b/intelligence_finance_v1_ga/client.go @@ -0,0 +1,351 @@ +package intelligence_finance_v1_ga + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// 供应商业务类型常量。 +const ( + BizTypeCreateSupplier = "create_supplier" + BizTypeQuerySupplierByName = "query_supplier_by_name" + BizTypeUpdateSupplier = "update_supplier" + BizTypeQuerySupplierByUserDefine = "query_supplier_by_userDefineCode" +) + +// Client 业财连接接口客户端。 +type Client struct { + baseURL string + tenantID string + clientID string + clientSecret string + signEnabled bool + httpClient *http.Client + + // 各接口路径(可通过 Option 覆盖) + pathSubmitInvoiceApply string + pathQueryInvoiceStatus string + pathCreatePayment string + pathQueryPaymentStatus string + pathCreateSupplier string + pathQuerySupplierByName string + pathUpdateSupplier string + pathQuerySupplierByUserDefineCode string +} + +// Option 客户端配置项。 +type Option func(*Client) + +// WithHTTPClient 自定义 http.Client。 +func WithHTTPClient(c *http.Client) Option { + return func(cli *Client) { cli.httpClient = c } +} + +// WithSignEnabled 是否启用请求签名(钉钉AI表格场景可关闭,签名由AI表格自动完成)。 +func WithSignEnabled(enabled bool) Option { + return func(cli *Client) { cli.signEnabled = enabled } +} + +// WithPathSubmitInvoiceApply 设置提交订单开票申请接口路径。 +func WithPathSubmitInvoiceApply(path string) Option { + return func(cli *Client) { cli.pathSubmitInvoiceApply = path } +} + +// WithPathQueryInvoiceStatus 设置开票状态查询接口路径。 +func WithPathQueryInvoiceStatus(path string) Option { + return func(cli *Client) { cli.pathQueryInvoiceStatus = path } +} + +// WithPathCreatePayment 设置创建付款单据接口路径。 +func WithPathCreatePayment(path string) Option { + return func(cli *Client) { cli.pathCreatePayment = path } +} + +// WithPathQueryPaymentStatus 设置支付状态查询接口路径。 +func WithPathQueryPaymentStatus(path string) Option { + return func(cli *Client) { cli.pathQueryPaymentStatus = path } +} + +// WithPathCreateSupplier 设置创建供应商接口路径。 +func WithPathCreateSupplier(path string) Option { + return func(cli *Client) { cli.pathCreateSupplier = path } +} + +// WithPathQuerySupplierByName 设置根据名称查询供应商接口路径。 +func WithPathQuerySupplierByName(path string) Option { + return func(cli *Client) { cli.pathQuerySupplierByName = path } +} + +// WithPathUpdateSupplier 设置更新供应商接口路径。 +func WithPathUpdateSupplier(path string) Option { + return func(cli *Client) { cli.pathUpdateSupplier = path } +} + +// WithPathQuerySupplierByUserDefineCode 设置根据用户自定义编码查询供应商接口路径。 +func WithPathQuerySupplierByUserDefineCode(path string) Option { + return func(cli *Client) { cli.pathQuerySupplierByUserDefineCode = path } +} + +// NewClient 创建业财连接接口客户端。 +// +// baseURL 为平台接口根地址;tenantID 为平台分配的租户唯一标识; +// clientID 为平台分配的应用标识(钉钉AI表格固定为 "dd-ai-table"); +// clientSecret 为平台分配的密钥,用于请求签名。 +func NewClient(baseURL, tenantID, clientID, clientSecret string, opts ...Option) *Client { + c := &Client{ + baseURL: strings.TrimRight(baseURL, "/"), + tenantID: tenantID, + clientID: clientID, + clientSecret: clientSecret, + signEnabled: true, + httpClient: &http.Client{Timeout: 30 * time.Second}, + + pathSubmitInvoiceApply: "/invoice/apply", + pathQueryInvoiceStatus: "/invoice/status", + pathCreatePayment: "/payment/create", + pathQueryPaymentStatus: "/payment/status", + pathCreateSupplier: "/supplier/create", + pathQuerySupplierByName: "/supplier/queryByName", + pathUpdateSupplier: "/supplier/update", + pathQuerySupplierByUserDefineCode: "/supplier/queryByUserDefineCode", + } + for _, opt := range opts { + opt(c) + } + return c +} + +// doRequest 发送 POST 请求并解析响应。 +func (c *Client) doRequest(ctx context.Context, path string, reqBody, respBody interface{}) error { + body, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set("tenant-id", c.tenantID) + httpReq.Header.Set("client-id", c.clientID) + + if c.signEnabled { + timestamp := GenerateTimestamp() + nonce, err := GenerateNonce(32) + if err != nil { + return fmt.Errorf("generate nonce: %w", err) + } + sign := HmacSHA256Base64(c.clientSecret, timestamp+nonce) + httpReq.Header.Set("x-bfl-signature-timestamp", timestamp) + httpReq.Header.Set("x-bfl-signature-nonce", nonce) + httpReq.Header.Set("x-bfl-signature", sign) + } + + httpResp, err := c.httpClient.Do(httpReq) + if err != nil { + return fmt.Errorf("do request: %w", err) + } + defer httpResp.Body.Close() + + respData, err := io.ReadAll(httpResp.Body) + if err != nil { + return fmt.Errorf("read response: %w", err) + } + + if httpResp.StatusCode != http.StatusOK { + return &APIError{StatusCode: httpResp.StatusCode, Body: string(respData)} + } + + if err := json.Unmarshal(respData, respBody); err != nil { + return fmt.Errorf("unmarshal response: %w", err) + } + return nil +} + +// checkBusinessCode 校验通用响应结构中的业务 code。 +func checkBusinessCode(code int, msg string) error { + if code != 0 && code != 200 { + return &BusinessError{Code: code, Msg: msg} + } + return nil +} + +// SubmitInvoiceApply 提交订单开票申请(接口1)。 +func (c *Client) SubmitInvoiceApply(ctx context.Context, req *InvoiceApplyRequest) (*InvoiceApplyResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *InvoiceApplyResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathSubmitInvoiceApply, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// QueryInvoiceStatus 查询订单开票状态(接口2)。 +func (c *Client) QueryInvoiceStatus(ctx context.Context, req *InvoiceStatusQueryRequest) (*InvoiceStatusQueryResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *InvoiceStatusQueryResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathQueryInvoiceStatus, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// CreatePayment 创建付款单据(接口3)。 +func (c *Client) CreatePayment(ctx context.Context, req *CreatePaymentRequest) (*CreatePaymentResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *CreatePaymentResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathCreatePayment, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// QueryPaymentStatus 查询支付状态(接口5),响应数据与支付通知一致。 +func (c *Client) QueryPaymentStatus(ctx context.Context, req *PaymentStatusQueryRequest) (*PaymentNotifyData, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *PaymentNotifyData `json:"data"` + } + if err := c.doRequest(ctx, c.pathQueryPaymentStatus, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// CreateSupplier 创建供应商(接口6)。 +func (c *Client) CreateSupplier(ctx context.Context, req *CreateSupplierRequest) (*CreateSupplierResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *CreateSupplierResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathCreateSupplier, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// QuerySupplierByName 根据名称查询供应商(接口7)。 +func (c *Client) QuerySupplierByName(ctx context.Context, req *QuerySupplierByNameRequest) (*QuerySupplierByNameResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *QuerySupplierByNameResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathQuerySupplierByName, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// UpdateSupplier 更新供应商(接口8)。 +func (c *Client) UpdateSupplier(ctx context.Context, req *UpdateSupplierRequest) (*UpdateSupplierResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *UpdateSupplierResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathUpdateSupplier, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// QuerySupplierByUserDefineCode 根据用户自定义编码查询供应商(接口9)。 +func (c *Client) QuerySupplierByUserDefineCode(ctx context.Context, req *QuerySupplierByUserDefineCodeRequest) (*QuerySupplierByUserDefineCodeResponse, error) { + var envelope struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data *QuerySupplierByUserDefineCodeResponse `json:"data"` + } + if err := c.doRequest(ctx, c.pathQuerySupplierByUserDefineCode, req, &envelope); err != nil { + return nil, err + } + if err := checkBusinessCode(envelope.Code, envelope.Msg); err != nil { + return nil, err + } + return envelope.Data, nil +} + +// ==================== 通知机制辅助 ==================== + +// ParseNotification 解析通用通知数据。 +func ParseNotification(body []byte) (*Notification, error) { + var n Notification + if err := json.Unmarshal(body, &n); err != nil { + return nil, fmt.Errorf("parse notification: %w", err) + } + return &n, nil +} + +// ParsePaymentNotify 解析支付完成通知数据(接口4)。 +func ParsePaymentNotify(body []byte) (*PaymentNotifyData, error) { + var n PaymentNotifyData + if err := json.Unmarshal(body, &n); err != nil { + return nil, fmt.Errorf("parse payment notify: %w", err) + } + return &n, nil +} + +// VerifyNotificationSignature 校验通知签名。 +// +// 平台使用客户提供的验签密钥对原始请求体进行 HmacSHA256 签名并 Base64 编码, +// 通过请求头 x-bfl-signature 传递。verifyKey 为客户在平台配置的验签密钥。 +func VerifyNotificationSignature(verifyKey string, rawBody []byte, signature string) (bool, error) { + if signature == "" { + return false, &NotificationError{Reason: "empty signature"} + } + expected := HmacSHA256Base64(verifyKey, string(rawBody)) + return hmacEqual(expected, signature), nil +} + +// hmacEqual 常量时间比较两个 Base64 签名是否一致。 +func hmacEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + var v byte + for i := 0; i < len(a); i++ { + v |= a[i] ^ b[i] + } + return v == 0 +} \ No newline at end of file