Files
wei.wu 1f72488a8d 新增车辆表及OTA权限与逻辑优化
新增车辆任务与报警表,完善实体与DbContext配置。细化OTA权限校验,增强回传会话IP安全。优化OTA上传与设置面板,调度器支持重启恢复。报警采集逻辑支持历史分段。
2026-07-27 15:12:14 +08:00

386 lines
13 KiB
C#

using System.Text.Json;
using MiGu.Server.Infra;
using Microsoft.Extensions.Options;
namespace MiGu.Server.Ota;
public sealed class OtaStore
{
private readonly object _gate = new();
private readonly ILogger<OtaStore> _log;
private readonly JsonSerializerOptions _json = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true
};
public string Root { get; }
public string PackagesDir { get; }
public string JobsDir { get; }
public string HistoryDir { get; }
public string TargetFile { get; }
public string SettingsFile { get; }
private string? _lastPullId;
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, string> _pullByIp =
new(StringComparer.OrdinalIgnoreCase);
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _pullLastReceiveUtc =
new(StringComparer.OrdinalIgnoreCase);
private long _jobSeq;
public OtaStore(IWebHostEnvironment env, IOptions<OtaOptions> options, ILogger<OtaStore> log)
{
_log = log;
var cfg = options.Value.DataRoot;
Root = Path.IsPathRooted(cfg) ? cfg : Path.Combine(env.ContentRootPath, cfg);
PackagesDir = Path.Combine(Root, "packages");
JobsDir = Path.Combine(Root, "jobs");
HistoryDir = Path.Combine(Root, "history");
TargetFile = Path.Combine(Root, "target.json");
SettingsFile = Path.Combine(Root, "settings.json");
Directory.CreateDirectory(PackagesDir);
Directory.CreateDirectory(JobsDir);
Directory.CreateDirectory(HistoryDir);
}
public OtaSettings GetSettings()
{
lock (_gate)
{
if (!File.Exists(SettingsFile)) return new OtaSettings();
try
{
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
}
catch (Exception ex)
{
_log.LogWarning(ex, "OTA settings load failed");
return new OtaSettings();
}
}
}
public OtaSettings SaveSettings(OtaSettings settings)
{
lock (_gate)
{
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
return settings;
}
}
public OtaTarget? GetTarget()
{
lock (_gate)
{
if (!File.Exists(TargetFile)) return null;
try
{
return JsonSerializer.Deserialize<OtaTarget>(File.ReadAllText(TargetFile), _json);
}
catch (Exception ex)
{
_log.LogWarning(ex, "OTA target load failed");
return null;
}
}
}
public void SetTarget(OtaTarget target)
{
lock (_gate)
{
AtomicFile.WriteAllText(TargetFile, JsonSerializer.Serialize(target, _json));
var settings = GetSettingsUnlocked();
if (!string.IsNullOrWhiteSpace(target.Name))
{
settings.NewVersionName = target.Name;
AtomicFile.WriteAllText(SettingsFile, JsonSerializer.Serialize(settings, _json));
}
}
}
private OtaSettings GetSettingsUnlocked()
{
if (!File.Exists(SettingsFile)) return new OtaSettings();
try
{
return JsonSerializer.Deserialize<OtaSettings>(File.ReadAllText(SettingsFile), _json) ?? new OtaSettings();
}
catch
{
return new OtaSettings();
}
}
public string BeginPullPackage(string? sourceIp)
{
lock (_gate)
{
var baseId = $"{DateTime.UtcNow:yyyyMMddHHmmssfff}({SafeIdPart(sourceIp ?? "upload")})";
var id = baseId;
var dir = Path.Combine(PackagesDir, id);
for (var i = 1; Directory.Exists(dir); i++)
{
id = $"{baseId}-{i:D2}";
dir = Path.Combine(PackagesDir, id);
}
Directory.CreateDirectory(dir);
foreach (var app in new[] { "M", "D", "C" })
Directory.CreateDirectory(Path.Combine(dir, app));
Directory.CreateDirectory(Path.Combine(dir, "M", "plugins"));
var ip = NormalizeIp(sourceIp);
if (ip != null) _pullByIp[ip] = id;
_lastPullId = id;
return id;
}
}
public string? ActivePullId
{
get { lock (_gate) return _lastPullId; }
}
public bool TryGetActivePullId(string? sourceIp, out string? id)
{
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
// 必须匹配会话 IP;禁止无 IP 时回退到最近一次拉包(可被伪造/误写)。
if (ip != null && _pullByIp.TryGetValue(ip, out var byIp))
{
id = byIp;
return true;
}
id = null;
return false;
}
}
public void NotePullReceive(string? sourceIp)
{
var ip = NormalizeIp(sourceIp);
if (ip == null) return;
_pullLastReceiveUtc[ip] = DateTime.UtcNow;
}
public bool TryGetLastReceiveAt(string? sourceIp, out DateTime utc)
{
var ip = NormalizeIp(sourceIp);
if (ip != null && _pullLastReceiveUtc.TryGetValue(ip, out utc))
return true;
utc = default;
return false;
}
public void ClearActivePull(string? sourceIp = null)
{
lock (_gate)
{
var ip = NormalizeIp(sourceIp);
if (ip != null)
{
_pullByIp.TryRemove(ip, out _);
_pullLastReceiveUtc.TryRemove(ip, out _);
}
if (_pullByIp.IsEmpty) _lastPullId = null;
}
}
/// <summary>把 WatchDog 回传连接的远端 IP 归一(去掉 IPv6 映射前缀,如 ::ffff:192.168.1.13)。</summary>
private static string? NormalizeIp(string? ip)
{
if (string.IsNullOrWhiteSpace(ip)) return null;
if (System.Net.IPAddress.TryParse(ip, out var addr))
return (addr.IsIPv4MappedToIPv6 ? addr.MapToIPv4() : addr).ToString();
return ip.Trim();
}
public string ResolveReceivePath(string routeKey, string? clientIp = null)
{
// routeKey examples: Medullaexe, Medulladll, Detourexe, ClumsyConsoleexe, ClumsyConsoledll
// Do not fall back to the latest session for a known-but-unregistered client.
if (!TryGetActivePullId(clientIp, out var id) || id == null)
throw new InvalidOperationException("无进行中的拉取会话");
var dir = Path.Combine(PackagesDir, id);
return routeKey.ToLowerInvariant() switch
{
"medullaexe" => Path.Combine(dir, "M", "Medulla.exe"),
"medulladll" => Path.Combine(dir, "M", "plugins", "CartActivator.dll"),
"medullapdb" => Path.Combine(dir, "M", "plugins", "CartActivator.pdb"),
"detourexe" => Path.Combine(dir, "D", "Detour.exe"),
"clumsyconsoleexe" or "clumsyexe" => Path.Combine(dir, "C", "ClumsyConsole.exe"),
"clumsyconsoledll" or "clumsydll" => Path.Combine(dir, "C", "FG2305014_C.dll"),
"clumsyconsolepdb" or "clumsypdb" => Path.Combine(dir, "C", "FG2305014_C.pdb"),
_ => throw new ArgumentException($"未知接收路由: {routeKey}")
};
}
public OtaPackageInfo ScanPackage(string id)
{
var dir = ResolveUnder(PackagesDir, id);
if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(id);
var info = new OtaPackageInfo
{
Id = id,
CreatedAt = Directory.GetCreationTimeUtc(dir),
SourceIp = ExtractSourceIp(id)
};
long total = 0;
foreach (var key in OtaPathMap.ComponentKeys)
{
var rel = OtaPathMap.RelPathFor(key);
if (rel == null) continue;
var path = Path.Combine(dir, rel.Replace('/', Path.DirectorySeparatorChar));
if (!File.Exists(path)) continue;
var fi = new FileInfo(path);
total += fi.Length;
info.Components[key] = new OtaFileArtifact
{
Hash = OtaHash.OfFile(path),
Path = path,
FileName = Path.GetFileName(path),
Time = fi.LastWriteTime.ToString("yyyy-MM-dd HH:mm:ss"),
Size = fi.Length
};
}
info.TotalBytes = total;
var target = GetTarget();
info.IsTarget = target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal);
return info;
}
public List<OtaPackageInfo> ListPackages()
{
if (!Directory.Exists(PackagesDir)) return new();
var list = new List<OtaPackageInfo>();
foreach (var dir in Directory.GetDirectories(PackagesDir).OrderByDescending(d => d))
{
try { list.Add(ScanPackage(Path.GetFileName(dir))); }
catch (Exception ex) { _log.LogDebug(ex, "skip package {Dir}", dir); }
}
return list;
}
public void DeletePackage(string id)
{
lock (_gate)
{
var target = GetTarget();
if (target != null && string.Equals(target.PackageId, id, StringComparison.Ordinal))
throw new InvalidOperationException("不能删除当前目标版本");
var dir = ResolveUnder(PackagesDir, id);
if (Directory.Exists(dir)) Directory.Delete(dir, true);
}
}
public OtaTarget ActivatePackage(string id, string? name)
{
var pkg = ScanPackage(id);
if (pkg.Components.Count == 0)
throw new InvalidOperationException("包内无有效组件文件");
var safeId = RequireSafeId(id);
var target = new OtaTarget
{
PackageId = safeId,
Name = name ?? safeId,
ActivatedAt = DateTimeOffset.UtcNow,
Components = pkg.Components
};
SetTarget(target);
return target;
}
public string PackageDir(string id) => ResolveUnder(PackagesDir, id);
public void SaveJob(OtaJob job)
{
lock (_gate)
{
var path = ResolveUnder(JobsDir, $"{RequireSafeId(job.Id)}.json");
AtomicFile.WriteAllText(path, JsonSerializer.Serialize(job, _json));
}
}
public OtaJob? GetJob(string id)
{
var path = ResolveUnder(JobsDir, $"{RequireSafeId(id)}.json");
if (!File.Exists(path)) return null;
try
{
return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(path), _json);
}
catch (Exception ex)
{
_log.LogWarning(ex, "job load failed {Id}", id);
return null;
}
}
public List<OtaJob> ListJobs(int take = 100)
{
if (!Directory.Exists(JobsDir)) return new();
return Directory.GetFiles(JobsDir, "*.json")
.Select(f =>
{
try { return JsonSerializer.Deserialize<OtaJob>(File.ReadAllText(f), _json); }
catch { return null; }
})
.Where(j => j != null)
.Cast<OtaJob>()
.OrderByDescending(j => j.CreatedAt)
.Take(take)
.ToList();
}
public string NextJobId()
{
// 进程内自增序号保证唯一(同秒也不会撞 ID → 不会两个 job 写同一文件)。
var seq = System.Threading.Interlocked.Increment(ref _jobSeq);
return $"J{DateTime.UtcNow:yyyyMMddHHmmss}-{seq:D4}";
}
private static string? ExtractSourceIp(string id)
{
var open = id.IndexOf('(');
var close = id.IndexOf(')');
if (open >= 0 && close > open) return id[(open + 1)..close];
return null;
}
private static string SafeIdPart(string raw)
{
var safe = raw.Trim();
foreach (var ch in Path.GetInvalidFileNameChars())
safe = safe.Replace(ch, '_');
return string.IsNullOrWhiteSpace(safe) ? "upload" : safe;
}
/// <summary>拒绝路径段(含 .. / 分隔符),只允许单层文件名。</summary>
private static string RequireSafeId(string id)
{
if (string.IsNullOrWhiteSpace(id))
throw new ArgumentException("无效标识");
var trimmed = id.Trim();
if (trimmed is "." or ".."
|| trimmed.Contains('/') || trimmed.Contains('\\')
|| trimmed.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
throw new ArgumentException("无效标识");
return trimmed;
}
private string ResolveUnder(string root, string id)
{
var safe = RequireSafeId(id);
var fullRoot = Path.GetFullPath(root);
var full = Path.GetFullPath(Path.Combine(fullRoot, safe));
var prefix = fullRoot.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
+ Path.DirectorySeparatorChar;
if (!full.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(full, fullRoot, StringComparison.OrdinalIgnoreCase))
throw new ArgumentException("无效标识");
return full;
}
}