master
lion 23 hours ago
parent 906fe4f138
commit a468a4135c

@ -1,136 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Models\Venue;
use App\Support\VenueAdminCredentials;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class BatchCreateVenueAdminAccountsCommand extends Command
{
protected $signature = 'venues:create-admin-accounts
{--output= : 导出 xlsx 绝对或相对路径(默认 storage/app/exports/venue_admins_时间戳.xlsx}
{--dry-run : 仅生成 Excel不向 users / user_venue 写入(预览或离线存档)}';
protected $description = '为每个场馆创建一个场馆管理员账号(姓名=馆名,用户名=拼音首字母缩写,密码为随机强密码),并绑定该场馆;导出含明文密码的 Excel 供运营一次性发放';
public function handle(): int
{
$venues = Venue::query()->orderBy('id')->get(['id', 'name']);
if ($venues->isEmpty()) {
$this->warn('数据库中暂无场馆。');
return self::FAILURE;
}
$this->info("将处理 {$venues->count()} 个场馆。");
$rows = [];
$reservedNames = User::query()->pluck('username')->all();
$reservedSet = array_fill_keys($reservedNames, true);
$batchUsed = [];
foreach ($venues as $venue) {
$name = (string) $venue->name;
$base = VenueAdminCredentials::acronymFromVenueName($name);
$plainPassword = VenueAdminCredentials::randomPassword();
$username = $base;
$suffix = 2;
while (isset($reservedSet[$username]) || isset($batchUsed[$username])) {
$username = $base.$suffix;
$suffix++;
}
$batchUsed[$username] = true;
$rows[] = [
'venue_id' => $venue->id,
'name' => $name,
'username' => $username,
'password_plain' => $plainPassword,
'role' => '场馆管理员',
'venue_name' => $name,
];
}
$defaultDir = storage_path('app/exports');
if (! is_dir($defaultDir)) {
mkdir($defaultDir, 0755, true);
}
$outPath = $this->option('output');
if (! $outPath) {
$outPath = $defaultDir.'/venue_admins_'.now()->format('Ymd_His').'.xlsx';
} elseif (! str_starts_with((string) $outPath, '/')) {
$outPath = base_path($outPath);
}
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
$this->warn('当前为 --dry-run只生成 Excel不会写入 users / user_venue。去掉 --dry-run 才会入库。');
} else {
$this->info('即将写入数据库users、user_venue随后生成 Excel…');
DB::transaction(function () use ($rows) {
foreach ($rows as $row) {
$user = User::updateOrCreate(
['username' => $row['username']],
[
'name' => $row['name'],
'email' => null,
'password' => $row['password_plain'],
'role' => 'venue_admin',
'is_active' => true,
]
);
$user->venues()->sync([$row['venue_id']]);
}
});
$this->info('数据库写入完成:已创建/更新 '.count($rows).' 个场馆管理员并完成场馆绑定。');
}
$this->writeXlsx($outPath, $rows);
$this->info("Excel 已生成:{$outPath}");
if ($dryRun) {
$this->warn('本次未写入数据库。若需要入库请执行php artisan venues:create-admin-accounts不要带 --dry-run');
return self::SUCCESS;
}
return self::SUCCESS;
}
/**
* @param array<int, array{venue_id:int, name:string, username:string, password_plain:string, role:string, venue_name:string}> $rows
*/
private function writeXlsx(string $path, array $rows): void
{
$sheetRows = [
['场馆ID', '姓名', '用户名', '密码(明文)', '角色', '绑定场馆'],
];
foreach ($rows as $r) {
$sheetRows[] = [
$r['venue_id'],
$r['name'],
$r['username'],
$r['password_plain'],
$r['role'],
$r['venue_name'],
];
}
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('场馆管理员账号');
$sheet->fromArray($sheetRows, null, 'A1');
$writer = new Xlsx($spreadsheet);
$writer->save($path);
}
}

@ -1,106 +0,0 @@
<?php
namespace App\Console\Commands;
use App\Models\User;
use App\Support\VenueAdminCredentials;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
class ResetVenueAdminPasswordsCommand extends Command
{
protected $signature = 'venues:reset-admin-passwords
{--output= : 导出 xlsx 绝对或相对路径(默认 storage/app/exports/venue_admin_password_reset_时间戳.xlsx}
{--dry-run : 仅列出将重置的账号,不改密码、不失效 token、不导出}';
protected $description = '重置全部场馆管理员role=venue_admin密码为随机强密码作废其 Sanctum token并导出仅供运维发放的 Excel';
public function handle(): int
{
$users = User::query()
->where('role', 'venue_admin')
->orderBy('id')
->get(['id', 'username', 'name']);
if ($users->isEmpty()) {
$this->warn('没有 role=venue_admin 的账号。');
return self::SUCCESS;
}
$this->info('将处理 '.$users->count().' 个场馆管理员账号。');
$dryRun = (bool) $this->option('dry-run');
if ($dryRun) {
foreach ($users as $user) {
$this->line($user->id."\t".$user->username);
}
$this->warn('当前为 --dry-run未改密码、未失效 token、未导出。去掉 --dry-run 才会写入。');
return self::SUCCESS;
}
$rows = [];
DB::transaction(function () use ($users, &$rows) {
foreach ($users as $user) {
$plainPassword = VenueAdminCredentials::randomPassword();
$user->password = $plainPassword;
$user->save();
$user->tokens()->delete();
$rows[] = [
'id' => $user->id,
'name' => (string) $user->name,
'username' => (string) $user->username,
'password_plain' => $plainPassword,
];
}
});
$defaultDir = storage_path('app/exports');
if (! is_dir($defaultDir)) {
mkdir($defaultDir, 0755, true);
}
$outPath = $this->option('output');
if (! $outPath) {
$outPath = $defaultDir.'/venue_admin_password_reset_'.now()->format('Ymd_His').'.xlsx';
} elseif (! str_starts_with((string) $outPath, '/')) {
$outPath = base_path($outPath);
}
$this->writeXlsx((string) $outPath, $rows);
$this->info('已重置 '.$users->count().' 个场馆管理员密码,并作废其现有 token。');
$this->info('Excel 已生成(请安全交给运营,不要提交到 git'.$outPath);
return self::SUCCESS;
}
/**
* @param array<int, array{id:int, name:string, username:string, password_plain:string}> $rows
*/
private function writeXlsx(string $path, array $rows): void
{
$sheetRows = [
['用户ID', '姓名', '用户名', '新密码(明文)'],
];
foreach ($rows as $r) {
$sheetRows[] = [
$r['id'],
$r['name'],
$r['username'],
$r['password_plain'],
];
}
$spreadsheet = new Spreadsheet;
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('场馆管理员新密码');
$sheet->fromArray($sheetRows, null, 'A1');
$writer = new Xlsx($spreadsheet);
$writer->save($path);
}
}

@ -1,34 +0,0 @@
<?php
namespace App\Support;
use Illuminate\Support\Str;
use Overtrue\Pinyin\Pinyin;
/**
* 场馆后台账号:用户名可用馆名拼音缩写;密码必须随机生成,不可由馆名推算。
*/
final class VenueAdminCredentials
{
/**
* 由场馆名称得到拼音首字母缩写(小写、仅字母数字),仅用于用户名。
*/
public static function acronymFromVenueName(string $name): string
{
$joined = Pinyin::abbr($name)->join('');
$joined = strtolower($joined);
$joined = preg_replace('/[^a-z0-9]/', '', $joined) ?? '';
return $joined !== '' ? $joined : 'v';
}
/**
* 随机强密码:每次不同,长度至少 12含大小写字母、数字与符号。
*/
public static function randomPassword(int $length = 16): string
{
$length = max(12, $length);
return Str::password($length, true, true, true, false);
}
}

@ -0,0 +1,130 @@
# 苏州科普站 Nginx 草稿:默认站点 + 上传目录禁止解析 PHP
# 仅供运维对照落地,不要直接覆盖线上未确认的路径/证书/php-fpm 套接字。
# 占位请替换:
# ROOT 本站 public 目录,例如 /var/www/szkp-map-service/public
# FPM_SOCK 本站独立 php-fpm例如 unix:/run/php/php8.1-fpm-szkp.sock
# SERVER_NAME 本站域名(可多个)
# 不要改应用 URL图片仍走 /storage/uploads/...
# ---------------------------------------------------------------------------
# 一、默认站点:扫 IP、未知 Host 不要落到本站
# 必须出现在本站 server 之前,或明确 listen ... default_server
# ---------------------------------------------------------------------------
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# 444直接断开不回你站的页面/框架/路径
return 444;
}
# Nginx >= 1.19.4 推荐HTTPS 扫 IP 时拒绝握手,不必准备假证书
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
server_name _;
ssl_reject_handshake on;
}
# 若当前 Nginx 不支持 ssl_reject_handshake改用下面这段二选一不要两段都开
# server {
# listen 443 ssl default_server;
# listen [::]:443 ssl default_server;
# server_name _;
# ssl_certificate /path/to/any.crt;
# ssl_certificate_key /path/to/any.key;
# return 444;
# }
# ---------------------------------------------------------------------------
# 二、本站(科普)正式站点
# 确认本站 listen 行上没有 default_server
# ---------------------------------------------------------------------------
server {
listen 80;
listen [::]:80;
server_name YOUR_SZKP_DOMAIN.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name YOUR_SZKP_DOMAIN.example.com;
root /var/www/szkp-map-service/public;
index index.php;
charset utf-8;
ssl_certificate /path/to/szkp.fullchain.pem;
ssl_certificate_key /path/to/szkp.privkey.pem;
add_header X-Content-Type-Options nosniff always;
add_header X-Frame-Options SAMEORIGIN always;
# 上传目录:只当静态文件,绝不进 PHP
# ^~ 优先于正则,避免 *.php 落到下面的 location ~ \.php$
location ^~ /storage/uploads {
alias /var/www/szkp-map-service/storage/app/public/uploads;
# 若已做 public/storage 软链,可改成不写 alias仅靠 root + try_files
# try_files $uri =404;
add_header X-Content-Type-Options nosniff always;
types { }
default_type application/octet-stream;
location ~* \.(php|phtml|phar|php[0-9]|pht|phps|shtml)$ {
deny all;
return 404;
}
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location = /favicon.ico { access_log off; log_not_found off; }
location = /robots.txt { access_log off; log_not_found off; }
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php/php8.1-fpm-szkp.sock;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
include fastcgi_params;
fastcgi_hide_header X-Powered-By;
}
location ~ /\.(?!well-known).* {
deny all;
}
}
# ---------------------------------------------------------------------------
# 三、同机其他站点(如 www.szyljt.com
# 若该站也能访问到同一份 storage必须同样禁止解析否则扫那个域名仍能打到上传文件。
# 把下面 location 原样放进那个站点的 server {},路径改成实际 uploads 目录。
# ---------------------------------------------------------------------------
# location ^~ /storage/uploads {
# alias /var/www/szkp-map-service/storage/app/public/uploads;
# add_header X-Content-Type-Options nosniff always;
# location ~* \.(php|phtml|phar|php[0-9]|pht|phps|shtml)$ {
# deny all;
# return 404;
# }
# try_files $uri =404;
# }
# ---------------------------------------------------------------------------
# 落地检查(运维执行,不必改代码)
# 1. nginx -t && 重载
# 2. 浏览器或 curl 访问 http://服务器IP/ 应无本站页面(连接被关或非 200 业务页)
# 3. 用本站域名访问一张已有 jpg地址仍是 /storage/uploads/xxx.jpg能正常显示
# 4. 访问 /storage/uploads 下任何 .php 应为 404且不应再被 PHP 执行
# 5. 本站与同机其他站使用不同 Linux 用户、不同 php-fpm 池,并限制 open_basedir
# ---------------------------------------------------------------------------

@ -1,3 +1,4 @@
# 完整草稿(默认站点 + 上传目录)见同目录 nginx-default-and-uploads.conf
# 运维将本片段放入对应 server {},不要改应用 URL。
# ^~ 优先于正则,避免 /storage/uploads 下的脚本落入 PHP location。
# 路径以实际部署的 public 目录为准;若已做 public/storage 软链,用下面这段即可。

@ -1,104 +0,0 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use PhpOffice\PhpSpreadsheet\IOFactory;
use Tests\TestCase;
class ResetVenueAdminPasswordsCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'database.default' => 'sqlite',
'database.connections.sqlite.database' => ':memory:',
]);
DB::purge();
DB::reconnect();
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('username')->nullable()->unique();
$table->string('name');
$table->string('email')->nullable();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->string('role')->default('venue_admin');
$table->boolean('is_active')->default(true);
$table->rememberToken();
$table->timestamps();
});
Schema::create('personal_access_tokens', function (Blueprint $table) {
$table->id();
$table->morphs('tokenable');
$table->string('name');
$table->string('token', 64)->unique();
$table->text('abilities')->nullable();
$table->timestamp('last_used_at')->nullable();
$table->timestamp('expires_at')->nullable();
$table->timestamps();
});
}
public function test_dry_run_does_not_change_password_or_tokens(): void
{
$user = $this->venueAdmin('old_admin');
$user->createToken('admin-token');
$oldHash = $user->password;
$this->artisan('venues:reset-admin-passwords', ['--dry-run' => true])
->assertSuccessful();
$fresh = $user->fresh();
$this->assertSame($oldHash, $fresh->password);
$this->assertSame(1, $fresh->tokens()->count());
}
public function test_reset_writes_new_hash_revokes_tokens_and_exports_xlsx(): void
{
$user = $this->venueAdmin('venue_one');
$user->createToken('admin-token');
$oldHash = $user->password;
$out = storage_path('app/exports/test_venue_admin_password_reset.xlsx');
if (is_file($out)) {
unlink($out);
}
$this->artisan('venues:reset-admin-passwords', ['--output' => $out])
->assertSuccessful();
$fresh = $user->fresh();
$this->assertNotSame($oldHash, $fresh->password);
$this->assertSame(0, $fresh->tokens()->count());
$this->assertFileExists($out);
$sheet = IOFactory::load($out)->getActiveSheet()->toArray();
$this->assertSame('venue_one', $sheet[1][2]);
$plain = (string) $sheet[1][3];
$this->assertGreaterThanOrEqual(12, strlen($plain));
$this->assertTrue(Hash::check($plain, $fresh->password));
unlink($out);
}
private function venueAdmin(string $username): User
{
return User::query()->create([
'username' => $username,
'name' => '场馆管理员',
'email' => $username.'@example.test',
'password' => 'OldPlace!holder1',
'role' => 'venue_admin',
'is_active' => true,
]);
}
}

@ -1,38 +0,0 @@
<?php
namespace Tests\Unit;
use App\Support\VenueAdminCredentials;
use Tests\TestCase;
class VenueAdminCredentialsTest extends TestCase
{
public function test_same_venue_name_yields_different_random_passwords(): void
{
$name = '苏州青少年科技馆';
$first = VenueAdminCredentials::randomPassword();
$second = VenueAdminCredentials::randomPassword();
$this->assertNotSame($first, $second);
$this->assertSame(
VenueAdminCredentials::acronymFromVenueName($name),
VenueAdminCredentials::acronymFromVenueName($name)
);
}
public function test_random_password_meets_strength_rules(): void
{
$password = VenueAdminCredentials::randomPassword();
$this->assertGreaterThanOrEqual(12, strlen($password));
$this->assertMatchesRegularExpression('/[A-Z]/', $password);
$this->assertMatchesRegularExpression('/[a-z]/', $password);
$this->assertMatchesRegularExpression('/[0-9]/', $password);
$this->assertMatchesRegularExpression('/[^A-Za-z0-9]/', $password);
}
public function test_legacy_acronym_password_helper_is_removed(): void
{
$this->assertFalse(method_exists(VenueAdminCredentials::class, 'passwordFromAcronym'));
}
}
Loading…
Cancel
Save