461 lines
18 KiB
PHP
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)
|
|
)."'";
|
|
}
|
|
}
|