## 新增 lib/admin_taxonomy.php - 软件:增删改、排序、启停;新增时自动预置 Bug/功能建议/其他 三个分类 - 分类:在选定软件下增删改、排序、启停 - 状态:改名、改「给用户看的话」、排序、启停;标识不可改(历史数据靠它对应) - 反馈改挂:th_move,可把反馈移到别的软件/分类,跨软件的非法分类会被拒 ## 安全与边界 - 所有动作走 CSRF 校验(沿用现有机制) - 不能删最后一个软件 / 没有分类的软件至少要留一个启用分类 - 有反馈挂着的软件或分类不能删,提示改为停用(停用后前台消失、数据保留) - th_move 加入 fb_admin_is_taxonomy_act 白名单,否则会被「问题不存在」静默吞掉 ## admin.php 接线 - 引入模块 + 动作分发 + tab 白名单加 taxonomy/statuses + 导航两项 - 前置检查放行不依赖 $t 的动作(否则新增软件报「问题不存在」) - 全部问题列表:加软件筛选、分类跟着软件走;修掉三处仍在用旧 fb_statuses()/fb_categories() 的遍历(那会渲染出错) - 详情页加「归属」表单,软件徽章可显示 ## 其他文件 - api.php:状态/分类改数据库;新增 ?sw= / ?software_id= / ?cid= 过滤, 老的 ?category=<slug> 仍可用(在所属软件内解析) - thread.php:详情页显示软件与分类徽章 - config.example.php:移除 categories / statuses,改由数据库管理 ## 修掉两个真实 bug 1. 移除配置后出厂分类兜底只剩 4 个,漏了「同步问题」—— 建库后核对数量才发现 2. th_move 不在白名单,POST 后返回 200 却静默不生效 验证:后台 e2e 29/29;改挂与联动 13/13;PHP 8.5 全部入口 0 弃用; 19 个 PHP 文件语法全过。
271 lines
11 KiB
PHP
271 lines
11 KiB
PHP
<?php
|
||
/**
|
||
* 只读数据接口(供自动化 / AI 分析使用)
|
||
* ------------------------------------------------------------------
|
||
* 需要令牌(config.php 里的 api_token):
|
||
* api.php?token=XXX&action=list
|
||
* api.php?token=XXX&action=thread&id=12
|
||
* api.php?token=XXX&action=export # 全量(默认不含邮箱/手机号)
|
||
* api.php?token=XXX&action=export&contacts=1 # 含联系方式(仅内部使用)
|
||
* api.php?token=XXX&action=digest # 返回 Markdown 摘要,便于阅读
|
||
* api.php?token=XXX&action=stats
|
||
*
|
||
* 通用参数:status / category / since(Unix 时间戳)/ limit
|
||
*/
|
||
require __DIR__ . '/lib/bootstrap.php';
|
||
|
||
function fb_api_json($data, $code = 200) {
|
||
http_response_code($code);
|
||
header('Content-Type: application/json; charset=utf-8');
|
||
header('Cache-Control: no-store');
|
||
echo json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
|
||
exit;
|
||
}
|
||
|
||
$expected = (string)fb_cfg('api_token');
|
||
$got = isset($_GET['token']) ? (string)$_GET['token'] : '';
|
||
if (!empty($_SERVER['HTTP_X_API_TOKEN'])) { $got = (string)$_SERVER['HTTP_X_API_TOKEN']; }
|
||
if ($expected === '' || $expected === 'CHANGE_ME_api_token' || !hash_equals($expected, $got)) {
|
||
fb_api_json(['ok' => false, 'error' => 'invalid token'], 403);
|
||
}
|
||
|
||
$pdo = fb_db();
|
||
$action = isset($_GET['action']) ? (string)$_GET['action'] : 'list';
|
||
$contacts = !empty($_GET['contacts']);
|
||
$limit = isset($_GET['limit']) ? max(1, min(500, (int)$_GET['limit'])) : 100;
|
||
|
||
$statuses = fb_status_keys();
|
||
|
||
/** 过滤条件 */
|
||
function fb_api_where(&$args, $cats, $statuses) {
|
||
$w = [];
|
||
if (!empty($_GET['status']) && isset($statuses[(string)$_GET['status']])) {
|
||
$w[] = 'status = ?'; $args[] = (string)$_GET['status'];
|
||
}
|
||
// 软件:?sw=<slug> 或 ?software_id=<id>
|
||
$swArg = '';
|
||
if (!empty($_GET['sw'])) {
|
||
$sw = fb_software((string)$_GET['sw']);
|
||
if ($sw) { $w[] = 'software_id = ?'; $args[] = (int)$sw['id']; $swArg = 'sw'; }
|
||
} elseif (!empty($_GET['software_id'])) {
|
||
$sw = fb_software((int)$_GET['software_id']);
|
||
if ($sw) { $w[] = 'software_id = ?'; $args[] = (int)$sw['id']; $swArg = 'id'; }
|
||
}
|
||
// 分类:?cid=<id> 优先;兼容老的 ?category=<slug>,在其所属软件内解析
|
||
if (!empty($_GET['cid'])) {
|
||
$c = fb_category((int)$_GET['cid']);
|
||
if ($c) { $w[] = 'category_id = ?'; $args[] = (int)$c['id']; }
|
||
} elseif (!empty($_GET['category'])) {
|
||
$slug = (string)$_GET['category'];
|
||
$swId = 0;
|
||
if (!empty($_GET['sw'])) {
|
||
$sw0 = fb_software((string)$_GET['sw']);
|
||
if ($sw0) { $swId = (int)$sw0['id']; }
|
||
}
|
||
if ($swId <= 0) {
|
||
$d = fb_default_software();
|
||
if ($d) { $swId = (int)$d['id']; }
|
||
}
|
||
$c = fb_category_by_slug($swId, $slug);
|
||
if ($c) { $w[] = 'category_id = ?'; $args[] = (int)$c['id']; }
|
||
}
|
||
if (!empty($_GET['since'])) {
|
||
$w[] = 'updated_at >= ?'; $args[] = (int)$_GET['since'];
|
||
}
|
||
return $w ? (' WHERE ' . implode(' AND ', $w)) : '';
|
||
}
|
||
|
||
/** 主题行 -> 对外结构 */
|
||
function fb_api_thread(array $t, $contacts) {
|
||
$out = [
|
||
'id' => (int)$t['id'],
|
||
'title' => $t['title'],
|
||
'category' => $t['category'],
|
||
'category_id' => isset($t['category_id']) ? (int)$t['category_id'] : null,
|
||
'category_cn' => fb_category_label(isset($t['category_id']) ? $t['category_id'] : 0),
|
||
'software_id' => isset($t['software_id']) ? (int)$t['software_id'] : null,
|
||
'status' => $t['status'],
|
||
'status_cn' => fb_status_label($t['status']),
|
||
'author' => $t['author_name'],
|
||
'reply_count' => (int)$t['reply_count'],
|
||
'created_at' => (int)$t['created_at'],
|
||
'created_time' => date('Y-m-d H:i', (int)$t['created_at']),
|
||
'updated_at' => (int)$t['updated_at'],
|
||
'updated_time' => date('Y-m-d H:i', (int)$t['updated_at']),
|
||
'url' => fb_base_url() . '/thread.php?id=' . (int)$t['id'],
|
||
];
|
||
if (isset($t['body'])) { $out['body'] = $t['body']; }
|
||
if ($contacts) {
|
||
$out['author_email'] = isset($t['author_email']) ? $t['author_email'] : null;
|
||
$out['author_phone'] = isset($t['author_phone']) ? $t['author_phone'] : null;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
function fb_api_reply(array $r, $contacts) {
|
||
$out = [
|
||
'id' => (int)$r['id'],
|
||
'author' => !empty($r['is_admin']) ? '官方' : $r['author_name'],
|
||
'is_official'=> !empty($r['is_admin']),
|
||
'created_at' => (int)$r['created_at'],
|
||
'created_time' => date('Y-m-d H:i', (int)$r['created_at']),
|
||
'body' => $r['body'],
|
||
];
|
||
if ($contacts && !empty($r['author_email'])) { $out['author_email'] = $r['author_email']; }
|
||
return $out;
|
||
}
|
||
|
||
/** 单条附件的对外结构(不含任何本地路径) */
|
||
function fb_api_attach(array $a) {
|
||
$kind = (string)$a['kind'];
|
||
$key = (string)$a['oss_key'];
|
||
$out = [
|
||
'id' => (int)$a['id'],
|
||
'kind' => $kind,
|
||
'name' => isset($a['orig_name']) ? $a['orig_name'] : null,
|
||
'mime' => isset($a['mime']) ? $a['mime'] : null,
|
||
'size' => (int)$a['size'],
|
||
'url' => fb_attach_url($key),
|
||
];
|
||
if ($kind === 'image') {
|
||
$out['thumb'] = fb_attach_thumb_url($key, 640);
|
||
$out['width'] = isset($a['width']) ? (int)$a['width'] : null;
|
||
$out['height'] = isset($a['height']) ? (int)$a['height'] : null;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/** 取主题的全部附件:[$mainList, [reply_id => [..]]] */
|
||
function fb_api_attach_rows(PDO $pdo, $threadId) {
|
||
$st = $pdo->prepare('SELECT * FROM attachments WHERE thread_id = ? ORDER BY seq ASC, id ASC');
|
||
$st->execute([(int)$threadId]);
|
||
$main = [];
|
||
$rep = [];
|
||
foreach ($st->fetchAll() as $r) {
|
||
$a = fb_api_attach($r);
|
||
if (empty($r['reply_id'])) { $main[] = $a; }
|
||
else { $rep[(int)$r['reply_id']][] = $a; }
|
||
}
|
||
return [$main, $rep];
|
||
}
|
||
|
||
/** 拉取一个主题的全部回复 */
|
||
function fb_api_replies(PDO $pdo, $threadId, $contacts, array $attByReply = []) {
|
||
$st = $pdo->prepare('SELECT * FROM replies WHERE thread_id = ? ORDER BY created_at ASC, id ASC');
|
||
$st->execute([(int)$threadId]);
|
||
$out = [];
|
||
foreach ($st->fetchAll() as $r) {
|
||
$item = fb_api_reply($r, $contacts);
|
||
if (!empty($attByReply[(int)$r['id']])) {
|
||
$item['attachments'] = $attByReply[(int)$r['id']];
|
||
}
|
||
$out[] = $item;
|
||
}
|
||
return $out;
|
||
}
|
||
|
||
/* -------------------- 路由 -------------------- */
|
||
|
||
if ($action === 'stats') {
|
||
$byStatus = [];
|
||
foreach ($pdo->query('SELECT status, COUNT(*) c FROM threads GROUP BY status')->fetchAll() as $r) {
|
||
$byStatus[$r['status']] = (int)$r['c'];
|
||
}
|
||
$byCat = [];
|
||
foreach ($pdo->query('SELECT category, COUNT(*) c FROM threads GROUP BY category')->fetchAll() as $r) {
|
||
$byCat[$r['category']] = (int)$r['c'];
|
||
}
|
||
fb_api_json([
|
||
'ok' => true,
|
||
'total' => (int)$pdo->query('SELECT COUNT(*) FROM threads')->fetchColumn(),
|
||
'replies' => (int)$pdo->query('SELECT COUNT(*) FROM replies')->fetchColumn(),
|
||
'attachments'=> (int)$pdo->query('SELECT COUNT(*) FROM attachments')->fetchColumn(),
|
||
'attach_bytes' => fb_attach_total_bytes($pdo),
|
||
'by_status' => $byStatus,
|
||
'by_category'=> $byCat,
|
||
'server_time'=> date('c'),
|
||
]);
|
||
}
|
||
|
||
if ($action === 'thread') {
|
||
$id = isset($_GET['id']) ? (int)$_GET['id'] : 0;
|
||
if ($id <= 0) { fb_api_json(['ok' => false, 'error' => 'id required'], 400); }
|
||
$st = $pdo->prepare('SELECT * FROM threads WHERE id = ?');
|
||
$st->execute([$id]);
|
||
$t = $st->fetch();
|
||
if (!$t) { fb_api_json(['ok' => false, 'error' => 'not found'], 404); }
|
||
list($mainAtt, $repAtt) = fb_api_attach_rows($pdo, $id);
|
||
$data = fb_api_thread($t, $contacts);
|
||
$data['attachments'] = $mainAtt;
|
||
$data['replies'] = fb_api_replies($pdo, $id, $contacts, $repAtt);
|
||
fb_api_json(['ok' => true, 'thread' => $data]);
|
||
}
|
||
|
||
if ($action === 'digest') {
|
||
$args = [];
|
||
$w = fb_api_where($args, $cats, $statuses);
|
||
$sql = 'SELECT * FROM threads' . $w . ' ORDER BY updated_at DESC LIMIT ' . $limit;
|
||
$st = $pdo->prepare($sql);
|
||
$st->execute($args);
|
||
$rows = $st->fetchAll();
|
||
|
||
header('Content-Type: text/markdown; charset=utf-8');
|
||
header('Cache-Control: no-store');
|
||
echo '# ' . fb_cfg('site_name', '轻反馈') . " · 反馈摘要\n\n";
|
||
echo '- 生成时间:' . date('Y-m-d H:i') . "\n";
|
||
echo '- 筛选:状态=' . (isset($_GET['status']) ? $_GET['status'] : '全部')
|
||
. ',分类=' . (isset($_GET['category']) ? $_GET['category'] : '全部') . "\n";
|
||
echo '- 条数:' . count($rows) . "\n\n---\n\n";
|
||
foreach ($rows as $t) {
|
||
echo '## #' . (int)$t['id'] . ' [' . fb_category_label($t['category']) . '] ' . $t['title'] . "\n\n";
|
||
echo '- 状态:' . fb_status_label($t['status']) . "\n";
|
||
echo '- 发起人:' . $t['author_name'] . '(' . date('Y-m-d H:i', (int)$t['created_at']) . ")\n";
|
||
echo '- 链接:' . fb_base_url() . '/thread.php?id=' . (int)$t['id'] . "\n";
|
||
list($mainAtt, ) = fb_api_attach_rows($pdo, $t['id']);
|
||
if ($mainAtt) {
|
||
echo '- 附件:' . count($mainAtt) . " 个\n";
|
||
foreach ($mainAtt as $a) {
|
||
echo ' - [' . $a['kind'] . '] ' . $a['url'] . "\n";
|
||
}
|
||
}
|
||
echo "\n" . $t['body'] . "\n\n";
|
||
foreach (fb_api_replies($pdo, $t['id'], false) as $r) {
|
||
echo '> **' . $r['author'] . '**(' . $r['created_time'] . ")\n>\n";
|
||
echo '> ' . str_replace("\n", "\n> ", $r['body']) . "\n\n";
|
||
}
|
||
echo "---\n\n";
|
||
}
|
||
exit;
|
||
}
|
||
|
||
/* 默认:list / export */
|
||
$args = [];
|
||
$w = fb_api_where($args, $cats, $statuses);
|
||
$st = $pdo->prepare('SELECT * FROM threads' . $w . ' ORDER BY updated_at DESC LIMIT ' . $limit);
|
||
$st->execute($args);
|
||
$rows = $st->fetchAll();
|
||
|
||
$tids = [];
|
||
foreach ($rows as $t) { $tids[] = (int)$t['id']; }
|
||
$counts = $tids ? fb_attach_count_map($pdo, $tids) : [];
|
||
|
||
$threads = [];
|
||
foreach ($rows as $t) {
|
||
$item = fb_api_thread($t, $contacts);
|
||
$item['attachment_count'] = isset($counts[(int)$t['id']]) ? $counts[(int)$t['id']] : 0;
|
||
if ($action === 'export') {
|
||
list($mainAtt, $repAtt) = fb_api_attach_rows($pdo, $t['id']);
|
||
$item['body'] = $t['body'];
|
||
$item['attachments'] = $mainAtt;
|
||
$item['replies'] = fb_api_replies($pdo, $t['id'], $contacts, $repAtt);
|
||
}
|
||
$threads[] = $item;
|
||
}
|
||
|
||
fb_api_json([
|
||
'ok' => true,
|
||
'action' => $action,
|
||
'count' => count($threads),
|
||
'threads' => $threads,
|
||
]);
|