> ' . escapeshellarg($log) . ' 2>&1 &'); } return true; } /** * 处理一条识版任务。 * @param int $id * @return array 任务行 * @throws Exception */ public static function run($id) { $id = (int)$id; $job = Db::name('ps_ai_job')->where('id', $id)->find(); if (!$job) { throw new Exception('识版任务不存在'); } @set_time_limit(1200); @ini_set('memory_limit', '512M'); self::keepDbAlive(); Db::name('ps_ai_job')->where('id', $id)->update([ 'status' => 'running', 'error_msg' => '', 'updatetime' => time(), ]); try { $path = self::resolveFilePath($job['file_url']); $client = LlmClient::forRecognize(); $local = LlmClient::local(); $model = trim((string)$job['model']); $fileType = $job['file_type'] === 'pdf' ? 'pdf' : 'image'; $raw = ''; $usedModel = $model; $parsed = ['cells' => []]; if ($fileType === 'pdf') { $imagePath = self::pdfFirstPageImage($path); if ($imagePath) { $got = self::extractLayout($client, $imagePath, $model); @unlink($imagePath); $raw = $got['raw']; $usedModel = $got['model']; $parsed = $got['schema']; } else { $text = self::pdfPlainText($path); if ($text === '') { throw new Exception('当前环境无法解析 PDF,请改传流程单图片(JPG/PNG)'); } $raw = $local->chat(self::promptCsv() . "\n\n以下是 PDF 抽取的文字:\n" . mb_substr($text, 0, 8000), $local->textModel(), false); $usedModel = $local->textModel(); $parsed = self::parseLayoutRaw($raw); } } else { $got = self::extractLayout($client, $path, $model); $raw = $got['raw']; $usedModel = $got['model']; $parsed = $got['schema']; } self::keepDbAlive(); $rawStore = mb_substr((string)$raw, 0, 60000); Db::name('ps_ai_job')->where('id', $id)->update([ 'model' => $usedModel, 'raw_text' => $rawStore, 'updatetime' => time(), ]); $schema = self::normalizeSchema($parsed, $job, $path); $templateId = self::saveTemplate($job, $schema); self::keepDbAlive(); Db::name('ps_ai_job')->where('id', $id)->update([ 'status' => 'success', 'model' => $usedModel, 'raw_text' => $rawStore, 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE), 'template_id' => $templateId, 'error_msg' => '', 'updatetime' => time(), ]); } catch (\Exception $e) { try { self::keepDbAlive(); Db::name('ps_ai_job')->where('id', $id)->update([ 'status' => 'fail', 'error_msg' => mb_substr($e->getMessage(), 0, 500), 'updatetime' => time(), ]); } catch (\Exception $ignore) { // 断线时至少不要让 CLI 再抛一次把状态钉死在 running } $job = ['id' => $id, 'status' => 'fail', 'error_msg' => $e->getMessage()]; try { $job = Db::name('ps_ai_job')->where('id', $id)->find() ?: $job; } catch (\Exception $ignore) { } $job['status'] = 'fail'; $job['error_msg'] = $e->getMessage(); return $job; } self::keepDbAlive(); return Db::name('ps_ai_job')->where('id', $id)->find(); } /** * @param int $limit * @return int[] */ public static function pendingIds($limit = 1) { $rows = Db::name('ps_ai_job') ->where('status', 'pending') ->order('id asc') ->limit(max(1, (int)$limit)) ->column('id'); return array_map('intval', $rows ?: []); } /** * 把上传返回的完整网址收成站内相对路径。 * @param string $fileUrl * @return string */ public static function localFileUrl($fileUrl) { $fileUrl = trim((string)$fileUrl); if ($fileUrl === '') { return ''; } if (preg_match('#^https?://[^/]+(/.*)$#i', $fileUrl, $m)) { $fileUrl = $m[1]; } $fileUrl = preg_replace('#\?.*$#', '', $fileUrl); return '/' . ltrim(str_replace('\\', '/', $fileUrl), '/'); } /** * @param string $fileUrl * @return string * @throws Exception */ protected static function resolveFilePath($fileUrl) { $fileUrl = self::localFileUrl($fileUrl); if ($fileUrl === '' || $fileUrl === '/') { throw new Exception('原稿文件为空'); } if (preg_match('#^https?://#i', $fileUrl)) { throw new Exception('暂不支持远程 URL,请先上传到本站'); } $relative = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, ltrim($fileUrl, '/\\')); $candidates = [ ROOT_PATH . 'public' . DIRECTORY_SEPARATOR . $relative, ROOT_PATH . $relative, ]; foreach ($candidates as $path) { if (is_file($path)) { return $path; } } throw new Exception('原稿文件不存在:' . $fileUrl); } /** * @param string $pdfPath * @return string 临时图片路径,失败返回空 */ protected static function pdfFirstPageImage($pdfPath) { if (!class_exists('\Imagick')) { return ''; } try { $im = new \Imagick(); $im->setResolution(120, 120); $im->readImage($pdfPath . '[0]'); $im->setImageFormat('jpeg'); $tmp = tempnam(sys_get_temp_dir(), 'psai') . '.jpg'; $im->writeImage($tmp); $im->clear(); $im->destroy(); return is_file($tmp) ? $tmp : ''; } catch (\Exception $e) { return ''; } } /** * @param string $pdfPath * @return string */ protected static function pdfPlainText($pdfPath) { $bin = IS_WIN ? 'pdftotext.exe' : 'pdftotext'; $tmp = tempnam(sys_get_temp_dir(), 'pstxt'); @exec(escapeshellarg($bin) . ' -enc UTF-8 ' . escapeshellarg($pdfPath) . ' ' . escapeshellarg($tmp), $out, $code); if ($code === 0 && is_file($tmp)) { $text = (string)file_get_contents($tmp); @unlink($tmp); return trim($text); } @unlink($tmp); return ''; } /** * @param array $schema * @param array $job * @param string $imagePath * @return array */ protected static function normalizeSchema(array $schema, array $job, $imagePath = '') { $name = trim((string)($schema['name'] ?? $schema['title'] ?? '')); $fileUrl = (string)$job['file_url']; $probe = is_file($imagePath) ? LayoutProbe::inspect($imagePath) : ['width' => 0, 'height' => 0, 'background' => '#ffffff', 'ratio' => 297 / 210]; $rawItems = PrintPlanner::collectLayoutItems($schema); $elements = PrintPlanner::normalizeElements($rawItems, (int)$probe['width'], (int)$probe['height']); if (!$elements && !empty($schema['fields'])) { $elements = PrintPlanner::elementsFromFields($schema['fields']); } if ($name === '' || $name === '模板名') { foreach ($elements as $el) { if (trim((string)($el['text'] ?? '')) !== '') { $name = mb_substr(trim($el['text']), 0, 40); break; } } if ($name === '' || $name === '模板名') { $name = 'AI识版 ' . date('Y-m-d H:i'); } } $processes = self::normalizeProcesses($schema['processes'] ?? []); $title = trim((string)($schema['title'] ?? $name)); $pageIn = isset($schema['page']) && is_array($schema['page']) ? $schema['page'] : []; $pageW = 210; $ratio = $probe['ratio'] > 0 ? $probe['ratio'] : 297 / 210; $pageH = isset($pageIn['h']) ? (float)$pageIn['h'] : round(210 * $ratio, 2); $pageBg = $pageIn['background'] ?? $probe['background']; $cells = []; foreach ($elements as $el) { if (($el['key'] ?? '') !== '' || ($el['role'] ?? '') === 'fill' || ($el['type'] ?? '') === 'field') { $cells[] = [ 'id' => $el['id'], 'key' => $el['key'], 'label' => $el['label'], 'x' => $el['x'], 'y' => $el['y'], 'w' => $el['w'], 'h' => $el['h'], ]; } } return [ 'name' => mb_substr($name, 0, 100), 'title' => $title, 'mode' => 'overlay', 'page' => [ 'w' => $pageW, 'h' => $pageH > 0 ? $pageH : round(210 * $ratio, 2), 'background' => $pageBg ?: '#ffffff', 'image_width' => (int)$probe['width'], 'image_height' => (int)$probe['height'], ], 'elements' => $elements, 'cells' => $cells, 'fields' => [], 'processes' => $processes, 'background' => $fileUrl, 'file_url' => $fileUrl, 'source' => 'ai', ]; } /** * @param array $job * @param array $schema * @return int */ protected static function saveTemplate(array $job, array $schema) { $now = time(); $factoryId = (int)$job['factory_id']; $userId = (int)$job['user_id']; $cover = (string)$job['file_url']; $existId = (int)($job['template_id'] ?? 0); if ($existId > 0) { $exist = Db::name('ps_template') ->where('id', $existId) ->where('factory_id', $factoryId) ->where('is_system', 0) ->find(); if ($exist) { $next = (int)Db::name('ps_template_version')->where('template_id', $existId)->max('version') + 1; if ($next < 1) { $next = 1; } $versionId = (int)Db::name('ps_template_version')->insertGetId([ 'template_id' => $existId, 'factory_id' => $factoryId, 'version' => $next, 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE), 'remark' => 'AI重新拆版', 'createtime' => $now, ]); Db::name('ps_template')->where('id', $existId)->update([ 'name' => $schema['name'], 'cover' => $cover, 'current_version_id' => $versionId, 'updatetime' => $now, ]); return $existId; } } $templateId = (int)Db::name('ps_template')->insertGetId([ 'factory_id' => $factoryId, 'creator_user_id' => $userId, 'name' => $schema['name'], 'cover' => $cover, 'source' => 'ai', 'license_type' => 'self', 'locked' => 0, 'source_template_id' => 0, 'source_version_id' => 0, 'current_version_id' => 0, 'is_system' => 0, 'status' => 'normal', 'createtime' => $now, 'updatetime' => $now, ]); $versionId = (int)Db::name('ps_template_version')->insertGetId([ 'template_id' => $templateId, 'factory_id' => $factoryId, 'version' => 1, 'schema_json' => json_encode($schema, JSON_UNESCAPED_UNICODE), 'remark' => 'AI识版自动入库', 'createtime' => $now, ]); Db::name('ps_template')->where('id', $templateId)->update([ 'current_version_id' => $versionId, 'updatetime' => $now, ]); return $templateId; } /** * @param mixed $fields * @return string[] */ protected static function normalizeFields($fields) { if (!is_array($fields)) { return ['factory.name', 'product.name', 'workorder.no', 'sheet.qty']; } $alias = [ '产品名称' => 'product.name', '产品' => 'product.name', '品名' => 'product.name', '产品编码' => 'product.code', '编码' => 'product.code', '货号' => 'product.code', '规格' => 'product.spec', '单位' => 'product.unit', '工厂' => 'factory.name', '厂名' => 'factory.name', '工单号' => 'workorder.no', '单号' => 'workorder.no', '流程单号' => 'workorder.no', '数量' => 'workorder.qty', '本张数量' => 'sheet.qty', '投料' => 'sheet.qty', '批次' => 'workorder.batch_no', '客户' => 'workorder.customer', '日期' => 'workorder.plan_date', '计划日' => 'workorder.plan_date', '备注' => 'workorder.remark', '操作员' => 'operator', '时间' => 'print_time', ]; $allowed = [ 'factory.name', 'factory.code', 'factory.contact', 'factory.mobile', 'product.code', 'product.name', 'product.spec', 'product.unit', 'workorder.no', 'workorder.qty', 'workorder.batch_no', 'workorder.plan_date', 'workorder.customer', 'workorder.remark', 'sheet.no', 'sheet.total', 'sheet.qty', 'sheet.no_text', 'process_names', 'operator', 'print_time', ]; $out = []; foreach ($fields as $item) { if (is_array($item)) { $item = $item['key'] ?? $item['name'] ?? ''; } $item = trim((string)$item); if (isset($alias[$item])) { $item = $alias[$item]; } if (in_array($item, $allowed, true) && !in_array($item, $out, true)) { $out[] = $item; } } return $out ?: ['factory.name', 'product.name', 'workorder.no', 'sheet.qty']; } /** * @param mixed $processes * @return array */ protected static function normalizeProcesses($processes) { if (!is_array($processes)) { return []; } $list = []; $seq = 1; foreach ($processes as $row) { if (is_string($row) && trim($row) !== '') { $list[] = [ 'seq' => $seq++, 'name' => trim($row), 'workstation' => '', 'standard_time' => '', 'qc_points' => '', ]; continue; } if (!is_array($row) || empty($row['name'])) { continue; } $list[] = [ 'seq' => isset($row['seq']) ? (int)$row['seq'] : $seq++, 'name' => (string)$row['name'], 'workstation' => isset($row['workstation']) ? (string)$row['workstation'] : '', 'standard_time' => isset($row['standard_time']) ? (string)$row['standard_time'] : '', 'qc_points' => isset($row['qc_points']) ? (string)$row['qc_points'] : '', ]; } return $list; } /** * @param array $schema * @return string */ protected static function fallbackHtml(array $schema) { $title = htmlspecialchars((string)($schema['title'] ?? $schema['name'] ?? '生产流程单'), ENT_QUOTES, 'UTF-8'); return '
' . '

' . $title . '

' . '
第 {sheet.no_text} 张
' . '' . '' . '' . '' . '' . '' . '
工厂{factory.name}工单号{workorder.no}
产品{product.name}编码{product.code}
规格{product.spec}单位{product.unit}
批次{workorder.batch_no}客户{workorder.customer}
本张数量{sheet.qty}操作员{operator}
' . '

工序

{processes.html}' . '

备注:{workorder.remark}

' . '

打印时间:{print_time}

' . '
'; } /** * 拆版:线上视觉优先;本地只作简单备用(横条切图、弱模型 CSV)。 * * @param LlmClient $client * @param string $path * @param string|null $preferredModel * @return array {raw, schema, model, score} * @throws Exception */ protected static function extractLayout(LlmClient $client, $path, $preferredModel = null) { $preferredModel = trim((string)$preferredModel); $online = LlmClient::online(); $local = LlmClient::local(); $best = ['raw' => '', 'schema' => ['cells' => []], 'model' => $preferredModel, 'score' => -1]; $preferLocal = LlmClient::isLocalModelName($preferredModel); if (!$preferLocal && $online->enabled()) { $model = ($preferredModel !== '' && !LlmClient::isLocalModelName($preferredModel)) ? $preferredModel : $online->visionModel(); try { $raw = $online->visionRaw(self::promptOnline(), $path, $model, true); $schema = self::parseLayoutRaw($raw); $score = self::layoutScore($schema); $best = ['raw' => $raw, 'schema' => $schema, 'model' => $model, 'score' => $score]; if ($score >= 3) { return $best; } } catch (\Exception $e) { throw new Exception('线上识版失败:' . $e->getMessage()); } throw new Exception('线上识版没有读出完整格子。请核对视觉模型名(需带视觉能力)。'); } $models = []; if ($preferLocal && $preferredModel !== '') { $models[] = $preferredModel; } foreach ([$local->visionModel(), $local->visionModelComplex()] as $m) { if ($m !== '' && !in_array($m, $models, true)) { $models[] = $m; } } if (!$models) { if ($best['score'] >= 3) { return $best; } throw new Exception('未配置视觉模型。请在后台「大模型设置」填写线上视觉模型,或保留本地 Ollama 视觉模型。'); } foreach ($models as $model) { $raw = $local->visionRaw(self::promptCsv(), $path, $model, false); $schema = self::parseLayoutRaw($raw); $score = self::layoutScore($schema); if ($score > $best['score']) { $best = ['raw' => $raw, 'schema' => $schema, 'model' => $model, 'score' => $score]; } if ($score < 8) { $tiled = self::extractTiles($local, $path, $model); if ($tiled['score'] > $best['score']) { $best = $tiled; } } if ($best['score'] >= 12) { break; } } if ($best['score'] < 3) { if ($online->enabled()) { throw new Exception('线上识版没有读出完整格子。请核对 API Key、接口地址和视觉模型名(需带视觉能力,如 qwen-vl-max / gpt-4o)。'); } throw new Exception('当前本地视觉模型读不出流程单格子。请在后台「大模型设置」启用线上视觉模型后再拆版。'); } return $best; } /** * @param LlmClient $client * @param string $path * @param string $model * @return array */ protected static function extractTiles(LlmClient $client, $path, $model) { $bands = self::splitImageBands($path, 3); if (!$bands) { return ['raw' => '', 'schema' => ['cells' => []], 'model' => $model, 'score' => 0]; } $cells = []; $rawParts = []; foreach ($bands as $i => $band) { try { $raw = $client->visionRaw(self::promptTile($i + 1, count($bands)), $band['path'], $model, false); } catch (\Exception $e) { $raw = ''; } $rawParts[] = '---band' . ($i + 1) . "---\n" . $raw; $part = self::parseLayoutRaw($raw); foreach (($part['cells'] ?? []) as $cell) { $y = (float)($cell['y'] ?? 0); $h = (float)($cell['h'] ?? 4); $cell['y'] = round(($band['y0'] + $y / 100 * $band['h']) / $band['fullH'] * 100, 2); $cell['h'] = max(1.2, round($h * $band['h'] / $band['fullH'], 2)); $cells[] = $cell; } if (is_file($band['path'])) { @unlink($band['path']); } } $schema = ['name' => '', 'cells' => $cells]; return [ 'raw' => implode("\n", $rawParts), 'schema' => $schema, 'model' => $model, 'score' => self::layoutScore($schema), ]; } /** * @param string $path * @param int $bands * @return array */ protected static function splitImageBands($path, $bands = 4) { if (!is_file($path) || !function_exists('imagecreatefromstring')) { return []; } $bin = @file_get_contents($path); $im = $bin ? @imagecreatefromstring($bin) : false; if (!$im) { return []; } $w = imagesx($im); $h = imagesy($im); $overlap = (int)($h * 0.08); $bandH = (int)ceil($h / $bands); $out = []; for ($i = 0; $i < $bands; $i++) { $y = max(0, $i * $bandH - ($i ? $overlap : 0)); $bh = min($h - $y, $bandH + $overlap); if ($bh < 20) { continue; } $dst = imagecreatetruecolor($w, $bh); imagecopy($dst, $im, 0, 0, 0, $y, $w, $bh); $tmp = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'psband_' . $i . '_' . uniqid() . '.jpg'; imagejpeg($dst, $tmp, 86); imagedestroy($dst); $out[] = ['path' => $tmp, 'y0' => $y, 'h' => $bh, 'fullH' => $h, 'fullW' => $w]; } imagedestroy($im); return $out; } /** * @param string $raw * @return array */ protected static function parseLayoutRaw($raw) { $raw = trim((string)$raw); if ($raw !== '' && (strpos($raw, '{') !== false || strpos($raw, '[') !== false)) { try { $json = LlmClient::extractJson($raw); if (is_array($json) && (!empty($json['cells']) || !empty($json['elements']) || !empty($json['fields']))) { if (self::layoutScore($json) >= 3) { return $json; } } } catch (\Exception $e) { // CSV / 非 JSON 走下面的管道行解析 } } $cells = []; foreach (preg_split("/\r\n|\n|\r/", $raw) as $line) { $line = trim($line); if ($line === '' || strpos($line, '|') === false) { continue; } $line = ltrim($line, "-* \t"); $parts = array_map('trim', explode('|', $line)); if (count($parts) < 6) { continue; } $head = mb_strtolower($parts[0]); if (in_array($head, ['text', '文字', '内容'], true)) { continue; } $x = self::toPct($parts[1]); $y = self::toPct($parts[2]); $w = self::toPct($parts[3]); $h = self::toPct($parts[4]); if ($w <= 0 || $h <= 0) { continue; } $role = mb_strtolower($parts[5]); $role = (strpos($role, 'fill') !== false || $role === '空' || $role === '填写') ? 'fill' : 'label'; $key = isset($parts[6]) ? trim($parts[6]) : ''; $color = isset($parts[7]) && preg_match('/^#?[0-9a-fA-F]{3,8}$/', $parts[7]) ? $parts[7] : '#000000'; if ($color && $color[0] !== '#') { $color = '#' . $color; } $bg = isset($parts[8]) && preg_match('/^#?[0-9a-fA-F]{3,8}$/', $parts[8]) ? $parts[8] : '#ffffff'; if ($bg && $bg[0] !== '#') { $bg = '#' . $bg; } $fontSize = isset($parts[9]) ? (int)$parts[9] : 13; $cells[] = [ 'text' => $parts[0], 'x' => $x, 'y' => $y, 'w' => $w, 'h' => $h, 'role' => $role, 'key' => $role === 'fill' ? $key : '', 'fontSize' => $fontSize > 0 ? $fontSize : 13, 'color' => $color ?: '#000000', 'background' => $bg ?: '#ffffff', 'borders' => 'tblr', ]; } return ['cells' => $cells]; } /** * @param mixed $v * @return float */ protected static function toPct($v) { $n = (float)preg_replace('/[^0-9.\-]/', '', (string)$v); if ($n < 0) { $n = 0; } if ($n > 100) { $n = 100; } return round($n, 2); } /** * @param array $schema * @return int */ protected static function layoutScore(array $schema) { $items = []; foreach (['cells', 'elements', 'fields'] as $k) { if (!empty($schema[$k]) && is_array($schema[$k])) { $items = array_merge($items, $schema[$k]); } } $n = count($items); if ($n <= 2) { $texts = []; foreach ($items as $it) { $texts[] = trim((string)($it['text'] ?? $it['key'] ?? '')); } $joined = implode(' ', $texts); if (mb_strpos($joined, '产品名称') !== false && $n <= 2) { return 0; } } $uniq = []; foreach ($items as $it) { $t = trim((string)($it['text'] ?? $it['key'] ?? $it['label'] ?? '')); if ($t !== '') { $uniq[$t] = 1; } else { $uniq['fill:' . ($it['x'] ?? '') . ',' . ($it['y'] ?? '')] = 1; } } return count($uniq); } /** * @return string */ protected static function promptCsv() { return <<<'PROMPT' 看这张印刷流程单照片,只找出空白填写格(还没写字的空格子)。不要解释,不要 JSON,不要抄示例。已印好的标题不要列出,它们会留在底图上。 每行格式: 旁标题|x|y|宽|高|fill|字段key|#111111|#transparent|13 规则: - x y 宽 高是空格相对整张图的百分比,0 到 100 - 根据格子旁边已印好的标题猜字段key,只能从这些选:factory.name product.code product.name product.spec product.unit workorder.no workorder.qty workorder.batch_no workorder.plan_date workorder.customer workorder.remark sheet.qty sheet.no_text process_names operator print_time ;对不上就留空 - 至少列出图上所有空格,禁止只写一两行 PROMPT; } /** * @param int $index * @param int $total * @return string */ protected static function promptTile($index, $total) { return '这是流程单的第' . $index . '/' . $total . "横条局部。只列出空白填写格,不要抄示例,不要 JSON。每行:旁标题|x|y|宽|高|fill|字段key|#111111|#transparent|13\n" . "x y 宽 高是本张局部图的百分比 0-100。已印好的字不要列。"; } /** * 线上视觉模型用 JSON。不要给可抄的两格示例。 * @return string */ protected static function promptOnline() { return <<<'PROMPT' 你是流程单套打定位引擎。只输出 JSON,不要解释、不要 Markdown。 打印时会把这张原图当底纸,只在空白格填工单/产品数据。因此只标出空白填写格,不要拆已印好的标题和线框。 根据空格旁边的印字猜测 key。x/y/w/h 是相对整张图的百分比 0~100。 key 只能从这些选,对不上就留空: factory.name product.code product.name product.spec product.unit workorder.no workorder.qty workorder.batch_no workorder.plan_date workorder.customer workorder.remark sheet.qty sheet.no_text process_names operator print_time 把图上所有空格都列出来。 {"name":"单据标题","mode":"overlay","cells":[{"text":"","x":10,"y":8,"w":18,"h":4,"role":"fill","key":"workorder.no","label":"工单号","fontSize":13,"align":"left","color":"#111111"}]} PROMPT; } /** * 识版会打很久,phpstudy 的 MySQL wait_timeout=120,必须先拉长会话再重连。 */ protected static function keepDbAlive() { try { Db::execute('SELECT 1'); } catch (\Exception $e) { Db::connect([], true); } try { Db::execute('SET SESSION wait_timeout=3600, SESSION interactive_timeout=3600'); } catch (\Exception $e) { // ignore } } /** * Windows 下 php-cgi 里 popen(start /B) 会等到子进程结束,Nginx 60 秒就 504。 * 必须真正脱离当前 FastCGI 请求。 * @param string $cwd * @param string $cmd * @param string $log * @param int $id */ protected static function spawnWindows($cwd, $cmd, $log, $id) { $line = 'cd /d "' . $cwd . '" && ' . $cmd . ' >> "' . $log . '" 2>&1'; if (class_exists('COM', false)) { try { $wsh = new \COM('WScript.Shell'); $wsh->Run('cmd /c ' . $line, 0, false); return; } catch (\Exception $e) { // 走 start 兜底 } } $bat = RUNTIME_PATH . 'ai_job_' . (int)$id . '.cmd'; @file_put_contents($bat, "@echo off\r\n" . $line . "\r\n"); if (is_file($bat)) { pclose(popen('start "ps-ai-' . (int)$id . '" /B "' . $bat . '"', 'r')); return; } pclose(popen('start "ps-ai-' . (int)$id . '" /B cmd /c "' . str_replace('"', '""', $line) . '"', 'r')); } /** * @return string */ protected static function phpBinary() { $php = PHP_BINARY ?: ''; if ($php && stripos($php, 'cgi') === false && stripos($php, 'fpm') === false && is_file($php)) { return $php; } $dir = $php ? dirname($php) : ''; $candidates = []; if ($dir) { $candidates[] = $dir . DIRECTORY_SEPARATOR . 'php.exe'; $candidates[] = $dir . DIRECTORY_SEPARATOR . 'php'; } $candidates[] = 'D:\\phpstudy_pro\\Extensions\\php\\php7.4.3nts\\php.exe'; foreach ($candidates as $candidate) { if ($candidate && is_file($candidate)) { return $candidate; } } return 'php'; } }