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', 'syncBatchAssignments', '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, string $id): JsonResponse { if ($id === 'batch-assignments') { return $this->syncBatchAssignments($request); } $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('PUT'), Apidoc\Url('/users/batch-assignments')] public function syncBatchAssignments(Request $request): JsonResponse { $validated = $request->validate([ 'user_ids' => ['required', 'array', 'min:1'], 'user_ids.*' => ['integer', 'exists:users,id'], 'role_ids' => ['present', 'array'], 'role_ids.*' => ['integer', 'exists:roles,id'], 'permission_ids' => ['present', 'array'], 'permission_ids.*' => ['integer', 'exists:permissions,id'], ]); $userIds = collect($validated['user_ids'])->map(fn (int $id): int => (int) $id)->unique()->values()->all(); $roleIds = collect($validated['role_ids'])->map(fn (int $id): int => (int) $id)->unique()->values()->all(); $permissionIds = collect($validated['permission_ids'])->map(fn (int $id): int => (int) $id)->unique()->values()->all(); $users = User::query()->whereIn('id', $userIds)->get(); foreach ($users as $user) { $user->syncRoles($roleIds); $user->syncPermissions($permissionIds); $this->syncServerResourcePermissionsByDirectPermissions($user, $permissionIds); } $this->auditLog($request, 'users_batch_assignments_update', [ 'metadata' => [ 'target_user_ids' => $userIds, 'role_ids' => $roleIds, 'permission_ids' => $permissionIds, ], ]); return response()->json([ 'code' => 0, 'message' => 'ok', 'data' => [ 'updated_count' => $users->count(), ], ]); } #[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', '密码'])), 'force_password_change' => $this->parseBooleanValue($this->firstMatchedValue($normalizedRow, ['force_password_change', 'require_password_change', '要求更改密码', 'force_change_password'])), ]; $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 $row * @param array $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 parseBooleanValue(string $value): bool { $normalized = strtolower(trim($value)); if ($normalized === '') { return false; } return in_array($normalized, ['1', 'true', 'yes', 'y', 'on', '是', '真'], true); } private function buildUsersImportTemplateXlsx(string $xlsxPath): void { $zip = new \ZipArchive; if ($zip->open($xlsxPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) { throw ValidationException::withMessages(['file' => ['无法生成 xlsx 模板文件。']]); } $contentTypes = '' .'' .'' .'' .'' .'' .''; $rels = '' .'' .'' .''; $workbook = '' .'' .''; $workbookRels = '' .'' .'' .''; $rows = [ ['nickname', 'email', 'phone', 'password', 'role_ids', 'force_password_change'], ['张三', 'zhangsan@example.com', '13800138000', 'Pass@123456', '1', '0'], ['李四', 'lisi@example.com', '13900139000', 'Pass@123456', '1,2', '1'], ]; $sheetData = ''; foreach ($rows as $rowIndex => $cells) { $r = $rowIndex + 1; $sheetData .= ''; foreach ($cells as $colIndex => $cellValue) { $column = chr(ord('A') + $colIndex).$r; $escapedValue = htmlspecialchars((string) $cellValue, ENT_XML1 | ENT_QUOTES, 'UTF-8'); $sheetData .= ''.$escapedValue.''; } $sheetData .= ''; } $sheet = '' .'' .$sheetData .''; $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; } }