添加文件: intelligence_finance_v1/client.go

This commit is contained in:
renzhiyuan 2026-08-17 15:48:49 +08:00
parent 78fce20c2e
commit 5ff9752895
1 changed files with 345 additions and 0 deletions

View File

@ -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, &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))
}
}