重构 monorepo 并完善网页端订阅与首页体验

- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构
- 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐
- 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作
- 新增管理后台、支付与云端文件 API,更新 README 与项目规范

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
24kycj
2026-06-08 18:17:39 +08:00
parent 5814b7bc0e
commit 88c6ce8ccc
511 changed files with 189528 additions and 22804 deletions
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Middleware;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Core\Jwt;
/**
* 管理员鉴权:JWT 校验 + 数据库 role 检查。
*/
final class AdminAuth
{
public static function require(): int
{
$token = Auth::bearerFromGlobals();
if ($token === '') {
Json::fail('unauthorized', '需要管理员令牌', 401);
}
$payload = Jwt::decode($token);
if ($payload === null) {
Json::fail('unauthorized', '令牌无效或已过期', 401);
}
if (($payload['typ'] ?? '') !== 'access') {
Json::fail('unauthorized', '需要访问令牌', 401);
}
$uid = (int)($payload['sub'] ?? 0);
if ($uid <= 0) {
Json::fail('unauthorized', '令牌主体错误', 401);
}
$stmt = Db::pdo()->prepare('SELECT role, status FROM users WHERE id = :id');
$stmt->execute(['id' => $uid]);
$u = $stmt->fetch();
if (!$u || $u['status'] === 'disabled' || $u['role'] !== 'admin') {
Json::fail('forbidden', '需要管理员权限', 403);
}
return $uid;
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Middleware;
use Soon\Api\Core\Db;
use Soon\Api\Core\Json;
use Soon\Api\Core\Jwt;
/**
* 普通用户 JWT 鉴权。
*/
final class Auth
{
public static function bearerFromGlobals(): string
{
$h = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
if ($h === '' && function_exists('apache_request_headers')) {
$headers = apache_request_headers();
$h = $headers['Authorization'] ?? '';
}
if ($h === '' || stripos($h, 'Bearer ') !== 0) {
return '';
}
return trim(substr($h, 7));
}
public static function require(): array
{
$token = self::bearerFromGlobals();
if ($token === '') {
Json::fail('unauthorized', '缺少访问令牌', 401);
}
$payload = Jwt::decode($token);
if ($payload === null) {
Json::fail('unauthorized', '令牌无效或已过期', 401);
}
if (($payload['typ'] ?? '') !== 'access') {
Json::fail('unauthorized', '需要访问令牌', 401);
}
$uid = (int)($payload['sub'] ?? 0);
if ($uid <= 0) {
Json::fail('unauthorized', '令牌主体错误', 401);
}
$stmt = Db::pdo()->prepare('SELECT id, email, role, admin_level, status FROM users WHERE id = :id');
$stmt->execute(['id' => $uid]);
$user = $stmt->fetch();
if (!$user || $user['status'] === 'disabled') {
Json::fail('unauthorized', '账号不存在或已停用', 401);
}
$out = [
'id' => (int)$user['id'],
'email' => $user['email'],
'role' => $user['role'],
];
if ($user['role'] === 'admin') {
$level = (string)($user['admin_level'] ?? 'full');
$out['admin_level'] = $level === 'ops' ? 'ops' : 'full';
}
return $out;
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace Soon\Api\Middleware;
use Soon\Api\Core\Config;
use Soon\Api\Core\Json;
/**
* 基于文件桶的简单限速。
* 规则来自 config.rate_limits
* ['admin/*' => ['capacity' => 60, 'window' => 60], 'POST /api/v1/auth/login' => [...]]
*/
final class RateLimit
{
public static function dir(): string
{
$dir = SOON_SERVER_ROOT . '/storage/cache/rate';
if (!is_dir($dir)) {
@mkdir($dir, 0775, true);
}
return $dir;
}
public static function keyFor(string $method, string $path, ?int $userId): string
{
if ($userId !== null) {
return 'u' . $userId;
}
$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
return 'ip' . md5($ip);
}
public static function matchRule(string $method, string $path, array $rules): ?array
{
$needle = $method . ' ' . $path;
foreach ($rules as $pattern => $rule) {
if ($pattern === 'admin/*' && str_starts_with($path, '/api/admin/')) {
return $rule;
}
if (str_contains($pattern, ' ')) {
if ($pattern === $needle) {
return $rule;
}
continue;
}
$regex = '#^' . str_replace('\*', '.*', preg_quote($pattern, '#')) . '$#';
if (preg_match($regex, $path)) {
return $rule;
}
}
return null;
}
public static function check(string $routeKey, string $method, string $path, ?int $userId): void
{
$rules = Config::get('rate_limits', []);
$rule = self::matchRule($method, $path, $rules);
if ($rule === null && $routeKey === 'admin/*') {
$rule = $rules['admin/*'] ?? null;
}
if ($rule === null) {
return;
}
$capacity = (int)($rule['capacity'] ?? 60);
$window = (int)($rule['window'] ?? 60);
$bucket = self::dir() . '/' . md5($routeKey . ':' . self::keyFor($method, $path, $userId)) . '.json';
$now = time();
$data = ['start' => $now, 'count' => 0];
if (is_file($bucket)) {
$raw = json_decode((string)file_get_contents($bucket), true);
if (is_array($raw) && isset($raw['start'], $raw['count'])) {
if ($now - (int)$raw['start'] < $window) {
$data = $raw;
}
}
}
$data['count']++;
@file_put_contents($bucket, json_encode($data));
if ($data['count'] > $capacity) {
Json::fail('rate_limited', '请求过于频繁,请稍后再试', 429);
}
}
}