Files
FeedLite/api.php
T
laoyang 55d2dfa46b 后台管理界面:软件/分类/状态可配,反馈可改挂归属 (#1)
## 新增 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 文件语法全过。
2026-11-01 12:03:11 +08:00

271 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
/**
* 只读数据接口(供自动化 / 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,
]);