Files
laoyang cede838b5e FeedLite 初始提交:通用轻量级反馈系统(PHP + SQLite,零依赖)
- 前台:问题列表 / 详情 / 发布与回复(趣味问答验证码、蜜罐、IP 限流)
- 后台:先审后显、站点设置(站点标题/描述、附件存储、OSS 参数)、操作日志
- 附件:关闭 / 本机硬盘 / 阿里云 OSS 浏览器直传(ECS 实例 RAM 角色)三种模式
- 品牌资源:全套图标(favicon.ico / SVG / PNG 各尺寸 / manifest)
- 安全:CSRF、session 加固、.htaccess 屏蔽敏感文件、口令仅存哈希
- 配置:config.example.php 为模板,config.php 不入库
2026-09-28 20:34:37 +08:00

246 lines
9.5 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
/**
* 只读数据接口(供自动化 / 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;
$cats = fb_categories();
$statuses = fb_statuses();
/** 过滤条件 */
function fb_api_where(&$args, $cats, $statuses) {
$w = [];
if (!empty($_GET['status']) && isset($statuses[(string)$_GET['status']])) {
$w[] = 'status = ?'; $args[] = (string)$_GET['status'];
}
if (!empty($_GET['category']) && isset($cats[(string)$_GET['category']])) {
$w[] = 'category = ?'; $args[] = (string)$_GET['category'];
}
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_cn' => fb_category_label($t['category']),
'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,
]);