Files
tyapi-server/internal/infrastructure/external/huibo/huibo_service.go
2026-05-26 16:17:09 +08:00

613 lines
17 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 huibo
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/hmac"
"crypto/md5"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"sort"
"strconv"
"strings"
"time"
"tyapi-server/internal/shared/external_logger"
"tyapi-server/internal/shared/pdfvalidate"
"go.uber.org/zap"
)
var (
ErrDatasource = errors.New("数据源异常")
ErrSystem = errors.New("系统异常")
)
const (
headerAuthorization = "Authorization"
headerWorkOrderCode = "workOrderCode"
headerOrderCode = "X-ORDER-CODE"
headerSecretIDHdr = "secretId"
headerAESKeyHdr = "aesKey"
)
// 汇博常见状态码
const (
HuiboStatusSuccess = "0"
HuiboStatusException = "1"
HuiboStatusNoData = "2"
HuiboStatusDataEmpty = "6010001"
HuiboStatusSignFailed = "6010002"
HuiboStatusDecryptFailed = "6010003"
HuiboStatusAppIDEmpty = "6010004"
HuiboStatusEncryptedEmpty = "6010005"
HuiboStatusRandomKeyEmpty = "6010006"
HuiboStatusTimestampEmpty = "6010007"
HuiboStatusProductCodeEmpty = "6010008"
HuiboStatusProductNotFound = "6010010"
HuiboStatusProductNotEnabled = "6010013"
HuiboStatusBalanceNotEnough = "6010020"
HuiboStatusUsageLimitReached = "6010021"
)
var huiboStatusMessage = map[string]string{
HuiboStatusSuccess: "操作成功",
HuiboStatusException: "异常",
HuiboStatusNoData: "数据未查得",
HuiboStatusDataEmpty: "请求体 data 为空",
HuiboStatusSignFailed: "验证签名失败",
HuiboStatusDecryptFailed: "使用 AES/SM4 加解密失败",
HuiboStatusAppIDEmpty: "appId 不能为空",
HuiboStatusEncryptedEmpty: "AES/SM4 加密后的内容不可为空",
HuiboStatusRandomKeyEmpty: "随机 AES/SM4 加密密钥不可为空",
HuiboStatusTimestampEmpty: "请求时间戳不可为空",
HuiboStatusProductCodeEmpty: "产品 code 不能为空",
HuiboStatusProductNotFound: "产品不存在",
HuiboStatusProductNotEnabled: "企业未开通产品",
HuiboStatusBalanceNotEnough: "企业账户余额不足",
HuiboStatusUsageLimitReached: "产品使用次数到达限制",
}
type HuiboConfig struct {
URL string
AppID string
AppKey string
XOrderCode string
SecretID string
AESKey string
WorkOrderCode string
ProductCode string
BaseURL2 string // CallAPI2 使用的 URL
AppCode2 string // CallAPI2 使用的 AppCode
}
type HuiboService struct {
config HuiboConfig
logger *external_logger.ExternalServiceLogger
}
type responseWrapper struct {
Code json.RawMessage `json:"code"`
Msg string `json:"msg"`
Data struct {
Status json.RawMessage `json:"status"`
Data string `json:"data"`
} `json:"data"`
}
// CallAPI2Response CallAPI2 的响应结构体
type CallAPI2Response struct {
Code string `json:"code"`
Data map[string]interface{} `json:"data"`
Msg string `json:"msg"`
}
func NewHuiboService(config HuiboConfig, logger *external_logger.ExternalServiceLogger) *HuiboService {
return &HuiboService{config: config, logger: logger}
}
// GetConfig 获取汇博配置
func (s *HuiboService) GetConfig() HuiboConfig {
return s.config
}
// CallEducationBackgroundDetailed 教育背景(详细)查询
func (s *HuiboService) CallEducationBackgroundDetailed(ctx context.Context, name, idCard, authPDFBase64 string) ([]byte, error) {
requestID := s.generateRequestID()
startTime := time.Now()
transactionID := ""
if v, ok := ctx.Value("transaction_id").(string); ok {
transactionID = v
}
if s.logger != nil {
s.logger.LogRequest(requestID, transactionID, "huibo_bg_check_ssw", s.config.URL)
}
if err := s.validateConfig(); err != nil {
return nil, errors.Join(ErrSystem, err)
}
pdfBytes, err := decodeAndValidatePDF(authPDFBase64)
if err != nil {
return nil, errors.Join(ErrDatasource, err)
}
bizParam := map[string]string{
"productCode": s.getProductCode(),
"name": name,
"idCard": idCard,
}
rawJSON, err := json.Marshal(bizParam)
if err != nil {
return nil, errors.Join(ErrSystem, err)
}
encryptedData, err := encryptAESGCMBase64(string(rawJSON), s.config.AESKey)
if err != nil {
return nil, errors.Join(ErrSystem, fmt.Errorf("AES-GCM加密失败: %w", err))
}
sortedParam := generateSortedParam(bizParam)
signature := hmacSHA256Base64(sortedParam, s.config.AESKey)
reqInner := map[string]string{
"data": encryptedData,
"requestId": requestID,
"secretId": s.config.SecretID,
"signature": signature,
}
reqInnerBytes, err := json.Marshal(reqInner)
if err != nil {
return nil, errors.Join(ErrSystem, err)
}
reqOuter := map[string]string{"data": string(reqInnerBytes)}
reqOuterBytes, err := json.Marshal(reqOuter)
if err != nil {
return nil, errors.Join(ErrSystem, err)
}
respBody, err := s.callAPI(ctx, reqOuterBytes, pdfBytes)
if err != nil {
if s.logger != nil {
s.logger.LogError(requestID, transactionID, "huibo_bg_check_ssw", err, map[string]interface{}{"name": name, "id_card": idCard})
}
return nil, err
}
if s.logger != nil {
s.logger.LogResponse(requestID, transactionID, "huibo_bg_check_ssw", http.StatusOK, time.Since(startTime))
}
var wrapper responseWrapper
if err = json.Unmarshal(respBody, &wrapper); err != nil {
return nil, errors.Join(ErrDatasource, fmt.Errorf("响应解析失败: %w", err))
}
outerCode := normalizeStatus(wrapper.Code)
outerMsg := strings.TrimSpace(wrapper.Msg)
if outerCode != "" && outerCode != "200" {
return nil, errors.Join(ErrDatasource, fmt.Errorf("汇博外层响应异常(code=%s,msg=%s)", outerCode, outerMsg))
}
status := normalizeStatus(wrapper.Data.Status)
// status=2「数据未查得」产品约定按调用成功计费对外返回 {}(与外层 code=200 成功一致,走应用层异步扣款)
if status == HuiboStatusNoData {
if s.logger != nil {
s.logger.LogInfo(
"汇博教育背景:数据未查得(status=2),返回空 JSON 并按成功计费",
zap.String("request_id", requestID),
zap.String("transaction_id", transactionID),
zap.String("name", name),
zap.String("id_card", idCard),
)
}
return []byte("{}"), nil
}
if status != HuiboStatusSuccess {
msg := wrapper.Data.Data
if strings.TrimSpace(msg) == "" {
msg = getHuiboStatusMessage(status)
}
if outerMsg != "" && !strings.Contains(msg, outerMsg) {
msg = msg + " | 外层消息: " + outerMsg
}
return nil, errors.Join(ErrDatasource, fmt.Errorf("汇博业务状态异常(status=%s,msg=%s)", status, msg))
}
if wrapper.Data.Data == "" {
return nil, errors.Join(ErrDatasource, errors.New("响应缺少加密数据"))
}
decrypted, err := decryptAESGCMBase64(wrapper.Data.Data, s.config.AESKey)
if err != nil {
return nil, errors.Join(ErrDatasource, fmt.Errorf("响应解密失败: %w", err))
}
return []byte(decrypted), nil
}
func (s *HuiboService) callAPI(ctx context.Context, reqOuterJSON []byte, pdfBytes []byte) ([]byte, error) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
// 与对接示例一致:先 file 再 req
part, err := writer.CreateFormFile("file", "authorization.pdf")
if err != nil {
return nil, errors.Join(ErrSystem, err)
}
if _, err = part.Write(pdfBytes); err != nil {
return nil, errors.Join(ErrSystem, err)
}
if err := writer.WriteField("req", string(reqOuterJSON)); err != nil {
return nil, errors.Join(ErrSystem, err)
}
if err = writer.Close(); err != nil {
return nil, errors.Join(ErrSystem, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.URL, &body)
if err != nil {
return nil, errors.Join(ErrSystem, err)
}
req.Header.Set(headerAuthorization, s.config.AppID+"::"+s.config.AppKey)
req.Header.Set(headerWorkOrderCode, s.config.WorkOrderCode)
req.Header.Set(headerOrderCode, s.config.XOrderCode)
req.Header.Set(headerSecretIDHdr, s.config.SecretID)
req.Header.Set(headerAESKeyHdr, s.config.AESKey)
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
if s.logger != nil {
s.logger.LogErrorWithFields("汇博 HTTP 请求失败",
zap.String("url", s.config.URL),
zap.Error(err),
)
}
return nil, errors.Join(ErrDatasource, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
if s.logger != nil {
s.logger.LogErrorWithFields("汇博 读取响应体失败",
zap.String("url", s.config.URL),
zap.Int("http_status", resp.StatusCode),
zap.Error(err),
)
}
return nil, errors.Join(ErrSystem, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
if s.logger != nil {
bodySnippet := string(respBody)
const maxLog = 1024
if len(bodySnippet) > maxLog {
bodySnippet = bodySnippet[:maxLog] + "...(truncated)"
}
s.logger.LogErrorWithFields("汇博 HTTP 状态异常",
zap.String("url", s.config.URL),
zap.Int("http_status", resp.StatusCode),
zap.String("response_body", bodySnippet),
)
}
return nil, errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码异常: %d, body: %s", resp.StatusCode, string(respBody)))
}
return respBody, nil
}
func (s *HuiboService) validateConfig() error {
if strings.TrimSpace(s.config.URL) == "" ||
strings.TrimSpace(s.config.AppID) == "" ||
strings.TrimSpace(s.config.AppKey) == "" ||
strings.TrimSpace(s.config.SecretID) == "" ||
strings.TrimSpace(s.config.AESKey) == "" ||
strings.TrimSpace(s.config.WorkOrderCode) == "" ||
strings.TrimSpace(s.config.XOrderCode) == "" {
return errors.New("汇博配置不完整")
}
return nil
}
func (s *HuiboService) getProductCode() string {
pc := strings.TrimSpace(s.config.ProductCode)
if pc == "" {
return "22089"
}
return pc
}
func (s *HuiboService) generateRequestID() string {
return "ssw_" + time.Now().Format("060102150405000") + randomDigits(6)
}
func decodeAndValidatePDF(base64PDF string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(base64PDF))
if err != nil {
return nil, fmt.Errorf("授权书文件base64格式错误: %w", err)
}
if err := pdfvalidate.ValidateDecodedPDFBinary(raw); err != nil {
return nil, err
}
return raw, nil
}
func generateSortedParam(m map[string]string) string {
keys := make([]string, 0, len(m))
for k, v := range m {
if strings.TrimSpace(v) == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, k+"="+m[k])
}
return strings.Join(parts, "&")
}
func hmacSHA256Base64(data, secret string) string {
m := hmac.New(sha256.New, []byte(secret))
_, _ = m.Write([]byte(data))
return base64.StdEncoding.EncodeToString(m.Sum(nil))
}
func encryptAESGCMBase64(plainText, base64Key string) (string, error) {
key, err := base64.StdEncoding.DecodeString(base64Key)
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
iv := make([]byte, 12)
if _, err = io.ReadFull(rand.Reader, iv); err != nil {
return "", err
}
ciphertext := gcm.Seal(nil, iv, []byte(plainText), nil)
out := append(iv, ciphertext...)
return base64.StdEncoding.EncodeToString(out), nil
}
func decryptAESGCMBase64(encryptedBase64, base64Key string) (string, error) {
key, err := base64.StdEncoding.DecodeString(base64Key)
if err != nil {
return "", err
}
raw, err := base64.StdEncoding.DecodeString(encryptedBase64)
if err != nil {
return "", err
}
if len(raw) < 13 {
return "", errors.New("密文长度非法")
}
iv := raw[:12]
ciphertext := raw[12:]
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", err
}
plain, err := gcm.Open(nil, iv, ciphertext, nil)
if err != nil {
return "", err
}
return string(plain), nil
}
func normalizeStatus(raw json.RawMessage) string {
s := strings.TrimSpace(string(raw))
if s == "" {
return ""
}
if strings.HasPrefix(s, "\"") && strings.HasSuffix(s, "\"") {
return strings.Trim(s, "\"")
}
return s
}
func getHuiboStatusMessage(status string) string {
if msg, ok := huiboStatusMessage[status]; ok {
return msg
}
if status == "" {
return "数据源返回失败"
}
return "未知状态码: " + status
}
func randomDigits(n int) string {
if n <= 0 {
return ""
}
raw := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, raw); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 10)
}
b := make([]byte, n)
for i := 0; i < n; i++ {
b[i] = byte('0' + int(raw[i])%10)
}
return string(b)
}
// MD5Encrypt 使用配置的 AppKey 进行 MD5 加密
func (s *HuiboService) MD5Encrypt(data string) string {
h := md5.New()
h.Write([]byte(data + s.config.AppKey))
return fmt.Sprintf("%x", h.Sum(nil))
}
// CallAPI2 通用 HTTP 调用方法,返回原始响应 JSON
func (s *HuiboService) CallAPI2(ctx context.Context, pcode string, requestData map[string]interface{}) ([]byte, error) {
startTime := time.Now()
transactionID := ""
if v, ok := ctx.Value("transaction_id").(string); ok {
transactionID = v
}
if s.logger != nil {
s.logger.LogRequest("", transactionID, "huibo_callapi2", s.config.BaseURL2)
}
if strings.TrimSpace(s.config.BaseURL2) == "" {
return nil, errors.Join(ErrSystem, errors.New("汇博配置不完整BaseURL2为空"))
}
if strings.TrimSpace(s.config.AppCode2) == "" {
return nil, errors.Join(ErrSystem, errors.New("汇博配置不完整AppCode2为空"))
}
reqJSON, err := json.Marshal(requestData)
if err != nil {
return nil, errors.Join(ErrSystem, fmt.Errorf("请求参数序列化失败: %w", err))
}
// 构建 curl 命令的 headers
headers := map[string]string{
"AppCode": s.config.AppCode2,
"pcode": pcode,
"Content-Type": "application/json",
"X-ORDER-CODE": s.config.XOrderCode,
}
// 生成包含请求体的 curl 命令用于日志记录
curlCmd := generateCurlCommandWithBody("POST", s.config.BaseURL2, headers, string(reqJSON))
// 创建 HTTP 请求
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.config.BaseURL2, bytes.NewBuffer(reqJSON))
if err != nil {
return nil, errors.Join(ErrSystem, fmt.Errorf("创建HTTP请求失败: %w", err))
}
// req.Header.Set(headerAuthorization, s.config.AppID+"::"+s.config.AppKey)
// req.Header.Set(headerWorkOrderCode, s.config.WorkOrderCode)
// req.Header.Set(headerOrderCode, s.config.XOrderCode)
// req.Header.Set(headerSecretIDHdr, s.config.SecretID)
// req.Header.Set(headerAESKeyHdr, s.config.AESKey)
// req.Header.Set("Content-Type", writer.FormDataContentType())
// 设置请求头
req.Header.Set("AppCode", s.config.AppCode2)
req.Header.Set("pcode", pcode)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-ORDER-CODE", s.config.XOrderCode)
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
if s.logger != nil {
s.logger.LogErrorWithFields("汇博 CallAPI2 HTTP 请求失败",
zap.String("url", s.config.BaseURL2),
zap.String("pcode", pcode),
zap.String("curl", curlCmd),
zap.Error(err),
)
}
return nil, errors.Join(ErrDatasource, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
if s.logger != nil {
s.logger.LogErrorWithFields("汇博 CallAPI2 读取响应体失败",
zap.String("url", s.config.BaseURL2),
zap.Int("http_status", resp.StatusCode),
zap.Error(err),
)
}
return nil, errors.Join(ErrSystem, fmt.Errorf("读取响应体失败: %w", err))
}
// 解析响应以检查业务状态码
var response CallAPI2Response
if err := json.Unmarshal(respBody, &response); err != nil {
if s.logger != nil {
s.logger.LogErrorWithFields("汇博 CallAPI2 响应解析失败",
zap.String("url", s.config.BaseURL2),
zap.String("pcode", pcode),
zap.Error(err),
)
}
return nil, errors.Join(ErrDatasource, fmt.Errorf("响应解析失败: %w", err))
}
// 根据业务状态码进行处理
switch response.Code {
case CallAPI2StatusSuccess:
// 查询成功
if s.logger != nil {
s.logger.LogInfo(
"汇博 CallAPI2 查询成功",
zap.String("pcode", pcode),
zap.String("code", response.Code),
zap.String("transaction_id", transactionID),
)
}
case CallAPI2StatusNoData:
// 查询成功,无数据
if s.logger != nil {
s.logger.LogInfo(
"汇博 CallAPI2 查询成功但无数据",
zap.String("pcode", pcode),
zap.String("code", response.Code),
zap.String("transaction_id", transactionID),
)
}
default:
// 其他错误状态码
message := GetCallAPI2StatusMessage(response.Code)
if s.logger != nil {
s.logger.LogErrorWithFields("汇博 CallAPI2 业务状态异常",
zap.String("url", s.config.BaseURL2),
zap.String("pcode", pcode),
zap.String("code", response.Code),
zap.String("message", message),
)
}
return nil, errors.Join(ErrDatasource, fmt.Errorf("业务状态异常(code=%s,msg=%s)", response.Code, message))
}
// 记录 curl 命令和响应
if s.logger != nil {
s.logger.LogInfo(
"汇博 CallAPI2 请求响应",
zap.String("curl", curlCmd),
zap.String("response_body", string(respBody)),
zap.String("transaction_id", transactionID),
)
}
if s.logger != nil {
s.logger.LogResponse("", transactionID, "huibo_callapi2", http.StatusOK, time.Since(startTime))
}
return respBody, nil
}