This commit is contained in:
2026-07-24 23:27:17 +08:00
parent b935c557b9
commit cc2513652e
17 changed files with 1600 additions and 7 deletions

View File

@@ -712,6 +712,40 @@ nuoer:
max_backups: 5 max_backups: 5
max_age: 30 max_age: 30
compress: true 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上游数据源配置 # 🌐 海宇API上游数据源配置
# =========================================== # ===========================================
@@ -742,3 +776,62 @@ haiyuapi:
max_backups: 5 max_backups: 5
max_age: 30 max_age: 30
compress: true compress: true
# ===========================================
# 📝 法大大服务配置FASC OpenAPI v5.1
# 应用名称:天远数据 | AppId80005307 | 状态:待上线
# ===========================================
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

View File

@@ -47,6 +47,7 @@ type Config struct {
Huibo HuiboConfig `mapstructure:"huibo"` Huibo HuiboConfig `mapstructure:"huibo"`
Nuoer NuoerConfig `mapstructure:"nuoer"` Nuoer NuoerConfig `mapstructure:"nuoer"`
Haiyuapi HaiyuapiConfig `mapstructure:"haiyuapi"` Haiyuapi HaiyuapiConfig `mapstructure:"haiyuapi"`
Yuyuecha YuyuechaConfig `mapstructure:"yuyuecha"`
QueryWhitelist QueryWhitelistConfig `mapstructure:"query_whitelist"` QueryWhitelist QueryWhitelistConfig `mapstructure:"query_whitelist"`
} }
@@ -775,6 +776,34 @@ type HaiyuapiLevelFileConfig struct {
Compress bool `mapstructure:"compress"` 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 域名配置 // DomainConfig 域名配置
type DomainConfig struct { type DomainConfig struct {
API string `mapstructure:"api"` // API域名 API string `mapstructure:"api"` // API域名

View File

@@ -56,6 +56,7 @@ import (
"tyapi-server/internal/infrastructure/external/westdex" "tyapi-server/internal/infrastructure/external/westdex"
"tyapi-server/internal/infrastructure/external/xingwei" "tyapi-server/internal/infrastructure/external/xingwei"
"tyapi-server/internal/infrastructure/external/yushan" "tyapi-server/internal/infrastructure/external/yushan"
"tyapi-server/internal/infrastructure/external/yuyuecha"
"tyapi-server/internal/infrastructure/external/zhicha" "tyapi-server/internal/infrastructure/external/zhicha"
"tyapi-server/internal/infrastructure/http/handlers" "tyapi-server/internal/infrastructure/http/handlers"
"tyapi-server/internal/infrastructure/http/routes" "tyapi-server/internal/infrastructure/http/routes"
@@ -415,6 +416,10 @@ func NewContainer() *Container {
func(cfg *config.Config) (*haiyuapi.HaiyuapiService, error) { func(cfg *config.Config) (*haiyuapi.HaiyuapiService, error) {
return haiyuapi.NewHaiyuapiServiceWithConfig(cfg) return haiyuapi.NewHaiyuapiServiceWithConfig(cfg)
}, },
// YuyuechaService - 愉悦查 OpenAPI
func(cfg *config.Config) (*yuyuecha.YuyuechaService, error) {
return yuyuecha.NewYuyuechaServiceWithConfig(cfg)
},
func(cfg *config.Config) *yushan.YushanService { func(cfg *config.Config) *yushan.YushanService {
return yushan.NewYushanService( return yushan.NewYushanService(
cfg.Yushan.URL, cfg.Yushan.URL,

View File

@@ -1313,3 +1313,10 @@ type IVYZHY87Req struct {
IDCard string `json:"id_card" validate:"required,validIDCard"` IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"` 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"`
}

View File

@@ -32,6 +32,7 @@ import (
"tyapi-server/internal/infrastructure/external/westdex" "tyapi-server/internal/infrastructure/external/westdex"
"tyapi-server/internal/infrastructure/external/xingwei" "tyapi-server/internal/infrastructure/external/xingwei"
"tyapi-server/internal/infrastructure/external/yushan" "tyapi-server/internal/infrastructure/external/yushan"
"tyapi-server/internal/infrastructure/external/yuyuecha"
"tyapi-server/internal/infrastructure/external/zhicha" "tyapi-server/internal/infrastructure/external/zhicha"
"tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/interfaces"
) )
@@ -72,6 +73,7 @@ func NewApiRequestService(
huiboService *huibo.HuiboService, huiboService *huibo.HuiboService,
nuoerService *nuoer.NuoerService, nuoerService *nuoer.NuoerService,
haiyuapiService *haiyuapi.HaiyuapiService, haiyuapiService *haiyuapi.HaiyuapiService,
yuyuechaService *yuyuecha.YuyuechaService,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService, productManagementService *services.ProductManagementService,
cfg *appconfig.Config, cfg *appconfig.Config,
@@ -90,6 +92,7 @@ func NewApiRequestService(
huiboService, huiboService,
nuoerService, nuoerService,
haiyuapiService, haiyuapiService,
yuyuechaService,
validator, validator,
productManagementService, productManagementService,
cfg, cfg,
@@ -113,6 +116,7 @@ func NewApiRequestServiceWithRepos(
huiboService *huibo.HuiboService, huiboService *huibo.HuiboService,
nuoerService *nuoer.NuoerService, nuoerService *nuoer.NuoerService,
haiyuapiService *haiyuapi.HaiyuapiService, haiyuapiService *haiyuapi.HaiyuapiService,
yuyuechaService *yuyuecha.YuyuechaService,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
productManagementService *services.ProductManagementService, productManagementService *services.ProductManagementService,
cfg *appconfig.Config, cfg *appconfig.Config,
@@ -142,6 +146,7 @@ func NewApiRequestServiceWithRepos(
huiboService, huiboService,
nuoerService, nuoerService,
haiyuapiService, haiyuapiService,
yuyuechaService,
validator, validator,
combService, combService,
reportRepo, reportRepo,
@@ -240,10 +245,10 @@ func registerAllProcessors(combService *comb.CombService) {
"JRZQV7MD": jrzq.ProcessJRZQV7MDRequest, // 特殊名单 "JRZQV7MD": jrzq.ProcessJRZQV7MDRequest, // 特殊名单
"JRZQ5T8S": jrzq.ProcessJRZQ5T8SRequest, // 借贷意向验证V2 "JRZQ5T8S": jrzq.ProcessJRZQ5T8SRequest, // 借贷意向验证V2
"JRZQT57Z": jrzq.ProcessJRZQT57ZRequest, // 团伙欺诈风险识别 "JRZQT57Z": jrzq.ProcessJRZQT57ZRequest, // 团伙欺诈风险识别
"JRZQK9P2": jrzq.ProcessJRZQK9P2Request, // 洞侦1.0(海宇,查空计费) "JRZQK9P2": jrzq.ProcessJRZQK9P2Request, // 洞侦1.0(海宇,查空计费)
"JRZQR4N7": jrzq.ProcessJRZQR4N7Request, // 借贷意向验证3.0(海宇,查空计费) "JRZQR4N7": jrzq.ProcessJRZQR4N7Request, // 借贷意向验证3.0(海宇,查空计费)
"JRZQP8D2": jrzq.ProcessJRZQP8D2Request, // 全景雷达BH海宇查空不计费 "JRZQP8D2": jrzq.ProcessJRZQP8D2Request, // 全景雷达BH海宇查空不计费
"JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, // 戎行贷后信息 Info360海宇 "JRZQ0OO1": jrzq.ProcessJRZQ0OO1Request, // 戎行贷后信息 Info360海宇
// QYGL系列处理器 // QYGL系列处理器
"QYGL8261": qygl.ProcessQYGL8261Request, "QYGL8261": qygl.ProcessQYGL8261Request,
@@ -284,6 +289,7 @@ func registerAllProcessors(combService *comb.CombService) {
"QYGL2YSB": qygl.ProcessQYGL2YSBRequest, //企业二要素认证shumai "QYGL2YSB": qygl.ProcessQYGL2YSBRequest, //企业二要素认证shumai
"QYGLDG77": qygl.ProcessQYGLDG77Request, //企业对公打款认证shumai "QYGLDG77": qygl.ProcessQYGLDG77Request, //企业对公打款认证shumai
"QYGLBH7Y": qygl.ProcessQYGLBH7YRequest, //企业涉诉案件查询海宇 "QYGLBH7Y": qygl.ProcessQYGLBH7YRequest, //企业涉诉案件查询海宇
"QYGL101A": qygl.ProcessQYGL101ARequest, //企业关系查询(北京正信 A01示例
// YYSY系列处理器 // YYSY系列处理器
"YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖 "YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖
@@ -419,7 +425,7 @@ func registerAllProcessors(combService *comb.CombService) {
"QYGLVR76": qygl.ProcessQYGLVR76Request, //名下企业诺尔 "QYGLVR76": qygl.ProcessQYGLVR76Request, //名下企业诺尔
"QCXG1S2L": qcxg.ProcessQCXG1S2LRequest, //车辆车五项信息核验V2 "QCXG1S2L": qcxg.ProcessQCXG1S2LRequest, //车辆车五项信息核验V2
"QCXGA8V3": qcxg.ProcessQCXGA8V3Request, //全国车辆配置查验(车五项+使用性质+承保) "QCXGA8V3": qcxg.ProcessQCXGA8V3Request, //全国车辆配置查验(车五项+使用性质+承保)
"QCXGCP77": qcxg.ProcessQCXGCP77Request, //全国车辆配置查验(车辆详情) "QCXGCP77": qcxg.ProcessQCXGCP77Request, //全国车辆配置查验(车辆详情)
"QCXG6U5G": qcxg.ProcessQCXG6U5GRequest, //名下车辆核验 "QCXG6U5G": qcxg.ProcessQCXG6U5GRequest, //名下车辆核验
// DWBG系列处理器 - 多维报告 // DWBG系列处理器 - 多维报告
"DWBG6A2C": dwbg.ProcessDWBG6A2CRequest, "DWBG6A2C": dwbg.ProcessDWBG6A2CRequest,

View File

@@ -352,6 +352,7 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
"QCXG6U5G": &dto.QCXG6U5GReq{}, //名下车辆核验 "QCXG6U5G": &dto.QCXG6U5GReq{}, //名下车辆核验
"IVYZ2MA8": &dto.IVYZ2MA8Req{}, //学历核验 "IVYZ2MA8": &dto.IVYZ2MA8Req{}, //学历核验
"IVYZHY87": &dto.IVYZHY87Req{}, //婚姻状态查询 "IVYZHY87": &dto.IVYZHY87Req{}, //婚姻状态查询
"QYGL101A": &dto.QYGL101AReq{}, //企业关系查询(北京正信 A01示例
} }
// 优先返回已配置的DTO // 优先返回已配置的DTO

View File

@@ -17,6 +17,7 @@ import (
"tyapi-server/internal/infrastructure/external/westdex" "tyapi-server/internal/infrastructure/external/westdex"
"tyapi-server/internal/infrastructure/external/xingwei" "tyapi-server/internal/infrastructure/external/xingwei"
"tyapi-server/internal/infrastructure/external/yushan" "tyapi-server/internal/infrastructure/external/yushan"
"tyapi-server/internal/infrastructure/external/yuyuecha"
"tyapi-server/internal/infrastructure/external/zhicha" "tyapi-server/internal/infrastructure/external/zhicha"
"tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/interfaces"
) )
@@ -46,6 +47,7 @@ type ProcessorDependencies struct {
HuiboService *huibo.HuiboService HuiboService *huibo.HuiboService
NuoerService *nuoer.NuoerService NuoerService *nuoer.NuoerService
HaiyuapiService *haiyuapi.HaiyuapiService HaiyuapiService *haiyuapi.HaiyuapiService
YuyuechaService *yuyuecha.YuyuechaService
Validator interfaces.RequestValidator Validator interfaces.RequestValidator
CombService CombServiceInterface // Changed to interface to break import cycle CombService CombServiceInterface // Changed to interface to break import cycle
Options *commands.ApiCallOptions // 添加Options支持 Options *commands.ApiCallOptions // 添加Options支持
@@ -76,6 +78,7 @@ func NewProcessorDependencies(
huiboService *huibo.HuiboService, huiboService *huibo.HuiboService,
nuoerService *nuoer.NuoerService, nuoerService *nuoer.NuoerService,
haiyuapiService *haiyuapi.HaiyuapiService, haiyuapiService *haiyuapi.HaiyuapiService,
yuyuechaService *yuyuecha.YuyuechaService,
validator interfaces.RequestValidator, validator interfaces.RequestValidator,
combService CombServiceInterface, // Changed to interface combService CombServiceInterface, // Changed to interface
reportRepo repositories.ReportRepository, reportRepo repositories.ReportRepository,
@@ -96,6 +99,7 @@ func NewProcessorDependencies(
HuiboService: huiboService, HuiboService: huiboService,
NuoerService: nuoerService, NuoerService: nuoerService,
HaiyuapiService: haiyuapiService, HaiyuapiService: haiyuapiService,
YuyuechaService: yuyuechaService,
Validator: validator, Validator: validator,
CombService: combService, CombService: combService,
Options: nil, // 初始化为nil在调用时设置 Options: nil, // 初始化为nil在调用时设置

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"tyapi-server/internal/domains/api/dto" "tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors" "tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/nuoer" "tyapi-server/internal/infrastructure/external/nuoer"

View File

@@ -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, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
if deps.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
}

View File

@@ -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
}

View File

@@ -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, "'", `'\''`)
}

View File

@@ -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 // 查询有数据(正式环境计费)
)

View File

@@ -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
}

View File

@@ -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 调用愉悦查业务 OpenAPIPOST
// 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)),
)
}

View File

@@ -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)
}

View File

@@ -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"`
}

View File

@@ -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 返回可见列表(仅 fadadaesign 标记 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 | fadadaNULL 表示尚未确认';
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `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 — 法大大 Provider5~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签宝前端隐藏」决策修订确认后进入开发。*
)