diff --git a/internal/app/app.go b/internal/app/app.go index 3364d4b..7f188d5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -222,6 +222,7 @@ func (a *Application) autoMigrate(db *gorm.DB) error { // 认证域 &certEntities.Certification{}, &certEntities.EnterpriseInfoSubmitRecord{}, + &certEntities.FadadaCorpAuthRecord{}, &certEntities.EsignContractGenerateRecord{}, &certEntities.EsignContractSignRecord{}, diff --git a/internal/application/certification/already_authorized_test.go b/internal/application/certification/already_authorized_test.go index 27abc87..cea7f99 100644 --- a/internal/application/certification/already_authorized_test.go +++ b/internal/application/certification/already_authorized_test.go @@ -2,7 +2,10 @@ package certification import ( "errors" + "fmt" "testing" + + "hyapi-server/internal/shared/fadada" ) func TestIsEnterpriseAlreadyAuthorizedErr(t *testing.T) { @@ -12,18 +15,38 @@ func TestIsEnterpriseAlreadyAuthorizedErr(t *testing.T) { want bool }{ { - name: "fadada 210002", + name: "structured 210002", + err: fadada.NewAPIError("获取法大大企业认证链接失败", fadada.CodeAuthScopeDuplicate, "授权范围重复获取", "abc"), + want: true, + }, + { + name: "structured 210013", + err: fadada.NewAPIError("获取法大大企业认证链接失败", fadada.CodeAuthScopeDuplicateCorp, "授权范围重复获取", "def"), + want: true, + }, + { + name: "legacy string 210002", err: errors.New("获取法大大企业认证链接失败: code=210002 msg=该企业已授权,无需重复操作 requestId=abc"), want: true, }, { - name: "exist authorize wording", - err: errors.New("当前应用已存在授权"), + name: "legacy string 210013", + err: errors.New("获取法大大企业认证链接失败: code=210013 msg=授权范围重复获取 requestId=xyz"), want: true, }, { - name: "other error", - err: errors.New("获取法大大企业认证链接失败: code=100011 msg=参数错误"), + name: "wrapped structured 210002", + err: fmt.Errorf("生成企业认证链接失败: %w", fadada.NewAPIError("获取法大大企业认证链接失败", fadada.CodeAuthScopeDuplicate, "授权范围重复获取", "rid")), + want: true, + }, + { + name: "msg only should not match", + err: errors.New("当前应用已存在授权"), + want: false, + }, + { + name: "other error 100011", + err: fadada.NewAPIError("获取法大大企业认证链接失败", fadada.CodeParamIllegal, "请求参数非法", "rid"), want: false, }, { diff --git a/internal/application/certification/certification_application_service_impl.go b/internal/application/certification/certification_application_service_impl.go index 900c2c6..948f3e1 100644 --- a/internal/application/certification/certification_application_service_impl.go +++ b/internal/application/certification/certification_application_service_impl.go @@ -27,6 +27,7 @@ import ( "hyapi-server/internal/infrastructure/external/storage" "hyapi-server/internal/shared/database" "hyapi-server/internal/shared/esign" + "hyapi-server/internal/shared/fadada" sharedOCR "hyapi-server/internal/shared/ocr" "go.uber.org/zap" @@ -51,6 +52,7 @@ type CertificationApplicationServiceImpl struct { // 仓储依赖 queryRepository repositories.CertificationQueryRepository enterpriseInfoSubmitRecordRepo repositories.EnterpriseInfoSubmitRecordRepository + fadadaCorpAuthRecordRepo repositories.FadadaCorpAuthRecordRepository txManager *database.TransactionManager wechatWorkService *notification.WeChatWorkService @@ -64,6 +66,7 @@ func NewCertificationApplicationService( userAggregateService user_service.UserAggregateService, queryRepository repositories.CertificationQueryRepository, enterpriseInfoSubmitRecordRepo repositories.EnterpriseInfoSubmitRecordRepository, + fadadaCorpAuthRecordRepo repositories.FadadaCorpAuthRecordRepository, smsCodeService *user_service.SMSCodeService, esignClient *esign.Client, esignConfig *esign.Config, @@ -87,6 +90,7 @@ func NewCertificationApplicationService( userAggregateService: userAggregateService, queryRepository: queryRepository, enterpriseInfoSubmitRecordRepo: enterpriseInfoSubmitRecordRepo, + fadadaCorpAuthRecordRepo: fadadaCorpAuthRecordRepo, smsCodeService: smsCodeService, esignClient: esignClient, esignConfig: esignConfig, @@ -160,6 +164,9 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( } } + // 企业名半角括号统一为全角,避免与工商登记不一致 + cmd.CompanyName = normalizeCompanyNameParentheses(cmd.CompanyName) + // 1.5 插入企业信息提交记录(包含扩展字段) record := entities.NewEnterpriseInfoSubmitRecord( cmd.UserID, @@ -445,7 +452,7 @@ func (s *CertificationApplicationServiceImpl) ConfirmAuth( s.logger.Info("确认状态-步骤2-获取最近提交记录成功", zap.String("user_id", cmd.UserID), zap.String("record_id", record.ID)) - s.logger.Info("确认状态-步骤3-开始查询三方实名状态", + s.logger.Info("确认状态-步骤3-开始查询三方实名/授权状态", zap.String("user_id", cmd.UserID), zap.String("company_name", record.CompanyName), zap.String("sign_platform", string(cert.ResolvedSignPlatform()))) @@ -453,6 +460,19 @@ func (s *CertificationApplicationServiceImpl) ConfirmAuth( if err != nil { return nil, err } + + // 法大大:ConfirmAuth 时再查一次授权状态并落库 openCorpId + authorizedByCorpGet := false + if cert.ResolvedSignPlatform() == enums.SignPlatformFadada { + authQuery := s.resolveCorpAuthQueryForGet(ctx, cert.UserID, record.UnifiedSocialCode, record.UnifiedSocialCode) + if authStatus, qErr := provider.QueryCorpAuthStatus(ctx, authQuery); qErr != nil { + s.logger.Warn("确认认证时查询企业授权状态失败", zap.Error(qErr), zap.String("user_id", cmd.UserID)) + } else if authStatus != nil && authStatus.Supported { + s.persistFadadaCorpAuthStatus(ctx, cert, record, authStatus, "corp_get_confirm_auth") + authorizedByCorpGet = authStatus.Authorized + } + } + verified, err := provider.QueryOrgVerified(ctx, &ports.OrgIdentityQuery{ OrgName: record.CompanyName, OrgIdentNo: record.UnifiedSocialCode, @@ -461,6 +481,9 @@ func (s *CertificationApplicationServiceImpl) ConfirmAuth( s.logger.Error("查询企业认证信息失败", zap.Error(err)) return nil, fmt.Errorf("查询企业认证信息失败: %w", err) } + if authorizedByCorpGet { + verified = true + } reason := "" if verified { s.logger.Info("确认状态-步骤3-三方实名状态已完成,准备事务内推进认证", @@ -547,9 +570,14 @@ func (s *CertificationApplicationServiceImpl) ApplyContract( return nil, err } ei := enterpriseInfo.EnterpriseInfo + submitRec, _ := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cmd.UserID) + _, transactorMobile, _ := pickTransactorFromRecord(submitRec) + if transactorMobile == "" { + transactorMobile = ei.LegalPersonPhone + } urlRes, err := provider.GetActorSignURL(ctx, &ports.GetActorSignURLRequest{ SignFlowID: flowID, - TransactorMobile: ei.LegalPersonPhone, + TransactorMobile: transactorMobile, PartyAName: ei.CompanyName, }) if err != nil { @@ -625,9 +653,14 @@ func (s *CertificationApplicationServiceImpl) RefreshContractSignURL( return nil, fmt.Errorf("获取企业信息失败: %w", err) } ei := enterpriseInfo.EnterpriseInfo + submitRec, _ := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, userID) + _, transactorMobile, _ := pickTransactorFromRecord(submitRec) + if transactorMobile == "" { + transactorMobile = ei.LegalPersonPhone + } urlRes, err := provider.GetActorSignURL(ctx, &ports.GetActorSignURLRequest{ SignFlowID: cert.EsignFlowID, - TransactorMobile: ei.LegalPersonPhone, + TransactorMobile: transactorMobile, PartyAName: ei.CompanyName, }) if err != nil { @@ -1349,16 +1382,11 @@ func isEnterpriseAlreadyRealnamedErr(err error) bool { return strings.Contains(msg, "企业用户已实名") || strings.Contains(msg, "已实名") } -// isEnterpriseAlreadyAuthorizedErr 法大大等平台:企业已对本应用授权,无需再获取授权链接(如 code=210002) +// isEnterpriseAlreadyAuthorizedErr 法大大:授权范围重复获取,无需再获取授权链接。 +// 官方错误码说明要求按 code 判断(210002 / 210013),勿依赖 msg 文案。 +// 文档:https://dev.fadada.com/api-doc/JOQQIJXLFL/H4F0HSKMHQ3HPIIM/5-1 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, "已存在授权") + return fadada.IsAuthAlreadyGranted(err) } // validateApplyContractCommand 验证申请合同命令 @@ -1380,6 +1408,44 @@ func (s *CertificationApplicationServiceImpl) validateContractApplicationPrecond return nil } +// pickAuthorizedRepName 合同模板「客户授权代表」: 优先企业提交记录中的授权代表, 否则为法定代表人 +func pickAuthorizedRepName(record *entities.EnterpriseInfoSubmitRecord, legalPersonName string) string { + if record != nil && strings.TrimSpace(record.AuthorizedRepName) != "" { + return strings.TrimSpace(record.AuthorizedRepName) + } + return legalPersonName +} + +// normalizeCompanyNameParentheses 将企业名中的半角括号 () 转为全角 () +func normalizeCompanyNameParentheses(name string) string { + name = strings.TrimSpace(name) + if name == "" { + return name + } + replacer := strings.NewReplacer("(", "(", ")", ")") + return replacer.Replace(name) +} + +// pickTransactorFromRecord 法大大经办人:优先授权代表,缺省回退法定代表人 +func pickTransactorFromRecord(record *entities.EnterpriseInfoSubmitRecord) (name, mobile, idNo string) { + if record == nil { + return "", "", "" + } + name = strings.TrimSpace(record.AuthorizedRepName) + mobile = strings.TrimSpace(record.AuthorizedRepPhone) + idNo = strings.TrimSpace(record.AuthorizedRepID) + if name == "" { + name = strings.TrimSpace(record.LegalPersonName) + } + if mobile == "" { + mobile = strings.TrimSpace(record.LegalPersonPhone) + } + if idNo == "" { + idNo = strings.TrimSpace(record.LegalPersonID) + } + return name, mobile, idNo +} + // generateContractAndSignURL 生成合同和签署链接 func (s *CertificationApplicationServiceImpl) generateContractAndSignURL(ctx context.Context, cert *entities.Certification, enterpriseInfo *user_entities.EnterpriseInfo) (*certification_value_objects.ContractInfo, error) { provider, err := s.resolveProvider(cert) @@ -1395,14 +1461,25 @@ func (s *CertificationApplicationServiceImpl) generateContractAndSignURL(ctx con address = record.EnterpriseAddress } } + transactorName, transactorMobile, transactorID := pickTransactorFromRecord(record) + if transactorName == "" { + transactorName = enterpriseInfo.LegalPersonName + } + if transactorMobile == "" { + transactorMobile = enterpriseInfo.LegalPersonPhone + } + if transactorID == "" { + transactorID = enterpriseInfo.LegalPersonID + } req := &ports.SignFlowCreateRequest{ Subject: "海宇数据-合作协议-" + enterpriseInfo.CompanyName, FileID: cert.ContractFileID, + PartyAOpenCorpID: s.partyAOpenCorpIDFromStore(ctx, cert.UserID, enterpriseInfo.UnifiedSocialCode), PartyAName: enterpriseInfo.CompanyName, PartyAUSCC: enterpriseInfo.UnifiedSocialCode, - TransactorName: enterpriseInfo.LegalPersonName, - TransactorMobile: enterpriseInfo.LegalPersonPhone, - TransactorID: enterpriseInfo.LegalPersonID, + TransactorName: transactorName, + TransactorMobile: transactorMobile, + TransactorID: transactorID, TransReferenceID: cert.ID, Fill: ensureContractFill(cert, enterpriseInfo.CompanyName, enterpriseInfo.UnifiedSocialCode, address, repName), } @@ -1450,6 +1527,7 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification( zap.String("user_id", userID), zap.String("record_id", record.ID)) + // 新用户:创建企业信息;重新认证:更新已有企业信息(CreateOrUpdateEnterpriseInfo 内部分流) err = s.userAggregateService.CreateOrUpdateEnterpriseInfo( ctx, userID, @@ -1464,7 +1542,7 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification( s.logger.Error("保存企业信息到用户域失败", zap.Error(err)) return fmt.Errorf("保存企业信息失败: %s", err.Error()) } - 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)) @@ -1512,14 +1590,16 @@ func (s *CertificationApplicationServiceImpl) createSignTaskAfterEnterpriseVerif } repName := pickAuthorizedRepName(record, record.LegalPersonName) address := record.EnterpriseAddress + transactorName, transactorMobile, transactorID := pickTransactorFromRecord(record) req := &ports.SignFlowCreateRequest{ Subject: "海宇数据-合作协议-" + record.CompanyName, FileID: cert.ContractFileID, + PartyAOpenCorpID: s.partyAOpenCorpIDFromStore(ctx, cert.UserID, record.UnifiedSocialCode), PartyAName: record.CompanyName, PartyAUSCC: record.UnifiedSocialCode, - TransactorName: record.LegalPersonName, - TransactorMobile: record.LegalPersonPhone, - TransactorID: record.LegalPersonID, + TransactorName: transactorName, + TransactorMobile: transactorMobile, + TransactorID: transactorID, TransReferenceID: cert.ID, Fill: ensureContractFill(cert, record.CompanyName, record.UnifiedSocialCode, address, repName), SkipActorURL: true, // 预创建不取甲方链接,避免消耗单次 EmbedURL @@ -1534,14 +1614,6 @@ func (s *CertificationApplicationServiceImpl) createSignTaskAfterEnterpriseVerif return nil } -// pickAuthorizedRepName 合同模板「客户授权代表」: 优先企业提交记录中的授权代表, 否则为法定代表人 -func pickAuthorizedRepName(record *entities.EnterpriseInfoSubmitRecord, legalPersonName string) string { - if record != nil && strings.TrimSpace(record.AuthorizedRepName) != "" { - return strings.TrimSpace(record.AuthorizedRepName) - } - return legalPersonName -} - // generateAndAddContractFile 生成并添加合同文件的公共方法 func (s *CertificationApplicationServiceImpl) generateAndAddContractFile( ctx context.Context, diff --git a/internal/application/certification/fadada_corp_auth.go b/internal/application/certification/fadada_corp_auth.go new file mode 100644 index 0000000..8bd18b9 --- /dev/null +++ b/internal/application/certification/fadada_corp_auth.go @@ -0,0 +1,151 @@ +package certification + +import ( + "context" + "errors" + "strings" + + "hyapi-server/internal/domains/certification/entities" + "hyapi-server/internal/domains/certification/ports" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +// resolveCorpAuthQueryForGet 构造 /corp/get 入参(三选一)。 +// 优先级:库中 openCorpId > corpIdentNo(USCC) > 库中 clientCorpId +func (s *CertificationApplicationServiceImpl) resolveCorpAuthQueryForGet( + ctx context.Context, + userID, uscc, fallbackClientCorpID string, +) *ports.CorpAuthStatusQuery { + uscc = strings.TrimSpace(uscc) + fallbackClientCorpID = strings.TrimSpace(fallbackClientCorpID) + rec := s.loadFadadaCorpAuthRecord(ctx, userID, uscc, fallbackClientCorpID) + + q := &ports.CorpAuthStatusQuery{} + if rec != nil && strings.TrimSpace(rec.OpenCorpID) != "" { + q.OpenCorpID = strings.TrimSpace(rec.OpenCorpID) + return q + } + if uscc != "" { + q.CorpIdentNo = uscc + return q + } + if rec != nil && strings.TrimSpace(rec.ClientCorpID) != "" { + q.ClientCorpID = strings.TrimSpace(rec.ClientCorpID) + return q + } + if fallbackClientCorpID != "" { + q.ClientCorpID = fallbackClientCorpID + } + return q +} + +func (s *CertificationApplicationServiceImpl) loadFadadaCorpAuthRecord( + ctx context.Context, + userID, uscc, clientCorpID string, +) *entities.FadadaCorpAuthRecord { + if s.fadadaCorpAuthRecordRepo == nil { + return nil + } + if userID != "" { + if rec, err := s.fadadaCorpAuthRecordRepo.FindLatestByUserID(ctx, userID); err == nil && rec != nil { + return rec + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.logger.Warn("按用户查询法大大授权记录失败", zap.String("user_id", userID), zap.Error(err)) + } + } + if uscc != "" { + if rec, err := s.fadadaCorpAuthRecordRepo.FindByUnifiedSocialCode(ctx, uscc); err == nil && rec != nil { + return rec + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.logger.Warn("按信用代码查询法大大授权记录失败", zap.String("uscc", uscc), zap.Error(err)) + } + } + if clientCorpID != "" { + if rec, err := s.fadadaCorpAuthRecordRepo.FindByClientCorpID(ctx, clientCorpID); err == nil && rec != nil { + return rec + } else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + s.logger.Warn("按clientCorpId查询法大大授权记录失败", zap.String("client_corp_id", clientCorpID), zap.Error(err)) + } + } + return nil +} + +func (s *CertificationApplicationServiceImpl) fadadaAppID() string { + if s.config != nil { + return strings.TrimSpace(s.config.Fadada.AppID) + } + return "" +} + +func (s *CertificationApplicationServiceImpl) ensureFadadaCorpAuthSeed( + ctx context.Context, + cert *entities.Certification, + record *entities.EnterpriseInfoSubmitRecord, + clientCorpID string, +) { + if s.fadadaCorpAuthRecordRepo == nil || cert == nil || record == nil { + return + } + seed := entities.NewFadadaCorpAuthRecord( + cert.UserID, + cert.ID, + s.fadadaAppID(), + record.CompanyName, + record.UnifiedSocialCode, + clientCorpID, + ) + seed.Source = "select_sign_platform" + if err := s.fadadaCorpAuthRecordRepo.UpsertByBizKey(ctx, seed); err != nil { + s.logger.Warn("初始化法大大授权记录失败", zap.Error(err), zap.String("user_id", cert.UserID)) + } +} + +func (s *CertificationApplicationServiceImpl) persistFadadaCorpAuthStatus( + ctx context.Context, + cert *entities.Certification, + record *entities.EnterpriseInfoSubmitRecord, + status *ports.CorpAuthStatusResult, + source string, +) { + if s.fadadaCorpAuthRecordRepo == nil || status == nil || !status.Found { + return + } + userID, certID, company, uscc := "", "", "", "" + if cert != nil { + userID = cert.UserID + certID = cert.ID + } + if record != nil { + company = record.CompanyName + uscc = record.UnifiedSocialCode + if userID == "" { + userID = record.UserID + } + } + rec := entities.NewFadadaCorpAuthRecord(userID, certID, s.fadadaAppID(), company, uscc, status.ClientCorpID) + rec.ApplyAuthStatus( + status.OpenCorpID, + status.ClientCorpID, + status.BindingStatus, + status.IdentStatus, + "", + nil, + source, + ) + if err := s.fadadaCorpAuthRecordRepo.UpsertByBizKey(ctx, rec); err != nil { + s.logger.Warn("保存法大大授权状态失败", zap.Error(err), zap.String("user_id", userID), zap.String("source", source)) + } +} + +func (s *CertificationApplicationServiceImpl) partyAOpenCorpIDFromStore( + ctx context.Context, + userID, uscc string, +) string { + rec := s.loadFadadaCorpAuthRecord(ctx, userID, uscc, uscc) + if rec == nil { + return "" + } + return strings.TrimSpace(rec.OpenCorpID) +} diff --git a/internal/application/certification/platform_flow.go b/internal/application/certification/platform_flow.go index 1ff1e43..e0603ff 100644 --- a/internal/application/certification/platform_flow.go +++ b/internal/application/certification/platform_flow.go @@ -88,19 +88,23 @@ func (s *CertificationApplicationServiceImpl) SelectSignPlatform( return nil, err } + transactorName, transactorMobile, transactorID := pickTransactorFromRecord(record) + clientCorpID := strings.TrimSpace(record.UnifiedSocialCode) authReq := &ports.EnterpriseAuthRequest{ - ClientCorpID: record.UnifiedSocialCode, - ClientUserID: record.LegalPersonID, + ClientCorpID: clientCorpID, + ClientUserID: transactorID, CompanyName: record.CompanyName, UnifiedSocialCode: record.UnifiedSocialCode, LegalPersonName: record.LegalPersonName, LegalPersonID: record.LegalPersonID, - TransactorName: record.LegalPersonName, - TransactorMobile: record.LegalPersonPhone, - TransactorID: record.LegalPersonID, + TransactorName: transactorName, + TransactorMobile: transactorMobile, + TransactorID: transactorID, } + // 选平台时先落库种子数据(clientCorpId/USCC),供后续 /corp/get 与签署复用 + s.ensureFadadaCorpAuthSeed(ctx, cert, record, clientCorpID) - authRes, alreadyVerified, err := s.generateEnterpriseAuthOrDetectVerifiedWithProvider(ctx, provider, authReq) + authRes, alreadyVerified, err := s.generateEnterpriseAuthOrDetectVerifiedWithProvider(ctx, provider, authReq, cert, record) if err != nil { return nil, fmt.Errorf("生成企业认证链接失败: %w", err) } @@ -166,7 +170,20 @@ func (s *CertificationApplicationServiceImpl) HandleFadadaCallback(ctx context.C zap.String("event", ev.Event), zap.Bool("auth_passed", ev.AuthPassed), zap.Bool("sign_completed", ev.SignCompleted), - zap.String("sign_flow_id", ev.SignFlowID)) + zap.String("sign_flow_id", ev.SignFlowID), + zap.String("open_corp_id", ev.OpenCorpID), + zap.String("client_corp_id", ev.ClientCorpID)) + + // 授权成功:落库 openCorpId / clientCorpId + if ev.AuthPassed && (strings.TrimSpace(ev.OpenCorpID) != "" || strings.TrimSpace(ev.ClientCorpID) != "") { + rec := entities.NewFadadaCorpAuthRecord("", "", s.fadadaAppID(), ev.OrgName, "", ev.ClientCorpID) + rec.ApplyAuthStatus(ev.OpenCorpID, ev.ClientCorpID, "authorized", "", "", nil, "callback_auth_passed") + if s.fadadaCorpAuthRecordRepo != nil { + if err := s.fadadaCorpAuthRecordRepo.UpsertByBizKey(ctx, rec); err != nil { + s.logger.Warn("回调落库法大大授权信息失败", zap.Error(err)) + } + } + } // 认证完成主要依赖 ConfirmAuth / details 轮询;签署完成按任务 ID 兜底推进 if ev.SignCompleted && ev.SignFlowID != "" { @@ -186,19 +203,59 @@ func (s *CertificationApplicationServiceImpl) generateEnterpriseAuthOrDetectVeri ctx context.Context, provider ports.SignPlatformProvider, authReq *ports.EnterpriseAuthRequest, + cert *entities.Certification, + record *entities.EnterpriseInfoSubmitRecord, ) (*ports.AuthLinkResult, bool, error) { + userID := "" + if cert != nil { + userID = cert.UserID + } + // 1) 正式查授权:POST /corp/get(三选一:库 openCorpId > USCC corpIdentNo > 库 clientCorpId) + authQuery := s.resolveCorpAuthQueryForGet(ctx, userID, authReq.UnifiedSocialCode, authReq.ClientCorpID) + s.logger.Info("查询企业授权状态入参", + zap.String("company_name", authReq.CompanyName), + zap.String("open_corp_id", authQuery.OpenCorpID), + zap.String("corp_ident_no", authQuery.CorpIdentNo), + zap.String("client_corp_id", authQuery.ClientCorpID), + zap.String("platform", string(provider.Platform()))) + authStatus, qErr := provider.QueryCorpAuthStatus(ctx, authQuery) + if qErr != nil { + s.logger.Warn("查询企业授权状态失败,将继续尝试获取授权链接", + zap.String("company_name", authReq.CompanyName), + zap.String("uscc", authReq.UnifiedSocialCode), + zap.String("platform", string(provider.Platform())), + zap.Error(qErr)) + } else if authStatus != nil && authStatus.Supported { + s.persistFadadaCorpAuthStatus(ctx, cert, record, authStatus, "corp_get_select_platform") + if authStatus.Authorized { + s.logger.Info("查询企业授权状态为已授权,跳过认证链接并视为认证完成", + zap.String("company_name", authReq.CompanyName), + zap.String("uscc", authReq.UnifiedSocialCode), + zap.String("platform", string(provider.Platform())), + zap.String("binding_status", authStatus.BindingStatus), + zap.String("open_corp_id", authStatus.OpenCorpID), + zap.String("ident_status", authStatus.IdentStatus)) + return nil, true, nil + } + } + + // 2) 未授权或不支持查询:走获取授权链接 authRes, err := provider.GenerateEnterpriseAuth(ctx, authReq) if err == nil { return authRes, false, nil } - // 法大大:企业已对本应用授权(210002)= 授权已完成,直接跳过授权页 + // 3) 兜底:get-auth-url 返回授权范围重复获取(210002/210013) if isEnterpriseAlreadyAuthorizedErr(err) { - s.logger.Info("第三方返回企业已授权,跳过认证链接并视为认证完成", + s.logger.Info("获取授权链接返回重复授权错误码,跳过认证链接并视为认证完成", zap.String("company_name", authReq.CompanyName), zap.String("uscc", authReq.UnifiedSocialCode), zap.String("platform", string(provider.Platform())), zap.Error(err)) + // 再查一次 /corp/get,尽量把 openCorpId 落库 + if authStatus2, qErr2 := provider.QueryCorpAuthStatus(ctx, authQuery); qErr2 == nil && authStatus2 != nil { + s.persistFadadaCorpAuthStatus(ctx, cert, record, authStatus2, "corp_get_after_duplicate_auth") + } return nil, true, nil } diff --git a/internal/container/container.go b/internal/container/container.go index 79c7b97..418d27e 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -614,6 +614,11 @@ func NewContainer() *Container { certification_repo.NewGormEnterpriseInfoSubmitRecordRepository, fx.As(new(domain_certification_repo.EnterpriseInfoSubmitRecordRepository)), ), + // 法大大企业授权记录仓储 + fx.Annotate( + certification_repo.NewGormFadadaCorpAuthRecordRepository, + fx.As(new(domain_certification_repo.FadadaCorpAuthRecordRepository)), + ), ), // 仓储层 - 财务域 @@ -986,6 +991,7 @@ func NewContainer() *Container { userAggregateService user_service.UserAggregateService, queryRepository domain_certification_repo.CertificationQueryRepository, enterpriseInfoSubmitRecordRepo domain_certification_repo.EnterpriseInfoSubmitRecordRepository, + fadadaCorpAuthRecordRepo domain_certification_repo.FadadaCorpAuthRecordRepository, smsCodeService *user_service.SMSCodeService, esignClient *esign.Client, esignConfig *esign.Config, @@ -1005,6 +1011,7 @@ func NewContainer() *Container { userAggregateService, queryRepository, enterpriseInfoSubmitRecordRepo, + fadadaCorpAuthRecordRepo, smsCodeService, esignClient, esignConfig, diff --git a/internal/domains/certification/entities/fadada_corp_auth_record.go b/internal/domains/certification/entities/fadada_corp_auth_record.go new file mode 100644 index 0000000..ca9f4fe --- /dev/null +++ b/internal/domains/certification/entities/fadada_corp_auth_record.go @@ -0,0 +1,89 @@ +package entities + +import ( + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// FadadaCorpAuthRecord 法大大企业授权相关数据(与 certifications 分离,便于签署链路复用) +// open_corp_id:企业对本应用授权后,法大大分配的企业标识 +type FadadaCorpAuthRecord struct { + ID string `json:"id" gorm:"primaryKey;type:varchar(36)"` + UserID string `json:"user_id" gorm:"type:varchar(36);not null;index;comment:用户ID"` + CertificationID string `json:"certification_id" gorm:"type:varchar(36);index;comment:关联认证记录ID"` + + AppID string `json:"app_id" gorm:"type:varchar(32);index;comment:法大大应用AppId"` + CompanyName string `json:"company_name" gorm:"type:varchar(200);comment:企业名称"` + UnifiedSocialCode string `json:"unified_social_code" gorm:"column:unified_social_code;type:varchar(50);index;comment:统一社会信用代码(corpIdentNo)"` + ClientCorpID string `json:"client_corp_id" gorm:"column:client_corp_id;type:varchar(64);index;comment:应用内企业标识(clientCorpId)"` + OpenCorpID string `json:"open_corp_id" gorm:"column:open_corp_id;type:varchar(64);index;comment:法大大企业openCorpId"` + + BindingStatus string `json:"binding_status" gorm:"type:varchar(32);comment:授权绑定状态 unauthorized/authorized"` + IdentStatus string `json:"ident_status" gorm:"type:varchar(32);comment:实名状态 unidentified/identified"` + AvailableStatus string `json:"available_status" gorm:"type:varchar(32);comment:启用状态 disable/enable"` + AuthScopes string `json:"auth_scopes" gorm:"type:text;comment:授权范围JSON数组字符串"` + Source string `json:"source" gorm:"type:varchar(64);comment:数据来源"` + + CreatedAt time.Time `json:"created_at" gorm:"not null"` + UpdatedAt time.Time `json:"updated_at" gorm:"not null"` + DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` +} + +func (FadadaCorpAuthRecord) TableName() string { + return "fadada_corp_auth_records" +} + +func NewFadadaCorpAuthRecord(userID, certificationID, appID, companyName, uscc, clientCorpID string) *FadadaCorpAuthRecord { + now := time.Now() + return &FadadaCorpAuthRecord{ + ID: uuid.New().String(), + UserID: strings.TrimSpace(userID), + CertificationID: strings.TrimSpace(certificationID), + AppID: strings.TrimSpace(appID), + CompanyName: strings.TrimSpace(companyName), + UnifiedSocialCode: strings.TrimSpace(uscc), + ClientCorpID: strings.TrimSpace(clientCorpID), + Source: "init", + CreatedAt: now, + UpdatedAt: now, + } +} + +func (r *FadadaCorpAuthRecord) ApplyAuthStatus( + openCorpID, clientCorpID, bindingStatus, identStatus, availableStatus string, + authScopes []string, + source string, +) { + if r == nil { + return + } + if v := strings.TrimSpace(openCorpID); v != "" { + r.OpenCorpID = v + } + if v := strings.TrimSpace(clientCorpID); v != "" { + r.ClientCorpID = v + } + if v := strings.TrimSpace(bindingStatus); v != "" { + r.BindingStatus = v + } + if v := strings.TrimSpace(identStatus); v != "" { + r.IdentStatus = v + } + if v := strings.TrimSpace(availableStatus); v != "" { + r.AvailableStatus = v + } + if len(authScopes) > 0 { + r.AuthScopes = strings.Join(authScopes, ",") + } + if v := strings.TrimSpace(source); v != "" { + r.Source = v + } + r.UpdatedAt = time.Now() +} + +func (r *FadadaCorpAuthRecord) IsAuthorized() bool { + return r != nil && strings.EqualFold(strings.TrimSpace(r.BindingStatus), "authorized") && strings.TrimSpace(r.OpenCorpID) != "" +} diff --git a/internal/domains/certification/ports/sign_platform_provider.go b/internal/domains/certification/ports/sign_platform_provider.go index 4357bd2..787d53e 100644 --- a/internal/domains/certification/ports/sign_platform_provider.go +++ b/internal/domains/certification/ports/sign_platform_provider.go @@ -34,6 +34,25 @@ type OrgIdentityQuery struct { ClientCorpID string } +// CorpAuthStatusQuery 企业授权状态查询(法大大 /corp/get;e签宝可返回未授权) +type CorpAuthStatusQuery struct { + CorpIdentNo string // 统一社会信用代码 + ClientCorpID string // 应用内企业标识 + OpenCorpID string // 法大大 openCorpId +} + +// CorpAuthStatusResult 企业授权状态 +type CorpAuthStatusResult struct { + Supported bool // 当前平台是否支持主动查询授权状态 + Found bool // 本应用下是否已有企业记录 + Authorized bool // bindingStatus=authorized + Identified bool // identStatus=identified + OpenCorpID string + ClientCorpID string + BindingStatus string + IdentStatus string +} + // ContractGenerateRequest 合同模板填单 type ContractGenerateRequest struct { AgreementNo string @@ -121,6 +140,8 @@ type CallbackEvent struct { SignRejected bool SignFlowID string AuthFlowID string + OpenCorpID string + ClientCorpID string OrgName string Raw map[string]interface{} } @@ -131,6 +152,8 @@ type SignPlatformProvider interface { GenerateEnterpriseAuth(ctx context.Context, req *EnterpriseAuthRequest) (*AuthLinkResult, error) QueryOrgVerified(ctx context.Context, req *OrgIdentityQuery) (bool, error) + // QueryCorpAuthStatus 查询企业是否已对本应用完成授权绑定(法大大:/corp/get bindingStatus) + QueryCorpAuthStatus(ctx context.Context, req *CorpAuthStatusQuery) (*CorpAuthStatusResult, error) GenerateContractFile(ctx context.Context, req *ContractGenerateRequest) (*ContractFileResult, error) CreateSignFlow(ctx context.Context, req *SignFlowCreateRequest) (*SignFlowResult, error) diff --git a/internal/domains/certification/repositories/fadada_corp_auth_record_repository.go b/internal/domains/certification/repositories/fadada_corp_auth_record_repository.go new file mode 100644 index 0000000..43a0821 --- /dev/null +++ b/internal/domains/certification/repositories/fadada_corp_auth_record_repository.go @@ -0,0 +1,21 @@ +package repositories + +import ( + "context" + + "hyapi-server/internal/domains/certification/entities" +) + +// FadadaCorpAuthRecordRepository 法大大企业授权数据仓储 +type FadadaCorpAuthRecordRepository interface { + Create(ctx context.Context, record *entities.FadadaCorpAuthRecord) error + Update(ctx context.Context, record *entities.FadadaCorpAuthRecord) error + FindByID(ctx context.Context, id string) (*entities.FadadaCorpAuthRecord, error) + FindLatestByUserID(ctx context.Context, userID string) (*entities.FadadaCorpAuthRecord, error) + FindByCertificationID(ctx context.Context, certificationID string) (*entities.FadadaCorpAuthRecord, error) + FindByOpenCorpID(ctx context.Context, openCorpID string) (*entities.FadadaCorpAuthRecord, error) + FindByUnifiedSocialCode(ctx context.Context, uscc string) (*entities.FadadaCorpAuthRecord, error) + FindByClientCorpID(ctx context.Context, clientCorpID string) (*entities.FadadaCorpAuthRecord, error) + // UpsertByBizKey 按认证ID优先,否则按 USCC/clientCorpId 合并写入 + UpsertByBizKey(ctx context.Context, record *entities.FadadaCorpAuthRecord) error +} diff --git a/internal/domains/user/services/user_aggregate_service.go b/internal/domains/user/services/user_aggregate_service.go index a7fc5f2..38d97e1 100644 --- a/internal/domains/user/services/user_aggregate_service.go +++ b/internal/domains/user/services/user_aggregate_service.go @@ -467,7 +467,9 @@ func (s *UserAggregateServiceImpl) CheckUnifiedSocialCodeExists(ctx context.Cont return exists, nil } -// CreateOrUpdateEnterpriseInfo 认证域专用:写入/覆盖企业信息 +// CreateOrUpdateEnterpriseInfo 认证域专用:按两条链路写入企业信息 +// - 新用户:尚无企业信息 → 创建 +// - 重新认证:已有企业信息 → 覆盖更新 func (s *UserAggregateServiceImpl) CreateOrUpdateEnterpriseInfo( ctx context.Context, userID, companyName, unifiedSocialCode, legalPersonName, legalPersonID, legalPersonPhone, enterpriseAddress string, @@ -476,19 +478,44 @@ func (s *UserAggregateServiceImpl) CreateOrUpdateEnterpriseInfo( if err != nil { return fmt.Errorf("用户不存在: %w", err) } - if user.EnterpriseInfo == nil { - enterpriseInfo, err := entities.NewEnterpriseInfo(userID, companyName, unifiedSocialCode, legalPersonName, legalPersonID, legalPersonPhone, enterpriseAddress) - if err != nil { - return err - } - user.EnterpriseInfo = enterpriseInfo - } else { - err := user.EnterpriseInfo.UpdateEnterpriseInfo(companyName, unifiedSocialCode, legalPersonName, legalPersonID, legalPersonPhone, enterpriseAddress) - if err != nil { - return err - } + + // 统一校验 USCC(排除本人),新用户创建与重新认证更新均适用 + exists, err := s.CheckUnifiedSocialCodeExists(ctx, unifiedSocialCode, userID) + if err != nil { + return fmt.Errorf("检查统一社会信用代码失败: %w", err) } - return s.SaveUser(ctx, user) + if exists { + return fmt.Errorf("统一社会信用代码已被其他用户使用") + } + + if user.HasEnterpriseInfo() { + s.logger.Info("重新认证链路:更新已有企业信息", + zap.String("user_id", userID), + zap.String("unified_social_code", unifiedSocialCode), + zap.String("enterprise_info_id", user.EnterpriseInfo.ID), + ) + if err := user.UpdateEnterpriseInfo(companyName, unifiedSocialCode, legalPersonName, legalPersonID, legalPersonPhone, enterpriseAddress); err != nil { + return fmt.Errorf("更新企业信息失败: %w", err) + } + if err := s.SaveUser(ctx, user); err != nil { + return fmt.Errorf("保存企业信息失败: %w", err) + } + s.logger.Info("重新认证链路:企业信息更新成功", zap.String("user_id", userID)) + return nil + } + + s.logger.Info("新用户认证链路:创建企业信息", + zap.String("user_id", userID), + zap.String("unified_social_code", unifiedSocialCode), + ) + if err := user.CreateEnterpriseInfo(companyName, unifiedSocialCode, legalPersonName, legalPersonID, legalPersonPhone, enterpriseAddress); err != nil { + return fmt.Errorf("创建企业信息失败: %w", err) + } + if err := s.SaveUser(ctx, user); err != nil { + return fmt.Errorf("保存企业信息失败: %w", err) + } + s.logger.Info("新用户认证链路:企业信息创建成功", zap.String("user_id", userID)) + return nil } // CompleteCertification 完成认证 diff --git a/internal/infrastructure/database/repositories/certification/gorm_fadada_corp_auth_record_repository.go b/internal/infrastructure/database/repositories/certification/gorm_fadada_corp_auth_record_repository.go new file mode 100644 index 0000000..1e23575 --- /dev/null +++ b/internal/infrastructure/database/repositories/certification/gorm_fadada_corp_auth_record_repository.go @@ -0,0 +1,210 @@ +package certification + +import ( + "context" + "errors" + "strings" + + "hyapi-server/internal/domains/certification/entities" + "hyapi-server/internal/domains/certification/repositories" + "hyapi-server/internal/shared/database" + + "go.uber.org/zap" + "gorm.io/gorm" +) + +const FadadaCorpAuthRecordsTable = "fadada_corp_auth_records" + +type GormFadadaCorpAuthRecordRepository struct { + *database.CachedBaseRepositoryImpl +} + +func NewGormFadadaCorpAuthRecordRepository(db *gorm.DB, logger *zap.Logger) *GormFadadaCorpAuthRecordRepository { + return &GormFadadaCorpAuthRecordRepository{ + CachedBaseRepositoryImpl: database.NewCachedBaseRepositoryImpl(db, logger, FadadaCorpAuthRecordsTable), + } +} + +func (r *GormFadadaCorpAuthRecordRepository) Create(ctx context.Context, record *entities.FadadaCorpAuthRecord) error { + return r.CreateEntity(ctx, record) +} + +func (r *GormFadadaCorpAuthRecordRepository) Update(ctx context.Context, record *entities.FadadaCorpAuthRecord) error { + return r.UpdateEntity(ctx, record) +} + +func (r *GormFadadaCorpAuthRecordRepository) FindByID(ctx context.Context, id string) (*entities.FadadaCorpAuthRecord, error) { + var record entities.FadadaCorpAuthRecord + if err := r.GetDB(ctx).Where("id = ?", id).First(&record).Error; err != nil { + return nil, err + } + return &record, nil +} + +func (r *GormFadadaCorpAuthRecordRepository) FindLatestByUserID(ctx context.Context, userID string) (*entities.FadadaCorpAuthRecord, error) { + var record entities.FadadaCorpAuthRecord + err := r.GetDB(ctx). + Where("user_id = ?", userID). + Order("updated_at DESC"). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *GormFadadaCorpAuthRecordRepository) FindByCertificationID(ctx context.Context, certificationID string) (*entities.FadadaCorpAuthRecord, error) { + if strings.TrimSpace(certificationID) == "" { + return nil, gorm.ErrRecordNotFound + } + var record entities.FadadaCorpAuthRecord + err := r.GetDB(ctx). + Where("certification_id = ?", certificationID). + Order("updated_at DESC"). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *GormFadadaCorpAuthRecordRepository) FindByOpenCorpID(ctx context.Context, openCorpID string) (*entities.FadadaCorpAuthRecord, error) { + if strings.TrimSpace(openCorpID) == "" { + return nil, gorm.ErrRecordNotFound + } + var record entities.FadadaCorpAuthRecord + err := r.GetDB(ctx). + Where("open_corp_id = ?", openCorpID). + Order("updated_at DESC"). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *GormFadadaCorpAuthRecordRepository) FindByUnifiedSocialCode(ctx context.Context, uscc string) (*entities.FadadaCorpAuthRecord, error) { + if strings.TrimSpace(uscc) == "" { + return nil, gorm.ErrRecordNotFound + } + var record entities.FadadaCorpAuthRecord + err := r.GetDB(ctx). + Where("unified_social_code = ?", uscc). + Order("updated_at DESC"). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *GormFadadaCorpAuthRecordRepository) FindByClientCorpID(ctx context.Context, clientCorpID string) (*entities.FadadaCorpAuthRecord, error) { + if strings.TrimSpace(clientCorpID) == "" { + return nil, gorm.ErrRecordNotFound + } + var record entities.FadadaCorpAuthRecord + err := r.GetDB(ctx). + Where("client_corp_id = ?", clientCorpID). + Order("updated_at DESC"). + First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + +func (r *GormFadadaCorpAuthRecordRepository) UpsertByBizKey(ctx context.Context, record *entities.FadadaCorpAuthRecord) error { + if record == nil { + return errors.New("法大大授权记录不能为空") + } + existing, err := r.findExisting(ctx, record) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return err + } + if existing == nil { + return r.Create(ctx, record) + } + mergeFadadaCorpAuthRecord(existing, record) + return r.Update(ctx, existing) +} + +func (r *GormFadadaCorpAuthRecordRepository) findExisting(ctx context.Context, record *entities.FadadaCorpAuthRecord) (*entities.FadadaCorpAuthRecord, error) { + if record.CertificationID != "" { + if rec, err := r.FindByCertificationID(ctx, record.CertificationID); err == nil { + return rec, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if record.OpenCorpID != "" { + if rec, err := r.FindByOpenCorpID(ctx, record.OpenCorpID); err == nil { + return rec, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if record.UnifiedSocialCode != "" { + if rec, err := r.FindByUnifiedSocialCode(ctx, record.UnifiedSocialCode); err == nil { + return rec, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if record.ClientCorpID != "" { + if rec, err := r.FindByClientCorpID(ctx, record.ClientCorpID); err == nil { + return rec, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + if record.UserID != "" { + if rec, err := r.FindLatestByUserID(ctx, record.UserID); err == nil { + return rec, nil + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + return nil, err + } + } + return nil, gorm.ErrRecordNotFound +} + +func mergeFadadaCorpAuthRecord(dst, src *entities.FadadaCorpAuthRecord) { + if src.UserID != "" { + dst.UserID = src.UserID + } + if src.CertificationID != "" { + dst.CertificationID = src.CertificationID + } + if src.AppID != "" { + dst.AppID = src.AppID + } + if src.CompanyName != "" { + dst.CompanyName = src.CompanyName + } + if src.UnifiedSocialCode != "" { + dst.UnifiedSocialCode = src.UnifiedSocialCode + } + if src.ClientCorpID != "" { + dst.ClientCorpID = src.ClientCorpID + } + if src.OpenCorpID != "" { + dst.OpenCorpID = src.OpenCorpID + } + if src.BindingStatus != "" { + dst.BindingStatus = src.BindingStatus + } + if src.IdentStatus != "" { + dst.IdentStatus = src.IdentStatus + } + if src.AvailableStatus != "" { + dst.AvailableStatus = src.AvailableStatus + } + if src.AuthScopes != "" { + dst.AuthScopes = src.AuthScopes + } + if src.Source != "" { + dst.Source = src.Source + } + dst.UpdatedAt = src.UpdatedAt +} + +var _ repositories.FadadaCorpAuthRecordRepository = (*GormFadadaCorpAuthRecordRepository)(nil) diff --git a/internal/infrastructure/database/repositories/user/gorm_user_repository.go b/internal/infrastructure/database/repositories/user/gorm_user_repository.go index 08a3f8a..5cbbf84 100644 --- a/internal/infrastructure/database/repositories/user/gorm_user_repository.go +++ b/internal/infrastructure/database/repositories/user/gorm_user_repository.go @@ -11,6 +11,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" "hyapi-server/internal/domains/user/entities" "hyapi-server/internal/domains/user/repositories" @@ -107,7 +108,48 @@ func (r *GormUserRepository) ExistsByUnifiedSocialCode(ctx context.Context, unif } func (r *GormUserRepository) Update(ctx context.Context, user entities.User) error { - return r.UpdateEntity(ctx, &user) + db := r.GetDB(ctx) + + return db.Transaction(func(tx *gorm.DB) error { + // 避免 GORM 自动保存关联触发 ON CONFLICT(受历史库索引差异影响) + if err := tx.WithContext(ctx).Omit(clause.Associations).Save(&user).Error; err != nil { + return err + } + + // 企业信息单独按 user_id 做更新或创建:新用户创建 / 重新认证更新 + if user.EnterpriseInfo == nil { + return nil + } + + enterpriseInfo := *user.EnterpriseInfo + enterpriseInfo.UserID = user.ID + enterpriseInfo.User = nil + + var count int64 + if err := tx.WithContext(ctx). + Model(&entities.EnterpriseInfo{}). + Where("user_id = ?", user.ID). + Count(&count).Error; err != nil { + return err + } + + if count > 0 { + updates := map[string]interface{}{ + "company_name": enterpriseInfo.CompanyName, + "unified_social_code": enterpriseInfo.UnifiedSocialCode, + "legal_person_name": enterpriseInfo.LegalPersonName, + "legal_person_id": enterpriseInfo.LegalPersonID, + "legal_person_phone": enterpriseInfo.LegalPersonPhone, + "enterprise_address": enterpriseInfo.EnterpriseAddress, + } + return tx.WithContext(ctx). + Model(&entities.EnterpriseInfo{}). + Where("user_id = ?", user.ID). + Updates(updates).Error + } + + return tx.WithContext(ctx).Create(&enterpriseInfo).Error + }) } func (r *GormUserRepository) CreateBatch(ctx context.Context, users []entities.User) error { diff --git a/internal/infrastructure/external/esign/esign_provider.go b/internal/infrastructure/external/esign/esign_provider.go index 4204cdf..abca7a8 100644 --- a/internal/infrastructure/external/esign/esign_provider.go +++ b/internal/infrastructure/external/esign/esign_provider.go @@ -56,6 +56,13 @@ func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQ return identity != nil && identity.Data.RealnameStatus == 1, nil } +func (p *Provider) QueryCorpAuthStatus(ctx context.Context, req *ports.CorpAuthStatusQuery) (*ports.CorpAuthStatusResult, error) { + _ = ctx + _ = req + // e签宝无对等「查询企业授权状态」接口,走取链/已实名兜底逻辑 + return &ports.CorpAuthStatusResult{Supported: false}, nil +} + func (p *Provider) GenerateContractFile(ctx context.Context, req *ports.ContractGenerateRequest) (*ports.ContractFileResult, error) { _ = ctx components := map[string]string{ diff --git a/internal/infrastructure/external/fadada/fadada_provider.go b/internal/infrastructure/external/fadada/fadada_provider.go index 31292cb..8ae63d3 100644 --- a/internal/infrastructure/external/fadada/fadada_provider.go +++ b/internal/infrastructure/external/fadada/fadada_provider.go @@ -54,6 +54,34 @@ func (p *Provider) QueryOrgVerified(ctx context.Context, req *ports.OrgIdentityQ }) } +func (p *Provider) QueryCorpAuthStatus(ctx context.Context, req *ports.CorpAuthStatusQuery) (*ports.CorpAuthStatusResult, error) { + _ = ctx + if req == nil { + return &ports.CorpAuthStatusResult{Supported: true, Found: false}, nil + } + res, err := p.client.QueryCorpAuth(&sharedfadada.QueryCorpAuthRequest{ + CorpIdentNo: req.CorpIdentNo, + ClientCorpID: req.ClientCorpID, + OpenCorpID: req.OpenCorpID, + }) + if err != nil { + return nil, err + } + if res == nil { + return &ports.CorpAuthStatusResult{Supported: true, Found: false}, nil + } + return &ports.CorpAuthStatusResult{ + Supported: true, + Found: res.Found, + Authorized: res.Authorized, + Identified: res.Identified, + OpenCorpID: res.OpenCorpID, + ClientCorpID: res.ClientCorpID, + BindingStatus: res.BindingStatus, + IdentStatus: res.IdentStatus, + }, nil +} + // 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) { @@ -199,10 +227,12 @@ func (p *Provider) ParseCallback(ctx context.Context, headers map[string]string, return nil, err } out := &ports.CallbackEvent{ - Event: ev.Event, - SignFlowID: ev.SignTaskID, - AuthFlowID: firstNonEmpty(ev.ClientCorpID, ev.OpenCorpID), - Raw: ev.Raw, + Event: ev.Event, + SignFlowID: ev.SignTaskID, + AuthFlowID: firstNonEmpty(ev.ClientCorpID, ev.OpenCorpID), + OpenCorpID: ev.OpenCorpID, + ClientCorpID: ev.ClientCorpID, + Raw: ev.Raw, } if ev.IsSignCompletedCallback() { out.SignCompleted = true diff --git a/internal/shared/fadada/api_types.go b/internal/shared/fadada/api_types.go index e631b58..5072362 100644 --- a/internal/shared/fadada/api_types.go +++ b/internal/shared/fadada/api_types.go @@ -49,6 +49,25 @@ type getCorpAuthURLData struct { AuthURL string `json:"authUrl"` } +// ---- /corp/get 查询企业授权状态 ---- + +type getCorpReq struct { + CorpIdentNo string `json:"corpIdentNo,omitempty"` + ClientCorpID string `json:"clientCorpId,omitempty"` + OpenCorpID string `json:"openCorpId,omitempty"` +} + +type getCorpData struct { + ClientCorpID string `json:"clientCorpId"` + ClientCorpName string `json:"clientCorpName,omitempty"` + OpenCorpID string `json:"openCorpId"` + EntityType string `json:"entityType,omitempty"` + BindingStatus string `json:"bindingStatus"` + AuthScope []string `json:"authScope,omitempty"` + IdentStatus string `json:"identStatus,omitempty"` + AvailableStatus string `json:"availableStatus,omitempty"` +} + // ---- /corp/get-identified-status ---- type getIdentifiedStatusReq struct { diff --git a/internal/shared/fadada/client.go b/internal/shared/fadada/client.go index 6b008f2..382121c 100644 --- a/internal/shared/fadada/client.go +++ b/internal/shared/fadada/client.go @@ -6,6 +6,7 @@ import "sync" const ( pathGetAccessToken = "/service/get-access-token" pathGetCorpAuthURL = "/corp/get-auth-url" + pathGetCorp = "/corp/get" pathGetCorpIdentifiedStatus = "/corp/get-identified-status" pathFillDocTemplateValues = "/doc-template/fill-values" pathCreateSignTaskTemplate = "/sign-task/create-with-template" diff --git a/internal/shared/fadada/corp_service.go b/internal/shared/fadada/corp_service.go index 78c8304..83ac512 100644 --- a/internal/shared/fadada/corp_service.go +++ b/internal/shared/fadada/corp_service.go @@ -74,7 +74,7 @@ func (c *Client) GenerateEnterpriseAuth(req *EnterpriseAuthRequest) (*Enterprise return nil, err } if res.Code != successCode { - return nil, fmt.Errorf("获取法大大企业认证链接失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID) + return nil, NewAPIError("获取法大大企业认证链接失败", res.Code, res.Msg, res.RequestID) } var data getCorpAuthURLData @@ -92,6 +92,83 @@ func (c *Client) GenerateEnterpriseAuth(req *EnterpriseAuthRequest) (*Enterprise }, nil } +// QueryCorpAuth 查询企业授权状态(POST /corp/get) +// 文档:https://dev.fadada.com/api-doc/X2JXRJIYRE/ELN3ZLQWR6PLXGCE/5-1 +// 以 bindingStatus=authorized 判断是否已完成帐号授权绑定(比 get-auth-url 的 210002/210013 更准确)。 +func (c *Client) QueryCorpAuth(req *QueryCorpAuthRequest) (*QueryCorpAuthResult, error) { + if req == nil { + return nil, fmt.Errorf("查询请求不能为空") + } + corpIdentNo := strings.TrimSpace(req.CorpIdentNo) + clientCorpID := strings.TrimSpace(req.ClientCorpID) + openCorpID := strings.TrimSpace(req.OpenCorpID) + filled := 0 + if corpIdentNo != "" { + filled++ + } + if clientCorpID != "" { + filled++ + } + if openCorpID != "" { + filled++ + } + if filled == 0 { + return nil, fmt.Errorf("corpIdentNo、clientCorpId、openCorpId 不能同时为空") + } + if filled > 1 { + // 文档要求三选一;业务优先级:openCorpId > corpIdentNo > clientCorpId + if openCorpID != "" { + corpIdentNo, clientCorpID = "", "" + } else if corpIdentNo != "" { + clientCorpID = "" + } + } + + accessToken, err := c.ensureAccessToken() + if err != nil { + return nil, err + } + + res, err := c.http.PostBiz(pathGetCorp, accessToken, &getCorpReq{ + CorpIdentNo: corpIdentNo, + ClientCorpID: clientCorpID, + OpenCorpID: openCorpID, + }) + if err != nil { + return nil, err + } + if res.Code != successCode { + // 企业尚未在本应用下建档/授权时,视为未找到,不算业务异常 + if isCorpNotFoundCode(res.Code) { + return &QueryCorpAuthResult{Found: false}, nil + } + return nil, NewAPIError("查询法大大企业授权状态失败", res.Code, res.Msg, res.RequestID) + } + + var data getCorpData + if err := decodeData(res.Data, &data); err != nil { + return nil, fmt.Errorf("解析企业授权状态失败: %w", err) + } + + binding := strings.TrimSpace(data.BindingStatus) + ident := strings.TrimSpace(data.IdentStatus) + return &QueryCorpAuthResult{ + Found: true, + Authorized: binding == BindingStatusAuthorized, + Identified: ident == IdentStatusIdentified, + ClientCorpID: strings.TrimSpace(data.ClientCorpID), + OpenCorpID: strings.TrimSpace(data.OpenCorpID), + BindingStatus: binding, + IdentStatus: ident, + AvailableStatus: strings.TrimSpace(data.AvailableStatus), + AuthScopes: append([]string{}, data.AuthScope...), + }, nil +} + +func isCorpNotFoundCode(code string) bool { + return strings.TrimSpace(code) == CodeCorpNotFound +} + // QueryOrgVerified 查询企业是否已在法大大完成实名(POST /corp/get-identified-status) func (c *Client) QueryOrgVerified(req *QueryOrgIdentityRequest) (bool, error) { result, err := c.QueryOrgIdentity(req) @@ -125,7 +202,7 @@ func (c *Client) QueryOrgIdentity(req *QueryOrgIdentityRequest) (*QueryOrgIdenti return nil, err } if res.Code != successCode { - return nil, fmt.Errorf("查询法大大企业实名状态失败: code=%s msg=%s requestId=%s", res.Code, res.Msg, res.RequestID) + return nil, NewAPIError("查询法大大企业实名状态失败", res.Code, res.Msg, res.RequestID) } var data getIdentifiedStatusData diff --git a/internal/shared/fadada/errors.go b/internal/shared/fadada/errors.go new file mode 100644 index 0000000..2dc79b8 --- /dev/null +++ b/internal/shared/fadada/errors.go @@ -0,0 +1,91 @@ +package fadada + +import ( + "errors" + "fmt" + "regexp" + "strings" +) + +// 法大大 FASC OpenAPI 错误码(以官方「错误码说明」文档 code 为准,勿依赖 msg 文案)。 +// 文档:https://dev.fadada.com/api-doc/JOQQIJXLFL/H4F0HSKMHQ3HPIIM/5-1 +const ( + CodeSuccess = "100000" // 请求成功 + + // 公共 + CodeUnknownException = "100001" // 不可预知异常 + CodeAccessTokenInvalid = "100002" // 访问凭证失效 + CodeSignInvalid = "100003" // 接口签名错误 + CodeTooFrequent = "100004" // 请求频繁 + CodeParamIllegal = "100011" // 请求参数非法 + CodeParamEmpty = "100012" // 请求参数为空 + CodeBusinessIDNotFound = "212007" // 业务场景标识不存在 + + // get-auth-url 返回「授权范围重复获取」时可用作兜底(非正式查授权接口) + // 正式查授权请用 POST /corp/get 的 bindingStatus + CodeAuthScopeDuplicate = "210002" // 授权范围重复获取 + CodeAuthScopeDuplicateCorp = "210013" // 授权范围重复获取(无须重复获取授权申请链接) + CodeCorpNotFound = "210032" // 企业用户不存在 +) + +// APIError 法大大业务错误(按 code 判断,不依赖 msg) +type APIError struct { + Op string // 调用场景说明 + Code string + Msg string + RequestID string +} + +func (e *APIError) Error() string { + if e == nil { + return "" + } + op := strings.TrimSpace(e.Op) + if op == "" { + op = "法大大接口调用失败" + } + return fmt.Sprintf("%s: code=%s msg=%s requestId=%s", op, e.Code, e.Msg, e.RequestID) +} + +// NewAPIError 构造结构化业务错误 +func NewAPIError(op, code, msg, requestID string) *APIError { + return &APIError{ + Op: strings.TrimSpace(op), + Code: strings.TrimSpace(code), + Msg: strings.TrimSpace(msg), + RequestID: strings.TrimSpace(requestID), + } +} + +// IsAuthAlreadyGranted 是否为 get-auth-url「授权范围重复获取」错误码(210002/210013)。 +// 注意:正式判断是否已授权应调用 POST /corp/get(bindingStatus=authorized); +// 本函数仅作取链失败时的兜底。 +func IsAuthAlreadyGranted(err error) bool { + if err == nil { + return false + } + var apiErr *APIError + if errors.As(err, &apiErr) && apiErr != nil { + return isAuthAlreadyGrantedCode(apiErr.Code) + } + return isAuthAlreadyGrantedCode(extractFadadaCode(err.Error())) +} + +func isAuthAlreadyGrantedCode(code string) bool { + switch strings.TrimSpace(code) { + case CodeAuthScopeDuplicate, CodeAuthScopeDuplicateCorp: + return true + default: + return false + } +} + +var fadadaCodeRe = regexp.MustCompile(`(?i)(?:^|[^\d])code\s*=\s*(\d{6})(?:[^\d]|$)`) + +func extractFadadaCode(msg string) string { + m := fadadaCodeRe.FindStringSubmatch(msg) + if len(m) < 2 { + return "" + } + return m[1] +} diff --git a/internal/shared/fadada/errors_test.go b/internal/shared/fadada/errors_test.go new file mode 100644 index 0000000..6f686b3 --- /dev/null +++ b/internal/shared/fadada/errors_test.go @@ -0,0 +1,28 @@ +package fadada + +import ( + "errors" + "fmt" + "testing" +) + +func TestIsAuthAlreadyGranted(t *testing.T) { + if !IsAuthAlreadyGranted(NewAPIError("op", CodeAuthScopeDuplicate, "授权范围重复获取", "1")) { + t.Fatal("210002 should match") + } + if !IsAuthAlreadyGranted(NewAPIError("op", CodeAuthScopeDuplicateCorp, "授权范围重复获取", "2")) { + t.Fatal("210013 should match") + } + if IsAuthAlreadyGranted(NewAPIError("op", CodeParamIllegal, "请求参数非法", "3")) { + t.Fatal("100011 should not match") + } + if !IsAuthAlreadyGranted(fmt.Errorf("wrap: %w", NewAPIError("op", CodeAuthScopeDuplicate, "x", "4"))) { + t.Fatal("wrapped 210002 should match") + } + if !IsAuthAlreadyGranted(errors.New("fail: code=210013 msg=授权范围重复获取 requestId=x")) { + t.Fatal("legacy string 210013 should match") + } + if IsAuthAlreadyGranted(errors.New("无需重复操作")) { + t.Fatal("msg-only must not match per official docs") + } +} diff --git a/internal/shared/fadada/types.go b/internal/shared/fadada/types.go index 0e93be8..9f8431a 100644 --- a/internal/shared/fadada/types.go +++ b/internal/shared/fadada/types.go @@ -38,6 +38,35 @@ type QueryOrgIdentityResult struct { CorpIdentNo string `json:"corpIdentNo"` } +// 企业授权/实名状态(/corp/get) +const ( + BindingStatusUnauthorized = "unauthorized" // 未授权 + BindingStatusAuthorized = "authorized" // 已授权 + IdentStatusUnidentified = "unidentified" // 未认证 + IdentStatusIdentified = "identified" // 已认证且有效 +) + +// QueryCorpAuthRequest 查询企业授权状态(POST /corp/get) +// corpIdentNo、clientCorpId、openCorpId 三选一,不能同时为空。 +type QueryCorpAuthRequest struct { + CorpIdentNo string `json:"corpIdentNo,omitempty"` + ClientCorpID string `json:"clientCorpId,omitempty"` + OpenCorpID string `json:"openCorpId,omitempty"` +} + +// QueryCorpAuthResult 企业授权状态查询结果 +type QueryCorpAuthResult struct { + Found bool `json:"found"` // 本应用下是否已有该企业记录 + Authorized bool `json:"authorized"` + Identified bool `json:"identified"` + ClientCorpID string `json:"clientCorpId"` + OpenCorpID string `json:"openCorpId"` + BindingStatus string `json:"bindingStatus"` + IdentStatus string `json:"identStatus"` + AvailableStatus string `json:"availableStatus"` + AuthScopes []string `json:"authScopes"` +} + // ContractFillRequest 合作协议模板填单请求(对齐 e签宝 generateAndAddContractFile 入参) type ContractFillRequest struct { AgreementNo string `json:"agreementNo"` // 协议编号