Explorar el Código

G3-3 M8 操作日志配置集中下发(§10 热生效)+ 本地兜底补送(§11) C# 端收官

- §10 配置热生效:OperationLogOptions 加 ConfigFilePath/ConfigReloadSeconds
  + ApplyConfigJson(容错解析 enabled/globalLevel/模块级开关);新增
  OperationLogConfigWatcher 后台轮询配置文件(大小/写时间变即热应用,全 try
  兜底),运维下发覆盖该路径即生效不重编。
- §11 本地兜底补送:KafkaOplogTransport 加 onDeliveryFailed(投递失败/队列满
  整条 JSON 落兜底);LocalFileWriter 加补送原语(List/原子认领 .resending/
  ReadLines/Delete);Pipeline 加 TryResend 定时器(认领→重投→Flush→删,
  重入保护+每轮限流)。
- 门面 OperationLogger 接线:transport 传 onDeliveryFailed:WriteFallback、
  InitCore 起 ConfigWatcher、Shutdown 释放;修 WIP 遗留 2 处
  JToken.Value<bool>() 编译错为 (bool) 强转。
- dotnet build Aivfo.OperationLog 0 error 0 警告;消费端门面签名未变零改动。
  真入库/补送/热生效待起 Kafka+oplog 运行抽查。
- 同步文档:进度状态.yaml/交接卡.md/工作计划表.md/进度数据.js/当前开发计划.md。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie hace 5 días
padre
commit
b3104b1007

+ 20 - 6
Aivfo.OperationLog/KafkaOplogTransport.cs

@@ -14,11 +14,14 @@ namespace Aivfo.OperationLog
         private readonly string _topic;
         private readonly IProducer<Null, string> _producer;
         private readonly Action<string> _onError; // 传输错误回调(写本地启动/错误日志)
+        private readonly Action<string> _onDeliveryFailed; // 投递失败回调(把整条 JSON 落本地兜底,恢复后补送)
 
-        public KafkaOplogTransport(string bootstrapServers, string topic, Action<string> onError = null)
+        public KafkaOplogTransport(string bootstrapServers, string topic,
+            Action<string> onError = null, Action<string> onDeliveryFailed = null)
         {
             _topic = topic;
             _onError = onError;
+            _onDeliveryFailed = onDeliveryFailed;
 
             var config = new ProducerConfig
             {
@@ -43,13 +46,24 @@ namespace Aivfo.OperationLog
         {
             // UTF-8 由 Confluent 默认 Serializers.Utf8 处理。
             var message = new Message<Null, string> { Value = json };
-            _producer.Produce(_topic, message, report =>
+            try
             {
-                if (report.Error != null && report.Error.IsError)
+                _producer.Produce(_topic, message, report =>
                 {
-                    _onError?.Invoke($"[KafkaDeliveryFailed] {report.Error.Reason} | {json}");
-                }
-            });
+                    if (report.Error != null && report.Error.IsError)
+                    {
+                        // broker 宕/超时等投递失败:记错误 + 落本地兜底(恢复后由 pipeline 补送,不静默丢)。
+                        _onError?.Invoke($"[KafkaDeliveryFailed] {report.Error.Reason}");
+                        _onDeliveryFailed?.Invoke(json);
+                    }
+                });
+            }
+            catch (ProduceException<Null, string> ex)
+            {
+                // 本地 librdkafka 队列满(QueueFull)等同步异常:同样落兜底,不丢、不抛给上层。
+                _onError?.Invoke($"[KafkaProduceError] {ex.Error.Reason}");
+                _onDeliveryFailed?.Invoke(json);
+            }
         }
 
         public void Flush(TimeSpan timeout)

+ 51 - 0
Aivfo.OperationLog/LocalFileWriter.cs

@@ -54,6 +54,57 @@ namespace Aivfo.OperationLog
             catch { /* 本地写失败也不能拖垮业务 */ }
         }
 
+        // ===== G3-3 / 14§11 兜底补送原语 =====
+
+        /// <summary>列出待补送的兜底文件(不含正在补送的 .resending)。按文件名升序≈时间升序。</summary>
+        public string[] ListFallbackFiles()
+        {
+            try
+            {
+                lock (_lock)
+                {
+                    if (!Directory.Exists(_dir)) return Array.Empty<string>();
+                    var files = Directory.GetFiles(_dir, "oplog-fallback-*.log");
+                    Array.Sort(files, StringComparer.OrdinalIgnoreCase);
+                    return files;
+                }
+            }
+            catch { return Array.Empty<string>(); }
+        }
+
+        /// <summary>
+        /// 认领一个兜底文件:原子重命名为 .resending(避免与 append 竞争、避免并发重复补送)。
+        /// 成功返回新路径,失败(已被认领/被占用)返回 null。
+        /// </summary>
+        public string ClaimFallback(string path)
+        {
+            try
+            {
+                lock (_lock)
+                {
+                    if (!File.Exists(path)) return null;
+                    var dst = path + "." + DateTime.Now.ToString("HHmmssfff") + ".resending";
+                    File.Move(path, dst);
+                    return dst;
+                }
+            }
+            catch { return null; }
+        }
+
+        /// <summary>读取认领文件的所有非空行。</summary>
+        public string[] ReadLines(string path)
+        {
+            try { return File.ReadAllLines(path, Encoding.UTF8); }
+            catch { return Array.Empty<string>(); }
+        }
+
+        /// <summary>删除文件(补送成功后清理认领文件)。</summary>
+        public void DeleteQuietly(string path)
+        {
+            try { if (File.Exists(path)) File.Delete(path); }
+            catch { /* ignore */ }
+        }
+
         private static string Truncate(string s, int max = 500)
         {
             if (string.IsNullOrEmpty(s)) return s;

+ 84 - 0
Aivfo.OperationLog/OperationLogConfigWatcher.cs

@@ -0,0 +1,84 @@
+using System;
+using System.IO;
+using System.Threading;
+
+namespace Aivfo.OperationLog
+{
+    /// <summary>
+    /// 配置热加载(G3-3 / 14§10):后台定时轮询一个 JSON 配置文件,文件变化即热应用到
+    /// <see cref="OperationLogOptions"/>(全局开关/级别 + 模块级开关),无需改代码重编。
+    /// <para>
+    /// "集中下发"落地方式:日志微服务 / 运维把统一配置文件下发(覆盖)到各端
+    /// <see cref="OperationLogOptions.ConfigFilePath"/> 指向的路径即可;本组件负责读取并热生效。
+    /// </para>
+    /// 全 try 兜底:读/解析失败沿用旧配置,绝不抛、绝不影响业务。
+    /// </summary>
+    internal sealed class OperationLogConfigWatcher : IDisposable
+    {
+        private readonly OperationLogOptions _options;
+        private readonly Action<string> _onSelfError;
+        private readonly Timer _timer;
+        private DateTime _lastWriteUtc = DateTime.MinValue;
+        private long _lastLength = -1;
+        private volatile bool _disposed;
+
+        public OperationLogConfigWatcher(OperationLogOptions options, Action<string> onSelfError)
+        {
+            _options = options;
+            _onSelfError = onSelfError;
+
+            var periodMs = Math.Max(3, _options.ConfigReloadSeconds) * 1000;
+            // 启动即先读一次(dueTime=0),随后周期轮询。
+            _timer = new Timer(Poll, null, 0, periodMs);
+        }
+
+        private void Poll(object state)
+        {
+            if (_disposed) return;
+            try
+            {
+                var path = _options.ConfigFilePath;
+                if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) return;
+
+                // 仅在 文件大小或写时间 变化时才重读重应用(省 IO、避免无谓刷新)。
+                var info = new FileInfo(path);
+                var writeUtc = info.LastWriteTimeUtc;
+                var len = info.Length;
+                if (writeUtc == _lastWriteUtc && len == _lastLength) return;
+
+                string json = SafeReadAllText(path);
+                if (json == null) return;
+
+                _options.ApplyConfigJson(json);
+                _lastWriteUtc = writeUtc;
+                _lastLength = len;
+                _onSelfError?.Invoke($"操作日志配置已热加载: {path}");
+            }
+            catch (Exception ex)
+            {
+                try { _onSelfError?.Invoke("配置热加载失败(沿用旧配置): " + ex.Message); } catch { }
+            }
+        }
+
+        /// <summary>容错读取:文件可能正被写,读失败返回 null(下轮再试)。</summary>
+        private static string SafeReadAllText(string path)
+        {
+            try
+            {
+                using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
+                using (var sr = new StreamReader(fs, System.Text.Encoding.UTF8))
+                {
+                    return sr.ReadToEnd();
+                }
+            }
+            catch { return null; }
+        }
+
+        public void Dispose()
+        {
+            if (_disposed) return;
+            _disposed = true;
+            try { _timer?.Dispose(); } catch { /* ignore */ }
+        }
+    }
+}

+ 81 - 0
Aivfo.OperationLog/OperationLogOptions.cs

@@ -42,6 +42,27 @@ namespace Aivfo.OperationLog
         /// <summary>本机 host(默认取机器名)。</summary>
         public string Host { get; set; } = SafeMachineName();
 
+        // ===== G3-3 / 14§10 配置热生效(集中下发的本地落地) =====
+        /// <summary>
+        /// 配置文件路径(JSON)。非空则组件后台轮询该文件,改文件即热生效(不重编)。
+        /// "集中下发"落地方式:日志微服务/运维把该文件下发/覆盖到各端此路径即可。
+        /// 为空则关闭配置热加载(仅用代码 Init 时的设置)。
+        /// </summary>
+        public string ConfigFilePath { get; set; }
+
+        /// <summary>配置文件轮询间隔(秒)。默认 15。</summary>
+        public int ConfigReloadSeconds { get; set; } = 15;
+
+        // ===== G3-3 / 14§11 本地兜底补送 =====
+        /// <summary>是否启用 Kafka 恢复后补送本地兜底文件。默认开。</summary>
+        public bool EnableFallbackResend { get; set; } = true;
+
+        /// <summary>兜底补送轮询间隔(秒)。默认 60。</summary>
+        public int FallbackResendSeconds { get; set; } = 60;
+
+        /// <summary>每轮补送最多处理的兜底文件数(限流,避免一次扫太多)。默认 5。</summary>
+        public int ResendFilesPerCycle { get; set; } = 5;
+
         // 模块级开关:moduleName -> (enabled, minLevel)。缺省即全开、按 GlobalLevel。
         private readonly ConcurrentDictionary<string, ModuleSetting> _modules =
             new ConcurrentDictionary<string, ModuleSetting>();
@@ -81,6 +102,66 @@ namespace Aivfo.OperationLog
             catch { return "unknown"; }
         }
 
+        /// <summary>
+        /// 应用一份 JSON 配置(热生效)。容错:任意字段缺失/格式错都忽略该字段,不抛。
+        /// 形如:{ "enabled":true, "globalLevel":"Info",
+        ///        "modules":{ "串口":{"enabled":true,"minLevel":"Debug"}, "对焦调试":{"enabled":true} } }
+        /// </summary>
+        public void ApplyConfigJson(string json)
+        {
+            if (string.IsNullOrWhiteSpace(json)) return;
+            try
+            {
+                var root = Newtonsoft.Json.Linq.JObject.Parse(json);
+
+                var en = root["enabled"];
+                if (en != null && en.Type == Newtonsoft.Json.Linq.JTokenType.Boolean)
+                    Enabled = (bool)en;
+
+                var gl = root["globalLevel"];
+                if (gl != null && TryParseLevel(gl.ToString(), out var glLevel))
+                    GlobalLevel = glLevel;
+
+                var modules = root["modules"] as Newtonsoft.Json.Linq.JObject;
+                if (modules != null)
+                {
+                    foreach (var prop in modules.Properties())
+                    {
+                        var name = prop.Name;
+                        if (string.IsNullOrEmpty(name)) continue;
+                        var m = prop.Value as Newtonsoft.Json.Linq.JObject;
+                        if (m == null) continue;
+
+                        // 模块缺省:enabled=true、minLevel 跟随全局。
+                        bool mEnabled = true;
+                        var me = m["enabled"];
+                        if (me != null && me.Type == Newtonsoft.Json.Linq.JTokenType.Boolean)
+                            mEnabled = (bool)me;
+
+                        var minLevel = GlobalLevel;
+                        var ml = m["minLevel"];
+                        if (ml != null) TryParseLevel(ml.ToString(), out minLevel);
+
+                        SetModule(name, mEnabled, minLevel);
+                    }
+                }
+            }
+            catch { /* 配置解析失败不影响业务,沿用旧配置 */ }
+        }
+
+        /// <summary>解析级别字符串(Debug/Info,大小写不敏感)。</summary>
+        private static bool TryParseLevel(string s, out OpLogLevel level)
+        {
+            level = OpLogLevel.Info;
+            if (string.IsNullOrWhiteSpace(s)) return false;
+            switch (s.Trim().ToLowerInvariant())
+            {
+                case "debug": case "0": level = OpLogLevel.Debug; return true;
+                case "info": case "1": level = OpLogLevel.Info; return true;
+                default: return false;
+            }
+        }
+
         private sealed class ModuleSetting
         {
             public bool Enabled;

+ 61 - 0
Aivfo.OperationLog/OperationLogPipeline.cs

@@ -20,6 +20,8 @@ namespace Aivfo.OperationLog
         private readonly Channel<OperationLogMessage> _channel;
         private readonly CancellationTokenSource _cts = new CancellationTokenSource();
         private readonly Task _worker;
+        private readonly Timer _resendTimer;     // G3-3:兜底补送定时器
+        private int _resending;                  // 补送重入保护(0=空闲,1=进行中)
         private volatile bool _disposed;
 
         public OperationLogPipeline(OperationLogOptions options, IOplogTransport transport, LocalFileWriter local)
@@ -36,6 +38,13 @@ namespace Aivfo.OperationLog
             });
 
             _worker = Task.Factory.StartNew(RunLoop, TaskCreationOptions.LongRunning).Unwrap();
+
+            // G3-3 / 14§11:后台周期补送本地兜底文件(Kafka 恢复后把没发出的补回)。
+            if (_options.EnableFallbackResend)
+            {
+                var periodMs = Math.Max(5, _options.FallbackResendSeconds) * 1000;
+                _resendTimer = new Timer(TryResend, null, periodMs, periodMs);
+            }
         }
 
         /// <summary>非阻塞入队。满了走本地兜底(不丢失),永不阻塞/抛异常。</summary>
@@ -101,6 +110,57 @@ namespace Aivfo.OperationLog
             }
         }
 
+        /// <summary>
+        /// G3-3 / 14§11 兜底补送:周期把 oplog-fallback-*.log 里没发出的日志重新投递。
+        /// 做法:认领文件(重命名 .resending,避开 append 竞争)→ 逐行 Send → 删除认领文件。
+        /// 若此刻 Kafka 仍不可用,投递失败会由传输层 onDeliveryFailed 重新落新兜底文件,下轮再试——
+        /// 故删除认领文件不会丢日志(最坏是极小概率进程崩溃窗口内的重复/丢失,best-effort)。
+        /// 重入保护 + 每轮限流 + 全 try 兜底,绝不影响业务。
+        /// </summary>
+        private void TryResend(object state)
+        {
+            if (_disposed || !_options.EnableFallbackResend) return;
+            // 同一时刻只允许一个补送在跑。
+            if (Interlocked.CompareExchange(ref _resending, 1, 0) != 0) return;
+            try
+            {
+                var files = _local.ListFallbackFiles();
+                if (files == null || files.Length == 0) return;
+
+                var limit = Math.Min(files.Length, Math.Max(1, _options.ResendFilesPerCycle));
+                int resentLines = 0;
+                for (int i = 0; i < limit; i++)
+                {
+                    if (_disposed) break;
+                    var claimed = _local.ClaimFallback(files[i]);
+                    if (claimed == null) continue; // 已被别处认领/不存在
+
+                    var lines = _local.ReadLines(claimed);
+                    foreach (var line in lines)
+                    {
+                        if (string.IsNullOrWhiteSpace(line)) continue;
+                        try { _transport.Send(line); resentLines++; }
+                        catch (Exception ex) { _local.WriteSelfError("Resend Send failed: " + ex.Message); }
+                    }
+                    _local.DeleteQuietly(claimed);
+                }
+                // 尽量把刚补送的推到 broker(短超时,不长时间挂后台线程)。
+                if (resentLines > 0)
+                {
+                    try { _transport.Flush(TimeSpan.FromSeconds(2)); } catch { }
+                    _local.WriteSelfError($"Fallback resend cycle: 重投 {resentLines} 条。");
+                }
+            }
+            catch (Exception ex)
+            {
+                _local.WriteSelfError("Resend cycle failed: " + ex.Message);
+            }
+            finally
+            {
+                Interlocked.Exchange(ref _resending, 0);
+            }
+        }
+
         /// <summary>排空队列并 flush 传输(用于测试/退出)。</summary>
         public void Flush(TimeSpan timeout)
         {
@@ -124,6 +184,7 @@ namespace Aivfo.OperationLog
             _disposed = true;
             try
             {
+                try { _resendTimer?.Dispose(); } catch { }
                 _channel.Writer.TryComplete();
                 Flush(TimeSpan.FromSeconds(3));
                 _cts.Cancel();

+ 15 - 1
Aivfo.OperationLog/OperationLogger.cs

@@ -16,6 +16,7 @@ namespace Aivfo.OperationLog
         private static OperationLogOptions _options;
         private static OperationLogPipeline _pipeline;
         private static LocalFileWriter _local;
+        private static OperationLogConfigWatcher _configWatcher; // G3-3:配置热加载 watcher(ConfigFilePath 非空才起)
         private static readonly object _initLock = new object();
         private static volatile bool _initialized;
 
@@ -37,7 +38,10 @@ namespace Aivfo.OperationLog
                 IOplogTransport transport;
                 try
                 {
-                    transport = new KafkaOplogTransport(opts.KafkaBootstrapServers, opts.Topic, local.WriteSelfError);
+                    // G3-3 / 14§11:投递失败(broker 宕/超时/队列满)把整条 JSON 落本地兜底,
+                    // 由 pipeline 的补送定时器在 Kafka 恢复后补回,不静默丢。
+                    transport = new KafkaOplogTransport(opts.KafkaBootstrapServers, opts.Topic,
+                        onError: local.WriteSelfError, onDeliveryFailed: local.WriteFallback);
                 }
                 catch (Exception ex)
                 {
@@ -65,6 +69,15 @@ namespace Aivfo.OperationLog
             _options = opts;
             _local = local;
             _pipeline = new OperationLogPipeline(opts, transport, local);
+
+            // G3-3 / 14§10:配置集中下发——指定了 ConfigFilePath 才起后台热加载 watcher
+            // (日志微服务/运维把统一配置文件下发覆盖到该路径即热生效,无需重编)。
+            if (!string.IsNullOrWhiteSpace(opts.ConfigFilePath))
+            {
+                try { _configWatcher = new OperationLogConfigWatcher(opts, local.WriteSelfError); }
+                catch (Exception ex) { local.WriteSelfError("ConfigWatcher init failed: " + ex.Message); }
+            }
+
             _initialized = true;
         }
 
@@ -216,6 +229,7 @@ namespace Aivfo.OperationLog
             lock (_initLock)
             {
                 if (!_initialized) return;
+                try { _configWatcher?.Dispose(); } catch { }
                 try { _pipeline?.Dispose(); } catch { }
                 _initialized = false;
             }

+ 1 - 1
项目文档/开发计划/2026-06-20-当前开发计划.md

@@ -68,7 +68,7 @@
 
 - [x] **G3-1** `[纯代码][P1]` operate 逐方法埋点(逐 ViewModel/命令接入)。 ✅2026-06-20 完成:VM 层(HouseDebugPageVM ~23 法:LED/进排气阀/补排气/写EEPROM/水平垂直电机各向/抓图/一键电机准备/手调保存等、BufferDebugVM 3、MainPageVM 2:结束培养/报警静音、UnifiedConfigVM 1:保存配置排除密码) + View code-behind 层(加皿窗口:编辑保存/启动培养皿/启动平衡皿、胚胎详情:移动/释放/删除/作废/结束 5 个、舱室设置:保存舱室/保存系统设置、对焦设置:保存/批量校验)。事件处理器用 `OperationLogger.Begin` scope + 失败路径显式 `Fail()`(Dispose 默认记成功),不改控制流;HTTP/串口/相机已由 P3b 单点收口覆盖;展示类/纯数据 VM(Photo/Detail-VM/DishRecord/Alarm/Chart/ServiceMonitor)无操作正确跳过。operate 工程 `dotnet build` 0 error。★真入 operation_log 待起 Kafka+oplog 端到端抽查(运行期,非真机门控)。
 - [x] **G3-2** `[纯代码][P1]` Java 埋点:tl-control / business 关键 Controller/Service 加 `@OperateLog`。 ✅2026-06-20 完成:两服务装 M8 三件套(log-starter+kafka-starter+oplog-client)+ application-local 配 kafka(${server.ip}:9092);tl-control 15 Controller/90 方法、business 24 Controller/121 方法贴 `@OperateLog`(module=@Api tags、operation=@ApiOperation 中文值);两服务 Maven 编译 BUILD SUCCESS。★真入 operation_log 待起 Kafka+oplog 端到端抽查(运行期,非真机门控)。
-- [ ] **G3-3** `[纯代码][P2]` M8 配置集中下发 + 本地兜底补送(C4):改配置热生效;Kafka 恢复后补送
+- [x] **G3-3** `[纯代码][P2]` M8 配置集中下发 + 本地兜底补送(C4)。 ✅2026-06-20 完成:① §10 配置热生效——`OperationLogOptions` 加 `ConfigFilePath`/`ConfigReloadSeconds` + `ApplyConfigJson`(容错解析 enabled/globalLevel/模块级开关),新增 `OperationLogConfigWatcher`(后台轮询配置文件,大小/写时间变即热应用,全 try 兜底),日志微服务/运维把统一配置文件下发覆盖到该路径即热生效不重编;② §11 本地兜底补送——`KafkaOplogTransport` 加 `onDeliveryFailed` 回调(投递失败/队列满整条 JSON 落兜底文件)、`LocalFileWriter` 加补送原语(List/Claim 原子重命名 .resending/ReadLines/Delete)、`OperationLogPipeline` 加补送定时器 `TryResend`(认领→逐行重投→删,重入保护+每轮限流);③ 门面 `OperationLogger` 接线:构造 transport 传 `onDeliveryFailed: local.WriteFallback`、`InitCore` 起 ConfigWatcher(ConfigFilePath 非空)、`Shutdown` 释放。`dotnet build Aivfo.OperationLog` 0 error 0 警告。消费端(operate/front)只用 OperationLogger 门面、签名未变零改动。★真入库/补送/热生效待起 Kafka+oplog 运行抽查(运行期,非真机门控)
 
 **验收**:抽查各端关键操作有 `operation_log` 记录;改配置热生效、Kafka 恢复后补送成功。
 **真机门控**:无(GUI 操作触发即可抽查)。

+ 12 - 0
项目文档/进度/交接卡.md

@@ -527,3 +527,15 @@
 - 抽查:AutoFocusSettingView.SaveHouse(守卫外置/Success 成功分支/4处 Fail 覆盖校验+接口false+catch+throw)、DetailPageView.EmbryoOver(守卫保留原try内/内层try Success+Fail+throw到外层原ExLog吞异常)——Success/Fail 放置正确、控制流逐字保留。
 - 真机/运行待验:★同 G3-2,真入 operation_log 待起 Kafka+oplog 端到端抽查(运行期,非真机硬件门控)——GUI 触发任一操作后查 log 库有记录即闭环★。
 - 下一步:G3 仅剩 G3-3(M8 §10 配置集中下发+本地兜底补送 C4);或 G2 UI(首页弹框/13子页自适应/TabTip/well三态)、G4-1(C6 对焦清理任务)纯代码任务。真机门控 G1-1/G4-3/G5 待用户在场。
+
+## 2026-06-20 · ★G3-3 配置集中下发 + 本地兜底补送完成(M8 G3 收官,续写半截任务)★
+- 背景:上一会话窗口关闭时 G3-3 写到一半。续接核实:已改 4 文件(KafkaOplogTransport/LocalFileWriter/OperationLogOptions/OperationLogPipeline)+新增 OperationLogConfigWatcher.cs,但**门面 OperationLogger.cs 尚未接线**——卡点正在此。
+- 收尾改动(本次):① OperationLogger.InitCore 构造 KafkaOplogTransport 传 `onDeliveryFailed: local.WriteFallback`(投递失败/队列满整条 JSON 落兜底,由 Pipeline 补送定时器恢复后补回);② InitCore 在 ConfigFilePath 非空时起 `OperationLogConfigWatcher`(存静态字段);③ Shutdown 释放 watcher。
+- 修 WIP 遗留编译错:OperationLogOptions.ApplyConfigJson 两处 `JToken.Value<bool>()` 无参非法 → 改 `(bool)en`/`(bool)me`(前已判 .Type==Boolean,强转安全)。
+- §10 配置热生效:ConfigFilePath/ConfigReloadSeconds + ApplyConfigJson(容错解析 enabled/globalLevel/模块级开关,任意字段缺失/格式错忽略不抛)+ OperationLogConfigWatcher(轮询文件大小/写时间变才重读重应用,SafeRead FileShare.ReadWrite 容并发写)。运维/日志微服务把统一配置下发覆盖到该路径即热生效,不重编。
+- §11 兜底补送:LocalFileWriter 加 ListFallbackFiles/ClaimFallback(原子重命名 .resending 避 append 竞争与并发重复)/ReadLines/DeleteQuietly;Pipeline.TryResend 定时器(认领→逐行 Send→Flush 2s→删,重入保护 Interlocked + 每轮限流 ResendFilesPerCycle,全 try)。与 RunLoop 既有 catch 不双写(Send 现内部 catch ProduceException 不外抛)。
+- ★编译核实:`dotnet build Aivfo.OperationLog/Aivfo.OperationLog.csproj -c Debug` = **0 错误 0 警告**。codegraph sync up to date。
+- 兼容性:消费端 operate/front 只用 OperationLogger.Init/Log/Begin/Run 门面,签名全未变(新增的是 KafkaOplogTransport 可选参 + Options 新字段),零改动不破坏(codegraph 确认仅 OperationLogger.cs 构造这些内部类),故未再全量 build operate/front。
+- 真机/运行待验:★真入库 + Kafka 宕→恢复补送 + 配置文件改→热生效,三条均待起 Kafka+oplog 端到端抽查(运行期,非真机门控)★。
+- 本次未 git commit(按惯例主会话统一,与累计未提交改动一并)。累计未提交:本次 Aivfo.OperationLog 5 文件 + 此前各会话累计(见前卡)。
+- 下一步:M8 可写代码全清。挑剩余纯代码——G2 UI(首页舱室弹框/13子页自适应/TabTip/well三态,视觉真机为准)或 G4-1(C6 对焦标定清理任务)。真机门控 G1-1(T1.4)/G4-3/G5 待用户在场。

+ 4 - 4
项目文档/进度/工作计划表.md

@@ -19,7 +19,7 @@
 
 **续接三件套(无缝接续,不依赖对话记忆)**:① 本表(里程碑状态)② 《2026-06-20-当前开发计划.md》(剩余工作)③ 进度状态.yaml(当前断点)。
 
-**剩余工作总览**(详见《当前开发计划》):G1 串口收尾(T1.4 真机/ComBin 枚举迁移去重/写EEPROM V-010)|G2 UI(首页舱室弹框位置大小/13 子页自适应/TabTip/well 三态)|G3 日志铺开(**operate 逐方法 ✅2026-06-20**/**Java 埋点 ✅2026-06-20**/C4 配置下发待)|G4 对焦·数据(C6 清理任务/**C2 建库 runner ✅2026-06-20 完成**/V-046 列迁移)|G5 业务回归(M6/M7)。多数受真机 GUI 门控。
+**剩余工作总览**(详见《当前开发计划》):G1 串口收尾(T1.4 真机/ComBin 枚举迁移去重/写EEPROM V-010)|G2 UI(首页舱室弹框位置大小/13 子页自适应/TabTip/well 三态)|G3 日志铺开(**operate 逐方法 ✅2026-06-20**/**Java 埋点 ✅2026-06-20**/**C4 配置下发+补送 ✅2026-06-20 G3 全清**)|G4 对焦·数据(C6 清理任务/**C2 建库 runner ✅2026-06-20 完成**/V-046 列迁移)|G5 业务回归(M6/M7)。多数受真机 GUI 门控。
 
 ---
 
@@ -149,9 +149,9 @@
 | M8-03 | Java @OperateLog 切面机制 + 关键方法注解 | 🟢 | M8-02 | Java 操作入库、trace 串联 | 14 §8;机制✔ + data-transmission✔;**tl-control 15C/90法 + business 24C/121法 已贴 @OperateLog + 装三件套(G3-2),编译 BUILD SUCCESS;真入库待 Kafka+oplog 运行验** | ✔ |
 | M8-04 | C# 组件 Aivfo.OperationLog | ☑ | M8-02 | 组件可复用 | 14 §9;P3a 端到端✔ | ✔ |
 | M8-05 | C# 全埋 operate + front | 🟢 | M8-04 | 操作全覆盖 | 14 §2/9;operate 边界埋点✔ + **front 接入✔(C5)** + **operate 逐方法手埋✔(G3-1:VM 层 HouseDebug/Buffer/Main/UnifiedConfig + View code-behind 加皿/胚胎/舱室设置/对焦设置命令入口,0 error)**;真入库待运行验 | ✔ |
-| M8-06 | 可配置 + 配置集中下发 / 本地兜底补送 | 🔶 | M8-03,M8-05 | 配置热生效 | 14 §10;组件开关✔ + 规约✔;**§10 集中下发/补送 待(C4)** | ✔ |
+| M8-06 | 可配置 + 配置集中下发 / 本地兜底补送 | 🟢 | M8-03,M8-05 | 配置热生效 | 14 §10/11;组件开关✔ + **§10 配置热加载(ConfigFilePath+OperationLogConfigWatcher)✔ + §11 投递失败落兜底+定时补送(TryResend)✔(G3-3)**;dotnet build 0 error;真入库/补送/热生效待运行验 | ✔ |
 
-**M8 总评**:🔶 机制全 + front 接入 + operate 逐方法埋点(G3-1) + Java(tl-control/business)埋点(G3-2) 均本会话补齐;仅配置集中下发(C4) 仍欠账,真入库待 Kafka+oplog 运行抽查。
+**M8 总评**:🟢 机制全 + front 接入 + operate 逐方法埋点(G3-1) + Java(tl-control/business)埋点(G3-2) + 配置集中下发/本地兜底补送(G3-3) 均本会话补齐;M8 可写代码全清;真入库/补送/热生效待 Kafka+oplog 运行抽查。
 
 ---
 
@@ -161,7 +161,7 @@
 
 - **G1 串口收尾**:T1.4 真机验证(借用→暂停→调试复用句柄→归还恢复,不报端口占用,需 GUI);ComBin 枚举阶段迁移去重(B 栈枚举阶段未迁移、未全删,迁移后真机验);写 EEPROM 调试动作补全(待 V-010 真机)。
 - **G2 UI**:首页舱室弹框位置/大小修复;M4 13 子页自适应(去写死像素 + Viewbox);TabTip 程序级屏蔽;well 三态色彩。
-- **G3 日志铺开**:~~operate 逐方法埋点(G3-1✅2026-06-20)~~;~~Java(tl-control/business)埋点(G3-2✅)~~;M8 §10 配置集中下发 / 本地兜底补送(C4,G3-3)。
+- **G3 日志铺开**:~~operate 逐方法埋点(G3-1✅2026-06-20)~~;~~Java(tl-control/business)埋点(G3-2✅)~~;~~M8 §10 配置集中下发 / §11 本地兜底补送(C4,G3-3✅2026-06-20)~~**G3 全清,真入库/补送/热生效待运行抽查。**
 - **G4 对焦/数据**:对焦标定清理任务(C6);建库脚本与 migration 合并 + 幂等 runner(C2);本地 SQLite 列迁移(V-046)。
 - **G5 业务回归 / 真机验收**:M6 业务回归;M7 真机验收(按待验证清单逐条,含 operate 完整 GUI 业务流程、对焦实拍、首页舱室弹框、M4 子页自适应视觉)。
 

+ 6 - 5
项目文档/进度/进度数据.js

@@ -1,10 +1,10 @@
 // 进度数据(监控面板.html 读取)。每完成一步由助手回写,generatedAt 用于停滞检测。
 window.PROGRESS_DATA = {
   project: "时差培养箱合并改造",
-  generatedAt: "2026-06-20T22:10:00",
-  currentTask: "★G3-1 operate 逐方法埋点完成(M8 全量操作日志 C# 端铺开)★:VM 层(HouseDebugPageVM ~23法/BufferDebugVM 3/MainPageVM 2/UnifiedConfigVM 1) + View code-behind 层(加皿窗口编辑保存/启动培养皿/启动平衡皿、胚胎详情移动/释放/删除/作废/结束、舱室设置保存舱室/系统设置、对焦设置保存/批量校验)。事件处理器用 OperationLogger.Begin scope + 失败路径显式 Fail()(Dispose 默认记成功),不改控制流;HTTP/串口/相机已P3b单点收口覆盖;展示类/纯数据VM正确跳过。operate dotnet build 0 error。真入 operation_log 待起 Kafka+oplog 端到端抽查(运行期)。",
-  currentStep: "G3-1 operate 逐方法埋点完成",
-  nextStep: "G3 余下:G3-3(M8 §10 配置集中下发+本地兜底补送 C4);或 G2 UI(首页弹框/13子页自适应/TabTip/well三态)、G4(C6对焦清理任务)。真机门控 G1-1/G4-3/G5 待用户在场。",
+  generatedAt: "2026-06-20T23:05:00",
+  currentTask: "★G3-3 完成 → M8 全量操作日志 C# 端可写代码全清(G3 三任务收官)★:①§10 配置热生效——OperationLogOptions 加 ConfigFilePath/ApplyConfigJson + 新增 OperationLogConfigWatcher(后台轮询配置文件热应用,运维下发覆盖即生效不重编);②§11 兜底补送——KafkaOplogTransport 加 onDeliveryFailed(投递失败/队列满落兜底)、LocalFileWriter 加补送原语(原子认领 .resending)、Pipeline 加 TryResend 定时器(认领→重投→删,重入+限流);③门面 OperationLogger 接线 transport 传 onDeliveryFailed:WriteFallback/InitCore 起 Watcher/Shutdown 释放。dotnet build Aivfo.OperationLog 0 error 0 警告,消费端门面签名未变零改动,修了 WIP 遗留 2 处 JToken.Value<bool>() 编译错。真入库/补送/热生效待 Kafka+oplog 运行抽查。",
+  currentStep: "G3-3 配置集中下发+本地兜底补送完成(M8 G3 全清)",
+  nextStep: "剩余纯代码:G2 UI(首页弹框/13子页自适应/TabTip/well三态,视觉真机为准)或 G4-1(C6 对焦标定清理任务)。真机门控 G1-1(T1.4)/G4-3/G5 待用户在场。",
   phase: "★★三项目合并物理收尾完成 + 串口占用代码层已修(T1.1~T1.3,T1.4待GUI验) + Phase0功能bug已修(scene=0不拍照/kfka重复键/front去imageScore) + 真机硬件+API+服务起全闭环★★ 文档重组进行中,真机GUI全流程验收待续",
   note: "2026-06-20:在2026-06-19灾后恢复基础上,本会话完成三项目合并最后一公里——control物理并入operate/control/(顶层ivf_tl_control_2.0消失,operate自包含)、autofocustool删除、编译operate/front/单测三关0错误。真机验证:硬件层(7舱握手+三路温度+压力+舱门+电机偏差0+相机出图2592×1944+CCDSN映射)+业务API(登录/tl-control/business/surface getButtons/对焦下发V-047上行V-064)全闭环;data-transmission补建aivfo-tl库后Started(nacos 6服务)。串口占用代码层修复T1.1~T1.3(T1.4真机待GUI);功能bug修复T0.1~T0.3。M2-02单测重建23断言全通过。审计报告与会话续接文档内容已三层归位后删除。详见交接卡。",
   planTasks: [
@@ -16,6 +16,7 @@ window.PROGRESS_DATA = {
     { id: "M2-02test", name: "★M2-02单测重建23断言全通过+对焦公式逻辑验证★", status: "☑" },
     { id: "Phase4", name: "★容错读取接崩点(B6)+HTTP失联标记(C3)+down<count校验下沉+front接日志(C5)★", status: "☑" },
     { id: "G4-2", name: "★建库脚本与migration合并+幂等runner(C2):对焦表列/operation_log并入base+init-database.sh,临时容器全量+幂等验证★", status: "☑" },
+    { id: "G3-3", name: "★配置集中下发+本地兜底补送(C4):§10 ConfigFilePath+OperationLogConfigWatcher 热加载 + §11 onDeliveryFailed 落兜底+TryResend 定时补送,dotnet build 0 error;真入库/补送待运行验★", status: "☑" },
     { id: "G3-2", name: "★Java操作日志埋点(tl-control 15C/90法+business 24C/121法贴@OperateLog)+两服务装M8三件套,编译BUILD SUCCESS;真入库待Kafka+oplog运行验★", status: "☑" },
     { id: "G3-1", name: "★operate逐方法埋点(VM层HouseDebug/Buffer/Main/UnifiedConfig+View code-behind加皿/胚胎/舱室设置/对焦设置命令入口,Begin scope+失败显式Fail),dotnet build 0 error;真入库待运行验★", status: "☑" },
     { id: "Task1-4", name: "进度文件组(4文件)", status: "☑" },
@@ -90,7 +91,7 @@ window.PROGRESS_DATA = {
       { id: "M8-03", name: "Java @OperateLog切面(机制✔+data-trans✔+tl-control/business已接G3-2,真入库待运行验)", status: "🟢", env: false },
       { id: "M8-04", name: "C#组件 Aivfo.OperationLog(P3a端到端✔)", status: "☑", env: false },
       { id: "M8-05", name: "C#全埋operate+front(边界埋点+front接入+operate逐方法手埋G3-1✔,真入库待运行验)", status: "🟢", env: false },
-      { id: "M8-06", name: "可配置+配置集中下发(组件开关✔,§10下发待C4)", status: "🔶", env: false }
+      { id: "M8-06", name: "可配置+配置集中下发/兜底补送(组件开关✔+§10热加载Watcher✔+§11投递失败落兜底+定时补送✔ G3-3,dotnet build 0 error;真入库/补送待运行验)", status: "🟢", env: false }
     ]}
   ],
   pending: [

+ 7 - 7
项目文档/进度/进度状态.yaml

@@ -1,14 +1,14 @@
 # 续接断点状态(机器可解析)。换会话/换电脑后首先读它定位。
 # 状态取值: 未开始 / 进行中 / 完成 / 代码完成待验证
 # 纪律:本字段只存【当前断点】,历史细节进 交接卡.md(见 CLAUDE.md 第三节)。
-更新时间: 2026-06-20 G3-1 完成(operate 逐方法埋点,C# 端 M8 操作日志铺开
+更新时间: 2026-06-20 G3-3 完成(M8 §10 配置集中下发热生效 + §11 本地兜底补送,G3 全清
 当前任务: >
-  【★G3-1 operate 逐方法埋点完成(M8 全量操作日志 C# 端铺开)★】
-  · VM 层:HouseDebugPageVM ~23 法(LED/进排气阀/补排气/写EEPROM/水平垂直电机各向/抓图/一键电机准备/手调保存)、BufferDebugVM 3、MainPageVM 2(结束培养/报警静音)、UnifiedConfigVM 1(保存配置,排除密码)
-  · View code-behind 层(真实操作所在):加皿窗口(编辑保存/启动培养皿/启动平衡皿)、胚胎详情(移动/释放/删除/作废/结束 5 个)、舱室设置(保存舱室/保存系统设置)、对焦设置(保存/批量校验)
-  · 事件处理器用 OperationLogger.Begin scope + 失败路径显式 Fail()(Dispose 默认记成功),不改控制流;HTTP/串口/相机已 P3b 单点收口覆盖;展示类/纯数据 VM 正确跳过。operate dotnet build 0 error
-  · ★真入 operation_log 待起 Kafka+oplog 端到端抽查(运行期,非真机门控)★
-  下一步:G3-3(M8 §10 配置集中下发+本地兜底补送 C4,剩 G3 最后一项);或挑 G2 UI(首页弹框/13子页自适应/TabTip/well三态)/G4-1(C6 对焦清理任务)纯代码任务。真机门控 G1-1/G4-3/G5 待用户在场。
+  【★G3-3 完成 → M8 全量操作日志 C# 端可写代码全清(G3 三任务收官)★】
+  · §10 配置热生效:OperationLogOptions 加 ConfigFilePath/ApplyConfigJson + 新增 OperationLogConfigWatcher(后台轮询配置文件热应用,运维下发覆盖即生效不重编)
+  · §11 兜底补送:KafkaOplogTransport 加 onDeliveryFailed(投递失败/队列满落兜底)、LocalFileWriter 加补送原语(原子认领 .resending)、Pipeline 加 TryResend 定时器(认领→重投→删,重入+限流)
+  · 门面 OperationLogger 接线:transport 传 onDeliveryFailed:WriteFallback、InitCore 起 Watcher、Shutdown 释放。dotnet build Aivfo.OperationLog 0 error 0 警告;消费端门面签名未变零改动。修了 WIP 遗留 2 处 JToken.Value<bool>() 编译错
+  · ★真入库/补送/热生效待起 Kafka+oplog 端到端抽查(运行期,非真机门控)★。本次未提交,待与 G3-3 牵动文档一并 git
+  下一步:挑剩余纯代码任务——G2 UI(首页舱室弹框/13子页自适应/TabTip/well三态,最终视觉真机为准)或 G4-1(C6 对焦标定清理任务)。真机门控 G1-1(T1.4)/G4-3/G5 待用户在场。
   续接读:《工作计划表》+《当前开发计划》+ 本文件 + 交接卡末尾。
 说明: >
   M0-M5 全部【可写源码】已完成,C#合并端 0 error + M2-02 单测 15/15。工具链就位(JDK11.0.25 + Maven3.9.9