diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 7cd9ea0..a806da8 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -11,7 +11,11 @@ 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 @@ -20,7 +24,7 @@ class UserController extends Controller { $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']); + $this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'import', 'importTemplate']); } #[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')] @@ -114,6 +118,335 @@ class UserController extends Controller 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 $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 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'], + ['张三', '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 .= ''; + 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()