Files
tyapi-server/internal/infrastructure/external/shumai/shumai_service.go
2026-03-05 11:05:01 +08:00

361 lines
10 KiB
Go
Raw 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 shumai
import (
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"tyapi-server/internal/shared/external_logger"
)
const (
// 错误日志中单条入参值的最大长度,避免 base64 等长内容打满日志
maxLogParamValueLen = 300
// 错误日志中 response_body 的最大长度
maxLogResponseBodyLen = 500
)
var (
ErrDatasource = errors.New("数据源异常")
ErrSystem = errors.New("系统异常")
ErrNotFound = errors.New("查询为空")
)
// truncateForLog 将字符串截断到指定长度,用于错误日志,避免 base64 等过长内容
func truncateForLog(s string, maxLen int) string {
if maxLen <= 0 {
return s
}
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "...[truncated, total " + strconv.Itoa(len(s)) + " chars]"
}
// requestParamsForLog 返回适合写入错误日志的入参副本(长字符串会被截断)
func requestParamsForLog(reqFormData map[string]interface{}) map[string]interface{} {
if reqFormData == nil {
return nil
}
out := make(map[string]interface{}, len(reqFormData))
for k, v := range reqFormData {
if v == nil {
out[k] = nil
continue
}
switch val := v.(type) {
case string:
out[k] = truncateForLog(val, maxLogParamValueLen)
default:
s := fmt.Sprint(v)
out[k] = truncateForLog(s, maxLogParamValueLen)
}
}
return out
}
// ShumaiResponse 数脉 API 通用响应(占位,按实际文档调整)
type ShumaiResponse struct {
Code int `json:"code"` // 状态码
Msg string `json:"msg"`
Message string `json:"message"`
Data interface{} `json:"data"`
}
// ShumaiConfig 数脉服务配置
type ShumaiConfig struct {
URL string
AppID string
AppSecret string
AppID2 string // 走政务接口使用这个
AppSecret2 string // 走政务接口使用这个
SignMethod SignMethod
Timeout time.Duration
}
// ShumaiService 数脉服务
type ShumaiService struct {
config ShumaiConfig
logger *external_logger.ExternalServiceLogger
useGovernment bool // 是否使用政务接口app_id2
}
// NewShumaiService 创建数脉服务实例
// appID2 和 appSecret2 用于政务接口,如果为空则只使用普通接口
func NewShumaiService(url, appID, appSecret string, signMethod SignMethod, timeout time.Duration, logger *external_logger.ExternalServiceLogger, appID2, appSecret2 string) *ShumaiService {
if signMethod == "" {
signMethod = SignMethodHMACMD5
}
if timeout == 0 {
timeout = 60 * time.Second
}
return &ShumaiService{
config: ShumaiConfig{
URL: url,
AppID: appID,
AppSecret: appSecret,
AppID2: appID2, // 走政务接口使用这个
AppSecret2: appSecret2, // 走政务接口使用这个
SignMethod: signMethod,
Timeout: timeout,
},
logger: logger,
useGovernment: false,
}
}
func (s *ShumaiService) generateRequestID() string {
timestamp := time.Now().UnixNano()
appID := s.getCurrentAppID()
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, appID)))
return fmt.Sprintf("shumai_%x", hash[:8])
}
// generateRequestIDWithAppID 根据指定的 AppID 生成请求ID用于不依赖全局状态的情况
func (s *ShumaiService) generateRequestIDWithAppID(appID string) string {
timestamp := time.Now().UnixNano()
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, appID)))
return fmt.Sprintf("shumai_%x", hash[:8])
}
// getCurrentAppID 获取当前使用的 AppID
func (s *ShumaiService) getCurrentAppID() string {
if s.useGovernment && s.config.AppID2 != "" {
return s.config.AppID2
}
return s.config.AppID
}
// getCurrentAppSecret 获取当前使用的 AppSecret
func (s *ShumaiService) getCurrentAppSecret() string {
if s.useGovernment && s.config.AppSecret2 != "" {
return s.config.AppSecret2
}
return s.config.AppSecret
}
// UseGovernment 切换到政务接口(使用 app_id2 和 app_secret2
func (s *ShumaiService) UseGovernment() {
s.useGovernment = true
}
// UseNormal 切换到普通接口(使用 app_id 和 app_secret
func (s *ShumaiService) UseNormal() {
s.useGovernment = false
}
// IsUsingGovernment 检查是否正在使用政务接口
func (s *ShumaiService) IsUsingGovernment() bool {
return s.useGovernment
}
// GetConfig 返回当前配置
func (s *ShumaiService) GetConfig() ShumaiConfig {
return s.config
}
// CallAPIForm 以表单方式调用数脉 APIapplication/x-www-form-urlencoded
// 在方法内部将 reqFormData 转为表单:先写入业务参数,再追加 appid、timestamp、sign。
// 签名算法md5(appid&timestamp&app_security)32 位小写,不足补 0。
// useGovernment 可选参数true 表示使用政务接口app_id2false 表示使用实时接口app_id
// 如果未提供参数,则使用全局状态(通过 UseGovernment()/UseNormal() 设置)
func (s *ShumaiService) CallAPIForm(ctx context.Context, apiPath string, reqFormData map[string]interface{}, useGovernment ...bool) ([]byte, error) {
// 确定是否使用政务接口:如果提供了参数则使用参数值,否则使用全局状态
var useGov bool
if len(useGovernment) > 0 {
useGov = useGovernment[0]
} else {
// 未提供参数时,使用全局状态以保持向后兼容
useGov = s.useGovernment
}
startTime := time.Now()
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
// 根据参数选择使用的 AppID 和 AppSecret而不是依赖全局状态
var appID, appSecret string
if useGov && s.config.AppID2 != "" {
appID = s.config.AppID2
appSecret = s.config.AppSecret2
} else {
appID = s.config.AppID
appSecret = s.config.AppSecret
}
// 使用指定的 AppID 生成请求ID
requestID := s.generateRequestIDWithAppID(appID)
sign := GenerateSignForm(appID, timestamp, appSecret)
var transactionID string
if id, ok := ctx.Value("transaction_id").(string); ok {
transactionID = id
}
form := url.Values{}
form.Set("appid", appID)
form.Set("timestamp", timestamp)
form.Set("sign", sign)
for k, v := range reqFormData {
if v == nil {
continue
}
form.Set(k, fmt.Sprint(v))
}
body := form.Encode()
baseURL := strings.TrimSuffix(s.config.URL, "/")
reqURL := baseURL
if apiPath != "" {
reqURL = baseURL + "/" + strings.TrimPrefix(apiPath, "/")
}
if apiPath == "" {
apiPath = "shumai_form"
}
if s.logger != nil {
s.logger.LogRequest(requestID, transactionID, apiPath, reqURL)
}
req, err := http.NewRequestWithContext(ctx, "POST", reqURL, strings.NewReader(body))
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, map[string]interface{}{"request_params": requestParamsForLog(reqFormData)})
}
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{Timeout: s.config.Timeout}
resp, err := client.Do(req)
if err != nil {
isTimeout := ctx.Err() == context.DeadlineExceeded
if !isTimeout {
if te, ok := err.(interface{ Timeout() bool }); ok && te.Timeout() {
isTimeout = true
}
}
if !isTimeout {
es := err.Error()
if strings.Contains(es, "deadline exceeded") || strings.Contains(es, "timeout") || strings.Contains(es, "canceled") {
isTimeout = true
}
}
if isTimeout {
err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err))
} else {
err = errors.Join(ErrSystem, err)
}
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, map[string]interface{}{"request_params": requestParamsForLog(reqFormData)})
}
return nil, err
}
defer resp.Body.Close()
duration := time.Since(startTime)
raw, err := io.ReadAll(resp.Body)
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, map[string]interface{}{"request_params": requestParamsForLog(reqFormData)})
}
return nil, err
}
if resp.StatusCode != http.StatusOK {
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP %d", resp.StatusCode))
if s.logger != nil {
errorPayload := map[string]interface{}{
"request_params": requestParamsForLog(reqFormData),
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
}
s.logger.LogError(requestID, transactionID, apiPath, err, errorPayload)
}
return nil, err
}
if s.logger != nil {
s.logger.LogResponse(requestID, transactionID, apiPath, resp.StatusCode, duration)
}
var shumaiResp ShumaiResponse
if err := json.Unmarshal(raw, &shumaiResp); err != nil {
parseErr := errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, parseErr, map[string]interface{}{
"request_params": requestParamsForLog(reqFormData),
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
})
}
return nil, parseErr
}
codeStr := strconv.Itoa(shumaiResp.Code)
msg := shumaiResp.Msg
if msg == "" {
msg = shumaiResp.Message
}
shumaiErr := NewShumaiErrorFromCode(codeStr)
if !shumaiErr.IsSuccess() {
if shumaiErr.Message == "未知错误" && msg != "" {
shumaiErr = NewShumaiError(codeStr, msg)
}
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, shumaiErr, map[string]interface{}{
"request_params": requestParamsForLog(reqFormData),
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
})
}
if shumaiErr.IsNoRecord() {
return nil, errors.Join(ErrNotFound, shumaiErr)
}
return nil, errors.Join(ErrDatasource, shumaiErr)
}
if shumaiResp.Data == nil {
return []byte("{}"), nil
}
dataBytes, err := json.Marshal(shumaiResp.Data)
if err != nil {
marshalErr := errors.Join(ErrSystem, fmt.Errorf("data 序列化失败: %w", err))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, marshalErr, map[string]interface{}{
"request_params": requestParamsForLog(reqFormData),
"response_body": truncateForLog(string(raw), maxLogResponseBodyLen),
})
}
return nil, marshalErr
}
return dataBytes, nil
}
func (s *ShumaiService) Encrypt(data string) (string, error) {
appSecret := s.getCurrentAppSecret()
encryptedValue, err := Encrypt(data, appSecret)
if err != nil {
return "", ErrSystem
}
return encryptedValue, nil
}
func (s *ShumaiService) Decrypt(encodedData string) ([]byte, error) {
appSecret := s.getCurrentAppSecret()
decryptedValue, err := Decrypt(encodedData, appSecret)
if err != nil {
return nil, ErrSystem
}
return decryptedValue, nil
}