add huibo ivyz4y27

This commit is contained in:
2026-04-28 12:25:41 +08:00
parent 4f64c22ebc
commit 3b7bf1052e
12 changed files with 632 additions and 6 deletions

View File

@@ -641,3 +641,39 @@ shujubao:
max_backups: 5
max_age: 30
compress: true
# ===========================================
# ✨ 汇博BHSC配置
# ===========================================
huibo:
url: "http://47.111.187.101:12654/api/v1/project-api/bg_check_ssw"
app_id: "db0029527bb4558c"
app_key: "a6c9935e967894e731c62ecfcd9b7c95"
x_order_code: "cpdd219219725093"
secret_id: "cf581fe84aaf46ca"
aes_key: "NQYN3YO+pb/GEcCBNX0ptMb7cUlnXSPvcX7VvNofBkc="
work_order_code: "gd219219725093"
product_code: "22089"
logging:
enabled: true
log_dir: "logs/external_services"
service_name: "huibo"
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

View File

@@ -44,6 +44,7 @@ type Config struct {
Shumai ShumaiConfig `mapstructure:"shumai"`
Shujubao ShujubaoConfig `mapstructure:"shujubao"`
PDFGen PDFGenConfig `mapstructure:"pdfgen"`
Huibo HuiboConfig `mapstructure:"huibo"`
}
// ServerConfig HTTP服务器配置
@@ -671,6 +672,37 @@ type PDFGenCacheConfig struct {
MaxSize int64 `mapstructure:"max_size"` // 最大缓存大小0表示不限制单位字节
}
// HuiboConfig 汇博BHSC配置
type HuiboConfig struct {
URL string `mapstructure:"url"`
AppID string `mapstructure:"app_id"`
AppKey string `mapstructure:"app_key"`
XOrderCode string `mapstructure:"x_order_code"`
SecretID string `mapstructure:"secret_id"`
AESKey string `mapstructure:"aes_key"`
WorkOrderCode string `mapstructure:"work_order_code"`
ProductCode string `mapstructure:"product_code"`
Logging HuiboLoggingConfig `mapstructure:"logging"`
}
// HuiboLoggingConfig 汇博日志配置
type HuiboLoggingConfig struct {
Enabled bool `mapstructure:"enabled"`
LogDir string `mapstructure:"log_dir"`
UseDaily bool `mapstructure:"use_daily"`
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
LevelConfigs map[string]HuiboLevelFileConfig `mapstructure:"level_configs"`
}
// HuiboLevelFileConfig 汇博级别日志配置
type HuiboLevelFileConfig struct {
MaxSize int `mapstructure:"max_size"`
MaxBackups int `mapstructure:"max_backups"`
MaxAge int `mapstructure:"max_age"`
Compress bool `mapstructure:"compress"`
}
// DomainConfig 域名配置
type DomainConfig struct {
API string `mapstructure:"api"` // API域名

View File

@@ -15,8 +15,8 @@ import (
"tyapi-server/internal/application/certification"
"tyapi-server/internal/application/finance"
"tyapi-server/internal/application/product"
subordinate_app "tyapi-server/internal/application/subordinate"
"tyapi-server/internal/application/statistics"
subordinate_app "tyapi-server/internal/application/subordinate"
"tyapi-server/internal/application/user"
"tyapi-server/internal/config"
api_repositories "tyapi-server/internal/domains/api/repositories"
@@ -28,8 +28,8 @@ import (
finance_service "tyapi-server/internal/domains/finance/services"
domain_product_repo "tyapi-server/internal/domains/product/repositories"
product_service "tyapi-server/internal/domains/product/services"
domain_subordinate_repo "tyapi-server/internal/domains/subordinate/repositories"
statistics_service "tyapi-server/internal/domains/statistics/services"
domain_subordinate_repo "tyapi-server/internal/domains/subordinate/repositories"
user_service "tyapi-server/internal/domains/user/services"
"tyapi-server/internal/infrastructure/cache"
"tyapi-server/internal/infrastructure/database"
@@ -39,10 +39,10 @@ import (
product_repo "tyapi-server/internal/infrastructure/database/repositories/product"
subordinate_db "tyapi-server/internal/infrastructure/database/repositories/subordinate"
infra_events "tyapi-server/internal/infrastructure/events"
subordinate_infra "tyapi-server/internal/infrastructure/subordinate"
"tyapi-server/internal/infrastructure/external/alicloud"
"tyapi-server/internal/infrastructure/external/captcha"
"tyapi-server/internal/infrastructure/external/email"
"tyapi-server/internal/infrastructure/external/huibo"
"tyapi-server/internal/infrastructure/external/jiguang"
"tyapi-server/internal/infrastructure/external/muzi"
"tyapi-server/internal/infrastructure/external/ocr"
@@ -57,6 +57,7 @@ import (
"tyapi-server/internal/infrastructure/external/zhicha"
"tyapi-server/internal/infrastructure/http/handlers"
"tyapi-server/internal/infrastructure/http/routes"
subordinate_infra "tyapi-server/internal/infrastructure/subordinate"
"tyapi-server/internal/infrastructure/task"
task_implementations "tyapi-server/internal/infrastructure/task/implementations"
asynq "tyapi-server/internal/infrastructure/task/implementations/asynq"
@@ -396,6 +397,10 @@ func NewContainer() *Container {
func(cfg *config.Config) (*shumai.ShumaiService, error) {
return shumai.NewShumaiServiceWithConfig(cfg)
},
// HuiboService - 汇博(BHSC)服务
func(cfg *config.Config) (*huibo.HuiboService, error) {
return huibo.NewHuiboServiceWithConfig(cfg)
},
// ShujubaoService - 数据宝服务
func(cfg *config.Config) (*shujubao.ShujubaoService, error) {
return shujubao.NewShujubaoServiceWithConfig(cfg)

View File

@@ -522,6 +522,11 @@ type IVYZ9K2LReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"`
PhotoData string `json:"photo_data" validate:"required,validBase64Image"`
}
type IVYZ4Y27Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
AuthAuthorizeFileBase64 string `json:"auth_authorize_file_base64" validate:"required,validBase64PDF"`
}
type IVYZP2Q6Req struct {
Name string `json:"name" validate:"required,min=1,validName"`
IDCard string `json:"id_card" validate:"required,validIDCard"`

View File

@@ -21,6 +21,7 @@ import (
"tyapi-server/internal/domains/api/services/processors/yysy"
"tyapi-server/internal/domains/product/services"
"tyapi-server/internal/infrastructure/external/alicloud"
"tyapi-server/internal/infrastructure/external/huibo"
"tyapi-server/internal/infrastructure/external/jiguang"
"tyapi-server/internal/infrastructure/external/muzi"
"tyapi-server/internal/infrastructure/external/shujubao"
@@ -66,6 +67,7 @@ func NewApiRequestService(
xingweiService *xingwei.XingweiService,
jiguangService *jiguang.JiguangService,
shumaiService *shumai.ShumaiService,
huiboService *huibo.HuiboService,
validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService,
cfg *appconfig.Config,
@@ -81,6 +83,7 @@ func NewApiRequestService(
xingweiService,
jiguangService,
shumaiService,
huiboService,
validator,
productManagementService,
cfg,
@@ -101,6 +104,7 @@ func NewApiRequestServiceWithRepos(
xingweiService *xingwei.XingweiService,
jiguangService *jiguang.JiguangService,
shumaiService *shumai.ShumaiService,
huiboService *huibo.HuiboService,
validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService,
cfg *appconfig.Config,
@@ -127,6 +131,7 @@ func NewApiRequestServiceWithRepos(
xingweiService,
jiguangService,
shumaiService,
huiboService,
validator,
combService,
reportRepo,
@@ -332,7 +337,7 @@ func registerAllProcessors(combService *comb.CombService) {
"IVYZ5E22": ivyz.ProcessIVYZ5E22Request, //双人婚姻评估查询zhicha版本
"IVYZRAX1": ivyz.ProcessIVYZRAX1Request, //融安信用分
"IVYZRAX2": ivyz.ProcessIVYZRAX2Request, //融御反欺诈分
"IVYZ4Y27": ivyz.ProcessIVYZ4Y27Request, //教育背景详细查询PDF授权书
// COMB系列处理器 - 只注册有自定义逻辑的组合包
"COMB86PM": comb.ProcessCOMB86PMRequest, // 有自定义逻辑重命名ApiCode

View File

@@ -121,6 +121,7 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
"IVYZ9A2B": &dto.IVYZ9A2BReq{},
"IVYZ7F2A": &dto.IVYZ7F2AReq{},
"IVYZ4E8B": &dto.IVYZ4E8BReq{},
"IVYZ4Y27": &dto.IVYZ4Y27Req{}, //教育背景详细PDF授权书
"IVYZ1C9D": &dto.IVYZ1C9DReq{},
"IVYZGZ08": &dto.IVYZGZ08Req{},
"FLXG8A3F": &dto.FLXG8A3FReq{},
@@ -401,6 +402,8 @@ func (s *FormConfigServiceImpl) parseValidationRules(validateTag string) string
frontendRules = append(frontendRules, "Base64图片格式JPG、BMP、PNG")
case rule == "base64" || rule == "validBase64":
frontendRules = append(frontendRules, "Base64编码格式支持图片/PDF")
case rule == "validBase64PDF":
frontendRules = append(frontendRules, "PDF文件的Base64编码仅PDF最大500KB")
case strings.HasPrefix(rule, "oneof="):
values := strings.TrimPrefix(rule, "oneof=")
frontendRules = append(frontendRules, "可选值: "+values)
@@ -507,7 +510,7 @@ func (s *FormConfigServiceImpl) generateFieldLabel(jsonTag string) string {
"color": "颜色",
"plate_color": "车牌颜色",
"marital_type": "婚姻状况类型",
"auth_authorize_file_base64": "PDF授权文件Base64编码5MB以内",
"auth_authorize_file_base64": "PDF授权文件Base64编码≤500KB仅PDF",
}
if label, exists := labelMap[jsonTag]; exists {

View File

@@ -6,6 +6,7 @@ import (
"tyapi-server/internal/application/api/commands"
"tyapi-server/internal/domains/api/repositories"
"tyapi-server/internal/infrastructure/external/alicloud"
"tyapi-server/internal/infrastructure/external/huibo"
"tyapi-server/internal/infrastructure/external/jiguang"
"tyapi-server/internal/infrastructure/external/muzi"
"tyapi-server/internal/infrastructure/external/shujubao"
@@ -40,6 +41,7 @@ type ProcessorDependencies struct {
XingweiService *xingwei.XingweiService
JiguangService *jiguang.JiguangService
ShumaiService *shumai.ShumaiService
HuiboService *huibo.HuiboService
Validator interfaces.RequestValidator
CombService CombServiceInterface // Changed to interface to break import cycle
Options *commands.ApiCallOptions // 添加Options支持
@@ -67,6 +69,7 @@ func NewProcessorDependencies(
xingweiService *xingwei.XingweiService,
jiguangService *jiguang.JiguangService,
shumaiService *shumai.ShumaiService,
huiboService *huibo.HuiboService,
validator interfaces.RequestValidator,
combService CombServiceInterface, // Changed to interface
reportRepo repositories.ReportRepository,
@@ -84,6 +87,7 @@ func NewProcessorDependencies(
XingweiService: xingweiService,
JiguangService: jiguangService,
ShumaiService: shumaiService,
HuiboService: huiboService,
Validator: validator,
CombService: combService,
Options: nil, // 初始化为nil在调用时设置

View File

@@ -0,0 +1,33 @@
package ivyz
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
)
// ProcessIVYZ4Y27Request IVYZ4Y27 API处理方法 - 教育背景(详细)查询
func ProcessIVYZ4Y27Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.IVYZ4Y27Req
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)
}
if deps.HuiboService == nil {
return nil, errors.Join(processors.ErrSystem, errors.New("汇博服务未初始化"))
}
respBytes, err := deps.HuiboService.CallEducationBackgroundDetailed(ctx, paramsDto.Name, paramsDto.IDCard, paramsDto.AuthAuthorizeFileBase64)
if err != nil {
return nil, errors.Join(processors.ErrDatasource, err)
}
return respBytes, nil
}

View File

@@ -0,0 +1,45 @@
package huibo
import (
"tyapi-server/internal/config"
"tyapi-server/internal/shared/external_logger"
)
// NewHuiboServiceWithConfig 使用配置创建汇博服务
func NewHuiboServiceWithConfig(cfg *config.Config) (*HuiboService, error) {
loggingConfig := external_logger.ExternalServiceLoggingConfig{
Enabled: cfg.Huibo.Logging.Enabled,
LogDir: cfg.Huibo.Logging.LogDir,
ServiceName: "huibo",
UseDaily: cfg.Huibo.Logging.UseDaily,
EnableLevelSeparation: cfg.Huibo.Logging.EnableLevelSeparation,
LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig),
}
for key, value := range cfg.Huibo.Logging.LevelConfigs {
loggingConfig.LevelConfigs[key] = external_logger.ExternalServiceLevelFileConfig{
MaxSize: value.MaxSize,
MaxBackups: value.MaxBackups,
MaxAge: value.MaxAge,
Compress: value.Compress,
}
}
logger, err := external_logger.NewExternalServiceLogger(loggingConfig)
if err != nil {
return nil, err
}
service := NewHuiboService(HuiboConfig{
URL: cfg.Huibo.URL,
AppID: cfg.Huibo.AppID,
AppKey: cfg.Huibo.AppKey,
XOrderCode: cfg.Huibo.XOrderCode,
SecretID: cfg.Huibo.SecretID,
AESKey: cfg.Huibo.AESKey,
WorkOrderCode: cfg.Huibo.WorkOrderCode,
ProductCode: cfg.Huibo.ProductCode,
}, logger)
return service, nil
}

View File

@@ -0,0 +1,414 @@
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)
}

View File

@@ -0,0 +1,27 @@
// Package pdfvalidate 对「已解码的 PDF 二进制」做格式与尺寸校验(与 multipart 发往数据源的字节一致)
package pdfvalidate
import (
"bytes"
"errors"
"fmt"
)
// MaxAuthorizePDFBytes 授权类 PDF 大小上限(与汇博等对接约定一致)
const MaxAuthorizePDFBytes = 500 * 1024
var pdfMagic = []byte("%PDF-")
// ValidateDecodedPDFBinary 仅校验已通过 Base64 解码得到的原始字节非空、长度、PDF 魔数头部。
func ValidateDecodedPDFBinary(raw []byte) error {
if len(raw) == 0 {
return errors.New("授权书文件不能为空")
}
if len(raw) > MaxAuthorizePDFBytes {
return fmt.Errorf("授权书文件不能超过500KB当前大小: %d字节", len(raw))
}
if len(raw) < len(pdfMagic) || !bytes.Equal(raw[:len(pdfMagic)], pdfMagic) {
return errors.New("授权书文件必须为PDF格式")
}
return nil
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"github.com/go-playground/validator/v10"
"tyapi-server/internal/shared/pdfvalidate"
)
// RegisterCustomValidators 注册所有自定义验证器
@@ -104,6 +105,9 @@ func RegisterCustomValidators(validate *validator.Validate) {
// Base64编码格式验证器
validate.RegisterValidation("base64", validateBase64)
validate.RegisterValidation("validBase64", validateBase64)
// PDF 文件 Base64与汇博授权书等场景一致仅 PDF≤500KB
validate.RegisterValidation("validBase64PDF", validateBase64PDF)
}
// validatePhone 手机号验证
@@ -1037,3 +1041,16 @@ func validateBase64(fl validator.FieldLevel) bool {
_, err := base64.StdEncoding.DecodeString(base64Str)
return err == nil
}
// validateBase64PDF先将入参当作 Base64 解码为二进制,再对二进制做 PDF 校验(与发往数据源的逻辑一致)。
func validateBase64PDF(fl validator.FieldLevel) bool {
base64Str := strings.TrimSpace(fl.Field().String())
if base64Str == "" {
return true
}
decoded, err := base64.StdEncoding.DecodeString(base64Str)
if err != nil {
return false
}
return pdfvalidate.ValidateDecodedPDFBinary(decoded) == nil
}