新增管理登录页面

This commit is contained in:
2026-04-14 13:43:16 +08:00
parent 596c7f357f
commit 426d01d99b
6 changed files with 908 additions and 0 deletions
@@ -0,0 +1,147 @@
<?php
/**
* 文件功能:后台隐藏登录控制器
*
* 仅提供站长独立登录入口,登录成功后直接进入后台控制台,
* 不经过聊天室首页与“登录即注册”流程。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\AdminLoginRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\View\View;
/**
* 类功能:处理站长隐藏登录页展示与登录提交。
*/
class AdminAuthController extends Controller
{
/**
* 隐藏登录入口后缀。
*/
private const LOGIN_SUFFIX = 'lkddi';
/**
* 站长账号固定主键。
*/
private const SITE_OWNER_ID = 1;
/**
* 显示站长隐藏登录页面。
*/
public function create(Request $request): View|RedirectResponse
{
// 已通过隐藏入口登录的站长再次访问时,直接回后台首页
if (Auth::id() === self::SITE_OWNER_ID && $request->session()->get('admin_login_via_hidden')) {
return redirect()->route('admin.dashboard');
}
return view('admin.auth.login', [
'loginSuffix' => self::LOGIN_SUFFIX,
]);
}
/**
* 处理站长隐藏登录请求。
*/
public function store(AdminLoginRequest $request): RedirectResponse
{
$validated = $request->validated();
$siteOwner = User::query()->find(self::SITE_OWNER_ID);
// 只有 id=1 的站长账号允许通过该入口进入后台
if (! $siteOwner || $siteOwner->username !== $validated['username']) {
return back()
->withInput($request->safe()->only(['username']))
->withErrors(['username' => '该入口仅限站长账号使用。']);
}
if (! $this->passwordMatches($siteOwner, $validated['password'])) {
return back()
->withInput($request->safe()->only(['username']))
->withErrors(['password' => '账号或密码错误。']);
}
// 若当前已有其他账号占用会话,先退出后再切换为站长会话
if (Auth::check() && Auth::id() !== $siteOwner->id) {
Auth::logout();
}
Auth::login($siteOwner);
$request->session()->regenerate();
$request->session()->put('admin_login_via_hidden', true);
// 复用主登录的会话登记逻辑,保证后台入口也会更新登录痕迹
$this->recordAdminLogin($siteOwner, (string) $request->ip());
return redirect()->route('admin.dashboard')->with('success', '站长后台登录成功。');
}
/**
* 校验站长密码,兼容旧库 MD5 并自动升级为 bcrypt。
*/
private function passwordMatches(User $siteOwner, string $plainPassword): bool
{
try {
if (Hash::check($plainPassword, $siteOwner->password)) {
return true;
}
} catch (\RuntimeException $exception) {
// 旧库非 bcrypt 密码会在这里抛异常,后续继续走 MD5 兼容逻辑
}
if (md5($plainPassword) !== $siteOwner->password) {
return false;
}
// 兼容老密码登录成功后,立即升级为 Laravel 默认哈希
$siteOwner->forceFill([
'password' => Hash::make($plainPassword),
])->save();
return true;
}
/**
* 记录站长通过隐藏入口登录后的访问痕迹。
*/
private function recordAdminLogin(User $siteOwner, string $ip): void
{
// 登录成功后补齐访问次数、IP 与时间,保持与前台登录统计一致
$siteOwner->increment('visit_num');
$siteOwner->update([
'previous_ip' => $siteOwner->last_ip,
'last_ip' => $ip,
'log_time' => now(),
'in_time' => now(),
]);
\App\Models\IpLog::create([
'ip' => $ip,
'sdate' => now(),
'uuname' => $siteOwner->username,
]);
try {
$wechatService = new \App\Services\WechatBot\WechatNotificationService;
$wechatService->notifyAdminOnline($siteOwner);
$wechatService->notifyFriendsOnline($siteOwner);
$wechatService->notifySpouseOnline($siteOwner);
} catch (\Exception $exception) {
// 机器人通知异常不影响站长进入后台,但需要落日志便于排查
Log::error('Hidden admin login notification failed', ['error' => $exception->getMessage()]);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
/**
* 文件功能:站长隐藏登录请求验证器
*
* 仅校验站长登录页提交的账号、密码与验证码字段,
* 不参与聊天室前台“登录即注册”流程。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
/**
* 类功能:校验站长隐藏登录表单。
*/
class AdminLoginRequest extends FormRequest
{
/**
* 判断当前请求是否允许继续处理。
*/
public function authorize(): bool
{
return true;
}
/**
* 获取站长隐藏登录所需的验证规则。
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'username' => ['required', 'string', 'max:255'],
'password' => ['required', 'string', 'min:1'],
'captcha' => ['required', 'captcha'],
];
}
/**
* 获取验证失败时展示的中文提示。
*
* @return array<string, string>
*/
public function messages(): array
{
return [
'username.required' => '必须填写站长账号。',
'password.required' => '必须填写登录密码。',
'password.min' => '登录密码格式不正确。',
'captcha.required' => '必须填写验证码。',
'captcha.captcha' => '验证码不正确。',
];
}
}
+561
View File
@@ -0,0 +1,561 @@
{{-- 文件功能:站长隐藏登录页 --}}
@php
$systemName = \App\Models\SysParam::where('alias', 'sys_name')->value('body') ?? '和平聊吧';
@endphp
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>站长后台入口 - {{ $systemName }}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700;900&family=Noto+Serif+SC:wght@700;900&display=swap" rel="stylesheet">
<style>
:root {
--bg: #fafaf9;
--paper: #f5f1e8;
--ink: #0c0a09;
--ink-soft: #57534e;
--ink-faint: #a8a29e;
--line: #d6d3d1;
--line-strong: #1c1917;
--panel: #111111;
--panel-soft: #1f1f1f;
--gold: #a16207;
--gold-soft: #f4d9a8;
--danger: #b91c1c;
--danger-bg: #fef2f2;
--success: #166534;
--success-bg: #f0fdf4;
--shadow: 0 28px 90px rgba(12, 10, 9, 0.12);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
}
body {
font-family: "DM Sans", "PingFang SC", "Microsoft YaHei", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(161, 98, 7, 0.08), transparent 18%),
linear-gradient(180deg, #fcfbf8 0%, #f7f4ee 100%);
}
.shell {
width: min(1380px, calc(100% - 32px));
margin: 16px auto;
min-height: calc(100vh - 32px);
border: 1px solid var(--line-strong);
background: var(--bg);
box-shadow: var(--shadow);
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(420px, 0.9fr);
position: relative;
overflow: hidden;
}
.shell::before {
content: "";
position: absolute;
inset: 18px;
border: 1px solid rgba(28, 25, 23, 0.08);
pointer-events: none;
}
.hero {
padding: 38px 42px 34px;
border-right: 1px solid var(--line-strong);
display: grid;
grid-template-rows: auto 1fr auto;
gap: 30px;
background:
linear-gradient(180deg, rgba(161, 98, 7, 0.03), transparent 26%),
linear-gradient(90deg, transparent 0, transparent calc(100% - 1px), rgba(28, 25, 23, 0.04) calc(100% - 1px));
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding-bottom: 18px;
border-bottom: 1px solid var(--line);
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
border: 1px solid var(--line-strong);
background: #fff;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.24em;
text-transform: uppercase;
}
.eyebrow::before {
content: "";
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--gold);
}
.system-name {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--ink-soft);
text-align: right;
}
.hero-main {
display: grid;
align-content: center;
gap: 24px;
}
.kicker {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.28em;
text-transform: uppercase;
color: var(--gold);
}
.title {
margin: 0;
font-size: clamp(4rem, 10vw, 8.8rem);
line-height: 0.88;
letter-spacing: -0.06em;
font-weight: 900;
text-transform: uppercase;
}
.title-accent {
display: block;
color: var(--gold);
}
.lead {
max-width: 620px;
margin: 0;
color: var(--ink-soft);
font-size: 18px;
line-height: 1.8;
}
.rule {
width: 92px;
height: 6px;
background: var(--line-strong);
}
.hero-meta {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
border-top: 1px solid var(--line);
padding-top: 22px;
}
.meta-card {
padding: 20px 18px;
background: var(--paper);
border: 1px solid var(--line);
min-height: 152px;
}
.meta-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--ink-soft);
}
.meta-value {
margin-top: 14px;
font-size: clamp(1.9rem, 3vw, 3.2rem);
line-height: 0.95;
font-weight: 900;
color: var(--ink);
}
.meta-copy {
margin-top: 12px;
color: var(--ink-soft);
font-size: 14px;
line-height: 1.8;
}
.panel {
background: var(--panel);
color: #fff;
padding: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.panel-card {
width: min(100%, 470px);
border: 1px solid rgba(255, 255, 255, 0.12);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.015)),
var(--panel);
padding: 28px;
position: relative;
}
.panel-card::before {
content: "";
position: absolute;
inset: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
pointer-events: none;
}
.panel-top {
position: relative;
z-index: 1;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 26px;
}
.panel-kicker {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--gold-soft);
}
.panel-title {
margin: 10px 0 8px;
font-family: "Noto Serif SC", serif;
font-size: 34px;
line-height: 1.12;
font-weight: 900;
}
.panel-copy {
margin: 0;
color: rgba(255, 255, 255, 0.72);
font-size: 15px;
line-height: 1.85;
}
.status-tag {
padding: 10px 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #fff;
white-space: nowrap;
}
.form {
position: relative;
z-index: 1;
display: grid;
gap: 18px;
}
.field {
display: grid;
gap: 8px;
}
.field label {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.06em;
color: rgba(255, 255, 255, 0.76);
}
.field input {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(255, 255, 255, 0.04);
color: #fff;
padding: 16px 18px;
font-size: 16px;
font-weight: 600;
outline: none;
transition: border-color .2s ease, background-color .2s ease, box-shadow .2s ease;
}
.field input::placeholder {
color: rgba(255, 255, 255, 0.36);
}
.field input:focus {
border-color: rgba(244, 217, 168, 0.9);
background: rgba(255, 255, 255, 0.08);
box-shadow: 0 0 0 3px rgba(161, 98, 7, 0.22);
}
.captcha-row {
display: grid;
grid-template-columns: minmax(0, 1fr) 148px;
gap: 12px;
}
.captcha-image {
width: 100%;
height: 56px;
object-fit: cover;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.16);
background: #fff;
}
.message {
padding: 14px 16px;
border: 1px solid;
font-size: 14px;
line-height: 1.75;
}
.message-success {
border-color: rgba(34, 197, 94, 0.34);
background: rgba(22, 101, 52, 0.18);
color: #dcfce7;
}
.message-error {
border-color: rgba(248, 113, 113, 0.34);
background: rgba(127, 29, 29, 0.16);
color: #fecaca;
}
.submit {
width: 100%;
border: 1px solid var(--gold);
background: linear-gradient(180deg, #d5a548 0%, #a16207 100%);
color: #fff;
padding: 17px 18px;
font-size: 15px;
font-weight: 900;
letter-spacing: 0.12em;
text-transform: uppercase;
cursor: pointer;
transition: transform .2s ease, filter .2s ease, box-shadow .2s ease;
box-shadow: 0 18px 28px rgba(161, 98, 7, 0.2);
}
.submit:hover {
transform: translateY(-2px);
filter: saturate(1.03);
box-shadow: 0 22px 34px rgba(161, 98, 7, 0.28);
}
.helper {
position: relative;
z-index: 1;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.12);
color: rgba(255, 255, 255, 0.56);
font-size: 13px;
line-height: 1.8;
}
.helper strong {
color: #fff;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation: none !important;
transition: none !important;
scroll-behavior: auto !important;
}
}
@media (max-width: 1080px) {
.shell {
grid-template-columns: 1fr;
}
.hero {
border-right: 0;
border-bottom: 1px solid var(--line-strong);
}
.hero-meta {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.shell {
width: min(100%, calc(100% - 18px));
margin: 9px auto;
min-height: calc(100vh - 18px);
}
.shell::before {
inset: 10px;
}
.hero,
.panel {
padding: 18px;
}
.hero-main {
gap: 18px;
}
.title {
font-size: clamp(3rem, 20vw, 4.8rem);
}
.panel-card {
padding: 20px;
}
.panel-top,
.captcha-row {
grid-template-columns: 1fr;
display: grid;
}
}
</style>
</head>
<body>
<main class="shell">
<section class="hero">
<header class="topbar">
<div class="eyebrow">Hidden Admin Entry</div>
<div class="system-name">{{ $systemName }}</div>
</header>
<div class="hero-main">
<div class="kicker">Trust / Authority / Private Access</div>
<h1 class="title">
Owner
<span class="title-accent">Console</span>
</h1>
<div class="rule" aria-hidden="true"></div>
<p class="lead">
这是一个独立于聊天室首页的后台登录入口。这里只做站长控制台访问,不承载普通用户登录,不会在登录成功后打开聊天室页面。
</p>
</div>
<div class="hero-meta">
<article class="meta-card">
<div class="meta-label">入口后缀</div>
<div class="meta-value">{{ $loginSuffix }}</div>
<div class="meta-copy">保留隐藏后缀,不在首页暴露。这个入口只用于后台控制台访问。</div>
</article>
<article class="meta-card">
<div class="meta-label">权限约束</div>
<div class="meta-value">id=1</div>
<div class="meta-copy">只有站长主账号可以从这里登录,其他账号即使密码正确也会被拒绝。</div>
</article>
<article class="meta-card">
<div class="meta-label">登录去向</div>
<div class="meta-value">Admin</div>
<div class="meta-copy">验证通过后直接进入后台首页,不跳聊天室大厅,不走“登录即注册”。</div>
</article>
</div>
</section>
<section class="panel">
<div class="panel-card">
<div class="panel-top">
<div>
<div class="panel-kicker">Owner Authentication</div>
<h2 class="panel-title">登录后台控制台</h2>
<p class="panel-copy">输入站长账号、密码和验证码后进入后台。</p>
</div>
<div class="status-tag">Restricted</div>
</div>
<form method="POST" action="{{ route('admin.login.store') }}" class="form">
@csrf
<div class="field">
<label for="username">站长账号</label>
<input id="username" name="username" type="text" value="{{ old('username') }}"
placeholder="请输入 id=1 对应的用户名" autocomplete="username" autofocus required>
</div>
<div class="field">
<label for="password">登录密码</label>
<input id="password" name="password" type="password" placeholder="请输入站长密码"
autocomplete="current-password" required>
</div>
<div class="field">
<label for="captcha">验证码</label>
<div class="captcha-row">
<input id="captcha" name="captcha" type="text" placeholder="输入验证码" required>
<img src="/captcha/default?{{ mt_rand() }}" alt="站长登录验证码" id="captcha-img" class="captcha-image"
onclick="refreshCaptcha()" title="点击刷新验证码">
</div>
</div>
@if (session('success'))
<div class="message message-success" role="status" aria-live="polite">
{{ session('success') }}
</div>
@endif
@if ($errors->any())
<div class="message message-error" role="alert" aria-live="assertive">
<ul style="margin: 0; padding-left: 18px;">
@foreach ($errors->all() as $error)
<li>{{ $error }}</li>
@endforeach
</ul>
</div>
@endif
<button type="submit" class="submit">进入后台控制台</button>
</form>
<div class="helper">
<strong>提示:</strong>点击右侧验证码图片可刷新。如果页面仍显示旧样式,直接强制刷新浏览器缓存即可。
</div>
</div>
</section>
</main>
<script>
/**
* 刷新验证码图片,避免浏览器缓存旧图。
*/
function refreshCaptcha() {
document.getElementById('captcha-img').src = '/captcha/default?' + Math.random();
}
</script>
</body>
</html>
+10
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Admin\AdminAuthController;
use App\Http\Controllers\AdminCommandController;
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ChangelogController;
@@ -15,6 +16,11 @@ use Illuminate\Support\Facades\Route;
// 聊天室首页 (即登录/注册页面)
Route::get('/', function () {
if (Auth::check()) {
// 通过隐藏入口登录的站长,首页统一回到后台而不是聊天室大厅
if (Auth::id() === 1 && session('admin_login_via_hidden')) {
return redirect()->route('admin.dashboard');
}
return redirect()->route('rooms.index');
}
@@ -24,6 +30,10 @@ Route::get('/', function () {
return view('index', compact('rooms'));
})->name('home');
// 站长隐藏登录入口(仅 id=1 可使用,成功后直接进入后台控制台)
Route::get('/lkddi', [AdminAuthController::class, 'create'])->name('admin.login');
Route::post('/lkddi', [AdminAuthController::class, 'store'])->name('admin.login.store');
// 处理登录/自动注册请求
Route::post('/login', [AuthController::class, 'login'])->name('login.post');
+15
View File
@@ -45,6 +45,21 @@ class ChatControllerTest extends TestCase
$this->assertEquals(1, Redis::hexists("room:{$room->id}:users", $user->username));
}
/**
* 测试主干默认聊天室页面不会渲染虚拟形象挂载点和配置。
*/
public function test_room_view_does_not_render_avatar_widget_or_config_by_default(): void
{
$room = Room::create(['room_name' => 'avguard']);
$user = User::factory()->create();
$response = $this->actingAs($user)->get(route('chat.room', $room->id));
$response->assertOk();
$response->assertDontSee('chat-avatar-widget');
$response->assertDontSee('chatAvatarWidget');
}
/**
* 测试用户可以发送普通文本消息。
*/
@@ -0,0 +1,115 @@
<?php
/**
* 文件功能:站长隐藏登录功能测试
*
* 覆盖隐藏登录页访问、站长登录成功、非站长拒绝登录
* 以及通过隐藏入口登录后首页不再回到聊天室大厅等核心场景。
*
* @author ChatRoom Laravel
*
* @version 1.0.0
*/
namespace Tests\Feature\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
/**
* 类功能:验证站长隐藏登录入口的行为。
*/
class AdminAuthControllerTest extends TestCase
{
use RefreshDatabase;
/**
* 测试前注册验证码校验桩,避免依赖真实验证码生成。
*/
protected function setUp(): void
{
parent::setUp();
Validator::extend('captcha', function () {
return true;
});
}
/**
* 验证隐藏登录页可以正常打开。
*/
public function test_hidden_admin_login_page_can_be_opened(): void
{
$response = $this->get('/lkddi');
$response->assertOk()
->assertSee('站长后台入口')
->assertSee('/lkddi');
}
/**
* 验证 id=1 站长可以通过隐藏入口登录并进入后台首页。
*/
public function test_site_owner_can_login_via_hidden_admin_entry(): void
{
$siteOwner = User::factory()->create([
'id' => 1,
'username' => 'site-owner',
'password' => Hash::make('secret-owner'),
]);
$response = $this->post('/lkddi', [
'username' => 'site-owner',
'password' => 'secret-owner',
'captcha' => '1234',
]);
$response->assertRedirect(route('admin.dashboard'));
$response->assertSessionHas('admin_login_via_hidden', true);
$this->assertAuthenticatedAs($siteOwner);
}
/**
* 验证非 id=1 账号即使密码正确,也不能使用隐藏入口。
*/
public function test_non_site_owner_cannot_login_via_hidden_admin_entry(): void
{
User::factory()->create([
'username' => 'manager',
'password' => Hash::make('secret-manager'),
]);
$response = $this->from('/lkddi')->post('/lkddi', [
'username' => 'manager',
'password' => 'secret-manager',
'captcha' => '1234',
]);
$response->assertRedirect('/lkddi');
$response->assertSessionHasErrors('username');
$this->assertGuest();
}
/**
* 验证通过隐藏入口登录的站长访问首页时不会被送去聊天室大厅。
*/
public function test_hidden_admin_login_keeps_homepage_redirecting_to_dashboard(): void
{
$siteOwner = User::factory()->create([
'id' => 1,
'username' => 'site-owner',
'password' => Hash::make('secret-owner'),
]);
$this->actingAs($siteOwner)->withSession([
'admin_login_via_hidden' => true,
]);
$response = $this->get('/');
$response->assertRedirect(route('admin.dashboard'));
}
}