Files
MovieLog_Server/edit.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

538 lines
24 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
// ============================================
// 文件: edit.php (完整可用)
// 说明: 支持海报、票根、合影裁剪,观影人管理
// ============================================
require_once 'config.php';
checkLogin();
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
if ($id <= 0) {
header('Location: index.php');
exit;
}
$movie = getMovie($id);
if (!$movie) {
header('Location: index.php');
exit;
}
$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'] ?? [];
$poster_crop = $_POST['poster_crop'] ?? '';
$ticket_crop = $_POST['ticket_crop'] ?? '';
$photo_crops = $_POST['photo_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 = $movie['poster_path'];
if (!empty($poster_crop)) {
$new_path = saveBase64Image($poster_crop, POSTER_DIR, 'poster');
if ($new_path) {
deleteStoredFile($poster_path);
$poster_path = $new_path;
} else {
$error = '海报裁剪保存失败,请重试';
}
} else {
$uploaded = uploadImageFile('poster', POSTER_DIR, 'uploads/posters/');
if ($uploaded) {
deleteStoredFile($poster_path);
$poster_path = $uploaded;
}
}
// ----- 票根:裁剪结果优先,其次上传新文件 -----
$ticket_path = $movie['ticket_path'];
if (!empty($ticket_crop)) {
$new_path = saveBase64Image($ticket_crop, TICKET_DIR, 'ticket');
if ($new_path) {
deleteStoredFile($ticket_path);
$ticket_path = $new_path;
} else {
$error = '票根裁剪保存失败,请重试';
}
} else {
$uploaded = uploadImageFile('ticket', TICKET_DIR, 'uploads/tickets/');
if ($uploaded) {
deleteStoredFile($ticket_path);
$ticket_path = $uploaded;
}
}
// 前面处理没有错误,才更新数据库
if (empty($error)) {
// 更新电影基本信息
$stmt = $db->prepare("
UPDATE movies
SET title = ?, info = ?, poster_path = ?, ticket_path = ?, cinema_id = ?, watch_date = ?
WHERE id = ?
");
$stmt->execute([$title, $info, $poster_path, $ticket_path, $cinema_id, $watch_date, $id]);
// ----- 合影:替换已裁剪的图片 -----
if (!empty($photo_crops)) {
foreach ($photo_crops as $photo_id => $base64_data) {
if (empty($base64_data)) continue;
$stmt = $db->prepare("SELECT photo_path FROM movie_photos WHERE id = ? AND movie_id = ?");
$stmt->execute([$photo_id, $id]);
$old = $stmt->fetch();
if ($old) {
$new_path = saveBase64Image($base64_data, PHOTO_DIR, 'photo');
if ($new_path) {
deleteStoredFile($old['photo_path']);
$stmt = $db->prepare("UPDATE movie_photos SET photo_path = ? WHERE id = ?");
$stmt->execute([$new_path, $photo_id]);
}
}
}
}
// ----- 新增合影照片 -----
uploadMultiplePhotos('new_photos', $id);
// ----- 一起看的人 -----
$stmt = $db->prepare("DELETE FROM viewers WHERE movie_id = ?");
$stmt->execute([$id]);
linkViewers($id, resolvePersonIds($viewer_selects, $viewer_news));
header('Location: detail.php?id=' . $id);
exit;
}
}
}
// 准备观影人数据
$current_person_ids = array_column($movie['viewers'], 'person_id');
$current_person_ids = array_filter($current_person_ids, function($v) { return $v > 0; });
if (empty($current_person_ids)) {
$current_person_ids = [0];
}
?>
<!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; }
.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 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-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; }
.current-files { font-size: 13px; color: var(--text-secondary); margin-top: 4px; }
.current-files a { color: var(--primary); text-decoration: none; }
.current-files a:hover { text-decoration: underline; }
.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; }
.image-preview {
max-width: 120px;
max-height: 120px;
border-radius: 4px;
border: 1px solid var(--border-color);
}
.photo-item {
display: inline-block;
margin: 4px;
text-align: center;
}
.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">修改「<?= h($movie['title']) ?>」的信息</p>
<?php if ($error): ?><div class="error-box"><?= h($error) ?></div><?php endif; ?>
<form method="POST" action="" enctype="multipart/form-data" id="editForm">
<!-- 电影名称 -->
<div class="form-group">
<label>电影名称 <span class="hint">*必填</span></label>
<input type="text" name="title" value="<?= h($movie['title']) ?>" required>
</div>
<!-- 电影信息 -->
<div class="form-group">
<label>电影信息</label>
<textarea name="info"><?= h($movie['info']) ?></textarea>
</div>
<!-- 观影日期 -->
<div class="form-group">
<label>观影日期 <span class="hint">*必填</span></label>
<input type="date" name="watch_date" value="<?= $movie['watch_date'] ?>" 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'] ?>" <?= ($movie['cinema_id'] == $c['id']) ? 'selected' : '' ?>>
<?= h($c['place']) ?> · <?= h($c['name']) ?>
</option>
<?php endforeach; ?>
<option value="new">➕ 新建电影院</option>
</select>
</div>
<div class="cinema-new-group" id="newCinemaGroup" style="display: none;">
<div class="form-group">
<label>所在地方 <span class="hint">如: 万达广场</span></label>
<input type="text" name="new_cinema_place" placeholder="如: 万达广场">
</div>
<div class="form-group">
<label>电影院名称 <span class="hint">*必填</span></label>
<input type="text" name="new_cinema_name" placeholder="如: 万达影城(IMAX店)">
</div>
<div class="form-group">
<label>详细地址</label>
<input type="text" name="new_cinema_address" placeholder="详细地址">
</div>
</div>
<!-- 观影人 -->
<div class="form-group">
<label>一起看的人</label>
<div id="viewerContainer"></div>
<button type="button" class="btn-add-viewer" onclick="addViewerRow()">➕ 添加一位</button>
<div class="form-hint">选择已有观影人,或选择“新增”后输入姓名</div>
</div>
<!-- 海报 -->
<div class="form-group">
<label>电影海报</label>
<div>
<?php if (storageExists($movie['poster_path'] ?? '')): ?>
<img src="<?= storageUrl($movie['poster_path']) ?>" class="image-preview" id="posterPreview">
<br>
<button type="button" class="crop-btn" onclick="openCrop('poster', '<?= storageUrl($movie['poster_path']) ?>')">✂️ 裁剪海报</button>
<input type="hidden" name="poster_crop" id="poster_crop">
<?php else: ?>
<span style="color:var(--text-secondary);font-size:14px;">暂无海报</span>
<?php endif; ?>
</div>
<div style="margin-top:6px;">
<label style="font-weight:400;font-size:13px;">或上传新海报:</label>
<input type="file" name="poster" accept="image/*">
</div>
</div>
<!-- 票根 -->
<div class="form-group">
<label>电影票扫描件</label>
<div>
<?php if (storageExists($movie['ticket_path'] ?? '')): ?>
<img src="<?= storageUrl($movie['ticket_path']) ?>" class="image-preview" id="ticketPreview">
<br>
<button type="button" class="crop-btn" onclick="openCrop('ticket', '<?= storageUrl($movie['ticket_path']) ?>')">✂️ 裁剪票根</button>
<input type="hidden" name="ticket_crop" id="ticket_crop">
<?php else: ?>
<span style="color:var(--text-secondary);font-size:14px;">暂无票根</span>
<?php endif; ?>
</div>
<div style="margin-top:6px;">
<label style="font-weight:400;font-size:13px;">或上传新票根:</label>
<input type="file" name="ticket" accept="image/*">
</div>
</div>
<!-- 观影照片 -->
<div class="form-group">
<label>观影照片</label>
<div>
<?php if (!empty($movie['photos'])): ?>
<?php foreach ($movie['photos'] as $photo): ?>
<div class="photo-item" id="photo_<?= $photo['id'] ?>">
<img src="<?= storageUrl($photo['photo_path']) ?>" class="image-preview" style="display:block;">
<button type="button" class="crop-btn" onclick="openCrop('photo_<?= $photo['id'] ?>', '<?= storageUrl($photo['photo_path']) ?>')">✂️ 裁剪</button>
<input type="hidden" name="photo_crop[<?= $photo['id'] ?>]" id="photo_crop_<?= $photo['id'] ?>">
</div>
<?php endforeach; ?>
<?php else: ?>
<span style="color:var(--text-secondary);font-size:14px;">暂无观影照片</span>
<?php endif; ?>
</div>
<div style="margin-top:6px;">
<label style="font-weight:400;font-size:13px;">新增照片(可多选):</label>
<input type="file" name="new_photos[]" accept="image/*" multiple>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">💾 保存修改</button>
<a href="detail.php?id=<?= $id ?>" 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>
// ============================================================
// 完整 JavaScript
// ============================================================
// ---------- 观影人 ----------
var allPersons = <?= json_encode($all_persons) ?>;
var selectedPersonIds = <?= json_encode($current_person_ids) ?>;
function createViewerRow(value) {
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>';
allPersons.forEach(function(p) {
var selected = (p.id == value) ? 'selected' : '';
selectHtml += '<option value="' + p.id + '" ' + selected + '>' + p.name + '</option>';
});
selectHtml += '<option value="new" ' + (value === 'new' ? 'selected' : '') + '>➕ 新增</option>';
selectHtml += '</select>';
var inputHtml = '<input type="text" name="viewers_new[]" placeholder="输入新姓名" style="flex:1;display:' + (value === 'new' ? 'block' : '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);
}
function addViewerRow(value) {
value = value || '';
createViewerRow(value);
}
document.addEventListener('DOMContentLoaded', function() {
var container = document.getElementById('viewerContainer');
container.innerHTML = '';
if (selectedPersonIds.length > 0) {
selectedPersonIds.forEach(function(pid) {
if (pid === 0) createViewerRow('');
else createViewerRow(pid);
});
} else {
createViewerRow('');
}
toggleNewCinema();
});
// ---------- 裁剪 ----------
let cropper = null;
let currentTarget = '';
function openCrop(target, imgSrc) {
currentTarget = target;
var img = document.getElementById('cropImage');
img.src = imgSrc;
document.getElementById('cropModal').classList.add('active');
if (cropper) {
cropper.destroy();
cropper = null;
}
setTimeout(function() {
cropper = new Cropper(img, {
aspectRatio: NaN,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
responsive: true,
restore: false,
});
}, 150);
}
function closeCrop() {
document.getElementById('cropModal').classList.remove('active');
if (cropper) {
cropper.destroy();
cropper = null;
}
}
function cropSave() {
if (!cropper) {
alert('请先加载图片');
return;
}
try {
var canvas = cropper.getCroppedCanvas({
width: 600,
height: 600,
imageSmoothingQuality: 'high',
});
if (!canvas) {
alert('裁剪失败,请重试');
return;
}
var base64 = canvas.toDataURL('image/jpeg', 0.9);
if (currentTarget === 'poster') {
document.getElementById('poster_crop').value = base64;
var preview = document.getElementById('posterPreview');
if (preview) preview.src = base64;
} else if (currentTarget === 'ticket') {
document.getElementById('ticket_crop').value = base64;
var preview = document.getElementById('ticketPreview');
if (preview) preview.src = base64;
} else if (currentTarget.startsWith('photo_')) {
var photoId = currentTarget.replace('photo_', '');
document.getElementById('photo_crop_' + photoId).value = base64;
var preview = document.querySelector('#photo_' + photoId + ' .image-preview');
if (preview) preview.src = base64;
}
closeCrop();
} catch (e) {
alert('裁剪失败:' + e.message);
}
}
// 点击模态框外部关闭
document.getElementById('cropModal').addEventListener('click', function(e) {
if (e.target === this) {
closeCrop();
}
});
</script>
</body>
</html>