Files
FeedLite/lib/attach.php
T

581 lines
24 KiB
PHP
Raw Normal View History

<?php
/**
* 附件(图片 / 视频)—— 校验、入库、查询、渲染、过期清理
* ------------------------------------------------------------------
* 设计要点:
* - 文件实体只存在 OSS,本地库只存元信息(对象键、大小、类型、尺寸)。
* - 客户端提交的只是「描述」,服务端必须 HEAD 回源复核,防止伪造或超限。
*/
if (!defined('FB_ROOT')) { http_response_code(403); exit('forbidden'); }
/** 读取 upload 配置 */
function fb_upload_cfg($k = null, $d = null) {
$c = (array)fb_cfg('upload', []);
if ($k === null) { return $c; }
return array_key_exists($k, $c) ? $c[$k] : $d;
}
/* ==================================================================
* 存储方式:off(关闭)| local(存本机磁盘)| oss(存阿里云 OSS)
* 同一条附件走哪种存储,由它的 key 前缀决定:
* local/... → 本机 uploads/ 目录 fb/... → OSS
* 这样中途改存储方式也不会让旧附件失效。
* ================================================================== */
/** 当前存储方式(配置 attachment.storage;未配置则按 oss.enabled 推断) */
function fb_store_mode() {
$m = strtolower(trim((string)fb_cfg('attachment.storage', '')));
if ($m === 'local' || $m === 'oss' || $m === 'off') { return $m; }
return fb_oss_enabled() ? 'oss' : 'off';
}
/** 本机附件目录(站点内可直连访问) */
function fb_local_dir() {
return rtrim(str_replace('\\', '/', FB_ROOT . '/uploads'), '/');
}
/**
* 确保本机附件目录存在,并补齐安全防护文件(.htaccess / index.html)。
* ⚠️ 目录被删掉后由 PHP 重建时,.htaccess 不会自动回来 —— 必须在这里补写,
* 否则「禁列目录 + 禁执行脚本」的防护就丢了。
*/
function fb_local_ensure_dir() {
$dir = fb_local_dir();
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
if (!is_dir($dir)) { return false; }
@chmod($dir, 0755);
}
$ht = $dir . '/.htaccess';
if (!is_file($ht)) {
$rule = "# 本机附件目录(由程序自动生成)\n"
. "# 允许直接读取图片 / 视频,但禁止列出目录、禁止执行任何脚本\n"
. "Options -Indexes\n\n"
. "<FilesMatch \"\\.(php|php[0-9]|phtml|pht|phar|pl|py|cgi|sh|htaccess)$\">\n"
. " <IfModule mod_authz_core.c>\n Require all denied\n </IfModule>\n"
. " <IfModule !mod_authz_core.c>\n Order allow,deny\n Deny from all\n </IfModule>\n"
. "</FilesMatch>\n";
@file_put_contents($ht, $rule);
@chmod($ht, 0644);
}
$idx = $dir . '/index.html';
if (!is_file($idx)) {
@file_put_contents($idx, '');
@chmod($idx, 0644);
}
return true;
}
/** 本机附件是否可写 */
function fb_local_writable() {
fb_local_ensure_dir();
$dir = fb_local_dir();
return is_dir($dir) && is_writable($dir);
}
/**
* 本机附件目录的诊断信息(供后台「存储体检」显示)。
* 真实踩过的坑:用 SFTP/部署账号手动建 uploads/ 时,目录属主是部署账号,
* PHP 进程(www)写不进去 —— 表现就是「文件已存在但不可写」。
* 此时最省事的办法是删掉该目录,让 PHP 自己重建(属主即 www)。
* @return array{ok:bool,dir:string,tip:string}
*/
function fb_local_diag() {
$dir = fb_local_dir();
fb_local_ensure_dir(); // 顺带补齐 .htaccess / index.html
$exist = is_dir($dir);
if (!$exist) {
return ['ok' => false, 'dir' => $dir,
'tip' => '目录不存在且无法创建:上级目录 feedback/ 对 PHP 进程不可写,请检查目录权限。'];
}
if (!is_writable($dir)) {
$owner = function_exists('posix_getpwuid') ? @posix_getpwuid(@fileowner($dir)) : null;
$oname = is_array($owner) && isset($owner['name']) ? $owner['name'] : ('uid ' . @fileowner($dir));
$me = function_exists('posix_geteuid') ? @posix_getpwuid(@posix_geteuid()) : null;
$mname = is_array($me) && isset($me['name']) ? $me['name'] : ('uid ' . @getmyuid());
return ['ok' => false, 'dir' => $dir,
'tip' => '目录属主是 ' . $oname . ',而 PHP 以 ' . $mname . ' 运行 → 不可写。'
. '处理办法:删掉整个 uploads/ 目录(包括里面的 .htaccess),再刷新本页,'
. 'PHP 会自动重建并把属主设为自己;或在宝塔面板里把 uploads/ 属主改为站点运行用户。'];
}
return ['ok' => true, 'dir' => $dir, 'tip' => ''];
}
/** 当前存储是否可用(决定要不要显示上传入口) */
function fb_store_enabled() {
$m = fb_store_mode();
if ($m === 'local') { return fb_local_writable(); }
if ($m === 'oss') { return fb_oss_enabled(); }
return false;
}
/** 生成「本机存储」的对象键 */
function fb_local_make_key($ext) {
$ext = strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
return 'local/' . date('Y/m') . '/' . bin2hex(random_bytes(8)) . '.' . $ext;
}
/** 该附件是不是存在本机 */
function fb_attach_is_local($key) {
return strpos((string)$key, 'local/') === 0;
}
/** key → 本机绝对路径(已做穿越防护) */
function fb_local_path_of($key) {
$rel = ltrim((string)$key, '/');
if (strpos($rel, 'local/') === 0) { $rel = substr($rel, 6); }
$rel = str_replace(['..', "\0"], '', $rel);
return fb_local_dir() . '/' . ltrim($rel, '/');
}
/** 附件的访问地址(自动按 key 前缀分流) */
function fb_attach_url($key) {
if (fb_attach_is_local($key)) {
return fb_base_url() . '/uploads/' . substr(ltrim((string)$key, '/'), 6);
}
return fb_oss_url($key);
}
/** 附件的展示地址(缩略图;本机存储没有图片处理,直接给原图) */
function fb_attach_thumb_url($key, $w = 640, $mime = '') {
if (fb_attach_is_local($key)) { return fb_attach_url($key); }
return fb_oss_thumb_url($key, $w, $mime);
}
/** 删除附件实体(先按 key 分流,返回是否可继续删库记录) */
function fb_attach_delete($key) {
if (fb_attach_is_local($key)) {
$p = fb_local_path_of($key);
if (!is_file($p)) { return true; }
return @unlink($p);
}
if (!fb_oss_enabled()) { return true; }
return fb_oss_delete($key);
}
/** 该类型是否可用(数量上限为 0 或未配置则视为关闭) */
function fb_upload_kind_on($kind) {
if (!fb_store_enabled()) { return false; }
return (int)fb_upload_cfg($kind . '_max_per_post', 0) > 0;
}
/** 归一化扩展名 */
function fb_upload_norm_ext($ext) {
return strtolower(preg_replace('/[^A-Za-z0-9]/', '', (string)$ext));
}
/** 扩展名白名单 */
function fb_upload_ext_ok($kind, $ext) {
$list = (array)fb_upload_cfg($kind . '_exts', []);
return in_array(fb_upload_norm_ext($ext), array_map('strtolower', $list), true);
}
/** MIME 白名单 */
function fb_upload_mime_ok($kind, $mime) {
$list = (array)fb_upload_cfg($kind . '_mimes', []);
return in_array(strtolower(trim((string)$mime)), array_map('strtolower', $list), true);
}
/** 按扩展名猜 MIME(HEAD 复核时服务端返回缺失的兜底) */
function fb_upload_mime_of_ext($ext) {
$map = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'webp' => 'image/webp', 'gif' => 'image/gif',
'mp4' => 'video/mp4', 'mov' => 'video/quicktime', 'webm' => 'video/webm',
];
$e = fb_upload_norm_ext($ext);
return isset($map[$e]) ? $map[$e] : 'application/octet-stream';
}
/** 截断(不依赖 mbstring 扩展) */
function fb_upload_shorten($s, $len) {
$s = trim((string)$s);
$s = preg_replace('/[\r\n\t]+/', ' ', $s);
if (fb_len($s) <= $len) { return $s; }
return (function_exists('mb_substr') ? mb_substr($s, 0, $len, 'UTF-8') : substr($s, 0, $len));
}
/** 对象键是否合法(且位于本站前缀之下) */
function fb_upload_key_ok($key) {
$key = (string)$key;
if (strlen($key) < 8 || strlen($key) > 200) { return false; }
if (!preg_match('#^[A-Za-z0-9][A-Za-z0-9._/-]*$#', $key)) { return false; }
if (strpos($key, '..') !== false || strpos($key, '//') !== false) { return false; }
if (fb_attach_is_local($key)) {
// 本机存储:必须是 local/YYYY/MM/随机名.扩展名
return (bool)preg_match('#^local/\d{4}/\d{2}/[a-f0-9]{16}\.[a-z0-9]{2,5}$#', $key);
}
$prefix = trim((string)fb_cfg('oss.prefix', 'fb'), '/');
return strpos($key, $prefix . '/') === 0;
}
/** 键的扩展名 */
function fb_upload_key_ext($key) {
$e = pathinfo((string)$key, PATHINFO_EXTENSION);
return fb_upload_norm_ext($e);
}
/** 当前附件总量(字节) */
function fb_attach_total_bytes(?PDO $pdo = null) {
$pdo = $pdo ?: fb_db();
return (int)$pdo->query('SELECT COALESCE(SUM(size), 0) FROM attachments')->fetchColumn();
}
/**
* 解析客户端提交的附件 JSON(隐藏字段 attachments)
* @return array [bool $ok, array $list, string $err]
*/
function fb_attach_parse($raw) {
$empty = [[], []];
$raw = trim((string)$raw);
if ($raw === '') { return [true, [], '']; }
if (strlen($raw) > 20000) { return [false, [], '附件信息过长。']; }
$arr = json_decode($raw, true);
if (!is_array($arr)) { return [false, [], '附件信息格式不正确。']; }
$list = [];
$counts = ['image' => 0, 'video' => 0];
$seen = [];
foreach ($arr as $it) {
if (!is_array($it)) { return [false, [], '附件信息格式不正确。']; }
$kind = isset($it['kind']) ? strtolower(trim((string)$it['kind'])) : '';
if ($kind !== 'image' && $kind !== 'video') { return [false, [], '不支持的附件类型。']; }
if (!fb_upload_kind_on($kind)) { return [false, [], '该类型附件未开放上传。']; }
$key = isset($it['key']) ? trim((string)$it['key']) : '';
$size = isset($it['size']) ? (int)$it['size'] : 0;
$mime = isset($it['mime']) ? strtolower(trim((string)$it['mime'])) : '';
if (!fb_upload_key_ok($key)) { return [false, [], '附件地址不合法,请重新上传。']; }
if (isset($seen[$key])) { return [false, [], '存在重复的附件,请重新上传。']; }
$ext = fb_upload_key_ext($key);
if (!fb_upload_ext_ok($kind, $ext)) { return [false, [], '附件格式不被允许(' . e($ext) . ')。']; }
$max = (int)fb_upload_cfg($kind . '_max_bytes', 0);
if ($size <= 0 || ($max > 0 && $size > $max)) {
return [false, [], ($kind === 'image' ? '单张图片' : '视频') . '大小超出限制。'];
}
if ($mime !== '' && !fb_upload_mime_ok($kind, $mime)) {
return [false, [], '附件类型不被允许。'];
}
$counts[$kind]++;
if ($counts[$kind] > (int)fb_upload_cfg($kind . '_max_per_post', 0)) {
return [false, [], ($kind === 'image' ? '图片最多上传 ' : '视频最多上传 ')
. (int)fb_upload_cfg($kind . '_max_per_post', 0) . ' 个。'];
}
$seen[$key] = true;
$list[] = [
'kind' => $kind,
'key' => $key,
'size' => $size,
'mime' => ($mime !== '' ? $mime : fb_upload_mime_of_ext($ext)),
'name' => isset($it['name']) ? fb_upload_shorten($it['name'], 120) : '',
'width' => isset($it['w']) ? max(0, (int)$it['w']) : null,
'height' => isset($it['h']) ? max(0, (int)$it['h']) : null,
];
}
return [true, $list, ''];
}
/**
* 回源复核:确认对象确实存在、大小一致、内容类型在白名单内。
* 复核结果会以服务端返回的 Content-Type / Content-Length 为准写回 $list。
* @return array [bool $ok, array $list, string $err]
*/
function fb_attach_verify(array $list) {
if (!$list) { return [true, $list, '']; }
if (!fb_store_enabled()) { return [false, [], '附件服务未启用。']; }
// 本机存储:直接查磁盘,不联网
if (fb_store_mode() === 'local') {
foreach ($list as $i => $it) {
if (!fb_attach_is_local($it['key'])) {
return [false, [], '附件地址与当前存储方式不符,请重新上传。'];
}
$p = fb_local_path_of($it['key']);
if (!is_file($p)) {
return [false, [], '附件上传未完成或已失效,请重新上传后提交。'];
}
$len = (int)@filesize($p);
if ($len > 0) {
if (abs($len - (int)$it['size']) > 1024) {
return [false, [], '附件大小与服务器记录不一致,请重新上传。'];
}
$list[$i]['size'] = $len;
}
$ct = '';
if (function_exists('finfo_open')) {
$f = @finfo_open(FILEINFO_MIME_TYPE);
if ($f) { $ct = strtolower(trim((string)@finfo_file($f, $p))); @finfo_close($f); }
} elseif (function_exists('mime_content_type')) {
$ct = strtolower(trim((string)@mime_content_type($p)));
}
if ($ct !== '' && $ct !== 'application/octet-stream') {
if (!fb_upload_mime_ok($it['kind'], $ct)) {
return [false, [], '附件内容类型不被允许。'];
}
$list[$i]['mime'] = $ct;
}
if (!fb_upload_mime_ok($it['kind'], $list[$i]['mime'])) {
return [false, [], '附件内容类型不被允许。'];
}
}
return [true, $list, ''];
}
if (!fb_oss_enabled()) { return [false, [], '附件服务未启用。']; }
if (!fb_oss_cfg('verify', true)) { return [true, $list, '']; }
foreach ($list as $i => $it) {
list($status, $h) = fb_oss_head($it['key']);
if ($status < 200 || $status >= 300) {
return [false, [], '附件上传未完成或已失效,请重新上传后提交。'];
}
$len = isset($h['content-length']) ? (int)$h['content-length'] : 0;
if ($len > 0) {
if (abs($len - (int)$it['size']) > 1024) {
return [false, [], '附件大小与服务器记录不一致,请重新上传。'];
}
$list[$i]['size'] = $len;
}
if (!empty($h['content-type'])) {
$ct = strtolower(trim((string)explode(';', $h['content-type'])[0]));
// 服务端返回的类型必须可信:不在白名单里就直接拒绝;
// 仅在「缺失 / octet-stream」时才回落到客户端声明的类型。
if ($ct !== '' && $ct !== 'application/octet-stream') {
if (!fb_upload_mime_ok($it['kind'], $ct)) {
return [false, [], '附件内容类型不被允许。'];
}
$list[$i]['mime'] = $ct;
}
}
if (!fb_upload_mime_ok($it['kind'], $list[$i]['mime'])) {
return [false, [], '附件内容类型不被允许。'];
}
}
return [true, $list, ''];
}
/** 总量上限检查 */
function fb_attach_cap_ok(PDO $pdo, array $list) {
$cap = (int)fb_upload_cfg('total_cap_bytes', 0);
if ($cap <= 0) { return true; }
$add = 0;
foreach ($list as $it) { $add += (int)$it['size']; }
return (fb_attach_total_bytes($pdo) + $add) <= $cap;
}
/** 落库(在主题 / 回复写入之后调用) */
function fb_attach_save(PDO $pdo, $threadId, $replyId, array $list) {
if (!$list) { return 0; }
$now = time();
$st = $pdo->prepare(
'INSERT INTO attachments(thread_id, reply_id, kind, oss_key, mime, size, width, height, orig_name, seq, created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)'
);
$n = 0;
foreach ($list as $i => $it) {
$st->execute([
(int)$threadId,
$replyId ? (int)$replyId : null,
$it['kind'], $it['key'], $it['mime'], (int)$it['size'],
$it['width'] ?: null, $it['height'] ?: null,
$it['name'] !== '' ? $it['name'] : null,
$i, $now,
]);
$n++;
// 转正成功即从「待入库」表移除(media.php 之后改走 attachments 表鉴权)
fb_attach_pending_clear($it['key']);
}
return $n;
}
/* ==================================================================
* 待入库附件登记
* ------------------------------------------------------------------
* 上传成功 → 登记;提交表单入库 → 转正并移除;一直没提交 → 由 cron 清理。
* 只为 media.php 的代理鉴权服务(上传后、入库前的缩略图预览)。
* ================================================================== */
/** 登记一条待入库附件 */
function fb_attach_pending_mark($key, $kind, $mime, $size) {
try {
$pdo = fb_db();
$pdo->prepare(
'INSERT INTO pending_attachments(oss_key, kind, mime, size, ip_hash, created_at)
VALUES(?,?,?,?,?,?)'
)->execute([(string)$key, (string)$kind, (string)$mime, (int)$size, fb_ip_hash(), time()]);
return true;
} catch (Throwable $e) { return false; }
}
/** 转正/放弃后移除登记(幂等) */
function fb_attach_pending_clear($key) {
try {
$pdo = fb_db();
$pdo->prepare('DELETE FROM pending_attachments WHERE oss_key = ?')->execute([(string)$key]);
return true;
} catch (Throwable $e) { return false; }
}
/** 该对象键是否由【当前访客】在有效期内登记过(media.php 鉴权用) */
function fb_attach_pending_mine($key, $ttl = 7200) {
try {
$pdo = fb_db();
$st = $pdo->prepare(
'SELECT id FROM pending_attachments
WHERE oss_key = ? AND ip_hash = ? AND created_at >= ? LIMIT 1'
);
$st->execute([(string)$key, fb_ip_hash(), time() - (int)$ttl]);
return (bool)$st->fetch();
} catch (Throwable $e) { return false; }
}
/** 清理过期的待入库登记(cron 调用;只删记录,不动 OSS 对象——那属于未提交的孤儿,另行处理) */
function fb_attach_pending_gc($ttl = 7200) {
try {
$pdo = fb_db();
$st = $pdo->prepare('DELETE FROM pending_attachments WHERE created_at < ?');
$st->execute([time() - (int)$ttl]);
return $st->rowCount();
} catch (Throwable $e) { return 0; }
}
/** 取某个主题的附件:['main' => [...], 'replies' => [reply_id => [...]]] */
function fb_attach_for_thread(PDO $pdo, $threadId) {
$st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC');
$st->execute([(int)$threadId]);
$out = ['main' => [], 'replies' => []];
foreach ($st->fetchAll() as $r) {
if (empty($r['reply_id'])) { $out['main'][] = $r; }
else { $out['replies'][(int)$r['reply_id']][] = $r; }
}
return $out;
}
/** 批量取(列表页),返回 [thread_id => [image rows]] */
function fb_attach_map_images(PDO $pdo, array $threadIds, $perThread = 3) {
$ids = [];
foreach ($threadIds as $i) { $i = (int)$i; if ($i > 0) { $ids[] = $i; } }
if (!$ids) { return []; }
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare("SELECT id, thread_id, oss_key, mime FROM attachments
WHERE kind = 'image' AND thread_id IN ($in) ORDER BY thread_id ASC, seq ASC, id ASC");
$st->execute($ids);
$out = [];
foreach ($st->fetchAll() as $r) {
$tid = (int)$r['thread_id'];
if (!isset($out[$tid])) { $out[$tid] = []; }
if (count($out[$tid]) < $perThread) { $out[$tid][] = $r; }
}
return $out;
}
/**
* 渲染附件区($rows 为 attachments 行数组)
* ------------------------------------------------------------------
* 输出带 data-gallery 的容器;同一容器内(=同一个主题 / 同一条回复)的全部附件
* 构成一个「灯箱画廊」,由 assets/app.js 接管:
* · 点击缩略图 → 页内放大(不跳转新标签页,仍是 <a href> 所以禁用 JS 也能看)
* · ‹ › / ← → / 触摸左右滑 → 在同一帖的多个附件间切换
* · 视频以「放大」按钮进入同一序列,方便逐条翻阅
*/
function fb_attach_render(array $rows, array $opt = []) {
if (!$rows) { return ''; }
$compact = !empty($opt['compact']);
$html = '<div class="atts' . ($compact ? ' compact' : '') . '" data-gallery>';
foreach ($rows as $r) {
$kind = (string)$r['kind'];
$key = (string)$r['oss_key'];
$mime = isset($r['mime']) ? (string)$r['mime'] : '';
$full = fb_attach_url($key);
$name = fb_attach_label($r);
$sizeH = fb_size_h($r['size']);
if ($kind === 'video') {
$html .= '<figure class="att att-video">'
. '<video controls preload="metadata" playsinline src="' . e($full) . '"></video>'
. '<figcaption><span class="att-name">' . e($name) . '</span>'
. '<span class="att-size">' . e($sizeH) . '</span>'
. '<button type="button" class="att-zoom" aria-label="放大查看该视频"'
. ' data-lb-kind="video" data-lb-src="' . e($full) . '"'
. ' data-lb-name="' . e($name) . '" data-lb-size="' . e($sizeH) . '">放大</button>'
. '</figcaption>'
. '</figure>';
} else {
// 缩略图(列表/正文)与灯箱显示图分开取尺寸,避免灯箱里加载原图
$thumb = fb_attach_thumb_url($key, $compact ? 360 : 640, $mime);
$view = fb_attach_thumb_url($key, 1600, $mime);
$html .= '<figure class="att att-image">'
. '<a class="att-lb" href="' . e($full) . '" target="_blank" rel="noopener noreferrer"'
. ' data-lb-kind="image" data-lb-src="' . e($view) . '" data-lb-full="' . e($full) . '"'
. ' data-lb-name="' . e($name) . '" data-lb-size="' . e($sizeH) . '">'
. '<img loading="lazy" src="' . e($thumb) . '" alt="' . e($name) . '"></a>'
. '</figure>';
}
}
return $html . '</div>';
}
/** 附件展示名(回退为「图片/视频 + 序号」) */
function fb_attach_label(array $r) {
$n = isset($r['orig_name']) ? trim((string)$r['orig_name']) : '';
if ($n !== '') { return fb_excerpt($n, 40); }
return ((string)$r['kind'] === 'video') ? '视频' : '图片';
}
/** 人类可读大小 */
function fb_size_h($bytes) {
$b = (int)$bytes;
if ($b < 1024) { return $b . ' B'; }
if ($b < 1048576) { return round($b / 1024, 1) . ' KB'; }
return round($b / 1048576, 1) . ' MB';
}
/** 统计某主题附件数(列表页标记用) */
function fb_attach_count_map(PDO $pdo, array $threadIds) {
$ids = [];
foreach ($threadIds as $i) { $i = (int)$i; if ($i > 0) { $ids[] = $i; } }
if (!$ids) { return []; }
$in = implode(',', array_fill(0, count($ids), '?'));
$st = $pdo->prepare("SELECT thread_id, COUNT(*) c FROM attachments WHERE thread_id IN ($in) GROUP BY thread_id");
$st->execute($ids);
$out = [];
foreach ($st->fetchAll() as $r) { $out[(int)$r['thread_id']] = (int)$r['c']; }
return $out;
}
/**
* 清理过期附件:先删 OSS 对象,成功后再删库记录(失败则留待下次重试)。
* @return int 已清理条数
*/
function fb_attach_cleanup(PDO $pdo, $days = null, $limit = 200) {
$days = $days === null ? (int)fb_upload_cfg('retention_days', 365) : (int)$days;
if ($days <= 0) { return 0; }
$cut = time() - $days * 86400;
$st = $pdo->prepare('SELECT id, oss_key FROM attachments WHERE created_at < ? ORDER BY created_at ASC LIMIT ' . (int)$limit);
$st->execute([$cut]);
$rows = $st->fetchAll();
$del = $pdo->prepare('DELETE FROM attachments WHERE id = ?');
$n = 0;
foreach ($rows as $r) {
if (fb_attach_delete($r['oss_key'])) {
$del->execute([(int)$r['id']]);
$n++;
}
}
return $n;
}