Initial commit: 心理咨询预约系统
PHP 前后端一体:首页咨询师列表、预约表单、管理后台、二维码准入与邮件通知;敏感配置与提交数据通过 .gitignore 排除。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
338
api/mailer.php
Normal file
338
api/mailer.php
Normal file
@@ -0,0 +1,338 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
define('MAIL_CONFIG_FILE', ROOT_DIR . '/config/mail.json');
|
||||
|
||||
function loadMailConfig(): array
|
||||
{
|
||||
static $config = null;
|
||||
if ($config !== null) {
|
||||
return $config;
|
||||
}
|
||||
if (!is_file(MAIL_CONFIG_FILE)) {
|
||||
return ['enabled' => false];
|
||||
}
|
||||
$data = json_decode(file_get_contents(MAIL_CONFIG_FILE) ?: '{}', true);
|
||||
$config = is_array($data) ? $data : ['enabled' => false];
|
||||
return $config;
|
||||
}
|
||||
|
||||
function getMailRecipients(array $mailConfig): array
|
||||
{
|
||||
$recipients = [];
|
||||
|
||||
if (!empty($mailConfig['recipients']) && is_array($mailConfig['recipients'])) {
|
||||
foreach ($mailConfig['recipients'] as $email) {
|
||||
$email = trim((string) $email);
|
||||
if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
$recipients[] = $email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$single = trim((string) ($mailConfig['recipient'] ?? ''));
|
||||
if ($single !== '' && filter_var($single, FILTER_VALIDATE_EMAIL)) {
|
||||
$recipients[] = $single;
|
||||
}
|
||||
|
||||
return array_values(array_unique($recipients));
|
||||
}
|
||||
|
||||
function formatSubmissionTime(string $iso): string
|
||||
{
|
||||
$ts = strtotime($iso);
|
||||
if ($ts === false) {
|
||||
return $iso;
|
||||
}
|
||||
return date('Y-m-d H:i:s', $ts);
|
||||
}
|
||||
|
||||
function sendSubmissionMail(array $allSubmissions): bool
|
||||
{
|
||||
$mailConfig = loadMailConfig();
|
||||
if (empty($mailConfig['enabled'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$recipients = getMailRecipients($mailConfig);
|
||||
$smtp = $mailConfig['smtp'] ?? [];
|
||||
if ($recipients === [] || empty($smtp['host'])) {
|
||||
error_log('[mail] 邮件配置不完整');
|
||||
return false;
|
||||
}
|
||||
|
||||
$count = count($allSubmissions);
|
||||
$subject = (string) ($mailConfig['subject'] ?? '【心理咨询】新预约提交');
|
||||
if ($count > 0) {
|
||||
$subject .= '(共' . $count . '条)';
|
||||
}
|
||||
|
||||
$htmlBody = buildAllSubmissionsMailHtml($allSubmissions);
|
||||
$plainBody = buildAllSubmissionsMailPlain($allSubmissions);
|
||||
$from = (string) ($smtp['from'] ?? $smtp['username'] ?? '');
|
||||
$fromName = (string) ($smtp['fromName'] ?? '心理咨询系统');
|
||||
|
||||
try {
|
||||
return smtpSend($smtp, $recipients, $subject, $htmlBody, $plainBody, $from, $fromName);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[mail] 发送失败: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sortSubmissions(array $submissions): array
|
||||
{
|
||||
usort($submissions, static function ($a, $b) {
|
||||
return strcmp($a['createdAt'] ?? '', $b['createdAt'] ?? '');
|
||||
});
|
||||
return $submissions;
|
||||
}
|
||||
|
||||
function escapeMailHtml(string $text): string
|
||||
{
|
||||
return htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
|
||||
}
|
||||
|
||||
function normalizeSubmissionRow(array $record, int $index): array
|
||||
{
|
||||
$doctorName = trim((string) ($record['doctorName'] ?? ''));
|
||||
|
||||
return [
|
||||
'index' => (string) $index,
|
||||
'time' => formatSubmissionTime((string) ($record['createdAt'] ?? '')),
|
||||
'nickname' => (string) ($record['nickname'] ?? ''),
|
||||
'gender' => (string) ($record['gender'] ?? ''),
|
||||
'contact' => (string) ($record['contact'] ?? ''),
|
||||
'doctor' => $doctorName !== '' ? $doctorName : '未指定',
|
||||
'description' => (string) ($record['description'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
function buildAllSubmissionsMailHtml(array $submissions): string
|
||||
{
|
||||
$submissions = sortSubmissions($submissions);
|
||||
$count = count($submissions);
|
||||
$generatedAt = date('Y-m-d H:i:s');
|
||||
|
||||
if ($submissions === []) {
|
||||
return '<p style="font-family:sans-serif;color:#666;">当前暂无提交记录。</p>';
|
||||
}
|
||||
|
||||
$rows = '';
|
||||
foreach ($submissions as $i => $record) {
|
||||
$row = normalizeSubmissionRow($record, $i + 1);
|
||||
$bg = ($i % 2 === 0) ? '#ffffff' : '#f8fbf9';
|
||||
$rows .= '<tr style="background:' . $bg . ';">'
|
||||
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;text-align:center;color:#333;">' . escapeMailHtml($row['index']) . '</td>'
|
||||
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;white-space:nowrap;color:#333;">' . escapeMailHtml($row['time']) . '</td>'
|
||||
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;color:#333;">' . escapeMailHtml($row['nickname']) . '</td>'
|
||||
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;text-align:center;color:#333;">' . escapeMailHtml($row['gender']) . '</td>'
|
||||
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;white-space:nowrap;color:#333;">' . escapeMailHtml($row['contact']) . '</td>'
|
||||
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;color:#40916c;font-weight:600;">' . escapeMailHtml($row['doctor']) . '</td>'
|
||||
. '<td style="padding:10px 8px;border:1px solid #e0ebe4;color:#555;line-height:1.6;">' . nl2br(escapeMailHtml($row['description'])) . '</td>'
|
||||
. '</tr>';
|
||||
}
|
||||
|
||||
return <<<HTML
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'PingFang SC','Helvetica Neue',Arial,sans-serif;color:#333;max-width:960px;">
|
||||
<h2 style="margin:0 0 8px;font-size:18px;color:#40916c;">心理咨询预约记录汇总</h2>
|
||||
<p style="margin:0 0 16px;font-size:13px;color:#888;">共 {$count} 条记录 · 生成时间 {$generatedAt}</p>
|
||||
<table style="width:100%;border-collapse:collapse;font-size:13px;table-layout:fixed;">
|
||||
<thead>
|
||||
<tr style="background:#40916c;color:#fff;">
|
||||
<th style="padding:10px 8px;border:1px solid #358f5a;width:40px;text-align:center;">序号</th>
|
||||
<th style="padding:10px 8px;border:1px solid #358f5a;width:140px;">提交时间</th>
|
||||
<th style="padding:10px 8px;border:1px solid #358f5a;width:72px;">提交人</th>
|
||||
<th style="padding:10px 8px;border:1px solid #358f5a;width:48px;text-align:center;">性别</th>
|
||||
<th style="padding:10px 8px;border:1px solid #358f5a;width:110px;">联系电话</th>
|
||||
<th style="padding:10px 8px;border:1px solid #358f5a;width:88px;">咨询师</th>
|
||||
<th style="padding:10px 8px;border:1px solid #358f5a;">需求简述</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{$rows}
|
||||
</tbody>
|
||||
</table>
|
||||
<p style="margin:20px 0 0;font-size:12px;color:#aaa;">—— 心理咨询系统自动通知</p>
|
||||
</div>
|
||||
HTML;
|
||||
}
|
||||
|
||||
function buildAllSubmissionsMailPlain(array $submissions): string
|
||||
{
|
||||
$submissions = sortSubmissions($submissions);
|
||||
if ($submissions === []) {
|
||||
return "当前暂无提交记录。\n";
|
||||
}
|
||||
|
||||
$lines = [
|
||||
'心理咨询预约记录汇总(共' . count($submissions) . '条)',
|
||||
str_repeat('=', 80),
|
||||
sprintf('%-4s %-19s %-8s %-4s %-13s %-10s %s', '序号', '提交时间', '提交人', '性别', '联系电话', '咨询师', '需求简述'),
|
||||
str_repeat('-', 80),
|
||||
];
|
||||
|
||||
foreach ($submissions as $i => $record) {
|
||||
$row = normalizeSubmissionRow($record, $i + 1);
|
||||
$desc = preg_replace('/\s+/', ' ', $row['description']);
|
||||
$lines[] = sprintf(
|
||||
'%-4s %-19s %-8s %-4s %-13s %-10s %s',
|
||||
$row['index'],
|
||||
$row['time'],
|
||||
$row['nickname'],
|
||||
$row['gender'],
|
||||
$row['contact'],
|
||||
$row['doctor'],
|
||||
$desc
|
||||
);
|
||||
}
|
||||
|
||||
$lines[] = str_repeat('-', 80);
|
||||
$lines[] = '—— 心理咨询系统自动通知';
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
function smtpSend(array $smtp, array $recipients, string $subject, string $htmlBody, string $plainBody, string $from, string $fromName): bool
|
||||
{
|
||||
if ($recipients === []) {
|
||||
throw new RuntimeException('收件人为空');
|
||||
}
|
||||
|
||||
$host = (string) $smtp['host'];
|
||||
$port = (int) ($smtp['port'] ?? 465);
|
||||
$username = (string) ($smtp['username'] ?? '');
|
||||
$password = (string) ($smtp['password'] ?? '');
|
||||
$encryption = strtolower((string) ($smtp['encryption'] ?? 'ssl'));
|
||||
|
||||
if ($username === '' || $password === '' || $from === '') {
|
||||
throw new RuntimeException('SMTP 账号或发件人未配置');
|
||||
}
|
||||
|
||||
$remote = ($encryption === 'ssl' ? 'ssl://' : '') . $host . ':' . $port;
|
||||
$context = stream_context_create([
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
'allow_self_signed' => false,
|
||||
],
|
||||
]);
|
||||
|
||||
$socket = @stream_socket_client($remote, $errno, $errstr, 30, STREAM_CLIENT_CONNECT, $context);
|
||||
if (!$socket) {
|
||||
throw new RuntimeException("无法连接 SMTP 服务器: {$errstr} ({$errno})");
|
||||
}
|
||||
|
||||
stream_set_timeout($socket, 30);
|
||||
|
||||
try {
|
||||
smtpExpect($socket, [220]);
|
||||
smtpCommand($socket, 'EHLO localhost');
|
||||
smtpExpect($socket, [250]);
|
||||
|
||||
smtpCommand($socket, 'AUTH LOGIN');
|
||||
smtpExpect($socket, [334]);
|
||||
smtpCommand($socket, base64_encode($username));
|
||||
smtpExpect($socket, [334]);
|
||||
smtpCommand($socket, base64_encode($password));
|
||||
smtpExpect($socket, [235]);
|
||||
|
||||
smtpCommand($socket, 'MAIL FROM:<' . $from . '>');
|
||||
smtpExpect($socket, [250]);
|
||||
foreach ($recipients as $to) {
|
||||
smtpCommand($socket, 'RCPT TO:<' . $to . '>');
|
||||
smtpExpect($socket, [250, 251]);
|
||||
}
|
||||
smtpCommand($socket, 'DATA');
|
||||
smtpExpect($socket, [354]);
|
||||
|
||||
$message = buildMimeMessage($from, $fromName, $recipients, $subject, $htmlBody, $plainBody);
|
||||
fwrite($socket, $message . "\r\n.\r\n");
|
||||
smtpExpect($socket, [250]);
|
||||
|
||||
smtpCommand($socket, 'QUIT');
|
||||
smtpExpect($socket, [221]);
|
||||
} finally {
|
||||
fclose($socket);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function buildMimeMessage(string $from, string $fromName, array $recipients, string $subject, string $htmlBody, string $plainBody): string
|
||||
{
|
||||
$encodedSubject = '=?UTF-8?B?' . base64_encode($subject) . '?=';
|
||||
$encodedFromName = '=?UTF-8?B?' . base64_encode($fromName) . '?=';
|
||||
$toHeader = implode(', ', array_map(static fn($email) => '<' . $email . '>', $recipients));
|
||||
$boundary = 'b_' . md5(uniqid((string) mt_rand(), true));
|
||||
|
||||
$headers = [
|
||||
'From: ' . $encodedFromName . ' <' . $from . '>',
|
||||
'To: ' . $toHeader,
|
||||
'Subject: ' . $encodedSubject,
|
||||
'MIME-Version: 1.0',
|
||||
'Content-Type: multipart/alternative; boundary="' . $boundary . '"',
|
||||
'Date: ' . date('r'),
|
||||
];
|
||||
|
||||
$plainPart = chunk_split(base64_encode($plainBody), 76, "\r\n");
|
||||
$htmlPart = chunk_split(base64_encode($htmlBody), 76, "\r\n");
|
||||
|
||||
$body = "--{$boundary}\r\n"
|
||||
. "Content-Type: text/plain; charset=UTF-8\r\n"
|
||||
. "Content-Transfer-Encoding: base64\r\n\r\n"
|
||||
. rtrim($plainPart) . "\r\n\r\n"
|
||||
. "--{$boundary}\r\n"
|
||||
. "Content-Type: text/html; charset=UTF-8\r\n"
|
||||
. "Content-Transfer-Encoding: base64\r\n\r\n"
|
||||
. rtrim($htmlPart) . "\r\n\r\n"
|
||||
. "--{$boundary}--";
|
||||
|
||||
return implode("\r\n", $headers) . "\r\n\r\n" . $body;
|
||||
}
|
||||
|
||||
function smtpCommand($socket, string $command): void
|
||||
{
|
||||
fwrite($socket, $command . "\r\n");
|
||||
}
|
||||
|
||||
function smtpExpect($socket, array $validCodes): string
|
||||
{
|
||||
$response = '';
|
||||
while (($line = fgets($socket, 515)) !== false) {
|
||||
$response .= $line;
|
||||
if (isset($line[3]) && $line[3] === ' ') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($response === '') {
|
||||
throw new RuntimeException('SMTP 无响应');
|
||||
}
|
||||
|
||||
$code = (int) substr($response, 0, 3);
|
||||
if (!in_array($code, $validCodes, true)) {
|
||||
throw new RuntimeException('SMTP 错误: ' . trim($response));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
function finishResponseAndContinue(): void
|
||||
{
|
||||
if (function_exists('fastcgi_finish_request')) {
|
||||
fastcgi_finish_request();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ob_get_level() > 0) {
|
||||
ob_end_flush();
|
||||
}
|
||||
flush();
|
||||
|
||||
if (function_exists('session_write_close')) {
|
||||
session_write_close();
|
||||
}
|
||||
|
||||
ignore_user_abort(true);
|
||||
}
|
||||
Reference in New Issue
Block a user