添加文件: 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