Files
litianming 08ce60344d Initial commit: 心理咨询预约系统
PHP 前后端一体:首页咨询师列表、预约表单、管理后台、二维码准入与邮件通知;敏感配置与提交数据通过 .gitignore 排除。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 10:17:09 +08:00

339 lines
12 KiB
PHP

<?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);
}