From cc2513652e4f0607139192014dff1dc55341a7a9 Mon Sep 17 00:00:00 2001 From: Mrxs <18278715334@163.com> Date: Fri, 24 Jul 2026 23:27:17 +0800 Subject: [PATCH] f --- config.yaml | 95 ++- internal/config/config.go | 29 + internal/container/container.go | 5 + internal/domains/api/dto/api_request_dto.go | 7 + .../api/services/api_request_service.go | 16 +- .../api/services/form_config_service.go | 1 + .../api/services/processors/dependencies.go | 4 + .../processors/ivyz/ivyz2ma8_processor.go | 1 - .../processors/qygl/yycxa01a_processor.go | 55 ++ .../external/yuyuecha/crypto.go | 79 ++ .../external/yuyuecha/curl_helper.go | 33 + .../external/yuyuecha/yuyuecha_errors.go | 27 + .../external/yuyuecha/yuyuecha_factory.go | 50 ++ .../external/yuyuecha/yuyuecha_service.go | 346 +++++++++ .../yuyuecha/yuyuecha_service_test.go | 95 +++ .../external/yuyuecha/yuyuecha_types.go | 48 ++ 多平台签署整合实现方案.md | 716 ++++++++++++++++++ 17 files changed, 1600 insertions(+), 7 deletions(-) create mode 100644 internal/domains/api/services/processors/qygl/yycxa01a_processor.go create mode 100644 internal/infrastructure/external/yuyuecha/crypto.go create mode 100644 internal/infrastructure/external/yuyuecha/curl_helper.go create mode 100644 internal/infrastructure/external/yuyuecha/yuyuecha_errors.go create mode 100644 internal/infrastructure/external/yuyuecha/yuyuecha_factory.go create mode 100644 internal/infrastructure/external/yuyuecha/yuyuecha_service.go create mode 100644 internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go create mode 100644 internal/infrastructure/external/yuyuecha/yuyuecha_types.go create mode 100644 多平台签署整合实现方案.md diff --git a/config.yaml b/config.yaml index a819546..93cc3dd 100644 --- a/config.yaml +++ b/config.yaml @@ -712,6 +712,40 @@ nuoer: max_backups: 5 max_age: 30 compress: true + +# =========================================== +# 🌐 愉悦查 OpenAPI(沙箱) +# 公司:海南海宇大数据有限公司 | 门户:13876051080 +# 额度/有效期以门户为准;正式环境请改 base_url 与凭据 +# =========================================== +yuyuecha: + base_url: "https://api.yuyuecha.com" + client_id: "prod-haiyu-20260724" + client_secret: "Ddgy_HgAJzU7DaX0V3hcIY20H19BCN1q5vCbFPJ0Pco" + timeout: 30s + logging: + enabled: true + log_dir: "logs/external_services" + service_name: "yuyuecha" + 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 + # =========================================== # 🌐 海宇API(上游数据源)配置 # =========================================== @@ -741,4 +775,63 @@ haiyuapi: max_size: 100 max_backups: 5 max_age: 30 - compress: true \ No newline at end of file + compress: true + + +# =========================================== +# 📝 法大大服务配置(FASC OpenAPI v5.1) +# 应用名称:天远数据 | AppId:80005307 | 状态:待上线 +# =========================================== +fadada: + app_id: "00004782" + app_secret: "E9GMVOQ9NBSOUDG5VOFM0J1CZPOAP2Y8" + # 待上线/联调用测试环境;正式上线后改为 https://api.fadada.com/api/v5/ + 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: "乙方" + # 签署模板内文档 docId(填单必填);可从模板详情/任务详情 docs[].docId 获取,留空则创建后自动解析 + template_doc_id: "" + # 模板控件编码(fieldId)——与法大大后台控件编码一致 + template_fields: + # 协议编号:线上由 GenerateContractCode 生成,格式 HYDATA-{当天YYYYMMDD}-R{6位随机数}(R=入驻),已有 contract_code 则复用;下列控件全部填同一编号 + agreement_no: + - "0277518263" + - "5199991781" + - "0923405093" + - "6021178427" + - "5137728710" + - "3181735831" + - "2594442367" + - "5884938840" + - "6914410621" + - "6308592720" + - "3347376554" + - "8264194159" + contract_date: "2039326319" # 签订日期(系统填写) + # 甲方企业名:控件不可复用,多处填同一企业名 + party_a_name: + - "6920634255" + - "1847502631" + party_a_uscc: "8702060291" # 甲方统一信用代码 + party_a_address: "4557539440" # 甲方联系地址 + party_a_rep: "8627432823" # 甲方授权代表/法人 + # 签署日期为签署控件(非填写控件),由甲/乙方签署时自动写入,勿走填单 API + party_a_sign_date: "" + party_b_sign_date: "" + contract: + name: "海宇数据-合作协议" + expire_days: 7 + retry_count: 3 + auth: + # 认证完成后顶层跳回企业入驻页(勿用 callback 页留在 iframe 内) + redirect_url: "https://console.haiyudata.com/profile/certification" + sign: + # 甲方签署完成后回跳(iframe 内会顶层跳出到入驻页) + redirect_url: "https://console.haiyudata.com/certification/callback/fadada/sign" + callback: + enabled: true diff --git a/internal/config/config.go b/internal/config/config.go index 78198e1..2c47d52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -47,6 +47,7 @@ type Config struct { Huibo HuiboConfig `mapstructure:"huibo"` Nuoer NuoerConfig `mapstructure:"nuoer"` Haiyuapi HaiyuapiConfig `mapstructure:"haiyuapi"` + Yuyuecha YuyuechaConfig `mapstructure:"yuyuecha"` QueryWhitelist QueryWhitelistConfig `mapstructure:"query_whitelist"` } @@ -775,6 +776,34 @@ type HaiyuapiLevelFileConfig struct { Compress bool `mapstructure:"compress"` } +// YuyuechaConfig 愉悦查 OpenAPI 配置 +type YuyuechaConfig struct { + BaseURL string `mapstructure:"base_url"` + ClientID string `mapstructure:"client_id"` + ClientSecret string `mapstructure:"client_secret"` + Timeout time.Duration `mapstructure:"timeout"` + + Logging YuyuechaLoggingConfig `mapstructure:"logging"` +} + +// YuyuechaLoggingConfig 愉悦查日志配置 +type YuyuechaLoggingConfig 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]YuyuechaLevelFileConfig `mapstructure:"level_configs"` +} + +// YuyuechaLevelFileConfig 愉悦查级别文件配置 +type YuyuechaLevelFileConfig 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域名 diff --git a/internal/container/container.go b/internal/container/container.go index 16b1c2c..a184b43 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -56,6 +56,7 @@ import ( "tyapi-server/internal/infrastructure/external/westdex" "tyapi-server/internal/infrastructure/external/xingwei" "tyapi-server/internal/infrastructure/external/yushan" + "tyapi-server/internal/infrastructure/external/yuyuecha" "tyapi-server/internal/infrastructure/external/zhicha" "tyapi-server/internal/infrastructure/http/handlers" "tyapi-server/internal/infrastructure/http/routes" @@ -415,6 +416,10 @@ func NewContainer() *Container { func(cfg *config.Config) (*haiyuapi.HaiyuapiService, error) { return haiyuapi.NewHaiyuapiServiceWithConfig(cfg) }, + // YuyuechaService - 愉悦查 OpenAPI + func(cfg *config.Config) (*yuyuecha.YuyuechaService, error) { + return yuyuecha.NewYuyuechaServiceWithConfig(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 f9c43ce..9aac8fd 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -1313,3 +1313,10 @@ type IVYZHY87Req struct { IDCard string `json:"id_card" validate:"required,validIDCard"` Name string `json:"name" validate:"required,min=1,validName"` } + +// QYGL101AReq 愉悦查人企关系查询(OpenAPI A01) +// 平台入参为明文身份证;调用上游前会转为 32 位 MD5(兼容 64 位 SHA-256)身份摘要 +type QYGL101AReq struct { + Name string `json:"name" validate:"required,min=1,validName"` + IDCard string `json:"id_card" validate:"required,min=15,max=64"` +} diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index e5acd84..88f0032 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -32,6 +32,7 @@ import ( "tyapi-server/internal/infrastructure/external/westdex" "tyapi-server/internal/infrastructure/external/xingwei" "tyapi-server/internal/infrastructure/external/yushan" + "tyapi-server/internal/infrastructure/external/yuyuecha" "tyapi-server/internal/infrastructure/external/zhicha" "tyapi-server/internal/shared/interfaces" ) @@ -72,6 +73,7 @@ func NewApiRequestService( huiboService *huibo.HuiboService, nuoerService *nuoer.NuoerService, haiyuapiService *haiyuapi.HaiyuapiService, + yuyuechaService *yuyuecha.YuyuechaService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -90,6 +92,7 @@ func NewApiRequestService( huiboService, nuoerService, haiyuapiService, + yuyuechaService, validator, productManagementService, cfg, @@ -113,6 +116,7 @@ func NewApiRequestServiceWithRepos( huiboService *huibo.HuiboService, nuoerService *nuoer.NuoerService, haiyuapiService *haiyuapi.HaiyuapiService, + yuyuechaService *yuyuecha.YuyuechaService, validator interfaces.RequestValidator, productManagementService *services.ProductManagementService, cfg *appconfig.Config, @@ -142,6 +146,7 @@ func NewApiRequestServiceWithRepos( huiboService, nuoerService, haiyuapiService, + yuyuechaService, validator, combService, reportRepo, @@ -240,10 +245,10 @@ func registerAllProcessors(combService *comb.CombService) { "JRZQV7MD": jrzq.ProcessJRZQV7MDRequest, // 特殊名单 "JRZQ5T8S": jrzq.ProcessJRZQ5T8SRequest, // 借贷意向验证V2 "JRZQT57Z": jrzq.ProcessJRZQT57ZRequest, // 团伙欺诈风险识别 - "JRZQK9P2": jrzq.ProcessJRZQK9P2Request, // 洞侦1.0(海宇,查空计费) - "JRZQR4N7": jrzq.ProcessJRZQR4N7Request, // 借贷意向验证3.0(海宇,查空计费) - "JRZQP8D2": jrzq.ProcessJRZQP8D2Request, // 全景雷达BH(海宇,查空不计费) - "JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, // 戎行贷后信息 Info360(海宇) + "JRZQK9P2": jrzq.ProcessJRZQK9P2Request, // 洞侦1.0(海宇,查空计费) + "JRZQR4N7": jrzq.ProcessJRZQR4N7Request, // 借贷意向验证3.0(海宇,查空计费) + "JRZQP8D2": jrzq.ProcessJRZQP8D2Request, // 全景雷达BH(海宇,查空不计费) + "JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, // 戎行贷后信息 Info360(海宇) // QYGL系列处理器 "QYGL8261": qygl.ProcessQYGL8261Request, @@ -284,6 +289,7 @@ func registerAllProcessors(combService *comb.CombService) { "QYGL2YSB": qygl.ProcessQYGL2YSBRequest, //企业二要素认证shumai "QYGLDG77": qygl.ProcessQYGLDG77Request, //企业对公打款认证shumai "QYGLBH7Y": qygl.ProcessQYGLBH7YRequest, //企业涉诉案件查询海宇 + "QYGL101A": qygl.ProcessQYGL101ARequest, //企业关系查询(北京正信 A01,示例) // YYSY系列处理器 "YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖 @@ -419,7 +425,7 @@ func registerAllProcessors(combService *comb.CombService) { "QYGLVR76": qygl.ProcessQYGLVR76Request, //名下企业诺尔 "QCXG1S2L": qcxg.ProcessQCXG1S2LRequest, //车辆车五项信息核验V2 "QCXGA8V3": qcxg.ProcessQCXGA8V3Request, //全国车辆配置查验(车五项+使用性质+承保) - "QCXGCP77": qcxg.ProcessQCXGCP77Request, //全国车辆配置查验(车辆详情) + "QCXGCP77": qcxg.ProcessQCXGCP77Request, //全国车辆配置查验(车辆详情) "QCXG6U5G": qcxg.ProcessQCXG6U5GRequest, //名下车辆核验 // DWBG系列处理器 - 多维报告 "DWBG6A2C": dwbg.ProcessDWBG6A2CRequest, diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index 06b2448..5ef9d66 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -352,6 +352,7 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "QCXG6U5G": &dto.QCXG6U5GReq{}, //名下车辆核验 "IVYZ2MA8": &dto.IVYZ2MA8Req{}, //学历核验 "IVYZHY87": &dto.IVYZHY87Req{}, //婚姻状态查询 + "QYGL101A": &dto.QYGL101AReq{}, //企业关系查询(北京正信 A01,示例) } // 优先返回已配置的DTO diff --git a/internal/domains/api/services/processors/dependencies.go b/internal/domains/api/services/processors/dependencies.go index a15200f..d1faba2 100644 --- a/internal/domains/api/services/processors/dependencies.go +++ b/internal/domains/api/services/processors/dependencies.go @@ -17,6 +17,7 @@ import ( "tyapi-server/internal/infrastructure/external/westdex" "tyapi-server/internal/infrastructure/external/xingwei" "tyapi-server/internal/infrastructure/external/yushan" + "tyapi-server/internal/infrastructure/external/yuyuecha" "tyapi-server/internal/infrastructure/external/zhicha" "tyapi-server/internal/shared/interfaces" ) @@ -46,6 +47,7 @@ type ProcessorDependencies struct { HuiboService *huibo.HuiboService NuoerService *nuoer.NuoerService HaiyuapiService *haiyuapi.HaiyuapiService + YuyuechaService *yuyuecha.YuyuechaService Validator interfaces.RequestValidator CombService CombServiceInterface // Changed to interface to break import cycle Options *commands.ApiCallOptions // 添加Options支持 @@ -76,6 +78,7 @@ func NewProcessorDependencies( huiboService *huibo.HuiboService, nuoerService *nuoer.NuoerService, haiyuapiService *haiyuapi.HaiyuapiService, + yuyuechaService *yuyuecha.YuyuechaService, validator interfaces.RequestValidator, combService CombServiceInterface, // Changed to interface reportRepo repositories.ReportRepository, @@ -96,6 +99,7 @@ func NewProcessorDependencies( HuiboService: huiboService, NuoerService: nuoerService, HaiyuapiService: haiyuapiService, + YuyuechaService: yuyuechaService, Validator: validator, CombService: combService, Options: nil, // 初始化为nil,在调用时设置 diff --git a/internal/domains/api/services/processors/ivyz/ivyz2ma8_processor.go b/internal/domains/api/services/processors/ivyz/ivyz2ma8_processor.go index 1304d45..8908c6c 100644 --- a/internal/domains/api/services/processors/ivyz/ivyz2ma8_processor.go +++ b/internal/domains/api/services/processors/ivyz/ivyz2ma8_processor.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "tyapi-server/internal/domains/api/dto" "tyapi-server/internal/domains/api/services/processors" "tyapi-server/internal/infrastructure/external/nuoer" diff --git a/internal/domains/api/services/processors/qygl/yycxa01a_processor.go b/internal/domains/api/services/processors/qygl/yycxa01a_processor.go new file mode 100644 index 0000000..1ed4892 --- /dev/null +++ b/internal/domains/api/services/processors/qygl/yycxa01a_processor.go @@ -0,0 +1,55 @@ +package qygl + +import ( + "context" + "encoding/json" + "errors" + + "tyapi-server/internal/domains/api/dto" + "tyapi-server/internal/domains/api/services/processors" + "tyapi-server/internal/infrastructure/external/yuyuecha" +) + +// ProcessQYGL101ARequest QYGL101A - 企业关系查询(愉悦查 A01 示例) +// +// 扣费策略(对齐上游 9.2 / 9.3): +// - HTTP 200 + success:按 X-Billable / billing.billable 决定是否平台扣费 +// - 沙箱 / 幂等重放通常 billable=false → SuccessNoBillError(返回数据但不扣费) +// - 正式环境 resultCode=0/1 且 billable=true → 正常成功并扣费 +// - 4xx/5xx / success=false → 哨兵错误,不扣费 +func ProcessQYGL101ARequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.QYGL101AReq + if err := json.Unmarshal(params, ¶msDto); 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.YuyuechaService == nil { + return nil, errors.Join(processors.ErrSystem, errors.New("北京正信服务未初始化")) + } + + // apiPath 入参;CallAPI 内拼接为 /openapi/v1/a01/person-company-relations + // 上游 idCard 要求 32 位 MD5(兼容 64 位 SHA-256),平台侧收明文后在此做摘要 + result, err := deps.YuyuechaService.CallAPI(ctx, "a01/person-company-relations", map[string]interface{}{ + "name": paramsDto.Name, + "idCard": yuyuecha.HashIDCard(paramsDto.IDCard), + }) + if err != nil { + if errors.Is(err, yuyuecha.ErrNotFound) { + return nil, errors.Join(processors.ErrNotFound, err) + } + if errors.Is(err, yuyuecha.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + if !result.Billable { + return nil, &processors.SuccessNoBillError{Response: result.Data} + } + + return result.Data, nil +} diff --git a/internal/infrastructure/external/yuyuecha/crypto.go b/internal/infrastructure/external/yuyuecha/crypto.go new file mode 100644 index 0000000..456e246 --- /dev/null +++ b/internal/infrastructure/external/yuyuecha/crypto.go @@ -0,0 +1,79 @@ +package yuyuecha + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "strconv" + "strings" + "time" + "unicode" +) + +// HashIDCard 将身份证号转为上游要求的身份摘要。 +// 规则:已是 32 位 MD5 / 64 位 SHA-256 十六进制则原样小写返回;否则对规范化身份证号做 MD5。 +func HashIDCard(idCard string) string { + id := strings.TrimSpace(idCard) + if isHexDigest(id, 32) || isHexDigest(id, 64) { + return strings.ToLower(id) + } + // 末位校验码 X 统一大写后再摘要,与授权侧常见落库一致 + sum := md5.Sum([]byte(strings.ToUpper(id))) + return hex.EncodeToString(sum[:]) +} + +func isHexDigest(s string, length int) bool { + if len(s) != length { + return false + } + for _, r := range s { + if !unicode.Is(unicode.ASCII_Hex_Digit, r) { + return false + } + } + return true +} + +// SignHeaders 按 OpenAPI 规范生成签名请求头 +// canonical: +// +// clientId={id}\ntimestamp={ms}\nnonce={hex}\nmethod={METHOD}\npath={path}\nbodySha256={sha256} +func SignHeaders(clientID, clientSecret, method, path string, body []byte) (map[string]string, error) { + timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10) + nonce, err := randomNonce(16) + if err != nil { + return nil, fmt.Errorf("生成 nonce 失败: %w", err) + } + + sum := sha256.Sum256(body) + bodySHA256 := hex.EncodeToString(sum[:]) + + canonical := "clientId=" + clientID + + "\ntimestamp=" + timestamp + + "\nnonce=" + nonce + + "\nmethod=" + method + + "\npath=" + path + + "\nbodySha256=" + bodySHA256 + + mac := hmac.New(sha256.New, []byte(clientSecret)) + mac.Write([]byte(canonical)) + signature := hex.EncodeToString(mac.Sum(nil)) + + return map[string]string{ + "clientId": clientID, + "timestamp": timestamp, + "nonce": nonce, + "signature": signature, + }, nil +} + +func randomNonce(nBytes int) (string, error) { + buf := make([]byte, nBytes) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/internal/infrastructure/external/yuyuecha/curl_helper.go b/internal/infrastructure/external/yuyuecha/curl_helper.go new file mode 100644 index 0000000..8c6b86f --- /dev/null +++ b/internal/infrastructure/external/yuyuecha/curl_helper.go @@ -0,0 +1,33 @@ +package yuyuecha + +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(escapeShellSingleQuote(key)) + cmd.WriteString(": ") + cmd.WriteString(escapeShellSingleQuote(value)) + cmd.WriteString("'") + } + + if body != "" { + cmd.WriteString(" \\\n -d '") + cmd.WriteString(escapeShellSingleQuote(body)) + cmd.WriteString("'") + } + + return cmd.String() +} + +func escapeShellSingleQuote(s string) string { + return strings.ReplaceAll(s, "'", `'\''`) +} diff --git a/internal/infrastructure/external/yuyuecha/yuyuecha_errors.go b/internal/infrastructure/external/yuyuecha/yuyuecha_errors.go new file mode 100644 index 0000000..c1b970e --- /dev/null +++ b/internal/infrastructure/external/yuyuecha/yuyuecha_errors.go @@ -0,0 +1,27 @@ +package yuyuecha + +import "errors" + +// 内部哨兵错误(供处理器 errors.Is 判断) +var ( + ErrDatasource = errors.New("数据源异常") + ErrSystem = errors.New("系统异常") + ErrNotFound = errors.New("查询为空") +) + +// HTTP 状态码与文档 9.3 对齐 +const ( + HTTPBadRequest = 400 // BAD_REQUEST / INVALID_NONCE + HTTPUnauthorized = 401 // 签名、时间戳或 clientId 错误 + HTTPPaymentRequired = 402 // INSUFFICIENT_BALANCE + HTTPForbidden = 403 // CLIENT_DISABLED / TRIAL_EXPIRED + HTTPConflict = 409 // NONCE_CONFLICT / REQUEST_PROCESSING + HTTPTooManyRequests = 429 // QUOTA_EXCEEDED + HTTPBadGateway = 502 // UPSTREAM_UNAVAILABLE +) + +// ResultCode 业务结果码(data[].resultCode) +const ( + ResultCodeNoData = 0 // 完成查询但暂无数据(正式环境计费) + ResultCodeHasData = 1 // 查询有数据(正式环境计费) +) diff --git a/internal/infrastructure/external/yuyuecha/yuyuecha_factory.go b/internal/infrastructure/external/yuyuecha/yuyuecha_factory.go new file mode 100644 index 0000000..001dc93 --- /dev/null +++ b/internal/infrastructure/external/yuyuecha/yuyuecha_factory.go @@ -0,0 +1,50 @@ +package yuyuecha + +import ( + "tyapi-server/internal/config" + "tyapi-server/internal/shared/external_logger" +) + +// NewYuyuechaServiceWithConfig 使用配置创建愉悦查服务 +func NewYuyuechaServiceWithConfig(cfg *config.Config) (*YuyuechaService, error) { + loggingConfig := external_logger.ExternalServiceLoggingConfig{ + Enabled: cfg.Yuyuecha.Logging.Enabled, + LogDir: cfg.Yuyuecha.Logging.LogDir, + ServiceName: "yuyuecha", + UseDaily: cfg.Yuyuecha.Logging.UseDaily, + EnableLevelSeparation: cfg.Yuyuecha.Logging.EnableLevelSeparation, + LevelConfigs: make(map[string]external_logger.ExternalServiceLevelFileConfig), + } + for k, v := range cfg.Yuyuecha.Logging.LevelConfigs { + loggingConfig.LevelConfigs[k] = external_logger.ExternalServiceLevelFileConfig{ + MaxSize: v.MaxSize, + MaxBackups: v.MaxBackups, + MaxAge: v.MaxAge, + Compress: v.Compress, + } + } + + var logger *external_logger.ExternalServiceLogger + var err error + if loggingConfig.Enabled { + logger, err = external_logger.NewExternalServiceLogger(loggingConfig) + if err != nil { + return nil, err + } + } + + timeout := defaultRequestTimeout + if cfg.Yuyuecha.Timeout > 0 { + timeout = cfg.Yuyuecha.Timeout + } + + serviceCfg := ServiceConfig{ + BaseURL: cfg.Yuyuecha.BaseURL, + ClientID: cfg.Yuyuecha.ClientID, + ClientSecret: cfg.Yuyuecha.ClientSecret, + Timeout: timeout, + Debug: cfg.App.IsDevelopment(), + } + + return NewYuyuechaService(serviceCfg, logger), nil +} diff --git a/internal/infrastructure/external/yuyuecha/yuyuecha_service.go b/internal/infrastructure/external/yuyuecha/yuyuecha_service.go new file mode 100644 index 0000000..2f749fb --- /dev/null +++ b/internal/infrastructure/external/yuyuecha/yuyuecha_service.go @@ -0,0 +1,346 @@ +package yuyuecha + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "tyapi-server/internal/shared/external_logger" + + "go.uber.org/zap" +) + +const ( + defaultRequestTimeout = 30 * time.Second + maxLogResponseBodyLen = 500 + + headerContentType = "Content-Type" + contentTypeJSON = "application/json" + + headerBillable = "X-Billable" + headerChargeFen = "X-Charge-Fen" + headerIdempotentReplay = "X-Idempotent-Replay" + + openAPIPrefix = "/openapi/v1" + accountPath = "/openapi/v1/account" +) + +// ServiceConfig 愉悦查服务配置 +type ServiceConfig struct { + BaseURL string + ClientID string + ClientSecret string + Timeout time.Duration + // Debug 开发环境开启时输出可复现 curl 与完整响应体,便于数据源排查 + Debug bool +} + +// YuyuechaService 愉悦查 OpenAPI 客户端 +type YuyuechaService struct { + config ServiceConfig + logger *external_logger.ExternalServiceLogger +} + +// NewYuyuechaService 创建愉悦查服务实例 +func NewYuyuechaService(cfg ServiceConfig, logger *external_logger.ExternalServiceLogger) *YuyuechaService { + if cfg.Timeout == 0 { + cfg.Timeout = defaultRequestTimeout + } + return &YuyuechaService{ + config: cfg, + logger: logger, + } +} + +func (s *YuyuechaService) generateRequestID() string { + timestamp := time.Now().UnixNano() + hash := md5.Sum([]byte(fmt.Sprintf("%d_%s", timestamp, s.config.ClientID))) + return fmt.Sprintf("yuyuecha_%x", hash[:8]) +} + +func truncateForLog(str string, maxLen int) string { + if maxLen <= 0 || len(str) <= maxLen { + return str + } + return str[:maxLen] + "...[truncated, total " + strconv.Itoa(len(str)) + " chars]" +} + +// CallAPI 调用愉悦查业务 OpenAPI(POST) +// apiPath 为相对路径入参,如 a01/person-company-relations;服务内拼接为 /openapi/v1/{apiPath} +// body 会按紧凑 JSON(无空格、保留 Unicode)序列化后参与签名,务必与上游一致。 +func (s *YuyuechaService) CallAPI(ctx context.Context, apiPath string, body map[string]interface{}) (*CallResult, error) { + path := buildOpenAPIPath(apiPath) + bodyBytes, err := json.Marshal(body) + if err != nil { + return nil, errors.Join(ErrSystem, fmt.Errorf("请求序列化失败: %w", err)) + } + return s.doRequest(ctx, http.MethodPost, path, bodyBytes, body) +} + +// buildOpenAPIPath 将相对 apiPath 拼成签名用完整 path:/openapi/v1/{apiPath} +func buildOpenAPIPath(apiPath string) string { + apiPath = strings.TrimSpace(apiPath) + apiPath = strings.TrimPrefix(apiPath, "/") + if strings.HasPrefix(apiPath, "openapi/v1/") { + return "/" + apiPath + } + return openAPIPrefix + "/" + apiPath +} + +// GetAccount 查询账户汇总(GET /openapi/v1/account,请求体为空,签名 bodySha256 对 b"") +func (s *YuyuechaService) GetAccount(ctx context.Context) (*AccountInfo, error) { + result, err := s.doRequest(ctx, http.MethodGet, accountPath, nil, nil) + if err != nil { + return nil, err + } + var info AccountInfo + if err := json.Unmarshal(result.Data, &info); err != nil { + return nil, errors.Join(ErrSystem, fmt.Errorf("账户响应解析失败: %w", err)) + } + return &info, nil +} + +func (s *YuyuechaService) doRequest(ctx context.Context, method, path string, bodyBytes []byte, logParams interface{}) (*CallResult, error) { + startTime := time.Now() + requestID := s.generateRequestID() + + var transactionID string + if id, ok := ctx.Value("transaction_id").(string); ok { + transactionID = id + } + + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + if bodyBytes == nil { + bodyBytes = []byte{} + } + + baseURL := strings.TrimSuffix(s.config.BaseURL, "/") + reqURL := baseURL + path + + if s.logger != nil { + s.logger.LogRequest(requestID, transactionID, path, reqURL) + } + + signHeaders, err := SignHeaders(s.config.ClientID, s.config.ClientSecret, method, path, bodyBytes) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams}) + return nil, err + } + + var bodyReader io.Reader + if method != http.MethodGet && method != http.MethodHead { + bodyReader = bytes.NewReader(bodyBytes) + } + + req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) + if err != nil { + err = errors.Join(ErrSystem, err) + s.logError(requestID, transactionID, path, err, map[string]interface{}{"request_params": logParams}) + return nil, err + } + curlHeaders := make(map[string]string, len(signHeaders)+1) + if bodyReader != nil { + req.Header.Set(headerContentType, contentTypeJSON) + curlHeaders[headerContentType] = contentTypeJSON + } + for k, v := range signHeaders { + req.Header.Set(k, v) + curlHeaders[k] = v + } + + var curlCmd string + if s.config.Debug { + bodyForCurl := "" + if method != http.MethodGet && method != http.MethodHead { + bodyForCurl = string(bodyBytes) + } + curlCmd = generateCurlCommand(method, reqURL, curlHeaders, bodyForCurl) + } + + client := &http.Client{Timeout: s.config.Timeout} + resp, err := client.Do(req) + if err != nil { + err = wrapHTTPError(err) + errParams := map[string]interface{}{"request_params": logParams} + if curlCmd != "" { + errParams["curl"] = curlCmd + } + s.logError(requestID, transactionID, path, err, errParams) + return nil, err + } + defer resp.Body.Close() + + duration := time.Since(startTime) + raw, err := io.ReadAll(resp.Body) + if err != nil { + err = errors.Join(ErrSystem, err) + errParams := map[string]interface{}{"request_params": logParams} + if curlCmd != "" { + errParams["curl"] = curlCmd + } + s.logError(requestID, transactionID, path, err, errParams) + return nil, err + } + + s.logDebugCurlAndResponse(requestID, transactionID, path, curlCmd, resp.StatusCode, duration, raw) + + if resp.StatusCode != http.StatusOK { + mapped := mapHTTPStatusError(resp.StatusCode, raw) + errParams := map[string]interface{}{ + "request_params": logParams, + "response_body": truncateForLog(string(raw), maxLogResponseBodyLen), + "http_status": resp.StatusCode, + } + if curlCmd != "" { + errParams["curl"] = curlCmd + } + s.logError(requestID, transactionID, path, mapped, errParams) + return nil, mapped + } + + if s.logger != nil { + s.logger.LogResponse(requestID, transactionID, path, resp.StatusCode, duration) + } + + var outer APIResponse + if err := json.Unmarshal(raw, &outer); err != nil { + parseErr := errors.Join(ErrSystem, fmt.Errorf("响应解析失败: %w", err)) + errParams := map[string]interface{}{ + "request_params": logParams, + "response_body": truncateForLog(string(raw), maxLogResponseBodyLen), + } + if curlCmd != "" { + errParams["curl"] = curlCmd + } + s.logError(requestID, transactionID, path, parseErr, errParams) + return nil, parseErr + } + + if !outer.Success { + msg := "上游返回 success=false" + if outer.ErrMessage != nil && *outer.ErrMessage != "" { + msg = *outer.ErrMessage + } else if outer.ErrCode != nil && *outer.ErrCode != "" { + msg = *outer.ErrCode + } + mapped := errors.Join(ErrDatasource, errors.New(msg)) + errParams := map[string]interface{}{ + "request_params": logParams, + "response_body": truncateForLog(string(raw), maxLogResponseBodyLen), + } + if curlCmd != "" { + errParams["curl"] = curlCmd + } + s.logError(requestID, transactionID, path, mapped, errParams) + return nil, mapped + } + + billable, chargeFen := resolveBilling(resp.Header, outer.Billing) + data := outer.Data + if len(data) == 0 || string(data) == "null" { + data = []byte("[]") + } + + return &CallResult{ + Data: data, + Billable: billable, + ChargeFen: chargeFen, + RequestID: outer.RequestID, + Sandbox: outer.Sandbox, + }, nil +} + +func resolveBilling(header http.Header, billing *BillingInfo) (billable bool, chargeFen int) { + // 优先响应头(文档 9.2) + if v := strings.TrimSpace(header.Get(headerBillable)); v != "" { + billable = strings.EqualFold(v, "true") + } else if billing != nil { + billable = billing.Billable + } + + if v := strings.TrimSpace(header.Get(headerChargeFen)); v != "" { + if n, err := strconv.Atoi(v); err == nil { + chargeFen = n + } + } else if billing != nil { + chargeFen = billing.ChargeFen + } + + // 幂等重放:上游不重复扣费 + if strings.EqualFold(strings.TrimSpace(header.Get(headerIdempotentReplay)), "true") { + billable = false + chargeFen = 0 + } + return billable, chargeFen +} + +func mapHTTPStatusError(status int, raw []byte) error { + msg := strings.TrimSpace(string(raw)) + if len(msg) > 200 { + msg = msg[:200] + } + detail := fmt.Sprintf("HTTP %d", status) + if msg != "" { + detail = detail + ": " + msg + } + + switch status { + case HTTPBadRequest: + return errors.Join(ErrSystem, errors.New(detail)) + case HTTPUnauthorized, HTTPPaymentRequired, HTTPForbidden, + HTTPConflict, HTTPTooManyRequests, HTTPBadGateway: + return errors.Join(ErrDatasource, errors.New(detail)) + default: + if status >= 500 { + return errors.Join(ErrDatasource, errors.New(detail)) + } + return errors.Join(ErrDatasource, errors.New(detail)) + } +} + +func wrapHTTPError(err error) error { + if err == context.DeadlineExceeded { + return errors.Join(ErrDatasource, err) + } + if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() { + return errors.Join(ErrDatasource, err) + } + es := err.Error() + if strings.Contains(es, "deadline exceeded") || strings.Contains(es, "timeout") || strings.Contains(es, "canceled") { + return errors.Join(ErrDatasource, err) + } + return errors.Join(ErrSystem, err) +} + +func (s *YuyuechaService) logError(requestID, transactionID, apiCode string, err error, params interface{}) { + if s.logger != nil { + s.logger.LogError(requestID, transactionID, apiCode, err, params) + } +} + +// logDebugCurlAndResponse 仅在 Debug(开发环境)输出完整 curl 与响应体 +func (s *YuyuechaService) logDebugCurlAndResponse(requestID, transactionID, apiCode, curlCmd string, statusCode int, duration time.Duration, raw []byte) { + if !s.config.Debug || s.logger == nil || curlCmd == "" { + return + } + s.logger.LogInfo("愉悦查请求响应(dev)", + zap.String("request_id", requestID), + zap.String("transaction_id", transactionID), + zap.String("api_code", apiCode), + zap.Int("http_status", statusCode), + zap.Duration("duration", duration), + zap.String("curl", curlCmd), + zap.String("response_body", string(raw)), + ) +} diff --git a/internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go b/internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go new file mode 100644 index 0000000..9731d05 --- /dev/null +++ b/internal/infrastructure/external/yuyuecha/yuyuecha_service_test.go @@ -0,0 +1,95 @@ +package yuyuecha + +import ( + "context" + "strings" + "testing" +) + +func TestBuildOpenAPIPath(t *testing.T) { + tests := []struct { + in string + want string + }{ + {"a01/person-company-relations", "/openapi/v1/a01/person-company-relations"}, + {"/a01/person-company-relations", "/openapi/v1/a01/person-company-relations"}, + {"openapi/v1/a01/person-company-relations", "/openapi/v1/a01/person-company-relations"}, + } + for _, tt := range tests { + if got := buildOpenAPIPath(tt.in); got != tt.want { + t.Fatalf("buildOpenAPIPath(%q)=%q, want %q", tt.in, got, tt.want) + } + } +} + +func TestHashIDCard(t *testing.T) { + got := HashIDCard("450322197603171539") + if len(got) != 32 { + t.Fatalf("md5 digest length=%d, want 32", len(got)) + } + if got != strings.ToLower(got) { + t.Fatalf("digest should be lowercase: %s", got) + } + if pass := HashIDCard(got); pass != got { + t.Fatalf("already-hashed pass-through: got %q want %q", pass, got) + } + if pass := HashIDCard(strings.ToUpper(got)); pass != got { + t.Fatalf("uppercase digest normalize: got %q want %q", pass, got) + } +} + +func sandboxService() *YuyuechaService { + return NewYuyuechaService(ServiceConfig{ + BaseURL: "https://sandbox-api.yuyuecha.com", + ClientID: "sandbox-haiyu-20260724", + ClientSecret: "HuidklNz6RnSfCjn6MIcUW_Rj8trLljdN4v6FUs3NsY", + Timeout: defaultRequestTimeout, + }, nil) +} + +func TestSandboxPersonCompanyRelations(t *testing.T) { + svc := sandboxService() + + result, err := svc.CallAPI(context.Background(), "a01/person-company-relations", map[string]interface{}{ + "name": "骆炳荣", + "idCard": HashIDCard("450322197603171539"), + }) + if err != nil { + t.Fatalf("CallAPI failed: %v", err) + } + if result == nil || len(result.Data) == 0 { + t.Fatal("expected non-empty data") + } + // 沙箱不计费 + if result.Billable { + t.Fatalf("sandbox should not be billable, got chargeFen=%d", result.ChargeFen) + } + t.Logf("requestId=%s sandbox=%v billable=%v data=%s", result.RequestID, result.Sandbox, result.Billable, string(result.Data)) +} + +// TestSandboxGetAccount 查询账户汇总:GET,请求体为空 +func TestSandboxGetAccount(t *testing.T) { + svc := sandboxService() + + info, err := svc.GetAccount(context.Background()) + if err != nil { + t.Fatalf("GetAccount failed: %v", err) + } + if info == nil { + t.Fatal("expected account info") + } + if info.ClientID != "sandbox-haiyu-20260724" { + t.Fatalf("clientId=%q", info.ClientID) + } + if info.Mode != "sandbox" { + t.Fatalf("mode=%q", info.Mode) + } + if !info.Enabled { + t.Fatal("expected enabled=true") + } + if info.TotalLimit <= 0 { + t.Fatalf("totalLimit=%d", info.TotalLimit) + } + t.Logf("account: clientId=%s mode=%s totalLimit=%d trialRemaining=%d trialEndsAt=%s totalCalls=%d", + info.ClientID, info.Mode, info.TotalLimit, info.TrialRemaining, info.TrialEndsAt, info.TotalCalls) +} diff --git a/internal/infrastructure/external/yuyuecha/yuyuecha_types.go b/internal/infrastructure/external/yuyuecha/yuyuecha_types.go new file mode 100644 index 0000000..ec87a52 --- /dev/null +++ b/internal/infrastructure/external/yuyuecha/yuyuecha_types.go @@ -0,0 +1,48 @@ +package yuyuecha + +import "encoding/json" + +// APIResponse 愉悦查 OpenAPI 业务响应 +type APIResponse struct { + Success bool `json:"success"` + RequestID string `json:"requestId"` + Sandbox bool `json:"sandbox"` + Status int `json:"status"` + ErrCode *string `json:"errCode"` + ErrMessage *string `json:"errMessage"` + Billing *BillingInfo `json:"billing"` + Data json.RawMessage `json:"data"` +} + +// BillingInfo 响应体内计费信息(与 X-Billable / X-Charge-Fen 头对齐) +type BillingInfo struct { + Billable bool `json:"billable"` + ChargeFen int `json:"chargeFen"` + Reason string `json:"reason"` +} + +// CallResult 调用结果(含上游计费信号,供处理器决定是否平台扣费) +type CallResult struct { + Data []byte + Billable bool + ChargeFen int + RequestID string + Sandbox bool +} + +// AccountInfo 账户汇总(GET /openapi/v1/account) +type AccountInfo struct { + ClientID string `json:"clientId"` + Mode string `json:"mode"` + Enabled bool `json:"enabled"` + TotalLimit int `json:"totalLimit"` + HitLimit int `json:"hitLimit"` + TotalCalls int `json:"totalCalls"` + HitCalls int `json:"hitCalls"` + PriceFen int `json:"priceFen"` + BalanceFen int `json:"balanceFen"` + ReservedFen int `json:"reservedFen"` + AvailableFen int `json:"availableFen"` + TrialEndsAt string `json:"trialEndsAt"` + TrialRemaining int `json:"trialRemaining"` +} diff --git a/多平台签署整合实现方案.md b/多平台签署整合实现方案.md new file mode 100644 index 0000000..34d4bac --- /dev/null +++ b/多平台签署整合实现方案.md @@ -0,0 +1,716 @@ +# 多平台签署整合实现方案(e签宝 + 法大大) + +> 面向 **tyapi**(天远数据)企业入驻流程的改造设计,供评审后决定实施范围与优先级。 +> 关联代码:`tyapi-server`(认证域 + `internal/shared/esign`)、`tyapi-frontend`(`pages/certification`)。 +> **本文档仅方案,未改代码;确认后按分期实施。** + +--- + +## 0. 本平台可行性结论(tyapi) + +### 0.1 结论摘要 + +| 维度 | 结论 | 说明 | +|------|------|------| +| 整体可行性 | **可行** | 入驻状态机、审核→认证→签署链路与文档假设一致,具备抽象多平台的条件 | +| 抽象层改造 | **高可行** | `CertificationApplicationServiceImpl` 已集中调用 `esignClient`,适合抽 `SignPlatformProvider` | +| 法大大接入 | **可行,依赖外部准备** | 需 AppID/模板/沙箱/回调公网;技术映射清晰,联调工期独立 | +| e签宝前端隐藏 | **可行且推荐** | 后端保留 e签宝 Provider(存量/兜底);前端不展示 e签宝选项,新用户走法大大 | +| 存量影响 | **可控** | 历史与进行中记录默认 `sign_platform=esign`,行为与现网一致 | + +### 0.2 与文档原稿的差异(重要) + +原稿部分内容来自 **hyapi / 海宇** 语境,**不能直接当作 tyapi 已完成项**: + +| 项 | 原稿表述 | tyapi 实际 | +|----|----------|------------| +| Phase 1~4 勾选 | 多项已打勾 | **均未落地**(无 `sign_platform` 字段、无 Fadada、无 PlatformSelect、无 SelectSignPlatform API) | +| 包路径 | `hyapi-server/...` | `tyapi-server/...` | +| 合同名 / 回调域名 | 海宇 / `haiyudata.com` | 天远 / `console.tianyuanapi.com` | +| e签宝包位置 | 文中写 `infrastructure/external/esign` | 现网为 `internal/shared/esign`(另有 `infrastructure/external/esign/certification_esign_service.go`) | + +实施时以 **本节 + 下文路径** 为准,不以原稿勾选状态为准。 + +### 0.3 现网关键触点(改造锚点) + +**后端(已绑定 e签宝):** + +- `AdminApproveSubmitRecord`:审核通过后**立即** `esignClient.GenerateEnterpriseAuth`,写入 `auth_url` → `info_submitted` +- `ConfirmAuth` / 企业认证轮询:`QueryOrgIdentityInfo` +- `ApplyContract` / 合同生成与签署:`FillTemplate` / `CreateSignFlow` / `GetSignURL` +- 签署完成:`QuerySignFlowDetail` / `DownloadSignedFile` +- 配置:`config.yaml` → `esign.*`(沙箱 `smlopenapi.esign.cn`,模板与合同名已是天远业务) + +**前端:** + +- 步骤:`enterprise_info` → `manual_review` → `enterprise_verify` → `contract_sign` → `completed` +- **无**平台选择步;`auth_url` / `contract_sign_url` 直接 iframe + +**数据模型:** + +- `certifications` 有 `auth_flow_id` / `auth_url` / `contract_file_id` / `esign_flow_id` / `contract_sign_url` +- **无** `sign_platform` 字段 + +### 0.4 产品决策(已定) + +| 决策项 | 结论 | +|--------|------| +| 平台选择时机 | **A**:审核通过后、生成认证链接前(见 2.2) | +| 是否新增 `platform_pending` | **否**:用 metadata 驱动(见 4.3 做法 1) | +| e签宝 是否保留 | **后端保留,前端隐藏**(见 0.5、7.7) | +| 审核通过后行为 | **不立即调第三方**;等用户完成平台确认(对用户侧可自动确认法大大,见 0.5) | +| 法大大接入 | 优先官方 Go SDK;若 SDK 不适配再自研 HTTP(与现 esign 包风格一致) | + +### 0.5 e签宝「前端隐藏」含义(本期落地口径) + +**目标:** 新用户入驻流程中**看不到、选不到 e签宝**;对外只走法大大。e签宝代码与配置保留,用于: + +1. 存量 / 进行中且已是 e签宝链路的用户继续完成 +2. 管理端排查、必要时管理员重置后内部指定平台 +3. 后续若需临时打开双平台,只改配置与前端可见列表,无需重写 Provider + +**推荐交互(隐藏后「单可见平台」):** + +``` +审核通过 + → GET /sign-platforms 返回可见列表(仅 fadada;esign 标记 hidden 或不下发) + → 可见平台仅 1 个时:前端不展示 PlatformSelect 步骤,自动 POST select-sign-platform=fadada + → 直接进入企业认证 iframe(法大大 auth_url) +``` + +| 规则 | 说明 | +|------|------| +| `sign_platform.enabled` | 配置仍可含 `esign`(后端可用) | +| `sign_platform.visible` / `frontend_hidden` | 控制前端是否展示;本期 `esign` 隐藏 | +| `GET /sign-platforms` | 默认只返回 **可见** 平台;管理端或内部调试可另开参数看全部 | +| `POST /select-sign-platform` | 拒绝用户选择 `hidden` 平台(除非管理员接口) | +| 步骤条 | 单可见平台时:**不增加**「选择签署平台」步骤,用户感知与现网一致(审核后直接认证) | +| 双可见平台时 | 才展示 `PlatformSelect.vue`(预留能力,本期不启用) | + +> 这样「多平台架构」一次到位,但 **本期用户体验 ≈ 只接法大大**,改动面主要在后端抽象 + 法大大联调,前端几乎无新增步骤。 + +--- + +## 1. 背景与目标 + +### 1.1 现状 + +当前企业入驻的**企业实名认证 + 合同签署**全流程绑定在 **e签宝** 单一平台: + +- 管理员审核通过后,直接调用 e签宝 `/v3/org-auth-url` 生成 `auth_url` +- 企业认证、模板填单、签署流程、回调/轮询均硬编码在 `CertificationApplicationServiceImpl` + `internal/shared/esign` + +### 1.2 目标 + +1. `certifications` 主表新增 **`sign_platform`(签署平台)** 字段 +2. 审核通过后按配置完成平台确认(本期:**自动法大大**;预留多平台选择) +3. 根据 `sign_platform` 发放对应的**企业认证链接**与**合同签署链接** +4. 复用现有状态机;存量 e签宝用户零感迁移 +5. e签宝 **前端隐藏**,后端 Provider 保留 + +### 1.3 非目标(本期可不做) + +- 同一用户在不同平台各签一份合同 +- 签署中途切换平台(选定后锁定,除非管理员重置) +- 替换掉 e签宝 的全部历史数据 +- 向普通用户展示 e签宝 选项(本期明确不做) + +--- + +## 2. 改造后流程设计 + +### 2.1 主流程(本期:自动法大大) + +``` +填写企业信息 + → 人工审核(info_pending_review) + → 【内部】确认 sign_platform=fadada(前端无选择页,或静默 select) + → 企业实名认证(info_submitted + auth_url) + → 合同预览与签署(enterprise_verified → contract_applied → completed) +``` + +若未来 `visible` 含多个平台,再插入「选择签署平台」步骤(见 7.1)。 + +### 2.2 平台确认时机(推荐方案 A) + +| 方案 | 时机 | 优点 | 缺点 | +|------|------|------|------| +| **A(推荐)** | 审核通过后、生成认证链接前确认平台 | 认证与签署同一平台;不浪费第三方调用 | 若多平台可见则多一步 | +| B | 提交企业信息时一并选择 | 步骤少 | 审核拒绝后需重选;用户尚不清楚平台差异 | +| C | 仅合同阶段选平台,认证仍固定 e签宝 | 改动小 | 法大大合同要求法大大侧企业实名,链路不完整 | + +**推荐采用方案 A**,状态流转如下: + +```mermaid +flowchart TD + A[info_pending_review] --> B[管理员审核通过] + B --> C{sign_platform 已选择?} + C -->|否| D{可见平台数量} + D -->|仅 1 个| E[前端静默 POST select-sign-platform] + D -->|多个| F[展示 PlatformSelect] + E --> G[写入 sign_platform] + F --> G + G --> H[按平台生成 auth_url] + C -->|是| H + H --> I[info_submitted + auth_url] + I --> J[iframe 企业认证] + J --> K[enterprise_verified + 生成合同] + K --> L[apply-contract 按平台发起签署] + L --> M[completed] +``` + +### 2.3 平台锁定规则 + +| 规则 | 说明 | +|------|------| +| 可选/可确认时机 | 审核通过后 ~ `info_submitted` 之前(`sign_platform` 为空) | +| 锁定时机 | 写入 `sign_platform` 并生成该平台认证链接后 | +| 不可变更 | 进入 `info_submitted` 后用户不可自行切换 | +| 管理员重置 | 可将 `sign_platform` 置空并回到待确认状态(见 4.4) | +| 前端隐藏 | 普通用户不可选 `esign`;存量 `sign_platform=esign` 仍按 e签宝 Provider 跑完 | + +--- + +## 3. 数据库变更 + +### 3.1 `certifications` 主表 + +```sql +ALTER TABLE certifications + ADD COLUMN sign_platform VARCHAR(20) DEFAULT NULL + COMMENT '签署平台: esign | fadada,NULL 表示尚未确认'; +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `sign_platform` | varchar(20), nullable | `esign` / `fadada` / `NULL` | + +**现有 e签宝 字段复用策略(推荐不重命名):** + +| 现有字段 | 多平台语义 | +|----------|------------| +| `auth_flow_id` | 第三方企业认证流程 ID(平台无关) | +| `auth_url` | 第三方企业认证页面 URL | +| `contract_file_id` | 第三方合同文件 ID | +| `esign_flow_id` | 第三方签署流程 ID(建议后续迭代改名为 `sign_flow_id`) | +| `contract_sign_url` | 第三方签署页面 URL | + +> 首期保留 `esign_flow_id` 列名,避免大面积迁移;代码层用 `SignFlowID` 语义理解即可。 + +### 3.2 子记录表(建议同步加平台字段) + +| 表 | 变更 | +|----|------| +| `esign_contract_generate_records` | 新增 `sign_platform varchar(20)`;后期可重命名为 `contract_generate_records` | +| `esign_contract_sign_records` | 新增 `sign_platform varchar(20)`;后期可重命名为 `contract_sign_records` | +| `contract_infos`(若独立落库) | 新增 `sign_platform varchar(20)`,便于历史合同溯源 | + +### 3.3 枚举定义(Go) + +```go +// tyapi-server/internal/domains/certification/enums/sign_platform.go +type SignPlatform string + +const ( + SignPlatformEsign SignPlatform = "esign" + SignPlatformFadada SignPlatform = "fadada" +) + +func IsValidSignPlatform(p SignPlatform) bool { /* ... */ } +``` + +### 3.4 历史数据迁移 + +```sql +-- 已有认证记录默认归属 e签宝(含进行中) +UPDATE certifications SET sign_platform = 'esign' WHERE sign_platform IS NULL; + +UPDATE esign_contract_generate_records SET sign_platform = 'esign' WHERE sign_platform IS NULL; +UPDATE esign_contract_sign_records SET sign_platform = 'esign' WHERE sign_platform IS NULL; +-- contract_infos 若存在同理 +``` + +> 注意:若采用「全表 NULL→esign」迁移,则**新用户在审核通过前**也会被刷成 esign。更稳妥做法: +> - 迁移只刷 **已进入 `info_submitted` 及之后** 的记录;或 +> - 迁移后审核通过且仍为 NULL 的走「待确认平台」逻辑(本期自动 fadada)。 +> **推荐:仅迁移「已有 auth_url / esign_flow_id / 已完成链路」的记录为 esign;其余保持 NULL。** + +--- + +## 4. 后端架构改造 + +### 4.1 核心思路:签署平台抽象层(Strategy + Registry) + +将 e签宝 特有逻辑从 `CertificationApplicationServiceImpl` 中抽离: + +``` +internal/domains/certification/ports/sign_platform_provider.go # 接口 +internal/infrastructure/external/esign/esign_provider.go # e签宝 Provider(封装 shared/esign) +internal/infrastructure/external/fadada/fadada_provider.go # 法大大 Provider +internal/infrastructure/external/signplatform/registry.go # 按 sign_platform 路由 +``` + +> 现有 HTTP/业务封装继续放在 `internal/shared/esign`;Provider 是薄适配层,避免一次性搬迁全部 esign 包。 + +**接口草案:** + +```go +type SignPlatformProvider interface { + Platform() enums.SignPlatform + + GenerateEnterpriseAuth(ctx context.Context, req *EnterpriseAuthRequest) (*AuthLinkResult, error) + QueryOrgVerified(ctx context.Context, req *OrgIdentityQuery) (bool, error) + + GenerateContractFile(ctx context.Context, req *ContractGenerateRequest) (*ContractFileResult, error) + CreateSignFlow(ctx context.Context, req *SignFlowCreateRequest) (*SignFlowResult, error) + QuerySignStatus(ctx context.Context, flowID string) (*SignStatusResult, error) + DownloadSignedFiles(ctx context.Context, flowID string) ([]*SignedFile, error) + + ParseCallback(ctx context.Context, raw []byte, headers map[string]string) (*CallbackEvent, error) + VerifyCallback(ctx context.Context, raw []byte, headers map[string]string) error +} +``` + +调用改为: + +```go +provider := s.platformRegistry.Get(cert.SignPlatform) +provider.GenerateEnterpriseAuth(...) +``` + +### 4.2 需改造的后端触点(按优先级) + +| 方法 | 现状 | 改造 | +|------|------|------| +| `AdminApproveSubmitRecord` | 立即 `esignClient.GenerateEnterpriseAuth` | 审核通过后**不立即**生成链接;仅更新审核态,等平台确认 | +| **新增** `SelectSignPlatform` | 无 | 写 `sign_platform` → provider 生成 `auth_url` → `info_submitted`;拒绝普通用户选 hidden 平台 | +| `ConfirmAuth` / 企业认证完成检查 | 查 e签宝 identity | `QueryOrgVerified` | +| `generateAndAddContractFile` | e签宝 FillTemplate | `GenerateContractFile` | +| `ApplyContract` / 签署 URL | e签宝 SignFlow | `CreateSignFlow` | +| `checkAndUpdateSignStatus` | e签宝 QuerySignFlowDetail | `QuerySignStatus` | +| `handleContractAfterSignComplete` | e签宝 DownloadSignedFile | `DownloadSignedFiles` | +| e签宝回调 | 现有回调入口 | 保留 `/callbacks/esign`;新增 `/callbacks/fadada` | + +### 4.3 状态机微调(推荐做法 1:不新增状态) + +- 管理员审核通过后:保持 `info_pending_review`,或进入 `info_submitted` 但 `auth_url` 为空 +- `details` metadata 驱动前端: + +```json +{ + "status": "info_pending_review", + "metadata": { + "manual_review_status": "approved", + "need_select_platform": true, + "available_platforms": ["fadada"], + "auto_select_platform": "fadada" + } +} +``` + +| 字段 | 含义 | +|------|------| +| `need_select_platform` | 是否还需确认平台(`sign_platform` 为空) | +| `available_platforms` | **前端可见**平台列表(本期仅 `fadada`) | +| `auto_select_platform` | 可见仅 1 个时下发,前端可静默提交 | + +**不推荐**本期新增 `platform_pending` 状态(改状态机、步骤映射、管理端筛选成本高)。 + +### 4.4 新增 API + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/certifications/sign-platforms` | 返回**可见**平台列表;可带 `include_hidden`(仅管理员) | +| POST | `/api/v1/certifications/select-sign-platform` | 确认平台并生成认证链接 | +| POST | `/api/v1/certifications/callbacks/fadada` | 法大大回调 | +| POST | `/api/v1/certifications/admin/reset-sign-platform` | 管理员重置平台(可选) | +| POST | `/api/v1/certifications/admin/select-sign-platform` | 管理员可指定含 esign 的平台(可选,运维用) | + +**`SelectSignPlatform` 请求体:** + +```json +{ "sign_platform": "fadada" } +``` + +**响应(与现有 details 对齐):** + +```json +{ + "status": "info_submitted", + "sign_platform": "fadada", + "metadata": { + "auth_url": "https://...", + "next_action": "请完成企业认证" + } +} +``` + +`apply-contract` / `confirm-sign` / `confirm-auth`:**路径不变**,内部按 `cert.SignPlatform` 路由。 + +### 4.5 管理员审核逻辑调整 + +``` +审核通过 → 仅更新 submit_record.manual_review_status = approved + → 认证状态:保持 info_pending_review(或 info_submitted 无 auth_url) + → 不调用任何第三方 +用户侧确认平台(本期自动 fadada)→ SelectSignPlatform → provider → auth_url → info_submitted +``` + +管理端列表建议展示 `sign_platform` 列(含存量 `esign`),便于客服排查。 + +--- + +## 5. 法大大接入方案 + +### 5.1 平台与文档 + +- 产品:法大大 **FASC OpenAPI v5.1**(公有云) +- 开放平台:`https://cloud.fadada.com` / 开发文档 `https://dev.fadada.com` +- 参考 SDK:`fasc-openapi-go-sdk`(官方 Go SDK,建议优先使用) + +### 5.2 需准备的法大大侧资源 + +| 资源 | 用途 | +|------|------| +| AppID / AppSecret | API 鉴权 | +| openCorpId | 天远数据在法大大侧的企业 ID | +| 应用模板 / 签署模板 | 对应《天远数据API合作协议》 | +| 测试环境域名 | 沙箱联调 | +| 回调地址 | `https://{domain}/api/v1/certifications/callbacks/fadada` | + +### 5.3 法大大 API 与 e签宝 映射 + +| 业务步骤 | e签宝(现有) | 法大大 FASC(待接) | +|----------|---------------|---------------------| +| 获取企业认证链接 | `POST /v3/org-auth-url` | Corp - 企业认证/授权 URL | +| 查询企业是否已实名 | `GET /v3/organizations/identity-info` | Corp - 查询企业认证状态 | +| 模板生成合同文件 | `POST /v3/files/create-by-doc-template` | AppTemplate + Doc - 填模板生成文档 | +| 创建签署流程 | `POST /v3/sign-flow/create-by-file` | `/sign-task/create` 或 create-with-template | +| 获取签署链接 | `POST /v3/sign-flow/{id}/sign-url` | `/sign-task/actor/get-url` | +| 查询签署状态 | `GET /v3/sign-flow/{id}/detail` | `/sign-task/app/get-detail` | +| 下载已签文件 | `GET /v3/sign-flow/{id}/file-download-url` | `/sign-task/owner/get-download-url` | +| 回调 | 现有 e签宝回调 | `X-FASC-*` HMAC-SHA256 验签 + ParseCallback | + +> 具体路径以法大大 v5.1 文档为准;实施时按官方 SDK 方法名落地。 + +### 5.4 法大大合同模板控件 + +模板 ID、控件 fieldId 以法大大控制台实际创建为准,写入 `config.yaml` → `fadada.contract_fields`(或等价结构)。 +业务字段应对齐现网 e签宝 控件语义(协议编号、甲乙方信息、签署日期等),日期格式与现网一致:`2006年01月02日`。 + +> 原稿中的法大大 fieldId 示例来自他站模板,**不可直接用于 tyapi**;上线前需在法大大新建天远模板并回填配置。 + +### 5.5 法大大 SDK 目录规划 + +``` +tyapi-server/internal/shared/fadada/ # 或 infrastructure/external/fadada/ +├── client.go +├── config.go +├── corp_service.go +├── signtask_service.go +├── template_service.go +├── callback.go +└── provider.go # 实现 SignPlatformProvider +``` + +--- + +## 6. 配置变更 + +### 6.1 config.yaml + +```yaml +# 签署平台全局开关 +sign_platform: + enabled: ["esign", "fadada"] # 后端已注册、可路由的平台 + visible: ["fadada"] # 前端可见(本期隐藏 e签宝) + default: "fadada" # 可见仅 1 个或自动确认时使用 + +esign: + # 保持现有配置不变(存量链路) + ... + +fadada: + app_id: "" + app_secret: "" + server_url: "https://api.fadada.com" # 以官方文档为准 + open_corp_id: "" + template_id: "" + contract: + name: "天远数据API合作协议" + expire_days: 7 + auth: + redirect_url: "https://console.tianyuanapi.com/certification/callback/fadada/auth" + sign: + redirect_url: "https://console.tianyuanapi.com/certification/callback/fadada/sign" + callback: + enabled: true +``` + +### 6.2 Go Config 结构 + +```go +type SignPlatformConfig struct { + Enabled []string `mapstructure:"enabled"` + Visible []string `mapstructure:"visible"` // 前端可见;空则等同 enabled + Default string `mapstructure:"default"` +} + +type FadadaConfig struct { + AppID string `mapstructure:"app_id"` + AppSecret string `mapstructure:"app_secret"` + ServerURL string `mapstructure:"server_url"` + OpenCorpID string `mapstructure:"open_corp_id"` + TemplateID string `mapstructure:"template_id"` + // Contract / Auth / Sign / Callback 与 esign 段对称 +} +``` + +**可见性规则:** + +- `visible` 必须是 `enabled` 的子集 +- 用户 `SelectSignPlatform`:只能选 `visible` 中的值 +- 管理员接口:可选 `enabled` 中的任意值(含隐藏的 esign) + +--- + +## 7. 前端改造 + +### 7.1 组件与步骤 + +``` +certification/components/ +├── PlatformSelect.vue # 预留:仅当 available_platforms.length > 1 时使用 +├── EnterpriseVerify.vue # 改造:兼容不同平台 iframe / 跳转 +├── ContractSign.vue # 改造:按平台展示签署页(多数情况仍为 iframe) +└── ... +``` + +**本期步骤条(与现网一致,不增加平台选择步):** + +```javascript +const certificationSteps = [ + { key: 'enterprise_info', title: '填写企业信息' }, + { key: 'manual_review', title: '人工审核' }, + // 不展示 platform_select(单可见平台自动确认) + { key: 'enterprise_verify', title: '企业认证' }, + { key: 'contract_sign', title: '签署合同' }, + { key: 'completed', title: '完成' }, +] +``` + +多平台可见时再插入: + +```javascript +{ key: 'platform_select', title: '选择签署平台' } +``` + +### 7.2 步骤路由逻辑(`setCurrentStepByStatus`) + +```javascript +// 审核已通过,尚未确认平台 +if ( + status === 'info_pending_review' && + metadata.manual_review_status === 'approved' && + !data.sign_platform +) { + const platforms = metadata.available_platforms || [] + if (platforms.length <= 1 && metadata.auto_select_platform) { + // 静默确认,不进入 platform_select + await certificationApi.selectSignPlatform({ + sign_platform: metadata.auto_select_platform, + }) + await getCertificationDetails() + return + } + currentStep.value = 'platform_select' + return +} +``` + +### 7.3 PlatformSelect.vue(仅多可见平台) + +- 调用 `GET /sign-platforms`(仅可见项) +- 卡片:名称、说明、合规提示 +- 确认 → `POST /select-sign-platform` → 跳转 `enterprise_verify` + +### 7.4 API 封装 + +```javascript +export const certificationApi = { + // ...existing + getSignPlatforms: () => request.get('/certifications/sign-platforms'), + selectSignPlatform: (data) => + request.post('/certifications/select-sign-platform', data), +} +``` + +### 7.5 回调路由扩展 + +现有: + +- `/certification/callback/auth` +- `/certification/callback/sign` + +建议扩展: + +- `/certification/callback/esign/auth` | `.../sign`(兼容旧 redirect,可 301/同组件) +- `/certification/callback/fadada/auth` | `.../sign` + +`IframeCallback.vue` 可增加 `platform` 参数,`postMessage` 携带 `{ type: 'CHECK_STATUS', scene, platform }`。 + +### 7.6 管理端 + +`certification-reviews/index.vue` 增加「签署平台」列;详情展示平台与第三方 flow ID。 +管理员重置/指定平台时可看到 **含 e签宝** 的完整 `enabled` 列表。 + +### 7.7 e签宝前端隐藏清单(验收标准) + +| 检查项 | 期望 | +|--------|------| +| 用户步骤条 | 无「e签宝」文案、无平台二选一(单可见时) | +| `GET /sign-platforms` | 不含 esign(或 `hidden: true` 且前端过滤) | +| 用户误传 `sign_platform=esign` | 后端 4xx,提示平台不可用 | +| 存量 esign 用户 | 仍能打开 e签宝认证/签署 iframe,流程可完成 | +| 文案 | 企业认证/签署页不出现「e签宝」品牌(可选:统一写「完成企业认证」) | + +--- + +## 8. 回调与轮询策略 + +| 环节 | e签宝(存量) | 法大大(新用户) | 统一策略 | +|------|---------------|------------------|----------| +| 企业认证完成 | 回调 + 轮询 identity | 认证回调 + 轮询 | 双保险 | +| 合同签署完成 | `confirm-sign` / details 轮询 | 同左 | `GET /details` 按平台同步 | +| 验签 | 现网若未开须补齐 | **必须实现** | 生产两平台均开启验签 | + +--- + +## 9. 实施分期建议(tyapi 现状:全部未做) + +### Phase 0 — 准备(1~2 天) + +- [ ] 法大大开户、应用、AppID/AppSecret/openCorpId +- [ ] 创建天远合作协议模板,控件与业务字段对齐 +- [ ] 确认沙箱与回调公网可达 + +### Phase 1 — 抽象层 + 数据字段(3~5 天) + +- [ ] `certifications.sign_platform` 及子表字段 +- [ ] `SignPlatform` 枚举 + `SignPlatformProvider` 接口 +- [ ] 现有 e签宝 逻辑迁入 / 适配为 `EsignProvider`(行为不变) +- [ ] `PlatformRegistry` 注入容器 +- [ ] 历史数据迁移(仅存量链路刷 `esign`,见 3.4) +- [ ] 回归:e签宝全流程无回归 + +### Phase 2 — 平台确认流程 + 前端隐藏(2~3 天) + +- [ ] `SelectSignPlatform` + `GetSignPlatforms`(visible / auto_select) +- [ ] 调整 `AdminApproveSubmitRecord`(审核后不调第三方) +- [ ] 前端:单可见平台静默 select;**不展示 e签宝** +- [ ] 预留 `PlatformSelect.vue`(多平台时再用) +- [ ] 配置 `enabled` / `visible` / `default` + +### Phase 3 — 法大大 Provider(5~8 天) + +- [ ] HTTP/SDK 封装 + `FadadaProvider` +- [ ] 企业认证链接、实名查询 +- [ ] 模板填单、签署任务、状态查询、文件下载 +- [ ] `/callbacks/fadada` + 验签 +- [ ] 沙箱端到端联调 + +### Phase 4 — 收尾(2~3 天) + +- [ ] 管理端展示 `sign_platform` / 重置能力 +- [ ] e签宝回调验签补齐(安全债) +- [ ] Swagger / 运维文档 +- [ ] 生产配置与灰度(建议先灰度新用户自动 fadada) + +**预估总工期:12~18 个工作日**(1 人全职,含联调;法大大审核/模板配置时间另计)。 +因本期前端不展示选择页,**Phase 2 前端工作量低于原稿**。 + +--- + +## 10. 风险与注意事项 + +| 风险 | 说明 | 缓解 | +|------|------|------| +| 两平台企业实名不互通 | 选法大大必须在法大大完成企业认证 | 新用户统一法大大;存量继续 e签宝 | +| 模板不一致 | 天远需在法大大单独建模板 | 上线前法务对齐检查清单 | +| 签署状态码差异 | e签宝与法大大状态码不同 | 封装在 Provider 内,对外统一 `SignStatusResult` | +| 经办人信息 | 现网 e签宝用法人作经办人 | 法大大侧同步规则并确认字段映射 | +| 合同文件过期 | e签宝约 50 分钟过期重生成 | 法大大侧确认过期策略并在 Provider 实现 | +| 误迁移把新单刷成 esign | 全表 UPDATE 过宽 | 按 3.4 推荐:只刷已进入签署链路的记录 | +| 前端隐藏但配置仍开 esign | 用户直接调 API 选 esign | `SelectSignPlatform` 校验 `visible` | +| 静默 auto-select 失败 | 审核通过后卡在待确认 | details 重试 + 明确错误提示 + 客服可管理员指定 | + +--- + +## 11. 决策记录 + +| 编号 | 议题 | 结论 | +|------|------|------| +| Q1 | 平台确认时机 | **A** 审核通过后、生成链接前 | +| Q2 | 是否新增 `platform_pending` | **否**,metadata 驱动 | +| Q3 | e签宝是否保留 | **后端保留 + 前端隐藏**;新用户默认/仅可见法大大 | +| Q4 | 审核通过后行为 | **不生成链接**,等平台确认(可自动) | +| Q5 | 法大大接入方式 | **优先官方 Go SDK**,不适配则自研 HTTP | +| Q6 | 合同模板 | **法大大新建天远等价模板**(不可复用他站 fieldId) | + +--- + +## 12. 文件改动清单(实施时参考) + +### 后端(tyapi-server) + +| 文件 | 改动类型 | +|------|----------| +| `domains/certification/entities/certification.go` | 新增 `SignPlatform` 字段 | +| `domains/certification/enums/sign_platform.go` | 新增 | +| `domains/certification/ports/sign_platform_provider.go` | 新增接口 | +| `infrastructure/external/esign/esign_provider.go` | 新增(适配 `shared/esign`) | +| `shared/fadada/*` 或 `infrastructure/external/fadada/*` | 新增 | +| `application/certification/certification_application_service_impl.go` | 改调 registry;审核逻辑调整 | +| `application/certification/dto/commands/` | 新增 `SelectSignPlatformCommand` | +| `infrastructure/http/routes/certification_routes.go` | 新增路由 | +| `infrastructure/http/handlers/certification_handler.go` | 新增 handler | +| `config/config.go` + `config.yaml` | `sign_platform` + `fadada` | +| `container/container.go` | 注册 Provider + Registry | + +### 前端(tyapi-frontend) + +| 文件 | 改动类型 | +|------|----------| +| `pages/certification/index.vue` | 审核通过后静默 select;步骤映射 | +| `pages/certification/components/PlatformSelect.vue` | 预留(多可见平台) | +| `pages/certification/IframeCallback.vue` | 支持多平台 callback path | +| `src/api/index.js` | 新增 API | +| `src/router/index.js` | 扩展 callback 路由 | +| `pages/admin/certification-reviews/index.vue` | 展示 / 可选重置 sign_platform | + +--- + +## 13. 总结 + +本次改造的核心不是「立刻砍掉 e签宝」,而是: + +1. **主表增加 `sign_platform`**,区分存量 e签宝与新用户法大大 +2. **抽象 `SignPlatformProvider`**,e签宝现网实现迁入/适配为 Provider +3. **审核通过后确认平台再发链接**;本期通过配置 **前端隐藏 e签宝 + 自动法大大**,用户无感多一步 +4. **新增法大大 Provider**,与 e签宝并行于后端 + +对存量 e签宝用户零影响;新用户只走法大大;后续若要双平台可选,只需把 `esign` 加入 `visible` 并启用 `PlatformSelect`。 + +--- + +## 14. 待你拍板后再改代码的事项 + +确认本方案后,建议按下列顺序开工(仍等你明确说「开始改代码」): + +1. Phase 0:法大大账号与天远模板是否已就绪? +2. Phase 1:是否先只做抽象 + e签宝 Provider 回归,再看法大大? +3. 迁移策略是否采用 **3.4 推荐(只刷存量链路)**? +4. 回调域名是否继续用 `console.tianyuanapi.com`? + +*文档已按 tyapi 现状与「e签宝前端隐藏」决策修订;确认后进入开发。* +) \ No newline at end of file