Files
MovieLog_Server/annual_report.php
T
laoyang 673a8abf78 feat: 云端图片存储(OSS/COS/七牛)+ 服务端代理读取,并补全文档
新增存储抽象层:图片可存本地或云对象存储,切换存储方式不影响已有图片。

存储层
- lib/storage.php 存储门面:本地优先读取、URL 生成、上传/删除、批量迁移、
  服务端读取(storageRead)与访问方式判定(storageAccessMode)
- lib/storage/{local,oss,cos,qiniu}.php 四种后端驱动;http.php 共用 cURL 客户端;
  meta.php 实例元数据鉴权(ECS RAM 角色 / CVM CAM 角色,免密钥)
- lib/settings.php 设置读写;云厂商密钥以 enc:v1:gcm 密文落库(db/.settings.key)
- 文件名改为内容指纹(md5 前 12 位_6 位随机[_角色].ext),内容不变则名不变,
  因此可下发 immutable 长缓存

两种图片访问方式
- 直连存储:storageUrl() 产出对象存储地址(桶需允许匿名读)
- 服务端代理:media.php 鉴权后由服务器签名读取并同源回吐,桶可保持私有
  · 未登录返回 403 纯文本(刻意不用 302,否则 <img> 会拿到一段 HTML)
  · 对象键白名单防目录穿越;Content-Type 按扩展名白名单决定 + nosniff(防同源 XSS)
  · 透传 If-None-Match / Range,上游 304 直接短路;private + immutable 长缓存
  · 鉴权后立即 session_write_close(),避免排他会话锁把并行图片请求串行化

内网 endpoint 判定(修「开了代理后慢到不可用」)
- 内网地址只在与桶同地域的 ECS 上可路由;跨地域时 DNS 能解析但 TCP 连不通,
  无脑「内网优先」会让每次读取先白等满一次连接超时(默认 10 秒)
- 改为先比本机 ECS 地域与桶地域(零成本、结论确定)→ 不同即跳过;
  结论落缓存;内网只给 2 秒连接超时,传输层失败立刻标记且短期内不再重试
- 「测试连通性」升级为 PUT → GET 回读 → DELETE,并显式报出内网判定结论

界面与页面
- settings.php 系统设置页:驱动选择、各云配置、图片访问方式、连接测试、迁移进度
- storage_api.php 提供 stat / migrate / test 三个 JSON 接口
- detail / edit / annual_report / persons 原先直接拼本地路径(file_exists / '/'.$path),
  迁云后会整块不渲染或 404,统一改为走存储门面
- js/main.js 增加破图占位兜底;config.php 统一下发 Referrer-Policy: same-origin

文档
- README 新增《图片存储与对象存储》整章:设计约定、两种访问方式取舍表、
  直连存储逐项配置说明(桶 ACL / 防盗链白名单 / 跨域 CORS 与 crossorigin /
  强制下载 / endpoint 地域 / 自定义域名)与上线检查清单、服务端代理实现要点、
  迁移步骤、12 条踩坑记录、可直接复制的排障命令手册
2026-09-21 22:06:02 +08:00

583 lines
21 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
// ============================================
// 文件: annual_report.php (年度报告)
// 说明: 修复标题居中问题
// ============================================
require_once 'config.php';
checkLogin();
$admin = getCurrentAdmin();
// 获取可选年份
$years = getYearsWithData();
$current_year = date('Y');
// 默认选择最新有数据的年份,否则当前年份
$selected_year = isset($_GET['year']) ? (int)$_GET['year'] : ( !empty($years) ? $years[0] : $current_year );
// 如果所选年份无数据,回退到最新有数据的年份
if (!in_array($selected_year, $years) && !empty($years)) {
$selected_year = $years[0];
}
$stats = getAnnualStats($selected_year);
$has_data = $stats['total'] > 0;
// 计算数据用于图表
$month_labels = ['1月','2月','3月','4月','5月','6月','7月','8月','9月','10月','11月','12月'];
$month_values = array_values($stats['month_distribution']);
$weekday_labels = ['周日','周一','周二','周三','周四','周五','周六'];
$weekday_values = array_values($stats['weekday_distribution']);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= $selected_year ?> 年度观影报告 - 电影日志</title>
<link rel="stylesheet" href="css/style.css">
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<style>
:root {
--report-bg: #0f0e17;
--report-card: #1a1a2e;
--report-text: #fffffe;
--report-muted: #a7a9be;
--report-primary: #e67e22;
--report-secondary: #f39c12;
}
body {
background: var(--report-bg);
color: var(--report-text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.report-container {
max-width: 1100px;
margin: 0 auto;
padding: 20px 20px 60px;
}
.report-header {
text-align: center;
padding: 30px 0 20px;
}
.report-header h1 {
font-size: 42px;
font-weight: 800;
background: linear-gradient(135deg, var(--report-primary), var(--report-secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0;
}
.report-header .sub {
color: var(--report-muted);
font-size: 18px;
margin-top: 8px;
}
.year-selector {
display: inline-block;
margin: 16px 0;
background: var(--report-card);
padding: 8px 16px;
border-radius: 30px;
border: 1px solid rgba(255,255,255,0.1);
}
.year-selector select {
background: transparent;
color: var(--report-text);
border: none;
font-size: 18px;
font-weight: 600;
padding: 8px 12px;
outline: none;
cursor: pointer;
}
.year-selector select option {
background: var(--report-card);
color: var(--report-text);
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
margin: 30px 0;
}
.summary-card {
background: var(--report-card);
border-radius: 16px;
padding: 20px 16px;
text-align: center;
border: 1px solid rgba(255,255,255,0.05);
transition: transform 0.2s;
}
.summary-card:hover {
transform: translateY(-4px);
}
.summary-card .number {
font-size: 32px;
font-weight: 700;
color: var(--report-primary);
}
.summary-card .label {
font-size: 14px;
color: var(--report-muted);
margin-top: 4px;
}
.highlight-card {
background: var(--report-card);
border-radius: 16px;
padding: 24px 28px;
margin-bottom: 24px;
border: 1px solid rgba(255,255,255,0.05);
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
}
.highlight-card .label {
color: var(--report-muted);
font-size: 14px;
}
.highlight-card .value {
font-size: 24px;
font-weight: 700;
color: var(--report-text);
}
.highlight-card .value .highlight {
color: var(--report-primary);
}
.highlight-card .exclude-hint {
font-size: 12px;
color: var(--report-muted);
margin-top: 4px;
}
.chart-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
margin-top: 30px;
}
.chart-box {
background: var(--report-card);
border-radius: 16px;
padding: 20px;
border: 1px solid rgba(255,255,255,0.05);
}
.chart-box h3 {
text-align: center;
color: var(--report-muted);
font-size: 16px;
font-weight: 400;
margin: 0 0 16px;
}
.chart-box canvas {
width: 100% !important;
height: auto !important;
max-height: 220px;
}
.keyword-section {
margin-top: 30px;
text-align: center;
}
.keyword-section h3 {
text-align: center;
color: var(--report-muted);
font-weight: 400;
}
.recent-movies {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-top: 12px;
}
.recent-movies .movie-tag {
background: var(--report-card);
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
color: var(--report-text);
border: 1px solid rgba(255,255,255,0.06);
}
.no-data {
text-align: center;
padding: 60px 20px;
color: var(--report-muted);
}
.no-data .big-icon {
font-size: 64px;
display: block;
margin-bottom: 16px;
}
/* 我的观影记录 - 无滚动条 */
.my-movies-section {
margin-top: 30px;
}
.my-movies-section h3 {
color: var(--report-muted);
font-weight: 400;
text-align: center;
}
.movie-entry {
display: flex;
align-items: center;
gap: 20px;
background: var(--report-card);
border-radius: 12px;
padding: 16px 20px;
margin-bottom: 12px;
border: 1px solid rgba(255,255,255,0.05);
}
.movie-entry .poster {
width: 80px;
height: 112px;
border-radius: 6px;
object-fit: cover;
background: #333;
flex-shrink: 0;
}
.movie-entry .info {
flex: 1;
}
.movie-entry .info .title {
font-size: 18px;
font-weight: 600;
margin-bottom: 4px;
}
.movie-entry .info .date {
font-size: 14px;
color: var(--report-muted);
}
.movie-entry .viewers {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 6px;
}
.movie-entry .viewers .viewer {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: var(--report-text);
background: rgba(255,255,255,0.06);
padding: 2px 8px 2px 4px;
border-radius: 20px;
}
.movie-entry .viewers .viewer img {
width: 20px;
height: 20px;
border-radius: 50%;
object-fit: cover;
}
.movie-entry .viewers .viewer.alone {
color: var(--report-muted);
background: transparent;
padding-left: 0;
}
/* 共同观影人列表 */
.common-viewers {
display: flex;
flex-wrap: wrap;
gap: 16px;
justify-content: center;
margin-top: 12px;
}
.common-viewer-item {
display: flex;
flex-direction: column;
align-items: center;
background: var(--report-card);
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(255,255,255,0.05);
min-width: 80px;
}
.common-viewer-item img {
width: 48px;
height: 48px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 4px;
}
.common-viewer-item .name {
font-size: 14px;
font-weight: 500;
}
.common-viewer-item .count {
font-size: 12px;
color: var(--report-muted);
}
@media (max-width: 768px) {
.chart-grid {
grid-template-columns: 1fr;
}
.report-header h1 {
font-size: 28px;
}
.summary-grid {
grid-template-columns: repeat(2, 1fr);
}
.highlight-card {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.highlight-card .value {
font-size: 20px;
}
.movie-entry {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.movie-entry .poster {
width: 100%;
height: auto;
aspect-ratio: 2/3;
max-height: 200px;
}
}
@media (max-width: 480px) {
.summary-grid {
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.summary-card .number {
font-size: 24px;
}
}
</style>
</head>
<body>
<?php include 'nav.php'; ?>
<main>
<div class="report-container">
<div class="report-header">
<h1>🎬 <?= $selected_year ?> 观影年度报告</h1>
<p class="sub">回顾你这一年的光影旅程</p>
<div class="year-selector">
<form method="GET" action="">
<select name="year" onchange="this.form.submit()">
<?php foreach ($years as $y): ?>
<option value="<?= $y ?>" <?= $y == $selected_year ? 'selected' : '' ?>><?= $y ?> 年</option>
<?php endforeach; ?>
<?php if (!in_array($current_year, $years)): ?>
<option value="<?= $current_year ?>" <?= $current_year == $selected_year ? 'selected' : '' ?>><?= $current_year ?> 年</option>
<?php endif; ?>
</select>
</form>
</div>
</div>
<?php if (!$has_data): ?>
<div class="no-data">
<span class="big-icon">📭</span>
<h2>这一年还没有观影记录</h2>
<p style="color: var(--report-muted);">快去添加电影,开启你的光影之旅吧</p>
<a href="add.php" class="btn btn-primary" style="margin-top: 20px;">➕ 添加电影</a>
</div>
<?php else: ?>
<!-- 总览卡片 -->
<div class="summary-grid">
<div class="summary-card">
<div class="number"><?= $stats['total'] ?></div>
<div class="label">🎬 观影总数</div>
</div>
<div class="summary-card">
<div class="number"><?= $stats['days'] ?></div>
<div class="label">📅 观影天数</div>
</div>
<div class="summary-card">
<div class="number"><?= $stats['avg_per_month'] ?></div>
<div class="label">📊 月均观影</div>
</div>
<div class="summary-card">
<div class="number"><?= $stats['avg_days_between'] ?></div>
<div class="label">📆 平均每<?= $stats['avg_days_between'] > 0 ? $stats['avg_days_between'] : '-' ?>天一部</div>
</div>
</div>
<!-- 亮点 -->
<div class="highlight-card">
<div>
<div class="label">🏛️ 最常去的影院</div>
<div class="value"><span class="highlight"><?= h($stats['top_cinema']) ?></span> (<?= $stats['top_cinema_count'] ?> 次)</div>
</div>
<div>
<div class="label">👥 最常一起观影的人</div>
<div class="value"><span class="highlight"><?= h($stats['top_person']) ?></span> (<?= $stats['top_person_count'] ?> 次)</div>
<?php if ($stats['self_person_name']): ?>
<div class="exclude-hint">✅ 已排除 "<?= h($stats['self_person_name']) ?>" 自己</div>
<?php else: ?>
<div class="exclude-hint">⚠️ 未设置自己的观影人身份,未排除自己</div>
<?php endif; ?>
</div>
<div>
<div class="label">🎫 单次最多人</div>
<div class="value"><span class="highlight"><?= h($stats['most_viewers_movie']) ?></span> (<?= $stats['most_viewers_count'] ?> 人)</div>
</div>
</div>
<!-- 图表 -->
<div class="chart-grid">
<div class="chart-box">
<h3>📈 月度观影分布</h3>
<canvas id="monthChart"></canvas>
</div>
<div class="chart-box">
<h3>📆 星期观影偏好</h3>
<canvas id="weekdayChart"></canvas>
</div>
</div>
<!-- 今年共同观影人 -->
<?php if (!empty($stats['common_viewers'])): ?>
<div class="keyword-section">
<h3>👥 今年共同观影人</h3>
<div class="common-viewers">
<?php foreach ($stats['common_viewers'] as $viewer): ?>
<div class="common-viewer-item">
<img src="<?= getAvatarUrl($viewer['avatar'], $viewer['name']) ?>" alt="">
<span class="name"><?= h($viewer['name']) ?></span>
<span class="count">共同观影 <?= $viewer['count'] ?> 次</span>
</div>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- 年末最近观影 -->
<?php if (!empty($stats['recent_movies'])): ?>
<div class="keyword-section">
<h3>🎥 年末最近观影</h3>
<div class="recent-movies">
<?php foreach ($stats['recent_movies'] as $title): ?>
<span class="movie-tag"><?= h($title) ?></span>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<!-- 我的观影记录(含海报和观影人头像) -->
<?php if (!empty($stats['my_movies'])): ?>
<div class="my-movies-section">
<h3>🎞️ 我的观影记录 (<?= count($stats['my_movies']) ?> 部)</h3>
<?php foreach ($stats['my_movies'] as $movie): ?>
<div class="movie-entry">
<?php
$poster = posterUrl($movie['poster_path']); // 本地/对象存储自动解析,无海报时回退占位图
?>
<img class="poster" src="<?= $poster ?>" alt="<?= h($movie['title']) ?>">
<div class="info">
<div class="title"><?= h($movie['title']) ?></div>
<div class="date">📅 <?= date('Y-m-d', strtotime($movie['watch_date'])) ?></div>
<div class="viewers">
<?php if (!empty($movie['viewers'])): ?>
<?php foreach ($movie['viewers'] as $viewer): ?>
<span class="viewer">
<img src="<?= getAvatarUrl($viewer['avatar'], $viewer['name']) ?>" alt="">
<?= h($viewer['name']) ?>
</span>
<?php endforeach; ?>
<?php else: ?>
<span class="viewer alone">独自观影</span>
<?php endif; ?>
</div>
</div>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- 年份跨度 -->
<div style="text-align: center; margin-top: 30px; color: var(--report-muted); font-size: 14px;">
首部观影:<?= $stats['first_date'] ? date('Y年m月d日', strtotime($stats['first_date'])) : '-' ?>
&nbsp;·&nbsp;
最后一部:<?= $stats['last_date'] ? date('Y年m月d日', strtotime($stats['last_date'])) : '-' ?>
</div>
<?php endif; ?>
</div>
</main>
<script>
<?php if ($has_data): ?>
// 月度图表
const monthCtx = document.getElementById('monthChart').getContext('2d');
new Chart(monthCtx, {
type: 'bar',
data: {
labels: <?= json_encode($month_labels) ?>,
datasets: [{
label: '观影数量',
data: <?= json_encode($month_values) ?>,
backgroundColor: 'rgba(230, 126, 34, 0.7)',
borderColor: '#e67e22',
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1, color: '#a7a9be' },
grid: { color: 'rgba(255,255,255,0.05)' }
},
x: {
ticks: { color: '#a7a9be' },
grid: { display: false }
}
}
}
});
// 星期图表
const weekdayCtx = document.getElementById('weekdayChart').getContext('2d');
new Chart(weekdayCtx, {
type: 'bar',
data: {
labels: <?= json_encode($weekday_labels) ?>,
datasets: [{
label: '观影数量',
data: <?= json_encode($weekday_values) ?>,
backgroundColor: 'rgba(243, 156, 18, 0.7)',
borderColor: '#f39c12',
borderWidth: 1,
borderRadius: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { stepSize: 1, color: '#a7a9be' },
grid: { color: 'rgba(255,255,255,0.05)' }
},
x: {
ticks: { color: '#a7a9be' },
grid: { display: false }
}
}
}
});
<?php endif; ?>
</script>
<script src="js/main.js"></script>
</body>
</html>