feat(server): 添加静态状态页与调试入口

- 将状态页、调试页改为 `wwwroot` 静态资源
  - 补充调试配置接口与前端脚本
  - 为兼容层、规划层和运行时补充日志
  - 更新集成测试覆盖新入口
This commit is contained in:
2026-04-29 14:05:02 +08:00
parent 0724efebed
commit c38faddbf0
27 changed files with 2630 additions and 1894 deletions

View File

@@ -8,7 +8,8 @@
"Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal)",
"Bash(DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet test tests/Flyshot.Server.IntegrationTests/Flyshot.Server.IntegrationTests.csproj --no-build -v minimal)",
"Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json'\\)\\); json.load\\(open\\('.vscode/tasks.json'\\)\\); print\\('JSON valid.'\\)\")",
"Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json', encoding='utf-8'\\)\\); json.load\\(open\\('.vscode/tasks.json', encoding='utf-8'\\)\\); print\\('JSON valid.'\\)\")"
"Bash(python -c \"import json; json.load\\(open\\('.vscode/launch.json', encoding='utf-8'\\)\\); json.load\\(open\\('.vscode/tasks.json', encoding='utf-8'\\)\\); print\\('JSON valid.'\\)\")",
"Bash(/bin/bash -lc 'DOTNET_CLI_HOME=/tmp NUGET_PACKAGES=/tmp/nuget-packages dotnet build FlyshotReplacement.sln --no-restore -v minimal')"
]
}
}

View File

@@ -6,21 +6,14 @@
throwExceptions="false"
internalLogLevel="Off" >
<!-- optional, add some variables
https://github.com/nlog/NLog/wiki/Configuration-file#variables
-->
<variable name="myvar" value="myvalue"/>
<!-- 环境变量配置:如果 ASPNETCORE_ENVIRONMENT 为空,则默认为 Development -->
<!-- 环境变量配置:如果 ASPNETCORE_ENVIRONMENT 为空,则默认为 Production -->
<variable name="env" value="${environment:ASPNETCORE_ENVIRONMENT:whenEmpty=Production}"/>
<!--
See https://github.com/nlog/nlog/wiki/Configuration-file
for information on customizing logging rules and outputs.
-->
<!-- 文件目标:按日期分文件,单文件超过 4MB 自动归档,保留最近 50 个归档文件 -->
<targets>
<target name="logfile" xsi:type="File"
fileName="${basedir}/logs/${shortdate}.log"
layout="${longdate}|${level:uppercase=true}|${message}"
layout="${longdate}|${level:uppercase=true}|${threadid}|${logger}|${message}${onexception:${newline} ${exception:format=tostring}}"
archiveFileName="${basedir}/logs/${shortdate}.{#}.log"
archiveAboveSize="4048576"
archiveNumbering="Sequence"
@@ -28,16 +21,20 @@
concurrentWrites="true"
keepFileOpen="false"
encoding="utf-8" />
<!--<target name="logfile" xsi:type="File" fileName="${basedir}/logs/${shortdate}.log" layout="${longdate}|${level:uppercase=true}|${logger}|${message}" />-->
<target name="logconsole" xsi:type="Console" layout="${longdate}|${level:uppercase=true}|${message}" />
<!-- 控制台目标:开发环境使用,带颜色高亮 -->
<target name="logconsole" xsi:type="Console"
layout="${longdate}|${level:uppercase=true}|${threadid}|${logger}|${message}${onexception:${newline} ${exception:format=tostring}}" />
</targets>
<rules>
<!-- 开发环境:显示控制台 + 详细文件,最低 Debug -->
<logger name="*" minLevel="Debug" writeTo="logconsole,logfile" condition="equals('${var:env}','Development')" />
<!-- 压制 ASP.NET Core MVC 的常规信息日志,只保留 Error 及以上级别。 -->
<logger name="Microsoft.AspNetCore.Mvc.*" minlevel="Error" writeTo="logconsole,logfile" />
<!-- 生产环境:仅文件+UI最低 Info -->
<logger name="*" minLevel="Info" writeTo="logfile" condition="not_equals('${var:env}','Development')" />
<!-- 开发环境:显示控制台 + 详细文件,最低 Debug -->
<logger name="*" minlevel="Debug" writeTo="logconsole,logfile" condition="equals('${var:env}','Development')" />
<!-- 生产环境:仅文件,最低 Info -->
<logger name="*" minlevel="Info" writeTo="logfile" condition="not_equals('${var:env}','Development')" />
</rules>
</nlog>

View File

@@ -1,6 +1,7 @@
using Flyshot.Core.Config;
using Flyshot.Core.Domain;
using Flyshot.Runtime.Common;
using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
@@ -17,6 +18,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
private readonly ControllerClientTrajectoryOrchestrator _trajectoryOrchestrator;
private readonly RobotConfigLoader _configLoader;
private readonly IFlyshotTrajectoryStore _trajectoryStore;
private readonly ILogger<ControllerClientCompatService>? _logger;
private RobotProfile? _activeRobotProfile;
private string? _configuredRobotName;
private CompatibilityRobotSettings? _robotSettings;
@@ -35,13 +37,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
/// <param name="trajectoryOrchestrator">轨迹规划与触发编排器。</param>
/// <param name="configLoader">旧版 RobotConfig.json 加载器。</param>
/// <param name="trajectoryStore">已上传轨迹持久化存储。</param>
/// <param name="logger">日志记录器;允许测试直接构造时传入 null。</param>
public ControllerClientCompatService(
ControllerClientCompatOptions options,
ControllerClientCompatRobotCatalog robotCatalog,
IControllerRuntime runtime,
ControllerClientTrajectoryOrchestrator trajectoryOrchestrator,
RobotConfigLoader configLoader,
IFlyshotTrajectoryStore trajectoryStore)
IFlyshotTrajectoryStore trajectoryStore,
ILogger<ControllerClientCompatService>? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_robotCatalog = robotCatalog ?? throw new ArgumentNullException(nameof(robotCatalog));
@@ -49,6 +53,7 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_trajectoryOrchestrator = trajectoryOrchestrator ?? throw new ArgumentNullException(nameof(trajectoryOrchestrator));
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
_trajectoryStore = trajectoryStore ?? throw new ArgumentNullException(nameof(trajectoryStore));
_logger = logger;
}
/// <inheritdoc />
@@ -90,6 +95,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_connectedServerIp = serverIp;
_connectedServerPort = port;
}
_logger?.LogInformation("ConnectServer 完成: {ServerIp}:{Port}", serverIp, port);
}
/// <inheritdoc />
@@ -107,6 +114,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
/// <inheritdoc />
public void SetUpRobot(string robotName)
{
_logger?.LogInformation("SetUpRobot 开始: robotName={RobotName}", robotName);
var robotSettings = TryLoadRobotSettings() ?? CreateDefaultRobotSettings();
var robotProfile = _robotCatalog.LoadProfile(
robotName,
@@ -129,6 +138,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
_uploadedTrajectories[saved.Key] = saved.Value;
}
}
_logger?.LogInformation(
"SetUpRobot 完成: robotName={RobotName}, dof={Dof}, accLimit={AccLimit}, jerkLimit={JerkLimit}, 恢复轨迹数={TrajCount}",
robotName,
robotProfile.DegreesOfFreedom,
robotSettings.AccLimitScale,
robotSettings.JerkLimitScale,
_uploadedTrajectories.Count);
}
/// <inheritdoc />
@@ -184,11 +201,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
}
_logger?.LogInformation("Connect 开始: robotIp={RobotIp}", robotIp);
lock (_stateLock)
{
EnsureRobotSetup();
_runtime.Connect(robotIp);
}
_logger?.LogInformation("Connect 完成: robotIp={RobotIp}", robotIp);
}
/// <inheritdoc />
@@ -204,31 +225,37 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
/// <inheritdoc />
public void EnableRobot(int bufferSize)
{
_logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}", bufferSize);
lock (_stateLock)
{
EnsureRobotSetup();
_runtime.EnableRobot(bufferSize);
}
_logger?.LogInformation("EnableRobot 完成");
}
/// <inheritdoc />
public void DisableRobot()
{
_logger?.LogInformation("DisableRobot 开始");
lock (_stateLock)
{
EnsureRobotSetup();
_runtime.DisableRobot();
}
_logger?.LogInformation("DisableRobot 完成");
}
/// <inheritdoc />
public void StopMove()
{
_logger?.LogInformation("StopMove 开始");
lock (_stateLock)
{
EnsureRobotSetup();
_runtime.StopMove();
}
_logger?.LogInformation("StopMove 完成");
}
/// <inheritdoc />
@@ -335,6 +362,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
{
ArgumentNullException.ThrowIfNull(jointPositions);
_logger?.LogInformation("MoveJoint 开始: 目标关节数={JointCount}", jointPositions.Count);
_logger?.LogDebug("MoveJoint 目标关节: {Joints}", string.Join(", ", jointPositions.Select(j => j.ToString("F4"))));
lock (_stateLock)
{
var robot = RequireActiveRobot();
@@ -345,8 +375,15 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
var speedRatio = _runtime.GetSnapshot().SpeedRatio;
var moveResult = MoveJointTrajectoryGenerator.CreateResult(robot, currentJointPositions, jointPositions, speedRatio);
_logger?.LogInformation(
"MoveJoint 规划完成: 当前速度倍率={SpeedRatio}, 规划时长={Duration}s, 采样点数={SampleCount}",
speedRatio,
moveResult.Duration.TotalSeconds,
moveResult.DenseJointTrajectory?.Count ?? 0);
_runtime.ExecuteTrajectory(moveResult, jointPositions);
}
_logger?.LogInformation("MoveJoint 完成");
}
/// <inheritdoc />
@@ -359,6 +396,11 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹路点不能为空。", nameof(waypoints));
}
_logger?.LogInformation("ExecuteTrajectory 开始: 路点数={WaypointCount}, method={Method}, saveTraj={SaveTraj}",
waypoints.Count, options.Method, options.SaveTrajectory);
_logger?.LogDebug("ExecuteTrajectory 路点详情: {Waypoints}",
string.Join(" | ", waypoints.Select(wp => $"[{string.Join(", ", wp.Select(j => j.ToString("F4")))}]")));
lock (_stateLock)
{
var robot = RequireActiveRobot();
@@ -366,9 +408,17 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
// 普通轨迹必须按调用方指定 method 规划,再把规划结果交给运行时执行。
var bundle = _trajectoryOrchestrator.PlanOrdinaryTrajectory(robot, waypoints, options);
_logger?.LogInformation(
"ExecuteTrajectory 规划完成: method={Method}, 时长={Duration}s, 有效={IsValid}, 采样点数={SampleCount}",
bundle.Result.Method,
bundle.Result.Duration.TotalSeconds,
bundle.Result.IsValid,
bundle.Result.DenseJointTrajectory?.Count ?? 0);
var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions;
_runtime.ExecuteTrajectory(bundle.Result, finalJointPositions);
}
_logger?.LogInformation("ExecuteTrajectory 完成");
}
/// <inheritdoc />
@@ -386,6 +436,12 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
{
ArgumentNullException.ThrowIfNull(trajectory);
_logger?.LogInformation(
"UploadTrajectory 开始: name={Name}, waypoints={WaypointCount}, shotFlags={ShotCount}",
trajectory.Name,
trajectory.Waypoints.Count,
trajectory.ShotFlags.Count(static f => f));
lock (_stateLock)
{
EnsureRuntimeEnabled();
@@ -395,6 +451,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
var settings = _robotSettings ?? CreateDefaultRobotSettings();
_trajectoryStore.Save(robotName, settings, trajectory);
}
_logger?.LogInformation("UploadTrajectory 完成: name={Name}", trajectory.Name);
}
/// <inheritdoc />
@@ -415,6 +473,10 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
}
_logger?.LogInformation(
"ExecuteTrajectoryByName 开始: name={Name}, method={Method}, moveToStart={MoveToStart}, useCache={UseCache}",
name, options.Method, options.MoveToStart, options.UseCache);
lock (_stateLock)
{
var robot = RequireActiveRobot();
@@ -422,24 +484,37 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
{
_logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹不存在 name={Name}", name);
throw new InvalidOperationException("FlyShot trajectory does not exist.");
}
if (trajectory.Waypoints.Count == 0)
{
_logger?.LogWarning("ExecuteTrajectoryByName 失败: 轨迹无路点 name={Name}", name);
throw new InvalidOperationException("FlyShot trajectory contains no waypoints.");
}
// 已上传飞拍轨迹必须按调用方指定 method 生成 shot timeline 后再交给运行时。
var bundle = _trajectoryOrchestrator.PlanUploadedFlyshot(robot, trajectory, options, RequireRobotSettings());
_logger?.LogInformation(
"ExecuteTrajectoryByName 规划完成: name={Name}, method={Method}, 时长={Duration}s, 触发事件数={TriggerCount}, 使用缓存={UsedCache}",
name,
bundle.Result.Method,
bundle.Result.Duration.TotalSeconds,
bundle.Result.TriggerTimeline.Count,
bundle.Result.UsedCache);
if (options.MoveToStart)
{
_logger?.LogInformation("ExecuteTrajectoryByName 先移动到起点");
_runtime.ExecuteTrajectory(CreateImmediateMoveResult(), bundle.PlannedTrajectory.PlannedWaypoints[0].Positions);
}
var finalJointPositions = bundle.PlannedTrajectory.PlannedWaypoints[^1].Positions;
_runtime.ExecuteTrajectory(bundle.Result, finalJointPositions);
}
_logger?.LogInformation("ExecuteTrajectoryByName 完成: name={Name}", name);
}
/// <inheritdoc />
@@ -450,11 +525,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
}
_logger?.LogInformation("SaveTrajectoryInfo 开始: name={Name}, method={Method}", name, method);
lock (_stateLock)
{
var robot = RequireActiveRobot();
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
{
_logger?.LogWarning("SaveTrajectoryInfo 失败: 轨迹不存在 name={Name}", name);
throw new InvalidOperationException("FlyShot trajectory does not exist.");
}
@@ -469,6 +547,8 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
var settings = _robotSettings ?? CreateDefaultRobotSettings();
_trajectoryStore.Save(robotName, settings, trajectory);
}
_logger?.LogInformation("SaveTrajectoryInfo 完成: name={Name}", name);
}
/// <inheritdoc />
@@ -479,11 +559,14 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
}
_logger?.LogInformation("IsFlyshotTrajectoryValid 开始: name={Name}, method={Method}", name, method);
lock (_stateLock)
{
var robot = RequireActiveRobot();
if (!_uploadedTrajectories.TryGetValue(name, out var trajectory))
{
_logger?.LogWarning("IsFlyshotTrajectoryValid 失败: 轨迹不存在 name={Name}", name);
throw new InvalidOperationException("FlyShot trajectory does not exist.");
}
@@ -494,6 +577,9 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
RequireRobotSettings());
duration = bundle.Result.Duration;
_logger?.LogInformation(
"IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s",
name, bundle.Result.IsValid, duration.TotalSeconds);
return bundle.Result.IsValid;
}
}
@@ -506,16 +592,21 @@ public sealed class ControllerClientCompatService : IControllerClientCompatServi
throw new ArgumentException("轨迹名称不能为空。", nameof(name));
}
_logger?.LogInformation("DeleteTrajectory 开始: name={Name}", name);
lock (_stateLock)
{
if (!_uploadedTrajectories.Remove(name))
{
_logger?.LogWarning("DeleteTrajectory 失败: 轨迹不存在 name={Name}", name);
throw new InvalidOperationException("DeleteFlyShotTraj failed");
}
var robotName = _configuredRobotName ?? throw new InvalidOperationException("Robot has not been setup.");
_trajectoryStore.Delete(robotName, name);
}
_logger?.LogInformation("DeleteTrajectory 完成: name={Name}", name);
}
/// <inheritdoc />

View File

@@ -3,6 +3,7 @@ using Flyshot.Core.Domain;
using Flyshot.Core.Planning;
using Flyshot.Core.Planning.Sampling;
using Flyshot.Core.Triggering;
using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
@@ -11,10 +12,22 @@ namespace Flyshot.ControllerClientCompat;
/// </summary>
public sealed class ControllerClientTrajectoryOrchestrator
{
private readonly ICspPlanner _icspPlanner = new();
private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner = new();
private readonly ICspPlanner _icspPlanner;
private readonly SelfAdaptIcspPlanner _selfAdaptIcspPlanner;
private readonly ShotTimelineBuilder _shotTimelineBuilder = new(new WaypointTimestampResolver());
private readonly Dictionary<string, PlannedExecutionBundle> _flyshotCache = new(StringComparer.Ordinal);
private readonly ILogger<ControllerClientTrajectoryOrchestrator>? _logger;
/// <summary>
/// 初始化轨迹编排器。
/// </summary>
/// <param name="logger">日志记录器;允许 null。</param>
public ControllerClientTrajectoryOrchestrator(ILogger<ControllerClientTrajectoryOrchestrator>? logger = null)
{
_logger = logger;
_icspPlanner = new(logger: null);
_selfAdaptIcspPlanner = new(logger: null);
}
/// <summary>
/// 对普通轨迹执行 ICSP 规划。
@@ -31,6 +44,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
ArgumentNullException.ThrowIfNull(waypoints);
options ??= new TrajectoryExecutionOptions();
_logger?.LogInformation(
"PlanOrdinaryTrajectory 开始: 路点数={WaypointCount}, method={Method}",
waypoints.Count, options.Method);
var program = CreateProgram(
name: "ordinary-trajectory",
waypoints: waypoints,
@@ -49,6 +66,11 @@ public sealed class ControllerClientTrajectoryOrchestrator
var shotTimeline = new ShotTimeline(Array.Empty<ShotEvent>(), Array.Empty<TrajectoryDoEvent>());
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
_logger?.LogInformation(
"PlanOrdinaryTrajectory 完成: 时长={Duration}s, 采样点数={SampleCount}",
result.Duration.TotalSeconds,
result.DenseJointTrajectory?.Count ?? 0);
return new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
}
@@ -69,6 +91,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
options ??= new FlyshotExecutionOptions();
settings ??= CreateDefaultRobotSettings();
_logger?.LogInformation(
"PlanUploadedFlyshot 开始: name={Name}, waypoints={WaypointCount}, method={Method}, useCache={UseCache}",
uploaded.Name, uploaded.Waypoints.Count, options.Method, options.UseCache);
var program = CreateProgram(
name: uploaded.Name,
waypoints: uploaded.Waypoints,
@@ -80,6 +106,7 @@ public sealed class ControllerClientTrajectoryOrchestrator
var cacheKey = CreateFlyshotCacheKey(robot, uploaded, options, settings);
if (options.UseCache && _flyshotCache.TryGetValue(cacheKey, out var cachedBundle))
{
_logger?.LogInformation("PlanUploadedFlyshot 命中缓存: name={Name}, cacheKey={CacheKey}", uploaded.Name, cacheKey);
// 命中缓存时只替换 TrajectoryResult 的 usedCache 标志,规划轨迹和触发时间轴保持不可变复用。
return new PlannedExecutionBundle(
cachedBundle.PlannedTrajectory,
@@ -104,6 +131,10 @@ public sealed class ControllerClientTrajectoryOrchestrator
var result = CreateResult(plannedTrajectory, shotTimeline, usedCache: false);
var bundle = new PlannedExecutionBundle(plannedTrajectory, shotTimeline, result);
_logger?.LogInformation(
"PlanUploadedFlyshot 完成: name={Name}, 时长={Duration}s, 触发事件数={TriggerCount}, 采样点数={SampleCount}",
uploaded.Name, result.Duration.TotalSeconds, result.TriggerTimeline.Count, result.DenseJointTrajectory?.Count ?? 0);
if (options.UseCache)
{
_flyshotCache[cacheKey] = bundle;

View File

@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Nodes;
using Flyshot.Core.Config;
using Flyshot.Core.Domain;
using Microsoft.Extensions.Logging;
namespace Flyshot.ControllerClientCompat;
@@ -41,16 +42,19 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
{
private readonly ControllerClientCompatOptions _options;
private readonly RobotConfigLoader _configLoader;
private readonly ILogger<JsonFlyshotTrajectoryStore>? _logger;
/// <summary>
/// 初始化基于 JSON 文件的轨迹存储。
/// </summary>
/// <param name="options">兼容层基础配置,用于定位工作区根目录。</param>
/// <param name="configLoader">旧版 RobotConfig.json 加载器,用于反序列化已保存的轨迹。</param>
public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader)
/// <param name="logger">日志记录器;允许 null。</param>
public JsonFlyshotTrajectoryStore(ControllerClientCompatOptions options, RobotConfigLoader configLoader, ILogger<JsonFlyshotTrajectoryStore>? logger = null)
{
_options = options ?? throw new ArgumentNullException(nameof(options));
_configLoader = configLoader ?? throw new ArgumentNullException(nameof(configLoader));
_logger = logger;
}
/// <inheritdoc />
@@ -59,6 +63,12 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(trajectory);
_logger?.LogInformation(
"TrajectoryStore 保存轨迹: robot={RobotName}, name={TrajectoryName}, waypoints={WaypointCount}",
robotName,
trajectory.Name,
trajectory.Waypoints.Count);
var path = ResolveStorePath(robotName);
var directory = Path.GetDirectoryName(path)!;
Directory.CreateDirectory(directory);
@@ -92,6 +102,8 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
File.WriteAllText(path, root.ToJsonString(writeOptions));
_logger?.LogInformation("TrajectoryStore 轨迹已保存到 {Path}", path);
}
/// <inheritdoc />
@@ -102,9 +114,12 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
throw new ArgumentException("轨迹名称不能为空。", nameof(trajectoryName));
}
_logger?.LogInformation("TrajectoryStore 删除轨迹: robot={RobotName}, name={TrajectoryName}", robotName, trajectoryName);
var path = ResolveStorePath(robotName);
if (!File.Exists(path))
{
_logger?.LogWarning("TrajectoryStore 删除失败: 文件不存在 {Path}", path);
return;
}
@@ -112,19 +127,27 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
var root = JsonNode.Parse(existingJson)?.AsObject();
if (root is null)
{
_logger?.LogWarning("TrajectoryStore 删除失败: 无法解析 JSON {Path}", path);
return;
}
if (root.TryGetPropertyValue("flying_shots", out var flyingShotsNode) && flyingShotsNode is JsonObject flyingShotsObj)
{
flyingShotsObj.Remove(trajectoryName);
var removed = flyingShotsObj.Remove(trajectoryName);
if (removed)
{
var writeOptions = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
};
File.WriteAllText(path, root.ToJsonString(writeOptions));
_logger?.LogInformation("TrajectoryStore 轨迹已删除: {TrajectoryName}", trajectoryName);
}
else
{
_logger?.LogWarning("TrajectoryStore 删除失败: 轨迹不存在 {TrajectoryName}", trajectoryName);
}
}
}
@@ -134,12 +157,15 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
var path = ResolveStorePath(robotName);
if (!File.Exists(path))
{
_logger?.LogInformation("TrajectoryStore 无持久化数据: {Path}", path);
settings = null;
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
}
try
{
_logger?.LogInformation("TrajectoryStore 正在加载: {Path}", path);
var workspaceRoot = ResolveWorkspaceRoot();
var loaded = _configLoader.Load(path, workspaceRoot);
settings = loaded.Robot;
@@ -156,10 +182,18 @@ public sealed class JsonFlyshotTrajectoryStore : IFlyshotTrajectoryStore
dict[program.Key] = traj;
}
_logger?.LogInformation(
"TrajectoryStore 加载完成: robot={RobotName}, 轨迹数={Count}, useDo={UseDo}, ioKeepCycles={IoKeepCycles}",
robotName,
dict.Count,
settings?.UseDo,
settings?.IoKeepCycles);
return dict;
}
catch
catch (Exception ex)
{
_logger?.LogError(ex, "TrajectoryStore 加载失败: {Path}", path);
settings = null;
return new Dictionary<string, ControllerClientCompatUploadedTrajectory>(StringComparer.Ordinal);
}

View File

@@ -6,6 +6,10 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
</ItemGroup>

View File

@@ -1,4 +1,5 @@
using Flyshot.Core.Domain;
using Microsoft.Extensions.Logging;
namespace Flyshot.Core.Planning;
@@ -33,6 +34,7 @@ public sealed class ICspPlanner
private readonly int _maxIterations;
private readonly bool _enforceFinalScale;
private readonly double _finalScaleTolerance;
private readonly ILogger<ICspPlanner>? _logger;
/// <summary>
/// 初始化 ICSP 规划器。
@@ -41,11 +43,13 @@ public sealed class ICspPlanner
/// <param name="maxIterations">最大迭代轮数。</param>
/// <param name="enforceFinalScale">是否在最终最优 scale 仍大于 1.0 时抛出失败。</param>
/// <param name="finalScaleTolerance">最终 scale 判定容差。</param>
/// <param name="logger">日志记录器;允许 null供无日志场景使用。</param>
public ICspPlanner(
double threshold = DefaultThreshold,
int maxIterations = DefaultMaxIterations,
bool enforceFinalScale = true,
double finalScaleTolerance = DefaultFinalScaleTolerance)
double finalScaleTolerance = DefaultFinalScaleTolerance,
ILogger<ICspPlanner>? logger = null)
{
if (threshold <= 0.0 || double.IsNaN(threshold) || double.IsInfinity(threshold))
{
@@ -66,6 +70,7 @@ public sealed class ICspPlanner
_maxIterations = maxIterations;
_enforceFinalScale = enforceFinalScale;
_finalScaleTolerance = finalScaleTolerance;
_logger = logger;
}
/// <summary>
@@ -81,9 +86,22 @@ public sealed class ICspPlanner
throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
}
_logger?.LogInformation(
"ICSP 规划开始: 名称={Name}, 路点数={WaypointCount}, 自由度={Dof}, threshold={Threshold}, maxIterations={MaxIterations}",
request.Program.Name, waypoints.Count, request.Robot.DegreesOfFreedom, _threshold, _maxIterations);
_logger?.LogDebug(
"ICSP 输入路点: {Waypoints}",
string.Join(" | ", waypoints.Select(wp => $"[{string.Join(", ", wp.Positions.Select(j => j.ToString("F4")))}]")));
var qs = WaypointsToArray(waypoints);
var (velLimits, accLimits, jerkLimits) = ExtractLimits(request.Robot);
_logger?.LogDebug(
"ICSP 约束限值: vel=[{Vel}], acc=[{Acc}], jerk=[{Jerk}]",
string.Join(", ", velLimits.Select(v => v.ToString("F2"))),
string.Join(", ", accLimits.Select(a => a.ToString("F2"))),
string.Join(", ", jerkLimits.Select(j => j.ToString("F2"))));
// 初始段时长直接取相邻示教点的关节空间欧氏距离。
var segmentDurations = ComputeInitialDurations(qs);
int nseg = segmentDurations.Length;
@@ -135,6 +153,9 @@ public sealed class ICspPlanner
if (currentThreshold < _threshold)
{
_logger?.LogDebug(
"ICSP 第 {Iteration} 轮收敛: threshold={CurrentThreshold:E6}",
iteration + 1, currentThreshold);
break;
}
@@ -152,10 +173,22 @@ public sealed class ICspPlanner
var globalScale = bestScales.Max();
if (_enforceFinalScale && globalScale > 1.0 + _finalScaleTolerance)
{
_logger?.LogError(
"ICSP 规划未收敛: global_scale={GlobalScale:F6} > {Tolerance:F6}, 段缩放=[{Scales}]",
globalScale, 1.0 + _finalScaleTolerance,
string.Join(", ", bestScales.Select(s => s.ToString("F4"))));
throw new InvalidOperationException(
$"ICSP 规划未收敛global_scale={globalScale:F6} > {1.0 + _finalScaleTolerance:F6},轨迹不可执行。");
}
_logger?.LogInformation(
"ICSP 规划完成: 名称={Name}, 迭代轮数={Iterations}, 收敛阈值={Threshold:E6}, 总时长={Duration:F4}s, global_scale={GlobalScale:F6}",
request.Program.Name, bestIterations, bestThreshold, bestWaypointTimes[^1], globalScale);
_logger?.LogDebug(
"ICSP 段时长: [{Durations}], 段缩放: [{Scales}]",
string.Join(", ", bestDurations.Select(d => d.ToString("F4"))),
string.Join(", ", bestScales.Select(s => s.ToString("F4"))));
return new PlannedTrajectory(
robot: request.Robot,
originalProgram: request.Program,

View File

@@ -1,4 +1,5 @@
using Flyshot.Core.Domain;
using Microsoft.Extensions.Logging;
namespace Flyshot.Core.Planning;
@@ -8,7 +9,7 @@ namespace Flyshot.Core.Planning;
/// 为什么需要这层?
/// ---
/// 逆向分析已经指出:原系统里普通 icsp 若仍有段 scale > 1不会直接返回未收敛结果
/// 配置中还明确存在 adapt_icsp_try_num。本层把超限段统一插入中点后再重规划的逻辑显式落地,
/// 配置中还明确存在 adapt_icsp_try_num。本层把"超限段统一插入中点后再重规划"的逻辑显式落地,
/// 补上 demo 缺失的失败恢复路径。
///
/// 补点策略:
@@ -24,7 +25,18 @@ public sealed class SelfAdaptIcspPlanner
/// </summary>
public const double ScaleTolerance = 5e-4;
private readonly ICspPlanner _innerPlanner = new(enforceFinalScale: false);
private readonly ICspPlanner _innerPlanner;
private readonly ILogger<SelfAdaptIcspPlanner>? _logger;
/// <summary>
/// 初始化 SelfAdaptIcspPlanner。
/// </summary>
/// <param name="logger">日志记录器;允许 null。</param>
public SelfAdaptIcspPlanner(ILogger<SelfAdaptIcspPlanner>? logger = null)
{
_innerPlanner = new ICspPlanner(enforceFinalScale: false, logger: null);
_logger = logger;
}
/// <summary>
/// 执行自适应 ICSP 规划,允许在超限段插入中点后重试。
@@ -48,6 +60,10 @@ public sealed class SelfAdaptIcspPlanner
throw new ArgumentException("ICSP 至少需要 4 个示教点。", nameof(request));
}
_logger?.LogInformation(
"SelfAdaptICSP 规划开始: 名称={Name}, 原始路点数={WaypointCount}, 最大补点次数={MaxAttempts}",
request.Program.Name, originalWaypointCount, adaptIcspTryNum);
var currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
var currentRequest = new TrajectoryRequest(
robot: request.Robot,
@@ -76,6 +92,9 @@ public sealed class SelfAdaptIcspPlanner
if (badSegments.Count == 0)
{
_logger?.LogInformation(
"SelfAdaptICSP 规划完成: 名称={Name}, 补点轮数={Attempts}, 最终路点数={WaypointCount}, 迭代次数={Iterations}, 总时长={Duration:F4}s",
request.Program.Name, attempt, currentWaypoints.Length, trajectory.Iterations, trajectory.WaypointTimes[^1]);
// 所有段都满足约束,收敛成功。返回包含补中点后路点的轨迹。
return new PlannedTrajectory(
robot: trajectory.Robot,
@@ -89,15 +108,24 @@ public sealed class SelfAdaptIcspPlanner
threshold: trajectory.Threshold);
}
_logger?.LogWarning(
"SelfAdaptICSP 第 {Attempt} 轮存在超限段: 超限段数={BadCount}, 段索引=[{Segments}], 最大缩放={MaxScale:F4}",
attempt, badSegments.Count, string.Join(", ", badSegments), trajectory.SegmentScales.Max());
if (attempt >= maxAttempts)
{
break;
}
// 对超限段插入中点,并同步扩展 shot 元数据。
int waypointCountBefore = currentWaypoints.Length;
(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs) =
InsertSegmentMidpoints(currentWaypoints, currentShotFlags, currentOffsets, currentAddrs, badSegments);
_logger?.LogDebug(
"SelfAdaptICSP 补中点: 路点数 {Before} -> {After}, 插入段=[{Segments}]",
waypointCountBefore, currentWaypoints.Length, string.Join(", ", badSegments));
currentProgram = BuildProgram(request.Program.Name, currentWaypoints, currentShotFlags, currentOffsets, currentAddrs);
currentRequest = new TrajectoryRequest(
robot: request.Robot,
@@ -109,6 +137,9 @@ public sealed class SelfAdaptIcspPlanner
}
double maxScale = lastTrajectory?.SegmentScales.Max() ?? double.NaN;
_logger?.LogError(
"SelfAdaptICSP 规划失败: 名称={Name}, 在 {Attempts} 轮补点后仍未收敛, 最大段缩放因子={MaxScale:F6}",
request.Program.Name, adaptIcspTryNum, maxScale);
throw new InvalidOperationException(
$"self-adapt ICSP 在 {adaptIcspTryNum} 轮补点后仍未收敛,最大段缩放因子={maxScale:F6}。");
}

View File

@@ -2,6 +2,7 @@ using System.Diagnostics;
using Flyshot.Core.Domain;
using Flyshot.Runtime.Common;
using Flyshot.Runtime.Fanuc.Protocol;
using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc;
@@ -16,6 +17,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
private readonly FanucCommandClient _commandClient;
private readonly FanucStateClient _stateClient;
private readonly FanucJ519Client _j519Client;
private readonly ILogger<FanucControllerRuntime>? _logger;
private RobotProfile? _robot;
private string? _robotName;
@@ -35,21 +37,24 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
/// <summary>
/// 初始化 FANUC 控制器运行时。
/// </summary>
public FanucControllerRuntime()
/// <param name="logger">日志记录器;允许 null供无日志场景使用。</param>
public FanucControllerRuntime(ILogger<FanucControllerRuntime>? logger = null)
{
_commandClient = new FanucCommandClient();
_stateClient = new FanucStateClient();
_j519Client = new FanucJ519Client();
_logger = logger;
}
/// <summary>
/// 供测试注入 mock 客户端的内部构造函数。
/// </summary>
internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client)
internal FanucControllerRuntime(FanucCommandClient commandClient, FanucStateClient stateClient, FanucJ519Client j519Client, ILogger<FanucControllerRuntime>? logger = null)
{
_commandClient = commandClient;
_stateClient = stateClient;
_j519Client = j519Client;
_logger = logger;
}
/// <inheritdoc />
@@ -61,6 +66,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
throw new ArgumentException("机器人名称不能为空。", nameof(robotName));
}
_logger?.LogInformation("ResetRobot: robotName={RobotName}, dof={Dof}", robotName, robot.DegreesOfFreedom);
lock (_stateLock)
{
DisconnectClients();
@@ -82,6 +89,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
/// <inheritdoc />
public void SetActiveController(bool sim)
{
_logger?.LogInformation("SetActiveController: sim={Sim}", sim);
lock (_stateLock)
{
EnsureRobotSetup();
@@ -101,6 +109,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
throw new ArgumentException("控制器 IP 不能为空。", nameof(robotIp));
}
_logger?.LogInformation("Connect 开始: robotIp={RobotIp}, 仿真={IsSim}", robotIp, _activeControllerIsSimulation);
lock (_stateLock)
{
EnsureActiveControllerSelected();
@@ -109,6 +119,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_connectedRobotIp = robotIp;
_isEnabled = false;
_isInMotion = false;
_logger?.LogInformation("Connect 完成(仿真): robotIp={RobotIp}", robotIp);
return;
}
@@ -121,11 +132,14 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isEnabled = false;
_isInMotion = false;
}
_logger?.LogInformation("Connect 完成(真机): robotIp={RobotIp}, 三条通道已建立", robotIp);
}
/// <inheritdoc />
public void Disconnect()
{
_logger?.LogInformation("Disconnect 开始");
lock (_stateLock)
{
EnsureRobotSetup();
@@ -135,6 +149,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isEnabled = false;
_isInMotion = false;
}
_logger?.LogInformation("Disconnect 完成");
}
/// <inheritdoc />
@@ -145,6 +160,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
throw new ArgumentOutOfRangeException(nameof(bufferSize), "buffer_size 必须大于 0。");
}
_logger?.LogInformation("EnableRobot 开始: bufferSize={BufferSize}, 仿真={IsSim}", bufferSize, _activeControllerIsSimulation);
lock (_stateLock)
{
EnsureConnected();
@@ -153,6 +170,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
if (IsSimulationMode)
{
_isEnabled = true;
_logger?.LogInformation("EnableRobot 完成(仿真)");
return;
}
@@ -165,11 +183,14 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_j519Client.StartMotion();
_isEnabled = true;
}
_logger?.LogInformation("EnableRobot 完成(真机): RVBUSTSM 已启动, J519 运动循环已开启");
}
/// <inheritdoc />
public void DisableRobot()
{
_logger?.LogInformation("DisableRobot 开始");
lock (_stateLock)
{
EnsureRobotSetup();
@@ -183,11 +204,13 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isEnabled = false;
_isInMotion = false;
}
_logger?.LogInformation("DisableRobot 完成");
}
/// <inheritdoc />
public void StopMove()
{
_logger?.LogInformation("StopMove 开始");
lock (_stateLock)
{
EnsureRobotSetup();
@@ -199,6 +222,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isInMotion = false;
}
_logger?.LogInformation("StopMove 完成");
}
/// <inheritdoc />
@@ -225,6 +249,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
throw new ArgumentOutOfRangeException(nameof(ratio), "ratio 必须是有限数值。");
}
_logger?.LogInformation("SetSpeedRatio: ratio={Ratio}", ratio);
lock (_stateLock)
{
EnsureConnected();
@@ -236,6 +262,8 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_speedRatio = clampedRatio;
}
_logger?.LogInformation("SetSpeedRatio 完成: clampedRatio={ClampedRatio}", _speedRatio);
}
/// <inheritdoc />
@@ -426,6 +454,11 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
ArgumentNullException.ThrowIfNull(result);
ArgumentNullException.ThrowIfNull(finalJointPositions);
_logger?.LogInformation(
"ExecuteTrajectory 开始: program={ProgramName}, method={Method}, 时长={Duration}s, 稠密采样={HasDense}, 触发事件数={TriggerCount}, speedRatio={SpeedRatio}",
result.ProgramName, result.Method, result.Duration.TotalSeconds,
result.DenseJointTrajectory is not null, result.TriggerTimeline.Count, _speedRatio);
lock (_stateLock)
{
EnsureEnabled();
@@ -447,6 +480,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_sendCts = new CancellationTokenSource();
var ct = _sendCts.Token;
_sendTask = Task.Run(() => SendDenseTrajectory(result, finalJointPositions, ct), ct);
_logger?.LogInformation("ExecuteTrajectory 已启动后台稠密发送任务");
return;
}
@@ -462,6 +496,7 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
_isInMotion = true;
_jointPositions = finalJointPositions.ToArray();
_isInMotion = false;
_logger?.LogInformation("ExecuteTrajectory 完成(单点模式)");
}
}
@@ -498,12 +533,18 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
var sampleCount = CalculateDenseSendSampleCount(durationSeconds, trajectoryStepSeconds);
var periodTicks = (long)(servoPeriodSeconds * Stopwatch.Frequency);
_logger?.LogInformation(
"SendDenseTrajectory 开始: program={ProgramName}, 采样数={SampleCount}, 时长={Duration}s, speedRatio={SpeedRatio}, 周期={Period}ms, 触发事件数={TriggerCount}",
result.ProgramName, sampleCount, durationSeconds, speedRatio, servoPeriodSeconds * 1000, triggers.Count);
var stopwatch = Stopwatch.StartNew();
long nextTick = stopwatch.ElapsedTicks;
ushort ioValue = 0;
ushort ioMask = 0;
int holdRemaining = -1;
int segmentIndex = 0;
long logInterval = Math.Max(1, sampleCount / 10);
int triggerFiredCount = 0;
try
{
@@ -538,6 +579,10 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
ioMask = ComputeIoValue(trigger.AddressGroup);
ioValue = ioMask;
holdRemaining = Math.Max(trigger.HoldCycles - 1, 0);
triggerFiredCount++;
_logger?.LogInformation(
"J519 IO触发: time={Time:F4}s, addr=[{Addr}], holdCycles={HoldCycles}",
trajectoryTime, string.Join(",", trigger.AddressGroup.Addresses), trigger.HoldCycles);
break;
}
}
@@ -558,15 +603,30 @@ public sealed class FanucControllerRuntime : IControllerRuntime, IDisposable
ioMask = 0;
}
// 周期性记录进度Debug 级别,避免高频 Info 日志)。
if (sampleIndex > 0 && sampleIndex % logInterval == 0)
{
_logger?.LogDebug(
"SendDenseTrajectory 进度: {Percent}% ({Current}/{Total}), time={Time:F4}s",
(int)((double)sampleIndex / sampleCount * 100), sampleIndex, sampleCount, trajectoryTime);
}
// 高精度忙等待直到下一伺服周期。
while (stopwatch.ElapsedTicks < nextTick)
{
Thread.SpinWait(1);
}
}
_logger?.LogInformation(
"SendDenseTrajectory 正常完成: 采样数={SampleCount}, 触发次数={TriggerFiredCount}, 实际耗时={ElapsedMs}ms",
sampleCount, triggerFiredCount, stopwatch.ElapsedMilliseconds);
}
catch (OperationCanceledException)
{
_logger?.LogWarning(
"SendDenseTrajectory 被取消: 已完成 {Percent}% ({Current}/{Total}), 触发次数={TriggerFiredCount}",
(int)((double)(sampleCount > 0 ? 0 : 0) / sampleCount * 100), 0, sampleCount, triggerFiredCount);
// 正常取消,轨迹被中断。
}
finally

View File

@@ -6,6 +6,10 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.1" />
</ItemGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Flyshot.Core.Tests</_Parameter1>

View File

@@ -1,4 +1,5 @@
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc.Protocol;
@@ -8,6 +9,7 @@ namespace Flyshot.Runtime.Fanuc.Protocol;
public sealed class FanucCommandClient : IDisposable
{
private readonly SemaphoreSlim _sendLock = new(1, 1);
private readonly ILogger<FanucCommandClient>? _logger;
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private bool _disposed;
@@ -17,6 +19,15 @@ public sealed class FanucCommandClient : IDisposable
/// </summary>
public bool IsConnected => _tcpClient?.Connected ?? false;
/// <summary>
/// 初始化 FANUC 命令通道客户端。
/// </summary>
/// <param name="logger">日志记录器;允许 null。</param>
public FanucCommandClient(ILogger<FanucCommandClient>? logger = null)
{
_logger = logger;
}
/// <summary>
/// 建立到 FANUC 控制柜 TCP 10012 命令通道的连接。
/// </summary>
@@ -37,9 +48,13 @@ public sealed class FanucCommandClient : IDisposable
throw new InvalidOperationException("命令通道已经连接,请先 Disconnect。");
}
_logger?.LogInformation("CommandClient ConnectAsync: {Ip}:{Port}", ip, port);
_tcpClient = new TcpClient { NoDelay = true };
await _tcpClient.ConnectAsync(ip, port, cancellationToken).ConfigureAwait(false);
_stream = _tcpClient.GetStream();
_logger?.LogInformation("CommandClient 已连接: {Ip}:{Port}", ip, port);
}
/// <summary>
@@ -49,6 +64,8 @@ public sealed class FanucCommandClient : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
_logger?.LogInformation("CommandClient Disconnect");
_stream?.Dispose();
_stream = null;
_tcpClient?.Dispose();
@@ -259,7 +276,9 @@ public sealed class FanucCommandClient : IDisposable
try
{
await _stream.WriteAsync(frame, cancellationToken).ConfigureAwait(false);
return await ReadResponseFrameAsync(cancellationToken).ConfigureAwait(false);
var response = await ReadResponseFrameAsync(cancellationToken).ConfigureAwait(false);
_logger?.LogDebug("CommandClient 发送帧成功: 帧长度={FrameLength}, 响应长度={ResponseLength}", frame.Length, response.Length);
return response;
}
finally
{
@@ -270,10 +289,11 @@ public sealed class FanucCommandClient : IDisposable
/// <summary>
/// 校验普通命令响应结果码,失败时抛出包含消息号和结果码的诊断异常。
/// </summary>
private static FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response)
private FanucCommandResultResponse EnsureSuccess(FanucCommandResultResponse response)
{
if (!response.IsSuccess)
{
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
@@ -283,10 +303,11 @@ public sealed class FanucCommandClient : IDisposable
/// <summary>
/// 校验程序状态响应结果码,失败时抛出包含消息号和结果码的诊断异常。
/// </summary>
private static FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response)
private FanucProgramStatusResponse EnsureSuccess(FanucProgramStatusResponse response)
{
if (!response.IsSuccess)
{
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
@@ -296,10 +317,11 @@ public sealed class FanucCommandClient : IDisposable
/// <summary>
/// 校验速度倍率响应结果码,失败时抛出包含消息号和结果码的诊断异常。
/// </summary>
private static FanucSpeedRatioResponse EnsureSuccess(FanucSpeedRatioResponse response)
private FanucSpeedRatioResponse EnsureSuccess(FanucSpeedRatioResponse response)
{
if (!response.IsSuccess)
{
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
@@ -309,10 +331,11 @@ public sealed class FanucCommandClient : IDisposable
/// <summary>
/// 校验 TCP 位姿响应结果码,失败时抛出包含消息号和结果码的诊断异常。
/// </summary>
private static FanucTcpResponse EnsureSuccess(FanucTcpResponse response)
private FanucTcpResponse EnsureSuccess(FanucTcpResponse response)
{
if (!response.IsSuccess)
{
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}
@@ -322,10 +345,11 @@ public sealed class FanucCommandClient : IDisposable
/// <summary>
/// 校验 IO 读取响应结果码,失败时抛出包含消息号和结果码的诊断异常。
/// </summary>
private static FanucIoResponse EnsureSuccess(FanucIoResponse response)
private FanucIoResponse EnsureSuccess(FanucIoResponse response)
{
if (!response.IsSuccess)
{
_logger?.LogError("FANUC 命令失败: msgId=0x{MessageId:X4}, resultCode={ResultCode}", response.MessageId, response.ResultCode);
throw CreateCommandFailureException(response.MessageId, response.ResultCode);
}

View File

@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc.Protocol;
@@ -10,6 +11,7 @@ public sealed class FanucJ519Client : IDisposable
{
private readonly object _commandLock = new();
private readonly object _responseLock = new();
private readonly ILogger<FanucJ519Client>? _logger;
private UdpClient? _udpClient;
private CancellationTokenSource? _cts;
private CancellationTokenSource? _sendCts;
@@ -26,6 +28,15 @@ public sealed class FanucJ519Client : IDisposable
/// </summary>
public bool IsConnected => _udpClient is not null;
/// <summary>
/// 初始化 FANUC J519 客户端。
/// </summary>
/// <param name="logger">日志记录器;允许 null。</param>
public FanucJ519Client(ILogger<FanucJ519Client>? logger = null)
{
_logger = logger;
}
/// <summary>
/// 建立到 FANUC 控制柜 UDP 60015 运动通道的连接并启动接收循环。
/// </summary>
@@ -46,11 +57,14 @@ public sealed class FanucJ519Client : IDisposable
throw new InvalidOperationException("J519 通道已经连接,请先 Disconnect。");
}
_logger?.LogInformation("J519 ConnectAsync: {Ip}:{Port}", ip, port);
_udpClient = new UdpClient();
_udpClient.Connect(ip, port);
// 发送初始化包。
await _udpClient.SendAsync(FanucJ519Protocol.PackInitPacket(), cancellationToken).ConfigureAwait(false);
_logger?.LogInformation("J519 初始化包已发送");
_cts = new CancellationTokenSource();
_receiveTask = Task.Run(() => ReceiveLoopAsync(_cts.Token), _cts.Token);
@@ -70,9 +84,11 @@ public sealed class FanucJ519Client : IDisposable
if (_sendTask is not null)
{
_logger?.LogDebug("J519 StartMotion: 发送循环已在运行");
return; // 已在运行。
}
_logger?.LogInformation("J519 StartMotion: 启动发送循环");
_sendCts = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token);
_sendTask = Task.Run(() => SendLoopAsync(_sendCts.Token), _sendCts.Token);
}
@@ -89,6 +105,8 @@ public sealed class FanucJ519Client : IDisposable
return;
}
_logger?.LogInformation("J519 StopMotionAsync: 停止发送循环");
if (_sendTask is not null)
{
_sendCts?.Cancel();
@@ -99,7 +117,7 @@ public sealed class FanucJ519Client : IDisposable
}
catch (TimeoutException)
{
// 发送循环可能未能在 1 秒内结束,继续执行后续清理。
_logger?.LogWarning("J519 StopMotionAsync: 发送循环未能在 1 秒内结束");
}
catch (OperationCanceledException)
{
@@ -114,6 +132,7 @@ public sealed class FanucJ519Client : IDisposable
// 发送结束包通知控制器停止伺服流。
await _udpClient.SendAsync(FanucJ519Protocol.PackEndPacket(), cancellationToken).ConfigureAwait(false);
_logger?.LogInformation("J519 StopMotionAsync: 结束包已发送");
}
/// <summary>
@@ -130,6 +149,12 @@ public sealed class FanucJ519Client : IDisposable
_currentCommand = command;
_commandHistoryForTests?.Add(command);
}
_logger?.LogDebug(
"J519 UpdateCommand: joints=[{Joints}], ioMask=0x{IoMask:X4}, ioValue=0x{IoValue:X4}",
string.Join(", ", command.TargetJoints.Select(j => j.ToString("F2"))),
command.WriteIoMask,
command.WriteIoValue);
}
/// <summary>
@@ -353,6 +378,10 @@ public sealed class FanucJ519Client : IDisposable
return;
}
_logger?.LogInformation("J519 ReceiveLoop 启动");
long receiveCount = 0;
FanucJ519Response? lastLoggedResponse = null;
try
{
while (!cancellationToken.IsCancellationRequested)
@@ -365,16 +394,40 @@ public sealed class FanucJ519Client : IDisposable
{
_latestResponse = response;
}
receiveCount++;
// 仅在状态变化时记录 Info避免高频日志。
if (lastLoggedResponse is null
|| lastLoggedResponse.Status != response.Status
|| lastLoggedResponse.RobotInMotion != response.RobotInMotion
|| lastLoggedResponse.SystemReady != response.SystemReady)
{
_logger?.LogInformation(
"J519 响应: status=0x{Status:X2}, seq={Seq}, accept={Accept}, sysrdy={SysRdy}, motion={Motion}, pose=[{Pose}], joints=[{Joints}]",
response.Status,
response.Sequence,
response.AcceptsCommand,
response.SystemReady,
response.RobotInMotion,
string.Join(", ", response.Pose.Select(v => v.ToString("F1"))),
string.Join(", ", response.JointDegrees.Take(6).Select(v => v.ToString("F2"))));
lastLoggedResponse = response;
}
else if (receiveCount % 1000 == 0)
{
_logger?.LogDebug("J519 已接收 {Count} 个响应包", receiveCount);
}
}
}
}
catch (OperationCanceledException)
{
// 正常取消,退出循环。
_logger?.LogInformation("J519 ReceiveLoop 正常取消,共接收 {Count} 个包", receiveCount);
}
catch (ObjectDisposedException)
{
// UDP 客户端已释放退出循环。
_logger?.LogInformation("J519 ReceiveLoop 因 UDP 释放退出,共接收 {Count} 个包", receiveCount);
}
}
}

View File

@@ -1,4 +1,5 @@
using System.Net.Sockets;
using Microsoft.Extensions.Logging;
namespace Flyshot.Runtime.Fanuc.Protocol;
@@ -114,6 +115,7 @@ public sealed class FanucStateClient : IDisposable
{
private readonly object _stateLock = new();
private readonly FanucStateClientOptions _options;
private readonly ILogger<FanucStateClient>? _logger;
private TcpClient? _tcpClient;
private NetworkStream? _stream;
private CancellationTokenSource? _receiveCts;
@@ -130,7 +132,7 @@ public sealed class FanucStateClient : IDisposable
/// 使用默认状态通道参数初始化客户端。
/// </summary>
public FanucStateClient()
: this(new FanucStateClientOptions())
: this(new FanucStateClientOptions(), null)
{
}
@@ -139,10 +141,21 @@ public sealed class FanucStateClient : IDisposable
/// </summary>
/// <param name="options">超时和重连参数。</param>
public FanucStateClient(FanucStateClientOptions options)
: this(options, null)
{
}
/// <summary>
/// 使用指定状态通道参数和日志记录器初始化客户端。
/// </summary>
/// <param name="options">超时和重连参数。</param>
/// <param name="logger">日志记录器;允许 null。</param>
public FanucStateClient(FanucStateClientOptions options, ILogger<FanucStateClient>? logger)
{
ArgumentNullException.ThrowIfNull(options);
ValidateOptions(options);
_options = options;
_logger = logger;
}
/// <summary>
@@ -170,6 +183,8 @@ public sealed class FanucStateClient : IDisposable
throw new InvalidOperationException("状态通道已经连接,请先 Disconnect。");
}
_logger?.LogInformation("StateClient ConnectAsync: {Ip}:{Port}", ip, port);
_receiveCts = new CancellationTokenSource();
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _receiveCts.Token);
@@ -187,8 +202,9 @@ public sealed class FanucStateClient : IDisposable
{
await OpenConnectionAsync(ip, port, linkedCts.Token).ConfigureAwait(false);
}
catch
catch (Exception exception)
{
_logger?.LogError(exception, "StateClient 连接失败: {Ip}:{Port}", ip, port);
CloseCurrentConnection();
lock (_stateLock)
{
@@ -203,6 +219,8 @@ public sealed class FanucStateClient : IDisposable
_receiveTask = Task.Run(
() => ReceiveAndReconnectLoopAsync(ip, port, _receiveCts.Token),
_receiveCts.Token);
_logger?.LogInformation("StateClient 已连接并启动接收循环: {Ip}:{Port}", ip, port);
}
/// <summary>
@@ -212,6 +230,7 @@ public sealed class FanucStateClient : IDisposable
{
ObjectDisposedException.ThrowIf(_disposed, this);
_logger?.LogInformation("StateClient Disconnect");
Shutdown(clearLatestFrame: true);
}
@@ -268,6 +287,7 @@ public sealed class FanucStateClient : IDisposable
private async Task ReceiveAndReconnectLoopAsync(string ip, int port, CancellationToken cancellationToken)
{
var reconnectDelay = _options.ReconnectInitialDelay;
_logger?.LogInformation("StateClient 接收循环启动: {Ip}:{Port}", ip, port);
while (!cancellationToken.IsCancellationRequested)
{
@@ -278,14 +298,17 @@ public sealed class FanucStateClient : IDisposable
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
_logger?.LogInformation("StateClient 接收循环正常取消");
return;
}
catch (TimeoutException ex)
{
_logger?.LogWarning(ex, "StateClient 接收超时");
MarkReceiveFailure(FanucStateConnectionState.TimedOut, ex.Message);
}
catch (Exception ex) when (ex is IOException or InvalidDataException or SocketException or ObjectDisposedException)
{
_logger?.LogWarning(ex, "StateClient 连接异常,准备重连");
MarkReceiveFailure(FanucStateConnectionState.Reconnecting, ex.Message);
}
@@ -312,6 +335,8 @@ public sealed class FanucStateClient : IDisposable
}
var buffer = new byte[FanucStateProtocol.StateFrameLength];
long frameCount = 0;
FanucStateFrame? lastLoggedFrame = null;
while (!cancellationToken.IsCancellationRequested)
{
@@ -325,6 +350,26 @@ public sealed class FanucStateClient : IDisposable
_connectionState = FanucStateConnectionState.Connected;
_lastErrorMessage = null;
}
frameCount++;
// 仅在状态变化或首次接收时记录 Info避免高频日志。
if (lastLoggedFrame is null
|| lastLoggedFrame.CartesianPose[0] != frame.CartesianPose[0]
|| !lastLoggedFrame.RawTailWords.SequenceEqual(frame.RawTailWords))
{
_logger?.LogInformation(
"StateClient 收到状态帧: pose=[{X:F1}, {Y:F1}, {Z:F1}], tail=[{Tail}]",
frame.CartesianPose[0],
frame.CartesianPose[1],
frame.CartesianPose[2],
string.Join(", ", frame.RawTailWords));
lastLoggedFrame = frame;
}
else if (frameCount % 1000 == 0)
{
_logger?.LogDebug("StateClient 已接收 {Count} 个状态帧", frameCount);
}
}
}
@@ -367,6 +412,8 @@ public sealed class FanucStateClient : IDisposable
var tcpClient = new TcpClient { NoDelay = true };
try
{
_logger?.LogInformation("StateClient 正在连接 {Ip}:{Port}...", ip, port);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(_options.ConnectTimeout);
await tcpClient.ConnectAsync(ip, port, timeoutCts.Token).ConfigureAwait(false);
@@ -378,14 +425,18 @@ public sealed class FanucStateClient : IDisposable
_lastConnectedAt = DateTimeOffset.UtcNow;
_connectionState = FanucStateConnectionState.Connected;
}
_logger?.LogInformation("StateClient 已连接到 {Ip}:{Port}", ip, port);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger?.LogWarning("StateClient 连接 {Ip}:{Port} 超时", ip, port);
tcpClient.Dispose();
throw new TimeoutException("状态通道建连超时。");
}
catch
catch (Exception ex)
{
_logger?.LogWarning(ex, "StateClient 连接 {Ip}:{Port} 失败", ip, port);
tcpClient.Dispose();
throw;
}
@@ -409,6 +460,12 @@ public sealed class FanucStateClient : IDisposable
_connectionState = FanucStateConnectionState.Reconnecting;
}
_logger?.LogInformation(
"StateClient 将在 {Delay}ms 后尝试重连 {Ip}:{Port}...",
nextDelay.TotalMilliseconds,
ip,
port);
await Task.Delay(nextDelay, cancellationToken).ConfigureAwait(false);
lock (_stateLock)
@@ -419,6 +476,11 @@ public sealed class FanucStateClient : IDisposable
try
{
await OpenConnectionAsync(ip, port, cancellationToken).ConfigureAwait(false);
_logger?.LogInformation(
"StateClient 重连成功: {Ip}:{Port}, 累计重连次数={Count}",
ip,
port,
_reconnectAttemptCount);
return _options.ReconnectInitialDelay;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
@@ -434,6 +496,13 @@ public sealed class FanucStateClient : IDisposable
_lastErrorMessage = ex.Message;
}
_logger?.LogWarning(
ex,
"StateClient 重连失败: {Ip}:{Port}, 下次等待={NextDelay}ms",
ip,
port,
nextDelay.TotalMilliseconds * 2);
nextDelay = IncreaseReconnectDelay(nextDelay);
}
}
@@ -470,6 +539,8 @@ public sealed class FanucStateClient : IDisposable
_connectionState = state;
_lastErrorMessage = message;
}
_logger?.LogWarning("StateClient 接收失败: state={State}, message={Message}", state, message);
}
/// <summary>

File diff suppressed because it is too large Load Diff

View File

@@ -12,14 +12,17 @@ namespace Flyshot.Server.Host.Controllers;
public sealed class LegacyHttpApiController : ControllerBase
{
private readonly IControllerClientCompatService _compatService;
private readonly ILogger<LegacyHttpApiController> _logger;
/// <summary>
/// 初始化旧 HTTP 兼容控制器。
/// </summary>
/// <param name="compatService">ControllerClient 兼容服务。</param>
public LegacyHttpApiController(IControllerClientCompatService compatService)
/// <param name="logger">日志记录器。</param>
public LegacyHttpApiController(IControllerClientCompatService compatService, ILogger<LegacyHttpApiController> logger)
{
_compatService = compatService ?? throw new ArgumentNullException(nameof(compatService));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
@@ -41,13 +44,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/connect_server/")]
public IActionResult ConnectServer([FromQuery] string server_ip, [FromQuery] int port)
{
_logger.LogInformation("ConnectServer 调用: server_ip={ServerIp}, port={Port}", server_ip, port);
try
{
_compatService.ConnectServer(server_ip, port);
_logger.LogInformation("ConnectServer 成功: server_ip={ServerIp}, port={Port}", server_ip, port);
return Ok(new { status = "connected" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "ConnectServer 失败: server_ip={ServerIp}, port={Port}", server_ip, port);
return LegacyBadRequest("Connect Server failed");
}
}
@@ -80,13 +86,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/setup_robot/")]
public IActionResult SetupRobot([FromQuery] string robot_name)
{
_logger.LogInformation("SetupRobot 调用: robot_name={RobotName}", robot_name);
try
{
_compatService.SetUpRobot(robot_name);
_logger.LogInformation("SetupRobot 成功: robot_name={RobotName}", robot_name);
return Ok(new { status = "robot setup" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "SetupRobot 失败: robot_name={RobotName}", robot_name);
return LegacyBadRequest("SetUpRobot failed");
}
}
@@ -152,13 +161,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpGet("/enable_robot/")]
public IActionResult EnableRobot([FromQuery] int buffer_size = 2)
{
_logger.LogInformation("EnableRobot 调用: buffer_size={BufferSize}", buffer_size);
try
{
_compatService.EnableRobot(buffer_size);
_logger.LogInformation("EnableRobot 成功");
return Ok(new { enable_robot = true });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "EnableRobot 失败");
return LegacyBadRequest("EnableRobot failed");
}
}
@@ -170,13 +182,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpGet("/disable_robot/")]
public IActionResult DisableRobot()
{
_logger.LogInformation("DisableRobot 调用");
try
{
_compatService.DisableRobot();
_logger.LogInformation("DisableRobot 成功");
return Ok(new { disable_robot = true });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "DisableRobot 失败");
return LegacyBadRequest("DisableRobot failed");
}
}
@@ -188,13 +203,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpGet("/stop_move/")]
public IActionResult StopMove()
{
_logger.LogInformation("StopMove 调用");
try
{
_compatService.StopMove();
_logger.LogInformation("StopMove 成功");
return Ok(new { status = "move stopped" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "StopMove 失败");
return LegacyBadRequest("StopMove failed");
}
}
@@ -207,13 +225,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/set_active_controller/")]
public IActionResult SetActiveController([FromQuery] bool sim)
{
_logger.LogInformation("SetActiveController 调用: sim={Sim}", sim);
try
{
_compatService.SetActiveController(sim);
_logger.LogInformation("SetActiveController 成功: sim={Sim}", sim);
return Ok(new { status = "active controller set" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "SetActiveController 失败: sim={Sim}", sim);
return LegacyBadRequest("SetActiveController failed");
}
}
@@ -226,13 +247,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/connect_robot/")]
public IActionResult ConnectRobot([FromQuery] string ip)
{
_logger.LogInformation("ConnectRobot 调用: ip={Ip}", ip);
try
{
_compatService.Connect(ip);
_logger.LogInformation("ConnectRobot 成功: ip={Ip}", ip);
return Ok(new { status = "robot connected" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "ConnectRobot 失败: ip={Ip}", ip);
return LegacyBadRequest("Connect failed");
}
}
@@ -244,13 +268,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/disconnect_robot/")]
public IActionResult DisconnectRobot()
{
_logger.LogInformation("DisconnectRobot 调用");
try
{
_compatService.Disconnect();
_logger.LogInformation("DisconnectRobot 成功");
return Ok(new { status = "robot disconnected" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "DisconnectRobot 失败");
return LegacyBadRequest("Disconnect failed");
}
}
@@ -286,13 +313,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/set_tcp/")]
public IActionResult SetTcp([FromBody] LegacyTcpRequest tcp_data)
{
_logger.LogInformation("SetTcp 调用: x={X}, y={Y}, z={Z}", tcp_data.x, tcp_data.y, tcp_data.z);
try
{
_compatService.SetTcp(tcp_data.x, tcp_data.y, tcp_data.z);
_logger.LogInformation("SetTcp 成功");
return Ok(new { status = "TCP set" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "SetTcp 失败");
return LegacyBadRequest("SetTCP failed");
}
}
@@ -324,13 +354,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/set_io/")]
public IActionResult SetIo([FromQuery] int port, [FromQuery] bool value, [FromQuery] string io_type)
{
_logger.LogInformation("SetIo 调用: port={Port}, value={Value}, io_type={IoType}", port, value, io_type);
try
{
_compatService.SetIo(port, value, io_type);
_logger.LogInformation("SetIo 成功: port={Port}, value={Value}", port, value);
return Ok(new { status = "IO set" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "SetIo 失败: port={Port}, value={Value}", port, value);
return LegacyBadRequest("SetDigitalOutput failed");
}
}
@@ -344,12 +377,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpGet("/get_io/")]
public IActionResult GetIo([FromQuery] int port, [FromQuery] string io_type)
{
_logger.LogInformation("GetIo 调用: port={Port}, io_type={IoType}", port, io_type);
try
{
return Ok(new { value = _compatService.GetIo(port, io_type) });
var value = _compatService.GetIo(port, io_type);
_logger.LogInformation("GetIo 成功: port={Port}, value={Value}", port, value);
return Ok(new { value });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "GetIo 失败: port={Port}", port);
return LegacyBadRequest("GetDigitalOutput failed");
}
}
@@ -379,13 +416,17 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/move_joint/")]
public IActionResult MoveJoint([FromBody] LegacyJointPositionRequest joint_data)
{
_logger.LogInformation("MoveJoint 调用: 关节数={JointCount}", joint_data.joints.Count);
_logger.LogDebug("MoveJoint 路点: {Joints}", string.Join(", ", joint_data.joints.Select(j => j.ToString("F4"))));
try
{
_compatService.MoveJoint(joint_data.joints);
_logger.LogInformation("MoveJoint 成功");
return Ok(new { status = "robot moved" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "MoveJoint 失败");
return LegacyBadRequest("MoveJoint failed");
}
}
@@ -441,16 +482,20 @@ public sealed class LegacyHttpApiController : ControllerBase
[FromQuery] string? method = null,
[FromQuery] bool? save_traj = null)
{
_logger.LogInformation("ExecuteTrajectory 调用: method={Method}, save_traj={SaveTraj}", method ?? "icsp", save_traj ?? false);
try
{
var request = ParseExecuteTrajectoryRequest(waypoints, method, save_traj);
_logger.LogDebug("ExecuteTrajectory 路点数={WaypointCount}, method={Method}", request.Waypoints.Count, request.Method);
_compatService.ExecuteTrajectory(
request.Waypoints,
new TrajectoryExecutionOptions(request.Method, request.SaveTrajectory));
_logger.LogInformation("ExecuteTrajectory 成功: method={Method}", request.Method);
return Ok(new { status = "trajectory executed" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "ExecuteTrajectory 失败");
return LegacyBadRequest("ExecuteTrajectory failed");
}
}
@@ -463,18 +508,30 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/upload_flyshot/")]
public IActionResult UploadFlyshot([FromBody] LegacyFlightTrajectoryRequest trajectory_data)
{
_logger.LogInformation(
"UploadFlyshot 调用: name={Name}, waypoints={WaypointCount}, shot_flags={ShotCount}",
trajectory_data.name,
trajectory_data.waypoints.Count,
trajectory_data.shot_flags.Count(static f => f));
if (trajectory_data.shot_flags.Count != trajectory_data.waypoints.Count)
{
_logger.LogWarning("UploadFlyshot 校验失败: shot_flags长度({ShotFlagsCount}) != 路点数({WaypointCount})",
trajectory_data.shot_flags.Count, trajectory_data.waypoints.Count);
return LegacyValidationError("shot_flags长度必须与路点数量相同");
}
if (trajectory_data.offset_values.Count != trajectory_data.waypoints.Count)
{
_logger.LogWarning("UploadFlyshot 校验失败: offset_values长度({OffsetCount}) != 路点数({WaypointCount})",
trajectory_data.offset_values.Count, trajectory_data.waypoints.Count);
return LegacyValidationError("offset_values长度必须与路点数量相同");
}
if (trajectory_data.addrs.Count != trajectory_data.waypoints.Count)
{
_logger.LogWarning("UploadFlyshot 校验失败: addrs长度({AddrCount}) != 路点数({WaypointCount})",
trajectory_data.addrs.Count, trajectory_data.waypoints.Count);
return LegacyValidationError("addrs长度必须与路点数量相同");
}
@@ -488,10 +545,12 @@ public sealed class LegacyHttpApiController : ControllerBase
addressGroups: trajectory_data.addrs);
_compatService.UploadTrajectory(trajectory);
_logger.LogInformation("UploadFlyshot 成功: name={Name}", trajectory_data.name);
return Ok(new { status = "FlyShot uploaded" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "UploadFlyshot 失败: name={Name}", trajectory_data.name);
return LegacyBadRequest("UploadFlyShotTraj failed");
}
}
@@ -504,6 +563,9 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/execute_flyshot/")]
public IActionResult ExecuteFlyshot([FromBody] LegacyExecuteFlyshotRequest data)
{
_logger.LogInformation(
"ExecuteFlyshot 调用: name={Name}, method={Method}, move_to_start={MoveToStart}, use_cache={UseCache}",
data.name, data.method, data.move_to_start, data.use_cache);
try
{
_compatService.ExecuteTrajectoryByName(
@@ -513,10 +575,12 @@ public sealed class LegacyHttpApiController : ControllerBase
method: data.method,
saveTrajectory: data.save_traj,
useCache: data.use_cache));
_logger.LogInformation("ExecuteFlyshot 成功: name={Name}", data.name);
return Ok(new { status = "FlyShot executed", success = true });
}
catch (Exception exception)
{
_logger.LogError(exception, "ExecuteFlyshot 失败: name={Name}", data.name);
return StatusCode(StatusCodes.Status500InternalServerError, new { detail = exception.Message });
}
}
@@ -529,17 +593,21 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/save_traj_info/")]
public IActionResult SaveTrajectoryInfo([FromBody] LegacyTrajectoryInfoRequest request)
{
_logger.LogInformation("SaveTrajectoryInfo 调用: name={Name}, method={Method}", request.name, request.method);
try
{
_compatService.SaveTrajectoryInfo(request.name, request.method);
_logger.LogInformation("SaveTrajectoryInfo 成功: name={Name}", request.name);
return Ok(new { status = "trajectory info saved", success = true });
}
catch (NotSupportedException exception)
{
_logger.LogWarning(exception, "SaveTrajectoryInfo 不支持: name={Name}", request.name);
return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "SaveTrajectoryInfo 失败: name={Name}", request.name);
return LegacyBadRequest("SaveTrajInfo failed");
}
}
@@ -552,6 +620,7 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/is_flyShotTrajValid/")]
public IActionResult IsFlyshotTrajectoryValid([FromBody] LegacyFlyshotValidationRequest request)
{
_logger.LogInformation("IsFlyshotTrajectoryValid 调用: name={Name}, method={Method}", request.name, request.method);
try
{
var isValid = _compatService.IsFlyshotTrajectoryValid(
@@ -560,14 +629,17 @@ public sealed class LegacyHttpApiController : ControllerBase
request.method,
request.save_traj);
_logger.LogInformation("IsFlyshotTrajectoryValid 结果: name={Name}, valid={Valid}, duration={Duration}s", request.name, isValid, duration.TotalSeconds);
return Ok(new { success = isValid, valid = isValid, time = duration.TotalSeconds });
}
catch (NotSupportedException exception)
{
_logger.LogWarning(exception, "IsFlyshotTrajectoryValid 不支持: name={Name}", request.name);
return StatusCode(StatusCodes.Status501NotImplemented, new { detail = exception.Message });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "IsFlyshotTrajectoryValid 失败: name={Name}", request.name);
return LegacyBadRequest("IsFlyShotTrajValid failed");
}
}
@@ -580,13 +652,23 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/set_speedRatio/")]
public IActionResult SetSpeedRatio([FromBody] LegacySpeedRatioRequest data)
{
_logger.LogInformation("SetSpeedRatio 调用: speed={Speed}", data.speed);
try
{
// 验证数值 范围符合预期(例如 0.01到 1.0),以避免对控制器造成潜在风险
if (data.speed < 0.01 || data.speed > 1.0)
{
_logger.LogWarning("SetSpeedRatio 参数无效: speed={Speed}", data.speed);
return BadRequest(new { detail = "Speed ratio must be between 0.01 and 1.0." });
}
_compatService.SetSpeedRatio(data.speed);
_logger.LogInformation("SetSpeedRatio 成功: speed={Speed}", data.speed);
return Ok(new { message = "set_speedRatio executed", returnCode = 0 });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "SetSpeedRatio 失败: speed={Speed}", data.speed);
return LegacyBadRequest("set_speedRatio failed");
}
}
@@ -599,13 +681,16 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/delete_flyshot/")]
public IActionResult DeleteFlyshot([FromBody] LegacyNameRequest request)
{
_logger.LogInformation("DeleteFlyshot 调用: name={Name}", request.name);
try
{
_compatService.DeleteTrajectory(request.name);
_logger.LogInformation("DeleteFlyshot 成功: name={Name}", request.name);
return Ok(new { status = "FlyShot deleted" });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "DeleteFlyshot 失败: name={Name}", request.name);
return LegacyBadRequest("DeleteFlyShotTraj failed");
}
}
@@ -618,22 +703,28 @@ public sealed class LegacyHttpApiController : ControllerBase
[HttpPost("/init_mpc_robt")]
public IActionResult InitMpcRobot([FromBody] LegacyInitMpcRobotRequest data)
{
_logger.LogInformation(
"InitMpcRobot 调用: robot_name={RobotName}, robot_ip={RobotIp}, sim={Sim}, server={ServerIp}:{Port}",
data.robot_name, data.robot_ip, data.sim, data.server_ip, data.port);
try
{
_compatService.ConnectServer(data.server_ip, data.port);
_compatService.SetUpRobot(data.robot_name);
if (!_compatService.IsSetUp)
{
_logger.LogWarning("InitMpcRobot 失败: Robot not setup");
return LegacyBadRequest("Robot not setup");
}
_compatService.SetActiveController(data.sim);
_compatService.Connect(data.robot_ip);
_compatService.EnableRobot(2);
_logger.LogInformation("InitMpcRobot 成功: robot_name={RobotName}", data.robot_name);
return Ok(new { message = "init_Success", returnCode = 0 });
}
catch
catch (Exception exception)
{
_logger.LogError(exception, "InitMpcRobot 失败");
return LegacyBadRequest("Connect Server failed");
}
}

View File

@@ -4,390 +4,12 @@ using Microsoft.AspNetCore.Mvc;
namespace Flyshot.Server.Host.Controllers;
/// <summary>
/// 提供只读状态监控页面和控制器状态快照 API。
/// 提供控制器状态快照 API,状态监控页面由 wwwroot 静态资源承载
/// </summary>
[ApiController]
[Tags("基础与状态")]
public sealed class StatusController : ControllerBase
{
/// <summary>
/// 浏览器端状态监控页面,页面逻辑只读取状态快照,不触发控制动作。
/// </summary>
private const string StatusPageHtml = """
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flyshot Replacement </title>
<style>
:root {
color-scheme: light;
--bg: #f5f7fb;
--surface: #ffffff;
--line: #d8dee9;
--text: #172033;
--muted: #5b667a;
--accent: #007c89;
--good: #12805c;
--warn: #b7791f;
--bad: #b42318;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
font-size: 15px;
letter-spacing: 0;
}
header {
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 18px 0;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
}
button {
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--accent);
color: #ffffff;
font: inherit;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
/* 顶部操作区按钮和外链按钮共用同一组视觉样式,便于现场顺手跳转。 */
.actions {
display: flex;
align-items: center;
gap: 10px;
}
.link-button {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--accent);
border-radius: 6px;
background: transparent;
color: var(--accent);
font: inherit;
text-decoration: none;
}
.link-button:hover {
background: rgba(0, 124, 137, 0.08);
}
main {
width: min(1180px, calc(100% - 32px));
margin: 22px auto;
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric,
section {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.metric {
min-height: 86px;
padding: 14px;
}
.label {
color: var(--muted);
font-size: 13px;
}
.value {
margin-top: 8px;
overflow-wrap: anywhere;
font-size: 24px;
font-weight: 650;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 12px;
height: 12px;
flex: 0 0 12px;
border-radius: 999px;
background: var(--warn);
}
.dot.good {
background: var(--good);
}
.dot.bad {
background: var(--bad);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
section h2 {
margin: 0;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
font-size: 16px;
font-weight: 650;
}
dl {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 0;
margin: 0;
padding: 4px 16px 12px;
}
dt,
dd {
min-height: 36px;
margin: 0;
padding: 9px 0;
border-bottom: 1px solid #edf1f7;
}
dt {
color: var(--muted);
}
dd {
overflow-wrap: anywhere;
font-family: Consolas, "Cascadia Mono", monospace;
}
.empty {
color: var(--muted);
font-family: inherit;
}
@media (max-width: 820px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.summary,
.grid {
grid-template-columns: 1fr;
}
dl {
grid-template-columns: 1fr;
}
dt {
border-bottom: 0;
padding-bottom: 2px;
}
dd {
padding-top: 2px;
}
}
</style>
</head>
<body>
<header>
<div class="topbar">
<h1>Flyshot Replacement </h1>
<div class="actions">
<a class="link-button" href="/debug" target="_blank" rel="noopener"></a>
<button id="refresh" type="button"></button>
</div>
</div>
</header>
<main>
<div class="summary">
<div class="metric">
<div class="label"></div>
<div class="value status-row"><span id="state-dot" class="dot"></span><span id="connection-state">--</span></div>
</div>
<div class="metric">
<div class="label"></div>
<div id="robot-name" class="value">--</div>
</div>
<div class="metric">
<div class="label"></div>
<div id="speed-ratio" class="value">--</div>
</div>
<div class="metric">
<div class="label"></div>
<div id="motion-state" class="value">--</div>
</div>
</div>
<div class="grid">
<section>
<h2></h2>
<dl>
<dt></dt><dd id="server-version">--</dd>
<dt></dt><dd id="client-version">--</dd>
<dt></dt><dd id="setup-state">--</dd>
<dt>使</dt><dd id="enabled-state">--</dd>
<dt>J519 </dt><dd id="j519-status">--</dd>
<dt>J519 </dt><dd id="j519-sequence">--</dd>
<dt></dt><dd id="captured-at">--</dd>
</dl>
</section>
<section>
<h2></h2>
<dl>
<dt></dt><dd id="dof">--</dd>
<dt></dt><dd id="joints">--</dd>
<dt>TCP 姿</dt><dd id="pose">--</dd>
<dt></dt><dd id="trajectories" class="empty">--</dd>
</dl>
</section>
</div>
</main>
<script>
const fields = {
connectionState: document.getElementById("connection-state"),
stateDot: document.getElementById("state-dot"),
robotName: document.getElementById("robot-name"),
speedRatio: document.getElementById("speed-ratio"),
motionState: document.getElementById("motion-state"),
serverVersion: document.getElementById("server-version"),
clientVersion: document.getElementById("client-version"),
setupState: document.getElementById("setup-state"),
enabledState: document.getElementById("enabled-state"),
j519Status: document.getElementById("j519-status"),
j519Sequence: document.getElementById("j519-sequence"),
capturedAt: document.getElementById("captured-at"),
dof: document.getElementById("dof"),
joints: document.getElementById("joints"),
pose: document.getElementById("pose"),
trajectories: document.getElementById("trajectories"),
refresh: document.getElementById("refresh")
};
function formatArray(values) {
if (!Array.isArray(values) || values.length === 0) {
return "--";
}
return values.map(value => Number(value).toFixed(4)).join(", ");
}
function formatNullableBool(value) {
if (value === true) {
return "是";
}
if (value === false) {
return "否";
}
return "--";
}
function formatJ519Status(snapshot) {
if (snapshot.j519Status === null || snapshot.j519Status === undefined) {
return "--";
}
const status = Number(snapshot.j519Status).toString(16).padStart(2, "0").toUpperCase();
return `0x${status} accept=${formatNullableBool(snapshot.j519AcceptsCommand)} received=${formatNullableBool(snapshot.j519ReceivedCommand)} sysrdy=${formatNullableBool(snapshot.j519SystemReady)} motion=${formatNullableBool(snapshot.j519RobotInMotion)}`;
}
function setDot(connectionState) {
fields.stateDot.className = "dot";
if (connectionState === "Connected") {
fields.stateDot.classList.add("good");
} else if (connectionState === "NotConfigured") {
fields.stateDot.classList.add("bad");
}
}
async function refreshStatus() {
fields.refresh.disabled = true;
try {
const response = await fetch("/api/status/snapshot", { cache: "no-store" });
const payload = await response.json();
const snapshot = payload.snapshot;
fields.connectionState.textContent = snapshot.connectionState;
fields.robotName.textContent = payload.robotName || "--";
fields.speedRatio.textContent = Number(snapshot.speedRatio).toFixed(2);
fields.motionState.textContent = snapshot.isInMotion ? "是" : "否";
fields.serverVersion.textContent = payload.serverVersion;
fields.clientVersion.textContent = payload.clientVersion;
fields.setupState.textContent = payload.isSetup ? "是" : "否";
fields.enabledState.textContent = snapshot.isEnabled ? "是" : "否";
fields.j519Status.textContent = formatJ519Status(snapshot);
fields.j519Sequence.textContent = snapshot.j519Sequence ?? "--";
fields.capturedAt.textContent = new Date(snapshot.capturedAt).toLocaleString();
fields.dof.textContent = payload.degreesOfFreedom;
fields.joints.textContent = formatArray(snapshot.jointPositions);
fields.pose.textContent = formatArray(snapshot.cartesianPose);
fields.trajectories.textContent = payload.uploadedTrajectories.length > 0
? payload.uploadedTrajectories.join(", ")
: "--";
fields.trajectories.classList.toggle("empty", payload.uploadedTrajectories.length === 0);
setDot(snapshot.connectionState);
} finally {
fields.refresh.disabled = false;
}
}
fields.refresh.addEventListener("click", refreshStatus);
refreshStatus();
window.setInterval(refreshStatus, 2000);
</script>
</body>
</html>
""";
private readonly IControllerClientCompatService _compatService;
/// <summary>
@@ -400,13 +22,23 @@ public sealed class StatusController : ControllerBase
}
/// <summary>
/// 返回浏览器可直接打开的状态监控页面
/// 提供短路由 `/status`,跳转到静态状态页
/// </summary>
/// <returns>HTML 状态页面。</returns>
/// <returns>重定向到 <c>/status.html</c>。</returns>
[HttpGet("/status")]
public ContentResult GetStatusPage()
public IActionResult StatusPage()
{
return Content(StatusPageHtml, "text/html; charset=utf-8");
return Redirect("/status.html");
}
/// <summary>
/// 提供短路由 `/debug`,跳转到静态调试页。
/// </summary>
/// <returns>重定向到 <c>/debug.html</c>。</returns>
[HttpGet("/debug")]
public IActionResult DebugPage()
{
return Redirect("/debug.html");
}
/// <summary>

View File

@@ -7,9 +7,20 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.11" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<!-- 运行时需要把仓库根目录的 NLog.config 带到 Host 输出目录,确保控制台和文件日志目标生效。 -->
<Content Include="..\..\NLog.config" Link="NLog.config" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<!-- 让静态调试页在构建和发布时都物理复制到输出目录,避免运行时只依赖源码树中的 wwwroot。 -->
<Content Update="wwwroot\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Flyshot.Core.Domain\Flyshot.Core.Domain.csproj" />
<ProjectReference Include="..\Flyshot.ControllerClientCompat\Flyshot.ControllerClientCompat.csproj" />

View File

@@ -0,0 +1,147 @@
using System.Diagnostics;
using System.Text;
namespace Flyshot.Server.Host.Middleware;
/// <summary>
/// HTTP 请求与响应日志中间件。
/// 记录每个 HTTP 请求的进入时间、方法、路径、查询串、请求体,
/// 以及响应的状态码、耗时和响应体(调试级别)。
/// </summary>
public sealed class RequestResponseLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<RequestResponseLoggingMiddleware> _logger;
/// <summary>
/// 请求体最大日志长度,超出则截断并附加省略标记。
/// </summary>
private const int MaxBodyLogLength = 4096;
/// <summary>
/// 初始化请求响应日志中间件。
/// </summary>
/// <param name="next">下一个中间件委托。</param>
/// <param name="logger">日志记录器。</param>
public RequestResponseLoggingMiddleware(RequestDelegate next, ILogger<RequestResponseLoggingMiddleware> logger)
{
_next = next ?? throw new ArgumentNullException(nameof(next));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
/// <summary>
/// 处理 HTTP 请求并记录输入输出。
/// </summary>
/// <param name="context">HTTP 上下文。</param>
public async Task InvokeAsync(HttpContext context)
{
var stopwatch = Stopwatch.StartNew();
var request = context.Request;
var requestId = Activity.Current?.Id ?? context.TraceIdentifier;
// 记录请求进入信息Info 级别:方法、路径、查询参数)。
_logger.LogInformation(
"[HTTP-REQ] [{RequestId}] {Method} {Path}{QueryString} — 客户端 {RemoteIp}",
requestId,
request.Method,
request.Path,
request.QueryString.HasValue ? request.QueryString.Value : string.Empty,
context.Connection.RemoteIpAddress);
// 读取并记录请求体Debug 级别)。
string? requestBody = null;
if (request.ContentLength > 0 && request.Body.CanRead)
{
request.EnableBuffering();
requestBody = await ReadBodyAsync(request.Body, context.RequestAborted).ConfigureAwait(false);
request.Body.Position = 0;
if (!string.IsNullOrEmpty(requestBody))
{
_logger.LogDebug(
"[HTTP-REQ-BODY] [{RequestId}] {Body}",
requestId,
TruncateBody(requestBody));
}
}
// 拦截响应流以便读取响应体。
var originalResponseBody = context.Response.Body;
using var responseBodyStream = new MemoryStream();
context.Response.Body = responseBodyStream;
try
{
await _next(context).ConfigureAwait(false);
}
catch (Exception exception)
{
_logger.LogError(
exception,
"[HTTP-ERR] [{RequestId}] {Method} {Path} 处理过程中发生未捕获异常",
requestId,
request.Method,
request.Path);
throw;
}
finally
{
stopwatch.Stop();
responseBodyStream.Position = 0;
var responseBody = await ReadBodyAsync(responseBodyStream, context.RequestAborted).ConfigureAwait(false);
responseBodyStream.Position = 0;
await responseBodyStream.CopyToAsync(originalResponseBody, context.RequestAborted).ConfigureAwait(false);
context.Response.Body = originalResponseBody;
var statusCode = context.Response.StatusCode;
var level = statusCode >= 500 ? LogLevel.Error : statusCode >= 400 ? LogLevel.Warning : LogLevel.Information;
// 记录响应概要Info/Warning/Error 级别)。
_logger.Log(
level,
"[HTTP-RES] [{RequestId}] {Method} {Path} => {StatusCode} ({ElapsedMs}ms)",
requestId,
request.Method,
request.Path,
statusCode,
stopwatch.ElapsedMilliseconds);
// 记录响应体Debug 级别)。
if (!string.IsNullOrEmpty(responseBody))
{
_logger.LogDebug(
"[HTTP-RES-BODY] [{RequestId}] {Body}",
requestId,
TruncateBody(responseBody));
}
}
}
/// <summary>
/// 从流中读取文本内容。
/// </summary>
private static async Task<string> ReadBodyAsync(Stream stream, CancellationToken cancellationToken)
{
if (!stream.CanRead)
{
return string.Empty;
}
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
var body = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
return body;
}
/// <summary>
/// 截断过长内容,避免日志膨胀。
/// </summary>
private static string TruncateBody(string body)
{
if (body.Length <= MaxBodyLogLength)
{
return body;
}
return body[..MaxBodyLogLength] + " ... [截断,总长度=" + body.Length + "]";
}
}

View File

@@ -1,20 +1,34 @@
using Flyshot.ControllerClientCompat;
using Flyshot.Server.Host;
using Flyshot.Server.Host.Middleware;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using NLog.Web;
using Swashbuckle.AspNetCore.SwaggerGen;
var builder = WebApplication.CreateBuilder(args);
// NLog: 在 ASP.NET Core 启动前完成配置加载,确保最早期的日志也能被捕获。
NLog.LogManager.Setup().LoadConfigurationFromAppSettings();
var logger = NLog.LogManager.GetCurrentClassLogger();
builder.Services.Configure<HostSwaggerOptions>(builder.Configuration.GetSection("Swagger"));
builder.Services.Configure<HostCorsOptions>(builder.Configuration.GetSection("Cors"));
builder.Services.AddControllerClientCompat(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
var swaggerOptions = builder.Configuration.GetSection("Swagger").Get<HostSwaggerOptions>() ?? new HostSwaggerOptions();
builder.Services.AddSwaggerGen(options =>
try
{
logger.Info("Flyshot Server Host 启动中...");
var builder = WebApplication.CreateBuilder(args);
// NLog: 替换默认日志提供者为 NLog清除其他 Provider 避免重复输出。
builder.Logging.ClearProviders();
builder.Host.UseNLog();
builder.Services.Configure<HostSwaggerOptions>(builder.Configuration.GetSection("Swagger"));
builder.Services.Configure<HostCorsOptions>(builder.Configuration.GetSection("Cors"));
builder.Services.AddControllerClientCompat(builder.Configuration);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
var swaggerOptions = builder.Configuration.GetSection("Swagger").Get<HostSwaggerOptions>() ?? new HostSwaggerOptions();
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc(swaggerOptions.DocumentName, new OpenApiInfo
{
Title = swaggerOptions.Title,
@@ -27,11 +41,11 @@ builder.Services.AddSwaggerGen(options =>
{
options.IncludeXmlComments(xmlDocumentationPath, includeControllerXmlComments: true);
}
});
});
var corsOptions = builder.Configuration.GetSection("Cors").Get<HostCorsOptions>() ?? new HostCorsOptions();
builder.Services.AddCors(options =>
{
var corsOptions = builder.Configuration.GetSection("Cors").Get<HostCorsOptions>() ?? new HostCorsOptions();
builder.Services.AddCors(options =>
{
options.AddPolicy(corsOptions.PolicyName, policyBuilder =>
{
// 兼容本地调试时最常见的任意源配置,同时保留显式白名单模式。
@@ -62,15 +76,15 @@ builder.Services.AddCors(options =>
policyBuilder.WithHeaders(corsOptions.AllowedHeaders);
}
});
});
});
var app = builder.Build();
var app = builder.Build();
var resolvedSwaggerOptions = app.Services.GetRequiredService<IOptions<HostSwaggerOptions>>().Value;
var resolvedCorsOptions = app.Services.GetRequiredService<IOptions<HostCorsOptions>>().Value;
var resolvedSwaggerOptions = app.Services.GetRequiredService<IOptions<HostSwaggerOptions>>().Value;
var resolvedCorsOptions = app.Services.GetRequiredService<IOptions<HostCorsOptions>>().Value;
if (resolvedSwaggerOptions.Enabled)
{
if (resolvedSwaggerOptions.Enabled)
{
app.UseSwagger(options =>
{
options.RouteTemplate = resolvedSwaggerOptions.JsonRouteTemplate;
@@ -84,11 +98,27 @@ if (resolvedSwaggerOptions.Enabled)
$"{resolvedSwaggerOptions.Title} {resolvedSwaggerOptions.Version}");
options.DocumentTitle = resolvedSwaggerOptions.Title;
});
}
app.UseCors(resolvedCorsOptions.PolicyName);
app.UseStaticFiles();
// 注册 HTTP 请求/响应日志中间件,记录所有 API 调用的输入输出。
app.UseMiddleware<RequestResponseLoggingMiddleware>();
app.MapControllers();
logger.Info("Flyshot Server Host 已就绪,开始监听请求。");
app.Run();
}
catch (Exception exception)
{
logger.Error(exception, "Flyshot Server Host 启动失败。");
throw;
}
finally
{
NLog.LogManager.Shutdown();
}
app.UseCors(resolvedCorsOptions.PolicyName);
app.MapControllers();
app.Run();
public partial class Program;

View File

@@ -0,0 +1,424 @@
:root {
color-scheme: light;
--bg: #f5f7fb;
--surface: #ffffff;
--line: #d8dee9;
--text: #172033;
--muted: #5b667a;
--accent: #007c89;
--good: #12805c;
--warn: #b7791f;
--bad: #b42318;
--get: #1f6feb;
--post: #2da44e;
--put: #9a6700;
--delete: #cf222e;
--code-bg: #f4f6fa;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
font-size: 14px;
}
header {
border-bottom: 1px solid var(--line);
background: var(--surface);
position: sticky;
top: 0;
z-index: 10;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: min(1280px, calc(100% - 32px));
margin: 0 auto;
padding: 18px 0;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
}
.actions {
display: flex;
align-items: center;
gap: 10px;
}
button {
min-height: 34px;
padding: 0 14px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--accent);
color: #ffffff;
font: inherit;
cursor: pointer;
}
button.secondary {
background: transparent;
color: var(--accent);
}
button:disabled {
opacity: 0.55;
cursor: default;
}
.link-button {
display: inline-flex;
align-items: center;
min-height: 34px;
padding: 0 14px;
border: 1px solid var(--accent);
border-radius: 6px;
background: transparent;
color: var(--accent);
font: inherit;
text-decoration: none;
}
.link-button:hover {
background: rgba(0, 124, 137, 0.08);
}
main {
width: min(1280px, calc(100% - 32px));
margin: 22px auto 60px;
}
.meta {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
margin-bottom: 18px;
}
.meta dl {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
margin: 0;
padding: 12px 16px;
}
.meta dt {
color: var(--muted);
font-size: 12px;
}
.meta dd {
margin: 4px 0 0;
font-family: Consolas, "Cascadia Mono", monospace;
overflow-wrap: anywhere;
}
.meta dd.bad { color: var(--bad); }
.meta dd.good { color: var(--good); }
.group {
margin-top: 22px;
}
.group h2 {
margin: 0 0 10px 4px;
font-size: 16px;
font-weight: 650;
color: var(--muted);
}
.card {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
margin-bottom: 12px;
overflow: hidden;
}
.card-head {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
cursor: pointer;
user-select: none;
}
.card-head:hover {
background: #fafbfd;
}
.badge {
flex: 0 0 auto;
min-width: 60px;
padding: 3px 10px;
border-radius: 999px;
text-align: center;
color: #ffffff;
font-weight: 650;
font-size: 12px;
letter-spacing: 0.5px;
}
.badge.GET { background: var(--get); }
.badge.POST { background: var(--post); }
.badge.PUT { background: var(--put); }
.badge.DELETE { background: var(--delete); }
.badge.OTHER { background: var(--muted); }
.card-path {
flex: 1 1 auto;
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 14px;
overflow-wrap: anywhere;
}
.card-summary {
flex: 0 1 auto;
max-width: 50%;
color: var(--muted);
font-size: 13px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-toggle {
flex: 0 0 auto;
color: var(--muted);
font-size: 12px;
}
.card-body {
padding: 12px 16px 16px;
border-top: 1px solid var(--line);
}
.card.collapsed .card-body {
display: none;
}
.form-row {
display: grid;
grid-template-columns: 180px minmax(0, 1fr) 90px;
gap: 8px 12px;
align-items: center;
margin-bottom: 8px;
}
.form-row .name {
font-family: Consolas, "Cascadia Mono", monospace;
color: var(--text);
overflow-wrap: anywhere;
}
.form-row .name .required {
color: var(--bad);
margin-left: 4px;
}
.form-row input[type="text"],
.form-row input[type="number"] {
width: 100%;
min-height: 32px;
padding: 4px 10px;
border: 1px solid var(--line);
border-radius: 4px;
font: inherit;
}
.form-row .type {
color: var(--muted);
font-size: 12px;
font-family: Consolas, "Cascadia Mono", monospace;
}
.body-block {
margin-top: 6px;
}
.body-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.body-label .left {
color: var(--muted);
font-size: 12px;
}
textarea.body-editor {
width: 100%;
min-height: 140px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 4px;
background: var(--code-bg);
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 13px;
resize: vertical;
}
.button-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.response-block {
margin-top: 14px;
padding-top: 12px;
border-top: 1px dashed var(--line);
}
.response-summary {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-bottom: 8px;
font-size: 13px;
}
.status-badge {
padding: 2px 8px;
border-radius: 4px;
color: #ffffff;
font-weight: 650;
}
.status-badge.s2xx { background: var(--good); }
.status-badge.s3xx { background: var(--get); }
.status-badge.s4xx { background: var(--warn); }
.status-badge.s5xx { background: var(--bad); }
.status-badge.error { background: var(--bad); }
pre.response-body,
pre.response-headers {
margin: 6px 0 0;
padding: 10px 12px;
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 4px;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: Consolas, "Cascadia Mono", monospace;
font-size: 12.5px;
}
pre.response-headers {
max-height: 160px;
}
details > summary {
cursor: pointer;
color: var(--muted);
font-size: 12px;
margin: 6px 0 0;
}
.empty-hint {
padding: 12px 0;
color: var(--muted);
font-style: italic;
}
.history {
position: fixed;
right: 16px;
bottom: 16px;
width: 360px;
max-width: calc(100vw - 32px);
max-height: 50vh;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 8px 24px rgba(23, 32, 51, 0.12);
display: flex;
flex-direction: column;
z-index: 20;
}
.history h3 {
margin: 0;
padding: 10px 14px;
border-bottom: 1px solid var(--line);
font-size: 13px;
font-weight: 650;
display: flex;
justify-content: space-between;
align-items: center;
}
.history h3 button {
min-height: 24px;
padding: 0 8px;
font-size: 12px;
}
.history ul {
list-style: none;
margin: 0;
padding: 6px 0;
overflow: auto;
}
.history li {
padding: 6px 14px;
font-size: 12px;
border-bottom: 1px solid #edf1f7;
display: grid;
grid-template-columns: 50px 1fr auto;
gap: 8px;
align-items: center;
}
.history li:last-child { border-bottom: 0; }
.history li .h-method {
font-weight: 650;
font-family: Consolas, "Cascadia Mono", monospace;
}
.history li .h-path {
font-family: Consolas, "Cascadia Mono", monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 920px) {
.form-row {
grid-template-columns: 1fr;
}
.meta dl {
grid-template-columns: 1fr;
}
.history {
position: static;
width: auto;
margin-top: 18px;
max-height: none;
}
}

View File

@@ -0,0 +1,670 @@
// 静态调试页通过配置 API 获取实际 Swagger JSON 地址,避免硬编码路由前缀。
const DEBUG_CONFIG_URL = "/api/debug/config";
const STORAGE_PREFIX = "flyshot.debug.";
const HISTORY_LIMIT = 10;
const groupTitleByPrefix = [
// 基础与状态分组:探活和状态快照两个固定 API 路径
{ match: function (op) { return op.path === "/healthz" || op.path === "/api/status/snapshot"; }, title: "基础与状态" },
// 默认兜底:剩余全部走 ControllerClient 兼容分组
{ match: function () { return true; }, title: "ControllerClient 兼容" }
];
const state = {
spec: null,
operations: [],
history: []
};
/** 简单的 escape把任意字符串安全嵌入 textContent 之外的位置时使用。 */
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }[ch];
});
}
/** 解析 OpenAPI 中的 $ref 引用,仅支持本地 components.schemas 形式。 */
function resolveRef(ref) {
if (!ref || !state.spec) return null;
const parts = ref.replace(/^#\//, "").split("/");
let cursor = state.spec;
for (const part of parts) {
if (cursor && Object.prototype.hasOwnProperty.call(cursor, part)) {
cursor = cursor[part];
} else {
return null;
}
}
return cursor;
}
/** 根据 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;
}
// 部分 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");
switch (type) {
case "object": {
const result = {};
const props = schema.properties || {};
Object.keys(props).forEach(function (key) {
result[key] = buildSampleFromSchema(props[key], depth + 1);
});
return result;
}
case "array":
return [];
case "integer":
case "number":
return 0;
case "boolean":
return false;
case "string":
default:
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
return "";
}
}
/** 把 schema.type 翻译成 input type 与展示文本。 */
function inputKindForType(schema) {
if (!schema) return { kind: "text", label: "string" };
const type = schema.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 列表。 */
function extractOperations(spec) {
const operations = [];
const paths = spec.paths || {};
Object.keys(paths).forEach(function (path) {
const pathItem = paths[path] || {};
["get", "post", "put", "delete", "patch", "options", "head"].forEach(function (method) {
const op = pathItem[method];
if (!op) return;
const parameters = (op.parameters || []).filter(function (p) { return p.in === "query" || p.in === "path"; });
let bodySchema = null;
if (op.requestBody && op.requestBody.content) {
const json = op.requestBody.content["application/json"];
if (json && json.schema) bodySchema = json.schema;
}
operations.push({
method: method.toUpperCase(),
path: path,
summary: op.summary || "",
description: op.description || "",
tags: op.tags || [],
parameters: parameters,
bodySchema: bodySchema
});
});
});
return operations;
}
/** 选择分组:优先用第一条匹配的 groupTitleByPrefix 规则OpenAPI tag 留作兜底。 */
function pickGroup(op) {
for (const rule of groupTitleByPrefix) {
if (rule.match(op)) return rule.title;
}
if (op.tags && op.tags.length > 0) return op.tags[0];
return "其它";
}
/** localStorage key 必须避免冲突,使用 method:path 复合键。 */
function storageKey(op) {
return STORAGE_PREFIX + op.method + ":" + op.path;
}
/** 读取本端点最近一次输入;解析失败则当作空。 */
function loadInputs(op) {
try {
const raw = window.localStorage.getItem(storageKey(op));
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
/** 保存本端点最近一次输入;写入失败时静默忽略,避免影响调试体验。 */
function saveInputs(op, payload) {
try {
window.localStorage.setItem(storageKey(op), JSON.stringify(payload));
} catch (e) {
// localStorage 可能被禁用或满载,忽略写入失败。
}
}
/** 拼接最终请求 URL含 query 串与 path 参数替换)。 */
function buildRequestUrl(op, paramValues) {
let path = op.path;
const queryPairs = [];
op.parameters.forEach(function (param) {
const raw = paramValues[param.name];
if (raw === undefined || raw === null || raw === "") return;
if (param.in === "path") {
path = path.replace("{" + param.name + "}", encodeURIComponent(raw));
} else if (param.in === "query") {
queryPairs.push(encodeURIComponent(param.name) + "=" + encodeURIComponent(raw));
}
});
return path + (queryPairs.length > 0 ? "?" + queryPairs.join("&") : "");
}
/** 生成与浏览器请求等价的 curl 命令,便于复制到终端复现。 */
function buildCurlCommand(op, requestUrl, body) {
const parts = ["curl", "-X", op.method, JSON.stringify(window.location.origin + requestUrl)];
if (body !== null && body !== undefined && body !== "") {
parts.push("-H", "\"Content-Type: application/json\"");
parts.push("--data-raw", JSON.stringify(body));
}
return parts.join(" ");
}
/** 渲染参数输入表单,返回收集函数。 */
function renderParameterRows(container, op, savedValues) {
if (op.parameters.length === 0) return function () { return {}; };
const inputs = {};
op.parameters.forEach(function (param) {
const row = document.createElement("div");
row.className = "form-row";
const nameNode = document.createElement("div");
nameNode.className = "name";
nameNode.textContent = param.name + " (" + param.in + ")";
if (param.required) {
const requiredMark = document.createElement("span");
requiredMark.className = "required";
requiredMark.textContent = "*";
nameNode.appendChild(requiredMark);
}
row.appendChild(nameNode);
const kind = inputKindForType(param.schema);
const inputNode = document.createElement("input");
inputNode.type = kind.kind;
if (kind.kind === "checkbox") {
inputNode.checked = savedValues && Object.prototype.hasOwnProperty.call(savedValues, param.name)
? Boolean(savedValues[param.name])
: Boolean(param.schema && param.schema.default);
} else {
let initial = "";
if (savedValues && Object.prototype.hasOwnProperty.call(savedValues, param.name)) {
initial = String(savedValues[param.name]);
} else if (param.schema && param.schema.default !== undefined) {
initial = String(param.schema.default);
}
inputNode.value = initial;
if (kind.kind === "number") inputNode.step = "any";
}
row.appendChild(inputNode);
const typeNode = document.createElement("div");
typeNode.className = "type";
typeNode.textContent = kind.label;
row.appendChild(typeNode);
container.appendChild(row);
inputs[param.name] = { node: inputNode, kind: kind.kind, schema: param.schema };
});
return function collect() {
const collected = {};
Object.keys(inputs).forEach(function (key) {
const item = inputs[key];
if (item.kind === "checkbox") {
collected[key] = item.node.checked;
} else {
const raw = item.node.value;
if (raw === "") {
collected[key] = "";
} else if (item.kind === "number") {
const num = Number(raw);
collected[key] = Number.isNaN(num) ? raw : num;
} else {
collected[key] = raw;
}
}
});
return collected;
};
}
/** 渲染请求体编辑器,返回收集函数。 */
function renderBodyEditor(container, op, savedBody) {
if (!op.bodySchema) return function () { return null; };
const block = document.createElement("div");
block.className = "body-block";
const labelRow = document.createElement("div");
labelRow.className = "body-label";
const left = document.createElement("div");
left.className = "left";
left.textContent = "请求体 (application/json)";
labelRow.appendChild(left);
const formatBtn = document.createElement("button");
formatBtn.type = "button";
formatBtn.className = "secondary";
formatBtn.textContent = "格式化 JSON";
labelRow.appendChild(formatBtn);
block.appendChild(labelRow);
const textarea = document.createElement("textarea");
textarea.className = "body-editor";
textarea.spellcheck = false;
let initialText;
if (savedBody !== undefined && savedBody !== null) {
initialText = typeof savedBody === "string" ? savedBody : JSON.stringify(savedBody, null, 2);
} else {
const sample = buildSampleFromSchema(op.bodySchema, 0);
initialText = sample === null ? "" : JSON.stringify(sample, null, 2);
}
textarea.value = initialText;
block.appendChild(textarea);
formatBtn.addEventListener("click", function () {
try {
const parsed = JSON.parse(textarea.value || "null");
textarea.value = parsed === null ? "" : JSON.stringify(parsed, null, 2);
} catch (e) {
window.alert("JSON 解析失败: " + e.message);
}
});
container.appendChild(block);
return function collect() {
return textarea.value;
};
}
/** 把 HTTP 状态码翻译成颜色徽标 class。 */
function statusBadgeClass(status) {
if (status >= 200 && status < 300) return "s2xx";
if (status >= 300 && status < 400) return "s3xx";
if (status >= 400 && status < 500) return "s4xx";
if (status >= 500) return "s5xx";
return "error";
}
/** 把响应头展开成可读字符串。 */
function formatHeaders(headers) {
const lines = [];
headers.forEach(function (value, key) { lines.push(key + ": " + value); });
return lines.join("\n");
}
/** 在历史面板顶部追加一条记录,超过上限则丢弃尾部。 */
function pushHistory(entry) {
state.history.unshift(entry);
if (state.history.length > HISTORY_LIMIT) state.history.length = HISTORY_LIMIT;
renderHistory();
}
function renderHistory() {
const list = document.getElementById("history-list");
list.innerHTML = "";
if (state.history.length === 0) {
const empty = document.createElement("li");
empty.textContent = "暂无调用记录";
empty.style.color = "var(--muted)";
empty.style.gridTemplateColumns = "1fr";
list.appendChild(empty);
return;
}
state.history.forEach(function (entry) {
const li = document.createElement("li");
const method = document.createElement("span");
method.className = "h-method";
method.textContent = entry.method;
method.style.color = entry.method === "GET" ? "var(--get)" : entry.method === "POST" ? "var(--post)" : "var(--muted)";
const path = document.createElement("span");
path.className = "h-path";
path.title = entry.url;
path.textContent = entry.url;
const meta = document.createElement("span");
meta.style.color = "var(--muted)";
meta.textContent = (entry.status || "ERR") + " · " + entry.elapsedMs + "ms";
li.appendChild(method);
li.appendChild(path);
li.appendChild(meta);
list.appendChild(li);
});
}
/** 渲染单个端点的卡片。 */
function renderOperationCard(op) {
const card = document.createElement("section");
card.className = "card collapsed";
const head = document.createElement("div");
head.className = "card-head";
const badge = document.createElement("span");
badge.className = "badge " + (["GET", "POST", "PUT", "DELETE"].indexOf(op.method) >= 0 ? op.method : "OTHER");
badge.textContent = op.method;
head.appendChild(badge);
const path = document.createElement("span");
path.className = "card-path";
path.textContent = op.path;
head.appendChild(path);
const summary = document.createElement("span");
summary.className = "card-summary";
summary.textContent = op.summary;
summary.title = op.summary;
head.appendChild(summary);
const toggle = document.createElement("span");
toggle.className = "card-toggle";
toggle.textContent = "展开 ▾";
head.appendChild(toggle);
head.addEventListener("click", function () {
const collapsed = card.classList.toggle("collapsed");
toggle.textContent = collapsed ? "展开 ▾" : "收起 ▴";
});
card.appendChild(head);
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 saved = loadInputs(op) || {};
// 参数区
let collectParams = function () { return {}; };
if (op.parameters.length > 0) {
const paramsContainer = document.createElement("div");
paramsContainer.className = "params";
body.appendChild(paramsContainer);
collectParams = renderParameterRows(paramsContainer, op, saved.params);
}
// 请求体区
const collectBody = renderBodyEditor(body, op, saved.body);
// 操作按钮
const buttonRow = document.createElement("div");
buttonRow.className = "button-row";
const sendBtn = document.createElement("button");
sendBtn.type = "button";
sendBtn.textContent = "发送";
const resetBtn = document.createElement("button");
resetBtn.type = "button";
resetBtn.className = "secondary";
resetBtn.textContent = "重置";
const curlBtn = document.createElement("button");
curlBtn.type = "button";
curlBtn.className = "secondary";
curlBtn.textContent = "复制 curl";
buttonRow.appendChild(sendBtn);
buttonRow.appendChild(resetBtn);
buttonRow.appendChild(curlBtn);
body.appendChild(buttonRow);
// 响应面板
const responseBlock = document.createElement("div");
responseBlock.className = "response-block";
responseBlock.style.display = "none";
body.appendChild(responseBlock);
function renderResponse(payload) {
responseBlock.style.display = "block";
responseBlock.innerHTML = "";
const summaryRow = document.createElement("div");
summaryRow.className = "response-summary";
const statusBadge = document.createElement("span");
statusBadge.className = "status-badge " + statusBadgeClass(payload.status || 0);
statusBadge.textContent = payload.status ? payload.status + " " + (payload.statusText || "") : "请求失败";
summaryRow.appendChild(statusBadge);
const elapsed = document.createElement("span");
elapsed.style.color = "var(--muted)";
elapsed.textContent = payload.elapsedMs + " ms · " + payload.url;
summaryRow.appendChild(elapsed);
responseBlock.appendChild(summaryRow);
if (payload.error) {
const pre = document.createElement("pre");
pre.className = "response-body";
pre.textContent = payload.error;
responseBlock.appendChild(pre);
return;
}
const headersDetails = document.createElement("details");
const headersSummary = document.createElement("summary");
headersSummary.textContent = "响应头";
headersDetails.appendChild(headersSummary);
const headersPre = document.createElement("pre");
headersPre.className = "response-headers";
headersPre.textContent = payload.headers;
headersDetails.appendChild(headersPre);
responseBlock.appendChild(headersDetails);
const bodyPre = document.createElement("pre");
bodyPre.className = "response-body";
bodyPre.textContent = payload.bodyText;
responseBlock.appendChild(bodyPre);
}
sendBtn.addEventListener("click", async function () {
sendBtn.disabled = true;
const params = collectParams();
const rawBody = collectBody();
saveInputs(op, { params: params, body: rawBody });
const requestUrl = buildRequestUrl(op, params);
const init = { method: op.method, headers: {} };
// 仅 POST/PUT/PATCH/DELETE 才认为可能携带 body对没有 bodySchema 的方法直接跳过。
const methodAllowsBody = ["POST", "PUT", "PATCH", "DELETE"].indexOf(op.method) >= 0;
if (methodAllowsBody && op.bodySchema && rawBody !== null && rawBody !== undefined && rawBody !== "") {
init.headers["Content-Type"] = "application/json";
init.body = rawBody;
}
const startedAt = performance.now();
try {
const response = await fetch(requestUrl, init);
const elapsedMs = Math.round(performance.now() - startedAt);
const text = await response.text();
const contentType = response.headers.get("content-type") || "";
let bodyText = text;
if (contentType.indexOf("application/json") >= 0) {
try {
bodyText = JSON.stringify(JSON.parse(text), null, 2);
} catch (e) {
bodyText = text;
}
}
renderResponse({
status: response.status,
statusText: response.statusText,
headers: formatHeaders(response.headers),
bodyText: bodyText,
url: requestUrl,
elapsedMs: elapsedMs
});
pushHistory({ method: op.method, url: requestUrl, status: response.status, elapsedMs: elapsedMs });
} catch (err) {
const elapsedMs = Math.round(performance.now() - startedAt);
renderResponse({
error: String(err && err.message ? err.message : err),
url: requestUrl,
elapsedMs: elapsedMs
});
pushHistory({ method: op.method, url: requestUrl, status: 0, elapsedMs: elapsedMs });
} finally {
sendBtn.disabled = false;
}
});
resetBtn.addEventListener("click", function () {
try { window.localStorage.removeItem(storageKey(op)); } catch (e) { /* 忽略 */ }
// 直接重新渲染当前卡片:替换原 DOM 节点。
const refreshed = renderOperationCard(op);
refreshed.classList.remove("collapsed");
refreshed.querySelector(".card-toggle").textContent = "收起 ▴";
card.parentNode.replaceChild(refreshed, card);
});
curlBtn.addEventListener("click", function () {
const params = collectParams();
const rawBody = collectBody();
const requestUrl = buildRequestUrl(op, params);
const methodAllowsBody = ["POST", "PUT", "PATCH", "DELETE"].indexOf(op.method) >= 0;
const bodyForCurl = methodAllowsBody && op.bodySchema ? rawBody : null;
const command = buildCurlCommand(op, requestUrl, bodyForCurl);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(command).then(function () {
curlBtn.textContent = "已复制 ✓";
window.setTimeout(function () { curlBtn.textContent = "复制 curl"; }, 1500);
}).catch(function () {
window.prompt("复制失败,手动复制:", command);
});
} else {
window.prompt("复制失败,手动复制:", command);
}
});
card.appendChild(body);
return card;
}
/** 把 operation 列表按分组渲染到主区域。 */
function renderGroups(operations) {
const root = document.getElementById("debug-console-app");
root.innerHTML = "";
if (operations.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-hint";
empty.textContent = "OpenAPI 文档中没有任何端点。";
root.appendChild(empty);
return;
}
const grouped = new Map();
operations.forEach(function (op) {
const groupTitle = pickGroup(op);
if (!grouped.has(groupTitle)) grouped.set(groupTitle, []);
grouped.get(groupTitle).push(op);
});
// 固定输出顺序基础与状态在前ControllerClient 兼容在后,其余按字典序。
const orderedTitles = [];
["基础与状态", "ControllerClient 兼容"].forEach(function (title) {
if (grouped.has(title)) orderedTitles.push(title);
});
Array.from(grouped.keys()).sort().forEach(function (title) {
if (orderedTitles.indexOf(title) < 0) orderedTitles.push(title);
});
orderedTitles.forEach(function (title) {
const ops = grouped.get(title);
ops.sort(function (a, b) {
if (a.path === b.path) return a.method.localeCompare(b.method);
return a.path.localeCompare(b.path);
});
const section = document.createElement("section");
section.className = "group";
const heading = document.createElement("h2");
heading.textContent = title + " (" + ops.length + ")";
section.appendChild(heading);
ops.forEach(function (op) { section.appendChild(renderOperationCard(op)); });
root.appendChild(section);
});
}
/** 加载 OpenAPI 文档并渲染。 */
async function loadSpecAndRender() {
const metaSpec = document.getElementById("meta-spec-url");
const metaCount = document.getElementById("meta-operation-count");
const metaStatus = document.getElementById("meta-status");
metaSpec.textContent = "正在读取调试配置...";
metaStatus.textContent = "正在拉取 OpenAPI 文档...";
metaStatus.className = "";
try {
const configResponse = await fetch(DEBUG_CONFIG_URL, { cache: "no-store" });
if (!configResponse.ok) throw new Error("调试配置 HTTP " + configResponse.status + " " + configResponse.statusText);
const config = await configResponse.json();
const swaggerJsonUrl = config.swaggerJsonUrl;
if (!swaggerJsonUrl) throw new Error("调试配置缺少 swaggerJsonUrl");
metaSpec.textContent = swaggerJsonUrl;
const response = await fetch(swaggerJsonUrl, { cache: "no-store" });
if (!response.ok) throw new Error("HTTP " + response.status + " " + response.statusText);
const spec = await response.json();
state.spec = spec;
state.operations = extractOperations(spec);
metaCount.textContent = state.operations.length;
metaStatus.textContent = "已加载";
metaStatus.className = "good";
renderGroups(state.operations);
} catch (err) {
metaStatus.textContent = "加载失败: " + (err && err.message ? err.message : err);
metaStatus.className = "bad";
metaCount.textContent = "0";
const root = document.getElementById("debug-console-app");
root.innerHTML = "";
const errBlock = document.createElement("div");
errBlock.className = "empty-hint";
errBlock.textContent = "无法加载 OpenAPI 文档,请确认 Swagger:Enabled = true 且 " + DEBUG_CONFIG_URL + " 可访问。";
root.appendChild(errBlock);
}
}
document.getElementById("reload-spec").addEventListener("click", loadSpecAndRender);
document.getElementById("history-clear").addEventListener("click", function () {
state.history.length = 0;
renderHistory();
});
renderHistory();
loadSpecAndRender();

View File

@@ -0,0 +1,214 @@
:root {
color-scheme: light;
--bg: #f5f7fb;
--surface: #ffffff;
--line: #d8dee9;
--text: #172033;
--muted: #5b667a;
--accent: #007c89;
--good: #12805c;
--warn: #b7791f;
--bad: #b42318;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
font-size: 15px;
letter-spacing: 0;
}
header {
border-bottom: 1px solid var(--line);
background: var(--surface);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 18px 0;
}
h1 {
margin: 0;
font-size: 22px;
font-weight: 650;
}
button {
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--accent);
border-radius: 6px;
background: var(--accent);
color: #ffffff;
font: inherit;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
cursor: default;
}
/* 顶部操作区按钮和外链按钮共用同一组视觉样式,便于现场顺手跳转。 */
.actions {
display: flex;
align-items: center;
gap: 10px;
}
.link-button {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 14px;
border: 1px solid var(--accent);
border-radius: 6px;
background: transparent;
color: var(--accent);
font: inherit;
text-decoration: none;
}
.link-button:hover {
background: rgba(0, 124, 137, 0.08);
}
main {
width: min(1180px, calc(100% - 32px));
margin: 22px auto;
}
.summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.metric,
section {
border: 1px solid var(--line);
border-radius: 8px;
background: var(--surface);
}
.metric {
min-height: 86px;
padding: 14px;
}
.label {
color: var(--muted);
font-size: 13px;
}
.value {
margin-top: 8px;
overflow-wrap: anywhere;
font-size: 24px;
font-weight: 650;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 12px;
height: 12px;
flex: 0 0 12px;
border-radius: 999px;
background: var(--warn);
}
.dot.good {
background: var(--good);
}
.dot.bad {
background: var(--bad);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
section h2 {
margin: 0;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
font-size: 16px;
font-weight: 650;
}
dl {
display: grid;
grid-template-columns: 160px minmax(0, 1fr);
gap: 0;
margin: 0;
padding: 4px 16px 12px;
}
dt,
dd {
min-height: 36px;
margin: 0;
padding: 9px 0;
border-bottom: 1px solid #edf1f7;
}
dt {
color: var(--muted);
}
dd {
overflow-wrap: anywhere;
font-family: Consolas, "Cascadia Mono", monospace;
}
.empty {
color: var(--muted);
font-family: inherit;
}
@media (max-width: 820px) {
.topbar {
align-items: flex-start;
flex-direction: column;
}
.summary,
.grid {
grid-template-columns: 1fr;
}
dl {
grid-template-columns: 1fr;
}
dt {
border-bottom: 0;
padding-bottom: 2px;
}
dd {
padding-top: 2px;
}
}

View File

@@ -0,0 +1,92 @@
const fields = {
connectionState: document.getElementById("connection-state"),
stateDot: document.getElementById("state-dot"),
robotName: document.getElementById("robot-name"),
speedRatio: document.getElementById("speed-ratio"),
motionState: document.getElementById("motion-state"),
serverVersion: document.getElementById("server-version"),
clientVersion: document.getElementById("client-version"),
setupState: document.getElementById("setup-state"),
enabledState: document.getElementById("enabled-state"),
j519Status: document.getElementById("j519-status"),
j519Sequence: document.getElementById("j519-sequence"),
capturedAt: document.getElementById("captured-at"),
dof: document.getElementById("dof"),
joints: document.getElementById("joints"),
pose: document.getElementById("pose"),
trajectories: document.getElementById("trajectories"),
refresh: document.getElementById("refresh")
};
function formatArray(values) {
if (!Array.isArray(values) || values.length === 0) {
return "--";
}
return values.map(value => Number(value).toFixed(4)).join(", ");
}
function formatNullableBool(value) {
if (value === true) {
return "是";
}
if (value === false) {
return "否";
}
return "--";
}
function formatJ519Status(snapshot) {
if (snapshot.j519Status === null || snapshot.j519Status === undefined) {
return "--";
}
const status = Number(snapshot.j519Status).toString(16).padStart(2, "0").toUpperCase();
return `0x${status} accept=${formatNullableBool(snapshot.j519AcceptsCommand)} received=${formatNullableBool(snapshot.j519ReceivedCommand)} sysrdy=${formatNullableBool(snapshot.j519SystemReady)} motion=${formatNullableBool(snapshot.j519RobotInMotion)}`;
}
function setDot(connectionState) {
fields.stateDot.className = "dot";
if (connectionState === "Connected") {
fields.stateDot.classList.add("good");
} else if (connectionState === "NotConfigured") {
fields.stateDot.classList.add("bad");
}
}
async function refreshStatus() {
fields.refresh.disabled = true;
try {
const response = await fetch("/api/status/snapshot", { cache: "no-store" });
const payload = await response.json();
const snapshot = payload.snapshot;
fields.connectionState.textContent = snapshot.connectionState;
fields.robotName.textContent = payload.robotName || "--";
fields.speedRatio.textContent = Number(snapshot.speedRatio).toFixed(2);
fields.motionState.textContent = snapshot.isInMotion ? "是" : "否";
fields.serverVersion.textContent = payload.serverVersion;
fields.clientVersion.textContent = payload.clientVersion;
fields.setupState.textContent = payload.isSetup ? "是" : "否";
fields.enabledState.textContent = snapshot.isEnabled ? "是" : "否";
fields.j519Status.textContent = formatJ519Status(snapshot);
fields.j519Sequence.textContent = snapshot.j519Sequence ?? "--";
fields.capturedAt.textContent = new Date(snapshot.capturedAt).toLocaleString();
fields.dof.textContent = payload.degreesOfFreedom;
fields.joints.textContent = formatArray(snapshot.jointPositions);
fields.pose.textContent = formatArray(snapshot.cartesianPose);
fields.trajectories.textContent = payload.uploadedTrajectories.length > 0
? payload.uploadedTrajectories.join(", ")
: "--";
fields.trajectories.classList.toggle("empty", payload.uploadedTrajectories.length === 0);
setDot(snapshot.connectionState);
} finally {
fields.refresh.disabled = false;
}
}
fields.refresh.addEventListener("click", refreshStatus);
refreshStatus();
window.setInterval(refreshStatus, 2000);

View File

@@ -0,0 +1,44 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flyshot Replacement 接口调试</title>
<link rel="stylesheet" href="/assets/debug.css">
</head>
<body>
<header>
<div class="topbar">
<h1>Flyshot Replacement 接口调试</h1>
<div class="actions">
<a class="link-button" href="/status.html">回到状态页</a>
<a class="link-button" href="/swagger" target="_blank" rel="noopener">Swagger UI</a>
<button id="reload-spec" type="button">重新加载 OpenAPI</button>
</div>
</div>
</header>
<main>
<section class="meta">
<dl>
<dt>OpenAPI 文档</dt>
<dd id="meta-spec-url">--</dd>
<dt>API 数量</dt>
<dd id="meta-operation-count">--</dd>
<dt>加载状态</dt>
<dd id="meta-status">初始化中...</dd>
</dl>
</section>
<div id="debug-console-app">
<div class="empty-hint">正在加载接口列表...</div>
</div>
</main>
<aside class="history" id="history-panel">
<h3>
<span>调用历史 (本次会话)</span>
<button type="button" id="history-clear" class="secondary">清空</button>
</h3>
<ul id="history-list"></ul>
</aside>
<script src="/assets/debug.js" defer></script>
</body>
</html>

View File

@@ -0,0 +1,64 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Flyshot Replacement 状态监控</title>
<link rel="stylesheet" href="/assets/status.css">
</head>
<body>
<header>
<div class="topbar">
<h1>Flyshot Replacement 状态监控</h1>
<div class="actions">
<a class="link-button" href="/debug.html" target="_blank" rel="noopener">调试接口</a>
<button id="refresh" type="button">刷新</button>
</div>
</div>
</header>
<main>
<div class="summary">
<div class="metric">
<div class="label">连接状态</div>
<div class="value status-row"><span id="state-dot" class="dot"></span><span id="connection-state">--</span></div>
</div>
<div class="metric">
<div class="label">机器人</div>
<div id="robot-name" class="value">--</div>
</div>
<div class="metric">
<div class="label">速度倍率</div>
<div id="speed-ratio" class="value">--</div>
</div>
<div class="metric">
<div class="label">运动中</div>
<div id="motion-state" class="value">--</div>
</div>
</div>
<div class="grid">
<section>
<h2>控制器</h2>
<dl>
<dt>服务端版本</dt><dd id="server-version">--</dd>
<dt>客户端版本</dt><dd id="client-version">--</dd>
<dt>已初始化</dt><dd id="setup-state">--</dd>
<dt>已使能</dt><dd id="enabled-state">--</dd>
<dt>J519 状态</dt><dd id="j519-status">--</dd>
<dt>J519 序号</dt><dd id="j519-sequence">--</dd>
<dt>采样时间</dt><dd id="captured-at">--</dd>
</dl>
</section>
<section>
<h2>机器人</h2>
<dl>
<dt>自由度</dt><dd id="dof">--</dd>
<dt>关节位置</dt><dd id="joints">--</dd>
<dt>TCP 位姿</dt><dd id="pose">--</dd>
<dt>已上传轨迹</dt><dd id="trajectories" class="empty">--</dd>
</dl>
</section>
</div>
</main>
<script src="/assets/status.js" defer></script>
</body>
</html>

View File

@@ -5,54 +5,74 @@ using Microsoft.Extensions.Configuration;
namespace Flyshot.Server.IntegrationTests;
/// <summary>
/// 验证 `/debug` 在线 API 调试页的暴露策略与基础内容契约。
/// 验证 `wwwroot` 静态调试页和调试配置 API 的基础内容契约。
/// </summary>
/// <remarks>
/// 调试页与 Swagger UI 共用 <c>Swagger:Enabled</c> 开关,开关关闭时
/// 调试页与 `/swagger` 一同下线,避免生产环境意外暴露调试入口
/// 调试页自身是静态 HTML真正的 Swagger 地址由配置 API 下发;
/// 当 Swagger 关闭时,配置 API 返回 404前端据此显示不可用状态
/// </remarks>
public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
{
private readonly FlyshotServerFactory _factory = factory;
/// <summary>
/// 当 Swagger 启用时,`/debug` 应当返回完整的 HTML 调试页
/// `debug.html` 应当作为可直接调试的静态页面暴露
/// </summary>
[Fact]
public async Task GetDebug_WhenSwaggerEnabled_ReturnsConsoleHtml()
public async Task GetDebugHtml_ReturnsConsoleStaticPage()
{
// 默认配置即开启 Swagger调试页应当作为浏览器可直接打开的 HTML 暴露。
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var response = await client.GetAsync("/debug");
using var response = await client.GetAsync("/debug.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
var html = await response.Content.ReadAsStringAsync();
// 页面标题与稳定锚点用于回归保护:调试页骨架一旦丢失,测试立即报警。
Assert.Contains("Flyshot Replacement 接口调试", html, StringComparison.Ordinal);
Assert.Contains("id=\"debug-console-app\"", html, StringComparison.Ordinal);
// 控制器需要在返回 HTML 前把 Swagger JSON URL 注入到页面占位符里,
// 否则前端无法在加载时拉取 OpenAPI 文档。
Assert.Contains("/swagger/v1/swagger.json", 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);
using var scriptResponse = await client.GetAsync("/assets/debug.js");
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
var script = await scriptResponse.Content.ReadAsStringAsync();
Assert.Contains("/api/debug/config", script, StringComparison.Ordinal);
}
/// <summary>
/// 当 Swagger 关闭时,`/debug` 应当与 `/swagger` 同步下线404
/// 当 Swagger 启用时,调试配置 API 应当返回实际 Swagger JSON 地址
/// </summary>
[Fact]
public async Task GetDebug_WhenSwaggerDisabled_ReturnsNotFound()
public async Task GetDebugConfig_WhenSwaggerEnabled_ReturnsSwaggerJsonUrl()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var response = await client.GetAsync("/api/debug/config");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
await using var responseStream = await response.Content.ReadAsStreamAsync();
using var document = await System.Text.Json.JsonDocument.ParseAsync(responseStream);
Assert.Equal("/swagger/v1/swagger.json", document.RootElement.GetProperty("swaggerJsonUrl").GetString());
}
/// <summary>
/// 当 Swagger 关闭时,调试配置 API 应当与 Swagger UI 同步下线404
/// </summary>
[Fact]
public async Task GetDebugConfig_WhenSwaggerDisabled_ReturnsNotFound()
{
// 显式把 Swagger:Enabled 置为 false此时调试页也不应当被访问到。
using var configuredFactory = CreateFactoryWithSwaggerEnabled(false);
using var client = configuredFactory.CreateClient();
using var response = await client.GetAsync("/debug");
using var response = await client.GetAsync("/api/debug/config");
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
@@ -81,21 +101,20 @@ public sealed class DebugConsoleEndpointTests(FlyshotServerFactory factory) : IC
}
/// <summary>
/// 状态页应当提供跳转到调试页的入口,便于现场顺手跳转。
/// 状态页应当提供跳转到静态调试页的入口,便于现场顺手跳转。
/// </summary>
[Fact]
public async Task GetStatusPage_LinksToDebugConsole()
public async Task GetStatusHtml_LinksToDebugConsole()
{
using var configuredFactory = CreateFactoryWithSwaggerEnabled(true);
using var client = configuredFactory.CreateClient();
using var response = await client.GetAsync("/status");
using var response = await client.GetAsync("/status.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var html = await response.Content.ReadAsStringAsync();
// 状态页头部需要至少一个指向 `/debug` 的链接,文案不强制以保留排版调整空间。
Assert.Contains("href=\"/debug\"", html, StringComparison.Ordinal);
Assert.Contains("href=\"/debug.html\"", html, StringComparison.Ordinal);
}
/// <summary>

View File

@@ -10,21 +10,28 @@ namespace Flyshot.Server.IntegrationTests;
public sealed class StatusEndpointTests(FlyshotServerFactory factory) : IClassFixture<FlyshotServerFactory>
{
/// <summary>
/// 验证状态页返回可由浏览器直接打开的 HTML并引用状态快照 API。
/// 验证状态页作为 wwwroot 静态 HTML 暴露,并引用状态快照 API。
/// </summary>
[Fact]
public async Task GetStatusPage_ReturnsMonitoringHtml()
public async Task GetStatusHtml_ReturnsMonitoringStaticPage()
{
using var client = factory.CreateClient();
using var response = await client.GetAsync("/status");
using var response = await client.GetAsync("/status.html");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.StartsWith("text/html", response.Content.Headers.ContentType?.MediaType);
var html = await response.Content.ReadAsStringAsync();
Assert.Contains("Flyshot Replacement 状态监控", html, StringComparison.Ordinal);
Assert.Contains("/api/status/snapshot", html, StringComparison.Ordinal);
Assert.Contains("/assets/status.css", html, StringComparison.Ordinal);
Assert.Contains("/assets/status.js", html, StringComparison.Ordinal);
using var scriptResponse = await client.GetAsync("/assets/status.js");
Assert.Equal(HttpStatusCode.OK, scriptResponse.StatusCode);
var script = await scriptResponse.Content.ReadAsStringAsync();
Assert.Contains("/api/status/snapshot", script, StringComparison.Ordinal);
}
/// <summary>