diff --git a/config.yaml b/config.yaml index 03ee80e..c4ec820 100644 --- a/config.yaml +++ b/config.yaml @@ -255,7 +255,7 @@ esign: app_id: "7439073138" app_secret: "d76e27fdd169b391e09262a0959dac5c" server_url: "https://smlopenapi.esign.cn" - template_id: "9f7a3f63cc5a48b085b127ba027d234d" + template_id: "6c91bfd5b1bb48c585f5eaceeea893d4" contract: name: "天远数据API合作协议" expire_days: 7 diff --git a/configs/env.development.yaml b/configs/env.development.yaml index 14aa98e..7959ede 100644 --- a/configs/env.development.yaml +++ b/configs/env.development.yaml @@ -46,10 +46,10 @@ ocr: # 📝 e签宝服务配置 # =========================================== esign: - app_id: "7439073713" - app_secret: "c7d8cb0d701f7890601d221e9b6edfef" - server_url: "https://smlopenapi.esign.cn" - template_id: "9f7a3f63cc5a48b085b127ba027d234d" + app_id: "5112008003" + app_secret: "d487672273e7aa70c800804a1d9499b9" + server_url: "https://openapi.esign.cn" + template_id: "6c91bfd5b1bb48c585f5eaceeea893d4" contract: name: "天远数据API合作协议" expire_days: 7 diff --git a/configs/env.production.yaml b/configs/env.production.yaml index 7d337ca..d273c01 100644 --- a/configs/env.production.yaml +++ b/configs/env.production.yaml @@ -79,7 +79,7 @@ esign: app_id: "5112008003" app_secret: "d487672273e7aa70c800804a1d9499b9" server_url: "https://openapi.esign.cn" - template_id: "9f7a3f63cc5a48b085b127ba027d234d" + template_id: "6c91bfd5b1bb48c585f5eaceeea893d4" contract: name: "天远数据API合作协议" expire_days: 7 diff --git a/internal/application/certification/certification_application_service_impl.go b/internal/application/certification/certification_application_service_impl.go index 8278ae8..37aaf51 100644 --- a/internal/application/certification/certification_application_service_impl.go +++ b/internal/application/certification/certification_application_service_impl.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "github.com/shopspring/decimal" "tyapi-server/internal/application/certification/dto/commands" "tyapi-server/internal/application/certification/dto/queries" "tyapi-server/internal/application/certification/dto/responses" @@ -19,11 +18,11 @@ import ( certification_value_objects "tyapi-server/internal/domains/certification/entities/value_objects" "tyapi-server/internal/domains/certification/enums" "tyapi-server/internal/domains/certification/repositories" + "tyapi-server/internal/domains/certification/services" finance_entities "tyapi-server/internal/domains/finance/entities" finance_repositories "tyapi-server/internal/domains/finance/repositories" - "tyapi-server/internal/domains/certification/services" - subordinate_repositories "tyapi-server/internal/domains/subordinate/repositories" finance_service "tyapi-server/internal/domains/finance/services" + subordinate_repositories "tyapi-server/internal/domains/subordinate/repositories" user_entities "tyapi-server/internal/domains/user/entities" user_service "tyapi-server/internal/domains/user/services" "tyapi-server/internal/infrastructure/external/notification" @@ -32,6 +31,8 @@ import ( "tyapi-server/internal/shared/esign" sharedOCR "tyapi-server/internal/shared/ocr" + "github.com/shopspring/decimal" + "go.uber.org/zap" ) @@ -726,7 +727,7 @@ func (s *CertificationApplicationServiceImpl) HandleEsignCallback( } // 生成合同 - err = s.generateAndAddContractFile(txCtx, cert, record.CompanyName, record.LegalPersonName, record.UnifiedSocialCode, record.EnterpriseAddress, record.LegalPersonPhone, record.LegalPersonID) + err = s.generateAndAddContractFile(txCtx, cert, record.CompanyName, record.UnifiedSocialCode, record.EnterpriseAddress, pickAuthorizedRepName(record, record.LegalPersonName)) if err != nil { return err } @@ -1287,6 +1288,25 @@ 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 +} + +// pickEnterpriseString 优先用户域企业表字段,为空则用最近一次认证提交记录(避免 enterprise_infos 未同步导致合同控件无值) +func pickEnterpriseString(primary string, record *entities.EnterpriseInfoSubmitRecord, fromRecord func(*entities.EnterpriseInfoSubmitRecord) string) string { + if strings.TrimSpace(primary) != "" { + return strings.TrimSpace(primary) + } + if record == nil { + return "" + } + return strings.TrimSpace(fromRecord(record)) +} + // generateContractAndSignURL 生成合同和签署链接 func (s *CertificationApplicationServiceImpl) generateContractAndSignURL(ctx context.Context, cert *entities.Certification, enterpriseInfo *user_entities.EnterpriseInfo) (*certification_value_objects.ContractInfo, error) { // 发起签署流程 @@ -1361,7 +1381,7 @@ func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification( } // 生成合同 - err = s.generateAndAddContractFile(ctx, cert, record.CompanyName, record.LegalPersonName, record.UnifiedSocialCode, record.EnterpriseAddress, record.LegalPersonPhone, record.LegalPersonID) + err = s.generateAndAddContractFile(ctx, cert, record.CompanyName, record.UnifiedSocialCode, record.EnterpriseAddress, pickAuthorizedRepName(record, record.LegalPersonName)) if err != nil { return err } @@ -1383,27 +1403,41 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile( ctx context.Context, cert *entities.Certification, companyName string, - legalPersonName string, unifiedSocialCode string, enterpriseAddress string, - legalPersonPhone string, - legalPersonID string, + authorizedRepName string, ) error { s.logger.Info("合同生成-步骤1-开始填充合同模板", zap.String("user_id", cert.UserID), zap.String("company_name", companyName)) + + // 协议编号:已有则复用,否则新生成 + if cert.ContractCode == "" { + cert.SetContractCode(user_entities.GenerateContractCode(user_entities.ContractTypeCooperation)) + } + agreementNo := cert.ContractCode + + // e签宝日期控件格式必须与模板预设一致(本模板为 yyyy年MM月dd日) + signDate := time.Now().Format("2006年01月02日") + + if strings.TrimSpace(unifiedSocialCode) == "" { + s.logger.Warn("合同模板控件 jftyshxydm:统一社会信用代码为空;若 PDF 上该处空白,请核对 enterprise_infos.unified_social_code、提交记录或 e签宝模板控件 componentKey 是否与代码键名一致", + zap.String("user_id", cert.UserID)) + } + + // 控件 key 须与 e签宝控制台该控件「控件编码/componentKey」完全一致(区分大小写) fileComponent := map[string]string{ - "YFCompanyName": companyName, - "YFCompanyName2": companyName, - "YFLegalPersonName": legalPersonName, - "YFLegalPersonName2": legalPersonName, - "YFUnifiedSocialCode": unifiedSocialCode, - "YFEnterpriseAddress": enterpriseAddress, - "YFContactPerson": legalPersonName, - "YFMobile": legalPersonPhone, - "SignDate": time.Now().Format("2006年01月02日"), - "SignDate2": time.Now().Format("2006年01月02日"), - "SignDate3": time.Now().Format("2006年01月02日"), + "jfqym": companyName, + "jfqym2": companyName, + "jfsqdb": authorizedRepName, + "jftyshxydm": unifiedSocialCode, + "jflxdz": enterpriseAddress, + // 甲方 + "xybh": agreementNo, + "qsrq1": signDate, + "qsrq3": signDate, + // 乙方 + "qsrq2": signDate, } fillTemplateResp, err := s.esignClient.FillTemplate(fileComponent) if err != nil { @@ -1412,7 +1446,8 @@ func (s *CertificationApplicationServiceImpl) generateAndAddContractFile( } s.logger.Info("合同生成-步骤1-模板填充成功", zap.String("user_id", cert.UserID), - zap.String("file_id", fillTemplateResp.FileID)) + zap.String("file_id", fillTemplateResp.FileID), + zap.String("contract_code", agreementNo)) err = cert.AddContractFileID(fillTemplateResp.FileID, fillTemplateResp.FileDownloadUrl) if err != nil { s.logger.Error("加入合同文件ID链接失败", zap.Error(err)) @@ -1432,9 +1467,26 @@ func (s *CertificationApplicationServiceImpl) updateContractFile(ctx context.Con s.logger.Error("获取企业信息失败", zap.Error(err)) return fmt.Errorf("获取企业信息失败: %w", err) } + if enterpriseInfo.EnterpriseInfo == nil { + return fmt.Errorf("用户企业信息不存在") + } + + ei := enterpriseInfo.EnterpriseInfo + submitRec, recErr := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID) + if recErr != nil { + s.logger.Warn("更新合同时加载企业提交记录失败,统一社会信用代码等仅以用户域为准", + zap.String("user_id", cert.UserID), + zap.Error(recErr)) + submitRec = nil + } + authRep := pickAuthorizedRepName(submitRec, ei.LegalPersonName) + + company := pickEnterpriseString(ei.CompanyName, submitRec, func(r *entities.EnterpriseInfoSubmitRecord) string { return r.CompanyName }) + uscc := pickEnterpriseString(ei.UnifiedSocialCode, submitRec, func(r *entities.EnterpriseInfoSubmitRecord) string { return r.UnifiedSocialCode }) + addr := pickEnterpriseString(ei.EnterpriseAddress, submitRec, func(r *entities.EnterpriseInfoSubmitRecord) string { return r.EnterpriseAddress }) // 生成合同 - err = s.generateAndAddContractFile(ctx, cert, enterpriseInfo.EnterpriseInfo.CompanyName, enterpriseInfo.EnterpriseInfo.LegalPersonName, enterpriseInfo.EnterpriseInfo.UnifiedSocialCode, enterpriseInfo.EnterpriseInfo.EnterpriseAddress, enterpriseInfo.EnterpriseInfo.LegalPersonPhone, enterpriseInfo.EnterpriseInfo.LegalPersonID) + err = s.generateAndAddContractFile(ctx, cert, company, uscc, addr, authRep) if err != nil { return err } @@ -1571,16 +1623,29 @@ func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ct s.logger.Info("合同文件已上传七牛云", zap.String("file_name", fileName), zap.String("qiniu_url", qiniuURL)) - // 4. 保存到合同聚合根 - _, err = s.contractAggregateService.CreateContract( - ctx, - user.EnterpriseInfo.ID, - cert.UserID, - fileName, - user_entities.ContractTypeCooperation, - fileId, - qiniuURL, - ) + // 4. 保存到合同聚合根(复用认证阶段生成的合同编号;旧数据无编号时退回自动生成) + if strings.TrimSpace(cert.ContractCode) != "" { + _, err = s.contractAggregateService.CreateContractWithCode( + ctx, + user.EnterpriseInfo.ID, + cert.UserID, + fileName, + user_entities.ContractTypeCooperation, + fileId, + qiniuURL, + strings.TrimSpace(cert.ContractCode), + ) + } else { + _, err = s.contractAggregateService.CreateContract( + ctx, + user.EnterpriseInfo.ID, + cert.UserID, + fileName, + user_entities.ContractTypeCooperation, + fileId, + qiniuURL, + ) + } if err != nil { s.logger.Error("保存合同信息到聚合根失败", zap.String("file_name", fileName), zap.Error(err)) continue diff --git a/internal/application/certification/new.md b/internal/application/certification/new.md new file mode 100644 index 0000000..0fc6ca8 --- /dev/null +++ b/internal/application/certification/new.md @@ -0,0 +1,1733 @@ +package certification + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "hyapi-server/internal/application/certification/dto/commands" + "hyapi-server/internal/application/certification/dto/queries" + "hyapi-server/internal/application/certification/dto/responses" + "hyapi-server/internal/config" + api_service "hyapi-server/internal/domains/api/services" + "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/repositories" + "hyapi-server/internal/domains/certification/services" + finance_service "hyapi-server/internal/domains/finance/services" + user_entities "hyapi-server/internal/domains/user/entities" + user_service "hyapi-server/internal/domains/user/services" + "hyapi-server/internal/infrastructure/external/notification" + "hyapi-server/internal/infrastructure/external/storage" + "hyapi-server/internal/shared/database" + "hyapi-server/internal/shared/esign" + sharedOCR "hyapi-server/internal/shared/ocr" + + "go.uber.org/zap" +) + +// CertificationApplicationServiceImpl 认证应用服务实现 +// 负责用例协调,DTO转换,是应用层的核心组件 +type CertificationApplicationServiceImpl struct { + // 领域服务依赖 + aggregateService services.CertificationAggregateService + userAggregateService user_service.UserAggregateService + smsCodeService *user_service.SMSCodeService + esignClient *esign.Client + esignConfig *esign.Config + qiniuStorageService *storage.QiNiuStorageService + contractAggregateService user_service.ContractAggregateService + walletAggregateService finance_service.WalletAggregateService + apiUserAggregateService api_service.ApiUserAggregateService + enterpriseInfoSubmitRecordService *services.EnterpriseInfoSubmitRecordService + ocrService sharedOCR.OCRService + // 仓储依赖 + queryRepository repositories.CertificationQueryRepository + enterpriseInfoSubmitRecordRepo repositories.EnterpriseInfoSubmitRecordRepository + txManager *database.TransactionManager + + wechatWorkService *notification.WeChatWorkService + logger *zap.Logger + config *config.Config +} + +// NewCertificationApplicationService 创建认证应用服务 +func NewCertificationApplicationService( + aggregateService services.CertificationAggregateService, + userAggregateService user_service.UserAggregateService, + queryRepository repositories.CertificationQueryRepository, + enterpriseInfoSubmitRecordRepo repositories.EnterpriseInfoSubmitRecordRepository, + smsCodeService *user_service.SMSCodeService, + esignClient *esign.Client, + esignConfig *esign.Config, + qiniuStorageService *storage.QiNiuStorageService, + contractAggregateService user_service.ContractAggregateService, + walletAggregateService finance_service.WalletAggregateService, + apiUserAggregateService api_service.ApiUserAggregateService, + enterpriseInfoSubmitRecordService *services.EnterpriseInfoSubmitRecordService, + ocrService sharedOCR.OCRService, + txManager *database.TransactionManager, + logger *zap.Logger, + cfg *config.Config, +) CertificationApplicationService { + var wechatSvc *notification.WeChatWorkService + if cfg != nil && cfg.WechatWork.WebhookURL != "" { + wechatSvc = notification.NewWeChatWorkService(cfg.WechatWork.WebhookURL, cfg.WechatWork.Secret, logger) + } + return &CertificationApplicationServiceImpl{ + aggregateService: aggregateService, + userAggregateService: userAggregateService, + queryRepository: queryRepository, + enterpriseInfoSubmitRecordRepo: enterpriseInfoSubmitRecordRepo, + smsCodeService: smsCodeService, + esignClient: esignClient, + esignConfig: esignConfig, + qiniuStorageService: qiniuStorageService, + contractAggregateService: contractAggregateService, + walletAggregateService: walletAggregateService, + apiUserAggregateService: apiUserAggregateService, + enterpriseInfoSubmitRecordService: enterpriseInfoSubmitRecordService, + ocrService: ocrService, + txManager: txManager, + wechatWorkService: wechatSvc, + logger: logger, + config: cfg, + } +} + +// ================ 用户操作用例 ================ + +// SubmitEnterpriseInfo 提交企业信息 +func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( + ctx context.Context, + cmd *commands.SubmitEnterpriseInfoCommand, +) (*responses.CertificationResponse, error) { + s.logger.Info("开始提交企业信息", + zap.String("user_id", cmd.UserID), + zap.String("company_name", cmd.CompanyName), + zap.String("unified_social_code", cmd.UnifiedSocialCode)) + + // 0. 若该用户已有待审核(认证状态仍在待审核),则不允许重复提交 + latestRecord, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cmd.UserID) + if err == nil && latestRecord != nil { + s.logger.Info("步骤0-检测到历史提交记录", + zap.String("user_id", cmd.UserID), + zap.String("latest_record_id", latestRecord.ID)) + cert, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID) + if loadErr == nil && cert != nil && cert.Status == enums.StatusInfoPendingReview { + s.logger.Warn("步骤0-存在待审核记录,拒绝重复提交", + zap.String("user_id", cmd.UserID), + zap.String("cert_status", string(cert.Status))) + return nil, fmt.Errorf("您已有待审核的提交,请等待管理员审核后再操作") + } + } + + // 0.5 已通过人工审核或已进入后续流程:幂等返回当前认证数据(不调 e签宝、不新建提交记录) + existsCertEarly, err := s.aggregateService.ExistsByUserID(ctx, cmd.UserID) + if err != nil { + return nil, fmt.Errorf("检查认证记录失败: %w", err) + } + if existsCertEarly { + certEarly, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID) + if loadErr != nil { + return nil, fmt.Errorf("加载认证信息失败: %w", loadErr) + } + switch certEarly.Status { + case enums.StatusInfoSubmitted, enums.StatusEnterpriseVerified, enums.StatusContractApplied, + enums.StatusContractSigned, enums.StatusCompleted, enums.StatusContractRejected, enums.StatusContractExpired: + meta, metaErr := s.AddStatusMetadata(ctx, certEarly) + if metaErr != nil { + return nil, metaErr + } + resp := s.convertToResponse(certEarly) + if meta != nil { + resp.Metadata = meta + } else { + resp.Metadata = map[string]interface{}{} + } + resp.Metadata["next_action"] = enums.GetUserActionHint(certEarly.Status) + s.logger.Info("企业信息提交幂等返回", zap.String("user_id", cmd.UserID), zap.String("status", string(certEarly.Status))) + return resp, nil + } + } + + // 1.5 插入企业信息提交记录(包含扩展字段) + record := entities.NewEnterpriseInfoSubmitRecord( + cmd.UserID, + cmd.CompanyName, + cmd.UnifiedSocialCode, + cmd.LegalPersonName, + cmd.LegalPersonID, + cmd.LegalPersonPhone, + cmd.EnterpriseAddress, + ) + + // 扩展字段赋值 + record.BusinessLicenseImageURL = cmd.BusinessLicenseImageURL + if len(cmd.OfficePlaceImageURLs) > 0 { + if data, mErr := json.Marshal(cmd.OfficePlaceImageURLs); mErr == nil { + record.OfficePlaceImageURLs = string(data) + } else { + s.logger.Warn("序列化办公场地图片URL失败", zap.Error(mErr)) + } + } + + record.APIUsage = cmd.APIUsage + if len(cmd.ScenarioAttachmentURLs) > 0 { + if data, mErr := json.Marshal(cmd.ScenarioAttachmentURLs); mErr == nil { + record.ScenarioAttachmentURLs = string(data) + } else { + s.logger.Warn("序列化场景附件图片URL失败", zap.Error(mErr)) + } + } + // 授权代表信息落库 + record.AuthorizedRepName = cmd.AuthorizedRepName + record.AuthorizedRepID = cmd.AuthorizedRepID + record.AuthorizedRepPhone = cmd.AuthorizedRepPhone + if len(cmd.AuthorizedRepIDImageURLs) > 0 { + if data, mErr := json.Marshal(cmd.AuthorizedRepIDImageURLs); mErr == nil { + record.AuthorizedRepIDImageURLs = string(data) + } else { + s.logger.Warn("序列化授权代表身份证图片URL失败", zap.Error(mErr)) + } + } + + // 验证验证码 + // 特殊验证码"768005"直接跳过验证环节 + if cmd.VerificationCode != "768005" { + s.logger.Info("步骤1-开始验证短信验证码", zap.String("user_id", cmd.UserID)) + if err := s.smsCodeService.VerifyCode(ctx, cmd.LegalPersonPhone, cmd.VerificationCode, user_entities.SMSSceneCertification); err != nil { + s.logger.Warn("步骤1-短信验证码校验失败", + zap.String("user_id", cmd.UserID), + zap.Error(err)) + record.MarkAsFailed(err.Error()) + saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record) + if saveErr != nil { + return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + return nil, fmt.Errorf("验证码错误或已过期") + } + s.logger.Info("步骤1-短信验证码校验通过", zap.String("user_id", cmd.UserID)) + } else { + s.logger.Info("步骤1-命中特殊验证码,跳过校验", zap.String("user_id", cmd.UserID)) + } + s.logger.Info("开始处理企业信息提交", + zap.String("user_id", cmd.UserID)) + // 1. 检查企业信息是否重复(统一社会信用代码:已认证或已提交待审核的都不能重复) + // 1.1 已写入用户域 enterprise_infos 的(已完成认证) + exists, err := s.userAggregateService.CheckUnifiedSocialCodeExists(ctx, cmd.UnifiedSocialCode, cmd.UserID) + if err != nil { + s.logger.Error("步骤2.1-检查用户域统一社会信用代码失败", + zap.String("user_id", cmd.UserID), + zap.String("unified_social_code", cmd.UnifiedSocialCode), + zap.Error(err)) + record.MarkAsFailed(err.Error()) + saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record) + if saveErr != nil { + return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + return nil, fmt.Errorf("检查企业信息失败: %s", err.Error()) + } + if exists { + s.logger.Warn("步骤2.1-统一社会信用代码已被占用(用户域)", + zap.String("user_id", cmd.UserID), + zap.String("unified_social_code", cmd.UnifiedSocialCode)) + record.MarkAsFailed("该企业信息已被其他用户使用,请确认企业信息是否正确") + saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record) + if saveErr != nil { + return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + return nil, fmt.Errorf("该企业信息已被其他用户使用,请确认企业信息是否正确") + } + // 1.2 已提交/已通过验证的提交记录(尚未完成认证但已占用的信用代码) + existsInSubmit, err := s.enterpriseInfoSubmitRecordRepo.ExistsByUnifiedSocialCodeExcludeUser(ctx, cmd.UnifiedSocialCode, cmd.UserID) + if err != nil { + s.logger.Error("步骤2.2-检查提交记录统一社会信用代码失败", + zap.String("user_id", cmd.UserID), + zap.String("unified_social_code", cmd.UnifiedSocialCode), + zap.Error(err)) + record.MarkAsFailed(err.Error()) + saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record) + if saveErr != nil { + return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + return nil, fmt.Errorf("检查企业信息失败: %s", err.Error()) + } + if existsInSubmit { + s.logger.Warn("步骤2.2-统一社会信用代码已被占用(提交记录)", + zap.String("user_id", cmd.UserID), + zap.String("unified_social_code", cmd.UnifiedSocialCode)) + record.MarkAsFailed("该企业信息已被其他用户使用,请确认企业信息是否正确") + saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record) + if saveErr != nil { + return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + return nil, fmt.Errorf("该企业信息已被其他用户使用,请确认企业信息是否正确") + } + + enterpriseInfo := &certification_value_objects.EnterpriseInfo{ + CompanyName: cmd.CompanyName, + UnifiedSocialCode: cmd.UnifiedSocialCode, + LegalPersonName: cmd.LegalPersonName, + LegalPersonID: cmd.LegalPersonID, + LegalPersonPhone: cmd.LegalPersonPhone, + EnterpriseAddress: cmd.EnterpriseAddress, + } + err = enterpriseInfo.Validate() + if err != nil { + s.logger.Error("企业信息验证失败", zap.Error(err)) + record.MarkAsFailed(err.Error()) + saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record) + if saveErr != nil { + return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + return nil, fmt.Errorf("企业信息验证失败: %s", err.Error()) + } + s.logger.Info("步骤3-企业信息基础校验通过", + zap.String("user_id", cmd.UserID), + zap.String("company_name", enterpriseInfo.CompanyName)) + err = s.enterpriseInfoSubmitRecordService.ValidateWithWestdex(ctx, enterpriseInfo) + if err != nil { + s.logger.Error("企业信息验证失败", zap.Error(err)) + record.MarkAsFailed(err.Error()) + saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record) + if saveErr != nil { + return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + return nil, fmt.Errorf("企业信息验证失败, %s", err.Error()) + } + s.logger.Info("步骤4-企业信息三方校验通过", + zap.String("user_id", cmd.UserID), + zap.String("company_name", enterpriseInfo.CompanyName)) + record.MarkAsVerified() + + var response *responses.CertificationResponse + err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { + s.logger.Info("步骤5-开始事务处理认证提交流程", zap.String("user_id", cmd.UserID)) + // 2. 检查用户认证是否存在 + existsCert, err := s.aggregateService.ExistsByUserID(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("检查用户认证是否存在失败: %s", err.Error()) + } + if !existsCert { + // 创建 + s.logger.Info("步骤5.1-认证记录不存在,开始创建", zap.String("user_id", cmd.UserID)) + _, err := s.aggregateService.CreateCertification(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("创建认证信息失败: %s", err.Error()) + } + s.logger.Info("步骤5.1-认证记录创建成功", zap.String("user_id", cmd.UserID)) + } + + // 3. 加载认证聚合根 + cert, err := s.aggregateService.LoadCertificationByUserID(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("加载认证信息失败: %s", err.Error()) + } + + // 4. 提交企业信息:进入人工审核(三真/企业信息审核);e签宝链接仅在管理员审核通过后生成(见 AdminApproveSubmitRecord) + if err := cert.SubmitEnterpriseInfoForReview(enterpriseInfo); err != nil { + return fmt.Errorf("提交企业信息失败: %s", err.Error()) + } + if err := s.aggregateService.SaveCertification(txCtx, cert); err != nil { + return fmt.Errorf("保存认证信息失败: %s", err.Error()) + } + + // 5. 提交记录与认证状态在同一事务内保存 + if saveErr := s.enterpriseInfoSubmitRecordService.Save(txCtx, record); saveErr != nil { + return fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + s.logger.Info("步骤5.3-企业信息提交记录保存成功", + zap.String("user_id", cmd.UserID), + zap.String("record_id", record.ID)) + + var enterpriseInfoMeta map[string]interface{} + if raw, mErr := json.Marshal(enterpriseInfo); mErr == nil { + _ = json.Unmarshal(raw, &enterpriseInfoMeta) + } + if enterpriseInfoMeta == nil { + enterpriseInfoMeta = map[string]interface{}{} + } + enterpriseInfoMeta["submit_at"] = record.SubmitAt.Format(time.RFC3339) + + respMeta := map[string]interface{}{ + "enterprise_info": enterpriseInfoMeta, + "polling": map[string]interface{}{ + "enabled": false, + "endpoint": "/api/v1/certifications/confirm-auth", + "interval_seconds": 3, + }, + "next_action": "请等待管理员审核企业信息", + "target_view": "manual_review", + } + // 6. 转换为响应 DTO + response = s.convertToResponse(cert) + response.Metadata = respMeta + return nil + }) + if err != nil { + return nil, err + } + + // 提醒管理员处理待审核申请(配置企业微信 Webhook 时生效) + if s.wechatWorkService != nil { + contactPhone := cmd.LegalPersonPhone + if strings.TrimSpace(cmd.AuthorizedRepPhone) != "" { + contactPhone = fmt.Sprintf("法人 %s;授权代表 %s", cmd.LegalPersonPhone, cmd.AuthorizedRepPhone) + } else { + contactPhone = fmt.Sprintf("%s(法人)", cmd.LegalPersonPhone) + } + _ = s.wechatWorkService.SendCertificationNotification(ctx, "pending_manual_review", map[string]interface{}{ + "company_name": cmd.CompanyName, + "legal_person_name": cmd.LegalPersonName, + "authorized_rep_name": cmd.AuthorizedRepName, + "contact_phone": contactPhone, + "api_usage": cmd.APIUsage, + "submit_at": record.SubmitAt.Format("2006-01-02 15:04:05"), + }) + } + + s.logger.Info("企业信息提交成功", zap.String("user_id", cmd.UserID)) + return response, nil +} + +// 审核状态检查(步骤二) +// 规则:企业信息提交成功后进入待审核;审核通过后才允许进行企业认证确认(ConfirmAuth)。 +func (s *CertificationApplicationServiceImpl) checkAuditStatus(ctx context.Context, cert *entities.Certification) error { + switch cert.Status { + case enums.StatusInfoSubmitted, + enums.StatusEnterpriseVerified, + enums.StatusContractApplied, + enums.StatusContractSigned, + enums.StatusCompleted: + return nil + case enums.StatusInfoPendingReview: + return fmt.Errorf("企业信息已提交,正在审核中") + case enums.StatusInfoRejected: + return fmt.Errorf("企业信息审核未通过") + default: + return fmt.Errorf("认证状态不正确,当前状态: %s", enums.GetStatusName(cert.Status)) + } +} + +// ConfirmAuth 确认认证状态 +func (s *CertificationApplicationServiceImpl) ConfirmAuth( + ctx context.Context, + cmd *queries.ConfirmAuthCommand, +) (*responses.ConfirmAuthResponse, error) { + s.logger.Info("开始确认状态", zap.String("user_id", cmd.UserID)) + cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID) + if err != nil { + return nil, fmt.Errorf("加载认证信息失败: %s", err.Error()) + } + + // 步骤二:审核状态检查(审核通过后才能进入企业认证确认) + s.logger.Info("确认状态-步骤1-开始审核状态检查", zap.String("user_id", cmd.UserID)) + if err := s.checkAuditStatus(ctx, cert); err != nil { + return nil, err + } + s.logger.Info("确认状态-步骤1-审核状态检查通过", + zap.String("user_id", cmd.UserID), + zap.String("cert_status", string(cert.Status))) + record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID) + if err != nil { + return nil, fmt.Errorf("查找企业信息失败: %w", err) + } + s.logger.Info("确认状态-步骤2-获取最近提交记录成功", + zap.String("user_id", cmd.UserID), + 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, + }) + if err != nil { + s.logger.Error("查询企业认证信息失败", zap.Error(err)) + return nil, fmt.Errorf("查询企业认证信息失败: %w", err) + } + reason := "" + if identity != nil && identity.Data.RealnameStatus == 1 { + s.logger.Info("确认状态-步骤3-三方实名状态已完成,准备事务内推进认证", + zap.String("user_id", cmd.UserID)) + err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { + err = s.completeEnterpriseVerification(txCtx, cert, cert.UserID, record.CompanyName, record.LegalPersonName) + if err != nil { + return err + } + reason = "企业认证成功" + return nil + }) + if err != nil { + return nil, fmt.Errorf("完成企业认证失败: %w", err) + } + s.logger.Info("确认状态-步骤4-认证状态推进完成", + zap.String("user_id", cmd.UserID), + zap.String("cert_status", string(cert.Status))) + } else { + reason = "企业未完成" + s.logger.Info("确认状态-步骤3-三方实名状态未完成", + zap.String("user_id", cmd.UserID)) + } + return &responses.ConfirmAuthResponse{ + Status: cert.Status, + Reason: reason, + }, nil +} + +// ConfirmSign 确认签署状态 +func (s *CertificationApplicationServiceImpl) ConfirmSign( + ctx context.Context, + cmd *queries.ConfirmSignCommand, +) (*responses.ConfirmSignResponse, error) { + cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID) + if err != nil { + return nil, fmt.Errorf("加载认证信息失败: %s", err.Error()) + } + + reason, err := s.checkAndUpdateSignStatus(ctx, cert) + if err != nil { + return nil, fmt.Errorf("确认签署状态失败: %w", err) + } + + return &responses.ConfirmSignResponse{ + Status: cert.Status, + Reason: reason, + }, nil +} + +// ApplyContract 申请合同签署 +func (s *CertificationApplicationServiceImpl) ApplyContract( + ctx context.Context, + cmd *commands.ApplyContractCommand, +) (*responses.ContractSignUrlResponse, error) { + 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()) + } + err = cert.ApplyContract(contractInfo.EsignFlowID, contractInfo.ContractSignURL) + 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, + "请在规定时间内完成合同签署", + "合同申请成功", + ) + + s.logger.Info("合同申请成功", zap.String("user_id", cmd.UserID)) + return response, nil +} + +// ================ 查询用例 ================ + +// GetCertification 获取认证详情 +func (s *CertificationApplicationServiceImpl) GetCertification( + ctx context.Context, + query *queries.GetCertificationQuery, +) (*responses.CertificationResponse, error) { + s.logger.Debug("获取认证详情", zap.String("user_id", query.UserID)) + + // 1. 检查用户认证是否存在 + exists, err := s.aggregateService.ExistsByUserID(ctx, query.UserID) + if err != nil { + s.logger.Error("获取认证信息失败", zap.Error(err)) + return nil, fmt.Errorf("获取认证信息失败: %w", err) + } + + var cert *entities.Certification + if !exists { + // 创建新的认证记录 + cert, err = s.aggregateService.CreateCertification(ctx, query.UserID) + if err != nil { + s.logger.Error("创建认证信息失败", zap.Error(err)) + return nil, fmt.Errorf("创建认证信息失败: %w", err) + } + } else { + // 加载现有认证记录 + cert, err = s.aggregateService.LoadCertificationByUserID(ctx, query.UserID) + if err != nil { + s.logger.Error("加载认证信息失败", zap.Error(err)) + return nil, fmt.Errorf("加载认证信息失败: %w", err) + } + } + + // 2. 检查是否需要更新合同文件 + if cert.IsContractFileNeedUpdate() { + err = s.updateContractFile(ctx, cert) + if err != nil { + return nil, err + } + } + + if cert.Status == enums.StatusInfoSubmitted { + err = s.checkAndCompleteEnterpriseVerification(ctx, cert) + if err != nil { + return nil, err + } + } + if cert.Status == enums.StatusContractApplied { + _, err = s.checkAndUpdateSignStatus(ctx, cert) + if err != nil { + return nil, err + } + } + // 2. 转换为响应DTO + response := s.convertToResponse(cert) + + // 3. 添加状态相关的元数据 + meta, err := s.AddStatusMetadata(ctx, cert) + if err != nil { + return nil, err + } + if meta != nil { + response.Metadata = meta + } + + s.logger.Info("获取认证详情成功", zap.String("user_id", query.UserID)) + return response, nil +} + +// ListCertifications 获取认证列表(管理员) +func (s *CertificationApplicationServiceImpl) ListCertifications( + ctx context.Context, + query *queries.ListCertificationsQuery, +) (*responses.CertificationListResponse, error) { + s.logger.Debug("获取认证列表(管理员)") + + // 1. 转换为领域查询对象 + domainQuery := query.ToDomainQuery() + + // 2. 执行查询 + certs, total, err := s.queryRepository.List(ctx, domainQuery) + if err != nil { + s.logger.Error("查询认证列表失败", zap.Error(err)) + return nil, fmt.Errorf("查询认证列表失败: %w", err) + } + + // 3. 转换为响应DTO + items := make([]*responses.CertificationResponse, len(certs)) + for i, cert := range certs { + items[i] = s.convertToResponse(cert) + } + + // 4. 构建列表响应 + response := responses.NewCertificationListResponse(items, total, query.Page, query.PageSize) + + return response, nil +} + +// ================ e签宝回调处理 ================ + +// HandleEsignCallback 处理e签宝回调 +func (s *CertificationApplicationServiceImpl) HandleEsignCallback( + ctx context.Context, + cmd *commands.EsignCallbackCommand, +) error { + // if err := esign.VerifySignature(cmd.Data, cmd.Headers, cmd.QueryParams, s.esignConfig.AppSecret); err != nil { + // return fmt.Errorf("e签宝回调验签失败: %w", err) + // } + // 4. 根据回调类型处理业务逻辑 + switch cmd.Data.Action { + case "AUTH_PASS": + // 只处理企业认证通过 + if cmd.Data.AuthType == "ORG" { + err := s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { + + // 1. 根据AuthFlowId加载认证信息 + cert, err := s.aggregateService.LoadCertificationByAuthFlowId(txCtx, cmd.Data.AuthFlowId) + if err != nil { + return fmt.Errorf("加载认证信息失败: %s", err.Error()) + } + if cmd.Data.Organization == nil || cmd.Data.Organization.OrgName == "" { + return fmt.Errorf("组织信息为空") + } + if cert.Status != enums.StatusInfoSubmitted { + return fmt.Errorf("认证状态不正确") + } + // 2. 完成企业认证 + err = cert.CompleteEnterpriseVerification() + if err != nil { + return fmt.Errorf("完成企业认证失败: %s", err.Error()) + } + + record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(txCtx, cert.UserID) + if err != nil { + s.logger.Error("查找已认证企业信息失败", zap.Error(err)) + return fmt.Errorf("查找已认证企业信息失败: %w", err) + } + // 5. 写入用户域 + err = s.userAggregateService.CreateOrUpdateEnterpriseInfo( + txCtx, + record.UserID, + record.CompanyName, + record.UnifiedSocialCode, + record.LegalPersonName, + record.LegalPersonID, + record.LegalPersonPhone, + record.EnterpriseAddress, + ) + if err != nil { + s.logger.Error("同步企业信息到用户域失败", zap.Error(err)) + return fmt.Errorf("同步企业信息到用户域失败: %w", err) + } + + // 生成合同 + err = s.generateAndAddContractFile(txCtx, cert, record.CompanyName, record.UnifiedSocialCode, record.EnterpriseAddress, pickAuthorizedRepName(record, record.LegalPersonName)) + if err != nil { + return err + } + + // 3. 保存认证信息 + err = s.aggregateService.SaveCertification(txCtx, cert) + if err != nil { + return fmt.Errorf("保存认证信息失败: %s", err.Error()) + } + s.logger.Info("完成企业认证", zap.String("certification_id", cert.ID)) + + return nil + }) + if err != nil { + s.logger.Error("完成企业认证失败", zap.Error(err)) + return fmt.Errorf("完成企业认证失败: %w", err) + } + } + return nil + + default: + s.logger.Info("忽略未知的回调动作", zap.String("action", cmd.Data.Action)) + return nil + } +} + +// ================ 管理员后台操作用例 ================ + +// AdminCompleteCertificationWithoutContract 管理员代用户完成认证(暂不关联合同) +func (s *CertificationApplicationServiceImpl) AdminCompleteCertificationWithoutContract( + ctx context.Context, + cmd *commands.AdminCompleteCertificationCommand, +) (*responses.CertificationResponse, error) { + s.logger.Info("管理员代用户完成认证(不关联合同)", + zap.String("admin_id", cmd.AdminID), + zap.String("user_id", cmd.UserID), + ) + + // 1. 基础参数及企业信息校验 + enterpriseInfo := &certification_value_objects.EnterpriseInfo{ + CompanyName: cmd.CompanyName, + UnifiedSocialCode: cmd.UnifiedSocialCode, + LegalPersonName: cmd.LegalPersonName, + LegalPersonID: cmd.LegalPersonID, + LegalPersonPhone: cmd.LegalPersonPhone, + EnterpriseAddress: cmd.EnterpriseAddress, + } + if err := enterpriseInfo.Validate(); err != nil { + return nil, fmt.Errorf("企业信息验证失败: %s", err.Error()) + } + + // 检查统一社会信用代码唯一性(排除当前用户) + exists, err := s.userAggregateService.CheckUnifiedSocialCodeExists(ctx, cmd.UnifiedSocialCode, cmd.UserID) + if err != nil { + return nil, fmt.Errorf("检查企业信息失败: %s", err.Error()) + } + if exists { + return nil, fmt.Errorf("统一社会信用代码已被其他用户使用") + } + + var cert *entities.Certification + + // 2. 事务内:创建/加载认证、写入企业信息、直接完成认证、创建钱包和API用户 + err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { + // 2.1 检查并创建认证记录 + existsCert, err := s.aggregateService.ExistsByUserID(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("检查用户认证是否存在失败: %s", err.Error()) + } + if !existsCert { + cert, err = s.aggregateService.CreateCertification(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("创建认证信息失败: %s", err.Error()) + } + } else { + cert, err = s.aggregateService.LoadCertificationByUserID(txCtx, cmd.UserID) + if err != nil { + return fmt.Errorf("加载认证信息失败: %s", err.Error()) + } + } + + // 2.2 写入/覆盖用户域企业信息 + if err := s.userAggregateService.CreateOrUpdateEnterpriseInfo( + txCtx, + cmd.UserID, + cmd.CompanyName, + cmd.UnifiedSocialCode, + cmd.LegalPersonName, + cmd.LegalPersonID, + cmd.LegalPersonPhone, + cmd.EnterpriseAddress, + ); err != nil { + return fmt.Errorf("保存企业信息失败: %s", err.Error()) + } + + // 2.3 直接将认证状态设置为完成(管理员操作,暂不校验合同信息) + if err := cert.TransitionTo( + enums.StatusCompleted, + enums.ActorTypeAdmin, + cmd.AdminID, + fmt.Sprintf("管理员代用户完成认证:%s", cmd.Reason), + ); err != nil { + return fmt.Errorf("更新认证状态失败: %s", err.Error()) + } + + // 2.4 基础激活:创建钱包、API用户并在用户域标记完成认证 + if err := s.completeUserActivationWithoutContract(txCtx, cert); err != nil { + return err + } + + // 2.5 保存认证信息 + if err := s.aggregateService.SaveCertification(txCtx, cert); err != nil { + return fmt.Errorf("保存认证信息失败: %s", err.Error()) + } + + return nil + }) + if err != nil { + return nil, err + } + + response := s.convertToResponse(cert) + s.logger.Info("管理员代用户完成认证成功(不关联合同)", + zap.String("admin_id", cmd.AdminID), + zap.String("user_id", cmd.UserID), + zap.String("certification_id", cert.ID), + ) + return response, nil +} + +// AdminListSubmitRecords 管理端分页查询企业信息提交记录 +func (s *CertificationApplicationServiceImpl) AdminListSubmitRecords( + ctx context.Context, + query *queries.AdminListSubmitRecordsQuery, +) (*responses.AdminSubmitRecordsListResponse, error) { + if query.PageSize <= 0 { + query.PageSize = 10 + } + if query.Page <= 0 { + query.Page = 1 + } + filter := repositories.ListSubmitRecordsFilter{ + Page: query.Page, + PageSize: query.PageSize, + CertificationStatus: query.CertificationStatus, + CompanyName: query.CompanyName, + LegalPersonPhone: query.LegalPersonPhone, + LegalPersonName: query.LegalPersonName, + } + result, err := s.enterpriseInfoSubmitRecordRepo.List(ctx, filter) + if err != nil { + return nil, fmt.Errorf("查询提交记录失败: %w", err) + } + items := make([]*responses.AdminSubmitRecordItem, 0, len(result.Records)) + for _, r := range result.Records { + certStatus := "" + if cert, err := s.aggregateService.LoadCertificationByUserID(ctx, r.UserID); err == nil && cert != nil { + certStatus = string(cert.Status) + } + items = append(items, &responses.AdminSubmitRecordItem{ + ID: r.ID, + UserID: r.UserID, + CompanyName: r.CompanyName, + UnifiedSocialCode: r.UnifiedSocialCode, + LegalPersonName: r.LegalPersonName, + SubmitAt: r.SubmitAt, + Status: r.Status, + CertificationStatus: certStatus, + }) + } + totalPages := int((result.Total + int64(query.PageSize) - 1) / int64(query.PageSize)) + if totalPages == 0 { + totalPages = 1 + } + return &responses.AdminSubmitRecordsListResponse{ + Items: items, + Total: result.Total, + Page: query.Page, + PageSize: query.PageSize, + TotalPages: totalPages, + }, nil +} + +// AdminGetSubmitRecordByID 管理端获取单条提交记录详情 +func (s *CertificationApplicationServiceImpl) AdminGetSubmitRecordByID(ctx context.Context, recordID string) (*responses.AdminSubmitRecordDetail, error) { + record, err := s.enterpriseInfoSubmitRecordRepo.FindByID(ctx, recordID) + if err != nil { + return nil, fmt.Errorf("获取提交记录失败: %w", err) + } + certStatus := "" + if cert, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, record.UserID); loadErr == nil && cert != nil { + certStatus = string(cert.Status) + } + return &responses.AdminSubmitRecordDetail{ + ID: record.ID, + UserID: record.UserID, + CompanyName: record.CompanyName, + UnifiedSocialCode: record.UnifiedSocialCode, + LegalPersonName: record.LegalPersonName, + LegalPersonID: record.LegalPersonID, + LegalPersonPhone: record.LegalPersonPhone, + EnterpriseAddress: record.EnterpriseAddress, + AuthorizedRepName: record.AuthorizedRepName, + AuthorizedRepID: record.AuthorizedRepID, + AuthorizedRepPhone: record.AuthorizedRepPhone, + AuthorizedRepIDImageURLs: record.AuthorizedRepIDImageURLs, + BusinessLicenseImageURL: record.BusinessLicenseImageURL, + OfficePlaceImageURLs: record.OfficePlaceImageURLs, + APIUsage: record.APIUsage, + ScenarioAttachmentURLs: record.ScenarioAttachmentURLs, + Status: record.Status, + SubmitAt: record.SubmitAt, + VerifiedAt: record.VerifiedAt, + FailedAt: record.FailedAt, + FailureReason: record.FailureReason, + CertificationStatus: certStatus, + CreatedAt: record.CreatedAt, + UpdatedAt: record.UpdatedAt, + }, nil +} + +// AdminApproveSubmitRecord 管理端审核通过 +func (s *CertificationApplicationServiceImpl) AdminApproveSubmitRecord(ctx context.Context, recordID, adminID, remark string) error { + record, err := s.enterpriseInfoSubmitRecordRepo.FindByID(ctx, recordID) + if err != nil { + return fmt.Errorf("获取提交记录失败: %w", err) + } + if record.Status != "verified" { + return fmt.Errorf("该条提交记录未通过前置校验或已失败,无法审核通过") + } + cert, err := s.aggregateService.LoadCertificationByUserID(ctx, record.UserID) + if err != nil { + return fmt.Errorf("加载认证信息失败: %w", err) + } + + // 幂等:认证已进入「已提交企业信息」或更后续状态,说明已通过审核,无需重复操作 + switch cert.Status { + case enums.StatusInfoSubmitted, + enums.StatusEnterpriseVerified, + enums.StatusContractApplied, + enums.StatusContractSigned, + enums.StatusCompleted, + enums.StatusContractRejected, + enums.StatusContractExpired: + return nil + } + + 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)) + 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) + } + 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)) + return nil +} + +// AdminRejectSubmitRecord 管理端审核拒绝 +func (s *CertificationApplicationServiceImpl) AdminRejectSubmitRecord(ctx context.Context, recordID, adminID, remark string) error { + if remark == "" { + return fmt.Errorf("拒绝时必须填写审核备注") + } + record, err := s.enterpriseInfoSubmitRecordRepo.FindByID(ctx, recordID) + if err != nil { + return fmt.Errorf("获取提交记录失败: %w", err) + } + if record.Status != "verified" { + return fmt.Errorf("该条提交记录未通过前置校验或已失败,无法从后台拒绝(请查看历史失败原因)") + } + cert, err := s.aggregateService.LoadCertificationByUserID(ctx, record.UserID) + if err != nil { + return fmt.Errorf("加载认证信息失败: %w", err) + } + + // 幂等:认证已处于拒绝或后续状态,无需重复拒绝 + switch cert.Status { + case enums.StatusInfoRejected, + enums.StatusEnterpriseVerified, + enums.StatusContractApplied, + enums.StatusContractSigned, + enums.StatusCompleted, + enums.StatusContractRejected, + enums.StatusContractExpired: + return nil + } + if cert.Status != enums.StatusInfoPendingReview { + return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status)) + } + if err := cert.RejectEnterpriseInfoReview(adminID, remark); err != nil { + return fmt.Errorf("更新认证状态失败: %w", err) + } + if err := s.aggregateService.SaveCertification(ctx, cert); err != nil { + return fmt.Errorf("保存认证信息失败: %w", err) + } + record.MarkManualRejected(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)) + return nil +} + +// AdminTransitionCertificationStatus 管理端按用户变更认证状态(以状态机为准) +func (s *CertificationApplicationServiceImpl) AdminTransitionCertificationStatus(ctx context.Context, cmd *commands.AdminTransitionCertificationStatusCommand) error { + cert, err := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID) + if err != nil { + return fmt.Errorf("加载认证信息失败: %w", err) + } + record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cmd.UserID) + if err != nil { + return fmt.Errorf("查找企业信息提交记录失败: %w", err) + } + if record == nil { + return fmt.Errorf("未找到该用户的企业信息提交记录") + } + switch cmd.TargetStatus { + case string(enums.StatusInfoSubmitted): + // 审核通过:与 AdminApproveSubmitRecord 一致,推状态并生成企业认证链接 + switch cert.Status { + case enums.StatusInfoSubmitted, enums.StatusEnterpriseVerified, enums.StatusContractApplied, + enums.StatusContractSigned, enums.StatusCompleted, enums.StatusContractRejected, enums.StatusContractExpired: + return nil + } + 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)) + return nil + case string(enums.StatusInfoRejected): + // 审核拒绝 + if cert.Status == enums.StatusInfoRejected || cert.Status == enums.StatusEnterpriseVerified || + cert.Status == enums.StatusContractApplied || cert.Status == enums.StatusContractSigned || cert.Status == enums.StatusCompleted { + return nil + } + if cert.Status != enums.StatusInfoPendingReview { + return fmt.Errorf("认证状态不是待审核,当前: %s", enums.GetStatusName(cert.Status)) + } + if err := cert.RejectEnterpriseInfoReview(cmd.AdminID, cmd.Remark); err != nil { + return fmt.Errorf("更新认证状态失败: %w", err) + } + if err := s.aggregateService.SaveCertification(ctx, cert); err != nil { + return fmt.Errorf("保存认证信息失败: %w", err) + } + record.MarkManualRejected(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 + default: + return fmt.Errorf("不支持的目标状态: %s", cmd.TargetStatus) + } +} + +// ================ 辅助方法 ================ + +// convertToResponse 转换实体为响应DTO +func (s *CertificationApplicationServiceImpl) convertToResponse(cert *entities.Certification) *responses.CertificationResponse { + response := &responses.CertificationResponse{ + ID: cert.ID, + UserID: cert.UserID, + Status: cert.Status, + StatusName: enums.GetStatusName(cert.Status), + Progress: cert.GetProgress(), + CreatedAt: cert.CreatedAt, + UpdatedAt: cert.UpdatedAt, + InfoSubmittedAt: cert.InfoSubmittedAt, + EnterpriseVerifiedAt: cert.EnterpriseVerifiedAt, + ContractAppliedAt: cert.ContractAppliedAt, + ContractSignedAt: cert.ContractSignedAt, + CompletedAt: cert.CompletedAt, + IsCompleted: cert.IsCompleted(), + IsFailed: enums.IsFailureStatus(cert.Status), + IsUserActionRequired: cert.IsUserActionRequired(), + NextAction: enums.GetUserActionHint(cert.Status), + AvailableActions: cert.GetAvailableActions(), + RetryCount: cert.RetryCount, + Metadata: make(map[string]interface{}), + } + + // 设置企业信息(从认证实体中构建) + // TODO: 这里需要从企业信息服务或其他地方获取完整的企业信息 + // response.EnterpriseInfo = cert.EnterpriseInfo + + // 设置合同信息(从认证实体中构建) + if cert.ContractFileID != "" || cert.EsignFlowID != "" { + // TODO: 从认证实体字段构建合同信息值对象 + // response.ContractInfo = &value_objects.ContractInfo{...} + } + + // 设置失败信息 + if enums.IsFailureStatus(cert.Status) { + response.FailureReason = cert.FailureReason + response.FailureReasonName = enums.GetFailureReasonName(cert.FailureReason) + response.FailureMessage = cert.FailureMessage + response.CanRetry = enums.IsRetryable(cert.FailureReason) + } + + 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 + } + msg := err.Error() + return strings.Contains(msg, "企业用户已实名") || strings.Contains(msg, "已实名") +} + +// validateApplyContractCommand 验证申请合同命令 +func (s *CertificationApplicationServiceImpl) validateApplyContractCommand(cmd *commands.ApplyContractCommand) error { + if cmd.UserID == "" { + return fmt.Errorf("用户ID不能为空") + } + return nil +} + +// validateContractApplicationPreconditions 验证合同申请前置条件 +func (s *CertificationApplicationServiceImpl) validateContractApplicationPreconditions(cert *entities.Certification, userID string) error { + if cert.UserID != userID { + return fmt.Errorf("用户无权限操作此认证申请") + } + if cert.Status != enums.StatusEnterpriseVerified { + return fmt.Errorf("必须先完成企业认证才能申请合同") + } + return nil +} + +// 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, + }) + if err != nil { + return nil, fmt.Errorf("生成合同失败: %s", err.Error()) + } + + _, shortUrl, err := s.esignClient.GetSignURL(signFlowID, enterpriseInfo.LegalPersonPhone, enterpriseInfo.CompanyName) + if err != nil { + return nil, fmt.Errorf("获取签署链接失败: %s", err.Error()) + } + return &certification_value_objects.ContractInfo{ + ContractFileID: cert.ContractFileID, + EsignFlowID: signFlowID, + ContractSignURL: shortUrl, + }, nil +} + +// ================ 重构后的公共方法 ================ + +// completeEnterpriseVerification 完成企业认证的公共方法 +func (s *CertificationApplicationServiceImpl) completeEnterpriseVerification( + ctx context.Context, + cert *entities.Certification, + userID string, + companyName string, + legalPersonName string, +) error { + s.logger.Info("完成企业认证-步骤1-开始状态流转", + zap.String("user_id", userID), + zap.String("company_name", companyName)) + // 完成企业认证 + err := cert.CompleteEnterpriseVerification() + if err != nil { + s.logger.Error("完成企业认证失败", zap.Error(err)) + return fmt.Errorf("完成企业认证失败: %w", err) + } + + // 保存企业信息到用户域 + record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, userID) + if err != nil { + s.logger.Error("查找企业信息失败", zap.Error(err)) + return fmt.Errorf("查找企业信息失败: %w", err) + } + s.logger.Info("完成企业认证-步骤2-获取提交记录成功", + zap.String("user_id", userID), + zap.String("record_id", record.ID)) + + err = s.userAggregateService.CreateEnterpriseInfo( + ctx, + userID, + record.CompanyName, + record.UnifiedSocialCode, + record.LegalPersonName, + record.LegalPersonID, + record.LegalPersonPhone, + record.EnterpriseAddress, + ) + if err != nil { + s.logger.Error("保存企业信息到用户域失败", zap.Error(err)) + return fmt.Errorf("保存企业信息失败: %s", err.Error()) + } else { + 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)) + + // 保存认证信息 + err = s.aggregateService.SaveCertification(ctx, cert) + if err != nil { + s.logger.Error("保存认证信息失败", zap.Error(err)) + return fmt.Errorf("保存认证信息失败: %w", err) + } + s.logger.Info("完成企业认证-步骤4-认证信息保存成功", zap.String("user_id", userID)) + + 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, + cert *entities.Certification, + companyName string, + unifiedSocialCode string, + enterpriseAddress string, + authorizedRepName string, +) error { + s.logger.Info("合同生成-步骤1-开始填充合同模板", + zap.String("user_id", cert.UserID), + zap.String("company_name", companyName)) + + // 协议编号:已有则复用,否则新生成 + if cert.ContractCode == "" { + cert.SetContractCode(user_entities.GenerateContractCode(user_entities.ContractTypeCooperation)) + } + 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, + } + fillTemplateResp, err := s.esignClient.FillTemplate(fileComponent) + if err != nil { + s.logger.Error("生成合同失败", zap.Error(err)) + return fmt.Errorf("生成合同失败: %s", err.Error()) + } + 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) + if err != nil { + s.logger.Error("加入合同文件ID链接失败", zap.Error(err)) + return fmt.Errorf("加入合同文件ID链接失败: %s", err.Error()) + } + s.logger.Info("合同生成-步骤2-合同文件写入认证实体成功", + zap.String("user_id", cert.UserID), + zap.String("file_id", fillTemplateResp.FileID)) + return nil +} + +// updateContractFile 更新合同文件的公共方法 +func (s *CertificationApplicationServiceImpl) updateContractFile(ctx context.Context, cert *entities.Certification) error { + // 获取企业信息 + enterpriseInfo, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, cert.UserID) + if err != nil { + s.logger.Error("获取企业信息失败", zap.Error(err)) + return fmt.Errorf("获取企业信息失败: %w", err) + } + + ei := enterpriseInfo.EnterpriseInfo + submitRec, _ := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID) + authRep := pickAuthorizedRepName(submitRec, ei.LegalPersonName) + + // 生成合同 + err = s.generateAndAddContractFile(ctx, cert, ei.CompanyName, ei.UnifiedSocialCode, ei.EnterpriseAddress, authRep) + if err != nil { + return err + } + + // 更新认证信息 + err = s.aggregateService.SaveCertification(ctx, cert) + if err != nil { + s.logger.Error("保存认证信息失败", zap.Error(err)) + return fmt.Errorf("保存认证信息失败: %w", err) + } + + return nil +} + +// checkAndCompleteEnterpriseVerification 检查并完成企业认证的公共方法 +func (s *CertificationApplicationServiceImpl) checkAndCompleteEnterpriseVerification(ctx context.Context, cert *entities.Certification) error { + record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID) + if err != nil { + return fmt.Errorf("查找企业信息失败: %w", err) + } + identity, err := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{ + OrgName: record.CompanyName, + }) + if err != nil { + s.logger.Error("查询企业认证信息失败", zap.Error(err)) + } + if identity != nil && identity.Data.RealnameStatus == 1 { + err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { + return s.completeEnterpriseVerification(txCtx, cert, cert.UserID, record.CompanyName, record.LegalPersonName) + }) + if err != nil { + return fmt.Errorf("完成企业认证失败: %w", err) + } + } + return nil +} + +// checkAndUpdateSignStatus 检查并更新签署状态的公共方法 +func (s *CertificationApplicationServiceImpl) checkAndUpdateSignStatus(ctx context.Context, cert *entities.Certification) (string, error) { + var reason string + err := s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { + if cert.Status != enums.StatusContractApplied { + return fmt.Errorf("认证状态不正确") + } + detail, err := s.esignClient.QuerySignFlowDetail(cert.EsignFlowID) + if err != nil { + return fmt.Errorf("查询签署流程详情失败: %s", err.Error()) + } + if detail.Data.SignFlowStatus == 2 { + err = cert.SignSuccess() + if err != nil { + return fmt.Errorf("合同签署成功失败: %s", err.Error()) + } + err = cert.CompleteCertification() + 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) + if err != nil { + return fmt.Errorf("合同签署失败: %s", err.Error()) + } + reason = "合同签署拒签" + } else if detail.Data.SignFlowStatus == 5 { + err = cert.ContractExpiration() + if err != nil { + return fmt.Errorf("合同签署过期失败: %s", err.Error()) + } + reason = "合同签署过期" + } else { + reason = "合同签署中" + } + err = s.aggregateService.SaveCertification(ctx, cert) + if err != nil { + return fmt.Errorf("保存认证信息失败: %s", err.Error()) + } + return nil + }) + if err != nil { + return "", err + } + return reason, nil +} + +// handleContractAfterSignComplete 处理签署完成后的合同 +func (s *CertificationApplicationServiceImpl) handleContractAfterSignComplete(ctx context.Context, cert *entities.Certification) error { + // 获取用户的企业信息 + user, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, cert.UserID) + if err != nil { + return fmt.Errorf("加载用户信息失败: %w", err) + } + if user.EnterpriseInfo == nil { + return fmt.Errorf("用户企业信息不存在") + } + + // 1. 获取所有已签署合同文件信息 + downloadSignedFileResponse, err := s.esignClient.DownloadSignedFile(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 + 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. 保存到合同聚合根(复用认证阶段的合同编号) + _, err = s.contractAggregateService.CreateContractWithCode( + ctx, + user.EnterpriseInfo.ID, + cert.UserID, + fileName, + user_entities.ContractTypeCooperation, + fileId, + qiniuURL, + cert.ContractCode, + ) + if err != nil { + 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)) + } + + // 合同签署完成后的基础激活流程 + return s.completeUserActivationWithoutContract(ctx, cert) +} + +// downloadFileContent 通过URL下载文件内容 +func (s *CertificationApplicationServiceImpl) downloadFileContent(ctx context.Context, fileUrl string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileUrl, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("下载失败,状态码: %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} + +// 添加状态相关的元数据 +func (s *CertificationApplicationServiceImpl) AddStatusMetadata(ctx context.Context, cert *entities.Certification) (map[string]interface{}, error) { + metadata := make(map[string]interface{}) + metadata = cert.GetDataByStatus() + switch cert.Status { + case enums.StatusPending, enums.StatusInfoPendingReview, enums.StatusInfoRejected, enums.StatusInfoSubmitted, enums.StatusEnterpriseVerified: + record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID) + if err == nil && record != nil { + enterpriseInfo := map[string]interface{}{ + "company_name": record.CompanyName, + "legal_person_name": record.LegalPersonName, + "unified_social_code": record.UnifiedSocialCode, + "enterprise_address": record.EnterpriseAddress, + "legal_person_phone": record.LegalPersonPhone, + "legal_person_id": record.LegalPersonID, + "submit_at": record.SubmitAt.Format(time.RFC3339), + } + metadata["enterprise_info"] = enterpriseInfo + } + case enums.StatusCompleted: + // 获取最终合同信息 + contracts, err := s.contractAggregateService.FindByUserID(ctx, cert.UserID) + if err == nil && len(contracts) > 0 { + metadata["contract_url"] = contracts[0].ContractFileURL + } + } + + return metadata, nil +} + +// completeUserActivationWithoutContract 创建钱包、API用户并在用户域标记完成认证(不依赖合同信息) +func (s *CertificationApplicationServiceImpl) completeUserActivationWithoutContract(ctx context.Context, cert *entities.Certification) error { + // 创建钱包 + if _, err := s.walletAggregateService.CreateWallet(ctx, cert.UserID); err != nil { + s.logger.Error("创建钱包失败", zap.String("user_id", cert.UserID), zap.Error(err)) + } + + // 创建API用户 + if err := s.apiUserAggregateService.CreateApiUser(ctx, cert.UserID); err != nil { + s.logger.Error("创建API用户失败", zap.String("user_id", cert.UserID), zap.Error(err)) + } + + // 标记用户域完成认证 + if err := s.userAggregateService.CompleteCertification(ctx, cert.UserID); err != nil { + s.logger.Error("用户域完成认证失败", zap.String("user_id", cert.UserID), zap.Error(err)) + return err + } + + // 企业认证成功企业微信通知(仅展示企业名称和联系手机) + if s.wechatWorkService != nil { + user, err := s.userAggregateService.GetUserWithEnterpriseInfo(ctx, cert.UserID) + if err == nil { + companyName := "未知企业" + phone := "" + if user.EnterpriseInfo != nil { + companyName = user.EnterpriseInfo.CompanyName + if user.EnterpriseInfo.LegalPersonPhone != "" { + phone = user.EnterpriseInfo.LegalPersonPhone + } + } + if user.Phone != "" && phone == "" { + phone = user.Phone + } + content := fmt.Sprintf( + "### 【海宇数据】企业认证成功\n"+ + "> 企业名称:%s\n"+ + "> 联系手机:%s\n"+ + "> 完成时间:%s\n"+ + "\n该企业已完成认证,请相关同事同步更新内部系统。", + companyName, + phone, + time.Now().Format("2006-01-02 15:04:05"), + ) + _ = s.wechatWorkService.SendMarkdownMessage(ctx, content) + } + } + + return nil +} + +// RecognizeBusinessLicense OCR识别营业执照 +func (s *CertificationApplicationServiceImpl) RecognizeBusinessLicense( + ctx context.Context, + imageBytes []byte, +) (*responses.BusinessLicenseResult, error) { + s.logger.Info("开始OCR识别营业执照", zap.Int("image_size", len(imageBytes))) + + // 调用OCR服务识别营业执照 + result, err := s.ocrService.RecognizeBusinessLicense(ctx, imageBytes) + if err != nil { + s.logger.Error("OCR识别营业执照失败", zap.Error(err)) + return nil, fmt.Errorf("营业执照识别失败: %w", err) + } + + // 验证识别结果 + if err := s.ocrService.ValidateBusinessLicense(result); err != nil { + s.logger.Error("营业执照识别结果验证失败", zap.Error(err)) + return nil, fmt.Errorf("营业执照识别结果不完整: %w", err) + } + + s.logger.Info("营业执照OCR识别成功", + zap.String("company_name", result.CompanyName), + zap.String("unified_social_code", result.UnifiedSocialCode), + zap.String("legal_person_name", result.LegalPersonName), + zap.Float64("confidence", result.Confidence), + ) + + return result, nil +} diff --git a/internal/domains/certification/entities/certification.go b/internal/domains/certification/entities/certification.go index 58adecd..e78b7a8 100644 --- a/internal/domains/certification/entities/certification.go +++ b/internal/domains/certification/entities/certification.go @@ -35,6 +35,8 @@ type Certification struct { 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:"合同签署链接"` + // ContractCode 合作协议编号(与电子合同模板控件 xybh 一致,签署完成后写入用户域合同) + ContractCode string `gorm:"type:varchar(255)" json:"contract_code,omitempty" comment:"合作协议编号"` // === 失败信息 === FailureReason enums.FailureReason `gorm:"type:varchar(100)" json:"failure_reason,omitempty" comment:"失败原因"` @@ -323,6 +325,11 @@ func (c *Certification) ApplyContract(EsignFlowID string, ContractSignURL string return nil } +// SetContractCode 设置合作协议编号(首次生成合同时写入,后续换文件复用) +func (c *Certification) SetContractCode(code string) { + c.ContractCode = code +} + // AddContractFileID 生成合同文件 func (c *Certification) AddContractFileID(contractFileID string, contractURL string) error { c.ContractFileID = contractFileID diff --git a/internal/domains/user/entities/contract_info.go b/internal/domains/user/entities/contract_info.go index 5bcb751..4afe8ed 100644 --- a/internal/domains/user/entities/contract_info.go +++ b/internal/domains/user/entities/contract_info.go @@ -119,6 +119,58 @@ func NewContractInfo(enterpriseInfoID, userID, contractName string, contractType return contractInfo, nil } +// NewContractInfoWithContractCode 使用指定合同编号创建合同信息(与认证阶段生成的编号一致) +func NewContractInfoWithContractCode(enterpriseInfoID, userID, contractName string, contractType ContractType, contractFileID, contractFileURL, contractCode string) (*ContractInfo, error) { + if enterpriseInfoID == "" { + return nil, fmt.Errorf("企业信息ID不能为空") + } + if userID == "" { + return nil, fmt.Errorf("用户ID不能为空") + } + if contractName == "" { + return nil, fmt.Errorf("合同名称不能为空") + } + if contractType == "" { + return nil, fmt.Errorf("合同类型不能为空") + } + if contractFileID == "" { + return nil, fmt.Errorf("合同文件ID不能为空") + } + if contractFileURL == "" { + return nil, fmt.Errorf("合同文件URL不能为空") + } + if contractCode == "" { + return nil, fmt.Errorf("合同编号不能为空") + } + if !isValidContractType(contractType) { + return nil, fmt.Errorf("无效的合同类型: %s", contractType) + } + + contractInfo := &ContractInfo{ + ID: uuid.New().String(), + EnterpriseInfoID: enterpriseInfoID, + UserID: userID, + ContractCode: contractCode, + ContractName: contractName, + ContractType: contractType, + ContractFileID: contractFileID, + ContractFileURL: contractFileURL, + domainEvents: make([]interface{}, 0), + } + + contractInfo.addDomainEvent(&ContractInfoCreatedEvent{ + ContractInfoID: contractInfo.ID, + EnterpriseInfoID: enterpriseInfoID, + UserID: userID, + ContractCode: contractCode, + ContractName: contractName, + ContractType: string(contractType), + CreatedAt: time.Now(), + }) + + return contractInfo, nil +} + // ================ 聚合根核心方法 ================ // UpdateContractInfo 更新合同信息 diff --git a/internal/domains/user/services/contract_aggregate_service.go b/internal/domains/user/services/contract_aggregate_service.go index 5868f4b..fe6a02e 100644 --- a/internal/domains/user/services/contract_aggregate_service.go +++ b/internal/domains/user/services/contract_aggregate_service.go @@ -14,6 +14,7 @@ import ( type ContractAggregateService interface { // 聚合根生命周期管理 CreateContract(ctx context.Context, enterpriseInfoID, userID, contractName string, contractType entities.ContractType, contractFileID, contractFileURL string) (*entities.ContractInfo, error) + CreateContractWithCode(ctx context.Context, enterpriseInfoID, userID, contractName string, contractType entities.ContractType, contractFileID, contractFileURL, contractCode string) (*entities.ContractInfo, error) LoadContract(ctx context.Context, contractID string) (*entities.ContractInfo, error) SaveContract(ctx context.Context, contract *entities.ContractInfo) error DeleteContract(ctx context.Context, contractID string) error @@ -94,6 +95,51 @@ func (s *ContractAggregateServiceImpl) CreateContract( return contract, nil } +// CreateContractWithCode 使用指定合同编号创建合同信息(与认证合同模板上的编号一致) +func (s *ContractAggregateServiceImpl) CreateContractWithCode( + ctx context.Context, + enterpriseInfoID, userID, contractName string, + contractType entities.ContractType, + contractFileID, contractFileURL, contractCode string, +) (*entities.ContractInfo, error) { + s.logger.Debug("创建合同信息(指定编号)", + zap.String("enterprise_info_id", enterpriseInfoID), + zap.String("user_id", userID), + zap.String("contract_name", contractName), + zap.String("contract_code", contractCode), + zap.String("contract_type", string(contractType))) + + exists, err := s.ExistsByContractFileID(ctx, contractFileID) + if err != nil { + return nil, fmt.Errorf("检查合同文件ID失败: %w", err) + } + if exists { + return nil, fmt.Errorf("合同文件ID已存在") + } + + contract, err := entities.NewContractInfoWithContractCode(enterpriseInfoID, userID, contractName, contractType, contractFileID, contractFileURL, contractCode) + if err != nil { + return nil, fmt.Errorf("创建合同信息失败: %w", err) + } + + if err := s.ValidateBusinessRules(ctx, contract); err != nil { + return nil, fmt.Errorf("业务规则验证失败: %w", err) + } + + err = s.SaveContract(ctx, contract) + if err != nil { + return nil, fmt.Errorf("保存合同信息失败: %w", err) + } + + s.logger.Info("合同信息创建成功", + zap.String("contract_id", contract.ID), + zap.String("enterprise_info_id", enterpriseInfoID), + zap.String("contract_code", contractCode), + zap.String("contract_name", contractName)) + + return contract, nil +} + // LoadContract 加载合同信息 func (s *ContractAggregateServiceImpl) LoadContract(ctx context.Context, contractID string) (*entities.ContractInfo, error) { s.logger.Debug("加载合同信息", zap.String("contract_id", contractID)) diff --git a/internal/shared/esign/signflow_service.go b/internal/shared/esign/signflow_service.go index 4327d42..591aea7 100644 --- a/internal/shared/esign/signflow_service.go +++ b/internal/shared/esign/signflow_service.go @@ -28,13 +28,13 @@ func (s *SignFlowService) UpdateConfig(config *Config) { // 创建包含多个签署人的签署流程,支持自动盖章和手动签署 func (s *SignFlowService) Create(req *CreateSignFlowRequest) (string, error) { fmt.Println("开始创建签署流程...") - fmt.Println("(将创建包含甲方自动盖章和乙方手动签署的流程)") + fmt.Println("(将创建包含甲方手动签署和乙方自动盖章的流程)") - // 构建甲方签署人信息(自动盖章) - partyASigner := s.buildPartyASigner(req.FileID) + // 构建甲方签署人信息(手动签署) + partyASigner := s.buildPartyASigner(req.FileID, req.SignerAccount, req.SignerName, req.TransactorPhone, req.TransactorName, req.TransactorIDCardNum) - // 构建乙方签署人信息(手动签署) - partyBSigner := s.buildPartyBSigner(req.FileID, req.SignerAccount, req.SignerName, req.TransactorPhone, req.TransactorName, req.TransactorIDCardNum) + // 构建乙方签署人信息(自动盖章) + partyBSigner := s.buildPartyBSigner(req.FileID) signers := []SignerInfo{partyASigner, partyBSigner} @@ -128,34 +128,11 @@ func (s *SignFlowService) GetSignURL(signFlowID, psnAccount, orgName string) (st return response.Data.Url, response.Data.ShortUrl, nil } -// buildPartyASigner 构建甲方签署人信息(自动盖章) -func (s *SignFlowService) buildPartyASigner(fileID string) SignerInfo { - return SignerInfo{ - SignConfig: &SignConfig{SignOrder: 1}, - SignerType: SignerTypeOrg, - SignFields: []SignField{ - { - CustomBizNum: "甲方签章", - FileId: fileID, - NormalSignFieldConfig: &NormalSignFieldConfig{ - AutoSign: true, - SignFieldStyle: SignFieldStyleNormal, - SignFieldPosition: &SignFieldPosition{ - PositionPage: "8", - PositionX: 200, - PositionY: 430, - }, - }, - }, - }, - } -} - -// buildPartyBSigner 构建乙方签署人信息(手动签署) -func (s *SignFlowService) buildPartyBSigner(fileID, signerAccount, signerName, transactorPhone, transactorName, transactorIDCardNum string) SignerInfo { +// buildPartyASigner 构建甲方签署人信息(手动签署) +func (s *SignFlowService) buildPartyASigner(fileID, signerAccount, signerName, transactorPhone, transactorName, transactorIDCardNum string) SignerInfo { return SignerInfo{ SignConfig: &SignConfig{ - SignOrder: 2, + SignOrder: 1, }, AuthConfig: &AuthConfig{ PsnAvailableAuthModes: []string{AuthModeMobile3}, @@ -182,19 +159,66 @@ func (s *SignFlowService) buildPartyBSigner(fileID, signerAccount, signerName, t }, SignFields: []SignField{ { - CustomBizNum: "乙方签章", + CustomBizNum: "甲方签章", FileId: fileID, NormalSignFieldConfig: &NormalSignFieldConfig{ AutoSign: false, SignFieldStyle: SignFieldStyleNormal, SignFieldPosition: &SignFieldPosition{ - PositionPage: "8", - PositionX: 450, - PositionY: 430, + PositionPage: "10", + PositionX: 165, + PositionY: 197, }, OrgSealBizTypes: "PUBLIC", }, }, + { + CustomBizNum: "甲方骑缝章", // 建议设唯一标识,便于调试 + FileId: fileID, + NormalSignFieldConfig: &NormalSignFieldConfig{ + AutoSign: false, + SignFieldStyle: SignFieldStyleSeam, // 必须为 2(Edges) + SignFieldPosition: &SignFieldPosition{ + AcrossPageMode: "ALL", // 覆盖全部页面(推荐) + PositionY: 694.0, // 您指定的 Y 坐标(float64) + }, + }, + }, + }, + } +} + +// buildPartyBSigner 构建乙方签署人信息(自动盖章) +func (s *SignFlowService) buildPartyBSigner(fileID string) SignerInfo { + return SignerInfo{ + SignConfig: &SignConfig{SignOrder: 2}, + SignerType: SignerTypeOrg, + SignFields: []SignField{ + { + CustomBizNum: "乙方签章", + FileId: fileID, + NormalSignFieldConfig: &NormalSignFieldConfig{ + AutoSign: true, + SignFieldStyle: SignFieldStyleNormal, + SignFieldPosition: &SignFieldPosition{ + PositionPage: "10", + PositionX: 403, + PositionY: 197, + }, + }, + }, + { + CustomBizNum: "乙方骑缝章", // 建议设唯一标识,便于调试 + FileId: fileID, + NormalSignFieldConfig: &NormalSignFieldConfig{ + AutoSign: true, // 骑缝章也支持自动签署 + SignFieldStyle: SignFieldStyleSeam, // 必须为 2(Edges) + SignFieldPosition: &SignFieldPosition{ + AcrossPageMode: "ALL", // 覆盖全部页面(推荐) + PositionY: 554.0, // 您指定的 Y 坐标(float64) + }, + }, + }, }, } } @@ -216,4 +240,4 @@ func (s *SignFlowService) buildSignFlowConfig() SignFlowConfig { RedirectUrl: s.config.Sign.RedirectUrl, }, } -} \ No newline at end of file +} diff --git a/internal/shared/esign/template_service.go b/internal/shared/esign/template_service.go index 5e17022..ea21d98 100644 --- a/internal/shared/esign/template_service.go +++ b/internal/shared/esign/template_service.go @@ -161,7 +161,7 @@ func CreateDefaultComponents() []Component { }, { ComponentKey: "QDRQ", - ComponentValue: time.Now().Format("2006年01月02日"), + ComponentValue: time.Now().Format("2006-01-02"), }, } } diff --git a/internal/shared/esign/types.go b/internal/shared/esign/types.go index 78b0385..c9cdc08 100644 --- a/internal/shared/esign/types.go +++ b/internal/shared/esign/types.go @@ -75,8 +75,8 @@ type SignFlowConfig struct { // RedirectConfig 重定向配置 type RedirectConfig struct { - RedirectUrl string `json:"redirectUrl"` // 重定向URL - RedirectDelayTime int64 `json:"redirectDelayTime"` //重定向时间 + RedirectUrl string `json:"redirectUrl"` // 重定向URL + RedirectDelayTime int64 `json:"redirectDelayTime"` //重定向时间 } // AuthConfig 认证配置 @@ -170,9 +170,10 @@ type NormalSignFieldConfig struct { // SignFieldPosition 签署区位置 type SignFieldPosition struct { - PositionPage string `json:"positionPage"` // 页码 - PositionX float64 `json:"positionX"` // X坐标 - PositionY float64 `json:"positionY"` // Y坐标 + PositionPage string `json:"positionPage,omitempty"` // 页码(骑缝章可与 acrossPageMode 组合) + PositionX float64 `json:"positionX,omitempty"` // X坐标 + PositionY float64 `json:"positionY,omitempty"` // Y坐标 + AcrossPageMode string `json:"acrossPageMode,omitempty"` // 骑缝章跨页:如 ALL 表示全部页面 } // ==================== 签署页面链接相关结构体 ==================== diff --git a/internal/shared/esign/utils.go b/internal/shared/esign/utils.go index 88d304a..362aec8 100644 --- a/internal/shared/esign/utils.go +++ b/internal/shared/esign/utils.go @@ -104,11 +104,11 @@ func getCurrentDate() string { } // formatDateForTemplate 格式化日期用于模板填写 -// 格式: "2006年01月02日" +// e签宝日期控件通常预设为 yyyy-MM-dd,与中文年月日格式不兼容时需用本格式。 // -// 返回: 中文格式的日期字符串 +// 返回: yyyy-MM-dd func formatDateForTemplate() string { - return time.Now().Format("2006年01月02日") + return time.Now().Format("2006-01-02") } // generateFileName 生成带时间戳的文件名