Initial commit: 心理咨询预约系统

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
litianming
2026-06-04 10:17:09 +08:00
commit 08ce60344d
49 changed files with 3490 additions and 0 deletions

178
api/bootstrap.php Normal file
View File

@@ -0,0 +1,178 @@
<?php
declare(strict_types=1);
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
ini_set('display_errors', '0');
define('ROOT_DIR', dirname(__DIR__));
define('DATA_DIR', ROOT_DIR . '/data');
define('CONFIG_FILE', ROOT_DIR . '/config/app.json');
define('SUBMISSIONS_FILE', DATA_DIR . '/submissions.json');
function loadAppConfig(): array
{
static $config = null;
if ($config !== null) {
return $config;
}
if (!is_file(CONFIG_FILE)) {
jsonError('配置文件不存在', 500);
}
$raw = file_get_contents(CONFIG_FILE);
$config = json_decode($raw ?: '{}', true);
if (!is_array($config)) {
jsonError('配置文件格式错误', 500);
}
return $config;
}
function jsonResponse($data, int $code = 200): void
{
http_response_code($code);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
function jsonError(string $message, int $code = 400): void
{
jsonResponse(['ok' => false, 'message' => $message], $code);
}
function readJsonFile(string $path): array
{
if (!is_file($path)) {
jsonError('数据文件不存在: ' . basename($path), 500);
}
$data = json_decode(file_get_contents($path) ?: '[]', true);
return is_array($data) ? $data : [];
}
function getRequestBody(): array
{
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '{}', true);
return is_array($data) ? $data : [];
}
function requireAdmin(): void
{
if (empty($_SESSION['admin'])) {
jsonError('未登录或会话已过期', 401);
}
}
function readSubmissions(): array
{
if (!is_file(SUBMISSIONS_FILE)) {
return [];
}
$data = json_decode(file_get_contents(SUBMISSIONS_FILE) ?: '[]', true);
return is_array($data) ? $data : [];
}
function getDataWriteError(): ?string
{
if (!is_dir(DATA_DIR)) {
if (!@mkdir(DATA_DIR, 0755, true) && !is_dir(DATA_DIR)) {
return 'data 目录无法创建。请在宝塔:文件 → data → 权限设为 755属主 www';
}
}
if (!is_writable(DATA_DIR)) {
return 'data 目录不可写。请在宝塔:文件 → data → 权限 755属主 www勾选「应用到子目录」';
}
if (is_file(SUBMISSIONS_FILE) && !is_writable(SUBMISSIONS_FILE)) {
return 'submissions.json 不可写。请在宝塔data/submissions.json → 权限 664属主 www';
}
return null;
}
function saveSubmissions(array $submissions): bool
{
$writeError = getDataWriteError();
if ($writeError !== null) {
error_log('[data] ' . $writeError);
return false;
}
$json = json_encode($submissions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$written = @file_put_contents(SUBMISSIONS_FILE, $json, LOCK_EX);
if ($written === false && !is_file(SUBMISSIONS_FILE)) {
$written = @file_put_contents(SUBMISSIONS_FILE, $json, LOCK_EX);
}
return $written !== false;
}
function getSaveSubmissionsError(): string
{
$writeError = getDataWriteError();
if ($writeError !== null) {
return $writeError;
}
return '保存失败,请稍后重试';
}
function validatePhone(string $phone): bool
{
return (bool) preg_match('/^1[3-9]\d{9}$/', $phone);
}
function getAssetVersion(): string
{
static $version = null;
if ($version !== null) {
return $version;
}
try {
$config = loadAppConfig();
if (!empty($config['assetVersion'])) {
$version = (string) $config['assetVersion'];
return $version;
}
} catch (Throwable $e) {
// 页面未就绪时回退到文件时间戳
}
$max = 0;
foreach (['css', 'js', 'assets', 'assets/avatars'] as $dirName) {
$dir = ROOT_DIR . '/' . $dirName;
if (!is_dir($dir)) {
continue;
}
$files = glob($dir . '/*');
if ($files === false) {
continue;
}
foreach ($files as $file) {
if (is_file($file)) {
$max = max($max, (int) filemtime($file));
}
}
}
$version = $max > 0 ? (string) $max : date('YmdHis');
return $version;
}
function asset(string $path): string
{
$path = ltrim($path, '/');
$sep = str_contains($path, '?') ? '&' : '?';
return $path . $sep . 'v=' . rawurlencode(getAssetVersion());
}
function sendHtmlNoCacheHeaders(): void
{
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
header('Expires: 0');
}

13
api/check.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
jsonResponse([
'ok' => true,
'loggedIn' => !empty($_SESSION['admin']),
]);

18
api/config.php Normal file
View File

@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
$config = loadAppConfig();
jsonResponse([
'ok' => true,
'siteTitle' => $config['siteTitle'] ?? '心理咨询',
'accessToken' => $config['accessToken'] ?? '',
'servicePhone' => $config['servicePhone'] ?? '',
'confirmPhone' => $config['confirmPhone'] ?? '',
]);

129
api/diagnostics.php Normal file
View File

@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require __DIR__ . '/mailer.php';
function requireDebugAccess(): void
{
$token = trim((string) ($_GET['access'] ?? $_POST['access'] ?? ''));
$config = loadAppConfig();
if ($token === '' || $token !== ($config['accessToken'] ?? '')) {
jsonError('无权访问,请在 URL 中携带 ?access=访问令牌', 403);
}
}
function checkWritableDir(string $dir): array
{
if (!is_dir($dir)) {
return ['ok' => false, 'message' => '目录不存在'];
}
if (!is_writable($dir)) {
return ['ok' => false, 'message' => '目录不可写,请检查权限'];
}
$testFile = $dir . '/.write_test_' . uniqid();
$written = @file_put_contents($testFile, 'ok');
if ($written === false) {
return ['ok' => false, 'message' => '无法写入测试文件'];
}
@unlink($testFile);
return ['ok' => true, 'message' => '可写'];
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
requireDebugAccess();
$dataDir = checkWritableDir(DATA_DIR);
$submissions = readSubmissions();
$mailConfig = loadMailConfig();
$recipients = getMailRecipients($mailConfig);
jsonResponse([
'ok' => true,
'time' => date('Y-m-d H:i:s'),
'phpVersion' => PHP_VERSION,
'assetVersion' => getAssetVersion(),
'extensions' => [
'json' => extension_loaded('json'),
'openssl' => extension_loaded('openssl'),
'session' => extension_loaded('session'),
],
'paths' => [
'root' => ROOT_DIR,
'dataDir' => DATA_DIR,
'submissionsFile' => SUBMISSIONS_FILE,
'mailConfig' => MAIL_CONFIG_FILE,
],
'dataDir' => $dataDir,
'submissions' => [
'count' => count($submissions),
'fileExists' => is_file(SUBMISSIONS_FILE),
'last' => $submissions !== [] ? end($submissions) : null,
],
'mail' => [
'enabled' => !empty($mailConfig['enabled']),
'recipientCount' => count($recipients),
'recipients' => $recipients,
'smtpHost' => $mailConfig['smtp']['host'] ?? '',
],
'apiUrls' => [
'config' => 'api/config.php',
'doctors' => 'api/doctors.php',
'submit' => 'api/submit.php',
'diagnostics' => 'api/diagnostics.php',
],
]);
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('方法不允许', 405);
}
requireDebugAccess();
$action = trim((string) ($_GET['action'] ?? 'submit'));
if ($action === 'mail') {
$submissions = readSubmissions();
if ($submissions === []) {
jsonError('暂无提交记录,无法测试邮件');
}
$sent = sendSubmissionMail($submissions);
if (!$sent) {
jsonError('邮件发送失败,请查看服务器 PHP 错误日志', 500);
}
jsonResponse(['ok' => true, 'message' => '测试邮件已发送', 'count' => count($submissions)]);
}
if ($action === 'submit') {
$suffix = date('His');
$record = [
'id' => uniqid('debug_', true),
'createdAt' => date('c'),
'doctorId' => 'song',
'doctorName' => '宋崇升',
'nickname' => '调试用户' . $suffix,
'gender' => '男',
'contact' => '1380013' . str_pad((string) random_int(0, 9999), 4, '0', STR_PAD_LEFT),
'description' => '这是一条调试提交 ' . date('Y-m-d H:i:s'),
];
$submissions = readSubmissions();
$submissions[] = $record;
if (!saveSubmissions($submissions)) {
jsonError(getSaveSubmissionsError(), 500);
}
$mailSent = sendSubmissionMail($submissions);
jsonResponse([
'ok' => true,
'message' => '调试提交成功',
'record' => $record,
'total' => count($submissions),
'mailSent' => $mailSent,
]);
}
jsonError('未知操作');

11
api/doctors.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
$doctors = readJsonFile(DATA_DIR . '/doctors.json');
jsonResponse(['ok' => true, 'data' => $doctors]);

50
api/export.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
requireAdmin();
$format = strtolower(trim((string) ($_GET['format'] ?? 'json')));
if (!in_array($format, ['json', 'csv'], true)) {
jsonError('不支持的导出格式');
}
$submissions = readSubmissions();
usort($submissions, function ($a, $b) {
return strcmp($b['createdAt'] ?? '', $a['createdAt'] ?? '');
});
$filename = 'consultations_' . date('Ymd_His');
if ($format === 'json') {
header('Content-Type: application/json; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.json"');
echo json_encode($submissions, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '.csv"');
echo "\xEF\xBB\xBF";
$out = fopen('php://output', 'w');
fputcsv($out, ['ID', '提交时间', '咨询师', '咨询师ID', '昵称', '性别', '联系电话', '需求简述']);
foreach ($submissions as $row) {
fputcsv($out, [
$row['id'] ?? '',
$row['createdAt'] ?? '',
$row['doctorName'] ?? '',
$row['doctorId'] ?? '',
$row['nickname'] ?? '',
$row['gender'] ?? '',
$row['contact'] ?? '',
$row['description'] ?? ($row['topic'] ?? ''),
]);
}
fclose($out);
exit;

23
api/login.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('方法不允许', 405);
}
$body = getRequestBody();
$user = trim((string) ($body['user'] ?? ''));
$password = trim((string) ($body['password'] ?? ''));
$config = loadAppConfig();
$adminUser = (string) ($config['adminUser'] ?? 'frankfrank');
$adminPassword = (string) ($config['adminPassword'] ?? 'frankfrank');
if ($user !== $adminUser || $password !== $adminPassword) {
jsonError('账号或密码错误', 401);
}
$_SESSION['admin'] = true;
jsonResponse(['ok' => true, 'message' => '登录成功']);

25
api/logout.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('方法不允许', 405);
}
$_SESSION = [];
if (ini_get('session.use_cookies')) {
$params = session_get_cookie_params();
setcookie(
session_name(),
'',
time() - 42000,
$params['path'],
$params['domain'],
$params['secure'],
$params['httponly']
);
}
session_destroy();
jsonResponse(['ok' => true, 'message' => '已退出']);

338
api/mailer.php Normal file
View 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);
}

17
api/submissions.php Normal file
View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
requireAdmin();
$submissions = readSubmissions();
usort($submissions, function ($a, $b) {
return strcmp($b['createdAt'] ?? '', $a['createdAt'] ?? '');
});
jsonResponse(['ok' => true, 'data' => $submissions, 'total' => count($submissions)]);

75
api/submit.php Normal file
View File

@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
require __DIR__ . '/mailer.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
jsonError('方法不允许', 405);
}
$body = getRequestBody();
$doctorId = trim((string) ($body['doctorId'] ?? ''));
$doctorName = trim((string) ($body['doctorName'] ?? ''));
$nickname = trim((string) ($body['nickname'] ?? ''));
$gender = trim((string) ($body['gender'] ?? ''));
$contact = trim((string) ($body['contact'] ?? ''));
$description = trim((string) ($body['description'] ?? ''));
if ($nickname === '') {
jsonError('请填写昵称');
}
if (!in_array($gender, ['男', '女'], true)) {
jsonError('请选择性别');
}
if ($contact === '') {
jsonError('请填写联系电话');
}
if (!validatePhone($contact)) {
jsonError('请输入正确的11位手机号码');
}
if ($description === '') {
jsonError('请填写需求简述');
}
if (mb_strlen($description) > 200) {
jsonError('需求简述不能超过200字');
}
$record = [
'id' => uniqid('sub_', true),
'createdAt' => date('c'),
'doctorId' => $doctorId,
'doctorName' => $doctorName,
'nickname' => $nickname,
'gender' => $gender,
'contact' => $contact,
'description' => $description,
];
$submissions = readSubmissions();
$submissions[] = $record;
if (!saveSubmissions($submissions)) {
jsonError(getSaveSubmissionsError(), 500);
}
$response = [
'ok' => true,
'message' => '提交成功',
'id' => $record['id'],
'total' => count($submissions),
];
http_response_code(200);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($response, JSON_UNESCAPED_UNICODE);
finishResponseAndContinue();
$mailSent = sendSubmissionMail($submissions);
if (!$mailSent) {
error_log('[submit] 数据已保存邮件通知未发送ID=' . $record['id']);
}
exit;

11
api/topics.php Normal file
View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
$topics = readJsonFile(DATA_DIR . '/topics.json');
jsonResponse(['ok' => true, 'data' => $topics]);

13
api/version.php Normal file
View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
require __DIR__ . '/bootstrap.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
jsonError('方法不允许', 405);
}
jsonResponse([
'ok' => true,
'version' => getAssetVersion(),
]);