json([ 'success' => true, 'data' => [ 'sprints' => $this->jiraService->getTestMailSprintOptions()->all(), 'defaults' => $this->jiraService->getTestMailTemplateDefaults(), ], ]); } public function data(Request $request): JsonResponse { $request->validate([ 'sprint' => 'required|string|max:50', ]); $sprint = trim((string) $request->query('sprint')); $issues = $this->jiraService->getSprintTestIssues($sprint); $period = $this->jiraService->resolveTestMailSprintPeriod($sprint, $issues); return response()->json([ 'success' => true, 'data' => [ 'sprint' => $sprint, 'sprint_period' => $period, 'suggested_subject' => $this->buildTestMailSubject($period ?: 'Sprint'.$sprint), 'jql' => sprintf('project in (WP, AM, TP) AND issuetype = Story AND status in (开发中, 测试中, 需求调研中, 需求已调研, 需求已评审, 已提测, 待上线, 需求已排期, 待提测, 产品验收) AND Sprint = %s ORDER BY priority DESC, cf[10004] ASC, key ASC', $sprint), 'issues' => $issues->values()->all(), 'defaults' => $this->jiraService->getTestMailTemplateDefaults(), 'sprints' => $this->jiraService->getTestMailSprintOptions()->all(), ], ]); } public function draftSections(Request $request): JsonResponse { $validated = $request->validate([ 'issues' => 'array', 'issues.*.key' => 'nullable|string|max:50', 'issues.*.summary' => 'nullable|string|max:500', 'issues.*.project_key' => 'nullable|string|max:50', 'issues.*.project_name' => 'nullable|string|max:120', 'issues.*.developer' => 'nullable|string|max:120', 'issues.*.assignee' => 'nullable|string|max:120', 'issues.*.requirement_type' => 'nullable|string|max:120', 'tech_docs' => 'nullable|string|max:2000', 'selected_containers' => 'array', 'selected_containers.*' => 'string|max:120', 'databases' => 'array', 'databases.*.system' => 'nullable|string|max:50', 'databases.*.has_database' => 'nullable|string|max:50', 'databases.*.branch' => 'nullable|string|max:160', ]); $fallback = $this->buildRuleBasedDraftSections($validated); $source = 'rules'; if ($this->aiService->isConfigured()) { try { $aiDraft = $this->buildAiDraftSections($validated); if (! empty($aiDraft['test_notes']) || ! empty($aiDraft['risks'])) { $fallback = array_replace($fallback, array_filter($aiDraft)); $source = 'ai'; } } catch (\Throwable) { $source = 'rules'; } } return response()->json([ 'success' => true, 'data' => [ 'test_notes' => $fallback['test_notes'], 'risks' => $fallback['risks'], 'source' => $source, ], ]); } public function databases(Request $request): JsonResponse { $validated = $request->validate([ 'selected_groups' => 'array', 'selected_groups.*' => 'string', 'versions' => 'array', ]); return response()->json([ 'success' => true, 'data' => [ 'databases' => $this->jiraService->buildTestMailDatabases( $validated['selected_groups'] ?? [], $validated['versions'] ?? [] ), ], ]); } public function download(Request $request) { $validated = $request->validate([ 'subject' => 'required|string|max:255', 'from' => 'nullable|string|max:255', 'to' => 'nullable|string', 'cc' => 'nullable|string', 'html' => 'required|string', 'text' => 'nullable|string', 'images' => 'array', 'images.*.cid' => 'required|string|max:180', 'images.*.name' => 'nullable|string|max:180', 'images.*.dataUrl' => 'required|string', ]); $subject = $validated['subject']; $eml = $this->buildEml( $subject, $validated['html'], $validated['text'] ?? $this->htmlToText($validated['html']), $validated['images'] ?? [], $validated['from'] ?: '万文山 ', $validated['to'] ?? '', $validated['cc'] ?? '' ); $filename = Str::slug(str_replace(['【', '】'], '', $subject), '_'); if ($filename === '') { $filename = 'test_mail'; } return response($eml) ->header('Content-Type', 'message/rfc822; charset=UTF-8') ->header('Content-Disposition', 'attachment; filename="'.$filename.'.eml"'); } public function openDraft(Request $request): JsonResponse { $validated = $request->validate([ 'subject' => 'required|string|max:255', 'from' => 'nullable|string|max:255', 'to' => 'required|string', 'cc' => 'nullable|string', 'html' => 'required|string', 'text' => 'required|string', 'images' => 'array', 'images.*.cid' => 'required|string|max:180', 'images.*.name' => 'nullable|string|max:180', 'images.*.dataUrl' => 'required|string', ]); $thunderbird = trim((string) shell_exec('command -v thunderbird 2>/dev/null')); if ($thunderbird === '') { return response()->json([ 'success' => false, 'message' => '未找到 thunderbird 命令', ], 422); } $draftDir = storage_path('app/test-mail-drafts'); if (! is_dir($draftDir)) { mkdir($draftDir, 0775, true); } $html = $this->inlineDraftImages($validated['html'], $validated['images'] ?? []); $filename = (Str::slug(str_replace(['【', '】'], '', $validated['subject']), '_') ?: 'test_mail').'_'.date('Ymd_His').'.html'; $draftPath = $draftDir.DIRECTORY_SEPARATOR.$filename; file_put_contents($draftPath, $html); $compose = implode(',', array_filter([ ! empty($validated['from']) ? 'from='.$this->thunderbirdComposeValue($validated['from']) : null, 'to='.$this->thunderbirdComposeValue($validated['to']), ! empty($validated['cc']) ? 'cc='.$this->thunderbirdComposeValue($validated['cc']) : null, 'subject='.$this->thunderbirdComposeValue($validated['subject']), 'message='.$this->thunderbirdComposeValue($draftPath), 'format=html', ])); $uid = function_exists('posix_getuid') ? (string) posix_getuid() : trim((string) shell_exec('id -u 2>/dev/null')); $xdgRuntimeDir = getenv('XDG_RUNTIME_DIR') ?: ($uid !== '' ? '/run/user/'.$uid : ''); $env = array_filter([ 'DISPLAY='.(getenv('DISPLAY') ?: ':0'), 'WAYLAND_DISPLAY='.(getenv('WAYLAND_DISPLAY') ?: 'wayland-0'), $xdgRuntimeDir !== '' ? 'XDG_RUNTIME_DIR='.$xdgRuntimeDir : null, 'DBUS_SESSION_BUS_ADDRESS='.(getenv('DBUS_SESSION_BUS_ADDRESS') ?: ($xdgRuntimeDir !== '' ? 'unix:path='.$xdgRuntimeDir.'/bus' : '')), ]); $command = sprintf( 'timeout 10s env %s %s -compose %s 2>&1', implode(' ', array_map('escapeshellarg', $env)), escapeshellarg($thunderbird), escapeshellarg($compose) ); exec($command, $output, $code); return response()->json([ 'success' => $code === 0, 'message' => $code === 0 ? '已向 Thunderbird 发送打开 HTML 邮件草稿请求' : ('打开 Thunderbird 失败:'.trim(implode("\n", $output))), ], $code === 0 ? 200 : 500); } private function buildTestMailSubject(string $period): string { return sprintf('【提测】%s需求提测(SP、PP、TP)', $period); } private function buildRuleBasedDraftSections(array $context): array { $issues = collect($context['issues'] ?? []); $containers = collect($context['selected_containers'] ?? [])->filter()->values(); $databases = collect($context['databases'] ?? [])->filter(fn ($row) => ($row['has_database'] ?? '') === '有')->values(); $owners = $issues->pluck('developer')->merge($issues->pluck('assignee'))->filter()->unique()->values(); $testNotes = []; if (trim((string) ($context['tech_docs'] ?? '')) !== '' && trim((string) ($context['tech_docs'] ?? '')) !== '无') { $testNotes[] = [ 'type' => '测试注意事项', 'issue' => $this->summarizeIssueKeys($issues), 'system' => 'SP/PP/TP', 'content' => '测试前请先阅读技术文档,重点关注接口、配置和兼容性说明。', 'owner' => $owners->first() ?? '', ]; } if ($containers->isNotEmpty()) { $testNotes[] = [ 'type' => '其他依赖项', 'issue' => $this->summarizeIssueKeys($issues), 'system' => $containers->take(4)->implode(', '), 'content' => '请确认本次涉及容器已部署对应版本,部署完成后再开始主流程验证。', 'owner' => $owners->first() ?? '', ]; } foreach ($databases as $database) { $testNotes[] = [ 'type' => '脚本', 'issue' => $this->summarizeIssueKeys($issues), 'system' => $database['system'] ?? '', 'content' => '请确认数据库脚本分支已合入并执行:'.($database['branch'] ?? ''), 'owner' => $owners->first() ?? '', ]; } if ($testNotes === []) { $testNotes[] = [ 'type' => '测试注意事项', 'issue' => $this->summarizeIssueKeys($issues), 'system' => 'SP/PP/TP', 'content' => '按本次提测需求逐项回归,重点覆盖新增流程、状态流转和权限边界。', 'owner' => $owners->first() ?? '', ]; } $risks = [[ 'problem' => $databases->isNotEmpty() ? '涉及数据库变更,需确认脚本执行顺序和环境一致性。' : '暂无明确已知问题,测试过程中如发现阻塞需及时同步。', 'impact' => $databases->isNotEmpty() ? '脚本遗漏或顺序错误可能影响相关需求验证。' : '可能影响本次提测范围内需求验收进度。', 'action' => $databases->isNotEmpty() ? '部署前由研发确认脚本分支,测试开始前完成环境冒烟。' : '按需求清单跟踪问题,阻塞项及时拉群确认处理方案。', 'owner' => $owners->first() ?? '', ]]; return [ 'test_notes' => array_slice($testNotes, 0, 5), 'risks' => $risks, ]; } private function buildAiDraftSections(array $context): array { $content = json_encode([ 'issues' => collect($context['issues'] ?? [])->take(20)->values(), 'tech_docs' => $context['tech_docs'] ?? '', 'selected_containers' => $context['selected_containers'] ?? [], 'databases' => $context['databases'] ?? [], ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $prompt = <<<'PROMPT' 请根据提测邮件上下文生成第九和第十部分的简略表格草稿。 只输出 JSON,不要 Markdown,不要解释。格式: { "test_notes": [{"type":"脚本|配置项|其他依赖项|测试注意事项","issue":"需求key或事项","system":"系统/容器","content":"简短说明","owner":"负责人"}], "risks": [{"problem":"已知问题/风险","impact":"影响范围","action":"处理方案/规避措施","owner":"负责人"}] } 要求:最多 5 条 test_notes,最多 3 条 risks;内容要短,适合直接放入提测邮件;没有明确风险时给一条“暂无明确已知问题”的保守项。 PROMPT; $response = $this->aiService->analyze($content ?: '{}', $prompt); $json = $this->extractJsonObject($response); if ($json === null) { return []; } $decoded = json_decode($json, true); if (! is_array($decoded)) { return []; } return [ 'test_notes' => $this->normalizeRows($decoded['test_notes'] ?? [], ['type', 'issue', 'system', 'content', 'owner']), 'risks' => $this->normalizeRows($decoded['risks'] ?? [], ['problem', 'impact', 'action', 'owner']), ]; } private function normalizeRows(array $rows, array $keys): array { return collect($rows)->filter(fn ($row) => is_array($row))->map(function ($row) use ($keys) { $normalized = []; foreach ($keys as $key) { $normalized[$key] = trim((string) ($row[$key] ?? '')); } return $normalized; })->filter(fn ($row) => collect($row)->filter()->isNotEmpty())->values()->all(); } private function extractJsonObject(string $response): ?string { $response = trim($response); if ($response === '') { return null; } if (str_starts_with($response, '```')) { $response = preg_replace('/^```(?:json)?\s*|\s*```$/u', '', $response); } if (preg_match('/\{.*\}/su', $response, $matches)) { return $matches[0]; } return null; } private function summarizeIssueKeys($issues): string { $keys = collect($issues)->pluck('key')->filter()->take(6)->implode(', '); return $keys ?: '本次提测需求'; } private function buildEml(string $subject, string $html, string $text, array $images, string $from, string $to, string $cc): string { $mixedBoundary = '----=_Part_'.bin2hex(random_bytes(12)); $relatedBoundary = '----=_Related_'.bin2hex(random_bytes(12)); $alternativeBoundary = '----=_Alternative_'.bin2hex(random_bytes(12)); $headers = [ 'From: '.$from, 'To: '.$to, 'Cc: '.$cc, 'Subject: =?UTF-8?B?'.base64_encode($subject).'?=', 'Date: '.date(DATE_RFC2822), 'MIME-Version: 1.0', 'Content-Type: multipart/mixed; boundary="'.$mixedBoundary.'"', ]; $body = []; $body[] = '--'.$mixedBoundary; $body[] = 'Content-Type: multipart/alternative; boundary="'.$alternativeBoundary.'"'; $body[] = ''; $body[] = '--'.$alternativeBoundary; $body[] = 'Content-Type: text/plain; charset=UTF-8'; $body[] = 'Content-Transfer-Encoding: base64'; $body[] = ''; $body[] = chunk_split(base64_encode($text)); $body[] = '--'.$alternativeBoundary; $body[] = 'Content-Type: multipart/related; boundary="'.$relatedBoundary.'"'; $body[] = ''; $body[] = '--'.$relatedBoundary; $body[] = 'Content-Type: text/html; charset=UTF-8'; $body[] = 'Content-Transfer-Encoding: base64'; $body[] = ''; $body[] = chunk_split(base64_encode($html)); foreach ($images as $index => $image) { $parsed = $this->parseDataUrl($image['dataUrl']); if (! $parsed) { continue; } $cid = trim($image['cid'], '<>'); $name = $image['name'] ?? ('screenshot-'.($index + 1).'.png'); $body[] = '--'.$relatedBoundary; $body[] = 'Content-Type: '.$parsed['mime'].'; name="'.$this->escapeHeader($name).'"'; $body[] = 'Content-Transfer-Encoding: base64'; $body[] = 'Content-ID: <'.$cid.'>'; $body[] = 'Content-Disposition: inline; filename="'.$this->escapeHeader($name).'"'; $body[] = ''; $body[] = chunk_split(base64_encode($parsed['bytes'])); } $body[] = '--'.$relatedBoundary.'--'; $body[] = ''; $body[] = '--'.$alternativeBoundary.'--'; $body[] = ''; $body[] = '--'.$mixedBoundary.'--'; $body[] = ''; return implode("\r\n", $headers)."\r\n\r\n".implode("\r\n", $body); } private function parseDataUrl(string $dataUrl): ?array { if (! preg_match('/^data:([^;]+);base64,(.*)$/s', $dataUrl, $matches)) { return null; } $bytes = base64_decode($matches[2], true); if ($bytes === false) { return null; } return ['mime' => $matches[1], 'bytes' => $bytes]; } private function htmlToText(string $html): string { return trim(html_entity_decode(strip_tags(str_replace(['
', '
', '
'], "\n", $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8')); } private function escapeHeader(string $value): string { return str_replace(['"', "\r", "\n"], ['\\"', '', ''], $value); } private function inlineDraftImages(string $html, array $images): string { foreach ($images as $image) { $cid = trim((string) ($image['cid'] ?? ''), '<>'); $dataUrl = (string) ($image['dataUrl'] ?? ''); if ($cid === '' || $dataUrl === '') { continue; } $html = str_replace('cid:'.$cid, $dataUrl, $html); } return $html; } private function thunderbirdComposeValue(string $value): string { return "'".str_replace( ["\\", "'"], ["\\\\", "\\'"], str_replace("\r\n", "\n", $value) )."'"; } }