contractModel = new Contract(); $this->logModel = new DocumentGenerateLog(); $this->dataSourceModel = new DocumentDataSourceConfig(); $this->site_id = 1; // 默认站点ID } /** * 获取模板列表 * @param array $where * @return array */ public function getPage(array $where = []) { $field = 'id,contract_name,contract_template,contract_status,contract_type,remarks,original_filename,file_size,placeholders,created_at,updated_at'; $order = 'id desc'; $search_model = $this->contractModel->withSearch(["contract_status", "contract_type", "created_at"], $where)->field($field)->order($order); $list = $this->pageQuery($search_model); // 处理数据格式 if (!empty($list['data'])) { foreach ($list['data'] as &$item) { $item['placeholders'] = $item['placeholders'] ? json_decode($item['placeholders'], true) : []; $item['file_size_formatted'] = $this->formatFileSize($item['file_size']); } } return $list; } /** * 获取模板详情 * @param int $id * @return array */ public function getInfo(int $id) { $field = 'id,contract_name,contract_template,contract_content,contract_status,contract_type,remarks,placeholder_config,original_filename,file_size,file_hash,placeholders,created_at,updated_at'; $info = $this->contractModel->field($field)->where([['id', "=", $id]])->findOrEmpty()->toArray(); if (!empty($info)) { $info['placeholder_config'] = $info['placeholder_config'] ? json_decode($info['placeholder_config'], true) : []; $info['placeholders'] = $info['placeholders'] ? json_decode($info['placeholders'], true) : []; $info['file_size_formatted'] = $this->formatFileSize($info['file_size']); // 获取数据源配置信息,从placeholder_config字段获取 $dataSourceConfigs = []; if (!empty($info['placeholder_config'])) { // 转换placeholder_config格式为前端需要的data_source_configs格式 foreach ($info['placeholder_config'] as $placeholder => $config) { $dataSourceConfigs[] = [ 'id' => 0, 'placeholder' => $placeholder, 'data_type' => $config['data_type'] ?? 'user_input', 'table_name' => $config['table_name'] ?? '', 'field_name' => $config['field_name'] ?? '', 'system_function' => $config['system_function'] ?? '', 'user_input_value' => $config['user_input_value'] ?? '', 'sign_party' => $config['sign_party'] ?? '', 'field_type' => $config['field_type'] ?? 'text', 'is_required' => $config['is_required'] ?? 0, 'default_value' => $config['default_value'] ?? '' ]; } } $info['data_source_configs'] = $dataSourceConfigs; // 如果没有数据源配置,但有占位符,则创建默认配置 if (empty($dataSourceConfigs) && !empty($info['placeholders'])) { $defaultConfigs = []; foreach ($info['placeholders'] as $placeholder) { $defaultConfigs[] = [ 'id' => 0, 'placeholder' => $placeholder, 'data_type' => 'user_input', 'table_name' => '', 'field_name' => '', 'system_function' => '', 'user_input_value' => '', 'sign_party' => '', 'field_type' => 'text', 'is_required' => 0, 'default_value' => '' ]; } $info['data_source_configs'] = $defaultConfigs; } } return $info; } /** * 保存数据源配置 * @param int $contractId 合同ID * @param array $configs 配置数据 * @return bool * @throws \Exception */ public function saveDataSourceConfig(int $contractId, array $configs): bool { // 验证合同是否存在 $contract = $this->contractModel->find($contractId); if (!$contract) { throw new \Exception('合同不存在'); } // 开启事务 \think\facade\Db::startTrans(); try { // 删除现有配置 $this->dataSourceModel->where('contract_id', $contractId)->delete(); // 批量插入新配置 if (!empty($configs)) { $insertData = []; foreach ($configs as $config) { // 验证必需字段 if (empty($config['placeholder'])) { throw new \Exception('占位符不能为空'); } $insertData[] = [ 'contract_id' => $contractId, 'placeholder' => $config['placeholder'], 'table_name' => $config['table_name'] ?? '', 'field_name' => $config['field_name'] ?? '', 'field_type' => $config['field_type'] ?? 'string', 'is_required' => $config['is_required'] ?? 0, 'default_value' => $config['default_value'] ?? '', 'created_at' => date('Y-m-d H:i:s') ]; } $result = $this->dataSourceModel->insertAll($insertData); if (!$result) { throw new \Exception('保存配置失败'); } } // 提交事务 \think\facade\Db::commit(); return true; } catch (\Exception $e) { // 回滚事务 \think\facade\Db::rollback(); throw $e; } } /** * 上传Word模板文件 * @param $file * @param array $data * @return array * @throws \Exception */ public function uploadTemplate($file, array $data = []) { // 验证文件类型 $allowedTypes = ['docx', 'doc']; $extension = strtolower($file->getOriginalExtension()); if (!in_array($extension, $allowedTypes)) { throw new \Exception('只支持 .docx 和 .doc 格式的Word文档'); } // 获取文件信息 $fileSize = $file->getSize(); $realPath = $file->getRealPath(); // 验证文件大小 (最大10MB) $maxSize = 10 * 1024 * 1024; if ($fileSize > $maxSize) { throw new \Exception('文件大小不能超过10MB'); } // 生成文件hash防重复 $fileHash = md5_file($realPath); try { // 生成保存路径 $uploadDir = 'contract_templates/' . date('Ymd'); $uploadPath = public_path() . '/upload/' . $uploadDir; // 确保目录存在 if (!is_dir($uploadPath)) { mkdir($uploadPath, 0777, true); } // 生成文件名 $fileName = md5(time() . $file->getOriginalName()) . '.' . $extension; $fullPath = $uploadPath . '/' . $fileName; $savePath = $uploadDir . '/' . $fileName; // 移动文件到目标位置 if (!move_uploaded_file($realPath, $fullPath)) { throw new \Exception('文件保存失败'); } // 解析Word文档内容和占位符 $parseResult = $this->parseWordTemplate($fullPath); // 准备保存到数据库的数据 $saveData = [ 'contract_name' => !empty($data['contract_name']) ? $data['contract_name'] : pathinfo($file->getOriginalName(), PATHINFO_FILENAME), 'contract_template' => $savePath, 'contract_content' => $parseResult['content'], 'contract_status' => 'draft', 'contract_type' => !empty($data['contract_type']) ? $data['contract_type'] : 'general', 'original_filename' => $file->getOriginalName(), 'file_size' => $fileSize, 'file_hash' => $fileHash, 'placeholders' => json_encode($parseResult['placeholders']), 'remarks' => !empty($data['remarks']) ? $data['remarks'] : '', 'created_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s') ]; // 保存到数据库 $template = $this->contractModel->create($saveData); return [ 'id' => $template->id, 'template_name' => $saveData['contract_name'], 'file_path' => $savePath, 'placeholders' => $parseResult['placeholders'], 'placeholder_count' => count($parseResult['placeholders']) ]; } catch (\Exception $e) { // 如果保存失败,删除已上传的文件 if (isset($fullPath) && file_exists($fullPath)) { unlink($fullPath); } throw new \Exception('模板上传失败:' . $e->getMessage()); } } /** * 解析Word模板占位符 * @param array $data * @return array * @throws \Exception */ public function parsePlaceholder(array $data) { if (!empty($data['template_id'])) { // 从数据库获取模板信息 $template = $this->contractModel->find($data['template_id']); if (!$template) { throw new \Exception('模板不存在'); } $templatePath = public_path() . '/upload/' . $template['contract_template']; } else if (!empty($data['template_path'])) { $templatePath = $data['template_path']; } else { throw new \Exception('请提供模板文件路径或模板ID'); } if (!file_exists($templatePath)) { throw new \Exception('模板文件不存在'); } return $this->parseWordTemplate($templatePath); } /** * 解析Word模板文件内容 * @param string $filePath * @return array * @throws \Exception */ private function parseWordTemplate(string $filePath) { try { // 读取Word文档 $templateProcessor = new TemplateProcessor($filePath); // 获取文档内容(简化版本,实际可能需要更复杂的解析) $content = $this->extractWordContent($filePath); // 提取占位符 - 匹配 {{...}} 格式 $placeholders = $this->extractPlaceholders($content); return [ 'content' => $content, 'placeholders' => $placeholders ]; } catch (\Exception $e) { Log::error('Word模板解析失败:' . $e->getMessage()); throw new \Exception('Word模板解析失败:' . $e->getMessage()); } } /** * 提取Word文档内容 * @param string $filePath * @return string */ private function extractWordContent(string $filePath) { try { $phpWord = IOFactory::load($filePath); $content = ''; // 遍历所有章节 foreach ($phpWord->getSections() as $section) { foreach ($section->getElements() as $element) { if (method_exists($element, 'getText')) { // 使用反射获取文本内容 $text = ''; if($element instanceof \PhpOffice\PhpWord\Element\Text) { $text = $element->getText(); } else if($element instanceof \PhpOffice\PhpWord\Element\TextRun) { foreach($element->getElements() as $item) { if($item instanceof \PhpOffice\PhpWord\Element\Text) { $text .= $item->getText(); } } } $content .= $text . "\n"; } } } return $content; } catch (\Exception $e) { Log::error('提取Word内容失败:' . $e->getMessage()); return ''; } } /** * 从内容中提取占位符 * @param string $content * @return array */ private function extractPlaceholders(string $content) { $placeholders = []; // 匹配 {{变量名}} 格式的占位符 if (preg_match_all('/\{\{([^}]+)\}\}/', $content, $matches)) { foreach ($matches[1] as $placeholder) { $placeholder = trim($placeholder); if (!in_array($placeholder, $placeholders)) { $placeholders[] = $placeholder; } } } return $placeholders; } /** * 保存占位符配置 * @param array $data * @return bool * @throws \Exception */ public function savePlaceholderConfig(int $templateId, array $configs) { $template = $this->contractModel->find($templateId); if (!$template) { throw new \Exception('模板不存在'); } // 转换配置数据格式以支持三种数据类型:database, system, user_input $configData = []; foreach ($configs as $config) { $placeholder = $config['placeholder']; $dataType = $config['data_type'] ?? 'user_input'; $configData[$placeholder] = [ 'data_type' => $dataType, 'table_name' => $config['table_name'] ?? '', 'field_name' => $config['field_name'] ?? '', 'system_function' => $config['system_function'] ?? '', 'user_input_value' => $config['user_input_value'] ?? '', 'sign_party' => $config['sign_party'] ?? '', 'field_type' => $config['field_type'] ?? 'text', 'is_required' => $config['is_required'] ?? 0, 'default_value' => $config['default_value'] ?? '' ]; } // 开启事务 \think\facade\Db::startTrans(); try { // 1. 保存配置到合同表的placeholder_config字段(保持兼容性) $template->placeholder_config = json_encode($configData); $template->updated_at = date('Y-m-d H:i:s'); $template->save(); // 2. 同时保存到独立的数据源配置表(用户期望的表) $this->saveConfigToDataSourceTable($templateId, $configData); \think\facade\Db::commit(); return true; } catch (\Exception $e) { \think\facade\Db::rollback(); throw $e; } } /** * 保存配置到数据源配置表 * @param int $contractId 合同ID * @param array $config 配置数据 * @return void * @throws \Exception */ private function saveConfigToDataSourceTable(int $contractId, array $config): void { // 删除现有配置 $this->dataSourceModel->where('contract_id', $contractId)->delete(); // 转换配置格式并保存 if (!empty($config)) { $insertData = []; foreach ($config as $placeholder => $settings) { $insertData[] = [ 'contract_id' => $contractId, 'placeholder' => $placeholder, 'data_type' => $settings['data_type'] ?? 'user_input', 'table_name' => $settings['table_name'] ?? '', 'field_name' => $settings['field_name'] ?? '', 'system_function' => $settings['system_function'] ?? '', 'sign_party' => $settings['sign_party'] ?? '', 'field_type' => $settings['field_type'] ?? 'text', 'is_required' => $settings['is_required'] ?? 0, 'default_value' => $settings['default_value'] ?? '', 'created_at' => date('Y-m-d H:i:s') ]; } if (!empty($insertData)) { $this->dataSourceModel->insertAll($insertData); } } } /** * 获取可用数据源列表 * @return array */ public function getDataSources() { return [ 'tables' => $this->getAvailableTables(), 'system_functions' => $this->getSystemFunctions() ]; } /** * 获取可用数据表配置 * @return array */ public function getAvailableTables() { return [ 'school_student' => [ 'label' => '学员表', 'fields' => [ 'id' => '学员ID', 'name' => '学员姓名', 'gender' => '性别', 'age' => '年龄', 'birthday' => '生日', 'emergency_contact' => '紧急联系人', 'contact_phone' => '联系人电话', 'status' => '学员状态', 'trial_class_count' => '体验课次数', 'created_at' => '创建时间', 'updated_at' => '修改时间' ] ], 'school_customer_resources' => [ 'label' => '客户资源表', 'fields' => [ 'id' => '编号', 'name' => '姓名', 'phone_number' => '联系电话', 'gender' => '性别', 'age' => '年龄', 'source_channel' => '来源渠道', 'source' => '来源', 'consultant' => '顾问', 'demand' => '需求', 'purchasing_power' => '购买力', 'initial_intent' => '客户初步意向度', 'trial_class_count' => '体验课次数', 'created_at' => '创建时间' ] ], 'school_order_table' => [ 'label' => '订单表', 'fields' => [ 'id' => '订单编号', 'payment_id' => '支付编号', 'order_type' => '订单类型', 'order_status' => '订单状态', 'payment_type' => '付款类型', 'order_amount' => '订单金额', 'discount_amount' => '优惠金额', 'payment_time' => '支付时间', 'created_at' => '创建时间', 'remark' => '订单备注' ] ], 'school_course' => [ 'label' => '课程表', 'fields' => [ 'id' => '课程编号', 'course_name' => '课程名称', 'course_type' => '课程类型', 'duration' => '课程时长', 'session_count' => '课时数量', 'single_session_count' => '单次消课数量', 'gift_session_count' => '赠送课时数量', 'price' => '课程价格', 'internal_reminder' => '内部提醒课时', 'customer_reminder' => '客户提醒课时', 'status' => '课程状态', 'created_at' => '创建时间' ] ], 'school_personnel' => [ 'label' => '人员表', 'fields' => [ 'id' => 'ID', 'name' => '姓名', 'gender' => '性别', 'phone' => '电话', 'email' => '邮箱', 'wx' => '微信号', 'address' => '家庭住址', 'education' => '学历', 'employee_number' => '员工编号', 'account_type' => '账号类型', 'status' => '状态', 'join_time' => '入职时间', 'create_time' => '创建时间' ] ] ]; } /** * 获取系统函数配置 * @return array */ public function getSystemFunctions() { return [ 'current_date' => '当前日期', 'current_time' => '当前时间', 'current_datetime' => '当前日期时间', 'current_year' => '当前年份', 'current_month' => '当前月份', 'current_day' => '当前日', 'random_number' => '随机编号', 'contract_generate_time' => '合同生成时间', 'system_name' => '系统名称', 'current_user' => '当前用户', 'current_campus' => '当前校区', // 签名占位符 'employee_signature' => '员工签名位置', 'student_signature' => '学员签名位置' ]; } /** * 生成Word文档 * @param array $data * @return array * @throws \Exception */ public function generateDocument(array $data) { $template = $this->contractModel->find($data['template_id']); if (!$template) { throw new \Exception('模板不存在'); } if (empty($template['placeholder_config'])) { throw new \Exception('模板尚未配置占位符'); } // 创建生成记录 $logData = [ 'site_id' => $this->site_id, 'template_id' => $data['template_id'], 'user_id' => $this->uid, 'user_type' => 1, 'fill_data' => json_encode($data['fill_data']), 'status' => 'pending', 'completed_at' => date('Y-m-d H:i:s') ]; $log = $this->logModel->create($logData); try { // 更新状态为处理中 $log->status = 'processing'; $log->process_start_time = date('Y-m-d H:i:s'); $log->save(); // 准备填充数据 $placeholderConfig = json_decode($template['placeholder_config'], true); // 检查是否传递了 use_direct_values 参数,如果是则直接使用传递的值 if (!empty($data['use_direct_values']) && $data['use_direct_values'] === true) { // 直接使用传递的 fill_data 作为填充值,不进行二次处理 $fillValues = $data['fill_data']; Log::info('使用直接填充模式', [ 'template_id' => $data['template_id'], 'fill_data_count' => count($fillValues), 'fill_data_keys' => array_keys($fillValues) ]); } else { // 使用原有的配置处理模式 $fillValues = $this->prepareFillData($placeholderConfig, $data['fill_data']); } // 生成文档 $templatePath = public_path() . '/upload/' . $template['contract_template']; $outputFileName = $data['output_filename'] ?: ($template['contract_name'] . '_' . date('YmdHis') . '.docx'); // 使用系统临时目录,避免权限问题 $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_documents'; $outputPath = 'generated_documents/' . date('Y/m/') . $outputFileName; // 尝试创建临时目录 $fullOutputPath = ''; $useTemp = false; if (!is_dir($tempDir)) { if (@mkdir($tempDir, 0755, true) || is_dir($tempDir)) { $fullOutputPath = $tempDir . DIRECTORY_SEPARATOR . $outputFileName; $useTemp = true; } } else { $fullOutputPath = $tempDir . DIRECTORY_SEPARATOR . $outputFileName; $useTemp = true; } // 如果临时目录创建失败,尝试使用项目根目录下的temp目录 if (!$useTemp) { $projectTempDir = dirname(app()->getRootPath()) . DIRECTORY_SEPARATOR . 'temp'; if (!is_dir($projectTempDir)) { if (@mkdir($projectTempDir, 0755, true) || is_dir($projectTempDir)) { $fullOutputPath = $projectTempDir . DIRECTORY_SEPARATOR . $outputFileName; $useTemp = true; } } else { $fullOutputPath = $projectTempDir . DIRECTORY_SEPARATOR . $outputFileName; $useTemp = true; } } // 如果所有目录创建都失败,抛出异常 if (!$useTemp || empty($fullOutputPath)) { throw new \Exception('无法创建临时文档存储目录,请检查系统权限'); } // 预处理:修复被格式化分割的占位符 $fixedTemplatePath = $this->fixBrokenPlaceholders($templatePath); // 使用 PhpWord 模板处理器 $templateProcessor = new TemplateProcessor($fixedTemplatePath); // 智能处理占位符,根据类型使用不同的处理方法 $this->processPlaceholders($templateProcessor, $fillValues, $placeholderConfig); $templateProcessor->saveAs($fullOutputPath); // 生成文档后,尝试复制到public目录供下载 $publicOutputPath = public_path() . '/upload/' . $outputPath; $publicOutputDir = dirname($publicOutputPath); $copySuccess = false; // 尝试创建public目录并复制文件 if (@mkdir($publicOutputDir, 0755, true) || is_dir($publicOutputDir)) { if (@copy($fullOutputPath, $publicOutputPath)) { $copySuccess = true; } } if ($copySuccess) { // 复制成功,删除临时文件 @unlink($fullOutputPath); // 更新生成记录 $log->status = 'completed'; $log->generated_file_path = $outputPath; $log->generated_file_name = $outputFileName; $log->process_end_time = date('Y-m-d H:i:s'); $log->save(); return [ 'log_id' => $log->id, 'file_path' => $outputPath, 'file_name' => $outputFileName, 'download_url' => url('/upload/' . $outputPath) ]; } else { // 复制失败,使用runtime目录作为替代方案 $runtimeDir = runtime_path() . 'generated_documents' . DIRECTORY_SEPARATOR; if (!is_dir($runtimeDir)) { @mkdir($runtimeDir, 0755, true); } $runtimePath = $runtimeDir . $outputFileName; $runtimeCopySuccess = false; // 尝试复制到runtime目录 if (@copy($fullOutputPath, $runtimePath)) { $runtimeCopySuccess = true; @unlink($fullOutputPath); // 删除临时文件 } // 更新生成记录 $log->status = 'completed'; $log->generated_file_path = $runtimeCopySuccess ? 'runtime/generated_documents/' . $outputFileName : 'temp/' . $outputFileName; $log->generated_file_name = $outputFileName; $log->temp_file_path = $runtimeCopySuccess ? $runtimePath : $fullOutputPath; $log->process_end_time = date('Y-m-d H:i:s'); $log->save(); return [ 'log_id' => $log->id, 'file_path' => $runtimeCopySuccess ? 'runtime/generated_documents/' . $outputFileName : 'temp/' . $outputFileName, 'file_name' => $outputFileName, 'download_url' => url('/adminapi/document/download/' . $log->id), 'temp_file_path' => $runtimeCopySuccess ? $runtimePath : $fullOutputPath, 'message' => $runtimeCopySuccess ? '文档已生成,使用临时下载链接' : '文档已生成,但无法复制到公共目录,请联系管理员处理权限问题' ]; } } catch (\Exception $e) { // 更新记录为失败状态 $log->status = 'failed'; $log->error_msg = $e->getMessage(); $log->process_end_time = date('Y-m-d H:i:s'); $log->save(); throw new \Exception('文档生成失败:' . $e->getMessage()); } } /** * 准备填充数据 * @param array $placeholderConfig * @param array $userFillData * @return array */ private function prepareFillData(array $placeholderConfig, array $userFillData) { $fillValues = []; foreach ($placeholderConfig as $placeholder => $config) { $value = ''; // 检查data_type字段是否存在,如果不存在则默认为user_input $dataType = $config['data_type'] ?? 'user_input'; switch ($dataType) { case 'user_input': // 用户输入的数据 $value = $userFillData[$placeholder] ?? $config['default_value'] ?? ''; break; case 'database': // 从数据库获取数据 $value = $this->getDataFromDatabase($config, $userFillData); break; case 'system': // 系统函数获取数据 $value = $this->getSystemData($config); break; case 'sign_img': // 签名图片 $value = $this->getSignatureImage($config, $userFillData); break; case 'signature': // 电子签名 $value = $this->getElectronicSignature($config, $userFillData); break; default: // 默认使用用户输入 $value = $userFillData[$placeholder] ?? $config['default_value'] ?? ''; break; } // 应用处理函数 if (!empty($config['process_function'])) { $value = $this->applyProcessFunction($value, $config['process_function']); } $fillValues[$placeholder] = $value; } return $fillValues; } /** * 从数据库获取数据 * @param array $config * @param array $userFillData * @return string */ private function getDataFromDatabase(array $config, array $userFillData) { try { $tableName = $config['table_name']; $fieldName = $config['field_name']; // 改进的数据库查询逻辑,支持条件查询 $model = \think\facade\Db::connect(); $query = $model->table($tableName); // 如果有传入的查询条件(比如学员ID),使用条件查询 if (!empty($userFillData) && is_array($userFillData)) { foreach ($userFillData as $key => $value) { // 支持常见的查询字段 if (in_array($key, ['id', 'student_id', 'user_id', 'person_id', 'contract_id'])) { $query->where($key, $value); break; // 只使用第一个匹配的条件 } } } $result = $query->field($fieldName)->find(); Log::info('数据库查询', [ 'table' => $tableName, 'field' => $fieldName, 'result' => $result, 'user_data' => $userFillData ]); return $result[$fieldName] ?? $config['default_value'] ?? ''; } catch (\Exception $e) { Log::error('数据库查询失败:' . $e->getMessage(), [ 'table' => $config['table_name'] ?? '', 'field' => $config['field_name'] ?? '', 'config' => $config ]); return $config['default_value'] ?? ''; } } /** * 获取系统数据 * @param array $config * @return string */ private function getSystemData(array $config) { try { $systemFunction = $config['system_function'] ?? ''; switch ($systemFunction) { case 'current_date': return date('Y-m-d'); case 'current_datetime': return date('Y-m-d H:i:s'); case 'current_time': return date('H:i:s'); case 'current_year': return date('Y'); case 'current_month': return date('m'); case 'current_day': return date('d'); case 'timestamp': return time(); default: return $config['default_value'] ?? ''; } } catch (\Exception $e) { Log::error('系统函数调用失败:' . $e->getMessage()); return $config['default_value'] ?? ''; } } /** * 获取签名图片 * @param array $config * @param array $userFillData * @return string */ private function getSignatureImage(array $config, array $userFillData) { try { $placeholder = $config['placeholder'] ?? ''; $signatureImagePath = $userFillData[$placeholder] ?? $config['default_value'] ?? ''; // 如果是相对路径,转换为绝对路径 if ($signatureImagePath && !str_starts_with($signatureImagePath, 'http')) { $signatureImagePath = public_path() . '/uploads/' . ltrim($signatureImagePath, '/'); } return $signatureImagePath; } catch (\Exception $e) { Log::error('签名图片获取失败:' . $e->getMessage()); return $config['default_value'] ?? ''; } } /** * 获取电子签名 * @param array $config * @param array $userFillData * @return string */ private function getElectronicSignature(array $config, array $userFillData) { try { $placeholder = $config['placeholder'] ?? ''; $signatureData = $userFillData[$placeholder] ?? $config['default_value'] ?? ''; // 如果是base64编码的签名数据,可以直接返回或进行处理 if ($signatureData && str_starts_with($signatureData, 'data:image/')) { // 处理base64图片数据 return $signatureData; } // 如果是签名文本或其他格式 return $signatureData; } catch (\Exception $e) { Log::error('电子签名获取失败:' . $e->getMessage()); return $config['default_value'] ?? ''; } } /** * 应用处理函数 * @param mixed $value * @param string $functionName * @return string */ private function applyProcessFunction($value, string $functionName) { switch ($functionName) { case 'formatDate': return $value ? date('Y年m月d日', strtotime($value)) : ''; case 'formatDateTime': return $value ? date('Y年m月d日 H:i', strtotime($value)) : ''; case 'formatNumber': return is_numeric($value) ? number_format($value, 2) : $value; case 'toUpper': return strtoupper($value); case 'toLower': return strtolower($value); default: return $value; } } /** * 下载生成的文档 * @param int $logId * @return Response * @throws \Exception */ public function downloadDocument(int $logId) { $log = $this->logModel->find($logId); if (!$log) { throw new \Exception('记录不存在'); } if ($log['status'] !== 'completed') { throw new \Exception('文档尚未生成完成'); } // 优先尝试从public目录下载 $filePath = public_path() . '/upload/' . $log['generated_file_path']; // 如果public目录文件不存在,尝试从临时文件路径下载 if (!file_exists($filePath) && !empty($log['temp_file_path'])) { $filePath = $log['temp_file_path']; } // 如果还是不存在,尝试从runtime目录 if (!file_exists($filePath)) { $runtimePath = runtime_path() . 'generated_documents' . DIRECTORY_SEPARATOR . $log['generated_file_name']; if (file_exists($runtimePath)) { $filePath = $runtimePath; } } if (!file_exists($filePath)) { throw new \Exception('文件不存在或已被删除'); } // 更新下载统计 $log->download_count = $log->download_count + 1; $log->last_download_time = date('Y-m-d H:i:s'); $log->save(); // 返回文件下载响应 return download($filePath, $log['generated_file_name']); } /** * 获取生成记录 * @param array $where * @return array */ public function getGenerateLog(array $where = []) { $field = 'id,template_id,user_id,user_type,generated_file_name,status,download_count,created_at,process_start_time,process_end_time'; $order = 'id desc'; $searchModel = $this->logModel ->alias('log') ->join('school_contract template', 'log.template_id = template.id') ->field($field . ',template.contract_name') ->where('log.site_id', $this->site_id) ->order($order); // 添加搜索条件 if (!empty($where['template_id'])) { $searchModel->where('log.template_id', $where['template_id']); } if (!empty($where['status'])) { $searchModel->where('log.status', $where['status']); } return $this->pageQuery($searchModel); } /** * 修复被Word格式化分割的占位符 * Word在编辑时会在占位符中插入格式化标签,导致{{placeholder}}被分割 * 这个方法会创建一个修复后的临时文件 * * @param string $templatePath 原始模板文件路径 * @return string 修复后的模板文件路径 */ private function fixBrokenPlaceholders(string $templatePath) { try { // 创建临时文件 $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_templates'; if (!is_dir($tempDir)) { mkdir($tempDir, 0755, true); } $fixedTemplatePath = $tempDir . DIRECTORY_SEPARATOR . 'fixed_' . basename($templatePath); // 复制原文件到临时位置 if (!copy($templatePath, $fixedTemplatePath)) { Log::warning('无法创建临时模板文件,使用原始模板', ['template_path' => $templatePath]); return $templatePath; } // 打开ZIP文件进行修复 $zip = new \ZipArchive(); if ($zip->open($fixedTemplatePath) === TRUE) { $content = $zip->getFromName('word/document.xml'); if ($content !== false) { $originalLength = strlen($content); $fixedContent = $content; // 第一步:修复跨XML标签的占位符(最常见的模式) // 模式1:修复 {{...内容}} 这种分割 $pattern1 = '/]*>\{\{<\/w:t>([^<]*)]*>([^<]*)\}\}<\/w:t>/'; $fixedContent = preg_replace_callback($pattern1, function($matches) { $placeholder = '{{' . $matches[1] . $matches[2] . '}}'; return '' . $placeholder . ''; }, $fixedContent); // 模式2:修复 {...{内容}} 这种分割 $pattern2 = '/]*>\{<\/w:t>([^<]*)]*>\{([^<]*)\}\}<\/w:t>/'; $fixedContent = preg_replace_callback($pattern2, function($matches) { $placeholder = '{{' . $matches[1] . $matches[2] . '}}'; return '' . $placeholder . ''; }, $fixedContent); // 模式3:修复三段式分割 {{...中间...}} $pattern3 = '/]*>\{\{<\/w:t>([^<]*)]*>([^<]*)<\/w:t>([^<]*)]*>([^<]*)\}\}<\/w:t>/'; $fixedContent = preg_replace_callback($pattern3, function($matches) { $placeholder = '{{' . $matches[1] . $matches[2] . $matches[4] . '}}'; return '' . $placeholder . ''; }, $fixedContent); // 第二步:处理更复杂的格式化分割(包含rPr标签的) // 匹配包含格式化信息的分割模式 $complexPattern = '/\{\{[^}]*?(?:<\/w:t><\/w:r>]*?>(?:]*?>.*?<\/w:rPr>)?]*?>)[^}]*?\}\}/s'; $fixedContent = preg_replace_callback($complexPattern, function($matches) { $placeholder = $matches[0]; // 移除所有XML标签,保留纯文本 $cleaned = preg_replace('/<[^>]*?>/', '', $placeholder); // 验证是否为有效占位符 if (!preg_match('/^\{\{[^}]+\}\}$/', $cleaned)) { return $placeholder; } // 保持原有的第一个w:t标签结构 if (preg_match('/^([^<]*]*>)/', $placeholder, $tagMatch)) { return $tagMatch[1] . $cleaned . ''; } return $cleaned; }, $fixedContent); // 第三步:修复任何剩余的基本分割模式 $basicPattern = '/\{\{[^}]*?<[^>]*?>[^}]*?\}\}/'; $fixedContent = preg_replace_callback($basicPattern, function($matches) { $placeholder = $matches[0]; $cleaned = preg_replace('/<[^>]*?>/', '', $placeholder); if (!preg_match('/^\{\{[^}]+\}\}$/', $cleaned)) { return $placeholder; } return $cleaned; }, $fixedContent); // 将修复后的内容写回ZIP文件 if ($zip->addFromString('word/document.xml', $fixedContent)) { $zip->close(); Log::info('占位符修复完成', [ 'template_file' => $templatePath, 'fixed_file' => $fixedTemplatePath, 'original_length' => $originalLength, 'fixed_length' => strlen($fixedContent), 'size_change' => strlen($fixedContent) - $originalLength ]); return $fixedTemplatePath; } else { $zip->close(); Log::warning('无法写入修复后的内容,使用原始模板', ['template_path' => $templatePath]); return $templatePath; } } else { $zip->close(); Log::warning('无法读取document.xml,使用原始模板', ['template_path' => $templatePath]); return $templatePath; } } else { Log::warning('无法打开Word文档,使用原始模板', ['template_path' => $templatePath]); return $templatePath; } } catch (\Exception $e) { Log::error('修复占位符失败', [ 'template_path' => $templatePath, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); // 修复失败不影响主流程,返回原始模板路径 return $templatePath; } } /** * 格式化文件大小 * @param int $size * @return string */ private function formatFileSize(int $size) { $units = ['B', 'KB', 'MB', 'GB']; $index = 0; while ($size >= 1024 && $index < count($units) - 1) { $size /= 1024; $index++; } return round($size, 2) . ' ' . $units[$index]; } /** * 预览模板 * @param int $id * @return array * @throws \Exception */ public function previewTemplate(int $id) { $template = $this->contractModel->find($id); if (!$template) { throw new \Exception('模板不存在'); } return [ 'content' => $template['contract_content'], 'placeholders' => json_decode($template['placeholders'], true) ?: [] ]; } /** * 删除模板 * @param int $id * @return bool * @throws \Exception */ public function delete(int $id) { $template = $this->contractModel->find($id); if (!$template) { throw new \Exception('模板不存在'); } // 删除关联的生成记录和文件 $logs = $this->logModel->where('template_id', $id)->select(); foreach ($logs as $log) { if ($log['generated_file_path']) { $filePath = public_path() . '/upload/' . $log['generated_file_path']; if (file_exists($filePath)) { unlink($filePath); } } } $this->logModel->where('template_id', $id)->delete(); // 删除模板文件 if ($template['contract_template']) { $templatePath = public_path() . '/upload/' . $template['contract_template']; if (file_exists($templatePath)) { unlink($templatePath); } } // 删除数据库记录 return $template->delete(); } /** * 复制模板 * @param int $id * @return array * @throws \Exception */ public function copy(int $id) { $template = $this->contractModel->find($id); if (!$template) { throw new \Exception('模板不存在'); } $newData = $template->toArray(); unset($newData['id']); $newData['contract_name'] = $newData['contract_name'] . '_副本'; $newData['created_at'] = date('Y-m-d H:i:s'); $newData['updated_at'] = date('Y-m-d H:i:s'); $newTemplate = $this->contractModel->create($newData); return ['id' => $newTemplate->id]; } /** * 批量删除生成记录 * @param array $ids * @return bool */ public function batchDeleteLog(array $ids) { $logs = $this->logModel->whereIn('id', $ids)->select(); foreach ($logs as $log) { if ($log['generated_file_path']) { $filePath = public_path() . '/upload/' . $log['generated_file_path']; if (file_exists($filePath)) { unlink($filePath); } } } return $this->logModel->whereIn('id', $ids)->delete(); } /** * 编辑模板 * @param int $id * @param array $data * @return bool * @throws \Exception */ public function update(int $id, array $data) { $template = $this->contractModel->find($id); if (!$template) { throw new \Exception('模板不存在'); } // 更新基本信息 if (isset($data['contract_name'])) { $template->contract_name = $data['contract_name']; } if (isset($data['contract_type'])) { $template->contract_type = $data['contract_type']; } if (isset($data['remarks'])) { $template->remarks = $data['remarks']; } $template->updated_at = date('Y-m-d H:i:s'); return $template->save(); } /** * 更新模板状态 * @param int $id * @param string $status * @return bool * @throws \Exception */ public function updateStatus(int $id, string $status) { $template = $this->contractModel->find($id); if (!$template) { throw new \Exception('模板不存在'); } $template->contract_status = $status; $template->updated_at = date('Y-m-d H:i:s'); return $template->save(); } /** * 重新识别占位符 * @param int $id 模板ID * @return array * @throws \Exception */ public function reidentifyPlaceholders(int $id) { $template = $this->contractModel->find($id); if (!$template) { throw new \Exception('模板不存在'); } // 检查模板文件路径是否存在 if (empty($template['contract_template'])) { throw new \Exception('模板未上传Word文档,无法识别占位符'); } // 检查模板文件是否存在 $templatePath = public_path() . '/upload/' . $template['contract_template']; if (!file_exists($templatePath)) { throw new \Exception('模板文件不存在:' . $template['contract_template']); } // 检查是否为文件而不是目录 if (is_dir($templatePath)) { throw new \Exception('模板路径指向的是目录而不是文件:' . $template['contract_template']); } try { // 重新解析Word文档内容和占位符 $parseResult = $this->parseWordTemplate($templatePath); // 更新数据库中的占位符列表 $template->placeholders = json_encode($parseResult['placeholders']); $template->contract_content = $parseResult['content']; $template->updated_at = date('Y-m-d H:i:s'); $template->save(); // 获取现有的占位符配置 $existingConfig = []; if ($template['placeholder_config']) { $existingConfig = json_decode($template['placeholder_config'], true) ?: []; } // 为新的占位符创建默认配置,保留现有配置 $newConfig = []; foreach ($parseResult['placeholders'] as $placeholder) { if (isset($existingConfig[$placeholder])) { // 保留现有配置 $newConfig[$placeholder] = $existingConfig[$placeholder]; } else { // 为新占位符创建默认配置 $newConfig[$placeholder] = [ 'data_type' => 'user_input', 'table_name' => '', 'field_name' => '', 'system_function' => '', 'user_input_value' => '', 'sign_party' => '', 'field_type' => 'text', 'is_required' => 0, 'default_value' => '' ]; } } // 更新占位符配置 if (!empty($newConfig)) { $template->placeholder_config = json_encode($newConfig); $template->save(); // 同步更新数据源配置表 $this->saveConfigToDataSourceTable($id, $newConfig); } return [ 'placeholders' => $parseResult['placeholders'], 'placeholder_count' => count($parseResult['placeholders']), 'new_placeholders' => array_diff($parseResult['placeholders'], array_keys($existingConfig)), 'removed_placeholders' => array_diff(array_keys($existingConfig), $parseResult['placeholders']) ]; } catch (\Exception $e) { Log::error('重新识别占位符失败:' . $e->getMessage()); throw new \Exception('重新识别占位符失败:' . $e->getMessage()); } } /** * 更新模板Word文档 * @param int $id 模板ID * @param $file 上传的文件 * @return array * @throws \Exception */ public function updateTemplateFile(int $id, $file) { $template = $this->contractModel->find($id); if (!$template) { throw new \Exception('模板不存在'); } // 验证文件类型 $allowedTypes = ['docx', 'doc']; $extension = strtolower($file->getOriginalExtension()); if (!in_array($extension, $allowedTypes)) { throw new \Exception('只支持 .docx 和 .doc 格式的Word文档'); } // 获取文件信息 $fileSize = $file->getSize(); $realPath = $file->getRealPath(); // 验证文件大小 (ORM大10MB) $maxSize = 10 * 1024 * 1024; if ($fileSize > $maxSize) { throw new \Exception('文件大小不能超过10MB'); } // 生成文件hash防重复 $fileHash = md5_file($realPath); try { // 删除旧文件 if ($template['contract_template']) { $oldFilePath = public_path() . '/upload/' . $template['contract_template']; if (file_exists($oldFilePath) && is_file($oldFilePath)) { unlink($oldFilePath); } } // 生成保存路径 $uploadDir = 'contract_templates/' . date('Ymd'); $uploadPath = public_path() . '/upload/' . $uploadDir; // 确保目录存在 if (!is_dir($uploadPath)) { mkdir($uploadPath, 0777, true); } // 生成文件名 $fileName = md5(time() . $file->getOriginalName()) . '.' . $extension; $fullPath = $uploadPath . '/' . $fileName; $savePath = $uploadDir . '/' . $fileName; // 移动文件到目标位置 if (!move_uploaded_file($realPath, $fullPath)) { throw new \Exception('文件保存失败'); } // 解析Word文档内容和占位符 $parseResult = $this->parseWordTemplate($fullPath); // 更新数据库记录 $template->contract_template = $savePath; $template->contract_content = $parseResult['content']; $template->original_filename = $file->getOriginalName(); $template->file_size = $fileSize; $template->file_hash = $fileHash; $template->placeholders = json_encode($parseResult['placeholders']); $template->updated_at = date('Y-m-d H:i:s'); $template->save(); // 获取现有的占位符配置 $existingConfig = []; if ($template['placeholder_config']) { $existingConfig = json_decode($template['placeholder_config'], true) ?: []; } // 为新的占位符创建默认配置,保留现有配置 $newConfig = []; foreach ($parseResult['placeholders'] as $placeholder) { if (isset($existingConfig[$placeholder])) { // 保留现有配置 $newConfig[$placeholder] = $existingConfig[$placeholder]; } else { // 为新占位符创建默认配置 $newConfig[$placeholder] = [ 'data_type' => 'user_input', 'table_name' => '', 'field_name' => '', 'system_function' => '', 'user_input_value' => '', 'sign_party' => '', 'field_type' => 'text', 'is_required' => 0, 'default_value' => '' ]; } } // 更新占位符配置 if (!empty($newConfig)) { $template->placeholder_config = json_encode($newConfig); $template->save(); // 同步更新数据源配置表 $this->saveConfigToDataSourceTable($id, $newConfig); } return [ 'id' => $template->id, 'template_name' => $template->contract_name, 'file_path' => $savePath, 'placeholders' => $parseResult['placeholders'], 'placeholder_count' => count($parseResult['placeholders']), 'new_placeholders' => array_diff($parseResult['placeholders'], array_keys($existingConfig)), 'removed_placeholders' => array_diff(array_keys($existingConfig), $parseResult['placeholders']) ]; } catch (\Exception $e) { // 如果保存失败,删除已上传的文件 if (isset($fullPath) && file_exists($fullPath)) { unlink($fullPath); } throw new \Exception('模板文档更新失败:' . $e->getMessage()); } } /** * 使用XML字符串操作生成Word文档 * 此方法直接读取Word模板的XML内容,进行字符串替换,然后保存为新的Word文档 * @param array $data * @return array * @throws \Exception */ public function generateDocumentByXmlString(array $data) { $template = $this->contractModel->find($data['template_id']); if (!$template) { throw new \Exception('模板不存在'); } if (empty($template['placeholder_config'])) { throw new \Exception('模板尚未配置占位符'); } // 创建生成记录 $logData = [ 'site_id' => $this->site_id, 'template_id' => $data['template_id'], 'user_id' => $this->uid, 'user_type' => 1, 'fill_data' => json_encode($data['fill_data']), 'status' => 'pending', 'completed_at' => date('Y-m-d H:i:s') ]; $log = $this->logModel->create($logData); try { // 更新状态为处理中 $log->status = 'processing'; $log->process_start_time = date('Y-m-d H:i:s'); $log->save(); // 准备填充数据 $placeholderConfig = json_decode($template['placeholder_config'], true); if (!empty($data['use_direct_values']) && $data['use_direct_values'] === true) { $fillValues = $data['fill_data']; Log::info('使用直接填充模式(XML字符串方法)', [ 'template_id' => $data['template_id'], 'fill_data_count' => count($fillValues), 'fill_data_keys' => array_keys($fillValues) ]); } else { $fillValues = $this->prepareFillData($placeholderConfig, $data['fill_data']); } // 原始模板路径 $templatePath = public_path() . '/upload/' . $template['contract_template']; $outputFileName = $data['output_filename'] ?: ($template['contract_name'] . '_' . date('YmdHis') . '.docx'); // 生成输出路径 $outputPath = 'generated_documents/' . date('Y/m/') . $outputFileName; $publicOutputPath = public_path() . '/upload/' . $outputPath; $publicOutputDir = dirname($publicOutputPath); // 确保输出目录存在 if (!is_dir($publicOutputDir)) { if (!mkdir($publicOutputDir, 0755, true) && !is_dir($publicOutputDir)) { throw new \Exception('无法创建输出目录:' . $publicOutputDir); } } // 使用XML字符串方法生成文档 $success = $this->processWordDocumentXml($templatePath, $publicOutputPath, $fillValues); if (!$success) { throw new \Exception('XML字符串处理失败'); } // 更新生成记录 $log->status = 'completed'; $log->generated_file_path = $outputPath; $log->generated_file_name = $outputFileName; $log->process_end_time = date('Y-m-d H:i:s'); $log->save(); Log::info('XML字符串方法生成Word文档成功', [ 'template_id' => $data['template_id'], 'output_path' => $outputPath, 'fill_values_count' => count($fillValues) ]); return [ 'log_id' => $log->id, 'file_path' => $outputPath, 'file_name' => $outputFileName, 'download_url' => url('/upload/' . $outputPath) ]; } catch (\Exception $e) { // 更新记录为失败状态 $log->status = 'failed'; $log->error_msg = $e->getMessage(); $log->process_end_time = date('Y-m-d H:i:s'); $log->save(); Log::error('XML字符串方法生成Word文档失败', [ 'template_id' => $data['template_id'], 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); throw new \Exception('文档生成失败:' . $e->getMessage()); } } /** * 处理Word文档的XML内容进行占位符替换 * @param string $templatePath 模板文件路径 * @param string $outputPath 输出文件路径 * @param array $fillValues 填充值数组 * @return bool * @throws \Exception */ private function processWordDocumentXml(string $templatePath, string $outputPath, array $fillValues): bool { try { // 检查模板文件是否存在 if (!file_exists($templatePath)) { throw new \Exception('模板文件不存在:' . $templatePath); } // 创建临时工作目录 $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'word_processing_' . uniqid(); if (!mkdir($tempDir, 0755, true)) { throw new \Exception('无法创建临时工作目录'); } try { // 1. 解压Word文档到临时目录 $zip = new \ZipArchive(); $result = $zip->open($templatePath); if ($result !== TRUE) { throw new \Exception('无法打开Word模板文件,错误代码:' . $result); } if (!$zip->extractTo($tempDir)) { $zip->close(); throw new \Exception('无法解压Word模板文件'); } $zip->close(); // 2. 读取document.xml文件 $documentXmlPath = $tempDir . DIRECTORY_SEPARATOR . 'word' . DIRECTORY_SEPARATOR . 'document.xml'; if (!file_exists($documentXmlPath)) { throw new \Exception('Word文档缺少document.xml文件'); } $xmlContent = file_get_contents($documentXmlPath); if ($xmlContent === false) { throw new \Exception('无法读取document.xml文件内容'); } Log::info('读取XML内容成功', [ 'original_length' => strlen($xmlContent), 'placeholders_to_replace' => array_keys($fillValues) ]); // 3. 进行占位符替换 $modifiedXmlContent = $this->replaceXmlPlaceholders($xmlContent, $fillValues); Log::info('XML占位符替换完成', [ 'original_length' => strlen($xmlContent), 'modified_length' => strlen($modifiedXmlContent), 'size_change' => strlen($modifiedXmlContent) - strlen($xmlContent) ]); // 4. 写回修改后的document.xml if (file_put_contents($documentXmlPath, $modifiedXmlContent) === false) { throw new \Exception('无法写入修改后的document.xml'); } // 5. 重新打包为Word文档 $newZip = new \ZipArchive(); $result = $newZip->open($outputPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE); if ($result !== TRUE) { throw new \Exception('无法创建输出Word文件,错误代码:' . $result); } // 递归添加所有文件到ZIP $this->addDirectoryToZip($newZip, $tempDir, ''); if (!$newZip->close()) { throw new \Exception('无法保存输出Word文件'); } Log::info('Word文档重新打包成功', [ 'output_path' => $outputPath, 'file_size' => filesize($outputPath) ]); return true; } finally { // 清理临时目录 $this->removeDirectory($tempDir); } } catch (\Exception $e) { Log::error('处理Word文档XML失败', [ 'template_path' => $templatePath, 'output_path' => $outputPath, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); throw $e; } } /** * 在XML内容中替换占位符 * @param string $xmlContent 原始XML内容 * @param array $fillValues 替换值数组 * @return string 替换后的XML内容 */ private function replaceXmlPlaceholders(string $xmlContent, array $fillValues): string { $modifiedContent = $xmlContent; $replacementCount = 0; foreach ($fillValues as $placeholder => $value) { // 确保占位符格式正确 $searchPattern = '{{' . $placeholder . '}}'; // 转义特殊字符,确保安全的XML替换 $safeValue = htmlspecialchars((string)$value, ENT_XML1, 'UTF-8'); // 进行替换 $beforeLength = strlen($modifiedContent); $modifiedContent = str_replace($searchPattern, $safeValue, $modifiedContent, $count); $afterLength = strlen($modifiedContent); if ($count > 0) { $replacementCount += $count; Log::info('占位符替换成功', [ 'placeholder' => $placeholder, 'value' => $value, 'safe_value' => $safeValue, 'replacement_count' => $count, 'content_length_change' => $afterLength - $beforeLength ]); } else { Log::warning('占位符未找到', [ 'placeholder' => $placeholder, 'search_pattern' => $searchPattern ]); } } Log::info('所有占位符处理完成', [ 'total_replacements' => $replacementCount, 'processed_placeholders' => count($fillValues) ]); return $modifiedContent; } /** * 递归添加目录到ZIP文件 * @param \ZipArchive $zip ZIP文件对象 * @param string $sourcePath 源目录路径 * @param string $relativePath ZIP内的相对路径 * @return void */ private function addDirectoryToZip(\ZipArchive $zip, string $sourcePath, string $relativePath): void { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($sourcePath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST ); foreach ($iterator as $file) { $filePath = $file->getRealPath(); $relativeName = $relativePath . substr($filePath, strlen($sourcePath) + 1); // 在Windows系统中统一使用正斜杠 $relativeName = str_replace('\\', '/', $relativeName); if ($file->isDir()) { // 添加目录 $zip->addEmptyDir($relativeName); } else { // 添加文件 $zip->addFile($filePath, $relativeName); } } } /** * 递归删除目录 * @param string $dir 要删除的目录路径 * @return void */ private function removeDirectory(string $dir): void { if (!is_dir($dir)) { return; } $files = array_diff(scandir($dir), ['.', '..']); foreach ($files as $file) { $filePath = $dir . DIRECTORY_SEPARATOR . $file; if (is_dir($filePath)) { $this->removeDirectory($filePath); } else { unlink($filePath); } } rmdir($dir); } /** * 智能处理占位符,根据类型使用不同的处理方法 * @param TemplateProcessor $templateProcessor * @param array $fillValues * @param array $placeholderConfig * @return void */ private function processPlaceholders(TemplateProcessor $templateProcessor, array $fillValues, array $placeholderConfig) { // 如果没有配置信息,使用简单的文本替换 if (empty($placeholderConfig)) { foreach ($fillValues as $placeholder => $value) { $templateProcessor->setValue($placeholder, $value); } return; } $placeholderConfig = is_string($placeholderConfig) ? json_decode($placeholderConfig, true) : $placeholderConfig; foreach ($fillValues as $placeholder => $value) { $config = $placeholderConfig[$placeholder] ?? []; $fieldType = $config['field_type'] ?? 'text'; $dataType = $config['data_type'] ?? 'user_input'; Log::info('处理占位符', [ 'placeholder' => $placeholder, 'value' => $value, 'field_type' => $fieldType, 'data_type' => $dataType ]); try { // 根据字段类型选择处理方式 if ($fieldType === 'image' || $dataType === 'sign_img' || $dataType === 'signature') { // 处理图片类型 $this->setImageValue($templateProcessor, $placeholder, $value); } else { // 处理文本类型 $templateProcessor->setValue($placeholder, $value); } } catch (\Exception $e) { Log::error('占位符处理失败', [ 'placeholder' => $placeholder, 'value' => $value, 'error' => $e->getMessage() ]); // 如果图片处理失败,尝试作为文本处理 $templateProcessor->setValue($placeholder, $value); } } } /** * 设置图片占位符的值 * @param TemplateProcessor $templateProcessor * @param string $placeholder * @param mixed $value * @return void */ private function setImageValue(TemplateProcessor $templateProcessor, string $placeholder, $value) { if (empty($value)) { // 如果值为空,设置为空文本 $templateProcessor->setValue($placeholder, ''); return; } $imagePath = null; try { // 判断图片数据类型并处理 if (is_string($value)) { if (str_starts_with($value, 'data:image/')) { // 处理base64图片数据 $imagePath = $this->saveBase64Image($value); } elseif (str_starts_with($value, 'http://') || str_starts_with($value, 'https://')) { // 处理网络图片URL $imagePath = $this->downloadImage($value); } elseif (file_exists($value)) { // 处理本地文件路径 $imagePath = $value; } elseif (file_exists(public_path() . '/' . ltrim($value, '/'))) { // 处理相对路径 $imagePath = public_path() . '/' . ltrim($value, '/'); } } if ($imagePath && file_exists($imagePath)) { // 验证图片文件 $imageInfo = getimagesize($imagePath); if ($imageInfo === false) { throw new \Exception('无效的图片文件'); } // 设置图片,限制尺寸 $templateProcessor->setImageValue($placeholder, [ 'path' => $imagePath, 'width' => 100, // 可以根据需要调整 'height' => 100, 'ratio' => true ]); Log::info('图片占位符设置成功', [ 'placeholder' => $placeholder, 'image_path' => $imagePath, 'image_size' => $imageInfo ]); // 如果是临时文件,标记稍后删除 if (str_contains($imagePath, sys_get_temp_dir())) { register_shutdown_function(function() use ($imagePath) { if (file_exists($imagePath)) { @unlink($imagePath); } }); } } else { // 如果无法处理为图片,使用文本替换 $templateProcessor->setValue($placeholder, $value); Log::warning('图片处理失败,使用文本替换', [ 'placeholder' => $placeholder, 'value' => $value ]); } } catch (\Exception $e) { Log::error('图片设置失败', [ 'placeholder' => $placeholder, 'value' => $value, 'error' => $e->getMessage() ]); // 如果图片处理失败,使用文本替换 $templateProcessor->setValue($placeholder, $value); } } /** * 保存base64图片数据到临时文件 * @param string $base64Data * @return string|null */ private function saveBase64Image(string $base64Data): ?string { try { // 解析base64数据 if (preg_match('/^data:image\/(\w+);base64,(.+)$/', $base64Data, $matches)) { $imageType = $matches[1]; $imageData = base64_decode($matches[2]); if ($imageData === false) { throw new \Exception('base64解码失败'); } // 创建临时文件 $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_images'; if (!is_dir($tempDir)) { mkdir($tempDir, 0755, true); } $fileName = uniqid('img_') . '.' . $imageType; $filePath = $tempDir . DIRECTORY_SEPARATOR . $fileName; if (file_put_contents($filePath, $imageData) === false) { throw new \Exception('保存图片文件失败'); } return $filePath; } } catch (\Exception $e) { Log::error('保存base64图片失败:' . $e->getMessage()); } return null; } /** * 下载网络图片到临时文件 * @param string $url * @return string|null */ private function downloadImage(string $url): ?string { try { // 创建临时文件 $tempDir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'niucloud_images'; if (!is_dir($tempDir)) { mkdir($tempDir, 0755, true); } $pathInfo = pathinfo(parse_url($url, PHP_URL_PATH)); $extension = $pathInfo['extension'] ?? 'jpg'; $fileName = uniqid('img_') . '.' . $extension; $filePath = $tempDir . DIRECTORY_SEPARATOR . $fileName; // 下载图片 $context = stream_context_create([ 'http' => [ 'timeout' => 30, 'user_agent' => 'Mozilla/5.0 (compatible; Document Generator)', ] ]); $imageData = file_get_contents($url, false, $context); if ($imageData === false) { throw new \Exception('下载图片失败'); } if (file_put_contents($filePath, $imageData) === false) { throw new \Exception('保存图片文件失败'); } // 验证是否为有效图片 if (getimagesize($filePath) === false) { unlink($filePath); throw new \Exception('下载的文件不是有效图片'); } return $filePath; } catch (\Exception $e) { Log::error('下载图片失败:' . $e->getMessage(), ['url' => $url]); } return null; } }