✨ feat(server): 增加混淆发布链路与调试页静态说明

- 新增 Obfuscar 混淆发布脚本、发布测试与产物隐私检查
- 调整 VS Code 发布任务和宿主发布配置,统一走混淆流程
- 为 debug 页面内嵌静态接口说明,并补充对应集成测试
- 更新 README 与设计计划文档,记录跨平台发布约束
This commit is contained in:
2026-06-22 15:55:34 +08:00
parent ac41d04a54
commit 070d2ba5c0
13 changed files with 1570 additions and 49 deletions

5
.gitignore vendored
View File

@@ -400,3 +400,8 @@ Config/Data/*
.dotnet-home/*
codex-dotnet-home/*
.dotnet-sdk8/*
# Flyshot 混淆发布产物、映射文件和临时工作目录
outputs/obfuscated/
outputs/obfuscation-maps/
.tmp/obfuscation/

15
.vscode/tasks.json vendored
View File

@@ -142,19 +142,16 @@
}
},
{
// Release 配置发布到 publish/,用于现场部署包打包
// 调用环境中的 Obfuscar,生成 Windows/Linux 两套自包含混淆发布包
"label": "publish",
"command": "dotnet",
"command": "pwsh",
"type": "process",
"args": [
"publish",
"${workspaceFolder}/src/Flyshot.Server.Host/Flyshot.Server.Host.csproj",
"-c",
"Release",
"-o",
"${workspaceFolder}/publish"
"-NoProfile",
"-File",
"${workspaceFolder}/scripts/Publish-Obfuscated.ps1"
],
"problemMatcher": "$msCompile"
"problemMatcher": []
}
]
}

View File

@@ -67,6 +67,7 @@
- [x] 将 `ExecuteTrajectory` / `ExecuteFlyShotTraj` 接入 FANUC 运行时链路
- [x] 落地 Web 状态页
- [x] 落地浏览器内 OpenAPI 自动驱动的接口调试页(`/debug`),与 `Swagger:Enabled` 同步可见
- [x] 调试页的人类可读说明文本已静态内嵌到 `debug.html`,即使发布产物删除 XML 文档也能继续展示接口提示
- [x] 固化 `10010 / 10012 / 60015` FANUC 基础协议帧编解码,确认 `10010` 状态帧为 90B
- [x] 使用本地 TCP/UDP 模拟器覆盖命令通道、状态通道和 J519 基础收发
- [x] 补齐 `Get/SetSpeedRatio`、`Get/SetTCP`、`Get/SetIO` 真机命令体与响应解析
@@ -122,6 +123,48 @@
- [ ] 增加运行日志、协议摘要日志和状态快照导出,便于现场排查。
7. 发布与部署
- [x] 增加 Obfuscar 跨平台混淆发布脚本,全部 `Flyshot.*` 自研程序集会生成一份不区分运行平台的 .NET 8 框架依赖部署包。
- [ ] 固化 Windows / Linux 启动脚本和 systemd 服务配置。
- [ ] 补充生产配置模板、端口说明和现场部署检查表。
- [ ] 给 Web 状态页增加程序状态和最近报警显示;J519 状态位已通过快照和状态页显示。
## 混淆发布
发布脚本直接依赖构建环境,不在仓库中引用或还原 Obfuscar 包。首次使用前安装全局工具:
```powershell
dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50
```
安装后确认 `obfuscar.console` 已加入当前终端的 `PATH`,然后在仓库根目录执行:
```powershell
pwsh -NoProfile -File .\scripts\Publish-Obfuscated.ps1
```
也可以在 VS Code 中运行 `publish` 任务。脚本不再接受 RID 或运行平台参数,默认生成:
- `outputs/obfuscated/framework-dependent/`:Windows 与 Linux 共用的 .NET 8 框架依赖混淆部署包。
- `outputs/obfuscation-maps/framework-dependent/Mapping.txt`:仅供内部还原异常堆栈使用,不得交付给用户。
Visual Studio 的 `FolderProfile` 已接入同一套混淆流程。在“发布”页面选择 `FolderProfile` 并点击“发布”后,Visual Studio 会生成 .NET 8 框架依赖多文件包,再自动混淆发布目录中的全部 `Flyshot.*.dll`。如果环境缺少 `obfuscar.console`、缺少对应的 .NET 8 共享框架或混淆失败,Visual Studio 发布会直接失败,不会静默留下未保护产物。
混淆范围包含发布目录中的全部 `Flyshot.*.dll` 自研程序集,并校验当前八个核心程序集不可缺失;后续新增的 `Flyshot.*` 模块也会自动纳入。第三方程序集不会被处理;公共 API、属性和事件名称会保留,以维持 ASP.NET Core 控制器发现、依赖注入、配置绑定、JSON 序列化和 Swagger 行为,内部类型、私有方法及字段会执行重命名。
若环境缺少 `obfuscar.console`、发布目录缺少任一自研程序集、混淆器返回失败或未生成完整输出,脚本会返回非零退出码并停止,不会把未完整保护的产物作为成功结果交付。
混淆完成后会删除发布目录中的 `Flyshot*.xml` XML 文档和 `Flyshot*.pdb` 调试符号,避免反编译器自动显示源码文档注释。生产环境 Swagger 因此不再展示来自 XML 文档的接口说明,但接口路由和调用不受影响。
启动部署包时必须先进入对应 RID 的发布目录,确保宿主能从当前目录加载 `appsettings.json`、`NLog.config` 和 `Config/`:
```powershell
Set-Location .\outputs\obfuscated\framework-dependent
dotnet .\Flyshot.Server.Host.dll
```
```bash
cd outputs/obfuscated/framework-dependent
dotnet Flyshot.Server.Host.dll
```
Windows 与 Linux 目标机器都必须预先安装 .NET 8 ASP.NET Core Runtime。

View File

@@ -0,0 +1,55 @@
# Flyshot 跨平台混淆发布 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 为全部 `Flyshot.*` 自研程序集建立依赖环境 Obfuscar 的跨平台框架依赖混淆发布流程。
**Architecture:** 使用一个 PowerShell 入口编排 `dotnet publish`、绝对路径 Obfuscar XML 生成、混淆执行、产物完整性检查和映射文件隔离。发布脚本不修改项目运行时代码,也不引入混淆器包依赖。
**Tech Stack:** PowerShell 7、.NET 8 SDK、Obfuscar.GlobalTool 2.2.50、ASP.NET Core 自包含发布。
---
### Task 1: 发布脚本契约测试
**Files:**
- Create: `tests/Publishing/Publish-Obfuscated.Tests.ps1`
- [ ] 编写测试夹具,创建包含八个 `Flyshot.*.dll` 和一个第三方 DLL 的临时发布目录。
- [ ] 调用尚不存在的配置生成入口,断言测试因缺少 `scripts/Publish-Obfuscated.ps1` 而失败。
- [ ] 保留测试入口,使后续实现可以在 `-GenerateConfigOnly` 模式下验证配置内容而不执行真实发布。
### Task 2: 双平台混淆发布脚本
**Files:**
- Create: `scripts/Publish-Obfuscated.ps1`
- [ ] 定义带完整注释的参数、静态程序集清单和辅助函数。
- [ ] 检查 PowerShell、`dotnet` 与 `obfuscar.console` 环境命令。
- [ ] 使用 `dotnet publish -c Release -r <rid> --self-contained true -p:PublishSingleFile=false` 生成原始产物。
- [ ] 为八个 `Flyshot.*.dll` 生成使用绝对路径的 Obfuscar XML。
- [ ] 执行 Obfuscar,并检查每个输入模块都有混淆输出。
- [ ] 将混淆 DLL/PDB 替换进发布目录,并将 `Mapping.txt` 隔离到 `outputs/obfuscation-maps/<rid>/`。
- [ ] 不传 RID,生成一份 Windows/Linux 共用的 .NET 8 框架依赖发布包。
### Task 3: 发布任务与文档
**Files:**
- Modify: `.vscode/tasks.json`
- Modify: `README.md`
- [ ] 将 VS Code `publish` 任务改为调用 PowerShell 混淆发布脚本。
- [ ] 在 README 发布章节记录环境安装、发布命令、输出目录和保护边界。
### Task 4: 验证
**Files:**
- Modify: `docs/superpowers/plans/2026-06-22-cross-platform-obfuscation.md`
- [ ] 运行脚本契约测试并确认通过。
- [ ] 运行 `dotnet test FlyshotReplacement.sln -c Release -v minimal`。
- [ ] 运行 `dotnet build FlyshotReplacement.sln -c Release --no-restore -v minimal`。
- [ ] 环境安装 Obfuscar 后执行 `pwsh -File scripts/Publish-Obfuscated.ps1`。
- [ ] 启动 Windows 混淆产物并请求 `/healthz`。
- [ ] 检查 Linux 混淆产物结构;存在 WSL 时启动并请求 `/healthz`。
- [ ] 检查 `git diff`,确认没有第三方程序集或映射文件进入部署包。

View File

@@ -0,0 +1,44 @@
# Flyshot 跨平台混淆发布设计
## 目标
为全部 `Flyshot.*` 自研程序集增加统一的 Obfuscar 混淆发布流程,生成一份 Windows 与 Linux 共用的 .NET 8 框架依赖部署包。混淆流程必须显式失败,不能在工具缺失或混淆失败时留下看似成功的未保护发布包。
## 工具与环境边界
- 使用环境中安装的 Obfuscar 全局工具,命令名为 `obfuscar.console`。
- 仓库不增加 Obfuscar NuGet `PackageReference`,也不增加 `.config/dotnet-tools.json`。
- 构建环境需预先执行 `dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50`。
- 发布脚本只依赖 PowerShell 7、.NET 8 SDK 和环境中的 Obfuscar。
## 发布结构
发布脚本不接受运行平台或 RID,固定执行一次框架依赖发布:
1. 使用 `dotnet publish` 生成自包含、多文件的原始发布目录。
2. 动态生成包含绝对路径的 Obfuscar XML 配置。
3. 校验当前八个核心程序集不可缺失,并将发布目录中的全部 `Flyshot.*.dll` 加入混淆模块列表。
4. Obfuscar 输出到独立临时目录。
5. 仅在混淆成功且输出程序集完整时,用混淆程序集替换原始发布目录中的对应文件。
6. 将映射文件移入不随部署包交付的 `outputs/obfuscation-maps/<rid>/`。
7. 最终部署包输出到 `outputs/obfuscated/framework-dependent/`。
## 混淆边界
- `KeepPublicApi=true`:保留公共 API 名称,保护 ASP.NET Core 控制器发现、配置绑定、DI、JSON DTO、Swagger 和程序集间调用。
- `HidePrivateApi=true`:重命名内部类型、私有方法和字段。
- `RenameProperties=false`、`RenameEvents=false`:避免配置绑定、JSON 序列化和框架约定受属性或事件重命名影响。
- 不处理 `Microsoft.*`、`System.*`、NLog、Swashbuckle 等第三方程序集。
- 所有 `Flyshot.*.dll` 必须被发现并加入配置;缺少任何当前核心程序集都视为发布失败,未来新增模块会自动纳入。
## 验证
- 脚本契约测试验证:RID 列表、环境工具缺失失败、全部自研程序集进入配置、第三方程序集不进入配置、映射文件不进入部署目录。
- 执行现有解决方案测试与 Release 构建。
- 真实执行框架依赖发布及混淆,并分别在 Windows 与 Linux 的 .NET 8 运行时上启动。
- Windows 包实际启动并请求 `/healthz`。
- Linux 包至少验证 ELF 启动文件、依赖文件和全部混淆程序集完整;环境存在 WSL 时再实际启动并请求 `/healthz`。
## 风险与限制
Obfuscar 能显著提高普通反编译的阅读成本,但不能绝对阻止有经验的逆向分析。保留公共 API 是当前 ASP.NET Core 稳定运行所需的安全边界;若未来需要更强的控制流混淆、字符串加密或防篡改,应评估商业混淆器。

View File

@@ -0,0 +1,635 @@
<#
.SYNOPSIS
发布并混淆 Flyshot 跨平台框架依赖部署包。
.DESCRIPTION
脚本先通过 dotnet publish 生成不区分运行平台的框架依赖产物,再调用环境中安装的
Obfuscar 全局工具混淆全部 Flyshot.* 自研程序集。第三方程序集不会进入混淆模块,
映射文件会保存到独立目录,不会随部署包交付。目标机器需要安装 .NET 8 运行时。
.PARAMETER OutputRoot
最终混淆部署包目录。
.PARAMETER MappingRoot
Obfuscar 映射文件的保存根目录。该目录不得作为部署包交付。
.PARAMETER GenerateConfigOnly
仅生成 Obfuscar 配置,供自动化测试和问题诊断使用,不执行发布或混淆。
.PARAMETER InputDirectory
GenerateConfigOnly 模式下的程序集输入目录。
.PARAMETER ObfuscatedDirectory
GenerateConfigOnly 模式下的混淆输出目录。
.PARAMETER ConfigPath
GenerateConfigOnly 模式下生成的 Obfuscar XML 文件路径。
.PARAMETER ObfuscateExistingPublish
仅混淆已经由 Visual Studio 或 dotnet publish 生成的现有发布目录,不再次执行发布。
.PARAMETER ExistingPublishDirectory
ObfuscateExistingPublish 模式下需要原地替换自研程序集的发布目录。
#>
[CmdletBinding()]
param(
[string]$OutputRoot,
[string]$MappingRoot,
[switch]$GenerateConfigOnly,
[string]$InputDirectory,
[string]$ObfuscatedDirectory,
[string]$ConfigPath,
[switch]$ObfuscateExistingPublish,
[string]$ExistingPublishDirectory
)
$ErrorActionPreference = 'Stop'
Set-StrictMode -Version Latest
# 该清单固定了当前解决方案必须保护的全部自研程序集,避免新增或丢失模块时静默发布。
$script:FlyshotAssemblyNames = @(
'Flyshot.Server.Host.dll'
'Flyshot.ControllerClientCompat.dll'
'Flyshot.Core.Config.dll'
'Flyshot.Core.Domain.dll'
'Flyshot.Core.Planning.dll'
'Flyshot.Core.Triggering.dll'
'Flyshot.Runtime.Fanuc.dll'
'Flyshot.Runtime.Common.dll'
)
<#
.SYNOPSIS
将路径转换为规范化绝对路径。
.PARAMETER Path
需要规范化的文件或目录路径,可以是相对路径或绝对路径。
.OUTPUTS
返回由 System.IO.Path 解析后的绝对路径字符串。
#>
function ConvertTo-AbsolutePath
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Path
)
return [System.IO.Path]::GetFullPath($Path)
}
<#
.SYNOPSIS
检查发布目录是否包含全部预期的 Flyshot 自研程序集。
.PARAMETER Directory
待检查的发布输入目录。
.OUTPUTS
按固定清单顺序返回每个程序集的绝对路径。
.EXCEPTION
当目录不存在或任一预期程序集缺失时抛出异常,阻止生成未完整保护的发布包。
#>
function Get-FlyshotAssemblyPaths
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$Directory
)
$absoluteDirectory = ConvertTo-AbsolutePath -Path $Directory
if (-not (Test-Path -LiteralPath $absoluteDirectory -PathType Container))
{
throw "程序集输入目录不存在:$absoluteDirectory"
}
foreach ($assemblyName in $script:FlyshotAssemblyNames)
{
$assemblyPath = Join-Path $absoluteDirectory $assemblyName
if (-not (Test-Path -LiteralPath $assemblyPath -PathType Leaf))
{
throw "发布目录缺少必须混淆的自研程序集:$assemblyName"
}
}
# 固定清单负责阻止当前核心模块缺失,通配发现负责自动覆盖未来新增的 Flyshot 自研程序集。
$assemblyPaths = Get-ChildItem -LiteralPath $absoluteDirectory -Filter 'Flyshot.*.dll' -File |
Sort-Object Name |
ForEach-Object { ConvertTo-AbsolutePath -Path $_.FullName }
return @($assemblyPaths)
}
<#
.SYNOPSIS
发现当前环境中与项目目标框架匹配的 .NET 8 共享框架目录。
.DESCRIPTION
框架依赖发布不会把 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 的程序集复制到发布目录。
Obfuscar 仍需要这些目录解析继承关系,因此通过 dotnet --list-runtimes 动态发现实际安装位置。
.OUTPUTS
返回 Microsoft.NETCore.App 与 Microsoft.AspNetCore.App 最新 .NET 8 运行时的绝对目录。
.EXCEPTION
当环境未安装所需的 .NET 8 共享框架时抛出异常,避免生成无法解析依赖的混淆配置。
#>
function Get-DotNetSharedFrameworkSearchPaths
{
[CmdletBinding()]
param()
$runtimeEntries = & dotnet --list-runtimes
if ($LASTEXITCODE -ne 0)
{
throw "无法查询已安装的 .NET 运行时,dotnet --list-runtimes 退出码为 $LASTEXITCODE。"
}
$requiredFrameworks = @(
'Microsoft.NETCore.App'
'Microsoft.AspNetCore.App'
)
$searchPaths = foreach ($frameworkName in $requiredFrameworks)
{
$candidates = foreach ($runtimeEntry in $runtimeEntries)
{
if ($runtimeEntry -match '^(?<name>\S+)\s+(?<version>8\.\d+\.\d+)\s+\[(?<root>.+)\]$')
{
if ($Matches.name -eq $frameworkName)
{
[pscustomobject]@{
Version = [version]$Matches.version
Path = Join-Path $Matches.root $Matches.version
}
}
}
}
$selectedRuntime = $candidates |
Sort-Object Version -Descending |
Select-Object -First 1
if ($null -eq $selectedRuntime -or -not (Test-Path -LiteralPath $selectedRuntime.Path -PathType Container))
{
throw "环境缺少 Obfuscar 解析依赖所需的 .NET 8 共享框架:$frameworkName"
}
ConvertTo-AbsolutePath -Path $selectedRuntime.Path
}
return @($searchPaths)
}
<#
.SYNOPSIS
生成仅包含 Flyshot 自研程序集的 Obfuscar XML 配置。
.PARAMETER SourceDirectory
包含发布程序集及其依赖项的输入目录。
.PARAMETER TargetDirectory
Obfuscar 写入混淆程序集的独立输出目录。
.PARAMETER DestinationPath
要写入的 Obfuscar XML 配置文件路径。
.OUTPUTS
返回生成后的 Obfuscar XML 配置绝对路径。
#>
function New-ObfuscarConfiguration
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$SourceDirectory,
[Parameter(Mandatory)]
[string]$TargetDirectory,
[Parameter(Mandatory)]
[string]$DestinationPath
)
$absoluteSourceDirectory = ConvertTo-AbsolutePath -Path $SourceDirectory
$absoluteTargetDirectory = ConvertTo-AbsolutePath -Path $TargetDirectory
$absoluteDestinationPath = ConvertTo-AbsolutePath -Path $DestinationPath
$assemblyPaths = Get-FlyshotAssemblyPaths -Directory $absoluteSourceDirectory
$assemblySearchPaths = Get-DotNetSharedFrameworkSearchPaths
$destinationParent = Split-Path -Parent $absoluteDestinationPath
New-Item -ItemType Directory -Path $destinationParent -Force | Out-Null
New-Item -ItemType Directory -Path $absoluteTargetDirectory -Force | Out-Null
$settings = [ordered]@{
InPath = $absoluteSourceDirectory
OutPath = $absoluteTargetDirectory
KeepPublicApi = 'true'
HidePrivateApi = 'true'
RenameProperties = 'false'
RenameEvents = 'false'
RenameFields = 'true'
SkipGenerated = 'true'
SkipSpecialName = 'true'
}
$xmlSettings = [System.Xml.XmlWriterSettings]::new()
$xmlSettings.Indent = $true
$xmlSettings.Encoding = [System.Text.UTF8Encoding]::new($false)
$writer = [System.Xml.XmlWriter]::Create($absoluteDestinationPath, $xmlSettings)
try
{
$writer.WriteStartDocument()
$writer.WriteStartElement('Obfuscator')
foreach ($setting in $settings.GetEnumerator())
{
$writer.WriteStartElement('Var')
$writer.WriteAttributeString('name', [string]$setting.Key)
$writer.WriteAttributeString('value', [string]$setting.Value)
$writer.WriteEndElement()
}
foreach ($assemblySearchPath in $assemblySearchPaths)
{
# 框架依赖发布不携带共享框架 DLL,Obfuscar 通过这些目录解析 ASP.NET Core 与运行时依赖。
$writer.WriteStartElement('AssemblySearchPath')
$writer.WriteAttributeString('path', $assemblySearchPath)
$writer.WriteEndElement()
}
foreach ($assemblyPath in $assemblyPaths)
{
# 使用明确的绝对路径,兼容 Obfuscar 当前版本并避免工作目录差异。
$writer.WriteStartElement('Module')
$writer.WriteAttributeString('file', $assemblyPath)
# 匿名类型会被 ASP.NET Core 的 System.Text.Json 反射序列化,必须完整保留类型及构造参数元数据。
$writer.WriteStartElement('SkipType')
$writer.WriteAttributeString('name', '^<>f__AnonymousType.*')
$writer.WriteAttributeString('skipMethods', 'true')
$writer.WriteAttributeString('skipFields', 'true')
$writer.WriteAttributeString('skipProperties', 'true')
$writer.WriteAttributeString('skipEvents', 'true')
$writer.WriteEndElement()
$writer.WriteEndElement()
}
$writer.WriteEndElement()
$writer.WriteEndDocument()
}
finally
{
$writer.Dispose()
}
return $absoluteDestinationPath
}
<#
.SYNOPSIS
确认发布依赖的外部命令已安装并可从 PATH 调用。
.PARAMETER CommandName
要检查的命令名称。
.PARAMETER InstallHint
命令缺失时展示给构建人员的安装提示。
#>
function Assert-EnvironmentCommand
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$CommandName,
[Parameter(Mandatory)]
[string]$InstallHint
)
if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue))
{
throw "环境缺少命令 '$CommandName'。$InstallHint"
}
}
<#
.SYNOPSIS
执行外部命令并在非零退出码时终止发布。
.PARAMETER FilePath
需要执行的外部命令。
.PARAMETER ArgumentList
按顺序传入外部命令的参数数组。
#>
function Invoke-CheckedCommand
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$FilePath,
[Parameter(Mandatory)]
[string[]]$ArgumentList
)
& $FilePath @ArgumentList
if ($LASTEXITCODE -ne 0)
{
throw "命令执行失败,退出码为 $LASTEXITCODE:$FilePath $($ArgumentList -join ' ')"
}
}
<#
.SYNOPSIS
将 Obfuscar 输出安全替换进发布目录并隔离映射文件。
.PARAMETER PublishedDirectory
dotnet publish 生成的最终部署目录。
.PARAMETER ObfuscatedOutputDirectory
Obfuscar 独立输出目录。
.PARAMETER RuntimeMappingDirectory
框架依赖发布对应的映射文件保存目录。
#>
function Install-ObfuscatedAssemblies
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PublishedDirectory,
[Parameter(Mandatory)]
[string]$ObfuscatedOutputDirectory,
[Parameter(Mandatory)]
[string]$RuntimeMappingDirectory
)
$assemblyNames = Get-FlyshotAssemblyPaths -Directory $PublishedDirectory |
ForEach-Object { [System.IO.Path]::GetFileName($_) }
foreach ($assemblyName in $assemblyNames)
{
$obfuscatedAssemblyPath = Join-Path $ObfuscatedOutputDirectory $assemblyName
if (-not (Test-Path -LiteralPath $obfuscatedAssemblyPath -PathType Leaf))
{
throw "Obfuscar 未生成预期程序集:$assemblyName"
}
}
foreach ($assemblyName in $assemblyNames)
{
Copy-Item `
-LiteralPath (Join-Path $ObfuscatedOutputDirectory $assemblyName) `
-Destination (Join-Path $PublishedDirectory $assemblyName) `
-Force
$pdbName = [System.IO.Path]::ChangeExtension($assemblyName, '.pdb')
$obfuscatedPdbPath = Join-Path $ObfuscatedOutputDirectory $pdbName
if (Test-Path -LiteralPath $obfuscatedPdbPath -PathType Leaf)
{
Copy-Item `
-LiteralPath $obfuscatedPdbPath `
-Destination (Join-Path $PublishedDirectory $pdbName) `
-Force
}
}
$mappingPath = Join-Path $ObfuscatedOutputDirectory 'Mapping.txt'
if (-not (Test-Path -LiteralPath $mappingPath -PathType Leaf))
{
throw 'Obfuscar 未生成 Mapping.txt,无法确认混淆映射结果。'
}
New-Item -ItemType Directory -Path $RuntimeMappingDirectory -Force | Out-Null
Copy-Item -LiteralPath $mappingPath -Destination (Join-Path $RuntimeMappingDirectory 'Mapping.txt') -Force
# 部署目录不得包含符号映射,避免把重命名关系直接交付给最终用户。
$publishedMappingPath = Join-Path $PublishedDirectory 'Mapping.txt'
if (Test-Path -LiteralPath $publishedMappingPath)
{
Remove-Item -LiteralPath $publishedMappingPath -Force
}
# XML 文档包含源码 /// 注释,PDB 包含调试符号;生产混淆包不得向反编译器暴露这些辅助信息。
Get-ChildItem -LiteralPath $PublishedDirectory -File |
Where-Object {
$_.Name -like 'Flyshot*.xml' -or
$_.Name -like 'Flyshot*.pdb'
} |
Remove-Item -Force
}
<#
.SYNOPSIS
发布并混淆 Flyshot 框架依赖主程序。
.PARAMETER RepositoryRoot
Flyshot Replacement 仓库根目录。
.PARAMETER PublishedOutputDirectory
最终混淆部署包目录。
.PARAMETER MappingOutputRoot
全部混淆映射文件的根目录。
#>
function Publish-ObfuscatedFrameworkDependent
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$RepositoryRoot,
[Parameter(Mandatory)]
[string]$PublishedOutputDirectory,
[Parameter(Mandatory)]
[string]$MappingOutputRoot
)
$hostProject = Join-Path $RepositoryRoot 'src\Flyshot.Server.Host\Flyshot.Server.Host.csproj'
$workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\framework-dependent'
$obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated'
$configurationPath = Join-Path $workingDirectory 'obfuscar.xml'
$runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent'
# 每次发布使用干净目录,避免旧程序集或旧映射掩盖本轮失败。
foreach ($directory in @($PublishedOutputDirectory, $workingDirectory, $runtimeMappingDirectory))
{
if (Test-Path -LiteralPath $directory)
{
Remove-Item -LiteralPath $directory -Recurse -Force
}
}
New-Item -ItemType Directory -Path $PublishedOutputDirectory -Force | Out-Null
Write-Host '正在发布跨平台框架依赖包 ...'
Invoke-CheckedCommand -FilePath 'dotnet' -ArgumentList @(
'publish'
$hostProject
'-c'
'Release'
'--self-contained'
'false'
'-p:PublishSingleFile=false'
'-p:UseAppHost=false'
'-p:DebugType=none'
'-o'
$PublishedOutputDirectory
)
New-ObfuscarConfiguration `
-SourceDirectory $PublishedOutputDirectory `
-TargetDirectory $obfuscatedOutputDirectory `
-DestinationPath $configurationPath | Out-Null
Write-Host '正在混淆框架依赖包中的 Flyshot 自研程序集 ...'
Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath)
Install-ObfuscatedAssemblies `
-PublishedDirectory $PublishedOutputDirectory `
-ObfuscatedOutputDirectory $obfuscatedOutputDirectory `
-RuntimeMappingDirectory $runtimeMappingDirectory
Write-Host "已生成跨平台框架依赖混淆包:$PublishedOutputDirectory"
}
<#
.SYNOPSIS
混淆 Visual Studio 已生成的现有发布目录。
.PARAMETER PublishedDirectory
包含全部发布文件的目录;混淆成功后会原地替换其中的 Flyshot 自研程序集。
.PARAMETER RepositoryRoot
Flyshot Replacement 仓库根目录。
.PARAMETER MappingOutputRoot
混淆映射文件的根目录;映射文件不会进入发布目录。
#>
function Protect-ExistingPublishedDirectory
{
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string]$PublishedDirectory,
[Parameter(Mandatory)]
[string]$RepositoryRoot,
[Parameter(Mandatory)]
[string]$MappingOutputRoot
)
$absolutePublishedDirectory = ConvertTo-AbsolutePath -Path $PublishedDirectory
$workingDirectory = Join-Path $RepositoryRoot '.tmp\obfuscation\visual-studio\framework-dependent'
$obfuscatedOutputDirectory = Join-Path $workingDirectory 'obfuscated'
$configurationPath = Join-Path $workingDirectory 'obfuscar.xml'
$runtimeMappingDirectory = Join-Path $MappingOutputRoot 'framework-dependent'
if (-not (Test-Path -LiteralPath $absolutePublishedDirectory -PathType Container))
{
throw "Visual Studio 发布目录不存在:$absolutePublishedDirectory"
}
# 后处理必须使用独立输出目录,避免 Obfuscar 读写同一个程序集导致文件锁或半成品。
foreach ($directory in @($workingDirectory, $runtimeMappingDirectory))
{
if (Test-Path -LiteralPath $directory)
{
Remove-Item -LiteralPath $directory -Recurse -Force
}
}
New-ObfuscarConfiguration `
-SourceDirectory $absolutePublishedDirectory `
-TargetDirectory $obfuscatedOutputDirectory `
-DestinationPath $configurationPath | Out-Null
Write-Host "正在混淆 Visual Studio 发布目录中的 Flyshot 自研程序集:$absolutePublishedDirectory"
Invoke-CheckedCommand -FilePath 'obfuscar.console' -ArgumentList @($configurationPath)
Install-ObfuscatedAssemblies `
-PublishedDirectory $absolutePublishedDirectory `
-ObfuscatedOutputDirectory $obfuscatedOutputDirectory `
-RuntimeMappingDirectory $runtimeMappingDirectory
Write-Host "Visual Studio 发布目录混淆完成:$absolutePublishedDirectory"
}
try
{
if ($GenerateConfigOnly)
{
foreach ($requiredValue in @{
InputDirectory = $InputDirectory
ObfuscatedDirectory = $ObfuscatedDirectory
ConfigPath = $ConfigPath
}.GetEnumerator())
{
if ([string]::IsNullOrWhiteSpace([string]$requiredValue.Value))
{
throw "GenerateConfigOnly 模式缺少参数:$($requiredValue.Key)"
}
}
New-ObfuscarConfiguration `
-SourceDirectory $InputDirectory `
-TargetDirectory $ObfuscatedDirectory `
-DestinationPath $ConfigPath | Out-Null
exit 0
}
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path
if ([string]::IsNullOrWhiteSpace($OutputRoot))
{
$OutputRoot = Join-Path $repositoryRoot 'outputs\obfuscated\framework-dependent'
}
if ([string]::IsNullOrWhiteSpace($MappingRoot))
{
$MappingRoot = Join-Path $repositoryRoot 'outputs\obfuscation-maps'
}
Assert-EnvironmentCommand -CommandName 'dotnet' -InstallHint '请安装仓库 global.json 指定的 .NET SDK。'
Assert-EnvironmentCommand `
-CommandName 'obfuscar.console' `
-InstallHint '请执行:dotnet tool install --global Obfuscar.GlobalTool --version 2.2.50'
if ($ObfuscateExistingPublish)
{
if ([string]::IsNullOrWhiteSpace($ExistingPublishDirectory))
{
throw 'ObfuscateExistingPublish 模式缺少参数:ExistingPublishDirectory'
}
Protect-ExistingPublishedDirectory `
-PublishedDirectory $ExistingPublishDirectory `
-RepositoryRoot $repositoryRoot `
-MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot)
exit 0
}
Publish-ObfuscatedFrameworkDependent `
-RepositoryRoot $repositoryRoot `
-PublishedOutputDirectory (ConvertTo-AbsolutePath -Path $OutputRoot) `
-MappingOutputRoot (ConvertTo-AbsolutePath -Path $MappingRoot)
Write-Host 'Flyshot 跨平台框架依赖混淆发布包已生成。'
exit 0
}
catch
{
Write-Error $_
exit 1
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<!-- 生成 XML 文档以便 Swashbuckle 把控制器/DTO 上的 /// summary 注释注入 OpenAPI 文档,
调试页和 Swagger UI 的端点标题都依赖这一份文档。1591 抑制掉 “缺失 XML 注释” 的噪音。 -->
<!-- 生成 XML 文档以便 Swashbuckle 把控制器/DTO 上的 /// summary 注释注入 OpenAPI 文档;
调试页说明文本已经静态内嵌在 debug.html 中,这份 XML 现在只服务 Swagger UI。1591 抑制掉 “缺失 XML 注释” 的噪音。 -->
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>

View File

@@ -40,7 +40,8 @@ try
Version = swaggerOptions.Version
});
// 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;调试页据此渲染端点标题。
// 把控制器与 DTO 上的 /// summary 注释纳入 OpenAPI 文档;当前主要服务 Swagger UI,
// 调试页的人类可读说明已经静态内嵌在 debug.html 中,不再依赖发布目录中的 XML 文档。
var xmlDocumentationPath = Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml");
if (File.Exists(xmlDocumentationPath))
{

View File

@@ -2,6 +2,7 @@
const DEBUG_CONFIG_URL = "/api/debug/config";
const STORAGE_PREFIX = "flyshot.debug.";
const HISTORY_LIMIT = 10;
const STATIC_DOCS_ELEMENT_ID = "debug-static-docs";
const groupTitleByPrefix = [
// 基础与状态分组:探活和状态快照两个固定 API 路径
@@ -13,7 +14,8 @@ const groupTitleByPrefix = [
const state = {
spec: null,
operations: [],
history: []
history: [],
staticDocsByKey: loadStaticDocs()
};
const requestBodySamples = {
@@ -34,6 +36,29 @@ function escapeHtml(value) {
});
}
/** 为 METHOD PATH 文档块构造统一索引键。 */
function buildOperationKey(method, path) {
return String(method || "").toUpperCase() + " " + String(path || "");
}
/** 读取 debug.html 中内嵌的静态说明 JSON,并建立 METHOD PATH 索引。 */
function loadStaticDocs() {
const docNode = document.getElementById(STATIC_DOCS_ELEMENT_ID);
if (!docNode) return {};
try {
const parsed = JSON.parse(docNode.textContent || "{}");
const indexed = {};
Object.keys(parsed).forEach(function (key) {
indexed[key] = parsed[key];
});
return indexed;
} catch (error) {
console.warn("读取静态调试说明失败:", error);
return {};
}
}
/** 解析 OpenAPI 中的 $ref 引用,仅支持本地 components.schemas 形式。 */
function resolveRef(ref) {
if (!ref || !state.spec) return null;
@@ -49,34 +74,49 @@ function resolveRef(ref) {
return cursor;
}
/** 展开 schema,统一处理 $ref / oneOf / anyOf / allOf。 */
function resolveSchema(schema, depth) {
depth = depth || 0;
if (!schema || depth > 4) return null;
if (schema.$ref) {
return resolveSchema(resolveRef(schema.$ref), depth + 1);
}
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return resolveSchema(schema.oneOf[0], depth + 1);
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return resolveSchema(schema.anyOf[0], depth + 1);
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
const merged = { type: "object", properties: {}, required: [] };
schema.allOf.forEach(function (subSchema) {
const resolved = resolveSchema(subSchema, depth + 1);
if (!resolved) return;
if (resolved.properties) Object.assign(merged.properties, resolved.properties);
if (Array.isArray(resolved.required)) {
resolved.required.forEach(function (name) {
if (merged.required.indexOf(name) < 0) merged.required.push(name);
});
}
});
return merged;
}
return schema;
}
/** 根据 schema 生成默认 JSON 模板,用于自动填充请求体编辑器。 */
function buildSampleFromSchema(schema, depth) {
depth = depth || 0;
// 防御递归:复杂自引用 schema 在 4 层后停下,避免栈爆。
if (!schema || depth > 4) return null;
if (schema.$ref) {
const resolved = resolveRef(schema.$ref);
return resolved ? buildSampleFromSchema(resolved, depth + 1) : null;
}
const resolvedSchema = resolveSchema(schema, depth);
if (!resolvedSchema) return null;
// 部分 schema 只标 oneOf/anyOf/allOf,挑第一个分支即可,调试场景够用。
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) return buildSampleFromSchema(schema.oneOf[0], depth + 1);
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) return buildSampleFromSchema(schema.anyOf[0], depth + 1);
if (Array.isArray(schema.allOf) && schema.allOf.length > 0) {
const merged = {};
schema.allOf.forEach(function (sub) {
const value = buildSampleFromSchema(sub, depth + 1);
if (value && typeof value === "object" && !Array.isArray(value)) Object.assign(merged, value);
});
return merged;
}
const type = schema.type || (schema.properties ? "object" : "string");
const type = resolvedSchema.type || (resolvedSchema.properties ? "object" : "string");
switch (type) {
case "object": {
const result = {};
const props = schema.properties || {};
const props = resolvedSchema.properties || {};
Object.keys(props).forEach(function (key) {
result[key] = buildSampleFromSchema(props[key], depth + 1);
});
@@ -91,7 +131,7 @@ function buildSampleFromSchema(schema, depth) {
return false;
case "string":
default:
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
if (resolvedSchema.enum && resolvedSchema.enum.length > 0) return resolvedSchema.enum[0];
return "";
}
}
@@ -99,13 +139,65 @@ function buildSampleFromSchema(schema, depth) {
/** 把 schema.type 翻译成 input type 与展示文本。 */
function inputKindForType(schema) {
if (!schema) return { kind: "text", label: "string" };
const type = schema.type || "string";
const resolvedSchema = resolveSchema(schema, 0) || schema;
const type = resolvedSchema.type || "string";
if (type === "boolean") return { kind: "checkbox", label: "boolean" };
if (type === "integer" || type === "number") return { kind: "number", label: type };
return { kind: "text", label: type };
}
/** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表。 */
/** 把 schema 转成适合字段表展示的简短类型文本。 */
function describeSchemaType(schema) {
const resolvedSchema = resolveSchema(schema, 0);
if (!resolvedSchema) return "unknown";
if (resolvedSchema.type === "array") {
const itemType = describeSchemaType(resolvedSchema.items);
return itemType === "unknown" ? "array" : "array<" + itemType + ">";
}
if (resolvedSchema.type) return resolvedSchema.type;
if (resolvedSchema.properties) return "object";
return "unknown";
}
/** 当静态说明缺失时,从普通 object schema 推导字段表。 */
function buildFieldDocsFromSchema(schema) {
const resolvedSchema = resolveSchema(schema, 0);
if (!resolvedSchema) return [];
const schemaType = resolvedSchema.type || (resolvedSchema.properties ? "object" : "");
if (schemaType !== "object" || !resolvedSchema.properties) return [];
const requiredSet = new Set(resolvedSchema.required || []);
return Object.keys(resolvedSchema.properties).map(function (name) {
const propertySchema = resolvedSchema.properties[name];
return {
name: name,
type: describeSchemaType(propertySchema),
required: requiredSet.has(name),
description: propertySchema && propertySchema.description ? propertySchema.description : "暂无说明"
};
});
}
/** 规范化静态字段说明,避免缺字段时前端渲染异常。 */
function normalizeFieldDocs(fieldDocs) {
if (!Array.isArray(fieldDocs)) return [];
return fieldDocs.map(function (fieldDoc) {
return {
name: fieldDoc && fieldDoc.name ? String(fieldDoc.name) : "",
type: fieldDoc && fieldDoc.type ? String(fieldDoc.type) : "unknown",
required: Boolean(fieldDoc && fieldDoc.required),
description: fieldDoc && fieldDoc.description ? String(fieldDoc.description) : "暂无说明"
};
}).filter(function (fieldDoc) {
return fieldDoc.name !== "";
});
}
/** 把 OpenAPI 的 paths 节点展开成扁平的 operation 列表,并合并静态说明。 */
function extractOperations(spec) {
const operations = [];
const paths = spec.paths || {};
@@ -120,6 +212,11 @@ function extractOperations(spec) {
const json = op.requestBody.content["application/json"];
if (json && json.schema) bodySchema = json.schema;
}
const operationKey = buildOperationKey(method, path);
const staticDoc = state.staticDocsByKey[operationKey] || null;
const staticFieldDocs = staticDoc ? normalizeFieldDocs(staticDoc.requestBodyFields) : [];
const schemaFieldDocs = staticFieldDocs.length === 0 ? buildFieldDocsFromSchema(bodySchema) : [];
operations.push({
method: method.toUpperCase(),
path: path,
@@ -127,7 +224,11 @@ function extractOperations(spec) {
description: op.description || "",
tags: op.tags || [],
parameters: parameters,
bodySchema: bodySchema
bodySchema: bodySchema,
staticDoc: staticDoc,
mergedSummary: staticDoc && staticDoc.summary ? staticDoc.summary : "暂无说明",
mergedDescription: staticDoc && staticDoc.description ? staticDoc.description : "暂无说明",
requestBodyFieldDocs: staticFieldDocs.length > 0 ? staticFieldDocs : schemaFieldDocs
});
});
});
@@ -150,7 +251,8 @@ function storageKey(op) {
/** 返回指定端点的手工调试样例,处理 JsonElement 等 OpenAPI 无法自动推断字段的接口。 */
function getRequestBodySample(op) {
return requestBodySamples[op.method + " " + op.path] || null;
const operationKey = op.method + " " + op.path;
return requestBodySamples[operationKey] || null;
}
/** 读取本端点最近一次输入;解析失败则当作空。 */
@@ -268,10 +370,80 @@ function renderParameterRows(container, op, savedValues) {
};
}
/** 渲染请求体字段说明表;静态说明缺失时退回 schema 推导。 */
function renderRequestBodyFieldDocs(container, op) {
if (!op.bodySchema && op.requestBodyFieldDocs.length === 0) return;
const wrapper = document.createElement("div");
wrapper.className = "body-block";
const title = document.createElement("div");
title.className = "body-label";
const left = document.createElement("div");
left.className = "left";
left.textContent = "请求体字段说明";
title.appendChild(left);
wrapper.appendChild(title);
if (op.requestBodyFieldDocs.length === 0) {
const empty = document.createElement("div");
empty.style.color = "var(--muted)";
empty.style.fontSize = "13px";
empty.textContent = "暂无说明";
wrapper.appendChild(empty);
container.appendChild(wrapper);
return;
}
const table = document.createElement("table");
table.style.width = "100%";
table.style.borderCollapse = "collapse";
table.style.fontSize = "13px";
const headerRow = document.createElement("tr");
["字段名", "类型", "必填", "说明"].forEach(function (text) {
const th = document.createElement("th");
th.textContent = text;
th.style.textAlign = "left";
th.style.padding = "6px 8px";
th.style.borderBottom = "1px solid rgba(148, 163, 184, 0.25)";
th.style.color = "var(--muted)";
headerRow.appendChild(th);
});
table.appendChild(headerRow);
op.requestBodyFieldDocs.forEach(function (fieldDoc) {
const row = document.createElement("tr");
[
fieldDoc.name,
fieldDoc.type,
fieldDoc.required ? "是" : "否",
fieldDoc.description || "暂无说明"
].forEach(function (value, index) {
const cell = document.createElement("td");
cell.style.padding = "8px";
cell.style.verticalAlign = "top";
cell.style.borderBottom = "1px solid rgba(148, 163, 184, 0.12)";
if (index === 0 || index === 1) {
cell.style.fontFamily = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace";
}
cell.textContent = value;
row.appendChild(cell);
});
table.appendChild(row);
});
wrapper.appendChild(table);
container.appendChild(wrapper);
}
/** 渲染请求体编辑器,返回收集函数。 */
function renderBodyEditor(container, op, savedBody) {
if (!op.bodySchema) return function () { return null; };
// 字段说明先于 JSON 编辑器出现,便于现场先看约定再改样例。
renderRequestBodyFieldDocs(container, op);
const block = document.createElement("div");
block.className = "body-block";
@@ -342,6 +514,7 @@ function pushHistory(entry) {
renderHistory();
}
/** 渲染请求历史列表。 */
function renderHistory() {
const list = document.getElementById("history-list");
list.innerHTML = "";
@@ -376,7 +549,7 @@ function renderHistory() {
/** 渲染单个端点的卡片。 */
function renderOperationCard(op) {
const card = document.createElement("section");
card.className = "card collapsed";
card.className = "card";
const head = document.createElement("div");
head.className = "card-head";
@@ -393,13 +566,13 @@ function renderOperationCard(op) {
const summary = document.createElement("span");
summary.className = "card-summary";
summary.textContent = op.summary;
summary.title = op.summary;
summary.textContent = op.mergedSummary;
summary.title = op.mergedSummary;
head.appendChild(summary);
const toggle = document.createElement("span");
toggle.className = "card-toggle";
toggle.textContent = "展开 ▾";
toggle.textContent = "收起 ▴";
head.appendChild(toggle);
head.addEventListener("click", function () {
@@ -411,15 +584,12 @@ function renderOperationCard(op) {
const body = document.createElement("div");
body.className = "card-body";
// 描述(来自 XML summary)独立成一段
if (op.summary) {
const desc = document.createElement("div");
desc.style.color = "var(--muted)";
desc.style.marginBottom = "10px";
desc.style.fontSize = "13px";
desc.textContent = op.summary;
body.appendChild(desc);
}
const desc = document.createElement("div");
desc.style.color = "var(--muted)";
desc.style.marginBottom = "10px";
desc.style.fontSize = "13px";
desc.textContent = op.mergedDescription;
body.appendChild(desc);
const saved = loadInputs(op) || {};
@@ -464,6 +634,7 @@ function renderOperationCard(op) {
responseBlock.style.display = "none";
body.appendChild(responseBlock);
/** 在卡片内渲染 HTTP 响应摘要、头和正文。 */
function renderResponse(payload) {
responseBlock.style.display = "block";
responseBlock.innerHTML = "";

View File

@@ -39,6 +39,247 @@
</h3>
<ul id="history-list"></ul>
</aside>
<script id="debug-static-docs" type="application/json">
{
"GET /": {
"summary": "根路径探活",
"description": "返回旧 HTTP 服务约定的 Hello World 响应,用来确认兼容层宿主已启动。",
"requestBodyFields": []
},
"GET /healthz": {
"summary": "宿主健康检查",
"description": "返回 replacement 宿主的基础健康状态,适合探活和部署验收。",
"requestBodyFields": []
},
"GET /status": {
"summary": "跳转状态页",
"description": "短路由入口,浏览器访问后会跳转到静态状态监控页 `/status.html`。",
"requestBodyFields": []
},
"GET /debug": {
"summary": "跳转调试页",
"description": "短路由入口,浏览器访问后会跳转到静态调试页 `/debug.html`。",
"requestBodyFields": []
},
"GET /api/status/snapshot": {
"summary": "读取状态快照",
"description": "返回当前 ControllerClient 兼容层和 FANUC 运行时的聚合状态快照,供状态页和外部诊断读取。",
"requestBodyFields": []
},
"POST /connect_server/": {
"summary": "记录旧客户端目标服务",
"description": "兼容旧 `connect_server` 调用形状;在 replacement 宿主中只记录调用方期望连接的服务端地址,不会再启动旧 TCP+JSON 网关。",
"requestBodyFields": []
},
"GET /get_server_version/": {
"summary": "读取服务端版本",
"description": "返回当前 replacement 宿主暴露给旧客户端语义的服务端版本号。",
"requestBodyFields": []
},
"GET /get_client_version/": {
"summary": "读取客户端版本",
"description": "返回兼容层对外声明的客户端版本号,便于旧链路做版本诊断。",
"requestBodyFields": []
},
"POST /setup_robot/": {
"summary": "按名称加载机器人",
"description": "根据机器人名称加载对应模型和运行配置,为后续连接、使能和执行做准备。",
"requestBodyFields": []
},
"POST /setup_robot_from_env/": {
"summary": "按环境文件加载机器人",
"description": "兼容旧 `env_file` 入口,根据环境文件路径完成机器人初始化配置加载。",
"requestBodyFields": []
},
"POST /set_show_tcp/": {
"summary": "设置 TCP 显示开关",
"description": "兼容旧可视化参数接口,记录是否显示 TCP 坐标轴及其尺寸参数。",
"requestBodyFields": []
},
"GET /is_setup/": {
"summary": "查询是否已初始化",
"description": "返回当前兼容层是否已经完成 `SetUpRobot` 初始化。",
"requestBodyFields": []
},
"GET /enable_robot/": {
"summary": "使能机器人",
"description": "调用兼容层使能机器人,并可通过查询参数传入控制器执行缓冲区大小。",
"requestBodyFields": []
},
"GET /disable_robot/": {
"summary": "去使能机器人",
"description": "调用兼容层关闭机器人使能,通常用于结束调试或执行链路回收。",
"requestBodyFields": []
},
"GET /stop_move/": {
"summary": "停止当前运动",
"description": "触发兼容层的 `StopMove` 语义,用于终止当前点动或轨迹执行。",
"requestBodyFields": []
},
"POST /set_active_controller/": {
"summary": "切换控制器模式",
"description": "通过查询参数 `sim` 指定当前使用仿真控制器还是真机控制器。",
"requestBodyFields": []
},
"POST /connect_robot/": {
"summary": "连接机器人控制器",
"description": "根据查询参数 `ip` 连接机器人控制器,建立后续命令与状态链路。",
"requestBodyFields": []
},
"POST /disconnect_robot/": {
"summary": "断开机器人连接",
"description": "主动断开当前机器人控制器连接,释放兼容层运行时资源。",
"requestBodyFields": []
},
"GET /robot_info/": {
"summary": "读取机器人信息",
"description": "返回机器人名称、自由度、速度倍率和服务端版本等聚合元信息。",
"requestBodyFields": []
},
"POST /set_tcp/": {
"summary": "设置 TCP 偏移",
"description": "设置当前工具中心点的三维偏移,单位按旧接口约定使用毫米。",
"requestBodyFields": [
{ "name": "x", "type": "number", "required": true, "description": "TCP 的 X 偏移,单位 mm。" },
{ "name": "y", "type": "number", "required": true, "description": "TCP 的 Y 偏移,单位 mm。" },
{ "name": "z", "type": "number", "required": true, "description": "TCP 的 Z 偏移,单位 mm。" }
]
},
"GET /get_tcp/": {
"summary": "读取 TCP 偏移",
"description": "返回当前生效的 TCP 三维偏移值。",
"requestBodyFields": []
},
"POST /set_io/": {
"summary": "设置数字 IO",
"description": "通过查询参数设置指定端口的 IO 值和 IO 类型,常用于现场脉冲或夹具联动调试。",
"requestBodyFields": []
},
"GET /get_io/": {
"summary": "读取数字 IO",
"description": "读取指定端口和类型的当前 IO 值,便于确认控制柜当前输入输出状态。",
"requestBodyFields": []
},
"GET /get_joint_position/": {
"summary": "读取当前关节位置",
"description": "返回旧 HTTP 层兼容的关节数组外形,便于现场确认当前姿态。",
"requestBodyFields": []
},
"POST /move_joint/": {
"summary": "执行关节点到点运动",
"description": "根据目标关节数组执行一次关节空间点到点运动;实际执行链路会进入兼容层与 J519 运行时。",
"requestBodyFields": [
{ "name": "joints", "type": "array<number>", "required": true, "description": "目标关节数组,按关节顺序给出数值。" }
]
},
"POST /move_pose/": {
"summary": "执行笛卡尔点到点运动",
"description": "以 `[x,y,z,w,p,r]` 直角位姿执行点到点移动。六个字段必须完整提供,`x/y/z` 使用 mm,`w/p/r` 使用 deg,并受当前入口限幅约束。",
"requestBodyFields": [
{ "name": "x", "type": "number", "required": true, "description": "TCP X,单位 mm,当前入口限制在 -1000 到 1000 之间。" },
{ "name": "y", "type": "number", "required": true, "description": "TCP Y,单位 mm,当前入口限制在 -1000 到 1000 之间。" },
{ "name": "z", "type": "number", "required": true, "description": "TCP Z,单位 mm,当前入口限制在 0 到 1200 之间。" },
{ "name": "w", "type": "number", "required": true, "description": "姿态 W,单位 deg,当前入口限制在 -180 到 180 之间。" },
{ "name": "p", "type": "number", "required": true, "description": "姿态 P,单位 deg,当前入口限制在 -90 到 90 之间。" },
{ "name": "r", "type": "number", "required": true, "description": "姿态 R,单位 deg,当前入口限制在 -180 到 180 之间。" }
]
},
"POST /get_nearest_ik/": {
"summary": "求最近 IK",
"description": "根据目标位姿和 seed 关节数组求最近一组 IK 解;当前若底层未实现,会返回 501。",
"requestBodyFields": [
{ "name": "pose", "type": "array<number>", "required": true, "description": "目标位姿数组,约定为 `[x,y,z,qx,qy,qz,qw]`。" },
{ "name": "seed", "type": "array<number>", "required": true, "description": "IK seed 关节数组,用作就近求解参考。" }
]
},
"GET /list_flyShotTraj/": {
"summary": "列出已上传飞拍轨迹",
"description": "返回当前运行目录中已经保存的飞拍轨迹名称列表。",
"requestBodyFields": []
},
"POST /execute_trajectory/": {
"summary": "执行普通轨迹",
"description": "执行普通关节轨迹。该接口兼容两种历史请求体形态:一是对象形态 `{ waypoints, method, save_traj }`,二是直接传纯数组路点;查询参数里的 `method` 和 `save_traj` 仍可覆盖请求体值。",
"requestBodyFields": [
{ "name": "waypoints", "type": "array<array<number>> | array<object>", "required": true, "description": "历史兼容对象形态中的轨迹路点集合;每个路点既可直接是关节数组,也可写成 `{ joints: [...] }`。" },
{ "name": "method", "type": "string", "required": false, "description": "对象形态中的轨迹规划方法,默认 `icsp`。" },
{ "name": "save_traj", "type": "boolean", "required": false, "description": "对象形态中的是否保存轨迹标记,默认 `false`。" },
{ "name": "根数组", "type": "array<array<number>> | array<object>", "required": true, "description": "纯数组形态时,请直接提交路点数组本身,而不是对象包裹层。" },
{ "name": "根数组[].joints", "type": "array<number>", "required": false, "description": "当路点元素是对象形态时,兼容层会读取其中的 `joints` 数组。" }
]
},
"POST /upload_flyshot/": {
"summary": "上传飞拍轨迹",
"description": "上传一条带拍照触发信息的飞拍轨迹。`waypoints`、`shot_flags`、`offset_values`、`addrs` 四组数据长度必须一致。",
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "飞拍轨迹名称,后续执行、校验和删除都通过它定位。" },
{ "name": "waypoints", "type": "array<array<number>>", "required": true, "description": "飞拍轨迹的关节路点集合。" },
{ "name": "shot_flags", "type": "array<boolean>", "required": true, "description": "与每个路点对齐的拍照触发标志数组。" },
{ "name": "offset_values", "type": "array<number>", "required": true, "description": "与每个路点对齐的触发偏移周期集合。" },
{ "name": "addrs", "type": "array<array<integer>>", "required": true, "description": "与每个路点对齐的 IO 地址组集合。" }
]
},
"POST /execute_flyshot/": {
"summary": "执行已上传飞拍轨迹",
"description": "按名称执行一条已上传的飞拍轨迹,可控制是否先移动到起点、是否复用缓存、是否保存轨迹以及是否等待执行完成。",
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "要执行的飞拍轨迹名称。" },
{ "name": "move_to_start", "type": "boolean", "required": false, "description": "是否先用临时 PTP 轨迹移动到飞拍起点,默认 `true`。" },
{ "name": "method", "type": "string", "required": false, "description": "飞拍轨迹生成方法,默认 `icsp`。" },
{ "name": "save_traj", "type": "boolean", "required": false, "description": "是否保存轨迹信息,默认 `true`。" },
{ "name": "use_cache", "type": "boolean", "required": false, "description": "是否优先复用已缓存的轨迹结果,默认 `true`。" },
{ "name": "wait", "type": "boolean", "required": false, "description": "是否等待整条飞拍轨迹执行完成后再返回,默认 `true`。" }
]
},
"POST /save_traj_info/": {
"summary": "导出轨迹文件",
"description": "为指定飞拍轨迹生成并导出 `SaveTrajInfo` 兼容文件集合;底层未实现时会返回 501。",
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "目标飞拍轨迹名称。" },
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`。" }
]
},
"POST /is_flyShotTrajValid/": {
"summary": "校验飞拍轨迹有效性",
"description": "检查指定飞拍轨迹是否可生成有效执行结果,并返回轨迹时长;底层未实现时会返回 501。",
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "要校验的飞拍轨迹名称。" },
{ "name": "method", "type": "string", "required": false, "description": "轨迹规划方法,默认 `icsp`。" },
{ "name": "save_traj", "type": "boolean", "required": false, "description": "是否同时保存轨迹信息,默认 `true`。" }
]
},
"POST /set_speedRatio/": {
"summary": "设置运行速度倍率",
"description": "设置机器人运行速度倍率。当前接口要求 `speed` 在 `0.01` 到 `1.0` 之间,否则会直接返回 400。",
"requestBodyFields": [
{ "name": "speed", "type": "number", "required": true, "description": "目标速度倍率,允许范围为 0.01 到 1.0。" }
]
},
"POST /delete_flyshot/": {
"summary": "删除飞拍轨迹",
"description": "按名称删除一条已上传的飞拍轨迹及其持久化记录。",
"requestBodyFields": [
{ "name": "name", "type": "string", "required": true, "description": "要删除的飞拍轨迹名称。" }
]
},
"POST /init_mpc_robt": {
"summary": "一键初始化旧链路",
"description": "兼容旧 `init_mpc_robt` 入口,按历史拼写保留。会串联执行 `SetUpRobot -> SetActiveController -> Connect -> EnableRobot`。",
"requestBodyFields": [
{ "name": "server_ip", "type": "string", "required": true, "description": "旧客户端记录的目标服务端 IP,仅保留兼容语义。" },
{ "name": "port", "type": "integer", "required": true, "description": "旧客户端记录的目标服务端端口。" },
{ "name": "robot_name", "type": "string", "required": true, "description": "机器人名称,用于加载模型与配置。" },
{ "name": "robot_ip", "type": "string", "required": true, "description": "机器人控制器 IP。" },
{ "name": "sim", "type": "boolean", "required": false, "description": "是否连接仿真控制器;默认 `false` 表示真机。" }
]
},
"GET /get_pose": {
"summary": "读取当前末端位姿",
"description": "返回当前末端位姿数组,便于确认机器人 TCP 的当前空间位置。",
"requestBodyFields": []
}
}
</script>
<script src="/assets/debug.js" defer></script>
</body>
</html>

View File

@@ -33,6 +33,9 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal);
Assert.Contains("id=\"debug-console-app\"", html, StringComparison.Ordinal);
Assert.Contains("id=\"debug-static-docs\"", html, StringComparison.Ordinal);
Assert.Contains("POST /move_pose/", html, StringComparison.Ordinal);
Assert.Contains("POST /upload_flyshot/", html, StringComparison.Ordinal);
Assert.DoesNotContain("__SWAGGER_JSON_URL__", html, StringComparison.Ordinal);
Assert.Contains("/assets/debug.css", html, StringComparison.Ordinal);
Assert.Contains("/assets/debug.js", html, StringComparison.Ordinal);
@@ -66,6 +69,45 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
Assert.Contains("r: 0.0", script, StringComparison.Ordinal);
}
/// <summary>
/// 调试脚本应当优先读取静态说明并在缺失时回退到 OpenAPI / “暂无说明”。
/// </summary>
[Fact]
public async Task GetDebugScript_ContainsStaticDocumentationMergeLogic()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var scriptResponse = await client.GetAsync("/assets/debug.js");
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
var script = await scriptResponse.Content.ReadAsStringAsync();
Assert.Contains("debug-static-docs", script, StringComparison.Ordinal);
Assert.Contains("op.method + \" \" + op.path", script, StringComparison.Ordinal);
Assert.Contains("mergedSummary", script, StringComparison.Ordinal);
Assert.Contains("mergedDescription", script, StringComparison.Ordinal);
Assert.Contains("requestBodyFieldDocs", script, StringComparison.Ordinal);
Assert.Contains("暂无说明", script, StringComparison.Ordinal);
}
/// <summary>
/// 调试卡片默认应直接展开,确保静态说明和字段表在初始页面就可见。
/// </summary>
[Fact]
public async Task GetDebugScript_DefaultsCardsToExpandedForVisibleDocumentation()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var scriptResponse = await client.GetAsync("/assets/debug.js");
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
var script = await scriptResponse.Content.ReadAsStringAsync();
Assert.DoesNotContain("card.className = \"card collapsed\"", script, StringComparison.Ordinal);
Assert.Contains("card.className = \"card\"", script, StringComparison.Ordinal);
Assert.Contains("toggle.textContent = \"收起 ▴\"", script, StringComparison.Ordinal);
}
/// <summary>
/// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址。
/// </summary>

View File

@@ -0,0 +1,252 @@
<#
.SYNOPSIS
验证混淆发布脚本生成的 Obfuscar 配置满足仓库发布契约。
.DESCRIPTION
该测试使用临时空文件模拟发布目录,仅验证模块筛选、绝对路径和失败语义,
不会执行 dotnet publish,也不会要求测试环境安装 Obfuscar。
#>
$ErrorActionPreference = 'Stop'
$repositoryRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
$publishScriptPath = Join-Path $repositoryRoot 'scripts\Publish-Obfuscated.ps1'
$publishProfilePath = Join-Path $repositoryRoot 'src\Flyshot.Server.Host\Properties\PublishProfiles\FolderProfile.pubxml'
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "flyshot-obfuscation-test-$([guid]::NewGuid().ToString('N'))"
$inputDirectory = Join-Path $temporaryRoot 'input'
$outputDirectory = Join-Path $temporaryRoot 'obfuscated'
$configPath = Join-Path $temporaryRoot 'obfuscar.xml'
$expectedAssemblies = @(
'Flyshot.Server.Host.dll'
'Flyshot.ControllerClientCompat.dll'
'Flyshot.Core.Config.dll'
'Flyshot.Core.Domain.dll'
'Flyshot.Core.Planning.dll'
'Flyshot.Core.Triggering.dll'
'Flyshot.Runtime.Fanuc.dll'
'Flyshot.Runtime.Common.dll'
)
$futureAssemblyName = 'Flyshot.Future.Module.dll'
try
{
$publishScriptContent = Get-Content -LiteralPath $publishScriptPath -Raw
$publishScriptTokens = $null
$publishScriptErrors = $null
$publishScriptAst = [System.Management.Automation.Language.Parser]::ParseInput(
$publishScriptContent,
[ref]$publishScriptTokens,
[ref]$publishScriptErrors)
if ($publishScriptErrors.Count -ne 0)
{
throw "Publish-Obfuscated.ps1 存在 PowerShell 语法错误:$($publishScriptErrors[0].Message)"
}
$scriptParameters = @($publishScriptAst.ParamBlock.Parameters | ForEach-Object {
$_.Name.VariablePath.UserPath
})
if ('RuntimeIdentifiers' -in $scriptParameters -or 'ExistingRuntimeIdentifier' -in $scriptParameters)
{
throw '发布脚本不应再暴露运行平台或 RID 参数。'
}
if ($publishScriptContent -match "'-r'|--self-contained'\s*,?\s*'true'|win-x64|linux-x64")
{
throw '发布脚本默认发布流程不得再指定 RID 或生成自包含平台包。'
}
if ($publishScriptContent -notmatch '-p:UseAppHost=false')
{
throw '框架依赖发布必须关闭平台相关 apphost,只保留跨平台 DLL 入口。'
}
[xml]$publishProfile = Get-Content -LiteralPath $publishProfilePath -Raw
$publishNamespace = [System.Xml.XmlNamespaceManager]::new($publishProfile.NameTable)
$publishNamespace.AddNamespace('msb', $publishProfile.Project.NamespaceURI)
$useAppHost = $publishProfile.SelectSingleNode(
'/msb:Project/msb:PropertyGroup/msb:UseAppHost',
$publishNamespace)
if ($null -eq $useAppHost -or $useAppHost.InnerText -ne 'false')
{
throw 'FolderProfile.pubxml 必须关闭平台相关 apphost。'
}
$afterPublishTarget = $publishProfile.SelectSingleNode(
'/msb:Project/msb:Target[@Name="ObfuscatePublishedFlyshotAssemblies"]',
$publishNamespace)
if ($null -eq $afterPublishTarget -or $afterPublishTarget.AfterTargets -ne 'Publish')
{
throw 'FolderProfile.pubxml 必须注册 Publish 完成后的混淆 Target。'
}
$publishExec = $afterPublishTarget.SelectSingleNode('msb:Exec', $publishNamespace)
if (
$null -eq $publishExec -or
$publishExec.Command -notmatch '-ObfuscateExistingPublish' -or
$publishExec.Command -notmatch '\$\(PublishDir\)\.' -or
$publishExec.Command -match 'RuntimeIdentifier'
)
{
throw 'Visual Studio 发布 Target 必须把 PublishDir 以安全的非反斜杠结尾形式交给混淆模式。'
}
New-Item -ItemType Directory -Path $inputDirectory -Force | Out-Null
foreach ($assemblyName in $expectedAssemblies)
{
New-Item -ItemType File -Path (Join-Path $inputDirectory $assemblyName) -Force | Out-Null
}
# 模拟未来新增模块,证明脚本会自动保护所有 Flyshot.* 程序集,而不是只处理当前固定清单。
New-Item -ItemType File -Path (Join-Path $inputDirectory $futureAssemblyName) -Force | Out-Null
# 第三方程序集用于证明配置生成器只选择 Flyshot 自研模块。
New-Item -ItemType File -Path (Join-Path $inputDirectory 'NLog.dll') -Force | Out-Null
& pwsh -NoProfile -File $publishScriptPath `
-GenerateConfigOnly `
-InputDirectory $inputDirectory `
-ObfuscatedDirectory $outputDirectory `
-ConfigPath $configPath
if ($LASTEXITCODE -ne 0)
{
throw "配置生成命令退出码不是 0:$LASTEXITCODE"
}
[xml]$configuration = Get-Content -LiteralPath $configPath -Raw
$modulePaths = @($configuration.Obfuscator.Module | ForEach-Object { [string]$_.file })
$allExpectedAssemblies = @($expectedAssemblies) + $futureAssemblyName
if ($modulePaths.Count -ne $allExpectedAssemblies.Count)
{
throw "预期生成 $($allExpectedAssemblies.Count) 个模块,实际为 $($modulePaths.Count) 个。"
}
foreach ($assemblyName in $allExpectedAssemblies)
{
$expectedPath = [System.IO.Path]::GetFullPath((Join-Path $inputDirectory $assemblyName))
if ($expectedPath -notin $modulePaths)
{
throw "Obfuscar 配置缺少自研程序集:$assemblyName"
}
}
if ($modulePaths | Where-Object { [System.IO.Path]::GetFileName($_) -eq 'NLog.dll' })
{
throw '第三方程序集 NLog.dll 不应进入 Obfuscar 模块列表。'
}
foreach ($path in $modulePaths)
{
if (-not [System.IO.Path]::IsPathFullyQualified($path))
{
throw "Obfuscar 模块路径不是绝对路径:$path"
}
}
foreach ($module in @($configuration.Obfuscator.Module))
{
$anonymousTypeRule = @($module.SkipType | Where-Object {
[string]$_.name -eq '^<>f__AnonymousType.*'
})
if ($anonymousTypeRule.Count -ne 1)
{
throw "模块 $($module.file) 必须显式保留匿名类型元数据。"
}
if (
[string]$anonymousTypeRule[0].skipMethods -ne 'true' -or
[string]$anonymousTypeRule[0].skipFields -ne 'true' -or
[string]$anonymousTypeRule[0].skipProperties -ne 'true' -or
[string]$anonymousTypeRule[0].skipEvents -ne 'true'
)
{
throw "模块 $($module.file) 的匿名类型排除规则不完整。"
}
}
$assemblySearchPaths = @($configuration.Obfuscator.AssemblySearchPath | ForEach-Object {
[string]$_.path
})
if ($assemblySearchPaths.Count -lt 2)
{
throw 'Obfuscar 配置必须包含 .NETCore.App 与 ASP.NET Core 共享框架搜索目录。'
}
foreach ($assemblySearchPath in $assemblySearchPaths)
{
if (-not [System.IO.Path]::IsPathFullyQualified($assemblySearchPath))
{
throw "共享框架搜索目录不是绝对路径:$assemblySearchPath"
}
}
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.NETCore\.App' }))
{
throw 'Obfuscar 配置缺少 Microsoft.NETCore.App 搜索目录。'
}
if (-not ($assemblySearchPaths | Where-Object { $_ -match 'Microsoft\.AspNetCore\.App' }))
{
throw 'Obfuscar 配置缺少 Microsoft.AspNetCore.App 搜索目录。'
}
$variables = @{}
foreach ($variable in $configuration.Obfuscator.Var)
{
$variables[[string]$variable.name] = [string]$variable.value
}
if ($variables['KeepPublicApi'] -ne 'true')
{
throw 'KeepPublicApi 必须为 true,以保护 ASP.NET Core 和公共契约。'
}
if ($variables['HidePrivateApi'] -ne 'true')
{
throw 'HidePrivateApi 必须为 true,以混淆内部实现。'
}
if ($variables['RenameProperties'] -ne 'false' -or $variables['RenameEvents'] -ne 'false')
{
throw '属性和事件重命名必须关闭,以保护配置绑定及序列化契约。'
}
if ($variables['SkipGenerated'] -ne 'true')
{
throw '编译器生成类型必须跳过混淆,以保护匿名 JSON 响应和异步状态机。'
}
if ($variables['SkipSpecialName'] -ne 'true')
{
throw 'SpecialName 成员必须跳过混淆,以保护属性、事件和框架反射约定。'
}
Remove-Item -LiteralPath (Join-Path $inputDirectory 'Flyshot.Runtime.Common.dll') -Force
$missingAssemblyConfigPath = Join-Path $temporaryRoot 'missing-assembly.xml'
& pwsh -NoProfile -File $publishScriptPath `
-GenerateConfigOnly `
-InputDirectory $inputDirectory `
-ObfuscatedDirectory $outputDirectory `
-ConfigPath $missingAssemblyConfigPath *> $null
if ($LASTEXITCODE -eq 0)
{
throw '缺少预期的 Flyshot 程序集时,配置生成必须失败。'
}
Write-Host 'Publish-Obfuscated.Tests.ps1: PASS'
}
finally
{
if (Test-Path -LiteralPath $temporaryRoot)
{
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force
}
}

View File

@@ -0,0 +1,35 @@
<#
.SYNOPSIS
验证混淆发布包不会泄露 Flyshot 源码文档或调试符号。
.PARAMETER PublishDirectory
需要检查的框架依赖混淆发布目录。
#>
[CmdletBinding()]
param(
[string]$PublishDirectory = (Join-Path $PSScriptRoot '..\..\outputs\obfuscated\framework-dependent')
)
$ErrorActionPreference = 'Stop'
$absolutePublishDirectory = [System.IO.Path]::GetFullPath($PublishDirectory)
if (-not (Test-Path -LiteralPath $absolutePublishDirectory -PathType Container))
{
throw "混淆发布目录不存在:$absolutePublishDirectory"
}
$sensitiveFiles = @(
Get-ChildItem -LiteralPath $absolutePublishDirectory -File |
Where-Object {
$_.Name -like 'Flyshot*.xml' -or
$_.Name -like 'Flyshot*.pdb'
}
)
if ($sensitiveFiles.Count -gt 0)
{
throw "混淆发布包仍包含源码文档或调试符号:$($sensitiveFiles.Name -join ', ')"
}
Write-Host 'Test-PublishedArtifactPrivacy.ps1: PASS'