Files
jj2/api.php
2025-12-12 11:54:44 +08:00

1539 lines
54 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST');
header('Access-Control-Allow-Headers: Content-Type');
class FundMonitorAPI {
private $dataFile = 'data/visits.json';
private $statsFile = 'data/stats.json';
private $configFile = 'data/fund_config.json';
private $historyFile = 'data/fund_history.json';
private $operationFile = 'data/operation_log.json';
private $dailyDataFile = 'data/fund_daily_data.json';
// 简短缓存,降低频繁请求带来的压力(仅用于监控页实时数据)
private $cacheDir = 'data/cache';
private $fundCacheTTL = 15; // 单基金数据缓存(秒)
private $batchCacheTTL = 60; // 批量基金数据缓存(秒)
// 邮件配置文件路径
private $emailConfigFile = 'data/email_config.json';
// 邮件推送配置
private $emailConfig = [];
// 邮件推送时间配置
private $emailSchedule = [
'days' => [1, 2, 3, 4, 5], // 1-5代表周一到周五
'hour' => 14,
'minute' => 30
];
// 邮件推送状态文件
private $emailStatusFile = 'data/email_status.json';
public function __construct() {
// 确保数据目录存在
if (!is_dir('data')) {
mkdir('data', 0755, true);
}
// 初始化统计文件(如果不存在)
if (!file_exists($this->statsFile)) {
$this->initStats();
}
// 初始化配置文件(如果不存在)
if (!file_exists($this->configFile)) {
$this->initConfig();
}
// 初始化历史数据文件(如果不存在)
if (!file_exists($this->historyFile)) {
$this->initHistory();
}
// 初始化操作日志文件(如果不存在)
if (!file_exists($this->operationFile)) {
$this->initOperationLog();
}
// 初始化每日数据文件(如果不存在)
if (!file_exists($this->dailyDataFile)) {
$this->initDailyData();
}
// 初始化邮件状态文件(如果不存在)
if (!file_exists($this->emailStatusFile)) {
$this->initEmailStatus();
}
// 加载邮箱配置
$this->loadEmailConfig();
}
/**
* 加载邮箱配置
*/
private function loadEmailConfig() {
if (file_exists($this->emailConfigFile)) {
$this->emailConfig = json_decode(file_get_contents($this->emailConfigFile), true);
} else {
// 如果配置文件不存在,使用默认配置
$this->initEmailConfig();
}
}
/**
* 初始化邮箱配置文件
*/
private function initEmailConfig() {
$defaultEmailConfig = [
'smtp_server' => 'smtp.qq.com',
'smtp_port' => 465,
'smtp_secure' => 'ssl',
'username' => '',
'password' => '',
'from_email' => '',
'from_name' => '基金监控系统',
'to_emails' => []
];
file_put_contents($this->emailConfigFile, json_encode($defaultEmailConfig, JSON_PRETTY_PRINT));
$this->emailConfig = $defaultEmailConfig;
}
/**
* 初始化统计数据
*/
private function initStats() {
$initialStats = [
'total_visits' => 0,
'today_visits' => 0,
'unique_visitors' => 0,
'today_unique_visitors' => 0,
'last_reset' => date('Y-m-d'),
'daily_stats' => [],
'unique_ips' => []
];
file_put_contents($this->statsFile, json_encode($initialStats, JSON_PRETTY_PRINT));
}
/**
* 初始化配置文件
*/
private function initConfig() {
$defaultConfig = [
[
"channel" => "0",
"fund_code" => "003766",
"investment" => 1000
],
[
"channel" => "0",
"fund_code" => "004206",
"investment" => 1000
],
[
"channel" => "0",
"fund_code" => "019432",
"investment" => 1000
],
[
"channel" => "1",
"fund_code" => "003766",
"investment" => 1000
],
[
"channel" => "1",
"fund_code" => "008327",
"investment" => 1000
],
[
"channel" => "2",
"fund_code" => "017811",
"investment" => 1000
]
];
file_put_contents($this->configFile, json_encode($defaultConfig, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
/**
* 初始化历史数据
*/
private function initHistory() {
$initialHistory = [
'last_update_date' => date('Y-m-d'),
'yesterday_data' => [],
'today_data' => []
];
file_put_contents($this->historyFile, json_encode($initialHistory, JSON_PRETTY_PRINT));
}
/**
* 初始化操作日志
*/
private function initOperationLog() {
$initialLog = [
'operations' => []
];
file_put_contents($this->operationFile, json_encode($initialLog, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
/**
* 初始化每日数据
*/
private function initDailyData() {
$initialDailyData = [
'last_update' => date('Y-m-d'),
'funds' => []
];
file_put_contents($this->dailyDataFile, json_encode($initialDailyData, JSON_PRETTY_PRINT));
}
/**
* 初始化邮件状态文件
*/
private function initEmailStatus() {
$initialStatus = [
'last_sent_date' => null,
'last_sent_time' => null,
'sent_count' => 0,
'failed_count' => 0
];
file_put_contents($this->emailStatusFile, json_encode($initialStatus, JSON_PRETTY_PRINT));
}
/**
* 读取SMTP服务器多行响应的私有方法
* @param resource $socket Socket连接资源
* @return string 完整的响应内容
*/
private function readSmtpResponse($socket) {
$response = '';
do {
$line = fgets($socket, 512);
$response .= $line;
} while (substr($line, 3, 1) == '-' && !feof($socket));
return $response;
}
/**
* 发送邮件 - 使用Socket直接连接SMTP服务器
*/
private function sendEmail($subject, $body) {
$config = $this->emailConfig;
$toEmails = $config['to_emails'];
// SMTP服务器配置
$smtpServer = $config['smtp_server'];
$smtpPort = $config['smtp_port'];
$smtpSecure = $config['smtp_secure'];
$smtpUser = $config['username'];
$smtpPassword = $config['password'];
$fromName = $config['from_name'];
$fromEmail = $config['from_email'];
// 发送邮件到所有收件人
$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;
}
}
// 更新邮件状态
$status = $this->loadEmailStatus();
if ($success) {
$status['last_sent_date'] = date('Y-m-d');
$status['last_sent_time'] = date('H:i:s');
$status['sent_count']++;
} else {
$status['failed_count']++;
// 保存错误日志
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;
}
/**
* 加载邮件状态
*/
private function loadEmailStatus() {
return $this->loadFile($this->emailStatusFile, [], [$this, 'initEmailStatus']);
}
/**
* 保存邮件状态
*/
private function saveEmailStatus($status) {
file_put_contents($this->emailStatusFile, json_encode($status, JSON_PRETTY_PRINT));
}
/**
* 格式化基金数据为邮件内容
*/
private function formatFundEmailContent($fundData) {
$content = "<h2>基金情况推送</h2>";
$content .= "<p>推送时间:" . date('Y年m月d日 H:i:s') . "</p>";
// 总体统计
$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 .= "</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 .= "</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>";
}
$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;
}
/**
* 判断是否应该发送邮件
*/
private function shouldSendEmail() {
$now = new DateTime();
$currentDay = intval($now->format('w'));
$currentHour = intval($now->format('H'));
$currentMinute = intval($now->format('i'));
$schedule = $this->emailSchedule;
$status = $this->loadEmailStatus();
// 检查是否在设定的工作日
if (!in_array($currentDay, $schedule['days'])) {
return false;
}
// 检查是否在设定的时间范围内14:30
if ($currentHour != $schedule['hour'] || $currentMinute < $schedule['minute']) {
return false;
}
// 检查今天是否已经发送过邮件
if ($status['last_sent_date'] == date('Y-m-d')) {
return false;
}
return true;
}
/**
* 发送基金状态邮件
*/
public function sendFundStatusEmail() {
if (!$this->shouldSendEmail()) {
return false;
}
// 获取基金数据
$fundData = $this->getFundData();
// 格式化邮件内容
$subject = "基金情况推送 - " . date('Y年m月d日');
$body = $this->formatFundEmailContent($fundData);
// 发送邮件
return $this->sendEmail($subject, $body);
}
/**
* 测试邮件发送功能
*/
public function testEmail($subject, $body) {
return $this->sendEmail($subject, $body);
}
/**
* 加载基金配置
*/
private function loadConfig() {
return $this->loadFile($this->configFile, [], [$this, 'initConfig']);
}
/**
* 加载历史数据
*/
private function loadHistory() {
return $this->loadFile($this->historyFile, [], [$this, 'initHistory']);
}
/**
* 保存历史数据
*/
private function saveHistory($history) {
return file_put_contents($this->historyFile, json_encode($history, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
/**
* 更新历史数据
*/
private function updateHistory($fundsData) {
$history = $this->loadHistory();
$today = date('Y-m-d');
// 确保数组结构完整
if (!isset($history['yesterday_data']) || !is_array($history['yesterday_data'])) {
$history['yesterday_data'] = [];
}
if (!isset($history['today_data']) || !is_array($history['today_data'])) {
$history['today_data'] = [];
}
if (!isset($history['last_update_date'])) {
$history['last_update_date'] = $today;
}
// 如果是新的一天,将昨天的数据移到历史记录中
if ($history['last_update_date'] !== $today) {
// 将昨天的数据保存为历史数据
// 确保今天的数据不为空才移动,避免覆盖已有的历史数据
if (!empty($history['today_data'])) {
$history['yesterday_data'] = $history['today_data'];
}
// 清空今天的数据,为新数据做准备
$history['today_data'] = [];
// 更新最后更新日期
$history['last_update_date'] = $today;
}
// 保存今天的数据
foreach ($fundsData as $fundCode => $fund) {
$history['today_data'][$fundCode] = [
'date' => $today,
'change' => floatval($fund['gszzl']),
'name' => $fund['name'] ?? '未知基金'
];
}
// 确保保存操作成功
if (!$this->saveHistory($history)) {
// 保存失败时记录错误信息(可以根据需要扩展错误处理)
error_log('Failed to save history data on ' . $today);
}
return $history;
}
/**
* 获取基金的历史涨幅(昨天的数据)
*/
private function getFundHistory($fundCode) {
$history = $this->loadHistory();
if (isset($history['yesterday_data'][$fundCode])) {
return $history['yesterday_data'][$fundCode]['change'];
}
return null; // 没有历史数据
}
/**
* 缓存路径
*/
private function getFundCachePath($fundCode) {
return $this->cacheDir . "/fund_{$fundCode}.json";
}
/**
* 从缓存读取基金数据(短期)
*/
private function getFundFromCache($fundCode) {
$file = $this->getFundCachePath($fundCode);
if (!file_exists($file)) return false;
// 过期判断
if (time() - filemtime($file) > $this->fundCacheTTL) return false;
$raw = @file_get_contents($file);
if ($raw === false) return false;
$data = json_decode($raw, true);
return is_array($data) ? $data : false;
}
/**
* 写入缓存
*/
private function cacheFundData($fundCode, $fundData) {
// 保证目录存在
if (!is_dir($this->cacheDir)) {
@mkdir($this->cacheDir, 0777, true);
}
@file_put_contents($this->getFundCachePath($fundCode), json_encode($fundData, JSON_UNESCAPED_UNICODE));
}
/**
* 获取批量数据缓存路径
*/
private function getBatchCachePath($key) {
return $this->cacheDir . "/batch_{$key}.json";
}
/**
* 从缓存读取批量数据
*/
private function getFromBatchCache($key) {
$file = $this->getBatchCachePath($key);
if (!file_exists($file)) return false;
// 过期判断
if (time() - filemtime($file) > $this->batchCacheTTL) return false;
$raw = @file_get_contents($file);
if ($raw === false) return false;
$data = json_decode($raw, true);
return is_array($data) ? $data : false;
}
/**
* 写入批量数据缓存
*/
private function cacheBatchData($key, $data) {
// 保证目录存在
if (!is_dir($this->cacheDir)) {
@mkdir($this->cacheDir, 0777, true);
}
@file_put_contents($this->getBatchCachePath($key), json_encode($data, JSON_UNESCAPED_UNICODE));
}
/**
* 记录操作日志
*/
private function logOperation($type, $fundCode, $channel = null, $investment = null, $details = '') {
$log = $this->loadOperationLog();
$operation = [
'id' => uniqid(),
'type' => $type, // 'add', 'update', 'delete'
'fund_code' => $fundCode,
'channel' => $channel,
'investment' => $investment,
'details' => $details,
'timestamp' => time(),
'date' => date('Y-m-d H:i:s')
];
$log['operations'][] = $operation;
// 只保留最近100条操作记录
if (count($log['operations']) > 100) {
$log['operations'] = array_slice($log['operations'], -100);
}
file_put_contents($this->operationFile, json_encode($log, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $operation;
}
/**
* 加载操作日志
*/
private function loadOperationLog() {
return $this->loadFile($this->operationFile, ['operations' => []], [$this, 'initOperationLog']);
}
/**
* 记录每日基金数据
*/
private function recordDailyData($fundsData) {
$dailyData = $this->loadDailyData();
$today = date('Y-m-d');
// 如果是新的一天清理旧数据只保留5天
if ($dailyData['last_update'] !== $today) {
$fiveDaysAgo = date('Y-m-d', strtotime('-5 days'));
foreach ($dailyData['funds'] as $fundCode => &$fundHistory) {
$fundHistory = array_filter($fundHistory, function($record) use ($fiveDaysAgo) {
return $record['date'] >= $fiveDaysAgo;
});
// 重新索引数组
$fundHistory = array_values($fundHistory);
}
$dailyData['last_update'] = $today;
}
// 记录今天的数据
foreach ($fundsData as $fundCode => $fund) {
if (!isset($dailyData['funds'][$fundCode])) {
$dailyData['funds'][$fundCode] = [];
}
$todayRecord = [
'date' => $today,
'dwjz' => $fund['dwjz'], // 单位净值
'gsz' => $fund['gsz'], // 估算净值
'gszzl' => floatval($fund['gszzl']), // 估算涨幅
'name' => $fund['name'] ?? '未知基金'
];
// 检查是否已经记录了今天的数据
$todayExists = false;
foreach ($dailyData['funds'][$fundCode] as $record) {
if ($record['date'] === $today) {
$todayExists = true;
break;
}
}
if (!$todayExists) {
$dailyData['funds'][$fundCode][] = $todayRecord;
// 按日期排序
usort($dailyData['funds'][$fundCode], function($a, $b) {
return strtotime($a['date']) - strtotime($b['date']);
});
}
}
file_put_contents($this->dailyDataFile, json_encode($dailyData, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $dailyData;
}
/**
* 加载每日数据
*/
private function loadDailyData() {
return $this->loadFile($this->dailyDataFile, ['last_update' => date('Y-m-d'), 'funds' => []], [$this, 'initDailyData']);
}
/**
* 获取基金的历史数据5天内
*/
private function getFundHistoryData($fundCode) {
$dailyData = $this->loadDailyData();
if (isset($dailyData['funds'][$fundCode])) {
return $dailyData['funds'][$fundCode];
}
return [];
}
/**
* 获取操作日志
*/
public function getOperationLog($limit = 20) {
try {
$log = $this->loadOperationLog();
$operations = $log['operations'];
// 按时间倒序排列
usort($operations, function($a, $b) {
return $b['timestamp'] - $a['timestamp'];
});
// 限制返回数量
$operations = array_slice($operations, 0, $limit);
return [
'success' => true,
'data' => $operations
];
} catch (Exception $e) {
return [
'success' => false,
'message' => '获取操作日志失败: ' . $e->getMessage()
];
}
}
/**
* 获取基金历史图表数据
*/
public function getFundChartData($fundCode) {
try {
$historyData = $this->getFundHistoryData($fundCode);
// 格式化图表数据
$chartData = [
'labels' => [],
'netValues' => [],
'changes' => []
];
foreach ($historyData as $record) {
$chartData['labels'][] = date('m-d', strtotime($record['date']));
$chartData['netValues'][] = floatval($record['gsz']);
$chartData['changes'][] = floatval($record['gszzl']);
}
return [
'success' => true,
'data' => $chartData
];
} catch (Exception $e) {
return [
'success' => false,
'message' => '获取图表数据失败: ' . $e->getMessage()
];
}
}
/**
* 记录访问
*/
private function recordVisit() {
$clientIP = $this->getClientIP();
$userAgent = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
$timestamp = time();
$date = date('Y-m-d H:i:s');
// 检查是否需要记录这次访问(避免短时间内重复计数)
if (!$this->shouldRecordVisit($clientIP, $timestamp)) {
return false; // 不需要记录
}
$visitorData = [
'ip' => $clientIP,
'user_agent' => $userAgent,
'timestamp' => $timestamp,
'date' => $date,
'referer' => $_SERVER['HTTP_REFERER'] ?? 'Direct'
];
// 保存访问记录
$visits = $this->loadVisits();
$visits[] = $visitorData;
// 只保留最近1000条记录平衡数据完整性和存储压力
if (count($visits) > 1000) {
$visits = array_slice($visits, -1000);
}
file_put_contents($this->dataFile, json_encode($visits, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
// 更新统计
$this->updateStats($clientIP);
return true;
}
/**
* 判断是否应该记录这次访问
* 同一IP在10分钟内的访问只记录一次
*/
private function shouldRecordVisit($clientIP, $currentTimestamp) {
$visits = $this->loadVisits();
$timeThreshold = 10 * 60; // 10分钟
// 检查最近的访问记录
for ($i = count($visits) - 1; $i >= 0; $i--) {
$visit = $visits[$i];
// 如果找到相同IP且时间间隔小于阈值则不记录
if ($visit['ip'] === $clientIP && ($currentTimestamp - $visit['timestamp']) < $timeThreshold) {
return false;
}
// 只检查最近的记录,避免遍历所有数据
if (($currentTimestamp - $visit['timestamp']) > $timeThreshold) {
break;
}
}
return true;
}
/**
* 获取客户端IP
*/
private function getClientIP() {
$ip = 'Unknown';
if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} elseif (!empty($_SERVER['REMOTE_ADDR'])) {
$ip = $_SERVER['REMOTE_ADDR'];
}
// 处理多个IP的情况如经过代理
if (strpos($ip, ',') !== false) {
$ips = explode(',', $ip);
$ip = trim($ips[0]);
}
return $ip;
}
/**
* 通用文件加载函数
* @param string $filePath 文件路径
* @param mixed $default 默认返回值
* @param callable $initFunc 初始化函数(可选)
* @return mixed 加载的数据
*/
private function loadFile($filePath, $default = [], $initFunc = null) {
if (!file_exists($filePath)) {
if ($initFunc) {
call_user_func($initFunc);
return $this->loadFile($filePath, $default);
}
return $default;
}
$data = @file_get_contents($filePath);
if (!$data) {
if ($initFunc) {
call_user_func($initFunc);
return $this->loadFile($filePath, $default);
}
return $default;
}
$result = json_decode($data, true);
return is_array($result) ? $result : $default;
}
/**
* 加载访问记录
*/
private function loadVisits() {
return $this->loadFile($this->dataFile, []);
}
/**
* 加载统计数据
*/
private function loadStats() {
$stats = $this->loadFile($this->statsFile, [], [$this, 'initStats']);
// 确保数据结构完整
if (!isset($stats['unique_ips'])) {
$stats['unique_ips'] = [];
}
return $stats;
}
/**
* 更新统计数据
*/
private function updateStats($clientIP) {
$stats = $this->loadStats();
$visits = $this->loadVisits();
// 检查是否需要重置今日统计
$today = date('Y-m-d');
if ($stats['last_reset'] !== $today) {
$stats['today_visits'] = 0;
$stats['today_unique_visitors'] = 0;
$stats['last_reset'] = $today;
}
// 更新总访问量
$stats['total_visits'] = count($visits);
$stats['today_visits']++;
// 更新独立访客统计
$allIPs = array_column($visits, 'ip');
$uniqueIPs = array_unique($allIPs);
$stats['unique_visitors'] = count($uniqueIPs);
// 更新今日独立访客
$todayVisits = array_filter($visits, function($visit) use ($today) {
return date('Y-m-d', $visit['timestamp']) === $today;
});
$todayIPs = array_unique(array_column($todayVisits, 'ip'));
$stats['today_unique_visitors'] = count($todayIPs);
// 更新每日统计
$todayKey = date('Y-m-d');
if (!isset($stats['daily_stats'][$todayKey])) {
$stats['daily_stats'][$todayKey] = 0;
}
$stats['daily_stats'][$todayKey]++;
// 只保留最近30天的统计
if (count($stats['daily_stats']) > 30) {
$stats['daily_stats'] = array_slice($stats['daily_stats'], -30, 30, true);
}
// 保存唯一IP列表用于快速计算
$stats['unique_ips'] = array_values($uniqueIPs);
file_put_contents($this->statsFile, json_encode($stats, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $stats;
}
/**
* 获取访问统计
*/
public function getVisitStats() {
$stats = $this->loadStats();
$visits = $this->loadVisits();
// 获取最近访问记录最新的10条
$recentVisits = array_slice($visits, -10);
$recentVisits = array_reverse($recentVisits); // 最新的在前面
return [
'success' => true,
'data' => [
'total_visits' => $stats['total_visits'],
'today_visits' => $stats['today_visits'],
'unique_visitors' => $stats['unique_visitors'],
'today_unique_visitors' => $stats['today_unique_visitors'],
'last_reset' => $stats['last_reset'],
'daily_stats' => $stats['daily_stats'],
'recent_visits' => $recentVisits
]
];
}
/**
* 批量获取基金数据
*/
public function getBatchFundData($fundCodes) {
$fundsData = [];
$errors = [];
$needToFetch = [];
// 先从缓存获取可用的数据
foreach ($fundCodes as $fundCode) {
$fundData = $this->getFundFromCache($fundCode);
if ($fundData) {
$fundsData[$fundCode] = $fundData;
} else {
$needToFetch[] = $fundCode;
}
}
// 只获取缓存中不存在的数据
if (!empty($needToFetch)) {
foreach ($needToFetch as $fundCode) {
$fundData = $this->getSingleFundData($fundCode);
if ($fundData) {
$fundsData[$fundCode] = $fundData;
} else {
$errors[] = "基金 {$fundCode} 数据获取失败";
}
// 高频请求时移除延迟
// usleep(150000); // 0.15秒
}
}
return [
'success' => $fundsData,
'errors' => $errors
];
}
/**
* 获取单个基金数据
*/
private function getSingleFundData($fundCode) {
// 先尝试短期缓存
$cached = $this->getFundFromCache($fundCode);
if ($cached) return $cached;
$apiUrl = "http://fundgz.1234567.com.cn/js/{$fundCode}.js?" . time();
$context = stream_context_create([
'http' => [
'timeout' => 10,
'header' => "User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36\r\n",
'ignore_errors' => true
]
]);
$response = @file_get_contents($apiUrl, false, $context);
if (!$response) {
return false;
}
// 兼容不同jsonp格式
if (preg_match('/jsonpgz\d*\((.*)\);?/', $response, $matches)) {
$data = json_decode($matches[1], true);
// 检查数据是否有效
if ($data && isset($data['fundcode']) && $data['fundcode'] == $fundCode) {
// 写入缓存以便后续短期复用
$this->cacheFundData($fundCode, $data);
return $data;
}
}
return false;
}
/**
* 计算各渠道统计信息
*/
private function calculateChannelStats($fundsData, $fundChannelMap, $fundInvestmentMap) {
$channelStats = [];
// 初始化渠道统计
$channelNames = array_unique($fundChannelMap);
foreach ($channelNames as $channelName) {
$channelStats[$channelName] = [
'count' => 0,
'totalChange' => 0,
'upCount' => 0,
'downCount' => 0,
'totalInvestment' => 0,
'totalCurrentValue' => 0,
'totalProfitLoss' => 0,
'funds' => []
];
}
// 统计各渠道数据
foreach ($fundsData as $fundCode => $fund) {
$channelName = $fundChannelMap[$fundCode];
// 明确使用当前的估算涨跌幅(gszzl),而不是历史数据
$currentChange = floatval($fund['gszzl']);
$channelStats[$channelName]['totalChange'] += $currentChange;
// 使用配置的投资金额
$investment = $fundInvestmentMap[$fundCode] ?? 1000;
// 更真实的基金计算方式
// 1. 获取单位净值(使用昨日的单位净值作为购买时的净值)
$unitNetValue = floatval($fund['dwjz']) > 0 ? floatval($fund['dwjz']) : 1.0;
// 2. 计算购买份额(投资金额 / 单位净值)
$shares = $investment / $unitNetValue;
// 3. 获取当前估算净值
$currentNetValue = floatval($fund['gsz']) > 0 ? floatval($fund['gsz']) : $unitNetValue;
// 4. 计算当前价值(份额 * 当前估算净值)
$currentValue = $shares * $currentNetValue;
// 5. 计算盈亏
$profitLoss = $currentValue - $investment;
// 正确计算昨日收盘时的价值
// 使用正确的公式:昨日单位净值 = 当前单位净值 / (1 + 今日涨幅/100) * (1 + 昨日涨幅/100)
$todayChange = floatval($fund['gszzl']);
$yesterdayChange = floatval($fund['yesterday_change'] ?? 0);
// 计算昨日单位净值
// 如果今日涨幅为-100%(不可能),则使用替代方案
$yesterdayUnitNetValue = ($todayChange == -100) ?
$unitNetValue : ($unitNetValue / (1 + $todayChange / 100)) * (1 + $yesterdayChange / 100);
// 计算昨日价值
$yesterdayValue = $shares * $yesterdayUnitNetValue;
// 7. 计算今日盈亏(从昨日收盘价到当前估值)
$todayProfitLoss = $currentValue - $yesterdayValue;
$channelStats[$channelName]['count']++;
$channelStats[$channelName]['totalInvestment'] += $investment;
$channelStats[$channelName]['totalCurrentValue'] += $currentValue;
$channelStats[$channelName]['totalProfitLoss'] += $profitLoss;
// 确保昨天涨幅数据也被包含在channelStats中
$formattedYesterdayChange = $yesterdayChange !== null ?
($yesterdayChange > 0 ? '+' : '') . $this->formatNumber($yesterdayChange) . '%' : '--';
$channelStats[$channelName]['funds'][$fundCode] = array_merge($fund, [
'investment' => $investment,
'current_value' => $currentValue,
'profit_loss' => $profitLoss,
'shares' => $shares,
'unit_net_value' => $unitNetValue,
'current_net_value' => $currentNetValue,
'yesterday_unit_net_value' => $yesterdayUnitNetValue, // 添加昨日单位净值
'yesterday_value' => $yesterdayValue, // 添加昨日收盘价值
'today_profit_loss' => $todayProfitLoss, // 添加今日盈亏
'yesterday_change' => $yesterdayChange, // 添加昨日涨幅
'formatted_yesterday_change' => $formattedYesterdayChange // 添加格式化的昨日涨幅
]);
// 使用更严格的判断标准,避免浮点数精度问题
if ($currentChange > 0.001) {
$channelStats[$channelName]['upCount']++;
} elseif ($currentChange < -0.001) {
$channelStats[$channelName]['downCount']++;
}
}
// 计算平均涨跌幅和盈亏比例
foreach ($channelStats as $channelName => &$stats) {
$stats['avgChange'] = $stats['count'] > 0 ? $stats['totalChange'] / $stats['count'] : 0;
$stats['profitLossRate'] = $stats['totalInvestment'] > 0 ? ($stats['totalProfitLoss'] / $stats['totalInvestment']) * 100 : 0;
}
return $channelStats;
}
/**
* 根据渠道索引获取渠道名称
*/
private function getChannelName($channelIndex) {
$channels = ["招商银行", "天天基金", "支付宝"];
return $channels[$channelIndex] ?? "未知渠道";
}
/**
* 获取渠道CSS类名
*/
private function getChannelClass($channelName) {
$channelMap = [
'招商银行' => 'cmb',
'天天基金' => 'tt',
'支付宝' => 'zfb'
];
return $channelMap[$channelName] ?? 'cmb';
}
/**
* 获取渠道图标
*/
private function getChannelIcon($channelName) {
$iconMap = [
'招商银行' => '🏦',
'天天基金' => '📱',
'支付宝' => '💙'
];
return $iconMap[$channelName] ?? '🏦';
}
/**
* 获取渠道描述
*/
private function getChannelDescription($channelName) {
$descMap = [
'招商银行' => '银行渠道,申购费率较低,服务稳定',
'天天基金' => '专业基金平台,品种齐全,数据精准',
'支付宝' => '便捷支付,操作简单,用户体验佳'
];
return $descMap[$channelName] ?? '基金购买渠道';
}
/**
* 格式化数字
*/
private function formatNumber($number) {
return number_format($number, 2);
}
/**
* 格式化货币金额
*/
private function formatCurrency($amount) {
if ($amount >= 10000) {
return number_format($amount / 10000, 2) . '万元';
}
return number_format($amount, 2) . '元';
}
/**
* 获取基金数据
*/
public function getFundData() {
// 尝试加载批量缓存
$batchCacheKey = 'batch_fund_data';
$batchCached = $this->getFromBatchCache($batchCacheKey);
if ($batchCached) {
return $batchCached;
}
// 从配置文件读取基金配置
$config = $this->loadConfig();
if (empty($config)) {
return [
'success' => false,
'message' => '基金配置文件不存在或为空'
];
}
// 按渠道分组基金代码
$channelFunds = [];
foreach ($config as $fund) {
$channelIndex = $fund['channel'];
$fundCode = $fund['fund_code'];
if (!isset($channelFunds[$channelIndex])) {
$channelFunds[$channelIndex] = ["$channelIndex"];
}
$channelFunds[$channelIndex][] = $fundCode;
}
// 重新索引数组
$channelFunds = array_values($channelFunds);
// 合并所有基金代码
$allFundCodes = [];
$fundChannelMap = []; // 基金代码到渠道的映射
$fundInvestmentMap = []; // 基金代码到投资金额的映射
foreach ($channelFunds as $channelData) {
$channelIndex = $channelData[0];
$channelName = $this->getChannelName($channelIndex);
for ($i = 1; $i < count($channelData); $i++) {
$fundCode = $channelData[$i];
$allFundCodes[] = $fundCode;
$fundChannelMap[$fundCode] = $channelName;
// 查找对应的投资金额
foreach ($config as $fund) {
if ($fund['fund_code'] === $fundCode && $fund['channel'] === $channelIndex) {
$fundInvestmentMap[$fundCode] = $fund['investment'];
break;
}
}
}
}
// 获取基金数据
$result = $this->getBatchFundData($allFundCodes);
$fundsData = $result['success'];
$errors = $result['errors'];
// 首先计算总体统计信息,确保使用原始的当前数据,避免任何数据混淆
// 总体统计信息
$total = count($fundsData);
$upCount = 0;
$downCount = 0;
$flatCount = 0;
$totalChange = 0;
// 添加总体盈亏统计
$totalInvestment = 0;
$totalCurrentValue = 0;
$totalProfitLoss = 0;
$totalProfitLossRate = 0;
foreach ($fundsData as $fundCode => $fund) {
// 明确使用当前的估算涨跌幅(gszzl),而不是历史数据
// gszzl字段是当前基金的实时估算涨跌幅
$currentChange = isset($fund['gszzl']) ? floatval($fund['gszzl']) : 0;
$totalChange += $currentChange;
// 精确判断当前涨跌状态
if ($currentChange > 0.001) $upCount++; // 当前上涨
elseif ($currentChange < -0.001) $downCount++; // 当前下跌
else $flatCount++; // 持平
}
// 然后再进行其他操作
// 更新历史数据 - 记录今天的数据
$this->updateHistory($fundsData);
// 记录每日数据
$this->recordDailyData($fundsData);
// 为每个基金添加历史涨幅数据(昨天的数据)
foreach ($fundsData as $fundCode => &$fund) {
$yesterdayChange = $this->getFundHistory($fundCode);
// 确保yesterday_change始终有值即使没有历史数据也提供默认值
$fund['yesterday_change'] = $yesterdayChange !== null ? $yesterdayChange : 0;
$fund['formatted_yesterday_change'] = $yesterdayChange !== null ?
($yesterdayChange > 0 ? '+' : '') . $this->formatNumber($yesterdayChange) . '%' : '--';
}
// 计算各渠道统计信息
$channelStats = $this->calculateChannelStats($fundsData, $fundChannelMap, $fundInvestmentMap);
// 使用配置的投资金额计算盈亏 - 更真实的计算方式
foreach ($fundsData as $fundCode => &$fund) {
$investment = $fundInvestmentMap[$fundCode] ?? 1000;
// 1. 获取单位净值
$unitNetValue = floatval($fund['dwjz']) > 0 ? floatval($fund['dwjz']) : 1.0;
// 2. 计算购买份额
$shares = $investment / $unitNetValue;
// 3. 获取当前估算净值
$currentNetValue = floatval($fund['gsz']) > 0 ? floatval($fund['gsz']) : $unitNetValue;
// 4. 计算当前价值
$currentValue = $shares * $currentNetValue;
$profitLoss = $currentValue - $investment;
// 正确计算昨日收盘时的价值
// 使用正确的公式:昨日单位净值 = 当前单位净值 / (1 + 今日涨幅/100) * (1 + 昨日涨幅/100)
$todayChange = floatval($fund['gszzl']);
$yesterdayChange = floatval($fund['yesterday_change'] ?? 0);
// 计算昨日单位净值
// 如果今日涨幅为-100%(不可能),则使用替代方案
$yesterdayUnitNetValue = ($todayChange == -100) ?
$unitNetValue : ($unitNetValue / (1 + $todayChange / 100)) * (1 + $yesterdayChange / 100);
// 计算昨日价值和今日盈亏
$yesterdayValue = $shares * $yesterdayUnitNetValue;
$todayProfitLoss = $currentValue - $yesterdayValue;
// 确保数据一致性
$fund['yesterday_unit_net_value'] = $yesterdayUnitNetValue;
$totalInvestment += $investment;
$totalCurrentValue += $currentValue;
$totalProfitLoss += $profitLoss;
}
$avgChange = $total > 0 ? $totalChange / $total : 0;
$totalProfitLossRate = $totalInvestment > 0 ? ($totalProfitLoss / $totalInvestment) * 100 : 0;
// 格式化数据返回
$result = [
'success' => true,
'data' => [
'summary' => [
'total' => $total,
'upCount' => $upCount,
'downCount' => $downCount,
'flatCount' => $flatCount,
'avgChange' => $avgChange,
'totalInvestment' => $totalInvestment,
'totalCurrentValue' => $totalCurrentValue,
'totalProfitLoss' => $totalProfitLoss,
'totalProfitLossRate' => $totalProfitLossRate,
'formattedTotalInvestment' => $this->formatCurrency($totalInvestment),
'formattedTotalCurrentValue' => $this->formatCurrency($totalCurrentValue),
'formattedTotalProfitLoss' => $this->formatCurrency($totalProfitLoss),
'formattedProfitLossRate' => $this->formatNumber($totalProfitLossRate),
'profitLossClass' => $totalProfitLoss >= 0 ? 'profit' : 'loss',
'profitLossIcon' => $totalProfitLoss >= 0 ? '📈' : '📉'
],
'channelStats' => $channelStats,
'fundsData' => $fundsData,
'fundChannelMap' => $fundChannelMap,
'errors' => $errors,
'timestamp' => date('Y-m-d H:i:s')
]
];
// 缓存批量结果
$this->cacheBatchData($batchCacheKey, $result);
return $result;
}
/**
* 处理API请求
*/
public function handleRequest() {
try {
$method = $_SERVER['REQUEST_METHOD'];
// 解析请求参数
$queryString = $_SERVER['QUERY_STRING'] ?? '';
parse_str($queryString, $params);
$action = $params['action'] ?? 'fund_data';
// 记录访问(除了统计接口本身)
if ($action !== 'get_stats') {
$this->recordVisit();
}
if ($method === 'GET') {
switch ($action) {
case 'fund_data':
case 'get_fund_data':
// 获取基金数据同时支持get_fund_data和fund_data两种action
// 检查是否提供了单个fund_code参数
$fundCode = isset($params['fund_code']) ? trim($params['fund_code']) : '';
if (!empty($fundCode)) {
$fundData = $this->getSingleFundData($fundCode);
if ($fundData) {
$result = ['success' => true, 'data' => $fundData];
} else {
$result = ['success' => false, 'message' => '获取基金数据失败: ' . $fundCode];
}
} else {
// 否则获取所有基金数据
$result = $this->getFundData();
}
break;
case 'get_stats':
// 获取访问统计
$result = $this->getVisitStats();
break;
case 'get_operation_log':
$limit = isset($params['limit']) ? intval($params['limit']) : 20;
$result = $this->getOperationLog($limit);
break;
case 'get_fund_chart':
$fundCode = isset($params['fund_code']) ? trim($params['fund_code']) : '';
if (empty($fundCode)) {
$result = ['success' => false, 'message' => '基金代码不能为空'];
} else {
$result = $this->getFundChartData($fundCode);
}
break;
default:
http_response_code(404);
$result = ['error' => 'Endpoint not found'];
}
// 检查是否需要发送基金情况邮件
$this->sendFundStatusEmail();
// 统一返回JSON响应
echo json_encode($result, JSON_UNESCAPED_UNICODE);
} else {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed'], JSON_UNESCAPED_UNICODE);
}
} catch (Exception $e) {
// 捕获异常,返回错误响应
http_response_code(500);
echo json_encode(['success' => false, 'message' => '服务器内部错误: ' . $e->getMessage()], JSON_UNESCAPED_UNICODE);
}
}
}
// 错误处理
try {
// 处理API请求
$api = new FundMonitorAPI();
$api->handleRequest();
} catch (Exception $e) {
http_response_code(500);
echo json_encode([
'success' => false,
'error' => '服务器内部错误: ' . $e->getMessage()
]);
}
?>