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

481 lines
22 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
// ============================================
// 文件: persons.php (含头像裁剪)
// ============================================
require_once 'config.php';
checkLogin();
$admin = getCurrentAdmin();
$db = getDB();
$message = '';
$error = '';
// ---------- 处理添加 ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'add') {
$name = trim($_POST['name'] ?? '');
$color = normalizeHexColor($_POST['color'] ?? '#888888');
// 头像 base64
$avatar_base64 = $_POST['avatar_base64'] ?? '';
if (empty($name)) {
$error = '姓名不能为空';
} else {
// 检查重复
$stmt = $db->prepare("SELECT id FROM persons WHERE name = ?");
$stmt->execute([$name]);
if ($stmt->fetch()) {
$error = '该姓名已存在';
} else {
// 保存头像
$avatar_path = null;
if (!empty($avatar_base64)) {
$avatar_path = saveBase64Image($avatar_base64, AVATAR_DIR, 'avatar');
}
$stmt = $db->prepare("INSERT INTO persons (name, color, avatar) VALUES (?, ?, ?)");
$stmt->execute([$name, $color, $avatar_path]);
$message = '观影人添加成功';
}
}
}
// ---------- 处理编辑 ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['action']) && $_POST['action'] === 'edit') {
$id = (int)$_POST['id'];
$name = trim($_POST['name'] ?? '');
$color = normalizeHexColor($_POST['color'] ?? '#888888');
$avatar_base64 = $_POST['avatar_base64'] ?? '';
if (empty($name)) {
$error = '姓名不能为空';
} else {
// 检查重复
$stmt = $db->prepare("SELECT id FROM persons WHERE name = ? AND id != ?");
$stmt->execute([$name, $id]);
if ($stmt->fetch()) {
$error = '该姓名已存在';
} else {
// 获取旧头像
$stmt = $db->prepare("SELECT avatar FROM persons WHERE id = ?");
$stmt->execute([$id]);
$old = $stmt->fetch();
$old_avatar = $old['avatar'] ?? null;
// 保存新头像
$avatar_path = $old_avatar;
if (!empty($avatar_base64)) {
// 删除旧头像
deleteStoredFile($old_avatar);
$avatar_path = saveBase64Image($avatar_base64, AVATAR_DIR, 'avatar');
}
$stmt = $db->prepare("UPDATE persons SET name = ?, color = ?, avatar = ? WHERE id = ?");
$stmt->execute([$name, $color, $avatar_path, $id]);
$message = '观影人信息已更新';
}
}
}
// ---------- 处理删除 ----------
if (isset($_GET['action']) && $_GET['action'] === 'delete' && isset($_GET['id'])) {
$id = (int)$_GET['id'];
$stmt = $db->prepare("SELECT COUNT(*) FROM viewers WHERE person_id = ?");
$stmt->execute([$id]);
$count = $stmt->fetchColumn();
if ($count > 0) {
$error = "该观影人关联了 {$count} 部电影,不能直接删除。请先移除关联。";
} else {
// 删除头像文件
$stmt = $db->prepare("SELECT avatar FROM persons WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch();
if ($row) {
deleteStoredFile($row['avatar']);
}
$stmt = $db->prepare("DELETE FROM persons WHERE id = ?");
$stmt->execute([$id]);
$message = '观影人已删除';
}
}
// ---------- 获取列表 ----------
$stmt = $db->query("SELECT * FROM persons ORDER BY name");
$persons = $stmt->fetchAll();
// ---------- 获取编辑数据 ----------
$edit_data = null;
if (isset($_GET['action']) && $_GET['action'] === 'edit' && isset($_GET['id'])) {
$id = (int)$_GET['id'];
$stmt = $db->prepare("SELECT * FROM persons WHERE id = ?");
$stmt->execute([$id]);
$edit_data = $stmt->fetch();
if (!$edit_data) header('Location: persons.php');
}
// 50种预设颜色
$preset_colors = [
'#e74c3c','#c0392b','#e67e22','#d35400','#f39c12',
'#f1c40f','#f7dc6f','#2ecc71','#27ae60','#1abc9c',
'#16a085','#3498db','#2980b9','#9b59b6','#8e44ad',
'#34495e','#2c3e50','#e84393','#fd79a8','#00b894',
'#00cec9','#0984e3','#6c5ce7','#fdcb6e','#e17055',
'#d63031','#f0932b','#6ab04c','#22a6b3','#4834d4',
'#be2edd','#130f40','#535c68','#95afc0','#7ed6df',
'#e056a0','#ffb142','#33d9b2','#227093','#218c74',
'#b33939','#cd6133','#f97f51','#82589f','#3dc1d3',
'#b8e994','#78e08f','#f6b93b','#e55039','#4a69bd'
];
?>
<!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">
<!-- Cropper.js -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.css">
<style>
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
.card { background: var(--card-bg); border-radius: 16px; padding: 24px 28px; box-shadow: var(--shadow); margin-bottom: 24px; }
.card h2 { font-size: 22px; font-weight: 700; margin: 0 0 8px; color: var(--text-primary); }
.card .sub { color: var(--text-secondary); font-size: 14px; margin-bottom: 16px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-weight: 600; font-size: 14px; color: var(--text-primary); margin-bottom: 4px; }
.form-group input { width: 100%; padding: 10px 14px; border: 1px solid var(--border-color); border-radius: 8px; font-size: 15px; box-sizing: border-box; }
.form-group input:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(230,126,34,0.12); }
.form-row { display: flex; gap: 16px; flex-wrap: wrap; }
.form-row .form-group { flex: 1; min-width: 180px; }
.btn { padding: 10px 20px; border-radius: 8px; font-size: 14px; font-weight: 600; border: none; cursor: pointer; transition: var(--transition); text-decoration: none; display: inline-block; }
.btn-primary { background: linear-gradient(135deg, var(--primary), var(--primary-light)); color: #fff; }
.btn-primary:hover { transform: translateY(-2px); box-shadow: 0 8px 28px rgba(230,126,34,0.35); }
.btn-danger { background: #e74c3c; color: #fff; }
.btn-danger:hover { background: #c0392b; }
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-outline:hover { background: var(--bg-secondary); }
.btn-sm { padding: 4px 12px; font-size: 13px; }
.message-box { padding: 10px 14px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; }
.message-box.success { background: rgba(39,174,96,0.08); border: 1px solid rgba(39,174,96,0.2); color: #27ae60; }
.message-box.error { background: rgba(231,76,60,0.08); border: 1px solid rgba(231,76,60,0.2); color: #e74c3c; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-size: 14px; }
table th { text-align: left; padding: 10px 8px; border-bottom: 2px solid var(--border-color); color: var(--text-secondary); font-weight: 600; }
table td { padding: 10px 8px; border-bottom: 1px solid var(--border-color); }
table tr:hover td { background: var(--bg-secondary); }
.actions { display: flex; gap: 8px; }
.empty-row { text-align: center; color: var(--text-secondary); padding: 20px 0; }
.preset-grid { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.preset-color { display: inline-block; width: 28px; height: 28px; border-radius: 50%; cursor: pointer; border: 2px solid transparent; transition: 0.2s; }
.preset-color:hover { transform: scale(1.15); }
.preset-color.active { border-color: #333; }
/* 头像样式 */
.avatar-upload {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.avatar-preview {
width: 60px;
height: 60px;
border-radius: 50%;
object-fit: cover;
border: 2px solid var(--border-color);
}
.avatar-upload input[type="file"] {
flex: 1;
padding: 6px;
}
.current-avatar {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 14px;
}
/* 裁剪模态框 */
.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-content .modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.modal-content .modal-header h3 {
margin: 0;
}
.modal-content .modal-header .close-btn {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--text-secondary);
}
.modal-content .modal-body img {
max-width: 100%;
display: block;
}
.modal-content .modal-footer {
margin-top: 16px;
display: flex;
gap: 12px;
justify-content: flex-end;
}
@media (max-width: 600px) {
.card { padding: 16px; }
.form-row { flex-direction: column; }
.actions { flex-wrap: wrap; }
.modal-content { width: 95%; padding: 16px; }
}
</style>
</head>
<body>
<?php include 'nav.php'; ?>
<main class="main-content">
<div class="container">
<?php if ($message): ?><div class="message-box success"><?= h($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="message-box error"><?= $error ?></div><?php endif; ?>
<!-- 添加/编辑表单 -->
<div class="card">
<h2><?= $edit_data ? '✏️ 编辑观影人' : '➕ 添加观影人' ?></h2>
<p class="sub"><?= $edit_data ? '修改观影人信息' : '录入新的观影人' ?></p>
<form method="POST" action="" id="personForm" enctype="multipart/form-data">
<input type="hidden" name="action" value="<?= $edit_data ? 'edit' : 'add' ?>">
<?php if ($edit_data): ?><input type="hidden" name="id" value="<?= $edit_data['id'] ?>"><?php endif; ?>
<!-- 隐藏字段存放裁剪后的 base64 -->
<input type="hidden" name="avatar_base64" id="avatar_base64" value="">
<div class="form-row">
<div class="form-group">
<label>姓名 <span class="hint">*必填</span></label>
<input type="text" name="name" value="<?= $edit_data ? h($edit_data['name']) : '' ?>" placeholder="如:张三" required>
</div>
</div>
<div class="form-group">
<label>主题颜色</label>
<div style="display: flex; gap: 10px; align-items: center; flex-wrap: wrap;">
<input type="color" name="color" id="colorPicker" value="<?= $edit_data ? h($edit_data['color']) : '#888888' ?>" style="width:50px;height:40px;padding:0;border:none;cursor:pointer;">
<span style="font-size:13px;color:var(--text-secondary);">点击色块选择,或使用拾色器</span>
</div>
<div class="preset-grid">
<?php foreach ($preset_colors as $pc): ?>
<span class="preset-color" data-color="<?= $pc ?>" style="background:<?= $pc ?>;" onclick="setColor('<?= $pc ?>')"></span>
<?php endforeach; ?>
</div>
</div>
<div class="form-group">
<label>头像</label>
<div class="avatar-upload">
<?php if ($edit_data && $edit_data['avatar']): ?>
<img src="<?= storageUrl($edit_data['avatar']) ?>" class="avatar-preview" id="avatarPreview">
<?php else: ?>
<img src="<?= getAvatarUrl(null, $edit_data['name'] ?? '') ?>" class="avatar-preview" id="avatarPreview">
<?php endif; ?>
<input type="file" name="avatar_upload" id="avatarUpload" accept="image/*">
<span style="font-size:13px;color:var(--text-secondary);">点击选择图片,裁剪后生效</span>
</div>
<?php if ($edit_data && $edit_data['avatar']): ?>
<div class="current-avatar">
当前头像:<img src="<?= storageUrl($edit_data['avatar']) ?>" style="width:30px;height:30px;border-radius:50%;vertical-align:middle;">
<a href="javascript:void(0)" onclick="removeAvatar()" style="color:var(--danger, #e74c3c);font-size:13px;">删除</a>
</div>
<?php endif; ?>
</div>
<div style="margin-top:8px;">
<button type="submit" class="btn btn-primary">💾 保存</button>
<?php if ($edit_data): ?><a href="persons.php" class="btn btn-outline">取消编辑</a><?php endif; ?>
</div>
</form>
</div>
<!-- 列表 -->
<div class="card">
<h2>👥 观影人列表</h2>
<p class="sub">共 <?= count($persons) ?> 人</p>
<div class="table-wrap">
<?php if (count($persons) > 0): ?>
<table>
<thead><tr><th>头像</th><th>姓名</th><th>颜色</th><th>操作</th></tr></thead>
<tbody>
<?php foreach ($persons as $p): ?>
<tr>
<td>
<img src="<?= getAvatarUrl($p['avatar'], $p['name']) ?>" style="width:30px;height:30px;border-radius:50%;object-fit:cover;vertical-align:middle;">
</td>
<td>
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:<?= h($p['color'] ?: '#888888') ?>;vertical-align:middle;margin-right:6px;"></span>
<?= h($p['name']) ?>
</td>
<td><span style="background:<?= h($p['color'] ?: '#888888') ?>;padding:2px 10px;border-radius:4px;color:#fff;font-size:12px;"><?= h($p['color'] ?: '#888888') ?></span></td>
<td>
<div class="actions">
<a href="?action=edit&id=<?= $p['id'] ?>" class="btn btn-outline btn-sm">✏️ 编辑</a>
<a href="?action=delete&id=<?= $p['id'] ?>" class="btn btn-danger btn-sm" onclick="return confirm('确定删除「<?= h($p['name']) ?>」吗?')">🗑️ 删除</a>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<div class="empty-row">暂无观影人,请添加</div>
<?php endif; ?>
</div>
</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>
<!-- Cropper.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/cropperjs/1.5.13/cropper.min.js"></script>
<script>
let cropper = null;
let currentFile = null;
// ---------- 颜色 ----------
function setColor(color) {
document.getElementById('colorPicker').value = color;
document.querySelectorAll('.preset-color').forEach(el => el.classList.remove('active'));
document.querySelector(`.preset-color[data-color="${color}"]`).classList.add('active');
}
document.addEventListener('DOMContentLoaded', function() {
var currentColor = document.getElementById('colorPicker').value;
if (currentColor) {
var preset = document.querySelector(`.preset-color[data-color="${currentColor}"]`);
if (preset) preset.classList.add('active');
}
});
// ---------- 头像裁剪 ----------
document.getElementById('avatarUpload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
// 检查文件类型
if (!file.type.startsWith('image/')) {
alert('请选择图片文件');
this.value = '';
return;
}
currentFile = file;
const reader = new FileReader();
reader.onload = function(ev) {
const img = document.getElementById('cropImage');
img.src = ev.target.result;
document.getElementById('cropModal').classList.add('active');
// 初始化 Cropper
if (cropper) cropper.destroy();
cropper = new Cropper(img, {
aspectRatio: 1,
viewMode: 1,
dragMode: 'move',
autoCropArea: 0.8,
responsive: true,
});
};
reader.readAsDataURL(file);
});
function closeCrop() {
document.getElementById('cropModal').classList.remove('active');
if (cropper) {
cropper.destroy();
cropper = null;
}
document.getElementById('avatarUpload').value = '';
}
function cropSave() {
if (!cropper) return;
// 获取裁剪后的 canvas
const canvas = cropper.getCroppedCanvas({
width: 200,
height: 200,
imageSmoothingQuality: 'high',
});
if (!canvas) return;
// 转换为 base64
const base64 = canvas.toDataURL('image/jpeg', 0.9);
// 更新预览
document.getElementById('avatarPreview').src = base64;
// 存入隐藏字段
document.getElementById('avatar_base64').value = base64;
closeCrop();
}
// 删除头像(仅编辑时可用)
function removeAvatar() {
if (confirm('确定删除头像吗?')) {
// 清空预览为默认
var name = document.querySelector('input[name="name"]').value || '';
document.getElementById('avatarPreview').src = '<?= getAvatarUrl(null, '') ?>';
document.getElementById('avatar_base64').value = '';
// 删除旧头像文件由后端处理(保存时如果 base64 为空则保留旧头像,但我们通过隐藏字段为空表示删除)
// 这里我们设置一个标记,但需要后端处理:如果 avatar_base64 为空且没有上传新文件,则删除头像。
// 我们可以在后端增加逻辑:如果 avatar_base64 为空且没有上传文件,则保留原头像;但用户想删除,我们可加一个隐藏的删除标记。
// 简便做法:提交时如果 avatar_base64 为空,且上传文件也为空,则不修改头像。
// 但用户点击删除,我们希望删除。所以增加一个隐藏字段 delete_avatar=1
var input = document.createElement('input');
input.type = 'hidden';
input.name = 'delete_avatar';
input.value = '1';
document.getElementById('personForm').appendChild(input);
}
}
// 在表单提交前,如果没有 base64 且没有上传文件,但 delete_avatar 存在,则后端处理删除
// 后端逻辑:如果 post 中有 delete_avatar,则 avatar_path = null
// 在 persons.php 的编辑中添加:
// if (isset($_POST['delete_avatar'])) { $avatar_path = null; 删除旧文件 }
// 我会在后端代码中添加此逻辑,但由于代码已生成,我在这里说明。
// 另外,如果用户上传了文件但未裁剪直接取消,则不清除文件输入,但不会触发裁剪,所以正常。
</script>
<script src="js/main.js"></script>
</body>
</html>