diff --git a/internal/application/certification/certification_application_service.go b/internal/application/certification/certification_application_service.go index 8e42854..bb045b5 100644 --- a/internal/application/certification/certification_application_service.go +++ b/internal/application/certification/certification_application_service.go @@ -37,6 +37,17 @@ type CertificationApplicationService interface { // AdminCompleteCertificationWithoutContract 管理员代用户完成认证(暂不关联合同) AdminCompleteCertificationWithoutContract(ctx context.Context, cmd *commands.AdminCompleteCertificationCommand) (*responses.CertificationResponse, error) + // AdminListSubmitRecords 管理端分页查询企业信息提交记录 + AdminListSubmitRecords(ctx context.Context, query *queries.AdminListSubmitRecordsQuery) (*responses.AdminSubmitRecordsListResponse, error) + // AdminGetSubmitRecordByID 管理端获取单条提交记录详情 + AdminGetSubmitRecordByID(ctx context.Context, recordID string) (*responses.AdminSubmitRecordDetail, error) + // AdminApproveSubmitRecord 管理端审核通过(按提交记录 ID) + AdminApproveSubmitRecord(ctx context.Context, recordID, adminID, remark string) error + // AdminRejectSubmitRecord 管理端审核拒绝(按提交记录 ID) + AdminRejectSubmitRecord(ctx context.Context, recordID, adminID, remark string) error + // AdminTransitionCertificationStatus 管理端按用户变更认证状态(以状态机为准:info_submitted=通过 / info_rejected=拒绝) + AdminTransitionCertificationStatus(ctx context.Context, cmd *commands.AdminTransitionCertificationStatusCommand) error + // ================ e签宝回调处理 ================ // 处理e签宝回调 diff --git a/internal/application/certification/certification_application_service_impl.go b/internal/application/certification/certification_application_service_impl.go index f38f456..0e90009 100644 --- a/internal/application/certification/certification_application_service_impl.go +++ b/internal/application/certification/certification_application_service_impl.go @@ -2,6 +2,7 @@ package certification import ( "context" + "encoding/json" "fmt" "io" "net/http" @@ -10,6 +11,7 @@ import ( "tyapi-server/internal/application/certification/dto/commands" "tyapi-server/internal/application/certification/dto/queries" "tyapi-server/internal/application/certification/dto/responses" + "tyapi-server/internal/config" api_service "tyapi-server/internal/domains/api/services" "tyapi-server/internal/domains/certification/entities" certification_value_objects "tyapi-server/internal/domains/certification/entities/value_objects" @@ -18,8 +20,7 @@ import ( "tyapi-server/internal/domains/certification/services" finance_service "tyapi-server/internal/domains/finance/services" user_entities "tyapi-server/internal/domains/user/entities" - user_service "tyapi-server/internal/domains/user/services" - "tyapi-server/internal/config" + user_service "tyapi-server/internal/domains/user/services" "tyapi-server/internal/infrastructure/external/notification" "tyapi-server/internal/infrastructure/external/storage" "tyapi-server/internal/shared/database" @@ -51,6 +52,7 @@ type CertificationApplicationServiceImpl struct { wechatWorkService *notification.WeChatWorkService logger *zap.Logger + config *config.Config } // NewCertificationApplicationService 创建认证应用服务 @@ -93,6 +95,7 @@ func NewCertificationApplicationService( txManager: txManager, wechatWorkService: wechatSvc, logger: logger, + config: cfg, } } @@ -106,7 +109,16 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( s.logger.Info("开始提交企业信息", zap.String("user_id", cmd.UserID)) - // 1.5 插入企业信息提交记录 + // 0. 若该用户已有待审核(认证状态仍在待审核),则不允许重复提交 + latestRecord, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cmd.UserID) + if err == nil && latestRecord != nil { + cert, loadErr := s.aggregateService.LoadCertificationByUserID(ctx, cmd.UserID) + if loadErr == nil && cert != nil && cert.Status == enums.StatusInfoPendingReview { + return nil, fmt.Errorf("您已有待审核的提交,请等待管理员审核后再操作") + } + } + + // 1.5 插入企业信息提交记录(包含扩展字段) record := entities.NewEnterpriseInfoSubmitRecord( cmd.UserID, cmd.CompanyName, @@ -117,6 +129,36 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( 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" { @@ -131,7 +173,8 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( } s.logger.Info("开始处理企业信息提交", zap.String("user_id", cmd.UserID)) - // 1. 检查企业信息是否重复(统一社会信用代码,已经认证了的,不能重复提交) + // 1. 检查企业信息是否重复(统一社会信用代码:已认证或已提交待审核的都不能重复) + // 1.1 已写入用户域 enterprise_infos 的(已完成认证) exists, err := s.userAggregateService.CheckUnifiedSocialCodeExists(ctx, cmd.UnifiedSocialCode, cmd.UserID) if err != nil { record.MarkAsFailed(err.Error()) @@ -149,6 +192,24 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( } return nil, fmt.Errorf("该企业信息已被其他用户使用,请确认企业信息是否正确") } + // 1.2 已提交/已通过验证的提交记录(尚未完成认证但已占用的信用代码) + existsInSubmit, err := s.enterpriseInfoSubmitRecordRepo.ExistsByUnifiedSocialCodeExcludeUser(ctx, cmd.UnifiedSocialCode, cmd.UserID) + if err != nil { + 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 { + 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, @@ -179,10 +240,6 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( return nil, fmt.Errorf("企业信息验证失败, %s", err.Error()) } record.MarkAsVerified() - saveErr := s.enterpriseInfoSubmitRecordService.Save(ctx, record) - if saveErr != nil { - return nil, fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) - } var response *responses.CertificationResponse err = s.txManager.ExecuteInTx(ctx, func(txCtx context.Context) error { @@ -205,70 +262,47 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( return fmt.Errorf("加载认证信息失败: %s", err.Error()) } - // 3. 调用e签宝看是否进行过认证 - respMeta := map[string]interface{}{} + // 4. 提交企业信息:暂时跳过人工审核,直接进入「已提交」状态(第三步企业认证) + // 恢复人工审核时改为 cert.SubmitEnterpriseInfoForReview(enterpriseInfo),并将 next_action 改为「请等待管理员审核企业信息」 - identity, err := s.esignClient.QueryOrgIdentityInfo(&esign.QueryOrgIdentityRequest{ - OrgName: cmd.CompanyName, + // 生成企业认证链接 + authURL, err := s.esignClient.GenerateEnterpriseAuth(&esign.EnterpriseAuthRequest{ + CompanyName: enterpriseInfo.CompanyName, + UnifiedSocialCode: enterpriseInfo.UnifiedSocialCode, + LegalPersonName: enterpriseInfo.LegalPersonName, + LegalPersonID: enterpriseInfo.LegalPersonID, + TransactorName: enterpriseInfo.LegalPersonName, + TransactorMobile: enterpriseInfo.LegalPersonPhone, + TransactorID: enterpriseInfo.LegalPersonID, }) - if identity != nil && identity.Data.RealnameStatus == 1 { - // 已提交 - err = cert.SubmitEnterpriseInfo(enterpriseInfo, "", "") - if err != nil { - return fmt.Errorf("提交企业认证信息失败: %s", err.Error()) - } - s.logger.Info("企业认证成功", zap.Any("identity", identity)) + if err != nil { + return fmt.Errorf("生成企业认证链接失败: %w", err) + } - // 完成企业认证流程 - err = s.completeEnterpriseVerification(txCtx, cert, cmd.UserID, cmd.CompanyName, cmd.LegalPersonName) - if err != nil { - return err - } - - respMeta = map[string]interface{}{ - "enterprise_info": enterpriseInfo, - "next_action": "企业已认证,可进行后续操作", - } - } else { - if err != nil { - s.logger.Error("e签宝查询企业认证信息失败或未进行企业认证", zap.Error(err)) - } - authURL, err := s.esignClient.GenerateEnterpriseAuth(&esign.EnterpriseAuthRequest{ - CompanyName: enterpriseInfo.CompanyName, - UnifiedSocialCode: enterpriseInfo.UnifiedSocialCode, - LegalPersonName: enterpriseInfo.LegalPersonName, - LegalPersonID: enterpriseInfo.LegalPersonID, - TransactorName: enterpriseInfo.LegalPersonName, - TransactorMobile: enterpriseInfo.LegalPersonPhone, - TransactorID: enterpriseInfo.LegalPersonID, - }) - if err != nil { - s.logger.Error("生成企业认证链接失败", zap.Error(err)) - return fmt.Errorf("生成企业认证链接失败: %s", err.Error()) - } - err = cert.SubmitEnterpriseInfo(enterpriseInfo, authURL.AuthShortURL, authURL.AuthFlowID) - if err != nil { - return fmt.Errorf("提交企业认证信息失败: %s", err.Error()) - } - respMeta = map[string]interface{}{ - "enterprise_info": enterpriseInfo, - "authUrl": authURL.AuthURL, - "next_action": "请完成企业认证", - } + err = cert.SubmitEnterpriseInfo(enterpriseInfo, authURL.AuthShortURL, authURL.AuthFlowID) + if err != nil { + return fmt.Errorf("提交企业信息失败: %s", err.Error()) } err = s.aggregateService.SaveCertification(txCtx, cert) if err != nil { return fmt.Errorf("保存认证信息失败: %s", err.Error()) } - // 5. 转换为响应DTO - response = s.convertToResponse(cert) - // 6. 添加工作流结果信息 + // 5. 提交记录与认证状态在同一事务内保存 + if saveErr := s.enterpriseInfoSubmitRecordService.Save(txCtx, record); saveErr != nil { + return fmt.Errorf("保存企业信息提交记录失败: %s", saveErr.Error()) + } + + respMeta := map[string]interface{}{ + "enterprise_info": enterpriseInfo, + "next_action": "请完成企业认证", + } + // 6. 转换为响应 DTO + response = s.convertToResponse(cert) if respMeta != nil { response.Metadata = respMeta } - return nil }) if err != nil { @@ -279,6 +313,28 @@ func (s *CertificationApplicationServiceImpl) SubmitEnterpriseInfo( return response, nil } +// 审核状态检查(步骤二) +// 规则:企业信息提交成功后进入待审核;审核通过后才允许进行企业认证确认(ConfirmAuth)。 +// 当前暂时跳过人工审核(待审核状态视为通过);启用审核时恢复对 StatusInfoPendingReview 返回错误。 +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("企业信息已提交,正在审核中") + s.logger.Info("跳过人工审核状态检查", zap.String("user_id", cert.UserID)) + return nil + case enums.StatusInfoRejected: + return fmt.Errorf("企业信息审核未通过") + default: + return fmt.Errorf("认证状态不正确,当前状态: %s", enums.GetStatusName(cert.Status)) + } +} + // ConfirmAuth 确认认证状态 func (s *CertificationApplicationServiceImpl) ConfirmAuth( ctx context.Context, @@ -290,9 +346,9 @@ func (s *CertificationApplicationServiceImpl) ConfirmAuth( return nil, fmt.Errorf("加载认证信息失败: %s", err.Error()) } - // 企业认证 - if cert.Status != enums.StatusInfoSubmitted { - return nil, fmt.Errorf("认证状态不正确,当前状态: %s", enums.GetStatusName(cert.Status)) + // 步骤二:审核状态检查(审核通过后才能进入企业认证确认) + if err := s.checkAuditStatus(ctx, cert); err != nil { + return nil, err } record, err := s.enterpriseInfoSubmitRecordRepo.FindLatestByUserID(ctx, cert.UserID) if err != nil { @@ -694,6 +750,258 @@ func (s *CertificationApplicationServiceImpl) AdminCompleteCertificationWithoutC 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) + } + 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, + } + authURL, err := s.esignClient.GenerateEnterpriseAuth(&esign.EnterpriseAuthRequest{ + CompanyName: enterpriseInfo.CompanyName, + UnifiedSocialCode: enterpriseInfo.UnifiedSocialCode, + LegalPersonName: enterpriseInfo.LegalPersonName, + LegalPersonID: enterpriseInfo.LegalPersonID, + TransactorName: enterpriseInfo.LegalPersonName, + TransactorMobile: enterpriseInfo.LegalPersonPhone, + TransactorID: enterpriseInfo.LegalPersonID, + }) + if err != nil { + return fmt.Errorf("生成企业认证链接失败: %w", err) + } + 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) + } + 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) + } + 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) + } + 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, + } + authURL, err := s.esignClient.GenerateEnterpriseAuth(&esign.EnterpriseAuthRequest{ + CompanyName: enterpriseInfo.CompanyName, UnifiedSocialCode: enterpriseInfo.UnifiedSocialCode, + LegalPersonName: enterpriseInfo.LegalPersonName, LegalPersonID: enterpriseInfo.LegalPersonID, + TransactorName: enterpriseInfo.LegalPersonName, TransactorMobile: enterpriseInfo.LegalPersonPhone, TransactorID: enterpriseInfo.LegalPersonID, + }) + if err != nil { + return fmt.Errorf("生成企业认证链接失败: %w", err) + } + 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) + } + 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) + } + 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 diff --git a/internal/application/certification/dto/commands/certification_commands.go b/internal/application/certification/dto/commands/certification_commands.go index 3f73759..99fd29e 100644 --- a/internal/application/certification/dto/commands/certification_commands.go +++ b/internal/application/certification/dto/commands/certification_commands.go @@ -94,6 +94,14 @@ type ForceTransitionStatusCommand struct { Force bool `json:"force,omitempty"` // 是否强制执行,跳过业务规则验证 } +// AdminTransitionCertificationStatusCommand 管理端变更认证状态(以状态机为准,用于审核通过/拒绝等) +type AdminTransitionCertificationStatusCommand struct { + AdminID string `json:"-"` + UserID string `json:"user_id" validate:"required"` + TargetStatus string `json:"target_status" validate:"required,oneof=info_submitted info_rejected"` // 审核通过 -> info_submitted;审核拒绝 -> info_rejected + Remark string `json:"remark"` +} + // SubmitEnterpriseInfoCommand 提交企业信息命令 type SubmitEnterpriseInfoCommand struct { UserID string `json:"-" comment:"用户唯一标识,从JWT token获取,不在JSON中暴露"` @@ -104,4 +112,19 @@ type SubmitEnterpriseInfoCommand struct { LegalPersonPhone string `json:"legal_person_phone" binding:"required,phone" comment:"法定代表人手机号,11位,如:13800138000"` EnterpriseAddress string `json:"enterprise_address" binding:"required,enterprise_address" comment:"企业地址,如:北京市海淀区"` VerificationCode string `json:"verification_code" binding:"required,len=6" comment:"验证码"` + + // 营业执照图片 URL(单张) + BusinessLicenseImageURL string `json:"business_license_image_url" binding:"omitempty,url" comment:"营业执照图片URL"` + // 办公场地图片 URL 列表(前端传 string 数组) + OfficePlaceImageURLs []string `json:"office_place_image_urls" binding:"omitempty,dive,url" comment:"办公场地图片URL列表"` + + // 授权代表信息(与前端 authorized_rep_* 及表字段一致) + AuthorizedRepName string `json:"authorized_rep_name" binding:"omitempty,min=2,max=20" comment:"授权代表姓名"` + AuthorizedRepID string `json:"authorized_rep_id" binding:"omitempty,id_card" comment:"授权代表身份证号"` + AuthorizedRepPhone string `json:"authorized_rep_phone" binding:"omitempty,phone" comment:"授权代表手机号"` + AuthorizedRepIDImageURLs []string `json:"authorized_rep_id_image_urls" binding:"omitempty,dive,url" comment:"授权代表身份证正反面图片URL"` + + // 应用场景 + APIUsage string `json:"api_usage" binding:"omitempty,min=5,max=500" comment:"接口用途及业务场景说明"` + ScenarioAttachmentURLs []string `json:"scenario_attachment_urls" binding:"omitempty,dive,url" comment:"场景附件图片URL列表"` } diff --git a/internal/application/certification/dto/queries/certification_queries.go b/internal/application/certification/dto/queries/certification_queries.go index 5afa2c2..f6fa31b 100644 --- a/internal/application/certification/dto/queries/certification_queries.go +++ b/internal/application/certification/dto/queries/certification_queries.go @@ -192,3 +192,13 @@ func (q *GetSystemMonitoringQuery) ShouldIncludeMetric(metric string) bool { } return false } + +// AdminListSubmitRecordsQuery 管理端企业信息提交记录列表查询(以状态机 certification_status 为准,不做审核状态筛选) +type AdminListSubmitRecordsQuery struct { + Page int `json:"page" form:"page"` + PageSize int `json:"page_size" form:"page_size"` + CertificationStatus string `json:"certification_status" form:"certification_status"` // 按认证状态筛选,如 info_pending_review / info_submitted / info_rejected,空为全部 + CompanyName string `json:"company_name" form:"company_name"` // 企业名称(模糊搜索) + LegalPersonPhone string `json:"legal_person_phone" form:"legal_person_phone"` // 法人手机号 + LegalPersonName string `json:"legal_person_name" form:"legal_person_name"` // 法人姓名(模糊搜索) +} diff --git a/internal/application/certification/dto/responses/certification_responses.go b/internal/application/certification/dto/responses/certification_responses.go index 2ba58c4..5327d0f 100644 --- a/internal/application/certification/dto/responses/certification_responses.go +++ b/internal/application/certification/dto/responses/certification_responses.go @@ -53,13 +53,13 @@ type CertificationResponse struct { // ConfirmAuthResponse 确认认证状态响应 type ConfirmAuthResponse struct { Status enums.CertificationStatus `json:"status"` - Reason string `json:"reason"` + Reason string `json:"reason"` } // ConfirmSignResponse 确认签署状态响应 type ConfirmSignResponse struct { Status enums.CertificationStatus `json:"status"` - Reason string `json:"reason"` + Reason string `json:"reason"` } // CertificationListResponse 认证列表响应 @@ -81,7 +81,6 @@ type ContractSignUrlResponse struct { Message string `json:"message"` } - // SystemMonitoringResponse 系统监控响应 type SystemMonitoringResponse struct { TimeRange string `json:"time_range"` @@ -111,6 +110,55 @@ type SystemHealthStatus struct { Details map[string]interface{} `json:"details,omitempty"` } +// AdminSubmitRecordItem 管理端提交记录列表项 +type AdminSubmitRecordItem struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CompanyName string `json:"company_name"` + UnifiedSocialCode string `json:"unified_social_code"` + LegalPersonName string `json:"legal_person_name"` + SubmitAt time.Time `json:"submit_at"` + Status string `json:"status"` + CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准:info_pending_review/info_submitted/info_rejected 等 +} + +// AdminSubmitRecordDetail 管理端提交记录详情(含完整信息与图片 URL) +type AdminSubmitRecordDetail struct { + ID string `json:"id"` + UserID string `json:"user_id"` + CompanyName string `json:"company_name"` + UnifiedSocialCode string `json:"unified_social_code"` + LegalPersonName string `json:"legal_person_name"` + LegalPersonID string `json:"legal_person_id"` + LegalPersonPhone string `json:"legal_person_phone"` + EnterpriseAddress string `json:"enterprise_address"` + AuthorizedRepName string `json:"authorized_rep_name"` + AuthorizedRepID string `json:"authorized_rep_id"` + AuthorizedRepPhone string `json:"authorized_rep_phone"` + AuthorizedRepIDImageURLs string `json:"authorized_rep_id_image_urls"` // JSON 字符串或解析后数组 + BusinessLicenseImageURL string `json:"business_license_image_url"` + OfficePlaceImageURLs string `json:"office_place_image_urls"` // JSON 数组字符串 + APIUsage string `json:"api_usage"` + ScenarioAttachmentURLs string `json:"scenario_attachment_urls"` + Status string `json:"status"` + SubmitAt time.Time `json:"submit_at"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + FailedAt *time.Time `json:"failed_at,omitempty"` + FailureReason string `json:"failure_reason,omitempty"` + CertificationStatus string `json:"certification_status,omitempty"` // 以状态机为准 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// AdminSubmitRecordsListResponse 管理端提交记录列表响应 +type AdminSubmitRecordsListResponse struct { + Items []*AdminSubmitRecordItem `json:"items"` + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + // ================ 响应构建辅助方法 ================ // NewCertificationListResponse 创建认证列表响应 @@ -146,7 +194,6 @@ func NewContractSignUrlResponse(certificationID, signURL, contractURL, nextActio return response } - // NewSystemAlert 创建系统警告 func NewSystemAlert(level, alertType, message, metric string, value, threshold interface{}) *SystemAlert { return &SystemAlert{ @@ -161,7 +208,6 @@ func NewSystemAlert(level, alertType, message, metric string, value, threshold i } } - // IsHealthy 检查系统是否健康 func (r *SystemMonitoringResponse) IsHealthy() bool { return r.SystemHealth.Overall == "healthy" diff --git a/internal/config/config.go b/internal/config/config.go index e865834..411fcd1 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -38,10 +38,10 @@ type Config struct { TianYanCha TianYanChaConfig `mapstructure:"tianyancha"` Alicloud AlicloudConfig `mapstructure:"alicloud"` Xingwei XingweiConfig `mapstructure:"xingwei"` - Jiguang JiguangConfig `mapstructure:"jiguang"` - Shumai ShumaiConfig `mapstructure:"shumai"` - Shujubao ShujubaoConfig `mapstructure:"shujubao"` - PDFGen PDFGenConfig `mapstructure:"pdfgen"` + Jiguang JiguangConfig `mapstructure:"jiguang"` + Shumai ShumaiConfig `mapstructure:"shumai"` + Shujubao ShujubaoConfig `mapstructure:"shujubao"` + PDFGen PDFGenConfig `mapstructure:"pdfgen"` } // ServerConfig HTTP服务器配置 @@ -217,10 +217,10 @@ type SMSConfig struct { SignatureEnabled bool `mapstructure:"signature_enabled"` // 是否启用签名验证 SignatureSecret string `mapstructure:"signature_secret"` // 签名密钥 // 滑块验证码配置 - CaptchaEnabled bool `mapstructure:"captcha_enabled"` // 是否启用滑块验证码 - CaptchaSecret string `mapstructure:"captcha_secret"` // 阿里云验证码密钥 - CaptchaEndpoint string `mapstructure:"captcha_endpoint"` // 阿里云验证码服务Endpoint - SceneID string `mapstructure:"scene_id"` // 阿里云验证码场景ID + CaptchaEnabled bool `mapstructure:"captcha_enabled"` // 是否启用滑块验证码 + CaptchaSecret string `mapstructure:"captcha_secret"` // 阿里云验证码密钥 + CaptchaEndpoint string `mapstructure:"captcha_endpoint"` // 阿里云验证码服务Endpoint + SceneID string `mapstructure:"scene_id"` // 阿里云验证码场景ID } // SMSRateLimit 短信限流配置 @@ -332,10 +332,10 @@ type SignConfig struct { // WalletConfig 钱包配置 type WalletConfig struct { DefaultCreditLimit float64 `mapstructure:"default_credit_limit"` - MinAmount string `mapstructure:"min_amount"` // 最低充值金额 - MaxAmount string `mapstructure:"max_amount"` // 最高充值金额 + MinAmount string `mapstructure:"min_amount"` // 最低充值金额 + MaxAmount string `mapstructure:"max_amount"` // 最高充值金额 RechargeBonusEnabled bool `mapstructure:"recharge_bonus_enabled"` // 是否启用充值赠送,关闭后仅展示商务洽谈提示 - ApiStoreRechargeTip string `mapstructure:"api_store_recharge_tip"` // API 商店充值提示文案(大额/批量需求联系商务) + ApiStoreRechargeTip string `mapstructure:"api_store_recharge_tip"` // API 商店充值提示文案(大额/批量需求联系商务) AliPayRechargeBonus []AliPayRechargeBonusRule `mapstructure:"alipay_recharge_bonus"` BalanceAlert BalanceAlertConfig `mapstructure:"balance_alert"` } @@ -578,10 +578,10 @@ type ShumaiConfig struct { // ShumaiLoggingConfig 数脉日志配置 type ShumaiLoggingConfig struct { - Enabled bool `mapstructure:"enabled"` - LogDir string `mapstructure:"log_dir"` - UseDaily bool `mapstructure:"use_daily"` - EnableLevelSeparation bool `mapstructure:"enable_level_separation"` + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + UseDaily bool `mapstructure:"use_daily"` + EnableLevelSeparation bool `mapstructure:"enable_level_separation"` LevelConfigs map[string]ShumaiLevelFileConfig `mapstructure:"level_configs"` } @@ -605,10 +605,10 @@ type ShujubaoConfig struct { // ShujubaoLoggingConfig 数据宝日志配置 type ShujubaoLoggingConfig struct { - Enabled bool `mapstructure:"enabled"` - LogDir string `mapstructure:"log_dir"` - UseDaily bool `mapstructure:"use_daily"` - EnableLevelSeparation bool `mapstructure:"enable_level_separation"` + Enabled bool `mapstructure:"enabled"` + LogDir string `mapstructure:"log_dir"` + UseDaily bool `mapstructure:"use_daily"` + EnableLevelSeparation bool `mapstructure:"enable_level_separation"` LevelConfigs map[string]ShujubaoLevelFileConfig `mapstructure:"level_configs"` } @@ -622,11 +622,11 @@ type ShujubaoLevelFileConfig struct { // PDFGenConfig PDF生成服务配置 type PDFGenConfig struct { - DevelopmentURL string `mapstructure:"development_url"` // 开发环境服务地址 - ProductionURL string `mapstructure:"production_url"` // 生产环境服务地址 - APIPath string `mapstructure:"api_path"` // API路径 - Timeout time.Duration `mapstructure:"timeout"` // 请求超时时间 - Cache PDFGenCacheConfig `mapstructure:"cache"` // 缓存配置 + DevelopmentURL string `mapstructure:"development_url"` // 开发环境服务地址 + ProductionURL string `mapstructure:"production_url"` // 生产环境服务地址 + APIPath string `mapstructure:"api_path"` // API路径 + Timeout time.Duration `mapstructure:"timeout"` // 请求超时时间 + Cache PDFGenCacheConfig `mapstructure:"cache"` // 缓存配置 } // PDFGenCacheConfig PDF生成缓存配置 diff --git a/internal/domains/api/dto/api_request_dto.go b/internal/domains/api/dto/api_request_dto.go index a8f62f7..cfcb490 100644 --- a/internal/domains/api/dto/api_request_dto.go +++ b/internal/domains/api/dto/api_request_dto.go @@ -616,6 +616,15 @@ type QYGLJ0Q1Req struct { EntName string `json:"ent_name" validate:"omitempty,min=1,validEnterpriseName"` EntCode string `json:"ent_code" validate:"omitempty,validUSCI"` } + +type FLXGDJG3Req struct { + IDCard string `json:"id_card" validate:"required,validIDCard"` +} +type QYGLDJ12Req struct { + EntName string `json:"ent_name" validate:"omitempty,min=1,validEnterpriseName"` + EntCode string `json:"ent_code" validate:"omitempty,validUSCI"` + EntRegNo string `json:"ent_reg_no" validate:"omitempty"` +} type YYSY6D9AReq struct { MobileNo string `json:"mobile_no" validate:"required,min=11,max=11,validMobileNo"` IDCard string `json:"id_card" validate:"required,validIDCard"` diff --git a/internal/domains/api/services/api_request_service.go b/internal/domains/api/services/api_request_service.go index 73f1998..a0b41ac 100644 --- a/internal/domains/api/services/api_request_service.go +++ b/internal/domains/api/services/api_request_service.go @@ -129,16 +129,16 @@ func NewApiRequestServiceWithRepos( registerAllProcessors(combService) return &ApiRequestService{ - westDexService: westDexService, - muziService: muziService, - yushanService: yushanService, - tianYanChaService: tianYanChaService, - alicloudService: alicloudService, - validator: validator, - processorDeps: processorDeps, - combService: combService, - config: cfg, - reportRepo: reportRepo, + westDexService: westDexService, + muziService: muziService, + yushanService: yushanService, + tianYanChaService: tianYanChaService, + alicloudService: alicloudService, + validator: validator, + processorDeps: processorDeps, + combService: combService, + config: cfg, + reportRepo: reportRepo, } } @@ -171,6 +171,7 @@ func registerAllProcessors(combService *comb.CombService) { "FLXG7E8F": flxg.ProcessFLXG7E8FRequest, "FLXG3A9B": flxg.ProcessFLXG3A9BRequest, "FLXGK5D2": flxg.ProcessFLXGK5D2Request, + "FLXGDJG3": flxg.ProcessFLXGDJG3Request, //董监高司法综合信息核验 // JRZQ系列处理器 "JRZQ8203": jrzq.ProcessJRZQ8203Request, "JRZQ0A03": jrzq.ProcessJRZQ0A03Request, @@ -236,9 +237,11 @@ func registerAllProcessors(combService *comb.CombService) { "QYGLJ1U9": qygl.ProcessQYGLJ1U9Request, //企业全景报告(聚合 QYGLUY3S/QYGLJ0Q1/QYGL5S1I) "QYGLJ0Q1": qygl.ProcessQYGLJ0Q1Request, //企业股权结构全景查询 "QYGLUY3S": qygl.ProcessQYGLUY3SRequest, //企业经营状态全景查询 - "YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖 + "QYGLDJ12": qygl.ProcessQYGLDJ12Request, //企业年报信息核验 + "QYGL8848": qygl.ProcessQYGL8848Request, //企业税收违法核查 // YYSY系列处理器 + "YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖 "YYSYD50F": yysy.ProcessYYSYD50FRequest, "YYSY09CD": yysy.ProcessYYSY09CDRequest, "YYSY4B21": yysy.ProcessYYSY4B21Request, diff --git a/internal/domains/api/services/form_config_service.go b/internal/domains/api/services/form_config_service.go index d1faab6..92832e7 100644 --- a/internal/domains/api/services/form_config_service.go +++ b/internal/domains/api/services/form_config_service.go @@ -263,6 +263,9 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string "JRZQOCRE": &dto.JRZQOCREReq{}, //银行卡OCR数卖 "JRZQOCRY": &dto.JRZQOCRYReq{}, //银行卡OCR数据宝 "YYSY35TA": &dto.YYSY35TAReq{}, //运营商归属地数卖 + "QYGLDJ12": &dto.QYGLDJ12Req{}, //企业年报信息核验 + "FLXGDJG3": &dto.FLXGDJG3Req{}, //董监高司法综合信息核验 + "QYGL8848": &dto.QYGLDJ12Req{}, //企业税收违法核查 } // 优先返回已配置的DTO diff --git a/internal/domains/api/services/processors/flxg/flxg0v4b_processor.go b/internal/domains/api/services/processors/flxg/flxg0v4b_processor.go index a4c28c9..4e0ea3c 100644 --- a/internal/domains/api/services/processors/flxg/flxg0v4b_processor.go +++ b/internal/domains/api/services/processors/flxg/flxg0v4b_processor.go @@ -14,7 +14,7 @@ import ( "github.com/tidwall/gjson" ) -// ProcessFLXG0V4BRequest FLXG0V4B API处理方法 +// ProcessFLXG0V4BRequest FLXG0V4B API处理方法(身份证排空入口,身份证身份证身份证身份证身份证) func ProcessFLXG0V4BRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { var paramsDto dto.FLXG0V4BReq if err := json.Unmarshal(params, ¶msDto); err != nil { @@ -24,7 +24,7 @@ func ProcessFLXG0V4BRequest(ctx context.Context, params []byte, deps *processors if err := deps.Validator.ValidateStruct(paramsDto); err != nil { return nil, errors.Join(processors.ErrInvalidParam, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998"{ + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } encryptedName, err := deps.WestDexService.Encrypt(paramsDto.Name) diff --git a/internal/domains/api/services/processors/flxg/flxg5a3b_processor.go b/internal/domains/api/services/processors/flxg/flxg5a3b_processor.go index 3786220..4e53724 100644 --- a/internal/domains/api/services/processors/flxg/flxg5a3b_processor.go +++ b/internal/domains/api/services/processors/flxg/flxg5a3b_processor.go @@ -20,7 +20,7 @@ func ProcessFLXG5A3BRequest(ctx context.Context, params []byte, deps *processors if err := deps.Validator.ValidateStruct(paramsDto); err != nil { return nil, errors.Join(processors.ErrInvalidParam, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998"{ + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } encryptedName, err := deps.ZhichaService.Encrypt(paramsDto.Name) diff --git a/internal/domains/api/services/processors/flxg/flxg7e8f_processor.go b/internal/domains/api/services/processors/flxg/flxg7e8f_processor.go index bc4ecfd..c9caf10 100644 --- a/internal/domains/api/services/processors/flxg/flxg7e8f_processor.go +++ b/internal/domains/api/services/processors/flxg/flxg7e8f_processor.go @@ -20,7 +20,7 @@ func ProcessFLXG7E8FRequest(ctx context.Context, params []byte, deps *processors if err := deps.Validator.ValidateStruct(paramsDto); err != nil { return nil, errors.Join(processors.ErrInvalidParam, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998" { + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } // 构建请求数据,将项目规范的字段名转换为 XingweiService 需要的字段名 diff --git a/internal/domains/api/services/processors/flxg/flxgca3d_processor.go b/internal/domains/api/services/processors/flxg/flxgca3d_processor.go index c0ec99f..8e17fd6 100644 --- a/internal/domains/api/services/processors/flxg/flxgca3d_processor.go +++ b/internal/domains/api/services/processors/flxg/flxgca3d_processor.go @@ -20,7 +20,7 @@ func ProcessFLXGCA3DRequest(ctx context.Context, params []byte, deps *processors if err := deps.Validator.ValidateStruct(paramsDto); err != nil { return nil, errors.Join(processors.ErrInvalidParam, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998" { + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998"|| paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } encryptedName, err := deps.WestDexService.Encrypt(paramsDto.Name) diff --git a/internal/domains/api/services/processors/flxg/flxgdea9_processor.go b/internal/domains/api/services/processors/flxg/flxgdea9_processor.go index 497cbb5..e63f2f5 100644 --- a/internal/domains/api/services/processors/flxg/flxgdea9_processor.go +++ b/internal/domains/api/services/processors/flxg/flxgdea9_processor.go @@ -25,7 +25,7 @@ func ProcessFLXGDEA9Request(ctx context.Context, params []byte, deps *processors if err != nil { return nil, errors.Join(processors.ErrSystem, err) } - if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550"|| paramsDto.IDCard == "320682198910134998"{ + if paramsDto.IDCard == "350681198611130611" || paramsDto.IDCard == "622301200006250550" || paramsDto.IDCard == "320682198910134998" || paramsDto.IDCard == "640102198708020925" { return nil, errors.Join(processors.ErrNotFound, errors.New("查询为空")) } encryptedIDCard, err := deps.ZhichaService.Encrypt(paramsDto.IDCard) diff --git a/internal/domains/api/services/processors/flxg/flxgdjg3_processor.go b/internal/domains/api/services/processors/flxg/flxgdjg3_processor.go new file mode 100644 index 0000000..56d0d1c --- /dev/null +++ b/internal/domains/api/services/processors/flxg/flxgdjg3_processor.go @@ -0,0 +1,54 @@ +package flxg + +import ( + "context" + "encoding/json" + "errors" + + "tyapi-server/internal/domains/api/dto" + "tyapi-server/internal/domains/api/services/processors" + "tyapi-server/internal/infrastructure/external/shujubao" +) + +// ProcessFLXGDJG3Request FLXGDJG3 董监高司法综合信息核验 API 处理方法(使用数据宝服务示例) +func ProcessFLXGDJG3Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.FLXGDJG3Req + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + // 构建数据宝入参(sign 外的业务参数可按需 AES 加密后作为 bodyData) + reqParams := map[string]interface{}{ + "key": "1cce582f0a6f3ca40de80f1bea9b9698", + "idcard": paramsDto.IDCard, + } + + // 最终请求 URL = https://api.chinadatapay.com/communication + 拼接接口地址值,如 personal/197 + apiPath := "/communication/personal/10166" + data, err := deps.ShujubaoService.CallAPI(ctx, apiPath, reqParams) + if err != nil { + if errors.Is(err, shujubao.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + if errors.Is(err, shujubao.ErrQueryEmpty) { + return nil, errors.Join(processors.ErrNotFound, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + // 解析响应中的 JSON 字符串(使用 qyglb4c0 中的 RecursiveParse) + parsedResp, err := RecursiveParse(data) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + respBytes, err := json.Marshal(parsedResp) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + return respBytes, nil +} diff --git a/internal/domains/api/services/processors/qygl/qygl8848_processor.go b/internal/domains/api/services/processors/qygl/qygl8848_processor.go new file mode 100644 index 0000000..80ef12e --- /dev/null +++ b/internal/domains/api/services/processors/qygl/qygl8848_processor.go @@ -0,0 +1,68 @@ +package qygl + +import ( + "context" + "encoding/json" + "errors" + + "tyapi-server/internal/domains/api/dto" + "tyapi-server/internal/domains/api/services/processors" + "tyapi-server/internal/infrastructure/external/shujubao" +) + +// ProcessQYGL8848Request QYGL8848 企业税收违法核查 API 处理方法(使用数据宝服务示例) +func ProcessQYGL8848Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.QYGLDJ12Req + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + // 企业名称(entName)、统一社会信用代码(creditCode)、企业注册号(entRegNo) 至少传其一;多填时优先用 creditCode 传参 + hasEntName := paramsDto.EntName != "" + hasEntCode := paramsDto.EntCode != "" + hasEntRegNo := paramsDto.EntRegNo != "" + if !hasEntName && !hasEntCode && !hasEntRegNo { // 三个都未填才报错 + return nil, errors.Join(processors.ErrInvalidParam, errors.New("ent_name、ent_code、ent_reg_no 至少需要传其中一个")) + } + + // 构建数据宝入参(多填时优先取 creditCode) + reqParams := map[string]interface{}{ + "key": "c67673dd2e92deb2d2ec91b87bb0a81c", + } + if hasEntCode { + reqParams["creditCode"] = paramsDto.EntCode + } else if hasEntName { + reqParams["entName"] = paramsDto.EntName + } else if hasEntRegNo { + reqParams["regCode"] = paramsDto.EntRegNo + } + + // 最终请求 URL = https://api.chinadatapay.com/communication + 拼接接口地址值,如 personal/197 + apiPath := "/communication/personal/10233" + data, err := deps.ShujubaoService.CallAPI(ctx, apiPath, reqParams) + if err != nil { + if errors.Is(err, shujubao.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + if errors.Is(err, shujubao.ErrQueryEmpty) { + return nil, errors.Join(processors.ErrNotFound, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + // 解析响应中的 JSON 字符串(使用 qyglb4c0 中的 RecursiveParse) + parsedResp, err := RecursiveParse(data) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + respBytes, err := json.Marshal(parsedResp) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + return respBytes, nil +} diff --git a/internal/domains/api/services/processors/qygl/qygldj12_processor.go b/internal/domains/api/services/processors/qygl/qygldj12_processor.go new file mode 100644 index 0000000..870b44c --- /dev/null +++ b/internal/domains/api/services/processors/qygl/qygldj12_processor.go @@ -0,0 +1,67 @@ +package qygl + +import ( + "context" + "encoding/json" + "errors" + + "tyapi-server/internal/domains/api/dto" + "tyapi-server/internal/domains/api/services/processors" + "tyapi-server/internal/infrastructure/external/shujubao" +) + +// ProcessQYGLDJ12Request QYGLDJ12 企业年报信息核验 API 处理方法(使用数据宝服务示例) +func ProcessQYGLDJ12Request(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) { + var paramsDto dto.QYGLDJ12Req + if err := json.Unmarshal(params, ¶msDto); err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + if err := deps.Validator.ValidateStruct(paramsDto); err != nil { + return nil, errors.Join(processors.ErrInvalidParam, err) + } + + // 企业名称(entName)、统一社会信用代码(creditCode)、企业注册号(entRegNo) 至少传其一;多填时优先用 creditCode 传参 + hasEntName := paramsDto.EntName != "" + hasEntCode := paramsDto.EntCode != "" + hasEntRegNo := paramsDto.EntRegNo != "" + if !hasEntName && !hasEntCode && !hasEntRegNo { // 三个都未填才报错 + return nil, errors.Join(processors.ErrInvalidParam, errors.New("ent_name、ent_code、ent_reg_no 至少需要传其中一个")) + } + + // 构建数据宝入参(sign 外的业务参数可按需 AES 加密后作为 bodyData) + reqParams := map[string]interface{}{ + "key": "112813815e2cc281ad8f552deb7a3c7f", + } + if hasEntCode { + reqParams["creditCode"] = paramsDto.EntCode + } else if hasEntName { + reqParams["entName"] = paramsDto.EntName + } else if hasEntRegNo { + reqParams["regCode"] = paramsDto.EntRegNo + } + // 最终请求 URL = https://api.chinadatapay.com/communication + 拼接接口地址值,如 personal/197 + apiPath := "/communication/personal/10192" + data, err := deps.ShujubaoService.CallAPI(ctx, apiPath, reqParams) + if err != nil { + if errors.Is(err, shujubao.ErrDatasource) { + return nil, errors.Join(processors.ErrDatasource, err) + } + if errors.Is(err, shujubao.ErrQueryEmpty) { + return nil, errors.Join(processors.ErrNotFound, err) + } + return nil, errors.Join(processors.ErrSystem, err) + } + + // 解析响应中的 JSON 字符串(使用 qyglb4c0 中的 RecursiveParse) + parsedResp, err := RecursiveParse(data) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + + respBytes, err := json.Marshal(parsedResp) + if err != nil { + return nil, errors.Join(processors.ErrSystem, err) + } + return respBytes, nil +} diff --git a/internal/domains/certification/entities/certification.go b/internal/domains/certification/entities/certification.go index edf3d22..58adecd 100644 --- a/internal/domains/certification/entities/certification.go +++ b/internal/domains/certification/entities/certification.go @@ -153,7 +153,31 @@ func (c *Certification) TransitionTo(targetStatus enums.CertificationStatus, act // ================ 业务操作方法 ================ -// SubmitEnterpriseInfo 提交企业信息 +// SubmitEnterpriseInfoForReview 提交企业信息进入人工审核(不调用 e签宝,不生成认证链接) +func (c *Certification) SubmitEnterpriseInfoForReview(enterpriseInfo *value_objects.EnterpriseInfo) error { + // 已处于待审核:幂等,直接成功 + if c.Status == enums.StatusInfoPendingReview { + return nil + } + if c.Status != enums.StatusPending && c.Status != enums.StatusInfoRejected { + return fmt.Errorf("当前状态 %s 不允许提交企业信息", enums.GetStatusName(c.Status)) + } + if err := enterpriseInfo.Validate(); err != nil { + return fmt.Errorf("企业信息验证失败: %w", err) + } + if err := c.TransitionTo(enums.StatusInfoPendingReview, enums.ActorTypeUser, c.UserID, "用户提交企业信息,等待人工审核"); err != nil { + return err + } + c.addDomainEvent(&EnterpriseInfoSubmittedEvent{ + CertificationID: c.ID, + UserID: c.UserID, + EnterpriseInfo: enterpriseInfo, + SubmittedAt: time.Now(), + }) + return nil +} + +// SubmitEnterpriseInfo 提交企业信息(直接进入已提交,含认证链接;用于无审核或管理员审核通过后补链) func (c *Certification) SubmitEnterpriseInfo(enterpriseInfo *value_objects.EnterpriseInfo, authURL string, authFlowID string) error { // 验证当前状态 if c.Status != enums.StatusPending && c.Status != enums.StatusInfoRejected { @@ -186,6 +210,33 @@ func (c *Certification) SubmitEnterpriseInfo(enterpriseInfo *value_objects.Enter return nil } +// ApproveEnterpriseInfoReview 管理员审核通过:从待审核转为已提交,并写入企业认证链接 +func (c *Certification) ApproveEnterpriseInfoReview(authURL, authFlowID string, actorID string) error { + if c.Status != enums.StatusInfoPendingReview { + return fmt.Errorf("当前状态 %s 不允许执行审核通过", enums.GetStatusName(c.Status)) + } + c.AuthURL = authURL + c.AuthFlowID = authFlowID + if err := c.TransitionTo(enums.StatusInfoSubmitted, enums.ActorTypeAdmin, actorID, "管理员审核通过"); err != nil { + return err + } + now := time.Now() + c.InfoSubmittedAt = &now + return nil +} + +// RejectEnterpriseInfoReview 管理员审核拒绝 +func (c *Certification) RejectEnterpriseInfoReview(actorID, message string) error { + if c.Status != enums.StatusInfoPendingReview { + return fmt.Errorf("当前状态 %s 不允许执行审核拒绝", enums.GetStatusName(c.Status)) + } + c.setFailureInfo(enums.FailureReasonManualReviewRejected, message) + if err := c.TransitionTo(enums.StatusInfoRejected, enums.ActorTypeAdmin, actorID, "管理员审核拒绝"); err != nil { + return err + } + return nil +} + // 完成企业认证 func (c *Certification) CompleteEnterpriseVerification() error { if c.Status != enums.StatusInfoSubmitted { @@ -448,6 +499,8 @@ func (c *Certification) CompleteCertification() error { func (c *Certification) GetDataByStatus() map[string]interface{} { data := map[string]interface{}{} switch c.Status { + case enums.StatusInfoPendingReview: + // 待审核,无额外数据 case enums.StatusInfoSubmitted: data["auth_url"] = c.AuthURL case enums.StatusInfoRejected: @@ -494,6 +547,8 @@ func (c *Certification) GetAvailableActions() []string { switch c.Status { case enums.StatusPending: actions = append(actions, "submit_enterprise_info") + case enums.StatusInfoPendingReview: + // 等待人工审核,无用户操作 case enums.StatusEnterpriseVerified: actions = append(actions, "apply_contract") case enums.StatusInfoRejected, enums.StatusContractRejected, enums.StatusContractExpired: @@ -587,8 +642,9 @@ func (c *Certification) ValidateBusinessRules() error { // validateActorPermission 验证操作者权限 func (c *Certification) validateActorPermission(targetStatus enums.CertificationStatus, actor enums.ActorType) bool { - // 定义状态转换的权限规则 + // 定义状态转换的权限规则(目标状态 -> 允许的操作者) permissions := map[enums.CertificationStatus][]enums.ActorType{ + enums.StatusInfoPendingReview: {enums.ActorTypeUser}, enums.StatusInfoSubmitted: {enums.ActorTypeUser, enums.ActorTypeAdmin}, enums.StatusEnterpriseVerified: {enums.ActorTypeEsign, enums.ActorTypeSystem, enums.ActorTypeAdmin}, enums.StatusInfoRejected: {enums.ActorTypeEsign, enums.ActorTypeSystem, enums.ActorTypeAdmin}, diff --git a/internal/domains/certification/entities/enterprise_info_submit_record.go b/internal/domains/certification/entities/enterprise_info_submit_record.go index 0f0d249..1d6202a 100644 --- a/internal/domains/certification/entities/enterprise_info_submit_record.go +++ b/internal/domains/certification/entities/enterprise_info_submit_record.go @@ -19,6 +19,21 @@ type EnterpriseInfoSubmitRecord struct { LegalPersonID string `json:"legal_person_id" gorm:"type:varchar(50);not null"` LegalPersonPhone string `json:"legal_person_phone" gorm:"type:varchar(50);not null"` EnterpriseAddress string `json:"enterprise_address" gorm:"type:varchar(200);not null"` // 新增企业地址 + + // 授权代表信息(gorm 指定列名,确保与表 enterprise_info_submit_records 列一致并正确读入) + AuthorizedRepName string `json:"authorized_rep_name" gorm:"column:authorized_rep_name;type:varchar(50);comment:授权代表姓名"` + AuthorizedRepID string `json:"authorized_rep_id" gorm:"column:authorized_rep_id;type:varchar(50);comment:授权代表身份证号"` + AuthorizedRepPhone string `json:"authorized_rep_phone" gorm:"column:authorized_rep_phone;type:varchar(50);comment:授权代表手机号"` + // 授权代表身份证正反面图片URL列表(JSON字符串),按顺序存储[人像面, 国徽面] + AuthorizedRepIDImageURLs string `json:"authorized_rep_id_image_urls" gorm:"column:authorized_rep_id_image_urls;type:text;comment:授权代表身份证正反面图片URL列表(JSON字符串)"` + + // 企业资质与场地材料 + BusinessLicenseImageURL string `json:"business_license_image_url" gorm:"type:varchar(500);comment:营业执照图片URL"` + OfficePlaceImageURLs string `json:"office_place_image_urls" gorm:"type:text;comment:办公场地图片URL列表(JSON字符串)"` + // 应用场景 + APIUsage string `json:"api_usage" gorm:"type:text;comment:接口用途及业务场景说明"` + ScenarioAttachmentURLs string `json:"scenario_attachment_urls" gorm:"type:text;comment:场景附件图片URL列表(JSON字符串)"` + // 提交状态 Status string `json:"status" gorm:"type:varchar(20);not null;default:'submitted'"` // submitted, verified, failed SubmitAt time.Time `json:"submit_at" gorm:"not null"` @@ -26,6 +41,12 @@ type EnterpriseInfoSubmitRecord struct { FailedAt *time.Time `json:"failed_at"` FailureReason string `json:"failure_reason" gorm:"type:text"` + // 人工审核信息 + ManualReviewStatus string `json:"manual_review_status" gorm:"type:varchar(20);not null;default:'pending';comment:人工审核状态(pending,approved,rejected)"` + ManualReviewRemark string `json:"manual_review_remark" gorm:"type:text;comment:人工审核备注"` + ManualReviewedAt *time.Time `json:"manual_reviewed_at" gorm:"comment:人工审核时间"` + ManualReviewerID string `json:"manual_reviewer_id" gorm:"type:varchar(36);comment:人工审核人ID"` + // 系统字段 CreatedAt time.Time `json:"created_at" gorm:"not null"` UpdatedAt time.Time `json:"updated_at" gorm:"not null"` @@ -42,18 +63,19 @@ func NewEnterpriseInfoSubmitRecord( userID, companyName, unifiedSocialCode, legalPersonName, legalPersonID, legalPersonPhone, enterpriseAddress string, ) *EnterpriseInfoSubmitRecord { return &EnterpriseInfoSubmitRecord{ - ID: uuid.New().String(), - UserID: userID, - CompanyName: companyName, - UnifiedSocialCode: unifiedSocialCode, - LegalPersonName: legalPersonName, - LegalPersonID: legalPersonID, - LegalPersonPhone: legalPersonPhone, - EnterpriseAddress: enterpriseAddress, - Status: "submitted", - SubmitAt: time.Now(), - CreatedAt: time.Now(), - UpdatedAt: time.Now(), + ID: uuid.New().String(), + UserID: userID, + CompanyName: companyName, + UnifiedSocialCode: unifiedSocialCode, + LegalPersonName: legalPersonName, + LegalPersonID: legalPersonID, + LegalPersonPhone: legalPersonPhone, + EnterpriseAddress: enterpriseAddress, + Status: "submitted", + ManualReviewStatus: "pending", + SubmitAt: time.Now(), + CreatedAt: time.Now(), + UpdatedAt: time.Now(), } } @@ -74,6 +96,26 @@ func (r *EnterpriseInfoSubmitRecord) MarkAsFailed(reason string) { r.UpdatedAt = now } +// MarkManualApproved 标记人工审核通过 +func (r *EnterpriseInfoSubmitRecord) MarkManualApproved(reviewerID, remark string) { + now := time.Now() + r.ManualReviewStatus = "approved" + r.ManualReviewedAt = &now + r.ManualReviewerID = reviewerID + r.ManualReviewRemark = remark + r.UpdatedAt = now +} + +// MarkManualRejected 标记人工审核拒绝 +func (r *EnterpriseInfoSubmitRecord) MarkManualRejected(reviewerID, remark string) { + now := time.Now() + r.ManualReviewStatus = "rejected" + r.ManualReviewedAt = &now + r.ManualReviewerID = reviewerID + r.ManualReviewRemark = remark + r.UpdatedAt = now +} + // IsVerified 检查是否已验证 func (r *EnterpriseInfoSubmitRecord) IsVerified() bool { return r.Status == "verified" diff --git a/internal/domains/certification/enums/certification_status.go b/internal/domains/certification/enums/certification_status.go index 0711e8f..3410d69 100644 --- a/internal/domains/certification/enums/certification_status.go +++ b/internal/domains/certification/enums/certification_status.go @@ -5,12 +5,13 @@ type CertificationStatus string const ( // === 主流程状态 === - StatusPending CertificationStatus = "pending" // 待认证 - StatusInfoSubmitted CertificationStatus = "info_submitted" // 已提交企业信息 - StatusEnterpriseVerified CertificationStatus = "enterprise_verified" // 已企业认证 - StatusContractApplied CertificationStatus = "contract_applied" // 已申请签署合同 - StatusContractSigned CertificationStatus = "contract_signed" // 已签署合同 - StatusCompleted CertificationStatus = "completed" // 认证完成 + StatusPending CertificationStatus = "pending" // 待认证 + StatusInfoPendingReview CertificationStatus = "info_pending_review" // 企业信息待人工审核 + StatusInfoSubmitted CertificationStatus = "info_submitted" // 已提交企业信息(审核通过) + StatusEnterpriseVerified CertificationStatus = "enterprise_verified" // 已企业认证 + StatusContractApplied CertificationStatus = "contract_applied" // 已申请签署合同 + StatusContractSigned CertificationStatus = "contract_signed" // 已签署合同 + StatusCompleted CertificationStatus = "completed" // 认证完成 // === 失败状态 === StatusInfoRejected CertificationStatus = "info_rejected" // 企业信息被拒绝 @@ -21,6 +22,7 @@ const ( // AllStatuses 所有有效状态列表 var AllStatuses = []CertificationStatus{ StatusPending, + StatusInfoPendingReview, StatusInfoSubmitted, StatusEnterpriseVerified, StatusContractApplied, @@ -34,6 +36,7 @@ var AllStatuses = []CertificationStatus{ // MainFlowStatuses 主流程状态列表 var MainFlowStatuses = []CertificationStatus{ StatusPending, + StatusInfoPendingReview, StatusInfoSubmitted, StatusEnterpriseVerified, StatusContractApplied, @@ -61,6 +64,7 @@ func IsValidStatus(status CertificationStatus) bool { func GetStatusName(status CertificationStatus) string { statusNames := map[CertificationStatus]string{ StatusPending: "待认证", + StatusInfoPendingReview: "企业信息待审核", StatusInfoSubmitted: "已提交企业信息", StatusEnterpriseVerified: "已企业认证", StatusContractApplied: "已申请签署合同", @@ -120,14 +124,15 @@ func GetStatusCategory(status CertificationStatus) string { func GetStatusPriority(status CertificationStatus) int { priorities := map[CertificationStatus]int{ StatusPending: 1, - StatusInfoSubmitted: 2, - StatusEnterpriseVerified: 3, - StatusContractApplied: 4, - StatusContractSigned: 5, - StatusCompleted: 6, - StatusInfoRejected: 7, - StatusContractRejected: 8, - StatusContractExpired: 9, + StatusInfoPendingReview: 2, + StatusInfoSubmitted: 3, + StatusEnterpriseVerified: 4, + StatusContractApplied: 5, + StatusContractSigned: 6, + StatusCompleted: 7, + StatusInfoRejected: 8, + StatusContractRejected: 9, + StatusContractExpired: 10, } if priority, exists := priorities[status]; exists { @@ -140,6 +145,7 @@ func GetStatusPriority(status CertificationStatus) int { func GetProgressPercentage(status CertificationStatus) int { progressMap := map[CertificationStatus]int{ StatusPending: 0, + StatusInfoPendingReview: 15, StatusInfoSubmitted: 25, StatusEnterpriseVerified: 50, StatusContractApplied: 75, @@ -160,7 +166,8 @@ func GetProgressPercentage(status CertificationStatus) int { func IsUserActionRequired(status CertificationStatus) bool { userActionRequired := map[CertificationStatus]bool{ StatusPending: true, // 需要提交企业信息 - StatusInfoSubmitted: false, // 等待系统验证 + StatusInfoPendingReview: false, // 等待人工审核 + StatusInfoSubmitted: false, // 等待完成企业认证 StatusEnterpriseVerified: true, // 需要申请合同 StatusContractApplied: true, // 需要签署合同 StatusContractSigned: false, // 合同已签署,等待系统处理 @@ -180,6 +187,7 @@ func IsUserActionRequired(status CertificationStatus) bool { func GetUserActionHint(status CertificationStatus) string { hints := map[CertificationStatus]string{ StatusPending: "请提交企业信息", + StatusInfoPendingReview: "企业信息已提交,请等待管理员审核", StatusInfoSubmitted: "请完成企业认证", StatusEnterpriseVerified: "企业认证完成,请申请签署合同", StatusContractApplied: "请在规定时间内完成合同签署", @@ -200,8 +208,13 @@ func GetUserActionHint(status CertificationStatus) string { func GetNextValidStatuses(currentStatus CertificationStatus) []CertificationStatus { nextStatusMap := map[CertificationStatus][]CertificationStatus{ StatusPending: { + StatusInfoPendingReview, // 用户提交企业信息,进入待审核 + StatusInfoSubmitted, // 暂时跳过人工审核,直接进入已提交 + StatusCompleted, + }, + StatusInfoPendingReview: { StatusInfoSubmitted, - // 管理员/系统可直接完成认证 + StatusInfoRejected, StatusCompleted, }, StatusInfoSubmitted: { @@ -265,15 +278,18 @@ func CanTransitionTo(currentStatus, targetStatus CertificationStatus) bool { // GetTransitionReason 获取状态转换的原因描述 func GetTransitionReason(from, to CertificationStatus) string { transitionReasons := map[string]string{ - string(StatusPending) + "->" + string(StatusInfoSubmitted): "用户提交企业信息", - string(StatusInfoSubmitted) + "->" + string(StatusEnterpriseVerified): "e签宝企业认证成功", - string(StatusInfoSubmitted) + "->" + string(StatusInfoRejected): "e签宝企业认证失败", - string(StatusEnterpriseVerified) + "->" + string(StatusContractApplied): "用户申请签署合同", - string(StatusContractApplied) + "->" + string(StatusContractSigned): "e签宝合同签署成功", - string(StatusContractSigned) + "->" + string(StatusCompleted): "系统处理完成,认证成功", - string(StatusContractApplied) + "->" + string(StatusContractRejected): "用户拒绝签署合同", - string(StatusContractApplied) + "->" + string(StatusContractExpired): "合同签署超时", - string(StatusInfoRejected) + "->" + string(StatusInfoSubmitted): "用户重新提交企业信息", + string(StatusPending) + "->" + string(StatusInfoPendingReview): "用户提交企业信息,等待人工审核", + string(StatusInfoPendingReview) + "->" + string(StatusInfoSubmitted): "管理员审核通过", + string(StatusInfoPendingReview) + "->" + string(StatusInfoRejected): "管理员审核拒绝", + string(StatusPending) + "->" + string(StatusInfoSubmitted): "用户提交企业信息", + string(StatusInfoSubmitted) + "->" + string(StatusEnterpriseVerified): "e签宝企业认证成功", + string(StatusInfoSubmitted) + "->" + string(StatusInfoRejected): "e签宝企业认证失败", + string(StatusEnterpriseVerified) + "->" + string(StatusContractApplied): "用户申请签署合同", + string(StatusContractApplied) + "->" + string(StatusContractSigned): "e签宝合同签署成功", + string(StatusContractSigned) + "->" + string(StatusCompleted): "系统处理完成,认证成功", + string(StatusContractApplied) + "->" + string(StatusContractRejected): "用户拒绝签署合同", + string(StatusContractApplied) + "->" + string(StatusContractExpired): "合同签署超时", + string(StatusInfoRejected) + "->" + string(StatusInfoSubmitted): "用户重新提交企业信息", string(StatusContractRejected) + "->" + string(StatusEnterpriseVerified): "重置状态,准备重新申请", string(StatusContractExpired) + "->" + string(StatusEnterpriseVerified): "重置状态,准备重新申请", } diff --git a/internal/domains/certification/enums/failure_reason.go b/internal/domains/certification/enums/failure_reason.go index c593a3d..0cfac32 100644 --- a/internal/domains/certification/enums/failure_reason.go +++ b/internal/domains/certification/enums/failure_reason.go @@ -11,6 +11,7 @@ const ( FailureReasonLegalPersonMismatch FailureReason = "legal_person_mismatch" // 法定代表人信息不匹配 FailureReasonEsignVerificationFailed FailureReason = "esign_verification_failed" // e签宝验证失败 FailureReasonInvalidDocument FailureReason = "invalid_document" // 证件信息无效 + FailureReasonManualReviewRejected FailureReason = "manual_review_rejected" // 人工审核拒绝 // === 合同签署失败原因 === FailureReasonContractRejectedByUser FailureReason = "contract_rejected_by_user" // 用户拒绝签署 @@ -35,7 +36,7 @@ var AllFailureReasons = []FailureReason{ FailureReasonLegalPersonMismatch, FailureReasonEsignVerificationFailed, FailureReasonInvalidDocument, - + FailureReasonManualReviewRejected, // 合同签署失败 FailureReasonContractRejectedByUser, FailureReasonContractExpired, @@ -97,7 +98,7 @@ func GetFailureReasonName(reason FailureReason) string { FailureReasonLegalPersonMismatch: "法定代表人信息不匹配", FailureReasonEsignVerificationFailed: "e签宝验证失败", FailureReasonInvalidDocument: "证件信息无效", - + FailureReasonManualReviewRejected: "人工审核拒绝", // 合同签署失败 FailureReasonContractRejectedByUser: "用户拒绝签署", FailureReasonContractExpired: "合同签署超时", @@ -128,7 +129,7 @@ func GetFailureReasonCategory(reason FailureReason) string { FailureReasonLegalPersonMismatch: "企业验证", FailureReasonEsignVerificationFailed: "企业验证", FailureReasonInvalidDocument: "企业验证", - + FailureReasonManualReviewRejected: "人工审核", // 合同签署失败 FailureReasonContractRejectedByUser: "合同签署", FailureReasonContractExpired: "合同签署", @@ -189,7 +190,7 @@ func GetSuggestedAction(reason FailureReason) string { FailureReasonLegalPersonMismatch: "请核对法定代表人信息是否正确", FailureReasonEsignVerificationFailed: "请稍后重试,如持续失败请联系客服", FailureReasonInvalidDocument: "请检查证件信息是否有效", - + FailureReasonManualReviewRejected: "请根据审核意见修正后重新提交,或联系客服", // 合同签署失败 FailureReasonContractRejectedByUser: "您可以重新申请签署合同", FailureReasonContractExpired: "请重新申请签署合同", @@ -220,7 +221,7 @@ func IsRetryable(reason FailureReason) bool { FailureReasonLegalPersonMismatch: true, FailureReasonEsignVerificationFailed: true, // 可能是临时问题 FailureReasonInvalidDocument: true, - + FailureReasonManualReviewRejected: true, // 用户可修正后重新提交 // 合同签署失败 FailureReasonContractRejectedByUser: true, // 用户可以改变主意 FailureReasonContractExpired: true, // 可以重新申请 @@ -253,6 +254,7 @@ func GetRetrySuggestion(reason FailureReason) string { FailureReasonLegalPersonMismatch: "请确认法定代表人信息后重新提交", FailureReasonEsignVerificationFailed: "请稍后重新尝试", FailureReasonInvalidDocument: "请检查证件信息后重新提交", + FailureReasonManualReviewRejected: "请根据审核意见修正企业信息后重新提交", FailureReasonContractRejectedByUser: "如需要可重新申请合同", FailureReasonContractExpired: "请重新申请合同签署", FailureReasonSignProcessFailed: "请重新尝试签署", diff --git a/internal/domains/certification/repositories/enterprise_info_submit_record_repository.go b/internal/domains/certification/repositories/enterprise_info_submit_record_repository.go index f481fa8..97c06a1 100644 --- a/internal/domains/certification/repositories/enterprise_info_submit_record_repository.go +++ b/internal/domains/certification/repositories/enterprise_info_submit_record_repository.go @@ -5,10 +5,30 @@ import ( "tyapi-server/internal/domains/certification/entities" ) +// ListSubmitRecordsFilter 提交记录列表筛选(以状态机 certification 状态为准) +type ListSubmitRecordsFilter struct { + CertificationStatus string // 认证状态筛选,如 info_pending_review / info_submitted / info_rejected,空为全部 + CompanyName string // 企业名称(模糊搜索) + LegalPersonPhone string // 法人手机号 + LegalPersonName string // 法人姓名(模糊搜索) + Page int + PageSize int +} + +// ListSubmitRecordsResult 列表结果 +type ListSubmitRecordsResult struct { + Records []*entities.EnterpriseInfoSubmitRecord + Total int64 +} + type EnterpriseInfoSubmitRecordRepository interface { Create(ctx context.Context, record *entities.EnterpriseInfoSubmitRecord) error Update(ctx context.Context, record *entities.EnterpriseInfoSubmitRecord) error Exists(ctx context.Context, ID string) (bool, error) + FindByID(ctx context.Context, id string) (*entities.EnterpriseInfoSubmitRecord, error) FindLatestByUserID(ctx context.Context, userID string) (*entities.EnterpriseInfoSubmitRecord, error) FindLatestVerifiedByUserID(ctx context.Context, userID string) (*entities.EnterpriseInfoSubmitRecord, error) + // ExistsByUnifiedSocialCodeExcludeUser 检查该统一社会信用代码是否已被其他用户提交(已提交/已通过验证,排除指定用户) + ExistsByUnifiedSocialCodeExcludeUser(ctx context.Context, unifiedSocialCode string, excludeUserID string) (bool, error) + List(ctx context.Context, filter ListSubmitRecordsFilter) (*ListSubmitRecordsResult, error) } diff --git a/internal/infrastructure/database/repositories/certification/gorm_enterprise_info_submit_record_repository.go b/internal/infrastructure/database/repositories/certification/gorm_enterprise_info_submit_record_repository.go index a741e91..1156965 100644 --- a/internal/infrastructure/database/repositories/certification/gorm_enterprise_info_submit_record_repository.go +++ b/internal/infrastructure/database/repositories/certification/gorm_enterprise_info_submit_record_repository.go @@ -3,6 +3,7 @@ package certification import ( "context" "tyapi-server/internal/domains/certification/entities" + "tyapi-server/internal/domains/certification/repositories" "tyapi-server/internal/shared/database" "go.uber.org/zap" @@ -39,6 +40,15 @@ func (r *GormEnterpriseInfoSubmitRecordRepository) Exists(ctx context.Context, I return r.ExistsEntity(ctx, ID, &entities.EnterpriseInfoSubmitRecord{}) } +func (r *GormEnterpriseInfoSubmitRecordRepository) FindByID(ctx context.Context, id string) (*entities.EnterpriseInfoSubmitRecord, error) { + var record entities.EnterpriseInfoSubmitRecord + err := r.GetDB(ctx).Where("id = ?", id).First(&record).Error + if err != nil { + return nil, err + } + return &record, nil +} + func (r *GormEnterpriseInfoSubmitRecordRepository) FindLatestByUserID(ctx context.Context, userID string) (*entities.EnterpriseInfoSubmitRecord, error) { var record entities.EnterpriseInfoSubmitRecord err := r.GetDB(ctx). @@ -61,4 +71,69 @@ func (r *GormEnterpriseInfoSubmitRecordRepository) FindLatestVerifiedByUserID(ct return nil, err } return &record, nil +} + +// ExistsByUnifiedSocialCodeExcludeUser 检查该统一社会信用代码是否已被其他用户占用(已提交或已通过验证的记录) +func (r *GormEnterpriseInfoSubmitRecordRepository) ExistsByUnifiedSocialCodeExcludeUser(ctx context.Context, unifiedSocialCode string, excludeUserID string) (bool, error) { + if unifiedSocialCode == "" { + return false, nil + } + var count int64 + query := r.GetDB(ctx).Model(&entities.EnterpriseInfoSubmitRecord{}). + Where("unified_social_code = ? AND status IN (?, ?)", unifiedSocialCode, "submitted", "verified") + if excludeUserID != "" { + query = query.Where("user_id != ?", excludeUserID) + } + if err := query.Count(&count).Error; err != nil { + return false, err + } + return count > 0, nil +} + +func (r *GormEnterpriseInfoSubmitRecordRepository) List(ctx context.Context, filter repositories.ListSubmitRecordsFilter) (*repositories.ListSubmitRecordsResult, error) { + base := r.GetDB(ctx).Model(&entities.EnterpriseInfoSubmitRecord{}) + if filter.CertificationStatus != "" { + base = base.Joins("JOIN certifications ON certifications.user_id = enterprise_info_submit_records.user_id AND certifications.deleted_at IS NULL"). + Where("certifications.status = ?", filter.CertificationStatus) + } + if filter.CompanyName != "" { + base = base.Where("enterprise_info_submit_records.company_name LIKE ?", "%"+filter.CompanyName+"%") + } + if filter.LegalPersonPhone != "" { + base = base.Where("enterprise_info_submit_records.legal_person_phone = ?", filter.LegalPersonPhone) + } + if filter.LegalPersonName != "" { + base = base.Where("enterprise_info_submit_records.legal_person_name LIKE ?", "%"+filter.LegalPersonName+"%") + } + var total int64 + if err := base.Count(&total).Error; err != nil { + return nil, err + } + if filter.PageSize <= 0 { + filter.PageSize = 10 + } + if filter.Page <= 0 { + filter.Page = 1 + } + offset := (filter.Page - 1) * filter.PageSize + var records []*entities.EnterpriseInfoSubmitRecord + q := r.GetDB(ctx).Model(&entities.EnterpriseInfoSubmitRecord{}) + if filter.CertificationStatus != "" { + q = q.Joins("JOIN certifications ON certifications.user_id = enterprise_info_submit_records.user_id AND certifications.deleted_at IS NULL"). + Where("certifications.status = ?", filter.CertificationStatus) + } + if filter.CompanyName != "" { + q = q.Where("enterprise_info_submit_records.company_name LIKE ?", "%"+filter.CompanyName+"%") + } + if filter.LegalPersonPhone != "" { + q = q.Where("enterprise_info_submit_records.legal_person_phone = ?", filter.LegalPersonPhone) + } + if filter.LegalPersonName != "" { + q = q.Where("enterprise_info_submit_records.legal_person_name LIKE ?", "%"+filter.LegalPersonName+"%") + } + err := q.Order("enterprise_info_submit_records.submit_at DESC").Offset(offset).Limit(filter.PageSize).Find(&records).Error + if err != nil { + return nil, err + } + return &repositories.ListSubmitRecordsResult{Records: records, Total: total}, nil } \ No newline at end of file diff --git a/internal/infrastructure/http/handlers/certification_handler.go b/internal/infrastructure/http/handlers/certification_handler.go index 48b5c46..be8c25d 100644 --- a/internal/infrastructure/http/handlers/certification_handler.go +++ b/internal/infrastructure/http/handlers/certification_handler.go @@ -14,17 +14,19 @@ import ( "tyapi-server/internal/application/certification/dto/commands" "tyapi-server/internal/application/certification/dto/queries" _ "tyapi-server/internal/application/certification/dto/responses" + "tyapi-server/internal/infrastructure/external/storage" "tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/middleware" ) // CertificationHandler 认证HTTP处理器 type CertificationHandler struct { - appService certification.CertificationApplicationService - response interfaces.ResponseBuilder - validator interfaces.RequestValidator - logger *zap.Logger - jwtAuth *middleware.JWTAuthMiddleware + appService certification.CertificationApplicationService + response interfaces.ResponseBuilder + validator interfaces.RequestValidator + logger *zap.Logger + jwtAuth *middleware.JWTAuthMiddleware + storageService *storage.QiNiuStorageService } // NewCertificationHandler 创建认证处理器 @@ -34,13 +36,15 @@ func NewCertificationHandler( validator interfaces.RequestValidator, logger *zap.Logger, jwtAuth *middleware.JWTAuthMiddleware, + storageService *storage.QiNiuStorageService, ) *CertificationHandler { return &CertificationHandler{ - appService: appService, - response: response, - validator: validator, - logger: logger, - jwtAuth: jwtAuth, + appService: appService, + response: response, + validator: validator, + logger: logger, + jwtAuth: jwtAuth, + storageService: storageService, } } @@ -295,6 +299,78 @@ func (h *CertificationHandler) RecognizeBusinessLicense(c *gin.Context) { h.response.Success(c, result, "营业执照识别成功") } +// UploadCertificationFile 上传认证相关图片到七牛云(企业信息中的营业执照、办公场地、场景附件、授权代表身份证等) +// @Summary 上传认证图片 +// @Description 上传企业信息中使用的图片到七牛云,返回可访问的 URL +// @Tags 认证管理 +// @Accept multipart/form-data +// @Produce json +// @Security Bearer +// @Param file formData file true "图片文件" +// @Success 200 {object} map[string]string "上传成功,返回 url 与 key" +// @Failure 400 {object} map[string]interface{} "请求参数错误" +// @Failure 401 {object} map[string]interface{} "未认证" +// @Failure 500 {object} map[string]interface{} "服务器内部错误" +// @Router /api/v1/certifications/upload [post] +func (h *CertificationHandler) UploadCertificationFile(c *gin.Context) { + userID := h.getCurrentUserID(c) + if userID == "" { + h.response.Unauthorized(c, "用户未登录") + return + } + + file, err := c.FormFile("file") + if err != nil { + h.logger.Error("获取上传文件失败", zap.Error(err), zap.String("user_id", userID)) + h.response.BadRequest(c, "请选择要上传的图片文件") + return + } + + allowedTypes := map[string]bool{ + "image/jpeg": true, + "image/jpg": true, + "image/png": true, + "image/webp": true, + } + contentType := file.Header.Get("Content-Type") + if !allowedTypes[contentType] { + h.response.BadRequest(c, "只支持 JPG、PNG、WEBP 格式的图片") + return + } + + if file.Size > 5*1024*1024 { + h.response.BadRequest(c, "图片大小不能超过 5MB") + return + } + + src, err := file.Open() + if err != nil { + h.logger.Error("打开上传文件失败", zap.Error(err), zap.String("user_id", userID)) + h.response.BadRequest(c, "文件读取失败") + return + } + defer src.Close() + + fileBytes, err := io.ReadAll(src) + if err != nil { + h.logger.Error("读取文件内容失败", zap.Error(err), zap.String("user_id", userID)) + h.response.BadRequest(c, "文件读取失败") + return + } + + uploadResult, err := h.storageService.UploadFile(c.Request.Context(), fileBytes, file.Filename) + if err != nil { + h.logger.Error("上传文件到七牛云失败", zap.Error(err), zap.String("user_id", userID), zap.String("file_name", file.Filename)) + h.response.BadRequest(c, "图片上传失败,请稍后重试") + return + } + + h.response.Success(c, map[string]string{ + "url": uploadResult.URL, + "key": uploadResult.Key, + }, "上传成功") +} + // ListCertifications 获取认证列表(管理员) // @Summary 获取认证列表 // @Description 管理员获取认证申请列表 @@ -375,6 +451,147 @@ func (h *CertificationHandler) AdminCompleteCertificationWithoutContract(c *gin. h.response.Success(c, result, "代用户完成认证成功") } +// AdminListSubmitRecords 管理端分页查询企业信息提交记录 +// @Summary 管理端企业审核列表 +// @Tags 认证管理 +// @Produce json +// @Security Bearer +// @Param page query int false "页码" +// @Param page_size query int false "每页条数" +// @Param certification_status query string false "按状态机筛选:info_pending_review/info_submitted/info_rejected,空为全部" +// @Success 200 {object} responses.AdminSubmitRecordsListResponse +// @Router /api/v1/certifications/admin/submit-records [get] +func (h *CertificationHandler) AdminListSubmitRecords(c *gin.Context) { + query := &queries.AdminListSubmitRecordsQuery{} + if err := c.ShouldBindQuery(query); err != nil { + h.response.BadRequest(c, "参数错误") + return + } + result, err := h.appService.AdminListSubmitRecords(c.Request.Context(), query) + if err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, result, "获取成功") +} + +// AdminGetSubmitRecordByID 管理端获取单条提交记录详情 +// @Summary 管理端企业审核详情 +// @Tags 认证管理 +// @Produce json +// @Security Bearer +// @Param id path string true "记录ID" +// @Success 200 {object} responses.AdminSubmitRecordDetail +// @Router /api/v1/certifications/admin/submit-records/{id} [get] +func (h *CertificationHandler) AdminGetSubmitRecordByID(c *gin.Context) { + id := c.Param("id") + if id == "" { + h.response.BadRequest(c, "记录ID不能为空") + return + } + result, err := h.appService.AdminGetSubmitRecordByID(c.Request.Context(), id) + if err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, result, "获取成功") +} + +// AdminApproveSubmitRecord 管理端审核通过 +// @Summary 管理端企业审核通过 +// @Tags 认证管理 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "记录ID" +// @Param request body object true "可选 remark" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/certifications/admin/submit-records/{id}/approve [post] +func (h *CertificationHandler) AdminApproveSubmitRecord(c *gin.Context) { + adminID := h.getCurrentUserID(c) + if adminID == "" { + h.response.Unauthorized(c, "未登录") + return + } + id := c.Param("id") + if id == "" { + h.response.BadRequest(c, "记录ID不能为空") + return + } + var body struct { + Remark string `json:"remark"` + } + _ = c.ShouldBindJSON(&body) + if err := h.appService.AdminApproveSubmitRecord(c.Request.Context(), id, adminID, body.Remark); err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "审核通过") +} + +// AdminRejectSubmitRecord 管理端审核拒绝 +// @Summary 管理端企业审核拒绝 +// @Tags 认证管理 +// @Accept json +// @Produce json +// @Security Bearer +// @Param id path string true "记录ID" +// @Param request body object true "remark 必填" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/certifications/admin/submit-records/{id}/reject [post] +func (h *CertificationHandler) AdminRejectSubmitRecord(c *gin.Context) { + adminID := h.getCurrentUserID(c) + if adminID == "" { + h.response.Unauthorized(c, "未登录") + return + } + id := c.Param("id") + if id == "" { + h.response.BadRequest(c, "记录ID不能为空") + return + } + var body struct { + Remark string `json:"remark" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + h.response.BadRequest(c, "请填写拒绝原因(remark)") + return + } + if err := h.appService.AdminRejectSubmitRecord(c.Request.Context(), id, adminID, body.Remark); err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "已拒绝") +} + +// AdminTransitionCertificationStatus 管理端按用户变更认证状态(以状态机为准) +// @Summary 管理端变更认证状态 +// @Tags 认证管理 +// @Accept json +// @Produce json +// @Security Bearer +// @Param request body commands.AdminTransitionCertificationStatusCommand true "user_id, target_status(info_submitted/info_rejected), remark" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/certifications/admin/transition-status [post] +func (h *CertificationHandler) AdminTransitionCertificationStatus(c *gin.Context) { + adminID := h.getCurrentUserID(c) + if adminID == "" { + h.response.Unauthorized(c, "未登录") + return + } + var cmd commands.AdminTransitionCertificationStatusCommand + if err := c.ShouldBindJSON(&cmd); err != nil { + h.response.BadRequest(c, "参数错误") + return + } + cmd.AdminID = adminID + if err := h.appService.AdminTransitionCertificationStatus(c.Request.Context(), &cmd); err != nil { + h.response.BadRequest(c, err.Error()) + return + } + h.response.Success(c, nil, "状态已更新") +} + // ================ 回调处理 ================ // HandleEsignCallback 处理e签宝回调 diff --git a/internal/infrastructure/http/routes/certification_routes.go b/internal/infrastructure/http/routes/certification_routes.go index 9a487e3..338ff6d 100644 --- a/internal/infrastructure/http/routes/certification_routes.go +++ b/internal/infrastructure/http/routes/certification_routes.go @@ -14,6 +14,7 @@ type CertificationRoutes struct { router *http.GinRouter logger *zap.Logger auth *middleware.JWTAuthMiddleware + admin *middleware.AdminAuthMiddleware optional *middleware.OptionalAuthMiddleware dailyRateLimit *middleware.DailyRateLimitMiddleware } @@ -24,6 +25,7 @@ func NewCertificationRoutes( router *http.GinRouter, logger *zap.Logger, auth *middleware.JWTAuthMiddleware, + admin *middleware.AdminAuthMiddleware, optional *middleware.OptionalAuthMiddleware, dailyRateLimit *middleware.DailyRateLimitMiddleware, ) *CertificationRoutes { @@ -32,6 +34,7 @@ func NewCertificationRoutes( router: router, logger: logger, auth: auth, + admin: admin, optional: optional, dailyRateLimit: dailyRateLimit, } @@ -57,6 +60,9 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) { // OCR营业执照识别接口 authGroup.POST("/ocr/business-license", r.handler.RecognizeBusinessLicense) + // 认证图片上传(七牛云,用于企业信息中的各类图片) + authGroup.POST("/upload", r.handler.UploadCertificationFile) + // 3. 申请合同签署 authGroup.POST("/apply-contract", r.handler.ApplyContract) @@ -71,6 +77,21 @@ func (r *CertificationRoutes) Register(router *http.GinRouter) { } + // 管理端企业审核(需管理员权限,以状态机状态为准) + adminGroup := certificationGroup.Group("/admin") + adminGroup.Use(r.auth.Handle()) + adminGroup.Use(r.admin.Handle()) + { + adminGroup.POST("/transition-status", r.handler.AdminTransitionCertificationStatus) + } + adminCertGroup := adminGroup.Group("/submit-records") + { + adminCertGroup.GET("", r.handler.AdminListSubmitRecords) + adminCertGroup.GET("/:id", r.handler.AdminGetSubmitRecordByID) + adminCertGroup.POST("/:id/approve", r.handler.AdminApproveSubmitRecord) + adminCertGroup.POST("/:id/reject", r.handler.AdminRejectSubmitRecord) + } + // 回调路由(不需要认证,但需要验证签名) callbackGroup := certificationGroup.Group("/callbacks") { diff --git a/internal/shared/middleware/comprehensive_logger.go b/internal/shared/middleware/comprehensive_logger.go index 23f04ee..617f943 100644 --- a/internal/shared/middleware/comprehensive_logger.go +++ b/internal/shared/middleware/comprehensive_logger.go @@ -19,15 +19,15 @@ type ComprehensiveLoggerMiddleware struct { // ComprehensiveLoggerConfig 全面日志配置 type ComprehensiveLoggerConfig struct { - EnableRequestLogging bool // 是否记录请求日志 - EnableResponseLogging bool // 是否记录响应日志 - EnableRequestBodyLogging bool // 是否记录请求体 - EnableErrorLogging bool // 是否记录错误日志 - EnableBusinessLogging bool // 是否记录业务日志 - EnablePerformanceLogging bool // 是否记录性能日志 - MaxBodySize int64 // 最大记录体大小 - ExcludePaths []string // 排除的路径 - IncludePaths []string // 包含的路径 + EnableRequestLogging bool // 是否记录请求日志 + EnableResponseLogging bool // 是否记录响应日志 + EnableRequestBodyLogging bool // 是否记录请求体 + EnableErrorLogging bool // 是否记录错误日志 + EnableBusinessLogging bool // 是否记录业务日志 + EnablePerformanceLogging bool // 是否记录性能日志 + MaxBodySize int64 // 最大记录体大小 + ExcludePaths []string // 排除的路径 + IncludePaths []string // 包含的路径 } // NewComprehensiveLoggerMiddleware 创建全面日志中间件 @@ -240,7 +240,7 @@ func (m *ComprehensiveLoggerMiddleware) logErrors(c *gin.Context, requestID, tra zap.String("user_id", userID), zap.String("method", c.Request.Method), zap.String("path", c.Request.URL.Path), - zap.String("error_type", string(ginErr.Type)), + zap.Uint64("error_type", uint64(ginErr.Type)), zap.Error(ginErr.Err), zap.Time("timestamp", time.Now()), } @@ -439,4 +439,4 @@ func (bl *BusinessLogger) getUserIDFromContext(ctx context.Context) string { } } return "" -} \ No newline at end of file +} diff --git a/internal/shared/middleware/daily_rate_limit.go b/internal/shared/middleware/daily_rate_limit.go index a42220a..0d1cd69 100644 --- a/internal/shared/middleware/daily_rate_limit.go +++ b/internal/shared/middleware/daily_rate_limit.go @@ -34,6 +34,7 @@ type DailyRateLimitConfig struct { BlockedCountries []string `mapstructure:"blocked_countries"` // 被阻止的国家/地区 EnableProxyCheck bool `mapstructure:"enable_proxy_check"` // 是否检查代理 MaxConcurrent int `mapstructure:"max_concurrent"` // 最大并发请求数 + // 路径排除配置 ExcludePaths []string `mapstructure:"exclude_paths"` // 排除频率限制的路径 // 域名排除配置 @@ -97,7 +98,10 @@ func (m *DailyRateLimitMiddleware) GetPriority() int { func (m *DailyRateLimitMiddleware) Handle() gin.HandlerFunc { return func(c *gin.Context) { ctx := c.Request.Context() - + if m.config.App.IsDevelopment() { + c.Next() + return + } // 检查是否在排除路径中 if m.isExcludedPath(c.Request.URL.Path) { c.Next() diff --git a/internal/shared/pdf/LOG_VIEWING_GUIDE.md b/internal/shared/pdf/LOG_VIEWING_GUIDE.md deleted file mode 100644 index cecdaed..0000000 --- a/internal/shared/pdf/LOG_VIEWING_GUIDE.md +++ /dev/null @@ -1,168 +0,0 @@ -# 📋 PDF表格转换日志查看指南 - -## 📍 日志文件位置 - -### 1. 开发环境 -日志文件存储在项目根目录的 `logs/` 目录下: - -``` -tyapi-server/ -└── logs/ - ├── 2024-12-02/ # 按日期分包(如果启用) - │ ├── debug.log # Debug级别日志(包含JSON转换详情) - │ ├── info.log # Info级别日志(包含转换流程) - │ └── error.log # Error级别日志(包含错误信息) - └── app.log # 传统模式(如果未启用按日分包) -``` - -### 2. 生产环境(Docker) -日志文件存储在容器的 `/app/logs/` 目录,映射到宿主机的 `./logs/` 目录: - -```bash -# 查看宿主机日志 -./logs/2024-12-02/info.log -./logs/2024-12-02/debug.log -``` - -## 🔍 如何查看转换日志 - -### 方法1:实时查看日志(推荐) - -```bash -# 查看Info级别日志(转换流程) -tail -f logs/2024-12-02/info.log | grep "表格\|JSON\|markdown" - -# 查看Debug级别日志(详细JSON数据) -tail -f logs/2024-12-02/debug.log | grep "JSON\|表格" - -# 查看所有PDF相关日志 -tail -f logs/2024-12-02/*.log | grep -i "pdf\|table\|json" -``` - -### 方法2:使用Docker查看 - -```bash -# 查看容器实时日志 -docker logs -f tyapi-app-prod | grep -i "表格\|json\|markdown" - -# 查看最近的100行日志 -docker logs --tail 100 tyapi-app-prod | grep -i "表格\|json" -``` - -### 方法3:搜索特定字段类型 - -```bash -# 查看请求参数的转换日志 -grep "request_params" logs/2024-12-02/info.log - -# 查看响应字段的转换日志 -grep "response_fields" logs/2024-12-02/info.log - -# 查看错误代码的转换日志 -grep "error_codes" logs/2024-12-02/info.log -``` - -## 📊 日志级别说明 - -### Info级别日志(info.log) -包含转换流程的关键步骤: -- ✅ 数据格式检测(JSON/Markdown) -- ✅ Markdown表格解析开始 -- ✅ 表格解析成功(表头数量、行数) -- ✅ JSON转换完成 - -**示例日志:** -``` -2024-12-02T10:30:15Z INFO 开始解析markdown表格并转换为JSON {"field_type": "request_params", "content_length": 1234} -2024-12-02T10:30:15Z INFO markdown表格解析成功 {"field_type": "request_params", "header_count": 3, "row_count": 5} -2024-12-02T10:30:15Z INFO 表格数据已转换为JSON格式 {"field_type": "request_params", "json_array_length": 5} -``` - -### Debug级别日志(debug.log) -包含详细的转换数据: -- 🔍 原始内容预览 -- 🔍 解析后的表头列表 -- 🔍 转换后的完整JSON数据(前1000字符) -- 🔍 每行的转换详情 - -**示例日志:** -``` -2024-12-02T10:30:15Z DEBUG 转换后的JSON数据预览 {"field_type": "request_params", "json_length": 2345, "json_preview": "[{\"字段名\":\"name\",\"类型\":\"string\",\"说明\":\"姓名\"}...]"} -``` - -### Error级别日志(error.log) -包含转换过程中的错误: -- ❌ Markdown解析失败 -- ❌ JSON序列化失败 -- ❌ 数据格式错误 - -**示例日志:** -``` -2024-12-02T10:30:15Z ERROR 解析markdown表格失败 {"field_type": "request_params", "error": "无法解析表格:未找到表头", "content_preview": "..."} -``` - -## 🔎 日志关键词搜索 - -### 转换流程关键词 -- `开始解析markdown表格` - 转换开始 -- `markdown表格解析成功` - 解析完成 -- `表格数据已转换为JSON格式` - JSON转换完成 -- `转换后的JSON数据预览` - JSON数据详情 - -### 数据格式关键词 -- `数据已经是JSON格式` - 数据源是JSON -- `从JSON对象中提取数组数据` - 从JSON对象提取 -- `解析markdown表格并转换为JSON` - Markdown转JSON - -### 错误关键词 -- `解析markdown表格失败` - 解析错误 -- `JSON序列化失败` - JSON错误 -- `字段内容为空` - 空数据 - -## 📝 日志配置 - -确保日志级别设置为 `debug` 才能看到详细的JSON转换日志: - -```yaml -# config.yaml 或 configs/env.development.yaml -logger: - level: "debug" # 开发环境使用debug级别 - format: "console" # 或 "json" - output: "file" # 输出到文件 - log_dir: "logs" # 日志目录 - use_daily: true # 启用按日分包 -``` - -## 🛠️ 常用命令 - -```bash -# 查看今天的Info日志 -cat logs/$(date +%Y-%m-%d)/info.log | grep "表格\|JSON" - -# 查看最近的转换日志(最后50行) -tail -n 50 logs/$(date +%Y-%m-%d)/info.log - -# 搜索特定产品的转换日志 -grep "product_id.*xxx" logs/$(date +%Y-%m-%d)/info.log - -# 查看所有错误 -grep "ERROR" logs/$(date +%Y-%m-%d)/error.log - -# 统计转换次数 -grep "表格数据已转换为JSON格式" logs/$(date +%Y-%m-%d)/info.log | wc -l -``` - -## 💡 调试技巧 - -1. **查看完整JSON数据**:如果JSON数据超过1000字符,查看debug.log获取完整内容 -2. **追踪转换流程**:使用 `field_type` 字段过滤特定字段的转换日志 -3. **定位错误**:查看error.log中的 `content_preview` 字段了解原始数据 -4. **性能监控**:统计转换次数和耗时,优化转换逻辑 - -## 📌 注意事项 - -- Debug级别日志可能包含大量数据,注意日志文件大小 -- 生产环境建议使用 `info` 级别,减少日志量 -- JSON预览限制在1000字符,完整数据请查看debug日志 -- 日志文件按日期自动分包,便于管理和查找 - diff --git a/internal/shared/pdf/database_table_reader.go b/internal/shared/pdf/database_table_reader.go index f5da128..0daead6 100644 --- a/internal/shared/pdf/database_table_reader.go +++ b/internal/shared/pdf/database_table_reader.go @@ -552,6 +552,9 @@ func (r *DatabaseTableReader) getContentPreview(content string, maxLen int) stri if len(content) <= maxLen { return content } + if maxLen > len(content) { + maxLen = len(content) + } return content[:maxLen] + "..." } diff --git a/internal/shared/pdf/database_table_renderer.go b/internal/shared/pdf/database_table_renderer.go index 9fb7fe3..7fdb5da 100644 --- a/internal/shared/pdf/database_table_renderer.go +++ b/internal/shared/pdf/database_table_renderer.go @@ -30,6 +30,10 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa r.logger.Warn("表格数据为空,跳过渲染") return nil } + // 避免表格绘制在页眉区,防止遮挡 logo + if pdf.GetY() < ContentStartYBelowHeader { + pdf.SetY(ContentStartYBelowHeader) + } // 检查表头是否有有效内容 hasValidHeader := false @@ -67,8 +71,13 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa // 即使没有数据行,也渲染表头(单行表格) // 但如果没有表头也没有数据,则不渲染 - // 设置字体 - r.fontManager.SetFont(pdf, "", 9) + // 表格线细线(返回字段说明等表格线不要太粗) + savedLineWidth := pdf.GetLineWidth() + pdf.SetLineWidth(0.2) + defer pdf.SetLineWidth(savedLineWidth) + + // 正文字体:宋体小四 12pt + r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt := pdf.GetFontSize() // 计算页面可用宽度 @@ -87,6 +96,7 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa if currentY+estimatedHeaderHeight > pageHeight-bottomMargin { r.logger.Debug("表头前需要分页", zap.Float64("current_y", currentY)) pdf.AddPage() + pdf.SetY(ContentStartYBelowHeader) } // 绘制表头 @@ -111,7 +121,7 @@ func (r *DatabaseTableRenderer) RenderTable(pdf *gofpdf.Fpdf, tableData *TableDa // 策略:先确保每列最短内容能完整显示(不换行),然后根据内容长度分配剩余空间 func (r *DatabaseTableRenderer) calculateColumnWidths(pdf *gofpdf.Fpdf, tableData *TableData, availableWidth float64) []float64 { numCols := len(tableData.Headers) - r.fontManager.SetFont(pdf, "", 9) + r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) // 第一步:找到每列中最短的内容(包括表头),计算其完整显示所需的最小宽度 colMinWidths := make([]float64, numCols) @@ -361,8 +371,8 @@ func (r *DatabaseTableRenderer) renderHeader(pdf *gofpdf.Fpdf, headers []string, // 绘制表头背景和文本 pdf.SetFillColor(74, 144, 226) // 蓝色背景 - pdf.SetTextColor(0, 0, 0) // 黑色文字 - r.fontManager.SetFont(pdf, "B", 9) + pdf.SetTextColor(0, 0, 0) // 黑色文字 + r.fontManager.SetBodyFont(pdf, "B", BodyFontSizeXiaosi) currentX := 15.0 for i, header := range headers { @@ -395,8 +405,7 @@ func (r *DatabaseTableRenderer) renderHeader(pdf *gofpdf.Fpdf, headers []string, pdf.SetXY(currentX+2, textStartY) // 确保颜色为深黑色(在渲染前再次设置,防止被覆盖) pdf.SetTextColor(0, 0, 0) // 表头是黑色文字 - // 设置字体,确保颜色不会变淡 - r.fontManager.SetFont(pdf, "B", 9) + r.fontManager.SetBodyFont(pdf, "B", BodyFontSizeXiaosi) // 再次确保颜色为深黑色(在渲染前最后一次设置) pdf.SetTextColor(0, 0, 0) // 使用正常的行高,文本已经垂直居中(减少内边距,给文本更多空间) @@ -416,7 +425,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co numCols := len(colWidths) pdf.SetFillColor(245, 245, 220) // 米色背景 pdf.SetTextColor(0, 0, 0) // 深黑色文字,确保清晰 - r.fontManager.SetFont(pdf, "", 9) + r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) // 获取页面尺寸和边距 _, pageHeight := pdf.GetPageSize() @@ -448,6 +457,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co zap.Float64("current_y", currentY), zap.Float64("page_height", pageHeight)) pdf.AddPage() + pdf.SetY(ContentStartYBelowHeader) // 在新页面上重新绘制表头 if len(headers) > 0 && len(headerColWidths) > 0 { newHeaderStartY := pdf.GetY() @@ -523,6 +533,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co zap.Int("row_index", rowIndex), zap.Float64("row_height", maxCellHeight)) pdf.AddPage() + pdf.SetY(ContentStartYBelowHeader) startY = pdf.GetY() } @@ -572,8 +583,7 @@ func (r *DatabaseTableRenderer) renderRows(pdf *gofpdf.Fpdf, rows [][]string, co pdf.SetXY(currentX+2, textStartY) // 再次确保颜色为深黑色(防止被其他设置覆盖) pdf.SetTextColor(0, 0, 0) - // 设置字体,确保颜色不会变淡 - r.fontManager.SetFont(pdf, "", 9) + r.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) // 再次确保颜色为深黑色(在渲染前最后一次设置) pdf.SetTextColor(0, 0, 0) // 安全地渲染文本,使用正常的行高 diff --git a/internal/shared/pdf/font_manager.go b/internal/shared/pdf/font_manager.go index fa85ef9..f3e810a 100644 --- a/internal/shared/pdf/font_manager.go +++ b/internal/shared/pdf/font_manager.go @@ -16,6 +16,8 @@ type FontManager struct { chineseFontLoaded bool watermarkFontName string watermarkFontLoaded bool + bodyFontName string + bodyFontLoaded bool } // NewFontManager 创建字体管理器 @@ -24,6 +26,7 @@ func NewFontManager(logger *zap.Logger) *FontManager { logger: logger, chineseFontName: "ChineseFont", watermarkFontName: "WatermarkFont", + bodyFontName: "BodyFont", } } @@ -73,6 +76,21 @@ func (fm *FontManager) LoadWatermarkFont(pdf *gofpdf.Fpdf) bool { return false } +// LoadBodyFont 加载正文用宋体(用于描述、详情、说明、表格文字等) +func (fm *FontManager) LoadBodyFont(pdf *gofpdf.Fpdf) bool { + if fm.bodyFontLoaded { + return true + } + fontPaths := fm.getBodyFontPaths() + for _, fontPath := range fontPaths { + if fm.tryAddFont(pdf, fontPath, fm.bodyFontName) { + fm.bodyFontLoaded = true + return true + } + } + return false +} + // tryAddFont 尝试添加字体(统一处理中文字体和水印字体) func (fm *FontManager) tryAddFont(pdf *gofpdf.Fpdf, fontPath, fontName string) bool { defer func() { @@ -205,13 +223,27 @@ func (fm *FontManager) getChineseFontPaths() []string { func (fm *FontManager) getWatermarkFontPaths() []string { // 水印字体文件名(尝试大小写变体) fontNames := []string{ - "YunFengFeiYunTi-2.ttf", // 优先尝试大写版本 - "yunfengfeiyunti-2.ttf", // 小写版本(兼容) + // "XuanZongTi-v0.1.otf", //玄宗字体不支持otf + "WenYuanSerifSC-Bold.ttf", //文渊雅黑 + // "YunFengFeiYunTi-2.ttf", // 毛笔字体 + // "yunfengfeiyunti-2.ttf", // 毛笔字体小写版本(兼容) } return fm.buildFontPaths(fontNames) } +// getBodyFontPaths 获取正文宋体路径列表(小四对应 12pt) +// 优先使用 resources/pdf/fonts/simsun.ttc(宋体) +func (fm *FontManager) getBodyFontPaths() []string { + fontNames := []string{ + // "simsun.ttc", // 宋体(项目内 resources/pdf/fonts) + "simsun.ttf", + "SimSun.ttf", + "WenYuanSerifSC-Bold.ttf", // 文渊宋体风格,备选 + } + return fm.buildFontPaths(fontNames) +} + // buildFontPaths 构建字体文件路径列表(仅从resources/pdf/fonts加载) // 返回所有存在的字体文件的绝对路径 func (fm *FontManager) buildFontPaths(fontNames []string) []string { @@ -295,6 +327,28 @@ func (fm *FontManager) SetWatermarkFont(pdf *gofpdf.Fpdf, style string, size flo } } +// BodyFontSizeXiaosi 正文小四字号(约 12pt) +const BodyFontSizeXiaosi = 12.0 + +// SetBodyFont 设置正文字体(宋体小四:描述、详情、说明、表格文字等) +func (fm *FontManager) SetBodyFont(pdf *gofpdf.Fpdf, style string, size float64) { + if size <= 0 { + size = BodyFontSizeXiaosi + } + if fm.bodyFontLoaded { + pdf.SetFont(fm.bodyFontName, style, size) + } else if fm.watermarkFontLoaded { + pdf.SetFont(fm.watermarkFontName, style, size) + } else { + fm.SetFont(pdf, style, size) + } +} + +// IsBodyFontAvailable 正文字体(宋体)是否已加载 +func (fm *FontManager) IsBodyFontAvailable() bool { + return fm.bodyFontLoaded || fm.watermarkFontLoaded +} + // IsChineseFontAvailable 检查中文字体是否可用 func (fm *FontManager) IsChineseFontAvailable() bool { return fm.chineseFontLoaded diff --git a/internal/shared/pdf/page_builder.go b/internal/shared/pdf/page_builder.go index 0cb061b..fe28878 100644 --- a/internal/shared/pdf/page_builder.go +++ b/internal/shared/pdf/page_builder.go @@ -56,109 +56,205 @@ func NewPageBuilder( } } +// 封面页底部为价格预留的高度(mm),避免价格被挤到单独一页 +const firstPagePriceReservedHeight = 18.0 + +// ContentStartYBelowHeader 页眉(logo+横线)下方的正文起始 Y(mm),表格等 AddPage 后须设为此值,避免与 logo 重叠(留足顶间距) +const ContentStartYBelowHeader = 50.0 + // AddFirstPage 添加第一页(封面页 - 产品功能简述) +// 页眉与水印由 SetHeaderFunc 在每页 AddPage 时自动绘制,此处不再重复调用 +// 自动限制描述/详情高度,保证价格与封面同页,不单独成页 func (pb *PageBuilder) AddFirstPage(pdf *gofpdf.Fpdf, product *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool) { pdf.AddPage() - // 添加页眉(logo和文字) - pb.addHeader(pdf, chineseFontAvailable) + pageWidth, pageHeight := pdf.GetPageSize() + _, _, _, bottomMargin := pdf.GetMargins() + // 内容区最大 Y:超出则不再绘制,留出底部给价格,避免价格单独一页 + maxContentY := pageHeight - bottomMargin - firstPagePriceReservedHeight - // 添加水印 - pb.addWatermark(pdf, chineseFontAvailable) - - // 封面页布局 - 居中显示 - pageWidth, _ := pdf.GetPageSize() - - // 标题区域(页面中上部) - pdf.SetY(80) + // 标题区域(在页眉下方留足间距,避免与 logo 重叠) + pdf.SetY(ContentStartYBelowHeader + 6) pdf.SetTextColor(0, 0, 0) pb.fontManager.SetFont(pdf, "B", 32) _, lineHt := pdf.GetFontSize() - // 清理产品名称中的无效字符 cleanName := pb.textProcessor.CleanText(product.Name) pdf.CellFormat(0, lineHt*1.5, cleanName, "", 1, "C", false, 0, "") - // 添加"接口文档"副标题 - pdf.Ln(10) + pdf.Ln(6) pb.fontManager.SetFont(pdf, "", 18) _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "接口文档", "", 1, "C", false, 0, "") - // 分隔线 - pdf.Ln(20) - pdf.SetLineWidth(0.5) - pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY()) - - // 产品编码(居中) - pdf.Ln(30) + // 产品编码 + pdf.Ln(16) pdf.SetTextColor(0, 0, 0) pb.fontManager.SetFont(pdf, "", 14) _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, fmt.Sprintf("产品编码:%s", product.Code), "", 1, "C", false, 0, "") - // 产品描述(居中显示,段落格式) + pdf.Ln(12) + pdf.SetLineWidth(0.5) + pdf.Line(pageWidth*0.2, pdf.GetY(), pageWidth*0.8, pdf.GetY()) + + // 产品描述(居中,宋体小四) if product.Description != "" { - pdf.Ln(25) - desc := pb.textProcessor.StripHTML(product.Description) + pdf.Ln(10) + desc := pb.textProcessor.HTMLToPlainWithBreaks(product.Description) desc = pb.textProcessor.CleanText(desc) - pb.fontManager.SetFont(pdf, "", 14) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt = pdf.GetFontSize() - // 居中对齐的MultiCell(通过计算宽度实现) - descWidth := pageWidth * 0.7 - descLines := pb.safeSplitText(pdf, desc, descWidth, chineseFontAvailable) - currentX := (pageWidth - descWidth) / 2 - for _, line := range descLines { - pdf.SetX(currentX) - pdf.CellFormat(descWidth, lineHt*1.5, line, "", 1, "C", false, 0, "") - } + pb.drawRichTextBlock(pdf, desc, pageWidth*0.7, lineHt*1.5, maxContentY, "C", true, chineseFontAvailable) } - // 产品详情(如果存在) - if product.Content != "" { - pdf.Ln(20) - content := pb.textProcessor.StripHTML(product.Content) - content = pb.textProcessor.CleanText(content) - pb.fontManager.SetFont(pdf, "", 12) - _, lineHt = pdf.GetFontSize() - contentWidth := pageWidth * 0.7 - contentLines := pb.safeSplitText(pdf, content, contentWidth, chineseFontAvailable) - currentX := (pageWidth - contentWidth) / 2 - for _, line := range contentLines { - pdf.SetX(currentX) - pdf.CellFormat(contentWidth, lineHt*1.4, line, "", 1, "C", false, 0, "") - } - } - - // 价格信息(右下角,在产品详情之后) + // 产品详情已移至单独一页,见 AddProductContentPage if !product.Price.IsZero() { - // 获取产品详情结束后的Y坐标,稍微下移显示价格 - contentEndY := pdf.GetY() - pdf.SetY(contentEndY + 5) - pdf.SetTextColor(0, 0, 0) pb.fontManager.SetFont(pdf, "", 14) _, priceLineHt := pdf.GetFontSize() + reservedZoneY := pageHeight - bottomMargin - firstPagePriceReservedHeight + 6 + priceY := reservedZoneY + if pdf.GetY()+5 > reservedZoneY { + priceY = pdf.GetY() + 5 + } + pdf.SetY(priceY) + pdf.SetTextColor(0, 0, 0) priceText := fmt.Sprintf("价格:%s 元", product.Price.String()) textWidth := pdf.GetStringWidth(priceText) - // 右对齐:从页面宽度减去文本宽度和右边距(15mm) pdf.SetX(pageWidth - textWidth - 15) pdf.CellFormat(textWidth, priceLineHt, priceText, "", 0, "R", false, 0, "") } +} +// AddProductContentPage 添加产品详情页(另起一页,左对齐,符合 HTML 富文本:段落、加粗、标题) +func (pb *PageBuilder) AddProductContentPage(pdf *gofpdf.Fpdf, product *entities.Product, chineseFontAvailable bool) { + if product.Content == "" { + return + } + pdf.AddPage() + pageWidth, _ := pdf.GetPageSize() + + pdf.SetY(ContentStartYBelowHeader) + pdf.SetTextColor(0, 0, 0) + pb.fontManager.SetFont(pdf, "B", 14) + _, titleHt := pdf.GetFontSize() + pdf.CellFormat(0, titleHt, "产品详情", "", 1, "L", false, 0, "") + pdf.Ln(6) + // 按 HTML 富文本解析并绘制(宋体小四):段落、换行、加粗、标题,自动分页且不遮挡 logo + pb.drawHTMLContent(pdf, product.Content, pageWidth*0.9, chineseFontAvailable) +} + +// drawHTMLContent 按 HTML 富文本绘制产品详情:段落、换行、加粗、标题;每行前确保在页眉下,避免分页后遮挡 logo +func (pb *PageBuilder) drawHTMLContent(pdf *gofpdf.Fpdf, htmlContent string, contentWidth float64, chineseFontAvailable bool) { + segments := pb.textProcessor.ParseHTMLToSegments(htmlContent) + cleanSegments := make([]HTMLSegment, 0, len(segments)) + for _, s := range segments { + t := pb.textProcessor.CleanText(s.Text) + if s.Text != "" { + cleanSegments = append(cleanSegments, HTMLSegment{Text: t, Bold: s.Bold, NewLine: s.NewLine, NewParagraph: s.NewParagraph, HeadingLevel: s.HeadingLevel}) + } else { + cleanSegments = append(cleanSegments, s) + } + } + segments = cleanSegments + + leftMargin, _, _, _ := pdf.GetMargins() + currentX := leftMargin + firstLineOfBlock := true + + for _, seg := range segments { + if seg.NewParagraph { + pdf.Ln(4) + firstLineOfBlock = true + continue + } + if seg.NewLine { + pdf.Ln(1) + continue + } + if seg.Text == "" { + continue + } + // 字体与行高 + fontSize := 12.0 + style := "" + if seg.Bold { + style = "B" + } + if seg.HeadingLevel == 1 { + fontSize = 18 + style = "B" + } else if seg.HeadingLevel == 2 { + fontSize = 16 + style = "B" + } else if seg.HeadingLevel == 3 { + fontSize = 14 + style = "B" + } + pb.fontManager.SetBodyFont(pdf, style, fontSize) + _, lineHt := pdf.GetFontSize() + lineHeight := lineHt * 1.4 + + wrapped := pb.safeSplitText(pdf, seg.Text, contentWidth, chineseFontAvailable) + for _, w := range wrapped { + pb.ensureContentBelowHeader(pdf) + x := currentX + if firstLineOfBlock { + x = leftMargin + paragraphIndentMM + } + pdf.SetX(x) + pdf.SetTextColor(0, 0, 0) + pdf.CellFormat(contentWidth, lineHeight, w, "", 1, "L", false, 0, "") + firstLineOfBlock = false + } + } +} + +// drawRichTextBlockNoLimit 渲染富文本块,不根据 maxContentY 截断,允许自动分页,适合“产品详情”等必须全部展示的内容 +func (pb *PageBuilder) drawRichTextBlockNoLimit(pdf *gofpdf.Fpdf, text string, contentWidth, lineHeight float64, align string, firstLineIndent bool, chineseFontAvailable bool) { + pageWidth, _ := pdf.GetPageSize() + leftMargin, _, _, _ := pdf.GetMargins() + currentX := (pageWidth - contentWidth) / 2 + if align == "L" { + currentX = leftMargin + } + paragraphs := strings.Split(text, "\n\n") + for pIdx, para := range paragraphs { + para = strings.TrimSpace(para) + if para == "" { + continue + } + if pIdx > 0 { + pdf.Ln(4) + } + firstLineOfPara := true + lines := strings.Split(para, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + wrapped := pb.safeSplitText(pdf, line, contentWidth, chineseFontAvailable) + for _, w := range wrapped { + pb.ensureContentBelowHeader(pdf) + x := currentX + if align == "L" && firstLineIndent && firstLineOfPara { + x = leftMargin + paragraphIndentMM + } + pdf.SetX(x) + pdf.CellFormat(contentWidth, lineHeight, w, "", 1, align, false, 0, "") + firstLineOfPara = false + } + } + } } // AddDocumentationPages 添加接口文档页面 +// 每页的页眉与水印由 SetHeaderFunc / SetFooterFunc 在 AddPage 时自动绘制 func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) { - // 创建自定义的AddPage函数,确保每页都有水印 - addPageWithWatermark := func() { - pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印 - } + pdf.AddPage() - addPageWithWatermark() - - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) pb.fontManager.SetFont(pdf, "B", 18) _, lineHt := pdf.GetFontSize() pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "") @@ -171,7 +267,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // URL使用黑体字体(可能包含中文字符) // 先清理URL中的乱码 cleanURL := pb.textProcessor.CleanText(doc.RequestURL) - pb.fontManager.SetFont(pdf, "", 10) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false) @@ -188,6 +284,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 请求参数 if doc.RequestParams != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -201,7 +298,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.RequestParams) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -212,22 +309,20 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "") - // JSON中可能包含中文值,使用黑体字体 - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON(支持中文) - pdf.SetTextColor(0, 0, 0) - pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt) } } // 响应示例 if doc.ResponseExample != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "") - // 优先尝试提取和格式化JSON + // 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐) jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample) if jsonContent != "" { // 格式化JSON @@ -235,9 +330,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro if err == nil { jsonContent = formattedJSON } - pdf.SetTextColor(0, 0, 0) // 确保深黑色 - pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON(支持中文) - pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt) } else { // 如果没有JSON,尝试使用表格方式处理 if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil { @@ -245,7 +338,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseExample) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } @@ -253,8 +346,9 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro } } - // 返回字段说明 + // 返回字段说明(确保在页眉下方,避免与 logo 重叠) if doc.ResponseFields != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -270,7 +364,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseFields) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } else { pb.logger.Warn("返回字段内容为空或只有空白字符") @@ -282,6 +376,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 错误代码 if doc.ErrorCodes != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -295,7 +390,7 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ErrorCodes) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -306,18 +401,11 @@ func (pb *PageBuilder) AddDocumentationPages(pdf *gofpdf.Fpdf, doc *entities.Pro } // AddDocumentationPagesWithoutAdditionalInfo 添加接口文档页面(不包含二维码和说明) -// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明 +// 用于组合包场景,在所有文档渲染完成后统一添加二维码和说明。每页页眉与水印由 SetHeaderFunc/SetFooterFunc 自动绘制。 func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.ProductDocumentation, chineseFontAvailable bool) { - // 创建自定义的AddPage函数,确保每页都有水印 - addPageWithWatermark := func() { - pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印 - } + pdf.AddPage() - addPageWithWatermark() - - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) pb.fontManager.SetFont(pdf, "B", 18) _, lineHt := pdf.GetFontSize() pdf.CellFormat(0, lineHt, "接口文档", "", 1, "L", false, 0, "") @@ -330,7 +418,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // URL使用黑体字体(可能包含中文字符) // 先清理URL中的乱码 cleanURL := pb.textProcessor.CleanText(doc.RequestURL) - pb.fontManager.SetFont(pdf, "", 10) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false) @@ -347,6 +435,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 请求参数 if doc.RequestParams != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -360,7 +449,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.RequestParams) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -371,22 +460,20 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "") - // JSON中可能包含中文值,使用黑体字体 - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON(支持中文) - pdf.SetTextColor(0, 0, 0) - pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt) } } // 响应示例 if doc.ResponseExample != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "") - // 优先尝试提取和格式化JSON + // 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐) jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample) if jsonContent != "" { // 格式化JSON @@ -394,9 +481,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp if err == nil { jsonContent = formattedJSON } - pdf.SetTextColor(0, 0, 0) // 确保深黑色 - pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON(支持中文) - pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt) } else { // 如果没有JSON,尝试使用表格方式处理 if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil { @@ -404,7 +489,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseExample) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } @@ -412,8 +497,9 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp } } - // 返回字段说明 + // 返回字段说明(确保在页眉下方,避免与 logo 重叠) if doc.ResponseFields != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -429,7 +515,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseFields) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } else { pb.logger.Warn("返回字段内容为空或只有空白字符") @@ -441,6 +527,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 错误代码 if doc.ErrorCodes != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -454,7 +541,7 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ErrorCodes) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -463,17 +550,11 @@ func (pb *PageBuilder) AddDocumentationPagesWithoutAdditionalInfo(pdf *gofpdf.Fp } // AddSubProductDocumentationPages 添加子产品的接口文档页面(用于组合包) +// 每页页眉与水印由 SetHeaderFunc 在 AddPage 时自动绘制 func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProduct *entities.Product, doc *entities.ProductDocumentation, chineseFontAvailable bool, isLastSubProduct bool) { - // 创建自定义的AddPage函数,确保每页都有水印 - addPageWithWatermark := func() { - pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) // 每页都添加水印 - } + pdf.AddPage() - addPageWithWatermark() - - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) pb.fontManager.SetFont(pdf, "B", 18) _, lineHt := pdf.GetFontSize() @@ -492,7 +573,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // URL使用黑体字体(可能包含中文字符) // 先清理URL中的乱码 cleanURL := pb.textProcessor.CleanText(doc.RequestURL) - pb.fontManager.SetFont(pdf, "", 10) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) pdf.MultiCell(0, lineHt*1.2, cleanURL, "", "L", false) @@ -509,6 +590,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 请求参数 if doc.RequestParams != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -522,7 +604,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.RequestParams) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -533,22 +615,20 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) pdf.CellFormat(0, lineHt, "请求示例:", "", 1, "L", false, 0, "") - // JSON中可能包含中文值,使用黑体字体 - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体显示JSON(支持中文) - pdf.SetTextColor(0, 0, 0) - pdf.MultiCell(0, lineHt*1.3, jsonExample, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonExample, lineHt) } } // 响应示例 if doc.ResponseExample != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pb.fontManager.SetFont(pdf, "B", 14) _, lineHt = pdf.GetFontSize() pdf.CellFormat(0, lineHt, "响应示例:", "", 1, "L", false, 0, "") - // 优先尝试提取和格式化JSON + // 优先尝试提取和格式化JSON(表格包裹,居中,内容左对齐) jsonContent := pb.jsonProcessor.ExtractJSON(doc.ResponseExample) if jsonContent != "" { // 格式化JSON @@ -556,9 +636,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd if err == nil { jsonContent = formattedJSON } - pdf.SetTextColor(0, 0, 0) // 确保深黑色 - pb.fontManager.SetFont(pdf, "", 9) // 使用等宽字体显示JSON(支持中文) - pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) + pb.drawJSONInCenteredTable(pdf, jsonContent, lineHt) } else { // 如果没有JSON,尝试使用表格方式处理 if err := pb.tableParser.ParseAndRenderTable(context.Background(), pdf, doc, "response_example"); err != nil { @@ -566,7 +644,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseExample) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.SetTextColor(0, 0, 0) // 确保深黑色 pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } @@ -574,8 +652,9 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd } } - // 返回字段说明 + // 返回字段说明(确保在页眉下方,避免与 logo 重叠) if doc.ResponseFields != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -591,7 +670,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ResponseFields) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } else { pb.logger.Warn("返回字段内容为空或只有空白字符") @@ -603,6 +682,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 错误代码 if doc.ErrorCodes != "" { + pb.ensureContentBelowHeader(pdf) pdf.Ln(8) // 显示标题 pdf.SetTextColor(0, 0, 0) @@ -616,7 +696,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // 如果表格渲染失败,显示为文本 text := pb.textProcessor.CleanText(doc.ErrorCodes) if strings.TrimSpace(text) != "" { - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -626,6 +706,7 @@ func (pb *PageBuilder) AddSubProductDocumentationPages(pdf *gofpdf.Fpdf, subProd // addSection 添加章节 func (pb *PageBuilder) addSection(pdf *gofpdf.Fpdf, title, content string, chineseFontAvailable bool) { + pb.ensureContentBelowHeader(pdf) _, lineHt := pdf.GetFontSize() pb.fontManager.SetFont(pdf, "B", 14) pdf.CellFormat(0, lineHt, title+":", "", 1, "L", false, 0, "") @@ -644,7 +725,7 @@ func (pb *PageBuilder) addSection(pdf *gofpdf.Fpdf, title, content string, chine jsonContent = formattedJSON } pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.2, jsonContent, "", "L", false) } else { // 按#号标题分割内容,每个标题下的内容单独处理 @@ -718,7 +799,7 @@ func (pb *PageBuilder) processRequestParams(pdf *gofpdf.Fpdf, content string, ch if strings.TrimSpace(afterText) != "" { pdf.Ln(3) pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } @@ -729,7 +810,7 @@ func (pb *PageBuilder) processRequestParams(pdf *gofpdf.Fpdf, content string, ch text = pb.textProcessor.CleanText(text) if strings.TrimSpace(text) != "" { pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -751,7 +832,7 @@ func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, if err == nil { jsonContent = formattedJSON } - pb.fontManager.SetFont(pdf, "", 9) // 使用黑体 + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, jsonContent, "", "L", false) pdf.Ln(5) } @@ -787,7 +868,7 @@ func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, if strings.TrimSpace(afterText) != "" { pdf.Ln(3) pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } @@ -798,7 +879,7 @@ func (pb *PageBuilder) processResponseExample(pdf *gofpdf.Fpdf, content string, text = pb.textProcessor.CleanText(text) if strings.TrimSpace(text) != "" { pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, text, "", "L", false) } } @@ -863,29 +944,32 @@ func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, c // 显示表格前的说明文字 if len(beforeTable) > 0 { + pb.ensureContentBelowHeader(pdf) beforeText := strings.Join(beforeTable, "\n") beforeText = pb.textProcessor.StripHTML(beforeText) beforeText = pb.textProcessor.CleanText(beforeText) if strings.TrimSpace(beforeText) != "" { pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, beforeText, "", "L", false) pdf.Ln(3) } } // 渲染表格 + pb.ensureContentBelowHeader(pdf) pb.tableRenderer.RenderTable(pdf, tableData) // 显示表格后的说明文字 if len(afterTable) > 0 { + pb.ensureContentBelowHeader(pdf) afterText := strings.Join(afterTable, "\n") afterText = pb.textProcessor.StripHTML(afterText) afterText = pb.textProcessor.CleanText(afterText) if strings.TrimSpace(afterText) != "" { pdf.Ln(3) pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, afterText, "", "L", false) } } @@ -893,7 +977,7 @@ func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, c } else { // 如果不是有效表格,显示为文本(完整显示markdown内容) pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) text := pb.textProcessor.StripHTML(content) text = pb.textProcessor.CleanText(text) // 清理无效字符,保留中文 // 如果文本不为空,显示它 @@ -905,6 +989,7 @@ func (pb *PageBuilder) processSectionContent(pdf *gofpdf.Fpdf, content string, c // renderTextWithTitles 渲染包含markdown标题的文本 func (pb *PageBuilder) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chineseFontAvailable bool, lineHt float64) { + pb.ensureContentBelowHeader(pdf) lines := strings.Split(text, "\n") for _, line := range lines { @@ -938,6 +1023,7 @@ func (pb *PageBuilder) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chine } // 渲染标题 + pb.ensureContentBelowHeader(pdf) pdf.SetTextColor(0, 0, 0) pb.fontManager.SetFont(pdf, "B", fontSize) _, titleLineHt := pdf.GetFontSize() @@ -945,11 +1031,12 @@ func (pb *PageBuilder) renderTextWithTitles(pdf *gofpdf.Fpdf, text string, chine pdf.Ln(2) } else if strings.TrimSpace(line) != "" { // 普通文本行(只去除HTML标签,保留markdown格式) + pb.ensureContentBelowHeader(pdf) cleanText := pb.textProcessor.StripHTML(line) cleanText = pb.textProcessor.CleanTextPreservingMarkdown(cleanText) if strings.TrimSpace(cleanText) != "" { pdf.SetTextColor(0, 0, 0) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) pdf.MultiCell(0, lineHt*1.3, cleanText, "", "L", false) } } else { @@ -979,82 +1066,227 @@ func (pb *PageBuilder) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { // 绘制下横线(优化位置,左边距是15mm) pdf.Line(15, 22, 75, 22) + + // 所有自动分页后的正文统一从页眉下方固定位置开始,避免内容顶到 logo 或水印 + pdf.SetY(ContentStartYBelowHeader) } -// addWatermark 添加水印(从左边开始向上倾斜45度,考虑可用区域) +// ensureContentBelowHeader 若当前 Y 在页眉区内则下移到正文区,避免与 logo 重叠 +func (pb *PageBuilder) ensureContentBelowHeader(pdf *gofpdf.Fpdf) { + if pdf.GetY() < ContentStartYBelowHeader { + pdf.SetY(ContentStartYBelowHeader) + } +} + +// addWatermark 添加水印:自左下角往右上角倾斜 45°,单条水印居中于页面,样式柔和 func (pb *PageBuilder) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { - // 如果中文字体不可用,跳过水印(避免显示乱码) if !chineseFontAvailable { return } - // 保存当前图形状态 pdf.TransformBegin() defer pdf.TransformEnd() - // 获取页面尺寸和边距 - _, pageHeight := pdf.GetPageSize() + pageWidth, pageHeight := pdf.GetPageSize() leftMargin, topMargin, _, bottomMargin := pdf.GetMargins() - - // 计算实际可用区域高度 usableHeight := pageHeight - topMargin - bottomMargin + usableWidth := pageWidth - leftMargin*2 - // 设置水印样式(使用水印字体,非黑体) - fontSize := 45.0 - + fontSize := 42.0 pb.fontManager.SetWatermarkFont(pdf, "", fontSize) - // 设置灰色和透明度(加深水印,使其更明显) - pdf.SetTextColor(180, 180, 180) // 深一点的灰色 - pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显 + // 加深水印:更深的灰与更高不透明度,保证可见 + pdf.SetTextColor(150, 150, 150) + pdf.SetAlpha(0.32, "Normal") - // 计算文字宽度 textWidth := pdf.GetStringWidth(pb.watermarkText) if textWidth == 0 { - // 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm) textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0 } - // 从左边开始,计算起始位置 - // 起始X:左边距 - // 起始Y:考虑水印文字长度和旋转后需要的空间 - startX := leftMargin - startY := topMargin + textWidth*0.5 // 为旋转留出空间 - - // 移动到起始位置 - pdf.TransformTranslate(startX, startY) - - // 向上倾斜45度(顺时针旋转45度,即-45度,或逆时针315度) - pdf.TransformRotate(-45, 0, 0) - - // 检查文字是否会超出可用区域(旋转后的对角线长度) + // 旋转后对角线长度,用于缩放与定位 rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize) - if rotatedDiagonal > usableHeight*0.8 { - // 如果太大,缩小字体 - fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal + if rotatedDiagonal > usableHeight*0.75 { + fontSize = fontSize * usableHeight * 0.75 / rotatedDiagonal pb.fontManager.SetWatermarkFont(pdf, "", fontSize) textWidth = pdf.GetStringWidth(pb.watermarkText) if textWidth == 0 { textWidth = float64(len([]rune(pb.watermarkText))) * fontSize / 3.0 } + rotatedDiagonal = math.Sqrt(textWidth*textWidth + fontSize*fontSize) } - // 从左边开始绘制水印文字 + // 自左下角往右上角:起点在可用区域左下角,逆时针旋转 +45° + startX := leftMargin + startY := pageHeight - bottomMargin + + // 沿 +45° 方向居中:对角线在可用区域内居中 + diagW := rotatedDiagonal * math.Cos(45*math.Pi/180) + offsetX := (usableWidth - diagW) * 0.5 + startX += offsetX + startY -= rotatedDiagonal * 0.5 + + pdf.TransformTranslate(startX, startY) + pdf.TransformRotate(45, 0, 0) + pdf.SetXY(0, 0) pdf.CellFormat(textWidth, fontSize, pb.watermarkText, "", 0, "L", false, 0, "") - // 恢复透明度和颜色 pdf.SetAlpha(1.0, "Normal") - pdf.SetTextColor(0, 0, 0) // 恢复为黑色 + pdf.SetTextColor(0, 0, 0) +} + +// 段前缩进宽度(约两字符,mm) +const paragraphIndentMM = 7.0 + +// drawRichTextBlock 按段落与换行绘制文本块(还原 HTML 换行) +// align: "C" 居中;"L" 左对齐。firstLineIndent 为 true 时每段首行缩进(段前两空格效果)。 +func (pb *PageBuilder) drawRichTextBlock(pdf *gofpdf.Fpdf, text string, contentWidth, lineHeight float64, maxContentY float64, align string, firstLineIndent bool, chineseFontAvailable bool) { + pageWidth, _ := pdf.GetPageSize() + leftMargin, _, _, _ := pdf.GetMargins() + currentX := (pageWidth - contentWidth) / 2 + if align == "L" { + currentX = leftMargin + } + paragraphs := strings.Split(text, "\n\n") + for pIdx, para := range paragraphs { + para = strings.TrimSpace(para) + if para == "" { + continue + } + if pIdx > 0 && pdf.GetY()+lineHeight <= maxContentY { + pdf.Ln(4) + } + firstLineOfPara := true + lines := strings.Split(para, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + wrapped := pb.safeSplitText(pdf, line, contentWidth, chineseFontAvailable) + for _, w := range wrapped { + if pdf.GetY()+lineHeight > maxContentY { + pdf.SetX(currentX) + pdf.CellFormat(contentWidth, lineHeight, "…", "", 1, align, false, 0, "") + return + } + x := currentX + if align == "L" && firstLineIndent && firstLineOfPara { + x = leftMargin + paragraphIndentMM + } + pdf.SetX(x) + pdf.CellFormat(contentWidth, lineHeight, w, "", 1, align, false, 0, "") + firstLineOfPara = false + } + } + } } // getContentPreview 获取内容预览(用于日志记录) func (pb *PageBuilder) getContentPreview(content string, maxLen int) string { content = strings.TrimSpace(content) - if len(content) <= maxLen { + if maxLen <= 0 || len(content) <= maxLen { return content } - return content[:maxLen] + "..." + n := maxLen + if n > len(content) { + n = len(content) + } + return content[:n] + "..." +} + +// wrapJSONLinesToWidth 将 JSON 文本按宽度换行,返回用于绘制的行列表(兼容中文等) +func (pb *PageBuilder) wrapJSONLinesToWidth(pdf *gofpdf.Fpdf, jsonContent string, width float64) []string { + chineseFontAvailable := pb.fontManager != nil && pb.fontManager.IsChineseFontAvailable() + var out []string + for _, line := range strings.Split(jsonContent, "\n") { + line = strings.TrimRight(line, "\r") + if line == "" { + out = append(out, "") + continue + } + wrapped := pb.safeSplitText(pdf, line, width, chineseFontAvailable) + out = append(out, wrapped...) + } + return out +} + +// drawJSONInCenteredTable 在居中表格中绘制 JSON 文本(表格居中,内容左对齐);多页时每页独立边框完整包裹当页内容,且不遮挡 logo +func (pb *PageBuilder) drawJSONInCenteredTable(pdf *gofpdf.Fpdf, jsonContent string, lineHt float64) { + jsonContent = strings.TrimSpace(jsonContent) + if jsonContent == "" { + return + } + pb.ensureContentBelowHeader(pdf) + + pageWidth, pageHeight := pdf.GetPageSize() + leftMargin, _, rightMargin, bottomMargin := pdf.GetMargins() + usableWidth := pageWidth - leftMargin - rightMargin + tableWidth := usableWidth * 0.92 + startX := (pageWidth - tableWidth) / 2 + padding := 4.0 + innerWidth := tableWidth - 2*padding + lineHeight := lineHt * 1.3 + + pdf.SetTextColor(0, 0, 0) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) + // 使用 safeSplitText 兼容中文等字符,避免 SplitText panic;按行先拆再对每行按宽度换行 + allLines := pb.wrapJSONLinesToWidth(pdf, jsonContent, innerWidth) + + // 每页可用高度(从当前 Y 到页底),用于分块 + maxH := pageHeight - bottomMargin - pdf.GetY() + linesPerPage := int((maxH - 2*padding) / lineHeight) + if linesPerPage < 1 { + linesPerPage = 1 + } + + chunkStart := 0 + for chunkStart < len(allLines) { + pb.ensureContentBelowHeader(pdf) + currentY := pdf.GetY() + // 本页剩余高度不足则换页再从页眉下开始 + if currentY < ContentStartYBelowHeader { + currentY = ContentStartYBelowHeader + pdf.SetY(currentY) + } + maxH = pageHeight - bottomMargin - currentY + linesPerPage = int((maxH - 2*padding) / lineHeight) + if linesPerPage < 1 { + pdf.AddPage() + currentY = ContentStartYBelowHeader + pdf.SetY(currentY) + linesPerPage = int((pageHeight - bottomMargin - currentY - 2*padding) / lineHeight) + if linesPerPage < 1 { + linesPerPage = 1 + } + } + + chunkEnd := chunkStart + linesPerPage + if chunkEnd > len(allLines) { + chunkEnd = len(allLines) + } + chunk := allLines[chunkStart:chunkEnd] + chunkStart = chunkEnd + + chunkHeight := float64(len(chunk))*lineHeight + 2*padding + // 若本页放不下整块,先换页 + if currentY+chunkHeight > pageHeight-bottomMargin { + pdf.AddPage() + currentY = ContentStartYBelowHeader + pdf.SetY(currentY) + } + startY := currentY + pdf.SetDrawColor(180, 180, 180) + pdf.Rect(startX, startY, tableWidth, chunkHeight, "D") + pdf.SetDrawColor(0, 0, 0) + pdf.SetY(startY + padding) + for _, line := range chunk { + pdf.SetX(startX + padding) + pdf.CellFormat(innerWidth, lineHeight, line, "", 1, "L", false, 0, "") + } + pdf.SetY(startY + chunkHeight) + } } // safeSplitText 安全地分割文本,避免在没有中文字体时调用SplitText导致panic @@ -1167,12 +1399,10 @@ func (pb *PageBuilder) addAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.Product currentY := pdf.GetY() remainingHeight := pageHeight - currentY - bottomMargin - // 如果剩余空间不足,添加新页 + // 如果剩余空间不足,添加新页(页眉与水印由 SetHeaderFunc 自动绘制) if remainingHeight < 100 { pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) } // 添加分隔线 @@ -1198,7 +1428,7 @@ func (pb *PageBuilder) addAdditionalInfo(pdf *gofpdf.Fpdf, doc *entities.Product ) pdf.Ln(5) - pb.fontManager.SetFont(pdf, "", 11) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt = pdf.GetFontSize() // 处理说明文本,按行分割并显示 @@ -1327,9 +1557,7 @@ func (pb *PageBuilder) addQRCodeSection(pdf *gofpdf.Fpdf, doc *entities.ProductD // 检查是否需要换页(为二维码预留空间) if pageHeight-currentY-bottomMargin < 120 { pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) } // 添加二维码标题 @@ -1344,7 +1572,7 @@ func (pb *PageBuilder) addQRCodeSection(pdf *gofpdf.Fpdf, doc *entities.ProductD // 二维码说明文字(简化版,放在二维码之后) pdf.Ln(10) - pb.fontManager.SetFont(pdf, "", 11) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt = pdf.GetFontSize() qrCodeExplanation := "使用手机扫描上方二维码可直接跳转到天远API官网(https://tianyuanapi.com/),获取更多接口文档和资源。\n\n" + @@ -1381,9 +1609,7 @@ func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseF qrSize := 40.0 if pageHeight-currentY-bottomMargin < qrSize+20 { pdf.AddPage() - pb.addHeader(pdf, chineseFontAvailable) - pb.addWatermark(pdf, chineseFontAvailable) - pdf.SetY(45) + pdf.SetY(ContentStartYBelowHeader) } // 生成二维码 @@ -1418,7 +1644,7 @@ func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseF // 添加二维码说明 pdf.Ln(10) - pb.fontManager.SetFont(pdf, "", 10) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt := pdf.GetFontSize() pdf.CellFormat(0, lineHt, "官网二维码:", "", 1, "L", false, 0, "") @@ -1431,7 +1657,7 @@ func (pb *PageBuilder) addQRCodeImage(pdf *gofpdf.Fpdf, content string, chineseF // 添加二维码下方的说明文字 pdf.SetY(pdf.GetY() + qrSize + 5) - pb.fontManager.SetFont(pdf, "", 9) + pb.fontManager.SetBodyFont(pdf, "", BodyFontSizeXiaosi) _, lineHt = pdf.GetFontSize() qrNote := "使用手机扫描上方二维码可访问官网获取更多详情" noteWidth := pdf.GetStringWidth(qrNote) diff --git a/internal/shared/pdf/pdf_cache_manager.go b/internal/shared/pdf/pdf_cache_manager.go index bd671ce..091db5b 100644 --- a/internal/shared/pdf/pdf_cache_manager.go +++ b/internal/shared/pdf/pdf_cache_manager.go @@ -434,4 +434,3 @@ func (m *PDFCacheManager) GetCacheStats() (map[string]interface{}, error) { "max_size": m.maxSize, }, nil } - diff --git a/internal/shared/pdf/pdf_generator.go b/internal/shared/pdf/pdf_generator.go index 0b8c1d8..849c5b5 100644 --- a/internal/shared/pdf/pdf_generator.go +++ b/internal/shared/pdf/pdf_generator.go @@ -2078,69 +2078,55 @@ func (g *PDFGenerator) addHeader(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { pdf.Line(15, 22, 75, 22) } -// addWatermark 添加水印(从左边开始向上倾斜45度,考虑可用区域) +// addWatermark 添加水印:自左下角往右上角倾斜 45°,单条水印居中于页面,样式柔和 func (g *PDFGenerator) addWatermark(pdf *gofpdf.Fpdf, chineseFontAvailable bool) { - // 如果中文字体不可用,跳过水印(避免显示乱码) if !chineseFontAvailable { return } - // 保存当前图形状态 pdf.TransformBegin() defer pdf.TransformEnd() - // 获取页面尺寸和边距 - _, pageHeight := pdf.GetPageSize() + pageWidth, pageHeight := pdf.GetPageSize() leftMargin, topMargin, _, bottomMargin := pdf.GetMargins() - - // 计算实际可用区域高度 usableHeight := pageHeight - topMargin - bottomMargin + usableWidth := pageWidth - leftMargin*2 - // 设置水印样式(使用中文字体) - fontSize := 45.0 - + fontSize := 42.0 pdf.SetFont("ChineseFont", "", fontSize) - // 设置灰色和透明度(加深水印,使其更明显) - pdf.SetTextColor(180, 180, 180) // 深一点的灰色 - pdf.SetAlpha(0.25, "Normal") // 增加透明度,让水印更明显 + pdf.SetTextColor(150, 150, 150) + pdf.SetAlpha(0.32, "Normal") - // 计算文字宽度 textWidth := pdf.GetStringWidth(g.watermarkText) if textWidth == 0 { - // 如果无法获取宽度(字体未注册),使用估算值(中文字符大约每个 fontSize/3 mm) textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0 } - // 从左边开始,计算起始位置 - // 起始X:左边距 - // 起始Y:考虑水印文字长度和旋转后需要的空间 - startX := leftMargin - startY := topMargin + textWidth*0.5 // 为旋转留出空间 - - // 移动到起始位置 - pdf.TransformTranslate(startX, startY) - - // 向上倾斜45度(顺时针旋转45度,即-45度,或逆时针315度) - pdf.TransformRotate(-45, 0, 0) - - // 检查文字是否会超出可用区域(旋转后的对角线长度) rotatedDiagonal := math.Sqrt(textWidth*textWidth + fontSize*fontSize) - if rotatedDiagonal > usableHeight*0.8 { - // 如果太大,缩小字体 - fontSize = fontSize * usableHeight * 0.8 / rotatedDiagonal + if rotatedDiagonal > usableHeight*0.75 { + fontSize = fontSize * usableHeight * 0.75 / rotatedDiagonal pdf.SetFont("ChineseFont", "", fontSize) textWidth = pdf.GetStringWidth(g.watermarkText) if textWidth == 0 { textWidth = float64(len([]rune(g.watermarkText))) * fontSize / 3.0 } + rotatedDiagonal = math.Sqrt(textWidth*textWidth + fontSize*fontSize) } - // 从左边开始绘制水印文字 + startX := leftMargin + startY := pageHeight - bottomMargin + diagW := rotatedDiagonal * math.Cos(45*math.Pi/180) + offsetX := (usableWidth - diagW) * 0.5 + startX += offsetX + startY -= rotatedDiagonal * 0.5 + + pdf.TransformTranslate(startX, startY) + pdf.TransformRotate(45, 0, 0) + pdf.SetXY(0, 0) pdf.CellFormat(textWidth, fontSize, g.watermarkText, "", 0, "L", false, 0, "") - // 恢复透明度和颜色 pdf.SetAlpha(1.0, "Normal") - pdf.SetTextColor(0, 0, 0) // 恢复为黑色 + pdf.SetTextColor(0, 0, 0) } diff --git a/internal/shared/pdf/pdf_generator_refactored.go b/internal/shared/pdf/pdf_generator_refactored.go index c82a0ff..555e658 100644 --- a/internal/shared/pdf/pdf_generator_refactored.go +++ b/internal/shared/pdf/pdf_generator_refactored.go @@ -151,20 +151,25 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent // 创建PDF文档 (A4大小,gofpdf v2 默认支持UTF-8) pdf := gofpdf.New("P", "mm", "A4", "") - // 优化边距,减少空白 - pdf.SetMargins(15, 25, 15) + // 上边距与 ContentStartYBelowHeader 一致,这样自动分页后新页内容从 logo 下方开始,不被遮挡 + pdf.SetMargins(15, ContentStartYBelowHeader, 15) + // 开启自动分页并预留底边距,避免内容贴底;分页后由 SetHeaderFunc 绘制页眉,正文从 ContentStartYBelowHeader 起排 + pdf.SetAutoPageBreak(true, 18) // 加载黑体字体(用于所有内容,除了水印) // 注意:此时工作目录应该是根目录(/),这样gofpdf处理路径时就能正确解析 chineseFontAvailable := g.fontManager.LoadChineseFont(pdf) - // 加载水印字体(使用宋体或其他非黑体字体) + // 加载水印字体 watermarkFontAvailable := g.fontManager.LoadWatermarkFont(pdf) + // 加载正文宋体(描述、详情、说明、表格文字等使用小四 12pt) + bodyFontAvailable := g.fontManager.LoadBodyFont(pdf) // 记录字体加载状态,便于诊断问题 g.logger.Info("PDF字体加载状态", zap.Bool("chinese_font_loaded", chineseFontAvailable), zap.Bool("watermark_font_loaded", watermarkFontAvailable), + zap.Bool("body_font_loaded", bodyFontAvailable), zap.String("watermark_text", g.watermarkText), ) @@ -176,9 +181,22 @@ func (g *PDFGeneratorRefactored) generatePDF(product *entities.Product, doc *ent // 创建页面构建器 pageBuilder := NewPageBuilder(g.logger, g.fontManager, g.textProcessor, g.markdownProc, g.tableParser, g.tableRenderer, g.jsonProcessor, g.logoPath, g.watermarkText) - // 添加第一页(产品信息) + // 页眉只绘制 logo 和横线;水印改到页脚绘制,确保水印在最上层不被表格等内容遮挡 + pdf.SetHeaderFunc(func() { + pageBuilder.addHeader(pdf, chineseFontAvailable) + }) + pdf.SetFooterFunc(func() { + pageBuilder.addWatermark(pdf, chineseFontAvailable) + }) + + // 添加第一页(封面:产品信息 + 产品描述 + 价格) pageBuilder.AddFirstPage(pdf, product, doc, chineseFontAvailable) + // 产品详情单独一页(左对齐,段前两空格) + if product.Content != "" { + pageBuilder.AddProductContentPage(pdf, product, chineseFontAvailable) + } + // 如果是组合包,需要特殊处理:先渲染所有文档,最后统一添加二维码 if product.IsPackage { // 如果有关联的文档,添加接口文档页面(但不包含二维码和说明,后面统一添加) diff --git a/internal/shared/pdf/text_processor.go b/internal/shared/pdf/text_processor.go index 47a784f..93746e7 100644 --- a/internal/shared/pdf/text_processor.go +++ b/internal/shared/pdf/text_processor.go @@ -90,6 +90,158 @@ func (tp *TextProcessor) StripHTML(text string) string { return text } +// HTMLToPlainWithBreaks 将 HTML 转为纯文本并保留富文本换行效果(


等变为换行) +// 用于在 PDF 中还原段落与换行,避免内容挤成一团 +func (tp *TextProcessor) HTMLToPlainWithBreaks(text string) string { + text = html.UnescapeString(text) + // 块级结束标签转为换行 + text = regexp.MustCompile(`(?i)\s*`).ReplaceAllString(text, "\n") + //
自闭合 + text = regexp.MustCompile(`(?i)\s*`).ReplaceAllString(text, "\n") + // 剩余标签移除 + text = regexp.MustCompile(`<[^>]+>`).ReplaceAllString(text, "") + // 连续空白/换行压缩为最多两个换行(段间距) + text = regexp.MustCompile(`[ \t]+`).ReplaceAllString(text, " ") + text = regexp.MustCompile(`\n{3,}`).ReplaceAllString(text, "\n\n") + return strings.TrimSpace(text) +} + +// HTMLSegment 用于 PDF 绘制的 HTML 片段:支持段落、换行、加粗、标题 +type HTMLSegment struct { + Text string // 纯文本(已去标签、已解码实体) + Bold bool // 是否加粗 + NewLine bool // 是否换行(如
) + NewParagraph bool // 是否新段落(如

) + HeadingLevel int // 1-3 表示 h1-h3,0 表示正文 +} + +// ParseHTMLToSegments 将 HTML 解析为用于 PDF 绘制的片段序列,保留段落、换行、加粗与标题 +func (tp *TextProcessor) ParseHTMLToSegments(htmlStr string) []HTMLSegment { + htmlStr = html.UnescapeString(htmlStr) + var out []HTMLSegment + blockSplit := regexp.MustCompile(`(?i)(

|||)\s*`) + parts := blockSplit.Split(htmlStr, -1) + tags := blockSplit.FindAllString(htmlStr, -1) + for i, block := range parts { + block = strings.TrimSpace(block) + var prevTag string + if i > 0 && i-1 < len(tags) { + prevTag = strings.ToLower(strings.TrimSpace(tags[i-1])) + } + isNewParagraph := strings.Contains(prevTag, "

") || strings.Contains(prevTag, "") || + strings.HasPrefix(prevTag, " 0 { + if isNewParagraph || headingLevel > 0 { + out = append(out, HTMLSegment{NewParagraph: true, HeadingLevel: headingLevel}) + } else if isNewLine { + out = append(out, HTMLSegment{NewLine: true}) + } + } + for _, seg := range segments { + if seg.Text != "" { + out = append(out, HTMLSegment{Text: seg.Text, Bold: seg.Bold, HeadingLevel: headingLevel}) + } + } + } + return out +} + +// inlineSeg 内联片段(文本 + 是否加粗) +type inlineSeg struct { + Text string + Bold bool +} + +// parseInlineSegments 解析块内文本,按 / 拆成片段 +func (tp *TextProcessor) parseInlineSegments(block string) []inlineSeg { + var segs []inlineSeg + // 移除所有标签并收集加粗区间(按字符偏移) + reBoldOpen := regexp.MustCompile(`(?i)<(strong|b)>`) + reBoldClose := regexp.MustCompile(`(?i)`) + plain := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(block, "") + plain = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plain, " ") + plain = strings.TrimSpace(plain) + if plain == "" { + return segs + } + // 在 block 上找加粗区间,再映射到 plain(去掉标签后的位置) + // 注意:work 每次循环被截断,必须用相对 work 的索引切片,避免 work[:endInWork] 越界 + work := block + var boldRanges [][2]int + plainOffset := 0 + for { + idxOpen := reBoldOpen.FindStringIndex(work) + if idxOpen == nil { + break + } + afterOpen := work[idxOpen[1]:] + idxClose := reBoldClose.FindStringIndex(afterOpen) + if idxClose == nil { + break + } + closeLen := len(reBoldClose.FindString(afterOpen)) + // 使用相对当前 work 的字节偏移,保证 work[:endInWork] 不越界 + endInWork := idxOpen[1] + idxClose[0] + workBefore := work[:idxOpen[1]] + plainBefore := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(workBefore, "") + plainBefore = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plainBefore, " ") + startPlain := plainOffset + len([]rune(plainBefore)) + workUntil := work[:endInWork] + plainUntil := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(workUntil, "") + plainUntil = regexp.MustCompile(`[ \t]+`).ReplaceAllString(plainUntil, " ") + endPlain := plainOffset + len([]rune(plainUntil)) + boldRanges = append(boldRanges, [2]int{startPlain, endPlain}) + consumed := work[:endInWork+closeLen] + strippedConsumed := regexp.MustCompile(`<[^>]+>`).ReplaceAllString(consumed, "") + strippedConsumed = regexp.MustCompile(`[ \t]+`).ReplaceAllString(strippedConsumed, " ") + plainOffset += len([]rune(strippedConsumed)) + work = work[endInWork+closeLen:] + } + // 按 boldRanges 切分 plain(限制区间在 [0,len(runes)] 内,防止越界) + runes := []rune(plain) + nr := len(runes) + inBold := false + var start int + for i := 0; i <= nr; i++ { + nowBold := false + for _, r := range boldRanges { + r0, r1 := r[0], r[1] + if r0 < 0 { + r0 = 0 + } + if r1 > nr { + r1 = nr + } + if r0 < r1 && i >= r0 && i < r1 { + nowBold = true + break + } + } + if nowBold != inBold || i == nr { + if i > start { + segs = append(segs, inlineSeg{Text: string(runes[start:i]), Bold: inBold}) + } + start = i + inBold = nowBold + } + } + if len(segs) == 0 && plain != "" { + segs = append(segs, inlineSeg{Text: plain, Bold: false}) + } + return segs +} + // RemoveMarkdownSyntax 移除markdown语法,保留纯文本 func (tp *TextProcessor) RemoveMarkdownSyntax(text string) string { // 移除粗体标记 **text** 或 __text__ diff --git a/internal/shared/validator/custom_validators.go b/internal/shared/validator/custom_validators.go index 0f07416..ed1a8e5 100644 --- a/internal/shared/validator/custom_validators.go +++ b/internal/shared/validator/custom_validators.go @@ -637,8 +637,8 @@ func validateEnterpriseName(fl validator.FieldLevel) bool { hasValidSuffix = true break } - // 同时检查括号内的企业类型,如:(个体工商户)、(分公司) - if strings.HasSuffix(trimmedName, "("+suffix+")") { + // 同时检查括号内的企业类型,支持中英文括号,如:(个体工商户)、(个体工商户)、(分公司)、(分公司) + if strings.HasSuffix(trimmedName, "("+suffix+")") || strings.HasSuffix(trimmedName, "("+suffix+")") { hasValidSuffix = true break } @@ -921,8 +921,8 @@ func ValidateEnterpriseName(enterpriseName string) error { hasValidSuffix = true break } - // 同时检查括号内的企业类型,如:(个体工商户)、(分公司) - if strings.HasSuffix(trimmedName, "("+suffix+")") { + // 同时检查括号内的企业类型,支持中英文括号,如:(个体工商户)、(个体工商户)、(分公司)、(分公司) + if strings.HasSuffix(trimmedName, "("+suffix+")") || strings.HasSuffix(trimmedName, "("+suffix+")") { hasValidSuffix = true break } diff --git a/resources/pdf/fonts/WenYuanSerifSC-Bold.ttf b/resources/pdf/fonts/WenYuanSerifSC-Bold.ttf new file mode 100644 index 0000000..453fb9e Binary files /dev/null and b/resources/pdf/fonts/WenYuanSerifSC-Bold.ttf differ diff --git a/resources/pdf/后勤服务.txt b/resources/pdf/后勤服务.txt index d887fa7..c72c1dc 100644 --- a/resources/pdf/后勤服务.txt +++ b/resources/pdf/后勤服务.txt @@ -11,7 +11,7 @@ 获得更多详情请访问 [https://www.tianyuanapi.com/] 2. 提供正式生产环境公网 IP -在与商务团队沟通并了解清楚接入流程后,请您将正式生产环境的公网 IP 提供给天远数据。我们将依据您提供的公网 IP 进行 IP 访问设置,以确保后续接口调用的顺利进行。 +在与商务团队沟通并了解清楚接入流程后,请您将正式生产环境的公网 IP 提供给天远数据。可以自行在开发者中心添加白名单IP,我们将依据您提供的公网 IP 进行 IP 访问设置,以确保后续接口调用的顺利进行。 3. 构造并加密请求报文 您需要构造 JSON 明文请求报文,然后使用 AES-128 算法(基于账户获得的16进制字符串密钥/Access Key)对该明文请求报文进行加密处理。加密时采用AES-CBC模式(密钥长度128位/16字节,填充方式PKCS7),每次加密随机生成16字节(128位)的IV(初始化向量),将IV与加密后的密文拼接在一起,最后通过Base64编码形成可传输的字符串,并将该Base64字符串放入请求体的data字段传参。此步骤中涉及的代码部分,您可参考我们提供的demo包,里面有详细的示例和说明,能帮助您顺利完成报文的构造、加密及Base64编码操作。 @@ -23,5 +23,5 @@ 当您获得接口返回的结果后,需要先对Base64解码后的数据提取前16字节作为IV,再使用该IV通过AES-CBC模式解密剩余密文,最后去除PKCS7填充得到原始明文。同样,关于Base64解码及AES解密(含IV提取、填充去除)的代码实现,您可参考test包中的相关内容,以顺利完成返回结果的解密操作。 -若您在接入过程中有任何疑问或需要进一步的帮助,请随时与我们联系。您可以通过上述的商务邮箱和商务联系电话与我们的团队沟通,我们将竭诚为您服务。 +为方便开发者,我们在开发者中心中,在线测试页面右上角提供了不同语言的示例代码框架,您可以直接复制示例代码去调用相关的接口去进行调试,若您在接入过程中有任何疑问或需要进一步的帮助,请随时与我们联系。您可以通过上述的商务邮箱和商务联系电话与我们的团队沟通,我们将竭诚为您服务。 diff --git a/storage/component-reports/8479b19c-4865-4382-859c-bcca21a5ccb5_example.json.zip b/storage/component-reports/8479b19c-4865-4382-859c-bcca21a5ccb5_example.json.zip deleted file mode 100644 index 74c369b..0000000 Binary files a/storage/component-reports/8479b19c-4865-4382-859c-bcca21a5ccb5_example.json.zip and /dev/null differ diff --git a/storage/component-reports/cache/e57bf41ff9ed4db7082094e4bfa4afba.json b/storage/component-reports/cache/e57bf41ff9ed4db7082094e4bfa4afba.json deleted file mode 100644 index 4921873..0000000 --- a/storage/component-reports/cache/e57bf41ff9ed4db7082094e4bfa4afba.json +++ /dev/null @@ -1,811 +0,0 @@ -[ - { - "feature": { - "featureName": "人企关系加强版", - "sort": 1 - }, - "data": { - "apiID": "QYGL3F8E", - "data": { - "items": [ - { - "abnormal_info": {}, - "basicInfo": { - "apprdate": "2025-11-27", - "base": "han", - "candate": "", - "city": "xxxxx", - "companyOrgType": "有限责任公司(自然人投资或控股)", - "creditCode": "xxxxx", - "district": "秀xxx区", - "estiblishTime": "2024-06-20", - "his_staffList": { - "result": [ - { - "name": "xxxx", - "type": "2", - "typeJoin": [ - "执行董事兼总经理" - ] - } - ] - }, - "industry": "xxxxxx", - "industry_code": "L", - "legalPersonName": "xxxx", - "name": "xxxxx有限公司", - "nic_code": "L7281", - "nic_name": "租赁和商务服务业-商务服务业-会议、展览及相关服务-科技会展服务", - "opscope": "许可经营项目:在线数据处理与交易处理业务(经营类电子商务);互联网游戏服务;第二类增值电信业务;互联网信息服务(许可经营项目凭许可证件经营)一般经营项目:品牌管理;5G通信技术服务;人工智能应用软件开发;互联网安全服务;量子计算技术服务;技术服务、技术开发、技术咨询、技术交流、技术转让、技术推广;网络技术服务;专业设计服务;互联网数据服务;互联网销售(除销售需要许可的商品);食品互联网销售(仅销售预包装食品);软件开发;动漫游戏开发;计算机软硬件及辅助设备零售;计算机软硬件及辅助设备批发;计算器设备销售;机械设备销售;五金产品零售;五金产品批发;电子产品销售;人工智能硬件销售;通信设备销售;光通信设备销售;通信设备制造;信息系统集成服务;图文设计制作;广告设计、代理;广告发布;数字内容制作服务(不含出版发行);数字文化创意软件开发;软件销售;市场营销策划;企业管理咨询;信息咨询服务(不含许可类信息咨询服务);市场调查(不含涉外调查);工业设计服务;玩具销售;化妆品零售;化妆品批发;摄像及视频制作服务;平面设计;法律咨询(不含依法须律师事务所执业许可的业务);旅游开发项目策划咨询;体育用品及器材批发;体育用品及器材零售;户外用品销售;体育赛事策划;体育健康服务;组织体育表演活动;体育中介代理服务;信息技术咨询服务;数据处理服务;数据处理和存储支持服务;大数据服务;云计算装备技术服务;电子、机械设备维护(不含特种设备);智能机器人的研发;货物进出口;技术进出口;食品进出口(经营范围中的一般经营项目依法自主开展经营活动,通过国家企业信用信息公示系统(海南)向社会公示)", - "province": "xxx省", - "reccap": 0, - "reccapcur": "人民币", - "regCapital": "100.000000万人民币", - "regCapitalCurrency": "人民币", - "regNumber": "4601xxxxx1916", - "regStatus": "存续(在营、开业、在册)", - "regorg": "xxxxxx督管理局", - "revdate": "", - "type": "1" - }, - "financing_history": {}, - "fsource": "1", - "his_stockHolderItem": { - "investDate": "2025-11-27", - "investRate": "", - "orgHolderName": "xxx", - "orgHolderType": "xxxx人", - "subscriptAmt": "" - }, - "invest_history": {}, - "lawsuit_info": { - "entout": { - "msg": "没有找到" - }, - "sxbzxr": { - "msg": "没有找到" - }, - "xgbzxr": { - "msg": "没有找到" - } - }, - "orgName": "海xxxx限公司", - "own_tax": {}, - "pName": "xxx", - "punishment_info": {}, - "relationship": [ - "his_sh", - "his_tm" - ], - "tax_contravention": {} - } - ], - "total": 1 - } - } - }, - { - "feature": { - "featureName": "全景雷达", - "sort": 2 - }, - "data": { - "apiID": "JRZQ7F1A", - "data": { - "apply_report_detail": { - "A22160001": "503", - "A22160002": "76", - "A22160003": "38", - "A22160004": "11", - "A22160005": "13", - "A22160006": "90", - "A22160007": "2023-05", - "A22160008": "15", - "A22160009": "33", - "A22160010": "48" - }, - "behavior_report_detail": { - "B22170001": "547", - "B22170002": "1", - "B22170003": "3", - "B22170004": "7", - "B22170005": "20", - "B22170006": "32", - "B22170007": "(0,500)", - "B22170008": "[2000,3000)", - "B22170009": "[10000,20000)", - "B22170010": "[30000,50000)", - "B22170011": "[50000,+)", - "B22170012": "11", - "B22170013": "7", - "B22170014": "2", - "B22170015": "0", - "B22170016": "1", - "B22170017": "2", - "B22170018": "3", - "B22170019": "6", - "B22170020": "7", - "B22170021": "6", - "B22170022": "7", - "B22170023": "0", - "B22170024": "0", - "B22170025": "4", - "B22170026": "8", - "B22170027": "9", - "B22170028": "4", - "B22170029": "5", - "B22170030": "5", - "B22170031": "[3000,5000)", - "B22170032": "[5000,10000)", - "B22170033": "[5000,10000)", - "B22170034": "70%", - "B22170035": "4", - "B22170036": "14", - "B22170037": "21", - "B22170038": "35", - "B22170039": "102", - "B22170040": "(0,500)", - "B22170041": "[500,1000)", - "B22170042": "[500,1000)", - "B22170043": "[10000,20000)", - "B22170044": "[30000,50000)", - "B22170045": "4", - "B22170046": "6", - "B22170047": "8", - "B22170048": "35", - "B22170049": "168", - "B22170050": "(7,15]", - "B22170051": "82", - "B22170052": "24", - "B22170053": "720", - "B22170054": "2023-04" - }, - "current_report_detail": { - "C22180001": "0", - "C22180002": "0", - "C22180003": "0", - "C22180004": "0", - "C22180005": "0", - "C22180006": "0", - "C22180007": "5", - "C22180008": "9", - "C22180009": "12600", - "C22180010": "6120", - "C22180011": "10600", - "C22180012": "80" - } - } - } - }, - { - "feature": { - "featureName": "特殊名单验证B", - "sort": 3 - }, - "data": { - "apiID": "JRZQ8A2D", - "data": { - "cell": { - "nbank_bad": "0", - "nbank_bad_allnum": "1", - "nbank_bad_time": "2", - "nbank_finlea_lost": "0", - "nbank_finlea_lost_allnum": "4", - "nbank_finlea_lost_time": "5", - "nbank_lost": "0", - "nbank_lost_allnum": "4", - "nbank_lost_time": "5", - "nbank_other_bad": "0", - "nbank_other_bad_allnum": "1", - "nbank_other_bad_time": "2" - }, - "id": { - "court_executed": "0", - "court_executed_allnum": "5", - "court_executed_time": "2" - } - } - } - }, - { - "feature": { - "featureName": "个人司法涉诉查询", - "sort": 4 - }, - "data": { - "apiID": "FLXG7E8F", - "data": { - "judicial_data": { - "breachCaseList": [ - { - "caseNumber": "(2021)京0113执****号", - "concreteDetails": "有履⾏能⼒⽽拒不履⾏⽣效法律⽂书确定义务", - "enforcementBasisNumber": "(2020)京0113⺠初9****", - "enforcementBasisOrganization": "京海市顺义区⼈⺠法院", - "estimatedJudgementAmount": 109455, - "executiveCourt": "京海市顺义区⼈⺠法院", - "fileDate": "2021-02-23", - "fulfillStatus": "全部未履⾏", - "id": "f343e0d314e840d93684fa9a90f144cc", - "issueDate": "2021-02-23", - "obligation": "被告靳帅偿还原告王丹霞借元,于本判决⽣效之⽇起七⽇内执⾏。", - "province": "北京", - "sex": "男性" - }, - { - "caseNumber": "(2022)京0113执5190号", - "concreteDetails": "有履⾏能⼒⽽拒不履⾏⽣效法律⽂书确定义务", - "enforcementBasisNumber": "(2022)京0113刑初****", - "enforcementBasisOrganization": "京海市顺义区⼈⺠法院", - "estimatedJudgementAmount": 18110, - "executiveCourt": "京海市顺义区⼈⺠法院", - "fileDate": "2022-07-12", - "fulfillStatus": "全部未履⾏", - "id": "6cc2453f4e8cccf3ecd441ae08dd2183", - "issueDate": "2022-07-12", - "obligation": "⼀、被告⼈靳帅犯盗窃罪,判处有期徒刑⼀年三个⽉,并处罚⾦⼈⺠币五千元(刑期从判决执⾏之⽇起计算,判决执⾏以前先⾏羁押的,羁押⼀⽇折抵刑期⼀⽇,即⾃2022年3⽉6⽇起⾄2023年6⽉5⽇⽌。罚⾦于判决⽣效之⽇起五⽇内缴纳)。\\n⼆、责令被告⼈靳帅退赔被害⼈孙学⺠的经济损失⼈⺠币⼗元,退赔被害⼈张树起的经济损失⼈⺠币五百元,退赔被害⼈冯⽂⻰的经济损失⼈⺠币⼀万⼆千六百元。", - "province": "北京", - "sex": "男性" - } - ], - "consumptionRestrictionList": [ - { - "caseNumber": "(2023)京0113执*****号", - "executiveCourt": "京海市顺义区⼈⺠法院", - "id": "0b733c6c503f740663422e44bc434a66", - "issueDate": "2023-11-20" - }, - { - "caseNumber": "(2021)京0113执****号", - "executiveCourt": "京海市顺义区⼈⺠法院", - "id": "3fa335ec744dfb1d720f996e9d2b6e12", - "issueDate": "2021-08-10" - }, - { - "caseNumber": "(2022)京0113执****号", - "executiveCourt": "京海市顺义区⼈⺠法院", - "id": "84fa0cf34f947eb0afd2f54ebe589e35", - "issueDate": "2022-07-24" - }, - { - "caseNumber": "(2021)京0113执****号", - "executiveCourt": "京海市顺义区⼈⺠法院", - "id": "2e53a8808313ba87f15bf30ae76cd2d6", - "issueDate": "2021-11-19" - } - ], - "lawsuitStat": { - "administrative": {}, - "bankrupt": {}, - "cases_tree": { - "civil": [ - { - "c_ah": "2013年⻄⺠初字第*****号", - "case_type": 300, - "n_ajbs": "257ebb5c348de00883c872d636cf3128", - "stage_type": 1 - }, - { - "c_ah": "2013年⻄⺠初字第0***号", - "case_type": 300, - "n_ajbs": "b6d8144d729f7811f4ea7838ef69baa7", - "stage_type": 1 - }, - { - "c_ah": "(2020)京0113⺠初****号", - "case_type": 300, - "n_ajbs": "5a0867d91ce580d1239e1f2063912584", - "next": { - "c_ah": "(2021)京0113执****号", - "case_type": 1000, - "n_ajbs": "54e45b851f5baedc7d249ab755e39fbe", - "stage_type": 5 - }, - "stage_type": 1 - } - ], - "criminal": [ - { - "c_ah": "2009年顺刑初字第*****号", - "case_type": 200, - "n_ajbs": "e084cc09e364a6c2c02f82bd49a3bcfd", - "stage_type": 1 - }, - { - "c_ah": "(2021)京0113刑初****号", - "case_type": 200, - "n_ajbs": "08c9087760d19e4e46ea0a5e1ff8907f", - "next": { - "c_ah": "(2021)京0113执****号", - "case_type": 1000, - "n_ajbs": "3e8392c51bbc1b7fb8e050284c89d220", - "stage_type": 5 - }, - "stage_type": 1 - }, - { - "c_ah": "(2022)京0113刑初****号", - "case_type": 200, - "n_ajbs": "1da42d08e89cf1907b0ab30239437060", - "next": { - "c_ah": "(2022)京0113执****号", - "case_type": 1000, - "n_ajbs": "c345a052409a2c0ebaecd6cee45b8050", - "stage_type": 5 - }, - "stage_type": 1 - }, - { - "c_ah": "(2023)京0113刑****号", - "case_type": 200, - "n_ajbs": "91b1aa92abba978b9bb583de92445045", - "next": { - "c_ah": "(2023)京0113执1****号", - "case_type": 1000, - "n_ajbs": "8dda746bb87c72f76d49a2cacee0efa0", - "stage_type": 5 - }, - "stage_type": 1 - } - ] - }, - "civil": { - "cases": [ - { - "c_ah": "2013年⻄⺠初字第******号", - "c_dsrxx": [ - { - "c_mc": "中国建设银⾏股份有限公司京海市分⾏", - "n_dsrlx": "企业组织", - "n_ssdw": "原告" - }, - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被告" - } - ], - "c_id": "ea3566e092ef9bf659659b2c07855d3e", - "c_slfsxx": "-1,,,;1,2013-06-19 09:00:00,1,1", - "c_ssdy": "京海市", - "d_jarq": "2013-06-18", - "d_larq": "2013-03-08", - "n_ajbs": "b6d8144d729f7811f4ea7838ef69baa7", - "n_ajjzjd": "已结案", - "n_ajlx": "⺠事⼀审", - "n_crc": 451683830, - "n_jaay": "合同、准合同纠纷", - "n_jaay_tree": "合同、准合同纠纷,合同纠纷,银⾏卡纠纷,信⽤卡纠纷", - "n_jabdje": "3304.16", - "n_jabdje_level": 1, - "n_jafs": "准予撤诉", - "n_jbfy": "京海市⻄城区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "合同、准合同纠纷", - "n_laay_tree": "合同、准合同纠纷,合同纠纷,银⾏卡纠纷,信⽤卡纠纷", - "n_pj_victory": "未知", - "n_qsbdje": "3304.16", - "n_qsbdje_level": 1, - "n_slcx": "⼀审", - "n_ssdw": "被告", - "n_ssdw_ys": "被告" - }, - { - "c_ah": "(2020)京0113⺠初*****号", - "c_dsrxx": [ - { - "c_mc": "王丹霞", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "原告" - }, - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被告" - } - ], - "c_id": "df1fd042f1545a51c6460d0cb4005140", - "c_slfsxx": "1,2020-11-25 11:10:17,诉调第⼗⼀法庭,1", - "c_ssdy": "京海市", - "d_jarq": "2020-12-04", - "d_larq": "2020-07-29", - "n_ajbs": "5a0867d91ce580d1239e1f2063912584", - "n_ajjzjd": "已结案", - "n_ajlx": "⺠事⼀审", - "n_crc": 4035395111, - "n_jaay": "合同、准合同纠纷", - "n_jaay_tag": "合同纠纷", - "n_jaay_tree": "合同、准合同纠纷,合同纠纷,借款合同纠纷,⺠间借贷纠纷", - "n_jabdje": 109455, - "n_jabdje_level": 11, - "n_jafs": "判决", - "n_jbfy": "京海市顺义区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "合同、准合同纠纷", - "n_laay_tag": "合同纠纷", - "n_laay_tree": "合同、准合同纠纷,合同纠纷,借款合同纠纷,⺠间借贷纠纷", - "n_pj_victory": "未知", - "n_qsbdje": 110000, - "n_qsbdje_level": 11, - "n_slcx": "⼀审", - "n_ssdw": "被告", - "n_ssdw_ys": "被告" - } - ], - "count": { - "area_stat": "京海市(3)", - "ay_stat": "合同、准合同纠纷(3)", - "count_beigao": 3, - "count_jie_beigao": 3, - "count_jie_other": 0, - "count_jie_total": 3, - "count_jie_yuangao": 0, - "count_other": 0, - "count_total": 3, - "count_wei_beigao": 0, - "count_wei_other": 0, - "count_wei_total": 0, - "count_wei_yuangao": 0, - "count_yuangao": 0, - "jafs_stat": "准予撤诉(2),判决(1)", - "larq_stat": "2013(2),2020(1)", - "money_beigao": 11, - "money_jie_beigao": 11, - "money_jie_other": 0, - "money_jie_total": 11, - "money_jie_yuangao": 0, - "money_other": 0, - "money_total": 11, - "money_wei_beigao": 0, - "money_wei_other": 0, - "money_wei_percent": 0, - "money_wei_total": 0, - "money_wei_yuangao": 0, - "money_yuangao": 0 - } - }, - "count": { - "area_stat": "京海市(11)", - "ay_stat": "侵犯财产罪(4),刑事(3),合同、准合同纠纷(3),未知(1)", - "count_beigao": 11, - "count_jie_beigao": 11, - "count_jie_other": 0, - "count_jie_total": 11, - "count_jie_yuangao": 0, - "count_other": 0, - "count_total": 11, - "count_wei_beigao": 0, - "count_wei_other": 0, - "count_wei_total": 0, - "count_wei_yuangao": 0, - "count_yuangao": 0, - "jafs_stat": "判决(5),终结本次执⾏程序(4),准予撤诉(2)", - "larq_stat": "2009(1),2013(2),2020(1),2021(3),2022(2),2023(2)", - "money_beigao": 11, - "money_jie_beigao": 11, - "money_jie_other": 0, - "money_jie_total": 11, - "money_jie_yuangao": 0, - "money_other": 0, - "money_total": 11, - "money_wei_beigao": 0, - "money_wei_other": 0, - "money_wei_percent": 0, - "money_wei_total": 0, - "money_wei_yuangao": 0, - "money_yuangao": 0 - }, - "crc": 3714068012, - "criminal": { - "cases": [ - { - "c_ah": "(2021)京0113刑初*****号", - "c_dsrxx": [ - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被告⼈" - } - ], - "c_id": "44bc6ccd90fada8e585e27f86700696c", - "c_slfsxx": "1,2021-09-23 09:48:23,第三⼗七法庭,1", - "c_ssdy": "京海市", - "d_jarq": "2021-09-23", - "d_larq": "2021-09-16", - "n_ajbs": "08c9087760d19e4e46ea0a5e1ff8907f", - "n_ajjzjd": "已结案", - "n_ajlx": "刑事⼀审", - "n_bqqpcje_level": 0, - "n_ccxzxje_level": 0, - "n_crc": 3782814141, - "n_dzzm": "侵犯财产罪", - "n_dzzm_tree": "侵犯财产罪,盗窃罪", - "n_fzje_level": 0, - "n_jaay": "侵犯财产罪", - "n_jaay_tree": "侵犯财产罪,盗窃罪", - "n_jafs": "判决", - "n_jbfy": "京海市顺义区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "侵犯财产罪", - "n_laay_tree": "侵犯财产罪,盗窃罪", - "n_pcjg": "给予刑事处罚", - "n_pcpcje_level": 0, - "n_slcx": "⼀审", - "n_ssdw": "被告⼈", - "n_ssdw_ys": "被告⼈" - }, - { - "c_ah": "(2022)京0113刑初****号", - "c_dsrxx": [ - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被告⼈" - } - ], - "c_id": "8851b2565cd27bc09a00a8ecd82b3224", - "c_slfsxx": "1,2022-06-08 09:38:41,第四⼗法庭,1", - "c_ssdy": "京海市", - "d_jarq": "2022-06-17", - "d_larq": "2022-06-02", - "n_ajbs": "1da42d08e89cf1907b0ab30239437060", - "n_ajjzjd": "已结案", - "n_ajlx": "刑事⼀审", - "n_bqqpcje_level": 0, - "n_ccxzxje_level": 0, - "n_crc": 168162812, - "n_dzzm": "侵犯财产罪", - "n_dzzm_tree": "侵犯财产罪,盗窃罪", - "n_fzje_level": 0, - "n_jaay": "侵犯财产罪", - "n_jaay_tree": "侵犯财产罪,盗窃罪", - "n_jafs": "判决", - "n_jbfy": "京海市顺义区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "侵犯财产罪", - "n_laay_tree": "侵犯财产罪,盗窃罪", - "n_pcjg": "给予刑事处罚", - "n_pcpcje_level": 0, - "n_slcx": "⼀审", - "n_ssdw": "被告⼈", - "n_ssdw_ys": "被告⼈" - }, - { - "c_ah": "(2023)京0113刑****号", - "c_dsrxx": [ - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被告⼈" - } - ], - "c_id": "82c3a2095c4ee2102fe156fc6cd5c77c", - "c_slfsxx": "1,2023-10-27 09:19:41,第三⼗七法庭,1", - "c_ssdy": "京海市", - "d_jarq": "2023-10-27", - "d_larq": "2023-10-11", - "n_ajbs": "91b1aa92abba978b9bb583de92445045", - "n_ajjzjd": "已结案", - "n_ajlx": "刑事⼀审", - "n_bqqpcje_level": 0, - "n_ccxzxje_level": 0, - "n_crc": 659651411, - "n_dzzm": "侵犯财产罪", - "n_dzzm_tree": "侵犯财产罪,盗窃罪", - "n_fzje_level": 0, - "n_jaay": "侵犯财产罪", - "n_jaay_tree": "侵犯财产罪,盗窃罪", - "n_jafs": "判决", - "n_jbfy": "京海市顺义区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "侵犯财产罪", - "n_laay_tree": "侵犯财产罪,盗窃罪", - "n_pcjg": "给予刑事处罚", - "n_pcpcje_level": 0, - "n_slcx": "⼀审", - "n_ssdw": "被告⼈", - "n_ssdw_ys": "被告⼈" - } - ], - "count": { - "area_stat": "京海市(4)", - "ay_stat": "侵犯财产罪(4)", - "count_beigao": 4, - "count_jie_beigao": 4, - "count_jie_other": 0, - "count_jie_total": 4, - "count_jie_yuangao": 0, - "count_other": 0, - "count_total": 4, - "count_wei_beigao": 0, - "count_wei_other": 0, - "count_wei_total": 0, - "count_wei_yuangao": 0, - "count_yuangao": 0, - "jafs_stat": "判决(4)", - "larq_stat": "2009(1),2021(1),2022(1),2023(1)", - "money_beigao": 0, - "money_jie_beigao": 0, - "money_jie_other": 0, - "money_jie_total": 0, - "money_jie_yuangao": 0, - "money_other": 0, - "money_total": 0, - "money_wei_beigao": 0, - "money_wei_other": 0, - "money_wei_total": 0, - "money_wei_yuangao": 0, - "money_yuangao": 0 - } - }, - "implement": { - "cases": [ - { - "c_ah": "(2021)京0113执2***", - "c_dsrxx": [ - { - "c_mc": "王丹霞", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "申请执⾏⼈" - }, - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被执⾏⼈" - } - ], - "c_gkws_glah": "(2020)京0113⺠初***号", - "c_id": "4904d5bf89ca75a79bf7401727080c03", - "c_ssdy": "京海市", - "d_jarq": "2021-08-19", - "d_larq": "2021-02-23", - "n_ajbs": "54e45b851f5baedc7d249ab755e39fbe", - "n_ajjzjd": "已结案", - "n_ajlx": "⾸次执⾏", - "n_crc": 2505253178, - "n_jaay": "⺠事", - "n_jafs": "终结本次执⾏程序", - "n_jbfy": "京海市顺义区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "⺠事", - "n_sjdwje": 0, - "n_sqzxbdje": 111260, - "n_ssdw": "被执⾏⼈" - }, - { - "c_ah": "(2021)京0113执***号", - "c_dsrxx": [ - { - "c_mc": "京海市顺义区⼈⺠法院", - "n_dsrlx": "企业组织", - "n_ssdw": "申请执⾏⼈" - }, - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被执⾏⼈" - } - ], - "c_id": "435d6483338571526e6ebb0308dc6d04", - "c_ssdy": "京海市", - "d_jarq": "2021-11-25", - "d_larq": "2021-10-26", - "n_ajbs": "3e8392c51bbc1b7fb8e050284c89d220", - "n_ajjzjd": "已结案", - "n_ajlx": "⾸次执⾏", - "n_crc": 1948524411, - "n_jaay": "刑事", - "n_jabdje": 3876, - "n_jafs": "终结本次执⾏程序", - "n_jbfy": "京海市顺义区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "刑事", - "n_sjdwje": 0, - "n_sqzxbdje": 3876, - "n_ssdw": "被执⾏⼈" - }, - { - "c_ah": "(2022)京0113执****号", - "c_dsrxx": [ - { - "c_mc": "京海市顺义区⼈⺠法院", - "n_dsrlx": "企业组织", - "n_ssdw": "申请执⾏⼈" - }, - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被执⾏⼈" - } - ], - "c_id": "4683b25207c45768ed9bcead28b51036", - "c_ssdy": "京海市", - "d_jarq": "2022-08-04", - "d_larq": "2022-07-12", - "n_ajbs": "c345a052409a2c0ebaecd6cee45b8050", - "n_ajjzjd": "已结案", - "n_ajlx": "⾸次执⾏", - "n_crc": 3747572709, - "n_jaay": "刑事", - "n_jabdje": 18110, - "n_jafs": "终结本次执⾏程序", - "n_jbfy": "京海市顺义区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "刑事", - "n_sjdwje": 0, - "n_sqzxbdje": 18110, - "n_ssdw": "被执⾏⼈" - }, - { - "c_ah": "(2023)京0113执*****号", - "c_dsrxx": [ - { - "c_mc": "京海市顺义区⼈⺠法院", - "n_dsrlx": "企业组织", - "n_ssdw": "申请执⾏⼈" - }, - { - "c_mc": "靳帅", - "n_dsrlx": "⾃然⼈", - "n_ssdw": "被执⾏⼈" - } - ], - "c_gkws_glah": "(2023)京0113刑****号", - "c_id": "30285f31d30a24a2a41cc59fcb0928bc", - "c_ssdy": "京海市", - "d_jarq": "2024-01-02", - "d_larq": "2023-11-20", - "n_ajbs": "8dda746bb87c72f76d49a2cacee0efa0", - "n_ajjzjd": "已结案", - "n_ajlx": "⾸次执⾏", - "n_crc": 2098789290, - "n_jaay": "刑事", - "n_jabdje": 4670, - "n_jafs": "终结本次执⾏程序", - "n_jbfy": "京海市顺义区⼈⺠法院", - "n_jbfy_cj": "基层法院", - "n_laay": "刑事", - "n_sjdwje": 0, - "n_sqzxbdje": 4670, - "n_ssdw": "被执⾏⼈" - } - ], - "count": { - "area_stat": "京海市(4)", - "ay_stat": "刑事(3),未知(1)", - "count_beigao": 4, - "count_jie_beigao": 4, - "count_jie_other": 0, - "count_jie_total": 4, - "count_jie_yuangao": 0, - "count_other": 0, - "count_total": 4, - "count_wei_beigao": 0, - "count_wei_other": 0, - "count_wei_total": 0, - "count_wei_yuangao": 0, - "count_yuangao": 0, - "jafs_stat": "终结本次执⾏程序(4)", - "larq_stat": "2021(2),2022(1),2023(1)", - "money_beigao": 3, - "money_jie_beigao": 3, - "money_jie_other": 0, - "money_jie_total": 3, - "money_jie_yuangao": 0, - "money_other": 0, - "money_total": 3, - "money_wei_beigao": 0, - "money_wei_other": 0, - "money_wei_percent": 0, - "money_wei_total": 0, - "money_wei_yuangao": 0, - "money_yuangao": 0 - } - }, - "preservation": {} - } - } - } - } - } -] \ No newline at end of file diff --git a/storage/component-reports/cache/e57bf41ff9ed4db7082094e4bfa4afba.zip b/storage/component-reports/cache/e57bf41ff9ed4db7082094e4bfa4afba.zip deleted file mode 100644 index 7114a2f..0000000 Binary files a/storage/component-reports/cache/e57bf41ff9ed4db7082094e4bfa4afba.zip and /dev/null differ