Files
toolbox/app/Http/Controllers/TestMailController.php
T

461 lines
18 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Services\AiService;
use App\Services\JiraService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class TestMailController extends Controller
{
public function __construct(
private JiraService $jiraService,
private AiService $aiService
)
{
}
public function sprints(): JsonResponse
{
return response()->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'] ?: '万文山 <wanwenshan@angelalign.com>',
$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(['<br>', '<br/>', '<br />'], "\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)
)."'";
}
}