Files
MovieLog_Server/add.php
T
laoyang ddf2eeafaa feat: 「自动获取」改用豆瓣数据源,海报自动下载入库
此前的 OMDb 数据源必须用英文片名,国产片覆盖极差。改用豆瓣公开接口后
中文片名直接可搜,冷门片/老片/剧集也能命中。

fetch_movie.php(全量重写)
- 搜索走 movie.douban.com/j/subject_suggest;详情走移动端
  m.douban.com/rexxar/api/v2/movie/<id>(PC 详情页有反爬 302,故用移动端接口)
- 海报下载后写入 uploads/posters/,文件名沿用内容指纹规则
- 新增登录鉴权:未登录返回 401(此前该接口完全公开)
- 启用 TLS 证书校验(原为 CURLOPT_SSL_VERIFYPEER=false)
- 魔数兜底 + 12MB 上限:挡掉反爬返回的 HTML 页面与异常大响应
- old_poster 残留清理:重复点击时删掉上一张,且仅删「未被数据库引用」的文件
- 接口地址支持环境变量覆盖,便于用 mock 做端到端测试

lib/helpers.php
- 新增 isValidStoredKey():校验表单回传的存储键,防目录穿越与越权引用

add.php
- 前端改用新字段(豆瓣评分 / 地区 / 又名),并把 poster_key 放进隐藏域回传
- 提交时「手动上传的海报优先」,没有才采用自动获取已入库的海报

README.md
- 同步为豆瓣数据源说明,移除已过时的 OMDb API Key 与 SSL 校验两条安全提示

验证
- 本地 mock 豆瓣端到端:33/33 通过
- 既有存储回归套件:196/196 通过
- A/B 归一化比对:除 add.php 按预期变化外,其余 45 个响应完全一致
- 线上实测:流浪地球 / 哪吒之魔童闹海 均抓取成功,海报落盘为合法 JPEG
2026-09-21 23:05:40 +08:00

668 lines
28 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
// ============================================
// 文件: add.php (支持豆瓣自动获取 + 票根裁剪)
// ============================================
require_once 'config.php';
checkLogin();
$admin = getCurrentAdmin();
$cinemas = getCinemas();
// 获取所有观影人
$db = getDB();
$stmt = $db->query("SELECT * FROM persons ORDER BY name");
$all_persons = $stmt->fetchAll();
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$title = trim($_POST['title'] ?? '');
$info = trim($_POST['info'] ?? '');
$watch_date = $_POST['watch_date'] ?? '';
$cinema_id = $_POST['cinema_id'] ?? '';
$viewer_selects = $_POST['viewer_select'] ?? [];
$viewer_news = $_POST['viewers_new'] ?? [];
// 票根裁剪数据
$ticket_crop = $_POST['ticket_crop'] ?? '';
// 电影院:选择已有影院、新建影院或留空
$cinema_id = resolveCinemaId(
$cinema_id,
trim($_POST['new_cinema_place'] ?? ''),
trim($_POST['new_cinema_name'] ?? ''),
trim($_POST['new_cinema_address'] ?? '')
);
if (empty($title)) {
$error = '请输入电影名称';
} elseif (empty($watch_date)) {
$error = '请选择观影日期';
} else {
// ----- 海报:优先手动上传,其次采用「自动获取」已抓取入库的海报 -----
$poster_path = uploadImageFile('poster', POSTER_DIR, 'uploads/posters/') ?? '';
if ($poster_path === '') {
// 前端把「自动获取」接口返回的 poster_key 放在隐藏域回传。
// 该键来自客户端,不可信任:需校验目录/文件名,并确认文件确实存在才采用。
$fetched_poster = trim($_POST['fetched_poster_key'] ?? '');
if (isValidStoredKey($fetched_poster, 'uploads/posters/') && storageExists($fetched_poster)) {
$poster_path = $fetched_poster;
}
}
// ----- 票根:裁剪结果优先,其次原始上传 -----
$ticket_path = '';
if (!empty($ticket_crop)) {
$new_path = saveBase64Image($ticket_crop, TICKET_DIR, 'ticket');
if ($new_path) {
$ticket_path = $new_path;
} else {
$error = '票根裁剪保存失败,请重试';
}
} else {
$ticket_path = uploadImageFile('ticket', TICKET_DIR, 'uploads/tickets/') ?? '';
}
// 没有错误才继续
if (empty($error)) {
// 插入电影
$stmt = $db->prepare("
INSERT INTO movies (title, info, poster_path, ticket_path, cinema_id, watch_date)
VALUES (?, ?, ?, ?, ?, ?)
");
$stmt->execute([$title, $info, $poster_path, $ticket_path, $cinema_id, $watch_date]);
$movie_id = $db->lastInsertId();
// ---- 观影照片(多张)----
uploadMultiplePhotos('photos', $movie_id);
// ---- 一起看的人 ----
linkViewers($movie_id, resolvePersonIds($viewer_selects, $viewer_news));
header('Location: detail.php?id=' . $movie_id);
exit;
}
}
}
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>添加电影 - 电影日志</title>
<link rel="stylesheet" href="css/style.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css">
<style>
.form-container {
max-width: 720px;
margin: 0 auto;
padding: 20px;
}
.form-card {
background: var(--card-bg);
border-radius: 16px;
padding: 32px 36px;
box-shadow: var(--shadow);
}
.form-card h2 {
font-size: 24px;
font-weight: 700;
margin: 0 0 8px;
color: var(--text-primary);
}
.form-card .sub {
color: var(--text-secondary);
font-size: 14px;
margin-bottom: 24px;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
font-weight: 600;
font-size: 14px;
color: var(--text-primary);
margin-bottom: 6px;
}
.form-group label .hint {
font-weight: 400;
color: var(--text-secondary);
font-size: 13px;
}
.form-group input, .form-group textarea, .form-group select {
width: 100%;
padding: 12px 16px;
border: 1px solid var(--border-color);
border-radius: 10px;
font-size: 15px;
font-family: inherit;
background: var(--input-bg);
color: var(--text-primary);
transition: border-color 0.2s, box-shadow 0.2s;
box-sizing: border-box;
}
.form-group input:focus, .form-group textarea:focus, .form-group select:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 4px rgba(230,126,34,0.12);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.form-group input[type="file"] {
padding: 10px 12px;
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
}
.form-group input[type="file"]:hover {
border-color: var(--primary);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.form-hint {
font-size: 13px;
color: var(--text-secondary);
margin-top: 4px;
}
.viewer-input-group {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 8px;
}
.viewer-input-group select, .viewer-input-group input {
flex: 1;
}
.viewer-input-group select {
width: 60%;
}
.viewer-input-group input[type="text"] {
width: 40%;
}
.btn-remove-viewer {
background: none;
border: none;
color: #e74c3c;
font-size: 20px;
cursor: pointer;
padding: 0 8px;
line-height: 1;
transition: transform 0.2s;
}
.btn-remove-viewer:hover {
transform: scale(1.2);
}
.btn-add-viewer {
background: var(--bg-secondary);
border: 1px dashed var(--border-color);
border-radius: 8px;
padding: 8px 16px;
color: var(--text-secondary);
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
width: 100%;
}
.btn-add-viewer:hover {
border-color: var(--primary);
color: var(--primary);
background: rgba(230,126,34,0.05);
}
.form-actions {
display: flex;
gap: 12px;
margin-top: 28px;
padding-top: 20px;
border-top: 1px solid var(--border-color);
}
.form-actions .btn {
padding: 12px 32px;
font-size: 16px;
}
.btn-secondary {
background: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
}
.btn-secondary:hover {
background: var(--border-color);
}
.error-box {
background: rgba(231,76,60,0.08);
border: 1px solid rgba(231,76,60,0.2);
color: #e74c3c;
padding: 12px 16px;
border-radius: 10px;
margin-bottom: 20px;
font-size: 14px;
}
.cinema-new-group {
margin-top: 12px;
padding: 16px 18px;
background: var(--bg-secondary);
border-radius: 10px;
border: 1px solid var(--border-color);
}
.cinema-new-group .form-group:last-child {
margin-bottom: 0;
}
/* 裁剪预览 */
.image-preview {
max-width: 120px;
max-height: 120px;
border-radius: 4px;
border: 1px solid var(--border-color);
margin-top: 4px;
}
.crop-btn {
display: inline-block;
padding: 4px 12px;
background: #3498db;
color: #fff;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
margin-top: 4px;
}
.crop-btn:hover { background: #2980b9; }
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.6);
z-index: 999;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal-content {
background: var(--card-bg);
max-width: 600px;
width: 90%;
padding: 24px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-header h3 { margin: 0; }
.modal-header .close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--text-secondary);
}
.modal-body img { max-width: 100%; display: block; }
.modal-footer {
margin-top: 16px;
display: flex;
gap: 12px;
justify-content: flex-end;
}
@media (max-width: 600px) {
.form-card { padding: 20px 16px; }
.form-row { grid-template-columns: 1fr; }
.form-actions { flex-direction: column; }
.form-actions .btn { width: 100%; text-align: center; }
}
</style>
</head>
<body>
<?php include 'nav.php'; ?>
<main class="main-content">
<div class="form-container">
<div class="form-card">
<h2>🎬 添加新电影</h2>
<p class="sub">记录一部你曾看过的电影</p>
<?php if ($error): ?>
<div class="error-box"><?= h($error) ?></div>
<?php endif; ?>
<form method="POST" action="" enctype="multipart/form-data" id="addForm">
<!-- 电影名称 + 自动获取按钮 -->
<div class="form-group">
<label>电影名称 <span class="hint">*必填</span></label>
<div style="display: flex; gap: 10px;">
<input type="text" name="title" id="movieTitle" value="<?= h($_POST['title'] ?? '') ?>" placeholder="请输入电影名称" required style="flex:1;">
<button type="button" class="btn btn-primary" id="fetchBtn" style="white-space:nowrap;">📡 自动获取</button>
</div>
<div id="fetchStatus" style="font-size:13px;color:var(--text-secondary);margin-top:4px;"></div>
</div>
<!-- 电影信息 -->
<div class="form-group">
<label>电影信息</label>
<textarea name="info" id="movieInfo" placeholder="剧情简介、观影感受..."><?= h($_POST['info'] ?? '') ?></textarea>
</div>
<!-- 观影日期 -->
<div class="form-group">
<label>观影日期 <span class="hint">*必填</span></label>
<input type="date" name="watch_date" value="<?= h($_POST['watch_date'] ?? date('Y-m-d')) ?>" required>
</div>
<!-- 电影院 -->
<div class="form-group">
<label>电影院</label>
<select name="cinema_id" id="cinemaSelect" onchange="toggleNewCinema()">
<option value="">-- 不选择 --</option>
<?php foreach ($cinemas as $c): ?>
<option value="<?= $c['id'] ?>" <?= (isset($_POST['cinema_id']) && $_POST['cinema_id'] == $c['id']) ? 'selected' : '' ?>>
<?= h($c['place']) ?> · <?= h($c['name']) ?>
</option>
<?php endforeach; ?>
<option value="new" <?= (isset($_POST['cinema_id']) && $_POST['cinema_id'] === 'new') ? 'selected' : '' ?>>➕ 新建电影院</option>
</select>
</div>
<div class="cinema-new-group" id="newCinemaGroup" style="display: <?= (isset($_POST['cinema_id']) && $_POST['cinema_id'] === 'new') ? 'block' : 'none' ?>;">
<div class="form-group">
<label>所在地方 <span class="hint">如: 万达广场</span></label>
<input type="text" name="new_cinema_place" value="<?= h($_POST['new_cinema_place'] ?? '') ?>" placeholder="如: 万达广场">
</div>
<div class="form-group">
<label>电影院名称 <span class="hint">*必填</span></label>
<input type="text" name="new_cinema_name" value="<?= h($_POST['new_cinema_name'] ?? '') ?>" placeholder="如: 万达影城(IMAX店)">
</div>
<div class="form-group">
<label>详细地址</label>
<input type="text" name="new_cinema_address" value="<?= h($_POST['new_cinema_address'] ?? '') ?>" placeholder="详细地址">
</div>
</div>
<!-- 观影人 -->
<div class="form-group">
<label>一起看的人</label>
<div id="viewerContainer">
<?php
$viewer_selects = $_POST['viewer_select'] ?? [];
$viewer_news = $_POST['viewers_new'] ?? [];
if (empty($viewer_selects)) {
$viewer_selects = [''];
}
foreach ($viewer_selects as $idx => $val):
$new_name = $viewer_news[$idx] ?? '';
?>
<div class="viewer-input-group">
<select name="viewer_select[]" onchange="toggleNewViewer(this)" class="viewer-select" style="flex:1;padding:8px;border:1px solid #ddd;border-radius:6px;">
<option value="">-- 选择 --</option>
<?php foreach ($all_persons as $p): ?>
<option value="<?= $p['id'] ?>" <?= ($val == $p['id']) ? 'selected' : '' ?>><?= h($p['name']) ?></option>
<?php endforeach; ?>
<option value="new" <?= ($val === 'new') ? 'selected' : '' ?>>➕ 新增</option>
</select>
<input type="text" name="viewers_new[]" placeholder="输入新姓名" style="flex:1;display:<?= ($val === 'new') ? 'block' : 'none' ?>;padding:8px;border:1px solid #ddd;border-radius:6px;" value="<?= h($new_name) ?>">
<?php if ($idx > 0): ?>
<button type="button" class="btn-remove-viewer" onclick="removeViewerRow(this)">×</button>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<button type="button" class="btn-add-viewer" onclick="addViewerRow()">➕ 添加一位</button>
<div class="form-hint">选择已有观影人,或选择“新增”后输入姓名</div>
</div>
<!-- 海报(直接上传 + 预览) -->
<div class="form-row">
<div class="form-group">
<label>电影海报</label>
<input type="file" name="poster" accept="image/*" id="posterInput">
<input type="hidden" name="fetched_poster_key" id="fetchedPosterKey" value="">
<div id="posterPreviewWrap" style="display:none;margin-top:6px;">
<img id="posterPreview" style="max-width:150px;max-height:200px;border-radius:4px;border:1px solid var(--border-color);">
<br>
<span id="posterPreviewHint" style="font-size:12px;color:var(--text-secondary);"></span>
</div>
<div class="form-hint">推荐比例 2:3(「自动获取」会连同海报一起下载;手动上传时以上传的为准)</div>
</div>
<!-- 票根(带裁剪) -->
<div class="form-group">
<label>电影票扫描件</label>
<input type="file" name="ticket" id="ticketInput" accept="image/*" onchange="handleTicketSelect(this)">
<img id="ticketPreview" class="image-preview" style="display:none;">
<input type="hidden" name="ticket_crop" id="ticket_crop">
</div>
</div>
<!-- 观影照片(多张) -->
<div class="form-group">
<label>观影照片 <span class="hint">可多选</span></label>
<input type="file" name="photos[]" accept="image/*" multiple>
<div class="form-hint">按住 Ctrl(或 ⌘)可一次选择多张</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">✅ 保存电影</button>
<a href="index.php" class="btn btn-secondary">取消</a>
</div>
</form>
</div>
</div>
</main>
<!-- 裁剪模态框 -->
<div class="modal-overlay" id="cropModal">
<div class="modal-content">
<div class="modal-header">
<h3>✂️ 裁剪票根</h3>
<button class="close-btn" onclick="closeCrop()">&times;</button>
</div>
<div class="modal-body">
<img id="cropImage" src="">
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeCrop()">取消</button>
<button class="btn btn-primary" onclick="cropSave()">✅ 确认裁剪</button>
</div>
</div>
</div>
<script src="js/main.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
<script>
// ---------- 观影人 ----------
function addViewerRow() {
var container = document.getElementById('viewerContainer');
var div = document.createElement('div');
div.className = 'viewer-input-group';
var selectHtml = '<select name="viewer_select[]" onchange="toggleNewViewer(this)" class="viewer-select" style="flex:1;padding:8px;border:1px solid #ddd;border-radius:6px;">';
selectHtml += '<option value="">-- 选择 --</option>';
<?php foreach ($all_persons as $p): ?>
selectHtml += '<option value="<?= $p['id'] ?>"><?= h($p['name']) ?></option>';
<?php endforeach; ?>
selectHtml += '<option value="new">➕ 新增</option>';
selectHtml += '</select>';
var inputHtml = '<input type="text" name="viewers_new[]" placeholder="输入新姓名" style="flex:1;display:none;padding:8px;border:1px solid #ddd;border-radius:6px;">';
var removeBtn = '<button type="button" class="btn-remove-viewer" onclick="removeViewerRow(this)">×</button>';
div.innerHTML = selectHtml + inputHtml + removeBtn;
container.appendChild(div);
}
document.addEventListener('DOMContentLoaded', function() {
toggleNewCinema();
document.querySelectorAll('.viewer-input-group').forEach(function(group) {
var select = group.querySelector('select');
if (select) toggleNewViewer(select);
});
});
// ---------- 票根裁剪 ----------
let cropper = null;
let currentFileInput = null;
function handleTicketSelect(input) {
const file = input.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('请选择图片文件');
input.value = '';
return;
}
currentFileInput = input;
const reader = new FileReader();
reader.onload = function(e) {
const img = document.getElementById('cropImage');
img.src = e.target.result;
document.getElementById('cropModal').classList.add('active');
if (cropper) cropper.destroy();
setTimeout(function() {
cropper = new Cropper(img, {
aspectRatio: NaN,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
responsive: true,
restore: false,
});
}, 150);
};
reader.readAsDataURL(file);
}
function closeCrop() {
document.getElementById('cropModal').classList.remove('active');
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function cropSave() {
if (!cropper) {
alert('请先加载图片');
return;
}
try {
const canvas = cropper.getCroppedCanvas({
width: 600,
height: 600,
imageSmoothingQuality: 'high',
});
if (!canvas) {
alert('裁剪失败,请重试');
return;
}
const base64 = canvas.toDataURL('image/jpeg', 0.9);
document.getElementById('ticket_crop').value = base64;
const preview = document.getElementById('ticketPreview');
preview.src = base64;
preview.style.display = 'block';
// 清空文件输入,避免同时提交原始文件
document.getElementById('ticketInput').value = '';
closeCrop();
} catch (e) {
alert('裁剪失败:' + e.message);
}
}
// 点击模态框外部关闭
document.getElementById('cropModal').addEventListener('click', function(e) {
if (e.target === this) {
closeCrop();
}
});
// ---------- 豆瓣自动获取 ----------
document.getElementById('fetchBtn').addEventListener('click', function() {
const title = document.getElementById('movieTitle').value.trim();
const status = document.getElementById('fetchStatus');
const btn = this;
if (!title) {
status.style.color = '#e74c3c';
status.textContent = '⚠️ 请先输入电影名称';
return;
}
btn.disabled = true;
status.style.color = 'var(--text-secondary)';
status.textContent = '⏳ 正在获取…';
// 上一次抓取但仍未采用的海报键,交给服务端清理,避免重复点击堆积文件
const oldKey = document.getElementById('fetchedPosterKey').value;
let url = 'fetch_movie.php?q=' + encodeURIComponent(title);
if (oldKey) url += '&old_poster=' + encodeURIComponent(oldKey);
fetch(url)
.then(res => res.json())
.then(data => {
if (data.error) {
status.style.color = '#e74c3c';
status.textContent = '❌ ' + data.error;
btn.disabled = false;
return;
}
// 片名以豆瓣的正名称为准(自动补全/纠正)
if (data.title) document.getElementById('movieTitle').value = data.title;
// 组装电影信息文本
let infoText = '';
if (data.year) infoText += '📅 年份:' + data.year + '\n';
if (data.country) infoText += '🌍 地区:' + data.country + '\n';
if (data.genre) infoText += '🏷️ 类型:' + data.genre + '\n';
if (data.runtime) infoText += '⏱️ 片长:' + data.runtime + '\n';
if (data.director) infoText += '🎬 导演:' + data.director + '\n';
if (data.actors) infoText += '👤 主演:' + data.actors + '\n';
if (data.rating) infoText += '⭐ 豆瓣评分:' + data.rating + '\n';
if (data.alias) infoText += '🔖 又名:' + data.alias + '\n';
if (data.plot) infoText += '\n📝 ' + data.plot;
document.getElementById('movieInfo').value = infoText;
// 海报:已由服务端下载入库。这里记录键值(提交时写入 poster_path)并预览。
const keyInput = document.getElementById('fetchedPosterKey');
const preview = document.getElementById('posterPreview');
const wrap = document.getElementById('posterPreviewWrap');
const hint = document.getElementById('posterPreviewHint');
if (data.poster && data.poster_key) {
keyInput.value = data.poster_key;
preview.src = data.poster;
hint.textContent = '海报已自动保存,提交时一并写入(若手动上传图片,以上传的为准)';
wrap.style.display = 'block';
status.textContent = '✅ 已获取信息与海报';
} else {
keyInput.value = '';
wrap.style.display = 'none';
status.textContent = '✅ 已获取基本信息(该片暂无海报)';
}
status.style.color = '#27ae60';
btn.disabled = false;
})
.catch(err => {
status.style.color = '#e74c3c';
status.textContent = '❌ 网络错误,请稍后重试';
btn.disabled = false;
console.error(err);
});
});
// 回车触发获取
document.getElementById('movieTitle').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('fetchBtn').click();
}
});
</script>
</body>
</html>