diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index 502db1e..32a7220 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -329,31 +329,31 @@ func (s *FormConfigServiceImpl) getFieldType(fieldType reflect.Type, validation func (s *FormConfigServiceImpl) generateFieldLabel(jsonTag string) string { // 将下划线命名转换为中文标签 labelMap := map[string]string{ - "mobile_no": "手机号码", - "id_card": "身份证号", - "name": "姓名", - "man_name": "男方姓名", - "woman_name": "女方姓名", - "man_id_card": "男方身份证", - "woman_id_card": "女方身份证", - "ent_name": "企业名称", - "legal_person": "法人姓名", - "ent_code": "企业代码", - "auth_date": "授权日期", - "time_range": "时间范围", - "authorized": "是否授权", - "authorization_url": "授权链接", - "unique_id": "唯一标识", - "return_url": "返回链接", - "mobile_type": "手机类型", - "start_date": "开始日期", - "years": "年数", - "bank_card": "银行卡号", - "user_type": "关系类型", - "vehicle_type": "车辆类型", - "page_num": "页码", - "page_size": "每页数量", - "use_scenario": "使用场景", + "mobile_no": "手机号码", + "id_card": "身份证号", + "name": "姓名", + "man_name": "男方姓名", + "woman_name": "女方姓名", + "man_id_card": "男方身份证", + "woman_id_card": "女方身份证", + "ent_name": "企业名称", + "legal_person": "法人姓名", + "ent_code": "企业代码", + "auth_date": "授权日期", + "time_range": "时间范围", + "authorized": "是否授权", + "authorization_url": "授权链接", + "unique_id": "唯一标识", + "return_url": "返回链接", + "mobile_type": "手机类型", + "start_date": "开始日期", + "years": "年数", + "bank_card": "银行卡号", + "user_type": "关系类型", + "vehicle_type": "车辆类型", + "page_num": "页码", + "page_size": "每页数量", + "use_scenario": "使用场景", "auth_authorize_file_code": "授权文件编码", } @@ -368,29 +368,29 @@ func (s *FormConfigServiceImpl) generateFieldLabel(jsonTag string) string { // generateExampleValue 生成示例值 func (s *FormConfigServiceImpl) generateExampleValue(fieldType reflect.Type, jsonTag string) string { exampleMap := map[string]string{ - "mobile_no": "13800138000", - "id_card": "110101199001011234", - "name": "张三", - "man_name": "张三", - "woman_name": "李四", - "ent_name": "示例企业有限公司", - "legal_person": "王五", - "ent_code": "91110000123456789X", - "auth_date": "20240101-20241231", - "time_range": "09:00-18:00", - "authorized": "1", - "years": "5", - "bank_card": "6222021234567890123", - "mobile_type": "移动", - "start_date": "2024-01-01", - "unique_id": "UNIQUE123456", - "return_url": "https://example.com/return", - "authorization_url": "https://example.com/auth20250101.pdf", - "user_type": "1", - "vehicle_type": "0", - "page_num": "1", - "page_size": "10", - "use_scenario": "1", + "mobile_no": "13800138000", + "id_card": "110101199001011234", + "name": "张三", + "man_name": "张三", + "woman_name": "李四", + "ent_name": "示例企业有限公司", + "legal_person": "王五", + "ent_code": "91110000123456789X", + "auth_date": "20240101-20241231", + "time_range": "09:00-18:00", + "authorized": "1", + "years": "5", + "bank_card": "6222021234567890123", + "mobile_type": "移动", + "start_date": "2024-01-01", + "unique_id": "UNIQUE123456", + "return_url": "https://example.com/return", + "authorization_url": "https://example.com/auth20250101.pdf 注意:请不要使用示例链接,示例链接仅作为参考格式。必须为实际的被查询人授权具有法律效益的授权书文件链接,如访问不到或为不实授权书将追究责任。协议必须为http https", + "user_type": "1", + "vehicle_type": "0", + "page_num": "1", + "page_size": "10", + "use_scenario": "1", "auth_authorize_file_code": "AUTH123456", } @@ -414,29 +414,29 @@ func (s *FormConfigServiceImpl) generateExampleValue(fieldType reflect.Type, jso // generatePlaceholder 生成占位符 func (s *FormConfigServiceImpl) generatePlaceholder(jsonTag string, fieldType string) string { placeholderMap := map[string]string{ - "mobile_no": "请输入11位手机号码", - "id_card": "请输入18位身份证号码", - "name": "请输入真实姓名", - "man_name": "请输入男方真实姓名", - "woman_name": "请输入女方真实姓名", - "ent_name": "请输入企业全称", - "legal_person": "请输入法人真实姓名", - "ent_code": "请输入统一社会信用代码", - "auth_date": "请输入授权日期范围(YYYYMMDD-YYYYMMDD)", - "time_range": "请输入时间范围(HH:MM-HH:MM)", - "authorized": "请选择是否授权", - "years": "请输入查询年数(0-100)", - "bank_card": "请输入银行卡号", - "mobile_type": "请选择手机类型", - "start_date": "请选择开始日期", - "unique_id": "请输入唯一标识", - "return_url": "请输入返回链接", - "authorization_url": "请输入授权链接", - "user_type": "请选择关系类型", - "vehicle_type": "请选择车辆类型", - "page_num": "请输入页码", - "page_size": "请输入每页数量(1-100)", - "use_scenario": "请选择使用场景", + "mobile_no": "请输入11位手机号码", + "id_card": "请输入18位身份证号码", + "name": "请输入真实姓名", + "man_name": "请输入男方真实姓名", + "woman_name": "请输入女方真实姓名", + "ent_name": "请输入企业全称", + "legal_person": "请输入法人真实姓名", + "ent_code": "请输入统一社会信用代码", + "auth_date": "请输入授权日期范围(YYYYMMDD-YYYYMMDD)", + "time_range": "请输入时间范围(HH:MM-HH:MM)", + "authorized": "请选择是否授权", + "years": "请输入查询年数(0-100)", + "bank_card": "请输入银行卡号", + "mobile_type": "请选择手机类型", + "start_date": "请选择开始日期", + "unique_id": "请输入唯一标识", + "return_url": "请输入返回链接", + "authorization_url": "请输入授权链接", + "user_type": "请选择关系类型", + "vehicle_type": "请选择车辆类型", + "page_num": "请输入页码", + "page_size": "请输入每页数量(1-100)", + "use_scenario": "请选择使用场景", "auth_authorize_file_code": "请输入授权文件编码", } @@ -462,29 +462,29 @@ func (s *FormConfigServiceImpl) generatePlaceholder(jsonTag string, fieldType st // generateDescription 生成字段描述 func (s *FormConfigServiceImpl) generateDescription(jsonTag string, validation string) string { descMap := map[string]string{ - "mobile_no": "请输入11位手机号码", - "id_card": "请输入18位身份证号码", - "name": "请输入真实姓名", - "man_name": "请输入男方真实姓名", - "woman_name": "请输入女方真实姓名", - "ent_name": "请输入企业全称", - "legal_person": "请输入法人真实姓名", - "ent_code": "请输入统一社会信用代码", - "auth_date": "请输入授权日期范围,格式:YYYYMMDD-YYYYMMDD,且日期范围必须包括今天", - "time_range": "请输入时间范围,格式:HH:MM-HH:MM", - "authorized": "请输入是否授权:0-未授权,1-已授权", - "years": "请输入查询年数(0-100)", - "bank_card": "请输入银行卡号", - "mobile_type": "请选择手机类型", - "start_date": "请选择开始日期", - "unique_id": "请输入唯一标识", - "return_url": "请输入返回链接", - "authorization_url": "请输入授权链接", - "user_type": "关系类型:1-ETC开户人;2-车辆所有人;3-ETC经办人(默认1-ETC开户人)", - "vehicle_type": "车辆类型:0-客车;1-货车;2-全部(默认查全部)", - "page_num": "请输入页码,从1开始", - "page_size": "请输入每页数量,范围1-100", - "use_scenario": "使用场景:1-信贷审核;2-保险评估;3-招聘背景调查;4-其他业务场景;99-其他", + "mobile_no": "请输入11位手机号码", + "id_card": "请输入18位身份证号码", + "name": "请输入真实姓名", + "man_name": "请输入男方真实姓名", + "woman_name": "请输入女方真实姓名", + "ent_name": "请输入企业全称", + "legal_person": "请输入法人真实姓名", + "ent_code": "请输入统一社会信用代码", + "auth_date": "请输入授权日期范围,格式:YYYYMMDD-YYYYMMDD,且日期范围必须包括今天", + "time_range": "请输入时间范围,格式:HH:MM-HH:MM", + "authorized": "请输入是否授权:0-未授权,1-已授权", + "years": "请输入查询年数(0-100)", + "bank_card": "请输入银行卡号", + "mobile_type": "请选择手机类型", + "start_date": "请选择开始日期", + "unique_id": "请输入唯一标识", + "return_url": "请输入返回链接", + "authorization_url": "请输入授权链接", + "user_type": "关系类型:1-ETC开户人;2-车辆所有人;3-ETC经办人(默认1-ETC开户人)", + "vehicle_type": "车辆类型:0-客车;1-货车;2-全部(默认查全部)", + "page_num": "请输入页码,从1开始", + "page_size": "请输入每页数量,范围1-100", + "use_scenario": "使用场景:1-信贷审核;2-保险评估;3-招聘背景调查;4-其他业务场景;99-其他", "auth_authorize_file_code": "请输入授权文件编码", } @@ -501,7 +501,7 @@ func (s *FormConfigServiceImpl) mergeCombPackageDTOs(ctx context.Context, apiCod if s.productManagementService == nil { return &struct{}{}, nil } - + // 1. 从数据库获取组合包产品信息 packageProduct, err := s.productManagementService.GetProductByCode(ctx, apiCode) if err != nil { @@ -560,7 +560,7 @@ func (s *FormConfigServiceImpl) mergeCombPackageDTOs(ctx context.Context, apiCod // 创建结构体类型 structType := reflect.StructOf(fields) - + // 创建并返回结构体实例 structValue := reflect.New(structType) return structValue.Interface(), nil diff --git a/internal/domains/api/services/processors/comb/comb_service.go b/internal/domains/api/services/processors/comb/comb_service.go index 2feb463..7b7b1c4 100644 --- a/internal/domains/api/services/processors/comb/comb_service.go +++ b/internal/domains/api/services/processors/comb/comb_service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "sort" + "strings" "sync" "tyapi-server/internal/domains/api/services/processors" @@ -136,11 +137,37 @@ func (cs *CombService) processSingleSubProduct( } // combineResults 组合所有子产品的结果 +// 只要至少有一个子产品成功,就返回成功结果(部分成功也算成功) +// 只有当所有子产品都失败时,才返回错误 func (cs *CombService) combineResults(results []*processors.SubProductResult) (*processors.CombinedResult, error) { + // 检查是否至少有一个成功的子产品 + hasSuccess := false + for _, result := range results { + if result.Success { + hasSuccess = true + break + } + } + // 构建组合结果 combinedResult := &processors.CombinedResult{ Responses: results, } + + // 如果所有子产品都失败,返回错误 + if !hasSuccess && len(results) > 0 { + // 构建错误信息,包含所有失败的原因 + errorMessages := make([]string, 0, len(results)) + for _, result := range results { + if result.Error != "" { + errorMessages = append(errorMessages, fmt.Sprintf("%s: %s", result.ApiCode, result.Error)) + } + } + errorMsg := fmt.Sprintf("组合包所有子产品调用失败: %s", strings.Join(errorMessages, "; ")) + return nil, fmt.Errorf(errorMsg) + } + + // 至少有一个成功,返回成功结果 return combinedResult, nil } diff --git a/internal/infrastructure/external/alicloud/alicloud_service.go b/internal/infrastructure/external/alicloud/alicloud_service.go index 9be4f35..86e5fdc 100644 --- a/internal/infrastructure/external/alicloud/alicloud_service.go +++ b/internal/infrastructure/external/alicloud/alicloud_service.go @@ -7,6 +7,7 @@ import ( "net/http" "net/url" "strings" + "time" ) var ( @@ -58,10 +59,27 @@ func (a *AlicloudService) CallAPI(path string, params map[string]interface{}) (r req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8") req.Header.Set("Authorization", "APPCODE "+a.config.AppCode) - // 发送请求 - client := &http.Client{} + // 发送请求,超时时间设置为60秒 + client := &http.Client{ + Timeout: 60 * time.Second, + } resp, err := client.Do(req) if err != nil { + // 检查是否是超时错误 + isTimeout := false + if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := err.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + if isTimeout { + return nil, fmt.Errorf("%w: API请求超时: %s", ErrDatasource, err.Error()) + } return nil, fmt.Errorf("%w: %s", ErrSystem, err.Error()) } defer resp.Body.Close() diff --git a/internal/infrastructure/external/notification/wechat_work_service.go b/internal/infrastructure/external/notification/wechat_work_service.go index 08470af..70bf3d1 100644 --- a/internal/infrastructure/external/notification/wechat_work_service.go +++ b/internal/infrastructure/external/notification/wechat_work_service.go @@ -52,7 +52,7 @@ func NewWeChatWorkService(webhookURL, secret string, logger *zap.Logger) *WeChat return &WeChatWorkService{ webhookURL: webhookURL, secret: secret, - timeout: 30 * time.Second, + timeout: 60 * time.Second, logger: logger, } } @@ -385,7 +385,25 @@ func (s *WeChatWorkService) sendMessage(ctx context.Context, message map[string] // 发送请求 resp, err := client.Do(req) if err != nil { - return fmt.Errorf("发送请求失败: %w", err) + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := err.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + errorMsg := "发送请求失败" + if isTimeout { + errorMsg = "发送请求超时" + } + return fmt.Errorf("%s: %w", errorMsg, err) } defer resp.Body.Close() diff --git a/internal/infrastructure/external/ocr/baidu_ocr_service.go b/internal/infrastructure/external/ocr/baidu_ocr_service.go index 57bd01f..11d085d 100644 --- a/internal/infrastructure/external/ocr/baidu_ocr_service.go +++ b/internal/infrastructure/external/ocr/baidu_ocr_service.go @@ -31,7 +31,7 @@ func NewBaiduOCRService(apiKey, secretKey string, logger *zap.Logger) *BaiduOCRS apiKey: apiKey, secretKey: secretKey, endpoint: "https://aip.baidubce.com", - timeout: 30 * time.Second, + timeout: 60 * time.Second, logger: logger, } } @@ -258,6 +258,23 @@ func (s *BaiduOCRService) sendRequest(ctx context.Context, method, url string, b // 发送请求 resp, err := client.Do(req) if err != nil { + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := err.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + if isTimeout { + return nil, fmt.Errorf("API请求超时: %w", err) + } return nil, fmt.Errorf("发送请求失败: %w", err) } defer resp.Body.Close() @@ -443,7 +460,7 @@ func (s *BaiduOCRService) extractWords(result map[string]interface{}) []string { func (s *BaiduOCRService) downloadImage(ctx context.Context, imageURL string) ([]byte, error) { // 创建HTTP客户端 client := &http.Client{ - Timeout: 30 * time.Second, + Timeout: 60 * time.Second, } // 创建请求 diff --git a/internal/infrastructure/external/storage/qiniu_storage_service.go b/internal/infrastructure/external/storage/qiniu_storage_service.go index 423cbe9..73d1749 100644 --- a/internal/infrastructure/external/storage/qiniu_storage_service.go +++ b/internal/infrastructure/external/storage/qiniu_storage_service.go @@ -285,9 +285,9 @@ func (s *QiNiuStorageService) UploadFromReader(ctx context.Context, reader io.Re func (s *QiNiuStorageService) DownloadFile(ctx context.Context, fileURL string) ([]byte, error) { s.logger.Info("开始从七牛云下载文件", zap.String("file_url", fileURL)) - // 创建HTTP客户端 + // 创建HTTP客户端,超时时间设置为60秒 client := &http.Client{ - Timeout: 30 * time.Second, + Timeout: 60 * time.Second, } // 创建请求 @@ -299,11 +299,29 @@ func (s *QiNiuStorageService) DownloadFile(ctx context.Context, fileURL string) // 发送请求 resp, err := client.Do(req) if err != nil { - s.logger.Error("下载文件失败", + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := err.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + errorMsg := "下载文件失败" + if isTimeout { + errorMsg = "下载文件超时" + } + s.logger.Error(errorMsg, zap.String("file_url", fileURL), zap.Error(err), ) - return nil, fmt.Errorf("下载文件失败: %w", err) + return nil, fmt.Errorf("%s: %w", errorMsg, err) } defer resp.Body.Close() diff --git a/internal/infrastructure/external/tianyancha/tianyancha_service.go b/internal/infrastructure/external/tianyancha/tianyancha_service.go index 406519d..011f6ef 100644 --- a/internal/infrastructure/external/tianyancha/tianyancha_service.go +++ b/internal/infrastructure/external/tianyancha/tianyancha_service.go @@ -59,7 +59,7 @@ type TianYanChaResponse struct { // NewTianYanChaService 创建天眼查服务实例 func NewTianYanChaService(baseURL, token string, timeout time.Duration) *TianYanChaService { if timeout == 0 { - timeout = 30 * time.Second + timeout = 60 * time.Second } return &TianYanChaService{ @@ -112,6 +112,23 @@ func (t *TianYanChaService) CallAPI(ctx context.Context, apiCode string, params client := &http.Client{Timeout: t.config.Timeout} resp, err := client.Do(req) if err != nil { + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := err.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + if isTimeout { + return nil, errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err)) + } return nil, errors.Join(ErrDatasource, fmt.Errorf("API 请求异常: %v", err)) } defer resp.Body.Close() diff --git a/internal/infrastructure/external/westdex/westdex_service.go b/internal/infrastructure/external/westdex/westdex_service.go index 06b75fa..df666db 100644 --- a/internal/infrastructure/external/westdex/westdex_service.go +++ b/internal/infrastructure/external/westdex/westdex_service.go @@ -120,11 +120,31 @@ func (w *WestDexService) CallAPI(ctx context.Context, code string, reqData map[s // 设置请求头 req.Header.Set("Content-Type", "application/json") - // 发送请求 - client := &http.Client{} + // 发送请求,超时时间设置为60秒 + client := &http.Client{ + Timeout: 60 * time.Second, + } httpResp, clientDoErr := client.Do(req) if clientDoErr != nil { - err = errors.Join(ErrSystem, clientDoErr) + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := clientDoErr.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := clientDoErr.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + if isTimeout { + err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", clientDoErr)) + } else { + err = errors.Join(ErrSystem, clientDoErr) + } if w.logger != nil { w.logger.LogError(requestID, transactionID, code, err, reqData) } @@ -273,11 +293,31 @@ func (w *WestDexService) G05HZ01CallAPI(ctx context.Context, code string, reqDat // 设置请求头 req.Header.Set("Content-Type", "application/json") - // 发送请求 - client := &http.Client{} + // 发送请求,超时时间设置为60秒 + client := &http.Client{ + Timeout: 60 * time.Second, + } httpResp, clientDoErr := client.Do(req) if clientDoErr != nil { - err = errors.Join(ErrSystem, clientDoErr) + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := clientDoErr.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := clientDoErr.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + if isTimeout { + err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", clientDoErr)) + } else { + err = errors.Join(ErrSystem, clientDoErr) + } if w.logger != nil { w.logger.LogError(requestID, transactionID, code, err, reqData) } diff --git a/internal/infrastructure/external/xingwei/xingwei_service.go b/internal/infrastructure/external/xingwei/xingwei_service.go index 4a1cdce..1fa8942 100644 --- a/internal/infrastructure/external/xingwei/xingwei_service.go +++ b/internal/infrastructure/external/xingwei/xingwei_service.go @@ -158,15 +158,33 @@ func (x *XingweiService) CallAPI(ctx context.Context, projectID string, params m req.Header.Set("API-ID", x.config.ApiID) req.Header.Set("project_id", projectID) - // 创建HTTP客户端 + // 创建HTTP客户端,超时时间设置为60秒 client := &http.Client{ - Timeout: 20 * time.Second, + Timeout: 60 * time.Second, } // 发送请求 httpResp, clientDoErr := client.Do(req) if clientDoErr != nil { - err = errors.Join(ErrSystem, clientDoErr) + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := clientDoErr.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := clientDoErr.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + if isTimeout { + err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", clientDoErr)) + } else { + err = errors.Join(ErrSystem, clientDoErr) + } if x.logger != nil { x.logger.LogError(requestID, transactionID, "xingwei_api", err, params) } diff --git a/internal/infrastructure/external/yushan/yushan_service.go b/internal/infrastructure/external/yushan/yushan_service.go index c1c1536..96e9bd7 100644 --- a/internal/infrastructure/external/yushan/yushan_service.go +++ b/internal/infrastructure/external/yushan/yushan_service.go @@ -107,9 +107,9 @@ func (y *YushanService) CallAPI(ctx context.Context, code string, params map[str // 将加密后的数据编码为 Base64 字符串 content := base64.StdEncoding.EncodeToString(cipherText) - // 发起 HTTP 请求 + // 发起 HTTP 请求,超时时间设置为60秒 client := &http.Client{ - Timeout: 20 * time.Second, + Timeout: 60 * time.Second, } req, err := http.NewRequestWithContext(ctx, "POST", y.config.URL, strings.NewReader(content)) if err != nil { @@ -125,7 +125,25 @@ func (y *YushanService) CallAPI(ctx context.Context, code string, params map[str // 执行请求 resp, err := client.Do(req) if err != nil { - err = errors.Join(ErrSystem, err) + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + isTimeout = true + } else if errStr := err.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + if isTimeout { + err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err)) + } else { + err = errors.Join(ErrSystem, err) + } if y.logger != nil { y.logger.LogError(requestID, transactionID, code, err, params) } diff --git a/internal/infrastructure/external/zhicha/zhicha_service.go b/internal/infrastructure/external/zhicha/zhicha_service.go index 8059ea1..7c23fab 100644 --- a/internal/infrastructure/external/zhicha/zhicha_service.go +++ b/internal/infrastructure/external/zhicha/zhicha_service.go @@ -118,15 +118,35 @@ func (z *ZhichaService) CallAPI(ctx context.Context, proID string, params map[st req.Header.Set("timestamp", strconv.FormatInt(timestamp, 10)) req.Header.Set("sign", z.generateSign(timestamp)) - // 创建HTTP客户端 + // 创建HTTP客户端,超时时间设置为60秒 client := &http.Client{ - Timeout: 20 * time.Second, + Timeout: 60 * time.Second, } // 发送请求 response, err := client.Do(req) if err != nil { - err = errors.Join(ErrSystem, err) + // 检查是否是超时错误 + isTimeout := false + if ctx.Err() == context.DeadlineExceeded { + isTimeout = true + } else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + // 检查是否是网络超时错误 + isTimeout = true + } else if errStr := err.Error(); + errStr == "context deadline exceeded" || + errStr == "timeout" || + errStr == "Client.Timeout exceeded" || + errStr == "net/http: request canceled" { + isTimeout = true + } + + if isTimeout { + // 超时错误应该返回数据源异常,而不是系统异常 + err = errors.Join(ErrDatasource, fmt.Errorf("API请求超时: %v", err)) + } else { + err = errors.Join(ErrSystem, err) + } if z.logger != nil { z.logger.LogError(requestID, transactionID, proID, err, params) } diff --git a/internal/shared/validator/custom_validators.go b/internal/shared/validator/custom_validators.go index a91bdd7..7a2543f 100644 --- a/internal/shared/validator/custom_validators.go +++ b/internal/shared/validator/custom_validators.go @@ -1,7 +1,11 @@ package validator import ( + "bytes" + "context" "fmt" + "io" + "net/http" "net/url" "regexp" "strconv" @@ -211,6 +215,8 @@ func validateUUID(fl validator.FieldLevel) bool { // validateURL URL验证 func validateURL(fl validator.FieldLevel) bool { urlStr := fl.Field().String() + // 去除首尾空白字符 + urlStr = strings.TrimSpace(urlStr) _, err := url.ParseRequestURI(urlStr) return err == nil } @@ -462,19 +468,31 @@ func validateLuhn(cardNumber string) bool { } // validateAuthorizationURL 授权书URL验证器 +// 验证URL格式、可访问性和文件类型 +// 安全措施: +// 1. 仅允许http/https协议 +// 2. 设置超时时间防止阻塞 +// 3. 限制重定向次数 +// 4. 检查Content-Type和文件签名 func validateAuthorizationURL(fl validator.FieldLevel) bool { urlStr := fl.Field().String() if urlStr == "" { return true // 空值由required标签处理 } + // 去除首尾空白字符 + urlStr = strings.TrimSpace(urlStr) + if urlStr == "" { + return false + } + // 解析URL parsedURL, err := url.Parse(urlStr) if err != nil { return false } - // 检查协议 + // 检查协议(仅允许http和https) if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { return false } @@ -489,8 +507,165 @@ func validateAuthorizationURL(fl validator.FieldLevel) bool { break } } + if !hasValidExtension { + return false + } - return hasValidExtension + // 验证URL可访问性和文件类型 + return validateURLAccessibility(parsedURL) +} + +// validateURLAccessibility 验证URL可访问性和文件类型 +func validateURLAccessibility(parsedURL *url.URL) bool { + // 创建带超时的context(5秒超时,避免阻塞验证流程) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // 创建HTTP客户端,设置安全参数 + client := &http.Client{ + Timeout: 5 * time.Second, + // 限制重定向次数,防止重定向攻击 + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // 最多允许3次重定向 + if len(via) >= 3 { + return fmt.Errorf("重定向次数过多") + } + // 检查重定向后的URL是否仍然是http/https + if req.URL.Scheme != "http" && req.URL.Scheme != "https" { + return fmt.Errorf("不允许重定向到非HTTP协议") + } + return nil + }, + } + + // 先尝试HEAD请求(更高效,不下载文件内容) + req, err := http.NewRequestWithContext(ctx, "HEAD", parsedURL.String(), nil) + if err != nil { + return false + } + + // 设置User-Agent,避免某些服务器拒绝请求 + req.Header.Set("User-Agent", "TYAPI-Validator/1.0") + + // 发送HEAD请求 + resp, err := client.Do(req) + if err != nil { + // HEAD请求失败,尝试GET请求(某些服务器不支持HEAD) + return validateWithGETRequest(ctx, client, parsedURL) + } + defer resp.Body.Close() + + // 检查HTTP状态码 + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return false + } + + // 验证Content-Type + if !isValidContentType(resp.Header.Get("Content-Type")) { + // Content-Type无效,尝试读取文件签名验证 + return validateWithGETRequest(ctx, client, parsedURL) + } + + return true +} + +// validateWithGETRequest 使用GET请求验证文件(仅在HEAD失败时使用) +func validateWithGETRequest(ctx context.Context, client *http.Client, parsedURL *url.URL) bool { + req, err := http.NewRequestWithContext(ctx, "GET", parsedURL.String(), nil) + if err != nil { + return false + } + + req.Header.Set("User-Agent", "TYAPI-Validator/1.0") + // 只读取部分内容,不下载整个文件 + req.Header.Set("Range", "bytes=0-1023") // 只读取前1024字节 + + resp, err := client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + // 检查HTTP状态码(206是部分内容,200是完整内容) + if resp.StatusCode != 200 && resp.StatusCode != 206 { + return false + } + + // 验证Content-Type + contentType := resp.Header.Get("Content-Type") + if isValidContentType(contentType) { + return true + } + + // 读取文件签名(magic bytes)验证文件类型 + return validateFileSignature(resp.Body) +} + +// isValidContentType 检查Content-Type是否有效 +func isValidContentType(contentType string) bool { + contentType = strings.ToLower(strings.TrimSpace(contentType)) + if contentType == "" { + return false + } + + // 移除charset等参数,只检查主类型 + if idx := strings.Index(contentType, ";"); idx != -1 { + contentType = contentType[:idx] + } + contentType = strings.TrimSpace(contentType) + + // 允许的Content-Type列表 + validContentTypes := []string{ + "application/pdf", // PDF + "image/jpeg", // JPEG + "image/jpg", // JPG + "image/png", // PNG + "image/bmp", // BMP + "image/x-ms-bmp", // BMP (另一种MIME类型) + } + + for _, validType := range validContentTypes { + if contentType == validType { + return true + } + } + + return false +} + +// validateFileSignature 通过文件签名(magic bytes)验证文件类型 +func validateFileSignature(body io.Reader) bool { + // 读取文件前16字节(足够识别所有支持的文件类型) + buffer := make([]byte, 16) + n, err := body.Read(buffer) + if err != nil && err != io.EOF { + return false + } + if n < 4 { + return false + } + + // PDF签名: %PDF (前4字节) + if n >= 4 && bytes.Equal(buffer[0:4], []byte("%PDF")) { + return true + } + + // JPEG签名: FF D8 FF (前3字节) + if n >= 3 && buffer[0] == 0xFF && buffer[1] == 0xD8 && buffer[2] == 0xFF { + return true + } + + // PNG签名: 89 50 4E 47 0D 0A 1A 0A (前8字节) + if n >= 8 && bytes.Equal(buffer[0:8], []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}) { + return true + } + + // BMP签名: BM (前2字节: 42 4D) + if n >= 2 && bytes.Equal(buffer[0:2], []byte("BM")) { + return true + } + + return false } // validateUniqueID 唯一标识验证器(小于等于32位字符串) @@ -517,6 +692,9 @@ func validateReturnURL(fl validator.FieldLevel) bool { return true // 空值由required标签处理 } + // 去除首尾空白字符 + returnURL = strings.TrimSpace(returnURL) + // 检查长度:不能超过500字符 if len(returnURL) > 500 { return false diff --git a/internal/shared/validator/validator.go b/internal/shared/validator/validator.go index 2ca804d..fee2531 100644 --- a/internal/shared/validator/validator.go +++ b/internal/shared/validator/validator.go @@ -27,21 +27,21 @@ func InitGlobalValidator() { once.Do(func() { // 1. 创建新的校验器实例 globalValidator = validator.New() - + // 2. 创建中文翻译器 zhLocale := zh.New() uni := ut.New(zhLocale, zhLocale) globalTranslator, _ = uni.GetTranslator("zh") - + // 3. 注册官方中文翻译 zh_translations.RegisterDefaultTranslations(globalValidator, globalTranslator) - + // 4. 注册自定义校验规则 RegisterCustomValidators(globalValidator) - + // 5. 注册自定义中文翻译 RegisterCustomTranslations(globalValidator, globalTranslator) - + // 6. 设置到Gin全局校验器(确保Gin使用我们的校验器) if binding.Validator.Engine() != nil { // 如果Gin已经初始化,则替换其校验器 @@ -78,11 +78,11 @@ type RequestValidator struct { func NewRequestValidator(response interfaces.ResponseBuilder) interfaces.RequestValidator { // 确保全局校验器已初始化 InitGlobalValidator() - + return &RequestValidator{ response: response, - translator: globalTranslator, // 使用全局翻译器 - validator: globalValidator, // 使用全局校验器 + translator: globalTranslator, // 使用全局翻译器 + validator: globalValidator, // 使用全局校验器 } }