package service import ( "bdrp-server/app/main/api/internal/config" "bdrp-server/app/main/model" "bytes" "context" "database/sql" "fmt" "net/url" "os" "path/filepath" "strings" "time" "github.com/jung-kurt/gofpdf" "github.com/pkg/errors" "github.com/zeromicro/go-zero/core/logx" ) type AuthorizationService struct { config config.Config authDocModel model.AuthorizationDocumentModel fileStoragePath string fileBaseURL string } // NewAuthorizationService 创建授权书服务实例 func NewAuthorizationService(c config.Config, authDocModel model.AuthorizationDocumentModel) *AuthorizationService { absStoragePath := determineStoragePath() return &AuthorizationService{ config: c, authDocModel: authDocModel, fileStoragePath: absStoragePath, fileBaseURL: c.Authorization.FileBaseURL, // 从配置文件读取 } } // GenerateAuthorizationDocument 生成授权书PDF func (s *AuthorizationService) GenerateAuthorizationDocument( ctx context.Context, userID int64, orderID int64, queryID int64, userInfo map[string]interface{}, ) (*model.AuthorizationDocument, error) { // 1. 生成PDF内容 pdfBytes, err := s.generatePDFContent(userInfo) if err != nil { return nil, errors.Wrapf(err, "生成PDF内容失败") } // 2. 创建文件存储目录 year := time.Now().Format("2006") month := time.Now().Format("01") dirPath := filepath.Join(s.fileStoragePath, year, month) if err := os.MkdirAll(dirPath, 0755); err != nil { return nil, errors.Wrapf(err, "创建存储目录失败: %s", dirPath) } // 3. 生成文件名和路径 fileName := fmt.Sprintf("auth_%d_%d_%s.pdf", userID, orderID, time.Now().Format("20060102_150405")) filePath := filepath.Join(dirPath, fileName) // 只存储相对路径,不包含域名 relativePath := fmt.Sprintf("%s/%s/%s", year, month, fileName) // 4. 保存PDF文件 if err := os.WriteFile(filePath, pdfBytes, 0644); err != nil { return nil, errors.Wrapf(err, "保存PDF文件失败: %s", filePath) } // 5. 保存到数据库 authDoc := &model.AuthorizationDocument{ UserId: userID, OrderId: orderID, QueryId: queryID, FileName: fileName, FilePath: filePath, FileUrl: relativePath, // 只存储相对路径 FileSize: int64(len(pdfBytes)), FileType: "pdf", Status: "active", ExpireTime: sql.NullTime{Valid: false}, // 永久保留,不设置过期时间 } result, err := s.authDocModel.Insert(ctx, nil, authDoc) if err != nil { // 如果数据库保存失败,删除已创建的文件 os.Remove(filePath) return nil, errors.Wrapf(err, "保存授权书记录失败") } authDoc.Id, _ = result.LastInsertId() logx.Infof("授权书生成成功: userID=%d, orderID=%d, filePath=%s", userID, orderID, filePath) return authDoc, nil } // GetFullFileURL 获取完整的文件访问URL func (s *AuthorizationService) GetFullFileURL(relativePath string) string { if relativePath == "" { return "" } return fmt.Sprintf("%s/%s", s.fileBaseURL, relativePath) } // ResolveFilePath 根据存储的路径信息解析出本地文件的绝对路径 func (s *AuthorizationService) ResolveFilePath(filePath string, relativePath string) string { candidates := []string{} cleanRelative := func(p string) string { p = strings.TrimPrefix(p, "/") p = strings.TrimPrefix(p, "\\") normalized := filepath.ToSlash(p) if strings.HasPrefix(normalized, "http://") || strings.HasPrefix(normalized, "https://") { if parsed, err := url.Parse(normalized); err == nil { normalized = filepath.ToSlash(strings.TrimPrefix(parsed.Path, "/")) } } normalized = strings.TrimPrefix(normalized, "data/authorization_docs/") normalized = strings.TrimPrefix(normalized, "authorization_docs/") normalized = strings.TrimPrefix(normalized, "./") normalized = strings.TrimPrefix(normalized, "../") return normalized } if filePath != "" { candidates = append(candidates, filePath) } if relativePath != "" { candidates = append(candidates, filepath.Join(s.fileStoragePath, filepath.FromSlash(cleanRelative(relativePath)))) } if filePath != "" { cleaned := filepath.Clean(filePath) if strings.HasPrefix(cleaned, s.fileStoragePath) { candidates = append(candidates, cleaned) } else { trimmed := strings.TrimPrefix(cleaned, "data"+string(os.PathSeparator)+"authorization_docs"+string(os.PathSeparator)) trimmed = strings.TrimPrefix(trimmed, "data/authorization_docs/") if trimmed != cleaned { candidates = append(candidates, filepath.Join(s.fileStoragePath, filepath.FromSlash(cleanRelative(trimmed)))) } if !filepath.IsAbs(cleaned) { candidates = append(candidates, filepath.Join(s.fileStoragePath, filepath.FromSlash(cleanRelative(cleaned)))) } } } for _, candidate := range candidates { if candidate == "" { continue } pathCandidate := candidate if !filepath.IsAbs(pathCandidate) { if absPath, err := filepath.Abs(pathCandidate); err == nil { pathCandidate = absPath } } if statErr := checkFileExists(pathCandidate); statErr == nil { return pathCandidate } } logx.Errorf("授权书文件路径解析失败 filePath=%s fileUrl=%s candidates=%v", filePath, relativePath, candidates) return "" } func determineStoragePath() string { candidatePaths := []string{ "data/authorization_docs", "../data/authorization_docs", "../../data/authorization_docs", "../../../data/authorization_docs", } for _, candidate := range candidatePaths { absPath, err := filepath.Abs(candidate) if err != nil { logx.Errorf("解析授权书存储路径失败: %s, err=%v", candidate, err) continue } if info, err := os.Stat(absPath); err == nil && info.IsDir() { logx.Infof("授权书存储路径选择: %s", absPath) return absPath } } // 如果没有现成的目录,使用第一个候选的绝对路径 absPath, err := filepath.Abs(candidatePaths[0]) if err != nil { logx.Errorf("解析默认授权书存储路径失败,使用相对路径: %s, err=%v", candidatePaths[0], err) return candidatePaths[0] } logx.Infof("授权书存储路径创建: %s", absPath) return absPath } func checkFileExists(path string) error { info, err := os.Stat(path) if err != nil { return err } if info.IsDir() { return fmt.Errorf("path %s is directory", path) } return nil } // generatePDFContent 生成PDF内容 func (s *AuthorizationService) generatePDFContent(userInfo map[string]interface{}) ([]byte, error) { // 创建PDF文档 pdf := gofpdf.New("P", "mm", "A4", "") pdf.AddPage() // 添加中文字体支持 - 参考imageService的路径处理方式 fontPaths := []string{ "static/SIMHEI.TTF", // 相对于工作目录的路径(与imageService一致) "/app/static/SIMHEI.TTF", // Docker容器内的字体文件 "app/main/api/static/SIMHEI.TTF", // 开发环境备用路径 } // 尝试添加字体 fontAdded := false for _, fontPath := range fontPaths { if _, err := os.Stat(fontPath); err == nil { pdf.AddUTF8Font("ChineseFont", "", fontPath) fontAdded = true logx.Infof("成功加载字体: %s", fontPath) break } else { logx.Debugf("字体文件不存在: %s, 错误: %v", fontPath, err) } } // 如果没有找到字体文件,使用默认字体,并记录警告 if !fontAdded { pdf.SetFont("Arial", "", 12) logx.Errorf("未找到中文字体文件,使用默认Arial字体,可能无法正确显示中文") } else { // 设置默认字体 pdf.SetFont("ChineseFont", "", 12) } // 获取用户信息 name := getUserInfoString(userInfo, "name") idCard := getUserInfoString(userInfo, "id_card") // 生成当前日期 currentDate := time.Now().Format("2006年1月2日") // 设置标题样式 - 大字体、居中 if fontAdded { pdf.SetFont("ChineseFont", "", 20) // 使用20号字体 } else { pdf.SetFont("Arial", "", 20) } pdf.CellFormat(0, 15, "授权书", "", 1, "C", false, 0, "") // 添加空行 pdf.Ln(5) // 设置正文样式 - 正常字体 if fontAdded { pdf.SetFont("ChineseFont", "", 12) } else { pdf.SetFont("Arial", "", 12) } // 构建授权书内容(与前端 Authorization.vue 保持一致) content := fmt.Sprintf(`戎行技术有限公司: 本人(姓名:%s/身份证号码:%s)拟向贵司申请业务,贵司需要了解本人相关状况,用于查询大数据分析报告,因此本人特同意并不可撤销的授权: (一)贵司向依法成立的第三方服务商(包括但不限于天津津北数字产业发展集团有限公司)根据本人提交的信息进行核实;并有权通过前述第三方服务机构查询、使用本人的身份信息、电话号码等,查询本人信息(包括但不限于学历、婚姻、资产状况及对信息主体产生负面影响的不良信息),出具相关报告。 (二)第三方服务商应当在上述处理目的、处理方式和个人信息的种类等范围内处理个人信息。变更原先的处理目的、处理方式的,应当依法重新取得您的同意。 本人在此声明已充分理解上述授权条款含义,知晓并自愿承担上述因收集等本人数据可能会给本人的生活行为(评分)结果产生不利影响,以及该等数据被使用者依法提供给第三方后被他人不当利用的风险,但本人仍同意上述授权。 特别提示: 为了保障您的合法权益,请您务必阅读并充分理解与遵守本授权书;若您不接受本授权书的任何条款,请您立即终止授权。贵司已经对上述事宜及其风险向本人做了充分说明,本人已知晓并同意。 你通过“赤眉”APP或代理商推广查询模式,自愿支付相应费用,用于购买戎行技术有限公司的大数据报告产品。 你向戎行技术有限公司的支付方式为:戎行技术有限公司及其关联公司的支付宝及微信账户。 本授权书一经本人在网上点击勾选同意即完成签署。本授权书是本人真实意思表示,本人同意承担由此带来的一切法律后果。 授权人:%s 身份证号:%s 签署时间:%s`, name, idCard, name, idCard, currentDate) // 将内容写入PDF pdf.MultiCell(0, 6, content, "", "", false) // 生成PDF字节数组 var buf bytes.Buffer err := pdf.Output(&buf) if err != nil { return nil, errors.Wrapf(err, "生成PDF字节数组失败") } return buf.Bytes(), nil } // getUserInfoString 安全获取用户信息字符串 func getUserInfoString(userInfo map[string]interface{}, key string) string { if value, exists := userInfo[key]; exists { if str, ok := value.(string); ok { return str } } return "" }