CentralFeedbackServer.ps1 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142
  1. [CmdletBinding()]
  2. param(
  3. [string]$ConfigPath = (Join-Path $PSScriptRoot 'config\central-feedback.config.dpapi'),
  4. [string]$AuditPath = (Join-Path $PSScriptRoot 'data\submissions.jsonl')
  5. )
  6. $ErrorActionPreference = 'Stop'
  7. . (Join-Path $PSScriptRoot 'Common.ps1')
  8. Add-Type -AssemblyName System.Net.Http
  9. function Invoke-FeishuJson {
  10. param([System.Net.Http.HttpClient]$Client, [string]$Uri, [object]$Body)
  11. $content = New-Object -TypeName System.Net.Http.StringContent -ArgumentList @(($Body | ConvertTo-Json -Compress -Depth 6), [Text.Encoding]::UTF8, 'application/json')
  12. try {
  13. $response = $Client.PostAsync($Uri, $content).GetAwaiter().GetResult()
  14. $raw = $response.Content.ReadAsStringAsync().GetAwaiter().GetResult()
  15. }
  16. finally { $content.Dispose() }
  17. if (-not $response.IsSuccessStatusCode) { throw (New-Object -TypeName Net.WebException -ArgumentList "Feishu HTTP $([int]$response.StatusCode)") }
  18. $data = $raw | ConvertFrom-Json
  19. if ($data.code -ne 0) { throw (New-Object -TypeName InvalidOperationException -ArgumentList "Feishu code $($data.code)") }
  20. $data
  21. }
  22. function Invoke-FeishuFileSend {
  23. param([pscustomobject]$Config, [byte[]]$FileBytes, [string]$FileName)
  24. $client = New-Object System.Net.Http.HttpClient
  25. try {
  26. $token = Invoke-FeishuJson -Client $client -Uri 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal' -Body @{ app_id = [string]$Config.app_id; app_secret = [string]$Config.app_secret }
  27. $client.DefaultRequestHeaders.Authorization = New-Object -TypeName System.Net.Http.Headers.AuthenticationHeaderValue -ArgumentList @('Bearer', [string]$token.tenant_access_token)
  28. $form = New-Object System.Net.Http.MultipartFormDataContent
  29. try {
  30. $form.Add((New-Object -TypeName System.Net.Http.StringContent -ArgumentList 'stream'), 'file_type')
  31. $form.Add((New-Object -TypeName System.Net.Http.StringContent -ArgumentList @($FileName, [Text.Encoding]::UTF8)), 'file_name')
  32. $fileContent = New-Object -TypeName System.Net.Http.ByteArrayContent -ArgumentList (,$FileBytes)
  33. $fileContent.Headers.ContentType = [System.Net.Http.Headers.MediaTypeHeaderValue]::Parse('application/octet-stream')
  34. $form.Add($fileContent, 'file', $FileName)
  35. $uploadResponse = $client.PostAsync('https://open.feishu.cn/open-apis/im/v1/files', $form).GetAwaiter().GetResult()
  36. $uploadRaw = $uploadResponse.Content.ReadAsStringAsync().GetAwaiter().GetResult()
  37. }
  38. finally { $form.Dispose() }
  39. if (-not $uploadResponse.IsSuccessStatusCode) { throw (New-Object -TypeName Net.WebException -ArgumentList "Feishu upload HTTP $([int]$uploadResponse.StatusCode)") }
  40. $upload = $uploadRaw | ConvertFrom-Json
  41. if ($upload.code -ne 0 -or [string]::IsNullOrWhiteSpace($upload.data.file_key)) { throw (New-Object -TypeName InvalidOperationException -ArgumentList "Feishu upload code $($upload.code)") }
  42. return Invoke-FeishuJson -Client $client -Uri 'https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=chat_id' -Body @{
  43. receive_id = [string]$Config.chat_id
  44. msg_type = 'file'
  45. content = (@{ file_key = [string]$upload.data.file_key; file_name = $FileName } | ConvertTo-Json -Compress)
  46. }
  47. }
  48. finally { $client.Dispose() }
  49. }
  50. function Test-AlreadySent {
  51. param([string]$SubmissionId, [string]$Path)
  52. if (-not (Test-Path -LiteralPath $Path)) { return $false }
  53. return [bool](Select-String -LiteralPath $Path -Pattern ('"submission_id":"' + $SubmissionId + '"') -SimpleMatch -Quiet)
  54. }
  55. function Add-AuditEntry {
  56. param([hashtable]$Entry, [string]$Path)
  57. $directory = Split-Path -Parent $Path
  58. New-Item -ItemType Directory -Force -Path $directory | Out-Null
  59. Add-Content -LiteralPath $Path -Value ($Entry | ConvertTo-Json -Compress) -Encoding UTF8
  60. }
  61. $config = Get-ProtectedConfig -Path $ConfigPath
  62. if (-not $config.listen_prefix -or -not $config.app_id -or -not $config.app_secret -or -not $config.chat_id) { throw 'Server configuration is incomplete.' }
  63. $listener = New-Object Net.HttpListener
  64. $listener.Prefixes.Add([string]$config.listen_prefix)
  65. $listener.Start()
  66. Write-Output "Central feedback sender is listening on $($config.listen_prefix)"
  67. try {
  68. while ($listener.IsListening) {
  69. $context = $listener.GetContext()
  70. try {
  71. $request = $context.Request
  72. if ($request.HttpMethod -eq 'GET' -and $request.Url.AbsolutePath -eq '/health') {
  73. Write-HttpJson -Context $context -StatusCode 200 -Body @{ status = 'ok' }
  74. continue
  75. }
  76. if ($request.HttpMethod -ne 'POST' -or $request.Url.AbsolutePath -ne '/v1/feedback') {
  77. Write-HttpJson -Context $context -StatusCode 404 -Body @{ status = 'not_found' }
  78. continue
  79. }
  80. if ($request.ContentLength64 -le 0 -or $request.ContentLength64 -gt 15MB -or $request.ContentType -notlike 'application/json*') {
  81. Write-HttpJson -Context $context -StatusCode 400 -Body @{ status = 'rejected'; category = 'invalid_request' }
  82. continue
  83. }
  84. $reader = New-Object -TypeName IO.StreamReader -ArgumentList @($request.InputStream, [Text.Encoding]::UTF8)
  85. try { $payload = $reader.ReadToEnd() | ConvertFrom-Json }
  86. finally { $reader.Dispose() }
  87. $projectId = [string]$payload.project_id
  88. $recordVersion = [string]$payload.record_version
  89. $trigger = [string]$payload.trigger
  90. $fileName = [string]$payload.file_name
  91. $metadataChecks = [ordered]@{
  92. # 项目端 Agent 已执行正式 Hook、授权与版本规则;中央端只保留传输与审计所需的最小校验,避免格式差异阻断发送。
  93. project_id = [bool](-not [string]::IsNullOrWhiteSpace($projectId) -and $projectId.Length -le 128)
  94. record_version = [bool](-not [string]::IsNullOrWhiteSpace($recordVersion) -and $recordVersion.Length -le 128)
  95. trigger = [bool](-not [string]::IsNullOrWhiteSpace($trigger) -and $trigger.Length -le 128)
  96. file_name = [bool](-not [string]::IsNullOrWhiteSpace($fileName) -and $fileName.Length -le 200 -and $fileName -match '\.md$' -and [IO.Path]::GetFileName($fileName) -eq $fileName)
  97. }
  98. $invalidFields = @($metadataChecks.GetEnumerator() | Where-Object { -not $_.Value } | ForEach-Object { $_.Key })
  99. if ($invalidFields.Count -gt 0) {
  100. Write-HttpJson -Context $context -StatusCode 400 -Body @{ status = 'rejected'; category = 'invalid_metadata'; invalid_fields = $invalidFields }
  101. continue
  102. }
  103. try { $fileBytes = [Convert]::FromBase64String([string]$payload.file_base64) }
  104. catch { Write-HttpJson -Context $context -StatusCode 400 -Body @{ status = 'rejected'; category = 'invalid_file' }; continue }
  105. try {
  106. if ($fileBytes.Length -eq 0 -or $fileBytes.Length -gt 10MB) { Write-HttpJson -Context $context -StatusCode 400 -Body @{ status = 'rejected'; category = 'invalid_file_size' }; continue }
  107. $fileHash = Get-Sha256Hex -Bytes $fileBytes
  108. $submissionId = Get-Sha256Hex -Bytes ([Text.Encoding]::UTF8.GetBytes("$projectId|$recordVersion|$fileHash"))
  109. # 旧版 Windows/.NET 对 multipart 文件名中的非 ASCII 字符存在兼容性问题;群内使用稳定 ASCII 名称,文件正文仍保留原始中文内容。
  110. $displayFileName = ('{0}-framework-feedback-{1}.md' -f $projectId, $recordVersion)
  111. $displayFileName = $displayFileName -replace '[^A-Za-z0-9._-]', '_'
  112. if ([string]::IsNullOrWhiteSpace($displayFileName)) { throw 'Generated display file name is empty.' }
  113. if (Test-AlreadySent -SubmissionId $submissionId -Path $AuditPath) {
  114. Write-HttpJson -Context $context -StatusCode 200 -Body @{ status = 'already_sent'; submission_id = $submissionId; file_hash = $fileHash }
  115. continue
  116. }
  117. $result = Invoke-FeishuFileSend -Config $config -FileBytes $fileBytes -FileName $displayFileName
  118. $entry = @{ submission_id = $submissionId; project_id = $projectId; record_version = $recordVersion; trigger = $trigger; file_hash = $fileHash; message_id = [string]$result.data.message_id; sent_at = (Get-Date).ToString('o') }
  119. Add-AuditEntry -Entry $entry -Path $AuditPath
  120. Write-HttpJson -Context $context -StatusCode 200 -Body @{ status = 'sent'; submission_id = $submissionId; file_hash = $fileHash; message_id = [string]$result.data.message_id; sent_at = $entry.sent_at }
  121. }
  122. finally { if ($fileBytes) { [Array]::Clear($fileBytes, 0, $fileBytes.Length) } }
  123. }
  124. catch {
  125. Write-Host "Request failed: $($_.Exception.GetType().Name)" -ForegroundColor Yellow
  126. if (-not $context.Response.OutputStream.CanWrite) { continue }
  127. Write-HttpJson -Context $context -StatusCode 500 -Body @{ status = 'not_sent'; category = 'server_or_feishu_error' }
  128. }
  129. }
  130. }
  131. finally {
  132. $listener.Stop()
  133. $listener.Close()
  134. $config.app_secret = $null
  135. }