diff --git a/intelligence_finance_v1/client.go b/intelligence_finance_v1/client.go new file mode 100644 index 0000000..a889c00 --- /dev/null +++ b/intelligence_finance_v1/client.go @@ -0,0 +1,345 @@ +package intelligence_finance_v1 + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +const ( + // DefaultTimeout 默认请求超时时间 + DefaultTimeout = 30 * time.Second + // DefaultNonceLength 默认随机数长度 + DefaultNonceLength = 16 + // HeaderTenantID 租户ID请求头 + HeaderTenantID = "tenant-id" + // HeaderClientID 应用标识请求头 + HeaderClientID = "client-id" + // HeaderSignatureTimestamp 签名时间戳请求头 + HeaderSignatureTimestamp = "x-bfl-signature-timestamp" + // HeaderSignatureNonce 签名随机数请求头 + HeaderSignatureNonce = "x-bfl-signature-nonce" + // HeaderSignature 签名信息请求头 + HeaderSignature = "x-bfl-signature" + // ClientIDDingTalk 钉钉AI表格的固定 client-id + ClientIDDingTalk = "dd-ai-table" +) + +// Client 业财连接 SDK 客户端 +// 用于调用平台提供的接口(订单开票、开票状态查询、创建付款单据、支付状态查询) +// 以及处理平台回调通知 +type Client struct { + // BaseURL 平台接口基础地址 + BaseURL string + // TenantID 平台分配的租户唯一标识 + TenantID string + // ClientID 平台分配的应用标识 + ClientID string + // ClientSecret 平台分配的密钥,用于签名 + ClientSecret string + // HTTPClient HTTP 客户端 + HTTPClient *http.Client + // NonceLength 随机数长度 + NonceLength int +} + +// ClientOption 客户端配置选项 +type ClientOption func(*Client) + +// WithHTTPClient 设置自定义 HTTP 客户端 +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(c *Client) { + c.HTTPClient = httpClient + } +} + +// WithNonceLength 设置随机数长度 +func WithNonceLength(length int) ClientOption { + return func(c *Client) { + c.NonceLength = length + } +} + +// NewClient 创建一个新的业财连接客户端 +// baseURL: 平台接口基础地址(如 "https://api.example.com") +// tenantID: 平台分配的租户唯一标识 +// clientID: 平台分配的应用标识(钉钉AI表格固定为 "dd-ai-table") +// clientSecret: 平台分配的密钥 +func NewClient(baseURL, tenantID, clientID, clientSecret string, opts ...ClientOption) *Client { + c := &Client{ + BaseURL: baseURL, + TenantID: tenantID, + ClientID: clientID, + ClientSecret: clientSecret, + HTTPClient: &http.Client{ + Timeout: DefaultTimeout, + }, + NonceLength: DefaultNonceLength, + } + for _, opt := range opts { + opt(c) + } + return c +} + +// NewDingTalkClient 创建一个钉钉AI表格专用的客户端 +// baseURL: 平台接口基础地址 +// tenantID: 平台分配的租户唯一标识 +// clientSecret: 平台分配的密钥(在钉钉AI表格中配置为 APPSecret) +// 注意:钉钉AI表格的 client-id 固定为 "dd-ai-table",签名由AI表格自动完成 +func NewDingTalkClient(baseURL, tenantID, clientSecret string, opts ...ClientOption) *Client { + return NewClient(baseURL, tenantID, ClientIDDingTalk, clientSecret, opts...) +} + +// ============================================================================ +// 签名相关方法 +// ============================================================================ + +// buildSignature 构建请求签名 +// 签名算法:HmacSHA256(client-secret, timestamp + nonce),结果 Base64 编码 +func (c *Client) buildSignature(timestamp, nonce string) string { + data := timestamp + nonce + return HmacSHA256Sign(c.ClientSecret, data) +} + +// setAuthHeaders 设置认证请求头 +func (c *Client) setAuthHeaders(req *http.Request, timestamp, nonce string) { + req.Header.Set(HeaderTenantID, c.TenantID) + req.Header.Set(HeaderClientID, c.ClientID) + req.Header.Set(HeaderSignatureTimestamp, timestamp) + req.Header.Set(HeaderSignatureNonce, nonce) + req.Header.Set(HeaderSignature, c.buildSignature(timestamp, nonce)) + req.Header.Set("Content-Type", "application/json") +} + +// ============================================================================ +// 内部 HTTP 请求方法 +// ============================================================================ + +// doRequest 执行带签名的 POST 请求 +func (c *Client) doRequest(ctx context.Context, path string, requestBody interface{}) (*commonResponse, error) { + bodyBytes, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("marshal request body: %w", err) + } + + url := c.BaseURL + path + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + timestamp := GenerateTimestamp() + nonce, err := GenerateNonce(c.NonceLength) + if err != nil { + return nil, fmt.Errorf("generate nonce: %w", err) + } + + c.setAuthHeaders(req, timestamp, nonce) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("unexpected HTTP status: %d, body: %s", resp.StatusCode, string(respBody)) + } + + var commonResp commonResponse + if err := json.Unmarshal(respBody, &commonResp); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + + if commonResp.Code != 0 { + return nil, NewAPIError(commonResp.Code, commonResp.Msg) + } + + return &commonResp, nil +} + +// ============================================================================ +// 接口 1:订单开票 +// ============================================================================ + +// CreateInvoice 提交订单开票请求 +// path: 接口路径(接口短码,由平台对接时分配) +// req: 开票请求参数 +// 返回开票响应,包含开票状态和发票数据列表 +func (c *Client) CreateInvoice(ctx context.Context, path string, req *InvoiceRequest) (*InvoiceResponse, error) { + commonResp, err := c.doRequest(ctx, path, req) + if err != nil { + return nil, err + } + + // 将 data 字段重新序列化后反序列化为 InvoiceResponse + dataBytes, err := json.Marshal(commonResp.Data) + if err != nil { + return nil, fmt.Errorf("marshal response data: %w", err) + } + + var invoiceResp InvoiceResponse + if err := json.Unmarshal(dataBytes, &invoiceResp); err != nil { + return nil, fmt.Errorf("unmarshal invoice response: %w", err) + } + + return &invoiceResp, nil +} + +// ============================================================================ +// 接口 2:开票状态查询 +// ============================================================================ + +// QueryInvoiceStatus 查询订单开票状态 +// path: 接口路径(接口短码,由平台对接时分配) +// req: 查询请求参数 +// 返回开票状态查询响应 +func (c *Client) QueryInvoiceStatus(ctx context.Context, path string, req *InvoiceStatusQueryRequest) (*InvoiceStatusQueryResponse, error) { + commonResp, err := c.doRequest(ctx, path, req) + if err != nil { + return nil, err + } + + dataBytes, err := json.Marshal(commonResp.Data) + if err != nil { + return nil, fmt.Errorf("marshal response data: %w", err) + } + + var statusResp InvoiceStatusQueryResponse + if err := json.Unmarshal(dataBytes, &statusResp); err != nil { + return nil, fmt.Errorf("unmarshal invoice status response: %w", err) + } + + return &statusResp, nil +} + +// ============================================================================ +// 接口 3:创建付款单据 +// ============================================================================ + +// CreatePaymentDocument 创建付款单据 +// path: 接口路径(接口短码,由平台对接时分配) +// req: 付款单据请求参数 +// 返回创建结果,包含单据唯一标识 +func (c *Client) CreatePaymentDocument(ctx context.Context, path string, req *PaymentDocumentRequest) (*PaymentDocumentResponse, error) { + commonResp, err := c.doRequest(ctx, path, req) + if err != nil { + return nil, err + } + + dataBytes, err := json.Marshal(commonResp.Data) + if err != nil { + return nil, fmt.Errorf("marshal response data: %w", err) + } + + var paymentResp PaymentDocumentResponse + if err := json.Unmarshal(dataBytes, &paymentResp); err != nil { + return nil, fmt.Errorf("unmarshal payment document response: %w", err) + } + + return &paymentResp, nil +} + +// ============================================================================ +// 接口 5:支付状态查询 +// ============================================================================ + +// QueryPaymentStatus 查询支付状态 +// path: 接口路径(接口短码,由平台对接时分配) +// req: 查询请求参数 +// 返回支付状态信息,结构与支付通知一致 +func (c *Client) QueryPaymentStatus(ctx context.Context, path string, req *PaymentStatusQueryRequest) (*PaymentStatusQueryResponse, error) { + commonResp, err := c.doRequest(ctx, path, req) + if err != nil { + return nil, err + } + + dataBytes, err := json.Marshal(commonResp.Data) + if err != nil { + return nil, fmt.Errorf("marshal response data: %w", err) + } + + var paymentStatusResp PaymentStatusQueryResponse + if err := json.Unmarshal(dataBytes, &paymentStatusResp); err != nil { + return nil, fmt.Errorf("unmarshal payment status response: %w", err) + } + + return &paymentStatusResp, nil +} + +// ============================================================================ +// 回调通知处理(接口 4:支付完成通知) +// ============================================================================ + +// ParsePaymentNotification 从 HTTP 请求中解析支付完成通知 +// 该方法用于客户接收平台回调时使用 +func ParsePaymentNotification(r *http.Request) (*PaymentNotification, error) { + if r.Method != http.MethodPost { + return nil, fmt.Errorf("invalid HTTP method: %s, expected POST", r.Method) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("read notification body: %w", err) + } + defer r.Body.Close() + + var notification PaymentNotification + if err := json.Unmarshal(body, ¬ification); err != nil { + return nil, fmt.Errorf("unmarshal payment notification: %w", err) + } + + return ¬ification, nil +} + +// ParseGeneralNotification 从 HTTP 请求中解析通用通知 +func ParseGeneralNotification(r *http.Request) (*GeneralNotification, error) { + if r.Method != http.MethodPost { + return nil, fmt.Errorf("invalid HTTP method: %s, expected POST", r.Method) + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, fmt.Errorf("read notification body: %w", err) + } + defer r.Body.Close() + + var notification GeneralNotification + if err := json.Unmarshal(body, ¬ification); err != nil { + return nil, fmt.Errorf("unmarshal general notification: %w", err) + } + + return ¬ification, nil +} + +// VerifyNotificationSignature 验证回调通知签名 +// payload: 原始请求体 +// signature: 请求中的签名 +// secret: 客户在平台配置的验签密钥 +// 使用 HmacSHA256 算法验证 +func VerifyNotificationSignature(payload []byte, signature, secret string) bool { + return HmacSHA256Verify(secret, string(payload), signature) +} + +// WriteNotificationResponse 写入通知响应 +// success: true 返回 "SUCCESS",false 返回 "FAILED" +func WriteNotificationResponse(w http.ResponseWriter, success bool) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + if success { + w.WriteHeader(http.StatusOK) + w.Write([]byte(NotificationSuccess)) + } else { + w.WriteHeader(http.StatusOK) + w.Write([]byte(NotificationFailed)) + } +} \ No newline at end of file