重构 monorepo 并完善网页端订阅与首页体验
- 迁移为 frontend-web、frontend-electron、backend-web 与 docker 部署结构 - 网页端:订阅门禁二次弹窗、套餐/支付组件化、顶栏分组对齐 - 首页:最近文件与模板库布局优化,缩略图对齐,下载与删除操作 - 新增管理后台、支付与云端文件 API,更新 README 与项目规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Soon\Api\Admin\Controllers;
|
||||
|
||||
use Soon\Api\Core\Db;
|
||||
use Soon\Api\Core\Json;
|
||||
use Soon\Api\Services\AdminPermission;
|
||||
use Soon\Api\Services\AuditService;
|
||||
|
||||
final class UsersController
|
||||
{
|
||||
public function list(int $adminId): void
|
||||
{
|
||||
$page = max(1, (int)($_GET['page'] ?? 1));
|
||||
$size = max(1, min(200, (int)($_GET['size'] ?? 20)));
|
||||
$offset = ($page - 1) * $size;
|
||||
$q = trim((string)($_GET['q'] ?? ''));
|
||||
$baseSql = 'SELECT u.id, u.email, u.role, u.admin_level, u.status, u.created_at, '
|
||||
. '(SELECT p.name FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
|
||||
. 'WHERE s.user_id = u.id AND s.status = \'active\' AND s.expires_at > NOW() '
|
||||
. 'ORDER BY s.expires_at DESC LIMIT 1) AS plan_name '
|
||||
. 'FROM users u';
|
||||
$pdo = Db::pdo();
|
||||
if ($q !== '') {
|
||||
$like = '%' . $q . '%';
|
||||
$stmt = $pdo->prepare($baseSql . ' WHERE u.email LIKE :q ORDER BY u.id DESC LIMIT :lim OFFSET :off');
|
||||
$stmt->bindValue('q', $like);
|
||||
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$countStmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE email LIKE :q');
|
||||
$countStmt->execute(['q' => $like]);
|
||||
$count = (int)$countStmt->fetchColumn();
|
||||
} else {
|
||||
$stmt = $pdo->prepare($baseSql . ' ORDER BY u.id DESC LIMIT :lim OFFSET :off');
|
||||
$stmt->bindValue('lim', $size, \PDO::PARAM_INT);
|
||||
$stmt->bindValue('off', $offset, \PDO::PARAM_INT);
|
||||
$stmt->execute();
|
||||
$count = (int)$pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
|
||||
}
|
||||
AuditService::log($adminId, 'users.list', 'users', ['page' => $page, 'size' => $size, 'q' => $q]);
|
||||
Json::ok(['items' => $stmt->fetchAll(), 'total' => $count, 'page' => $page, 'size' => $size]);
|
||||
}
|
||||
|
||||
public function create(int $adminId): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$email = trim((string)($body['email'] ?? ''));
|
||||
$password = (string)($body['password'] ?? '');
|
||||
$role = (string)($body['role'] ?? 'user');
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
Json::fail('bad_request', '邮箱格式不正确', 400);
|
||||
}
|
||||
if (strlen($password) < 8) {
|
||||
Json::fail('bad_request', '密码至少 8 位', 400);
|
||||
}
|
||||
if (!in_array($role, ['user', 'admin'], true)) {
|
||||
Json::fail('bad_request', 'role 不合法', 400);
|
||||
}
|
||||
if ($role === 'admin') {
|
||||
AdminPermission::requireFull($adminId);
|
||||
}
|
||||
$adminLevel = null;
|
||||
if ($role === 'admin') {
|
||||
$adminLevel = (string)($body['admin_level'] ?? 'ops');
|
||||
if (!in_array($adminLevel, ['full', 'ops'], true)) {
|
||||
Json::fail('bad_request', 'admin_level 不合法', 400);
|
||||
}
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$dup = $pdo->prepare('SELECT id FROM users WHERE email = :e');
|
||||
$dup->execute(['e' => $email]);
|
||||
if ($dup->fetch()) {
|
||||
Json::fail('conflict', '邮箱已存在', 409);
|
||||
}
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
$stmt = $pdo->prepare(
|
||||
'INSERT INTO users (email, password_hash, role, admin_level, status, created_at) '
|
||||
. 'VALUES (:e, :h, :r, :lv, \'active\', :ts)'
|
||||
);
|
||||
$stmt->execute([
|
||||
'e' => $email,
|
||||
'h' => $hash,
|
||||
'r' => $role,
|
||||
'lv' => $adminLevel,
|
||||
'ts' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$id = (int)$pdo->lastInsertId();
|
||||
AuditService::log($adminId, 'users.create', 'user:' . $id, ['email' => $email, 'role' => $role]);
|
||||
Json::ok(['id' => $id, 'email' => $email, 'role' => $role]);
|
||||
}
|
||||
|
||||
public function detail(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$user = self::findUser($pdo, $id);
|
||||
if ($user === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
|
||||
$subStmt = $pdo->prepare(
|
||||
'SELECT s.id AS subscription_id, s.plan_id, s.status, s.started_at, s.expires_at, p.name AS plan_name, p.code AS plan_code '
|
||||
. 'FROM subscriptions s JOIN plans p ON p.id = s.plan_id '
|
||||
. 'WHERE s.user_id = :uid ORDER BY (s.status = \'active\') DESC, s.id DESC LIMIT 1'
|
||||
);
|
||||
$subStmt->execute(['uid' => $id]);
|
||||
$subscription = $subStmt->fetch() ?: null;
|
||||
|
||||
$usageStmt = $pdo->prepare(
|
||||
'SELECT COUNT(*) AS files_count, COALESCE(SUM(size), 0) AS storage_bytes '
|
||||
. 'FROM soon_files WHERE user_id = :uid AND deleted_at IS NULL'
|
||||
);
|
||||
$usageStmt->execute(['uid' => $id]);
|
||||
$usage = $usageStmt->fetch() ?: ['files_count' => 0, 'storage_bytes' => 0];
|
||||
|
||||
$orderStmt = $pdo->prepare(
|
||||
'SELECT id, order_no, amount_cents, status, channel, created_at FROM pay_orders '
|
||||
. 'WHERE user_id = :uid ORDER BY id DESC LIMIT 5'
|
||||
);
|
||||
$orderStmt->execute(['uid' => $id]);
|
||||
$recentOrders = $orderStmt->fetchAll();
|
||||
|
||||
AuditService::log($adminId, 'users.detail', 'user:' . $id);
|
||||
Json::ok([
|
||||
'user' => $user,
|
||||
'subscription' => $subscription,
|
||||
'usage' => [
|
||||
'files_count' => (int)$usage['files_count'],
|
||||
'storage_bytes' => (int)$usage['storage_bytes'],
|
||||
],
|
||||
'recent_orders' => $recentOrders,
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
$user = self::findUser($pdo, $id);
|
||||
if ($user === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
$body = Json::readBody();
|
||||
$email = array_key_exists('email', $body) ? trim((string)$body['email']) : null;
|
||||
$role = array_key_exists('role', $body) ? (string)$body['role'] : null;
|
||||
$adminLevel = array_key_exists('admin_level', $body) ? (string)$body['admin_level'] : null;
|
||||
|
||||
if ($email !== null) {
|
||||
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||
Json::fail('bad_request', '邮箱格式不正确', 400);
|
||||
}
|
||||
$dup = $pdo->prepare('SELECT id FROM users WHERE email = :e AND id != :id');
|
||||
$dup->execute(['e' => $email, 'id' => $id]);
|
||||
if ($dup->fetch()) {
|
||||
Json::fail('conflict', '邮箱已被使用', 409);
|
||||
}
|
||||
}
|
||||
if ($role !== null && !in_array($role, ['user', 'admin'], true)) {
|
||||
Json::fail('bad_request', 'role 不合法', 400);
|
||||
}
|
||||
if ($role === 'user' && $user['role'] === 'admin') {
|
||||
self::assertAdminRemovable($pdo, $id);
|
||||
}
|
||||
if ($role === 'admin' || $user['role'] === 'admin' || $adminLevel !== null) {
|
||||
AdminPermission::requireFull($adminId);
|
||||
}
|
||||
if ($adminLevel !== null && !in_array($adminLevel, ['full', 'ops'], true)) {
|
||||
Json::fail('bad_request', 'admin_level 不合法', 400);
|
||||
}
|
||||
if ($role !== null && $role !== 'admin' && $adminLevel !== null) {
|
||||
Json::fail('bad_request', '仅管理员账号可设置 admin_level', 400);
|
||||
}
|
||||
|
||||
$sets = [];
|
||||
$params = ['id' => $id];
|
||||
if ($email !== null) {
|
||||
$sets[] = 'email = :e';
|
||||
$params['e'] = $email;
|
||||
}
|
||||
if ($role !== null) {
|
||||
$sets[] = 'role = :r';
|
||||
$params['r'] = $role;
|
||||
if ($role === 'user') {
|
||||
$sets[] = 'admin_level = NULL';
|
||||
}
|
||||
}
|
||||
if ($adminLevel !== null) {
|
||||
$sets[] = 'admin_level = :lv';
|
||||
$params['lv'] = $adminLevel;
|
||||
}
|
||||
if ($sets === []) {
|
||||
Json::fail('bad_request', '无更新字段', 400);
|
||||
}
|
||||
$pdo->prepare('UPDATE users SET ' . implode(', ', $sets) . ' WHERE id = :id')->execute($params);
|
||||
AuditService::log($adminId, 'users.update', 'user:' . $id, [
|
||||
'email' => $email,
|
||||
'role' => $role,
|
||||
'admin_level' => $adminLevel,
|
||||
]);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
|
||||
public function delete(int $adminId, int $id): void
|
||||
{
|
||||
if ($id === $adminId) {
|
||||
Json::fail('bad_request', '不能删除自己的账号', 400);
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$user = self::findUser($pdo, $id);
|
||||
if ($user === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
if ($user['role'] === 'admin') {
|
||||
AdminPermission::requireFull($adminId);
|
||||
self::assertAdminRemovable($pdo, $id);
|
||||
}
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('UPDATE soon_files SET deleted_at = :ts WHERE user_id = :u AND deleted_at IS NULL')
|
||||
->execute(['ts' => date('Y-m-d H:i:s'), 'u' => $id]);
|
||||
$pdo->prepare('DELETE FROM subscriptions WHERE user_id = :u')->execute(['u' => $id]);
|
||||
$pdo->prepare('DELETE FROM users WHERE id = :id')->execute(['id' => $id]);
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
AuditService::log($adminId, 'users.delete', 'user:' . $id, ['email' => $user['email']]);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
|
||||
public function setPassword(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
if (self::findUser($pdo, $id) === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
$password = (string)(Json::readBody()['password'] ?? '');
|
||||
if (strlen($password) < 8) {
|
||||
Json::fail('bad_request', '密码至少 8 位', 400);
|
||||
}
|
||||
$hash = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);
|
||||
$pdo->prepare('UPDATE users SET password_hash = :h WHERE id = :id')->execute(['h' => $hash, 'id' => $id]);
|
||||
AuditService::log($adminId, 'users.setPassword', 'user:' . $id);
|
||||
Json::ok(['id' => $id]);
|
||||
}
|
||||
|
||||
public function setSubscription(int $adminId, int $id): void
|
||||
{
|
||||
$pdo = Db::pdo();
|
||||
if (self::findUser($pdo, $id) === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
$body = Json::readBody();
|
||||
$planId = (int)($body['plan_id'] ?? 0);
|
||||
$days = (int)($body['days'] ?? 0);
|
||||
if ($planId <= 0) {
|
||||
Json::fail('bad_request', 'plan_id 必填', 400);
|
||||
}
|
||||
$planStmt = $pdo->prepare('SELECT * FROM plans WHERE id = :id');
|
||||
$planStmt->execute(['id' => $planId]);
|
||||
$plan = $planStmt->fetch();
|
||||
if (!$plan) {
|
||||
Json::fail('not_found', '套餐不存在', 404);
|
||||
}
|
||||
$durationDays = $days > 0 ? $days : (int)$plan['duration_days'];
|
||||
$pdo->beginTransaction();
|
||||
try {
|
||||
$pdo->prepare('UPDATE subscriptions SET status = \'expired\' WHERE user_id = :u AND status = \'active\'')
|
||||
->execute(['u' => $id]);
|
||||
if ($plan['code'] !== 'free' && $durationDays > 0) {
|
||||
$now = date('Y-m-d H:i:s');
|
||||
$expires = date('Y-m-d H:i:s', time() + $durationDays * 86400);
|
||||
$pdo->prepare(
|
||||
'INSERT INTO subscriptions (user_id, plan_id, status, started_at, expires_at) '
|
||||
. 'VALUES (:u, :p, \'active\', :sa, :ea)'
|
||||
)->execute(['u' => $id, 'p' => $planId, 'sa' => $now, 'ea' => $expires]);
|
||||
}
|
||||
$pdo->commit();
|
||||
} catch (\Throwable $e) {
|
||||
if ($pdo->inTransaction()) {
|
||||
$pdo->rollBack();
|
||||
}
|
||||
throw $e;
|
||||
}
|
||||
AuditService::log($adminId, 'users.setSubscription', 'user:' . $id, [
|
||||
'plan_id' => $planId,
|
||||
'days' => $durationDays,
|
||||
]);
|
||||
Json::ok(['id' => $id, 'plan_id' => $planId]);
|
||||
}
|
||||
|
||||
public function setStatus(int $adminId, int $id): void
|
||||
{
|
||||
$body = Json::readBody();
|
||||
$status = (string)($body['status'] ?? '');
|
||||
if (!in_array($status, ['active', 'disabled'], true)) {
|
||||
Json::fail('bad_request', 'status 必须为 active 或 disabled', 400);
|
||||
}
|
||||
if ($id === $adminId && $status === 'disabled') {
|
||||
Json::fail('bad_request', '不能停用自己的账号', 400);
|
||||
}
|
||||
$pdo = Db::pdo();
|
||||
$user = self::findUser($pdo, $id);
|
||||
if ($user === null) {
|
||||
Json::fail('not_found', '用户不存在', 404);
|
||||
}
|
||||
if ($status === 'disabled' && $user['role'] === 'admin') {
|
||||
self::assertAdminRemovable($pdo, $id);
|
||||
}
|
||||
$pdo->prepare('UPDATE users SET status = :s WHERE id = :id')->execute(['s' => $status, 'id' => $id]);
|
||||
AuditService::log($adminId, 'users.setStatus', 'user:' . $id, ['status' => $status]);
|
||||
Json::ok(['id' => $id, 'status' => $status]);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed>|null */
|
||||
private static function findUser(\PDO $pdo, int $id): ?array
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT id, email, role, admin_level, status, created_at FROM users WHERE id = :id');
|
||||
$stmt->execute(['id' => $id]);
|
||||
$row = $stmt->fetch();
|
||||
return $row ?: null;
|
||||
}
|
||||
|
||||
private static function assertAdminRemovable(\PDO $pdo, int $excludeId): void
|
||||
{
|
||||
$stmt = $pdo->prepare('SELECT COUNT(*) FROM users WHERE role = \'admin\' AND status = \'active\' AND id != :id');
|
||||
$stmt->execute(['id' => $excludeId]);
|
||||
if ((int)$stmt->fetchColumn() < 1) {
|
||||
Json::fail('bad_request', '至少保留一名活跃管理员', 400);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user