This commit is contained in:
2026-07-22 17:15:33 +08:00
parent fe4158af57
commit ad732dc796
20 changed files with 1057 additions and 0 deletions

View File

@@ -716,6 +716,37 @@ nuoer:
max_age: 30 max_age: 30
compress: true compress: true
# ===========================================
# ✨ 集奕JIYI配置
# ===========================================
jiyi:
url: "https://api.jiyidata.cn"
app_id: "5DS2yMY9"
app_secret: "16aa682d9b66e17def6aca82e14afc8931fca15d"
timeout: 4s
logging:
enabled: true
log_dir: "logs/external_services"
service_name: "jiyi"
use_daily: true
enable_level_separation: true
level_configs:
info:
max_size: 100
max_backups: 5
max_age: 30
compress: true
error:
max_size: 200
max_backups: 10
max_age: 90
compress: true
warn:
max_size: 100
max_backups: 5
max_age: 30
compress: true
# =========================================== # ===========================================
# ✨ 汇博BHSC配置 # ✨ 汇博BHSC配置
# =========================================== # ===========================================

View File

@@ -197,6 +197,12 @@ nuoer:
url: "https://api.enolfax.com/enol/api" url: "https://api.enolfax.com/enol/api"
app_id: "t4qO2mR3" app_id: "t4qO2mR3"
app_secret: "d1515bf9ed2f2fe063b5f4f7e2c50f0ec65bfd58" app_secret: "d1515bf9ed2f2fe063b5f4f7e2c50f0ec65bfd58"
# 集奕配置
jiyi:
url: "https://api.jiyidata.cn"
app_id: "5DS2yMY9"
app_secret: "16aa682d9b66e17def6aca82e14afc8931fca15d"
development: development:
enable_cors: true enable_cors: true
cors_allowed_origins: "http://localhost:5173,http://localhost:8080" cors_allowed_origins: "http://localhost:5173,http://localhost:8080"

View File

@@ -1087,6 +1087,10 @@ func (s *ProductApplicationServiceImpl) getDTOMap() map[string]interface{} {
"IVYZ6G7H": &dto.IVYZ6G7HReq{}, "IVYZ6G7H": &dto.IVYZ6G7HReq{},
"IVYZ8I9J": &dto.IVYZ8I9JReq{}, "IVYZ8I9J": &dto.IVYZ8I9JReq{},
"JRZQ0L85": &dto.JRZQ0L85Req{}, "JRZQ0L85": &dto.JRZQ0L85Req{},
"JRZQK9P2": &dto.JRZQK9P2Req{}, //洞侦1.0
"JRZQR4N7": &dto.JRZQR4N7Req{}, //借贷意向验证3.0
"JRZQH6M3": &dto.JRZQH6M3Req{}, //无间司南-纯黑A版
"JRZQW3L8": &dto.JRZQW3L8Req{}, //信用司南
} }
} }

View File

@@ -43,6 +43,7 @@ type Config struct {
Tianyuanapi TianyuanapiConfig `mapstructure:"tianyuanapi"` Tianyuanapi TianyuanapiConfig `mapstructure:"tianyuanapi"`
Shujubao ShujubaoConfig `mapstructure:"shujubao"` Shujubao ShujubaoConfig `mapstructure:"shujubao"`
Nuoer NuoerConfig `mapstructure:"nuoer"` Nuoer NuoerConfig `mapstructure:"nuoer"`
Jiyi JiyiConfig `mapstructure:"jiyi"`
Huibo HuiboConfig `mapstructure:"huibo"` Huibo HuiboConfig `mapstructure:"huibo"`
PDFGen PDFGenConfig `mapstructure:"pdfgen"` PDFGen PDFGenConfig `mapstructure:"pdfgen"`
} }
@@ -738,6 +739,33 @@ type NuoerLevelFileConfig struct {
Compress bool `mapstructure:"compress"` Compress bool `mapstructure:"compress"`
} }
// JiyiConfig 集奕配置
type JiyiConfig struct {
URL string `mapstructure:"url"`
AppID string `mapstructure:"app_id"`
AppSecret string `mapstructure:"app_secret"`
Timeout time.Duration `mapstructure:"timeout"`
Logging JiyiLoggingConfig `mapstructure:"logging"`
}
// JiyiLoggingConfig 集奕日志配置
type JiyiLoggingConfig struct {
Enabled bool `mapstructure:"enabled"`
LogDir string `mapstructure:"log_dir"`
UseDaily bool `mapstructure:"use_daily"`
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
LevelConfigs map[string]JiyiLevelFileConfig `mapstructure:"level_configs"`
}
// JiyiLevelFileConfig 集奕日志级别配置
type JiyiLevelFileConfig struct {
MaxSize int `mapstructure:"max_size"`
MaxBackups int `mapstructure:"max_backups"`
MaxAge int `mapstructure:"max_age"`
Compress bool `mapstructure:"compress"`
}
// HuiboConfig 汇博配置 // HuiboConfig 汇博配置
type HuiboConfig struct { type HuiboConfig struct {
URL string `mapstructure:"url"` URL string `mapstructure:"url"`

View File

@@ -42,6 +42,7 @@ import (
"hyapi-server/internal/infrastructure/external/email" "hyapi-server/internal/infrastructure/external/email"
"hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/huibo"
"hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/jiguang"
"hyapi-server/internal/infrastructure/external/jiyi"
"hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/muzi"
"hyapi-server/internal/infrastructure/external/nuoer" "hyapi-server/internal/infrastructure/external/nuoer"
"hyapi-server/internal/infrastructure/external/ocr" "hyapi-server/internal/infrastructure/external/ocr"
@@ -415,6 +416,10 @@ func NewContainer() *Container {
func(cfg *config.Config) (*nuoer.NuoerService, error) { func(cfg *config.Config) (*nuoer.NuoerService, error) {
return nuoer.NewNuoerServiceWithConfig(cfg) return nuoer.NewNuoerServiceWithConfig(cfg)
}, },
// JiyiService - 集奕服务
func(cfg *config.Config) (*jiyi.JiyiService, error) {
return jiyi.NewJiyiServiceWithConfig(cfg)
},
// HuiboService - 汇博服务 // HuiboService - 汇博服务
func(cfg *config.Config) (*huibo.HuiboService, error) { func(cfg *config.Config) (*huibo.HuiboService, error) {
return huibo.NewHuiboServiceWithConfig(cfg) return huibo.NewHuiboServiceWithConfig(cfg)

View File

@@ -211,6 +211,35 @@ type JRZQTH74Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"` IDCard string `json:"id_card" validate:"required,validIDCard"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
} }
// JRZQK9P2Req 洞侦1.0
type JRZQK9P2Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
// JRZQR4N7Req 借贷意向验证3.0
type JRZQR4N7Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
// JRZQH6M3Req 无间司南-纯黑A版
type JRZQH6M3Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
// JRZQW3L8Req 信用司南
type JRZQW3L8Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"`
}
type QCXG76VAReq struct { type QCXG76VAReq struct {
PlateNo string `json:"plate_no" validate:"required"` PlateNo string `json:"plate_no" validate:"required"`
} }

View File

@@ -20,6 +20,7 @@ import (
"hyapi-server/internal/infrastructure/external/alicloud" "hyapi-server/internal/infrastructure/external/alicloud"
"hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/huibo"
"hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/jiguang"
jiyiext "hyapi-server/internal/infrastructure/external/jiyi"
"hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/muzi"
nuoerext "hyapi-server/internal/infrastructure/external/nuoer" nuoerext "hyapi-server/internal/infrastructure/external/nuoer"
"hyapi-server/internal/infrastructure/external/shujubao" "hyapi-server/internal/infrastructure/external/shujubao"
@@ -66,6 +67,7 @@ func NewApiRequestService(
jiguangService *jiguang.JiguangService, jiguangService *jiguang.JiguangService,
tianyuanapiService *tianyuanapi.TianyuanapiService, tianyuanapiService *tianyuanapi.TianyuanapiService,
nuoerService *nuoerext.NuoerService, nuoerService *nuoerext.NuoerService,
jiyiService *jiyiext.JiyiService,
huiboService *huibo.HuiboService, huiboService *huibo.HuiboService,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService, productManagementService *services.ProductManagementService,
@@ -83,6 +85,7 @@ func NewApiRequestService(
jiguangService, jiguangService,
tianyuanapiService, tianyuanapiService,
nuoerService, nuoerService,
jiyiService,
huiboService, huiboService,
validator, validator,
productManagementService, productManagementService,
@@ -105,6 +108,7 @@ func NewApiRequestServiceWithRepos(
jiguangService *jiguang.JiguangService, jiguangService *jiguang.JiguangService,
tianyuanapiService *tianyuanapi.TianyuanapiService, tianyuanapiService *tianyuanapi.TianyuanapiService,
nuoerService *nuoerext.NuoerService, nuoerService *nuoerext.NuoerService,
jiyiService *jiyiext.JiyiService,
huiboService *huibo.HuiboService, huiboService *huibo.HuiboService,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService, productManagementService *services.ProductManagementService,
@@ -133,6 +137,7 @@ func NewApiRequestServiceWithRepos(
jiguangService, jiguangService,
tianyuanapiService, tianyuanapiService,
nuoerService, nuoerService,
jiyiService,
huiboService, huiboService,
validator, validator,
combService, combService,
@@ -193,6 +198,10 @@ func registerAllProcessors(combService *comb.CombService) {
"JRZQTH74": jrzq.ProcessJRZQTH74Request, //团伙欺诈 "JRZQTH74": jrzq.ProcessJRZQTH74Request, //团伙欺诈
"JRZQGJ99": jrzq.ProcessJRZQGJ99Request, //公积金核验 "JRZQGJ99": jrzq.ProcessJRZQGJ99Request, //公积金核验
"JRZQFC59": jrzq.ProcessJRZQFC59Request, //房产核验 "JRZQFC59": jrzq.ProcessJRZQFC59Request, //房产核验
"JRZQK9P2": jrzq.ProcessJRZQK9P2Request, //洞侦1.0
"JRZQR4N7": jrzq.ProcessJRZQR4N7Request, //借贷意向验证3.0
"JRZQH6M3": jrzq.ProcessJRZQH6M3Request, //无间司南-纯黑A版
"JRZQW3L8": jrzq.ProcessJRZQW3L8Request, //信用司南
// QYGL系列处理器 // QYGL系列处理器
"QYGL7HBN": qygl.ProcessQYGL7HBNRequest, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I "QYGL7HBN": qygl.ProcessQYGL7HBNRequest, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I

View File

@@ -312,6 +312,10 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
"JRZQTH74": &dto.JRZQTH74Req{}, //团伙欺诈 "JRZQTH74": &dto.JRZQTH74Req{}, //团伙欺诈
"JRZQGJ99": &dto.JRZQGJ99Req{}, //公积金核验 "JRZQGJ99": &dto.JRZQGJ99Req{}, //公积金核验
"JRZQFC59": &dto.JRZQFC59Req{}, //房产核验 "JRZQFC59": &dto.JRZQFC59Req{}, //房产核验
"JRZQK9P2": &dto.JRZQK9P2Req{}, //洞侦1.0
"JRZQR4N7": &dto.JRZQR4N7Req{}, //借贷意向验证3.0
"JRZQH6M3": &dto.JRZQH6M3Req{}, //无间司南-纯黑A版
"JRZQW3L8": &dto.JRZQW3L8Req{}, //信用司南
"IVYZX7J9": &dto.IVYZX7J9Req{}, //学籍核验 "IVYZX7J9": &dto.IVYZX7J9Req{}, //学籍核验
"QCXG6U5G": &dto.QCXG6U5GReq{}, //车辆核验 "QCXG6U5G": &dto.QCXG6U5GReq{}, //车辆核验
"QCXG7K2N": &dto.QCXG7K2NReq{}, //人车核验加强版 "QCXG7K2N": &dto.QCXG7K2NReq{}, //人车核验加强版

View File

@@ -8,6 +8,7 @@ import (
"hyapi-server/internal/infrastructure/external/alicloud" "hyapi-server/internal/infrastructure/external/alicloud"
"hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/huibo"
"hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/jiguang"
"hyapi-server/internal/infrastructure/external/jiyi"
"hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/muzi"
"hyapi-server/internal/infrastructure/external/nuoer" "hyapi-server/internal/infrastructure/external/nuoer"
"hyapi-server/internal/infrastructure/external/shujubao" "hyapi-server/internal/infrastructure/external/shujubao"
@@ -43,6 +44,7 @@ type ProcessorDependencies struct {
JiguangService *jiguang.JiguangService JiguangService *jiguang.JiguangService
TianyuanapiService *tianyuanapi.TianyuanapiService TianyuanapiService *tianyuanapi.TianyuanapiService
NuoerService *nuoer.NuoerService NuoerService *nuoer.NuoerService
JiyiService *jiyi.JiyiService
HuiboService *huibo.HuiboService HuiboService *huibo.HuiboService
Validator interfaces.RequestValidator Validator interfaces.RequestValidator
CombService CombServiceInterface // Changed to interface to break import cycle CombService CombServiceInterface // Changed to interface to break import cycle
@@ -72,6 +74,7 @@ func NewProcessorDependencies(
jiguangService *jiguang.JiguangService, jiguangService *jiguang.JiguangService,
tianyuanapiService *tianyuanapi.TianyuanapiService, tianyuanapiService *tianyuanapi.TianyuanapiService,
nuoerService *nuoer.NuoerService, nuoerService *nuoer.NuoerService,
jiyiService *jiyi.JiyiService,
huiboService *huibo.HuiboService, huiboService *huibo.HuiboService,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
combService CombServiceInterface, // Changed to interface combService CombServiceInterface, // Changed to interface
@@ -91,6 +94,7 @@ func NewProcessorDependencies(
JiguangService: jiguangService, JiguangService: jiguangService,
TianyuanapiService: tianyuanapiService, TianyuanapiService: tianyuanapiService,
NuoerService: nuoerService, NuoerService: nuoerService,
JiyiService: jiyiService,
HuiboService: huiboService, HuiboService: huiboService,
Validator: validator, Validator: validator,
CombService: combService, CombService: combService,

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQH6M3Request 无间司南-纯黑A版上游 jy000052
func ProcessJRZQH6M3Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQH6M3Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": paramsDto.Name,
"idNo": paramsDto.IDCard,
"mobile": paramsDto.MobileNo,
}
apiKey := "jy000052"
apiPath := "/api/v1/wujsinan/blacka"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.TopEncryptionOptions(1))
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQK9P2Request 洞侦1.0(上游 jy000022
func ProcessJRZQK9P2Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQK9P2Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": paramsDto.Name,
"idNo": paramsDto.IDCard,
"mobile": paramsDto.MobileNo,
}
apiKey := "jy000022"
apiPath := "/api/v1/cave/invest"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.CaveInvestOptions(0))
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQR4N7Request 借贷意向验证3.0(上游 jy000042
func ProcessJRZQR4N7Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQR4N7Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": paramsDto.Name,
"idNo": paramsDto.IDCard,
"mobile": paramsDto.MobileNo,
}
apiKey := "jy000042"
apiPath := "/api/v1/loaninten/ver3"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.TopEncryptionOptions(1))
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,50 @@
package jrzq
import (
"context"
"encoding/json"
"errors"
"hyapi-server/internal/domains/api/dto"
"hyapi-server/internal/domains/api/services/processors"
"hyapi-server/internal/infrastructure/external/jiyi"
)
// ProcessJRZQW3L8Request 信用司南(上游 jy000004查空不计费
func ProcessJRZQW3L8Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.JRZQW3L8Req
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
body := map[string]string{
"name": paramsDto.Name,
"idCard": paramsDto.IDCard,
"mobile": paramsDto.MobileNo,
}
apiKey := "jy000004"
apiPath := "/api/v1/credit/panorama"
resp, err := deps.JiyiService.CallAPI(ctx, apiKey, apiPath, body, jiyi.TopEncryptionOptions(1))
if err != nil {
if errors.Is(err, jiyi.ErrDatasource) {
return nil, errors.Join(processors.ErrDatasource, err)
}
if errors.Is(err, jiyi.ErrNotFound) {
return nil, errors.Join(processors.ErrNotFound, err)
}
return nil, errors.Join(processors.ErrSystem, err)
}
respBytes, err := json.Marshal(resp.Data)
if err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,40 @@
package jiyi
// CallMode 请求组装模式(不同产品加密字段位置不同)
type CallMode int
const (
// CallModePlain 明文,不附加 encryptType/encryptionType/timestamp
CallModePlain CallMode = iota
// CallModeBodyEncrypt 洞侦类body.encryptType0 明文 / 1 MD5并自动补 timestamp
CallModeBodyEncrypt
// CallModeTopEncrypt 借贷意向类:顶层 encryptionType1 明文 / 2 MD5
CallModeTopEncrypt
)
// CallOptions 调用选项
type CallOptions struct {
Mode CallMode
// EncryptValue
// - CallModeBodyEncrypt0 明文1 MD5默认 0
// - CallModeTopEncrypt1 明文2 MD5默认 1
EncryptValue int
}
// DefaultCallOptions 默认:明文且不注入额外字段
func DefaultCallOptions() CallOptions {
return CallOptions{Mode: CallModePlain}
}
// CaveInvestOptions 洞侦1.0 等body 内 encryptType + timestamp
func CaveInvestOptions(encryptType int) CallOptions {
return CallOptions{Mode: CallModeBodyEncrypt, EncryptValue: encryptType}
}
// TopEncryptionOptions 借贷意向验证3.0 等:顶层 encryptionType
func TopEncryptionOptions(encryptionType int) CallOptions {
if encryptionType <= 0 {
encryptionType = 1
}
return CallOptions{Mode: CallModeTopEncrypt, EncryptValue: encryptionType}
}

View File

@@ -0,0 +1,67 @@
package jiyi
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[:])
}
// MD5Encrypt 对业务入参做 MD5 加密(小写十六进制),用于 encryptType=1。
func MD5Encrypt(plaintext string) string {
return genMD5(plaintext)
}
// encryptBodyMD5 将三要素等敏感字段值转为 MD5 密文(保留 encryptType、timestamp
func encryptBodyMD5(body map[string]string) map[string]string {
if len(body) == 0 {
return body
}
skip := map[string]struct{}{
"encryptType": {},
"timestamp": {},
}
encrypted := make(map[string]string, len(body))
for k, v := range body {
if _, ok := skip[k]; ok {
encrypted[k] = v
continue
}
if strings.TrimSpace(v) == "" {
encrypted[k] = v
continue
}
encrypted[k] = MD5Encrypt(v)
}
return encrypted
}

View File

@@ -0,0 +1,43 @@
package jiyi
import "testing"
func TestSign(t *testing.T) {
// 与文档 Java 示例一致的待签字符串顺序
body := map[string]string{
"idNo": "440181199807041111",
"mobile": "13000000000",
"name": "黄雅莉",
"encryptType": "0",
"timestamp": "1720940866832",
}
secret := "a661a2728f2a54b71601d3e8a7f6210d650c2018"
got := Sign(body, secret)
want := genMD5("encryptType0idNo440181199807041111mobile13000000000name黄雅莉timestamp1720940866832" + secret)
if got != want {
t.Fatalf("sign mismatch: got %s want %s", got, want)
}
}
func TestEncryptBodyMD5(t *testing.T) {
body := map[string]string{
"idNo": "440181199807041111",
"mobile": "13000000000",
"name": "黄雅莉",
"encryptType": "1",
"timestamp": "1720940866832",
}
got := encryptBodyMD5(body)
if got["encryptType"] != "1" || got["timestamp"] != "1720940866832" {
t.Fatalf("meta fields should keep plaintext: %+v", got)
}
if got["idNo"] != MD5Encrypt("440181199807041111") {
t.Fatalf("idNo not encrypted: %s", got["idNo"])
}
if got["mobile"] != MD5Encrypt("13000000000") {
t.Fatalf("mobile not encrypted: %s", got["mobile"])
}
if got["name"] != MD5Encrypt("黄雅莉") {
t.Fatalf("name not encrypted: %s", got["name"])
}
}

View File

@@ -0,0 +1,53 @@
package jiyi
import "testing"
func TestIsEmptyBusinessResult(t *testing.T) {
cases := []struct {
name string
data interface{}
empty bool
}{
{"nil_map", map[string]interface{}{}, true},
{"missing_result", map[string]interface{}{"foo": 1}, true},
{"nil_result", map[string]interface{}{"result": nil}, true},
{"empty_result_map", map[string]interface{}{"result": map[string]interface{}{}}, true},
{"black_tags_zero_not_empty", map[string]interface{}{
"result": map[string]interface{}{
"black_list": "0",
"black_tag04": "0",
},
}, false},
{"score_present", map[string]interface{}{
"result": map[string]interface{}{
"score": "617",
"reason": "",
"contents": map[string]interface{}{"TC_A001": "0"},
},
}, false},
{"all_blank_strings", map[string]interface{}{
"result": map[string]interface{}{
"score": "",
"reason": "",
},
}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := isEmptyBusinessResult(tc.data)
if got != tc.empty {
t.Fatalf("got %v want %v", got, tc.empty)
}
})
}
}
func TestIsQueryBillingAPIKey(t *testing.T) {
if !isQueryBillingAPIKey("jy000022") || !isQueryBillingAPIKey("jy000042") || !isQueryBillingAPIKey("jy000052") {
t.Fatal("查空计费产品应在白名单中")
}
if isQueryBillingAPIKey("jy000004") {
t.Fatal("信用司南查空不计费,不应在白名单中")
}
}

View File

@@ -0,0 +1,119 @@
package jiyi
import (
"errors"
"fmt"
)
// 平台层 code 返回码
const (
CodeSuccess = 0 // 成功
CodeResponseError = -1 // 异常
)
// 业务层 busiCode 返回码(洞侦等接口通用)
const (
BusiCodeSuccess = 10 // 查询成功
BusiCodeNotFound = 1000 // 未查得
BusiCodeSignError = 1005 // 签名异常
BusiCodeDataRequestError = 1007 // 数据请求异常
BusiCodeParamError = 1010 // 参数错误
BusiCodeUnknown = -1 // 未知错误
)
var (
ErrDatasource = errors.New("数据源异常")
ErrSystem = errors.New("系统异常")
ErrNotFound = errors.New("查询为空")
)
var platformCodeDesc = map[int]string{
CodeSuccess: "成功",
CodeResponseError: "异常",
}
var busiCodeDesc = map[int]string{
BusiCodeSuccess: "查询成功",
BusiCodeNotFound: "未查得",
BusiCodeSignError: "签名异常",
BusiCodeDataRequestError: "数据请求异常",
BusiCodeParamError: "参数错误",
BusiCodeUnknown: "未知错误",
}
// 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 ""
}
type jiyiError struct {
Code int
Message string
}
func (e *jiyiError) Error() string {
return fmt.Sprintf("集奕返回错误code: %dmsg: %s", e.Code, e.Message)
}
// NewJiyiError 创建平台层错误
func NewJiyiError(code int, message string) *jiyiError {
if message == "" {
if desc := GetPlatformCodeDesc(code); desc != "" {
message = desc
} else {
message = "集奕返回未知错误"
}
}
return &jiyiError{Code: code, Message: message}
}
type jiyiBusiError struct {
BusiCode int
BusiMsg string
}
func (e *jiyiBusiError) Error() string {
return fmt.Sprintf("集奕业务错误busiCode: %dbusiMsg: %s", e.BusiCode, e.BusiMsg)
}
// NewJiyiBusiError 创建业务层错误
func NewJiyiBusiError(busiCode int, busiMsg string) *jiyiBusiError {
if busiMsg == "" {
if desc := GetBusiCodeDesc(busiCode); desc != "" {
busiMsg = desc
} else {
busiMsg = "集奕业务返回未知错误"
}
}
return &jiyiBusiError{BusiCode: busiCode, BusiMsg: busiMsg}
}
// GetErrByBusiCode 将 busiCode 映射为内部哨兵错误
func GetErrByBusiCode(busiCode int) error {
if busiCode == BusiCodeSuccess {
return nil
}
if busiCode == BusiCodeNotFound {
return ErrNotFound
}
return ErrDatasource
}
// GetErrByPlatformCode 将平台 code 映射为内部哨兵错误
func GetErrByPlatformCode(code int) error {
if code == CodeSuccess {
return nil
}
return ErrDatasource
}

View File

@@ -0,0 +1,64 @@
package jiyi
import (
"time"
"hyapi-server/internal/config"
"hyapi-server/internal/shared/external_logger"
)
// NewJiyiServiceWithConfig 使用配置创建集奕服务
func NewJiyiServiceWithConfig(cfg *config.Config) (*JiyiService, error) {
loggingConfig := external_logger.ExternalServiceLoggingConfig{
Enabled: cfg.Jiyi.Logging.Enabled,
LogDir: cfg.Jiyi.Logging.LogDir,
ServiceName: "jiyi",
UseDaily: cfg.Jiyi.Logging.UseDaily,
EnableLevelSeparation: cfg.Jiyi.Logging.EnableLevelSeparation,
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
}
for level, levelCfg := range cfg.Jiyi.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.Jiyi.Timeout
if timeout <= 0 {
timeout = defaultRequestTimeout
}
return NewJiyiService(
cfg.Jiyi.URL,
cfg.Jiyi.AppID,
cfg.Jiyi.AppSecret,
timeout,
logger,
), nil
}
// NewJiyiServiceWithLogging 使用自定义日志配置创建集奕服务
func NewJiyiServiceWithLogging(url, appID, appSecret string, timeout time.Duration, loggingConfig external_logger.ExternalServiceLoggingConfig) (*JiyiService, error) {
loggingConfig.ServiceName = "jiyi"
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
if err != nil {
return nil, err
}
return NewJiyiService(url, appID, appSecret, timeout, logger), nil
}
// NewJiyiServiceSimple 创建无日志的集奕服务
func NewJiyiServiceSimple(url, appID, appSecret string, timeout time.Duration) *JiyiService {
return NewJiyiService(url, appID, appSecret, timeout, nil)
}

View File

@@ -0,0 +1,351 @@
package jiyi
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"hyapi-server/internal/shared/external_logger"
)
const defaultRequestTimeout = 4 * time.Second
// queryBillingAPIKeys 查询计费接口:未查得/空结果仍按成功返回空数据,由平台侧计费
var queryBillingAPIKeys = map[string]struct{}{
"jy000022": {}, // 洞侦1.0
"jy000042": {}, // 借贷意向验证3.0
"jy000052": {}, // 无间司南-纯黑A版
}
func isQueryBillingAPIKey(apiKey string) bool {
_, ok := queryBillingAPIKeys[apiKey]
return ok
}
// jiyiResponse 集奕通用响应
type jiyiResponse struct {
Code int `json:"code"`
Msg string `json:"msg"`
Fee string `json:"fee"`
SeqNo string `json:"seqNo"`
Data interface{} `json:"data"`
}
// Response 对外暴露的响应别名,便于处理器取值
type Response = jiyiResponse
type serviceConfig struct {
URL string
AppID string
AppSecret string
Timeout time.Duration
}
// JiyiService 集奕数据服务
type JiyiService struct {
config serviceConfig
logger *external_logger.ExternalServiceLogger
}
// NewJiyiService 创建集奕服务实例
func NewJiyiService(url, appID, appSecret string, timeout time.Duration, logger *external_logger.ExternalServiceLogger) *JiyiService {
if timeout <= 0 {
timeout = defaultRequestTimeout
}
return &JiyiService{
config: serviceConfig{
URL: url,
AppID: appID,
AppSecret: appSecret,
Timeout: timeout,
},
logger: logger,
}
}
func (s *JiyiService) logResponse(transactionID, apiKey string, statusCode int, duration time.Duration, seqNo string) {
if s.logger == nil {
return
}
s.logger.LogResponse(seqNo, transactionID, apiKey, statusCode, duration)
}
func (s *JiyiService) logError(transactionID, apiKey, seqNo string, err error, payload interface{}) {
if s.logger == nil {
return
}
s.logger.LogError(seqNo, transactionID, apiKey, err, payload)
}
// CallAPI 调用集奕接口。opts 为空时按明文、不注入额外字段处理。
func (s *JiyiService) CallAPI(ctx context.Context, apiKey, apiPath string, body map[string]string, opts ...CallOptions) (*Response, error) {
requestURL := strings.TrimSuffix(s.config.URL, "/")
if apiPath != "" {
if !strings.HasPrefix(apiPath, "/") {
apiPath = "/" + apiPath
}
requestURL += apiPath
}
startTime := time.Now()
var transactionID string
if id, ok := ctx.Value("transaction_id").(string); ok {
transactionID = id
}
opt := DefaultCallOptions()
if len(opts) > 0 {
opt = opts[0]
}
requestBody := cloneBody(body)
switch opt.Mode {
case CallModeBodyEncrypt:
encType := opt.EncryptValue
if _, ok := requestBody["timestamp"]; !ok || strings.TrimSpace(requestBody["timestamp"]) == "" {
requestBody["timestamp"] = strconv.FormatInt(time.Now().UnixMilli(), 10)
}
requestBody["encryptType"] = strconv.Itoa(encType)
if encType == 1 {
requestBody = encryptBodyMD5(requestBody)
}
case CallModeTopEncrypt:
if opt.EncryptValue == 2 {
requestBody = encryptBodyMD5(requestBody)
}
}
sign := Sign(requestBody, s.config.AppSecret)
requestPayload := map[string]interface{}{
"appId": s.config.AppID,
"sign": sign,
"apiKey": apiKey,
"body": requestBody,
}
if opt.Mode == CallModeTopEncrypt {
encVal := opt.EncryptValue
if encVal <= 0 {
encVal = 1
}
// 顶层 encryptionType部分接口要求 JSON number统一传整型
requestPayload["encryptionType"] = encVal
}
if s.logger != nil {
s.logger.LogRequest("", transactionID, apiKey, requestURL)
}
bodyBytes, err := json.Marshal(requestPayload)
if err != nil {
err = errors.Join(ErrSystem, err)
s.logError(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)
s.logError(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)
s.logError(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)
s.logError(transactionID, apiKey, "", err, requestPayload)
return nil, err
}
duration := time.Since(startTime)
if resp.StatusCode != http.StatusOK {
err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", resp.StatusCode))
s.logError(transactionID, apiKey, "", err, requestPayload)
return nil, err
}
var jiyiResp jiyiResponse
if err := json.Unmarshal(respBody, &jiyiResp); err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err))
s.logError(transactionID, apiKey, "", err, requestPayload)
return nil, err
}
if jiyiResp.Code != CodeSuccess {
jiyiErr := NewJiyiError(jiyiResp.Code, jiyiResp.Msg)
err = errors.Join(GetErrByPlatformCode(jiyiResp.Code), jiyiErr)
s.logError(transactionID, apiKey, jiyiResp.SeqNo, jiyiErr, requestPayload)
return nil, err
}
if jiyiResp.Data == nil {
// 部分接口查空时 data 可能为 null
if isQueryBillingAPIKey(apiKey) {
jiyiResp.Data = map[string]interface{}{}
s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo)
return &jiyiResp, nil
}
err = errors.Join(ErrNotFound, errors.New("响应 data 为空"))
s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload)
return nil, err
}
busiCode, busiMsg, ok := parseDataBusiInfo(jiyiResp.Data)
if !ok || (busiCode == 0 && strings.TrimSpace(busiMsg) == "") {
err = errors.Join(ErrSystem, errors.New("响应 data 无法解析 busiCode"))
s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload)
return nil, err
}
if busiCode != BusiCodeSuccess {
// 查询计费产品:未查得仍返回空数据,由平台计费
if busiCode == BusiCodeNotFound && isQueryBillingAPIKey(apiKey) {
jiyiResp.Data = map[string]interface{}{}
s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo)
return &jiyiResp, nil
}
busiErr := NewJiyiBusiError(busiCode, busiMsg)
err = errors.Join(GetErrByBusiCode(busiCode), busiErr)
s.logError(transactionID, apiKey, jiyiResp.SeqNo, busiErr, requestPayload)
return nil, err
}
cleanedData, err := stripBusiMetaFromData(jiyiResp.Data)
if err != nil {
err = errors.Join(ErrSystem, fmt.Errorf("响应 data 清理失败: %w", err))
s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload)
return nil, err
}
if isEmptyBusinessResult(cleanedData) {
if isQueryBillingAPIKey(apiKey) {
jiyiResp.Data = map[string]interface{}{}
s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo)
return &jiyiResp, nil
}
emptyErr := NewJiyiBusiError(BusiCodeNotFound, "未查得")
err = errors.Join(ErrNotFound, emptyErr)
s.logError(transactionID, apiKey, jiyiResp.SeqNo, err, requestPayload)
return nil, err
}
jiyiResp.Data = cleanedData
s.logResponse(transactionID, apiKey, resp.StatusCode, duration, jiyiResp.SeqNo)
return &jiyiResp, nil
}
type jiyiDataBusiMeta struct {
BusiCode int `json:"busiCode"`
BusiMsg string `json:"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 jiyiDataBusiMeta
if err := json.Unmarshal(raw, &meta); err != nil {
return 0, "", false
}
return meta.BusiCode, meta.BusiMsg, true
}
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
}
// isEmptyBusinessResult 判断业务载荷是否为空(无 result或 result 无有效内容)
func isEmptyBusinessResult(data interface{}) bool {
payload, ok := data.(map[string]interface{})
if !ok || len(payload) == 0 {
return true
}
result, exists := payload["result"]
if !exists || result == nil {
return true
}
return isEmptyValue(result)
}
func isEmptyValue(v interface{}) bool {
if v == nil {
return true
}
switch x := v.(type) {
case string:
return strings.TrimSpace(x) == ""
case map[string]interface{}:
if len(x) == 0 {
return true
}
for _, item := range x {
if !isEmptyValue(item) {
return false
}
}
return true
case []interface{}:
return len(x) == 0
default:
return false
}
}
func cloneBody(body map[string]string) map[string]string {
if body == nil {
return map[string]string{}
}
cloned := make(map[string]string, len(body)+2)
for k, v := range body {
cloned[k] = v
}
return cloned
}
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)
}
}