diff --git a/config.yaml b/config.yaml index a8cd257..6f9abe5 100644 --- a/config.yaml +++ b/config.yaml @@ -298,14 +298,14 @@ esign: # 应用名称:海宇数据 | AppId:80005307 | 状态:待上线 # =========================================== fadada: - app_id: "80005307" - app_secret: "SM0R81LFZBOAFQY6MV6SRXUJZKWHPLW7" + app_id: "00004782" + app_secret: "E9GMVOQ9NBSOUDG5VOFM0J1CZPOAP2Y8" # 待上线/联调用测试环境;正式上线后改为 https://api.fadada.com/api/v5/ - server_url: "https://uat-api.fadada.com/api/v5/" - open_corp_id: "cc1a281c715e4f289b681eb15d512e3f" # 海宇数据在法大大侧企业 ID - # 免验证签场景码:仅 freeSignType=business 时需要;当前按模板免验证签(template),可留空 - no_auth_scene_code: "" - template_id: "1784281786354133853" # 合作协议模板 ID(填单/签署) + server_url: "https://api.fadada.com/api/v5/" + open_corp_id: "7118e66dfd9349d998c0395c5d0742ad" # 海宇数据在法大大侧企业 ID + # 免验证签场景码(businessId):乙方 requestVerifyFree=true 时必传,平台校验是否已授权 + no_auth_scene_code: "c66587b4a048bba3c1cb032eb7750182" + template_id: "1784619066713193840" # 合作协议模板 ID(填单/签署;freeSignType=template) # 签署模板中的参与方标识(actorId),必须与模板「签署角色」名称完全一致 party_a_actor_id: "甲方" party_b_actor_id: "乙方" @@ -344,10 +344,10 @@ fadada: retry_count: 3 auth: # 认证完成后顶层跳回企业入驻页(勿用 callback 页留在 iframe 内) - redirect_url: "http://localhost:5173/profile/certification" + redirect_url: "https://console.haiyudata.com/profile/certification" sign: # 甲方签署完成后回跳(iframe 内会顶层跳出到入驻页) - redirect_url: "http://localhost:5173/certification/callback/fadada/sign" + redirect_url: "https://console.haiyudata.com/certification/callback/fadada/sign" callback: enabled: true @@ -818,3 +818,38 @@ huibo: max_backups: 5 max_age: 30 compress: true + +# =========================================== +# ✨ 戎行配置 +# =========================================== + +rongxing: + url: "http://192.168.3.43:7007" # 基础地址,登录=/auth/login,查询=/third/loan/info360 + account: "haiyukeji" + password: "haiyukeji@123" # 明文,请求时自动 Base64 + app_id: "hykj" + private_key: "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw==" + timeout: 10s + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "rongxing" + 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 + diff --git a/configs/env.development.yaml b/configs/env.development.yaml index 4122ef2..af9ce5e 100644 --- a/configs/env.development.yaml +++ b/configs/env.development.yaml @@ -75,8 +75,8 @@ fadada: # 待上线/联调用测试环境;正式上线后改为 https://api.fadada.com/api/v5/ server_url: "https://uat-api.fadada.com/api/v5/" open_corp_id: "cc1a281c715e4f289b681eb15d512e3f" # 海宇数据在法大大侧企业 ID - # 免验证签场景码:仅 freeSignType=business 时需要;当前按模板免验证签(template),可留空 - no_auth_scene_code: "" + # 免验证签场景码(businessId):乙方 requestVerifyFree=true 时必传 + no_auth_scene_code: "d2a3af30bf4fa5740bc65ffff446ab16" template_id: "1784281786354133853" # 合作协议模板 ID(填单/签署) # 签署模板中的参与方标识(actorId),必须与模板「签署角色」名称完全一致 party_a_actor_id: "甲方" diff --git a/configs/env.production.yaml b/configs/env.production.yaml index 2e03a20..ef9d645 100644 --- a/configs/env.production.yaml +++ b/configs/env.production.yaml @@ -104,9 +104,9 @@ fadada: # 待上线/联调用测试环境;正式上线后改为 https://api.fadada.com/api/v5/ server_url: "https://api.fadada.com/api/v5/" open_corp_id: "7118e66dfd9349d998c0395c5d0742ad" # 海宇数据在法大大侧企业 ID - # 免验证签场景码:仅 freeSignType=business 时需要;当前按模板免验证签(template),可留空 - no_auth_scene_code: "" - template_id: "1784619066713193840" # 合作协议模板 ID(填单/签署;免验证签依据此模板) + # 免验证签场景码(businessId):乙方 requestVerifyFree=true 时必传,平台校验是否已授权 + no_auth_scene_code: "c66587b4a048bba3c1cb032eb7750182" + template_id: "1784619066713193840" # 合作协议模板 ID(填单/签署;freeSignType=template) # 签署模板中的参与方标识(actorId),必须与模板「签署角色」名称完全一致 party_a_actor_id: "甲方" party_b_actor_id: "乙方" diff --git a/internal/config/config.go b/internal/config/config.go index 38fd152..827a88e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,6 +45,7 @@ type Config struct { Shujubao ShujubaoConfig `mapstructure:"shujubao"` Nuoer NuoerConfig `mapstructure:"nuoer"` Huibo HuiboConfig `mapstructure:"huibo"` + Rongxing RongxingConfig `mapstructure:"rongxing"` PDFGen PDFGenConfig `mapstructure:"pdfgen"` } @@ -395,7 +396,7 @@ type FadadaConfig struct { AppSecret string `mapstructure:"app_secret"` // 应用密钥 ServerURL string `mapstructure:"server_url"` // 服务器URL OpenCorpID string `mapstructure:"open_corp_id"` // 接入方(海宇)在法大大侧企业ID - NoAuthSceneCode string `mapstructure:"no_auth_scene_code"` // 免验证签场景码(仅 freeSignType=business 时用;当前为 template 可留空) + NoAuthSceneCode string `mapstructure:"no_auth_scene_code"` // 免验证签场景码(businessId;requestVerifyFree 时必传) TemplateID string `mapstructure:"template_id"` // 模板ID PartyAActorID string `mapstructure:"party_a_actor_id"` // 签署模板甲方参与方标识(actorId) PartyBActorID string `mapstructure:"party_b_actor_id"` // 签署模板乙方参与方标识(actorId) @@ -809,6 +810,36 @@ type HuiboLevelFileConfig struct { Compress bool `mapstructure:"compress"` } +// RongxingConfig 戎行(Info360)配置 +type RongxingConfig struct { + URL string `mapstructure:"url"` // 基础地址,如 http://host:7007 + Account string `mapstructure:"account"` // 登录账号 + Password string `mapstructure:"password"` // 明文密码(请求时 Base64) + AppID string `mapstructure:"app_id"` // 应用 ID + PrivateKey string `mapstructure:"private_key"` // RSA 私钥(PEM 或 PKCS#8 Base64) + Timeout time.Duration `mapstructure:"timeout"` + + Logging RongxingLoggingConfig `mapstructure:"logging"` +} + +// RongxingLoggingConfig 戎行日志配置 +type RongxingLoggingConfig struct { + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + ServiceName string `mapstructure:"service_name"` + UseDaily bool `mapstructure:"use_daily"` + EnableLevelSeparation bool `mapstructure:"enable_level_separation"` + LevelConfigs map[string]RongxingLevelFileConfig `mapstructure:"level_configs"` +} + +// RongxingLevelFileConfig 戎行日志级别配置 +type RongxingLevelFileConfig struct { + MaxSize int `mapstructure:"max_size"` + MaxBackups int `mapstructure:"max_backups"` + MaxAge int `mapstructure:"max_age"` + Compress bool `mapstructure:"compress"` +} + // PDFGenConfig PDF生成服务配置 type PDFGenConfig struct { DevelopmentURL string `mapstructure:"development_url"` // 开发环境服务地址 diff --git a/internal/container/container.go b/internal/container/container.go index 6f80a8c..7095556 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -43,6 +43,7 @@ import ( infraesign "hyapi-server/internal/infrastructure/external/esign" infrafadada "hyapi-server/internal/infrastructure/external/fadada" "hyapi-server/internal/infrastructure/external/huibo" + "hyapi-server/internal/infrastructure/external/rongxing" "hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/nuoer" @@ -472,6 +473,10 @@ func NewContainer() *Container { func(cfg *config.Config) (*huibo.HuiboService, error) { return huibo.NewHuiboServiceWithConfig(cfg) }, + // RongxingService - 戎行 Info360 服务 + func(cfg *config.Config) (*rongxing.RongxingService, error) { + return rongxing.NewRongxingServiceWithConfig(cfg) + }, func(cfg *config.Config) *yushan.YushanService { return yushan.NewYushanService( cfg.Yushan.URL, diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index f9b67d1..e693c3c 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -1318,3 +1318,9 @@ type QYGLLUCMReq struct { EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"` AuthPDFBase64 string `json:"auth_pdf_base64" validate:"required,validAuthPDFBase64"` } + +type JRZQ0OO1Req struct { + MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` + IDCard string `json:"id_card" validate:"required,validIDCard"` + Name string `json:"name" validate:"required,min=1,validName"` +} diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index aa2ca90..00bb6e3 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -22,6 +22,7 @@ import ( "hyapi-server/internal/infrastructure/external/jiguang" "hyapi-server/internal/infrastructure/external/muzi" nuoerext "hyapi-server/internal/infrastructure/external/nuoer" + "hyapi-server/internal/infrastructure/external/rongxing" "hyapi-server/internal/infrastructure/external/shujubao" "hyapi-server/internal/infrastructure/external/tianyancha" "hyapi-server/internal/infrastructure/external/tianyuanapi" @@ -67,6 +68,7 @@ func NewApiRequestService( tianyuanapiService *tianyuanapi.TianyuanapiService, nuoerService *nuoerext.NuoerService, huiboService *huibo.HuiboService, + rongxingService *rongxing.RongxingService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -84,6 +86,7 @@ func NewApiRequestService( tianyuanapiService, nuoerService, huiboService, + rongxingService, validator, productManagementService, cfg, @@ -106,6 +109,7 @@ func NewApiRequestServiceWithRepos( tianyuanapiService *tianyuanapi.TianyuanapiService, nuoerService *nuoerext.NuoerService, huiboService *huibo.HuiboService, + rongxingService *rongxing.RongxingService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -134,6 +138,7 @@ func NewApiRequestServiceWithRepos( tianyuanapiService, nuoerService, huiboService, + rongxingService, validator, combService, reportRepo, @@ -193,6 +198,7 @@ func registerAllProcessors(combService *comb.CombService) { "JRZQTH74": jrzq.ProcessJRZQTH74Request, //团伙欺诈 "JRZQGJ99": jrzq.ProcessJRZQGJ99Request, //公积金核验 "JRZQFC59": jrzq.ProcessJRZQFC59Request, //房产核验 + "JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, //戎行贷后信息 // QYGL系列处理器 "QYGL7HBN": qygl.ProcessQYGL7HBNRequest, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I) diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index 0da19f2..f0414de 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -314,7 +314,8 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "JRZQFC59": &dto.JRZQFC59Req{}, //房产核验 "IVYZX7J9": &dto.IVYZX7J9Req{}, //学籍核验 "QCXG6U5G": &dto.QCXG6U5GReq{}, //车辆核验 - "QCXG7K2N": &dto.QCXG7K2NReq{}, //人车核验加强版 + "JRZQ0OO1": &dto.JRZQ0OO1Req{}, //戎行贷后信息 Info360 + } // 优先返回已配置的DTO diff --git a/internal/domains/api/services/processors/dependencies.go b/internal/domains/api/services/processors/dependencies.go index aee4c06..e54beea 100644 --- a/internal/domains/api/services/processors/dependencies.go +++ b/internal/domains/api/services/processors/dependencies.go @@ -8,6 +8,7 @@ import ( "hyapi-server/internal/infrastructure/external/alicloud" "hyapi-server/internal/infrastructure/external/huibo" "hyapi-server/internal/infrastructure/external/jiguang" + "hyapi-server/internal/infrastructure/external/rongxing" "hyapi-server/internal/infrastructure/external/muzi" "hyapi-server/internal/infrastructure/external/nuoer" "hyapi-server/internal/infrastructure/external/shujubao" @@ -44,6 +45,7 @@ type ProcessorDependencies struct { TianyuanapiService *tianyuanapi.TianyuanapiService NuoerService *nuoer.NuoerService HuiboService *huibo.HuiboService + RongxingService *rongxing.RongxingService Validator interfaces.RequestValidator CombService CombServiceInterface // Changed to interface to break import cycle Options *commands.ApiCallOptions // 添加Options支持 @@ -73,6 +75,7 @@ func NewProcessorDependencies( tianyuanapiService *tianyuanapi.TianyuanapiService, nuoerService *nuoer.NuoerService, huiboService *huibo.HuiboService, + rongxingService *rongxing.RongxingService, validator interfaces.RequestValidator, combService CombServiceInterface, // Changed to interface reportRepo repositories.ReportRepository, @@ -92,6 +95,7 @@ func NewProcessorDependencies( TianyuanapiService: tianyuanapiService, NuoerService: nuoerService, HuiboService: huiboService, + RongxingService: rongxingService, Validator: validator, CombService: combService, Options: nil, // 初始化为nil,在调用时设置 diff --git a/internal/domains/api/services/processors/jrzq/jrzq0oo1_processor.go b/internal/domains/api/services/processors/jrzq/jrzq0oo1_processor.go new file mode 100644 index 0000000..d37c02d --- /dev/null +++ b/internal/domains/api/services/processors/jrzq/jrzq0oo1_processor.go @@ -0,0 +1,46 @@ +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/rongxing" +) + +func ProcessJRZQ0OO1Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.JRZQ0OO1Req + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if deps.RongxingService == nil { + return nil, errors.Join(processors.ErrSystem, errors.New("戎行服务未初始化")) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + // 业务入参在处理器侧组装;三要素按接口要求 MD5 大写后入请求体 + reqdata := map[string]interface{}{ + "phone": rongxing.MD5Upper(paramsDto.MobileNo), + "idCard": rongxing.MD5Upper(paramsDto.IDCard), + "name": rongxing.MD5Upper(paramsDto.Name), + } + + respBytes, err := deps.RongxingService.CallAPI(ctx, "/third/loan/info360", reqdata) + if err != nil { + if errors.Is(err, rongxing.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + if errors.Is(err, rongxing.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + return respBytes, nil +} diff --git a/internal/infrastructure/external/rongxing/crypto.go b/internal/infrastructure/external/rongxing/crypto.go new file mode 100644 index 0000000..89cb2da --- /dev/null +++ b/internal/infrastructure/external/rongxing/crypto.go @@ -0,0 +1,94 @@ +package rongxing + +import ( + "crypto" + "crypto/md5" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/pem" + "fmt" + "sort" + "strings" +) + +// MD5Upper 对明文做 MD5,返回 32 位大写十六进制(入参说明要求大写)。 +func MD5Upper(plaintext string) string { + sum := md5.Sum([]byte(strings.TrimSpace(plaintext))) + return strings.ToUpper(hex.EncodeToString(sum[:])) +} + +// EncodePasswordBase64 将明文密码做 Base64 编码(登录接口要求)。 +func EncodePasswordBase64(password string) string { + return base64.StdEncoding.EncodeToString([]byte(password)) +} + +// BuildSignContent 生成登录签名原文:过滤 sign/空值 → 按 key 升序 → key=value&... +func BuildSignContent(params map[string]interface{}) string { + keys := make([]string, 0, len(params)) + for k, v := range params { + if strings.EqualFold(k, "sign") || v == nil { + continue + } + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%v", k, params[k])) + } + return strings.Join(parts, "&") +} + +// SignSHA256WithRSA 使用私钥对原文做 SHA256withRSA 签名,返回 Base64。 +func SignSHA256WithRSA(content string, privateKey *rsa.PrivateKey) (string, error) { + if privateKey == nil { + return "", fmt.Errorf("私钥为空") + } + hashed := sha256.Sum256([]byte(content)) + signed, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, hashed[:]) + if err != nil { + return "", fmt.Errorf("RSA 签名失败: %w", err) + } + return base64.StdEncoding.EncodeToString(signed), nil +} + +// ParsePrivateKey 解析 RSA 私钥(支持 PEM / PKCS#8 / PKCS#1 原始 Base64)。 +func ParsePrivateKey(raw string) (*rsa.PrivateKey, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("私钥为空") + } + + var der []byte + if block, _ := pem.Decode([]byte(raw)); block != nil { + der = block.Bytes + } else { + normalized := strings.ReplaceAll(raw, "\n", "") + normalized = strings.ReplaceAll(normalized, "\r", "") + normalized = strings.ReplaceAll(normalized, " ", "") + decoded, err := base64.StdEncoding.DecodeString(normalized) + if err != nil { + return nil, fmt.Errorf("私钥 Base64 解码失败: %w", err) + } + der = decoded + } + + if key, err := x509.ParsePKCS8PrivateKey(der); err == nil { + rsaKey, ok := key.(*rsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("私钥不是 RSA 类型") + } + return rsaKey, nil + } + + if key, err := x509.ParsePKCS1PrivateKey(der); err == nil { + return key, nil + } + + return nil, fmt.Errorf("无法解析 RSA 私钥(需 PKCS#8 或 PKCS#1)") +} diff --git a/internal/infrastructure/external/rongxing/crypto_test.go b/internal/infrastructure/external/rongxing/crypto_test.go new file mode 100644 index 0000000..6701630 --- /dev/null +++ b/internal/infrastructure/external/rongxing/crypto_test.go @@ -0,0 +1,66 @@ +package rongxing + +import ( + "crypto/rand" + "crypto/rsa" + "testing" +) + +func TestMD5Upper(t *testing.T) { + if got := MD5Upper(""); got != "D41D8CD98F00B204E9800998ECF8427E" { + t.Fatalf("MD5Upper empty = %s", got) + } + if got := MD5Upper(" hello "); got != MD5Upper("hello") { + t.Fatalf("MD5Upper should trim spaces") + } +} + +func TestBuildSignContent(t *testing.T) { + params := map[string]interface{}{ + "account": "haiyukeji", + "password": "cGFzcw==", + "appId": "hykj", + "timestamp": int64(1710000000000), + "sign": "ignored", + } + got := BuildSignContent(params) + want := "account=haiyukeji&appId=hykj&password=cGFzcw==×tamp=1710000000000" + if got != want { + t.Fatalf("BuildSignContent mismatch\ngot: %s\nwant: %s", got, want) + } +} + +func TestSignSHA256WithRSA(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 1024) + if err != nil { + t.Fatalf("generate key: %v", err) + } + sig, err := SignSHA256WithRSA("account=a&appId=b", key) + if err != nil { + t.Fatalf("sign: %v", err) + } + if sig == "" { + t.Fatal("empty signature") + } +} + +func TestParsePrivateKeyPKCS8Base64(t *testing.T) { + // 与对接文档一致的 PKCS#8 Base64(无 PEM 头) + raw := "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBAI1eWDx325U/DCpaw+394555voDNwKyfg6rAIBYGSmJDmUyCi6c8LeqVtYnmtUMJrTDrfUWFG7jQ+mQy65rXMY7zrZ0Cc+orP7uRgrvCBsH1775KuSji8TkVbEBw+Upro7FuNkutBItNxZCvcpFpNqNBwCCkCK1xscVN8gErxc7/AgMBAAECgYAL/ZVHWU7Ni5TyrLmTBwAjWD+RX9V4iGkb3QLiGCayZS05NGNq/ytrCvqxMSY6HIKAZ6Du+hmXvr+JXll/slvyGs1ETOgDi7563RAT/2TVicZF16IM2d7nhK6eTJffmiG2ZZC8n043F0QNposleEIMhM251iT1xiwZfg+QqHB0EQJBAI+BFcbYl+Vxpxdouvuwq11gYMTNepcGdY3OaPZzW4sQA41s+6aZhZ7tAk65Tk7PgLfdU2yimBKNkJstlSUT1w8CQQD8MKjAi5wngnDO+04n5mt5EotpNy6xpvkQP2izL0FWKmvwZtc0ihJpxa64vfo4+1jE8YXx1/qGe16A15fmywkRAkAbImVjvAC8ucjGfF8eyEEe3uJtVA0iEW6Y6bafIyDkIpsJWtoanlzNuDL/f7p23HWSTp8/o17t4ya8sNnKsP2xAkEArIQc7JqUl/KDeRQwwtq9anVlKPS23JB8kMDPvsP0zhz2+d1gGnDZZ8HzZC2RnqlScGdIWciFeLmsTDcvkpISAQJANaQU0uKBPvgMx+uedbCbn9MfYmpEiONHblrINH8WAa3Z1pr7R0wBUog1fEWimnxOX5wGwsGmDOMw5B+4xJ4Zfw==" + key, err := ParsePrivateKey(raw) + if err != nil { + t.Fatalf("ParsePrivateKey: %v", err) + } + if key == nil { + t.Fatal("nil key") + } + if _, err := SignSHA256WithRSA("account=haiyukeji&appId=hykj", key); err != nil { + t.Fatalf("sign with parsed key: %v", err) + } +} + +func TestEncodePasswordBase64(t *testing.T) { + if got := EncodePasswordBase64("haiyukeji@123"); got != "aGFpeXVrZWppQDEyMw==" { + t.Fatalf("EncodePasswordBase64 = %s", got) + } +} diff --git a/internal/infrastructure/external/rongxing/curl_helper.go b/internal/infrastructure/external/rongxing/curl_helper.go new file mode 100644 index 0000000..49c3832 --- /dev/null +++ b/internal/infrastructure/external/rongxing/curl_helper.go @@ -0,0 +1,35 @@ +package rongxing + +import ( + "strings" +) + +// generateCurlCommand 生成可直接复现的 curl 命令,便于联调排查。 +func generateCurlCommand(method, url string, headers map[string]string, body string) string { + var cmd strings.Builder + cmd.WriteString("curl -X ") + cmd.WriteString(method) + cmd.WriteString(" '") + cmd.WriteString(url) + cmd.WriteString("'") + + for key, value := range headers { + cmd.WriteString(" \\\n -H '") + cmd.WriteString(key) + cmd.WriteString(": ") + cmd.WriteString(escapeSingleQuotes(value)) + cmd.WriteString("'") + } + + if body != "" { + cmd.WriteString(" \\\n -d '") + cmd.WriteString(escapeSingleQuotes(body)) + cmd.WriteString("'") + } + + return cmd.String() +} + +func escapeSingleQuotes(s string) string { + return strings.ReplaceAll(s, "'", `'\''`) +} diff --git a/internal/infrastructure/external/rongxing/rongxing_factory.go b/internal/infrastructure/external/rongxing/rongxing_factory.go new file mode 100644 index 0000000..795777a --- /dev/null +++ b/internal/infrastructure/external/rongxing/rongxing_factory.go @@ -0,0 +1,50 @@ +package rongxing + +import ( + "hyapi-server/internal/config" + "hyapi-server/internal/shared/external_logger" +) + +// NewRongxingServiceWithConfig 使用配置创建戎行服务 +func NewRongxingServiceWithConfig(cfg *config.Config) (*RongxingService, error) { + loggingConfig := external_logger.ExternalServiceLoggingConfig{ + Enabled: cfg.Rongxing.Logging.Enabled, + LogDir: cfg.Rongxing.Logging.LogDir, + ServiceName: "rongxing", + UseDaily: cfg.Rongxing.Logging.UseDaily, + EnableLevelSeparation: cfg.Rongxing.Logging.EnableLevelSeparation, + LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig), + } + + if cfg.Rongxing.Logging.ServiceName != "" { + loggingConfig.ServiceName = cfg.Rongxing.Logging.ServiceName + } + + for key, value := range cfg.Rongxing.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 + } + + timeout := cfg.Rongxing.Timeout + if timeout <= 0 { + timeout = defaultRequestTimeout + } + + return NewRongxingService(serviceConfig{ + BaseURL: cfg.Rongxing.URL, + Account: cfg.Rongxing.Account, + Password: cfg.Rongxing.Password, + AppID: cfg.Rongxing.AppID, + PrivateKey: cfg.Rongxing.PrivateKey, + Timeout: timeout, + }, logger), nil +} diff --git a/internal/infrastructure/external/rongxing/rongxing_service.go b/internal/infrastructure/external/rongxing/rongxing_service.go new file mode 100644 index 0000000..9021b9a --- /dev/null +++ b/internal/infrastructure/external/rongxing/rongxing_service.go @@ -0,0 +1,432 @@ +package rongxing + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "hyapi-server/internal/shared/external_logger" + + "go.uber.org/zap" +) + +const ( + defaultRequestTimeout = 10 * time.Second + apiKeyLogin = "auth_login" + pathAuthLogin = "/auth/login" + headerDmsToken = "dms-token" +) + +// serviceConfig 戎行服务运行时配置 +type serviceConfig struct { + BaseURL string + Account string + Password string + AppID string + PrivateKey string + Timeout time.Duration +} + +// RongxingService 戎行数据源服务 +type RongxingService struct { + config serviceConfig + logger *external_logger.ExternalServiceLogger + + tokenMu sync.RWMutex + cachedToken string +} + +// apiResponse 戎行统一响应。code 可能是数字或字符串;扣费以 consumeFlag 为准。 +type apiResponse struct { + Flag bool `json:"flag"` + Code json.RawMessage `json:"code"` + Msg string `json:"msg"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` + ConsumeFlag int `json:"consumeFlag"` +} + +func (r apiResponse) text() string { + if r.Msg != "" { + return r.Msg + } + return r.Message +} + +func (r apiResponse) code() string { + return parseCode(r.Code) +} + +// NewRongxingService 创建戎行服务实例 +func NewRongxingService(cfg serviceConfig, logger *external_logger.ExternalServiceLogger) *RongxingService { + if cfg.Timeout <= 0 { + cfg.Timeout = defaultRequestTimeout + } + cfg.BaseURL = strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/") + return &RongxingService{config: cfg, logger: logger} +} + +// GetConfig 获取运行时配置 +func (s *RongxingService) GetConfig() serviceConfig { + return s.config +} + +// CallAPI 通用业务接口调用。 +// apiPath 为相对路径(如 /third/loan/info360),reqData 为已组装好的请求体。 +// Token 获取与 Header 注入由服务内部处理;401/403 时自动刷新 Token 并重试一次。 +func (s *RongxingService) CallAPI(ctx context.Context, apiPath string, reqData map[string]interface{}) ([]byte, error) { + apiKey := strings.Trim(apiPath, "/") + + var transactionID string + if id, ok := ctx.Value("transaction_id").(string); ok { + transactionID = id + } + + if err := s.validateConfig(); err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKey, "", err, nil) + return nil, err + } + + if !strings.HasPrefix(apiPath, "/") { + apiPath = "/" + apiPath + } + requestURL := s.config.BaseURL + apiPath + + bodyBytes, err := json.Marshal(reqData) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKey, "", err, reqData) + return nil, err + } + bodyStr := string(bodyBytes) + + for attempt := 0; attempt < 2; attempt++ { + startTime := time.Now() + + token, err := s.getToken(ctx, transactionID) + if err != nil { + return nil, err + } + + headers := map[string]string{ + "Content-Type": "application/json", + headerDmsToken: token, + } + curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr) + s.logCurl(transactionID, apiKey, requestURL, curlCmd) + + if s.logger != nil { + s.logger.LogRequest("", transactionID, apiKey, requestURL) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, "") + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set(headerDmsToken, token) + + respBody, statusCode, err := s.doHTTP(req) + if err != nil { + err = errors.Join(ErrDatasource, err) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, "") + return nil, err + } + + duration := time.Since(startTime) + respStr := string(respBody) + + if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden { + s.clearToken() + if attempt == 0 { + continue + } + err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", statusCode)) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr) + return nil, err + } + + if statusCode != http.StatusOK { + err = errors.Join(ErrDatasource, fmt.Errorf("HTTP状态码 %d", statusCode)) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr) + return nil, err + } + + var resp apiResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w, body=%s", err, respStr)) + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr) + return nil, err + } + + code := resp.code() + payload := extractBusinessPayload(resp.Data) + s.logResponse(transactionID, apiKey, statusCode, duration, respStr) + + // 扣费只看 consumeFlag:1 扣费(按成功返回),0 不扣费 + if IsBillable(resp.ConsumeFlag) { + return payload, nil + } + + sentinel := MapNonBillableToErr(code) + err = errors.Join(sentinel, NewRongxingError(code, resp.text())) + if !errors.Is(sentinel, ErrNotFound) { + s.logErrorWithCurl(transactionID, apiKey, err, reqData, curlCmd, respStr) + } + return nil, err + } + + return nil, errors.Join(ErrDatasource, errors.New("请求失败")) +} + +func (s *RongxingService) getToken(ctx context.Context, transactionID string) (string, error) { + s.tokenMu.RLock() + token := s.cachedToken + s.tokenMu.RUnlock() + if token != "" { + return token, nil + } + + s.tokenMu.Lock() + defer s.tokenMu.Unlock() + if s.cachedToken != "" { + return s.cachedToken, nil + } + + token, err := s.login(ctx, transactionID) + if err != nil { + return "", err + } + s.cachedToken = token + return token, nil +} + +func (s *RongxingService) clearToken() { + s.tokenMu.Lock() + s.cachedToken = "" + s.tokenMu.Unlock() +} + +func (s *RongxingService) login(ctx context.Context, transactionID string) (string, error) { + privateKey, err := ParsePrivateKey(s.config.PrivateKey) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKeyLogin, "", err, nil) + return "", err + } + + passwordB64 := EncodePasswordBase64(s.config.Password) + timestamp := time.Now().UnixMilli() + signParams := map[string]interface{}{ + "account": s.config.Account, + "password": passwordB64, + "appId": s.config.AppID, + "timestamp": timestamp, + } + content := BuildSignContent(signParams) + sign, err := SignSHA256WithRSA(content, privateKey) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKeyLogin, "", err, nil) + return "", err + } + + payload := map[string]interface{}{ + "account": s.config.Account, + "password": passwordB64, + "appId": s.config.AppID, + "timestamp": timestamp, + "sign": sign, + } + requestURL := s.config.BaseURL + pathAuthLogin + + bodyBytes, err := json.Marshal(payload) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(transactionID, apiKeyLogin, "", err, map[string]interface{}{ + "account": s.config.Account, + "appId": s.config.AppID, + }) + return "", err + } + bodyStr := string(bodyBytes) + + headers := map[string]string{"Content-Type": "application/json"} + curlCmd := generateCurlCommand(http.MethodPost, requestURL, headers, bodyStr) + s.logCurl(transactionID, apiKeyLogin, requestURL, curlCmd) + + if s.logger != nil { + s.logger.LogRequest("", transactionID, apiKeyLogin, requestURL) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, requestURL, bytes.NewBuffer(bodyBytes)) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, "") + return "", err + } + req.Header.Set("Content-Type", "application/json") + + startTime := time.Now() + respBody, statusCode, err := s.doHTTP(req) + if err != nil { + err = errors.Join(ErrDatasource, err) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, "") + return "", err + } + duration := time.Since(startTime) + respStr := string(respBody) + + if statusCode != http.StatusOK { + err = errors.Join(ErrDatasource, fmt.Errorf("登录 HTTP状态码 %d, body=%s", statusCode, respStr)) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + + var loginResp apiResponse + if err := json.Unmarshal(respBody, &loginResp); err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("登录响应解析失败: %w, body=%s", err, respStr)) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + + code := loginResp.code() + if code != CodeSuccess { + rxErr := NewRongxingError(code, loginResp.text()) + err = errors.Join(ErrDatasource, rxErr) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + + var token string + if err := json.Unmarshal(loginResp.Data, &token); err != nil { + err = errors.Join(ErrSystem, fmt.Errorf("登录响应 Token 解析失败: %w, body=%s", err, respStr)) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + token = strings.TrimSpace(token) + if token == "" { + err = errors.Join(ErrSystem, fmt.Errorf("登录响应 Token 为空, body=%s", respStr)) + s.logErrorWithCurl(transactionID, apiKeyLogin, err, nil, curlCmd, respStr) + return "", err + } + + s.logResponse(transactionID, apiKeyLogin, statusCode, duration, respStr) + return token, nil +} + +func (s *RongxingService) doHTTP(req *http.Request) ([]byte, int, error) { + client := &http.Client{Timeout: s.config.Timeout} + resp, err := client.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + return body, resp.StatusCode, nil +} + +func (s *RongxingService) validateConfig() error { + if s.config.BaseURL == "" { + return errors.New("戎行 url 未配置") + } + if strings.TrimSpace(s.config.Account) == "" { + return errors.New("戎行 account 未配置") + } + if strings.TrimSpace(s.config.Password) == "" { + return errors.New("戎行 password 未配置") + } + if strings.TrimSpace(s.config.AppID) == "" { + return errors.New("戎行 app_id 未配置") + } + if strings.TrimSpace(s.config.PrivateKey) == "" { + return errors.New("戎行 private_key 未配置") + } + return nil +} + +func parseCode(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var s string + if err := json.Unmarshal(raw, &s); err == nil { + return strings.TrimSpace(s) + } + var n json.Number + if err := json.Unmarshal(raw, &n); err == nil { + return n.String() + } + return strings.Trim(string(raw), `"`) +} + +// extractBusinessPayload 提取对外返回的业务 data。 +// 若外层 data 内还嵌套 data(如 Info360),则取内层标签对象。 +func extractBusinessPayload(data json.RawMessage) []byte { + if len(data) == 0 || string(data) == "null" { + return []byte("{}") + } + + var wrap struct { + Data json.RawMessage `json:"data"` + } + if err := json.Unmarshal(data, &wrap); err == nil && + len(wrap.Data) > 0 && string(wrap.Data) != "null" { + return wrap.Data + } + return data +} + +func (s *RongxingService) logCurl(transactionID, apiKey, url, curlCmd string) { + if s.logger == nil { + return + } + s.logger.LogInfo("rongxing curl", + zap.String("transaction_id", transactionID), + zap.String("api_code", apiKey), + zap.String("url", url), + zap.String("curl", curlCmd), + ) +} + +func (s *RongxingService) logResponse(transactionID, apiKey string, statusCode int, duration time.Duration, responseBody string) { + if s.logger == nil { + return + } + s.logger.LogResponseWithBody("", transactionID, apiKey, statusCode, duration, responseBody) +} + +func (s *RongxingService) logError(transactionID, apiKey, requestID string, err error, payload interface{}) { + if s.logger == nil { + return + } + s.logger.LogError(requestID, transactionID, apiKey, err, payload) +} + +func (s *RongxingService) logErrorWithCurl(transactionID, apiKey string, err error, payload interface{}, curlCmd, respBody string) { + if s.logger == nil { + return + } + s.logger.LogErrorWithFields("rongxing API错误", + zap.String("transaction_id", transactionID), + zap.String("api_code", apiKey), + zap.Error(err), + zap.Any("params", payload), + zap.String("curl", curlCmd), + zap.String("response_body", respBody), + ) +} diff --git a/internal/infrastructure/external/rongxing/status_codes.go b/internal/infrastructure/external/rongxing/status_codes.go new file mode 100644 index 0000000..10e0f36 --- /dev/null +++ b/internal/infrastructure/external/rongxing/status_codes.go @@ -0,0 +1,75 @@ +package rongxing + +import ( + "errors" + "fmt" +) + +// 业务返回码(仅用于错误描述/分类,不作为扣费依据) +const ( + CodeSuccess = "200" // 成功 + CodeNoData = "204" // 未查到数据 + CodeInternalErr = "503" // 内部服务错误 +) + +// consumeFlag 扣费标记(对方各业务码场景下均可能返回) +const ( + ConsumeFlagNoCharge = 0 // 不扣费 + ConsumeFlagCharge = 1 // 扣费 +) + +var ( + ErrDatasource = errors.New("数据源异常") + ErrSystem = errors.New("系统异常") + ErrNotFound = errors.New("查询为空") +) + +var codeMessage = map[string]string{ + CodeSuccess: "成功", + CodeNoData: "未查到数据", + CodeInternalErr: "内部服务错误", +} + +// GetCodeMessage 返回码描述 +func GetCodeMessage(code string) string { + if msg, ok := codeMessage[code]; ok { + return msg + } + return "" +} + +// IsBillable 按 consumeFlag 判断是否扣费(不以 code 为依据) +func IsBillable(consumeFlag int) bool { + return consumeFlag == ConsumeFlagCharge +} + +type rongxingError struct { + Code string + Message string +} + +func (e *rongxingError) Error() string { + return fmt.Sprintf("戎行返回错误,code: %s,message: %s", e.Code, e.Message) +} + +// NewRongxingError 创建戎行业务错误 +func NewRongxingError(code, message string) *rongxingError { + if message == "" { + if desc := GetCodeMessage(code); desc != "" { + message = desc + } else { + message = "戎行返回未知错误" + } + } + return &rongxingError{Code: code, Message: message} +} + +// MapNonBillableToErr 不扣费场景下,将返回码映射为内部哨兵错误 +func MapNonBillableToErr(code string) error { + switch code { + case CodeSuccess, CodeNoData: + return ErrNotFound + default: + return ErrDatasource + } +} diff --git a/internal/shared/external_logger/external_logger.go b/internal/shared/external_logger/external_logger.go index 837466c..eb959c2 100644 --- a/internal/shared/external_logger/external_logger.go +++ b/internal/shared/external_logger/external_logger.go @@ -327,6 +327,24 @@ func (e *ExternalServiceLogger) LogResponse(requestID, transactionID, apiCode st ) } +// LogResponseWithBody 记录响应日志(含原始响应体) +func (e *ExternalServiceLogger) LogResponseWithBody(requestID, transactionID, apiCode string, statusCode int, duration time.Duration, responseBody string) { + logger := e.responseLogger + if logger == nil { + logger = e.logger + } + logger.Info(fmt.Sprintf("%s API响应", e.serviceName), + zap.String("service", e.serviceName), + zap.String("request_id", requestID), + zap.String("transaction_id", transactionID), + zap.String("api_code", apiCode), + zap.Int("status_code", statusCode), + zap.Duration("duration", duration), + zap.String("response_body", responseBody), + zap.String("timestamp", time.Now().Format(time.RFC3339)), + ) +} + // LogResponseWithID 记录包含响应ID的响应日志 func (e *ExternalServiceLogger) LogResponseWithID(requestID, transactionID, apiCode string, statusCode int, duration time.Duration, responseID string) { logger := e.responseLogger diff --git a/internal/shared/fadada/api_types.go b/internal/shared/fadada/api_types.go index 84d3216..e631b58 100644 --- a/internal/shared/fadada/api_types.go +++ b/internal/shared/fadada/api_types.go @@ -110,7 +110,7 @@ type signConfigInfo struct { OrderNo int `json:"orderNo"` JoinByLink bool `json:"joinByLink"` RequestVerifyFree bool `json:"requestVerifyFree"` - FreeSignType string `json:"freeSignType,omitempty"` // template=按模板免验证签;business=按场景码(需 businessId) + FreeSignType string `json:"freeSignType,omitempty"` // template=按模板;business=按场景码 FreeLogin bool `json:"freeLogin"` BlockHere bool `json:"blockHere"` } @@ -125,7 +125,7 @@ type createSignTaskWithTemplateReq struct { SignInOrder bool `json:"signInOrder,omitempty"` ExpiresTime string `json:"expiresTime,omitempty"` FreeSignType string `json:"freeSignType,omitempty"` // template:按模板;business:按场景码 - BusinessID string `json:"businessId,omitempty"` // 仅 freeSignType=business 时传场景码 + BusinessID string `json:"businessId,omitempty"` // 免验证签场景码;requestVerifyFree=true 时必传 TransReferenceID string `json:"transReferenceId,omitempty"` Actors []signTaskActor `json:"actors"` } diff --git a/internal/shared/fadada/config.go b/internal/shared/fadada/config.go index 59f7ce0..5ee7316 100644 --- a/internal/shared/fadada/config.go +++ b/internal/shared/fadada/config.go @@ -47,7 +47,7 @@ type Config struct { AppSecret string `json:"appSecret" yaml:"app_secret"` ServerURL string `json:"serverUrl" yaml:"server_url"` OpenCorpID string `json:"openCorpId" yaml:"open_corp_id"` - // NoAuthSceneCode 免验证签场景码;仅 freeSignType=business 时需要。当前按模板免验证签,可留空。 + // NoAuthSceneCode 免验证签场景码(businessId);requestVerifyFree=true 时必传。 NoAuthSceneCode string `json:"noAuthSceneCode" yaml:"no_auth_scene_code"` TemplateID string `json:"templateId" yaml:"template_id"` // PartyAActorID / PartyBActorID 签署模板中的参与方标识(actorId),须与模板角色名一致 diff --git a/internal/shared/fadada/signtask_service.go b/internal/shared/fadada/signtask_service.go index c13ea83..f5e7d64 100644 --- a/internal/shared/fadada/signtask_service.go +++ b/internal/shared/fadada/signtask_service.go @@ -10,7 +10,7 @@ import ( // CreateSignFlow 基于签署任务模板创建签署任务(POST /sign-task/create-with-template) // - autoStart:无填单时创建即自动提交;有填单时创建后填控件再 Start(完成自动提交) // - 签署顺序:甲方 orderNo=1 先签(短信/验证);乙方 orderNo=2 后签,由系统按模板免验证签自动盖章 -// - 乙方参与方开启免验证签(requestVerifyFree + freeSignType=template) +// - 乙方参与方开启免验证签(requestVerifyFree=true + freeSignType=template);requestVerifyFree 时 businessId 必传 func (c *Client) CreateSignFlow(req *CreateSignFlowRequest) (*CreateSignFlowResult, error) { if err := validateCreateSignFlowRequest(req); err != nil { return nil, err @@ -31,6 +31,11 @@ func (c *Client) CreateSignFlow(req *CreateSignFlowRequest) (*CreateSignFlowResu // 有填单须先填再提交,创建时不能 autoStart;无填单则创建时自动提交 autoStart := !needFill + businessID, err := c.resolveBusinessID() + if err != nil { + return nil, err + } + subject := strings.TrimSpace(req.Subject) if subject == "" { if c.config.Contract != nil && c.config.Contract.Name != "" { @@ -55,7 +60,8 @@ func (c *Client) CreateSignFlow(req *CreateSignFlowRequest) (*CreateSignFlowResu AutoFinish: true, SignInOrder: true, ExpiresTime: expiresTime, - FreeSignType: freeSignTypeTemplate, // 按模板免验证签,无需 businessId + FreeSignType: freeSignTypeTemplate, // 按模板免验证签 + BusinessID: businessID, // requestVerifyFree=true 时必传场景码 TransReferenceID: strings.TrimSpace(req.TransReferenceID), Actors: actors, }) @@ -338,7 +344,19 @@ func (c *Client) GenerateContractSigning(req *ContractSigningRequest) (*Contract }, nil } -const freeSignTypeTemplate = "template" // 根据模板免验证签(无需 businessId) +const freeSignTypeTemplate = "template" // 根据模板免验证签;requestVerifyFree 时仍须传 businessId + +// resolveBusinessID 免验证签场景码(requestVerifyFree=true 时 businessId 必传) +func (c *Client) resolveBusinessID() (string, error) { + if c.config == nil { + return "", fmt.Errorf("法大大配置为空") + } + businessID := strings.TrimSpace(c.config.NoAuthSceneCode) + if businessID == "" { + return "", fmt.Errorf("法大大 no_auth_scene_code(免验证签场景码)不能为空:requestVerifyFree 时 businessId 必传") + } + return businessID, nil +} func (c *Client) buildSignActors(req *CreateSignFlowRequest) []signTaskActor { partyAActorID := c.config.ResolvePartyAActorID() @@ -366,7 +384,7 @@ func (c *Client) buildSignActors(req *CreateSignFlowRequest) []signTaskActor { }, } - // 乙方(平台)后签 OrderNo=2:甲方签完后由系统按模板免验证签自动盖章 + // 乙方(平台)后签 OrderNo=2:甲方签完后由系统按模板免验证签自动盖章(任务级传 businessId) partyB := signTaskActor{ Actor: &actor{ ActorType: "corp", diff --git a/internal/shared/fadada/signtask_service_test.go b/internal/shared/fadada/signtask_service_test.go index 1b38824..28896a1 100644 --- a/internal/shared/fadada/signtask_service_test.go +++ b/internal/shared/fadada/signtask_service_test.go @@ -19,11 +19,12 @@ func TestIsSignTaskCompletedStatus(t *testing.T) { func TestBuildSignActors(t *testing.T) { c := NewClient(&Config{ - AppID: "app", - AppSecret: "secret", - ServerURL: "https://uat-api.fadada.com/api/v5/", - OpenCorpID: "haiyu-open-corp", - TemplateID: "tpl", + AppID: "app", + AppSecret: "secret", + ServerURL: "https://uat-api.fadada.com/api/v5/", + OpenCorpID: "haiyu-open-corp", + NoAuthSceneCode: "scene-code", + TemplateID: "tpl", }) actors := c.buildSignActors(&CreateSignFlowRequest{ PartyAOpenCorpID: "client-open-corp", @@ -45,6 +46,10 @@ func TestBuildSignActors(t *testing.T) { if actors[1].SignConfigInfo.FreeSignType != freeSignTypeTemplate { t.Fatalf("party B freeSignType=%q, want %s", actors[1].SignConfigInfo.FreeSignType, freeSignTypeTemplate) } + businessID, err := c.resolveBusinessID() + if err != nil || businessID != "scene-code" { + t.Fatalf("businessId=%q err=%v, want scene-code", businessID, err) + } if actors[0].Actor.ActorID == "party_a" || actors[1].Actor.ActorID == "party_b" { t.Fatal("actorId must match sign-template role names, not party_a/party_b") } diff --git a/worker b/worker index 802976f..18bd068 100644 Binary files a/worker and b/worker differ