✨ feat(server): 增加混淆发布链路与调试页静态说明
- 新增 Obfuscar 混淆发布脚本、发布测试与产物隐私检查 - 调整 VS Code 发布任务和宿主发布配置,统一走混淆流程 - 为 debug 页面内嵌静态接口说明,并补充对应集成测试 - 更新 README 与设计计划文档,记录跨平台发布约束
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -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
15
.vscode/tasks.json
vendored
@@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
43
README.md
43
README.md
@@ -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。
|
||||
|
||||
@@ -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`,确认没有第三方程序集或映射文件进入部署包。
|
||||
@@ -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 稳定运行所需的安全边界;若未来需要更强的控制流混淆、字符串加密或防篡改,应评估商业混淆器。
|
||||
635
scripts/Publish-Obfuscated.ps1
Normal file
635
scripts/Publish-Obfuscated.ps1
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
252
tests/Publishing/Publish-Obfuscated.Tests.ps1
Normal file
252
tests/Publishing/Publish-Obfuscated.Tests.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
35
tests/Publishing/Test-PublishedArtifactPrivacy.ps1
Normal file
35
tests/Publishing/Test-PublishedArtifactPrivacy.ps1
Normal 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'
|
||||
Reference in New Issue
Block a user