This commit is contained in:
2026-07-21 15:53:29 +08:00
parent 024b6614ba
commit c943f575d5
40 changed files with 5362 additions and 275 deletions

View File

@@ -0,0 +1,51 @@
package certification
import (
"errors"
"testing"
)
func TestIsEnterpriseAlreadyAuthorizedErr(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{
name: "fadada 210002",
err: errors.New("获取法大大企业认证链接失败: code=210002 msg=该企业已授权,无需重复操作 requestId=abc"),
want: true,
},
{
name: "exist authorize wording",
err: errors.New("当前应用已存在授权"),
want: true,
},
{
name: "other error",
err: errors.New("获取法大大企业认证链接失败: code=100011 msg=参数错误"),
want: false,
},
{
name: "nil",
err: nil,
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isEnterpriseAlreadyAuthorizedErr(tc.err); got != tc.want {
t.Fatalf("got %v want %v", got, tc.want)
}
})
}
}
func TestIsEnterpriseAlreadyRealnamedErr(t *testing.T) {
if !isEnterpriseAlreadyRealnamedErr(errors.New("企业用户已实名")) {
t.Fatal("expected true for 已实名")
}
if isEnterpriseAlreadyRealnamedErr(errors.New("该企业已授权,无需重复操作")) {
t.Fatal("已授权 should not match 已实名 helper")
}
}

View File

@@ -20,6 +20,14 @@ type CertificationApplicationService interface {
ConfirmSign(ctx context.Context, cmd *queries.ConfirmSignCommand) (*responses.ConfirmSignResponse, error)
// 申请合同签署
ApplyContract(ctx context.Context, cmd *commands.ApplyContractCommand) (*responses.ContractSignUrlResponse, error)
// RefreshContractSignURL 重新获取签署 iframe 长链(法大大 EmbedURL 约 10 分钟/单次有效)
RefreshContractSignURL(ctx context.Context, userID string) (*responses.ContractSignUrlResponse, error)
// RefreshContractPreviewURL 获取签署任务预览链接(法大大 get-preview-url
RefreshContractPreviewURL(ctx context.Context, userID string) (*responses.ContractPreviewUrlResponse, error)
// ListSignPlatforms 可选签署平台列表
ListSignPlatforms(ctx context.Context) (*responses.SignPlatformsResponse, error)
// SelectSignPlatform 选择签署平台并生成企业认证链接
SelectSignPlatform(ctx context.Context, cmd *commands.SelectSignPlatformCommand) (*responses.CertificationResponse, error)
// OCR营业执照识别
RecognizeBusinessLicense(ctx context.Context, imageBytes []byte) (*responses.BusinessLicenseResult, error)
@@ -48,8 +56,10 @@ type CertificationApplicationService interface {
// AdminTransitionCertificationStatus 管理端按用户变更认证状态以状态机为准info_submitted=通过 / info_rejected=拒绝)
AdminTransitionCertificationStatus(ctx context.Context, cmd *commands.AdminTransitionCertificationStatusCommand) error
// ================ e签宝回调处理 ================
// ================ 第三方回调处理 ================
// 处理e签宝回调
HandleEsignCallback(ctx context.Context, cmd *commands.EsignCallbackCommand) error
// 处理法大大回调
HandleFadadaCallback(ctx context.Context, headers map[string]string, body []byte) error
}

View File

@@ -17,6 +17,7 @@ import (
"hyapi-server/internal/domains/certification/entities"
certification_value_objects "hyapi-server/internal/domains/certification/entities/value_objects"
"hyapi-server/internal/domains/certification/enums"
"hyapi-server/internal/domains/certification/ports"
"hyapi-server/internal/domains/certification/repositories"
"hyapi-server/internal/domains/certification/services"
finance_service "hyapi-server/internal/domains/finance/services"
@@ -40,6 +41,7 @@ type CertificationApplicationServiceImpl struct {
smsCodeService *user_service.SMSCodeService
esignClient *esign.Client
esignConfig *esign.Config
platformRegistry ports.SignPlatformRegistry
qiniuStorageService *storage.QiNiuStorageService
contractAggregateService user_service.ContractAggregateService
walletAggregateService finance_service.WalletAggregateService
@@ -65,6 +67,7 @@ func NewCertificationApplicationService(
smsCodeService *user_service.SMSCodeService,
esignClient *esign.Client,
esignConfig *esign.Config,
platformRegistry ports.SignPlatformRegistry,
qiniuStorageService *storage.QiNiuStorageService,
contractAggregateService user_service.ContractAggregateService,
walletAggregateService finance_service.WalletAggregateService,
@@ -87,6 +90,7 @@ func NewCertificationApplicationService(
smsCodeService: smsCodeService,
esignClient: esignClient,
esignConfig: esignConfig,
platformRegistry: platformRegistry,
qiniuStorageService: qiniuStorageService,
contractAggregateService: contractAggregateService,
walletAggregateService: walletAggregateService,
@@ -443,16 +447,22 @@ func (s *CertificationApplicationServiceImpl) ConfirmAuth(
zap.String("record_id", record.ID))
s.logger.Info("确认状态-步骤3-开始查询三方实名状态",
zap.String("user_id", cmd.UserID),
zap.String("company_name", record.CompanyName))
identity, err := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{
OrgName: record.CompanyName,
zap.String("company_name", record.CompanyName),
zap.String("sign_platform", string(cert.ResolvedSignPlatform())))
provider, err := s.resolveProvider(cert)
if err != nil {
return nil, err
}
verified, err := provider.QueryOrgVerified(ctx, &ports.OrgIdentityQuery{
OrgName: record.CompanyName,
OrgIdentNo: record.UnifiedSocialCode,
})
if err != nil {
s.logger.Error("查询企业认证信息失败", zap.Error(err))
return nil, fmt.Errorf("查询企业认证信息失败: %w", err)
}
reason := ""
if identity != nil && identity.Data.RealnameStatus == 1 {
if verified {
s.logger.Info("确认状态-步骤3-三方实名状态已完成,准备事务内推进认证",
zap.String("user_id", cmd.UserID))
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
@@ -501,7 +511,7 @@ func (s *CertificationApplicationServiceImpl) ConfirmSign(
}, nil
}
// ApplyContract 申请合同签署
// ApplyContract 立即签署:若认证完成时已创建任务则只取 iframe 长链并推进状态;否则创建任务
func (s *CertificationApplicationServiceImpl) ApplyContract(
ctx context.Context,
cmd *commands.ApplyContractCommand,
@@ -509,59 +519,179 @@ func (s *CertificationApplicationServiceImpl) ApplyContract(
s.logger.Info("开始申请合同签署",
zap.String("user_id", cmd.UserID))
// 1. 验证命令完整性
if err := s.validateApplyContractCommand(cmd); err != nil {
return nil, fmt.Errorf("命令验证失败: %s", err.Error())
}
// 2. 加载认证聚合根
cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID)
if err != nil {
return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
}
// 3. 验证业务前置条件
if err := s.validateContractApplicationPreconditions(cert, cmd.UserID); err != nil {
return nil, fmt.Errorf("业务前置条件验证失败: %s", err.Error())
}
// 5. 生成合同和签署链接
enterpriseInfo, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, cmd.UserID)
if err != nil {
s.logger.Error("获取企业信息失败", zap.Error(err))
return nil, fmt.Errorf("获取企业信息失败: %w", err)
}
contractInfo, err := s.generateContractAndSignURL(ctx, cert, enterpriseInfo.EnterpriseInfo)
if err != nil {
s.logger.Error("生成合同失败", zap.Error(err))
return nil, fmt.Errorf("生成合同失败: %s", err.Error())
var embedURL, shortURL string
flowID := strings.TrimSpace(cert.EsignFlowID)
if flowID != "" {
// 任务已在企业认证完成后创建(乙方通常已免签),此处只刷新甲方 iframe 长链
provider, err := s.resolveProvider(cert)
if err != nil {
return nil, err
}
ei := enterpriseInfo.EnterpriseInfo
urlRes, err := provider.GetActorSignURL(ctx, &ports.GetActorSignURLRequest{
SignFlowID: flowID,
TransactorMobile: ei.LegalPersonPhone,
PartyAName: ei.CompanyName,
})
if err != nil {
return nil, fmt.Errorf("获取签署链接失败: %s", err.Error())
}
embedURL = firstNonEmptyStr(urlRes.EmbedURL, urlRes.ShortURL)
shortURL = strings.TrimSpace(urlRes.ShortURL)
} else {
contractInfo, err := s.generateContractAndSignURL(ctx, cert, enterpriseInfo.EnterpriseInfo)
if err != nil {
s.logger.Error("生成合同失败", zap.Error(err))
return nil, fmt.Errorf("生成合同失败: %s", err.Error())
}
flowID = contractInfo.EsignFlowID
embedURL = contractInfo.ContractSignURL
shortURL = contractInfo.ContractSignShortURL
}
err = cert.ApplyContract(contractInfo.EsignFlowID, contractInfo.ContractSignURL)
if embedURL == "" {
return nil, fmt.Errorf("签署链接为空")
}
storeURL := firstNonEmptyStr(shortURL, cert.ContractSignURL, embedURL)
err = cert.ApplyContract(flowID, storeURL)
if err != nil {
s.logger.Error("合同申请状态转换失败", zap.Error(err))
return nil, fmt.Errorf("合同申请失败: %s", err.Error())
}
// 7. 保存认证信息
err = s.aggregateService.SaveCertification(ctx, cert)
if err != nil {
s.logger.Error("保存认证信息失败", zap.Error(err))
return nil, fmt.Errorf("保存认证信息失败: %s", err.Error())
}
// 8. 构建响应
response := responses.NewContractSignUrlResponse(
cert.ID,
contractInfo.ContractSignURL,
contractInfo.ContractURL,
embedURL,
shortURL,
cert.ContractURL,
"请在规定时间内完成合同签署",
"合同申请成功",
)
s.logger.Info("合同申请成功", zap.String("user_id", cmd.UserID))
s.logger.Info("合同申请成功", zap.String("user_id", cmd.UserID), zap.String("esign_flow_id", flowID))
return response, nil
}
// RefreshContractSignURL 进入签署页时刷新 iframe 长链
func (s *CertificationApplicationServiceImpl) RefreshContractSignURL(
ctx context.Context,
userID string,
) (*responses.ContractSignUrlResponse, error) {
if strings.TrimSpace(userID) == "" {
return nil, fmt.Errorf("用户ID不能为空")
}
cert, err := s.aggregateService.LoadCertificationByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
}
if cert.Status != enums.StatusContractApplied && cert.Status != enums.StatusEnterpriseVerified {
return nil, fmt.Errorf("当前状态不允许获取签署链接")
}
if strings.TrimSpace(cert.EsignFlowID) == "" {
return nil, fmt.Errorf("签署任务尚未创建")
}
provider, err := s.resolveProvider(cert)
if err != nil {
return nil, err
}
enterpriseInfo, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, userID)
if err != nil {
return nil, fmt.Errorf("获取企业信息失败: %w", err)
}
ei := enterpriseInfo.EnterpriseInfo
urlRes, err := provider.GetActorSignURL(ctx, &ports.GetActorSignURLRequest{
SignFlowID: cert.EsignFlowID,
TransactorMobile: ei.LegalPersonPhone,
PartyAName: ei.CompanyName,
})
if err != nil {
return nil, fmt.Errorf("刷新签署链接失败: %s", err.Error())
}
embedURL := firstNonEmptyStr(urlRes.EmbedURL, urlRes.ShortURL)
if embedURL == "" {
return nil, fmt.Errorf("签署链接为空")
}
// 短链可回写库(稳定);长链不落库(易过期且可能超长)
if short := strings.TrimSpace(urlRes.ShortURL); short != "" && short != cert.ContractSignURL {
cert.ContractSignURL = short
_ = s.aggregateService.SaveCertification(ctx, cert)
}
return responses.NewContractSignUrlResponse(
cert.ID,
embedURL,
urlRes.ShortURL,
cert.ContractURL,
"请在规定时间内完成合同签署",
"签署链接已刷新",
), nil
}
// RefreshContractPreviewURL 合同预览页:调用法大大 get-preview-url非 PDF 直链)
func (s *CertificationApplicationServiceImpl) RefreshContractPreviewURL(
ctx context.Context,
userID string,
) (*responses.ContractPreviewUrlResponse, error) {
if strings.TrimSpace(userID) == "" {
return nil, fmt.Errorf("用户ID不能为空")
}
cert, err := s.aggregateService.LoadCertificationByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("加载认证信息失败: %s", err.Error())
}
if cert.Status != enums.StatusEnterpriseVerified && cert.Status != enums.StatusContractApplied {
return nil, fmt.Errorf("当前状态不允许预览合同")
}
if strings.TrimSpace(cert.EsignFlowID) == "" {
return nil, fmt.Errorf("签署任务尚未创建,无法预览")
}
provider, err := s.resolveProvider(cert)
if err != nil {
return nil, err
}
preview, err := provider.GetSignTaskPreviewURL(ctx, cert.EsignFlowID)
if err != nil {
return nil, fmt.Errorf("获取预览链接失败: %s", err.Error())
}
mode := "iframe"
if cert.ResolvedSignPlatform() == enums.SignPlatformEsign {
mode = "pdf"
}
return &responses.ContractPreviewUrlResponse{
CertificationID: cert.ID,
PreviewURL: preview.PreviewURL,
PreviewMode: mode,
SignFlowID: cert.EsignFlowID,
Message: "预览链接已刷新",
}, nil
}
// ================ 查询用例 ================
// GetCertification 获取认证详情
@@ -603,10 +733,36 @@ func (s *CertificationApplicationServiceImpl) GetCertification(
}
}
// 脏数据修复:曾因预创建签署软失败推进到 enterprise_verified 但无任务,回退到待选平台
if cert.Status == enums.StatusEnterpriseVerified && strings.TrimSpace(cert.EsignFlowID) == "" {
if err := cert.ResetAfterSignTaskCreateFailed(); err != nil {
s.logger.Warn("回退未创建签署任务的脏状态失败", zap.String("user_id", query.UserID), zap.Error(err))
} else if saveErr := s.aggregateService.SaveCertification(ctx, cert); saveErr != nil {
s.logger.Warn("保存回退状态失败", zap.String("user_id", query.UserID), zap.Error(saveErr))
} else {
s.logger.Info("已回退未创建签署任务的脏状态到待选平台", zap.String("user_id", query.UserID))
}
}
if cert.Status == enums.StatusInfoSubmitted {
err = s.checkAndCompleteEnterpriseVerification(ctx, cert)
if err != nil {
return nil, err
if autoErr := s.checkAndCompleteEnterpriseVerification(ctx, cert); autoErr != nil {
// 自动推进失败时仍返回当前认证详情,避免前端无法渲染步骤而回退到「填写企业信息」
s.logger.Error("获取详情时自动完成企业认证失败,返回当前状态",
zap.String("user_id", query.UserID),
zap.String("cert_status", string(cert.Status)),
zap.Error(autoErr))
fresh, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, query.UserID)
if loadErr != nil {
return nil, fmt.Errorf("加载认证信息失败: %w", loadErr)
}
cert = fresh
response := s.convertToResponse(cert)
meta, metaErr := s.AddStatusMetadata(ctx, cert)
if metaErr == nil && meta != nil {
response.Metadata = meta
response.Metadata["auto_complete_error"] = autoErr.Error()
}
return response, nil
}
}
if cert.Status == enums.StatusContractApplied {
@@ -873,8 +1029,10 @@ func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords(
items := make([]*responses.AdminSubmitRecordItem, 0, len(result.Records))
for _, r := range result.Records {
certStatus := ""
signPlatform := ""
if cert, err := s.aggregateService.LoadCertificationByUserID(ctx, r.UserID); err == nil && cert != nil {
certStatus = string(cert.Status)
signPlatform = string(cert.SignPlatform)
}
items = append(items, &responses.AdminSubmitRecordItem{
ID: r.ID,
@@ -884,6 +1042,8 @@ func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords(
LegalPersonName: r.LegalPersonName,
SubmitAt: r.SubmitAt,
Status: r.Status,
ManualReviewStatus: r.ManualReviewStatus,
SignPlatform: signPlatform,
CertificationStatus: certStatus,
})
}
@@ -907,8 +1067,10 @@ func (s *CertificationApplicationServiceImpl) AdminGetSubmitRecordByID(ctx conte
return nil, fmt.Errorf("获取提交记录失败: %w", err)
}
certStatus := ""
signPlatform := ""
if cert, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, record.UserID); loadErr == nil && cert != nil {
certStatus = string(cert.Status)
signPlatform = string(cert.SignPlatform)
}
return &responses.AdminSubmitRecordDetail{
ID: record.ID,
@@ -932,6 +1094,9 @@ func (s *CertificationApplicationServiceImpl) AdminGetSubmitRecordByID(ctx conte
VerifiedAt: record.VerifiedAt,
FailedAt: record.FailedAt,
FailureReason: record.FailureReason,
ManualReviewStatus: record.ManualReviewStatus,
ManualReviewRemark: record.ManualReviewRemark,
SignPlatform: signPlatform,
CertificationStatus: certStatus,
CreatedAt: record.CreatedAt,
UpdatedAt: record.UpdatedAt,
@@ -967,52 +1132,19 @@ func (s *CertificationApplicationServiceImpl) AdminApproveSubmitRecord(ctx conte
if cert.Status != enums.StatusInfoPendingReview {
return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status))
}
enterpriseInfo := &certification_value_objects.EnterpriseInfo{
CompanyName: record.CompanyName,
UnifiedSocialCode: record.UnifiedSocialCode,
LegalPersonName: record.LegalPersonName,
LegalPersonID: record.LegalPersonID,
LegalPersonPhone: record.LegalPersonPhone,
EnterpriseAddress: record.EnterpriseAddress,
}
authReq := &esign.EnterpriseAuthRequest{
CompanyName: enterpriseInfo.CompanyName,
UnifiedSocialCode: enterpriseInfo.UnifiedSocialCode,
LegalPersonName: enterpriseInfo.LegalPersonName,
LegalPersonID: enterpriseInfo.LegalPersonID,
TransactorName: enterpriseInfo.LegalPersonName,
TransactorMobile: enterpriseInfo.LegalPersonPhone,
TransactorID: enterpriseInfo.LegalPersonID,
}
authURL, alreadyVerified, err := s.generateEnterpriseAuthOrDetectVerified(ctx, authReq)
if err != nil {
return fmt.Errorf("生成企业认证链接失败: %w", err)
}
if alreadyVerified {
if err := cert.ApproveEnterpriseInfoReview("", "", adminID); err != nil {
return fmt.Errorf("更新认证状态失败: %w", err)
}
if err := s.completeEnterpriseVerification(ctx, cert, cert.UserID, record.CompanyName, record.LegalPersonName); err != nil {
return err
}
record.MarkManualApproved(adminID, remark)
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
return fmt.Errorf("保存企业信息提交记录失败: %w", err)
}
s.logger.Info("管理员审核通过企业信息", zap.String("record_id", recordID), zap.String("admin_id", adminID))
// 幂等:已审核通过、等待用户选平台
if record.ManualReviewStatus == "approved" {
return nil
}
if err := cert.ApproveEnterpriseInfoReview(authURL.AuthShortURL, authURL.AuthFlowID, adminID); err != nil {
return fmt.Errorf("更新认证状态失败: %w", err)
}
if err := s.aggregateService.SaveCertification(ctx, cert); err != nil {
return fmt.Errorf("保存认证信息失败: %w", err)
}
// 审核通过后不立即生成认证链接由用户选择签署平台e签宝/法大大)后再生成
record.MarkManualApproved(adminID, remark)
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
return fmt.Errorf("保存企业信息提交记录失败: %w", err)
}
s.logger.Info("管理员审核通过企业信息", zap.String("record_id", recordID), zap.String("admin_id", adminID))
s.logger.Info("管理员审核通过企业信息,等待用户选择签署平台",
zap.String("record_id", recordID),
zap.String("admin_id", adminID),
zap.String("user_id", record.UserID))
return nil
}
@@ -1076,7 +1208,7 @@ func (s *CertificationApplicationServiceImpl) AdminTransitionCertificationStatus
}
switch cmd.TargetStatus {
case string(enums.StatusInfoSubmitted):
// 审核通过:与 AdminApproveSubmitRecord 一致,推状态并生成企业认证链接
// 审核通过:与 AdminApproveSubmitRecord 一致,仅标记审核通过,等待用户选择签署平台
switch cert.Status {
case enums.StatusInfoSubmitted, enums.StatusEnterpriseVerified, enums.StatusContractApplied,
enums.StatusContractSigned, enums.StatusCompleted, enums.StatusContractRejected, enums.StatusContractExpired:
@@ -1085,45 +1217,13 @@ func (s *CertificationApplicationServiceImpl) AdminTransitionCertificationStatus
if cert.Status != enums.StatusInfoPendingReview {
return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status))
}
enterpriseInfo := &certification_value_objects.EnterpriseInfo{
CompanyName: record.CompanyName, UnifiedSocialCode: record.UnifiedSocialCode,
LegalPersonName: record.LegalPersonName, LegalPersonID: record.LegalPersonID,
LegalPersonPhone: record.LegalPersonPhone, EnterpriseAddress: record.EnterpriseAddress,
}
authReq := &esign.EnterpriseAuthRequest{
CompanyName: enterpriseInfo.CompanyName, UnifiedSocialCode: enterpriseInfo.UnifiedSocialCode,
LegalPersonName: enterpriseInfo.LegalPersonName, LegalPersonID: enterpriseInfo.LegalPersonID,
TransactorName: enterpriseInfo.LegalPersonName, TransactorMobile: enterpriseInfo.LegalPersonPhone, TransactorID: enterpriseInfo.LegalPersonID,
}
authURL, alreadyVerified, err := s.generateEnterpriseAuthOrDetectVerified(ctx, authReq)
if err != nil {
return fmt.Errorf("生成企业认证链接失败: %w", err)
}
if alreadyVerified {
if err := cert.ApproveEnterpriseInfoReview("", "", cmd.AdminID); err != nil {
return fmt.Errorf("更新认证状态失败: %w", err)
}
if err := s.completeEnterpriseVerification(ctx, cert, cert.UserID, record.CompanyName, record.LegalPersonName); err != nil {
return err
}
record.MarkManualApproved(cmd.AdminID, cmd.Remark)
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
return fmt.Errorf("保存企业信息提交记录失败: %w", err)
}
s.logger.Info("管理端变更认证状态为通过", zap.String("user_id", cmd.UserID), zap.String("admin_id", cmd.AdminID))
return nil
}
if err := cert.ApproveEnterpriseInfoReview(authURL.AuthShortURL, authURL.AuthFlowID, cmd.AdminID); err != nil {
return fmt.Errorf("更新认证状态失败: %w", err)
}
if err := s.aggregateService.SaveCertification(ctx, cert); err != nil {
return fmt.Errorf("保存认证信息失败: %w", err)
}
record.MarkManualApproved(cmd.AdminID, cmd.Remark)
if err := s.enterpriseInfoSubmitRecordService.Save(ctx, record); err != nil {
return fmt.Errorf("保存企业信息提交记录失败: %w", err)
}
s.logger.Info("管理端变更认证状态为通过", zap.String("user_id", cmd.UserID), zap.String("admin_id", cmd.AdminID))
s.logger.Info("管理端审核通过,等待用户选择签署平台",
zap.String("user_id", cmd.UserID),
zap.String("admin_id", cmd.AdminID))
return nil
case string(enums.StatusInfoRejected):
// 审核拒绝
@@ -1160,6 +1260,7 @@ func (s *CertificationApplicationServiceImpl) convertToResponse(cert *entities.C
UserID: cert.UserID,
Status: cert.Status,
StatusName: enums.GetStatusName(cert.Status),
SignPlatform: string(cert.SignPlatform),
Progress: cert.GetProgress(),
CreatedAt: cert.CreatedAt,
UpdatedAt: cert.UpdatedAt,
@@ -1198,58 +1299,6 @@ func (s *CertificationApplicationServiceImpl) convertToResponse(cert *entities.C
return response
}
func (s *CertificationApplicationServiceImpl) generateEnterpriseAuthOrDetectVerified(
ctx context.Context,
req *esign.EnterpriseAuthRequest,
) (*esign.EnterpriseAuthResult, bool, error) {
s.logger.Info("企业认证链接生成-步骤1-开始调用三方创建认证链接",
zap.String("company_name", req.CompanyName),
zap.String("unified_social_code", req.UnifiedSocialCode))
authURL, err := s.esignClient.GenerateEnterpriseAuth(req)
if err == nil {
s.logger.Info("企业认证链接生成-步骤1-创建成功",
zap.String("company_name", req.CompanyName),
zap.String("auth_flow_id", authURL.AuthFlowID))
return authURL, false, nil
}
if !isEnterpriseAlreadyRealnamedErr(err) {
s.logger.Error("企业认证链接生成-步骤1-创建失败且非已实名场景",
zap.String("company_name", req.CompanyName),
zap.Error(err))
return nil, false, err
}
s.logger.Warn("企业已实名,跳过生成认证链接并转为自动确认",
zap.String("company_name", req.CompanyName),
zap.String("unified_social_code", req.UnifiedSocialCode),
zap.Error(err))
identity, identityErr := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{
OrgIDCardNum: req.UnifiedSocialCode,
OrgIDCardType: esign.OrgIDCardTypeUSCC,
})
if identityErr != nil {
s.logger.Warn("企业认证链接生成-步骤2-按信用代码查询实名状态失败,回退按企业名查询",
zap.String("company_name", req.CompanyName),
zap.Error(identityErr))
identity, identityErr = s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{
OrgName: req.CompanyName,
})
}
if identityErr != nil {
return nil, false, fmt.Errorf("企业用户已实名,但查询实名状态失败: %w", identityErr)
}
s.logger.Info("企业认证链接生成-步骤2-实名状态查询成功",
zap.String("company_name", req.CompanyName),
zap.Int32("realname_status", identity.Data.RealnameStatus))
if identity == nil || identity.Data.RealnameStatus != 1 {
return nil, false, err
}
s.logger.Info("企业认证链接生成-步骤3-确认企业已实名,返回自动确认标记",
zap.String("company_name", req.CompanyName))
return nil, true, nil
}
func isEnterpriseAlreadyRealnamedErr(err error) bool {
if err == nil {
return false
@@ -1258,6 +1307,18 @@ func isEnterpriseAlreadyRealnamedErr(err error) bool {
return strings.Contains(msg, "企业用户已实名") || strings.Contains(msg, "已实名")
}
// isEnterpriseAlreadyAuthorizedErr 法大大等平台:企业已对本应用授权,无需再获取授权链接(如 code=210002
func isEnterpriseAlreadyAuthorizedErr(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "210002") ||
strings.Contains(msg, "该企业已授权") ||
strings.Contains(msg, "无需重复操作") ||
strings.Contains(msg, "已存在授权")
}
// validateApplyContractCommand 验证申请合同命令
func (s *CertificationApplicationServiceImpl) validateApplyContractCommand(cmd *commands.ApplyContractCommand) error {
if cmd.UserID == "" {
@@ -1279,27 +1340,41 @@ func (s *CertificationApplicationServiceImpl) validateContractApplicationPrecond
// generateContractAndSignURL 生成合同和签署链接
func (s *CertificationApplicationServiceImpl) generateContractAndSignURL(ctx context.Context, cert *entities.Certification, enterpriseInfo *user_entities.EnterpriseInfo) (*certification_value_objects.ContractInfo, error) {
// 发起签署流程
signFlowID, err := s.esignClient.CreateSignFlow(&esign.CreateSignFlowRequest{
FileID: cert.ContractFileID,
SignerAccount: enterpriseInfo.UnifiedSocialCode,
SignerName: enterpriseInfo.CompanyName,
TransactorPhone: enterpriseInfo.LegalPersonPhone,
TransactorName: enterpriseInfo.LegalPersonName,
TransactorIDCardNum: enterpriseInfo.LegalPersonID,
})
provider, err := s.resolveProvider(cert)
if err != nil {
return nil, fmt.Errorf("生成合同失败: %s", err.Error())
return nil, err
}
_, shortUrl, err := s.esignClient.GetSignURL(signFlowID, enterpriseInfo.LegalPersonPhone, enterpriseInfo.CompanyName)
record, _ := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID)
repName := enterpriseInfo.LegalPersonName
address := enterpriseInfo.EnterpriseAddress
if record != nil {
repName = pickAuthorizedRepName(record, enterpriseInfo.LegalPersonName)
if address == "" {
address = record.EnterpriseAddress
}
}
req := &ports.SignFlowCreateRequest{
Subject: "海宇数据-合作协议-" + enterpriseInfo.CompanyName,
FileID: cert.ContractFileID,
PartyAName: enterpriseInfo.CompanyName,
PartyAUSCC: enterpriseInfo.UnifiedSocialCode,
TransactorName: enterpriseInfo.LegalPersonName,
TransactorMobile: enterpriseInfo.LegalPersonPhone,
TransactorID: enterpriseInfo.LegalPersonID,
TransReferenceID: cert.ID,
Fill: ensureContractFill(cert, enterpriseInfo.CompanyName, enterpriseInfo.UnifiedSocialCode, address, repName),
}
signRes, err := provider.CreateSignFlow(ctx, req)
if err != nil {
return nil, fmt.Errorf("获取签署链接失败: %s", err.Error())
return nil, fmt.Errorf("生成签署流程失败: %s", err.Error())
}
// iframe 必须用长链 SignURL法大大 EmbedURL短链不可嵌入
embedURL := firstNonEmptyStr(signRes.SignURL, signRes.ShortURL)
return &certification_value_objects.ContractInfo{
ContractFileID: cert.ContractFileID,
EsignFlowID: signFlowID,
ContractSignURL: shortUrl,
ContractFileID: cert.ContractFileID,
EsignFlowID: signRes.SignFlowID,
ContractSignURL: embedURL,
ContractSignShortURL: strings.TrimSpace(signRes.ShortURL),
}, nil
}
@@ -1333,7 +1408,7 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification(
zap.String("user_id", userID),
zap.String("record_id", record.ID))
err = s.userAggregateService.CreateEnterpriseInfo(
err = s.userAggregateService.CreateOrUpdateEnterpriseInfo(
ctx,
userID,
record.CompanyName,
@@ -1346,17 +1421,26 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification(
if err != nil {
s.logger.Error("保存企业信息到用户域失败", zap.Error(err))
return fmt.Errorf("保存企业信息失败: %s", err.Error())
} else {
s.logger.Info("企业信息已保存到用户域", zap.String("user_id", userID))
}
s.logger.Info("企业信息已写入用户域(创建或更新)", zap.String("user_id", userID))
// 生成合同
// 生成合同(填单/文件)
err = s.generateAndAddContractFile(ctx, cert, record.CompanyName, record.UnifiedSocialCode, record.EnterpriseAddress, pickAuthorizedRepName(record, record.LegalPersonName))
if err != nil {
return err
}
s.logger.Info("完成企业认证-步骤3-合同文件生成并写入认证成功", zap.String("user_id", userID))
// 立即创建并提交签署任务:乙方免验证签先盖章;甲方稍后在「立即签署」取 iframe 长链
// 创建失败必须整体失败,不得推进到 enterprise_verified / 签署合同步骤
if err := s.createSignTaskAfterEnterpriseVerified(ctx, cert, record); err != nil {
s.logger.Error("企业认证后预创建签署任务失败", zap.Error(err), zap.String("user_id", userID))
return fmt.Errorf("创建签署任务失败: %w", err)
}
s.logger.Info("完成企业认证-步骤3b-签署任务已创建(乙方免签)",
zap.String("user_id", userID),
zap.String("esign_flow_id", cert.EsignFlowID))
// 保存认证信息
err = s.aggregateService.SaveCertification(ctx, cert)
if err != nil {
@@ -1368,6 +1452,46 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification(
return nil
}
// createSignTaskAfterEnterpriseVerified 认证完成后创建签署任务并启动(乙方免签),不推进到 contract_applied
func (s *CertificationApplicationServiceImpl) createSignTaskAfterEnterpriseVerified(
ctx context.Context,
cert *entities.Certification,
record *entities.EnterpriseInfoSubmitRecord,
) error {
if cert == nil || record == nil {
return fmt.Errorf("认证或企业信息为空")
}
if strings.TrimSpace(cert.EsignFlowID) != "" {
return nil
}
provider, err := s.resolveProvider(cert)
if err != nil {
return err
}
repName := pickAuthorizedRepName(record, record.LegalPersonName)
address := record.EnterpriseAddress
req := &ports.SignFlowCreateRequest{
Subject: "海宇数据-合作协议-" + record.CompanyName,
FileID: cert.ContractFileID,
PartyAName: record.CompanyName,
PartyAUSCC: record.UnifiedSocialCode,
TransactorName: record.LegalPersonName,
TransactorMobile: record.LegalPersonPhone,
TransactorID: record.LegalPersonID,
TransReferenceID: cert.ID,
Fill: ensureContractFill(cert, record.CompanyName, record.UnifiedSocialCode, address, repName),
SkipActorURL: true, // 预创建不取甲方链接,避免消耗单次 EmbedURL
}
signRes, err := provider.CreateSignFlow(ctx, req)
if err != nil {
return err
}
storeURL := firstNonEmptyStr(signRes.ShortURL, signRes.SignURL)
cert.BindSignTask(signRes.SignFlowID, storeURL)
// 预览链接约 2 小时/单次,进入预览页时再调 get-preview-url此处不落库 PDF 直链
return nil
}
// pickAuthorizedRepName 合同模板「客户授权代表」: 优先企业提交记录中的授权代表, 否则为法定代表人
func pickAuthorizedRepName(record *entities.EnterpriseInfoSubmitRecord, legalPersonName string) string {
if record != nil && strings.TrimSpace(record.AuthorizedRepName) != "" {
@@ -1396,22 +1520,18 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile(
agreementNo := cert.ContractCode
signDate := time.Now().Format("2006年01月02日")
// 控件 key 与 e 签宝合同模板中控件名一致(新合同)
fileComponent := map[string]string{
"jfqym": companyName,
"jfqym2": companyName,
"jfsqdb": authorizedRepName,
"jftyshxydm": unifiedSocialCode,
"jflxdz": enterpriseAddress,
// 甲方
"xybh": agreementNo,
"qsrq1": signDate,
"qsrq3": signDate,
// 乙方
"qsrq2": signDate,
provider, err := s.resolveProvider(cert)
if err != nil {
return err
}
fillTemplateResp, err := s.esignClient.FillTemplate(fileComponent)
fillTemplateResp, err := provider.GenerateContractFile(ctx, &ports.ContractGenerateRequest{
AgreementNo: agreementNo,
CompanyName: companyName,
UnifiedSocialCode: unifiedSocialCode,
EnterpriseAddress: enterpriseAddress,
AuthorizedRepName: authorizedRepName,
SignDate: signDate,
})
if err != nil {
s.logger.Error("生成合同失败", zap.Error(err))
return fmt.Errorf("生成合同失败: %s", err.Error())
@@ -1419,8 +1539,9 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile(
s.logger.Info("合同生成-步骤1-模板填充成功",
zap.String("user_id", cert.UserID),
zap.String("file_id", fillTemplateResp.FileID),
zap.String("contract_code", agreementNo))
err = cert.AddContractFileID(fillTemplateResp.FileID, fillTemplateResp.FileDownloadUrl)
zap.String("contract_code", agreementNo),
zap.String("sign_platform", string(cert.ResolvedSignPlatform())))
err = cert.AddContractFileID(fillTemplateResp.FileID, fillTemplateResp.FileDownloadURL)
if err != nil {
s.logger.Error("加入合同文件ID链接失败", zap.Error(err))
return fmt.Errorf("加入合同文件ID链接失败: %s", err.Error())
@@ -1466,13 +1587,19 @@ func (s *CertificationApplicationServiceImpl) checkAndCompleteEnterpriseVerifica
if err != nil {
return fmt.Errorf("查找企业信息失败: %w", err)
}
identity, err := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{
OrgName: record.CompanyName,
provider, err := s.resolveProvider(cert)
if err != nil {
return err
}
verified, err := provider.QueryOrgVerified(ctx, &ports.OrgIdentityQuery{
OrgName: record.CompanyName,
OrgIdentNo: record.UnifiedSocialCode,
})
if err != nil {
s.logger.Error("查询企业认证信息失败", zap.Error(err))
return nil
}
if identity != nil && identity.Data.RealnameStatus == 1 {
if verified {
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
return s.completeEnterpriseVerification(txCtx, cert, cert.UserID, record.CompanyName, record.LegalPersonName)
})
@@ -1490,11 +1617,15 @@ func (s *CertificationApplicationServiceImpl) checkAndUpdateSignStatus(ctx conte
if cert.Status != enums.StatusContractApplied {
return fmt.Errorf("认证状态不正确")
}
detail, err := s.esignClient.QuerySignFlowDetail(cert.EsignFlowID)
provider, err := s.resolveProvider(cert)
if err != nil {
return err
}
detail, err := provider.QuerySignStatus(txCtx, cert.EsignFlowID)
if err != nil {
return fmt.Errorf("查询签署流程详情失败: %s", err.Error())
}
if detail.Data.SignFlowStatus == 2 {
if detail.Completed {
err = cert.SignSuccess()
if err != nil {
return fmt.Errorf("合同签署成功失败: %s", err.Error())
@@ -1503,21 +1634,19 @@ func (s *CertificationApplicationServiceImpl) checkAndUpdateSignStatus(ctx conte
if err != nil {
return fmt.Errorf("完成认证失败: %s", err.Error())
}
// 同步合同信息到用户域
err = s.handleContractAfterSignComplete(txCtx, cert)
if err != nil {
s.logger.Error("同步合同信息到用户域失败", zap.Error(err))
return fmt.Errorf("同步合同信息失败: %s", err.Error())
}
reason = "合同签署成功"
} else if detail.Data.SignFlowStatus == 7 {
err = cert.ContractRejection(detail.Data.SignFlowDescription)
} else if detail.Rejected {
err = cert.ContractRejection(detail.Message)
if err != nil {
return fmt.Errorf("合同签署失败: %s", err.Error())
}
reason = "合同签署拒签"
} else if detail.Data.SignFlowStatus == 5 {
} else if detail.Expired {
err = cert.ContractExpiration()
if err != nil {
return fmt.Errorf("合同签署过期失败: %s", err.Error())
@@ -1549,40 +1678,42 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct
return fmt.Errorf("用户企业信息不存在")
}
// 1. 获取所有已签署合同文件信息
downloadSignedFileResponse, err := s.esignClient.DownloadSignedFile(cert.EsignFlowID)
provider, err := s.resolveProvider(cert)
if err != nil {
return err
}
files, err := provider.DownloadSignedFiles(ctx, cert.EsignFlowID)
if err != nil {
return fmt.Errorf("下载已签署文件失败: %s", err.Error())
}
files := downloadSignedFileResponse.Data.Files
if len(files) == 0 {
return fmt.Errorf("未获取到已签署合同文件")
}
for _, file := range files {
fileUrl := file.DownloadUrl
fileName := file.FileName
fileId := file.FileId
for i, file := range files {
fileUrl := file.DownloadURL
fileName := fmt.Sprintf("合作协议-%s-%d.pdf", cert.ContractCode, i+1)
fileId := firstNonEmptyStr(file.DownloadID, cert.EsignFlowID)
s.logger.Info("下载已签署文件准备", zap.String("file_url", fileUrl), zap.String("file_name", fileName))
// 2. 下载文件内容
fileBytes, err := s.downloadFileContent(ctx, fileUrl)
if err != nil {
s.logger.Error("下载合同文件内容失败", zap.String("file_name", fileName), zap.Error(err))
continue
}
// 3. 上传到七牛云
uploadResult, err := s.qiniuStorageService.UploadFile(ctx, fileBytes, fileName)
if err != nil {
s.logger.Error("上传合同文件到七牛云失败", zap.String("file_name", fileName), zap.Error(err))
continue
}
qiniuURL := uploadResult.URL
s.logger.Info("合同文件已上传七牛云", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL))
// 4. 保存到合同聚合根(复用认证阶段的合同编号)
if i == 0 {
cert.ContractURL = qiniuURL
}
_, err = s.contractAggregateService.CreateContractWithCode(
ctx,
user.EnterpriseInfo.ID,
@@ -1597,7 +1728,6 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct
s.logger.Error("保存合同信息到聚合根失败", zap.String("file_name", fileName), zap.Error(err))
continue
}
s.logger.Info("合同信息已保存到聚合根", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL))
}
@@ -1648,10 +1778,22 @@ func (s *CertificationApplicationServiceImpl) AddStatusMetadata(ctx context.Cont
metadata["contract_url"] = contracts[0].ContractFileURL
}
}
metadata = s.appendPlatformSelectMetadata(ctx, cert, metadata)
if need, _ := metadata["need_select_platform"].(bool); need {
metadata["next_action"] = "请选择签署平台以继续企业认证"
}
return metadata, nil
}
func firstNonEmptyStr(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证不依赖合同信息
func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error {
// 创建钱包

View File

@@ -102,6 +102,12 @@ type AdminTransitionCertificationStatusCommand struct {
Remark string `json:"remark"`
}
// SelectSignPlatformCommand 用户选择签署平台
type SelectSignPlatformCommand struct {
UserID string `json:"-"`
SignPlatform string `json:"sign_platform" binding:"required"`
}
// SubmitEnterpriseInfoCommand 提交企业信息命令
type SubmitEnterpriseInfoCommand struct {
UserID string `json:"-" comment:"用户唯一标识从JWT token获取不在JSON中暴露"`

View File

@@ -9,11 +9,12 @@ import (
// CertificationResponse 认证响应
type CertificationResponse struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Status enums.CertificationStatus `json:"status"`
StatusName string `json:"status_name"`
Progress int `json:"progress"`
ID string `json:"id"`
UserID string `json:"user_id"`
Status enums.CertificationStatus `json:"status"`
StatusName string `json:"status_name"`
SignPlatform string `json:"sign_platform,omitempty"`
Progress int `json:"progress"`
// 企业信息
EnterpriseInfo *value_objects.EnterpriseInfo `json:"enterprise_info,omitempty"`
@@ -73,12 +74,26 @@ type CertificationListResponse struct {
// ContractSignUrlResponse 合同签署URL响应
type ContractSignUrlResponse struct {
CertificationID string `json:"certification_id"`
ContractSignURL string `json:"contract_sign_url"`
ContractURL string `json:"contract_url,omitempty"`
ExpireAt *time.Time `json:"expire_at,omitempty"`
NextAction string `json:"next_action"`
Message string `json:"message"`
CertificationID string `json:"certification_id"`
// ContractSignURL iframe 可用的签署长链(法大大 EmbedURL约 10 分钟/单次)
ContractSignURL string `json:"contract_sign_url"`
// ContractSignShortURL 短链(不可 iframe一年有效
ContractSignShortURL string `json:"contract_sign_short_url,omitempty"`
ContractURL string `json:"contract_url,omitempty"`
ExpireAt *time.Time `json:"expire_at,omitempty"`
NextAction string `json:"next_action"`
Message string `json:"message"`
}
// ContractPreviewUrlResponse 合同预览链接响应(法大大 get-preview-url
type ContractPreviewUrlResponse struct {
CertificationID string `json:"certification_id"`
// PreviewURL 签署任务预览 EUI 链接(约 2 小时/单次),用 iframe 打开,勿当 PDF 直链
PreviewURL string `json:"preview_url"`
// PreviewMode iframe | pdf
PreviewMode string `json:"preview_mode"`
SignFlowID string `json:"sign_flow_id,omitempty"`
Message string `json:"message,omitempty"`
}
// SystemMonitoringResponse 系统监控响应
@@ -119,7 +134,9 @@ type AdminSubmitRecordItem struct {
LegalPersonName string `json:"legal_person_name"`
SubmitAt time.Time `json:"submit_at"`
Status string `json:"status"`
CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准info_pending_review/info_submitted/info_rejected
ManualReviewStatus string `json:"manual_review_status,omitempty"` // pending/approved/rejected
SignPlatform string `json:"sign_platform,omitempty"` // esign/fadada
CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准info_pending_review/info_submitted/info_rejected 等
}
// AdminSubmitRecordDetail 管理端提交记录详情(含完整信息与图片 URL
@@ -145,6 +162,9 @@ type AdminSubmitRecordDetail struct {
VerifiedAt *time.Time `json:"verified_at,omitempty"`
FailedAt *time.Time `json:"failed_at,omitempty"`
FailureReason string `json:"failure_reason,omitempty"`
ManualReviewStatus string `json:"manual_review_status,omitempty"`
ManualReviewRemark string `json:"manual_review_remark,omitempty"`
SignPlatform string `json:"sign_platform,omitempty"`
CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
@@ -178,17 +198,19 @@ func NewCertificationListResponse(items []*CertificationResponse, total int64, p
}
// NewContractSignUrlResponse 创建合同签署URL响应
func NewContractSignUrlResponse(certificationID, signURL, contractURL, nextAction, message string) *ContractSignUrlResponse {
// signURL 为 iframe 长链shortURL 为短链(可选)
func NewContractSignUrlResponse(certificationID, signURL, shortURL, contractURL, nextAction, message string) *ContractSignUrlResponse {
response := &ContractSignUrlResponse{
CertificationID: certificationID,
ContractSignURL: signURL,
ContractURL: contractURL,
NextAction: nextAction,
Message: message,
CertificationID: certificationID,
ContractSignURL: signURL,
ContractSignShortURL: shortURL,
ContractURL: contractURL,
NextAction: nextAction,
Message: message,
}
// 设置过期时间默认24小时
expireAt := time.Now().Add(24 * time.Hour)
// 法大大嵌入链接约 10 分钟;统一按 10 分钟提示前端刷新
expireAt := time.Now().Add(10 * time.Minute)
response.ExpireAt = &expireAt
return response
@@ -208,6 +230,19 @@ func NewSystemAlert(level, alertType, message, metric string, value, threshold i
}
}
// SignPlatformItem 可选签署平台
type SignPlatformItem struct {
Platform string `json:"platform"`
Name string `json:"name"`
Description string `json:"description"`
Available bool `json:"available"`
}
// SignPlatformsResponse 签署平台列表响应
type SignPlatformsResponse struct {
Platforms []*SignPlatformItem `json:"platforms"`
}
// IsHealthy 检查系统是否健康
func (r *SystemMonitoringResponse) IsHealthy() bool {
return r.SystemHealth.Overall == "healthy"

View File

@@ -0,0 +1,273 @@
package certification
import (
"context"
"fmt"
"strings"
"time"
"hyapi-server/internal/application/certification/dto/commands"
"hyapi-server/internal/application/certification/dto/responses"
"hyapi-server/internal/domains/certification/entities"
"hyapi-server/internal/domains/certification/enums"
"hyapi-server/internal/domains/certification/ports"
user_entities "hyapi-server/internal/domains/user/entities"
"go.uber.org/zap"
)
// ListSignPlatforms 可选签署平台列表
func (s *CertificationApplicationServiceImpl) ListSignPlatforms(ctx context.Context) (*responses.SignPlatformsResponse, error) {
_ = ctx
items := make([]*responses.SignPlatformItem, 0, 2)
for _, p := range s.platformRegistry.List() {
platform := p.Platform()
item := &responses.SignPlatformItem{
Platform: string(platform),
Name: enums.GetSignPlatformName(platform),
Available: true,
Description: "",
}
switch platform {
case enums.SignPlatformEsign:
item.Description = "通过 e签宝 完成企业实名认证与合同签署"
case enums.SignPlatformFadada:
item.Description = "通过法大大完成企业实名认证与合同签署"
}
items = append(items, item)
}
return &responses.SignPlatformsResponse{Platforms: items}, nil
}
// SelectSignPlatform 用户选择签署平台并生成企业认证链接
func (s *CertificationApplicationServiceImpl) SelectSignPlatform(
ctx context.Context,
cmd *commands.SelectSignPlatformCommand,
) (*responses.CertificationResponse, error) {
if cmd == nil || cmd.UserID == "" {
return nil, fmt.Errorf("用户ID不能为空")
}
platform := enums.SignPlatform(strings.TrimSpace(cmd.SignPlatform))
if !enums.IsValidSignPlatform(platform) {
return nil, fmt.Errorf("无效的签署平台: %s", cmd.SignPlatform)
}
cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID)
if err != nil {
return nil, fmt.Errorf("加载认证信息失败: %w", err)
}
record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cmd.UserID)
if err != nil || record == nil {
return nil, fmt.Errorf("未找到企业信息提交记录")
}
if record.ManualReviewStatus != "approved" {
return nil, fmt.Errorf("请先等待管理员审核通过后再选择签署平台")
}
// 已选择且已有认证链接:幂等返回
if cert.SignPlatform == platform && cert.Status == enums.StatusInfoSubmitted && cert.AuthURL != "" {
resp := s.convertToResponse(cert)
meta, _ := s.AddStatusMetadata(ctx, cert)
resp.Metadata = meta
return resp, nil
}
if cert.SignPlatform != "" && cert.SignPlatform != platform {
return nil, fmt.Errorf("签署平台已选择为 %s不可更改", enums.GetSignPlatformName(cert.SignPlatform))
}
if cert.Status != enums.StatusInfoPendingReview && !(cert.Status == enums.StatusInfoSubmitted && cert.AuthURL == "") {
return nil, fmt.Errorf("当前状态不允许选择签署平台: %s", enums.GetStatusName(cert.Status))
}
if err := cert.SetSignPlatform(platform); err != nil {
return nil, err
}
provider, err := s.platformRegistry.Get(platform)
if err != nil {
return nil, err
}
authReq := &ports.EnterpriseAuthRequest{
ClientCorpID: record.UnifiedSocialCode,
ClientUserID: record.LegalPersonID,
CompanyName: record.CompanyName,
UnifiedSocialCode: record.UnifiedSocialCode,
LegalPersonName: record.LegalPersonName,
LegalPersonID: record.LegalPersonID,
TransactorName: record.LegalPersonName,
TransactorMobile: record.LegalPersonPhone,
TransactorID: record.LegalPersonID,
}
authRes, alreadyVerified, err := s.generateEnterpriseAuthOrDetectVerifiedWithProvider(ctx, provider, authReq)
if err != nil {
return nil, fmt.Errorf("生成企业认证链接失败: %w", err)
}
if alreadyVerified {
// 已授权/已实名:跳过授权页,直接推进到 enterprise_verified
err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error {
clientCorpID := strings.TrimSpace(record.UnifiedSocialCode)
if cert.Status == enums.StatusInfoPendingReview {
if err := cert.ApproveEnterpriseInfoReview("", clientCorpID, cmd.UserID); err != nil {
return err
}
} else if cert.Status == enums.StatusInfoSubmitted {
if clientCorpID != "" && cert.AuthFlowID == "" {
cert.AuthFlowID = clientCorpID
}
} else {
return fmt.Errorf("当前状态不允许完成已授权企业认证: %s", enums.GetStatusName(cert.Status))
}
return s.completeEnterpriseVerification(txCtx, cert, cert.UserID, record.CompanyName, record.LegalPersonName)
})
if err != nil {
return nil, err
}
} else {
authURL := authRes.AuthShortURL
if authURL == "" {
authURL = authRes.AuthURL
}
if err := cert.ApproveEnterpriseInfoReview(authURL, authRes.AuthFlowID, cmd.UserID); err != nil {
return nil, err
}
if err := s.aggregateService.SaveCertification(ctx, cert); err != nil {
return nil, fmt.Errorf("保存认证信息失败: %w", err)
}
}
s.logger.Info("用户已选择签署平台",
zap.String("user_id", cmd.UserID),
zap.String("sign_platform", string(platform)),
zap.Bool("already_verified", alreadyVerified))
resp := s.convertToResponse(cert)
meta, _ := s.AddStatusMetadata(ctx, cert)
resp.Metadata = meta
return resp, nil
}
// HandleFadadaCallback 法大大回调
func (s *CertificationApplicationServiceImpl) HandleFadadaCallback(ctx context.Context, headers map[string]string, body []byte) error {
provider, err := s.platformRegistry.Get(enums.SignPlatformFadada)
if err != nil {
return err
}
if err := provider.VerifyCallback(ctx, headers, body); err != nil {
return fmt.Errorf("法大大回调验签失败: %w", err)
}
ev, err := provider.ParseCallback(ctx, headers, body)
if err != nil {
return fmt.Errorf("法大大回调解析失败: %w", err)
}
s.logger.Info("收到法大大回调",
zap.String("event", ev.Event),
zap.Bool("auth_passed", ev.AuthPassed),
zap.Bool("sign_completed", ev.SignCompleted),
zap.String("sign_flow_id", ev.SignFlowID))
// 认证完成主要依赖 ConfirmAuth / details 轮询;签署完成按任务 ID 兜底推进
if ev.SignCompleted && ev.SignFlowID != "" {
cert, err := s.queryRepository.FindByEsignFlowID(ctx, ev.SignFlowID)
if err == nil && cert != nil && cert.Status == enums.StatusContractApplied {
_, _ = s.checkAndUpdateSignStatus(ctx, cert)
}
}
return nil
}
func (s *CertificationApplicationServiceImpl) resolveProvider(cert *entities.Certification) (ports.SignPlatformProvider, error) {
return s.platformRegistry.Get(cert.ResolvedSignPlatform())
}
func (s *CertificationApplicationServiceImpl) generateEnterpriseAuthOrDetectVerifiedWithProvider(
ctx context.Context,
provider ports.SignPlatformProvider,
authReq *ports.EnterpriseAuthRequest,
) (*ports.AuthLinkResult, bool, error) {
authRes, err := provider.GenerateEnterpriseAuth(ctx, authReq)
if err == nil {
return authRes, false, nil
}
// 法大大企业已对本应用授权210002= 授权已完成,直接跳过授权页
if isEnterpriseAlreadyAuthorizedErr(err) {
s.logger.Info("第三方返回企业已授权,跳过认证链接并视为认证完成",
zap.String("company_name", authReq.CompanyName),
zap.String("uscc", authReq.UnifiedSocialCode),
zap.String("platform", string(provider.Platform())),
zap.Error(err))
return nil, true, nil
}
// e签宝等提示已实名时再查一次实名状态确认
if !isEnterpriseAlreadyRealnamedErr(err) && !strings.Contains(strings.ToLower(err.Error()), "identified") {
return nil, false, err
}
ok, qErr := provider.QueryOrgVerified(ctx, &ports.OrgIdentityQuery{
OrgName: authReq.CompanyName,
OrgIdentNo: authReq.UnifiedSocialCode,
})
if qErr != nil {
return nil, false, fmt.Errorf("%v; 且查询实名状态失败: %w", err, qErr)
}
if ok {
s.logger.Info("第三方返回企业已实名,跳过认证链接并视为认证完成",
zap.String("company_name", authReq.CompanyName),
zap.String("platform", string(provider.Platform())))
return nil, true, nil
}
return nil, false, err
}
func (s *CertificationApplicationServiceImpl) appendPlatformSelectMetadata(
ctx context.Context,
cert *entities.Certification,
metadata map[string]interface{},
) map[string]interface{} {
if metadata == nil {
metadata = map[string]interface{}{}
}
record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID)
if err != nil || record == nil {
return metadata
}
metadata["manual_review_status"] = record.ManualReviewStatus
if record.ManualReviewStatus == "approved" &&
cert.Status == enums.StatusInfoPendingReview &&
(cert.SignPlatform == "" || cert.AuthURL == "") {
metadata["need_select_platform"] = true
platforms := make([]map[string]string, 0, 2)
for _, p := range s.platformRegistry.List() {
platforms = append(platforms, map[string]string{
"platform": string(p.Platform()),
"name": enums.GetSignPlatformName(p.Platform()),
})
}
metadata["available_platforms"] = platforms
metadata["next_action"] = "请选择签署平台以继续企业认证"
}
if cert.SignPlatform != "" {
metadata["sign_platform"] = string(cert.SignPlatform)
metadata["sign_platform_name"] = enums.GetSignPlatformName(cert.SignPlatform)
}
return metadata
}
// ensureContractFill 签署模板路径补齐协议编号与日期(协议编号生成规则与 e签宝一致
func ensureContractFill(cert *entities.Certification, companyName, uscc, address, repName string) *ports.ContractGenerateRequest {
if cert.ContractCode == "" {
cert.SetContractCode(user_entities.GenerateContractCode(user_entities.ContractTypeCooperation))
}
signDate := time.Now().Format("2006年01月02日")
return &ports.ContractGenerateRequest{
AgreementNo: cert.ContractCode,
CompanyName: companyName,
UnifiedSocialCode: uscc,
EnterpriseAddress: address,
AuthorizedRepName: repName,
SignDate: signDate,
}
}

View File

@@ -28,6 +28,7 @@ type Config struct {
App AppConfig `mapstructure:"app"`
WechatWork WechatWorkConfig `mapstructure:"wechat_work"`
Esign EsignConfig `mapstructure:"esign"`
Fadada FadadaConfig `mapstructure:"fadada"`
Wallet WalletConfig `mapstructure:"wallet"`
WestDex WestDexConfig `mapstructure:"westdex"`
Zhicha ZhichaConfig `mapstructure:"zhicha"`
@@ -388,6 +389,41 @@ type SignConfig struct {
RedirectURL string `mapstructure:"redirect_url"` // 重定向URL
}
// FadadaConfig 法大大FASC OpenAPI配置
type FadadaConfig struct {
AppID string `mapstructure:"app_id"` // 应用ID
AppSecret string `mapstructure:"app_secret"` // 应用密钥
ServerURL string `mapstructure:"server_url"` // 服务器URL
OpenCorpID string `mapstructure:"open_corp_id"` // 接入方海宇在法大大侧企业ID
NoAuthSceneCode string `mapstructure:"no_auth_scene_code"` // 免验证签场景码(仅 freeSignType=business 时用;当前为 template 可留空)
TemplateID string `mapstructure:"template_id"` // 模板ID
PartyAActorID string `mapstructure:"party_a_actor_id"` // 签署模板甲方参与方标识actorId
PartyBActorID string `mapstructure:"party_b_actor_id"` // 签署模板乙方参与方标识actorId
TemplateDocID string `mapstructure:"template_doc_id"` // 签署模板文档 docId填单用
TemplateFields FadadaTemplateFields `mapstructure:"template_fields"` // 模板控件编码
Contract ContractConfig `mapstructure:"contract"` // 合同配置
Auth AuthConfig `mapstructure:"auth"` // 认证配置
Sign SignConfig `mapstructure:"sign"` // 签署配置
Callback CallbackConfig `mapstructure:"callback"` // 回调配置
}
// FadadaTemplateFields 法大大合作协议模板控件 fieldId
type FadadaTemplateFields struct {
AgreementNo []string `mapstructure:"agreement_no"` // 协议编号(多控件同值)
ContractDate string `mapstructure:"contract_date"` // 签订日期
PartyAName []string `mapstructure:"party_a_name"` // 甲方企业名(多控件同值)
PartyAUSCC string `mapstructure:"party_a_uscc"` // 甲方统一信用代码
PartyAAddress string `mapstructure:"party_a_address"` // 甲方联系地址
PartyARep string `mapstructure:"party_a_rep"` // 甲方授权代表/法人
PartyASignDate string `mapstructure:"party_a_sign_date"` // 甲方签署日期(签署控件,可空)
PartyBSignDate string `mapstructure:"party_b_sign_date"` // 乙方签署日期(签署控件,可空)
}
// CallbackConfig 第三方回调配置
type CallbackConfig struct {
Enabled bool `mapstructure:"enabled"`
}
// WalletConfig 钱包配置
type WalletConfig struct {
DefaultCreditLimit float64 `mapstructure:"default_credit_limit"`
@@ -669,10 +705,10 @@ type TianyuanapiConfig struct {
// TianyuanapiLoggingConfig 天远 API 日志配置
type TianyuanapiLoggingConfig struct {
Enabled bool `mapstructure:"enabled"`
LogDir string `mapstructure:"log_dir"`
UseDaily bool `mapstructure:"use_daily"`
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
Enabled bool `mapstructure:"enabled"`
LogDir string `mapstructure:"log_dir"`
UseDaily bool `mapstructure:"use_daily"`
EnableLevelSeparation bool `mapstructure:"enable_level_separation"`
LevelConfigs map[string]TianyuanapiLevelFileConfig `mapstructure:"level_configs"`
}
@@ -740,28 +776,28 @@ type NuoerLevelFileConfig struct {
// HuiboConfig 汇博配置
type HuiboConfig struct {
URL string `mapstructure:"url"`
AppID string `mapstructure:"app_id"`
AppKey string `mapstructure:"app_key"`
XOrderCode string `mapstructure:"x_order_code"`
SecretID string `mapstructure:"secret_id"`
AESKey string `mapstructure:"aes_key"`
WorkOrderCode string `mapstructure:"work_order_code"`
ProductCode string `mapstructure:"product_code"`
BaseURL2 string `mapstructure:"baseUrl2"`
AppCode2 string `mapstructure:"app_code2"`
AuthPDFStorageDir string `mapstructure:"auth_pdf_storage_dir"`
URL string `mapstructure:"url"`
AppID string `mapstructure:"app_id"`
AppKey string `mapstructure:"app_key"`
XOrderCode string `mapstructure:"x_order_code"`
SecretID string `mapstructure:"secret_id"`
AESKey string `mapstructure:"aes_key"`
WorkOrderCode string `mapstructure:"work_order_code"`
ProductCode string `mapstructure:"product_code"`
BaseURL2 string `mapstructure:"baseUrl2"`
AppCode2 string `mapstructure:"app_code2"`
AuthPDFStorageDir string `mapstructure:"auth_pdf_storage_dir"`
Logging HuiboLoggingConfig `mapstructure:"logging"`
}
// HuiboLoggingConfig 汇博日志配置
type HuiboLoggingConfig 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"`
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]HuiboLevelFileConfig `mapstructure:"level_configs"`
}

View File

@@ -39,11 +39,14 @@ import (
"hyapi-server/internal/infrastructure/external/alicloud"
"hyapi-server/internal/infrastructure/external/captcha"
"hyapi-server/internal/infrastructure/external/email"
infraesign "hyapi-server/internal/infrastructure/external/esign"
infrafadada "hyapi-server/internal/infrastructure/external/fadada"
"hyapi-server/internal/infrastructure/external/huibo"
"hyapi-server/internal/infrastructure/external/jiguang"
"hyapi-server/internal/infrastructure/external/muzi"
"hyapi-server/internal/infrastructure/external/nuoer"
"hyapi-server/internal/infrastructure/external/ocr"
"hyapi-server/internal/infrastructure/external/signplatform"
"hyapi-server/internal/infrastructure/external/shujubao"
"hyapi-server/internal/infrastructure/external/shumai"
"hyapi-server/internal/infrastructure/external/sms"
@@ -64,6 +67,7 @@ import (
component_report "hyapi-server/internal/shared/component_report"
shared_database "hyapi-server/internal/shared/database"
"hyapi-server/internal/shared/esign"
"hyapi-server/internal/shared/fadada"
shared_events "hyapi-server/internal/shared/events"
"hyapi-server/internal/shared/export"
"hyapi-server/internal/shared/health"
@@ -319,6 +323,55 @@ func NewContainer() *Container {
func(esignConfig *esign.Config) *esign.Client {
return esign.NewClient(esignConfig)
},
// 法大大配置
func(cfg *config.Config) (*fadada.Config, error) {
return fadada.NewConfig(
cfg.Fadada.AppID,
cfg.Fadada.AppSecret,
cfg.Fadada.ServerURL,
cfg.Fadada.OpenCorpID,
cfg.Fadada.NoAuthSceneCode,
cfg.Fadada.TemplateID,
cfg.Fadada.PartyAActorID,
cfg.Fadada.PartyBActorID,
cfg.Fadada.TemplateDocID,
&fadada.TemplateFields{
AgreementNo: cfg.Fadada.TemplateFields.AgreementNo,
ContractDate: cfg.Fadada.TemplateFields.ContractDate,
PartyAName: cfg.Fadada.TemplateFields.PartyAName,
PartyAUSCC: cfg.Fadada.TemplateFields.PartyAUSCC,
PartyAAddress: cfg.Fadada.TemplateFields.PartyAAddress,
PartyARep: cfg.Fadada.TemplateFields.PartyARep,
PartyASignDate: cfg.Fadada.TemplateFields.PartyASignDate,
PartyBSignDate: cfg.Fadada.TemplateFields.PartyBSignDate,
},
&fadada.ContractConfig{
Name: cfg.Fadada.Contract.Name,
ExpireDays: cfg.Fadada.Contract.ExpireDays,
RetryCount: cfg.Fadada.Contract.RetryCount,
},
&fadada.AuthConfig{
RedirectURL: cfg.Fadada.Auth.RedirectURL,
},
&fadada.SignConfig{
RedirectURL: cfg.Fadada.Sign.RedirectURL,
},
&fadada.CallbackConfig{
Enabled: cfg.Fadada.Callback.Enabled,
},
)
},
// 法大大服务(企业认证 / 实名查询 / 模板填单)
func(fadadaConfig *fadada.Config) *fadada.Client {
return fadada.NewClient(fadadaConfig)
},
// 签署平台 Registrye签宝 + 法大大)
func(esignClient *esign.Client, fadadaClient *fadada.Client) *signplatform.Registry {
return signplatform.NewRegistry(
infraesign.NewProvider(esignClient),
infrafadada.NewProvider(fadadaClient),
)
},
// 支付宝支付服务
func(cfg *config.Config) *payment.AliPayService {
config := payment.AlipayConfig{
@@ -921,6 +974,7 @@ func NewContainer() *Container {
smsCodeService *user_service.SMSCodeService,
esignClient *esign.Client,
esignConfig *esign.Config,
platformRegistry *signplatform.Registry,
qiniuStorageService *storage.QiNiuStorageService,
contractAggregateService user_service.ContractAggregateService,
walletAggregateService finance_services.WalletAggregateService,
@@ -939,6 +993,7 @@ func NewContainer() *Container {
smsCodeService,
esignClient,
esignConfig,
platformRegistry,
qiniuStorageService,
contractAggregateService,
walletAggregateService,

View File

@@ -3,6 +3,7 @@ package entities
import (
"errors"
"fmt"
"strings"
"time"
"hyapi-server/internal/domains/certification/entities/value_objects"
@@ -28,14 +29,17 @@ type Certification struct {
CompletedAt *time.Time `json:"completed_at,omitempty" comment:"认证完成时间"`
ContractFileCreatedAt *time.Time `json:"contract_file_created_at,omitempty" comment:"合同文件生成时间"`
// === e签宝相关信息 ===
// === 签署平台esign | fadada===
SignPlatform enums.SignPlatform `gorm:"type:varchar(20);index" json:"sign_platform,omitempty" comment:"签署平台: esign|fadada"`
// === 第三方认证/签署信息(字段名历史兼容,语义为平台无关)===
AuthFlowID string `gorm:"type:varchar(500)" json:"auth_flow_id,omitempty" comment:"企业认证流程ID"`
AuthURL string `gorm:"type:varchar(500)" json:"auth_url,omitempty" comment:"企业认证链接"`
AuthURL string `gorm:"type:text" json:"auth_url,omitempty" comment:"企业认证链接"`
ContractCode string `gorm:"type:varchar(100)" json:"contract_code,omitempty" comment:"合同/协议编号"`
ContractFileID string `gorm:"type:varchar(500)" json:"contract_file_id,omitempty" comment:"合同文件ID"`
EsignFlowID string `gorm:"type:varchar(500)" json:"esign_flow_id,omitempty" comment:"签署流程ID"`
ContractURL string `gorm:"type:varchar(500)" json:"contract_url,omitempty" comment:"合同文件访问链接"`
ContractSignURL string `gorm:"type:varchar(500)" json:"contract_sign_url,omitempty" comment:"合同签署链接"`
EsignFlowID string `gorm:"type:varchar(500)" json:"esign_flow_id,omitempty" comment:"签署流程ID(兼容字段名)"`
ContractURL string `gorm:"type:text" json:"contract_url,omitempty" comment:"合同文件访问链接(法大大下载链可能很长)"`
ContractSignURL string `gorm:"type:text" json:"contract_sign_url,omitempty" comment:"合同签署链接(短链/长链)"`
// === 失败信息 ===
FailureReason enums.FailureReason `gorm:"type:varchar(100)" json:"failure_reason,omitempty" comment:"失败原因"`
@@ -238,8 +242,11 @@ func (c *Certification) RejectEnterpriseInfoReview(actorID, message string) erro
return nil
}
// 完成企业认证
// 完成企业认证(幂等:已是 enterprise_verified 则跳过状态流转,便于中途失败后重试)
func (c *Certification) CompleteEnterpriseVerification() error {
if c.Status == enums.StatusEnterpriseVerified {
return nil
}
if c.Status != enums.StatusInfoSubmitted {
return fmt.Errorf("当前状态 %s 不允许完成企业认证", enums.GetStatusName(c.Status))
}
@@ -310,8 +317,12 @@ func (c *Certification) ApplyContract(EsignFlowID string, ContractSignURL string
if err := c.TransitionTo(enums.StatusContractApplied, enums.ActorTypeUser, c.UserID, "用户申请合同签署"); err != nil {
return err
}
c.EsignFlowID = EsignFlowID
c.ContractSignURL = ContractSignURL
if EsignFlowID != "" {
c.EsignFlowID = EsignFlowID
}
if ContractSignURL != "" {
c.ContractSignURL = ContractSignURL
}
now := time.Now()
c.ContractFileCreatedAt = &now
// 添加业务事件
@@ -324,6 +335,18 @@ func (c *Certification) ApplyContract(EsignFlowID string, ContractSignURL string
return nil
}
// BindSignTask 企业认证完成后预创建签署任务(不改状态;乙方可先免签)
func (c *Certification) BindSignTask(esignFlowID, contractSignURL string) {
if esignFlowID != "" {
c.EsignFlowID = esignFlowID
}
if contractSignURL != "" {
c.ContractSignURL = contractSignURL
}
now := time.Now()
c.ContractFileCreatedAt = &now
}
// AddContractFileID 生成合同文件
func (c *Certification) AddContractFileID(contractFileID string, contractURL string) error {
c.ContractFileID = contractFileID
@@ -500,13 +523,58 @@ func (c *Certification) CompleteCertification() error {
return nil
}
// ResolvedSignPlatform 解析签署平台(空则默认 e签宝兼容历史数据
func (c *Certification) ResolvedSignPlatform() enums.SignPlatform {
if enums.IsValidSignPlatform(c.SignPlatform) {
return c.SignPlatform
}
return enums.DefaultSignPlatform()
}
// ResetAfterSignTaskCreateFailed 签署任务未创建却已到 enterprise_verified 时,回退到待选平台
func (c *Certification) ResetAfterSignTaskCreateFailed() error {
if c.Status != enums.StatusEnterpriseVerified {
return fmt.Errorf("当前状态 %s 无需回退", enums.GetStatusName(c.Status))
}
if strings.TrimSpace(c.EsignFlowID) != "" {
return fmt.Errorf("签署任务已存在,不能回退到选平台")
}
c.Status = enums.StatusInfoPendingReview
c.SignPlatform = ""
c.AuthURL = ""
c.AuthFlowID = ""
c.ContractFileID = ""
c.ContractURL = ""
c.ContractSignURL = ""
c.EsignFlowID = ""
c.EnterpriseVerifiedAt = nil
c.UpdatedAt = time.Now()
return nil
}
// SetSignPlatform 设置签署平台(选定后锁定)
func (c *Certification) SetSignPlatform(platform enums.SignPlatform) error {
if !enums.IsValidSignPlatform(platform) {
return fmt.Errorf("无效的签署平台: %s", platform)
}
if c.SignPlatform != "" && c.SignPlatform != platform {
return fmt.Errorf("签署平台已选择为 %s不可更改", enums.GetSignPlatformName(c.SignPlatform))
}
c.SignPlatform = platform
return nil
}
// ================ 查询方法 ================
// GetDataByStatus 根据当前状态获取对应的数据
func (c *Certification) GetDataByStatus() map[string]interface{} {
data := map[string]interface{}{}
if c.SignPlatform != "" {
data["sign_platform"] = string(c.SignPlatform)
data["sign_platform_name"] = enums.GetSignPlatformName(c.SignPlatform)
}
switch c.Status {
case enums.StatusInfoPendingReview:
// 待审核,额外数据
// 待审核/待选平台,额外数据由应用层补充
case enums.StatusInfoSubmitted:
data["auth_url"] = c.AuthURL
case enums.StatusInfoRejected:
@@ -514,11 +582,24 @@ func (c *Certification) GetDataByStatus() map[string]interface{} {
data["failure_message"] = c.FailureMessage
case enums.StatusEnterpriseVerified:
data["ContractURL"] = c.ContractURL
data["contract_url"] = c.ContractURL
if c.EsignFlowID != "" {
data["esign_flow_id"] = c.EsignFlowID
data["sign_task_ready"] = true
}
case enums.StatusContractApplied:
data["contract_sign_url"] = c.ContractSignURL
data["ContractURL"] = c.ContractURL
data["contract_url"] = c.ContractURL
if c.EsignFlowID != "" {
data["esign_flow_id"] = c.EsignFlowID
}
case enums.StatusContractSigned:
case enums.StatusCompleted:
data["completed_at"] = c.CompletedAt
if c.ContractURL != "" {
data["contract_url"] = c.ContractURL
}
case enums.StatusContractRejected:
data["failure_reason"] = c.FailureReason
data["failure_message"] = c.FailureMessage

View File

@@ -15,7 +15,9 @@ type ContractInfo struct {
ContractFileID string `json:"contract_file_id"` // 合同文件ID
EsignFlowID string `json:"esign_flow_id"` // e签宝签署流程ID
ContractURL string `json:"contract_url"` // 合同文件访问链接
ContractSignURL string `json:"contract_sign_url"` // 合同签署链接
ContractSignURL string `json:"contract_sign_url"` // 合同签署链接iframe 长链,可能短期有效)
// ContractSignShortURL 短链(法大大不可 iframe可选
ContractSignShortURL string `json:"contract_sign_short_url,omitempty"`
// 合同元数据
ContractTitle string `json:"contract_title"` // 合同标题

View File

@@ -0,0 +1,36 @@
package enums
// SignPlatform 签署/认证平台
type SignPlatform string
const (
SignPlatformEsign SignPlatform = "esign"
SignPlatformFadada SignPlatform = "fadada"
)
// IsValidSignPlatform 是否为合法平台
func IsValidSignPlatform(p SignPlatform) bool {
switch p {
case SignPlatformEsign, SignPlatformFadada:
return true
default:
return false
}
}
// GetSignPlatformName 平台中文名
func GetSignPlatformName(p SignPlatform) string {
switch p {
case SignPlatformEsign:
return "e签宝"
case SignPlatformFadada:
return "法大大"
default:
return string(p)
}
}
// DefaultSignPlatform 历史数据默认平台
func DefaultSignPlatform() SignPlatform {
return SignPlatformEsign
}

View File

@@ -0,0 +1,152 @@
package ports
import (
"context"
"hyapi-server/internal/domains/certification/enums"
)
// EnterpriseAuthRequest 企业认证链接请求
type EnterpriseAuthRequest struct {
ClientCorpID string
ClientUserID string
CompanyName string
UnifiedSocialCode string
LegalPersonName string
LegalPersonID string
TransactorName string
TransactorMobile string
TransactorID string
}
// AuthLinkResult 企业认证链接结果
type AuthLinkResult struct {
AuthFlowID string
AuthURL string
AuthShortURL string
}
// OrgIdentityQuery 企业实名查询
type OrgIdentityQuery struct {
OrgName string
OrgIdentNo string
OpenCorpID string
ClientCorpID string
}
// ContractGenerateRequest 合同模板填单
type ContractGenerateRequest struct {
AgreementNo string
CompanyName string
UnifiedSocialCode string
EnterpriseAddress string
AuthorizedRepName string
SignDate string
FileName string
}
// ContractFileResult 合同文件结果
type ContractFileResult struct {
FileID string
FileDownloadURL string
}
// SignFlowCreateRequest 创建签署流程
type SignFlowCreateRequest struct {
Subject string
FileID string
DocName string
PartyAOpenCorpID string
PartyAName string
PartyAUSCC string
TransactorName string
TransactorMobile string
TransactorID string
TransReferenceID string
Fill *ContractGenerateRequest
// SkipActorURL 为企业认证后预创建任务时跳过取签署链接(避免消耗 10 分钟单次 EmbedURL
SkipActorURL bool
}
// SignFlowResult 签署流程创建结果
type SignFlowResult struct {
SignFlowID string
// SignURL 可供 iframe 嵌入的签署长链(法大大 actorSignTaskEmbedUrl / e签宝 url
SignURL string
// ShortURL 短链(法大大 actorSignTaskUrl不可 iframe
ShortURL string
}
// GetActorSignURLRequest 重新获取参与方签署链接
type GetActorSignURLRequest struct {
SignFlowID string
TransactorMobile string
PartyAName string
}
// ActorSignURLResult 参与方签署链接(长链可 iframe短链不可
type ActorSignURLResult struct {
SignFlowID string
EmbedURL string // iframe 用
ShortURL string // 短链,一年有效,需登录
}
// SignTaskPreviewURLResult 签署任务预览链接
type SignTaskPreviewURLResult struct {
SignFlowID string
PreviewURL string
}
// SignStatusResult 签署状态
type SignStatusResult struct {
SignFlowID string
Status string
Completed bool
Rejected bool
Expired bool
Message string
}
// SignedFile 已签文件
type SignedFile struct {
DownloadURL string
DownloadID string
}
// CallbackEvent 回调事件
type CallbackEvent struct {
Event string
AuthPassed bool
SignCompleted bool
SignRejected bool
SignFlowID string
AuthFlowID string
OrgName string
Raw map[string]interface{}
}
// SignPlatformProvider 签署平台统一能力e签宝 / 法大大)
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)
// GetActorSignURL 按已有签署任务重新获取参与方链接(法大大长链 10 分钟/单次,进入签署页时应刷新)
GetActorSignURL(ctx context.Context, req *GetActorSignURLRequest) (*ActorSignURLResult, error)
// GetSignTaskPreviewURL 获取签署任务预览链接(法大大 EUI约 2 小时/单次;勿用 PDF 直链做预览)
GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*SignTaskPreviewURLResult, error)
QuerySignStatus(ctx context.Context, flowID string) (*SignStatusResult, error)
DownloadSignedFiles(ctx context.Context, flowID string) ([]*SignedFile, error)
VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error
ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*CallbackEvent, error)
}
// SignPlatformRegistry 按平台路由 Provider
type SignPlatformRegistry interface {
Get(platform enums.SignPlatform) (SignPlatformProvider, error)
List() []SignPlatformProvider
}

View File

@@ -0,0 +1,204 @@
package esign
import (
"context"
"encoding/json"
"fmt"
"strings"
"hyapi-server/internal/domains/certification/enums"
"hyapi-server/internal/domains/certification/ports"
sharedesign "hyapi-server/internal/shared/esign"
)
type Provider struct {
client *sharedesign.Client
}
func NewProvider(client *sharedesign.Client) *Provider {
return &Provider{client: client}
}
func (p *Provider) Platform() enums.SignPlatform {
return enums.SignPlatformEsign
}
func (p *Provider) GenerateEnterpriseAuth(ctx context.Context, req *ports.EnterpriseAuthRequest) (*ports.AuthLinkResult, error) {
_ = ctx
res, err := p.client.GenerateEnterpriseAuth(&sharedesign.EnterpriseAuthRequest{
CompanyName: req.CompanyName,
UnifiedSocialCode: req.UnifiedSocialCode,
LegalPersonName: req.LegalPersonName,
LegalPersonID: req.LegalPersonID,
TransactorName: req.TransactorName,
TransactorMobile: req.TransactorMobile,
TransactorID: req.TransactorID,
})
if err != nil {
return nil, err
}
return &ports.AuthLinkResult{
AuthFlowID: res.AuthFlowID,
AuthURL: res.AuthURL,
AuthShortURL: firstNonEmpty(res.AuthShortURL, res.AuthURL),
}, nil
}
func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQuery) (bool, error) {
_ = ctx
identity, err := p.client.QueryOrgIdentityInfo(&sharedesign.QueryOrgIdentityRequest{
OrgName: req.OrgName,
OrgIDCardNum: req.OrgIdentNo,
})
if err != nil {
return false, err
}
return identity != nil && identity.Data.RealnameStatus == 1, nil
}
func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) {
_ = ctx
components := map[string]string{
"jfqym": req.CompanyName,
"jfqym2": req.CompanyName,
"jfsqdb": req.AuthorizedRepName,
"jftyshxydm": req.UnifiedSocialCode,
"jflxdz": req.EnterpriseAddress,
"xybh": req.AgreementNo,
"qsrq1": req.SignDate,
"qsrq3": req.SignDate,
"qsrq2": req.SignDate,
}
res, err := p.client.FillTemplate(components)
if err != nil {
return nil, err
}
return &ports.ContractFileResult{
FileID: res.FileID,
FileDownloadURL: res.FileDownloadUrl,
}, nil
}
func (p *Provider) CreateSignFlow(ctx context.Context, req *ports.SignFlowCreateRequest) (*ports.SignFlowResult, error) {
_ = ctx
flowID, err := p.client.CreateSignFlow(&sharedesign.CreateSignFlowRequest{
FileID: req.FileID,
SignerAccount: req.PartyAUSCC,
SignerName: req.PartyAName,
TransactorPhone: req.TransactorMobile,
TransactorName: req.TransactorName,
TransactorIDCardNum: req.TransactorID,
})
if err != nil {
return nil, err
}
if req.SkipActorURL {
return &ports.SignFlowResult{SignFlowID: flowID}, nil
}
longURL, shortURL, err := p.client.GetSignURL(flowID, req.TransactorMobile, req.PartyAName)
if err != nil {
return nil, err
}
return &ports.SignFlowResult{
SignFlowID: flowID,
SignURL: firstNonEmpty(longURL, shortURL),
ShortURL: shortURL,
}, nil
}
func (p *Provider) GetActorSignURL(ctx context.Context, req *ports.GetActorSignURLRequest) (*ports.ActorSignURLResult, error) {
_ = ctx
if req == nil || strings.TrimSpace(req.SignFlowID) == "" {
return nil, fmt.Errorf("signFlowId 不能为空")
}
longURL, shortURL, err := p.client.GetSignURL(req.SignFlowID, req.TransactorMobile, req.PartyAName)
if err != nil {
return nil, err
}
return &ports.ActorSignURLResult{
SignFlowID: req.SignFlowID,
EmbedURL: firstNonEmpty(longURL, shortURL),
ShortURL: shortURL,
}, nil
}
func (p *Provider) GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*ports.SignTaskPreviewURLResult, error) {
_ = ctx
// e签宝无独立预览接口用已签/合同文件下载链作为预览源(前端用 PDF 打开)
files, err := p.DownloadSignedFiles(ctx, signFlowID)
if err != nil {
return nil, err
}
if len(files) == 0 || strings.TrimSpace(files[0].DownloadURL) == "" {
return nil, fmt.Errorf("未获取到可预览的合同文件")
}
return &ports.SignTaskPreviewURLResult{
SignFlowID: signFlowID,
PreviewURL: files[0].DownloadURL,
}, nil
}
func (p *Provider) QuerySignStatus(ctx context.Context, flowID string) (*ports.SignStatusResult, error) {
_ = ctx
detail, err := p.client.QuerySignFlowDetail(flowID)
if err != nil {
return nil, err
}
status := detail.Data.SignFlowStatus
return &ports.SignStatusResult{
SignFlowID: flowID,
Status: fmt.Sprintf("%d", status),
Completed: status == 2,
Rejected: status == 7,
Expired: status == 5,
Message: detail.Data.SignFlowDescription,
}, nil
}
func (p *Provider) DownloadSignedFiles(ctx context.Context, flowID string) ([]*ports.SignedFile, error) {
_ = ctx
res, err := p.client.DownloadSignedFile(flowID)
if err != nil {
return nil, err
}
files := make([]*ports.SignedFile, 0, len(res.Data.Files))
for _, f := range res.Data.Files {
files = append(files, &ports.SignedFile{DownloadURL: f.DownloadUrl})
}
return files, nil
}
func (p *Provider) VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error {
_ = ctx
_ = headers
_ = body
// e签宝现有回调走独立验签逻辑此处保持兼容由原 HandleEsignCallback 处理)
return nil
}
func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*ports.CallbackEvent, error) {
_ = ctx
_ = headers
var raw map[string]interface{}
_ = json.Unmarshal(body, &raw)
action, _ := raw["action"].(string)
ev := &ports.CallbackEvent{Event: action, Raw: raw}
if action == "AUTH_PASS" {
ev.AuthPassed = true
}
if action == "SIGN_FLOW_COMPLETE" || action == "SIGN_MISSON_COMPLETE" {
ev.SignCompleted = true
}
return ev, nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
var _ ports.SignPlatformProvider = (*Provider)(nil)

View File

@@ -0,0 +1,247 @@
package fadada
import (
"context"
"fmt"
"net/url"
"strings"
"hyapi-server/internal/domains/certification/enums"
"hyapi-server/internal/domains/certification/ports"
sharedfadada "hyapi-server/internal/shared/fadada"
)
type Provider struct {
client *sharedfadada.Client
}
func NewProvider(client *sharedfadada.Client) *Provider {
return &Provider{client: client}
}
func (p *Provider) Platform() enums.SignPlatform {
return enums.SignPlatformFadada
}
func (p *Provider) GenerateEnterpriseAuth(ctx context.Context, req *ports.EnterpriseAuthRequest) (*ports.AuthLinkResult, error) {
_ = ctx
res, err := p.client.GenerateEnterpriseAuth(&sharedfadada.EnterpriseAuthRequest{
ClientCorpID: req.ClientCorpID,
ClientUserID: req.ClientUserID,
CompanyName: req.CompanyName,
UnifiedSocialCode: req.UnifiedSocialCode,
LegalPersonName: req.LegalPersonName,
LegalPersonID: req.LegalPersonID,
TransactorName: req.TransactorName,
TransactorMobile: req.TransactorMobile,
TransactorID: req.TransactorID,
})
if err != nil {
return nil, err
}
return &ports.AuthLinkResult{
AuthFlowID: res.AuthFlowID,
AuthURL: res.AuthURL,
AuthShortURL: firstNonEmpty(res.AuthShortURL, res.AuthURL),
}, nil
}
func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQuery) (bool, error) {
_ = ctx
return p.client.QueryOrgVerified(&sharedfadada.QueryOrgIdentityRequest{
CorpName: req.OrgName,
CorpIdentNo: req.OrgIdentNo,
})
}
// GenerateContractFile 法大大走签署任务模板(/sign-task/create-with-template
// 此处返回空结果,实际填单由 CreateSignFlow 通过 /sign-task/field/fill-values 完成。
func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) {
_ = ctx
_ = req
return &ports.ContractFileResult{FileID: "", FileDownloadURL: ""}, nil
}
func (p *Provider) CreateSignFlow(ctx context.Context, req *ports.SignFlowCreateRequest) (*ports.SignFlowResult, error) {
_ = ctx
createReq := &sharedfadada.CreateSignFlowRequest{
Subject: req.Subject,
PartyAOpenCorpID: req.PartyAOpenCorpID,
PartyAName: req.PartyAName,
PartyAUSCC: req.PartyAUSCC,
TransactorName: req.TransactorName,
TransactorMobile: req.TransactorMobile,
TransactorID: req.TransactorID,
TransReferenceID: req.TransReferenceID,
}
if req.Fill != nil {
createReq.Fill = &sharedfadada.ContractFillRequest{
AgreementNo: req.Fill.AgreementNo,
CompanyName: req.Fill.CompanyName,
UnifiedSocialCode: req.Fill.UnifiedSocialCode,
EnterpriseAddress: req.Fill.EnterpriseAddress,
AuthorizedRepName: req.Fill.AuthorizedRepName,
SignDate: req.Fill.SignDate,
FileName: req.Fill.FileName,
}
} else {
// 签署模板创建必须填控件
createReq.Fill = &sharedfadada.ContractFillRequest{
CompanyName: req.PartyAName,
UnifiedSocialCode: req.PartyAUSCC,
AuthorizedRepName: req.TransactorName,
}
}
createRes, err := p.client.CreateSignFlow(createReq)
if err != nil {
return nil, err
}
if req.SkipActorURL {
return &ports.SignFlowResult{
SignFlowID: createRes.SignTaskID,
}, nil
}
urlRes, err := p.client.GetSignURL(createRes.SignTaskID, sharedfadada.ActorIDPartyA)
if err != nil {
return nil, err
}
embedURL := strings.TrimSpace(urlRes.EmbedURL)
if embedURL == "" {
return nil, fmt.Errorf("法大大未返回可嵌入签署链接 actorSignTaskEmbedUrl")
}
return &ports.SignFlowResult{
SignFlowID: createRes.SignTaskID,
SignURL: embedURL,
ShortURL: strings.TrimSpace(urlRes.SignURL),
}, nil
}
func (p *Provider) GetActorSignURL(ctx context.Context, req *ports.GetActorSignURLRequest) (*ports.ActorSignURLResult, error) {
_ = ctx
if req == nil || strings.TrimSpace(req.SignFlowID) == "" {
return nil, fmt.Errorf("signFlowId 不能为空")
}
urlRes, err := p.client.GetSignURL(strings.TrimSpace(req.SignFlowID), sharedfadada.ActorIDPartyA)
if err != nil {
return nil, err
}
embedURL := strings.TrimSpace(urlRes.EmbedURL)
if embedURL == "" {
return nil, fmt.Errorf("法大大未返回可嵌入签署链接 actorSignTaskEmbedUrl")
}
return &ports.ActorSignURLResult{
SignFlowID: req.SignFlowID,
EmbedURL: embedURL,
ShortURL: strings.TrimSpace(urlRes.SignURL),
}, nil
}
func (p *Provider) GetSignTaskPreviewURL(ctx context.Context, signFlowID string) (*ports.SignTaskPreviewURLResult, error) {
_ = ctx
signFlowID = strings.TrimSpace(signFlowID)
if signFlowID == "" {
return nil, fmt.Errorf("signFlowId 不能为空")
}
res, err := p.client.GetSignTaskPreviewURL(signFlowID)
if err != nil {
return nil, err
}
return &ports.SignTaskPreviewURLResult{
SignFlowID: signFlowID,
PreviewURL: res.PreviewURL,
}, nil
}
func (p *Provider) QuerySignStatus(ctx context.Context, flowID string) (*ports.SignStatusResult, error) {
_ = ctx
st, err := p.client.QuerySignStatus(flowID)
if err != nil {
return nil, err
}
return &ports.SignStatusResult{
SignFlowID: flowID,
Status: st.SignTaskStatus,
Completed: st.Completed,
Rejected: st.Terminated,
Expired: false,
Message: firstNonEmpty(st.TerminationNote, st.RevokeReason),
}, nil
}
func (p *Provider) DownloadSignedFiles(ctx context.Context, flowID string) ([]*ports.SignedFile, error) {
_ = ctx
res, err := p.client.DownloadSignedFiles(flowID)
if err != nil {
return nil, err
}
files := make([]*ports.SignedFile, 0, len(res.Files))
for _, f := range res.Files {
files = append(files, &ports.SignedFile{
DownloadURL: f.DownloadURL,
DownloadID: f.DownloadID,
})
}
return files, nil
}
func (p *Provider) VerifyCallback(ctx context.Context, headers map[string]string, body []byte) error {
_ = ctx
bizContent := extractBizContent(body)
return p.client.VerifyCallback(headers, bizContent)
}
func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, body []byte) (*ports.CallbackEvent, error) {
_ = ctx
bizContent := extractBizContent(body)
ev, err := p.client.ParseCallback(headers, bizContent)
if err != nil {
return nil, err
}
out := &ports.CallbackEvent{
Event: ev.Event,
SignFlowID: ev.SignTaskID,
AuthFlowID: firstNonEmpty(ev.ClientCorpID, ev.OpenCorpID),
Raw: ev.Raw,
}
if ev.IsSignCompletedCallback() {
out.SignCompleted = true
}
// 企业授权/认证成功事件
switch strings.ToLower(ev.Event) {
case "corp-authorize", "corp-authorize-success", "corporation_authorize", "auth-pass":
out.AuthPassed = true
}
if strings.EqualFold(ev.AuthResult, "success") || strings.EqualFold(ev.AuthResult, "pass") {
out.AuthPassed = true
}
return out, nil
}
func extractBizContent(body []byte) string {
raw := strings.TrimSpace(string(body))
if raw == "" {
return ""
}
if strings.HasPrefix(raw, "{") {
return raw
}
values, err := url.ParseQuery(raw)
if err == nil {
if v := values.Get("bizContent"); v != "" {
return v
}
}
return raw
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
var _ ports.SignPlatformProvider = (*Provider)(nil)

View File

@@ -0,0 +1,52 @@
package signplatform
import (
"fmt"
"hyapi-server/internal/domains/certification/enums"
"hyapi-server/internal/domains/certification/ports"
)
type Registry struct {
providers map[enums.SignPlatform]ports.SignPlatformProvider
order []enums.SignPlatform
}
func NewRegistry(providers ...ports.SignPlatformProvider) *Registry {
r := &Registry{
providers: make(map[enums.SignPlatform]ports.SignPlatformProvider, len(providers)),
order: make([]enums.SignPlatform, 0, len(providers)),
}
for _, p := range providers {
if p == nil {
continue
}
platform := p.Platform()
r.providers[platform] = p
r.order = append(r.order, platform)
}
return r
}
func (r *Registry) Get(platform enums.SignPlatform) (ports.SignPlatformProvider, error) {
if platform == "" {
platform = enums.DefaultSignPlatform()
}
p, ok := r.providers[platform]
if !ok {
return nil, fmt.Errorf("不支持的签署平台: %s", platform)
}
return p, nil
}
func (r *Registry) List() []ports.SignPlatformProvider {
out := make([]ports.SignPlatformProvider, 0, len(r.order))
for _, platform := range r.order {
if p, ok := r.providers[platform]; ok {
out = append(out, p)
}
}
return out
}
var _ ports.SignPlatformRegistry = (*Registry)(nil)

View File

@@ -305,11 +305,10 @@ func (s *QiNiuStorageService) DownloadFile(ctx context.Context, fileURL string)
isTimeout = true
} else if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
isTimeout = true
} else if errStr := err.Error();
errStr == "context deadline exceeded" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {
} else if errStr := err.Error(); errStr == "context deadline exceeded" ||
errStr == "timeout" ||
errStr == "Client.Timeout exceeded" ||
errStr == "net/http: request canceled" {
isTimeout = true
}

View File

@@ -154,6 +154,59 @@ func (h *CertificationHandler) ConfirmAuth(c *gin.Context) {
h.response.Success(c, result, "状态确认成功")
}
// ListSignPlatforms 可选签署平台列表
func (h *CertificationHandler) ListSignPlatforms(c *gin.Context) {
result, err := h.appService.ListSignPlatforms(c.Request.Context())
if err != nil {
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, result, "获取签署平台列表成功")
}
// SelectSignPlatform 选择签署平台并生成企业认证链接
func (h *CertificationHandler) SelectSignPlatform(c *gin.Context) {
var cmd commands.SelectSignPlatformCommand
cmd.UserID = h.getCurrentUserID(c)
if cmd.UserID == "" {
h.response.Unauthorized(c, "用户未登录")
return
}
if err := c.ShouldBindJSON(&cmd); err != nil {
h.response.BadRequest(c, "请求参数错误")
return
}
result, err := h.appService.SelectSignPlatform(c.Request.Context(), &cmd)
if err != nil {
h.logger.Error("选择签署平台失败", zap.Error(err), zap.String("user_id", cmd.UserID))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, result, "已选择签署平台")
}
// HandleFadadaCallback 法大大回调
func (h *CertificationHandler) HandleFadadaCallback(c *gin.Context) {
headers := make(map[string]string)
for key, values := range c.Request.Header {
if len(values) > 0 {
headers[key] = values[0]
}
}
body, err := io.ReadAll(c.Request.Body)
if err != nil {
h.logger.Error("读取法大大回调失败", zap.Error(err))
c.String(400, "fail")
return
}
if err := h.appService.HandleFadadaCallback(c.Request.Context(), headers, body); err != nil {
h.logger.Error("处理法大大回调失败", zap.Error(err))
c.String(400, "fail")
return
}
c.Data(200, "application/json", []byte(`{"msg":"success"}`))
}
// ConfirmSign 前端确认是否完成签署
// @Summary 前端确认签署状态
// @Description 前端轮询确认合同签署是否完成
@@ -219,6 +272,61 @@ func (h *CertificationHandler) ApplyContract(c *gin.Context) {
h.response.Success(c, result, "合同申请成功")
}
// RefreshContractSignURL 刷新合同签署 iframe 长链
// @Summary 刷新合同签署链接
// @Description 重新获取可供 iframe 嵌入的签署长链(法大大约 10 分钟/单次有效)
// @Tags 认证管理
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} responses.ContractSignUrlResponse "刷新成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Router /api/v1/certifications/refresh-contract-sign-url [post]
func (h *CertificationHandler) RefreshContractSignURL(c *gin.Context) {
userID := h.getCurrentUserID(c)
if userID == "" {
h.response.Unauthorized(c, "用户未登录")
return
}
result, err := h.appService.RefreshContractSignURL(c.Request.Context(), userID)
if err != nil {
h.logger.Error("刷新签署链接失败", zap.Error(err), zap.String("user_id", userID))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, result, "签署链接已刷新")
}
// RefreshContractPreviewURL 刷新合同预览链接
// @Summary 刷新合同预览链接
// @Description 获取签署任务预览链接(法大大 /sign-task/get-preview-url
// @Tags 认证管理
// @Accept json
// @Produce json
// @Security Bearer
// @Success 200 {object} responses.ContractPreviewUrlResponse "刷新成功"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Router /api/v1/certifications/refresh-contract-preview-url [post]
func (h *CertificationHandler) RefreshContractPreviewURL(c *gin.Context) {
userID := h.getCurrentUserID(c)
if userID == "" {
h.response.Unauthorized(c, "用户未登录")
return
}
result, err := h.appService.RefreshContractPreviewURL(c.Request.Context(), userID)
if err != nil {
h.logger.Error("刷新合同预览链接失败", zap.Error(err), zap.String("user_id", userID))
h.response.BadRequest(c, err.Error())
return
}
h.response.Success(c, result, "预览链接已刷新")
}
// RecognizeBusinessLicense OCR识别营业执照
// @Summary OCR识别营业执照
// @Description 上传营业执照图片进行OCR识别自动填充企业信息

View File

@@ -65,10 +65,17 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
// 3. 申请合同签署
authGroup.POST("/apply-contract", r.handler.ApplyContract)
// 刷新签署 iframe 长链(法大大 EmbedURL 短期有效)
authGroup.POST("/refresh-contract-sign-url", r.handler.RefreshContractSignURL)
authGroup.POST("/refresh-contract-preview-url", r.handler.RefreshContractPreviewURL)
// 前端确认是否完成认证
authGroup.POST("/confirm-auth", r.handler.ConfirmAuth)
// 签署平台选择(审核通过后、企业认证前)
authGroup.GET("/sign-platforms", r.handler.ListSignPlatforms)
authGroup.POST("/select-sign-platform", r.handler.SelectSignPlatform)
// 前端确认是否完成签署
authGroup.POST("/confirm-sign", r.handler.ConfirmSign)
@@ -95,7 +102,8 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) {
// 回调路由(不需要认证,但需要验证签名)
callbackGroup := certificationGroup.Group("/callbacks")
{
callbackGroup.POST("/esign", r.handler.HandleEsignCallback) // e签宝回调(统一处理企业认证和合同签署回调)
callbackGroup.POST("/esign", r.handler.HandleEsignCallback) // e签宝回调
callbackGroup.POST("/fadada", r.handler.HandleFadadaCallback) // 法大大回调
}
}

View File

@@ -0,0 +1,188 @@
package fadada
// ---- 通用 ----
type openID struct {
IDType string `json:"idType"`
OpenID string `json:"openId"`
}
// ---- accessToken ----
type accessTokenData struct {
AccessToken string `json:"accessToken"`
ExpiresIn string `json:"expiresIn"`
}
// ---- /corp/get-auth-url ----
type getCorpAuthURLReq struct {
ClientCorpID string `json:"clientCorpId"`
ClientUserID string `json:"clientUserId,omitempty"`
AccountName string `json:"accountName,omitempty"`
CorpIdentInfo *corpIdentInfo `json:"corpIdentInfo,omitempty"`
CorpNonEditableInfo []string `json:"corpNonEditableInfo,omitempty"`
OprIdentInfo *oprIdentInfo `json:"oprIdentInfo,omitempty"`
OprNonEditableInfo []string `json:"oprNonEditableInfo,omitempty"`
CorpIdentInfoMatch bool `json:"corpIdentInfoMatch"`
AuthScopes []string `json:"authScopes,omitempty"`
RedirectURL string `json:"redirectUrl,omitempty"`
}
type corpIdentInfo struct {
CorpName string `json:"corpName,omitempty"`
CorpIdentType string `json:"corpIdentType,omitempty"`
CorpIdentNo string `json:"corpIdentNo,omitempty"`
LegalRepName string `json:"legalRepName,omitempty"`
CorpIdentMethod []string `json:"corpIdentMethod,omitempty"`
}
type oprIdentInfo struct {
UserName string `json:"userName,omitempty"`
UserIdentType string `json:"userIdentType,omitempty"`
UserIdentNo string `json:"userIdentNo,omitempty"`
Mobile string `json:"mobile,omitempty"`
OprIdentMethod []string `json:"oprIdentMethod,omitempty"`
}
type getCorpAuthURLData struct {
AuthURL string `json:"authUrl"`
}
// ---- /corp/get-identified-status ----
type getIdentifiedStatusReq struct {
CorpName string `json:"corpName,omitempty"`
CorpIdentNo string `json:"corpIdentNo,omitempty"`
}
type getIdentifiedStatusData struct {
IdentStatus bool `json:"identStatus"`
}
// ---- 模板 / 签署 ----
type docFieldValue struct {
// DocID 签署任务中的文档标识fill-values 接口必填(缺则报 100012 docId 不能为空)
DocID string `json:"docId,omitempty"`
// FieldDocID 部分文档/示例使用 fieldDocId与 DocID 同值一并传以保证兼容
FieldDocID string `json:"fieldDocId,omitempty"`
FieldID string `json:"fieldId"`
FieldName string `json:"fieldName,omitempty"`
FieldValue string `json:"fieldValue"`
}
type fillDocTemplateReq struct {
OwnerID *openID `json:"ownerId"`
DocTemplateID string `json:"docTemplateId"`
FileName string `json:"fileName,omitempty"`
DocFieldValues []docFieldValue `json:"docFieldValues"`
}
type fillDocTemplateData struct {
FileID string `json:"fileId"`
FileDownloadURL string `json:"fileDownloadUrl"`
}
type fillSignTaskFieldsReq struct {
SignTaskID string `json:"signTaskId"`
DocFieldValues []docFieldValue `json:"docFieldValues"`
}
type signTaskActor struct {
Actor *actor `json:"actor"`
SignConfigInfo *signConfigInfo `json:"signConfigInfo,omitempty"`
}
type actor struct {
ActorType string `json:"actorType"`
ActorID string `json:"actorId"`
ActorName string `json:"actorName,omitempty"`
Permissions []string `json:"permissions,omitempty"`
ActorOpenID string `json:"actorOpenId,omitempty"`
IdentNameForMatch string `json:"identNameForMatch,omitempty"`
CertNoForMatch string `json:"certNoForMatch,omitempty"`
AccountName string `json:"accountName,omitempty"`
ClientUserID string `json:"clientUserId,omitempty"`
}
type signConfigInfo struct {
OrderNo int `json:"orderNo"`
JoinByLink bool `json:"joinByLink"`
RequestVerifyFree bool `json:"requestVerifyFree"`
FreeSignType string `json:"freeSignType,omitempty"` // template=按模板免验证签business=按场景码(需 businessId
FreeLogin bool `json:"freeLogin"`
BlockHere bool `json:"blockHere"`
}
type createSignTaskWithTemplateReq struct {
SignTaskSubject string `json:"signTaskSubject"`
Initiator *openID `json:"initiator"`
SignTemplateID string `json:"signTemplateId"`
AutoStart bool `json:"autoStart"`
AutoFillFinalize bool `json:"autoFillFinalize"`
AutoFinish bool `json:"autoFinish"`
SignInOrder bool `json:"signInOrder,omitempty"`
ExpiresTime string `json:"expiresTime,omitempty"`
FreeSignType string `json:"freeSignType,omitempty"` // template按模板business按场景码
BusinessID string `json:"businessId,omitempty"` // 仅 freeSignType=business 时传场景码
TransReferenceID string `json:"transReferenceId,omitempty"`
Actors []signTaskActor `json:"actors"`
}
type createSignTaskData struct {
SignTaskID string `json:"signTaskId"`
}
type startSignTaskReq struct {
SignTaskID string `json:"signTaskId"`
}
type getActorURLReq struct {
SignTaskID string `json:"signTaskId"`
ActorID string `json:"actorId"`
RedirectURL string `json:"redirectUrl,omitempty"`
}
type getActorURLData struct {
ActorSignTaskURL string `json:"actorSignTaskUrl"`
ActorSignTaskEmbedURL string `json:"actorSignTaskEmbedUrl"`
}
type getSignTaskPreviewURLReq struct {
SignTaskID string `json:"signTaskId"`
RedirectURL string `json:"redirectUrl,omitempty"`
}
type getSignTaskPreviewURLData struct {
SignTaskPreviewURL string `json:"signTaskPreviewUrl"`
}
type getSignTaskDetailReq struct {
SignTaskID string `json:"signTaskId"`
}
type getSignTaskDetailData struct {
SignTaskID string `json:"signTaskId"`
SignTaskStatus string `json:"signTaskStatus"`
FinishTime string `json:"finishTime,omitempty"`
TerminationNote string `json:"terminationNote,omitempty"`
RevokeReason string `json:"revokeReason,omitempty"`
Docs []signTaskDocBrief `json:"docs,omitempty"`
}
type signTaskDocBrief struct {
DocID string `json:"docId"`
DocName string `json:"docName,omitempty"`
}
type getOwnerDownloadURLReq struct {
OwnerID *openID `json:"ownerId"`
SignTaskID string `json:"signTaskId"`
FileType string `json:"fileType,omitempty"`
}
type getOwnerDownloadURLData struct {
DownloadURL string `json:"downloadUrl"`
DownloadID string `json:"downloadId,omitempty"`
}

View File

@@ -0,0 +1,184 @@
package fadada
import (
"encoding/json"
"fmt"
"net/http"
"strings"
)
// 常见回调事件名(以官方实际推送为准,解析时兼容多种字段)
const (
CallbackEventSignTaskSigned = "sign-task-signed"
CallbackEventSignTaskFinished = "sign-task-finished"
CallbackEventSignTaskCanceled = "sign-task-canceled"
CallbackEventCorpAuthorize = "corp-authorize"
CallbackEventCorpAuthSuccess = "corp-authorize-success"
)
// SuccessCallbackBody 回调应答,约定返回成功
var SuccessCallbackBody = []byte(`{"msg":"success"}`)
// VerifyCallback 校验法大大回调签名HMAC-SHA256与出站请求同一套算法
func (c *Client) VerifyCallback(headers map[string]string, bizContent string) error {
if c.config == nil || c.config.AppSecret == "" {
return fmt.Errorf("法大大 app_secret 未配置,无法验签")
}
normalized := normalizeHeaderMap(headers)
sign := firstHeader(normalized, "X-FASC-Sign", "X-Fasc-Sign")
if sign == "" {
return fmt.Errorf("缺少回调签名头 X-FASC-Sign")
}
timestamp := firstHeader(normalized, "X-FASC-Timestamp", "X-Fasc-Timestamp")
if timestamp == "" {
return fmt.Errorf("缺少回调时间戳头 X-FASC-Timestamp")
}
headMap := map[string]string{
"X-FASC-App-Id": firstHeader(normalized, "X-FASC-App-Id", "X-Fasc-App-Id"),
"X-FASC-Sign-Type": firstHeader(normalized, "X-FASC-Sign-Type", "X-Fasc-Sign-Type"),
"X-FASC-Timestamp": timestamp,
"X-FASC-Nonce": firstHeader(normalized, "X-FASC-Nonce", "X-Fasc-Nonce"),
"X-FASC-Api-SubVersion": firstHeader(normalized, "X-FASC-Api-SubVersion", "X-Fasc-Api-SubVersion"),
"bizContent": bizContent,
}
if event := firstHeader(normalized, "X-FASC-Event", "X-Fasc-Event"); event != "" {
headMap["X-FASC-Event"] = event
}
if token := firstHeader(normalized, "X-FASC-AccessToken", "X-Fasc-AccessToken"); token != "" {
headMap["X-FASC-AccessToken"] = token
}
expected := SignByMap(headMap, timestamp, c.config.AppSecret)
if !strings.EqualFold(expected, sign) {
return fmt.Errorf("法大大回调签名校验失败")
}
appID := firstHeader(normalized, "X-FASC-App-Id", "X-Fasc-App-Id")
if appID != "" && c.config.AppID != "" && appID != c.config.AppID {
return fmt.Errorf("法大大回调 AppId 不匹配")
}
return nil
}
// VerifyCallbackFromHTTP 从 http.Header 验签
func (c *Client) VerifyCallbackFromHTTP(h http.Header, bizContent string) error {
headers := make(map[string]string, len(h))
for k, vals := range h {
if len(vals) > 0 {
headers[k] = vals[0]
}
}
return c.VerifyCallback(headers, bizContent)
}
// ParseCallback 解析 bizContent 为统一 CallbackEvent
func (c *Client) ParseCallback(headers map[string]string, bizContent string) (*CallbackEvent, error) {
bizContent = strings.TrimSpace(bizContent)
if bizContent == "" {
return nil, fmt.Errorf("回调 bizContent 不能为空")
}
raw := make(map[string]interface{})
if err := json.Unmarshal([]byte(bizContent), &raw); err != nil {
return nil, fmt.Errorf("解析回调 bizContent 失败: %w", err)
}
normalized := normalizeHeaderMap(headers)
event := firstHeader(normalized, "X-FASC-Event", "X-Fasc-Event")
if event == "" {
event = stringValue(raw, "event", "eventType", "type")
}
ev := &CallbackEvent{
Event: event,
SignTaskID: stringValue(raw, "signTaskId", "sign_task_id"),
SignTaskStatus: stringValue(raw, "signTaskStatus", "sign_task_status"),
ClientCorpID: stringValue(raw, "clientCorpId", "client_corp_id"),
OpenCorpID: stringValue(raw, "openCorpId", "open_corp_id"),
AuthResult: stringValue(raw, "authResult", "auth_result", "result"),
EventTime: stringValue(raw, "eventTime", "event_time", "timestamp"),
Raw: raw,
}
if data, ok := raw["data"].(map[string]interface{}); ok {
if ev.SignTaskID == "" {
ev.SignTaskID = stringValue(data, "signTaskId", "sign_task_id")
}
if ev.SignTaskStatus == "" {
ev.SignTaskStatus = stringValue(data, "signTaskStatus", "sign_task_status")
}
if ev.ClientCorpID == "" {
ev.ClientCorpID = stringValue(data, "clientCorpId", "client_corp_id")
}
if ev.OpenCorpID == "" {
ev.OpenCorpID = stringValue(data, "openCorpId", "open_corp_id")
}
if ev.AuthResult == "" {
ev.AuthResult = stringValue(data, "authResult", "auth_result", "result")
}
}
return ev, nil
}
// IsSignCompletedCallback 是否签署完成类回调
func (e *CallbackEvent) IsSignCompletedCallback() bool {
if e == nil {
return false
}
if IsSignTaskCompletedStatus(e.SignTaskStatus) {
return true
}
switch e.Event {
case CallbackEventSignTaskFinished, CallbackEventSignTaskSigned:
return true
default:
return false
}
}
func normalizeHeaderMap(headers map[string]string) map[string]string {
out := make(map[string]string, len(headers))
for k, v := range headers {
out[http.CanonicalHeaderKey(k)] = v
out[k] = v
}
return out
}
func firstHeader(headers map[string]string, keys ...string) string {
for _, key := range keys {
if v := strings.TrimSpace(headers[key]); v != "" {
return v
}
if v := strings.TrimSpace(headers[http.CanonicalHeaderKey(key)]); v != "" {
return v
}
}
return ""
}
func stringValue(m map[string]interface{}, keys ...string) string {
for _, key := range keys {
if v, ok := m[key]; ok && v != nil {
switch t := v.(type) {
case string:
if strings.TrimSpace(t) != "" {
return strings.TrimSpace(t)
}
case float64:
return fmt.Sprintf("%.0f", t)
case json.Number:
return t.String()
default:
s := strings.TrimSpace(fmt.Sprint(t))
if s != "" && s != "<nil>" {
return s
}
}
}
}
return ""
}

View File

@@ -0,0 +1,57 @@
package fadada
import "testing"
func TestVerifyCallback(t *testing.T) {
secret := "test-secret"
c := NewClient(&Config{
AppID: "80005307",
AppSecret: secret,
ServerURL: "https://uat-api.fadada.com/api/v5/",
})
biz := `{"signTaskId":"st-1","signTaskStatus":"task_finished"}`
timestamp := "1720000000000"
nonce := "nonce-1"
headMap := map[string]string{
"X-FASC-App-Id": "80005307",
"X-FASC-Sign-Type": "HMAC-SHA256",
"X-FASC-Timestamp": timestamp,
"X-FASC-Nonce": nonce,
"X-FASC-Event": "sign-task-finished",
"bizContent": biz,
}
sign := SignByMap(headMap, timestamp, secret)
headers := map[string]string{
"X-FASC-App-Id": "80005307",
"X-FASC-Sign-Type": "HMAC-SHA256",
"X-FASC-Timestamp": timestamp,
"X-FASC-Nonce": nonce,
"X-FASC-Event": "sign-task-finished",
"X-FASC-Sign": sign,
}
if err := c.VerifyCallback(headers, biz); err != nil {
t.Fatalf("verify failed: %v", err)
}
headers["X-FASC-Sign"] = "deadbeef"
if err := c.VerifyCallback(headers, biz); err == nil {
t.Fatal("expected signature failure")
}
}
func TestSignByMapStable(t *testing.T) {
m := map[string]string{
"X-FASC-App-Id": "app",
"X-FASC-Nonce": "n1",
"X-FASC-Sign-Type": "HMAC-SHA256",
"X-FASC-Timestamp": "1720000000000",
"bizContent": `{"a":1}`,
}
s1 := SignByMap(m, "1720000000000", "secret")
s2 := SignByMap(m, "1720000000000", "secret")
if s1 == "" || s1 != s2 {
t.Fatalf("signature unstable: %s vs %s", s1, s2)
}
}

View File

@@ -0,0 +1,40 @@
package fadada
import "sync"
// API 路径常量FASC OpenAPI v5.1
const (
pathGetAccessToken = "/service/get-access-token"
pathGetCorpAuthURL = "/corp/get-auth-url"
pathGetCorpIdentifiedStatus = "/corp/get-identified-status"
pathFillDocTemplateValues = "/doc-template/fill-values"
pathCreateSignTaskTemplate = "/sign-task/create-with-template"
pathFillSignTaskFieldValues = "/sign-task/field/fill-values"
pathStartSignTask = "/sign-task/start"
pathGetSignTaskActorURL = "/sign-task/actor/get-url"
pathGetSignTaskDetail = "/sign-task/app/get-detail"
pathGetSignTaskPreviewURL = "/sign-task/get-preview-url"
pathGetOwnerSignTaskDownload = "/sign-task/owner/get-download-url"
)
// Client 法大大客户端(纯 HTTP不依赖官方 SDK
type Client struct {
config *Config
http *HTTPClient
tokenCache *tokenCache
tokenMu sync.Mutex
}
// NewClient 创建法大大客户端
func NewClient(config *Config) *Client {
return &Client{
config: config,
http: NewHTTPClient(config),
tokenCache: &tokenCache{},
}
}
// GetConfig 获取当前配置
func (c *Client) GetConfig() *Config {
return c.config
}

View File

@@ -0,0 +1,187 @@
package fadada
import (
"fmt"
"strings"
)
// AuthConfig 认证相关配置
type AuthConfig struct {
RedirectURL string `json:"redirectUrl" yaml:"redirect_url"`
}
// SignConfig 签署相关配置
type SignConfig struct {
RedirectURL string `json:"redirectUrl" yaml:"redirect_url"`
}
// ContractConfig 合同相关配置
type ContractConfig struct {
Name string `json:"name" yaml:"name"`
ExpireDays int `json:"expireDays" yaml:"expire_days"`
RetryCount int `json:"retryCount" yaml:"retry_count"`
}
// CallbackConfig 回调配置
type CallbackConfig struct {
Enabled bool `json:"enabled" yaml:"enabled"`
}
// TemplateFields 合作协议模板控件 fieldId
type TemplateFields struct {
// AgreementNo 协议编号控件编码列表(法大大控件不可复用,多处填同一值)
AgreementNo []string `json:"agreementNo" yaml:"agreement_no"`
ContractDate string `json:"contractDate" yaml:"contract_date"`
// PartyAName 甲方企业名控件编码列表(多处填同一企业名)
PartyAName []string `json:"partyAName" yaml:"party_a_name"`
PartyAUSCC string `json:"partyAUscc" yaml:"party_a_uscc"`
PartyAAddress string `json:"partyAAddress" yaml:"party_a_address"`
PartyARep string `json:"partyARep" yaml:"party_a_rep"`
PartyASignDate string `json:"partyASignDate" yaml:"party_a_sign_date"`
PartyBSignDate string `json:"partyBSignDate" yaml:"party_b_sign_date"`
}
// Config 法大大服务配置
type Config struct {
AppID string `json:"appId" yaml:"app_id"`
AppSecret string `json:"appSecret" yaml:"app_secret"`
ServerURL string `json:"serverUrl" yaml:"server_url"`
OpenCorpID string `json:"openCorpId" yaml:"open_corp_id"`
// NoAuthSceneCode 免验证签场景码;仅 freeSignType=business 时需要。当前按模板免验证签,可留空。
NoAuthSceneCode string `json:"noAuthSceneCode" yaml:"no_auth_scene_code"`
TemplateID string `json:"templateId" yaml:"template_id"`
// PartyAActorID / PartyBActorID 签署模板中的参与方标识actorId须与模板角色名一致
PartyAActorID string `json:"partyAActorId" yaml:"party_a_actor_id"`
PartyBActorID string `json:"partyBActorId" yaml:"party_b_actor_id"`
// TemplateDocID 签署模板文档 docId填单必填为空则创建任务后从详情自动解析
TemplateDocID string `json:"templateDocId" yaml:"template_doc_id"`
TemplateFields *TemplateFields `json:"templateFields" yaml:"template_fields"`
Contract *ContractConfig `json:"contract" yaml:"contract"`
Auth *AuthConfig `json:"auth" yaml:"auth"`
Sign *SignConfig `json:"sign" yaml:"sign"`
Callback *CallbackConfig `json:"callback" yaml:"callback"`
}
// NewConfig 创建并校验法大大配置
func NewConfig(
appID, appSecret, serverURL, openCorpID, noAuthSceneCode, templateID string,
partyAActorID, partyBActorID, templateDocID string,
fields *TemplateFields,
contract *ContractConfig,
auth *AuthConfig,
sign *SignConfig,
callback *CallbackConfig,
) (*Config, error) {
if appID == "" {
return nil, fmt.Errorf("法大大应用ID不能为空")
}
if appSecret == "" {
return nil, fmt.Errorf("法大大应用密钥不能为空")
}
if serverURL == "" {
return nil, fmt.Errorf("法大大服务器URL不能为空")
}
return &Config{
AppID: appID,
AppSecret: appSecret,
ServerURL: serverURL,
OpenCorpID: openCorpID,
NoAuthSceneCode: noAuthSceneCode,
TemplateID: templateID,
PartyAActorID: partyAActorID,
PartyBActorID: partyBActorID,
TemplateDocID: templateDocID,
TemplateFields: fields,
Contract: contract,
Auth: auth,
Sign: sign,
Callback: callback,
}, nil
}
// Validate 验证配置完整性
func (c *Config) Validate() error {
if c.AppID == "" {
return fmt.Errorf("法大大应用ID不能为空")
}
if c.AppSecret == "" {
return fmt.Errorf("法大大应用密钥不能为空")
}
if c.ServerURL == "" {
return fmt.Errorf("法大大服务器URL不能为空")
}
return nil
}
// ValidateTemplateFields 校验模板填单所需配置
func (c *Config) ValidateTemplateFields() error {
if c.TemplateID == "" {
return fmt.Errorf("法大大模板ID不能为空")
}
if c.OpenCorpID == "" {
return fmt.Errorf("法大大 open_corp_id 不能为空")
}
if c.TemplateFields == nil {
return fmt.Errorf("法大大 template_fields 未配置")
}
f := c.TemplateFields
if len(f.AgreementNo) == 0 {
return fmt.Errorf("法大大 template_fields.agreement_no 不能为空")
}
for i, id := range f.AgreementNo {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("法大大 template_fields.agreement_no[%d] 不能为空", i)
}
}
if len(f.PartyAName) == 0 {
return fmt.Errorf("法大大 template_fields.party_a_name 不能为空")
}
for i, id := range f.PartyAName {
if strings.TrimSpace(id) == "" {
return fmt.Errorf("法大大 template_fields.party_a_name[%d] 不能为空", i)
}
}
// party_a/b_sign_date 为签署控件,签署时自动写入,填单可不配
required := map[string]string{
"contract_date": f.ContractDate,
"party_a_uscc": f.PartyAUSCC,
"party_a_address": f.PartyAAddress,
"party_a_rep": f.PartyARep,
}
for name, id := range required {
if id == "" {
return fmt.Errorf("法大大 template_fields.%s 不能为空", name)
}
}
return nil
}
// ResolvePartyAActorID 甲方参与方标识(优先配置,默认与签署模板角色名一致)
func (c *Config) ResolvePartyAActorID() string {
if c != nil && c.PartyAActorID != "" {
return c.PartyAActorID
}
return ActorIDPartyA
}
// ResolvePartyBActorID 乙方参与方标识(优先配置,默认与签署模板角色名一致)
func (c *Config) ResolvePartyBActorID() string {
if c != nil && c.PartyBActorID != "" {
return c.PartyBActorID
}
return ActorIDPartyB
}
const (
// 接口成功码
successCode = "100000"
// 个人证件类型:身份证(文档 userIdentType
UserIdentTypeIDCard = "id_card"
// 企业认证方式:法人认证(文档 corpIdentMethod
CorpIdentMethodLegalRep = "legalRep"
// 经办人认证方式:手机号(文档 oprIdentMethod
OprIdentMethodMobile = "mobile"
)

View File

@@ -0,0 +1,180 @@
package fadada
import (
"fmt"
"net/url"
"strings"
)
// 默认授权范围:认证信息 + 后续签署所需能力
var defaultAuthScopes = []string{
"ident_info",
"seal_info",
"signtask_info",
"signtask_init",
"signtask_file",
"organization",
"template",
}
// GenerateEnterpriseAuth 获取企业认证/授权链接POST /corp/get-auth-url
// 文档见 1.mdcorpIdentType 置空由用户在页面选择组织类型。
func (c *Client) GenerateEnterpriseAuth(req *EnterpriseAuthRequest) (*EnterpriseAuthResult, error) {
if err := validateEnterpriseAuthRequest(req); err != nil {
return nil, err
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return nil, err
}
clientCorpID := strings.TrimSpace(req.ClientCorpID)
if clientCorpID == "" {
clientCorpID = strings.TrimSpace(req.UnifiedSocialCode)
}
clientUserID := strings.TrimSpace(req.ClientUserID)
if clientUserID == "" {
clientUserID = strings.TrimSpace(req.TransactorID)
}
redirectURL := ""
if c.config.Auth != nil {
redirectURL = c.config.Auth.RedirectURL
}
apiReq := &getCorpAuthURLReq{
ClientCorpID: clientCorpID,
ClientUserID: clientUserID,
AccountName: strings.TrimSpace(req.TransactorMobile),
CorpIdentInfo: &corpIdentInfo{
CorpName: strings.TrimSpace(req.CompanyName),
// 按官方文档:不传默认为企业;置空让用户在页面选择组织类型
// CorpIdentType: "",
CorpIdentNo: strings.TrimSpace(req.UnifiedSocialCode),
LegalRepName: strings.TrimSpace(req.LegalPersonName),
CorpIdentMethod: []string{CorpIdentMethodLegalRep},
},
// 文档合法值仅 corpName/corpIdentType/corpIdentNo不传表示都可修改
// CorpNonEditableInfo: ,
OprIdentInfo: &oprIdentInfo{
UserName: strings.TrimSpace(req.TransactorName),
UserIdentType: UserIdentTypeIDCard,
UserIdentNo: strings.TrimSpace(req.TransactorID),
Mobile: strings.TrimSpace(req.TransactorMobile),
OprIdentMethod: []string{OprIdentMethodMobile},
},
OprNonEditableInfo: nil,
AuthScopes: append([]string{}, defaultAuthScopes...),
RedirectURL: encodeRedirectURL(redirectURL),
}
res, err := c.http.PostBiz(pathGetCorpAuthURL, accessToken, apiReq)
if err != nil {
return nil, err
}
if res.Code != successCode {
return nil, fmt.Errorf("获取法大大企业认证链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data getCorpAuthURLData
if err := decodeData(res.Data, &data); err != nil {
return nil, fmt.Errorf("解析企业认证链接响应失败: %w", err)
}
if data.AuthURL == "" {
return nil, fmt.Errorf("获取法大大企业认证链接失败: authUrl 为空 requestId=%s", res.RequestID)
}
return &EnterpriseAuthResult{
AuthFlowID: clientCorpID,
AuthURL: data.AuthURL,
AuthShortURL: data.AuthURL,
}, nil
}
// QueryOrgVerified 查询企业是否已在法大大完成实名POST /corp/get-identified-status
func (c *Client) QueryOrgVerified(req *QueryOrgIdentityRequest) (bool, error) {
result, err := c.QueryOrgIdentity(req)
if err != nil {
return false, err
}
return result.Identified, nil
}
// QueryOrgIdentity 查询企业实名认证状态详情
func (c *Client) QueryOrgIdentity(req *QueryOrgIdentityRequest) (*QueryOrgIdentityResult, error) {
if req == nil {
return nil, fmt.Errorf("查询请求不能为空")
}
corpName := strings.TrimSpace(req.CorpName)
corpIdentNo := strings.TrimSpace(req.CorpIdentNo)
if corpName == "" && corpIdentNo == "" {
return nil, fmt.Errorf("企业名称与统一社会信用代码不能同时为空")
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return nil, err
}
res, err := c.http.PostBiz(pathGetCorpIdentifiedStatus, accessToken, &getIdentifiedStatusReq{
CorpName: corpName,
CorpIdentNo: corpIdentNo,
})
if err != nil {
return nil, err
}
if res.Code != successCode {
return nil, fmt.Errorf("查询法大大企业实名状态失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data getIdentifiedStatusData
if err := decodeData(res.Data, &data); err != nil {
return nil, fmt.Errorf("解析企业实名状态失败: %w", err)
}
return &QueryOrgIdentityResult{
Identified: data.IdentStatus,
CorpName: corpName,
CorpIdentNo: corpIdentNo,
}, nil
}
func validateEnterpriseAuthRequest(req *EnterpriseAuthRequest) error {
if req == nil {
return fmt.Errorf("认证请求不能为空")
}
if strings.TrimSpace(req.CompanyName) == "" {
return fmt.Errorf("企业名称不能为空")
}
if strings.TrimSpace(req.UnifiedSocialCode) == "" {
return fmt.Errorf("统一社会信用代码不能为空")
}
if strings.TrimSpace(req.LegalPersonName) == "" {
return fmt.Errorf("法人姓名不能为空")
}
if strings.TrimSpace(req.TransactorName) == "" {
return fmt.Errorf("经办人姓名不能为空")
}
if strings.TrimSpace(req.TransactorMobile) == "" {
return fmt.Errorf("经办人手机号不能为空")
}
if strings.TrimSpace(req.TransactorID) == "" {
return fmt.Errorf("经办人身份证号不能为空")
}
return nil
}
// encodeRedirectURL 按官方文档对 redirectUrl 做 URL encode已编码则原样返回
func encodeRedirectURL(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if strings.Contains(raw, "%") {
if decoded, err := url.QueryUnescape(raw); err == nil && decoded != raw {
return raw
}
}
return url.QueryEscape(raw)
}

View File

@@ -0,0 +1,35 @@
package fadada
import "testing"
func TestValidateEnterpriseAuthRequest(t *testing.T) {
err := validateEnterpriseAuthRequest(&EnterpriseAuthRequest{
CompanyName: "测试企业有限公司",
UnifiedSocialCode: "91110000MA01234567",
LegalPersonName: "张三",
TransactorName: "张三",
TransactorMobile: "13800138000",
TransactorID: "110101199001011234",
})
if err != nil {
t.Fatalf("expected nil, got %v", err)
}
if err := validateEnterpriseAuthRequest(nil); err == nil {
t.Fatal("expected error for nil request")
}
if err := validateEnterpriseAuthRequest(&EnterpriseAuthRequest{}); err == nil {
t.Fatal("expected error for empty request")
}
}
func TestEncodeRedirectURL(t *testing.T) {
raw := "http://localhost:5173/profile/certification"
encoded := encodeRedirectURL(raw)
if encoded == "" || encoded == raw {
t.Fatalf("expected url-encoded redirect, got %q", encoded)
}
if encodeRedirectURL(encoded) != encoded {
t.Fatalf("already-encoded url should stay unchanged")
}
}

View File

@@ -0,0 +1,140 @@
package fadada
import (
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// debug 日志:将每次法大大 OpenAPI 调用的 cURL 命令 + 响应落盘到 logs/fadada/<date>/fadada.log
// 用于排查 /sign-task/create-with-template 等接口的实际请求/响应(如免验证签不生效)。
// 敏感字段AppSecret不会出现在日志里AccessToken / Sign 会以明文写入以便复现。
const (
debugLogDir = "logs/fadada"
debugLogFile = "fadada.log"
debugMaxBytes = 10 * 1024 * 1024 // 单文件 10MB 后截断覆盖(轮转保持简单)
)
var (
debugLogMu sync.Mutex
debugLogFilePtr *os.File
debugLogDate string
debugLogSize int64
)
// writeDebugLog 把一次 API 调用的请求与响应追加到当日日志文件。
// 调用方需保证 headers / body / respBody 不包含 AppSecret法大大签名里只有时间戳参与secret 本身不出现在 header/body 中)。
func writeDebugLog(method, fullURL string, headers map[string]string, body string, httpStatus int, respBody string, requestID string) {
if strings.TrimSpace(fullURL) == "" {
return
}
entry := buildDebugEntry(method, fullURL, headers, body, httpStatus, respBody, requestID)
debugLogMu.Lock()
defer debugLogMu.Unlock()
if err := openDebugLogFileLocked(); err != nil {
// 日志失败不影响业务
return
}
n, _ := debugLogFilePtr.WriteString(entry)
debugLogSize += int64(n)
if debugLogSize >= debugMaxBytes {
_ = debugLogFilePtr.Close()
debugLogFilePtr = nil
debugLogSize = 0
}
}
func openDebugLogFileLocked() error {
today := time.Now().Format("2006-01-02")
if debugLogFilePtr != nil && debugLogDate == today {
return nil
}
if debugLogFilePtr != nil {
_ = debugLogFilePtr.Close()
}
dateDir := filepath.Join(debugLogDir, today)
if err := os.MkdirAll(dateDir, 0o755); err != nil {
return err
}
full := filepath.Join(dateDir, debugLogFile)
f, err := os.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
if info, err := f.Stat(); err == nil {
debugLogSize = info.Size()
} else {
debugLogSize = 0
}
debugLogFilePtr = f
debugLogDate = today
return nil
}
// buildDebugEntry 生成一段可读的日志条目cURL 命令 + 响应)。
func buildDebugEntry(method, fullURL string, headers map[string]string, body string, httpStatus int, respBody string, requestID string) string {
now := time.Now().Format("2006-01-02 15:04:05.000")
var sb strings.Builder
sb.WriteString("\n==================== ")
sb.WriteString(now)
sb.WriteString(" ====================\n")
// ---- cURL ----
sb.WriteString("curl -sS -X ")
sb.WriteString(strings.ToUpper(strings.TrimSpace(method)))
sb.WriteString(" '")
sb.WriteString(fullURL)
sb.WriteString("'")
for _, k := range sortedHeaderKeys(headers) {
sb.WriteString(" \\\n -H '")
sb.WriteString(k)
sb.WriteString(": ")
sb.WriteString(headers[k])
sb.WriteString("'")
}
if strings.TrimSpace(body) != "" {
sb.WriteString(" \\\n --data-urlencode 'bizContent=")
sb.WriteString(body)
sb.WriteString("'")
}
sb.WriteString("\n")
// ---- RESPONSE ----
sb.WriteString("--- RESPONSE ---\n")
sb.WriteString("HTTP ")
sb.WriteString(fmt.Sprintf("%d", httpStatus))
if requestID != "" {
sb.WriteString(" requestId=")
sb.WriteString(requestID)
}
sb.WriteString("\n")
sb.WriteString("body: ")
sb.WriteString(respBody)
sb.WriteString("\n")
return sb.String()
}
func sortedHeaderKeys(headers map[string]string) []string {
keys := make([]string, 0, len(headers))
for k := range headers {
keys = append(keys, k)
}
// 简单冒泡,依赖少
for i := 0; i < len(keys); i++ {
for j := i + 1; j < len(keys); j++ {
if keys[i] > keys[j] {
keys[i], keys[j] = keys[j], keys[i]
}
}
}
return keys
}

View File

@@ -0,0 +1,187 @@
package fadada
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
// HTTPClient 法大大 OpenAPI HTTP 客户端(不依赖官方 SDK
type HTTPClient struct {
config *Config
client *http.Client
}
func NewHTTPClient(config *Config) *HTTPClient {
return &HTTPClient{
config: config,
client: &http.Client{Timeout: 30 * time.Second},
}
}
// apiResponse 通用响应外壳
type apiResponse struct {
Code string `json:"code"`
Msg string `json:"msg"`
Data json.RawMessage `json:"data"`
RequestID string `json:"requestId,omitempty"`
}
// PostBiz 发送业务 APIPOST form bizContent=<JSON>,带 AccessToken 签名
func (h *HTTPClient) PostBiz(path, accessToken string, biz any) (*apiResponse, error) {
bizJSON, err := marshalBizContent(biz)
if err != nil {
return nil, err
}
headers := h.buildBizHeaders(accessToken, bizJSON)
return h.doPost(path, headers, bizJSON)
}
// PostToken 获取 accessToken无 AccessToken带 Grant-Type
func (h *HTTPClient) PostToken(path string) (*apiResponse, error) {
headers := h.buildTokenHeaders()
return h.doPost(path, headers, "")
}
func (h *HTTPClient) doPost(path string, headers map[string]string, bizJSON string) (*apiResponse, error) {
fullURL := joinURL(h.config.ServerURL, path)
form := url.Values{}
form.Set("bizContent", bizJSON)
body := form.Encode()
req, err := http.NewRequest(http.MethodPost, fullURL, strings.NewReader(body))
if err != nil {
return nil, fmt.Errorf("创建法大大请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for k, v := range headers {
req.Header.Set(k, v)
}
resp, err := h.client.Do(req)
if err != nil {
// 网络错误也记录一次,便于排查连接问题
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, -1, fmt.Sprintf("HTTP ERROR: %v", err), "")
return nil, fmt.Errorf("调用法大大接口失败 %s: %w", path, err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, resp.StatusCode, fmt.Sprintf("READ BODY ERROR: %v", err), "")
return nil, fmt.Errorf("读取法大大响应失败 %s: %w", path, err)
}
requestID := ""
if vals := resp.Header.Values("X-FASC-Request-Id"); len(vals) > 0 {
requestID = vals[0]
}
// 落盘 cURL + 响应(含 X-FASC-* 头与 bizContent便于排查免验证签等问题
writeDebugLog(http.MethodPost, fullURL, headers, bizJSON, resp.StatusCode, string(respBody), requestID)
var out apiResponse
if err := json.Unmarshal(respBody, &out); err != nil {
return nil, fmt.Errorf("解析法大大响应失败 %s: %w body=%s", path, err, truncate(string(respBody), 512))
}
if out.RequestID == "" {
out.RequestID = requestID
}
return &out, nil
}
func (h *HTTPClient) buildTokenHeaders() map[string]string {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
nonce := randomNonce()
headMap := map[string]string{
"X-FASC-App-Id": h.config.AppID,
"X-FASC-Sign-Type": signTypeHMACSHA256,
"X-FASC-Timestamp": timestamp,
"X-FASC-Nonce": nonce,
"X-FASC-Grant-Type": "client_credential",
"X-FASC-Api-SubVersion": apiSubVersion,
}
headMap["X-FASC-Sign"] = SignByMap(headMap, timestamp, h.config.AppSecret)
return headMap
}
func (h *HTTPClient) buildBizHeaders(accessToken, bizJSON string) map[string]string {
timestamp := strconv.FormatInt(time.Now().UnixMilli(), 10)
nonce := randomNonce()
headMap := map[string]string{
"X-FASC-App-Id": h.config.AppID,
"X-FASC-Sign-Type": signTypeHMACSHA256,
"X-FASC-Timestamp": timestamp,
"X-FASC-Nonce": nonce,
"X-FASC-AccessToken": accessToken,
"X-FASC-Api-SubVersion": apiSubVersion,
"bizContent": bizJSON,
}
sign := SignByMap(headMap, timestamp, h.config.AppSecret)
delete(headMap, "bizContent")
headMap["X-FASC-Sign"] = sign
return headMap
}
func marshalBizContent(biz any) (string, error) {
if biz == nil {
return "", nil
}
switch v := biz.(type) {
case string:
return v, nil
case []byte:
return string(v), nil
default:
b, err := json.Marshal(biz)
if err != nil {
return "", fmt.Errorf("序列化 bizContent 失败: %w", err)
}
// 紧凑 JSON避免多余空格影响签名
var buf bytes.Buffer
if err := json.Compact(&buf, b); err == nil {
return buf.String(), nil
}
return string(b), nil
}
}
func joinURL(base, path string) string {
base = strings.TrimRight(base, "/")
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return base + path
}
func randomNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 16)
}
return hex.EncodeToString(b)
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "..."
}
// decodeData 将 apiResponse.Data 反序列化到目标结构
func decodeData(raw json.RawMessage, dest any) error {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
return json.Unmarshal(raw, dest)
}

View File

@@ -0,0 +1,57 @@
package fadada
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"sort"
"strings"
)
const (
signTypeHMACSHA256 = "HMAC-SHA256"
apiSubVersion = "5.1"
)
// SignByMap 法大大 HMAC-SHA256 签名(与官方 SDK 算法一致)
// 1) 对 map key 字典序排序,拼接 key=value&...(跳过空值)
// 2) signText = hex(SHA256(signContent))
// 3) signKey = HMAC-SHA256(timestamp, appSecret) 原始字节
// 4) signature = lowercase(hex(HMAC-SHA256(signText, signKey)))
func SignByMap(headMap map[string]string, timestamp, appSecret string) string {
signContent := sortMapForSign(headMap)
signText := sha256Hex(signContent)
secretSigning := hmacSHA256Bytes(timestamp, []byte(appSecret))
return strings.ToLower(hmacSHA256Hex(signText, secretSigning))
}
func sortMapForSign(headMap map[string]string) string {
keys := make([]string, 0, len(headMap))
for k := range headMap {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
if v := headMap[k]; v != "" {
parts = append(parts, k+"="+v)
}
}
return strings.Join(parts, "&")
}
func sha256Hex(src string) string {
sum := sha256.Sum256([]byte(src))
return hex.EncodeToString(sum[:])
}
func hmacSHA256Bytes(data string, secret []byte) []byte {
h := hmac.New(sha256.New, secret)
h.Write([]byte(data))
return h.Sum(nil)
}
func hmacSHA256Hex(data string, secret []byte) string {
return hex.EncodeToString(hmacSHA256Bytes(data, secret))
}

View File

@@ -0,0 +1,417 @@
package fadada
import (
"fmt"
"strconv"
"strings"
"time"
)
// CreateSignFlow 基于签署任务模板创建签署任务POST /sign-task/create-with-template
// - autoStart无填单时创建即自动提交有填单时创建后填控件再 Start完成自动提交
// - 签署顺序:甲方 orderNo=1 先签(短信/验证);乙方 orderNo=2 后签,由系统按模板免验证签自动盖章
// - 乙方参与方开启免验证签requestVerifyFree + freeSignType=template
func (c *Client) CreateSignFlow(req *CreateSignFlowRequest) (*CreateSignFlowResult, error) {
if err := validateCreateSignFlowRequest(req); err != nil {
return nil, err
}
if c.config.OpenCorpID == "" {
return nil, fmt.Errorf("法大大 open_corp_id 不能为空")
}
if c.config.TemplateID == "" {
return nil, fmt.Errorf("法大大 template_id 不能为空")
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return nil, err
}
needFill := req.Fill != nil
// 有填单须先填再提交,创建时不能 autoStart无填单则创建时自动提交
autoStart := !needFill
subject := strings.TrimSpace(req.Subject)
if subject == "" {
if c.config.Contract != nil && c.config.Contract.Name != "" {
subject = c.config.Contract.Name
} else {
subject = "海宇数据-合作协议"
}
if req.PartyAName != "" {
subject = fmt.Sprintf("%s-%s", subject, req.PartyAName)
}
}
actors := c.buildSignActors(req)
expiresTime := c.buildExpiresTime()
res, err := c.http.PostBiz(pathCreateSignTaskTemplate, accessToken, &createSignTaskWithTemplateReq{
SignTaskSubject: subject,
Initiator: &openID{IDType: "corp", OpenID: c.config.OpenCorpID},
SignTemplateID: c.config.TemplateID,
AutoStart: autoStart,
AutoFillFinalize: true,
AutoFinish: true,
SignInOrder: true,
ExpiresTime: expiresTime,
FreeSignType: freeSignTypeTemplate, // 按模板免验证签,无需 businessId
TransReferenceID: strings.TrimSpace(req.TransReferenceID),
Actors: actors,
})
if err != nil {
return nil, err
}
if res.Code != successCode {
return nil, fmt.Errorf("基于模板创建法大大签署任务失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data createSignTaskData
if err := decodeData(res.Data, &data); err != nil {
return nil, fmt.Errorf("解析创建签署任务响应失败: %w", err)
}
signTaskID := data.SignTaskID
if signTaskID == "" {
return nil, fmt.Errorf("创建法大大签署任务失败: signTaskId 为空")
}
// 填单后自动提交:乙方免验证签自动盖章,甲方再取签署链接
if needFill {
fillReq := *req.Fill
if fillReq.CompanyName == "" {
fillReq.CompanyName = req.PartyAName
}
if fillReq.UnifiedSocialCode == "" {
fillReq.UnifiedSocialCode = req.PartyAUSCC
}
if fillReq.AuthorizedRepName == "" {
fillReq.AuthorizedRepName = req.TransactorName
}
if err := c.FillSignTaskFields(signTaskID, &fillReq); err != nil {
return nil, err
}
if err := c.StartSignTask(signTaskID); err != nil {
return nil, err
}
}
return &CreateSignFlowResult{SignTaskID: signTaskID}, nil
}
// StartSignTask 提交签署任务POST /sign-task/start
func (c *Client) StartSignTask(signTaskID string) error {
signTaskID = strings.TrimSpace(signTaskID)
if signTaskID == "" {
return fmt.Errorf("signTaskId 不能为空")
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return err
}
res, err := c.http.PostBiz(pathStartSignTask, accessToken, &startSignTaskReq{SignTaskID: signTaskID})
if err != nil {
return err
}
if res.Code != successCode {
return fmt.Errorf("提交法大大签署任务失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
return nil
}
// GetSignURL 获取参与方签署链接POST /sign-task/actor/get-url
func (c *Client) GetSignURL(signTaskID, actorID string) (*SignURLResult, error) {
signTaskID = strings.TrimSpace(signTaskID)
if signTaskID == "" {
return nil, fmt.Errorf("signTaskId 不能为空")
}
if actorID == "" {
actorID = c.config.ResolvePartyAActorID()
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return nil, err
}
redirectURL := ""
if c.config.Sign != nil {
redirectURL = encodeRedirectURL(c.config.Sign.RedirectURL)
}
res, err := c.http.PostBiz(pathGetSignTaskActorURL, accessToken, &getActorURLReq{
SignTaskID: signTaskID,
ActorID: actorID,
RedirectURL: redirectURL,
})
if err != nil {
return nil, err
}
if res.Code != successCode {
return nil, fmt.Errorf("获取法大大签署链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data getActorURLData
if err := decodeData(res.Data, &data); err != nil {
return nil, fmt.Errorf("解析签署链接响应失败: %w", err)
}
if data.ActorSignTaskURL == "" && data.ActorSignTaskEmbedURL == "" {
return nil, fmt.Errorf("获取法大大签署链接失败: url 为空 requestId=%s", res.RequestID)
}
return &SignURLResult{
SignURL: data.ActorSignTaskURL,
EmbedURL: data.ActorSignTaskEmbedURL,
ActorID: actorID,
SignTaskID: signTaskID,
}, nil
}
// GetSignTaskPreviewURL 获取签署任务预览链接POST /sign-task/get-preview-url约 2 小时/单次)
func (c *Client) GetSignTaskPreviewURL(signTaskID string) (*SignTaskPreviewURLResult, error) {
signTaskID = strings.TrimSpace(signTaskID)
if signTaskID == "" {
return nil, fmt.Errorf("signTaskId 不能为空")
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return nil, err
}
redirectURL := ""
if c.config.Sign != nil {
redirectURL = encodeRedirectURL(c.config.Sign.RedirectURL)
}
res, err := c.http.PostBiz(pathGetSignTaskPreviewURL, accessToken, &getSignTaskPreviewURLReq{
SignTaskID: signTaskID,
RedirectURL: redirectURL,
})
if err != nil {
return nil, err
}
if res.Code != successCode {
return nil, fmt.Errorf("获取法大大预览链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data getSignTaskPreviewURLData
if err := decodeData(res.Data, &data); err != nil {
return nil, fmt.Errorf("解析预览链接响应失败: %w", err)
}
previewURL := strings.TrimSpace(data.SignTaskPreviewURL)
if previewURL == "" {
return nil, fmt.Errorf("获取法大大预览链接失败: signTaskPreviewUrl 为空 requestId=%s", res.RequestID)
}
return &SignTaskPreviewURLResult{
SignTaskID: signTaskID,
PreviewURL: previewURL,
}, nil
}
// QuerySignStatus 查询签署任务状态POST /sign-task/app/get-detail
func (c *Client) QuerySignStatus(signTaskID string) (*SignStatusResult, error) {
signTaskID = strings.TrimSpace(signTaskID)
if signTaskID == "" {
return nil, fmt.Errorf("signTaskId 不能为空")
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return nil, err
}
res, err := c.http.PostBiz(pathGetSignTaskDetail, accessToken, &getSignTaskDetailReq{
SignTaskID: signTaskID,
})
if err != nil {
return nil, err
}
if res.Code != successCode {
return nil, fmt.Errorf("查询法大大签署状态失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data getSignTaskDetailData
if err := decodeData(res.Data, &data); err != nil {
return nil, fmt.Errorf("解析签署状态响应失败: %w", err)
}
status := data.SignTaskStatus
return &SignStatusResult{
SignTaskID: signTaskID,
SignTaskStatus: status,
Completed: IsSignTaskCompletedStatus(status),
Terminated: status == SignTaskStatusTerminated,
FinishTime: data.FinishTime,
TerminationNote: data.TerminationNote,
RevokeReason: data.RevokeReason,
}, nil
}
// IsSignTaskCompleted 签署任务是否已完成(可下载)
func (c *Client) IsSignTaskCompleted(signTaskID string) (bool, error) {
status, err := c.QuerySignStatus(signTaskID)
if err != nil {
return false, err
}
return status.Completed, nil
}
// IsSignTaskCompletedStatus 状态是否视为签署完成
func IsSignTaskCompletedStatus(status string) bool {
switch status {
case SignTaskStatusFinished, SignTaskStatusSignCompleted:
return true
default:
return false
}
}
// DownloadSignedFiles 获取签署完成文件下载地址POST /sign-task/owner/get-download-url
func (c *Client) DownloadSignedFiles(signTaskID string) (*DownloadSignedFilesResult, error) {
signTaskID = strings.TrimSpace(signTaskID)
if signTaskID == "" {
return nil, fmt.Errorf("signTaskId 不能为空")
}
if c.config.OpenCorpID == "" {
return nil, fmt.Errorf("法大大 open_corp_id 不能为空")
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return nil, err
}
res, err := c.http.PostBiz(pathGetOwnerSignTaskDownload, accessToken, &getOwnerDownloadURLReq{
OwnerID: &openID{IDType: "corp", OpenID: c.config.OpenCorpID},
SignTaskID: signTaskID,
FileType: "doc",
})
if err != nil {
return nil, err
}
if res.Code != successCode {
return nil, fmt.Errorf("获取法大大已签文件下载地址失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data getOwnerDownloadURLData
if err := decodeData(res.Data, &data); err != nil {
return nil, fmt.Errorf("解析下载地址响应失败: %w", err)
}
if data.DownloadURL == "" {
return nil, fmt.Errorf("获取法大大已签文件下载地址失败: downloadUrl 为空 requestId=%s", res.RequestID)
}
return &DownloadSignedFilesResult{
Files: []*SignedFile{{
DownloadURL: data.DownloadURL,
DownloadID: data.DownloadID,
}},
}, nil
}
// GenerateContractSigning 一站式:创建签署任务(可带填单)→ 获取甲方签署链接
func (c *Client) GenerateContractSigning(req *ContractSigningRequest) (*ContractSigningResult, error) {
if req == nil {
return nil, fmt.Errorf("签署请求不能为空")
}
createReq := req.CreateSignFlowRequest
createReq.Fill = &req.Fill
if createReq.PartyAName == "" {
createReq.PartyAName = req.Fill.CompanyName
}
if createReq.PartyAUSCC == "" {
createReq.PartyAUSCC = req.Fill.UnifiedSocialCode
}
if createReq.TransactorName == "" {
createReq.TransactorName = req.Fill.AuthorizedRepName
}
createRes, err := c.CreateSignFlow(&createReq)
if err != nil {
return nil, err
}
urlRes, err := c.GetSignURL(createRes.SignTaskID, c.config.ResolvePartyAActorID())
if err != nil {
return nil, err
}
return &ContractSigningResult{
SignTaskID: createRes.SignTaskID,
SignURL: firstNonEmpty(urlRes.EmbedURL, urlRes.SignURL),
EmbedURL: urlRes.EmbedURL,
}, nil
}
const freeSignTypeTemplate = "template" // 根据模板免验证签(无需 businessId
func (c *Client) buildSignActors(req *CreateSignFlowRequest) []signTaskActor {
partyAActorID := c.config.ResolvePartyAActorID()
partyBActorID := c.config.ResolvePartyBActorID()
// 甲方先签 OrderNo=1用户预览后「立即签署」进 iframe完成短信验证 / 实名确认
partyA := signTaskActor{
Actor: &actor{
ActorType: "corp",
ActorID: partyAActorID,
ActorName: firstNonEmpty(strings.TrimSpace(req.PartyAName), partyAActorID),
Permissions: []string{"sign"},
ActorOpenID: strings.TrimSpace(req.PartyAOpenCorpID),
IdentNameForMatch: strings.TrimSpace(req.PartyAName),
CertNoForMatch: strings.TrimSpace(req.PartyAUSCC),
AccountName: strings.TrimSpace(req.TransactorMobile),
ClientUserID: firstNonEmpty(strings.TrimSpace(req.TransactorID), strings.TrimSpace(req.TransactorMobile)),
},
SignConfigInfo: &signConfigInfo{
OrderNo: 1,
JoinByLink: true,
RequestVerifyFree: false,
FreeLogin: false,
BlockHere: false,
},
}
// 乙方(平台)后签 OrderNo=2甲方签完后由系统按模板免验证签自动盖章
partyB := signTaskActor{
Actor: &actor{
ActorType: "corp",
ActorID: partyBActorID,
ActorName: partyBActorID,
Permissions: []string{"sign"},
ActorOpenID: c.config.OpenCorpID,
},
SignConfigInfo: &signConfigInfo{
OrderNo: 2,
JoinByLink: true, // 链接加入false 时要求 actorCorpMembers 非空
RequestVerifyFree: true,
FreeSignType: freeSignTypeTemplate,
FreeLogin: true,
BlockHere: false,
},
}
// 数组顺序与 orderNo 无强绑定(法大大按 orderNo 决定先后),保持 [甲方, 乙方] 便于阅读
return []signTaskActor{partyA, partyB}
}
func (c *Client) buildExpiresTime() string {
days := 7
if c.config.Contract != nil && c.config.Contract.ExpireDays > 0 {
days = c.config.Contract.ExpireDays
}
return strconv.FormatInt(time.Now().AddDate(0, 0, days).UnixMilli(), 10)
}
func validateCreateSignFlowRequest(req *CreateSignFlowRequest) error {
if req == nil {
return fmt.Errorf("创建签署请求不能为空")
}
if strings.TrimSpace(req.PartyAName) == "" && (req.Fill == nil || strings.TrimSpace(req.Fill.CompanyName) == "") {
return fmt.Errorf("甲方企业名不能为空")
}
return nil
}
func firstNonEmpty(values ...string) string {
for _, v := range values {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}

View File

@@ -0,0 +1,69 @@
package fadada
import "testing"
func TestIsSignTaskCompletedStatus(t *testing.T) {
cases := map[string]bool{
SignTaskStatusFinished: true,
SignTaskStatusSignCompleted: true,
SignTaskStatusSignProgress: false,
SignTaskStatusTerminated: false,
"": false,
}
for status, want := range cases {
if got := IsSignTaskCompletedStatus(status); got != want {
t.Fatalf("status %q: got %v want %v", status, got, want)
}
}
}
func TestBuildSignActors(t *testing.T) {
c := NewClient(&Config{
AppID: "app",
AppSecret: "secret",
ServerURL: "https://uat-api.fadada.com/api/v5/",
OpenCorpID: "haiyu-open-corp",
TemplateID: "tpl",
})
actors := c.buildSignActors(&CreateSignFlowRequest{
PartyAOpenCorpID: "client-open-corp",
PartyAName: "测试企业",
PartyAUSCC: "91110000MA01234567",
TransactorMobile: "13800138000",
TransactorID: "110101199001011234",
})
if len(actors) != 2 {
t.Fatalf("actors len=%d", len(actors))
}
// 甲方先签 orderNo=1需手动验证乙方后签 orderNo=2模板免验证签自动盖章
if actors[0].Actor.ActorID != ActorIDPartyA || actors[0].SignConfigInfo.RequestVerifyFree || actors[0].SignConfigInfo.OrderNo != 1 {
t.Fatalf("party A (first/manual-sign)6 unexpected: %+v", actors[0])
}
if actors[1].Actor.ActorID != ActorIDPartyB || !actors[1].SignConfigInfo.RequestVerifyFree || actors[1].SignConfigInfo.OrderNo != 2 {
t.Fatalf("party B (second/free-sign) unexpected: %+v", actors[1])
}
if actors[1].SignConfigInfo.FreeSignType != freeSignTypeTemplate {
t.Fatalf("party B freeSignType=%q, want %s", actors[1].SignConfigInfo.FreeSignType, freeSignTypeTemplate)
}
if actors[0].Actor.ActorID == "party_a" || actors[1].Actor.ActorID == "party_b" {
t.Fatal("actorId must match sign-template role names, not party_a/party_b")
}
if actors[1].Actor.ActorOpenID != "haiyu-open-corp" {
t.Fatalf("party B openCorpId=%s", actors[1].Actor.ActorOpenID)
}
}
func TestParseCallback(t *testing.T) {
c := NewClient(&Config{AppID: "app", AppSecret: "secret", ServerURL: "https://x/"})
biz := `{"signTaskId":"st-1","signTaskStatus":"task_finished","eventTime":"2026-07-14T10:00:00+08:00"}`
ev, err := c.ParseCallback(map[string]string{"X-FASC-Event": "sign-task-finished"}, biz)
if err != nil {
t.Fatal(err)
}
if ev.SignTaskID != "st-1" || ev.SignTaskStatus != SignTaskStatusFinished {
t.Fatalf("unexpected event: %+v", ev)
}
if !ev.IsSignCompletedCallback() {
t.Fatal("expected completed callback")
}
}

View File

@@ -0,0 +1,262 @@
package fadada
import (
"fmt"
"strings"
"time"
)
// FillTemplate 填写文档模板并生成合同文件POST /doc-template/fill-values
func (c *Client) FillTemplate(req *ContractFillRequest) (*ContractFileResult, error) {
if err := c.config.ValidateTemplateFields(); err != nil {
return nil, err
}
if err := validateContractFillRequest(req); err != nil {
return nil, err
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return nil, err
}
signDate := strings.TrimSpace(req.SignDate)
if signDate == "" {
signDate = time.Now().Format("2006年01月02日")
}
fileName := strings.TrimSpace(req.FileName)
if fileName == "" {
if c.config.Contract != nil && c.config.Contract.Name != "" {
fileName = c.config.Contract.Name
} else {
fileName = "海宇数据-合作协议"
}
fileName = fmt.Sprintf("%s-%s", fileName, strings.TrimSpace(req.AgreementNo))
}
fieldValues := c.BuildTemplateFieldValues(&ContractFillRequest{
AgreementNo: req.AgreementNo,
CompanyName: req.CompanyName,
UnifiedSocialCode: req.UnifiedSocialCode,
EnterpriseAddress: req.EnterpriseAddress,
AuthorizedRepName: req.AuthorizedRepName,
SignDate: signDate,
})
docFieldValues := make([]docFieldValue, 0, len(fieldValues))
for _, fv := range fieldValues {
docFieldValues = append(docFieldValues, docFieldValue{
FieldID: fv.FieldID,
FieldValue: fv.FieldValue,
})
}
res, err := c.http.PostBiz(pathFillDocTemplateValues, accessToken, &fillDocTemplateReq{
OwnerID: &openID{IDType: "corp", OpenID: c.config.OpenCorpID},
DocTemplateID: c.config.TemplateID,
FileName: fileName,
DocFieldValues: docFieldValues,
})
if err != nil {
return nil, err
}
if res.Code != successCode {
return nil, fmt.Errorf("法大大填写模板失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data fillDocTemplateData
if err := decodeData(res.Data, &data); err != nil {
return nil, fmt.Errorf("解析模板填单响应失败: %w", err)
}
if data.FileID == "" {
return nil, fmt.Errorf("法大大填写模板失败: fileId 为空 requestId=%s", res.RequestID)
}
return &ContractFileResult{
FileID: data.FileID,
FileDownloadURL: data.FileDownloadURL,
}, nil
}
// FillSignTaskFields 向已创建的签署任务填写控件POST /sign-task/field/fill-values
func (c *Client) FillSignTaskFields(signTaskID string, req *ContractFillRequest) error {
if strings.TrimSpace(signTaskID) == "" {
return fmt.Errorf("signTaskId 不能为空")
}
if err := c.config.ValidateTemplateFields(); err != nil {
return err
}
if err := validateContractFillRequest(req); err != nil {
return err
}
accessToken, err := c.ensureAccessToken()
if err != nil {
return err
}
docID, err := c.resolveSignTaskDocID(signTaskID, accessToken)
if err != nil {
return err
}
signDate := strings.TrimSpace(req.SignDate)
if signDate == "" {
signDate = time.Now().Format("2006年01月02日")
}
fieldValues := c.BuildTemplateFieldValues(&ContractFillRequest{
AgreementNo: req.AgreementNo,
CompanyName: req.CompanyName,
UnifiedSocialCode: req.UnifiedSocialCode,
EnterpriseAddress: req.EnterpriseAddress,
AuthorizedRepName: req.AuthorizedRepName,
SignDate: signDate,
})
docFieldValues := make([]docFieldValue, 0, len(fieldValues))
for _, fv := range fieldValues {
docFieldValues = append(docFieldValues, docFieldValue{
DocID: docID,
FieldDocID: docID,
FieldID: fv.FieldID,
FieldValue: fv.FieldValue,
})
}
res, err := c.http.PostBiz(pathFillSignTaskFieldValues, accessToken, &fillSignTaskFieldsReq{
SignTaskID: signTaskID,
DocFieldValues: docFieldValues,
})
if err != nil {
return err
}
if res.Code != successCode {
return fmt.Errorf("法大大签署任务填单失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
return nil
}
// resolveSignTaskDocID 解析签署任务文档 docId填单必填
func (c *Client) resolveSignTaskDocID(signTaskID, accessToken string) (string, error) {
if c.config != nil {
if id := strings.TrimSpace(c.config.TemplateDocID); id != "" {
return id, nil
}
}
res, err := c.http.PostBiz(pathGetSignTaskDetail, accessToken, &getSignTaskDetailReq{
SignTaskID: signTaskID,
})
if err != nil {
return "", fmt.Errorf("查询签署任务文档失败: %w", err)
}
if res.Code != successCode {
return "", fmt.Errorf("查询签署任务文档失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data getSignTaskDetailData
if err := decodeData(res.Data, &data); err != nil {
return "", fmt.Errorf("解析签署任务文档失败: %w", err)
}
for _, d := range data.Docs {
if id := strings.TrimSpace(d.DocID); id != "" {
return id, nil
}
}
return "", fmt.Errorf("签署任务未返回 docId请在配置 fadada.template_doc_id 中填写模板文档ID")
}
// BuildTemplateFieldValues 将业务字段映射为法大大控件编码/值
func (c *Client) BuildTemplateFieldValues(req *ContractFillRequest) []TemplateFieldValue {
fields := c.config.TemplateFields
if fields == nil || req == nil {
return nil
}
signDate := strings.TrimSpace(req.SignDate)
agreementNo := strings.TrimSpace(req.AgreementNo)
companyName := strings.TrimSpace(req.CompanyName)
pairs := make([]struct {
fieldID string
value string
}, 0, len(fields.AgreementNo)+len(fields.PartyAName)+6)
for _, id := range fields.AgreementNo {
id = strings.TrimSpace(id)
if id == "" {
continue
}
pairs = append(pairs, struct {
fieldID string
value string
}{id, agreementNo})
}
for _, id := range fields.PartyAName {
id = strings.TrimSpace(id)
if id == "" {
continue
}
pairs = append(pairs, struct {
fieldID string
value string
}{id, companyName})
}
pairs = append(pairs,
struct {
fieldID string
value string
}{fields.ContractDate, signDate},
struct {
fieldID string
value string
}{fields.PartyAUSCC, strings.TrimSpace(req.UnifiedSocialCode)},
struct {
fieldID string
value string
}{fields.PartyAAddress, strings.TrimSpace(req.EnterpriseAddress)},
struct {
fieldID string
value string
}{fields.PartyARep, strings.TrimSpace(req.AuthorizedRepName)},
struct {
fieldID string
value string
}{fields.PartyASignDate, signDate},
struct {
fieldID string
value string
}{fields.PartyBSignDate, signDate},
)
out := make([]TemplateFieldValue, 0, len(pairs))
for _, p := range pairs {
if p.fieldID == "" {
continue
}
out = append(out, TemplateFieldValue{
FieldID: p.fieldID,
FieldValue: p.value,
})
}
return out
}
func validateContractFillRequest(req *ContractFillRequest) error {
if req == nil {
return fmt.Errorf("合同填单请求不能为空")
}
if strings.TrimSpace(req.AgreementNo) == "" {
return fmt.Errorf("协议编号不能为空")
}
if strings.TrimSpace(req.CompanyName) == "" {
return fmt.Errorf("甲方企业名不能为空")
}
if strings.TrimSpace(req.UnifiedSocialCode) == "" {
return fmt.Errorf("甲方统一信用代码不能为空")
}
if strings.TrimSpace(req.AuthorizedRepName) == "" {
return fmt.Errorf("甲方授权代表不能为空")
}
return nil
}

View File

@@ -0,0 +1,104 @@
package fadada
import (
"testing"
)
func TestBuildTemplateFieldValues(t *testing.T) {
client := NewClient(&Config{
AppID: "app",
AppSecret: "secret",
ServerURL: "https://uat-api.fadada.com/api/v5/",
OpenCorpID: "corp",
TemplateID: "1784013372537192273",
TemplateFields: &TemplateFields{
AgreementNo: []string{
"5199991781",
"0923405093",
},
ContractDate: "2039326319",
PartyAName: []string{
"6920634255",
"1847502631",
},
PartyAUSCC: "8702060291",
PartyAAddress: "4557539440",
PartyARep: "8627432823",
},
})
values := client.BuildTemplateFieldValues(&ContractFillRequest{
AgreementNo: "HYDATA-20260714-R000001",
CompanyName: "测试企业有限公司",
UnifiedSocialCode: "91110000MA01234567",
EnterpriseAddress: "海南省海口市",
AuthorizedRepName: "张三",
SignDate: "2026年07月14日",
})
want := map[string]string{
"5199991781": "HYDATA-20260714-R000001",
"0923405093": "HYDATA-20260714-R000001",
"6920634255": "测试企业有限公司",
"1847502631": "测试企业有限公司",
"2039326319": "2026年07月14日",
"8702060291": "91110000MA01234567",
"4557539440": "海南省海口市",
"8627432823": "张三",
}
if len(values) != len(want) {
t.Fatalf("field count = %d, want %d", len(values), len(want))
}
got := make(map[string]string, len(values))
for _, v := range values {
got[v.FieldID] = v.FieldValue
}
for id, val := range want {
if got[id] != val {
t.Fatalf("field %s = %q, want %q", id, got[id], val)
}
}
}
func TestResolveSignTaskDocIDFromConfig(t *testing.T) {
c := NewClient(&Config{
AppID: "app",
AppSecret: "secret",
ServerURL: "https://uat-api.fadada.com/api/v5/",
TemplateDocID: "19161471",
})
id, err := c.resolveSignTaskDocID("task-1", "")
if err != nil {
t.Fatal(err)
}
if id != "19161471" {
t.Fatalf("docId=%s", id)
}
}
func TestValidateTemplateFields(t *testing.T) {
cfg := &Config{
TemplateID: "1784013372537192273",
OpenCorpID: "corp",
TemplateFields: &TemplateFields{
AgreementNo: []string{"0923405093", "5199991781"},
ContractDate: "2039326319",
PartyAName: []string{"6920634255", "1847502631"},
PartyAUSCC: "8702060291",
PartyAAddress: "4557539440",
PartyARep: "8627432823",
},
}
if err := cfg.ValidateTemplateFields(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
cfg.TemplateFields.PartyAName = nil
if err := cfg.ValidateTemplateFields(); err == nil {
t.Fatal("expected error when party_a_name empty")
}
cfg.TemplateFields.PartyAName = []string{"6920634255"}
cfg.TemplateFields.AgreementNo = nil
if err := cfg.ValidateTemplateFields(); err == nil {
t.Fatal("expected error when agreement_no empty")
}
}

View File

@@ -0,0 +1,71 @@
package fadada
import (
"fmt"
"strconv"
"sync"
"time"
)
// tokenCache accessToken 内存缓存(官方有效期约 2 小时)
type tokenCache struct {
mu sync.Mutex
token string
expiresAt time.Time
}
func (c *tokenCache) get() (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token == "" || time.Now().After(c.expiresAt) {
return "", false
}
return c.token, true
}
func (c *tokenCache) set(token string, ttl time.Duration) {
c.mu.Lock()
defer c.mu.Unlock()
c.token = token
c.expiresAt = time.Now().Add(ttl)
}
// ensureAccessToken 获取可用 accessToken过期前自动刷新
func (c *Client) ensureAccessToken() (string, error) {
if token, ok := c.tokenCache.get(); ok {
return token, nil
}
c.tokenMu.Lock()
defer c.tokenMu.Unlock()
if token, ok := c.tokenCache.get(); ok {
return token, nil
}
res, err := c.http.PostToken(pathGetAccessToken)
if err != nil {
return "", err
}
if res.Code != "" && res.Code != successCode {
return "", fmt.Errorf("获取法大大 accessToken 失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID)
}
var data accessTokenData
if err := decodeData(res.Data, &data); err != nil {
return "", fmt.Errorf("解析 accessToken 失败: %w", err)
}
if data.AccessToken == "" {
return "", fmt.Errorf("获取法大大 accessToken 失败: 响应为空 code=%s msg=%s", res.Code, res.Msg)
}
ttl := 2*time.Hour - 5*time.Minute
if expiresIn, err := strconv.Atoi(data.ExpiresIn); err == nil && expiresIn > 0 {
ttl = time.Duration(expiresIn)*time.Second - 5*time.Minute
if ttl < time.Minute {
ttl = time.Minute
}
}
c.tokenCache.set(data.AccessToken, ttl)
return data.AccessToken, nil
}

View File

@@ -0,0 +1,167 @@
package fadada
// EnterpriseAuthRequest 企业认证请求(与 e签宝 字段对齐,便于后续统一调用)
type EnterpriseAuthRequest struct {
// ClientCorpID 企业在应用内的唯一标识;为空时默认使用统一社会信用代码
ClientCorpID string `json:"clientCorpId,omitempty"`
// ClientUserID 经办人在应用内的唯一标识;为空时默认使用经办人身份证号
ClientUserID string `json:"clientUserId,omitempty"`
CompanyName string `json:"companyName"`
UnifiedSocialCode string `json:"unifiedSocialCode"`
LegalPersonName string `json:"legalPersonName"`
LegalPersonID string `json:"legalPersonId"`
TransactorName string `json:"transactorName"`
TransactorMobile string `json:"transactorMobile"`
TransactorID string `json:"transactorId"`
}
// EnterpriseAuthResult 企业认证结果
type EnterpriseAuthResult struct {
// AuthFlowID 映射为法大大 clientCorpId便于后续按企业标识查询
AuthFlowID string `json:"authFlowId"`
AuthURL string `json:"authUrl"`
AuthShortURL string `json:"authShortUrl"`
}
// QueryOrgIdentityRequest 查询企业是否已实名
type QueryOrgIdentityRequest struct {
CorpName string `json:"corpName"`
CorpIdentNo string `json:"corpIdentNo"` // 统一社会信用代码
}
// QueryOrgIdentityResult 企业实名查询结果
type QueryOrgIdentityResult struct {
Identified bool `json:"identified"`
CorpName string `json:"corpName"`
CorpIdentNo string `json:"corpIdentNo"`
}
// ContractFillRequest 合作协议模板填单请求(对齐 e签宝 generateAndAddContractFile 入参)
type ContractFillRequest struct {
AgreementNo string `json:"agreementNo"` // 协议编号
CompanyName string `json:"companyName"` // 甲方企业名
UnifiedSocialCode string `json:"unifiedSocialCode"` // 甲方统一信用代码
EnterpriseAddress string `json:"enterpriseAddress"` // 甲方联系地址
AuthorizedRepName string `json:"authorizedRepName"` // 甲方授权代表/法人
// SignDate 签署/签订日期建议格式2006年01月02日为空则由调用方保证或使用当天
SignDate string `json:"signDate"`
FileName string `json:"fileName,omitempty"` // 生成文件名,可选
}
// ContractFileResult 模板填单结果(对齐 e签宝 FillTemplate 返回)
type ContractFileResult struct {
FileID string `json:"fileId"`
FileDownloadURL string `json:"fileDownloadUrl"`
}
// TemplateFieldValue 单个控件填写值
type TemplateFieldValue struct {
FieldID string `json:"fieldId"`
FieldValue string `json:"fieldValue"`
}
// 签署任务参与方 ActorId
// 基于签署模板创建时,必须与模板中「签署角色」名称完全一致。
const (
ActorIDPartyA = "甲方" // 甲方(企业入驻方)
ActorIDPartyB = "乙方" // 乙方(平台海宇)
)
// 签署任务状态(法大大 signTaskStatus
const (
SignTaskStatusCreated = "task_created"
SignTaskStatusFillProgress = "fill_progress"
SignTaskStatusFillCompleted = "fill_completed"
SignTaskStatusSignProgress = "sign_progress"
SignTaskStatusSignCompleted = "sign_completed"
SignTaskStatusFinished = "task_finished"
SignTaskStatusTerminated = "task_terminated"
)
// CreateSignFlowRequest 基于签署任务模板创建签署任务请求
type CreateSignFlowRequest struct {
// Subject 签署任务主题;为空则用合同名称
Subject string `json:"subject,omitempty"`
// PartyAOpenCorpID 甲方在法大大侧 openCorpId企业认证授权完成后获得
PartyAOpenCorpID string `json:"partyAOpenCorpId,omitempty"`
PartyAName string `json:"partyAName"`
PartyAUSCC string `json:"partyAUscc,omitempty"`
TransactorName string `json:"transactorName,omitempty"`
TransactorMobile string `json:"transactorMobile,omitempty"`
TransactorID string `json:"transactorId,omitempty"`
// TransReferenceID 业务侧关联号(如认证记录 ID
TransReferenceID string `json:"transReferenceId,omitempty"`
// Fill 创建模板任务后立即填写控件;非空时创建不 autoStart填完后自动 Start
Fill *ContractFillRequest `json:"fill,omitempty"`
}
// CreateSignFlowResult 创建签署任务结果
type CreateSignFlowResult struct {
SignTaskID string `json:"signTaskId"`
}
// SignURLResult 参与方签署链接
type SignURLResult struct {
SignURL string `json:"signUrl"`
EmbedURL string `json:"embedUrl,omitempty"`
ActorID string `json:"actorId"`
SignTaskID string `json:"signTaskId"`
}
// SignTaskPreviewURLResult 签署任务预览链接EUI非 PDF 直链)
type SignTaskPreviewURLResult struct {
SignTaskID string `json:"signTaskId"`
PreviewURL string `json:"previewUrl"`
}
// SignStatusResult 签署状态查询结果
type SignStatusResult struct {
SignTaskID string `json:"signTaskId"`
SignTaskStatus string `json:"signTaskStatus"`
Completed bool `json:"completed"`
Terminated bool `json:"terminated"`
FinishTime string `json:"finishTime,omitempty"`
TerminationNote string `json:"terminationNote,omitempty"`
RevokeReason string `json:"revokeReason,omitempty"`
}
// SignedFile 已签署文件下载信息
type SignedFile struct {
DownloadURL string `json:"downloadUrl"`
DownloadID string `json:"downloadId,omitempty"`
}
// DownloadSignedFilesResult 下载结果
type DownloadSignedFilesResult struct {
Files []*SignedFile `json:"files"`
}
// CallbackEvent 法大大回调事件(认证/签署)
type CallbackEvent struct {
Event string `json:"event"`
SignTaskID string `json:"signTaskId,omitempty"`
SignTaskStatus string `json:"signTaskStatus,omitempty"`
ClientCorpID string `json:"clientCorpId,omitempty"`
OpenCorpID string `json:"openCorpId,omitempty"`
AuthResult string `json:"authResult,omitempty"`
EventTime string `json:"eventTime,omitempty"`
Raw map[string]interface{} `json:"raw,omitempty"`
}
// ContractSigningRequest 一站式:填单 → 创建签署 → 取甲方链接
type ContractSigningRequest struct {
CreateSignFlowRequest
Fill ContractFillRequest `json:"fill"`
}
// ContractSigningResult 一站式签署结果
type ContractSigningResult struct {
SignTaskID string `json:"signTaskId"`
SignURL string `json:"signUrl"`
EmbedURL string `json:"embedUrl,omitempty"`
}