添加文件: intelligence_finance_v1/client.go
This commit is contained in:
parent
78fce20c2e
commit
5ff9752895
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue