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

840 lines
46 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
// ============================================
// 文件: settings.php
// 说明: 系统设置 —— 图片存储方式(本地硬盘 / 阿里云 OSS / 腾讯云 COS / 七牛云)
// 含各驱动的凭据配置、连通性测试与历史图片迁移。
// ============================================
require_once 'config.php';
checkLogin();
$admin = getCurrentAdmin();
$message = '';
$error = '';
// 密钥打码显示
function maskSecret($value) {
$value = (string)$value;
$len = strlen($value);
if ($len === 0) return '';
if ($len <= 8) return str_repeat('*', $len);
return substr($value, 0, 4) . str_repeat('*', 6) . substr($value, -4);
}
// ---------- 保存设置 ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'save_storage') {
$driver = $_POST['storage_driver'] ?? 'local';
if (!in_array($driver, ['local', 'oss', 'cos', 'qiniu'], true)) {
$driver = 'local';
}
// 各驱动的字段定义:'secret' 表示敏感字段(留空则不修改)
$fieldMap = [
'oss' => [
'auth_mode' => 'plain',
'access_key_id' => 'plain',
'access_key_secret' => 'secret',
'bucket' => 'plain',
'endpoint' => 'plain',
'prefix' => 'plain',
'ram_role_name' => 'plain',
'custom_domain' => 'plain',
'internal_endpoint' => 'plain',
'https' => 'plain',
],
'cos' => [
'auth_mode' => 'plain',
'secret_id' => 'plain',
'secret_key' => 'secret',
'bucket' => 'plain',
'region' => 'plain',
'prefix' => 'plain',
'cam_role_name' => 'plain',
'custom_domain' => 'plain',
'https' => 'plain',
],
'qiniu' => [
'access_key' => 'plain',
'secret_key' => 'secret',
'bucket' => 'plain',
'region' => 'plain',
'prefix' => 'plain',
'custom_domain' => 'plain',
'https' => 'plain',
],
];
foreach ($fieldMap as $d => $fields) {
$old = storageConfigFor($d);
$in = isset($_POST[$d]) && is_array($_POST[$d]) ? $_POST[$d] : [];
$clearSecrets = !empty($in['clear_secrets']);
$new = [];
foreach ($fields as $f => $kind) {
$posted = isset($in[$f]) ? trim((string)$in[$f]) : '';
if ($kind === 'secret') {
if ($clearSecrets) {
$new[$f] = '';
} else {
// 留空表示保持原值
$new[$f] = $posted === '' ? (string)($old[$f] ?? '') : $posted;
}
} else {
$new[$f] = $posted !== '' ? $posted : (string)($old[$f] ?? '');
}
}
// 复选框型字段(未勾选时浏览器不提交该字段,必须显式归零而不是沿用旧值)
$new['https'] = ($new['https'] ?? '') === '0' ? '0' : '1';
if ($d === 'oss') {
$new['internal_endpoint'] = ($new['internal_endpoint'] ?? '') === '1' ? '1' : '0';
}
saveStorageConfig($d, $new);
}
setSetting('storage_driver', $driver);
// 图片访问方式:direct(直连存储)/ proxy(经 media.php 服务端代理读取)
$accessMode = ($_POST['storage_access_mode'] ?? 'direct') === 'proxy' ? 'proxy' : 'direct';
setSetting('storage_access_mode', $accessMode);
$message = '设置已保存。当前存储方式:' . (storageDriverLabels()[$driver] ?? $driver)
. ';图片访问方式:' . ($accessMode === 'proxy' ? '服务端代理' : '直连存储');
}
// ---------- 清除错误提示 ----------
if ($_SERVER['REQUEST_METHOD'] === 'POST' && ($_POST['action'] ?? '') === 'clear_storage_error') {
storageClearError();
$message = '已清除错误提示';
}
$drivers = storageDriverLabels();
$current = currentStorageDriver();
$configs = [];
foreach (['oss', 'cos', 'qiniu'] as $d) {
$configs[$d] = storageConfigFor($d);
}
$lastError = storageLastError();
$isRemote = storageIsRemote();
$remoteReady = $current !== 'local' && storageConfigComplete($current);
// 本地图片统计(只读目录,不查库)
function localImageStat() {
$count = 0; $bytes = 0;
$base = UPLOAD_DIR;
if (!is_dir($base)) return ['count' => 0, 'bytes' => 0];
$it = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($it as $f) {
if (!$f->isFile()) continue;
if ($f->getFilename() === '.htaccess') continue;
$count++;
$bytes += $f->getSize();
}
return ['count' => $count, 'bytes' => $bytes];
}
$localStat = localImageStat();
?>
<!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">
<style>
.page-wrap { max-width: 880px; margin: 0 auto; padding: 20px; }
.card {
background: var(--card-bg); border-radius: 16px; padding: 28px 32px;
box-shadow: var(--shadow); margin-bottom: 24px;
}
.card h2 { font-size: 20px; font-weight: 700; margin: 0 0 6px; color: var(--text-primary); }
.card .sub { color: var(--text-secondary); font-size: 14px; margin-bottom: 18px; line-height: 1.6; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-weight: 600; font-size: 14px; margin-bottom: 5px; color: var(--text-primary); }
.form-group input[type=text], .form-group input[type=password], .form-group select {
width: 100%; padding: 10px 14px; border: 1px solid var(--border-color);
border-radius: 8px; font-size: 15px; box-sizing: border-box; transition: border-color .2s;
font-family: inherit;
}
.form-group input:focus, .form-group select:focus {
outline: none; border-color: var(--primary); box-shadow: 0 0 0 4px rgba(230,126,34,.12);
}
.hint { font-size: 12.5px; color: var(--text-secondary); margin-top: 5px; line-height: 1.6; }
.hint code { background: var(--bg-secondary); padding: 1px 5px; border-radius: 4px; font-size: 12px; }
.driver-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; }
.driver-opt {
border: 2px solid var(--border-color); border-radius: 12px; padding: 14px 16px;
cursor: pointer; transition: var(--transition); background: var(--card-bg);
}
.driver-opt:hover { border-color: var(--primary-light); }
.driver-opt.active { border-color: var(--primary); background: #fff8f0; }
.driver-opt .t { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; }
.driver-opt .d { font-size: 12.5px; color: var(--text-secondary); margin-top: 4px; }
.driver-opt input { margin-right: 6px; }
.panel { display: none; margin-top: 20px; padding-top: 20px; border-top: 1px dashed var(--border-color); }
.panel.active { display: block; }
.panel-title { font-weight: 600; font-size: 15px; margin-bottom: 14px; color: var(--text-primary); }
.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,.35); }
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border-color); }
.btn-outline:hover { background: var(--bg-secondary); }
.btn-sm { padding: 6px 14px; font-size: 13px; }
.btn:disabled { opacity: .5; cursor: not-allowed; transform: none !important; box-shadow: none !important; }
.alert { padding: 12px 16px; border-radius: 10px; font-size: 14px; margin-bottom: 18px; line-height: 1.6; }
.alert-ok { background: #e8f8f0; color: #1e7e46; border: 1px solid #b6e6cd; }
.alert-err { background: #fdecea; color: #a93226; border: 1px solid #f5c6c0; }
.alert-info { background: #eaf4fd; color: #1b5f96; border: 1px solid #bcdcf5; }
.status-grid { display: grid; grid-template-columns: 130px 1fr; gap: 8px 16px; font-size: 14px; }
.status-grid .k { color: var(--text-secondary); }
.status-grid .v { color: var(--text-primary); font-weight: 500; word-break: break-all; }
.badge { display: inline-block; padding: 2px 9px; border-radius: 20px; font-size: 12.5px; font-weight: 600; }
.badge-ok { background: #e8f8f0; color: #1e7e46; }
.badge-off { background: var(--bg-secondary); color: var(--text-secondary); }
.badge-warn { background: #fef5e7; color: #a9711a; }
.progress { height: 10px; background: var(--bg-secondary); border-radius: 20px; overflow: hidden; margin: 14px 0 8px; }
.progress > i { display: block; height: 100%; width: 0; background: linear-gradient(90deg, var(--primary), var(--primary-light)); transition: width .3s; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
.row-flex { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.chk { font-size: 13px; color: var(--text-secondary); display: flex; align-items: center; gap: 6px; margin-top: 6px; }
.chk input { margin: 0; }
</style>
</head>
<body>
<?php include 'nav.php'; ?>
<div class="page-wrap">
<?php if ($message): ?><div class="alert alert-ok"><?= h($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-err"><?= h($error) ?></div><?php endif; ?>
<?php if ($lastError && !empty($lastError['message'])): ?>
<div class="alert alert-err">
<strong>最近一次存储错误</strong>(<?= h($lastError['time'] ?? '') ?>,
驱动:<?= h($drivers[$lastError['driver'] ?? ''] ?? ($lastError['driver'] ?? '未知')) ?>)<br>
<span class="mono"><?= h($lastError['message']) ?></span>
<form method="post" style="margin-top:10px;">
<input type="hidden" name="action" value="clear_storage_error">
<button class="btn btn-outline btn-sm" type="submit">清除提示</button>
</form>
</div>
<?php endif; ?>
<div class="card">
<h2>图片存储方式</h2>
<div class="sub">
图片可以存放在本站硬盘上,也可以托管到对象存储。改用对象存储后,图片不再占用服务器磁盘,
以后搬迁网站时也不必搬运图片文件。<br>
切换存储方式<strong>不会影响已有图片</strong>:系统按"本地优先"读取,
本地存在的文件继续走本地,新上传的图片才走云端。
</div>
<form method="post" id="storageForm">
<input type="hidden" name="action" value="save_storage">
<div class="driver-grid">
<?php
$driverDesc = [
'local' => '默认方式,图片存在 uploads/ 目录',
'oss' => '阿里云对象存储,支持 ECS 免密钥',
'cos' => '腾讯云对象存储,支持 CVM 免密钥',
'qiniu' => '七牛云对象存储,需绑定域名',
];
foreach ($drivers as $id => $label): ?>
<label class="driver-opt <?= $current === $id ? 'active' : '' ?>" data-driver="<?= h($id) ?>">
<div class="t">
<input type="radio" name="storage_driver" value="<?= h($id) ?>"
<?= $current === $id ? 'checked' : '' ?>>
<?= h($label) ?>
</div>
<div class="d"><?= h($driverDesc[$id] ?? '') ?></div>
</label>
<?php endforeach; ?>
</div>
<!-- ========== 图片访问方式 ========== -->
<?php
$accessModeNow = storageAccessMode();
// 内网 endpoint 的推导与可用性判定(结论有缓存,不会每次翻页都探测)。
// 结论要显式展示:跨地域时内网会**静默拖慢**每次读取,不报出来无从察觉。
$ossInternalInfo = null;
if ($current === 'oss'
&& trim((string)($configs['oss']['endpoint'] ?? '')) !== ''
&& trim((string)($configs['oss']['internal_endpoint'] ?? '1')) === '1') {
$ossInternalInfo = storageDriverInstance('oss', $configs['oss'])->internalStatus();
}
?>
<div class="form-group" style="margin-top:22px;padding-top:18px;border-top:1px dashed var(--border-color);">
<label>图片访问方式</label>
<div style="display:flex;flex-direction:column;gap:12px;margin-top:6px;">
<label class="chk" style="align-items:flex-start;gap:8px;">
<input type="radio" name="storage_access_mode" value="direct"
<?= $accessModeNow === 'direct' ? 'checked' : '' ?>>
<span>
<strong>直连存储</strong>
<span class="hint" style="display:block;margin-top:2px;">
图片 URL 直接指向对象存储。要求桶允许匿名读取(公共读),否则图片会 403 打不开。
</span>
</span>
</label>
<label class="chk" style="align-items:flex-start;gap:8px;">
<input type="radio" name="storage_access_mode" value="proxy"
<?= $accessModeNow === 'proxy' ? 'checked' : '' ?>>
<span>
<strong>服务端代理(更安全)</strong>
<span class="hint" style="display:block;margin-top:2px;">
图片经本站 <code>media.php</code> 鉴权后由服务器读取回吐。<strong>桶可以设为私有</strong>,
无需防盗链;且因同源交付,图片裁剪功能不再受跨域限制。
代价:图片流量走本服务器出网带宽。<br>
目前支持服务端读取的驱动:本地硬盘、阿里云 OSS。
</span>
</span>
</label>
</div>
</div>
<!-- ========== 阿里云 OSS ========== -->
<div class="panel <?= $current === 'oss' ? 'active' : '' ?>" data-panel="oss">
<div class="panel-title">阿里云 OSS 配置</div>
<div class="form-group">
<label>鉴权方式</label>
<select name="oss[auth_mode]" class="auth-mode" data-driver="oss">
<option value="ak" <?= $configs['oss']['auth_mode'] === 'ak' ? 'selected' : '' ?>>AccessKey(显式填写密钥)</option>
<option value="ecs_role" <?= $configs['oss']['auth_mode'] === 'ecs_role' ? 'selected' : '' ?>>ECS RAM 角色(免密钥,实例元数据自动获取)</option>
</select>
<div class="hint">服务器在阿里云 ECS 上并已绑定 RAM 角色时,选第二项可完全不填密钥。</div>
</div>
<div class="ak-fields" data-driver="oss">
<div class="form-group">
<label>AccessKey ID</label>
<input type="text" name="oss[access_key_id]" value="<?= h($configs['oss']['access_key_id']) ?>"
placeholder="LTAI..." autocomplete="off">
</div>
<div class="form-group">
<label>AccessKey Secret</label>
<input type="password" name="oss[access_key_secret]" value=""
placeholder="<?= $configs['oss']['access_key_secret'] !== '' ? '已保存(' . h(maskSecret($configs['oss']['access_key_secret'])) . '),留空则不修改' : '请输入 AccessKey Secret' ?>"
autocomplete="new-password">
<?php if ($configs['oss']['access_key_secret'] !== ''): ?>
<label class="chk"><input type="checkbox" name="oss[clear_secrets]" value="1"> 清除已保存的密钥</label>
<?php endif; ?>
</div>
</div>
<div class="role-fields" data-driver="oss" style="display:none;">
<div class="form-group">
<label>RAM 角色名称(可留空自动发现)</label>
<input type="text" name="oss[ram_role_name]" value="<?= h($configs['oss']['ram_role_name']) ?>"
placeholder="留空则自动读取实例绑定的角色">
<div class="hint">需要该角色被授予目标 Bucket 的读写权限。</div>
</div>
</div>
<div class="form-group">
<label>Bucket 名称</label>
<input type="text" name="oss[bucket]" value="<?= h($configs['oss']['bucket']) ?>" placeholder="例如 movielog">
</div>
<div class="form-group">
<label>Endpoint</label>
<input type="text" name="oss[endpoint]" value="<?= h($configs['oss']['endpoint']) ?>"
placeholder="例如 oss-cn-beijing.aliyuncs.com">
<div class="hint">在 OSS 控制台"概览"里查看,形如 <code>oss-cn-地域.aliyuncs.com</code>,不要带 <code>https://</code>。</div>
</div>
<div class="form-group">
<label class="chk">
<input type="checkbox" name="oss[internal_endpoint]" value="1"
<?= ($configs['oss']['internal_endpoint'] ?? '1') === '1' ? 'checked' : '' ?>>
服务端读取走内网 endpoint
</label>
<div class="hint">仅在「服务端代理」访问方式下生效。服务器<strong>与桶同地域</strong>时,
读取会改用 <code>oss-&lt;地域&gt;-internal.aliyuncs.com</code>——内网流量免费且更快。<br>
<strong>不同地域时内网地址连不通</strong>(DNS 能解析、TCP 建不起来),
硬走内网会让每次读取先白等一次连接超时。系统会自动判定地域、不可用就跳过并回退公网,
不会因此变慢;此处结论见下方「当前状态」。</div>
<?php if ($ossInternalInfo !== null && $ossInternalInfo['endpoint'] !== ''): ?>
<div class="hint" style="margin-top:6px;">
当前判定:
<?php if ($ossInternalInfo['ok']): ?>
<span class="badge badge-ok">内网可用</span>
<?php else: ?>
<span class="badge badge-warn">已跳过内网,走公网</span>
<?php endif; ?>
<span class="mono"><?= h($ossInternalInfo['endpoint']) ?></span>
<?php if ($ossInternalInfo['reason'] !== ''): ?>
—— <?= h($ossInternalInfo['reason']) ?>
<?php endif; ?>
</div>
<?php endif; ?>
</div>
<div class="form-group">
<label>对象前缀(可选)</label>
<input type="text" name="oss[prefix]" value="<?= h($configs['oss']['prefix']) ?>"
placeholder="例如 movielog">
<div class="hint">填写后图片会存到桶内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
同一个桶存放多个站点时用它区分。留空则直接放在桶根目录。</div>
</div>
<div class="form-group">
<label>自定义域名 / CDN(可选)</label>
<input type="text" name="oss[custom_domain]" value="<?= h($configs['oss']['custom_domain']) ?>"
placeholder="例如 img.example.com">
<div class="hint">填写后图片 URL 会使用该域名;留空则使用 OSS 默认域名。</div>
</div>
<div class="form-group">
<label>协议</label>
<select name="oss[https]">
<option value="1" <?= $configs['oss']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
<option value="0" <?= $configs['oss']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
</select>
</div>
</div>
<!-- ========== 腾讯云 COS ========== -->
<div class="panel <?= $current === 'cos' ? 'active' : '' ?>" data-panel="cos">
<div class="panel-title">腾讯云 COS 配置</div>
<div class="form-group">
<label>鉴权方式</label>
<select name="cos[auth_mode]" class="auth-mode" data-driver="cos">
<option value="ak" <?= $configs['cos']['auth_mode'] === 'ak' ? 'selected' : '' ?>>SecretId / SecretKey(显式填写密钥)</option>
<option value="cvm_role" <?= $configs['cos']['auth_mode'] === 'cvm_role' ? 'selected' : '' ?>>CVM CAM 角色(免密钥,实例元数据自动获取)</option>
</select>
<div class="hint">服务器在腾讯云 CVM 上并已绑定 CAM 角色时,选第二项可完全不填密钥。</div>
</div>
<div class="ak-fields" data-driver="cos">
<div class="form-group">
<label>SecretId</label>
<input type="text" name="cos[secret_id]" value="<?= h($configs['cos']['secret_id']) ?>"
placeholder="AKID..." autocomplete="off">
</div>
<div class="form-group">
<label>SecretKey</label>
<input type="password" name="cos[secret_key]" value=""
placeholder="<?= $configs['cos']['secret_key'] !== '' ? '已保存(' . h(maskSecret($configs['cos']['secret_key'])) . '),留空则不修改' : '请输入 SecretKey' ?>"
autocomplete="new-password">
<?php if ($configs['cos']['secret_key'] !== ''): ?>
<label class="chk"><input type="checkbox" name="cos[clear_secrets]" value="1"> 清除已保存的密钥</label>
<?php endif; ?>
</div>
</div>
<div class="role-fields" data-driver="cos" style="display:none;">
<div class="form-group">
<label>CAM 角色名称(可留空自动发现)</label>
<input type="text" name="cos[cam_role_name]" value="<?= h($configs['cos']['cam_role_name']) ?>"
placeholder="留空则自动读取实例绑定的角色">
</div>
</div>
<div class="form-group">
<label>存储桶名称(含 APPID)</label>
<input type="text" name="cos[bucket]" value="<?= h($configs['cos']['bucket']) ?>"
placeholder="例如 movielog-1250000000">
<div class="hint">COS 的桶名必须带 APPID 后缀。</div>
</div>
<div class="form-group">
<label>地域 Region</label>
<input type="text" name="cos[region]" value="<?= h($configs['cos']['region']) ?>"
placeholder="例如 ap-beijing">
<div class="hint">形如 <code>ap-地域</code>,在 COS 控制台桶的"概览"中可查。</div>
</div>
<div class="form-group">
<label>对象前缀(可选)</label>
<input type="text" name="cos[prefix]" value="<?= h($configs['cos']['prefix']) ?>"
placeholder="例如 movielog">
<div class="hint">填写后图片会存到桶内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
同一个桶存放多个站点时用它区分。留空则直接放在桶根目录。</div>
</div>
<div class="form-group">
<label>自定义域名 / CDN(可选)</label>
<input type="text" name="cos[custom_domain]" value="<?= h($configs['cos']['custom_domain']) ?>"
placeholder="例如 img.example.com">
</div>
<div class="form-group">
<label>协议</label>
<select name="cos[https]">
<option value="1" <?= $configs['cos']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
<option value="0" <?= $configs['cos']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
</select>
</div>
</div>
<!-- ========== 七牛云 ========== -->
<div class="panel <?= $current === 'qiniu' ? 'active' : '' ?>" data-panel="qiniu">
<div class="panel-title">七牛云 Kodo 配置</div>
<div class="form-group">
<label>AccessKey</label>
<input type="text" name="qiniu[access_key]" value="<?= h($configs['qiniu']['access_key']) ?>"
autocomplete="off">
</div>
<div class="form-group">
<label>SecretKey</label>
<input type="password" name="qiniu[secret_key]" value=""
placeholder="<?= $configs['qiniu']['secret_key'] !== '' ? '已保存(' . h(maskSecret($configs['qiniu']['secret_key'])) . '),留空则不修改' : '请输入 SecretKey' ?>"
autocomplete="new-password">
<?php if ($configs['qiniu']['secret_key'] !== ''): ?>
<label class="chk"><input type="checkbox" name="qiniu[clear_secrets]" value="1"> 清除已保存的密钥</label>
<?php endif; ?>
</div>
<div class="form-group">
<label>空间名称 Bucket</label>
<input type="text" name="qiniu[bucket]" value="<?= h($configs['qiniu']['bucket']) ?>">
</div>
<div class="form-group">
<label>存储区域</label>
<select name="qiniu[region]">
<?php
$regions = ['z0' => '华东 z0', 'z1' => '华北 z1', 'z2' => '华南 z2', 'na0' => '北美 na0', 'as0' => '亚太 as0'];
foreach ($regions as $rv => $rl): ?>
<option value="<?= h($rv) ?>" <?= $configs['qiniu']['region'] === $rv ? 'selected' : '' ?>><?= h($rl) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label>绑定域名(必填)</label>
<input type="text" name="qiniu[custom_domain]" value="<?= h($configs['qiniu']['custom_domain']) ?>"
placeholder="例如 cdn.example.com">
<div class="hint">七牛云的对象必须通过绑定域名访问,未绑定域名则图片无法显示。</div>
</div>
<div class="form-group">
<label>对象前缀(可选)</label>
<input type="text" name="qiniu[prefix]" value="<?= h($configs['qiniu']['prefix']) ?>"
placeholder="例如 movielog">
<div class="hint">填写后图片会存到空间内该目录下,键变为 <code>前缀/uploads/posters/xxx.jpg</code>;
同一个空间存放多个站点时用它区分。留空则直接放在空间根目录。</div>
</div>
<div class="form-group">
<label>协议</label>
<select name="qiniu[https]">
<option value="1" <?= $configs['qiniu']['https'] === '1' ? 'selected' : '' ?>>HTTPS</option>
<option value="0" <?= $configs['qiniu']['https'] === '0' ? 'selected' : '' ?>>HTTP</option>
</select>
</div>
</div>
<div class="row-flex" style="margin-top:22px;">
<button class="btn btn-primary" type="submit">保存设置</button>
<button class="btn btn-outline" type="button" id="btnTest">测试连接</button>
<span id="testResult" class="hint" style="margin:0;"></span>
</div>
</form>
</div>
<div class="card">
<h2>当前状态</h2>
<div class="status-grid">
<div class="k">生效方式</div>
<div class="v">
<?= h($drivers[$current] ?? $current) ?>
<?php if ($current !== 'local'): ?>
<?php if ($remoteReady): ?>
<span class="badge badge-ok">配置完整</span>
<?php else: ?>
<span class="badge badge-warn">配置不完整,暂时按本地存储处理</span>
<?php endif; ?>
<?php else: ?>
<span class="badge badge-off">本地存储</span>
<?php endif; ?>
</div>
<div class="k">图片访问方式</div>
<div class="v">
<?php if ($accessModeNow === 'proxy'): ?>
<span class="badge badge-ok">服务端代理</span>
<span class="hint" style="margin-left:8px;">经 media.php 鉴权读取,桶可设为私有</span>
<?php else: ?>
<span class="badge badge-off">直连存储</span>
<span class="hint" style="margin-left:8px;">图片 URL 直接指向存储</span>
<?php endif; ?>
</div>
<?php if ($accessModeNow === 'proxy' && $ossInternalInfo !== null && $ossInternalInfo['endpoint'] !== ''): ?>
<div class="k">服务端读取通道</div>
<div class="v">
<?php if ($ossInternalInfo['ok']): ?>
<span class="badge badge-ok">内网</span>
<?php else: ?>
<span class="badge badge-off">公网(已跳过内网)</span>
<?php endif; ?>
<span class="hint mono" style="margin-left:8px;"><?= h($ossInternalInfo['endpoint']) ?></span>
<?php if ($ossInternalInfo['reason'] !== ''): ?>
<span class="hint" style="margin-left:8px;"><?= h($ossInternalInfo['reason']) ?></span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="k">本地图片</div>
<div class="v"><?= $localStat['count'] ?> 张,占用 <?= h(formatBytes($localStat['bytes'])) ?></div>
<div class="k">数据目录</div>
<div class="v mono"><?= h(str_replace(ROOT_DIR . '/', '', UPLOAD_DIR)) ?></div>
<?php if ($current !== 'local'): ?>
<div class="k">对象前缀</div>
<div class="v">
<?php $curPrefix = trim((string)($configs[$current]['prefix'] ?? ''), '/'); ?>
<?php if ($curPrefix !== ''): ?>
<span class="mono"><?= h($curPrefix) ?>/</span>
<?php else: ?>
<span class="hint" style="margin:0;">未设置,图片直接放在桶根目录</span>
<?php endif; ?>
</div>
<?php endif; ?>
<div class="k">凭据加密</div>
<div class="v">
<?php if (!settingsEncryptionAvailable()): ?>
<span class="badge badge-warn">加密不可用</span>
<span class="hint" style="margin-left:8px;">
未启用 openssl 或 db/ 目录不可写,云厂商密钥将以明文保存
</span>
<?php elseif (settingsDecryptFailed()): ?>
<span class="badge badge-warn">解密失败</span>
<span class="hint" style="margin-left:8px;">
密钥文件 db/.settings.key 丢失或损坏,请在各云厂商表单中重新填写密钥
</span>
<?php else: ?>
<span class="badge badge-ok">已加密</span>
<span class="hint" style="margin-left:8px;">
<?= h(settingsCipherLabel()) ?>,密钥单独存放于 db/.settings.key(不在数据库内)
</span>
<?php endif; ?>
</div>
<?php if ($current === 'oss' && $configs['oss']['auth_mode'] === 'ecs_role'): ?>
<div class="k">ECS 角色</div>
<div class="v">
<button class="btn btn-outline btn-sm" type="button" id="btnDetectCloud">探测云主机身份</button>
<span id="detectResult" class="hint" style="margin-left:8px;"></span>
</div>
<?php elseif ($current === 'cos' && $configs['cos']['auth_mode'] === 'cvm_role'): ?>
<div class="k">CVM 角色</div>
<div class="v">
<button class="btn btn-outline btn-sm" type="button" id="btnDetectCloud">探测云主机身份</button>
<span id="detectResult" class="hint" style="margin-left:8px;"></span>
</div>
<?php endif; ?>
</div>
</div>
<div class="card">
<h2>把已有图片迁移到云端</h2>
<div class="sub">
迁移会把 <code>uploads/</code> 目录下的图片逐个上传到当前对象存储,并<strong>按内容指纹重命名</strong>
(<code>&lt;md5前12位&gt;_&lt;随机6位&gt;.扩展名</code>),同时自动改写数据库里对应的图片引用,
因此已有记录不会裂图。已经是本内容指纹名的文件会跳过改名,重复迁移是安全的。<br>
执行顺序固定为<strong>先上传 → 再改库 → 最后才删本地</strong>:任一步失败都会停下并保留本地文件,
加上"本地优先"渲染,页面不会出现裂图。云端若已存在旧名对象,会保留不删(不影响使用)。
</div>
<?php if (!$remoteReady): ?>
<div class="alert alert-info">
当前未启用对象存储(或配置不完整),迁移功能暂不可用。请先选择云端存储并保存。
<?php if (storageIsRemote()): ?>(提示:当前驱动配置不完整,无法上传)<?php endif; ?>
</div>
<?php else: ?>
<div class="row-flex">
<button class="btn btn-outline btn-sm" type="button" id="btnStat">统计待迁移</button>
<button class="btn btn-primary btn-sm" type="button" id="btnMigrate">开始迁移</button>
<label class="chk" style="margin:0;">
<input type="checkbox" id="delLocal" checked> 迁移后删除本地文件(真正释放磁盘)
</label>
</div>
<div class="progress"><i id="bar"></i></div>
<div class="hint" id="migrateMsg">尚未开始。</div>
<div class="hint mono" id="migrateErrors" style="max-height:140px;overflow:auto;"></div>
<?php endif; ?>
</div>
</div>
<script>
(function () {
var API = 'storage_api.php';
// ---------- 驱动切换 ----------
function syncPanels() {
var picked = document.querySelector('input[name=storage_driver]:checked');
var driver = picked ? picked.value : 'local';
document.querySelectorAll('.driver-opt').forEach(function (el) {
el.classList.toggle('active', el.dataset.driver === driver);
});
document.querySelectorAll('.panel').forEach(function (el) {
el.classList.toggle('active', el.dataset.panel === driver);
});
}
document.querySelectorAll('input[name=storage_driver]').forEach(function (r) {
r.addEventListener('change', syncPanels);
});
// ---------- 鉴权方式切换 ----------
function syncAuthMode(driver) {
var sel = document.querySelector('.auth-mode[data-driver="' + driver + '"]');
if (!sel) return;
var isRole = sel.value === 'ecs_role' || sel.value === 'cvm_role';
var ak = document.querySelector('.ak-fields[data-driver="' + driver + '"]');
var role = document.querySelector('.role-fields[data-driver="' + driver + '"]');
if (ak) ak.style.display = isRole ? 'none' : '';
if (role) role.style.display = isRole ? '' : 'none';
}
document.querySelectorAll('.auth-mode').forEach(function (sel) {
sel.addEventListener('change', function () { syncAuthMode(sel.dataset.driver); });
syncAuthMode(sel.dataset.driver);
});
// ---------- 测试连接 ----------
var btnTest = document.getElementById('btnTest');
if (btnTest) {
btnTest.addEventListener('click', function () {
var out = document.getElementById('testResult');
var picked = document.querySelector('input[name=storage_driver]:checked');
var driver = picked ? picked.value : 'local';
if (driver === 'local') {
out.style.color = 'var(--text-secondary)';
out.textContent = '本地存储无需测试。';
return;
}
btnTest.disabled = true;
out.style.color = 'var(--text-secondary)';
out.textContent = '正在测试,请稍候…';
// 直接把当前表单(含未保存的修改)发去测试
var fd = new FormData(document.getElementById('storageForm'));
fd.append('action', 'test');
fd.append('driver', driver);
fetch(API, { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (j) {
out.style.color = j.ok ? '#1e7e46' : '#a93226';
out.textContent = (j.ok ? '✔ ' : '✘ ') + (j.message || j.error || '');
})
.catch(function (e) {
out.style.color = '#a93226';
out.textContent = '✘ 请求失败:' + e;
})
.finally(function () { btnTest.disabled = false; });
});
}
// ---------- 探测云主机 ----------
var btnDetect = document.getElementById('btnDetectCloud');
if (btnDetect) {
btnDetect.addEventListener('click', function () {
var out = document.getElementById('detectResult');
btnDetect.disabled = true;
out.textContent = '探测中…';
fetch(API + '?action=detect_cloud')
.then(function (r) { return r.json(); })
.then(function (j) {
out.textContent = j.message || j.error || '';
})
.catch(function (e) { out.textContent = '探测失败:' + e; })
.finally(function () { btnDetect.disabled = false; });
});
}
// ---------- 迁移 ----------
var btnStat = document.getElementById('btnStat');
var btnMigrate = document.getElementById('btnMigrate');
var bar = document.getElementById('bar');
var msg = document.getElementById('migrateMsg');
var errBox = document.getElementById('migrateErrors');
var delLocal = document.getElementById('delLocal');
if (btnStat) {
btnStat.addEventListener('click', function () {
btnStat.disabled = true;
msg.textContent = '统计中…';
fetch(API + '?action=stat')
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) { msg.textContent = '✘ ' + (j.error || '统计失败'); return; }
msg.textContent = '本地共有 ' + j.local_total + ' 张图片,占用 '
+ j.local_bytes_h + '。迁移会把它们上传到云端;'
+ '勾选"迁移后删除本地文件"才会真正释放磁盘空间。';
})
.catch(function (e) { msg.textContent = '✘ ' + e; })
.finally(function () { btnStat.disabled = false; });
});
}
if (btnMigrate) {
var running = false;
btnMigrate.addEventListener('click', function () {
if (running) return;
running = true;
btnMigrate.disabled = true;
btnStat.disabled = true;
errBox.textContent = '';
bar.style.width = '0%';
var total = 0, done = 0, migrated = 0, renamed = 0, failed = 0, cursor = '';
var errors = [];
function step() {
var fd = new FormData();
fd.append('action', 'migrate');
fd.append('cursor', cursor);
fd.append('limit', '10');
fd.append('delete_local', delLocal.checked ? '1' : '0');
fetch(API, { method: 'POST', body: fd })
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) {
errors.push('批次失败:' + (j.error || '未知错误'));
finish();
return;
}
total = j.total;
migrated += j.migrated;
renamed += (j.renamed || 0);
failed += j.failed;
if (j.errors && j.errors.length) errors = errors.concat(j.errors);
done += j.processed || 0;
if (total > 0) {
bar.style.width = Math.min(100, Math.round(done * 100 / total)) + '%';
}
msg.textContent = '进度 ' + done + '/' + total
+ ',成功 ' + migrated + '(改名 ' + renamed + '),失败 ' + failed + '…';
errBox.textContent = errors.join('\n');
cursor = j.cursor;
if (j.has_more) {
step();
} else {
finish();
}
})
.catch(function (e) {
errors.push('请求异常:' + e);
finish();
});
}
function finish() {
running = false;
btnMigrate.disabled = false;
btnStat.disabled = false;
bar.style.width = '100%';
msg.textContent = '迁移结束:共处理 ' + done + ' 张,成功 ' + migrated
+ '(其中按指纹改名 ' + renamed + ' 张),失败 ' + failed + '。';
errBox.textContent = errors.join('\n');
}
step();
});
}
})();
</script>
</body>
</html>