merge
This commit is contained in:
@@ -40,6 +40,7 @@ public static class PageCatalog
|
||||
new("admin-processes", "进程管理", "设计与编排", ScopePlatform),
|
||||
new("admin-scripts", "脚本管理", "设计与编排", ScopePlatform),
|
||||
new("admin-task-templates", "任务编排", "设计与编排", ScopePlatform),
|
||||
new("admin-simple-fields", "字段管理", "设计与编排", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||
|
||||
@@ -120,6 +120,12 @@ public sealed class RbacStore
|
||||
&& !r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase)
|
||||
&& hasProcessAndScript)
|
||||
r.Pages.Add("admin-task-templates");
|
||||
|
||||
// Simple 字段管理:与任务编排同属设计与编排,有任务编排权限时自动补齐。
|
||||
if (!r.Pages.Contains(PageCatalog.Wildcard)
|
||||
&& !r.Pages.Contains("admin-simple-fields", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-task-templates", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-simple-fields");
|
||||
}
|
||||
|
||||
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||
@@ -206,6 +212,15 @@ public sealed class RbacStore
|
||||
lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); }
|
||||
}
|
||||
|
||||
public RbacUser? FindUserById(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id);
|
||||
return u is null ? null : Clone(u);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前用户可登录的 scope 集合(其角色覆盖的 scope,<c>*</c> 角色覆盖全部)。</summary>
|
||||
public List<string> UsableScopes(RbacUser user)
|
||||
{
|
||||
|
||||
@@ -27,11 +27,14 @@ public static class DeploymentCatalog
|
||||
new Option("ptl", "PTL 拣选系统", "module", "Pick-to-Light 亮灯拣选与播种"),
|
||||
};
|
||||
|
||||
/// <summary>功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。</summary>
|
||||
/// <summary>
|
||||
/// 功能模块 → PageCatalog 页面 Key 列表的映射。PTL 暂无专属配置页,未纳入映射(选中不影响菜单)。
|
||||
/// 注意:Key 必须是 PageCatalog 当前有效 Key(不能用 LegacyKeyAliases 里的旧名,否则裁剪悄然失效)。
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string[]> ModuleToPages =
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["wms"] = new[] { "admin-config-warehouse" },
|
||||
["wms"] = new[] { "admin-config-facility" },
|
||||
};
|
||||
|
||||
/// <summary>所有「可被选型控制」的页面 Key(Module → 页 映射值的并集)。</summary>
|
||||
|
||||
@@ -35,15 +35,17 @@ public record DeploymentProfile(
|
||||
UpdatedBy: "");
|
||||
|
||||
/// <summary>
|
||||
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
|
||||
/// 导航方式 → SimpleLite 场景 id 映射。与 <c>*.scene.json.id</c>、<c>SimpleCore.Navigation.NavKind</c>
|
||||
/// 以及内核 <c>active-scenes.json.activeScenes</c> 一致;这是平台与内核之间「导航选型」的契约约定。
|
||||
/// <para>两平台制(StandardScene 拆分落地):磁导航 → <c>scene.mag</c>;
|
||||
/// 二维码与激光共用融合平台 <c>scene.qrlidar</c>(激光坐标导航 + 二维码按轨道逐段触发,可融合可单用)。</para>
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> NavKindToSceneId =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["magnetic"] = "scene.magnetic",
|
||||
["qrcode"] = "scene.qrcode",
|
||||
["laser"] = "scene.laser",
|
||||
["magnetic"] = "scene.mag",
|
||||
["qrcode"] = "scene.qrlidar",
|
||||
["laser"] = "scene.qrlidar",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -6,7 +6,7 @@ using MiGu.Server.Configs;
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
// AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。
|
||||
// GET (List/Get) 只要登录就放;PUT 强制 PlatformScope,避免运营人员误改业务配置。
|
||||
// GET (List/Get) 只要登录就放;PUT 按 scope 收紧:Platform 任意节,RCSMonitor 仅 ops 白名单。
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/config")]
|
||||
@@ -47,15 +47,24 @@ public class ConfigController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// 配置中心页面已有 PermissionGuard;此处仅要求登录即可保存,避免 RCSMonitor scope
|
||||
// 账号在特殊场景下无法写入 ops.monitor(地图监控动作)备份字段。
|
||||
/// <summary>RCSMonitor scope 允许写入的 section 白名单(地图监控动作备份等运营自有配置)。</summary>
|
||||
private static readonly string[] MonitorWritableSections = { "ops" };
|
||||
|
||||
// Platform scope 可写任意 section;RCSMonitor 仅允许写 ops(保留运营端
|
||||
// 「地图监控动作 ops.monitor 备份」既有功能),其余 section(routing/auth/system 等)一律 403。
|
||||
[HttpPut("{section}")]
|
||||
[Authorize]
|
||||
public IActionResult Put(string section, [FromBody] JsonElement payload)
|
||||
{
|
||||
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||
return NotFound(new { message = $"未知 section: {section}" });
|
||||
|
||||
var scope = User.FindFirst("scope")?.Value;
|
||||
if (!string.Equals(scope, "Platform", StringComparison.OrdinalIgnoreCase)
|
||||
&& !MonitorWritableSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
return StatusCode(403, new { message = $"当前账号无权修改配置节 {section}(需要 Platform 管理端权限)" });
|
||||
}
|
||||
|
||||
var env = _store.Put(section, payload);
|
||||
return Ok(new
|
||||
{
|
||||
|
||||
@@ -13,39 +13,33 @@ public class HealthController : ControllerBase
|
||||
|
||||
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||
|
||||
/// <summary>匿名存活探针:仅返回进程级状态,不暴露端口拓扑等部署细节。</summary>
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
status = "ok",
|
||||
mode = "WebEnabled",
|
||||
startTime = StartTime,
|
||||
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds,
|
||||
ports = new
|
||||
{
|
||||
webApi = 7001,
|
||||
webSocket = 7002,
|
||||
platform = 8080,
|
||||
vrender = 8223,
|
||||
vehicle = 8222
|
||||
},
|
||||
architecture = "v1.5"
|
||||
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
||||
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
|
||||
/// 含服务器本地路径等敏感信息,要求登录。
|
||||
/// </summary>
|
||||
[HttpGet("simplelite")]
|
||||
[Authorize]
|
||||
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
||||
|
||||
/// <summary>
|
||||
/// 关闭 SimpleLite、同步最新 DLL、重新拉起。用于「前往站点」API 缺失时一键更新。
|
||||
/// 会终止本机全部 SimpleLite 进程并重启,仅 Platform 管理端可调。
|
||||
/// </summary>
|
||||
[HttpPost("simplelite/restart-for-update")]
|
||||
[Authorize]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public IActionResult RestartSimpleLiteForUpdate([FromQuery] string launchMode = "webonly")
|
||||
{
|
||||
var result = _launcher.RestartForUpdate(launchMode);
|
||||
|
||||
@@ -747,9 +747,9 @@ public sealed class LogsController : ControllerBase
|
||||
}
|
||||
b.Latest = e.Content;
|
||||
b.LatestTime = e.Time;
|
||||
b.Recent.Add(e);
|
||||
// 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆。
|
||||
if (b.Recent.Count > maxPerTag) b.Recent.RemoveAt(0);
|
||||
b.Recent.Enqueue(e);
|
||||
// 仅保留最近 maxPerTag 条,避免高频标签把内存撑爆(Queue 头部出队 O(1))。
|
||||
if (b.Recent.Count > maxPerTag) b.Recent.Dequeue();
|
||||
}
|
||||
|
||||
private static object ToBookDto(Book b) => new
|
||||
@@ -831,6 +831,6 @@ public sealed class LogsController : ControllerBase
|
||||
public DateTime? LastTime { get; set; }
|
||||
public string Latest { get; set; } = "";
|
||||
public DateTime? LatestTime { get; set; }
|
||||
public List<LogEntry> Recent { get; } = new();
|
||||
public Queue<LogEntry> Recent { get; } = new();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,11 +59,11 @@ public class MapsContentController : ControllerBase
|
||||
return NotFound(new { message = $"地图文件不存在:{fileName}" });
|
||||
|
||||
var content = await System.IO.File.ReadAllTextAsync(fullPath, ct);
|
||||
// 不返回 fullPath:避免向前端泄露服务器目录结构。
|
||||
return Ok(new
|
||||
{
|
||||
name,
|
||||
fileName,
|
||||
path = fullPath,
|
||||
content
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace MiGu.Server.Controllers;
|
||||
/// <list type="bullet">
|
||||
/// <item><c>GET /api/wizard/options</c>:可选项目录(导航方式 / 模块 / 业务场景模板)。</item>
|
||||
/// <item><c>GET /api/wizard/profile</c>:回显当前部署画像(含由导航选型推导的激活场景 id)。</item>
|
||||
/// <item><c>PUT /api/wizard/profile</c>:保存并置 <c>Configured=true</c>。</item>
|
||||
/// <item><c>POST /api/wizard/reset</c>:把 <c>Configured</c> 置回 false 以重新引导(保留草稿)。</item>
|
||||
/// <item><c>PUT /api/wizard/profile</c>:保存并置 <c>Configured=true</c>(仅 Platform scope)。</item>
|
||||
/// <item><c>POST /api/wizard/reset</c>:把 <c>Configured</c> 置回 false 以重新引导(仅 Platform scope)。</item>
|
||||
/// </list>
|
||||
/// 说明:保存时即把选型固化为单一事实来源 <c>deployment</c> section,并同步联动 Launcher ——
|
||||
/// <see cref="SaveProfile"/> 调 <c>WriteActiveScenes</c> 写 <c>plugins/active-scenes.json</c> / 透传 <c>--scenes</c>,
|
||||
@@ -64,6 +64,7 @@ public class WizardController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPut("profile")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public IActionResult SaveProfile([FromBody] SaveWizardRequest req)
|
||||
{
|
||||
if (req == null)
|
||||
@@ -82,7 +83,10 @@ public class WizardController : ControllerBase
|
||||
|
||||
// 平台 → 内核联动:把导航选型写入 SimpleLite 的 plugins/active-scenes.json(下次启动选择性加载;
|
||||
// 已运行实例可由前端再调 POST /api/sl/projection/scenes/apply 触发增量 reload)。
|
||||
var sceneIds = profile.ToActiveSceneIds();
|
||||
// scene.device(门/充电桩/按钮盒驱动)为各类项目通用能力,向导暂无独立选项,固定并入激活集合;
|
||||
// scene.vda5050 等协议插件保持按需(不在集合则不加载)。基座 StandardScene.dll 由内核按
|
||||
// 清单 requiresCore 自动 alwaysLoad,无需在此声明。
|
||||
var sceneIds = profile.ToActiveSceneIds().Concat(new[] { "scene.device" }).Distinct().ToList();
|
||||
var write = _launcher.WriteActiveScenes(sceneIds, alwaysLoad: null, source: "deployment-profile");
|
||||
|
||||
_log.LogInformation("部署向导已保存 by={User} nav=[{Nav}] scenes=[{Scenes}] activeScenesWritten={Ok}",
|
||||
@@ -92,6 +96,7 @@ public class WizardController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("reset")]
|
||||
[Authorize(Policy = "PlatformScope")]
|
||||
public IActionResult Reset()
|
||||
{
|
||||
var reset = _store.GetDeployment() with { Configured = false };
|
||||
|
||||
@@ -50,13 +50,20 @@ public static class SimpleLiteBuildSync
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 探测 SimpleLite 是否带「前往站点」路由(区分新旧 DLL)。
|
||||
/// 必须用不存在的对象 id(-1):路由存在时后端进 handler 找不到对象,返回 JSON
|
||||
/// <c>success:false</c>(无任何副作用);路由不存在时 EmbedIO 返回 HTML 404。
|
||||
/// 早期版本误用 car/0 + siteId=0 —— 一旦场景里真有 id=0 的车和站点,每次健康
|
||||
/// 检查都会真实派车,属严重副作用,严禁回退到真实 id。
|
||||
/// </summary>
|
||||
public static bool? ProbeGotoSiteRoute(int port = 8222)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(2) };
|
||||
using var resp = client.PostAsync(
|
||||
$"http://127.0.0.1:{port}/projection/reflection/car/0/goto-site?siteId=0",
|
||||
$"http://127.0.0.1:{port}/projection/reflection/car/-1/goto-site?siteId=-1",
|
||||
null).GetAwaiter().GetResult();
|
||||
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
if (resp.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
|
||||
@@ -261,6 +261,9 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
private string BuildArguments(string displayMode, string extra)
|
||||
{
|
||||
var args = $"--display-mode={displayMode}";
|
||||
// 迷榖嵌入:平台 iframe 场景默认带 --migu,让 SimpleLite WebTerminal 默认纯画布(隐藏 ImGui panel),
|
||||
// 业务 UI 由 Vue 平台前端接管;declare 时序失败时也安全回退到画布。可用 appsettings:SimpleLite:EmbeddedCanvas=false 关闭。
|
||||
if (_opts.EmbeddedCanvas) args += " --migu";
|
||||
// 选择性加载:把平台写入的 plugins/active-scenes.json 同步透传为 --scenes(命令行优先级最高,与文件一致,双保险)。
|
||||
var sceneArg = ReadActiveScenesArg();
|
||||
if (!string.IsNullOrEmpty(sceneArg)) args += " " + sceneArg;
|
||||
@@ -392,12 +395,33 @@ public sealed class SimpleLiteLauncher : IDisposable
|
||||
return result;
|
||||
}
|
||||
|
||||
private bool? _gotoSiteProbeCache;
|
||||
private DateTimeOffset _gotoSiteProbeAt = DateTimeOffset.MinValue;
|
||||
private static readonly TimeSpan GotoSiteProbeTtl = TimeSpan.FromSeconds(60);
|
||||
|
||||
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
||||
public SimpleLiteDiagnostics GetDiagnostics()
|
||||
{
|
||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
||||
var gotoSite = projectionUp ? SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort) : null;
|
||||
// 探测结果缓存 60s:诊断端点可能被前端轮询,避免每次都对 SimpleLite 发探测请求。
|
||||
bool? gotoSite = null;
|
||||
if (projectionUp)
|
||||
{
|
||||
if (_gotoSiteProbeCache is bool cached && DateTimeOffset.UtcNow - _gotoSiteProbeAt < GotoSiteProbeTtl)
|
||||
{
|
||||
gotoSite = cached;
|
||||
}
|
||||
else
|
||||
{
|
||||
gotoSite = SimpleLiteBuildSync.ProbeGotoSiteRoute(_opts.ProjectionPort);
|
||||
if (gotoSite is not null)
|
||||
{
|
||||
_gotoSiteProbeCache = gotoSite;
|
||||
_gotoSiteProbeAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
var deployHint = gotoSite == false
|
||||
? "关闭 SimpleLite 后:运行 Migu2.0/scripts/redeploy-simplelite.ps1,或 POST /api/health/simplelite/restart-for-update"
|
||||
: null;
|
||||
|
||||
@@ -57,4 +57,15 @@ public sealed class SimpleLiteOptions
|
||||
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
||||
/// </summary>
|
||||
public bool FollowParent { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// 平台拉起 SimpleLite 时是否以「迷榖嵌入画布」模式运行(透传命令行 <c>--migu</c>),默认 <b>true</b>。
|
||||
///
|
||||
/// true:平台 iframe 嵌入场景,SimpleLite WebTerminal 默认只显示 3D 画布、隐藏所有 ImGui panel,
|
||||
/// 业务 UI 全部由 Vue 平台前端接管;declare 时序失败时也安全回退到纯画布。
|
||||
/// false:平台拉起的 SimpleLite web 端默认显示完整 panel(便于把平台拉起的实例直连 :8223 调试)。
|
||||
///
|
||||
/// 与 LaunchMode(web / web+local,是否保留本地调试窗口)正交,可任意组合。
|
||||
/// </summary>
|
||||
public bool EmbeddedCanvas { get; set; } = true;
|
||||
}
|
||||
|
||||
@@ -25,6 +25,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
|
||||
<Content Include="OpenApi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('OpenApi\simplelite-projection.json')" />
|
||||
<Content Include="..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json" Link="OpenApi\simplelite-projection.json" CopyToOutputDirectory="PreserveNewest" Condition="Exists('..\..\Simple\SimpleLite\Docs\openapi\simplelite-projection.json')" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MiGu.Server.Dashboard;
|
||||
using MiGu.Server.Wms;
|
||||
using MiGu.Server.SimpleFields;
|
||||
|
||||
namespace MiGu.Server.Persistence;
|
||||
|
||||
@@ -16,6 +18,8 @@ public sealed class PlatformDbContext : DbContext
|
||||
public DbSet<ContainerMaterial> ContainerMaterials => Set<ContainerMaterial>();
|
||||
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
|
||||
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
|
||||
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
||||
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -55,6 +59,46 @@ public sealed class PlatformDbContext : DbContext
|
||||
|
||||
modelBuilder.Entity<ContainerMaterial>().Property(x => x.Quantity).HasPrecision(18, 4);
|
||||
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
|
||||
|
||||
ConfigureSimpleField(modelBuilder);
|
||||
ConfigureUserDashboardShortcut(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
|
||||
{
|
||||
var e = modelBuilder.Entity<UserDashboardShortcut>();
|
||||
e.ToTable("user_dashboard_shortcuts");
|
||||
e.HasKey(x => new { x.UserId, x.Scope });
|
||||
e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
|
||||
e.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
|
||||
e.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
|
||||
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("O"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
e.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
|
||||
}
|
||||
|
||||
private static void ConfigureSimpleField(ModelBuilder modelBuilder)
|
||||
{
|
||||
var e = modelBuilder.Entity<SimpleField>();
|
||||
e.ToTable("simple_fields");
|
||||
e.HasKey(x => x.Id);
|
||||
e.Property(x => x.Id).HasColumnName("id");
|
||||
e.Property(x => x.CarType).HasColumnName("car_type").HasMaxLength(64);
|
||||
e.Property(x => x.FieldType).HasColumnName("field_type").HasMaxLength(64);
|
||||
e.Property(x => x.Key).HasColumnName("key").HasMaxLength(128);
|
||||
e.Property(x => x.Value).HasColumnName("value");
|
||||
e.Property(x => x.DataType).HasColumnName("data_type").HasMaxLength(128);
|
||||
e.Property(x => x.Chinese).HasColumnName("chinese").HasMaxLength(256).IsRequired(false);
|
||||
e.Property(x => x.English).HasColumnName("english").HasMaxLength(256).IsRequired(false);
|
||||
e.Property(x => x.Other).HasColumnName("other").HasMaxLength(512);
|
||||
e.Property(x => x.IsDefault).HasColumnName("is_default");
|
||||
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||
v => SimpleFieldDateTime.ToStorage(v),
|
||||
v => SimpleFieldDateTime.FromStorage(v));
|
||||
e.Property(x => x.CreateTime).HasColumnName("create_time").HasConversion(dateTime).HasMaxLength(19);
|
||||
e.Property(x => x.UpdateTime).HasColumnName("update_time").HasConversion(dateTime).HasMaxLength(19);
|
||||
e.HasIndex(x => new { x.CarType, x.FieldType, x.Key }).IsUnique();
|
||||
}
|
||||
|
||||
public override int SaveChanges(bool acceptAllChangesOnSuccess)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using MiGu.Server.Wms;
|
||||
using MiGu.Server.SimpleFields;
|
||||
|
||||
namespace MiGu.Server.Persistence;
|
||||
|
||||
@@ -38,6 +40,8 @@ public static class PlatformPersistence
|
||||
|
||||
services.AddScoped<WmsReferenceValidator>();
|
||||
services.AddScoped<WmsService>();
|
||||
services.AddScoped<SimpleFieldService>();
|
||||
services.AddScoped<MiGu.Server.Dashboard.DashboardShortcutService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -46,6 +50,122 @@ public static class PlatformPersistence
|
||||
using var scope = services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<PlatformDbContext>();
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
|
||||
await EnsureSimpleFieldsTableAsync(db);
|
||||
await EnsureUserDashboardShortcutsTableAsync(db);
|
||||
}
|
||||
|
||||
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
|
||||
private static async Task EnsureSimpleFieldsTableAsync(PlatformDbContext db)
|
||||
{
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS simple_fields (
|
||||
id TEXT NOT NULL CONSTRAINT PK_simple_fields PRIMARY KEY,
|
||||
car_type TEXT NOT NULL DEFAULT '',
|
||||
field_type TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
data_type TEXT NOT NULL DEFAULT '',
|
||||
chinese TEXT,
|
||||
english TEXT,
|
||||
other TEXT NOT NULL DEFAULT '',
|
||||
is_default INTEGER NOT NULL,
|
||||
create_time TEXT NOT NULL,
|
||||
update_time TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
// 须先删旧索引 (field_type, other, key):把 other 清空为「其他语言」后会与旧唯一约束冲突。
|
||||
await db.Database.ExecuteSqlRawAsync("DROP INDEX IF EXISTS IX_simple_fields_field_type_other_key;");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
UPDATE simple_fields SET car_type = other
|
||||
WHERE (car_type IS NULL OR car_type = '') AND other <> '';
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
UPDATE simple_fields SET other = ''
|
||||
WHERE other <> '' AND other = car_type;
|
||||
""");
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS IX_simple_fields_car_type_field_type_key
|
||||
ON simple_fields (car_type, field_type, "key");
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
// 非 SQLite:表不存在时尝试按当前模型创建(已有库不会走 EnsureCreated)。
|
||||
if (!await TableExistsAsync(db, "simple_fields"))
|
||||
{
|
||||
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||
await creator.CreateTablesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。</summary>
|
||||
private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db)
|
||||
{
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts (
|
||||
user_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
keys_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL,
|
||||
CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope)
|
||||
);
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await TableExistsAsync(db, "user_dashboard_shortcuts"))
|
||||
{
|
||||
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||
await creator.CreateTablesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查表是否存在
|
||||
/// </summary>
|
||||
/// <param name="db">数据库上下文</param>
|
||||
/// <param name="table">表名</param>
|
||||
/// <returns>表是否存在</returns>
|
||||
private static async Task<bool> TableExistsAsync(PlatformDbContext db, string table)
|
||||
{
|
||||
var conn = db.Database.GetDbConnection();
|
||||
if (conn.State != System.Data.ConnectionState.Open)
|
||||
await conn.OpenAsync();
|
||||
try
|
||||
{
|
||||
await using var cmd = conn.CreateCommand();
|
||||
if (db.Database.IsSqlServer())
|
||||
{
|
||||
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = @t";
|
||||
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||
}
|
||||
else if (db.Database.IsNpgsql())
|
||||
{
|
||||
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_name = @t";
|
||||
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||
}
|
||||
else if (db.Database.IsMySql())
|
||||
{
|
||||
cmd.CommandText = "SELECT 1 FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @t";
|
||||
var p = cmd.CreateParameter(); p.ParameterName = "@t"; p.Value = table; cmd.Parameters.Add(p);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var result = await cmd.ExecuteScalarAsync();
|
||||
return result != null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (conn.State == System.Data.ConnectionState.Open)
|
||||
await conn.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveConnectionString(IConfiguration configuration, IWebHostEnvironment env, string provider)
|
||||
|
||||
+15
-15
@@ -6,6 +6,7 @@ using Microsoft.OpenApi.Models;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using MiGu.Server.OpenApi;
|
||||
using MiGu.Server.Persistence;
|
||||
using Yarp.ReverseProxy.Transforms;
|
||||
|
||||
@@ -98,7 +99,13 @@ builder.Services.AddControllers()
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" });
|
||||
c.SwaggerDoc("v1", new()
|
||||
{
|
||||
Title = "MiGu.Server + SimpleLite",
|
||||
Version = "v1",
|
||||
Description = "咪咕平台后端 API,以及经 YARP 反代的 SimpleLite 数据 WebApi(标签 SimpleLite/*)。详见 Simple/SimpleLite/Docs/MIGU-API.md。"
|
||||
});
|
||||
c.DocumentFilter<SimpleLiteOpenApiDocumentFilter>();
|
||||
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
||||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
@@ -150,15 +157,6 @@ builder.Services.AddSingleton<InternalTokenStore>();
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(opt =>
|
||||
{
|
||||
// TokenValidationParameters 在第一次解析请求时从 JwtIssuer 拿,避免 ctor 顺序耦合。
|
||||
opt.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||
{
|
||||
// 完整参数在 OnMessageReceived 里替换为 JwtIssuer.BuildValidationParameters()
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateIssuerSigningKey = false,
|
||||
ValidateLifetime = false,
|
||||
};
|
||||
opt.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx =>
|
||||
@@ -169,13 +167,14 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
var cookie = ctx.Request.Cookies["simple.auth.token"];
|
||||
if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie;
|
||||
}
|
||||
// 用真实 JwtIssuer 参数替换占位 ValidationParameters。
|
||||
var issuer = ctx.HttpContext.RequestServices.GetRequiredService<JwtIssuer>();
|
||||
ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
// TokenValidationParameters 由 JwtIssuer(DI 单例)启动期一次性提供,
|
||||
// 替代旧的「每请求在 OnMessageReceived 里改写共享 Options」写法(并发坏味道)。
|
||||
builder.Services.AddOptions<JwtBearerOptions>(JwtBearerDefaults.AuthenticationScheme)
|
||||
.Configure<JwtIssuer>((opt, issuer) => opt.TokenValidationParameters = issuer.BuildValidationParameters());
|
||||
|
||||
builder.Services.AddAuthorization(opts =>
|
||||
{
|
||||
@@ -200,8 +199,9 @@ builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||
.AddTransforms(tctx =>
|
||||
{
|
||||
// 只对 sl-route 注入 internal token;vrender-route(webVRender iframe 静态资源)不需要。
|
||||
if (tctx.Route.RouteId != "sl-route") return;
|
||||
// 对全部 sl-* 路由(兜底 + map-edit/ai-config/reflection 管理面拆分路由)注入
|
||||
// internal token;vrender-route(webVRender iframe 静态资源)不需要。
|
||||
if (!tctx.Route.RouteId.StartsWith("sl-", StringComparison.Ordinal)) return;
|
||||
tctx.AddRequestTransform(rt =>
|
||||
{
|
||||
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
||||
|
||||
@@ -44,8 +44,48 @@
|
||||
"Dispatch": {
|
||||
}
|
||||
},
|
||||
"_comment_ReverseProxy": "sl-route 兜底 AnyAuthed(投影只读 + SSE)。管理面路径(map-edit / ai-config / reflection 写操作)单独拆路由挂 PlatformScope,防止运营账号经反代直达地图编辑与任意反射调用。",
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
"sl-mapedit-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "PlatformScope",
|
||||
"Order": -2,
|
||||
"Match": { "Path": "/api/sl/projection/map-edit/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"sl-aiconfig-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "PlatformScope",
|
||||
"Order": -2,
|
||||
"Match": { "Path": "/api/sl/projection/ai-config/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"sl-reflection-selection-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "AnyAuthed",
|
||||
"Order": -3,
|
||||
"Match": { "Path": "/api/sl/projection/reflection/selection/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"sl-reflection-write-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "PlatformScope",
|
||||
"Order": -1,
|
||||
"Match": {
|
||||
"Path": "/api/sl/projection/reflection/{**catch-all}",
|
||||
"Methods": [ "POST", "PUT", "PATCH", "DELETE" ]
|
||||
},
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"sl-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"AuthorizationPolicy": "AnyAuthed",
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user