This commit is contained in:
2026-05-08 00:05:51 +08:00
parent 549b706fcc
commit ed70a140a2
40 changed files with 5590 additions and 36 deletions
+64
View File
@@ -0,0 +1,64 @@
<?php
namespace app\controller;
use app\service\AdminAuthService;
use support\Request;
use support\Response;
class AdminController
{
public function landing(Request $request): Response
{
if ((new AdminAuthService())->current($request) !== null) {
return $this->redirect('/admin');
}
return view('admin/landing', [
'archiveCaskUrl' => config('admin.archive_cask_url', ''),
'version' => $this->version(),
]);
}
public function login(Request $request): Response
{
if ((new AdminAuthService())->current($request) !== null) {
return $this->redirect('/admin');
}
return view('admin/login', [
'archiveCaskUrl' => config('admin.archive_cask_url', ''),
'version' => $this->version(),
]);
}
public function dashboard(Request $request): Response
{
$admin = (new AdminAuthService())->current($request);
if ($admin === null) {
return $this->redirect('/admin/login');
}
return view('admin/dashboard', [
'archiveCaskUrl' => config('admin.archive_cask_url', ''),
'admin' => $admin,
'version' => $this->version(),
]);
}
private function redirect(string $location): Response
{
return response('', 302, ['Location' => $location]);
}
private function version(): string
{
$path = base_path('.version');
if (!is_file($path)) {
return 'unknown';
}
$value = trim((string) file_get_contents($path));
return $value !== '' ? $value : 'unknown';
}
}
+129
View File
@@ -0,0 +1,129 @@
<?php
namespace app\controller\Api;
use app\service\AdminAuthService;
use InvalidArgumentException;
use JsonException;
use support\Request;
use support\Response;
use Throwable;
class AdminAuthController
{
public function login(Request $request): Response
{
try {
$payload = $this->payload($request);
$username = trim((string) ($payload['username'] ?? ''));
$password = (string) ($payload['password'] ?? '');
if ($username === '' || $password === '') {
throw new InvalidArgumentException('username and password are required.');
}
$auth = new AdminAuthService();
$user = $auth->authenticate($username, $password);
if ($user === null) {
return $this->jsonResponse([
'code' => 401,
'message' => 'Admin login failed.',
'errors' => ['auth' => 'invalid username or password.'],
], 401);
}
$auth->login($request, $user);
} catch (JsonException $exception) {
return $this->jsonResponse([
'code' => 400,
'message' => 'Invalid JSON body.',
'errors' => ['body' => $exception->getMessage()],
], 400);
} catch (InvalidArgumentException $exception) {
return $this->jsonResponse([
'code' => 422,
'message' => 'Admin login validation failed.',
'errors' => ['auth' => $exception->getMessage()],
], 422);
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Admin login failed.',
'errors' => ['auth' => $exception->getMessage()],
], 500);
}
return $this->jsonResponse([
'code' => 0,
'message' => 'Admin login completed.',
'data' => ['admin' => $user],
], 200);
}
public function logout(Request $request): Response
{
try {
(new AdminAuthService())->logout($request);
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Admin logout failed.',
'errors' => ['auth' => $exception->getMessage()],
], 500);
}
return $this->jsonResponse([
'code' => 0,
'message' => 'Admin logout completed.',
], 200);
}
public function me(Request $request): Response
{
try {
$admin = (new AdminAuthService())->current($request);
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Admin session lookup failed.',
'errors' => ['auth' => $exception->getMessage()],
], 500);
}
if ($admin === null) {
return $this->jsonResponse([
'code' => 401,
'message' => 'Admin session not found.',
], 401);
}
return $this->jsonResponse([
'code' => 0,
'message' => 'Admin session loaded.',
'data' => ['admin' => $admin],
], 200);
}
/**
* @throws JsonException
*/
private function payload(Request $request): array
{
$rawBody = trim($request->rawBody());
if ($rawBody === '') {
return $request->post();
}
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
return is_array($payload) ? $payload : [];
}
private function jsonResponse(array $data, int $status): Response
{
return response(
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
$status,
['Content-Type' => 'application/json']
);
}
}
@@ -0,0 +1,351 @@
<?php
namespace app\controller\Api;
use app\service\AdminAuthService;
use app\service\AdminConsole\AdminDocService;
use app\service\AdminConsole\ArchiveAdminService;
use app\service\AdminConsole\MaintenanceScriptService;
use app\service\AdminConsole\OpenSearchAdminService;
use app\service\AdminUserRepository;
use InvalidArgumentException;
use JsonException;
use support\Request;
use support\Response;
use Throwable;
class AdminConsoleController
{
public function archives(Request $request): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$data = (new ArchiveAdminService())->list(
trim((string) $request->get('query', '')),
(int) $request->get('page', 1),
(int) $request->get('page_size', 20),
);
} catch (Throwable $exception) {
return $this->error(500, 'Archive list lookup failed.', ['archives' => $exception->getMessage()]);
}
return $this->ok('Archive list loaded.', $data);
}
public function archive(Request $request, string $archiveUid): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$archive = (new ArchiveAdminService())->detail($archiveUid);
} catch (Throwable $exception) {
return $this->error(500, 'Archive lookup failed.', ['archive' => $exception->getMessage()]);
}
if ($archive === null) {
return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
}
return $this->ok('Archive loaded.', $archive);
}
public function updateArchive(Request $request, string $archiveUid): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$archive = (new ArchiveAdminService())->update($archiveUid, $this->payload($request));
} catch (JsonException $exception) {
return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
} catch (InvalidArgumentException $exception) {
return $this->error(422, 'Archive update validation failed.', ['archive' => $exception->getMessage()], 422);
} catch (Throwable $exception) {
return $this->error(500, 'Archive update failed.', ['archive' => $exception->getMessage()]);
}
if ($archive === null) {
return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
}
return $this->ok('Archive updated.', $archive);
}
public function deleteArchive(Request $request, string $archiveUid): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$deleted = (new ArchiveAdminService())->delete($archiveUid);
} catch (Throwable $exception) {
return $this->error(500, 'Archive delete failed.', ['archive' => $exception->getMessage()]);
}
if (!$deleted) {
return $this->error(404, 'Archive not found.', ['archive_uid' => $archiveUid], 404);
}
return $this->ok('Archive deleted.', ['archive_uid' => $archiveUid]);
}
public function openSearchStatus(Request $request): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$status = (new OpenSearchAdminService())->status();
} catch (Throwable $exception) {
return $this->error(500, 'OpenSearch status lookup failed.', ['opensearch' => $exception->getMessage()]);
}
return $this->ok('OpenSearch status loaded.', $status);
}
public function openSearchDocuments(Request $request): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$documents = (new OpenSearchAdminService())->documents(
trim((string) $request->get('query', '')),
(int) $request->get('size', 20),
);
} catch (Throwable $exception) {
return $this->error(500, 'OpenSearch document lookup failed.', ['opensearch' => $exception->getMessage()]);
}
return $this->ok('OpenSearch documents loaded.', $documents);
}
public function users(Request $request): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$users = (new AdminUserRepository())->listAll();
} catch (Throwable $exception) {
return $this->error(500, 'Admin users lookup failed.', ['users' => $exception->getMessage()]);
}
return $this->ok('Admin users loaded.', ['items' => array_map(fn (array $user): array => $this->sanitizeUser($user), $users)]);
}
public function createUser(Request $request): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$payload = $this->payload($request);
$username = trim((string) ($payload['username'] ?? ''));
$password = trim((string) ($payload['password'] ?? ''));
$displayName = trim((string) ($payload['display_name'] ?? ''));
if ($username === '' || $password === '') {
throw new InvalidArgumentException('username and password are required.');
}
$repository = new AdminUserRepository();
if ($repository->findAnyByUsername($username)) {
throw new InvalidArgumentException('username already exists.');
}
$user = $repository->create($username, $password, $displayName !== '' ? $displayName : null);
} catch (JsonException $exception) {
return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
} catch (InvalidArgumentException $exception) {
return $this->error(422, 'Admin user creation validation failed.', ['user' => $exception->getMessage()], 422);
} catch (Throwable $exception) {
return $this->error(500, 'Admin user creation failed.', ['user' => $exception->getMessage()]);
}
return $this->ok('Admin user created.', ['user' => $this->sanitizeUser($user)]);
}
public function updateUser(Request $request, int $id): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$payload = $this->payload($request);
$repository = new AdminUserRepository();
if ($repository->findAnyById($id) === null) {
return $this->error(404, 'Admin user not found.', ['id' => $id], 404);
}
$updates = [];
if (array_key_exists('display_name', $payload)) {
$updates['display_name'] = $payload['display_name'];
}
if (array_key_exists('password', $payload)) {
$updates['password'] = $payload['password'];
}
if (array_key_exists('is_active', $payload)) {
$updates['is_active'] = (bool) $payload['is_active'];
}
$user = $repository->updateUser($id, $updates);
} catch (JsonException $exception) {
return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
} catch (Throwable $exception) {
return $this->error(500, 'Admin user update failed.', ['user' => $exception->getMessage()]);
}
return $this->ok('Admin user updated.', ['user' => $this->sanitizeUser($user ?? [])]);
}
public function docs(Request $request): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$docs = (new AdminDocService())->list();
} catch (Throwable $exception) {
return $this->error(500, 'API docs lookup failed.', ['docs' => $exception->getMessage()]);
}
return $this->ok('API docs loaded.', ['items' => $docs]);
}
public function doc(Request $request, string $name): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$doc = (new AdminDocService())->read($name);
} catch (Throwable $exception) {
return $this->error(404, 'API doc not found.', ['doc' => $exception->getMessage()], 404);
}
return $this->ok('API doc loaded.', $doc);
}
public function scripts(Request $request): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
return $this->ok('Maintenance scripts loaded.', ['items' => (new MaintenanceScriptService())->list()]);
}
public function script(Request $request, string $name): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$script = (new MaintenanceScriptService())->describe($name);
} catch (Throwable $exception) {
return $this->error(404, 'Maintenance script not found.', ['script' => $exception->getMessage()], 404);
}
return $this->ok('Maintenance script loaded.', $script);
}
public function runScript(Request $request): Response
{
if ($guard = $this->guard($request)) {
return $guard;
}
try {
$payload = $this->payload($request);
$scriptName = trim((string) ($payload['script_name'] ?? ''));
$args = $payload['args'] ?? [];
if ($scriptName === '') {
throw new InvalidArgumentException('script_name is required.');
}
if (!is_array($args)) {
throw new InvalidArgumentException('args must be an array.');
}
$result = (new MaintenanceScriptService())->run($scriptName, $args);
} catch (JsonException $exception) {
return $this->error(400, 'Invalid JSON body.', ['body' => $exception->getMessage()], 400);
} catch (InvalidArgumentException $exception) {
return $this->error(422, 'Script execution validation failed.', ['script' => $exception->getMessage()], 422);
} catch (Throwable $exception) {
return $this->error(500, 'Script execution failed.', ['script' => $exception->getMessage()]);
}
return $this->ok('Maintenance script finished.', $result);
}
private function guard(Request $request): ?Response
{
return (new AdminAuthService())->current($request) === null
? $this->error(401, 'Admin session not found.', [], 401)
: null;
}
/**
* @throws JsonException
*/
private function payload(Request $request): array
{
$rawBody = trim($request->rawBody());
if ($rawBody === '') {
return $request->post();
}
$payload = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);
return is_array($payload) ? $payload : [];
}
private function ok(string $message, array $data): Response
{
return $this->jsonResponse([
'code' => 0,
'message' => $message,
'data' => $data,
], 200);
}
private function error(int $code, string $message, array $errors = [], int $status = 500): Response
{
return $this->jsonResponse([
'code' => $code,
'message' => $message,
'errors' => $errors,
], $status);
}
private function sanitizeUser(array $user): array
{
unset($user['password_hash']);
return $user;
}
private function jsonResponse(array $data, int $status): Response
{
return response(
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
$status,
['Content-Type' => 'application/json']
);
}
}
+255
View File
@@ -0,0 +1,255 @@
<?php
namespace app\controller\Api;
use app\service\ArchiveRepository;
use support\Response;
use Throwable;
class EvidenceController
{
public function archive(string $archiveUid): Response
{
try {
$archive = (new ArchiveRepository())->findArchive($archiveUid);
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Archive lookup failed.',
'errors' => ['archive' => $exception->getMessage()],
], 500);
}
if ($archive === null) {
return $this->jsonResponse([
'code' => 404,
'message' => 'Archive not found.',
'errors' => ['archive_uid' => $archiveUid],
], 404);
}
$archive['chunk_count'] = is_array($archive['chunks'] ?? null) ? count($archive['chunks']) : 0;
return $this->jsonResponse([
'code' => 0,
'message' => 'Archive loaded.',
'data' => $archive,
], 200);
}
public function chunk(string $chunkUid): Response
{
try {
$chunk = (new ArchiveRepository())->findChunk($chunkUid);
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Chunk lookup failed.',
'errors' => ['chunk' => $exception->getMessage()],
], 500);
}
if ($chunk === null) {
return $this->jsonResponse([
'code' => 404,
'message' => 'Chunk not found.',
'errors' => ['chunk_uid' => $chunkUid],
], 404);
}
return $this->jsonResponse([
'code' => 0,
'message' => 'Chunk loaded.',
'data' => $chunk,
], 200);
}
public function archiveChunks(string $archiveUid): Response
{
try {
$repository = new ArchiveRepository();
$archive = $repository->findArchive($archiveUid);
$chunks = $archive === null ? [] : $repository->findArchiveChunks($archiveUid);
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Archive chunks lookup failed.',
'errors' => ['archive_chunks' => $exception->getMessage()],
], 500);
}
if ($archive === null) {
return $this->jsonResponse([
'code' => 404,
'message' => 'Archive not found.',
'errors' => ['archive_uid' => $archiveUid],
], 404);
}
return $this->jsonResponse([
'code' => 0,
'message' => 'Archive chunks loaded.',
'data' => [
'archive_uid' => $archive['archive_uid'],
'title' => $archive['title'],
'summary' => $archive['summary'],
'source' => $archive['source'],
'author' => $archive['author'],
'year' => $archive['year'],
'series' => $archive['series'],
'tags' => $archive['tags'],
'chunk_count' => count($chunks),
'chunks' => $chunks,
],
], 200);
}
public function evidence(string $chunkUid): Response
{
try {
$chunk = (new ArchiveRepository())->findChunk($chunkUid);
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Evidence lookup failed.',
'errors' => ['evidence' => $exception->getMessage()],
], 500);
}
if ($chunk === null) {
return $this->jsonResponse([
'code' => 404,
'message' => 'Evidence not found.',
'errors' => ['chunk_uid' => $chunkUid],
], 404);
}
$archive = $chunk['archive'];
$pages = $chunk['pages'];
$pageLabel = $this->pageLabel($pages);
return $this->jsonResponse([
'code' => 0,
'message' => 'Evidence loaded.',
'data' => [
'chunk_uid' => $chunk['chunk_uid'],
'archive_uid' => $chunk['archive_uid'],
'title' => $archive['title'] ?? null,
'source' => $archive['source'] ?? null,
'author' => $archive['author'] ?? null,
'year' => $archive['year'] ?? null,
'series' => $archive['series'] ?? null,
'tags' => $archive['tags'] ?? [],
'page_start' => $chunk['page_start'],
'page_end' => $chunk['page_end'],
'pages' => $pages,
'page_label' => $pageLabel,
'citation' => $this->citation($archive, $pageLabel),
'quote' => $chunk['text'],
'chunk' => [
'chunk_index' => $chunk['chunk_index'],
'length' => $chunk['length'],
'embedding_model' => $chunk['embedding_model'],
'embedding_status' => $chunk['embedding_status'],
'search_index_status' => $chunk['search_index_status'],
],
],
], 200);
}
public function archiveEvidence(string $archiveUid): Response
{
try {
$repository = new ArchiveRepository();
$archive = $repository->findArchive($archiveUid);
$chunks = $archive === null ? [] : $repository->findArchiveChunks($archiveUid);
} catch (Throwable $exception) {
return $this->jsonResponse([
'code' => 500,
'message' => 'Archive evidence lookup failed.',
'errors' => ['archive_evidence' => $exception->getMessage()],
], 500);
}
if ($archive === null) {
return $this->jsonResponse([
'code' => 404,
'message' => 'Archive not found.',
'errors' => ['archive_uid' => $archiveUid],
], 404);
}
$evidence = array_map(function (array $chunk): array {
$archive = $chunk['archive'];
$pages = $chunk['pages'];
$pageLabel = $this->pageLabel($pages);
return [
'chunk_uid' => $chunk['chunk_uid'],
'chunk_index' => $chunk['chunk_index'],
'page_start' => $chunk['page_start'],
'page_end' => $chunk['page_end'],
'pages' => $pages,
'page_label' => $pageLabel,
'citation' => $this->citation($archive, $pageLabel),
'quote' => $chunk['text'],
'length' => $chunk['length'],
'embedding_model' => $chunk['embedding_model'],
'embedding_status' => $chunk['embedding_status'],
'search_index_status' => $chunk['search_index_status'],
];
}, $chunks);
return $this->jsonResponse([
'code' => 0,
'message' => 'Archive evidence loaded.',
'data' => [
'archive_uid' => $archive['archive_uid'],
'title' => $archive['title'],
'summary' => $archive['summary'],
'source' => $archive['source'],
'author' => $archive['author'],
'year' => $archive['year'],
'series' => $archive['series'],
'tags' => $archive['tags'],
'chunk_count' => count($evidence),
'evidence' => $evidence,
],
], 200);
}
private function citation(array $archive, string $pageLabel): string
{
$parts = array_values(array_filter([
$archive['title'] ?? null,
$archive['author'] ?? null,
isset($archive['year']) ? (string) $archive['year'] : null,
$pageLabel === '' ? null : $pageLabel,
$archive['source'] ?? null,
], static fn ($value): bool => $value !== null && trim((string) $value) !== ''));
return implode(' | ', $parts);
}
private function pageLabel(array $pages): string
{
if ($pages === []) {
return '';
}
if (count($pages) === 1) {
return 'p. ' . (string) $pages[0];
}
return 'pp. ' . (string) $pages[0] . '-' . (string) $pages[count($pages) - 1];
}
private function jsonResponse(array $data, int $status): Response
{
return response(
json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR),
$status,
['Content-Type' => 'application/json']
);
}
}
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace app\service;
use support\Request;
class AdminAuthService
{
private const SESSION_KEY = 'proofdb_admin_user_id';
public function __construct(private readonly ?AdminUserRepository $users = null)
{
}
public function authenticate(string $username, string $password): ?array
{
$username = trim($username);
if ($username === '' || $password === '') {
return null;
}
$user = $this->users()->findByUsername($username);
if ($user === null || !password_verify($password, $user['password_hash'])) {
return null;
}
unset($user['password_hash']);
return $user;
}
public function login(Request $request, array $user): void
{
$request->session()->set(self::SESSION_KEY, (int) $user['id']);
$this->users()->touchLastLogin((int) $user['id']);
}
public function logout(Request $request): void
{
$request->session()->delete(self::SESSION_KEY);
}
public function current(Request $request): ?array
{
$id = (int) $request->session()->get(self::SESSION_KEY, 0);
if ($id <= 0) {
return null;
}
$user = $this->users()->findById($id);
if ($user === null) {
$request->session()->delete(self::SESSION_KEY);
return null;
}
unset($user['password_hash']);
return $user;
}
private function users(): AdminUserRepository
{
return $this->users ?? new AdminUserRepository();
}
}
@@ -0,0 +1,76 @@
<?php
namespace app\service\AdminConsole;
use RuntimeException;
class AdminDocService
{
public function __construct(private readonly ?MarkdownRenderer $renderer = null)
{
}
public function list(): array
{
$items = [];
foreach (glob(base_path('apidoc/*.md')) ?: [] as $path) {
$name = basename($path);
$content = (string) file_get_contents($path);
$items[] = [
'name' => $name,
'title' => $this->title($content, $name),
];
}
usort($items, fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
return $items;
}
public function read(string $name): array
{
$safeName = basename($name);
$path = base_path('apidoc/' . $safeName);
if (!is_file($path) || pathinfo($path, PATHINFO_EXTENSION) !== 'md') {
throw new RuntimeException('API doc not found.');
}
$content = (string) file_get_contents($path);
return [
'name' => $safeName,
'title' => $this->title($content, $safeName),
'content' => $content,
'html' => $this->renderer()->render($content),
];
}
public function readScriptDoc(string $name): array
{
$safeName = basename($name);
$path = base_path('scriptdoc/' . $safeName);
if (!is_file($path) || pathinfo($path, PATHINFO_EXTENSION) !== 'md') {
throw new RuntimeException('Script doc not found.');
}
$content = (string) file_get_contents($path);
return [
'name' => $safeName,
'title' => $this->title($content, $safeName),
'content' => $content,
'html' => $this->renderer()->render($content),
];
}
private function title(string $content, string $fallback): string
{
if (preg_match('/^#\s+(.+)$/m', $content, $matches)) {
return trim($matches[1]);
}
return $fallback;
}
private function renderer(): MarkdownRenderer
{
return $this->renderer ?? new MarkdownRenderer();
}
}
@@ -0,0 +1,205 @@
<?php
namespace app\service\AdminConsole;
use InvalidArgumentException;
use support\Db;
class ArchiveAdminService
{
public function list(string $query = '', int $page = 1, int $pageSize = 20): array
{
$page = max(1, $page);
$pageSize = min(100, max(1, $pageSize));
$builder = Db::table('archives');
$query = trim($query);
if ($query !== '') {
$like = '%' . $query . '%';
$builder->where(function ($subQuery) use ($like): void {
$subQuery
->orWhere('archive_uid', 'like', $like)
->orWhere('title', 'like', $like)
->orWhere('summary', 'like', $like)
->orWhere('author', 'like', $like)
->orWhere('source', 'like', $like)
->orWhere('series', 'like', $like);
});
}
$total = (clone $builder)->count();
$rows = $builder
->orderByDesc('updated_time')
->offset(($page - 1) * $pageSize)
->limit($pageSize)
->get([
'archive_uid',
'title',
'summary',
'year',
'author',
'source',
'series',
'tags',
'created_time',
'updated_time',
Db::raw('jsonb_array_length(chunks) as chunk_count'),
])
->all();
return [
'items' => array_map(fn (object $row): array => $this->listItem($row), $rows),
'total' => (int) $total,
'page' => $page,
'page_size' => $pageSize,
];
}
public function detail(string $archiveUid): ?array
{
$row = Db::table('archives')->where('archive_uid', $archiveUid)->first();
if (!$row) {
return null;
}
return $this->detailItem($row);
}
public function update(string $archiveUid, array $payload): ?array
{
if (!$this->detail($archiveUid)) {
return null;
}
$updates = [];
foreach (['title', 'summary', 'author', 'source', 'series', 'content', 'raw'] as $field) {
if (array_key_exists($field, $payload)) {
$updates[$field] = $this->nullableText($payload[$field]);
}
}
if (array_key_exists('year', $payload)) {
$year = trim((string) ($payload['year'] ?? ''));
if ($year === '') {
$updates['year'] = null;
} elseif (!preg_match('/^\d{1,4}$/', $year)) {
throw new InvalidArgumentException('year must be empty or a 1-4 digit number.');
} else {
$updates['year'] = (int) $year;
}
}
if (array_key_exists('tags', $payload)) {
$updates['tags'] = json_encode($this->normalizeTags($payload['tags']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if (array_key_exists('metadata', $payload)) {
$updates['metadata'] = json_encode($this->normalizeMetadata($payload['metadata']), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
if ($updates !== []) {
Db::table('archives')->where('archive_uid', $archiveUid)->update($updates);
}
return $this->detail($archiveUid);
}
public function delete(string $archiveUid): bool
{
return (int) Db::table('archives')->where('archive_uid', $archiveUid)->delete() > 0;
}
private function listItem(object $row): array
{
$chunks = $this->decodeJson($row->chunks ?? null, []);
return [
'archive_uid' => (string) $row->archive_uid,
'title' => $row->title,
'summary' => $row->summary,
'year' => $row->year === null ? null : (int) $row->year,
'author' => $row->author,
'source' => $row->source,
'series' => $row->series,
'tags' => $this->decodeJson($row->tags ?? null, []),
'chunk_count' => property_exists($row, 'chunk_count')
? ($row->chunk_count === null ? 0 : (int) $row->chunk_count)
: count(is_array($chunks) ? $chunks : []),
'created_time' => $row->created_time,
'updated_time' => $row->updated_time,
];
}
private function detailItem(object $row): array
{
$data = $this->listItem($row);
$data['metadata'] = $this->decodeJson($row->metadata ?? null, []);
$data['content'] = $row->content;
$data['raw'] = $row->raw;
$data['chunks'] = $this->decodeJson($row->chunks ?? null, []);
return $data;
}
private function normalizeTags(mixed $value): array
{
if (is_array($value)) {
$items = $value;
} else {
$text = trim((string) $value);
if ($text === '') {
return [];
}
$items = preg_split('/[\r\n,]+/', $text) ?: [];
}
$tags = [];
foreach ($items as $item) {
$tag = trim((string) $item);
if ($tag !== '') {
$tags[] = $tag;
}
}
return array_values(array_unique($tags));
}
private function normalizeMetadata(mixed $value): array
{
if (is_array($value)) {
return $value;
}
$text = trim((string) $value);
if ($text === '') {
return [];
}
$decoded = json_decode($text, true);
if (!is_array($decoded)) {
throw new InvalidArgumentException('metadata must be a JSON object or array.');
}
return $decoded;
}
private function nullableText(mixed $value): ?string
{
$text = trim((string) $value);
return $text === '' ? null : $text;
}
private function decodeJson(mixed $value, mixed $fallback): mixed
{
if ($value === null) {
return $fallback;
}
if (is_array($value)) {
return $value;
}
$decoded = json_decode((string) $value, true);
return $decoded === null && json_last_error() !== JSON_ERROR_NONE ? $fallback : $decoded;
}
}
@@ -0,0 +1,148 @@
<?php
namespace app\service\AdminConsole;
use RuntimeException;
class MaintenanceScriptService
{
private const ARG_PATTERN = '/^--[A-Za-z0-9_]+(?:=[A-Za-z0-9._:@\/-]+)?$/';
public function list(): array
{
$docs = new AdminDocService();
$items = [];
foreach ($this->definitions() as $definition) {
$item = $definition;
try {
$doc = $docs->readScriptDoc($definition['doc_name']);
$item['doc_title'] = $doc['title'];
$item['doc_html'] = $doc['html'];
$item['doc_content'] = $doc['content'];
} catch (RuntimeException) {
$item['doc_title'] = null;
$item['doc_html'] = null;
$item['doc_content'] = null;
}
$items[] = $item;
}
return $items;
}
public function describe(string $name): array
{
$definitions = $this->definitions();
if (!isset($definitions[$name])) {
throw new RuntimeException('Script is not allowed.');
}
foreach ($this->list() as $item) {
if ($item['name'] === $name) {
return $item;
}
}
throw new RuntimeException('Script metadata not found.');
}
public function run(string $name, array $args = []): array
{
$definitions = $this->definitions();
if (!isset($definitions[$name])) {
throw new RuntimeException('Script is not allowed.');
}
$script = $definitions[$name];
$scriptPath = base_path('scripts/' . $script['file']);
if (!is_file($scriptPath)) {
throw new RuntimeException('Script file not found.');
}
$safeArgs = [];
foreach ($args as $arg) {
$arg = trim((string) $arg);
if ($arg === '') {
continue;
}
if (!preg_match(self::ARG_PATTERN, $arg)) {
throw new RuntimeException('Only --key=value style arguments are allowed.');
}
$safeArgs[] = $arg;
}
$command = array_merge([PHP_BINARY, $scriptPath], $safeArgs);
$descriptors = [
0 => ['pipe', 'r'],
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
];
$process = proc_open($command, $descriptors, $pipes, base_path());
if (!is_resource($process)) {
throw new RuntimeException('Failed to start script process.');
}
fclose($pipes[0]);
$stdout = (string) stream_get_contents($pipes[1]);
fclose($pipes[1]);
$stderr = (string) stream_get_contents($pipes[2]);
fclose($pipes[2]);
$exitCode = proc_close($process);
return [
'script_name' => $name,
'command' => array_merge(['php', 'scripts/' . $script['file']], $safeArgs),
'exit_code' => $exitCode,
'stdout' => $stdout,
'stderr' => $stderr,
'ok' => $exitCode === 0,
];
}
private function definitions(): array
{
return [
'setup_database' => [
'name' => 'setup_database',
'file' => 'setup_database.php',
'label' => '初始化数据库',
'description' => '创建或补齐 archives、chunks 相关表结构与索引。',
'doc_name' => 'setup_database.md',
'args_hint' => '无参数',
],
'setup_opensearch' => [
'name' => 'setup_opensearch',
'file' => 'setup_opensearch.php',
'label' => '初始化 OpenSearch',
'description' => '创建或补齐 proofdb_chunks 索引与 mapping。',
'doc_name' => 'setup_opensearch.md',
'args_hint' => '无参数',
],
'reindex_opensearch' => [
'name' => 'reindex_opensearch',
'file' => 'reindex_opensearch.php',
'label' => '重建 OpenSearch 索引',
'description' => '把 PostgreSQL 中已向量化的数据重新写入 OpenSearch。',
'doc_name' => 'reindex_opensearch.md',
'args_hint' => '--archive_uid=01...',
],
'backfill_archive_content' => [
'name' => 'backfill_archive_content',
'file' => 'backfill_archive_content.php',
'label' => '回填 archive content',
'description' => '从 raw 或 chunks 回填 archives.content。',
'doc_name' => 'backfill_archive_content.md',
'args_hint' => '--archive_uid=01...',
],
'setup_admin_users' => [
'name' => 'setup_admin_users',
'file' => 'setup_admin_users.php',
'label' => '初始化管理员用户',
'description' => '创建 admin_users 表并写入或更新管理员账号。',
'doc_name' => 'setup_admin_users.md',
'args_hint' => '--username=admin --password=secret',
],
];
}
}
@@ -0,0 +1,185 @@
<?php
namespace app\service\AdminConsole;
class MarkdownRenderer
{
public function render(string $markdown): string
{
$lines = preg_split('/\r\n|\n|\r/', $markdown) ?: [];
$html = [];
$paragraph = [];
$listType = null;
$table = null;
$inCodeBlock = false;
$codeLines = [];
$flushParagraph = function () use (&$paragraph, &$html): void {
if ($paragraph === []) {
return;
}
$text = implode(' ', array_map('trim', $paragraph));
$html[] = '<p>' . $this->renderInline($text) . '</p>';
$paragraph = [];
};
$flushList = function () use (&$listType, &$html): void {
if ($listType !== null) {
$html[] = '</' . $listType . '>';
$listType = null;
}
};
$flushTable = function () use (&$table, &$html): void {
if ($table === null) {
return;
}
$html[] = '<table class="markdown-table"><thead><tr>' .
implode('', array_map(fn (string $cell): string => '<th>' . $this->renderInline($cell) . '</th>', $table['headers'])) .
'</tr></thead><tbody>';
foreach ($table['rows'] as $row) {
$html[] = '<tr>' .
implode('', array_map(fn (string $cell): string => '<td>' . $this->renderInline($cell) . '</td>', $row)) .
'</tr>';
}
$html[] = '</tbody></table>';
$table = null;
};
foreach ($lines as $line) {
if (preg_match('/^```/', $line)) {
$flushParagraph();
$flushList();
$flushTable();
if ($inCodeBlock) {
$html[] = '<pre class="markdown-pre"><code>' . htmlspecialchars(implode("\n", $codeLines), ENT_QUOTES, 'UTF-8') . '</code></pre>';
$codeLines = [];
$inCodeBlock = false;
} else {
$inCodeBlock = true;
}
continue;
}
if ($inCodeBlock) {
$codeLines[] = $line;
continue;
}
$trimmed = trim($line);
if ($trimmed === '') {
$flushParagraph();
$flushList();
$flushTable();
continue;
}
if (preg_match('/^(#{1,6})\s+(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$flushList();
$flushTable();
$level = strlen($matches[1]);
$html[] = sprintf('<h%d>%s</h%d>', $level, $this->renderInline($matches[2]), $level);
continue;
}
if (preg_match('/^>\s?(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$flushList();
$flushTable();
$html[] = '<blockquote>' . $this->renderInline($matches[1]) . '</blockquote>';
continue;
}
if (preg_match('/^---+$/', $trimmed)) {
$flushParagraph();
$flushList();
$flushTable();
$html[] = '<hr>';
continue;
}
if ($this->isTableDelimiter($trimmed) && $table !== null) {
continue;
}
if (str_contains($trimmed, '|')) {
$cells = $this->tableCells($trimmed);
if (count($cells) >= 2) {
$flushParagraph();
$flushList();
if ($table === null) {
$table = ['headers' => $cells, 'rows' => []];
} else {
$table['rows'][] = $cells;
}
continue;
}
}
if (preg_match('/^[-*]\s+(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$flushTable();
if ($listType !== 'ul') {
$flushList();
$listType = 'ul';
$html[] = '<ul>';
}
$html[] = '<li>' . $this->renderInline($matches[1]) . '</li>';
continue;
}
if (preg_match('/^\d+\.\s+(.+)$/', $trimmed, $matches)) {
$flushParagraph();
$flushTable();
if ($listType !== 'ol') {
$flushList();
$listType = 'ol';
$html[] = '<ol>';
}
$html[] = '<li>' . $this->renderInline($matches[1]) . '</li>';
continue;
}
$flushList();
$flushTable();
$paragraph[] = $trimmed;
}
if ($inCodeBlock) {
$html[] = '<pre class="markdown-pre"><code>' . htmlspecialchars(implode("\n", $codeLines), ENT_QUOTES, 'UTF-8') . '</code></pre>';
}
$flushParagraph();
$flushList();
$flushTable();
return implode("\n", $html);
}
private function renderInline(string $text): string
{
$text = htmlspecialchars($text, ENT_QUOTES, 'UTF-8');
$text = preg_replace('/`([^`]+)`/', '<code>$1</code>', $text) ?? $text;
$text = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $text) ?? $text;
$text = preg_replace('/\*([^*]+)\*/', '<em>$1</em>', $text) ?? $text;
$text = preg_replace('/\[(.+?)\]\((.+?)\)/', '<a href="$2" target="_blank" rel="noreferrer">$1</a>', $text) ?? $text;
return $text;
}
private function isTableDelimiter(string $line): bool
{
return (bool) preg_match('/^\|?[\s:-]+\|[\s|:-]*$/', $line);
}
private function tableCells(string $line): array
{
$line = trim($line);
$line = trim($line, '|');
return array_map(static fn (string $cell): string => trim($cell), explode('|', $line));
}
}
@@ -0,0 +1,153 @@
<?php
namespace app\service\AdminConsole;
use app\service\Search\OpenSearchClientFactory;
use support\Db;
use Throwable;
class OpenSearchAdminService
{
public function status(): array
{
$config = config('opensearch.default', []);
$indexName = config('opensearch.indices.chunks', 'proofdb_chunks');
$status = [
'config' => [
'hosts' => $config['hosts'] ?? [],
'ssl_verify' => (bool) ($config['ssl_verify'] ?? true),
'index_name' => $indexName,
],
'database' => [
'archives_total' => (int) Db::table('archives')->count(),
'chunks_total' => (int) Db::table('chunks')->count(),
'embedded_chunks' => (int) Db::table('chunks')->where('embedding_status', 3)->count(),
'indexed_chunks' => (int) Db::table('chunks')->where('search_index_status', 3)->count(),
],
'opensearch' => [
'reachable' => false,
'index_exists' => false,
'cluster_name' => null,
'health' => null,
'docs_count' => 0,
'mapping_fields' => [],
'error' => null,
],
];
try {
$client = (new OpenSearchClientFactory())->make();
$health = $client->cluster()->health();
$status['opensearch']['reachable'] = true;
$status['opensearch']['cluster_name'] = $health['cluster_name'] ?? null;
$status['opensearch']['health'] = $health['status'] ?? null;
$exists = (bool) $client->indices()->exists(['index' => $indexName]);
$status['opensearch']['index_exists'] = $exists;
if ($exists) {
$stats = $client->indices()->stats(['index' => $indexName]);
$mapping = $client->indices()->getMapping(['index' => $indexName]);
$status['opensearch']['docs_count'] = (int) (($stats['_all']['primaries']['docs']['count'] ?? 0));
$status['opensearch']['mapping_fields'] = array_keys($mapping[$indexName]['mappings']['properties'] ?? []);
}
} catch (Throwable $exception) {
$status['opensearch']['error'] = $exception->getMessage();
}
return $status;
}
public function documents(string $query = '', int $size = 20): array
{
$size = min(50, max(1, $size));
$indexName = config('opensearch.indices.chunks', 'proofdb_chunks');
$client = (new OpenSearchClientFactory())->make();
if (!(bool) $client->indices()->exists(['index' => $indexName])) {
return [
'index_name' => $indexName,
'items' => [],
'total' => 0,
];
}
$body = [
'_source' => [
'includes' => [
'chunk_uid',
'archive_uid',
'chunk_index',
'page_start',
'page_end',
'title',
'summary',
'source',
'author',
'year',
'series',
'tags',
'text',
'embedding_model',
'embedding_dimensions',
'created_time',
'updated_time',
],
],
'size' => $size,
'sort' => [
['updated_time' => ['order' => 'desc']],
],
];
$query = trim($query);
if ($query === '') {
$body['query'] = ['match_all' => (object) []];
} else {
$body['query'] = [
'multi_match' => [
'query' => $query,
'fields' => ['text^3', 'title^2', 'summary^2', 'source', 'author', 'tags'],
'type' => 'best_fields',
],
];
}
$response = $client->search([
'index' => $indexName,
'body' => $body,
]);
$hits = $response['hits']['hits'] ?? [];
return [
'index_name' => $indexName,
'total' => (int) (($response['hits']['total']['value'] ?? 0)),
'items' => array_map(function (array $hit): array {
$source = $hit['_source'] ?? [];
$text = trim((string) ($source['text'] ?? ''));
return [
'score' => $hit['_score'] ?? null,
'chunk_uid' => $source['chunk_uid'] ?? ($hit['_id'] ?? null),
'archive_uid' => $source['archive_uid'] ?? null,
'chunk_index' => $source['chunk_index'] ?? null,
'page_start' => $source['page_start'] ?? null,
'page_end' => $source['page_end'] ?? null,
'title' => $source['title'] ?? null,
'summary' => $source['summary'] ?? null,
'source' => $source['source'] ?? null,
'author' => $source['author'] ?? null,
'year' => $source['year'] ?? null,
'series' => $source['series'] ?? null,
'tags' => $source['tags'] ?? [],
'text_preview' => mb_substr($text, 0, 320),
'embedding_model' => $source['embedding_model'] ?? null,
'embedding_dimensions' => $source['embedding_dimensions'] ?? null,
'created_time' => $source['created_time'] ?? null,
'updated_time' => $source['updated_time'] ?? null,
];
}, $hits),
];
}
}
+108
View File
@@ -0,0 +1,108 @@
<?php
namespace app\service;
use support\Db;
class AdminUserRepository
{
public function listAll(): array
{
$rows = Db::table('admin_users')
->orderByDesc('id')
->get()
->all();
return array_map(fn (object $row): array => $this->toArray($row), $rows);
}
public function findByUsername(string $username): ?array
{
$row = Db::table('admin_users')
->where('username', $username)
->where('is_active', true)
->first();
return $row ? $this->toArray($row) : null;
}
public function findById(int $id): ?array
{
$row = Db::table('admin_users')
->where('id', $id)
->where('is_active', true)
->first();
return $row ? $this->toArray($row) : null;
}
public function findAnyById(int $id): ?array
{
$row = Db::table('admin_users')->where('id', $id)->first();
return $row ? $this->toArray($row) : null;
}
public function findAnyByUsername(string $username): ?array
{
$row = Db::table('admin_users')->where('username', $username)->first();
return $row ? $this->toArray($row) : null;
}
public function touchLastLogin(int $id): void
{
Db::table('admin_users')
->where('id', $id)
->update(['last_login_at' => Db::raw('CURRENT_TIMESTAMP')]);
}
public function create(string $username, string $password, ?string $displayName = null): array
{
$id = Db::table('admin_users')->insertGetId([
'username' => $username,
'display_name' => $displayName,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'is_active' => true,
'last_login_at' => null,
]);
return $this->findAnyById((int) $id) ?? [];
}
public function updateUser(int $id, array $fields): ?array
{
$updates = [];
if (array_key_exists('display_name', $fields)) {
$displayName = $fields['display_name'];
$updates['display_name'] = $displayName === null ? null : trim((string) $displayName);
}
if (array_key_exists('password', $fields) && trim((string) $fields['password']) !== '') {
$updates['password_hash'] = password_hash((string) $fields['password'], PASSWORD_DEFAULT);
}
if (array_key_exists('is_active', $fields)) {
$updates['is_active'] = (bool) $fields['is_active'];
}
if ($updates !== []) {
Db::table('admin_users')->where('id', $id)->update($updates);
}
return $this->findAnyById($id);
}
private function toArray(object $row): array
{
return [
'id' => (int) $row->id,
'username' => (string) $row->username,
'display_name' => $row->display_name,
'password_hash' => (string) $row->password_hash,
'is_active' => (bool) $row->is_active,
'last_login_at' => $row->last_login_at,
'created_time' => $row->created_time,
'updated_time' => $row->updated_time,
];
}
}
+160
View File
@@ -75,6 +75,102 @@ class ArchiveRepository
return implode("\n\n", array_map(fn ($chunk): string => (string) $chunk->text, $chunks));
}
public function findChunk(string $chunkUid): ?array
{
$row = Db::table('chunks')
->join('archives', 'chunks.archive_uid', '=', 'archives.archive_uid')
->where('chunks.chunk_uid', $chunkUid)
->first([
'chunks.chunk_uid',
'chunks.archive_uid',
'chunks.chunk_index',
'chunks.page_start',
'chunks.page_end',
'chunks.text',
'chunks.length',
'chunks.embedding_status',
'chunks.embedding_ref',
'chunks.embedding_model',
'chunks.embedding_error',
'chunks.search_index_status',
'chunks.search_index_error',
'archives.title',
'archives.summary',
'archives.year',
'archives.author',
'archives.source',
'archives.series',
'archives.tags',
'archives.metadata',
]);
if (!$row) {
return null;
}
return [
'chunk_uid' => (string) $row->chunk_uid,
'archive_uid' => (string) $row->archive_uid,
'chunk_index' => (int) $row->chunk_index,
'page_start' => $row->page_start === null ? null : (int) $row->page_start,
'page_end' => $row->page_end === null ? null : (int) $row->page_end,
'pages' => $this->pages($row->page_start, $row->page_end),
'text' => (string) $row->text,
'length' => $row->length === null ? null : (int) $row->length,
'embedding_status' => (int) $row->embedding_status,
'embedding_ref' => $this->decodeJson($row->embedding_ref ?? null, null),
'embedding_model' => $row->embedding_model,
'embedding_error' => $row->embedding_error,
'search_index_status' => (int) $row->search_index_status,
'search_index_error' => $row->search_index_error,
'archive' => [
'archive_uid' => (string) $row->archive_uid,
'title' => $row->title,
'summary' => $row->summary,
'year' => $row->year === null ? null : (int) $row->year,
'author' => $row->author,
'source' => $row->source,
'series' => $row->series,
'tags' => $this->decodeJson($row->tags ?? null, []),
'metadata' => $this->decodeJson($row->metadata ?? null, []),
],
];
}
public function findArchiveChunks(string $archiveUid): array
{
$rows = Db::table('chunks')
->join('archives', 'chunks.archive_uid', '=', 'archives.archive_uid')
->where('chunks.archive_uid', $archiveUid)
->orderBy('chunks.chunk_index')
->get([
'chunks.chunk_uid',
'chunks.archive_uid',
'chunks.chunk_index',
'chunks.page_start',
'chunks.page_end',
'chunks.text',
'chunks.length',
'chunks.embedding_status',
'chunks.embedding_ref',
'chunks.embedding_model',
'chunks.embedding_error',
'chunks.search_index_status',
'chunks.search_index_error',
'archives.title',
'archives.summary',
'archives.year',
'archives.author',
'archives.source',
'archives.series',
'archives.tags',
'archives.metadata',
])
->all();
return array_map(fn (object $row): array => $this->chunkRowToArray($row), $rows);
}
public function updateMetadata(string $archiveUid, array $fields, array $aiMeta): void
{
$archive = $this->findArchive($archiveUid);
@@ -136,4 +232,68 @@ class ArchiveRepository
'chunks' => json_decode($archive->chunks ?? '[]', true) ?: [],
];
}
private function chunkRowToArray(object $row): array
{
return [
'chunk_uid' => (string) $row->chunk_uid,
'archive_uid' => (string) $row->archive_uid,
'chunk_index' => (int) $row->chunk_index,
'page_start' => $row->page_start === null ? null : (int) $row->page_start,
'page_end' => $row->page_end === null ? null : (int) $row->page_end,
'pages' => $this->pages($row->page_start, $row->page_end),
'text' => (string) $row->text,
'length' => $row->length === null ? null : (int) $row->length,
'embedding_status' => (int) $row->embedding_status,
'embedding_ref' => $this->decodeJson($row->embedding_ref ?? null, null),
'embedding_model' => $row->embedding_model,
'embedding_error' => $row->embedding_error,
'search_index_status' => (int) $row->search_index_status,
'search_index_error' => $row->search_index_error,
'archive' => [
'archive_uid' => (string) $row->archive_uid,
'title' => $row->title,
'summary' => $row->summary,
'year' => $row->year === null ? null : (int) $row->year,
'author' => $row->author,
'source' => $row->source,
'series' => $row->series,
'tags' => $this->decodeJson($row->tags ?? null, []),
'metadata' => $this->decodeJson($row->metadata ?? null, []),
],
];
}
private function decodeJson(mixed $value, mixed $fallback): mixed
{
if ($value === null) {
return $fallback;
}
if (is_array($value)) {
return $value;
}
if (!is_string($value) || trim($value) === '') {
return $fallback;
}
$decoded = json_decode($value, true);
return $decoded === null && json_last_error() !== JSON_ERROR_NONE ? $fallback : $decoded;
}
private function pages(mixed $pageStart, mixed $pageEnd): array
{
if (!is_numeric($pageStart) || !is_numeric($pageEnd)) {
return array_values(array_filter([$pageStart, $pageEnd], static fn ($value): bool => $value !== null && $value !== ''));
}
$start = (int) $pageStart;
$end = (int) $pageEnd;
if ($end < $start) {
$end = $start;
}
return range($start, $end);
}
}
+63 -2
View File
@@ -70,6 +70,16 @@ class ArticleImportService
}
}
public function normalizeArchiveContentString(string $content): ?string
{
return $this->nullableClean($this->cleanMarkdownPage($content));
}
public function normalizeArchiveRawString(string $content): ?string
{
return $this->nullableClean($content);
}
private function validate(array $payload): array
{
$errors = [];
@@ -182,8 +192,8 @@ class ArticleImportService
'tags' => is_array($payload['tags'] ?? null) ? array_values($payload['tags']) : [],
'summary' => $this->nullableClean($payload['summary'] ?? null),
'metadata' => $payload['metadata'] ?? [],
'content' => $this->nullableClean($payload['content_url'] ?? $payload['content_path'] ?? null),
'raw' => $this->nullableClean($payload['raw_url'] ?? $payload['raw_path'] ?? null),
'content' => $this->normalizedArchiveContent($payload),
'raw' => $this->rawArchiveContent($payload),
];
}
@@ -200,6 +210,57 @@ class ArticleImportService
return $this->pageBlocksFromItems($payload, preg_split('/\R{2,}/u', $payload['content']));
}
private function normalizedArchiveContent(array $payload): ?string
{
if (isset($payload['pages']) && is_array($payload['pages'])) {
$parts = [];
foreach ($payload['pages'] as $page) {
if (!is_array($page) || !isset($page['content']) || !is_string($page['content'])) {
continue;
}
$content = $this->cleanMarkdownPage($page['content']);
if ($content !== '') {
$parts[] = $content;
}
}
return $this->nullableClean(implode("\n\n", $parts));
}
if (isset($payload['paragraphs']) && is_array($payload['paragraphs'])) {
$parts = [];
foreach ($payload['paragraphs'] as $paragraph) {
$content = is_array($paragraph) ? ($paragraph['content'] ?? '') : $paragraph;
if (!is_string($content)) {
continue;
}
$content = $this->clean($content);
if ($content !== '') {
$parts[] = $content;
}
}
return $this->nullableClean(implode("\n\n", $parts));
}
if (isset($payload['content']) && is_string($payload['content'])) {
return $this->normalizeArchiveContentString($payload['content']);
}
return null;
}
private function rawArchiveContent(array $payload): ?string
{
if (isset($payload['content']) && is_string($payload['content'])) {
return $this->normalizeArchiveRawString($payload['content']);
}
return null;
}
private function pageBlocksFromPages(array $payload): array
{
$pageBlocks = [];
@@ -7,6 +7,22 @@ use support\Db;
class ChunkSearchIndexRepository
{
public function resetEmbeddedChunksToPending(?string $archiveUid = null): int
{
$query = Db::table('chunks')
->where('embedding_status', EmbeddingStatus::EMBEDDED);
if ($archiveUid !== null && trim($archiveUid) !== '') {
$query->where('archive_uid', trim($archiveUid));
}
return $query->update([
'search_index_status' => SearchIndexStatus::PENDING,
'search_index_error' => null,
'search_index_updated_at' => null,
]);
}
public function queuePendingArchiveTasks(int $limit): array
{
$statuses = [
@@ -63,6 +79,7 @@ class ChunkSearchIndexRepository
'chunks.created_time',
'chunks.updated_time',
'archives.title',
'archives.summary',
'archives.source',
'archives.author',
'archives.year',
@@ -105,6 +122,7 @@ class ChunkSearchIndexRepository
'page_start' => $row->page_start === null ? null : (int) $row->page_start,
'page_end' => $row->page_end === null ? null : (int) $row->page_end,
'title' => $row->title,
'summary' => $row->summary,
'source' => $row->source,
'author' => $row->author,
'year' => $row->year === null ? null : (int) $row->year,
@@ -16,6 +16,7 @@ class OpenSearchChunkIndex
$index = $this->indexName();
if ($client->indices()->exists(['index' => $index])) {
$this->ensureProperties($client, $index);
return;
}
@@ -64,6 +65,7 @@ class OpenSearchChunkIndex
'page_start' => ['type' => 'integer'],
'page_end' => ['type' => 'integer'],
'title' => $this->textWithKeyword(),
'summary' => ['type' => 'text'],
'source' => $this->textWithKeyword(),
'author' => $this->textWithKeyword(),
'year' => ['type' => 'integer'],
@@ -93,6 +95,31 @@ class OpenSearchChunkIndex
return $this->client ?? (new OpenSearchClientFactory())->make();
}
private function ensureProperties(Client $client, string $index): void
{
$mapping = $client->indices()->getMapping(['index' => $index]);
$existing = $mapping[$index]['mappings']['properties'] ?? [];
$desired = $this->mapping()['mappings']['properties'] ?? [];
$missing = [];
foreach ($desired as $field => $definition) {
if (!array_key_exists($field, $existing)) {
$missing[$field] = $definition;
}
}
if ($missing === []) {
return;
}
$client->indices()->putMapping([
'index' => $index,
'body' => [
'properties' => $missing,
],
]);
}
private function indexName(): string
{
return config('opensearch.indices.chunks', 'proofdb_chunks');
@@ -39,6 +39,7 @@ class OpenSearchSearchService
'fields' => [
'text^4',
'title^3',
'summary^2',
'source^2',
'author^2',
'series^2',
@@ -219,6 +220,7 @@ class OpenSearchSearchService
'page_start' => $source['page_start'] ?? null,
'page_end' => $source['page_end'] ?? null,
'title' => $source['title'] ?? null,
'summary' => $source['summary'] ?? null,
'source' => $source['source'] ?? null,
'author' => $source['author'] ?? null,
'year' => $source['year'] ?? null,
@@ -322,6 +324,7 @@ class OpenSearchSearchService
'page_start',
'page_end',
'title',
'summary',
'source',
'author',
'year',
+835
View File
@@ -0,0 +1,835 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Proof DB 管理面板</title>
<link rel="stylesheet" href="/admin.css">
</head>
<body class="admin-dashboard">
<main class="admin-console page-shell-wide">
<section class="admin-console-shell panel">
<aside class="admin-console-sidebar">
<div class="admin-console-brand">
<div class="eyebrow">Maintenance Console / v<?=htmlspecialchars($version)?></div>
<h1 class="admin-console-title">Proof DB</h1>
<div class="admin-console-subtitle">管理员工作台</div>
</div>
<nav class="admin-console-nav">
<button class="admin-console-nav-item is-active" type="button" data-target="overview">总览</button>
<button class="admin-console-nav-item" type="button" data-target="archives">档案数据库</button>
<button class="admin-console-nav-item" type="button" data-target="opensearch">OpenSearch</button>
<button class="admin-console-nav-item" type="button" data-target="users">用户管理</button>
<button class="admin-console-nav-item" type="button" data-target="apidoc">API 文档</button>
<button class="admin-console-nav-item" type="button" data-target="scripts">维护脚本</button>
</nav>
<div class="admin-console-sidebar-foot">
<div class="metric-label">当前会话</div>
<div class="admin-console-identity"><?=htmlspecialchars($admin['display_name'] ?: $admin['username'])?></div>
<div class="admin-console-identity-sub">@<?=htmlspecialchars($admin['username'])?></div>
</div>
</aside>
<section class="admin-console-main">
<header class="admin-console-header">
<div>
<div class="eyebrow">Administrative Entry</div>
<h2 class="admin-console-header-title">Proof DB 管理面板</h2>
<p class="admin-console-header-copy">在这里维护 archives 表、OpenSearch 状态、管理员账号、API 文档,以及脚本级运维动作。</p>
</div>
<div class="admin-dashboard-actions">
<?php if ($archiveCaskUrl !== ''): ?>
<a class="button" href="<?=htmlspecialchars($archiveCaskUrl)?>">返回 Archive Cask</a>
<?php endif; ?>
<button class="button" id="logout-button" type="button">退出登录</button>
</div>
</header>
<div id="global-message" class="console-message" hidden></div>
<section class="admin-pane is-active" data-pane="overview">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">系统总览</h3>
<div class="admin-dashboard-section-note">集中查看数据库、OpenSearch 和当前版本状态。</div>
</div>
<button class="button" type="button" id="refresh-overview">刷新总览</button>
</div>
<div class="metric-grid admin-console-overview-grid" id="overview-metrics"></div>
<div class="admin-console-two-column">
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">OpenSearch 摘要</h4>
<div class="admin-dashboard-section-note">来自管理员状态 API</div>
</div>
<div class="terminal-block" id="overview-opensearch-terminal">等待加载...</div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">快速入口</h4>
<div class="admin-dashboard-section-note">直接跳到主要维护区块</div>
</div>
<div class="admin-console-quick-actions">
<button class="button" type="button" data-open-pane="archives">管理 archives</button>
<button class="button" type="button" data-open-pane="opensearch">查看 OpenSearch</button>
<button class="button" type="button" data-open-pane="users">管理用户</button>
<button class="button" type="button" data-open-pane="apidoc">查看 APIDOC</button>
<button class="button" type="button" data-open-pane="scripts">执行维护脚本</button>
</div>
</section>
</div>
</section>
<section class="admin-pane" data-pane="archives">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">archives 表管理</h3>
<div class="admin-dashboard-section-note">搜索、查看、编辑和删除档案记录。这里只操作 archives 表本身。</div>
</div>
</div>
<div class="admin-console-workbench">
<section class="admin-dashboard-section panel-soft">
<div class="admin-toolbar">
<input class="text-input admin-toolbar-input" id="archives-query" placeholder="按 archive_uid / title / summary / author / source 搜索">
<button class="button" type="button" id="archives-search">搜索</button>
<button class="button" type="button" id="archives-reload">刷新</button>
</div>
<div class="admin-list" id="archives-list"></div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">档案编辑器</h4>
<div class="admin-dashboard-section-note">选择左侧档案后可编辑。</div>
</div>
<form id="archive-form" class="admin-form-grid">
<label class="field-label" for="archive-uid">archive_uid</label>
<input class="text-input" id="archive-uid" name="archive_uid" readonly>
<label class="field-label" for="archive-title">title</label>
<input class="text-input" id="archive-title" name="title">
<label class="field-label" for="archive-year">year</label>
<input class="text-input" id="archive-year" name="year">
<label class="field-label" for="archive-author">author</label>
<input class="text-input" id="archive-author" name="author">
<label class="field-label" for="archive-source">source</label>
<input class="text-input" id="archive-source" name="source">
<label class="field-label" for="archive-series">series</label>
<input class="text-input" id="archive-series" name="series">
<label class="field-label" for="archive-tags">tags</label>
<textarea class="text-area" id="archive-tags" name="tags" rows="2" placeholder="逗号或换行分隔"></textarea>
<label class="field-label" for="archive-summary">summary</label>
<textarea class="text-area" id="archive-summary" name="summary" rows="5"></textarea>
<label class="field-label" for="archive-metadata">metadata</label>
<textarea class="text-area admin-code-area" id="archive-metadata" name="metadata" rows="8" placeholder='{"key":"value"}'></textarea>
<label class="field-label" for="archive-content">content</label>
<textarea class="text-area admin-code-area" id="archive-content" name="content" rows="10"></textarea>
<label class="field-label" for="archive-raw">raw</label>
<textarea class="text-area admin-code-area" id="archive-raw" name="raw" rows="10"></textarea>
<div class="admin-form-actions">
<button class="button primary" type="submit">保存档案</button>
<button class="button" type="button" id="archive-delete">删除档案</button>
</div>
</form>
</section>
</div>
</section>
<section class="admin-pane" data-pane="opensearch">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">OpenSearch 管理</h3>
<div class="admin-dashboard-section-note">查看集群、索引、数据库侧索引状态,以及索引中的文档粗览。</div>
</div>
<button class="button" type="button" id="opensearch-refresh">刷新状态</button>
</div>
<div class="metric-grid admin-console-opensearch-grid" id="opensearch-metrics"></div>
<section class="admin-dashboard-section panel-soft">
<div class="admin-toolbar">
<input class="text-input admin-toolbar-input" id="opensearch-query" placeholder="按 title / summary / source / author / text 搜索索引文档">
<button class="button" type="button" id="opensearch-search">搜索索引</button>
<button class="button" type="button" id="opensearch-reload-docs">刷新文档</button>
</div>
<div class="admin-list" id="opensearch-documents"></div>
</section>
<div class="admin-console-two-column">
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">OpenSearch 详情</h4>
<div class="admin-dashboard-section-note">主机、索引、mapping 字段等。</div>
</div>
<div class="terminal-block" id="opensearch-terminal">等待加载...</div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">建议动作</h4>
<div class="admin-dashboard-section-note">跳转到脚本面板执行维护脚本。</div>
</div>
<div class="admin-console-quick-actions">
<button class="button" type="button" data-script="setup_opensearch">执行 setup_opensearch</button>
<button class="button" type="button" data-script="reindex_opensearch">执行 reindex_opensearch</button>
</div>
</section>
</div>
</section>
<section class="admin-pane" data-pane="users">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">管理员用户管理</h3>
<div class="admin-dashboard-section-note">创建管理员账号,修改显示名、密码与启用状态。</div>
</div>
</div>
<div class="admin-console-two-column">
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">创建新管理员</h4>
<div class="admin-dashboard-section-note">账号创建后即默认启用。</div>
</div>
<form id="create-user-form" class="admin-form-grid compact">
<label class="field-label" for="new-username">username</label>
<input class="text-input" id="new-username" name="username" required>
<label class="field-label" for="new-display-name">display_name</label>
<input class="text-input" id="new-display-name" name="display_name">
<label class="field-label" for="new-password">password</label>
<input class="text-input" id="new-password" name="password" type="password" required>
<div class="admin-form-actions">
<button class="button primary" type="submit">创建用户</button>
</div>
</form>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">现有管理员</h4>
<div class="admin-dashboard-section-note">留空密码表示不修改。</div>
</div>
<div class="admin-user-list" id="users-list"></div>
</section>
</div>
</section>
<section class="admin-pane" data-pane="apidoc">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">APIDOC 查看</h3>
<div class="admin-dashboard-section-note">浏览 `/apidoc` 中的接口文档。</div>
</div>
</div>
<div class="admin-console-workbench">
<section class="admin-dashboard-section panel-soft">
<div class="admin-list" id="docs-list"></div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title" id="doc-title">文档内容</h4>
<div class="admin-dashboard-section-note" id="doc-name">请选择一份文档。</div>
</div>
<div class="admin-markdown-viewer" id="doc-content">等待加载...</div>
</section>
</div>
</section>
<section class="admin-pane" data-pane="scripts">
<div class="admin-pane-head">
<div>
<h3 class="admin-dashboard-section-title">维护脚本伪终端</h3>
<div class="admin-dashboard-section-note">仅允许执行白名单中的 `scripts/*.php` 维护脚本。</div>
</div>
</div>
<div class="admin-console-two-column">
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">可执行脚本</h4>
<div class="admin-dashboard-section-note">支持 `--key=value` 参数格式。</div>
</div>
<div class="admin-script-list" id="scripts-list"></div>
</section>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title">执行终端</h4>
<div class="admin-dashboard-section-note">脚本 stdout / stderr 会显示在下方。</div>
</div>
<form id="script-form" class="admin-form-grid compact">
<label class="field-label" for="script-select">script_name</label>
<select class="text-input" id="script-select" name="script_name"></select>
<label class="field-label" for="script-args">args</label>
<input class="text-input" id="script-args" name="args" placeholder="--archive_uid=01...">
<div class="admin-form-actions">
<button class="button primary" type="submit">执行脚本</button>
</div>
</form>
<div class="terminal-block" id="script-terminal"><span class="prompt">proofdb-admin$</span> 等待命令...</div>
</section>
</div>
<section class="admin-dashboard-section panel-soft">
<div class="admin-dashboard-section-head">
<h4 class="admin-dashboard-section-title" id="script-doc-title">脚本文档</h4>
<div class="admin-dashboard-section-note" id="script-doc-name">如果该脚本有文档,会显示在这里。</div>
</div>
<div class="admin-markdown-viewer" id="script-doc-content">等待加载...</div>
</section>
</section>
</section>
</section>
</main>
<script>
const state = {
archiveUid: null,
docName: null,
docHtml: null,
scripts: [],
opensearch: null,
selectedScript: null
};
const els = {
message: document.getElementById('global-message'),
overviewMetrics: document.getElementById('overview-metrics'),
overviewTerminal: document.getElementById('overview-opensearch-terminal'),
archivesList: document.getElementById('archives-list'),
archiveForm: document.getElementById('archive-form'),
archiveDelete: document.getElementById('archive-delete'),
opensearchMetrics: document.getElementById('opensearch-metrics'),
opensearchTerminal: document.getElementById('opensearch-terminal'),
opensearchDocuments: document.getElementById('opensearch-documents'),
usersList: document.getElementById('users-list'),
createUserForm: document.getElementById('create-user-form'),
docsList: document.getElementById('docs-list'),
docTitle: document.getElementById('doc-title'),
docName: document.getElementById('doc-name'),
docContent: document.getElementById('doc-content'),
scriptsList: document.getElementById('scripts-list'),
scriptForm: document.getElementById('script-form'),
scriptSelect: document.getElementById('script-select'),
scriptArgs: document.getElementById('script-args'),
scriptTerminal: document.getElementById('script-terminal'),
scriptDocTitle: document.getElementById('script-doc-title'),
scriptDocName: document.getElementById('script-doc-name'),
scriptDocContent: document.getElementById('script-doc-content')
};
function field(form, name) {
return form.elements.namedItem(name);
}
function showMessage(text, kind = 'info') {
if (!text) {
els.message.hidden = true;
els.message.textContent = '';
els.message.className = 'console-message';
return;
}
els.message.hidden = false;
els.message.textContent = text;
els.message.className = `console-message is-${kind}`;
}
function escapeHtml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
async function api(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
'Content-Type': 'application/json',
...(options.headers || {})
}
});
const data = await response.json();
if (!response.ok || data.code !== 0) {
throw new Error(data.errors ? Object.values(data.errors)[0] : data.message || '请求失败');
}
return data.data;
}
function activatePane(name) {
document.querySelectorAll('.admin-console-nav-item').forEach((button) => {
button.classList.toggle('is-active', button.dataset.target === name);
});
document.querySelectorAll('.admin-pane').forEach((pane) => {
pane.classList.toggle('is-active', pane.dataset.pane === name);
});
}
function tokeniseArgs(text) {
const tokens = text.match(/"[^"]*"|'[^']*'|\S+/g) || [];
return tokens.map((token) => token.replace(/^['"]|['"]$/g, ''));
}
function renderMetricCards(metrics) {
return metrics.map((metric) => `
<div class="metric-card">
<div class="metric-label">${escapeHtml(metric.label)}</div>
<div class="metric-value">${escapeHtml(metric.value)}</div>
<div class="metric-subvalue">${escapeHtml(metric.note || '')}</div>
</div>
`).join('');
}
async function loadOpenSearchStatus() {
const data = await api('/api/admin/opensearch/status');
state.opensearch = data;
const metrics = [
{label: 'archives', value: data.database.archives_total, note: 'archives 表记录数'},
{label: 'chunks', value: data.database.chunks_total, note: 'chunks 表记录数'},
{label: 'embedded', value: data.database.embedded_chunks, note: 'embedding_status = 3'},
{label: 'indexed', value: data.database.indexed_chunks, note: 'search_index_status = 3'},
{label: 'index', value: data.config.index_name, note: data.opensearch.index_exists ? '索引已存在' : '索引不存在'},
{label: 'docs.count', value: data.opensearch.docs_count, note: data.opensearch.health || '未获取健康状态'}
];
els.overviewMetrics.innerHTML = renderMetricCards(metrics);
els.opensearchMetrics.innerHTML = renderMetricCards(metrics);
const terminal = [
`hosts: ${(data.config.hosts || []).join(', ') || '[]'}`,
`ssl_verify: ${String(data.config.ssl_verify)}`,
`cluster_name: ${data.opensearch.cluster_name || '-'}`,
`reachable: ${String(data.opensearch.reachable)}`,
`index_exists: ${String(data.opensearch.index_exists)}`,
`health: ${data.opensearch.health || '-'}`,
`mapping_fields: ${(data.opensearch.mapping_fields || []).join(', ') || '-'}`,
data.opensearch.error ? `error: ${data.opensearch.error}` : ''
].filter(Boolean).join('\n');
els.overviewTerminal.textContent = terminal;
els.opensearchTerminal.textContent = terminal;
}
async function loadOpenSearchDocuments() {
const query = document.getElementById('opensearch-query').value.trim();
const data = await api(`/api/admin/opensearch/documents?query=${encodeURIComponent(query)}&size=20`);
if ((data.items || []).length === 0) {
els.opensearchDocuments.innerHTML = '<div class="admin-list-empty">当前没有可展示的索引文档。</div>';
return;
}
els.opensearchDocuments.innerHTML = data.items.map((item) => `
<article class="admin-list-item no-click">
<div class="admin-list-item-head">
<strong>${escapeHtml(item.title || item.chunk_uid)}</strong>
<span>${escapeHtml(item.chunk_uid || '-')}</span>
</div>
<div class="admin-list-item-copy">${escapeHtml(item.text_preview || item.summary || '无预览文本')}</div>
<div class="admin-list-item-meta">
<span>${escapeHtml(item.archive_uid || '-')}</span>
<span>p.${escapeHtml(item.page_start || '-')} - ${escapeHtml(item.page_end || '-')}</span>
<span>${escapeHtml(item.source || '-')}</span>
<span>${escapeHtml(item.embedding_model || '-')}</span>
</div>
</article>
`).join('');
}
async function loadArchives() {
const query = document.getElementById('archives-query').value.trim();
const data = await api(`/api/admin/archives?query=${encodeURIComponent(query)}`);
if (data.items.length === 0) {
els.archivesList.innerHTML = '<div class="admin-list-empty">没有找到档案记录。</div>';
return;
}
els.archivesList.innerHTML = data.items.map((item) => `
<button class="admin-list-item ${state.archiveUid === item.archive_uid ? 'is-active' : ''}" type="button" data-archive="${escapeHtml(item.archive_uid)}">
<div class="admin-list-item-head">
<strong>${escapeHtml(item.title || item.archive_uid)}</strong>
<span>${escapeHtml(item.archive_uid)}</span>
</div>
<div class="admin-list-item-copy">${escapeHtml(item.summary || '无 summary')}</div>
<div class="admin-list-item-meta">
<span>${escapeHtml(item.source || '-')}</span>
<span>${escapeHtml(item.year || '-')}</span>
<span>${escapeHtml(item.chunk_count)} chunks</span>
</div>
</button>
`).join('');
els.archivesList.querySelectorAll('[data-archive]').forEach((button) => {
button.addEventListener('click', async () => {
try {
await loadArchiveDetail(button.dataset.archive);
} catch (error) {
showMessage(error.message || '加载档案详情失败。', 'error');
}
});
});
if (!state.archiveUid && data.items[0]) {
await loadArchiveDetail(data.items[0].archive_uid);
}
}
async function loadArchiveDetail(archiveUid) {
const data = await api(`/api/admin/archives/${encodeURIComponent(archiveUid)}`);
state.archiveUid = archiveUid;
document.querySelectorAll('#archives-list [data-archive]').forEach((item) => {
item.classList.toggle('is-active', item.dataset.archive === archiveUid);
});
field(els.archiveForm, 'archive_uid').value = data.archive_uid || '';
field(els.archiveForm, 'title').value = data.title || '';
field(els.archiveForm, 'year').value = data.year || '';
field(els.archiveForm, 'author').value = data.author || '';
field(els.archiveForm, 'source').value = data.source || '';
field(els.archiveForm, 'series').value = data.series || '';
field(els.archiveForm, 'tags').value = (data.tags || []).join(', ');
field(els.archiveForm, 'summary').value = data.summary || '';
field(els.archiveForm, 'metadata').value = JSON.stringify(data.metadata || {}, null, 2);
field(els.archiveForm, 'content').value = data.content || '';
field(els.archiveForm, 'raw').value = data.raw || '';
}
async function saveArchive(event) {
event.preventDefault();
if (!state.archiveUid) {
showMessage('请先选择一条档案记录。', 'error');
return;
}
let metadata;
try {
const metadataField = field(els.archiveForm, 'metadata');
metadata = metadataField.value.trim() === '' ? {} : JSON.parse(metadataField.value);
} catch (error) {
showMessage('metadata 不是合法 JSON。', 'error');
return;
}
const payload = {
title: field(els.archiveForm, 'title').value,
year: field(els.archiveForm, 'year').value,
author: field(els.archiveForm, 'author').value,
source: field(els.archiveForm, 'source').value,
series: field(els.archiveForm, 'series').value,
tags: field(els.archiveForm, 'tags').value,
summary: field(els.archiveForm, 'summary').value,
metadata,
content: field(els.archiveForm, 'content').value,
raw: field(els.archiveForm, 'raw').value
};
await api(`/api/admin/archives/${encodeURIComponent(state.archiveUid)}`, {
method: 'PATCH',
body: JSON.stringify(payload)
});
showMessage('档案已更新。', 'success');
await loadArchiveDetail(state.archiveUid);
}
async function deleteArchive() {
if (!state.archiveUid) {
showMessage('请先选择一条档案记录。', 'error');
return;
}
if (!window.confirm(`确认删除档案 ${state.archiveUid} 吗?这会级联删除相关 chunks。`)) {
return;
}
await api(`/api/admin/archives/${encodeURIComponent(state.archiveUid)}`, {method: 'DELETE'});
showMessage('档案已删除。', 'success');
state.archiveUid = null;
els.archiveForm.reset();
await loadArchives();
await loadOpenSearchStatus();
}
function renderUsers(users) {
els.usersList.innerHTML = users.map((user) => `
<form class="admin-user-card" data-user-id="${user.id}">
<div class="admin-user-card-head">
<strong>${escapeHtml(user.username)}</strong>
<span>${user.is_active ? '启用中' : '已停用'}</span>
</div>
<label class="field-label">display_name</label>
<input class="text-input" name="display_name" value="${escapeHtml(user.display_name || '')}">
<label class="field-label">new_password</label>
<input class="text-input" type="password" name="password" placeholder="留空则不修改">
<label class="admin-inline-check">
<input type="checkbox" name="is_active" ${user.is_active ? 'checked' : ''}>
<span>is_active</span>
</label>
<div class="admin-form-actions">
<button class="button" type="submit">保存用户</button>
</div>
</form>
`).join('');
els.usersList.querySelectorAll('.admin-user-card').forEach((form) => {
form.addEventListener('submit', async (event) => {
event.preventDefault();
const id = form.dataset.userId;
await api(`/api/admin/users/${id}`, {
method: 'PATCH',
body: JSON.stringify({
display_name: field(form, 'display_name').value,
password: field(form, 'password').value,
is_active: field(form, 'is_active').checked
})
});
showMessage(`用户 ${id} 已更新。`, 'success');
await loadUsers();
});
});
}
async function loadUsers() {
const data = await api('/api/admin/users');
renderUsers(data.items || []);
}
async function createUser(event) {
event.preventDefault();
await api('/api/admin/users', {
method: 'POST',
body: JSON.stringify({
username: field(els.createUserForm, 'username').value.trim(),
display_name: field(els.createUserForm, 'display_name').value.trim(),
password: field(els.createUserForm, 'password').value
})
});
els.createUserForm.reset();
showMessage('管理员用户已创建。', 'success');
await loadUsers();
}
async function loadDocs() {
const data = await api('/api/admin/docs');
els.docsList.innerHTML = data.items.map((doc) => `
<button class="admin-list-item ${state.docName === doc.name ? 'is-active' : ''}" type="button" data-doc="${escapeHtml(doc.name)}">
<div class="admin-list-item-head">
<strong>${escapeHtml(doc.title)}</strong>
<span>${escapeHtml(doc.name)}</span>
</div>
</button>
`).join('');
els.docsList.querySelectorAll('[data-doc]').forEach((button) => {
button.addEventListener('click', () => loadDoc(button.dataset.doc));
});
if (!state.docName && data.items[0]) {
await loadDoc(data.items[0].name);
}
}
async function loadDoc(name) {
const data = await api(`/api/admin/docs/${encodeURIComponent(name)}`);
state.docName = name;
els.docTitle.textContent = data.title;
els.docName.textContent = data.name;
els.docContent.innerHTML = data.html || '';
await loadDocs();
}
async function loadScripts() {
const data = await api('/api/admin/scripts');
state.scripts = data.items || [];
els.scriptSelect.innerHTML = state.scripts.map((script) => `
<option value="${escapeHtml(script.name)}">${escapeHtml(script.label)} (${escapeHtml(script.name)})</option>
`).join('');
els.scriptsList.innerHTML = state.scripts.map((script) => `
<button class="admin-script-card" type="button" data-script-select="${escapeHtml(script.name)}">
<div class="admin-script-card-head">
<strong>${escapeHtml(script.label)}</strong>
<span>${escapeHtml(script.name)}</span>
</div>
<div class="admin-script-card-copy">${escapeHtml(script.description)}</div>
<div class="admin-script-card-meta">
<span>${escapeHtml(script.args_hint)}</span>
<span>${escapeHtml(script.doc_name)}</span>
</div>
</button>
`).join('');
els.scriptsList.querySelectorAll('[data-script-select]').forEach((button) => {
button.addEventListener('click', async () => {
els.scriptSelect.value = button.dataset.scriptSelect;
await loadScriptDoc(button.dataset.scriptSelect);
});
});
if (!state.selectedScript && state.scripts[0]) {
els.scriptSelect.value = state.scripts[0].name;
await loadScriptDoc(state.scripts[0].name);
}
}
async function loadScriptDoc(name) {
const data = await api(`/api/admin/scripts/${encodeURIComponent(name)}`);
state.selectedScript = name;
els.scriptDocTitle.textContent = data.doc_title || data.label || data.name;
els.scriptDocName.textContent = data.doc_name || '暂无文档';
els.scriptDocContent.innerHTML = data.doc_html || '<p>该脚本暂时没有文档。</p>';
}
async function runScript(event) {
event.preventDefault();
const scriptName = els.scriptSelect.value;
const args = tokeniseArgs(els.scriptArgs.value.trim());
const result = await api('/api/admin/scripts/run', {
method: 'POST',
body: JSON.stringify({
script_name: scriptName,
args
})
});
els.scriptTerminal.textContent = [
`$ ${result.command.join(' ')}`,
'',
result.stdout ? `[stdout]\n${result.stdout}` : '',
result.stderr ? `[stderr]\n${result.stderr}` : '',
`exit_code: ${result.exit_code}`
].filter(Boolean).join('\n');
showMessage(`脚本 ${scriptName} 执行完成。`, result.ok ? 'success' : 'error');
await loadOpenSearchStatus();
}
document.querySelectorAll('.admin-console-nav-item').forEach((button) => {
button.addEventListener('click', async () => {
activatePane(button.dataset.target);
showMessage('');
if (button.dataset.target === 'archives') {
await loadArchives();
} else if (button.dataset.target === 'opensearch' || button.dataset.target === 'overview') {
await loadOpenSearchStatus();
if (button.dataset.target === 'opensearch') {
await loadOpenSearchDocuments();
}
} else if (button.dataset.target === 'users') {
await loadUsers();
} else if (button.dataset.target === 'apidoc') {
await loadDocs();
} else if (button.dataset.target === 'scripts') {
await loadScripts();
}
});
});
document.querySelectorAll('[data-open-pane]').forEach((button) => {
button.addEventListener('click', () => {
document.querySelector(`.admin-console-nav-item[data-target="${button.dataset.openPane}"]`)?.click();
});
});
document.querySelectorAll('[data-script]').forEach((button) => {
button.addEventListener('click', async () => {
activatePane('scripts');
await loadScripts();
els.scriptSelect.value = button.dataset.script;
showMessage(`已切换到维护脚本,并选中 ${button.dataset.script}`);
});
});
document.getElementById('logout-button').addEventListener('click', async () => {
await fetch('/api/admin/logout', {method: 'POST'});
window.location.href = '/';
});
document.getElementById('refresh-overview').addEventListener('click', loadOpenSearchStatus);
document.getElementById('opensearch-refresh').addEventListener('click', loadOpenSearchStatus);
document.getElementById('opensearch-search').addEventListener('click', loadOpenSearchDocuments);
document.getElementById('opensearch-reload-docs').addEventListener('click', loadOpenSearchDocuments);
document.getElementById('archives-search').addEventListener('click', loadArchives);
document.getElementById('archives-reload').addEventListener('click', loadArchives);
els.scriptSelect.addEventListener('change', async () => {
try {
await loadScriptDoc(els.scriptSelect.value);
} catch (error) {
showMessage(error.message || '加载脚本文档失败。', 'error');
}
});
els.archiveForm.addEventListener('submit', async (event) => {
try {
await saveArchive(event);
} catch (error) {
showMessage(error.message || '保存档案失败。', 'error');
}
});
els.archiveDelete.addEventListener('click', async () => {
try {
await deleteArchive();
} catch (error) {
showMessage(error.message || '删除档案失败。', 'error');
}
});
els.createUserForm.addEventListener('submit', async (event) => {
try {
await createUser(event);
} catch (error) {
showMessage(error.message || '创建用户失败。', 'error');
}
});
els.scriptForm.addEventListener('submit', async (event) => {
try {
await runScript(event);
} catch (error) {
showMessage(error.message || '脚本执行失败。', 'error');
}
});
(async function bootstrap() {
try {
await loadOpenSearchStatus();
} catch (error) {
showMessage(error.message || '加载总览失败。', 'error');
}
})();
</script>
</body>
</html>
+83
View File
@@ -0,0 +1,83 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Proof DB Admin</title>
<link rel="stylesheet" href="/admin.css">
</head>
<body class="admin-landing">
<main class="admin-landing-page">
<header class="topbar page-shell">
<div class="brand">
<span class="brand-mark" aria-hidden="true"></span>
<span>Proof DB Admin Console</span>
</div>
<div>Version <?=htmlspecialchars($version)?></div>
</header>
<section class="admin-landing-hero page-shell">
<section class="admin-landing-intro panel">
<div>
<div class="eyebrow">Administrative Entry</div>
<h1 class="admin-landing-title">Proof DB</h1><br><h2>档案数据中心</h2>
<p class="lead admin-landing-lead">
档案储存 标签处理 向量存储 全文搜索
</p>
</div>
<div class="admin-landing-meta">
<div>
<div class="metric-label">关于Proof DB</div>
<div class="admin-landing-meta-value">ProofDB是一个专业级的历史档案后端数据中心,集成档案数据库、全文搜索引擎和RAG向量引擎。</div>
</div>
</div>
</section>
<section class="admin-landing-portal panel-soft">
<div class="admin-landing-portal-head">
<span>Access Control</span>
<span>v1</span>
</div>
<div>
<p class="admin-landing-portal-title">选择进入路径</p>
<p class="admin-landing-portal-copy">Tips: PoofDB的Proof是酒精度的意思</p>
</div>
<div class="admin-landing-button-stack">
<a class="button primary <?=$archiveCaskUrl === '' ? 'disabled' : ''?>" href="<?=htmlspecialchars($archiveCaskUrl ?: '#')?>">
<span>返回 Archive Cask</span>
<span class="button-key">BACK</span>
</a>
<a class="button" href="/admin/login">
<span>管理员登录</span>
<span class="button-key">Login</span>
</a>
</div>
<div class="admin-landing-status">
<?php if ($archiveCaskUrl === ''): ?>
<strong>Archive Cask 未配置。</strong> 请在 `.env` 中设置 `ARCHIVE_CASK_URL`。
<?php else: ?>
<strong>Archive Cask 已连接。</strong>
<?php endif; ?>
</div>
</section>
</section>
<footer class="footer page-shell">
<style>
lower {
text-transform: lowercase;
}
</style>
<span>
Proof DB
<lower>by</lower>
<a href="https://laysense.cn/" target="_blank">Shanghai Laysense Information Technology Co. Ltd.</a>
</span>
<span>Admin Surface / v<?=htmlspecialchars($version)?></span>
</footer>
</main>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>管理员登录</title>
<link rel="stylesheet" href="/admin.css">
</head>
<body class="admin-login">
<main class="admin-login-layout page-shell-wide">
<section class="admin-login-intro panel">
<div>
<div class="eyebrow">Protected Access / v<?=htmlspecialchars($version)?></div>
<h1 class="admin-login-title">管理员<br />登录</h1>
<p class="lead admin-login-lead">登入 Proof DB 管理后台。</p>
</div>
<div class="admin-login-facts">
<div>
<div class="metric-label">身份鉴别</div>
<div class="admin-landing-meta-value">内部账号密码</div>
</div>
</div>
</section>
<section class="admin-login-form-shell panel-soft">
<div class="admin-login-form-head">
<p class="admin-landing-portal-title">进入维护面板</p>
<p class="admin-login-form-copy">请使用您的账号和密码进行登录</p>
</div>
<div id="error" class="error-box"></div>
<form id="login-form">
<label class="field-label" for="username">用户名</label>
<input class="text-input" id="username" name="username" autocomplete="username" required>
<label class="field-label" for="password">密码</label>
<input class="text-input" id="password" name="password" type="password" autocomplete="current-password" required>
<div class="admin-login-actions">
<button class="button primary" type="submit">登录</button>
<a class="button" href="/">返回入口</a>
<?php if ($archiveCaskUrl !== ''): ?>
<a class="button" href="<?=htmlspecialchars($archiveCaskUrl)?>">返回 Archive Cask</a>
<?php endif; ?>
</div>
</form>
<div class="admin-login-footnote">如忘记密码请联系数据库管理员。</div>
</section>
</main>
<script>
const form = document.getElementById('login-form');
const errorBox = document.getElementById('error');
form.addEventListener('submit', async (event) => {
event.preventDefault();
errorBox.style.display = 'none';
errorBox.textContent = '';
const payload = {
username: form.username.value.trim(),
password: form.password.value
};
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
const data = await response.json();
if (!response.ok || data.code !== 0) {
throw new Error(data.errors?.auth || data.message || '登录失败。');
}
window.location.href = '/admin';
} catch (error) {
errorBox.textContent = error.message || '登录失败。';
errorBox.style.display = 'block';
}
});
</script>
</body>
</html>