diff --git a/intelligence_finance_v1_ga/client.go b/intelligence_finance_v1_ga/client.go new file mode 100644 index 0000000..83a2b1b --- /dev/null +++ b/intelligence_finance_v1_ga/client.go @@ -0,0 +1,237 @@ +package intelligence_finance_v1_ga + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// 默认接口路径(实际路径以接口对接时分配的短码为准,可通过 Option 覆盖)。 +const ( + DefaultInvoiceOrderPath = "/invoice/order" + DefaultQueryInvoiceStatusPath = "/invoice/status/query" + DefaultCreatePaymentPath = "/payment/create" + DefaultQueryPaymentStatusPath = "/payment/status/query" +) + +// Client 是业财连接接口的客户端。 +type Client struct { + baseURL string + tenantID string + clientID string + clientSecret string + httpClient *http.Client + signEnabled bool + + invoiceOrderPath string + queryInvoiceStatusPath string + createPaymentPath string + queryPaymentStatusPath string +} + +// Option 定义客户端配置选项。 +type Option func(*Client) + +// WithHTTPClient 设置自定义 HTTP 客户端。 +func WithHTTPClient(c *http.Client) Option { + return func(cli *Client) { + cli.httpClient = c + } +} + +// WithTimeout 设置请求超时时间。 +func WithTimeout(d time.Duration) Option { + return func(cli *Client) { + cli.httpClient.Timeout = d + } +} + +// WithSignEnabled 设置是否启用签名。 +// 客户应用需启用签名(默认启用);钉钉AI表格无需配置签名,可设置为 false。 +func WithSignEnabled(enabled bool) Option { + return func(cli *Client) { + cli.signEnabled = enabled + } +} + +// WithInvoiceOrderPath 覆盖订单开票接口路径。 +func WithInvoiceOrderPath(p string) Option { + return func(cli *Client) { cli.invoiceOrderPath = p } +} + +// WithQueryInvoiceStatusPath 覆盖开票状态查询接口路径。 +func WithQueryInvoiceStatusPath(p string) Option { + return func(cli *Client) { cli.queryInvoiceStatusPath = p } +} + +// WithCreatePaymentPath 覆盖创建付款单据接口路径。 +func WithCreatePaymentPath(p string) Option { + return func(cli *Client) { cli.createPaymentPath = p } +} + +// WithQueryPaymentStatusPath 覆盖支付状态查询接口路径。 +func WithQueryPaymentStatusPath(p string) Option { + return func(cli *Client) { cli.queryPaymentStatusPath = p } +} + +// 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, + httpClient: &http.Client{Timeout: 30 * time.Second}, + signEnabled: true, + invoiceOrderPath: DefaultInvoiceOrderPath, + queryInvoiceStatusPath: DefaultQueryInvoiceStatusPath, + createPaymentPath: DefaultCreatePaymentPath, + queryPaymentStatusPath: DefaultQueryPaymentStatusPath, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// doRequest 发送 POST 请求并处理通用响应结构,返回 data 字段的原始字节。 +func (c *Client) doRequest(ctx context.Context, path string, reqBody interface{}) (json.RawMessage, error) { + bodyBytes, err := json.Marshal(reqBody) + if err != nil { + return nil, &SDKError{Op: "doRequest", Err: fmt.Errorf("marshal request: %w", err)} + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, &SDKError{Op: "doRequest", Err: fmt.Errorf("new request: %w", err)} + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("tenant-id", c.tenantID) + req.Header.Set("client-id", c.clientID) + + if c.signEnabled { + timestamp := GenerateTimestamp() + nonce, err := GenerateNonce(16) + if err != nil { + return nil, &SDKError{Op: "doRequest", Err: fmt.Errorf("generate nonce: %w", err)} + } + // 签名算法:HmacSHA256,密钥为 client-secret,签名数据为 timestamp + nonce,结果 Base64 编码。 + signature := HmacSHA256Base64(c.clientSecret, timestamp+nonce) + req.Header.Set("x-bfl-signature-timestamp", timestamp) + req.Header.Set("x-bfl-signature-nonce", nonce) + req.Header.Set("x-bfl-signature", signature) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, &SDKError{Op: "doRequest", Err: fmt.Errorf("do request: %w", err)} + } + defer resp.Body.Close() + + respBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, &SDKError{Op: "doRequest", Err: fmt.Errorf("read response: %w", err)} + } + + if resp.StatusCode != http.StatusOK { + return nil, &APIError{HTTPStatus: resp.StatusCode, Body: string(respBytes)} + } + + var base BaseResponse + if err := json.Unmarshal(respBytes, &base); err != nil { + return nil, &SDKError{Op: "doRequest", Err: fmt.Errorf("unmarshal base response: %w", err)} + } + if base.Code != 0 && base.Code != 200 { + return nil, &APIError{Code: base.Code, Msg: base.Msg} + } + return base.Data, nil +} + +// InvoiceOrder 提交订单开票请求。 +func (c *Client) InvoiceOrder(ctx context.Context, req *InvoiceOrderRequest) (*InvoiceOrderData, error) { + data, err := c.doRequest(ctx, c.invoiceOrderPath, req) + if err != nil { + return nil, err + } + var out InvoiceOrderData + if len(data) > 0 { + if err := json.Unmarshal(data, &out); err != nil { + return nil, &SDKError{Op: "InvoiceOrder", Err: fmt.Errorf("unmarshal data: %w", err)} + } + } + return &out, nil +} + +// QueryInvoiceStatus 查询订单开票状态。 +func (c *Client) QueryInvoiceStatus(ctx context.Context, req *QueryInvoiceStatusRequest) (*QueryInvoiceStatusData, error) { + data, err := c.doRequest(ctx, c.queryInvoiceStatusPath, req) + if err != nil { + return nil, err + } + var out QueryInvoiceStatusData + if len(data) > 0 { + if err := json.Unmarshal(data, &out); err != nil { + return nil, &SDKError{Op: "QueryInvoiceStatus", Err: fmt.Errorf("unmarshal data: %w", err)} + } + } + return &out, nil +} + +// CreatePayment 创建付款单据。 +func (c *Client) CreatePayment(ctx context.Context, req *CreatePaymentRequest) (*CreatePaymentData, error) { + data, err := c.doRequest(ctx, c.createPaymentPath, req) + if err != nil { + return nil, err + } + var out CreatePaymentData + if len(data) > 0 { + if err := json.Unmarshal(data, &out); err != nil { + return nil, &SDKError{Op: "CreatePayment", Err: fmt.Errorf("unmarshal data: %w", err)} + } + } + return &out, nil +} + +// QueryPaymentStatus 查询支付状态。 +// 响应数据与支付完成通知的数据结构一致。 +func (c *Client) QueryPaymentStatus(ctx context.Context, req *QueryPaymentStatusRequest) (*PaymentInfo, error) { + data, err := c.doRequest(ctx, c.queryPaymentStatusPath, req) + if err != nil { + return nil, err + } + var out PaymentInfo + if len(data) > 0 { + if err := json.Unmarshal(data, &out); err != nil { + return nil, &SDKError{Op: "QueryPaymentStatus", Err: fmt.Errorf("unmarshal data: %w", err)} + } + } + return &out, nil +} + +// ParsePaymentNotify 解析支付完成通知请求体。 +// 该接口由客户提供、平台调用,用于在支付完成后接收平台回调。 +func ParsePaymentNotify(body []byte) (*PaymentInfo, error) { + var info PaymentInfo + if err := json.Unmarshal(body, &info); err != nil { + return nil, &SDKError{Op: "ParsePaymentNotify", Err: fmt.Errorf("unmarshal notify: %w", err)} + } + return &info, nil +} + +// ParseNotify 解析通用通知请求体(bizType / bizId / data)。 +func ParseNotify(body []byte) (*NotifyRequest, error) { + var req NotifyRequest + if err := json.Unmarshal(body, &req); err != nil { + return nil, &SDKError{Op: "ParseNotify", Err: fmt.Errorf("unmarshal notify: %w", err)} + } + return &req, nil +} \ No newline at end of file