Files
FamilyBook/app/Attachment.php
T
laoyang f927d7fb23 v79 服务端:端上任务面板需要的三个口子
配合鸿蒙端 v79(任务面板 / 任务模块功能补齐),服务端只补三处,
其余全部复用既有路由。

① GET /api/v1/tasks?view=boards —— 只回看板 + 分类元数据,**不带任务**。
   端上原来是从已加载任务里反推看板,那样只能看到"有任务的看板":
   用户刚建好、还没放任务的看板在端上永远选不到(表现为"网页能建,
   端上挪不过去")。

② POST /api/v1/tasks/<id>/attach(multipart 字段 file)与
   DELETE /api/v1/tasks/<id>/attach?attachment_id=<aid> —— 都要先
   requireWrite() 再 Task::canWrite(),两者都回整条任务(端上直接重画)。

③ ApiHandler::taskOut() —— Task::find() 只 SELECT t.*,不含看板名/色
   ⇒ 详情与各动作回包统一补 board_name / board_color_calc(7 个返回点)。

🔴 两条踩过的坑写进注释:
   · Api::dispatch() 只解析「资源/id/子动作」三段,附件 id 不能拼进路径;
   · DELETE 带 JSON body 时 Api::param 读不到(实测),id 必须走 query。

另新增 Attachment::removeFrom():删附件前先确认它确实挂在这条任务上,
拿别条任务的附件 id 来删自己的 → 404(已由 _fb_v79_api.py 钉死)。

验证:php -l 全过、e2e 1558/0、_fb_v79_api.py 26/0、已部署并冒烟。
2026-10-22 16:40:52 +08:00

280 lines
11 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
/**
* 统一附件(v12):旅程照片 / 资源附件 / 任务附件 / 评论附件,全都走这一张表。
*
* 为什么要统一:这几个模块对附件的要求其实是同一件事 ——
* "一个宿主挂多个文件、能下载、图片能预览、删宿主时别留下孤儿"。
* 各写一张表就要把"下载/预览/权限"写四遍,将来再加模块还得写第五遍。
*
* 下载地址**不落库**:一律由 media.php?k=<key> 现算(地址形态换过一次,
* 历史数据自动生效、零迁移 —— 这是 FeedLite 那边学到的最省事的一条)。
*/
declare(strict_types=1);
namespace App;
use App\Storage\Storage;
final class Attachment
{
/** 图片扩展名 → MIME(服务端自己判,不信浏览器给的类型) */
private const IMG_MIME = [
'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'png' => 'image/png',
'gif' => 'image/gif', 'webp' => 'image/webp', 'heic' => 'image/heic',
];
public const MAX_PER_HOST = 60;
/** 某个宿主的全部附件 */
public static function list(string $type, int $targetId): array
{
$st = Database::get()->prepare(
'SELECT * FROM attachments WHERE target_type = ? AND target_id = ? ORDER BY position, id'
);
$st->execute([$type, $targetId]);
return $st->fetchAll();
}
/**
* 存一个上传来的文件。
*
* @return array{ok:bool,error?:string,id?:int}
*/
public static function save(int $familyId, string $type, int $targetId, array $file, int $uploaderId = 0): array
{
if (!array_key_exists('error', $file)) {
return ['ok' => false, 'error' => ''];
}
if (is_array($file['error'])) {
return ['ok' => false, 'error' => '上传参数不对'];
}
if ($file['error'] === UPLOAD_ERR_NO_FILE) {
return ['ok' => false, 'error' => ''];
}
if ($file['error'] !== UPLOAD_ERR_OK) {
return ['ok' => false, 'error' => '上传失败(错误码 ' . (int)$file['error'] . ')'];
}
$stc = Database::get()->prepare('SELECT COUNT(*) FROM attachments WHERE target_type = ? AND target_id = ?');
$stc->execute([$type, $targetId]);
if ((int)$stc->fetchColumn() >= self::MAX_PER_HOST) {
return ['ok' => false, 'error' => '单个条目最多 ' . self::MAX_PER_HOST . ' 个附件'];
}
$tmp = (string)$file['tmp_name'];
$size = (int)@filesize($tmp);
$fino = new \finfo(FILEINFO_MIME_TYPE);
$mime = (string)$fino->file($tmp);
$name = self::safeName((string)($file['name'] ?? ''));
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($ext === '' && isset(self::IMG_MIME[$mime])) {
$ext = explode('/', $mime)[1] ?? '';
}
// 图片走 photo 槽位(ratio='free':不裁剪,等比缩到最长边 1600);
// 非图片(PDF/文档)原样存 —— 压缩反而会把它弄坏。
// ⚠️ 槽位键必须是 ImageSlot::SLOTS 里真实存在的那个:曾经这里写 'free'
// (把"不裁剪"的 ratio 当成槽位名用了),于是 Upload::image 直接返回
// 「上传位置配置不正确」,所有附件上传静默失败。
$isImage = strpos($mime, 'image/') === 0;
if ($isImage) {
$up = Upload::image($familyId, $file, 'photo');
if (empty($up['ok'])) {
return ['ok' => false, 'error' => (string)($up['error'] ?? '存图失败')];
}
$key = (string)$up['file'];
} else {
$key = 'files/' . bin2hex(random_bytes(12)) . ($ext !== '' ? '.' . preg_replace('/[^a-z0-9]/', '', $ext) : '');
$res = Storage::putFile($familyId, $tmp, $key, $mime !== '' ? $mime : 'application/octet-stream');
if (empty($res['ok'])) {
return ['ok' => false, 'error' => '保存失败:' . (string)($res['error'] ?? '')];
}
}
$db = Database::get();
$db->prepare(
'INSERT INTO attachments
(family_id, target_type, target_id, `key`, name, mime, size, is_image, position, uploader_id)
VALUES (?,?,?,?,?,?,?,?,?,?)'
)->execute([
$familyId, $type, $targetId, $key,
$name !== '' ? $name : null,
$mime !== '' ? $mime : null,
$size > 0 ? $size : null,
$isImage ? 1 : 0,
self::nextPosition($type, $targetId),
$uploaderId > 0 ? $uploaderId : null,
]);
return ['ok' => true, 'id' => (int)$db->lastInsertId()];
}
/** 删一个附件(连存储对象一起) */
public static function remove(int $id): void
{
$st = Database::get()->prepare('SELECT family_id, `key` FROM attachments WHERE id = ?');
$st->execute([$id]);
$r = $st->fetch();
if (!$r) {
return;
}
Database::get()->prepare('DELETE FROM attachments WHERE id = ?')->execute([$id]);
Storage::delete((int)$r['family_id'], (string)$r['key']);
}
/** 删某个宿主的全部附件(删旅程 / 删资源 / 删任务时调) */
public static function removeAll(string $type, int $targetId): void
{
foreach (self::list($type, $targetId) as $a) {
self::remove((int)$a['id']);
}
}
/**
* 删**某个宿主名下**的一颗附件(v79,App API 用)。
*
* 🔴 为什么不能直接用 `remove($id)`:那个只认附件 id,谁传一个别的任务的
* 附件 id 过来都能删掉(越权)。这里把 owner 一起放进 WHERE,
* 「不是这条任务的」一律返回 false,调用方据此回 404。
*/
public static function removeFrom(string $type, int $targetId, int $id): bool
{
if ($id <= 0) {
return false;
}
$st = Database::get()->prepare(
'SELECT COUNT(*) FROM attachments WHERE id = ? AND target_type = ? AND target_id = ?'
);
$st->execute([$id, $type, $targetId]);
if ((int)$st->fetchColumn() <= 0) {
return false;
}
self::remove($id);
return true;
}
/**
* 这个附件是「哪一类」—— 决定页面上画缩略图还是画图标、点开是预览还是下载。
*
* 返回:image / video / pdf / doc / xls / ppt / text / file
*
* ⚠️ 判据是**库里的 mime**(上传时由服务端嗅探内容得来),不看文件名后缀:
* 改个后缀骗不过它,而"文档其实是张图"这种也认得出来。
*/
public static function kind(array $a): string
{
if ((int)($a['is_image'] ?? 0) === 1) {
return 'image';
}
$mime = strtolower((string)($a['mime'] ?? ''));
$name = strtolower((string)($a['name'] ?? ''));
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
if ($mime === '' && $ext !== '') {
$mime = self::EXT_MIME[$ext] ?? '';
}
if (strpos($mime, 'video/') === 0) {
return 'video';
}
if (strpos($mime, 'audio/') === 0) {
return 'video'; // 音频用播放器放,与视频同一条路
}
if (strpos($mime, 'pdf') !== false || $ext === 'pdf') {
return 'pdf';
}
if (strpos($mime, 'text/') === 0 || in_array($ext, ['txt', 'md', 'csv', 'log', 'json'], true)) {
return 'text';
}
// Office 的 MIME 一长串且各家写法不一(含 -msword / officedocument / opendocument),
// 扩展名反而更稳,所以这里两条路都认。
if (self::inAny($mime . ' ' . $ext, ['word', 'doc', 'rtf', 'odt', 'wps'])) {
return 'doc';
}
if (self::inAny($mime . ' ' . $ext, ['excel', 'sheet', 'xls', 'xlsx', 'csv', 'ods', 'et'])) {
return 'xls';
}
if (self::inAny($mime . ' ' . $ext, ['powerpoint', 'presentation', 'ppt', 'pps', 'odp', 'dps'])) {
return 'ppt';
}
return 'file';
}
/**
* 能在**本页**直接预览吗。
*
* 图片与视频不用说;PDF 浏览器自带阅读器,也能直接看。
* Word / Excel 之类浏览器渲染不了 —— 点了就下载(不装"点了没反应"的预览)。
*/
public static function previewable(array $a): bool
{
return in_array(self::kind($a), ['image', 'video', 'pdf'], true);
}
/** 这一类在人话里叫什么(图标上的角标、预览标题都用它) */
public static function kindWord(string $kind): string
{
return self::KIND_WORDS[$kind] ?? '文件';
}
private const KIND_WORDS = [
'image' => '图片', 'video' => '视频', 'pdf' => 'PDF',
'doc' => 'Word', 'xls' => 'Excel', 'ppt' => 'PPT',
'text' => '文本', 'file' => '文件',
];
/** 扩展名兜底用的 MIME 表(库里 mime 为空时) */
private const EXT_MIME = [
'pdf' => 'application/pdf', 'txt' => 'text/plain', 'md' => 'text/markdown',
'csv' => 'text/csv', 'json' => 'application/json',
'mp4' => 'video/mp4', 'm4v' => 'video/mp4', 'webm' => 'video/webm',
'mov' => 'video/quicktime', 'avi' => 'video/x-msvideo', 'mkv' => 'video/x-matroska',
'mp3' => 'audio/mpeg', 'm4a' => 'audio/mp4', 'wav' => 'audio/wav',
'doc' => 'application/msword',
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'xls' => 'application/vnd.ms-excel',
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'ppt' => 'application/vnd.ms-powerpoint',
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed',
];
private static function inAny(string $hay, array $needles): bool
{
foreach ($needles as $n) {
if (strpos($hay, $n) !== false) {
return true;
}
}
return false;
}
/** 人话的大小 */
public static function sizeWord($bytes): string
{
$b = (int)$bytes;
if ($b <= 0) {
return '';
}
if ($b < 1024) {
return $b . ' B';
}
if ($b < 1024 * 1024) {
return round($b / 1024, 1) . ' KB';
}
return round($b / 1024 / 1024, 1) . ' MB';
}
private static function safeName(string $n): string
{
$n = str_replace(['\\', '/', ':', '*', '?', '"', '<', '>', '|'], '', $n);
$n = trim(preg_replace('/\s+/u', ' ', $n));
return mb_substr($n, 0, 180, 'UTF-8');
}
private static function nextPosition(string $type, int $targetId): int
{
$st = Database::get()->prepare(
'SELECT COALESCE(MAX(position),0)+10 FROM attachments WHERE target_type = ? AND target_id = ?'
);
$st->execute([$type, $targetId]);
return (int)$st->fetchColumn();
}
}