415 lines
11 KiB
Go
415 lines
11 KiB
Go
|
|
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)
|
|||
|
|
}
|