Files
tyapi-server/internal/infrastructure/external/huibo/huibo_service.go
2026-04-28 12:25:41 +08:00

415 lines
11 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/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"
headerYMDate = "YmDate"
headerOrderCode = "X-ORDER-CODE"
headerResponseType = "X-RESPONSE-TYPE"
headerResponseTypeDataVal = "data"
)
// 汇博常见状态码
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
}
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"`
}
func NewHuiboService(config HuiboConfig, logger *external_logger.ExternalServiceLogger) *HuiboService {
return &HuiboService{config: config, logger: logger}
}
// 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)
if err := writer.WriteField("req", string(reqOuterJSON)); err != nil {
return nil, errors.Join(ErrSystem, err)
}
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.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(headerYMDate, strconv.FormatInt(time.Now().UnixMilli(), 10))
req.Header.Set(headerOrderCode, s.config.XOrderCode)
req.Header.Set(headerResponseType, headerResponseTypeDataVal)
req.Header.Set("Content-Type", writer.FormDataContentType())
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, errors.Join(ErrDatasource, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Join(ErrSystem, err)
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
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.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)
}