using System.ComponentModel; using System.Runtime.CompilerServices; using System.Text; using Avalonia.Threading; using ClassHelper.ClassIslandPlugin.Interop; using ClassHelper.ClassIslandPlugin.Models; using ClassIsland.Core; using ClassIsland.Core.Abstractions.Services; using ClassIsland.Core.Models.Notification; using ClassIsland.Shared; using Microsoft.Extensions.Logging; namespace ClassHelper.ClassIslandPlugin.Services; /// /// 联动主循环。三件事: /// 1. 上报:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒, /// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper; /// 2. 接收:每次上报的返回值里带一条"尚未确认的下发提醒", /// 交给 在 ClassIsland 上弹出; /// 3. 镜像:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。 /// /// 为什么用"上报即拉取"而不是长连接: /// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题), /// 而教室机器的上报间隔本身就是心跳 —— 顺路把待提醒带回来,依赖更少、故障面更小。 /// /// 线程约定:HttpClient 回调在线程池上,因此所有跨线程可见的状态都用 lock 或 /// 赋值引用(string / int)保护,属性变化通过 通知设置页。 /// public sealed class BridgeService : INotifyPropertyChanged, IDisposable { private readonly PluginSettings _settings; private readonly ILogger _logger; private readonly ClassHelperClient _client = new(); private readonly object _gate = new(); private ILessonsService? _lessons; private Timer? _timer; private Timer? _notifyTimer; private bool _disposed; private bool _reporting; private bool _pulling; private bool _eventsHooked; /// /// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘, /// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。 /// private volatile string? _lastMirrorSignature; public BridgeService(PluginSettings settings, ILogger logger) { _settings = settings; _logger = logger; } public event PropertyChangedEventHandler? PropertyChanged; /// 上报结果变化时触发(设置页据此刷新)。 public event EventHandler? StateChanged; /// 需要把提醒丢给提醒提供方去显示。 public event EventHandler? NotificationReceived; /* ---------------------------------------------------------------- 供设置页绑定的状态 */ private string _lastReportMessage = "尚未上报"; /// 最近一次上报的结果描述。 public string LastReportMessage { get => _lastReportMessage; private set => SetField(ref _lastReportMessage, value); } private bool _lastReportOk; public bool LastReportOk { get => _lastReportOk; private set => SetField(ref _lastReportOk, value); } private DateTimeOffset? _lastReportAt; public DateTimeOffset? LastReportAt { get => _lastReportAt; private set { if (SetField(ref _lastReportAt, value)) Raise(nameof(LastReportAtText)); } } /// 供界面直接显示的"最后上报时间"。 public string LastReportAtText => LastReportAt is null ? "从未" : LastReportAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss"); private int _lastScheduleEntryCount; /// 最近一次上报里的课表条目数。 public int LastScheduleEntryCount { get => _lastScheduleEntryCount; private set => SetField(ref _lastScheduleEntryCount, value); } private int _lastTimeLayoutItemCount; /// 最近一次上报里的节次数量。 public int LastTimeLayoutItemCount { get => _lastTimeLayoutItemCount; private set => SetField(ref _lastTimeLayoutItemCount, value); } private int? _lastServerWeek; /// 服务端返回的教学周。 public int? LastServerWeek { get => _lastServerWeek; private set { if (SetField(ref _lastServerWeek, value)) Raise(nameof(ClientTimeOffsetText)); } } private int _notificationCount; /// 已弹出并回执的提醒数量。 public int NotificationCount { get => _notificationCount; private set => SetField(ref _notificationCount, value); } private bool _busy; /// 是否正在上报("立即上报"按钮据此禁用)。 public bool Busy { get => _busy; private set => SetField(ref _busy, value); } private TimeSpan? _serverTimeOffset; /// 服务端与本机的时钟差(诊断用:差得多说明教室机器时间不对)。 public string ClientTimeOffsetText => _serverTimeOffset is null ? "—" : $"{_serverTimeOffset.Value.TotalSeconds:+0;-0;0} 秒"; /// 当前配置是否完整(设置页据此提示"还没填令牌")。 public bool IsConfigured => _settings.IsConfigured; /// 是否已接入课程服务(拿到 ILessonsService 才算真正跑起来)。 public bool IsAttached { get { lock (_gate) return _lessons is not null; } } /* ---------------------------------------------------------------- 生命周期 */ /// 接入课程服务并订阅课程事件(由插件入口在 AppStarted 时调用)。 public void Attach(ILessonsService lessons) { lock (_gate) { _lessons = lessons; } if (!_eventsHooked) { // 上课 / 下课 / 放学 / 状态变化时立刻上报一次,不等到下一个定时点。 // 这些事件在 ClassIsland 的公开约定里是在**主线程**触发的,可以直接订阅。 lessons.OnClass += (_, _) => RequestReport("上课"); lessons.OnBreakingTime += (_, _) => RequestReport("下课"); lessons.OnAfterSchool += (_, _) => RequestReport("放学"); lessons.CurrentTimeStateChanged += (_, _) => RequestReport("状态变化"); _eventsHooked = true; } Raise(nameof(IsAttached)); _logger.LogInformation("ClassHelper 联动:已订阅课程事件"); RequestReport("应用启动"); } /// 按设置重建定时器(设置页改间隔后调用)。 public void RestartTimer() { lock (_gate) { _timer?.Dispose(); _timer = null; _notifyTimer?.Dispose(); _notifyTimer = null; if (!_settings.IsConfigured) return; if (_settings.AutoReport) { var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.ReportIntervalSeconds, 15, 3600)); _timer = new Timer(_ => RequestReport("定时"), null, interval, interval); } // 提醒单独用一个轻量轮询(只查待提醒,不上传课表): // 老师按下发送到教室弹出之间的延迟就是这个间隔,因此默认 10 秒,不跟随上报间隔。 if (_settings.ReceiveNotifications) { var poll = TimeSpan.FromSeconds(Math.Clamp(_settings.NotificationPollSeconds, 5, 600)); _notifyTimer = new Timer(_ => _ = PullPendingAsync(), null, poll, poll); } } } /// 发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。 public void RequestReport(string reason) { _ = ReportAsync(reason); } /// /// 执行一次上报并返回是否真的发出去了。 /// 为 true 时带上全量课表与节次时间。 /// public async Task ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false) { if (_disposed) return false; ILessonsService? lessons; lock (_gate) lessons = _lessons; if (lessons is null) return false; if (!_settings.IsConfigured) { SetState(false, "尚未配置服务器地址或设备令牌", 0, 0, null); return false; } lock (_gate) { if (_reporting) return false; _reporting = true; } Busy = true; try { var profile = ResolveProfile(); var request = new ReportRequestDto { PluginVersion = PluginVersion, ClassIslandVersion = SafeAppVersion(), // 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上 DeviceKey = _settings.DeviceKey, State = BuildState(lessons), }; var entryCount = 0; var layoutCount = 0; // 开关关着时也要留下原因:否则日志只有"上报成功:仅状态", // 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。 // forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求, // 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested) var uploadSchedule = forceSchedule || _settings.UploadSchedule; if (includeSchedule && !uploadSchedule) { _logger.LogInformation( "ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" + "需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」"); } if (includeSchedule && uploadSchedule && profile is not null) { var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings); foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning); entryCount = entries.Count; request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries }; // 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**: // 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。 if (entries.Count == 0) { request.Schedule = null; _logger.LogWarning( "ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" + "请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份"); } var layout = ResolvePrimaryTimeLayout(profile); if (layout is not null) { var items = ScheduleMapper.MapTimeLayout(layout); layoutCount = items.Count; request.TimeLayout = new TimeLayoutPayloadDto { Name = ScheduleMapper.Truncate( string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name, ScheduleMapper.PlanNameMaxLength)!, Mode = "replace", Items = items, }; } } var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None) .ConfigureAwait(false); if (!result.Ok || data is null) { _logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message); SetState(false, result.Message, entryCount, layoutCount, null); return false; } LastServerWeek = data.Week; UpdateClockOffset(data.ServerTime); if (data.ScheduleApplied) { _logger.LogInformation( "ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})", data.ScheduleCreated, data.ScheduleUpdated); } var detail = entryCount > 0 ? $"课表 {entryCount} 节 / 节次 {layoutCount} 条" : uploadSchedule ? "仅状态(ClassIsland 里还没有启用的课表)" : "仅状态(本机「上报课表到 ClassHelper」开关已关闭)"; SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周", entryCount, layoutCount, data.Week); // 顺路把"还没弹出过的"提醒带回来 var pending = data.PendingNotification; if (pending is not null && _settings.ReceiveNotifications) { NotificationReceived?.Invoke(this, pending); } // 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。 // 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。 if (data.Settings?.ScheduleRequested == true && !uploadSchedule) { _logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…"); _ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true); } if (_settings.MirrorSchedule) { await MirrorScheduleAsync().ConfigureAwait(false); } return true; } catch (Exception exception) { // ReportAsync 是通过 `_ = ReportAsync(reason)` 这种 fire-and-forget 方式发起的 // (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的 // "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有, // 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。 _logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason); SetState(false, $"上报异常:{exception.Message}", 0, 0, null); return false; } finally { Busy = false; lock (_gate) _reporting = false; } } /// /// 拉取本班尚未弹出过的提醒并逐条交给提醒提供方(用于"应用刚起来还没到第一个上报点"的场景)。 /// public async Task PullPendingAsync() { if (_disposed) return; if (!_settings.IsConfigured || !_settings.ReceiveNotifications) return; // 轮询与"上报顺路拉取"可能同时到达:同一时刻只放一个请求过去 lock (_gate) { if (_pulling) return; _pulling = true; } try { var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false); if (!result.Ok) { _logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message); return; } foreach (var item in items) { _logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id); NotificationReceived?.Invoke(this, item); } } finally { lock (_gate) _pulling = false; } } /// 把服务端的一条提醒标记为已弹出。 public async Task AckAsync(string id) { var result = await _client.AckAsync(_settings, id, CancellationToken.None).ConfigureAwait(false); if (result.Ok) { NotificationCount += 1; _logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id); } else { // 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次 _logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message); } } /// 把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。 public async Task MirrorScheduleAsync() { var profile = ResolveProfile(); if (profile is null) return; var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false); if (!result.Ok || plan is null) { // 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开 _logger.LogInformation( "ClassHelper 联动:跳过课表镜像({Message})。" + "需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期", result.Message); return; } // 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要 // 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。 var signature = MirrorSignature(plan); if (signature == _lastMirrorSignature) { _logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像"); return; } try { // **必须在 UI 线程上改写档案**:ClassIsland 的档案是 ObservableDictionary / // ObservableCollection,落盘是"属性变化即整体序列化 + FileStream 就地截断"。 // 而本方法是从线程池线程继续执行的(ReportAsync 里 ConfigureAwait(false) 之后, // 定时器回调本就在线程池上),在线程池上改集合会与 UI 线程的保存/绑定并发: // 轻则 InvalidOperationException(Collection was modified),重则两次写入互相截断, // **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。 var outcome = await Dispatcher.UIThread.InvokeAsync(() => { var applied = ClassPlanWriter.Apply(profile, plan); if (!applied.Applied) { return applied; } // 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘; // 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。 IAppHost.TryGetService()?.SaveProfile(); return applied; }); if (!outcome.Applied) { _logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})", outcome.Warnings.FirstOrDefault() ?? "服务端课表为空"); return; } _lastMirrorSignature = signature; _logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})", string.Join("、", outcome.PlanNames.Select(name => $"「{name}」"))); foreach (var warning in outcome.Warnings) { _logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning); } } catch (Exception exception) { _logger.LogError(exception, "ClassHelper 联动:课表镜像失败"); } } /// 镜像载荷的内容指纹(只用于"内容没变就跳过",不参与任何业务判断) private static string MirrorSignature(ClassPlanMirrorDto plan) { var builder = new StringBuilder(); foreach (var entry in plan.Entries) { builder.Append(entry.WeekDay).Append('|').Append(entry.WeekCountDiv).Append('|') .Append(entry.WeekCountDivTotal).Append('|').Append(entry.Subject).Append('|') .Append(entry.StartTime).Append('|').Append(entry.EndTime).Append(';'); } foreach (var layout in plan.TimeLayouts) { builder.Append('#').Append(layout.Id).Append(':'); foreach (var item in layout.Layouts) { builder.Append(item.StartTime).Append('-').Append(item.EndTime).Append('@') .Append(item.TimeType).Append(','); } } return builder.ToString(); } /* ---------------------------------------------------------------- 内部实现 */ /// 插件版本(与 manifest.yml / csproj 保持一致)。 private const string PluginVersion = "1.3.0.0"; /// ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。 private static string SafeAppVersion() { try { return AppBase.AppVersion ?? ""; } catch { return ""; } } /// /// 取得当前状态快照。 /// CurrentTimeLayoutItem 在"没有课表"时是 , /// 语义上等价于"没有节次",因此要转成 null 而不是 00:00。 /// private StateDto BuildState(ILessonsService lessons) { try { return ScheduleMapper.BuildState(lessons, LastServerWeek); } catch (Exception exception) { // 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报 _logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳"); return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") }; } } /// /// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。 /// /// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表, /// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去, /// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。 /// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。 /// private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout( ClassIsland.Shared.Models.Profile.Profile profile) { if (profile.TimeLayouts.Count == 0) return null; var best = profile.TimeLayouts .Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0))) .Where(item => item.ClassPoints > 0) .OrderByDescending(item => item.ClassPoints) .ToList(); if (best.Count > 0) return best[0].Value; // 兜底:档案里被激活的时间表,最后才是任意一个 return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated) ?? profile.TimeLayouts.Values.First(); } private static ClassIsland.Shared.Models.Profile.ClassPlan? CurrentClassPlan( ClassIsland.Shared.Models.Profile.Profile profile) { // 用课程服务拿"今天该上哪张课表"最准 try { var lessons = IAppHost.TryGetService(); return lessons?.CurrentClassPlan; } catch { return null; } } private ClassIsland.Shared.Models.Profile.Profile? ResolveProfile() { try { // 档案由 ProfileService 持有;这里用 TryGetService 避免服务未注册时抛异常 return IAppHost.TryGetService()?.Profile; } catch (Exception exception) { _logger.LogWarning(exception, "ClassHelper 联动:读取档案失败"); return null; } } /// 用服务端时间校准本机时钟差(只影响设置页展示,不改系统时间)。 private void UpdateClockOffset(string serverTime) { if (!DateTimeOffset.TryParse(serverTime, out var parsed)) return; _serverTimeOffset = parsed - DateTimeOffset.Now; Raise(nameof(ClientTimeOffsetText)); } private void SetState(bool ok, string message, int entryCount, int layoutCount, int? week) { LastReportOk = ok; LastReportMessage = message; LastReportAt = DateTimeOffset.Now; LastScheduleEntryCount = entryCount; LastTimeLayoutItemCount = layoutCount; if (week.HasValue) LastServerWeek = week; StateChanged?.Invoke(this, EventArgs.Empty); } private void Raise(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); private bool SetField(ref T field, T value, [CallerMemberName] string? propertyName = null) { if (EqualityComparer.Default.Equals(field, value)) return false; field = value; Raise(propertyName!); return true; } public void Dispose() { _disposed = true; lock (_gate) { _timer?.Dispose(); _timer = null; _notifyTimer?.Dispose(); _notifyTimer = null; } _client.Dispose(); } }