feat: 添加基金邮件定时发送功能并优化邮件内容格式

- 新增宝塔面板定时任务脚本 cron_send_email.php
- 添加手动发送邮件按钮到管理界面
- 使用PHPMailer替代原始SMTP实现
- 优化邮件内容格式,按涨跌幅排序基金数据
- 增加邮件发送时间窗口判断逻辑
- 更新项目文档说明定时任务配置
This commit is contained in:
LL
2025-12-12 17:10:03 +08:00
parent 354133d6e4
commit 69ef47412d
13 changed files with 395 additions and 197 deletions

283
api.php
View File

@@ -33,6 +33,9 @@ class FundMonitorAPI {
private $emailStatusFile = 'data/email_status.json';
public function __construct() {
// 设置时区为上海
date_default_timezone_set('Asia/Shanghai');
// 确保数据目录存在
if (!is_dir('data')) {
mkdir('data', 0755, true);
@@ -224,9 +227,13 @@ class FundMonitorAPI {
}
/**
* 发送邮件 - 使用Socket直接连接SMTP服务器
* 发送邮件 - 使用PHPMailer库实现
*/
private function sendEmail($subject, $body) {
require_once 'PHPMailer/src/PHPMailer.php';
require_once 'PHPMailer/src/SMTP.php';
require_once 'PHPMailer/src/Exception.php';
$config = $this->emailConfig;
$toEmails = $config['to_emails'];
@@ -238,92 +245,46 @@ class FundMonitorAPI {
$smtpPassword = $config['password'];
$fromName = $config['from_name'];
$fromEmail = $config['from_email'];
// 发送邮件到所有收件人
$mail = new PHPMailer\PHPMailer\PHPMailer(true);
$success = true;
$errorLog = "";
foreach ($toEmails as $toEmail) {
// 创建Socket连接
if ($smtpSecure == 'ssl') {
$socket = fsockopen("ssl://$smtpServer", $smtpPort, $errno, $errstr, 30);
} else {
$socket = fsockopen($smtpServer, $smtpPort, $errno, $errstr, 30);
}
if (!$socket) {
$errorLog .= "Failed to connect to $smtpServer:$smtpPort. Error: $errstr ($errno)\n";
$success = false;
continue;
}
// 读取SMTP服务器响应
$response = $this->readSmtpResponse($socket);
$errorLog .= "1. Connection: $response\n";
// 发送EHLO命令
fputs($socket, "EHLO localhost\r\n");
$response = $this->readSmtpResponse($socket);
$errorLog .= "2. EHLO: $response\n";
// 发送认证命令
fputs($socket, "AUTH LOGIN\r\n");
$response = $this->readSmtpResponse($socket);
$errorLog .= "3. AUTH LOGIN: $response\n";
// 发送用户名
fputs($socket, base64_encode($smtpUser) . "\r\n");
$response = $this->readSmtpResponse($socket);
$errorLog .= "4. Username: $response\n";
// 发送密码
fputs($socket, base64_encode($smtpPassword) . "\r\n");
$response = $this->readSmtpResponse($socket);
$errorLog .= "5. Password: $response\n";
// 发送MAIL FROM命令
fputs($socket, "MAIL FROM: <$fromEmail>\r\n");
$response = $this->readSmtpResponse($socket);
$errorLog .= "6. MAIL FROM: $response\n";
// 发送RCPT TO命令
fputs($socket, "RCPT TO: <$toEmail>\r\n");
$response = $this->readSmtpResponse($socket);
$errorLog .= "7. RCPT TO: $response\n";
// 发送DATA命令
fputs($socket, "DATA\r\n");
$response = $this->readSmtpResponse($socket);
$errorLog .= "8. DATA: $response\n";
// 发送邮件内容
$emailContent = "Subject: $subject\r\n";
$emailContent .= "From: $fromName <$fromEmail>\r\n";
$emailContent .= "To: $toEmail\r\n";
$emailContent .= "MIME-Version: 1.0\r\n";
$emailContent .= "Content-Type: text/html; charset=UTF-8\r\n";
$emailContent .= "\r\n";
$emailContent .= $body;
$emailContent .= "\r\n.\r\n";
fputs($socket, $emailContent);
$dataResponse = $this->readSmtpResponse($socket);
$errorLog .= "9. Content sent: $dataResponse\n";
// 发送QUIT命令
fputs($socket, "QUIT\r\n");
$quitResponse = $this->readSmtpResponse($socket);
$errorLog .= "10. QUIT: $quitResponse\n";
// 关闭Socket连接
fclose($socket);
// 检查DATA命令的响应码是否为250成功
if (substr($dataResponse, 0, 3) != '250') {
$success = false;
try {
// 服务器配置
$mail->isSMTP();
$mail->Host = $smtpServer;
$mail->SMTPAuth = true;
$mail->Username = $smtpUser;
$mail->Password = $smtpPassword;
$mail->SMTPSecure = $smtpSecure;
$mail->Port = $smtpPort;
$mail->CharSet = 'UTF-8';
$mail->Encoding = 'base64';
// 发件人
$mail->setFrom($fromEmail, $fromName);
// 收件人
foreach ($toEmails as $email) {
$mail->addAddress($email);
}
// 邮件内容
$mail->isHTML(true);
$mail->Subject = $subject;
$mail->Body = $body;
$mail->AltBody = strip_tags($body);
// 发送邮件
$mail->send();
$errorLog .= "邮件发送成功!\n";
} catch (PHPMailer\PHPMailer\Exception $e) {
$success = false;
$errorLog .= "邮件发送失败: " . $mail->ErrorInfo . "\n";
$errorLog .= "异常信息: " . $e->getMessage() . "\n";
}
// 更新邮件状态
$status = $this->loadEmailStatus();
if ($success) {
@@ -336,7 +297,7 @@ class FundMonitorAPI {
file_put_contents('email_error_log.txt', date('Y-m-d H:i:s') . "\n" . $errorLog . "\n\n", FILE_APPEND);
}
$this->saveEmailStatus($status);
return $success;
}
@@ -365,66 +326,39 @@ class FundMonitorAPI {
$summary = $fundData['summary'];
$content .= "<h3>总体统计</h3>";
$content .= "<ul>";
$content .= "<li>上涨基金:{$summary['up_count']}只</li>";
$content .= "<li>下跌基金:{$summary['down_count']}只</li>";
$content .= "<li>持平基金:{$summary['flat_count']}只</li>";
$content .= "<li>总投资:{$this->formatCurrency($summary['total_investment'])}</li>";
$content .= "<li>当前价值:{$this->formatCurrency($summary['total_current_value'])}</li>";
$content .= "<li>总盈亏:{$this->formatCurrency($summary['total_profit_loss'])}</li>";
$content .= "<li>盈亏比例:{$summary['total_profit_loss_rate']}%</li>";
$content .= "<li>上涨基金:{$summary['upCount']}只</li>";
$content .= "<li>下跌基金:{$summary['downCount']}只</li>";
$content .= "<li>持平基金:{$summary['flatCount']}只</li>";
$content .= "</ul>";
// 基金详情
$content .= "<h3>基金详情</h3>";
$content .= "<table border='1' cellpadding='5' cellspacing='0' style='border-collapse:collapse;'>";
$content .= "<tr style='background-color:#f0f0f0;'>";
$content .= "<th>基金代码</th><th>基金名称</th><th>当前净值</th><th>涨跌幅</th><th>昨日涨跌幅</th><th>投资金额</th><th>当前价值</th><th>盈亏</th>";
$content .= "<th>基金代码</th><th>基金名称</th><th>当前净值</th><th>昨日涨跌幅</th><th>涨跌幅</th>";
$content .= "</tr>";
foreach ($fundData['fundsData'] as $fundCode => $fund) {
$investment = $fundData['fundChannelMap'][$fundCode] ?? 0;
$currentValue = isset($fund['current_value']) ? $fund['current_value'] : 0;
$profitLoss = $currentValue - $investment;
$profitLossRate = $investment > 0 ? ($profitLoss / $investment) * 100 : 0;
// 涨跌颜色
$changeColor = floatval($fund['gszzl']) > 0 ? '#009900' : '#ff0000';
$yesterdayChangeColor = floatval($fund['yesterday_change']) > 0 ? '#009900' : '#ff0000';
$profitLossColor = $profitLoss > 0 ? '#009900' : '#ff0000';
$content .= "<tr>";
$content .= "<td>{$fundCode}</td>";
$content .= "<td>{$fund['name']}</td>";
$content .= "<td>{$fund['gsz']}</td>";
$content .= "<td style='color:{$changeColor};'>{$fund['gszzl']}%</td>";
$content .= "<td style='color:{$yesterdayChangeColor};'>{$fund['yesterday_change']}%</td>";
$content .= "<td>{$this->formatCurrency($investment)}</td>";
$content .= "<td>{$this->formatCurrency($currentValue)}</td>";
$content .= "<td style='color:{$profitLossColor};'>{$this->formatCurrency($profitLoss)} ({$this->formatNumber($profitLossRate)}%)</td>";
$content .= "</tr>";
// 确保有基金数据
if (isset($fundData['fundsData']) && !empty($fundData['fundsData'])) {
foreach ($fundData['fundsData'] as $fundCode => $fund) {
// 涨跌颜色
$changeColor = floatval($fund['gszzl'] ?? 0) > 0 ? '#009900' : '#ff0000';
$yesterdayChangeColor = floatval($fund['yesterday_change'] ?? 0) > 0 ? '#009900' : '#ff0000';
$content .= "<tr>";
$content .= "<td>{$fundCode}</td>";
$content .= "<td>{$fund['name']}</td>";
$content .= "<td>" . number_format(floatval($fund['gsz'] ?? 0), 4) . "</td>";
$content .= "<td style='color:{$yesterdayChangeColor};'>" . number_format(floatval($fund['yesterday_change'] ?? 0), 2) . "%</td>";
$content .= "<td style='color:{$changeColor};'>" . number_format(floatval($fund['gszzl'] ?? 0), 2) . "%</td>";
$content .= "</tr>";
}
} else {
$content .= "<tr><td colspan='5' style='text-align:center;'>暂无基金数据</td></tr>";
}
$content .= "</table>";
// 渠道统计
$content .= "<h3>渠道统计</h3>";
$content .= "<table border='1' cellpadding='5' cellspacing='0' style='border-collapse:collapse;'>";
$content .= "<tr style='background-color:#f0f0f0;'>";
$content .= "<th>渠道</th><th>基金数量</th><th>投资金额</th><th>当前价值</th><th>盈亏</th>";
$content .= "</tr>";
foreach ($fundData['channelStats'] as $channelName => $channelData) {
$profitLoss = $channelData['total_current_value'] - $channelData['total_investment'];
$profitLossColor = $profitLoss > 0 ? '#009900' : '#ff0000';
$content .= "<tr>";
$content .= "<td>{$channelName}</td>";
$content .= "<td>{$channelData['fund_count']}</td>";
$content .= "<td>{$this->formatCurrency($channelData['total_investment'])}</td>";
$content .= "<td>{$this->formatCurrency($channelData['total_current_value'])}</td>";
$content .= "<td style='color:{$profitLossColor};'>{$this->formatCurrency($profitLoss)}</td>";
$content .= "</tr>";
}
$content .= "</table>";
// 渠道统计部分已移除
return $content;
}
@@ -433,6 +367,9 @@ class FundMonitorAPI {
* 判断是否应该发送邮件
*/
private function shouldSendEmail() {
// 设置时区为北京时间
date_default_timezone_set('Asia/Shanghai');
$now = new DateTime();
$currentDay = intval($now->format('w'));
$currentHour = intval($now->format('H'));
@@ -446,8 +383,18 @@ class FundMonitorAPI {
return false;
}
// 检查是否在设定的时间范围14:30
if ($currentHour != $schedule['hour'] || $currentMinute < $schedule['minute']) {
// 检查是否在设定的时间窗口14:30 ± 5分钟
// 这样可以避免因为服务器时间稍微偏差或者定时任务执行延迟而导致的问题
$targetHour = $schedule['hour'];
$targetMinute = $schedule['minute'];
// 计算当前时间与目标时间的分钟差
$currentTotalMinutes = $currentHour * 60 + $currentMinute;
$targetTotalMinutes = $targetHour * 60 + $targetMinute;
$timeDifference = abs($currentTotalMinutes - $targetTotalMinutes);
// 如果不在目标小时或者时间差超过5分钟则不发送
if ($currentHour != $targetHour || $timeDifference > 5) {
return false;
}
@@ -461,21 +408,46 @@ class FundMonitorAPI {
/**
* 发送基金状态邮件
* @param bool $forceSend 是否强制发送(绕过时间和日期限制)
*/
public function sendFundStatusEmail() {
if (!$this->shouldSendEmail()) {
public function sendFundStatusEmail($forceSend = false) {
if (!$forceSend && !$this->shouldSendEmail()) {
return false;
}
// 获取基金数据
$fundData = $this->getFundData();
// 检查数据是否获取成功
if (!$fundData['success'] || empty($fundData['data'])) {
// 如果没有获取到数据,记录错误日志
$errorLog = "Failed to get fund data for email: " . json_encode($fundData, JSON_UNESCAPED_UNICODE);
file_put_contents('email_error_log.txt', date('Y-m-d H:i:s') . "\n" . $errorLog . "\n\n", FILE_APPEND);
return false;
}
// 格式化邮件内容
$subject = "基金情况推送 - " . date('Y年m月d日');
$body = $this->formatFundEmailContent($fundData);
$body = $this->formatFundEmailContent($fundData['data']);
// 如果是手动发送,保存原始的最后发送日期
$originalLastSentDate = null;
if ($forceSend) {
$status = $this->loadEmailStatus();
$originalLastSentDate = $status['last_sent_date'];
}
// 发送邮件
return $this->sendEmail($subject, $body);
$result = $this->sendEmail($subject, $body);
// 如果是手动发送,恢复原始的最后发送日期(避免影响定时发送)
if ($forceSend && $originalLastSentDate !== null) {
$status = $this->loadEmailStatus();
$status['last_sent_date'] = $originalLastSentDate;
$this->saveEmailStatus($status);
}
return $result;
}
/**
@@ -1410,6 +1382,14 @@ class FundMonitorAPI {
$avgChange = $total > 0 ? $totalChange / $total : 0;
$totalProfitLossRate = $totalInvestment > 0 ? ($totalProfitLoss / $totalInvestment) * 100 : 0;
// 对基金数据按涨跌幅排序
uasort($fundsData, function($a, $b) {
// 按涨跌幅从高到低排序
$aChange = floatval($a['gszzl'] ?? 0);
$bChange = floatval($b['gszzl'] ?? 0);
return $bChange <=> $aChange;
});
// 格式化数据返回
$result = [
'success' => true,
@@ -1523,16 +1503,19 @@ class FundMonitorAPI {
}
}
// 错误处理
try {
// 处理API请求
$api = new FundMonitorAPI();
$api->handleRequest();
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => '服务器内部错误: ' . $e->getMessage()
]);
// 只有当直接访问此文件时才处理API请求
if (basename(__FILE__) == basename($_SERVER['PHP_SELF'])) {
// 错误处理
try {
// 处理API请求
$api = new FundMonitorAPI();
$api->handleRequest();
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => '服务器内部错误: ' . $e->getMessage()
]);
}
}
?>