Files
XinagMuKanBan/api.php
2025-11-18 14:28:07 +08:00

511 lines
22 KiB
PHP
Raw Permalink 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
session_start();
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { exit; }
$action = $_GET['action'] ?? '';
$dataDir = __DIR__ . DIRECTORY_SEPARATOR . 'data';
$exportDir = $dataDir . DIRECTORY_SEPARATOR . 'exports';
$uploadDir = $dataDir . DIRECTORY_SEPARATOR . 'uploads';
$dataFile = $dataDir . DIRECTORY_SEPARATOR . 'projects.json';
// 存储AI润色结果的JSON文件
$aiTextFile = $dataDir . DIRECTORY_SEPARATOR . 'ai_reminder.json';
if (!is_dir($dataDir)) { @mkdir($dataDir, 0777, true); }
if (!is_dir($exportDir)) { @mkdir($exportDir, 0777, true); }
if (!is_dir($uploadDir)) { @mkdir($uploadDir, 0777, true); }
if (!file_exists($dataFile)) { file_put_contents($dataFile, json_encode([])); }
if (!file_exists($aiTextFile)) { file_put_contents($aiTextFile, json_encode(new stdClass())); }
function readJson($file) {
$raw = @file_get_contents($file);
$arr = json_decode($raw, true);
if (!is_array($arr)) $arr = [];
return $arr;
}
function writeJson($file, $arr) {
$json = json_encode($arr, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
$fp = fopen($file, 'w');
if ($fp) {
// 简单写锁
if (flock($fp, LOCK_EX)) {
fwrite($fp, $json);
fflush($fp);
flock($fp, LOCK_UN);
}
fclose($fp);
return true;
}
return false;
}
function uid() { return uniqid('', true); }
function nowStr() { return date('Y-m-d H:i:s'); }
function body_json() {
$raw = file_get_contents('php://input');
$json = json_decode($raw, true);
return is_array($json) ? $json : [];
}
function resp_json($data) {
header('Content-Type: application/json; charset=utf-8');
echo json_encode($data, JSON_UNESCAPED_UNICODE);
exit;
}
function find_index_by_id(&$arr, $id) {
foreach ($arr as $i => $it) { if (($it['id'] ?? '') === $id) return $i; }
return -1;
}
// 未登录则阻止除 login 以外的操作
if ($action !== 'login') {
if (!isset($_SESSION['auth']) || !$_SESSION['auth']) {
resp_json(['ok' => false, 'message' => '未登录', 'need_login' => true]);
}
}
switch ($action) {
case 'login': {
$b = body_json();
$pwd = trim($b['password'] ?? '');
if ($pwd === '123123') {
$_SESSION['auth'] = 1;
// 设置简单 Cookie供导出HTML等静态页前端校验
setcookie('auth_ok', '1', time() + 7*24*3600, '/');
resp_json(['ok' => true]);
} else {
resp_json(['ok' => false, 'message' => '密码错误']);
}
}
case 'logout': {
$_SESSION['auth'] = 0; unset($_SESSION['auth']);
setcookie('auth_ok', '', time() - 3600, '/');
resp_json(['ok' => true]);
}
case 'list_projects': {
$projects = readJson($dataFile);
resp_json(['ok' => true, 'projects' => $projects]);
}
case 'get_project': {
$id = $_GET['id'] ?? '';
if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']);
$projects = readJson($dataFile);
$idx = find_index_by_id($projects, $id);
if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']);
resp_json(['ok' => true, 'project' => $projects[$idx]]);
}
case 'cleanup_unused_uploads': {
// 主动触发一次清理:删除 data/uploads 下未被任何笔记引用的文件
$projects = readJson($dataFile);
$clean = cleanup_unused_uploads($projects, $uploadDir);
resp_json(['ok' => true, 'cleanup' => $clean]);
}
case 'create_project': {
$b = body_json();
$name = trim($b['name'] ?? '');
if ($name === '') resp_json(['ok' => false, 'message' => '项目名称不能为空']);
$projects = readJson($dataFile);
$p = [
'id' => uid(),
'name' => $name,
'status' => '待做',
'notes' => [],
'created_at' => nowStr(),
'updated_at' => nowStr()
];
$projects[] = $p;
writeJson($dataFile, $projects);
resp_json(['ok' => true, 'project' => $p]);
}
case 'update_project': {
$b = body_json();
$id = $b['id'] ?? '';
if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']);
$projects = readJson($dataFile);
$idx = find_index_by_id($projects, $id);
if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']);
$name = isset($b['name']) ? trim($b['name']) : null;
$status = isset($b['status']) ? trim($b['status']) : null;
if ($name !== null && $name !== '') $projects[$idx]['name'] = $name;
if ($status !== null && in_array($status, ['待做','进行','完成','异常'])) $projects[$idx]['status'] = $status;
$projects[$idx]['updated_at'] = nowStr();
writeJson($dataFile, $projects);
resp_json(['ok' => true, 'project' => $projects[$idx]]);
}
case 'delete_project': {
$b = body_json();
$id = $b['id'] ?? '';
if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']);
$projects = readJson($dataFile);
$idx = find_index_by_id($projects, $id);
if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']);
array_splice($projects, $idx, 1);
writeJson($dataFile, $projects);
// 同步删除该项目的AI润色存档
$aiMap = readJson($aiTextFile);
if (isset($aiMap[$id])) { unset($aiMap[$id]); writeJson($aiTextFile, $aiMap); }
// 删除项目后,清理未被任何笔记引用的上传文件
$clean = cleanup_unused_uploads($projects, $uploadDir);
resp_json(['ok' => true, 'cleanup' => $clean]);
}
case 'add_note': {
$b = body_json();
$pid = $b['project_id'] ?? '';
$content = trim($b['content'] ?? '');
if ($pid === '' || $content === '') resp_json(['ok' => false, 'message' => '缺少参数']);
$projects = readJson($dataFile);
$idx = find_index_by_id($projects, $pid);
if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']);
$note = [ 'id' => uid(), 'content' => $content, 'created_at' => nowStr(), 'updated_at' => nowStr() ];
if (!isset($projects[$idx]['notes']) || !is_array($projects[$idx]['notes'])) $projects[$idx]['notes'] = [];
$projects[$idx]['notes'][] = $note;
writeJson($dataFile, $projects);
resp_json(['ok' => true, 'note' => $note]);
}
case 'update_note': {
$b = body_json();
$pid = $b['project_id'] ?? '';
$nid = $b['note_id'] ?? '';
$content = trim($b['content'] ?? '');
if ($pid === '' || $nid === '') resp_json(['ok' => false, 'message' => '缺少参数']);
$projects = readJson($dataFile);
$pidx = find_index_by_id($projects, $pid);
if ($pidx < 0) resp_json(['ok' => false, 'message' => '项目不存在']);
$notes = $projects[$pidx]['notes'] ?? [];
$nidx = -1; foreach ($notes as $i => $n) { if (($n['id'] ?? '') === $nid) { $nidx = $i; break; } }
if ($nidx < 0) resp_json(['ok' => false, 'message' => '笔记不存在']);
if ($content !== '') { $projects[$pidx]['notes'][$nidx]['content'] = $content; }
$projects[$pidx]['notes'][$nidx]['updated_at'] = nowStr();
writeJson($dataFile, $projects);
// 更新笔记后尝试清理未使用的上传文件(防止删除了图片引用后残留)
$clean = cleanup_unused_uploads($projects, $uploadDir);
resp_json(['ok' => true, 'note' => $projects[$pidx]['notes'][$nidx], 'cleanup' => $clean]);
}
case 'delete_note': {
$b = body_json();
$pid = $b['project_id'] ?? '';
$nid = $b['note_id'] ?? '';
if ($pid === '' || $nid === '') resp_json(['ok' => false, 'message' => '缺少参数']);
$projects = readJson($dataFile);
$pidx = find_index_by_id($projects, $pid);
if ($pidx < 0) resp_json(['ok' => false, 'message' => '项目不存在']);
$notes = $projects[$pidx]['notes'] ?? [];
$nidx = -1; foreach ($notes as $i => $n) { if (($n['id'] ?? '') === $nid) { $nidx = $i; break; } }
if ($nidx < 0) resp_json(['ok' => false, 'message' => '笔记不存在']);
array_splice($projects[$pidx]['notes'], $nidx, 1);
writeJson($dataFile, $projects);
// 删除笔记后,清理未被任何笔记引用的上传文件
$clean = cleanup_unused_uploads($projects, $uploadDir);
resp_json(['ok' => true, 'cleanup' => $clean]);
}
case 'export_project_html': {
$id = $_GET['id'] ?? '';
if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']);
$projects = readJson($dataFile);
$idx = find_index_by_id($projects, $id);
if ($idx < 0) resp_json(['ok' => false, 'message' => '项目不存在']);
$p = $projects[$idx];
$safeName = preg_replace('/[^\w\x{4e00}-\x{9fa5}]+/u', '_', $p['name']);
$fname = $exportDir . DIRECTORY_SEPARATOR . 'project_' . $safeName . '_' . substr($p['id'], 0, 8) . '.html';
$html = "<!DOCTYPE html><html lang=\"zh-CN\"><meta charset=\"utf-8\"><title>" . htmlspecialchars($p['name']) . " - 导出</title><style>body{font-family:Arial,Microsoft YaHei;padding:16px}h1{margin-top:0} .note{border:1px dashed #ccc; padding:8px; border-radius:8px; margin:8px 0}</style><body>";
$html .= '<h1>' . htmlspecialchars($p['name']) . '</h1>';
$html .= '<p>状态:' . htmlspecialchars($p['status']) . '</p>';
$html .= '<h2>笔记</h2>';
foreach (($p['notes'] ?? []) as $n) {
$html .= '<div class="note"><div>' . render_note_content($n['content'] ?? '') . '</div>';
$html .= '<div style="color:#666;font-size:12px">创建:' . htmlspecialchars($n['created_at'] ?? '') . ' 更新:' . htmlspecialchars($n['updated_at'] ?? '') . '</div></div>';
}
$html .= '</body></html>';
file_put_contents($fname, $html);
// 构造相对URL便于在浏览器打开
$rel = 'data/exports/' . basename($fname);
resp_json(['ok' => true, 'url' => $rel]);
}
case 'ai_enrich_reminder': {
// 使用火山引擎 Doubao 模型对提醒文案进行情感润色
$b = body_json();
$raw = trim($b['raw'] ?? '');
$proj = $b['project'] ?? [];
$apiKey = trim($b['api_key'] ?? '');
if ($raw === '') resp_json(['ok' => false, 'message' => '缺少原始提醒内容']);
if ($apiKey === '') {
// 尝试从本地文件读取 Key可选
$kf = $dataDir . DIRECTORY_SEPARATOR . 'ark_api_key.txt';
if (is_file($kf)) { $apiKey = trim(@file_get_contents($kf)); }
}
if ($apiKey === '') resp_json(['ok' => false, 'message' => '缺少AI Key请将密钥写入 data/ark_api_key.txt']);
$name = isset($proj['name']) ? $proj['name'] : '';
$status = isset($proj['status']) ? $proj['status'] : '';
$pid = isset($proj['id']) ? $proj['id'] : '';
// 可选:结合项目最近笔记进行润色
$includeNotes = isset($b['include_notes']) ? !!($b['include_notes']) : true; // 默认结合笔记
$notesSummary = '';
if ($includeNotes && $pid !== '') {
$projectsAll = readJson($dataFile);
$pidx2 = find_index_by_id($projectsAll, $pid);
if ($pidx2 >= 0) {
$notesArr = $projectsAll[$pidx2]['notes'] ?? [];
// 取最近3条末尾3条
$cnt = count($notesArr);
$recent = [];
if ($cnt > 0) {
$recent = array_slice($notesArr, max(0, $cnt - 3));
}
$pieces = [];
foreach ($recent as $nn) {
$content = trim($nn['content'] ?? '');
if ($content !== '') {
// 去掉图片Markdown、URL等噪声
$content = preg_replace('/!\[[^\]]*\]\([^\)]*\)/', '', $content);
$content = preg_replace('/https?:\/\/\S+/i', '', $content);
$content = trim($content);
// 截断避免过长
if (function_exists('mb_strlen') && function_exists('mb_substr')) {
if (mb_strlen($content, 'UTF-8') > 120) { $content = mb_substr($content, 0, 120, 'UTF-8') . '…'; }
} else {
if (strlen($content) > 200) { $content = substr($content, 0, 200) . '…'; }
}
if ($content !== '') $pieces[] = $content;
}
}
if (!empty($pieces)) { $notesSummary = implode('', $pieces); }
}
}
$prompt = "请将以下项目提醒改写为更具同理心、简洁明确且可执行的中文提示长度不超过60字并适当使用 emoji最多2个避免官方语气。\n" .
"只输出一条润色后的中文提醒,不要附加任何解释、评分、括号中的说明、标签或代码块。\n" .
"项目名称:" . $name . "\n状态:" . $status . "\n提醒:" . $raw;
if ($notesSummary !== '') {
$prompt .= "\n参考最近笔记要点:" . $notesSummary;
}
$payload = [
'model' => 'doubao-seed-1-6-251015',
'max_completion_tokens' => 1024,
'messages' => [ [ 'role' => 'user', 'content' => [ [ 'type' => 'text', 'text' => $prompt ] ] ] ],
'reasoning_effort' => 'medium'
];
$ch = curl_init('https://ark.cn-beijing.volces.com/api/v3/chat/completions');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [ 'Content-Type: application/json', 'Authorization: Bearer ' . $apiKey ]);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
$resp = curl_exec($ch);
$err = curl_error($ch);
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($resp === false) resp_json(['ok' => false, 'message' => '请求失败: ' . $err]);
$j = json_decode($resp, true);
if (!is_array($j)) resp_json(['ok' => false, 'message' => '响应解析失败', 'code' => $code]);
// 提取文本内容
$txt = '';
// 1) 直接读取 output_text有些版本会提供该字段
if (isset($j['output_text']) && is_string($j['output_text'])) { $txt = $j['output_text']; }
// 2) choices.message.content 可能是字符串或数组
if ($txt === '' && isset($j['choices'][0]['message']['content'])) {
$content = $j['choices'][0]['message']['content'];
if (is_string($content)) { $txt = $content; }
elseif (is_array($content)) {
foreach ($content as $c) { if (isset($c['text'])) { $txt .= $c['text']; } }
}
}
// 3) 兜底读取常见位置
if ($txt === '' && isset($j['choices'][0]['message']['content'][0]['text'])) {
$txt = $j['choices'][0]['message']['content'][0]['text'];
}
if ($txt === '' && isset($j['data']['output_text'])) { $txt = $j['data']['output_text']; }
if ($txt === '') resp_json(['ok' => false, 'message' => '未获取到AI文本', 'raw_resp' => $j]);
// 仅保留第一行,并去掉末尾的括号型说明(例如:(字数合规…)或(...
$txt = trim((string)$txt);
$firstLine = preg_split('/\r?\n/', $txt)[0];
$firstLine = trim($firstLine);
// 去掉结尾括号段,但保留正文中的内容
$firstLine = preg_replace('/(?:[^]*|\([^\)]*\))\s*$/u', '', $firstLine);
$firstLine = trim($firstLine);
if ($firstLine === '') $firstLine = $txt; // 兜底
$txt = $firstLine;
// 写入到 ai_reminder.json 中按项目ID持久化
if ($pid !== '') {
$aiMap = readJson($aiTextFile);
if (!is_array($aiMap)) $aiMap = [];
$aiMap[$pid] = [ 'text' => $txt, 'raw' => $raw, 'updated_at' => nowStr(), 'notes' => $notesSummary ];
writeJson($aiTextFile, $aiMap);
}
resp_json(['ok' => true, 'text' => $txt, 'saved' => ($pid !== '')]);
}
case 'get_ai_reminder': {
// 获取指定项目的AI润色文本如果存在
$id = $_GET['id'] ?? ($_POST['id'] ?? '');
if ($id === '') resp_json(['ok' => false, 'message' => '缺少项目ID']);
$aiMap = readJson($aiTextFile);
if (isset($aiMap[$id]) && isset($aiMap[$id]['text'])) {
$item = $aiMap[$id];
resp_json(['ok' => true, 'text' => $item['text'], 'updated_at' => ($item['updated_at'] ?? '')]);
} else {
resp_json(['ok' => true, 'text' => '', 'updated_at' => '']);
}
}
case 'list_exports': {
// 列出 data/exports 下的导出文件(仅 .html
$files = [];
if (is_dir($exportDir)) {
foreach (scandir($exportDir) as $f) {
if ($f === '.' || $f === '..') continue;
if (!preg_match('/\.html?$/i', $f)) continue;
$path = $exportDir . DIRECTORY_SEPARATOR . $f;
if (is_file($path)) {
$files[] = [
'name' => $f,
'url' => 'data/exports/' . $f,
'size' => filesize($path),
'mtime' => date('Y-m-d H:i:s', filemtime($path))
];
}
}
}
// 最新时间倒序
usort($files, function($a,$b){ return strcmp($b['mtime'],$a['mtime']); });
resp_json(['ok' => true, 'files' => $files]);
}
case 'delete_export': {
// 删除指定的导出文件(仅限 data/exports
$b = body_json();
$name = $b['name'] ?? '';
if ($name === '') resp_json(['ok' => false, 'message' => '缺少文件名']);
$base = basename($name); // 防止路径穿越
$path = $exportDir . DIRECTORY_SEPARATOR . $base;
if (!is_file($path)) resp_json(['ok' => false, 'message' => '文件不存在']);
if (!@unlink($path)) resp_json(['ok' => false, 'message' => '删除失败']);
resp_json(['ok' => true]);
}
case 'upload_file': {
// 允许上传图片或常见文档,保存到 data/uploads
if ($_SERVER['REQUEST_METHOD'] !== 'POST') resp_json(['ok' => false, 'message' => '仅支持POST']);
$file = $_FILES['file'] ?? ($_FILES['image'] ?? null);
if (!$file) resp_json(['ok' => false, 'message' => '未选择文件']);
if (($file['error'] ?? UPLOAD_ERR_OK) !== UPLOAD_ERR_OK) resp_json(['ok' => false, 'message' => '上传错误: ' . ($file['error'] ?? '未知')]);
$name = $file['name'] ?? ('img_' . uniqid());
$ext = strtolower(pathinfo($name, PATHINFO_EXTENSION));
$allow = ['png','jpg','jpeg','gif','webp','pdf','doc','docx','xls','xlsx','ppt','pptx','txt','csv','zip','rar','7z'];
if (!$ext) $ext = 'bin';
if (!in_array($ext, $allow)) resp_json(['ok' => false, 'message' => '不支持的文件类型: ' . $ext]);
$size = $file['size'] ?? 0; if ($size > 30 * 1024 * 1024) resp_json(['ok' => false, 'message' => '文件过大限制30MB']);
$target = $uploadDir . DIRECTORY_SEPARATOR . ('up_' . date('Ymd_His') . '_' . substr(uniqid('', true), -6) . '.' . $ext);
if (!@move_uploaded_file($file['tmp_name'], $target)) resp_json(['ok' => false, 'message' => '保存失败']);
$rel = 'data/uploads/' . basename($target);
$mime = mime_content_type($target);
resp_json(['ok' => true, 'url' => $rel, 'name' => $name, 'mime' => $mime]);
}
default: resp_json(['ok' => false, 'message' => '未知操作']);
}
// 将笔记文本转换为HTML支持 Markdown 图片:![alt](url)),其余文本安全转义
function render_note_content($text) {
if ($text === null) $text = '';
$out = '';
$last = 0;
while (true) {
$img = preg_match('/!\[([^\]]*)\]\(([^\)]+)\)/u', $text, $im, PREG_OFFSET_CAPTURE, $last) ? $im : null;
$lnk = preg_match('/(?<!\!)\[([^\]]+)\]\(([^\)]+)\)/u', $text, $lm, PREG_OFFSET_CAPTURE, $last) ? $lm : null;
$ipos = $img ? $img[0][1] : -1;
$lpos = $lnk ? $lnk[0][1] : -1;
if ($ipos < 0 && $lpos < 0) break;
$nextPos = ($ipos >=0 && ($lpos < 0 || $ipos <= $lpos)) ? $ipos : $lpos;
$out .= nl2br(htmlspecialchars(substr($text, $last, $nextPos - $last), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
if ($nextPos === $ipos) {
$alt = htmlspecialchars($img[1][0], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$urlAdj = adjust_url_for_export($img[2][0]);
$url = htmlspecialchars($urlAdj, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$out .= '<img src="' . $url . '" alt="' . $alt . '" style="max-width:100%">';
$last = $ipos + strlen($img[0][0]);
} else {
$label = htmlspecialchars($lnk[1][0], ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
$urlRaw = $lnk[2][0];
$urlAdj = adjust_url_for_export($urlRaw);
$urlEsc = htmlspecialchars($urlAdj, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
if (is_image_url($urlRaw)) {
$out .= '<img src="' . $urlEsc . '" alt="' . $label . '" style="max-width:100%">';
} else {
$out .= '<a href="' . $urlEsc . '" target="_blank" rel="noopener noreferrer">' . $label . '</a>';
}
$last = $lpos + strlen($lnk[0][0]);
}
}
$out .= nl2br(htmlspecialchars(substr($text, $last), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'));
return $out;
}
function is_image_url($url) {
return preg_match('/\.(png|jpg|jpeg|gif|webp)(\?.*)?$/i', $url) || preg_match('/^data:image\//i', $url);
}
// 导出HTML时将相对路径调整为以导出文件为基准的相对路径
// 例如:原始插入为 data/uploads/xxx.png导出文件位于 data/exports/ 下,
// 浏览器在 exports 页面中引用 uploads 需使用 ../uploads/xxx.png
function adjust_url_for_export($url) {
if (!$url) return $url;
// 只处理本项目的相对路径
if (preg_match('/^data\/uploads\//i', $url)) {
// 去掉可能的查询参数
$parts = explode('?', $url, 2);
return '../uploads/' . substr($parts[0], strlen('data/uploads/')) . (isset($parts[1]) ? ('?' . $parts[1]) : '');
}
return $url;
}
// 提取笔记文本中所有 Markdown 链接/图片的 URL
function extract_note_urls($text) {
$urls = [];
if ($text === null) $text = '';
// 图片
if (preg_match_all('/!\[[^\]]*\]\(([^\)]+)\)/u', $text, $m1)) {
foreach ($m1[1] as $u) { $urls[] = $u; }
}
// 普通链接
if (preg_match_all('/\[[^\]]+\]\(([^\)]+)\)/u', $text, $m2)) {
foreach ($m2[1] as $u) { $urls[] = $u; }
}
return $urls;
}
// 仅清理 data/uploads 下未被任何笔记引用的文件
function cleanup_unused_uploads($projects, $uploadDir) {
try {
$used = [];
foreach ($projects as $p) {
$notes = $p['notes'] ?? [];
foreach ($notes as $n) {
$urls = extract_note_urls($n['content'] ?? '');
foreach ($urls as $u) {
// 只统计本地上传目录的相对路径
$pos = strpos($u, 'data/uploads/');
if ($pos !== false) {
$rel = substr($u, $pos);
// 去掉查询串
$rel = explode('?', $rel)[0];
$base = basename($rel);
if ($base) $used[$base] = true;
}
}
}
}
$deleted = 0;
if (is_dir($uploadDir)) {
$files = @scandir($uploadDir);
foreach ($files as $f) {
if ($f === '.' || $f === '..') continue;
if (!isset($used[$f])) {
$path = $uploadDir . DIRECTORY_SEPARATOR . $f;
if (is_file($path)) { @unlink($path); $deleted++; }
}
}
}
return [ 'deleted' => $deleted, 'kept' => count($used) ];
} catch (Throwable $e) {
return [ 'deleted' => 0, 'error' => $e->getMessage() ];
}
}