intelligence_finance_v1-202.../intelligence_finance_v1/client.go

345 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, &notification); err != nil {
return nil, fmt.Errorf("unmarshal payment notification: %w", err)
}
return &notification, 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, &notification); err != nil {
return nil, fmt.Errorf("unmarshal general notification: %w", err)
}
return &notification, 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))
}
}