diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php b/app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php
new file mode 100644
index 0000000..5c32685
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/Admin/Auth/CaptchaController.php
@@ -0,0 +1,25 @@
+create();
+
+ return ApiResponse::success([
+ 'captcha_key' => $payload['captcha_key'],
+ 'image_base64' => $payload['image_base64'],
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php
new file mode 100644
index 0000000..9aa378d
--- /dev/null
+++ b/app/Http/Controllers/Api/V1/Admin/Auth/LoginController.php
@@ -0,0 +1,88 @@
+lotteryLocale();
+
+ /** @var array{account:string,password:string,captcha_key:string,captcha_code:string} $data */
+ $data = validator($request->all(), [
+ 'account' => ['required', 'string', 'min:2', 'max:64', 'regex:/^[a-zA-Z0-9._-]+$/u'],
+ 'password' => ['required', 'string', 'max:256'],
+ 'captcha_key' => ['required', 'string', 'uuid'],
+ 'captcha_code' => ['required', 'string', 'max:32'],
+ ], [], [
+ 'account' => 'account',
+ 'password' => 'password',
+ 'captcha_key' => 'captcha_key',
+ 'captcha_code' => 'captcha_code',
+ ])->validate();
+
+ if (! $captcha->verify($data['captcha_key'], $data['captcha_code'])) {
+ return ApiResponse::error(
+ trans('admin.invalid_captcha', [], $locale),
+ ErrorCode::AdminCaptchaInvalid->value,
+ null,
+ 422,
+ );
+ }
+
+ $normalizedAccount = Str::lower(trim($data['account']));
+
+ /** @var AdminUser|null $admin */
+ $admin = AdminUser::query()->where('username', $normalizedAccount)->first();
+
+ $passwordOk = $admin !== null && Hash::check($data['password'], $admin->password);
+
+ if (! $passwordOk) {
+ /** 统一措辞,弱化枚举用户 */
+ return ApiResponse::error(
+ trans('admin.invalid_credentials', [], $locale),
+ ErrorCode::AdminCredentialsInvalid->value,
+ null,
+ 401,
+ );
+ }
+
+ if ((int) $admin->status !== 0) {
+ return ApiResponse::error(
+ trans('admin.account_disabled', [], $locale),
+ ErrorCode::AdminAccountDisabled->value,
+ null,
+ 403,
+ );
+ }
+
+ $plainToken = $admin->createToken('admin-api')->plainTextToken;
+
+ $admin->forceFill(['last_login_at' => now()])->save();
+
+ return ApiResponse::success([
+ 'token' => $plainToken,
+ 'token_type' => 'Bearer',
+ 'admin' => [
+ 'id' => $admin->id,
+ 'username' => $admin->username,
+ 'nickname' => $admin->name,
+ 'email' => $admin->email,
+ ],
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/Api/V1/Admin/PingController.php b/app/Http/Controllers/Api/V1/Admin/PingController.php
index 3b8d794..71febb7 100644
--- a/app/Http/Controllers/Api/V1/Admin/PingController.php
+++ b/app/Http/Controllers/Api/V1/Admin/PingController.php
@@ -7,7 +7,7 @@ use App\Support\ApiResponse;
use Illuminate\Http\JsonResponse;
/**
- * 无需登录(当前 admin 中间件为直通):确认 `/api/v1/admin` 前缀可达。
+ * Bearer Token 必填({@see EnsureAdminApi} + Sanctum);确认 `/api/v1/admin` 鉴权链路可达。
* 路由:GET /api/v1/admin/ping
*/
class PingController extends Controller
diff --git a/app/Http/Middleware/EnsureAdminApi.php b/app/Http/Middleware/EnsureAdminApi.php
index 0742e60..71e0621 100644
--- a/app/Http/Middleware/EnsureAdminApi.php
+++ b/app/Http/Middleware/EnsureAdminApi.php
@@ -2,19 +2,43 @@
namespace App\Http\Middleware;
+use App\Lottery\ErrorCode;
+use App\Models\AdminUser;
+use App\Support\ApiResponse;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
- * 后台 API 守卫:后续在此注入 Sanctum(admin_users)与权限校验。
- *
- * 当前为占位直通,勿在生产暴露敏感 admin 路由前长期保持空实现。
+ * 后台 API:`auth:sanctum` 之后执行,校验为 {@link AdminUser} 且未禁用;
+ * 上下文可通过 `$request->lotteryAdmin()` 读取。
*/
class EnsureAdminApi
{
public function handle(Request $request, Closure $next): Response
{
+ $user = $request->user();
+
+ if (! $user instanceof AdminUser) {
+ return ApiResponse::error(
+ trans('admin.unauthenticated', [], $request->lotteryLocale()),
+ ErrorCode::AdminUnauthenticated->value,
+ null,
+ 401,
+ );
+ }
+
+ if ((int) $user->status !== 0) {
+ return ApiResponse::error(
+ trans('admin.account_disabled', [], $request->lotteryLocale()),
+ ErrorCode::AdminAccountDisabled->value,
+ null,
+ 403,
+ );
+ }
+
+ $request->attributes->set('lottery_admin', $user);
+
return $next($request);
}
}
diff --git a/app/Lottery/ErrorCode.php b/app/Lottery/ErrorCode.php
index c117725..856cbf8 100644
--- a/app/Lottery/ErrorCode.php
+++ b/app/Lottery/ErrorCode.php
@@ -8,6 +8,18 @@ namespace App\Lottery;
*/
enum ErrorCode: int
{
+ /** 管理端 API:未登录或 Token 无效 */
+ case AdminUnauthenticated = 8110;
+
+ /** 管理端登录:验证码错误或过期 */
+ case AdminCaptchaInvalid = 8111;
+
+ /** 管理端登录:账号或密码不匹配(对外统一措辞) */
+ case AdminCredentialsInvalid = 8112;
+
+ /** 管理端登录:账号已禁用 */
+ case AdminAccountDisabled = 8113;
+
/** 表单 / Query 校验失败(见 ValidationException → 422) */
case ValidationFailed = 9001;
diff --git a/app/Models/AdminUser.php b/app/Models/AdminUser.php
index 3ef991d..3c20a01 100644
--- a/app/Models/AdminUser.php
+++ b/app/Models/AdminUser.php
@@ -4,14 +4,17 @@ namespace App\Models;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
+use Laravel\Sanctum\HasApiTokens;
class AdminUser extends Authenticatable
{
+ use HasApiTokens;
use Notifiable;
protected $table = 'admin_users';
protected $fillable = [
+ 'username',
'name',
'email',
'password',
diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php
index b58ce0d..2b9490f 100644
--- a/app/Providers/AppServiceProvider.php
+++ b/app/Providers/AppServiceProvider.php
@@ -2,8 +2,11 @@
namespace App\Providers;
+use App\Models\AdminUser;
use App\Models\Player;
+use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
+use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
@@ -36,5 +39,20 @@ class AppServiceProvider extends ServiceProvider
return (string) ($this->attributes->get('lottery_locale')
?? config('lottery.locales.fallback', 'en'));
});
+
+ Request::macro('lotteryAdmin', function (): ?AdminUser {
+ /** @var Request $this */
+ $v = $this->attributes->get('lottery_admin');
+
+ return $v instanceof AdminUser ? $v : null;
+ });
+
+ RateLimiter::for('admin-auth-captcha', function (Request $request) {
+ return Limit::perMinute(45)->by($request->ip());
+ });
+
+ RateLimiter::for('admin-auth-login', function (Request $request) {
+ return Limit::perMinute(15)->by($request->ip());
+ });
}
}
diff --git a/app/Services/AdminCaptchaService.php b/app/Services/AdminCaptchaService.php
new file mode 100644
index 0000000..983c6a6
--- /dev/null
+++ b/app/Services/AdminCaptchaService.php
@@ -0,0 +1,137 @@
+randomCode();
+ $key = (string) Str::uuid();
+
+ Cache::put(
+ self::PREFIX.$key,
+ $this->digest($code),
+ now()->addSeconds(self::TTL_SECONDS),
+ );
+
+ $svg = $this->renderSvg($code);
+
+ return [
+ 'captcha_key' => $key,
+ 'image_svg' => $svg,
+ 'image_base64' => base64_encode($svg),
+ ];
+ }
+
+ public function verify(?string $captchaKey, ?string $captchaInput): bool
+ {
+ if ($captchaKey === null || $captchaKey === ''
+ || $captchaInput === null || trim($captchaInput) === '') {
+ return false;
+ }
+
+ $digest = Cache::pull(self::PREFIX.$captchaKey);
+ if ($digest === null) {
+ return false;
+ }
+
+ $guess = strtolower(trim($captchaInput));
+
+ return hash_equals($digest, $this->digest($guess));
+ }
+
+ private function digest(string $normalizedCode): string
+ {
+ return hash_hmac(
+ 'sha256',
+ $normalizedCode,
+ (string) config('app.key'),
+ );
+ }
+
+ private function randomCode(int $length = 4): string
+ {
+ $out = '';
+ $max = strlen(self::CHARSET) - 1;
+ for ($i = 0; $i < $length; $i++) {
+ $out .= self::CHARSET[random_int(0, $max)];
+ }
+
+ return $out;
+ }
+
+ private function renderSvg(string $code): string
+ {
+ $w = 160;
+ $h = 48;
+ $bg = '#fafafa';
+
+ $escape = static fn (string $c): string => htmlspecialchars($c, ENT_XML1 | ENT_QUOTES);
+
+ $lines = '';
+ for ($i = 0; $i < 4; $i++) {
+ $x1 = random_int(0, $w);
+ $y1 = random_int(0, $h);
+ $x2 = random_int(0, $w);
+ $y2 = random_int(0, $h);
+ $stroke = sprintf('#%06x', random_int(0x9_00_000, 0xBF_BF_BF));
+ $lines .= sprintf(
+ '',
+ $x1,
+ $y1,
+ $x2,
+ $y2,
+ $stroke,
+ );
+ }
+
+ $chars = str_split($code);
+ $texts = '';
+ $x = 18;
+ foreach ($chars as $ch) {
+ $rot = random_int(-20, 20);
+ $dy = random_int(-3, 3);
+ $y = 32 + $dy;
+ $fill = sprintf('#%06x', random_int(0x20_20_20, 0x3F_3F_3F));
+ $texts .= sprintf(
+ '%s',
+ $x,
+ $y,
+ $fill,
+ $rot,
+ $x,
+ $y,
+ $escape($ch),
+ );
+ $x += 34;
+ }
+
+ return sprintf(
+ '',
+ $w,
+ $h,
+ $w,
+ $h,
+ $bg,
+ $lines,
+ $texts,
+ );
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index b38605f..456fce6 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -15,6 +15,7 @@ use App\Http\Middleware\NegotiateLotteryLocale;
use App\Lottery\ErrorCode;
use App\Support\ApiResponse;
use App\Support\LotteryLocale;
+use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@@ -54,6 +55,19 @@ return Application::configure(basePath: dirname(__DIR__))
return (string) ($request->attributes->get('lottery_locale') ?? LotteryLocale::resolve($request));
};
+ $exceptions->render(function (AuthenticationException $e, Request $request) use ($locale) {
+ if (! $request->is('api/*')) {
+ return null;
+ }
+
+ return ApiResponse::error(
+ trans('admin.unauthenticated', [], $locale($request)),
+ ErrorCode::AdminUnauthenticated->value,
+ null,
+ 401,
+ );
+ });
+
$exceptions->render(function (ValidationException $e, Request $request) use ($locale) {
if (! $request->is('api/*')) {
return null;
diff --git a/composer.json b/composer.json
index c41cca7..6fce83c 100644
--- a/composer.json
+++ b/composer.json
@@ -12,6 +12,7 @@
"php": "^8.3",
"firebase/php-jwt": "^6.11",
"laravel/framework": "^13.7",
+ "laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0"
},
"require-dev": {
diff --git a/composer.lock b/composer.lock
index 4e769d8..119db8e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "709df8b7c1a41b9d01918fea32398dfb",
+ "content-hash": "0921908c2ff678b179a811ae39a6c12b",
"packages": [
{
"name": "brick/math",
@@ -1173,6 +1173,69 @@
},
"time": "2026-04-20T16:07:33+00:00"
},
+ {
+ "name": "laravel/sanctum",
+ "version": "v4.3.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/sanctum.git",
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e",
+ "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "illuminate/console": "^11.0|^12.0|^13.0",
+ "illuminate/contracts": "^11.0|^12.0|^13.0",
+ "illuminate/database": "^11.0|^12.0|^13.0",
+ "illuminate/support": "^11.0|^12.0|^13.0",
+ "php": "^8.2",
+ "symfony/console": "^7.0|^8.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^9.15|^10.8|^11.0",
+ "phpstan/phpstan": "^1.10"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Sanctum\\SanctumServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Sanctum\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
+ "keywords": [
+ "auth",
+ "laravel",
+ "sanctum"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/sanctum/issues",
+ "source": "https://github.com/laravel/sanctum"
+ },
+ "time": "2026-04-30T11:46:25+00:00"
+ },
{
"name": "laravel/serializable-closure",
"version": "v2.0.13",
diff --git a/config/sanctum.php b/config/sanctum.php
new file mode 100644
index 0000000..cde73cf
--- /dev/null
+++ b/config/sanctum.php
@@ -0,0 +1,87 @@
+ explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
+ '%s%s',
+ 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
+ Sanctum::currentApplicationUrlWithPort(),
+ // Sanctum::currentRequestHost(),
+ ))),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Sanctum Guards
+ |--------------------------------------------------------------------------
+ |
+ | This array contains the authentication guards that will be checked when
+ | Sanctum is trying to authenticate a request. If none of these guards
+ | are able to authenticate the request, Sanctum will use the bearer
+ | token that's present on an incoming request for authentication.
+ |
+ */
+
+ 'guard' => ['web'],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Expiration Minutes
+ |--------------------------------------------------------------------------
+ |
+ | This value controls the number of minutes until an issued token will be
+ | considered expired. This will override any values set in the token's
+ | "expires_at" attribute, but first-party sessions are not affected.
+ |
+ */
+
+ 'expiration' => null,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Token Prefix
+ |--------------------------------------------------------------------------
+ |
+ | Sanctum can prefix new tokens in order to take advantage of numerous
+ | security scanning initiatives maintained by open source platforms
+ | that notify developers if they commit tokens into repositories.
+ |
+ | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning
+ |
+ */
+
+ 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Sanctum Middleware
+ |--------------------------------------------------------------------------
+ |
+ | When authenticating your first-party SPA with Sanctum you may need to
+ | customize some of the middleware Sanctum uses while processing the
+ | request. You may change the middleware listed below as required.
+ |
+ */
+
+ 'middleware' => [
+ 'authenticate_session' => AuthenticateSession::class,
+ 'encrypt_cookies' => EncryptCookies::class,
+ 'validate_csrf_token' => ValidateCsrfToken::class,
+ ],
+
+];
diff --git a/database/migrations/2026_05_09_023835_create_personal_access_tokens_table.php b/database/migrations/2026_05_09_023835_create_personal_access_tokens_table.php
new file mode 100644
index 0000000..40ff706
--- /dev/null
+++ b/database/migrations/2026_05_09_023835_create_personal_access_tokens_table.php
@@ -0,0 +1,33 @@
+id();
+ $table->morphs('tokenable');
+ $table->text('name');
+ $table->string('token', 64)->unique();
+ $table->text('abilities')->nullable();
+ $table->timestamp('last_used_at')->nullable();
+ $table->timestamp('expires_at')->nullable()->index();
+ $table->timestamps();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('personal_access_tokens');
+ }
+};
diff --git a/database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php b/database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php
new file mode 100644
index 0000000..4d886ef
--- /dev/null
+++ b/database/migrations/2026_05_09_120000_add_username_and_nullable_email_to_admin_users.php
@@ -0,0 +1,79 @@
+string('username', 64)->nullable()->after('id');
+ });
+
+ $this->backfillUsernames();
+
+ Schema::table('admin_users', function (Blueprint $table) {
+ $table->string('username', 64)->nullable(false)->change();
+ $table->unique('username');
+ });
+
+ Schema::table('admin_users', function (Blueprint $table) {
+ $table->dropUnique(['email']);
+ });
+
+ Schema::table('admin_users', function (Blueprint $table) {
+ $table->string('email')->nullable()->change();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('admin_users', function (Blueprint $table) {
+ $table->dropUnique(['username']);
+ });
+
+ Schema::table('admin_users', function (Blueprint $table) {
+ $table->dropColumn('username');
+ });
+
+ Schema::table('admin_users', function (Blueprint $table) {
+ $table->string('email')->nullable(false)->change();
+ });
+
+ Schema::table('admin_users', function (Blueprint $table) {
+ $table->unique('email');
+ });
+ }
+
+ private function backfillUsernames(): void
+ {
+ $reserved = [];
+
+ foreach (DB::table('admin_users')->orderBy('id')->cursor() as $row) {
+ $email = (string) $row->email;
+ $local = Str::lower(Str::before($email, '@'));
+ $slug = preg_replace('/[^a-z0-9._-]/', '', $local);
+ $base = Str::substr($slug !== '' ? $slug : 'admin'.(string) $row->id, 0, 50);
+ if ($base === '') {
+ $base = 'admin'.(string) $row->id;
+ }
+
+ $candidate = $base;
+ $n = 0;
+ while (in_array($candidate, $reserved, true)
+ || DB::table('admin_users')->where('username', $candidate)->where('id', '!=', $row->id)->exists()) {
+ $n++;
+ $suffix = '_'.$n;
+ $candidate = Str::substr($base, 0, 64 - strlen($suffix)).$suffix;
+ }
+
+ $reserved[] = $candidate;
+
+ DB::table('admin_users')->where('id', $row->id)->update(['username' => $candidate]);
+ }
+ }
+};
diff --git a/database/seeders/AdminRbacAndUserSeeder.php b/database/seeders/AdminRbacAndUserSeeder.php
index 42a8ba9..1f39853 100644
--- a/database/seeders/AdminRbacAndUserSeeder.php
+++ b/database/seeders/AdminRbacAndUserSeeder.php
@@ -7,7 +7,7 @@ use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
/**
- * 后台角色(super_admin)、若干权限占位;本地演示:**admin@admin.com** / **123456**(仅限非 production)。
+ * 后台角色(super_admin)、若干权限占位;本地演示:账号 **admin** / **123456**(仅限非 production)。
*/
class AdminRbacAndUserSeeder extends Seeder
{
@@ -50,11 +50,12 @@ class AdminRbacAndUserSeeder extends Seeder
);
}
- $email = 'admin@admin.com';
+ $username = 'admin';
AdminUser::query()->updateOrCreate(
- ['email' => $email],
+ ['username' => $username],
[
- 'name' => 'admin',
+ 'name' => '超级管理员',
+ 'email' => null,
/** 明文;模型 casts `password => hashed`,勿在生产库使用种子弱口令 */
'password' => '123456',
'status' => 0,
@@ -62,7 +63,7 @@ class AdminRbacAndUserSeeder extends Seeder
);
/** @var int $uid */
- $uid = (int) AdminUser::query()->where('email', $email)->value('id');
+ $uid = (int) AdminUser::query()->where('username', $username)->value('id');
DB::table('admin_user_roles')->updateOrInsert(
['admin_user_id' => $uid, 'role_id' => $rid],
[],
diff --git a/lang/en/admin.php b/lang/en/admin.php
new file mode 100644
index 0000000..e475da2
--- /dev/null
+++ b/lang/en/admin.php
@@ -0,0 +1,8 @@
+ 'Unauthenticated or session expired.',
+ 'invalid_captcha' => 'Invalid or expired captcha.',
+ 'invalid_credentials' => 'Invalid account or password.',
+ 'account_disabled' => 'This account has been disabled.',
+];
diff --git a/lang/ne/admin.php b/lang/ne/admin.php
new file mode 100644
index 0000000..5ad3782
--- /dev/null
+++ b/lang/ne/admin.php
@@ -0,0 +1,8 @@
+ 'प्रमाणीकरण छैन वा सेसन समाप्त भएको छ।',
+ 'invalid_captcha' => 'क्याप्चा गलत वा समय सकियो।',
+ 'invalid_credentials' => 'खाता वा पासवर्ड गलत।',
+ 'account_disabled' => 'यो खाता निष्क्रिय गरिएको छ।',
+];
diff --git a/lang/zh/admin.php b/lang/zh/admin.php
new file mode 100644
index 0000000..55b6584
--- /dev/null
+++ b/lang/zh/admin.php
@@ -0,0 +1,8 @@
+ '未登录或登录已失效。',
+ 'invalid_captcha' => '验证码错误或已过期,请重试。',
+ 'invalid_credentials' => '账号或密码错误。',
+ 'account_disabled' => '该账号已被禁用。',
+];
diff --git a/routes/api.php b/routes/api.php
index 7f2e167..2c0fae0 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -1,5 +1,7 @@
group(function (): void {
});
});
- Route::middleware('lottery.admin')
- ->prefix('admin')
+ Route::prefix('admin')
->name('api.v1.admin.')
->group(function (): void {
- // 名称:后台接口连通性探测
- Route::get('ping', AdminPingController::class)->name('ping');
+ Route::middleware('throttle:admin-auth-captcha')
+ ->get('auth/captcha', CaptchaController::class)
+ ->name('auth.captcha');
+
+ Route::middleware('throttle:admin-auth-login')
+ ->post('auth/login', LoginController::class)
+ ->name('auth.login');
+
+ Route::middleware(['auth:sanctum', 'lottery.admin'])->group(function (): void {
+ // 名称:后台接口连通性探测(需 Bearer Token)
+ Route::get('ping', AdminPingController::class)->name('ping');
+ });
});
});
diff --git a/tests/Feature/AdminAuthLoginTest.php b/tests/Feature/AdminAuthLoginTest.php
new file mode 100644
index 0000000..0d9e0e1
--- /dev/null
+++ b/tests/Feature/AdminAuthLoginTest.php
@@ -0,0 +1,83 @@
+getJson('/api/v1/admin/ping')->assertUnauthorized()
+ ->assertJsonPath('code', ErrorCode::AdminUnauthenticated->value);
+});
+
+test('admin login returns bearer token when captcha passes validation', function () {
+ AdminUser::query()->create([
+ 'username' => 'tester',
+ 'name' => '测试昵称',
+ 'email' => null,
+ 'password' => 'secret-strong',
+ 'status' => 0,
+ ]);
+
+ $mock = Mockery::mock(AdminCaptchaService::class);
+ $mock->shouldReceive('verify')->once()->andReturn(true);
+ $this->instance(AdminCaptchaService::class, $mock);
+
+ $resp = $this->postJson('/api/v1/admin/auth/login', [
+ 'account' => 'Tester',
+ 'password' => 'secret-strong',
+ 'captcha_key' => (string) Str::uuid(),
+ 'captcha_code' => 'xwz2',
+ ]);
+
+ $resp->assertOk()
+ ->assertJsonPath('code', 0)
+ ->assertJsonPath('data.admin.username', 'tester')
+ ->assertJsonPath('data.admin.nickname', '测试昵称')
+ ->assertJsonStructure(['data' => ['token', 'token_type', 'admin' => ['id', 'username', 'nickname', 'email']]]);
+
+ $token = $resp->json('data.token');
+ expect($token)->not->toBeNull();
+
+ $this->withHeader('Authorization', 'Bearer '.$token)
+ ->getJson('/api/v1/admin/ping')
+ ->assertOk()
+ ->assertJsonPath('data.scope', 'admin');
+});
+
+test('admin captcha exposes key and image base64', function () {
+ $resp = $this->getJson('/api/v1/admin/auth/captcha');
+
+ $resp->assertOk()
+ ->assertJsonPath('code', 0);
+
+ $data = $resp->json('data');
+ expect($data)->toBeArray()
+ ->and(Str::isUuid((string) $data['captcha_key']))->toBeTrue()
+ ->and((string) $data['image_base64'])->not->toBe('');
+});
+
+test('login rejects wrong password with masked message', function () {
+ AdminUser::query()->create([
+ 'username' => 'bad_tester',
+ 'name' => 'X',
+ 'email' => null,
+ 'password' => 'right-only',
+ 'status' => 0,
+ ]);
+
+ $mock = Mockery::mock(AdminCaptchaService::class);
+ $mock->shouldReceive('verify')->andReturn(true);
+ $this->instance(AdminCaptchaService::class, $mock);
+
+ $this->postJson('/api/v1/admin/auth/login', [
+ 'account' => 'bad_tester',
+ 'password' => 'wrong-password',
+ 'captcha_key' => (string) Str::uuid(),
+ 'captcha_code' => 'aaaa',
+ ])->assertUnauthorized()
+ ->assertJsonPath('code', ErrorCode::AdminCredentialsInvalid->value);
+});