Initial commit: 心理咨询预约系统
PHP 前后端一体:首页咨询师列表、预约表单、管理后台、二维码准入与邮件通知;敏感配置与提交数据通过 .gitignore 排除。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
178
api/bootstrap.php
Normal file
178
api/bootstrap.php
Normal 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
13
api/check.php
Normal 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
18
api/config.php
Normal 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
129
api/diagnostics.php
Normal 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
11
api/doctors.php
Normal 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
50
api/export.php
Normal 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
23
api/login.php
Normal 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
25
api/logout.php
Normal 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
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);
|
||||
}
|
||||
17
api/submissions.php
Normal file
17
api/submissions.php
Normal 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
75
api/submit.php
Normal 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
11
api/topics.php
Normal 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
13
api/version.php
Normal 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(),
|
||||
]);
|
||||
Reference in New Issue
Block a user