ebe191b06d
- 会员改为永久激活方案;云端文件不再拦截非会员;预览弹窗内导出/打印才校验 - 首页最近文件支持本地记录,登录后与云端合并;移除独立会员页与订阅页 - 模板库入库管理:soon_templates 表、Admin 上传 CRUD、/templates 轻量列表与按需下载 Co-authored-by: Cursor <cursoragent@cursor.com>
337 lines
14 KiB
PHP
337 lines
14 KiB
PHP
<?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;
|
|
use Soon\Api\Services\MembershipService;
|
|
|
|
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') {
|
|
$now = date('Y-m-d H:i:s');
|
|
$expires = MembershipService::subscriptionExpiresAt($durationDays);
|
|
$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);
|
|
}
|
|
}
|
|
}
|