param( [string]$VideoRoot = 'D:\视频库', [string]$BaseUrl = 'http://192.168.1.101:9000', [string]$OutputPath = '', [string]$AdminUsername = 'admin', [string]$AdminPassword = 'admin123', [string]$DefaultUserPassword = 'Pass@123456', [string]$RedisHost = '192.168.1.101', [int]$RedisPort = 6379, [string]$RedisPassword = '', [switch]$ReuseExistingUsers ) $ErrorActionPreference = 'Stop' if ([string]::IsNullOrWhiteSpace($OutputPath)) { $OutputPath = Join-Path $PSScriptRoot 'VIDEO_IMPORT_RESULTS.json' } function New-OrderedMap { return [ordered]@{} } function ConvertTo-PlainObject { param([object]$Value) if ($null -eq $Value) { return $null } if ($Value -is [System.Collections.IDictionary]) { $result = [ordered]@{} foreach ($key in $Value.Keys) { $result[$key] = ConvertTo-PlainObject -Value $Value[$key] } return $result } if ($Value -is [System.Collections.IEnumerable] -and -not ($Value -is [string])) { $items = @() foreach ($item in $Value) { $items += ,(ConvertTo-PlainObject -Value $item) } return $items } $type = $Value.GetType() if ($type.FullName -like 'System.Management.Automation.PSCustomObject' -or ($type.Namespace -and $type.Namespace -notlike 'System*')) { $result = [ordered]@{} foreach ($prop in $Value.PSObject.Properties) { $result[$prop.Name] = ConvertTo-PlainObject -Value $prop.Value } return $result } return $Value } function Save-Report { param([hashtable]$Report) $plain = ConvertTo-PlainObject -Value $Report $plain | ConvertTo-Json -Depth 20 | Set-Content -Path $OutputPath -Encoding utf8BOM } function Invoke-JsonApi { param( [Parameter(Mandatory = $true)][string]$Method, [Parameter(Mandatory = $true)][string]$Url, [hashtable]$Headers = @{}, $Body = $null, [int]$TimeoutSec = 180 ) $params = @{ Method = $Method Uri = $Url Headers = $Headers UseBasicParsing = $true SkipHttpErrorCheck = $true TimeoutSec = $TimeoutSec } if ($null -ne $Body) { $params.ContentType = 'application/json' if ($Body -is [string]) { $params.Body = $Body } else { $params.Body = ($Body | ConvertTo-Json -Depth 20) } } try { $response = Invoke-WebRequest @params $json = $null if ($response.Content) { try { $json = $response.Content | ConvertFrom-Json -Depth 50 } catch { $json = $null } } return [pscustomobject]@{ HttpStatus = [int]$response.StatusCode Json = $json Raw = $response.Content } } catch { return [pscustomobject]@{ HttpStatus = -1 Json = $null Raw = $_.Exception.Message } } } function Invoke-UploadApi { param( [Parameter(Mandatory = $true)][string]$Url, [Parameter(Mandatory = $true)][string]$FilePath, [hashtable]$Headers = @{}, [int]$TimeoutSec = 7200 ) $handler = $null $httpClient = $null $fileStream = $null try { $handler = New-Object System.Net.Http.HttpClientHandler $httpClient = New-Object System.Net.Http.HttpClient($handler) $httpClient.Timeout = [TimeSpan]::FromSeconds($TimeoutSec) foreach ($headerKey in $Headers.Keys) { [void]$httpClient.DefaultRequestHeaders.TryAddWithoutValidation($headerKey, [string]$Headers[$headerKey]) } $multipart = New-Object System.Net.Http.MultipartFormDataContent $fileStream = [System.IO.File]::OpenRead($FilePath) $fileContent = New-Object System.Net.Http.StreamContent($fileStream) $mimeType = switch ([System.IO.Path]::GetExtension($FilePath).ToLowerInvariant()) { '.mp4' { 'video/mp4' } '.mov' { 'video/quicktime' } '.mkv' { 'video/x-matroska' } '.avi' { 'video/x-msvideo' } '.webm' { 'video/webm' } '.m4v' { 'video/x-m4v' } '.flv' { 'video/x-flv' } default { 'video/mp4' } } $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse($mimeType) $multipart.Add($fileContent, 'file', [System.IO.Path]::GetFileName($FilePath)) $response = $httpClient.PostAsync($Url, $multipart).GetAwaiter().GetResult() $raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult() $json = $null if ($raw) { try { $json = $raw | ConvertFrom-Json -Depth 50 } catch { $json = $null } } return [pscustomobject]@{ HttpStatus = [int]$response.StatusCode Json = $json Raw = $raw } } catch { return [pscustomobject]@{ HttpStatus = -1 Json = $null Raw = $_.Exception.Message } } finally { if ($fileStream) { $fileStream.Dispose() } if ($httpClient) { $httpClient.Dispose() } if ($handler) { $handler.Dispose() } } } function Read-DotEnv { param([string]$Path) $map = @{} if (-not (Test-Path -LiteralPath $Path)) { return $map } foreach ($line in Get-Content -LiteralPath $Path) { if ([string]::IsNullOrWhiteSpace($line) -or $line.TrimStart().StartsWith('#')) { continue } $parts = $line -split '=', 2 if ($parts.Count -eq 2) { $map[$parts[0].Trim()] = $parts[1].Trim() } } return $map } function Read-RedisLine { param([System.IO.Stream]$Stream) $bytes = New-Object System.Collections.Generic.List[byte] while ($true) { $b = $Stream.ReadByte() if ($b -lt 0) { throw 'Redis 连接已关闭' } if ($b -eq 13) { $next = $Stream.ReadByte() if ($next -ne 10) { throw 'Redis 响应格式错误' } break } $bytes.Add([byte]$b) } return [System.Text.Encoding]::UTF8.GetString($bytes.ToArray()) } function Send-RedisCommand { param( [System.IO.Stream]$Stream, [string[]]$RedisArgs ) $builder = New-Object System.Text.StringBuilder [void]$builder.Append("*$($RedisArgs.Count)`r`n") foreach ($arg in $RedisArgs) { $bytes = [System.Text.Encoding]::UTF8.GetBytes($arg) [void]$builder.Append("$" + $bytes.Length + "`r`n") [void]$builder.Append($arg + "`r`n") } $payload = [System.Text.Encoding]::UTF8.GetBytes($builder.ToString()) $Stream.Write($payload, 0, $payload.Length) $Stream.Flush() $prefix = [char]$Stream.ReadByte() switch ($prefix) { '+' { return Read-RedisLine -Stream $Stream } '-' { throw "Redis 错误: $(Read-RedisLine -Stream $Stream)" } ':' { return [int64](Read-RedisLine -Stream $Stream) } '$' { $length = [int](Read-RedisLine -Stream $Stream) if ($length -lt 0) { return $null } $buffer = New-Object byte[] $length $offset = 0 while ($offset -lt $length) { $read = $Stream.Read($buffer, $offset, $length - $offset) if ($read -le 0) { throw 'Redis bulk string 读取失败' } $offset += $read } $null = Read-RedisLine -Stream $Stream return [System.Text.Encoding]::UTF8.GetString($buffer) } '*' { $count = [int](Read-RedisLine -Stream $Stream) $items = @() for ($i = 0; $i -lt $count; $i++) { $nestedPrefix = [char]$Stream.ReadByte() if ($nestedPrefix -ne '$') { throw "暂不支持的 Redis 数组子类型: $nestedPrefix" } $len = [int](Read-RedisLine -Stream $Stream) if ($len -lt 0) { $items += $null continue } $buf = New-Object byte[] $len $offset = 0 while ($offset -lt $len) { $read = $Stream.Read($buf, $offset, $len - $offset) if ($read -le 0) { throw 'Redis 数组元素读取失败' } $offset += $read } $null = Read-RedisLine -Stream $Stream $items += [System.Text.Encoding]::UTF8.GetString($buf) } return $items } default { throw "未知 Redis 响应前缀: $prefix" } } } function Get-AvailableRedisPasswords { $candidates = New-Object System.Collections.Generic.List[string] foreach ($candidate in @( $RedisPassword, 'redis_HA_Cluster_2024', 'replace-with-strong-redis-password', 'redis_EsJ8FX' )) { if (-not [string]::IsNullOrWhiteSpace($candidate) -and -not $candidates.Contains($candidate)) { $candidates.Add($candidate) } } return $candidates } function Get-RedisValue { param( [Parameter(Mandatory = $true)][string]$Key, [int]$RedisDbIndex = 0 ) $passwords = Get-AvailableRedisPasswords $lastError = $null foreach ($passwordCandidate in $passwords) { $tcp = $null try { $tcp = [System.Net.Sockets.TcpClient]::new() $tcp.ReceiveTimeout = 5000 $tcp.SendTimeout = 5000 $tcp.Connect($RedisHost, $RedisPort) $stream = $tcp.GetStream() [void](Send-RedisCommand -Stream $stream -RedisArgs @('AUTH', $passwordCandidate)) if ($RedisDbIndex -gt 0) { [void](Send-RedisCommand -Stream $stream -RedisArgs @('SELECT', $RedisDbIndex.ToString())) } $value = Send-RedisCommand -Stream $stream -RedisArgs @('GET', $Key) $tcp.Close() return $value } catch { $lastError = $_.Exception.Message if ($tcp) { $tcp.Close() } } } throw "读取 Redis 键失败: $Key;host=${RedisHost}:$RedisPort;lastError=$lastError" } function Get-CaptchaCode { param( [Parameter(Mandatory = $true)][string]$CaptchaUrl, [Parameter(Mandatory = $true)][string]$RedisKeyPrefix, [int]$RedisDb = 0 ) $captchaResp = Invoke-JsonApi -Method GET -Url $CaptchaUrl if ($captchaResp.HttpStatus -ne 200 -or -not $captchaResp.Json -or -not $captchaResp.Json.data) { throw "获取验证码失败: $($captchaResp.Raw)" } $captchaKey = [string]$captchaResp.Json.data.captchaKey $code = Get-RedisValue -Key "$RedisKeyPrefix$captchaKey" -RedisDbIndex $RedisDb return [pscustomobject]@{ CaptchaKey = $captchaKey CaptchaCode = ([string]$code).Trim('"').Trim() } } function Ensure-ApiSuccess { param( [Parameter(Mandatory = $true)]$Response, [Parameter(Mandatory = $true)][string]$Action ) if ($Response.HttpStatus -ne 200) { throw "$Action 失败,HTTP=$($Response.HttpStatus),响应=$($Response.Raw)" } if (-not $Response.Json) { throw "$Action 失败,响应不是 JSON: $($Response.Raw)" } if ($Response.Json.code -ne 200) { throw "$Action 失败,API code=$($Response.Json.code),message=$($Response.Json.message)" } } function New-RandomPhone { $digits = -join ((0..9) | Get-Random -Count 9) return "13$digits" } function New-Username { param( [string]$CategoryName, [int]$Index ) $stamp = (Get-Date).ToString('MMddHHmm') return ("u{0}{1:D2}" -f $stamp, $Index) } function Normalize-VideoTitle { param([string]$FileNameWithoutExtension) $title = $FileNameWithoutExtension.Trim() if ($title.Length -lt 5) { $title = ($title + ' 视频素材').Substring(0, [Math]::Min(($title + ' 视频素材').Length, 100)) } if ($title.Length -gt 100) { $title = $title.Substring(0, 100) } return $title } function Get-UserSession { param( [string]$Username, [string]$Password ) $captcha = Get-CaptchaCode -CaptchaUrl "$BaseUrl/auth/captcha" -RedisKeyPrefix 'captcha:' -RedisDb 0 $loginResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/auth/login" -Body @{ username = $Username password = $Password captcha = $captcha.CaptchaCode captchaKey = $captcha.CaptchaKey } Ensure-ApiSuccess -Response $loginResp -Action "用户登录($Username)" $accessToken = [string]$loginResp.Json.data.accessToken $headers = @{ Authorization = "Bearer $accessToken" } $currentResp = Invoke-JsonApi -Method GET -Url "$BaseUrl/auth/current" -Headers $headers Ensure-ApiSuccess -Response $currentResp -Action "读取当前用户($Username)" return [pscustomobject]@{ Username = $Username Password = $Password UserId = [long]$currentResp.Json.data.id Headers = $headers AccessToken = $accessToken } } function Get-ReusedUsersByCategory { param([string]$ReportPath) if (-not (Test-Path -LiteralPath $ReportPath)) { throw "复用用户失败,报告文件不存在: $ReportPath" } $json = Get-Content -LiteralPath $ReportPath -Raw | ConvertFrom-Json -Depth 50 $map = @{} foreach ($entry in @($json.users)) { $categoryName = [string]$entry.categoryName if (-not $map.ContainsKey($categoryName)) { $map[$categoryName] = New-Object System.Collections.Generic.List[object] } $session = Get-UserSession -Username ([string]$entry.username) -Password ([string]$entry.password) $session | Add-Member -NotePropertyName Nickname -NotePropertyValue ([string]$entry.nickname) -Force $session | Add-Member -NotePropertyName CategoryName -NotePropertyValue $categoryName -Force $map[$categoryName].Add($session) } return $map } function Register-ImportUser { param( [string]$CategoryName, [int]$UserIndex, [string]$Nickname ) $username = New-Username -CategoryName $CategoryName -Index $UserIndex $phone = New-RandomPhone $email = "$username@example.com" $captcha = Get-CaptchaCode -CaptchaUrl "$BaseUrl/auth/captcha" -RedisKeyPrefix 'captcha:' -RedisDb 0 $registerResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/auth/register" -Body @{ username = $username password = $DefaultUserPassword confirmPassword = $DefaultUserPassword email = $email phone = $phone captcha = $captcha.CaptchaCode captchaKey = $captcha.CaptchaKey } Ensure-ApiSuccess -Response $registerResp -Action "注册用户($username)" $session = Get-UserSession -Username $username -Password $DefaultUserPassword $profileResp = Invoke-JsonApi -Method PUT -Url "$BaseUrl/user/profile" -Headers $session.Headers -Body @{ nickname = $Nickname signature = "批量导入账号 - $CategoryName" region = '南京' gender = 0 } Ensure-ApiSuccess -Response $profileResp -Action "更新资料($username)" $session | Add-Member -NotePropertyName Nickname -NotePropertyValue $Nickname $session | Add-Member -NotePropertyName CategoryName -NotePropertyValue $CategoryName return $session } function Get-AdminSession { $captcha = Get-CaptchaCode -CaptchaUrl "$BaseUrl/admin/auth/captcha" -RedisKeyPrefix 'admin:captcha:' -RedisDb 5 $loginResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/admin/auth/login" -Body @{ username = $AdminUsername password = $AdminPassword captcha = $captcha.CaptchaCode captchaKey = $captcha.CaptchaKey } Ensure-ApiSuccess -Response $loginResp -Action '管理员登录' $token = [string]$loginResp.Json.data.token $headers = @{ Authorization = "Bearer $token" } $infoResp = Invoke-JsonApi -Method GET -Url "$BaseUrl/admin/auth/info" -Headers $headers Ensure-ApiSuccess -Response $infoResp -Action '获取管理员信息' return [pscustomobject]@{ Headers = $headers AdminId = [long]$infoResp.Json.data.id Username = $AdminUsername } } function Get-CategoryMap { $resp = Invoke-JsonApi -Method GET -Url "$BaseUrl/content/category/list" Ensure-ApiSuccess -Response $resp -Action '读取分类列表' $map = @{} foreach ($item in @($resp.Json.data)) { $map[[string]$item.name] = $item } return $map } function Get-VideoFilesForFolder { param([string]$FolderPath) $extensions = @('.mp4', '.mov', '.mkv', '.avi', '.flv', '.webm', '.m4v') return @(Get-ChildItem -LiteralPath $FolderPath -File | Where-Object { $extensions -contains $_.Extension.ToLowerInvariant() } | Sort-Object Name) } $report = [ordered]@{ generatedAt = (Get-Date).ToString('s') baseUrl = $BaseUrl videoRoot = $VideoRoot outputPath = $OutputPath userPassword = $DefaultUserPassword summary = [ordered]@{ categoryCount = 0 expectedUsers = 0 createdUsers = 0 expectedVideos = 0 uploadedVideos = 0 reviewedVideos = 0 failedVideos = 0 } categories = @() users = @() errors = @() } if (-not (Test-Path -LiteralPath $VideoRoot)) { throw "视频根目录不存在: $VideoRoot" } $categoryFolders = @(Get-ChildItem -LiteralPath $VideoRoot -Directory | Sort-Object Name) if ($categoryFolders.Count -ne 5) { throw "期望在 $VideoRoot 下找到 5 个分类目录,实际找到 $($categoryFolders.Count) 个" } $categoryMap = Get-CategoryMap $adminSession = Get-AdminSession $reusedUsersByCategory = $null if ($ReuseExistingUsers) { $reusedUsersByCategory = Get-ReusedUsersByCategory -ReportPath $OutputPath $report.users = @() foreach ($categoryUsers in $reusedUsersByCategory.Values) { foreach ($user in $categoryUsers) { $report.users += [ordered]@{ categoryName = $user.CategoryName username = $user.Username nickname = $user.Nickname password = $user.Password userId = $user.UserId assignedVideoCount = $null } } } $report.summary.createdUsers = $report.users.Count } Save-Report -Report $report $report.summary.categoryCount = $categoryFolders.Count $report.summary.expectedUsers = $categoryFolders.Count * 2 $report.summary.expectedVideos = $categoryFolders.Count * 5 $report.summary.uploadedVideos = 0 $report.summary.reviewedVideos = 0 $report.summary.failedVideos = 0 $report.categories = @() $report.errors = @() $globalUserIndex = 1 foreach ($folder in $categoryFolders) { $categoryName = $folder.Name if (-not $categoryMap.ContainsKey($categoryName)) { throw "平台中未找到分类: $categoryName" } $videoFiles = Get-VideoFilesForFolder -FolderPath $folder.FullName if ($videoFiles.Count -ne 5) { throw "分类 [$categoryName] 期望有 5 个视频,实际找到 $($videoFiles.Count) 个" } $platformCategory = $categoryMap[$categoryName] $categoryReport = [ordered]@{ categoryName = $categoryName categoryId = [long]$platformCategory.id folderPath = $folder.FullName userAssignments = @() } $report.categories += $categoryReport Save-Report -Report $report $assignmentSpecs = @( @{ UserLabel = 'A'; VideoIndexes = @(0, 1); VideoCount = 2 }, @{ UserLabel = 'B'; VideoIndexes = @(2, 3, 4); VideoCount = 3 } ) $reusedCategoryUsers = $null if ($ReuseExistingUsers) { if (-not $reusedUsersByCategory.ContainsKey($categoryName) -or $reusedUsersByCategory[$categoryName].Count -ne 2) { throw "分类 [$categoryName] 没有可复用的 2 个用户" } $reusedCategoryUsers = $reusedUsersByCategory[$categoryName].ToArray() } $assignmentIndex = 0 foreach ($assignmentSpec in $assignmentSpecs) { if ($ReuseExistingUsers) { $userSession = $reusedCategoryUsers[$assignmentIndex] $nickname = $userSession.Nickname } else { $nickname = "$categoryName-导入用户$($assignmentSpec.UserLabel)" $userSession = Register-ImportUser -CategoryName $categoryName -UserIndex $globalUserIndex -Nickname $nickname $globalUserIndex++ $report.summary.createdUsers++ $report.users += [ordered]@{ categoryName = $categoryName username = $userSession.Username nickname = $nickname password = $userSession.Password userId = $userSession.UserId assignedVideoCount = $assignmentSpec.VideoCount } } $userAssignmentReport = [ordered]@{ userId = $userSession.UserId username = $userSession.Username nickname = $nickname assignedVideoCount = $assignmentSpec.VideoCount videos = @() } foreach ($videoIndex in $assignmentSpec.VideoIndexes) { $file = $videoFiles[$videoIndex] $videoReport = [ordered]@{ sourceFile = $file.FullName fileName = $file.Name ownerUserId = $userSession.UserId ownerUsername = $userSession.Username status = 'processing' upload = $null create = $null review = $null publicCheck = $null error = $null } try { $uploadResp = Invoke-UploadApi -Url "$BaseUrl/content/video/upload" -FilePath $file.FullName -Headers $userSession.Headers Ensure-ApiSuccess -Response $uploadResp -Action "上传视频($($file.Name))" $videoTitle = Normalize-VideoTitle -FileNameWithoutExtension $file.BaseName $createResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/content/video" -Headers $userSession.Headers -Body @{ title = $videoTitle description = "批量导入视频;分类:$categoryName;源文件:$($file.Name)" categoryId = [long]$platformCategory.id tagIds = @() coverUrl = $uploadResp.Json.data.coverUrl videoUrl = $uploadResp.Json.data.videoUrl duration = $uploadResp.Json.data.duration width = $uploadResp.Json.data.width height = $uploadResp.Json.data.height format = $uploadResp.Json.data.format size = $uploadResp.Json.data.size } Ensure-ApiSuccess -Response $createResp -Action "创建视频($($file.Name))" $videoId = [long]$createResp.Json.data $reviewResp = Invoke-JsonApi -Method POST -Url "$BaseUrl/admin/platform/content/video/review/$videoId?status=1&reason=bulk-import" -Headers $adminSession.Headers Ensure-ApiSuccess -Response $reviewResp -Action "审核视频($videoId)" $publicCheckResp = Invoke-JsonApi -Method GET -Url "$BaseUrl/content/video/public/$videoId" Ensure-ApiSuccess -Response $publicCheckResp -Action "校验公开视频($videoId)" $videoReport.status = 'completed' $videoReport.upload = [ordered]@{ videoUrl = $uploadResp.Json.data.videoUrl coverUrl = $uploadResp.Json.data.coverUrl duration = $uploadResp.Json.data.duration width = $uploadResp.Json.data.width height = $uploadResp.Json.data.height format = $uploadResp.Json.data.format size = $uploadResp.Json.data.size } $videoReport.create = [ordered]@{ videoId = $videoId title = $videoTitle categoryId = [long]$platformCategory.id } $videoReport.review = [ordered]@{ status = 1 reviewerAdminId = $adminSession.AdminId } $videoReport.publicCheck = [ordered]@{ videoId = $videoId apiCode = $publicCheckResp.Json.code } $report.summary.uploadedVideos++ $report.summary.reviewedVideos++ } catch { $videoReport.status = 'failed' $videoReport.error = $_.Exception.Message $report.summary.failedVideos++ $report.errors += "[$categoryName][$($userSession.Username)][$($file.Name)] $($_.Exception.Message)" } $userAssignmentReport.videos += $videoReport Save-Report -Report $report } $categoryReport.userAssignments += $userAssignmentReport $assignmentIndex++ Save-Report -Report $report } Save-Report -Report $report } $report.generatedAt = (Get-Date).ToString('s') Save-Report -Report $report $report | ConvertTo-Json -Depth 20