#feature: add test mail generator
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
<?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)
|
||||
)."'";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user