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 }