using System.Net.Http.Headers;
using System.Text.Json;
using NewLife;
using NewLife.Log;
using NewLife.Remoting.Clients;
using NewLife.Serialization;
using Stardust;
using Stardust.Data;
using Stardust.Data.Configs;
using Stardust.Data.Deployment;
using Stardust.Data.Monitors;
using Stardust.Data.Nodes;
using Stardust.Data.Platform;
using XCode;
using XCode.DataAccessLayer;
using Xunit;
using Xunit.Abstractions;
namespace Stardust.McpApiTests;
/// <summary>é¢å‘真实 WebApi(6681) çš„ MCP 测试数æ®åˆå§‹åŒ–器(幂ç‰ï¼‰ã€‚
/// ç”¨æˆ·è¦æ±‚:登录ã€åˆå§‹åŒ–测试所需数æ®ã€å¼€å¯ MCPã€æ–°å¢ž MCP 项目以åŠä»¤ç‰Œï¼Œå…¨åšæˆæµ‹è¯•项目åˆå§‹åŒ–æ•°æ®çš„代ç 逻辑,已å˜åœ¨åˆ™è·³è¿‡ã€‚
/// 执行顺åºï¼šç™»å½•(HTTP) → EnsureMcpEnabled(读é…ç½®) → EnsureProject → EnsureTestData → EnsureTokens。
/// æ‰€æœ‰å†™å…¥å‡æŒ‰ä¸šåŠ¡å”¯ä¸€é”®æŸ¥é‡ï¼Œå·²å˜åœ¨ç›´æŽ¥è·³è¿‡å¹¶å¤ç”¨ï¼Œå¯é‡å¤è¿è¡Œã€‚</summary>
public static class McpTestDataInitializer
{
/// <summary>WebApi 宿主地å€ï¼ˆä»… API,Web ä¸åŒæ¥ï¼‰ã€‚å¯ç”¨çŽ¯å¢ƒå˜é‡ STARDUST_MCP_TEST_BASE_URL 覆盖,便于并行工作区æ¢ç«¯å£</summary>
public static String BaseUrl { get; } = Environment.GetEnvironmentVariable("STARDUST_MCP_TEST_BASE_URL") ?? "http://127.0.0.1:6681";
/// <summary>MCP 端点</summary>
public static String McpEndpoint => BaseUrl + "/mcp";
/// <summary>测试项目å</summary>
public const String ProjectName = "MCP 测试";
/// <summary>管ç†å‘˜ç™»å½•(Cube /Auth/Login 明文 JSON)</summary>
public const String AdminUser = "admin";
public const String AdminPassword = "Admin@1234";
/// <summary>测试数æ®å‰ç¼€ã€‚所有在测试项目下创建的业务数æ®ä½¿ç”¨æ¤å‰ç¼€ï¼Œé¿å…ä¸Žç”Ÿäº§æ•°æ®æ··æ·†</summary>
public const String Prefix = "mcpapi-";
/// <summary>åˆå§‹åŒ–åŽçš„令牌å—典:åç§° → Token 明文</summary>
public static readonly Dictionary<String, String> Tokens = new(StringComparer.OrdinalIgnoreCase);
/// <summary>åˆå§‹åŒ–åŽçš„æµ‹è¯•项目</summary>
public static GalaxyProject Project { get; private set; } = null!;
/// <summary>åˆå§‹åŒ–åŽçš„æµ‹è¯•æ•°æ®å¿«ç…§ï¼ˆä¾›æµ‹è¯•ç”¨ä¾‹å– ID)</summary>
public static Int32 AppId { get; private set; }
public static Int32 NodeId { get; private set; }
/// <summary>他人项目(项目1)的节点 Id,越æƒ-7 跨项目节点访问é¶</summary>
public static Int32 CrossNodeId { get; private set; }
public static Int32 ConfigId { get; private set; }
public static Int32 DeployId { get; private set; }
public static Int32 VersionId { get; private set; }
public static Int32 DeployNodeId { get; private set; }
public static Int32 PipelineId { get; private set; }
public static Int32 PipelineRunId { get; private set; }
public static Int64 AlarmId { get; private set; }
public static Int64 HistoryId { get; private set; }
public static Int32 ConfigHistoryId { get; private set; }
public static Int64 TraceDataId { get; private set; }
public static Int64 SampleDataId { get; private set; }
/// <summary>åŒä¸€æ¡ TraceId 的第二个 DataId(多值 Id In 分表路由回归数æ®ï¼‰</summary>
public static Int64 TraceDataId2 { get; private set; }
public static Int64 SampleDataId2 { get; private set; }
/// <summary>åˆå§‹åŒ–å™¨ä¸ºè°ƒç”¨é“¾é€ æ•°ä½¿ç”¨çš„ TraceId(两个 SampleData 共用)</summary>
public static String TraceId { get; private set; } = "";
/// <summary>真实集æˆï¼šçœŸå®žèŠ‚ç‚¹ Agent 在线节点(NodeOnline 新鲜心跳,Id=3 DGCWIAX10 ç‰ï¼‰ã€‚
/// 0 è¡¨ç¤ºæµ‹è¯•çŽ¯å¢ƒæ— çœŸå®žèŠ‚ç‚¹ Agent(覆盖-3 çš„ 4 个 Agent ä¾èµ–工具回退网关å—ç†æ–言)。</summary>
public static Int32 RealNodeId { get; private set; }
/// <summary>真实在线节点 Code(Agent ç™»å½•åŽæœåŠ¡ç«¯ä¸‹å‘的节点编ç ,如 F4683130)</summary>
public static String RealNodeCode { get; private set; } = "";
/// <summary>真实在线节点å(机器å)</summary>
public static String RealNodeName { get; private set; } = "";
/// <summary>真实应用实例工厂:测试进程内嵌 AppClient 以 mcpapi-app 身份登录 6600ï¼ˆä¿æ´»é˜² GC)</summary>
private static StarFactory? _realAppFactory;
/// <summary>åˆå§‹åŒ–是å¦å·²æ‰§è¡Œï¼ˆè¿›ç¨‹å†…一次性)</summary>
private static Int32 _initialized;
/// <summary>XCode DAL 是å¦å·²å°±ç»ª</summary>
private static Int32 _dalReady;
private static readonly Object _lock = new();
/// <summary>ç¡®ä¿ XCode DAL åˆå§‹åŒ–并强制 WAL(测试进程直连 Bin\Data\Stardust.db,与 6681 æœåС并å‘读写)</summary>
private static void EnsureDal()
{
if (Interlocked.Exchange(ref _dalReady, 1) == 1) return;
// 触å‘实体 Meta åˆå§‹åŒ–(读 appsettings.json ConnectionStrings:Stardust)
_ = McpToken.Meta.Session;
var dal = McpToken.Meta.Session.Dal;
try
{
dal.Execute("PRAGMA journal_mode=WAL;");
dal.Execute("PRAGMA busy_timeout=30000;");
}
catch (Exception ex)
{
XTrace.WriteLine($"[McpApiTests] EnsureDal PRAGMA 失败:{ex.Message}");
}
// 与 6681 æœåŠ¡å¯åЍæµç¨‹ï¼ˆStartup.cs)对é½ï¼šåå‘工程建表(自动跳过 TraceData/SampleData ç‰åˆ†è¡¨å®žä½“)
EntityFactory.InitConnection("Stardust");
EntityFactory.InitConnection("StardustData");
// TraceData/SampleData 按天分表(Trace{dd} 分库),需先åˆå§‹åŒ–分表ç–略,å¦åˆ™ FindAll 查询主表失败
StarDataHelper.SplitSqliteTables();
}
/// <summary>åˆ¤æ–æ˜¯å¦ä¸º SQLite æ•°æ®åº“é”定异常(并å‘竞争,å¯é‡è¯•)</summary>
private static Boolean IsDatabaseLocked(Exception ex)
{
for (var e = ex; e != null; e = e.InnerException)
if (e.Message != null && e.Message.Contains("database is locked", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
/// <summary>幂ç‰åˆå§‹åŒ–全部测试数æ®ã€‚å¯è¢«å¤šæ¬¡è°ƒç”¨ï¼Œç¬¬äºŒæ¬¡èµ·æ‰€æœ‰æ¥éª¤ç›´æŽ¥è·³è¿‡ã€‚</summary>
public static async Task EnsureAllAsync(ITestOutputHelper? output = null)
{
if (Interlocked.Exchange(ref _initialized, 1) == 1)
{
output?.WriteLine("✅ åˆå§‹åŒ–已在本进程执行过,跳过");
return;
}
lock (_lock)
{
EnsureDal();
// 1. å¼€å¯ MCP(读 dbConfig_StarServer.json,幂ç‰ï¼‰
EnsureMcpEnabled(output);
// 2. 测试项目(已å˜åœ¨åˆ™è·³è¿‡ï¼‰
EnsureProject(output);
// 3. 测试数æ®ï¼ˆåº”用/节点/é…ç½®/部署/æµæ°´çº¿/告è¦ï¼Œå·²å˜åœ¨åˆ™è·³è¿‡ï¼‰
EnsureTestData(output);
// 3.5 真实集æˆï¼ˆçœŸå®žèŠ‚ç‚¹ Agent / 应用实例 / 命令æƒé™æ”¾è¡Œï¼‰
EnsureRealAgentNode(output);
EnsureAllowControl(output);
EnsureRealAppClient(output);
EnsureDeployNodeTarget(output);
// 4. 多类测试令牌(已å˜åœ¨åˆ™è·³è¿‡ï¼‰
EnsureTokens(output);
}
// 5. 登录(HTTP 验è¯ï¼Œæ”¾æœ€åŽä»¥ä¾¿è¾“出完整)
await EnsureLoginAsync(output);
}
/// <summary>ç¡®ä¿ EnableMcp å¼€å¯ï¼ˆè¯» dbConfig_StarServer.json,已 true 则跳过;未开则写入)。
/// 注æ„:æœåŠ¡è¿è¡Œä¸æ”¹é…置文件ä¸çƒç”Ÿæ•ˆï¼Œæ¤å¤„ä»…ä¿è¯æ–‡ä»¶æ€æ£ç¡®ï¼›å®žé™…生效需æœåŠ¡é‡å¯æ—¶åŠ è½½ã€‚</summary>
private static void EnsureMcpEnabled(ITestOutputHelper? output)
{
var dir = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "Data"));
var file = Path.Combine(dir, "dbConfig_StarServer.json");
var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", ".."));
if (!File.Exists(file))
{
// 回退:仓库 Bin\Data
file = Path.Combine(root, "Bin", "Data", "dbConfig_StarServer.json");
}
if (!File.Exists(file))
{
output?.WriteLine("⚠未找到 dbConfig_StarServer.json,跳过 EnableMcp 检查(6681 若已å¯åЍ䏔 /mcp å¯ç”¨åˆ™ä¸å—å½±å“)");
return;
}
var text = File.ReadAllText(file);
var doc = JsonDocument.Parse(text);
var enable = doc.RootElement.TryGetProperty("EnableMcp", out var e) && e.ValueKind == JsonValueKind.String && e.GetString() == "true";
if (enable)
{
output?.WriteLine("✅ EnableMcp 已开å¯ï¼Œè·³è¿‡å†™å…¥");
}
else
{
// 写入 true(ä¿ç•™åŽŸæ ¼å¼ï¼šæ¯ä¸ª key 一行 "Key":"Value",用最简 JSON åˆå¹¶ï¼‰
var dict = new Dictionary<String, String?>(StringComparer.OrdinalIgnoreCase);
foreach (var p in doc.RootElement.EnumerateObject())
dict[p.Name] = p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : p.Value.ToString();
dict["EnableMcp"] = "true";
var sb = new System.Text.StringBuilder("{\r\n");
var idx = 0;
foreach (var kv in dict)
{
sb.Append($" \"{kv.Key}\":\"{kv.Value}\"");
if (idx++ < dict.Count - 1) sb.Append(',');
sb.Append("\r\n");
}
sb.Append("}");
File.WriteAllText(file, sb.ToString());
output?.WriteLine("✅ 已写入 EnableMcp=true 到 dbConfig_StarServer.json(需é‡å¯ 6681 生效)");
}
}
/// <summary>ç¡®ä¿ã€ŒMCP 测试ã€é¡¹ç›®å˜åœ¨ï¼ˆId=4 å·²å˜åœ¨åˆ™è·³è¿‡ï¼‰</summary>
private static void EnsureProject(ITestOutputHelper? output)
{
var project = GalaxyProject.FindAll().FirstOrDefault(e => e.Name == ProjectName);
if (project != null)
{
Project = project;
output?.WriteLine($"✅ 项目「{ProjectName}ã€å·²å˜åœ¨ï¼ˆId={project.Id}),跳过");
return;
}
var p = new GalaxyProject { Name = ProjectName, Enable = true, Remark = "MCP API 端到端测试专用项目(Stardust.McpApiTests åˆå§‹åŒ–)" };
p.Insert();
Project = p;
output?.WriteLine($"✅ 新建项目「{ProjectName}ã€ï¼ˆId={p.Id})");
}
/// <summary>ç¡®ä¿æµ‹è¯•æ•°æ®å˜åœ¨ï¼ˆæŒ‰ä¸šåŠ¡å”¯ä¸€é”®æŸ¥é‡ï¼Œå·²å˜åœ¨è·³è¿‡ï¼‰</summary>
private static void EnsureTestData(ITestOutputHelper? output)
{
var pid = Project.Id;
// --- 应用 ---
var app = App.FindAll().FirstOrDefault(e => e.Name == Prefix + "app" && e.ProjectId == pid);
if (app == null)
{
app = new App { ProjectId = pid, Name = Prefix + "app", DisplayName = "MCP API 测试应用", Category = "test", Enable = true, Version = "1.0.0", Secret = "test-secret" };
app.Insert();
output?.WriteLine($"✅ 新建应用 {app.Name}(Id={app.Id})");
}
AppId = app.Id;
// --- 节点 ---
var node = Node.FindAll().FirstOrDefault(e => e.Code == Prefix + "node" && e.ProjectId == pid);
if (node == null)
{
node = new Node { ProjectId = pid, Name = Prefix + "node", Code = Prefix + "node", Category = "test", Enable = true, Version = "1.0.0", IP = "127.0.0.1", Secret = "test-node-secret" };
node.Insert();
output?.WriteLine($"✅ 新建节点 {node.Name}(Id={node.ID})");
}
NodeId = node.ID;
// --- 他人项目节点(越æƒ-7 é¶ï¼‰---
// 属于项目1ï¼ˆé»˜è®¤é¡¹ç›®ï¼‰çš„èŠ‚ç‚¹ï¼Œä¸“ä¾›è·¨é¡¹ç›®è¶Šæƒæµ‹è¯•:全æƒé™ä»¤ç‰Œï¼ˆç»‘项目4)访问它必须被 McpGuard.Node æ‹’ç»ã€‚
// ä¸èƒ½å¤ç”¨æµ‹è¯•项目自己的节点(NodeId 已被本åˆå§‹åŒ–器å ç”¨æœ€å° Id),å¦åˆ™"跨项目"å‰æå¤±æ•ˆã€‚
var otherNode = Node.FindAll().FirstOrDefault(e => e.Code == Prefix + "crossnode");
if (otherNode == null)
{
otherNode = new Node { ProjectId = 1, Name = Prefix + "crossnode", Code = Prefix + "crossnode", Category = "test", Enable = true, Version = "1.0.0", IP = "127.0.0.1", Secret = "test-node-secret" };
otherNode.Insert();
output?.WriteLine($"✅ 新建他人项目节点 {otherNode.Name}(Id={otherNode.ID},ProjectId=1)");
}
CrossNodeId = otherNode.ID;
// --- é…置集 + é…ç½®é¡¹ï¼ˆå«æ•感 keyï¼Œä¾›è„±æ•æµ‹è¯•) ---
var cfg = AppConfig.FindAll().FirstOrDefault(e => e.Name == Prefix + "config" && e.ProjectId == pid);
if (cfg == null)
{
cfg = new AppConfig { ProjectId = pid, AppId = AppId, Name = Prefix + "config", DisplayName = "MCP API 测试é…ç½®", Enable = true, Version = 1 };
cfg.Insert();
output?.WriteLine($"✅ 新建é…置集 {cfg.Name}(Id={cfg.Id})");
}
ConfigId = cfg.Id;
// 普通键 + æ•æ„Ÿé”®ï¼ˆå¯†ç /å¯†é’¥ç±»ï¼ŒéªŒè¯ config_get 脱æ•)
var ck1 = ConfigData.FindAllByApp(cfg.Id).FirstOrDefault(e => e.Key == "ServerName");
if (ck1 == null)
{
var cd = new ConfigData { ConfigId = cfg.Id, Key = "ServerName", Value = "mcp-api-value", Enable = true, Version = 1 };
cd.Insert();
output?.WriteLine($"✅ 新建é…置项 {cd.Key}");
}
var ck2 = ConfigData.FindAllByApp(cfg.Id).FirstOrDefault(e => e.Key == "Password");
if (ck2 == null)
{
var cd = new ConfigData { ConfigId = cfg.Id, Key = "Password", Value = "top-secret-password", Enable = true, Version = 1 };
cd.Insert();
output?.WriteLine($"✅ æ–°å»ºæ•æ„Ÿé…置项 {cd.Key}");
}
// --- 部署集 + 版本 + 部署节点 ---
var deploy = AppDeploy.FindAll().FirstOrDefault(e => e.Name == Prefix + "deploy" && e.ProjectId == pid);
if (deploy == null)
{
deploy = new AppDeploy { ProjectId = pid, AppId = AppId, Name = Prefix + "deploy", Category = "test", Enable = true, Version = "1.0.0" };
deploy.Insert();
output?.WriteLine($"✅ 新建部署集 {deploy.Name}(Id={deploy.Id})");
}
DeployId = deploy.Id;
var ver = AppDeployVersion.FindAll().FirstOrDefault(e => e.DeployId == DeployId && e.Version == "1.0.0");
if (ver == null)
{
ver = new AppDeployVersion { DeployId = DeployId, Version = "1.0.0", Enable = true, Url = "http://127.0.0.1/pkg/1.0.0.zip", Size = 1024, CommitId = "abc123" };
ver.Insert();
output?.WriteLine($"✅ 新建部署版本 {ver.Version}(Id={ver.Id})");
}
VersionId = ver.Id;
var dn = AppDeployNode.FindAll().FirstOrDefault(e => e.DeployId == DeployId && e.NodeId == NodeId);
if (dn == null)
{
dn = new AppDeployNode { DeployId = DeployId, NodeId = NodeId, IP = "127.0.0.1", Enable = true, Version = "1.0.0" };
dn.Insert();
output?.WriteLine($"✅ 新建部署节点(Id={dn.Id})");
}
DeployNodeId = dn.Id;
// --- æµæ°´çº¿ + è¿è¡Œ ---
var pl = AppPipeline.FindAll().FirstOrDefault(e => e.DeployId == DeployId && e.Name == Prefix + "pipeline");
if (pl == null)
{
pl = new AppPipeline { DeployId = DeployId, Name = Prefix + "pipeline", Enable = true };
pl.Insert();
output?.WriteLine($"✅ æ–°å»ºæµæ°´çº¿ {pl.Name}(Id={pl.Id})");
}
PipelineId = pl.Id;
var run = AppPipelineRun.FindAll().FirstOrDefault(e => e.PipelineId == PipelineId);
if (run == null)
{
run = new AppPipelineRun
{
PipelineId = PipelineId,
Status = Stardust.Models.PipelineStatus.Success,
TriggerSource = "manual",
CommitId = "abc123",
CommitMessage = "mcp api test run",
CommitAuthor = "mcp-test",
Branch = "main",
};
run.Insert();
output?.WriteLine($"✅ æ–°å»ºæµæ°´çº¿è¿è¡Œï¼ˆId={run.Id})");
}
PipelineRunId = run.Id;
// --- 告è¦è®°å½•(Name 必须与应用å一致,alarm_list 按项目应用å过滤) ---
var alarm = AlarmRecord.FindAll().FirstOrDefault(e => e.Name == app.Name);
if (alarm == null)
{
alarm = new AlarmRecord
{
Name = app.Name,
Category = app.Category,
Action = "测试告è¦",
Status = AlarmStatuses.Alarming,
StartTime = DateTime.Now.AddMinutes(-10),
Times = 1,
Content = "MCP API 测试告è¦å†…容",
};
alarm.Insert();
output?.WriteLine($"✅ 新建告è¦è®°å½•(Id={alarm.Id})");
}
AlarmId = alarm.Id;
// --- 部署历å²ï¼ˆdeploy_history / now_changes æ•°æ®æºï¼‰ ---
var hist = AppDeployHistory.FindAll().FirstOrDefault(e => e.DeployId == DeployId && e.Remark == Prefix + "init");
if (hist == null)
{
hist = new AppDeployHistory { DeployId = DeployId, NodeId = NodeId, Action = "install", Success = true, Remark = Prefix + "init", CreateTime = DateTime.Now.AddMinutes(-5) };
hist.Insert();
output?.WriteLine($"✅ 新建部署历å²ï¼ˆId={hist.Id})");
}
HistoryId = hist.Id;
// --- é…置历å²ï¼ˆconfig_rollback æ•°æ®æºã€‚å¿…é¡» Action=Publish 且 Remark 为å¯è§£æžçš„ AppConfig JSON 快照) ---
var ch = ConfigHistory.FindAll().FirstOrDefault(e => e.ConfigId == ConfigId && e.Action == "Publish");
if (ch == null)
{
// Remark 必须能 ToJsonEntity<AppConfig> è§£æžï¼ˆConfigRollbackTool ä¾èµ–),æ¤å¤„ä¿å˜ cfg çš„ JSON å¿«ç…§
ch = new ConfigHistory { ConfigId = ConfigId, Action = "Publish", Success = true, Remark = cfg.ToJson(), CreateTime = DateTime.Now.AddMinutes(-5) };
ch.Insert();
output?.WriteLine($"✅ 新建é…置历å²ï¼ˆId={ch.Id},Remark=AppConfig快照)");
}
ConfigHistoryId = ch.Id;
// --- è°ƒç”¨é“¾é‡‡æ ·ï¼ˆtrace_search / trace_detail æ•°æ®æºï¼‰ ---
// TraceData/SampleData 为按天分表实体(Trace{dd} 分库),查询必须包在 CreateShard 作用域内
TraceData? td = null;
using (var split = TraceData.Meta.CreateShard(DateTime.Today))
{
td = TraceData.FindAll().FirstOrDefault(e => e.AppId == AppId && e.Name == Prefix + "trace");
}
if (td == null)
{
td = new TraceData { AppId = AppId, NodeId = NodeId, ClientId = "mcp-test", Name = Prefix + "trace", Cost = 10, Errors = 0, Total = 1, StatDate = DateTime.Today, StartTime = DateTime.Now.AddMinutes(-5).ToLong(), CreateTime = DateTime.Now.AddMinutes(-5) };
td.Insert();
output?.WriteLine($"✅ 新建 TraceData(Id={td.Id})");
}
TraceDataId = td.Id;
SampleData? sd = null;
using (var split = SampleData.Meta.CreateShard(DateTime.Today))
{
sd = SampleData.FindAll().FirstOrDefault(e => e.DataId == td.Id && e.SpanId == Prefix + "span");
}
if (sd == null)
{
sd = new SampleData { DataId = td.Id, TraceId = "mcp-api-" + td.Id, SpanId = Prefix + "span", ParentId = null, Cost = 10, Success = true, StartTime = DateTime.Now.AddMinutes(-5).ToLong(), CreateTime = DateTime.Now.AddMinutes(-5) };
sd.Insert();
output?.WriteLine($"✅ 新建 SampleData(Id={sd.Id})");
}
SampleDataId = sd.Id;
// 第二个 DataId:åŒä¸€æ¡ TraceId 下多片段,用于回归「TraceData åæŸ¥å¿…须按多值 Id 分片路由ã€
// ï¼ˆå• DataId çš„ In(...) 会退化为 =,XCode 能自动定ä½åˆ†ç‰‡ï¼Œæµ‹ä¸å‡ºç¼ºè¡¨ç¼ºé™·ï¼‰
TraceData? td2 = null;
using (var split = TraceData.Meta.CreateShard(DateTime.Today))
{
td2 = TraceData.FindAll().FirstOrDefault(e => e.AppId == AppId && e.Name == Prefix + "trace2");
}
if (td2 == null)
{
td2 = new TraceData { AppId = AppId, NodeId = NodeId, ClientId = "mcp-test", Name = Prefix + "trace2", Cost = 20, Errors = 1, Total = 1, StatDate = DateTime.Today, StartTime = DateTime.Now.AddMinutes(-5).ToLong(), CreateTime = DateTime.Now.AddMinutes(-5) };
td2.Insert();
output?.WriteLine($"✅ 新建 TraceData2(Id={td2.Id})");
}
TraceDataId2 = td2.Id;
SampleData? sd2 = null;
using (var split = SampleData.Meta.CreateShard(DateTime.Today))
{
sd2 = SampleData.FindAll().FirstOrDefault(e => e.DataId == td2.Id && e.SpanId == Prefix + "span2");
}
if (sd2 == null)
{
sd2 = new SampleData { DataId = td2.Id, TraceId = sd.TraceId, SpanId = Prefix + "span2", ParentId = null, Cost = 20, Success = false, StartTime = DateTime.Now.AddMinutes(-5).ToLong(), CreateTime = DateTime.Now.AddMinutes(-5) };
sd2.Insert();
output?.WriteLine($"✅ 新建 SampleData2(Id={sd2.Id},TraceId={sd2.TraceId})");
}
SampleDataId2 = sd2.Id;
TraceId = sd.TraceId;
// --- 应用在线 / 节点在线(node_list online / app_health) ---
var ao = AppOnline.FindAll().FirstOrDefault(e => e.AppId == AppId && e.NodeId == NodeId);
if (ao == null)
{
ao = new AppOnline { AppId = AppId, NodeId = NodeId, ProjectId = pid, Name = app.Name, Version = "1.0.0", ProcessId = 1001, IP = "127.0.0.1", CreateTime = DateTime.Now.AddMinutes(-5), UpdateTime = DateTime.Now };
ao.Insert();
output?.WriteLine($"✅ 新建应用在线记录");
}
var no = NodeOnline.FindAll().FirstOrDefault(e => e.NodeID == NodeId);
if (no == null)
{
no = new NodeOnline { NodeID = NodeId, ProjectId = pid, Name = node.Name, IP = "127.0.0.1", CpuRate = 0.1, MemoryUsed = 100, CreateTime = DateTime.Now.AddMinutes(-5), UpdateTime = DateTime.Now };
no.Insert();
output?.WriteLine($"✅ 新建节点在线记录");
}
// --- 应用日志(app_logs) ---
// AppClientLog 是分表实体(TimeShardPolicy:按月分库 StardustData_yyyyMM + 按天分表 AppClientLog_dd),
// 没有基础表(旧方案裸 SQL 建基础表已被废弃——MCP 工具现走带时间窗的分表感知查询)。
// 这里用实体 Insert 幂ç‰é€ 一æ¡ï¼ˆXCode 自动创建分库/分表,è½åœ¨æœ¬æµ‹è¯•进程数æ®ç›®å½•,与æœåŠ¡ç«¯å…±äº«ï¼‰ï¼Œ
// æœåŠ¡ç«¯ app_logs / app_health å³å¯æ£å¸¸è¯»åˆ°ã€‚
{
var msg = Prefix + "hello";
var start = DateTime.Now.AddHours(-1);
var end = DateTime.Now.AddMinutes(1);
var existed = AppClientLog.Search(AppId, null, 0, start, end, msg, null,
new NewLife.Data.PageParameter { PageIndex = 1, PageSize = 1 }).Count > 0;
if (!existed)
{
var now = DateTime.Now.AddMinutes(-5);
new AppClientLog
{
AppId = AppId,
ClientId = "mcp-test",
Time = now.ToString("yyyy-MM-dd HH:mm:ss"),
ThreadId = "1",
Kind = "Info",
Name = "mcp-test",
Message = msg,
CreateTime = now,
CreateIP = "127.0.0.1",
}.Insert();
output?.WriteLine("✅ 新建应用日志(分表实体 Insert,自动建分库/分表)");
}
}
}
/// <summary>ç¡®ä¿çœŸå®žèŠ‚ç‚¹ Agent 在线(幂ç‰ï¼‰ã€‚轮询 NodeOnline(StardustData.db)直至出现本项目新鲜心跳
/// (UpdateTime è·ä»Š ≤120s,真实 Agent æ¯ ~10s 心跳刷新);60s 内未出现则跳过(覆盖-3 回退网关å—ç†ï¼‰ã€‚
/// 注æ„:本方法必须在本机已å¯åŠ¨çœŸå®ž StarServer(6600)+StarAgent çš„å‰æä¸‹æ‰èƒ½æ‹¿åˆ°çœŸå®žèŠ‚ç‚¹ã€‚</summary>
private static void EnsureRealAgentNode(ITestOutputHelper? output)
{
var pid = Project.Id;
var deadline = DateTime.Now.AddSeconds(60);
NodeOnline? no = null;
while (DateTime.Now < deadline)
{
// 排除本åˆå§‹åŒ–器为 node_list/app_health é€ çš„å‡åœ¨çº¿è®°å½•(mcpapi-* èŠ‚ç‚¹ï¼Œæ— ä¼šè¯ï¼‰ï¼Œ
// åªè®¤çœŸå®ž Agent 注册的节点,å¦åˆ™å‡è®°å½•的新鲜心跳会顶替真实节点污染 RealNodeId
no = NodeOnline.FindAll(NodeOnline._.ProjectId == pid)
.Where(e => !(Node.FindByID(e.NodeID)?.Code ?? "").StartsWith(Prefix, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(e => e.UpdateTime)
.FirstOrDefault();
if (no != null && no.UpdateTime > DateTime.Now.AddSeconds(-120)) break;
Thread.Sleep(3000);
}
if (no == null || no.UpdateTime <= DateTime.Now.AddSeconds(-120))
{
output?.WriteLine("⚠未å‘现真实在线节点(NodeOnline æ— æ–°é²œå¿ƒè·³ï¼‰ï¼Œè¦†ç›–-3 çš„ 4 个 Agent ä¾èµ–工具回退网关å—ç†æ–言");
return;
}
RealNodeId = no.NodeID;
var node = Node.FindByID(RealNodeId);
RealNodeCode = node?.Code ?? "";
RealNodeName = node?.Name ?? no.Name;
output?.WriteLine($"✅ 真实节点 Agent 在线:Id={RealNodeId} Code={RealNodeCode} Name={RealNodeName}(心跳 {no.UpdateTime:HH:mm:ss})");
}
/// <summary>放行命令æƒé™ï¼ˆå¹‚ç‰ï¼‰ï¼šStarWebApi(6681 MCP 命令调用身份)与 mcpapi-app çš„ AllowControlNodes ç½® "*"。
/// NodeService.SendCommand æƒé™é—¸å£ï¼šAllowControlNodes 为空且 AllowControlNodesWhenEmpty=false → Unauthorized。</summary>
private static void EnsureAllowControl(ITestOutputHelper? output)
{
foreach (var name in new[] { "StarWebApi", "mcpapi-app" })
{
var app = App.FindAll(App._.Name == name).FirstOrDefault();
if (app == null) continue;
if (app.AllowControlNodes != "*")
{
app.AllowControlNodes = "*";
app.Update();
output?.WriteLine($"✅ 放行 {name} 节点控制æƒé™ï¼ˆAllowControlNodes=\"*\")");
}
}
}
/// <summary>内嵌真实应用实例(幂ç‰ï¼‰ï¼šæ¸…ç† mcpapi-app 的手工 AppOnline å‡è®°å½•,并以 mcpapi-app 身份
/// new StarFactory("http://127.0.0.1:6600", "mcpapi-app", "test-secret") 登录 6600,注册真实 AppOnline 实例。
/// æ¤åŽ stardust_app_command/app_process 会真实推达本进程 AppClient 并收到回包。</summary>
private static void EnsureRealAppClient(ITestOutputHelper? output)
{
// æ¸…ç†æ‰‹å·¥ AppOnline å‡å®žä¾‹ï¼ˆä¼šè¯ä¸å˜åœ¨ï¼ŒæŽ¨é€ä¼šç©ºç‰ 5s 超时;真实实例会自动注册)
AppOnline.Delete(AppOnline._.AppId == AppId);
try
{
_realAppFactory ??= new StarFactory("http://127.0.0.1:6600", "mcpapi-app", "test-secret");
// 注册测试命令处ç†å™¨ï¼šapp_process çš„ start/restart 推达本进程实例åŽå¯å›žçœŸå®žæˆåŠŸåŒ…ã€‚
// 䏿³¨å†Œæ—¶å®¢æˆ·ç«¯ä¼šå›žã€Œå‘½ä»¤ä¸å˜åœ¨ã€é”™è¯¯åŒ…(Status=å–æ¶ˆï¼‰ï¼Œæ–言会退化æˆé 错误回包å‡ç»¿ã€‚
var realApp = _realAppFactory.App;
if (realApp != null)
{
realApp.RegisterCommand("app/start", a => "start-ok");
realApp.RegisterCommand("app/restart", a => "restart-ok");
}
var deadline = DateTime.Now.AddSeconds(30);
var online = false;
while (DateTime.Now < deadline)
{
if (AppOnline.FindAll(AppOnline._.AppId == AppId).Any())
{
online = true;
break;
}
Thread.Sleep(2000);
}
output?.WriteLine(online
? "✅ mcpapi-app 真实应用实例在线(AppClient 已登录 6600,AppCommand 将真实推达)"
: "âš mcpapi-app 应用实例 30s 内未上线(app_command/app_process 回退网关å—ç†æ–言)");
}
catch (Exception ex)
{
output?.WriteLine($"⚠mcpapi-app AppClient 登录失败:{ex.Message}");
}
}
/// <summary>éƒ¨ç½²èŠ‚ç‚¹ç›®æ ‡æ”¹ä¸ºçœŸå®žèŠ‚ç‚¹ï¼ˆå¹‚ç‰ï¼‰ï¼šAppDeployNode çš„ NodeId 指å‘真实在线 Agent 节点,
/// 使 stardust_deploy_node_operate 的 deploy/start 命令真实推达 Agent。</summary>
private static void EnsureDeployNodeTarget(ITestOutputHelper? output)
{
if (RealNodeId == 0) return;
var dn = AppDeployNode.FindById(DeployNodeId);
if (dn != null && dn.NodeId != RealNodeId)
{
dn.NodeId = RealNodeId;
dn.Update();
output?.WriteLine($"✅ 部署节点 {dn.Id} ç›®æ ‡èŠ‚ç‚¹æ”¹ä¸ºçœŸå®žèŠ‚ç‚¹ Id={RealNodeId}(Code={RealNodeCode})");
}
}
/// <summary>ç¡®ä¿å¤šç±»æµ‹è¯•令牌å˜åœ¨ï¼ˆæŒ‰ Name 查é‡ï¼Œå·²å˜åœ¨è·³è¿‡ï¼‰ã€‚令牌绑定测试项目/èµ„æºæŽˆæƒã€‚</summary>
private static void EnsureTokens(ITestOutputHelper? output)
{
var pid = Project.Id;
// 主令牌:全 scope(全部域的 read/write)+ 项目/应用/èŠ‚ç‚¹å…¨èµ„æºæŽˆæƒ
var main = McpToken.FindAll(McpToken._.Name == Prefix + "full").FirstOrDefault();
if (main == null)
{
main = new McpToken
{
Name = Prefix + "full",
Token = McpToken.GenerateToken(),
Enable = true,
ExpireTime = DateTime.MinValue,
ProjectId = pid,
UserId = 0,
Scopes = "apps:read,apps:write,apps:admin,nodes:read,nodes:write,nodes:admin,config:read,config:write,deploy:read,deploy:write,monitor:read,monitor:write,now:read,platform:read,platform:write",
};
main.Insert();
// èµ„æºæŽˆæƒï¼šé¡¹ç›®å…¨é‡ + åº”ç”¨å…¨é‡ + 节点全é‡
foreach (var rt in new[] { McpResourceType.Project, McpResourceType.App, McpResourceType.Node })
{
var r = new McpTokenResource { TokenId = main.Id, ResourceType = rt.ToStorageName(), IsAll = true, Enable = true };
r.Insert();
}
output?.WriteLine($"✅ 新建主令牌 {main.Name}");
}
// 幂ç‰è‡ªæ„ˆï¼šæ—©æœŸåˆ›å»ºçš„令牌å¯èƒ½ç¼º L2 admin scope(apps:admin/nodes:adminï¼‰ï¼Œè¡¥å…¨åŽ tools/list æ‰ä¼šæš´éœ² app_command/node_command
{
var needAdmin = new[] { "apps:admin", "nodes:admin" };
var missing = needAdmin.Where(s => !main.Scopes.Split(',').Select(e => e.Trim()).Contains(s)).ToArray();
if (missing.Length > 0)
{
main.Scopes = String.Join(",", (main.Scopes + "," + String.Join(",", missing)).Split(',').Select(e => e.Trim()).Where(e => e.Length > 0).Distinct(StringComparer.OrdinalIgnoreCase));
main.Update();
output?.WriteLine($"✅ 主令牌补全 admin scope:{String.Join(",", missing)}");
}
}
Tokens[main.Name] = main.Token;
// é›¶ scope 令牌(仅 whoami å¯ç”¨ï¼‰
var zero = McpToken.FindAll(McpToken._.Name == Prefix + "zeroscope").FirstOrDefault();
if (zero == null)
{
zero = new McpToken
{
Name = Prefix + "zeroscope",
Token = McpToken.GenerateToken(),
Enable = true,
ExpireTime = DateTime.MinValue,
ProjectId = pid,
UserId = 0,
Scopes = "",
};
zero.Insert();
output?.WriteLine($"✅ 新建零 scope 令牌 {zero.Name}");
}
Tokens[zero.Name] = zero.Token;
// 未绑定项目令牌(ProjectId=0,业务工具应 -32003)
var unbound = McpToken.FindAll(McpToken._.Name == Prefix + "unbound").FirstOrDefault();
if (unbound == null)
{
unbound = new McpToken
{
Name = Prefix + "unbound",
Token = McpToken.GenerateToken(),
Enable = true,
ExpireTime = DateTime.MinValue,
ProjectId = 0,
UserId = 0,
Scopes = "apps:read,nodes:read,config:read,deploy:read,monitor:read,now:read,platform:read",
};
unbound.Insert();
output?.WriteLine($"✅ 新建未绑定项目令牌 {unbound.Name}");
}
Tokens[unbound.Name] = unbound.Token;
// ç¦ç”¨ä»¤ç‰Œ
var disabled = McpToken.FindAll(McpToken._.Name == Prefix + "disabled").FirstOrDefault();
if (disabled == null)
{
disabled = new McpToken
{
Name = Prefix + "disabled",
Token = McpToken.GenerateToken(),
Enable = false,
ExpireTime = DateTime.MinValue,
ProjectId = pid,
UserId = 0,
Scopes = "apps:read,nodes:read,config:read,deploy:read,monitor:read,now:read,platform:read",
};
disabled.Insert();
output?.WriteLine($"✅ 新建ç¦ç”¨ä»¤ç‰Œ {disabled.Name}");
}
Tokens[disabled.Name] = disabled.Token;
// 过期令牌
var expired = McpToken.FindAll(McpToken._.Name == Prefix + "expired").FirstOrDefault();
if (expired == null)
{
expired = new McpToken
{
Name = Prefix + "expired",
Token = McpToken.GenerateToken(),
Enable = true,
ExpireTime = DateTime.Now.AddMinutes(-5),
ProjectId = pid,
UserId = 0,
Scopes = "apps:read,nodes:read,config:read,deploy:read,monitor:read,now:read,platform:read",
};
expired.Insert();
output?.WriteLine($"✅ 新建过期令牌 {expired.Name}");
}
Tokens[expired.Name] = expired.Token;
// éžæˆå‘˜ç”¨æˆ·ä»¤ç‰Œï¼šç»‘定 UserId=1(adminï¼‰ä½†éž ProjectUser æˆå‘˜ → NeedProject -32003
var nonMember = McpToken.FindAll(McpToken._.Name == Prefix + "nonmember").FirstOrDefault();
if (nonMember == null)
{
nonMember = new McpToken
{
Name = Prefix + "nonmember",
Token = McpToken.GenerateToken(),
Enable = true,
ExpireTime = DateTime.MinValue,
ProjectId = pid,
UserId = 1,
Scopes = "apps:read,nodes:read,config:read,deploy:read,monitor:read,now:read,platform:read",
};
nonMember.Insert();
// èµ„æºæŽˆæƒé¡¹ç›®å…¨é‡ï¼ˆCheckResource 通过;NeedProject æˆå‘˜æ ¡éªŒåº”æ‹’ç»ï¼‰
var r = new McpTokenResource { TokenId = nonMember.Id, ResourceType = McpResourceType.Project.ToStorageName(), IsAll = true, Enable = true };
r.Insert();
output?.WriteLine($"✅ æ–°å»ºéžæˆå‘˜ç”¨æˆ·ä»¤ç‰Œ {nonMember.Name}");
}
Tokens[nonMember.Name] = nonMember.Token;
// 未授æƒèµ„æºä»¤ç‰Œï¼šåªæŽˆæƒé¡¹ç›®ï¼ŒæœªæŽˆæƒåº”用/节点 → CheckResource æ‹’ç»
var unauth = McpToken.FindAll(McpToken._.Name == Prefix + "unauth").FirstOrDefault();
if (unauth == null)
{
unauth = new McpToken
{
Name = Prefix + "unauth",
Token = McpToken.GenerateToken(),
Enable = true,
ExpireTime = DateTime.MinValue,
ProjectId = pid,
UserId = 0,
Scopes = "apps:read,nodes:read,config:read,deploy:read,monitor:read,now:read,platform:read",
};
unauth.Insert();
var rp = new McpTokenResource { TokenId = unauth.Id, ResourceType = McpResourceType.Project.ToStorageName(), ResourceId = pid, IsAll = false, Enable = true };
rp.Insert();
output?.WriteLine($"✅ 新建未授æƒèµ„æºä»¤ç‰Œ {unauth.Name}");
}
Tokens[unauth.Name] = unauth.Token;
}
/// <summary>管ç†å‘˜ç™»å½•(HTTP POST /Auth/Loginï¼‰ï¼Œæ‹¿å– JWT。验è¯ç™»å½•通路与管ç†å‘˜å¯ç”¨æ€§ã€‚</summary>
private static async Task EnsureLoginAsync(ITestOutputHelper? output)
{
using var client = new HttpClient { BaseAddress = new Uri(BaseUrl) };
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var body = new StringContent(JsonSerializer.Serialize(new { username = AdminUser, password = AdminPassword }), System.Text.Encoding.UTF8, "application/json");
try
{
var resp = await client.PostAsync("/Auth/Login", body);
var text = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
{
output?.WriteLine($"⚠登录 HTTP {(Int32)resp.StatusCode}:{text.Cut(300)}");
return;
}
var doc = JsonDocument.Parse(text);
if (doc.RootElement.TryGetProperty("data", out var data) && data.TryGetProperty("AccessToken", out var at))
{
output?.WriteLine("✅ 管ç†å‘˜ç™»å½•æˆåŠŸï¼ˆAccessToken 已获å–)");
}
else if (doc.RootElement.TryGetProperty("code", out var code) && code.GetInt32() != 0)
{
output?.WriteLine($"⚠登录被拦截 code={code.GetInt32()}(验è¯ç /MFA ç–略),MCP 鉴æƒç”¨ä»¤ç‰Œä¸å—å½±å“");
}
else
{
output?.WriteLine($"⚠登录å“åº”æœªå« AccessToken:{text.Cut(300)}");
}
}
catch (Exception ex)
{
output?.WriteLine($"⚠登录请求失败:{ex.Message}");
}
}
}
|