Selaa lähdekoodia

M8操作日志:加全局点击层(operate+front)+ §10按模块热开关接通(各项目自带oplog-config.json)

- 全局点击层:operate/front 各加 Helpers/ClickTrailLogger.cs,App 启动注册全局
  ButtonBase.Click 拦截,自动记 module="界面点击"、operation="页面·按钮文字"(导航/点击轨迹)。
  跳过软键盘按键(防泄露密码)与滚动条 RepeatButton;全 try 兜底不影响 UI。
  与逐动作埋点(带输入/输出/结果)互补:点击层记"点了什么",动作层记"结果如何"。
- §10 配置接通:两端 InitOperationLog 设 o.ConfigFilePath=BaseDirectory+oplog-config.json
  (此前未接,§10热开关形同虚设)。各项目根新增 oplog-config.json 随 exe 部署(csproj
  PreserveNewest),改部署目录那份并保存→≤15s热加载、免重启,按模块 enabled 开关日志。
  operate 列全部模块;front 仅 界面点击/HTTP(front 只有点击层+HTTP单点收口,无业务动作埋点)。
- CLAUDE.md 第五节扩成可操作排障方法:5.1两层日志/5.2起aivfo-oplog(全连108)/5.3无mysql客户端时
  JDK11+驱动JDBC直查operation_log/§10关层具体操作。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
huangjie 3 päivää sitten
vanhempi
commit
fea1c4179c

+ 22 - 0
CLAUDE.md

@@ -53,6 +53,28 @@
 - 系统做了"全量操作日志":C#/Java 所有操作记 谁/功能/输入/输出/报错/结果,经 Kafka→日志微服务 `aivfo-oplog`→`log` 库 `operation_log` 表,跨端共用 `trace_id`。
 - **排障第一步**:拿到 `trace_id`,把一次操作的跨端日志拉成时间线,找 `result=失败` 那条读 input+error 定位;调试级(串口/相机原始细节)走本地文件、按模块/按舱热开。
 
+### 5.1 两层日志(互补,别只看一层)
+- **动作层**(`OperationLogger.Begin`,各端逐方法埋点 G3-1/G3-2 + HTTP/串口/相机单点收口 P3b):带 **输入/输出/结果/报错/耗时**,module 如 `舱室设置/对焦调试/皿管理/胚胎操作/串口/相机/HTTP`。**定位失败靠这层**(读 `result=失败` 那条的 input+error)。
+- **点击层**(`module=界面点击`,operate+front 各有 `Helpers/ClickTrailLogger.cs` 全局 Button 点击拦截):记 **"哪个页面·点了哪个按钮"** 的导航/意图轨迹(含"进入舱室调试"这类纯导航),`operation="页面类名 · 按钮文字"`。只记点击瞬间、**不含结果**。已跳过软键盘按键(防泄露密码)与滚动条 RepeatButton。
+- **关层(怎么操作 §10 配置)**:每个程序**各有自己的 `oplog-config.json`**,源在项目根、随 exe 部署到其**输出目录(exe 同目录)**,改部署目录那份并保存,**≤15s 自动热加载、免重启**(已在两端 `App.xaml.cs InitOperationLog` 接 `o.ConfigFilePath = BaseDirectory + oplog-config.json`;监听器启动即读一次+轮询)。
+  - operate:源 `ivf_tl_operate_2.0/ivf_tl_Operate/oplog-config.json`(模块:界面点击/HTTP/串口/相机/舱室设置/对焦调试/对焦设置/缓冲瓶调试/皿管理/胚胎操作)。
+  - front:源 `aivfo-front-manament-2.0/ivf_tl_Manage/oplog-config.json`(模块仅 界面点击/HTTP——front 只有点击层+HTTP 单点收口,无业务动作埋点)。
+  - 格式:`{ "enabled":true, "globalLevel":"Info", "modules":{ "界面点击":{"enabled":false}, "HTTP":{"enabled":false} } }`。把模块 `enabled` 设 `false` 即停记;`modules` 没列的默认全开;顶层 `enabled=false` 一键全关;文件不存在=全开(开发默认)。生效后该程序日志有"操作日志配置已热加载"。
+
+### 5.2 怎么让日志真入库(运行期前置)
+1. **中间件全在 108 服务器**(MySQL `log` 库:3306 / Kafka:9092 / Nacos:8848)。换服务器见 `项目文档/开发环境/连接配置清单-换服务器必读.md`。
+2. **起 `aivfo-oplog` 消费端**(本机,默认 profile=local 全连 108;**机器重启即停需重起**):
+   `C:/TLData/tools/jdk-11/bin/java -Xmx256m -jar aivfo-oplog/target/aivfo-oplog-1.0.0-SNAPSHOT.jar`(或整集群 `bash 项目文档/开发环境/start-all.sh`)。日志看 `临时文件/run-oplog.log`,出现 `Subscribed to ... tl-oplog` + `HikariPool ... Start completed` 即就绪。
+3. operate/front 的 `App.config` `kfkaIP=192.168.0.108`、topic=`tl-oplog`,产消息端就绪。**不起 oplog 则消息只堆 Kafka 不入库**(这是"没记录"的最常见原因)。
+
+### 5.3 怎么查库(本机无 mysql 客户端时,用 JDK11 + 驱动直查)
+驱动:`C:/TLData/tools/maven-repo/mysql/mysql-connector-java/8.0.28/mysql-connector-java-8.0.28.jar`;库:`jdbc:mysql://192.168.0.108:3306/log`(root/root)。写个单文件 `临时文件/Q.java` 用 `java -cp <驱动> 临时文件/Q.java` 跑。`operation_log` 关键列:`id/trace_id/op_time/project/module/operation/operator/input/output/result/error/elapsed_ms/house_sn/well_sn/tl_sn`。常用查询:
+- 最近 N 条:`SELECT id,op_time,project,module,operation,result FROM operation_log ORDER BY id DESC LIMIT 20`
+- 拉一次操作跨端时间线:`... WHERE trace_id='xxx' ORDER BY op_time`
+- 只看失败:`... WHERE result='失败' ORDER BY id DESC LIMIT 20`(读对应 input+error)
+- 按模块/排除噪声:`... WHERE module<>'HTTP' ...` 或 `WHERE module='界面点击'`
+- 终端里中文显示乱码是控制台编码问题,库里是正确 UTF-8。
+
 ## 六、编译环境(若需本地编译)
 - JDK 11.0.25 + Maven 3.9.9 @ `C:\TLData\tools`;本地仓库 `C:\TLData\tools\maven-repo`。
 - Nexus 私服凭证已配在 `~/.m2/settings.xml`(admin);详细环境/账号见 `项目文档/开发环境/环境与账号清单.md`。

+ 6 - 0
aivfo-front-manament-2.0/ivf_tl_Manage/App.xaml.cs

@@ -57,6 +57,9 @@ namespace ivf_tl_Manage
             AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
             // C5:操作日志组件启动初始化(一次)。project=front,Kafka 从 App.config kfkaIP+kfkaPort。
             InitOperationLog();
+            // M8 点击层:注册全局按钮点击拦截,把每次点击记成 module="界面点击" 的操作日志(导航/点击轨迹)。
+            // 开发阶段全量记录便于排障;上线用 §10 配置关模块「界面点击」即可。
+            Helpers.ClickTrailLogger.Install();
             ChangeLanguage(ConfigurationManager.AppSettings["Language"].ToString());
         }
 
@@ -75,6 +78,9 @@ namespace ivf_tl_Manage
                     o.Project = "front";
                     o.KafkaBootstrapServers = $"{kafkaIp}:{kafkaPort}";
                     o.Topic = "tl-oplog";
+                    // M8 §10 配置热生效:改本程序 exe 同目录的 oplog-config.json 即可按模块开关日志(≤15s 生效,免重启)。
+                    // 跟项目走、随 exe 部署(源文件在项目根 oplog-config.json);文件不存在=全开(开发默认)。
+                    o.ConfigFilePath = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "oplog-config.json");
                 });
                 Log4netHelper.WriteLog($"[C5]操作日志组件已初始化 kafka={kafkaIp}:{kafkaPort} topic=tl-oplog");
             }

+ 100 - 0
aivfo-front-manament-2.0/ivf_tl_Manage/Helpers/ClickTrailLogger.cs

@@ -0,0 +1,100 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Media;
+using Aivfo.OperationLog;
+
+namespace ivf_tl_Manage.Helpers
+{
+    /// <summary>
+    /// 全局界面点击轨迹日志(M8 操作日志的「点击层」)。
+    /// App 启动时调用 <see cref="Install"/> 一次:注册全局 Button 点击拦截,
+    /// 自动把每次按钮点击记成一条 module="界面点击" 的操作日志,operation = "所在页面 · 按钮文字"。
+    ///
+    /// 设计要点:
+    /// - 开发阶段全量记录(含导航点击),便于排障;
+    /// - 上线时用 M8 §10 配置把模块「界面点击」整层关闭(OperationLogOptions.IsModuleEnabled),无需改代码;
+    /// - 与逐动作埋点(OperationLogger.Begin,带 输入/输出/结果/报错)互补:
+    ///   本层只记「点了什么」(意图/轨迹),动作层记「结果如何」(定位失败用);
+    /// - 一处注册、全 App 生效,无需各页逐个埋点;全 try 兜底,绝不影响 UI。
+    /// - 跳过控件内部机件与敏感按键:滚动条 RepeatButton、软键盘按键(避免逐键记录+泄露密码)。
+    /// </summary>
+    internal static class ClickTrailLogger
+    {
+        private static bool _installed;
+
+        public static void Install()
+        {
+            if (_installed) return;
+            _installed = true;
+            // handledEventsToo=true:即便点击被下游处理也能拿到,确保不漏。
+            EventManager.RegisterClassHandler(typeof(ButtonBase), ButtonBase.ClickEvent,
+                new RoutedEventHandler(OnAnyClick), true);
+        }
+
+        private static void OnAnyClick(object sender, RoutedEventArgs e)
+        {
+            try
+            {
+                if (!(sender is ButtonBase b)) return;
+                if (b is RepeatButton) return;                 // 滚动条/数值微调机件,非用户操作
+                if (IsInsideSoftKeyboard(b)) return;           // 软键盘按键:避免逐键记录 + 泄露密码
+                string label = ExtractLabel(b);
+                if (string.IsNullOrWhiteSpace(label)) return;  // 无可读标签(纯图标机件)不记
+                string page = FindPageName(b);
+                OperationLogger.Log("界面点击", page + " · " + label, result: "点击");
+            }
+            catch { /* 轨迹日志绝不影响 UI */ }
+        }
+
+        private static bool IsInsideSoftKeyboard(DependencyObject d)
+        {
+            for (int i = 0; d != null && i < 60; i++)
+            {
+                if (d.GetType().Name.IndexOf("SoftKeyboard", StringComparison.OrdinalIgnoreCase) >= 0) return true;
+                d = VisualTreeHelper.GetParent(d) ?? LogicalTreeHelper.GetParent(d);
+            }
+            return false;
+        }
+
+        private static string FindPageName(DependencyObject d)
+        {
+            for (int i = 0; d != null && i < 80; i++)
+            {
+                if (d is UserControl || d is Window) return d.GetType().Name;
+                d = VisualTreeHelper.GetParent(d) ?? LogicalTreeHelper.GetParent(d);
+            }
+            return "未知页面";
+        }
+
+        private static string ExtractLabel(ButtonBase b)
+        {
+            if (b.Content is string s && !string.IsNullOrWhiteSpace(s)) return Clip(s);
+            string t = FindFirstText(b);                       // 搜按钮可视子树第一个文字
+            if (!string.IsNullOrWhiteSpace(t)) return Clip(t);
+            if (!string.IsNullOrWhiteSpace(b.Name)) return b.Name;
+            if (b.ToolTip is string tip && !string.IsNullOrWhiteSpace(tip)) return Clip(tip);
+            return null;
+        }
+
+        private static string FindFirstText(DependencyObject d)
+        {
+            if (d is TextBlock tb && !string.IsNullOrWhiteSpace(tb.Text)) return tb.Text;
+            if (d is AccessText at && !string.IsNullOrWhiteSpace(at.Text)) return at.Text;
+            int n = VisualTreeHelper.GetChildrenCount(d);
+            for (int i = 0; i < n; i++)
+            {
+                string r = FindFirstText(VisualTreeHelper.GetChild(d, i));
+                if (!string.IsNullOrWhiteSpace(r)) return r;
+            }
+            return null;
+        }
+
+        private static string Clip(string s)
+        {
+            s = s.Trim().Replace("\r", " ").Replace("\n", " ");
+            return s.Length > 40 ? s.Substring(0, 40) : s;
+        }
+    }
+}

+ 8 - 0
aivfo-front-manament-2.0/ivf_tl_Manage/ivf_tl_Manage.csproj

@@ -465,6 +465,14 @@
     <None Remove="Resources\Images\zoomMinMouseOver.png" />
   </ItemGroup>
 
+  <ItemGroup>
+    <!-- M8 操作日志 §10 配置:随 exe 部署到输出目录,运行时按模块热开关。PreserveNewest=不覆盖运维在输出目录的改动。 -->
+    <None Remove="oplog-config.json" />
+    <Content Include="oplog-config.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+  </ItemGroup>
+
   <ItemGroup>
     <Content Include="Resources\1.ico" />
   </ItemGroup>

+ 11 - 0
aivfo-front-manament-2.0/ivf_tl_Manage/oplog-config.json

@@ -0,0 +1,11 @@
+{
+  "_说明": "M8 全量操作日志·按模块开关。本文件随 exe 部署在其同目录(front 自己的配置)。改完保存,front ≤15s 自动热加载、免重启。",
+  "_用法": "模块 enabled=false 即停记该模块;modules 里没列的模块默认全开;顶层 enabled=false 一键全关;globalLevel=Info/Debug。",
+  "_噪声层": "界面点击=全局点击轨迹;HTTP=接口调用(含高频轮询)。上线想清爽就把这两个设 false。",
+  "enabled": true,
+  "globalLevel": "Info",
+  "modules": {
+    "界面点击": { "enabled": true },
+    "HTTP": { "enabled": true }
+  }
+}

+ 6 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/App.xaml.cs

@@ -61,6 +61,9 @@ namespace ivf_tl_Operate
             ivf_tl_Operate.Helpers.AppConfigHelper.MigratePlaintextCredentials();
             // M8-P3b:操作日志组件启动初始化(一次)。project=operate,Kafka 从 App.config kfkaIP+kfkaPort。
             InitOperationLog();
+            // M8 点击层:注册全局按钮点击拦截,把每次点击记成 module="界面点击" 的操作日志(导航/点击轨迹)。
+            // 开发阶段全量记录便于排障;上线用 §10 配置关模块「界面点击」即可。
+            Helpers.ClickTrailLogger.Install();
             ChangeLanguage(ConfigurationManager.AppSettings["Language"].ToString());
         }
 
@@ -79,6 +82,9 @@ namespace ivf_tl_Operate
                     o.Project = "operate";
                     o.KafkaBootstrapServers = $"{kafkaIp}:{kafkaPort}";
                     o.Topic = "tl-oplog";
+                    // M8 §10 配置热生效:改本程序 exe 同目录的 oplog-config.json 即可按模块开关日志(≤15s 生效,免重启)。
+                    // 跟项目走、随 exe 部署(源文件在项目根 oplog-config.json);文件不存在=全开(开发默认)。
+                    o.ConfigFilePath = System.IO.Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "oplog-config.json");
                 });
                 Log4netHelper.WriteLog($"[M8-P3b]操作日志组件已初始化 kafka={kafkaIp}:{kafkaPort} topic=tl-oplog");
             }

+ 100 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/Helpers/ClickTrailLogger.cs

@@ -0,0 +1,100 @@
+using System;
+using System.Windows;
+using System.Windows.Controls;
+using System.Windows.Controls.Primitives;
+using System.Windows.Media;
+using Aivfo.OperationLog;
+
+namespace ivf_tl_Operate.Helpers
+{
+    /// <summary>
+    /// 全局界面点击轨迹日志(M8 操作日志的「点击层」)。
+    /// App 启动时调用 <see cref="Install"/> 一次:注册全局 Button 点击拦截,
+    /// 自动把每次按钮点击记成一条 module="界面点击" 的操作日志,operation = "所在页面 · 按钮文字"。
+    ///
+    /// 设计要点:
+    /// - 开发阶段全量记录(含「进入舱室调试」这类导航点击),便于排障;
+    /// - 上线时用 M8 §10 配置把模块「界面点击」整层关闭(OperationLogOptions.IsModuleEnabled),无需改代码;
+    /// - 与逐动作埋点(OperationLogger.Begin,带 输入/输出/结果/报错)互补:
+    ///   本层只记「点了什么」(意图/轨迹),动作层记「结果如何」(定位失败用);
+    /// - 一处注册、全 App 生效,无需各页逐个埋点;全 try 兜底,绝不影响 UI。
+    /// - 跳过控件内部机件与敏感按键:滚动条 RepeatButton、软键盘按键(避免逐键记录+泄露密码)。
+    /// </summary>
+    internal static class ClickTrailLogger
+    {
+        private static bool _installed;
+
+        public static void Install()
+        {
+            if (_installed) return;
+            _installed = true;
+            // handledEventsToo=true:即便点击被下游处理也能拿到,确保不漏。
+            EventManager.RegisterClassHandler(typeof(ButtonBase), ButtonBase.ClickEvent,
+                new RoutedEventHandler(OnAnyClick), true);
+        }
+
+        private static void OnAnyClick(object sender, RoutedEventArgs e)
+        {
+            try
+            {
+                if (!(sender is ButtonBase b)) return;
+                if (b is RepeatButton) return;                 // 滚动条/数值微调机件,非用户操作
+                if (IsInsideSoftKeyboard(b)) return;           // 软键盘按键:避免逐键记录 + 泄露密码
+                string label = ExtractLabel(b);
+                if (string.IsNullOrWhiteSpace(label)) return;  // 无可读标签(纯图标机件)不记
+                string page = FindPageName(b);
+                OperationLogger.Log("界面点击", page + " · " + label, result: "点击");
+            }
+            catch { /* 轨迹日志绝不影响 UI */ }
+        }
+
+        private static bool IsInsideSoftKeyboard(DependencyObject d)
+        {
+            for (int i = 0; d != null && i < 60; i++)
+            {
+                if (d.GetType().Name.IndexOf("SoftKeyboard", StringComparison.OrdinalIgnoreCase) >= 0) return true;
+                d = VisualTreeHelper.GetParent(d) ?? LogicalTreeHelper.GetParent(d);
+            }
+            return false;
+        }
+
+        private static string FindPageName(DependencyObject d)
+        {
+            for (int i = 0; d != null && i < 80; i++)
+            {
+                if (d is UserControl || d is Window) return d.GetType().Name;
+                d = VisualTreeHelper.GetParent(d) ?? LogicalTreeHelper.GetParent(d);
+            }
+            return "未知页面";
+        }
+
+        private static string ExtractLabel(ButtonBase b)
+        {
+            if (b.Content is string s && !string.IsNullOrWhiteSpace(s)) return Clip(s);
+            string t = FindFirstText(b);                       // 搜按钮可视子树第一个文字
+            if (!string.IsNullOrWhiteSpace(t)) return Clip(t);
+            if (!string.IsNullOrWhiteSpace(b.Name)) return b.Name;
+            if (b.ToolTip is string tip && !string.IsNullOrWhiteSpace(tip)) return Clip(tip);
+            return null;
+        }
+
+        private static string FindFirstText(DependencyObject d)
+        {
+            if (d is TextBlock tb && !string.IsNullOrWhiteSpace(tb.Text)) return tb.Text;
+            if (d is AccessText at && !string.IsNullOrWhiteSpace(at.Text)) return at.Text;
+            int n = VisualTreeHelper.GetChildrenCount(d);
+            for (int i = 0; i < n; i++)
+            {
+                string r = FindFirstText(VisualTreeHelper.GetChild(d, i));
+                if (!string.IsNullOrWhiteSpace(r)) return r;
+            }
+            return null;
+        }
+
+        private static string Clip(string s)
+        {
+            s = s.Trim().Replace("\r", " ").Replace("\n", " ");
+            return s.Length > 40 ? s.Substring(0, 40) : s;
+        }
+    }
+}

+ 8 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/ivf_tl_Operate.csproj

@@ -166,6 +166,14 @@
     <None Remove="Resources\newccd\小皿\Project2.dll" />
   </ItemGroup>
 
+  <ItemGroup>
+    <!-- M8 操作日志 §10 配置:随 exe 部署到输出目录,运行时按模块热开关。PreserveNewest=不覆盖运维在输出目录的改动。 -->
+    <None Remove="oplog-config.json" />
+    <Content Include="oplog-config.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </Content>
+  </ItemGroup>
+
   <ItemGroup>
     <Resource Include="Resources\1.ico">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>

+ 19 - 0
ivf_tl_operate_2.0/ivf_tl_Operate/oplog-config.json

@@ -0,0 +1,19 @@
+{
+  "_说明": "M8 全量操作日志·按模块开关。本文件随 exe 部署在其同目录(operate 自己的配置)。改完保存,operate ≤15s 自动热加载、免重启。",
+  "_用法": "模块 enabled=false 即停记该模块;modules 里没列的模块默认全开;顶层 enabled=false 一键全关;globalLevel=Info/Debug。",
+  "_噪声层": "界面点击=全局点击轨迹;HTTP=接口调用(含高频轮询 getAlarm/getAlarmNum/time)。上线想清爽就把这两个设 false。",
+  "enabled": true,
+  "globalLevel": "Info",
+  "modules": {
+    "界面点击": { "enabled": true },
+    "HTTP": { "enabled": true },
+    "串口": { "enabled": true },
+    "相机": { "enabled": true },
+    "舱室设置": { "enabled": true },
+    "对焦调试": { "enabled": true },
+    "对焦设置": { "enabled": true },
+    "缓冲瓶调试": { "enabled": true },
+    "皿管理": { "enabled": true },
+    "胚胎操作": { "enabled": true }
+  }
+}