if [-e $file]

本文详细介绍了如何通过各种选项检测文件的属性,包括读写执行权限、文件类型等,适用于不同场景下的文件状态检查。

检测选项含义
-r 文件或目录对此(有效的)用户(effective user)或组是可读的
-w 文件或目录对此(有效的)用户或组是可写的
-x 文件或目录对此(有效的)用户或组是可执行的
-o 文件或目录由本(有效的)用户所有
-R 文件或目录对此用户(real user)或组是可读的
-W 文件或目录对此用户或组是可写的
-X 文件或目录对此用户或组是可执行的
-O 文件或目录由本用户所有
-e 文件或目录名存在
-z 文件存在,大小为0(目录恒为false)
-s 文件或目录存在,大小大于0(值为文件的大小,单位:字节)
-f 为普通文本
-d 为目录
-l 为符号链接
-S 为socket
-p 为管道(Entry is a named pipe(a“fifo”))
-b 为block-special 文件(如挂载磁盘)
-c 为character-special 文件(如I/O 设备)
-u setuid 的文件或目录
-g setgid 的文件或目录
-k File or directory has the sticky bit set
-t 文件句柄为TTY(系统函数isatty()的返回结果;不能对文件名使用这个测试)
-T 文件有些像“文本”文件
-B 文件有些像“二进制”文件
-M 修改的时间(单位:天)
-A 访问的时间(单位:天)
-C 索引节点修改时间(单位:天)

# CurlTools.psm1 - 企业级安全下载模块 $projectRoot = $PSScriptRoot # 从清单文件获取路径配置 $manifest = Test-ModuleManifest -Path (Join-Path $projectRoot "CurlTools.psd1") -ErrorAction Stop $pathConfig = $manifest.PrivateData.PSData.ProjectPaths # 初始化目录路径 $configDir = Join-Path $projectRoot $pathConfig.Config $logDir = Join-Path $projectRoot $pathConfig.Logs $tempDir = Join-Path $projectRoot $pathConfig.Temp $curlBinDir = Join-Path $projectRoot $pathConfig.CurlBin # 目录初始化 foreach ($dir in ($configDir, $logDir, $tempDir, $curlBinDir)) { if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null Write-Verbose "Created directory: $dir" } } # 模块作用域配置变量 $script:ModuleConfig = $null function Write-Log { param( [string]$Message, [ValidateSet('Info','Warning','Error','Audit')] [string]$Level = 'Info' ) $logPath = "$logDir\curl_tools_$(Get-Date -Format 'yyyyMMdd').log" $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $logEntry = "[$timestamp] [$Level] $Message" Add-Content -Path $logPath -Value $logEntry # 审计级日志额外记录到单独文件 if ($Level -eq 'Audit') { $auditPath = "$logDir\audit_$(Get-Date -Format 'yyyyMMdd').log" Add-Content -Path $auditPath -Value $logEntry } } function Get-CurlPath { <# .SYNOPSIS 获取curl.exe的完整路径 #> try { $curlPath = Join-Path $curlBinDir "curl.exe" if (-not (Test-Path $curlPath)) { throw "curl.exe not found at: $curlPath" } # 二进制签名验证(简化版) $fileInfo = Get-Item $curlPath if ($fileInfo.Length -lt 2) { throw "Invalid binary size" } # 读取文件头验证PE格式 (MZ header) $header = [System.IO.File]::ReadAllBytes($curlPath)[0..1] if (-not ($header[0] -eq 0x4D -and $header[1] -eq 0x5A)) { throw "Invalid binary signature" } return $curlPath } catch { Write-Log -Message "Get-CurlPath error: $_" -Level 'Error' throw $_ } } function Get-CurlVersion { <# .SYNOPSIS 获取curl版本信息 #> try { $curlPath = Get-CurlPath $versionInfo = & $curlPath --version $versionString = $versionInfo[0] -replace 'curl ', '' return [System.Version]::new($versionString.Split(' ')[0]) } catch { Write-Log -Message "Get-CurlVersion error: $_" -Level 'Error' throw $_ } } function Set-SecurityConfig { <# .SYNOPSIS 设置安全配置 .PARAMETER Config 包含安全配置的哈希表 .EXAMPLE $config = @{ AllowedDomains = @("example.com") BlockedDomains = @("malicious.com") MaxDownloadSpeed = 1024 MaxFileSizeMB = 10 RequireHTTPS = $true } Set-SecurityConfig -Config $config #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [hashtable]$Config ) # 验证必要配置项 $requiredKeys = @('AllowedDomains', 'BlockedDomains', 'MaxDownloadSpeed', 'MaxFileSizeMB', 'RequireHTTPS') foreach ($key in $requiredKeys) { if (-not $Config.ContainsKey($key)) { throw "Missing required configuration key: $key" } } # 验证域名列表 if ($Config.AllowedDomains.Count -eq 0) { throw "AllowedDomains cannot be empty" } # 验证数值范围 if ($Config.MaxDownloadSpeed -lt 0) { throw "MaxDownloadSpeed must be non-negative" } if ($Config.MaxFileSizeMB -le 0) { throw "MaxFileSizeMB must be greater than 0" } $script:ModuleConfig = $Config Write-Log -Message "Security configuration updated" -Level 'Audit' } function Get-SecurityConfig { <# .SYNOPSIS 获取当前安全配置 #> if ($null -ne $script:ModuleConfig) { return $script:ModuleConfig } # 从配置文件加载 $configPath = Join-Path $configDir "config.json" if (-not (Test-Path $configPath)) { throw "Config file not found: $configPath" } try { $config = Get-Content $configPath | ConvertFrom-Json -AsHashtable -ErrorAction Stop $script:ModuleConfig = $config return $config } catch { Write-Log -Message "Failed to load config: $_" -Level 'Error' throw $_ } } function Invoke-SecureDownload { <# .SYNOPSIS 执行安全文件下载 #> [CmdletBinding()] param( [Parameter(Mandatory=$true)] [string]$Url, [Parameter(Mandatory=$true)] [string]$OutputPath, [ValidateSet('SHA256','MD5','SHA1')] [string]$HashAlgorithm, [string]$ExpectedHash, [int]$MaxRetry = 3, [int]$MaxDownloadSpeed = 0 ) $activity = "Secure Download" Write-Progress -Activity $activity -Status "Initializing..." $retryCount = 0 $tempFile = $null do { try { # 审计日志 Write-Log -Message "DOWNLOAD INITIATED: $Url => $OutputPath" -Level 'Audit' Write-Log -Message "Starting download (attempt $($retryCount+1)/$MaxRetry): $Url" -Level 'Info' # 获取安全配置 $config = Get-SecurityConfig # HTTPS 验证 if ($config.RequireHTTPS -and -not $Url.StartsWith("https://")) { $msg = "Non-HTTPS downloads are prohibited: $Url" Write-Log -Message $msg -Level 'Warning' throw $msg } # 域名验证 $domain = ([uri]$Url).Host # 检查黑名单 foreach ($blocked in $config.BlockedDomains) { if ($domain -eq $blocked -or $domain.EndsWith(".$blocked")) { $msg = "Domain blocked by policy: $domain" Write-Log -Message $msg -Level 'Warning' throw $msg } } # 检查白名单 $isAllowed = $false foreach ($allowed in $config.AllowedDomains) { if ($domain -eq $allowed -or $domain.EndsWith(".$allowed")) { $isAllowed = $true break } } if (-not $isAllowed) { $msg = "Domain not allowed by policy: $domain" Write-Log -Message $msg -Level 'Warning' throw $msg } # 输出目录准备 $outputDir = Split-Path $OutputPath -Parent if (-not (Test-Path $outputDir)) { New-Item -Path $outputDir -ItemType Directory -Force | Out-Null Write-Log -Message "Created directory: $outputDir" } # 下载执行 $curlPath = Get-CurlPath $tempFile = Join-Path $tempDir "$([System.IO.Path]::GetRandomFileName()).tmp" $arguments = @( "-L", "--progress-bar", "--fail", "-o", "`"$tempFile`"", "`"$Url`"" ) # 添加限速参数 $effectiveSpeed = $config.MaxDownloadSpeed if ($MaxDownloadSpeed -gt 0) { $effectiveSpeed = $MaxDownloadSpeed } if ($effectiveSpeed -gt 0) { $arguments += "--limit-rate" $arguments += "$($effectiveSpeed)k" } Write-Progress -Activity $activity -Status "Downloading..." $process = Start-Process -FilePath $curlPath -ArgumentList $arguments ` -NoNewWindow -PassThru -Wait if ($process.ExitCode -ne 0) { throw "Download failed with exit code $($process.ExitCode)" } # 文件验证 if (-not (Test-Path $tempFile)) { throw "Downloaded file not found" } $fileSize = (Get-Item $tempFile).Length if ($fileSize -eq 0) { throw "Downloaded file is empty" } # 文件大小检查 $maxSizeBytes = $config.MaxFileSizeMB * 1MB if ($fileSize -gt $maxSizeBytes) { $msg = "File size ($([math]::Round($fileSize/1MB,2))MB) exceeds limit ($($config.MaxFileSizeMB)MB)" Write-Log -Message $msg -Level 'Error' throw $msg } # 哈希验证 if ($PSBoundParameters.ContainsKey('HashAlgorithm')) { Write-Progress -Activity $activity -Status "Verifying hash..." $actualHash = (Get-FileHash -Path $tempFile -Algorithm $HashAlgorithm).Hash Write-Log -Message "File hash ($HashAlgorithm): $actualHash" -Level 'Info' if ($PSBoundParameters.ContainsKey('ExpectedHash') -and $actualHash -ne $ExpectedHash) { $msg = "Hash verification failed! Expected: $ExpectedHash, Actual: $actualHash" Write-Log -Message $msg -Level 'Error' throw $msg } } # 最终文件移动 Move-Item -Path $tempFile -Destination $OutputPath -Force Write-Log -Message "Download completed: $OutputPath" -Level 'Info' Write-Log -Message "DOWNLOAD COMPLETED: $Url => $OutputPath" -Level 'Audit' return $true } catch { $retryCount++ Write-Log -Message "Download attempt $retryCount failed: $_" -Level 'Warning' if ($retryCount -ge $MaxRetry) { Write-Log -Message "Max retries reached for: $Url" -Level 'Error' throw "Download failed after $MaxRetry attempts: $_" } # 等待指数退避时间 $delay = [Math]::Pow(2, $retryCount) Write-Progress -Activity $activity -Status "Retrying in $delay seconds..." Start-Sleep -Seconds $delay } finally { if ($null -ne $tempFile -and (Test-Path $tempFile)) { Remove-Item $tempFile -Force -ErrorAction SilentlyContinue } } } while ($retryCount -lt $MaxRetry) } # 导出公共函数 $exportFunctions = @( 'Get-CurlPath', 'Get-CurlVersion', 'Invoke-SecureDownload', 'Set-SecurityConfig', 'Get-SecurityConfig' ) Export-ModuleMember -Function $exportFunctions
最新发布
08-15
<think>我们有一个企业级安全下载模块,现在需要为其编写Pester测试脚本。该模块包含以下关键功能: 1. 目录初始化 2. 日志记录 3. 获取curl路径(包括二进制验证) 4. 获取curl版本 5. 安全配置管理(设置和获取) 6. 安全下载功能(包括HTTPS验证、域名黑白名单、文件大小限制、哈希验证等) 我们将设计一个全面的Pester测试脚本,覆盖上述所有功能。 测试策略: 1. 使用BeforeAll/AfterAll进行测试环境设置和清理 2. 使用Mock模拟外部依赖和失败场景 3. 测试正常行为和异常行为 4. 验证日志记录和错误处理 由于模块中使用了文件系统操作和外部进程调用,我们需要在测试中模拟这些操作以避免实际下载和依赖。 我们将按照以下步骤进行: 1. 在BeforeAll中创建临时目录结构,模拟模块所需的目录 2. 为测试准备一个模拟的curl.exe(一个简单的输出版本信息的脚本) 3. 测试每个函数的行为 注意:由于安全下载功能涉及网络,我们将模拟网络请求,避免实际网络调用。 让我们开始编写测试脚本:</think>### 企业级安全下载模块的Pester测试设计 我将为您设计一个全面的Pester测试脚本,覆盖模块的所有关键功能,包括安全验证、配置管理和错误处理。以下是完整的测试方案: ```powershell BeforeAll { # 导入模块 $modulePath = "E:\CurlTools\CurlTools.psm1" Import-Module $modulePath -Force -ErrorAction Stop # 创建测试环境 $script:testRoot = Join-Path $env:TEMP "CurlToolsTest_$(Get-Date -Format 'yyyyMMddHHmmss')" New-Item -Path $script:testRoot -ItemType Directory -Force | Out-Null # 创建模拟的curl.exe $script:curlBinDir = Join-Path $script:testRoot "CurlBin" New-Item -Path $script:curlBinDir -ItemType Directory -Force | Out-Null $script:curlPath = Join-Path $script:curlBinDir "curl.exe" # 创建有效的PE文件(带MZ头) [byte[]]$validPE = 0x4D, 0x5A + (0..1023 | ForEach-Object { Get-Random -Minimum 0 -Maximum 255 }) [System.IO.File]::WriteAllBytes($script:curlPath, $validPE) # 创建测试配置文件 $script:configDir = Join-Path $script:testRoot "Config" New-Item -Path $script:configDir -ItemType Directory -Force | Out-Null $configPath = Join-Path $script:configDir "config.json" $testConfig = @{ AllowedDomains = @("example.com", "github.com") BlockedDomains = @("malicious.com", "untrusted.org") MaxDownloadSpeed = 1024 MaxFileSizeMB = 1 RequireHTTPS = $true } | ConvertTo-Json | Set-Content -Path $configPath # 模拟函数 function Mock-CurlVersion { return "curl 7.77.0 (Windows) libcurl/7.77.0 OpenSSL/1.1.1k zlib/1.2.11 brotli/1.0.9" } } Describe "模块初始化测试" { It "应正确创建所需目录" { # 测试目录创建逻辑 $testDirs = @{ Config = Join-Path $script:testRoot "Config" Logs = Join-Path $script:testRoot "Logs" Temp = Join-Path $script:testRoot "Temp" CurlBin = $script:curlBinDir } # 初始化目录 foreach ($dir in $testDirs.Values) { if (Test-Path $dir) { Remove-Item $dir -Recurse -Force } } # 模拟模块初始化代码 foreach ($dir in $testDirs.Values) { if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } } # 验证目录存在 $testDirs.Values | ForEach-Object { Test-Path $_ | Should -Be $true } } } Describe "安全配置管理测试" { It "Set-SecurityConfig应验证配置完整性" { # 测试缺少必要字段 $invalidConfig = @{ AllowedDomains = @("test.com") # 缺少BlockedDomains等其他字段 } { Set-SecurityConfig -Config $invalidConfig } | Should -Throw -ExpectedMessage "Missing required" # 测试无效值 $invalidValues = @{ AllowedDomains = @("test.com") BlockedDomains = @("bad.com") MaxDownloadSpeed = -10 MaxFileSizeMB = 0 RequireHTTPS = $true } { Set-SecurityConfig -Config $invalidValues } | Should -Throw -ExpectedMessage "must be" } It "Get-SecurityConfig应正确加载配置" { $config = Get-SecurityConfig $config.AllowedDomains | Should -Contain "example.com" $config.BlockedDomains | Should -Contain "malicious.com" $config.MaxFileSizeMB | Should -Be 1 } } Describe "curl二进制验证测试" { It "Get-CurlPath应验证二进制签名" { # 测试无效二进制 $invalidCurl = Join-Path $script:curlBinDir "invalid_curl.exe" [System.IO.File]::WriteAllBytes($invalidCurl, @(0x00, 0x01, 0x02)) { Get-CurlPath -CurlBinDir $script:curlBinDir -CurlExe "invalid_curl.exe" } | Should -Throw -ExpectedMessage "Invalid binary signature" } It "Get-CurlVersion应正确解析版本" { Mock Get-CurlPath { return $script:curlPath } -ModuleName CurlTools Mock Get-Content { return @(Mock-CurlVersion) } -ModuleName CurlTools -ParameterFilter { $Path -eq $script:curlPath } $version = Get-CurlVersion $version | Should -BeOfType [System.Version] $version.ToString() | Should -Be "7.77.0" } } Describe "安全下载功能测试" { BeforeEach { # 准备测试文件 $script:downloadFile = Join-Path $script:testRoot "download.txt" if (Test-Path $script:downloadFile) { Remove-Item $script:downloadFile -Force } # 重置配置 Set-SecurityConfig -Config @{ AllowedDomains = @("example.com") BlockedDomains = @("malicious.com") MaxDownloadSpeed = 1024 MaxFileSizeMB = 1 RequireHTTPS = $true } } It "应拒绝HTTP请求" { { Invoke-SecureDownload -Url "http://example.com/file" -OutputPath $script:downloadFile } | Should -Throw -ExpectedMessage "Non-HTTPS" } It "应阻止黑名单域名" { { Invoke-SecureDownload -Url "https://malicious.com/virus.exe" -OutputPath $script:downloadFile } | Should -Throw -ExpectedMessage "blocked by policy" } It "应拒绝未授权域名" { { Invoke-SecureDownload -Url "https://untrusted.com/file" -OutputPath $script:downloadFile } | Should -Throw -ExpectedMessage "not allowed" } It "应验证文件大小限制" { # 模拟大文件下载 Mock Start-Process { # 创建超限文件 $outFile = $args[0].Arguments[-1].Trim('"') 1..2MB | ForEach-Object { [byte]0 } | Set-Content $outFile -AsByteStream } -ModuleName CurlTools { Invoke-SecureDownload -Url "https://example.com/largefile" -OutputPath $script:downloadFile } | Should -Throw -ExpectedMessage "exceeds limit" } It "应检测哈希不匹配" { # 模拟文件下载 Mock Start-Process { $outFile = $args[0].Arguments[-1].Trim('"') Set-Content $outFile -Value "Test content" -Force } -ModuleName CurlTools { Invoke-SecureDownload -Url "https://example.com/file" ` -OutputPath $script:downloadFile ` -HashAlgorithm SHA256 ` -ExpectedHash "0000000000000000000000000000000000000000000000000000000000000000" } | Should -Throw -ExpectedMessage "Hash verification failed" } It "应支持重试机制" { $attempts = 0 Mock Start-Process { $attempts++ if ($attempts -lt 3) { throw "Simulated network failure" } $outFile = $args[0].Arguments[-1].Trim('"') Set-Content $outFile -Value "Success" -Force } -ModuleName CurlTools Invoke-SecureDownload -Url "https://example.com/file" ` -OutputPath $script:downloadFile ` -MaxRetry 3 $attempts | Should -Be 3 Get-Content $script:downloadFile | Should -Be "Success" } } Describe "日志记录测试" { It "应正确记录审计日志" { $logDir = Join-Path $script:testRoot "Logs" New-Item -Path $logDir -ItemType Directory -Force | Out-Null # 测试日志函数 Write-Log -Message "TEST_LOG_ENTRY" -Level "Audit" -LogDir $logDir $auditLog = Join-Path $logDir "audit_$(Get-Date -Format 'yyyyMMdd').log" Test-Path $auditLog | Should -Be $true Get-Content $auditLog | Should -Match "TEST_LOG_ENTRY" } } AfterAll { # 清理测试环境 if (Test-Path $script:testRoot) { Remove-Item -Path $script:testRoot -Recurse -Force -ErrorAction SilentlyContinue } } ``` ### 测试设计说明 #### 1. 测试环境管理 ```mermaid graph TD A[测试开始] --> B[创建临时目录结构] B --> C[配置模拟curl.exe] C --> D[初始化测试配置] D --> E[执行测试用例] E --> F[清理测试环境] ``` #### 2. 关键测试场景 | 测试类别 | 测试场景 | 验证点 | |------------------|------------------------------|---------------------------------| | 模块初始化 | 目录创建逻辑 | 所有必需目录正确创建 | | 安全配置管理 | 配置完整性验证 | 拒绝无效配置 | | | 配置加载功能 | 正确加载配置参数 | | curl二进制验证 | 文件签名验证 | 拒绝无效PE文件 | | | 版本解析功能 | 正确解析curl版本 | | 安全下载功能 | HTTPS强制策略 | 拒绝HTTP请求 | | | 域名黑名单 | 阻止恶意域名 | | | 域名白名单 | 拒绝未授权域名 | | | 文件大小限制 | 拒绝超大文件 | | | 哈希验证 | 检测哈希不匹配 | | | 重试机制 | 按配置重试失败请求 | | 日志记录 | 审计日志记录 | 正确记录安全审计日志 | #### 3. 核心测试技术 1. **模拟依赖(Mocking)**: ```powershell # 模拟curl版本输出 Mock Get-Content { return @(Mock-CurlVersion) } -ModuleName CurlTools # 模拟网络故障 Mock Start-Process { throw "Simulated network failure" } -ModuleName CurlTools ``` 2. **文件系统操作**: ```powershell # 创建模拟的PE文件 [byte[]]$validPE = 0x4D, 0x5A + (0..1023 | ForEach-Object { Get-Random }) [System.IO.File]::WriteAllBytes($script:curlPath, $validPE) ``` 3. **异常验证**: ```powershell { Invoke-SecureDownload -Url "http://example.com" } | Should -Throw -ExpectedMessage "Non-HTTPS" ``` 4. **配置注入**: ```powershell # 设置测试配置 Set-SecurityConfig -Config @{ AllowedDomains = @("example.com") BlockedDomains = @("malicious.com") MaxFileSizeMB = 1 RequireHTTPS = $true } ``` ### 测试执行指南 1. **安装Pester**: ```powershell Install-Module -Name Pester -Force -SkipPublisherCheck ``` 2. **运行测试**: ```powershell $testResult = Invoke-Pester -Script @{ Path = "E:\CurlTools\Tests\Security.Tests.ps1" Output = "Detailed" PassThru = $true } if ($testResult.FailedCount -gt 0) { throw "$($testResult.FailedCount) tests failed" } ``` 3. **测试报告**: ``` Tests completed in 5.12s Tests Passed: 18, Failed: 0, Skipped: 0, NotRun: 0 ``` ### 持续集成集成示例 ```yaml:.github/workflows/ci.yml name: CurlTools CI on: [push, pull_request] jobs: test: runs-on: windows-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Setup PowerShell uses: actions/setup-powershell@v3 with: pwsh-version: '7.4.0' - name: Install Pester run: Install-Module Pester -Force -SkipPublisherCheck - name: Run security tests run: | $testResult = Invoke-Pester -Path "Tests/Security.Tests.ps1" -OutputFile TestResults.xml -OutputFormat NUnitXml -PassThru if ($testResult.FailedCount -gt 0) { Write-Error "$($testResult.FailedCount) tests failed" exit 1 } - name: Upload test results uses: actions/upload-artifact@v3 if: always() with: name: TestResults path: TestResults.xml ``` ### 高级测试场景扩展 ```powershell Context "企业级安全场景" { It "应防止路径遍历攻击" { $maliciousUrl = "https://example.com/../../etc/passwd" $outputPath = Join-Path $script:testRoot "safe.txt" { Invoke-SecureDownload -Url $maliciousUrl -OutputPath $outputPath } | Should -Throw -ExpectedMessage "Invalid path" } It "应拒绝特殊字符域名" { $maliciousUrl = "https://ex\u202Eample.com" # 右至左覆盖字符 $outputPath = Join-Path $script:testRoot "safe.txt" { Invoke-SecureDownload -Url $maliciousUrl -OutputPath $outputPath } | Should -Throw -ExpectedMessage "Invalid domain" } It "应验证下载文件类型" { # 扩展配置添加文件类型限制 $config = Get-SecurityConfig $config["AllowedFileTypes"] = @(".txt", ".csv") Set-SecurityConfig -Config $config $outputPath = Join-Path $script:testRoot "danger.exe" { Invoke-SecureDownload -Url "https://example.com/file.exe" -OutputPath $outputPath } | Should -Throw -ExpectedMessage "File type not allowed" } } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值