BastionSSO/app/Http/Controllers/Api/UserController.php
Boen_Shi d2c6ae7fc0 fix(用户导入): 仅保留 xlsx 导入并提供 xlsx 模板下载
- 移除 csv/txt 导入支持,接口仅接受 xlsx

- 新增 xlsx 模板文件生成逻辑

- 优化缺少 ZipArchive 扩展时的中文错误提示
2026-04-29 15:27:28 +08:00

522 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Models\ServerResource;
use App\Models\User;
use App\Models\UserServerPermission;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
#[Apidoc\Title('用户管理')]
class UserController extends Controller
{
public function __construct()
{
$this->middleware('auth:api');
$this->middleware('permission:platform.users.view,api')->only(['index', 'show']);
$this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'import', 'importTemplate']);
}
#[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')]
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', 'string', 'in:id,nickname,email,phone,created_at'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
]);
$sortBy = $validated['sort_by'] ?? 'created_at';
$sortOrder = $validated['sort_order'] ?? 'desc';
$perPage = (int) ($validated['per_page'] ?? 20);
$users = User::query()
->with('roles')
->orderBy($sortBy, $sortOrder)
->paginate($perPage);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $users]);
}
#[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')]
public function store(StoreUserRequest $request): JsonResponse
{
$user = User::query()->create($request->validated());
if ($request->filled('role_ids')) {
$user->syncRoles($request->validated('role_ids'));
}
$this->auditLog($request, 'user_create', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')], 201);
}
#[Apidoc\Title('用户详情'), Apidoc\Method('GET'), Apidoc\Url('/users/{id}')]
public function show(int $id): JsonResponse
{
$user = User::query()->with(['roles', 'permissions', 'serverResources'])->findOrFail($id);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user]);
}
#[Apidoc\Title('更新用户'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}')]
public function update(UpdateUserRequest $request, int $id): JsonResponse
{
$user = User::query()->findOrFail($id);
$user->fill($request->safe()->except(['role_ids']));
if ($request->filled('password')) {
$user->password = $request->validated('password');
}
$user->save();
if ($request->has('role_ids')) {
$user->syncRoles($request->validated('role_ids'));
}
$this->auditLog($request, 'user_update', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')]);
}
#[Apidoc\Title('同步用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/permissions')]
public function syncPermissions(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'permission_ids' => ['present', 'array'],
'permission_ids.*' => ['integer', 'exists:permissions,id'],
]);
$user = User::query()->findOrFail($id);
$user->syncPermissions($validated['permission_ids']);
$this->syncServerResourcePermissionsByDirectPermissions($user, $validated['permission_ids']);
$this->auditLog($request, 'user_permissions_update', ['metadata' => ['target_user_id' => $user->id, 'permission_ids' => $validated['permission_ids']]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'permissions'])]);
}
#[Apidoc\Title('删除用户'), Apidoc\Method('DELETE'), Apidoc\Url('/users/{id}')]
public function destroy(Request $request, int $id): JsonResponse
{
$user = User::query()->findOrFail($id);
$this->auditLog($request, 'user_delete', ['metadata' => ['target_user_id' => $user->id]]);
$user->delete();
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
}
#[Apidoc\Title('批量导入用户'), Apidoc\Method('POST'), Apidoc\Url('/users/import')]
public function import(Request $request): JsonResponse
{
$validated = $request->validate([
'file' => ['required', 'file', 'mimes:xlsx'],
]);
$file = $validated['file'];
$rows = $this->parseImportRows($file->getPathname(), (string) $file->getClientOriginalExtension());
if ($rows->isEmpty()) {
throw ValidationException::withMessages([
'file' => ['文件中没有可导入的数据行。'],
]);
}
$createdCount = 0;
$skippedCount = 0;
$errors = [];
foreach ($rows as $index => $row) {
$line = $index + 2;
$normalizedRow = [];
foreach ($row as $key => $value) {
$normalizedRow[$this->normalizeImportHeader((string) $key)] = (string) $value;
}
$payload = [
'nickname' => trim($this->firstMatchedValue($normalizedRow, ['nickname', 'nick_name', 'name', '用户名', '昵称'])),
'email' => trim($this->firstMatchedValue($normalizedRow, ['email', 'mail', '邮箱'])),
'phone' => trim($this->firstMatchedValue($normalizedRow, ['phone', 'mobile', '手机号', '手机', '电话'])),
'password' => trim($this->firstMatchedValue($normalizedRow, ['password', 'passwd', 'pwd', '密码'])),
];
$validator = Validator::make($payload, [
'nickname' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'phone' => ['nullable', 'string', 'max:32', 'unique:users,phone'],
'password' => ['required', 'string', 'min:6', 'max:255'],
]);
if ($validator->fails()) {
$errors[] = [
'line' => $line,
'message' => implode('', $validator->errors()->all()),
];
$skippedCount++;
continue;
}
$user = User::query()->create($payload);
$roleIds = $this->resolveRoleIds($normalizedRow);
if (! empty($roleIds)) {
$user->syncRoles($roleIds);
}
$createdCount++;
}
$this->auditLog($request, 'user_import', [
'metadata' => [
'created_count' => $createdCount,
'skipped_count' => $skippedCount,
'error_count' => count($errors),
],
]);
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => [
'created_count' => $createdCount,
'skipped_count' => $skippedCount,
'errors' => $errors,
],
]);
}
#[Apidoc\Title('下载用户导入模板'), Apidoc\Method('GET'), Apidoc\Url('/users/import/template')]
public function importTemplate()
{
if (! class_exists(\ZipArchive::class)) {
throw ValidationException::withMessages([
'file' => ['当前 PHP 环境未启用 ZipArchive 扩展,无法生成 xlsx 模板。'],
]);
}
$tempPath = tempnam(sys_get_temp_dir(), 'users_import_template_');
if ($tempPath === false) {
throw ValidationException::withMessages(['file' => ['无法创建临时模板文件。']]);
}
$xlsxPath = $tempPath.'.xlsx';
@rename($tempPath, $xlsxPath);
$this->buildUsersImportTemplateXlsx($xlsxPath);
return response()->download(
$xlsxPath,
'users_import_template.xlsx',
['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
)->deleteFileAfterSend(true);
}
private function parseImportRows(string $filePath, string $extension): Collection
{
$ext = strtolower($extension);
if ($ext !== 'xlsx') {
throw ValidationException::withMessages(['file' => ['仅支持 xlsx 文件导入。']]);
}
return $this->parseXlsxRows($filePath);
}
private function parseXlsxRows(string $filePath): Collection
{
if (! class_exists(\ZipArchive::class)) {
throw ValidationException::withMessages([
'file' => ['当前 PHP 环境未启用 ZipArchive 扩展,无法导入 xlsx。请启用 php_zip 扩展。'],
]);
}
$zip = new \ZipArchive;
if ($zip->open($filePath) !== true) {
throw ValidationException::withMessages(['file' => ['无法解析 xlsx 文件。']]);
}
$sharedStrings = [];
$sharedXml = $zip->getFromName('xl/sharedStrings.xml');
if (is_string($sharedXml)) {
$sharedDoc = new \SimpleXMLElement($sharedXml);
foreach ($sharedDoc->si as $si) {
$sharedStrings[] = (string) $si->t;
}
}
$sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
$zip->close();
if (! is_string($sheetXml)) {
throw ValidationException::withMessages(['file' => ['xlsx 文件缺少 sheet1。']]);
}
$sheet = new \SimpleXMLElement($sheetXml);
$rowsData = [];
foreach ($sheet->sheetData->row as $rowNode) {
$rowValues = [];
foreach ($rowNode->c as $cell) {
$ref = (string) $cell['r'];
preg_match('/^[A-Z]+/', $ref, $matches);
$column = $matches[0] ?? '';
$value = '';
if ((string) $cell['t'] === 's') {
$index = (int) ($cell->v ?? 0);
$value = (string) ($sharedStrings[$index] ?? '');
} else {
$value = (string) ($cell->v ?? '');
}
$rowValues[$column] = $value;
}
$rowsData[] = $rowValues;
}
if (count($rowsData) <= 1) {
return collect();
}
$headerRow = $rowsData[0];
$orderedColumns = array_keys($headerRow);
$headers = [];
foreach ($orderedColumns as $col) {
$headers[] = $this->normalizeImportHeader((string) ($headerRow[$col] ?? ''));
}
$resultRows = collect();
for ($i = 1; $i < count($rowsData); $i++) {
$row = $rowsData[$i];
$assoc = [];
$hasValue = false;
foreach ($orderedColumns as $idx => $col) {
$header = $headers[$idx] ?? '';
if ($header === '') {
continue;
}
$value = (string) ($row[$col] ?? '');
if (trim($value) !== '') {
$hasValue = true;
}
$assoc[$header] = $value;
}
if ($hasValue) {
$resultRows->push($assoc);
}
}
return $resultRows;
}
private function resolveRoleIds(array $row): array
{
$roleIdsRaw = trim((string) ($row['role_ids'] ?? ''));
if ($roleIdsRaw !== '') {
return collect(explode(',', $roleIdsRaw))
->map(fn ($id) => (int) trim($id))
->filter(fn (int $id): bool => $id > 0 && Role::query()->where('id', $id)->exists())
->values()
->all();
}
$roleNamesRaw = trim((string) ($row['roles'] ?? $row['role_names'] ?? ''));
if ($roleNamesRaw === '') {
return [];
}
$names = collect(explode(',', $roleNamesRaw))
->map(fn ($name) => trim($name))
->filter()
->values()
->all();
if (empty($names)) {
return [];
}
return Role::query()
->whereIn('name', $names)
->pluck('id')
->map(fn (int $id): int => (int) $id)
->all();
}
private function normalizeImportHeader(string $header): string
{
$normalized = trim($this->toUtf8($header));
$normalized = preg_replace('/^\xEF\xBB\xBF/u', '', $normalized) ?? $normalized;
$normalized = strtolower(str_replace([' ', '-', '.'], '_', trim($normalized)));
return match ($normalized) {
'昵称', '用户名', '姓名', 'name', 'nick_name' => 'nickname',
'邮箱', '邮件', 'mail' => 'email',
'手机号', '手机', '电话', 'mobile' => 'phone',
'密码', 'passwd', 'pwd' => 'password',
'角色id', '角色ids', 'role_id', '角色_id' => 'role_ids',
'角色', '角色名', 'role_name', 'role_names' => 'roles',
default => $normalized,
};
}
private function toUtf8(string $value): string
{
if ($value === '' || mb_check_encoding($value, 'UTF-8')) {
return $value;
}
return mb_convert_encoding($value, 'UTF-8', 'UTF-8,GB18030,GBK,Big5,ISO-8859-1');
}
/**
* @param array<string, string> $row
* @param array<int, string> $aliases
*/
private function firstMatchedValue(array $row, array $aliases): string
{
foreach ($aliases as $alias) {
$key = $this->normalizeImportHeader($alias);
if (array_key_exists($key, $row)) {
return (string) $row[$key];
}
}
return '';
}
private function buildUsersImportTemplateXlsx(string $xlsxPath): void
{
$zip = new \ZipArchive;
if ($zip->open($xlsxPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw ValidationException::withMessages(['file' => ['无法生成 xlsx 模板文件。']]);
}
$contentTypes = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
.'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
.'<Default Extension="xml" ContentType="application/xml"/>'
.'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
.'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
.'</Types>';
$rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
.'</Relationships>';
$workbook = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
.'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
.'<sheets><sheet name="Users" sheetId="1" r:id="rId1"/></sheets></workbook>';
$workbookRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
.'</Relationships>';
$rows = [
['nickname', 'email', 'phone', 'password', 'role_ids'],
['张三', 'zhangsan@example.com', '13800138000', 'Pass@123456', '1'],
['李四', 'lisi@example.com', '13900139000', 'Pass@123456', '1,2'],
];
$sheetData = '';
foreach ($rows as $rowIndex => $cells) {
$r = $rowIndex + 1;
$sheetData .= '<row r="'.$r.'">';
foreach ($cells as $colIndex => $cellValue) {
$column = chr(ord('A') + $colIndex).$r;
$escapedValue = htmlspecialchars((string) $cellValue, ENT_XML1 | ENT_QUOTES, 'UTF-8');
$sheetData .= '<c r="'.$column.'" t="inlineStr"><is><t>'.$escapedValue.'</t></is></c>';
}
$sheetData .= '</row>';
}
$sheet = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>'
.$sheetData
.'</sheetData></worksheet>';
$zip->addFromString('[Content_Types].xml', $contentTypes);
$zip->addFromString('_rels/.rels', $rels);
$zip->addFromString('xl/workbook.xml', $workbook);
$zip->addFromString('xl/_rels/workbook.xml.rels', $workbookRels);
$zip->addFromString('xl/worksheets/sheet1.xml', $sheet);
$zip->close();
}
private function syncServerResourcePermissionsByDirectPermissions(User $user, array $permissionIds): void
{
$resourcePermissions = Permission::query()
->select(['id', 'name', 'description'])
->whereIn('id', $permissionIds)
->where('guard_name', 'api')
->where('name', 'like', 'resource.servers.use.%')
->get();
$grantedResourceIds = $resourcePermissions
->map(fn (Permission $permission): ?int => $this->resourceIdFromPermissionDescription((string) $permission->description))
->filter(fn (?int $resourceId): bool => $resourceId !== null)
->map(fn (int $resourceId): int => (int) $resourceId)
->values()
->all();
if (count($grantedResourceIds) > 0) {
$existingResourceIds = ServerResource::query()
->whereIn('id', $grantedResourceIds)
->whereNotNull('parent_id')
->pluck('id')
->map(fn (int $resourceId): int => (int) $resourceId)
->all();
foreach ($existingResourceIds as $resourceId) {
UserServerPermission::query()->updateOrCreate(
[
'user_id' => $user->id,
'server_resource_id' => $resourceId,
],
[
'can_ssh' => true,
'can_sftp' => true,
'can_rdp' => true,
]
);
}
}
$managedResourceIds = Permission::query()
->where('guard_name', 'api')
->where('name', 'like', 'resource.servers.use.%')
->where('description', 'like', '服务器资源访问权限资源ID:%')
->pluck('description')
->map(fn (string $description): ?int => $this->resourceIdFromPermissionDescription($description))
->filter(fn (?int $resourceId): bool => $resourceId !== null)
->map(fn (int $resourceId): int => $resourceId)
->values()
->all();
if (count($managedResourceIds) > 0) {
UserServerPermission::query()
->where('user_id', $user->id)
->whereIn('server_resource_id', $managedResourceIds)
->whereNotIn('server_resource_id', $grantedResourceIds)
->update([
'can_ssh' => false,
'can_sftp' => false,
'can_rdp' => false,
]);
}
}
private function resourceIdFromPermissionDescription(string $description): ?int
{
if (! preg_match('/资源ID:\s*(\d+)/u', $description, $matches)) {
return null;
}
return isset($matches[1]) ? (int) $matches[1] : null;
}
}