This commit is contained in:
Mrx
2026-05-28 13:10:27 +08:00
parent 52aba13c9e
commit adb44cbedc
67 changed files with 3452 additions and 3 deletions

View File

@@ -0,0 +1,38 @@
package nuoer
import (
"crypto/md5"
"encoding/hex"
"sort"
"strings"
)
// Sign 根据 body 业务参数与 secret 生成 MD5 签名。
// 规则:排除空值参数,按 key 的 ASCII 升序排序,拼接「参数名+参数值」后追加 secret再 MD5小写十六进制
func Sign(body map[string]string, secret string) string {
if len(body) == 0 {
return genMD5(secret)
}
keys := make([]string, 0, len(body))
for k, v := range body {
if strings.TrimSpace(v) == "" {
continue
}
keys = append(keys, k)
}
sort.Strings(keys)
var sb strings.Builder
for _, k := range keys {
sb.WriteString(k)
sb.WriteString(body[k])
}
sb.WriteString(secret)
return genMD5(sb.String())
}
func genMD5(s string) string {
sum := md5.Sum([]byte(s))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,21 @@
package nuoer
import "testing"
func TestSign(t *testing.T) {
body := map[string]string{
"name": "张三",
"mobile": "13290879000",
"idCard": "330129199511153412",
}
secret := "secret"
got := Sign(body, secret)
if got == "" {
t.Fatal("sign should not be empty")
}
// 文档示例name张三mobile13290879000idCard330129199511153412secret
want := genMD5("idCard330129199511153412mobile13290879000name张三secret")
if got != want {
t.Fatalf("sign mismatch: got %s want %s", got, want)
}
}

View File

@@ -0,0 +1,141 @@
package nuoer
import (
"errors"
"fmt"
)
// 平台层 code 返回码见文档2
const (
CodeSuccess = 0 // 成功
CodeResponseError = -1 // 响应异常
)
// 业务层 busiCode 返回码见文档2
const (
BusiCodeSuccess = 10 // 查询成功【计费】
BusiCodeNotFound = 1000 // 数据未查得
BusiCodeInsufficientFund = 1001 // 账户余额不足
BusiCodeAccountNotFound = 1002 // 账户信息不存在
BusiCodeAppIDError = 1003 // appId异常
BusiCodeProductError = 1004 // 产品编号异常
BusiCodeAccountError = 1005 // 账号信息异常
BusiCodeOverdraftLimit = 1006 // 透支余额已达上限
BusiCodeDataRequestError = 1007 // 数据请求异常
BusiCodeServiceNotOpen = 1009 // 服务尚未开通
)
var (
ErrDatasource = errors.New("数据源异常")
ErrSystem = errors.New("系统异常")
ErrNotFound = errors.New("查询为空")
)
// platformCodeDesc 平台层 code -> 描述
var platformCodeDesc = map[int]string{
CodeSuccess: "成功",
CodeResponseError: "响应异常",
}
// busiCodeDesc 业务层 busiCode -> 描述
var busiCodeDesc = map[int]string{
BusiCodeSuccess: "查询成功【计费】",
BusiCodeNotFound: "数据未查得",
BusiCodeInsufficientFund: "账户余额不足",
BusiCodeAccountNotFound: "账户信息不存在",
BusiCodeAppIDError: "appId异常",
BusiCodeProductError: "产品编号异常",
BusiCodeAccountError: "账号信息异常",
BusiCodeOverdraftLimit: "透支余额已达上限",
BusiCodeDataRequestError: "数据请求异常",
BusiCodeServiceNotOpen: "服务尚未开通",
}
// GetPlatformCodeDesc 根据平台 code 获取描述
func GetPlatformCodeDesc(code int) string {
if desc, ok := platformCodeDesc[code]; ok {
return desc
}
return ""
}
// GetBusiCodeDesc 根据 busiCode 获取描述
func GetBusiCodeDesc(busiCode int) string {
if desc, ok := busiCodeDesc[busiCode]; ok {
return desc
}
return ""
}
// nuoerError 诺尔智汇平台层错误(响应 code 字段)
type nuoerError struct {
Code int
Message string
}
func (e *nuoerError) Error() string {
return fmt.Sprintf("诺尔智汇返回错误code: %dmsg: %s", e.Code, e.Message)
}
// NewNuoerError 创建平台层错误
func NewNuoerError(code int, message string) *nuoerError {
if message == "" {
if desc := GetPlatformCodeDesc(code); desc != "" {
message = desc
} else {
message = "诺尔智汇返回未知错误"
}
}
return &nuoerError{Code: code, Message: message}
}
// nuoerBusiError 诺尔智汇业务层错误data.busiCode 字段)
type nuoerBusiError struct {
BusiCode int
BusiMsg string
}
func (e *nuoerBusiError) Error() string {
return fmt.Sprintf("诺尔智汇业务错误busiCode: %dbusiMsg: %s", e.BusiCode, e.BusiMsg)
}
// NewNuoerBusiError 创建业务层错误
func NewNuoerBusiError(busiCode int, busiMsg string) *nuoerBusiError {
if busiMsg == "" {
if desc := GetBusiCodeDesc(busiCode); desc != "" {
busiMsg = desc
} else {
busiMsg = "诺尔智汇业务返回未知错误"
}
}
return &nuoerBusiError{BusiCode: busiCode, BusiMsg: busiMsg}
}
// GetNotFoundErrByBusiCode 将 busiCode 映射为「查询为空」类错误(不扣费场景)
func GetNotFoundErrByBusiCode(busiCode int) error {
switch busiCode {
case BusiCodeNotFound:
return ErrNotFound
default:
return nil
}
}
// GetErrByBusiCode 将 busiCode 映射为内部哨兵错误,供处理器 errors.Is 判断
func GetErrByBusiCode(busiCode int) error {
if busiCode == BusiCodeSuccess {
return nil
}
if notFound := GetNotFoundErrByBusiCode(busiCode); notFound != nil {
return notFound
}
return ErrDatasource
}
// GetErrByPlatformCode 将平台 code 映射为内部哨兵错误
func GetErrByPlatformCode(code int) error {
if code == CodeSuccess {
return nil
}
return ErrDatasource
}

View File

@@ -0,0 +1,64 @@
package nuoer
import (
"time"
"hyapi-server/internal/config"
"hyapi-server/internal/shared/external_logger"
)
// NewNuoerServiceWithConfig 使用配置创建诺尔智汇服务
func NewNuoerServiceWithConfig(cfg *config.Config) (*NuoerService, error) {
loggingConfig := external_logger.ExternalServiceLoggingConfig{
Enabled: cfg.Nuoer.Logging.Enabled,
LogDir: cfg.Nuoer.Logging.LogDir,
ServiceName: "nuoer",
UseDaily: cfg.Nuoer.Logging.UseDaily,
EnableLevelSeparation: cfg.Nuoer.Logging.EnableLevelSeparation,
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
}
for level, levelCfg := range cfg.Nuoer.Logging.LevelConfigs {
loggingConfig.LevelConfigs[level] = external_logger.ExternalServiceLevelFileConfig{
MaxSize: levelCfg.MaxSize,
MaxBackups: levelCfg.MaxBackups,
MaxAge: levelCfg.MaxAge,
Compress: levelCfg.Compress,
}
}
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
if err != nil {
return nil, err
}
timeout := cfg.Nuoer.Timeout
if timeout <= 0 {
timeout = defaultRequestTimeout
}
return NewNuoerService(
cfg.Nuoer.URL,
cfg.Nuoer.AppID,
cfg.Nuoer.AppSecret,
timeout,
logger,
), nil
}
// NewNuoerServiceWithLogging 使用自定义日志配置创建诺尔智汇服务
func NewNuoerServiceWithLogging(url, appID, appSecret string, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*NuoerService, error) {
loggingConfig.ServiceName = "nuoer"
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
if err != nil {
return nil, err
}
return NewNuoerService(url, appID, appSecret, timeout, logger), nil
}
// NewNuoerServiceSimple 创建无日志的诺尔智汇服务
func NewNuoerServiceSimple(url, appID, appSecret string, timeout time.Duration) *NuoerService {
return NewNuoerService(url, appID, appSecret, timeout, nil)
}

View File

@@ -0,0 +1,253 @@
package nuoer
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"hyapi-server/internal/shared/external_logger"
)
const defaultRequestTimeout = 4 * time.Second
// nuoerResponse 诺尔智汇通用响应
type nuoerResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
SeqNo string `json:"seqNo"`
Data interface{} `json:"data"`
}
// serviceConfig 诺尔智汇服务运行时配置
type serviceConfig struct {
URL string
AppID string
AppSecret string
Timeout time.Duration
}
// NuoerService 诺尔智汇服务
type NuoerService struct {
config serviceConfig
logger *external_logger.ExternalServiceLogger
}
// NewNuoerService 创建诺尔智汇服务实例
func NewNuoerService(url, appID, appSecret string, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *NuoerService {
if timeout <= 0 {
timeout = defaultRequestTimeout
}
return &NuoerService{
config: serviceConfig{
URL: url,
AppID: appID,
AppSecret: appSecret,
Timeout: timeout,
},
logger: logger,
}
}
func (s *NuoerService) generateRequestID() string {
timestamp := time.Now().UnixNano()
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, s.config.AppID)))
return fmt.Sprintf("nuoer_%x", hash[:8])
}
func (s *NuoerService) CallAPI(ctx context.Context, apiKey, apiPath string, body map[string]string) (*nuoerResponse, error) {
requestURL := strings.TrimSuffix(s.config.URL, "/")
if apiPath != "" {
if !strings.HasPrefix(apiPath, "/") {
apiPath = "/" + apiPath
}
requestURL += apiPath
}
requestID := s.generateRequestID()
startTime := time.Now()
var transactionID string
if id, ok := ctx.Value("transaction_id").(string); ok {
transactionID = id
}
// 对调用方传入的 body 全量参与加签(排除空值,按 key 升序,见 Sign
sign := Sign(body, s.config.AppSecret)
requestPayload := map[string]interface{}{
"appId": s.config.AppID,
"sign": sign,
"apiKey": apiKey,
"body": body,
}
if s.logger != nil {
s.logger.LogRequest(requestID, transactionID, apiKey, requestURL)
}
bodyBytes, err := json.Marshal(requestPayload)
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes))
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: s.config.Timeout}
resp, err := client.Do(req)
if err != nil {
err = wrapHTTPError(err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
if s.logger != nil {
s.logger.LogResponse(requestID, transactionID, apiKey, resp.StatusCode, time.Since(startTime))
}
if resp.StatusCode != http.StatusOK {
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
var nuoerResp nuoerResponse
if err := json.Unmarshal(respBody, &nuoerResp); err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
if nuoerResp.Code != CodeSuccess {
nuoerErr := NewNuoerError(nuoerResp.Code, nuoerResp.Msg)
err = errors.Join(GetErrByPlatformCode(nuoerResp.Code), nuoerErr)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, nuoerErr, requestPayload)
}
return nil, err
}
if nuoerResp.Data == nil {
err = errors.Join(ErrSystem, errors.New("响应 data 为空"))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
busiCode, busiMsg, ok := parseDataBusiInfo(nuoerResp.Data)
if !ok {
err = errors.Join(ErrSystem, errors.New("响应 data 无法解析 busiCode"))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
if busiCode != BusiCodeSuccess {
busiErr := NewNuoerBusiError(busiCode, busiMsg)
err = errors.Join(GetErrByBusiCode(busiCode), busiErr)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, busiErr, requestPayload)
}
return nil, err
}
cleanedData, err := stripBusiMetaFromData(nuoerResp.Data)
if err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("响应 data 清理失败: %w", err))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiKey, err, requestPayload)
}
return nil, err
}
nuoerResp.Data = cleanedData
return &nuoerResp, nil
}
// nuoerDataBusiMeta 业务层状态字段,仅用于解析校验,不对外返回
type nuoerDataBusiMeta struct {
BusiCode int `json:"busiCode"`
BusiMsg string `json:"busiMsg"`
}
// parseDataBusiInfo 从各接口不同的 data 结构中解析 busiCode、busiMsg
func parseDataBusiInfo(data interface{}) (busiCode int, busiMsg string, ok bool) {
if data == nil {
return 0, "", false
}
raw, err := json.Marshal(data)
if err != nil {
return 0, "", false
}
var meta nuoerDataBusiMeta
if err := json.Unmarshal(raw, &meta); err != nil {
return 0, "", false
}
return meta.BusiCode, meta.BusiMsg, true
}
// stripBusiMetaFromData 去掉 data 中的 busiCode、busiMsg仅保留业务载荷
func stripBusiMetaFromData(data interface{}) (interface{}, error) {
raw, err := json.Marshal(data)
if err != nil {
return nil, err
}
var payload map[string]interface{}
if err := json.Unmarshal(raw, &payload); err != nil {
return nil, err
}
delete(payload, "busiCode")
delete(payload, "busiMsg")
return payload, nil
}
func wrapHTTPError(err error) error {
if err == context.DeadlineExceeded {
return errors.Join(ErrDatasource, err)
}
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
return errors.Join(ErrDatasource, err)
}
switch err.Error() {
case "context deadline exceeded", "timeout", "Client.Timeout exceeded", "net/http: request canceled":
return errors.Join(ErrDatasource, err)
default:
return errors.Join(ErrSystem, err)
}
}