This commit is contained in:
Mrx
2026-06-01 14:39:45 +08:00
parent 928ff4d766
commit 8ab2a6d81d
14 changed files with 495 additions and 67 deletions

View File

@@ -0,0 +1,24 @@
package haiyuapi
import (
"encoding/json"
"tyapi-server/internal/shared/crypto"
)
// EncryptParams 将业务参数序列化为 JSON 后,使用 Access Key16进制AES-128-CBC 加密并 Base64 编码
func EncryptParams(params map[string]interface{}, accessKey string) (string, error) {
plainJSON, err := json.Marshal(params)
if err != nil {
return "", err
}
return crypto.AesEncrypt(plainJSON, accessKey)
}
// DecryptData 解密响应 data 字段IV+密文 Base64空字符串视为无数据返回 {}
func DecryptData(encrypted, accessKey string) ([]byte, error) {
if encrypted == "" {
return []byte("{}"), nil
}
return crypto.AesDecrypt(encrypted, accessKey)
}

View File

@@ -0,0 +1,93 @@
package haiyuapi
import (
"errors"
"fmt"
)
// 海宇API平台返回码
const (
CodeSuccess = 0 // 业务成功
CodeQueryEmpty = 1000 // 查询为空
CodeSystem = 1001 // 接口异常
CodeDecryptFail = 1002 // 参数解密失败
CodeRequestParam = 1003 // 基础参数校验不正确
CodeInvalidIP = 1004 // 未经授权的IP
CodeMissingAccessID = 1005 // 缺少Access-Id
CodeInvalidAccessID = 1006 // 未经授权的AccessId
CodeInsufficientBalance = 1007 // 账户余额不足,无法请求
CodeProductNotSubscribed = 1008 // 未开通此产品
CodeBusiness = 2001 // 业务失败
)
var (
ErrDatasource = errors.New("数据源异常")
ErrSystem = errors.New("系统异常")
ErrNotFound = errors.New("查询为空")
)
// haiyuapiAPIError 海宇API平台错误
type haiyuapiAPIError struct {
Code int
Message string
}
func (e *haiyuapiAPIError) Error() string {
return fmt.Sprintf("海宇API返回错误code: %dmessage: %s", e.Code, e.Message)
}
// NewHaiyuapiAPIError 创建平台错误
func NewHaiyuapiAPIError(code int, message string) *haiyuapiAPIError {
if message == "" {
if desc := GetPlatformCodeDesc(code); desc != "" {
message = desc
} else {
message = "海宇API返回未知错误"
}
}
return &haiyuapiAPIError{Code: code, Message: message}
}
// platformCodeDesc 平台 code -> 官方 message
var platformCodeDesc = map[int]string{
CodeSuccess: "业务成功",
CodeQueryEmpty: "查询为空",
CodeSystem: "接口异常",
CodeDecryptFail: "参数解密失败",
CodeRequestParam: "基础参数校验不正确",
CodeInvalidIP: "未经授权的IP",
CodeMissingAccessID: "缺少Access-Id",
CodeInvalidAccessID: "未经授权的AccessId",
CodeInsufficientBalance: "账户余额不足,无法请求",
CodeProductNotSubscribed: "未开通此产品",
CodeBusiness: "业务失败",
}
// GetPlatformCodeDesc 根据平台 code 获取描述
func GetPlatformCodeDesc(code int) string {
if desc, ok := platformCodeDesc[code]; ok {
return desc
}
return ""
}
// GetErrByCode 将海宇API code 映射为内部哨兵错误,供处理器 errors.Is 判断
//
// 1000 -> ErrNotFound查询为空可按产品约定当成功处理
// 1001, 1002, 1003 -> ErrSystem接口/解密/参数校验异常)
// 1004~1008, 2001 -> ErrDatasource鉴权、余额、产品、业务类上游错误
func GetErrByCode(code int) error {
switch code {
case CodeSuccess:
return nil
case CodeQueryEmpty:
return ErrNotFound
case CodeSystem, CodeDecryptFail, CodeRequestParam:
return ErrSystem
case CodeInvalidIP, CodeMissingAccessID, CodeInvalidAccessID,
CodeInsufficientBalance, CodeProductNotSubscribed, CodeBusiness:
return ErrDatasource
default:
return ErrDatasource
}
}

View File

@@ -0,0 +1,64 @@
package haiyuapi
import (
"time"
"tyapi-server/internal/config"
"tyapi-server/internal/shared/external_logger"
)
// NewHaiyuapiServiceWithConfig 使用配置创建海宇API服务
func NewHaiyuapiServiceWithConfig(cfg *config.Config) (*HaiyuapiService, error) {
loggingConfig := external_logger.ExternalServiceLoggingConfig{
Enabled: cfg.Haiyuapi.Logging.Enabled,
LogDir: cfg.Haiyuapi.Logging.LogDir,
ServiceName: "haiyuapi",
UseDaily: cfg.Haiyuapi.Logging.UseDaily,
EnableLevelSeparation: cfg.Haiyuapi.Logging.EnableLevelSeparation,
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
}
for level, levelCfg := range cfg.Haiyuapi.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.Haiyuapi.Timeout
if timeout <= 0 {
timeout = defaultRequestTimeout
}
return NewHaiyuapiService(
cfg.Haiyuapi.BaseURL,
cfg.Haiyuapi.AccessID,
cfg.Haiyuapi.SecretKey,
timeout,
logger,
), nil
}
// NewHaiyuapiServiceWithLogging 使用自定义日志配置创建海宇API服务
func NewHaiyuapiServiceWithLogging(baseURL, accessID, secretKey string, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*HaiyuapiService, error) {
loggingConfig.ServiceName = "haiyuapi"
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
if err != nil {
return nil, err
}
return NewHaiyuapiService(baseURL, accessID, secretKey, timeout, logger), nil
}
// NewHaiyuapiServiceSimple 创建无日志的海宇API服务
func NewHaiyuapiServiceSimple(baseURL, accessID, secretKey string, timeout time.Duration) *HaiyuapiService {
return NewHaiyuapiService(baseURL, accessID, secretKey, timeout, nil)
}

View File

@@ -0,0 +1,178 @@
package haiyuapi
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"tyapi-server/internal/shared/external_logger"
)
const defaultRequestTimeout = 60 * time.Second
// serviceConfig 海宇API服务运行时配置Access Key 为 16 进制 AES-128 密钥)
type serviceConfig struct {
BaseURL string
AccessID string
SecretKey string
Timeout time.Duration
}
// HaiyuapiService 海宇API上游服务客户端
type HaiyuapiService struct {
config serviceConfig
logger *external_logger.ExternalServiceLogger
}
// NewHaiyuapiService 创建海宇API服务实例
func NewHaiyuapiService(baseURL, accessID, secretKey string, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *HaiyuapiService {
if timeout <= 0 {
timeout = defaultRequestTimeout
}
return &HaiyuapiService{
config: serviceConfig{
BaseURL: strings.TrimRight(baseURL, "/"),
AccessID: accessID,
SecretKey: secretKey,
Timeout: timeout,
},
logger: logger,
}
}
// CallAPI 调用海宇APIapiPath 如 /api/v1/FLXGHB4F自动拼接 base_url 与 ?t=13位毫秒时间戳返回解密后的明文 JSON
func (s *HaiyuapiService) CallAPI(ctx context.Context, apiPath string, params map[string]interface{}) ([]byte, error) {
startTime := time.Now()
hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", time.Now().UnixNano(), s.config.SecretKey)))
requestID := fmt.Sprintf("haiyuapi_%x", hash[:8])
var transactionID string
if id, ok := ctx.Value("transaction_id").(string); ok {
transactionID = id
}
path := apiPath
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
reqURL := s.config.BaseURL + path + "?t=" + timestamp
if s.logger != nil {
s.logger.LogRequest(requestID, transactionID, apiPath, reqURL)
}
encryptedData, err := EncryptParams(params, s.config.SecretKey)
if err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("请求加密失败: %w", err))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, params)
}
return nil, err
}
bodyBytes, err := json.Marshal(RequestPayload{Data: encryptedData})
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, params)
}
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewBuffer(bodyBytes))
if err != nil {
err = errors.Join(ErrSystem, err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, params)
}
return nil, err
}
req.Header.Set(HeaderContentType, ContentTypeJSON)
req.Header.Set(HeaderAccessID, s.config.AccessID)
resp, err := (&http.Client{Timeout: s.config.Timeout}).Do(req)
if err != nil {
err = wrapHTTPError(err)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, params)
}
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, apiPath, err, params)
}
return nil, err
}
duration := 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, apiPath, err, params)
}
return nil, err
}
var apiResp APIResponse
if err := json.Unmarshal(respBody, &apiResp); err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, params)
}
return nil, err
}
if apiResp.Code != CodeSuccess {
apiErr := NewHaiyuapiAPIError(apiResp.Code, apiResp.Message)
err = errors.Join(GetErrByCode(apiResp.Code), apiErr)
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, apiErr, params)
}
return nil, err
}
plainResp, err := DecryptData(apiResp.Data, s.config.SecretKey)
if err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("响应解密失败: %w", err))
if s.logger != nil {
s.logger.LogError(requestID, transactionID, apiPath, err, params)
}
return nil, err
}
if s.logger != nil {
s.logger.LogResponse(requestID, transactionID, apiPath, resp.StatusCode, duration)
}
return plainResp, 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)
}
}

View File

@@ -0,0 +1,21 @@
package haiyuapi
// HTTP 请求头
const (
HeaderAccessID = "Access-Id"
HeaderContentType = "Content-Type"
ContentTypeJSON = "application/json"
)
// APIResponse 海宇API公共响应data 为 AES 加密后的 Base64 字符串)
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
TransactionID string `json:"transaction_id"`
Data string `json:"data"`
}
// RequestPayload 海宇API请求体业务参数加密后置于 data
type RequestPayload struct {
Data string `json:"data"`
}