From dd6258c98b832eba44e6a0359f015d7282860a0d Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 9 Sep 2026 02:48:07 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=B8=89=E7=B1=BB=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E7=9A=84=E8=A7=A6=E5=8F=91=E6=93=8D=E4=BD=9C=E5=92=8C?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E5=B9=B6=E5=AE=8C=E5=96=84=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/config/config.go | 17 ++-- backend/config/operation_log.go | 2 + backend/config/runtime_event.go | 32 ++++++++ backend/controllers/email/send.go | 2 +- backend/dto/response/response.go | 7 ++ backend/internal/app/app.go | 6 +- backend/listen/http_server/http_server.go | 2 +- backend/listen/http_server/https_server.go | 6 +- backend/listen/http_server/operation_log.go | 82 +++++++++++++++++-- backend/listen/imap_server/imap_server.go | 4 +- backend/listen/pop3_server/pop3server.go | 4 +- backend/listen/smtp_server/read_content.go | 43 ++++++++-- backend/listen/smtp_server/smtp.go | 6 +- backend/res_init/init.go | 28 +++---- backend/res_init/verification_delivery.go | 2 +- backend/services/logstore/mail.go | 59 ++++++++++++- backend/services/logstore/runtime.go | 4 +- backend/services/runtimelog/writer.go | 2 +- backend/services/setup/ssl/ssl.go | 20 +++-- backend/services/verification/delivery.go | 13 ++- backend/utils/send/send.go | 71 ++++++++++++++-- docs/日志改造任务.md | 11 +++ .../components/LogSettings/LogSettings.css | 10 ++- .../components/LogSettings/LogSettings.js | 10 ++- .../components/LogSettings/LogSettings.vue | 36 ++++++-- .../components/LogSettings/RuntimeLog.js | 16 ++++ test/backend/log_formatter_test.go | 30 +++++++ test/backend/mail_log_result_test.go | 45 ++++++++++ test/backend/operation_log_test.go | 56 ++++++++++++- test/backend/send_log_result_test.go | 23 ++++++ test/backend/smtp_mail_log_test.go | 67 +++++++++++++++ test/backend/verification_mail_log_test.go | 76 +++++++++++++++++ test/frontend/logs.test.mjs | 25 ++++++ test/run.mjs | 4 + 34 files changed, 734 insertions(+), 87 deletions(-) create mode 100644 backend/config/runtime_event.go create mode 100644 test/backend/mail_log_result_test.go create mode 100644 test/backend/send_log_result_test.go create mode 100644 test/backend/smtp_mail_log_test.go create mode 100644 test/backend/verification_mail_log_test.go diff --git a/backend/config/config.go b/backend/config/config.go index 5832a61..7bb4079 100644 --- a/backend/config/config.go +++ b/backend/config/config.go @@ -149,17 +149,19 @@ func (l *logFormatter) Format(entry *log.Entry) ([]byte, error) { b.WriteString(fmt.Sprintf("[%s]", entry.Level.String())) b.WriteString(fmt.Sprintf("[%s]", entry.Time.Format("2006-01-02 15:04:05"))) if entry.Context != nil { - ctx := entry.Context.(*context.Context) - if ctx != nil { + ctx, ok := entry.Context.(*context.Context) + if ok && ctx != nil { b.WriteString(fmt.Sprintf("[%s]", ctx.GetValue(context.LogID))) } } - b.WriteString(fmt.Sprintf("[%s:%d]", entry.Caller.File, entry.Caller.Line)) + if entry.Caller != nil { + b.WriteString(fmt.Sprintf("[%s:%d]", entry.Caller.File, entry.Caller.Line)) + } b.WriteString(entry.Message) // 错误字段也是有效内容,不能在消息为空时被格式器丢弃。 - if len(entry.Data) > 0 { - fields := make(map[string]any, len(entry.Data)) - for key, value := range entry.Data { + { + fields := runtimeEventFields(entry) + for key, value := range fields { if cause, ok := value.(error); ok { fields[key] = cause.Error() } else if _, err := json.Marshal(value); err != nil { @@ -172,7 +174,8 @@ func (l *logFormatter) Format(entry *log.Entry) ([]byte, error) { if err != nil { return nil, err } - b.WriteByte(' ') + // 固定边界使页面不必把消息中恰好出现的 JSON 当作可信事件元数据。 + b.WriteString(" @event ") b.Write(payload) } diff --git a/backend/config/operation_log.go b/backend/config/operation_log.go index 4376fe5..27ad49d 100644 --- a/backend/config/operation_log.go +++ b/backend/config/operation_log.go @@ -21,6 +21,8 @@ func (RuntimeConfigLogWriter) WriteConfigAudit(_ context.Context, event ConfigAu ConfigOperationMigration: "迁移配置", }[event.Operation] entry := log.WithFields(log.Fields{ + "action": action, + "object": event.Section, "operation": event.Operation, "section": event.Section, "actorId": event.ActorID, diff --git a/backend/config/runtime_event.go b/backend/config/runtime_event.go new file mode 100644 index 0000000..3d64355 --- /dev/null +++ b/backend/config/runtime_event.go @@ -0,0 +1,32 @@ +package config + +import ( + "strings" + + log "github.com/sirupsen/logrus" +) + +// runtimeEventFields 为文件日志提供稳定的事件边界;没有完成信号时只记为状态记录。 +// 不从任意消息中的“成功”或日志级别猜测业务结果,也不改变调用方的字段。 +func runtimeEventFields(entry *log.Entry) map[string]any { + fields := make(map[string]any, len(entry.Data)+4) + for key, value := range entry.Data { + fields[key] = value + } + fields["logSchema"] = 1 + if action, ok := fields["action"].(string); !ok || strings.TrimSpace(action) == "" { + action = "运行事件" + if entry.Caller != nil && entry.Caller.Function != "" { + function := entry.Caller.Function + if index := strings.LastIndex(function, "/"); index >= 0 { + function = function[index+1:] + } + action += " · " + function + } + fields["action"] = action + } + if result, ok := fields["result"].(string); !ok || strings.TrimSpace(result) == "" { + fields["result"] = "recorded" + } + return fields +} diff --git a/backend/controllers/email/send.go b/backend/controllers/email/send.go index 129d6bc..2cc05e8 100644 --- a/backend/controllers/email/send.go +++ b/backend/controllers/email/send.go @@ -285,7 +285,7 @@ func Send(ctx *context.Context, w http.ResponseWriter, req *http.Request) { async.New(ctx).Process(func(p any) { errMsg := "" - err, sendErr := send.Send(ctx, e) + err, sendErr := send.SendTriggered(ctx, e, "网页写信") log.WithContext(ctx).Debugf("插件执行--SendAfter") diff --git a/backend/dto/response/response.go b/backend/dto/response/response.go index 8b599d9..1e4a81b 100644 --- a/backend/dto/response/response.go +++ b/backend/dto/response/response.go @@ -31,6 +31,13 @@ func (p *Response) FPrint(w http.ResponseWriter) { if observer, ok := w.(interface{ ObserveResult(int) }); ok { observer.ObserveResult(p.ErrorNo) } + // 仅通知经过业务确认的中间状态,不向日志观察器传递响应载荷或验证凭据。 + if observer, ok := w.(interface{ ObservePending(bool) }); ok && p.ErrorNo == 0 { + if data, ok := p.Data.(map[string]any); ok { + pending, _ := data["pending"].(bool) + observer.ObservePending(pending) + } + } w.Header().Set("Content-Type", "application/json; charset=utf-8") if _, err := w.Write(bytesData); err != nil { log.WithError(err).Error("write HTTP response failed") diff --git a/backend/internal/app/app.go b/backend/internal/app/app.go index 13885ff..b50d301 100644 --- a/backend/internal/app/app.go +++ b/backend/internal/app/app.go @@ -36,7 +36,7 @@ func Run() (err error) { build.Version = "TestVersion" } - log.Infof("服务启动,版本:%s", build.Version) + log.WithFields(log.Fields{"action": "启动主服务", "object": "Smail", "result": "started"}).Infof("服务启动,版本:%s", build.Version) // 启动定时任务。 go cron_server.Start() @@ -46,7 +46,7 @@ func Run() (err error) { return err } - log.Warnf("Server Stoped \n") + log.WithFields(log.Fields{"action": "停止主服务", "object": "Smail", "result": "success"}).Info("主服务已停止") return nil } @@ -56,7 +56,7 @@ func finishRuntimeLogs(writer *runtimelog.Writer, runErr *error) { *runErr = fmt.Errorf("服务启动异常: %v", recovered) } if *runErr != nil { - log.WithError(*runErr).Error("服务因致命错误停止") + log.WithError(*runErr).WithFields(log.Fields{"action": "运行主服务", "object": "Smail", "result": "failure"}).Error("服务因致命错误停止") } if err := writer.Close(); err != nil { log.WithError(err).Error("关闭运行日志失败") diff --git a/backend/listen/http_server/http_server.go b/backend/listen/http_server/http_server.go index 20b0ea0..7e8e1f6 100644 --- a/backend/listen/http_server/http_server.go +++ b/backend/listen/http_server/http_server.go @@ -274,7 +274,7 @@ func HttpStart() error { IdleTimeout: time.Second * 30, MaxHeaderBytes: 16 << 10, } - log.Infof("HTTP server started on port %d", httpPort) + log.WithFields(log.Fields{"action": "启动 HTTP 监听", "object": fmt.Sprintf(":%d", httpPort), "result": "started"}).Info("开始绑定监听端口") if err := httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { return fmt.Errorf("serve HTTP: %w", err) } diff --git a/backend/listen/http_server/https_server.go b/backend/listen/http_server/https_server.go index 34afe97..a2e34bf 100644 --- a/backend/listen/http_server/https_server.go +++ b/backend/listen/http_server/https_server.go @@ -47,7 +47,7 @@ func HttpsStart() error { } httpCfg := config.CurrentHTTPConfig() if httpCfg.TLSMode == config.HTTPSTLSModeProxy { - log.Info("HTTPS 使用反向代理终止模式,跳过 Smail 内部 HTTPS 监听") + log.WithFields(log.Fields{"action": "启动内置 HTTPS 监听", "result": "skipped"}).Info("HTTPS 由反向代理处理,跳过内置监听") return nil } mux := http.NewServeMux() @@ -82,10 +82,10 @@ func HttpsStart() error { MaxHeaderBytes: 16 << 10, ErrorLog: nullLog, } - log.Infof("HTTPS server started on port %d", httpsPort) + log.WithFields(log.Fields{"action": "启动 HTTPS 监听", "object": httpsServer.Addr, "result": "started"}).Info("开始绑定监听端口") if err := httpsServer.ListenAndServeTLS(certificatePath, privateKeyPath); err != nil { if errors.Is(err, http.ErrServerClosed) { - log.Infof("HTTPS server closed normally on port %d", httpsPort) + log.WithFields(log.Fields{"action": "停止 HTTPS 监听", "object": httpsServer.Addr, "result": "success"}).Info("监听已正常关闭") return nil } return fmt.Errorf("serve HTTPS on port %d: %w", httpsPort, err) diff --git a/backend/listen/http_server/operation_log.go b/backend/listen/http_server/operation_log.go index aaec89e..9cc277b 100644 --- a/backend/listen/http_server/operation_log.go +++ b/backend/listen/http_server/operation_log.go @@ -5,8 +5,10 @@ import ( "encoding/json" "net/http" "strconv" + "strings" "time" + "git.pcux.cn/xuans/Smail/dto/response" "git.pcux.cn/xuans/Smail/services/logstore" appcontext "git.pcux.cn/xuans/Smail/utils/context" ) @@ -40,15 +42,70 @@ var loggedOperations = map[string]string{ type operationResponse struct { http.ResponseWriter - code int - known bool + code int + known bool + pending bool + status int } func (w *operationResponse) Unwrap() http.ResponseWriter { return w.ResponseWriter } func (w *operationResponse) ObserveResult(code int) { w.code, w.known = code, true } +func (w *operationResponse) ObservePending(pending bool) { w.pending = pending } + +func (w *operationResponse) WriteHeader(status int) { + if w.status == 0 { + w.status = status + } + w.ResponseWriter.WriteHeader(status) +} + +func (w *operationResponse) Write(data []byte) (int, error) { + if w.status == 0 { + w.status = http.StatusOK + } + return w.ResponseWriter.Write(data) +} + +// 只从白名单路由识别对象类别,不解析或复制含密码、验证码的请求正文。 +func operationObject(path string) string { + for _, item := range []struct{ prefix, object string }{ + {"/api/email/", "邮件"}, {"/api/group/", "邮件分组"}, {"/api/rule/", "邮件规则"}, + {"/api/user/", "用户账号"}, {"/api/security/", "账号安全"}, + {"/api/settings/", "账号设置"}, + } { + if strings.HasPrefix(path, item.prefix) { + return item.object + } + } + return "账号会话" +} + +func operationOutcome(code int, result string) string { + switch result { + case "success": + return "操作已完成" + case "pending": + return "等待后续验证,操作尚未完成" + case "unknown": + return "接口未返回可确认的业务结果" + } + switch code { + case response.ParamsError: + return "参数或身份验证未通过" + case response.NeedLogin: + return "需要重新登录" + case response.NoAccessPrivileges: + return "当前操作权限或安全条件不满足" + case response.NeedSetup: + return "系统尚未完成初始化" + default: + return "操作失败,请结合结果代码和运行日志排查" + } +} func operationLog(ctx *appcontext.Context, req *http.Request, w http.ResponseWriter) (http.ResponseWriter, func()) { - action := loggedOperations[normalizedAPIPath(req.URL.Path)] + path := normalizedAPIPath(req.URL.Path) + action := loggedOperations[path] if action == "" || !stateChangingMethod(req.Method) { return w, func() {} } @@ -61,11 +118,22 @@ func operationLog(ctx *appcontext.Context, req *http.Request, w http.ResponseWri result = "failure" } } + if result == "success" && observed.pending { + result = "pending" + } + if observed.status >= http.StatusBadRequest { + result = "failure" + } payload, _ := json.Marshal(struct { - ActorName string `json:"actorName"` - ActorRole string `json:"actorRole"` - ErrorCode int `json:"errorCode"` - }{ctx.UserAccount, string(ctx.EffectiveRole()), observed.code}) + ActorName string `json:"actorName"` + ActorRole string `json:"actorRole"` + ErrorCode int `json:"errorCode"` + Object string `json:"object"` + Trigger string `json:"trigger"` + Outcome string `json:"outcome"` + HTTPStatus int `json:"httpStatus"` + }{ctx.UserAccount, string(ctx.EffectiveRole()), observed.code, + operationObject(path), req.Method + " " + path, operationOutcome(observed.code, result), observed.status}) logstore.Record(context.Background(), logstore.Entry{ Kind: "operation", CreatedAt: time.Now(), ActorID: strconv.Itoa(ctx.UserID), Action: action, Result: result, Payload: payload, }) diff --git a/backend/listen/imap_server/imap_server.go b/backend/listen/imap_server/imap_server.go index 456735c..4d8c4a9 100644 --- a/backend/listen/imap_server/imap_server.go +++ b/backend/listen/imap_server/imap_server.go @@ -35,7 +35,7 @@ func Start() error { } instance = server imapPort := config.CurrentMailPorts().IMAP - log.Infof("IMAP With STARTTLS Server Start On Port :%d", imapPort) + log.WithFields(log.Fields{"action": "启动 IMAP STARTTLS 监听", "object": fmt.Sprintf(":%d", imapPort), "result": "started"}).Info("开始绑定监听端口") if err := instance.ListenAndServe(fmt.Sprintf(":%d", imapPort)); err != nil { return fmt.Errorf("serve IMAP STARTTLS: %w", err) } @@ -50,7 +50,7 @@ func StarTLS() error { } instanceTLS = server imapTLSPort := config.CurrentMailPorts().IMAP - log.Infof("IMAP With TLS Server Start On Port :%d", imapTLSPort) + log.WithFields(log.Fields{"action": "启动 IMAP TLS 监听", "object": fmt.Sprintf(":%d", imapTLSPort), "result": "started"}).Info("开始绑定监听端口") if err := instanceTLS.ListenAndServeTLS(fmt.Sprintf(":%d", imapTLSPort)); err != nil { return fmt.Errorf("serve IMAP TLS: %w", err) } diff --git a/backend/listen/pop3_server/pop3server.go b/backend/listen/pop3_server/pop3server.go index cc5c0d4..193b699 100644 --- a/backend/listen/pop3_server/pop3server.go +++ b/backend/listen/pop3_server/pop3server.go @@ -34,7 +34,7 @@ func StartWithTls() error { instanceTls = gopop.NewPop3Server(port, "pop."+domains.Primary, true, tlsConfig, action{}) instanceTls.ConnectAliveTime = 5 * time.Minute - log.Infof("POP3 With TLS Server Start On Port :%d", port) + log.WithFields(log.Fields{"action": "启动 POP3 TLS 监听", "object": fmt.Sprintf(":%d", port), "result": "started"}).Info("开始绑定监听端口") err = instanceTls.Start() if err != nil { @@ -63,7 +63,7 @@ func Start() error { port := config.CurrentMailPorts().POP3 instance = gopop.NewPop3Server(port, "pop."+domains.Primary, false, tlsConfig, action{}) instance.ConnectAliveTime = 5 * time.Minute - log.Infof("POP3 Server Start On Port :%d", port) + log.WithFields(log.Fields{"action": "启动 POP3 STLS 监听", "object": fmt.Sprintf(":%d", port), "result": "started"}).Info("开始绑定监听端口") err = instance.Start() if err != nil { diff --git a/backend/listen/smtp_server/read_content.go b/backend/listen/smtp_server/read_content.go index 925e760..ee7fa15 100644 --- a/backend/listen/smtp_server/read_content.go +++ b/backend/listen/smtp_server/read_content.go @@ -41,15 +41,25 @@ const DropUnknownRecipientEmails = true func (s *Session) Data(r io.Reader) (resultErr error) { messageBytes := 0 + result := "unknown" + details := logstore.MailDetails{Trigger: "SMTP 收信", Stage: "读取邮件内容"} defer func() { - result, direction := "success", "inbound" + direction := "inbound" if resultErr != nil { result = "failure" } if s.Ctx.UserID > 0 { direction = "submission" + details.Trigger = "SMTP 客户端提交" } - logstore.MailResult(s.Ctx.UserID, direction, s.From, len(s.To), messageBytes, result) + if result == "failure" { + details.Outcome = details.Stage + "失败" + var protocol *smtp.SMTPError + if oerrors.As(resultErr, &protocol) { + details.Outcome += fmt.Sprintf(",SMTP 状态码 %d", protocol.Code) + } + } + logstore.MailResult(s.Ctx.UserID, direction, s.From, len(s.To), messageBytes, result, details) }() ctx := s.Ctx @@ -68,6 +78,7 @@ func (s *Session) Data(r io.Reader) (resultErr error) { log.WithContext(ctx).Debugf("message_bytes=%d", len(emailData)) + details.Stage = "执行收信解析前插件" log.WithContext(ctx).Debugf("开始执行插件ReceiveParseBefore!") for _, hook := range hooks.Snapshot() { if hook == nil { @@ -80,6 +91,7 @@ func (s *Session) Data(r io.Reader) (resultErr error) { if len(emailData) > maxSMTPMessageBytes { return &smtp.SMTPError{Code: 552, EnhancedCode: smtp.EnhancedCode{5, 3, 4}, Message: "Message size exceeds fixed maximum message size"} } + details.Stage = "解析与校验邮件" email, err := parsemail.NewEmailFromReader(s.To, bytes.NewReader(emailData), len(emailData)) if err != nil { log.WithContext(ctx).Warnf("audit_event=message_parse_failed message_bytes=%d error=%v", len(emailData), err) @@ -104,10 +116,12 @@ func (s *Session) Data(r io.Reader) (resultErr error) { // 判断是收信还是转发,只要是登陆了,都当成转发处理 if s.Ctx.UserID > 0 { + details.Stage = "校验发件权限" if !authorizedSubmissionEmail(email, s.From, ctx) { return oerrors.New("No Auth") } + details.Stage = "执行发信前插件" log.WithContext(ctx).Debugf("开始执行插件SendBefore!") for _, hook := range hooks.Snapshot() { if hook == nil { @@ -124,12 +138,18 @@ func (s *Session) Data(r io.Reader) (resultErr error) { return oerrors.New("No Auth") } - return s.deliverOutgoingEmail(email, send.Send) + details.Stage = "保存并投递客户端邮件" + resultErr = s.deliverOutgoingEmail(email, send.Send) + if resultErr == nil { + result = "accepted" + } + return resultErr } else { // 收件 var dkimStatus, SPFStatus bool + details.Stage = "验证收信身份与插件处理" // DKIM校验 dkimStatus = parsemail.Check(ctx, bytes.NewReader(emailData)) @@ -158,11 +178,17 @@ func (s *Session) Data(r io.Reader) (resultErr error) { email.Status = 3 } + details.Stage = "保存收到的邮件" users, dbEmail, saveErr := saveEmail(ctx, len(emailData), email, 0, 0, s.To, SPFStatus, dkimStatus) if saveErr != nil { log.WithContext(ctx).Errorf("CRITICAL audit_event=initial_persist_failed direction=inbound msg_id=%q error=%v", email.MsgID, saveErr) return localPersistenceSMTPError() } + if dbEmail == nil { + result = "discarded" + return nil + } + details.Stage = "执行收信规则与通知" if email.MessageId > 0 { ruleBaseContext := ctx.Context @@ -175,19 +201,19 @@ func (s *Session) Data(r io.Reader) (resultErr error) { log.WithContext(ctx).Debugf("开始执行邮件规则!") for _, user := range users { if ruleExecutionContext.Err() != nil { - log.WithContext(ctx).Warn("email rule processing budget exceeded") + log.WithContext(ctx).WithFields(log.Fields{"action": "执行收信规则", "result": "failure"}).Warn("规则处理超时,已停止后续规则") break } // 执行邮件规则 rs, ruleErr := rule.GetAllRules(&ruleContext, user.ID) if ruleErr != nil { - log.WithContext(ctx).WithField("user_id", user.ID).WithError(ruleErr).Error("load email rules failed") + log.WithContext(ctx).WithFields(log.Fields{"action": "读取收信规则", "result": "failure", "user_id": user.ID}).WithError(ruleErr).Error("读取收信规则失败") continue } for _, r := range rs { if rule.MatchRule(&ruleContext, r, email) { if err := rule.DoRule(&ruleContext, r, email, user, emailData); err != nil { - log.WithContext(ctx).WithField("user_id", user.ID).WithError(err).Error("execute email rule failed") + log.WithContext(ctx).WithFields(log.Fields{"action": "执行收信规则", "result": "failure", "user_id": user.ID}).WithError(err).Error("执行收信规则失败") } } } @@ -216,12 +242,15 @@ func (s *Session) Data(r io.Reader) (resultErr error) { // IDLE命令通知 for _, user := range users { if err := imap_server.IdleNotice(ctx, user.ID, dbEmail); err != nil { - log.WithContext(ctx).WithError(err).Error("send IMAP IDLE notification failed") + log.WithContext(ctx).WithFields(log.Fields{"action": "通知 IMAP 客户端新邮件", "result": "failure"}).WithError(err).Error("邮件已保存,但新邮件通知失败") } } } + result = "success" + details.Stage = "接收并保存邮件" + details.Outcome = "邮件已写入本地邮箱;规则与通知的异常单独记录在运行日志" return nil } diff --git a/backend/listen/smtp_server/smtp.go b/backend/listen/smtp_server/smtp.go index 9e6f116..e740cff 100644 --- a/backend/listen/smtp_server/smtp.go +++ b/backend/listen/smtp_server/smtp.go @@ -50,7 +50,7 @@ func StartWithTLSNew() error { // Configure the TLS support for STARTTLS instanceTlsNew.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}, MinVersion: tls.VersionTLS12} - log.Println("Starting Smtp With STARTTLS Server Port:", instanceTlsNew.Addr) + log.WithFields(log.Fields{"action": "启动 SMTP STARTTLS 监听", "object": instanceTlsNew.Addr, "result": "started"}).Info("开始绑定监听端口") // 提交端口使用 STARTTLS(先连接,再升级 TLS),而非隐式 TLS。 if err := instanceTlsNew.ListenAndServe(); err != nil && !errors.Is(err, smtp.ErrServerClosed) { return fmt.Errorf("serve SMTP STARTTLS: %w", err) @@ -87,7 +87,7 @@ func StartWithTLS() error { // Configure the TLS support instanceTls.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}, MinVersion: tls.VersionTLS12} - log.Println("Starting Smtp With SSL Server Port:", instanceTls.Addr) + log.WithFields(log.Fields{"action": "启动 SMTP TLS 监听", "object": instanceTls.Addr, "result": "started"}).Info("开始绑定监听端口") if err := instanceTls.ListenAndServeTLS(); err != nil && !errors.Is(err, smtp.ErrServerClosed) { return fmt.Errorf("serve SMTP TLS: %w", err) } @@ -123,7 +123,7 @@ func Start() error { // Configure the TLS support instance.TLSConfig = &tls.Config{Certificates: []tls.Certificate{cer}, MinVersion: tls.VersionTLS12} - log.Println("Starting Smtp Server Port:", instance.Addr) + log.WithFields(log.Fields{"action": "启动 SMTP 收信监听", "object": instance.Addr, "result": "started"}).Info("开始绑定监听端口") if err := instance.ListenAndServe(); err != nil && !errors.Is(err, smtp.ErrServerClosed) { return fmt.Errorf("serve SMTP: %w", err) } diff --git a/backend/res_init/init.go b/backend/res_init/init.go index 2e005ac..1953ba1 100644 --- a/backend/res_init/init.go +++ b/backend/res_init/init.go @@ -187,7 +187,7 @@ func Init(serverVersion string) error { return err } if err := startPluginConfigMigration(); err != nil { - log.WithError(err).Warn("plugin secret migration failed; affected plugins remain disabled") + log.WithError(err).WithFields(log.Fields{"action": "迁移插件凭据", "result": "failure"}).Warn("插件凭据迁移失败,受影响的插件保持禁用") } if err := session.Init(); err != nil { return err @@ -246,24 +246,24 @@ func Init(serverVersion string) error { startService(serviceErrors, "IMAP TLS", imap_server.StarTLS) } - log.Info("邮箱服务已启动") + log.WithFields(log.Fields{"action": "启动邮箱监听", "object": "SMTP / IMAP / POP3", "result": "started"}).Info("邮箱监听任务已提交,具体监听结果由各服务报告") restart.SetCurrent(restart.NewSignalTrigger(signal.RestartChan)) select { case err := <-serviceErrors: - log.WithError(err).Error("邮箱服务运行失败") + log.WithError(err).WithFields(log.Fields{"action": "运行邮箱服务", "result": "failure"}).Error("邮箱服务运行失败") stopServices() return err case <-signal.RestartChan: - log.Infof("Server Restart!") + log.WithFields(log.Fields{"action": "重启服务", "result": "started"}).Info("已收到服务重启请求") if err := stopServices(); err != nil { return err } case <-signal.StopChan: - log.Infof("Server Stop!") + log.WithFields(log.Fields{"action": "停止服务", "result": "started"}).Info("已收到服务停止请求") return stopServices() } - log.Infof("Server Stop Success!") + log.WithFields(log.Fields{"action": "停止旧服务", "result": "success"}).Info("旧服务已停止,等待重新初始化") time.Sleep(5 * time.Second) } @@ -344,7 +344,7 @@ func startVerificationRuntime() error { controllers.ConfigureSecurityRuntime(service, external, recover, credential) credentialService = credential verificationRuntime = runtime - log.Info("安全验证码服务已启动,已配置中继时优先使用中继,否则使用项目自身邮件投递") + log.WithFields(log.Fields{"action": "初始化验证码服务", "result": "success"}).Info("安全验证码服务已启动,已配置中继时优先使用中继,否则使用项目自身邮件投递") return nil } @@ -384,7 +384,7 @@ func startWebAuthnRuntime() error { } webAuthnRuntimeService = service controllers.ConfigureWebAuthnRuntime(service) - log.Info("WebAuthn service started") + log.WithFields(log.Fields{"action": "初始化通行密钥服务", "result": "success"}).Info("通行密钥服务已启动") return nil } @@ -411,7 +411,7 @@ func startAdminResetControlWithListener(development bool, path string, listen fu return nil } if development { - log.Warn("开发模式:已停用本机超级管理员密码重置通道,后端无需为此提升权限;正式部署请移除开发参数") + log.WithFields(log.Fields{"action": "启动管理员恢复通道", "result": "skipped"}).Warn("开发模式:已停用本机超级管理员密码重置通道,后端无需为此提升权限;正式部署请移除开发参数") return nil } if path == "" { @@ -435,10 +435,10 @@ func startAdminResetControlWithListener(development bool, path string, listen fu }, adminreset.NewSessionGuard(2*time.Minute)) serveErrors <- err if err != nil { - log.WithError(err).Error("管理员重置通道启动失败") + log.WithError(err).WithFields(log.Fields{"action": "运行管理员恢复通道", "result": "failure"}).Error("管理员恢复通道运行失败") } }() - log.Info("管理员重置通道已启动") + log.WithFields(log.Fields{"action": "启动管理员恢复通道", "result": "started"}).Info("管理员恢复通道已创建,开始处理本机请求") return nil } @@ -449,7 +449,7 @@ func stopAdminResetControl() { } if adminResetControl != nil { if err := adminResetControl.Close(); err != nil { - log.WithError(err).Warn("关闭管理员重置通道失败") + log.WithError(err).WithFields(log.Fields{"action": "停止管理员恢复通道", "result": "failure"}).Warn("关闭管理员恢复通道失败") } adminResetControl = nil adminResetServeErrors = nil @@ -484,7 +484,7 @@ func waitHTTPReady(serviceErrors <-chan error) error { resp, err := client.Get(url) if err == nil && resp != nil && resp.StatusCode == http.StatusOK { _ = resp.Body.Close() - log.Infof("HTTP ready: %s", url) + log.WithFields(log.Fields{"action": "检查 HTTP 就绪状态", "result": "success"}).Infof("HTTP 服务健康检查通过:%s", url) return nil } if resp != nil { @@ -497,6 +497,6 @@ func waitHTTPReady(serviceErrors <-chan error) error { } time.Sleep(1 * time.Second) } - log.Warnf("HTTP not ready after 90s, skipping immediate SSL update") + log.WithFields(log.Fields{"action": "检查 HTTP 就绪状态", "result": "failure"}).Warn("HTTP 服务九十秒内未就绪,跳过即时证书更新") return nil } diff --git a/backend/res_init/verification_delivery.go b/backend/res_init/verification_delivery.go index e7feb90..8996a13 100644 --- a/backend/res_init/verification_delivery.go +++ b/backend/res_init/verification_delivery.go @@ -112,7 +112,7 @@ func deliverVerificationWithProjectMail(challenge verification.Challenge, code s Context: context.Background(), Values: make(map[string]any), } - if deliveryErr, _ := send.Send(ctx, message); deliveryErr != nil { + if deliveryErr, _ := send.SendTriggered(ctx, message, "系统安全验证邮件"); deliveryErr != nil { return fmt.Errorf("%w: project mail delivery failed", verification.ErrMailDeliveryFailed) } return nil diff --git a/backend/services/logstore/mail.go b/backend/services/logstore/mail.go index 36b628d..b41680b 100644 --- a/backend/services/logstore/mail.go +++ b/backend/services/logstore/mail.go @@ -2,13 +2,47 @@ package logstore import ( "context" + "crypto/tls" "encoding/json" + "errors" + "net" + "net/textproto" "strconv" "time" ) +type MailDetails struct { + Trigger string `json:"trigger"` + Stage string `json:"stage"` + Outcome string `json:"outcome"` + AcceptedCount int `json:"acceptedCount"` +} + +// MailFailureReason 使用错误类别给出原因,不保存可能携带凭据的远端响应原文。 +func MailFailureReason(err error) string { + var certificate *tls.CertificateVerificationError + var dns *net.DNSError + var protocol *textproto.Error + var network net.Error + switch { + case errors.As(err, &certificate): + return "对端证书验证失败" + case errors.As(err, &dns): + return "收件域名解析失败" + case errors.As(err, &protocol): + return "对端 SMTP 拒绝,状态码 " + strconv.Itoa(protocol.Code) + case errors.As(err, &network): + if network.Timeout() { + return "投递连接超时" + } + return "投递网络连接失败" + default: + return "投递未完成,请结合运行日志排查" + } +} + // MailResult 记录投递尝试元数据,不复制正文、附件、认证信息或服务器错误原文。 -func MailResult(userID int, direction string, sender string, recipientCount, size int, result string) { +func MailResult(userID int, direction string, sender string, recipientCount, size int, result string, details ...MailDetails) { if len(sender) > 320 { sender = "地址超出显示长度" } @@ -17,7 +51,8 @@ func MailResult(userID int, direction string, sender string, recipientCount, siz Sender string `json:"sender"` RecipientCount int `json:"recipientCount"` Bytes int `json:"bytes"` - }{direction, sender, recipientCount, size}) + MailDetails + }{direction, sender, recipientCount, size, mailDetails(direction, result, details)}) action := "接收邮件" if direction == "outbound" { action = "投递邮件" @@ -29,3 +64,23 @@ func MailResult(userID int, direction string, sender string, recipientCount, siz Kind: "mail", ActorID: strconv.Itoa(userID), CreatedAt: time.Now(), Action: action, Result: result, Payload: payload, }) } + +func mailDetails(direction, result string, details []MailDetails) MailDetails { + value := MailDetails{Trigger: "SMTP 收信", Stage: "接收处理"} + if direction == "outbound" { + value.Trigger, value.Stage = "邮件发送", "对外投递" + } else if direction == "submission" { + value.Trigger, value.Stage = "SMTP 客户端提交", "提交处理" + } + if len(details) > 0 { + value = details[0] + } + if value.Outcome == "" { + value.Outcome = map[string]string{ + "success": "当前处理阶段已完成", "accepted": "对端服务器已接收,不代表收件人已阅读或最终入箱", + "failure": "当前处理阶段失败", "partial": "部分收件人已被对端接收,其余投递失败", + "discarded": "已按收信策略丢弃,未写入邮箱", "unknown": "处理未正常完成,结果待确认", + }[result] + } + return value +} diff --git a/backend/services/logstore/runtime.go b/backend/services/logstore/runtime.go index 54bfdc2..41eb1da 100644 --- a/backend/services/logstore/runtime.go +++ b/backend/services/logstore/runtime.go @@ -23,7 +23,7 @@ func Record(ctx context.Context, entry Entry) { bounded, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() if err := store.Write(bounded, entry); err != nil { - log.WithError(err).Error("业务日志写入失败") + log.WithError(err).WithFields(log.Fields{"action": "写入业务日志", "object": entry.Kind, "result": "failure"}).Error("业务日志写入失败") } } @@ -47,7 +47,7 @@ func Start(store *Store) *Runtime { _, err := store.Cleanup(bounded, time.Now()) stop() if err != nil && ctx.Err() == nil { - log.WithError(err).Error("过期业务日志清理失败,将在下一轮重试") + log.WithError(err).WithFields(log.Fields{"action": "清理过期业务日志", "result": "failure"}).Error("过期业务日志清理失败,将在下一轮重试") } select { case <-ctx.Done(): diff --git a/backend/services/runtimelog/writer.go b/backend/services/runtimelog/writer.go index 705f5af..49a0a3d 100644 --- a/backend/services/runtimelog/writer.go +++ b/backend/services/runtimelog/writer.go @@ -71,7 +71,7 @@ func Start(root string) (*Writer, error) { err := w.Maintain(bounded, w.now()) stop() if err != nil && ctx.Err() == nil { - log.WithError(err).Error("运行日志维护失败,源文件保留并在下一轮重试") + log.WithError(err).WithFields(log.Fields{"action": "压缩与清理运行日志", "result": "failure"}).Error("运行日志维护失败,源文件保留并在下一轮重试") } select { case <-ctx.Done(): diff --git a/backend/services/setup/ssl/ssl.go b/backend/services/setup/ssl/ssl.go index d96a232..412261f 100644 --- a/backend/services/setup/ssl/ssl.go +++ b/backend/services/setup/ssl/ssl.go @@ -433,7 +433,7 @@ func renewCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config) error { Bundle: true, } - log.Infof("wait ssl renew") + log.WithFields(log.Fields{"action": "续期 TLS 证书", "result": "started"}).Info("开始请求续期证书") certificates, err := client.Certificate.Obtain(request) if err != nil { return errors.Wrap(err) @@ -500,22 +500,23 @@ func generateCertificate(privateKey *ecdsa.PrivateKey, cfg *config.Config, newAc as := async.New(nil) as.Process(func(params any) { - log.Infof("wait ssl") + log.WithFields(log.Fields{"action": "申请 TLS 证书", "result": "started"}).Info("开始向证书颁发机构申请证书") certificates, err := client.Certificate.Obtain(request) if err != nil { - log.WithError(err).Error("certificate issuance failed") + log.WithError(err).WithFields(log.Fields{"action": "申请 TLS 证书", "result": "failure"}).Error("证书申请失败") return } - log.Infof("证书校验通过!") + log.WithFields(log.Fields{"action": "申请 TLS 证书", "result": "success"}).Info("已取得证书,开始校验并保存") if err := writeCertificateFiles(cfg, certificates.PrivateKey, certificates.Certificate, certificates.IssuerCertificate); err != nil { - log.WithError(err).Error("certificate storage failed") + log.WithError(err).WithFields(log.Fields{"action": "保存 TLS 证书", "result": "failure"}).Error("证书保存失败") return } if err := setup.Finish(); err != nil { - log.WithError(err).Error("finish setup after certificate issuance failed") + log.WithError(err).WithFields(log.Fields{"action": "完成证书初始化", "result": "failure"}).Error("证书已保存,但完成初始化失败") return } + log.WithFields(log.Fields{"action": "完成证书初始化", "result": "success"}).Info("证书已保存并完成初始化") }, nil) @@ -602,15 +603,16 @@ func Update(needRestart bool) { if days < 30 || err != nil || nameMatchFail { if err != nil { - log.Errorf("SSL Check Error, Update SSL Certificate. Error Info :%+v", err) + log.WithError(err).WithFields(log.Fields{"action": "检查 TLS 证书", "result": "failure"}).Warn("证书检查失败,准备重新申请") } else { - log.Infof("SSL certificate remaining time is only %d days, renew SSL certificate.", days) + log.WithFields(log.Fields{"action": "检查 TLS 证书", "result": "recorded", "remainingDays": days}).Info("证书需要更新,准备重新申请") } err = GenSSL(true) if err != nil { - log.Errorf("SSL Update Error! %+v", err) + log.WithError(err).WithFields(log.Fields{"action": "更新 TLS 证书", "result": "failure"}).Error("证书更新失败") return } + log.WithFields(log.Fields{"action": "更新 TLS 证书", "result": "success"}).Info("证书已更新并保存") if needRestart { // 更新完证书,重启服务 signal.RestartChan <- true diff --git a/backend/services/verification/delivery.go b/backend/services/verification/delivery.go index d08161a..aed4266 100644 --- a/backend/services/verification/delivery.go +++ b/backend/services/verification/delivery.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "git.pcux.cn/xuans/Smail/services/logstore" smailsmtp "git.pcux.cn/xuans/Smail/utils/smtp" ) @@ -58,7 +59,17 @@ func (d *SMTPDelivery) Delivery() Delivery { } message := buildCodeMessage(d.settings.From, challenge.Email, code, challenge.Purpose) defer clearBytes(message) - return d.send(d.settings, challenge.Email, message) + err := d.send(d.settings, challenge.Email, message) + result := "accepted" + details := logstore.MailDetails{Trigger: "系统安全验证邮件", Stage: "SMTP 中继投递", AcceptedCount: 1} + if err != nil { + result = "failure" + details.AcceptedCount = 0 + details.Outcome = logstore.MailFailureReason(err) + } + // 只记录发送元数据,不保存外部邮箱、验证码、邮件内容及中继凭据。 + logstore.MailResult(0, "outbound", d.settings.From, 1, len(message), result, details) + return err } } diff --git a/backend/utils/send/send.go b/backend/utils/send/send.go index 8f94fdb..06dd30f 100644 --- a/backend/utils/send/send.go +++ b/backend/utils/send/send.go @@ -17,8 +17,10 @@ import ( "net" "net/mail" "net/textproto" + "sort" "strings" "sync" + "sync/atomic" ) type mxDomain struct { @@ -77,23 +79,33 @@ func forwardData(ctx *context.Context, fromDomain string, data []byte, forwardAd {EmailAddress: forwardAddress}, } - err, _ := doSend(ctx, fromDomain, data, to, from) + err, _ := doSend(ctx, fromDomain, data, to, from, "邮件规则转发") return err } func Send(ctx *context.Context, e *parsemail.Email) (error, map[string]error) { + return SendTriggered(ctx, e, "SMTP 客户端提交") +} +// SendTriggered 显式标记业务发起方,不从邮件主题或内容推断来源。 +func SendTriggered(ctx *context.Context, e *parsemail.Email, trigger string) (error, map[string]error) { _, fromDomain := e.From.GetDomainAccount() b, err := e.BuildBytes(ctx, true) if err != nil { + userID := 0 + if ctx != nil { + userID = ctx.UserID + } + logstore.MailResult(userID, "outbound", e.From.EmailAddress, len(e.To)+len(e.Cc)+len(e.Bcc), 0, "failure", + logstore.MailDetails{Trigger: trigger, Stage: "构建待发邮件", Outcome: "邮件编码或签名失败,未开始对外投递"}) return fmt.Errorf("build message: %w", err), nil } var to []*parsemail.User to = append(append(append(to, e.To...), e.Cc...), e.Bcc...) - return doSend(ctx, fromDomain, b, to, e.From.EmailAddress) + return doSend(ctx, fromDomain, b, to, e.From.EmailAddress, trigger) } @@ -120,17 +132,41 @@ func parseRecipientAddress(user *parsemail.User) (string, string, error) { return address, domain, nil } -func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemail.User, from string) (sendErr error, recipientErrors map[string]error) { +func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemail.User, from string, triggers ...string) (sendErr error, recipientErrors map[string]error) { + var accepted atomic.Int64 + trigger := "邮件发送" + if len(triggers) > 0 { + trigger = triggers[0] + } defer func() { - result := "success" - if sendErr != nil || len(recipientErrors) > 0 { - result = "failure" - } + result := deliveryLogResult(int(accepted.Load()), len(to), sendErr != nil || len(recipientErrors) > 0) userID := 0 if ctx != nil { userID = ctx.UserID } - logstore.MailResult(userID, "outbound", from, len(to), len(data), result) + details := logstore.MailDetails{Trigger: trigger, Stage: "对外投递", AcceptedCount: int(accepted.Load())} + if result == "failure" || result == "partial" { + details.Outcome = logstore.MailFailureReason(sendErr) + // 多域名失败按类别去重并排序,避免随机挑选某一个错误误导排查。 + reasons := make(map[string]bool) + for _, cause := range recipientErrors { + if cause != nil { + reasons[logstore.MailFailureReason(cause)] = true + } + } + if len(reasons) > 0 { + messages := make([]string, 0, len(reasons)) + for reason := range reasons { + messages = append(messages, reason) + } + sort.Strings(messages) + details.Outcome = strings.Join(messages, ";") + } + if result == "partial" { + details.Outcome = "部分收件人已被对端接收;其余投递失败:" + details.Outcome + } + } + logstore.MailResult(userID, "outbound", from, len(to), len(data), result, details) }() if len(to) == 0 { err := errors.New("no recipients") @@ -221,6 +257,8 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai err := smtp.SendMailUnsafe("localhost", domain.mxHost+":25", nil, from, fromDomain, buildAddress(tos), data) if err != nil { recordFailure(err) + } else { + accepted.Add(int64(len(tos))) } return } @@ -228,6 +266,7 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai // 优先尝试25端口,starttls方式投递 err := smtp.SendMail("", domain.mxHost+":25", nil, from, fromDomain, buildAddress(tos), data) if err == nil { + accepted.Add(int64(len(tos))) return } if isCertificateVerificationError(err) { @@ -243,6 +282,7 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai // 再试用587投递 err = smtp.SendMailWithTls(strings.TrimSuffix(domain.mxHost, "."), domain.mxHost+":587", nil, from, fromDomain, buildAddress(tos), data) if err == nil { + accepted.Add(int64(len(tos))) return } if isCertificateVerificationError(err) { @@ -258,6 +298,7 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai // 再次尝试465投递 err = smtp.SendMailWithTls(strings.TrimSuffix(domain.mxHost, "."), domain.mxHost+":465", nil, from, fromDomain, buildAddress(tos), data) if err == nil { + accepted.Add(int64(len(tos))) return } if isCertificateVerificationError(err) { @@ -293,6 +334,20 @@ func doSend(ctx *context.Context, fromDomain string, data []byte, to []*parsemai return nil, orgMap } +// deliveryLogResult 以实际接收人数为依据,不把函数正常返回等同于全部投递成功。 +func deliveryLogResult(accepted, total int, failed bool) string { + if accepted > 0 { + if accepted == total && !failed { + return "accepted" + } + return "partial" + } + if failed { + return "failure" + } + return "unknown" +} + func allowUnsafeSMTP(domain mxDomain) bool { return domain.localTest && domain.domain == "localhost" && domain.mxHost == "127.0.0.1" } diff --git a/docs/日志改造任务.md b/docs/日志改造任务.md index 77614c6..0108dd2 100644 --- a/docs/日志改造任务.md +++ b/docs/日志改造任务.md @@ -1,5 +1,16 @@ # 日志存储与可视化改造 +## 2026-09-09 三类日志补齐操作与结果 + +- [✓] 需求确认:操作、收发信、运行日志均显示触发操作和结果;补充操作对象、处理阶段及安全的结果说明。 +- [✓] 修改前检查点 `b50e5bd` 已推送;保留现有配置、账号、邮件和历史日志,不恢复独立配置审计。 +- [✓] 写入侧补充明确的事件元数据;等待验证、开始执行、对端接收和最终成功分别记录;旧记录缺失信息不编造。 +- [x] 日志列表与详情展示操作、对象或阶段、结果说明;继续倒序、每页十条、默认最近二十四小时,等待浏览器复审。 +- [✓] 全量回归 `test-log/run-2026-09-08T18-43-37-462Z-45220`:前端 153 项、部署 4 项及后端全包通过。 +- [✓] 补充收信策略丢弃、读取失败、投递人数状态和中继验证码脱敏测试;并发检测 `test-log/run-2026-09-08T18-46-29-763Z-34164`:配置、三类日志、验证码、HTTP、SMTP、投递及初始化相关包通过。 +- [~] 完整编译部署至 `yunx-test`,重启后端,MCP 验收并核验部署指纹。 +- [!] 覆盖边界:新格式为旧调用保留函数来源与“状态记录”,不猜业务完成态;关键启动、停止、监听、证书及日志维护点显式标记结果。操作日志沿用变更路由白名单,不复制请求正文,对象只标类别;会话或 CSRF 前置拒绝不保证经过此记录器。投递人数状态有自动测试,未新增真实外部多收件人投递验收。数据库复用三库既有载荷字段,未新增 MySQL、PostgreSQL 实库验收。 + ## 2026-09-09 取消独立配置审计 - [✓] 需求确认:取消配置审计分类、维护菜单及查询导出;配置修改仅在现有运行日志文件记录简短结果,操作记录与收发信存储方式不变。 diff --git a/frontend/src/views/components/LogSettings/LogSettings.css b/frontend/src/views/components/LogSettings/LogSettings.css index 83f356f..f91aa39 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.css +++ b/frontend/src/views/components/LogSettings/LogSettings.css @@ -43,7 +43,7 @@ .log-categories { display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); flex-shrink: 0; padding: 4px; border-radius: 10px; @@ -243,6 +243,14 @@ color: var(--el-text-color-primary); } +.log-action { + display: block; + margin-bottom: 3px; + overflow-wrap: anywhere; + font-size: 13px; + font-weight: 600; +} + .log-source { margin-top: 4px; overflow: hidden; diff --git a/frontend/src/views/components/LogSettings/LogSettings.js b/frontend/src/views/components/LogSettings/LogSettings.js index 9485d83..bdba8d8 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.js +++ b/frontend/src/views/components/LogSettings/LogSettings.js @@ -31,8 +31,10 @@ export function useLogs() { ] const isRuntime = computed(() => category.value === 'runtime') const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—' - const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '结果未知', accepted: '已接收' }[value] || value) - const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value) + const resultLabel = value => ({ success: '成功', failure: '失败', unknown: '未确认', accepted: '对端接收', pending: '待验证', started: '已开始', recorded: '状态记录', partial: '部分接收', discarded: '已丢弃', skipped: '已跳过' }[value] || value || '未记录') + const actionLabel = value => ({ update: '保存配置', rollback: '回滚配置', migration: '迁移配置' }[value] || value || '未记录操作') + const displayObject = item => item.payload?.object || item.payload?.stage || '未记录对象' + const displayOutcome = item => item.payload?.outcome || '历史记录未提供结果说明' const displayActor = item => item.payload?.actorName || (item.actorId && item.actorId !== '0' ? item.actorId : '系统') const details = computed(() => detail.value?.payload || {}) const runtimeRows = computed(() => lines.value.map((line, index) => parseRuntimeLine(line, offset.value + index + 1))) @@ -40,7 +42,7 @@ export function useLogs() { const visibleCount = computed(() => isRuntime.value ? lines.value.length : items.value.length) const pageRange = computed(() => visibleCount.value ? `第 ${offset.value + 1}–${offset.value + visibleCount.value} 条` : '暂无记录') const currentCategory = computed(() => categories.find(item => item.value === category.value)?.label) - const resultType = value => ({ success: 'success', accepted: 'success', failure: 'danger' }[value] || 'info') + const resultType = value => ({ success: 'success', accepted: 'primary', failure: 'danger', pending: 'warning', partial: 'warning', discarded: 'warning', started: 'primary' }[value] || 'info') const runtimeRowClass = ({ row }) => ['warning', 'danger'].includes(row.type) ? `log-row-${row.type}` : '' function requireData(result) { @@ -126,6 +128,6 @@ export function useLogs() { hasMore, actorId, dateRange, detail, details, isRuntime, runtimeDetail, runtimeRows, runtimeSummary, visibleCount, pageRange, currentCategory, resultType, runtimeRowClass, timeParts, - formatTime, resultLabel, actionLabel, displayActor, search, recent, next, previous + formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous } } diff --git a/frontend/src/views/components/LogSettings/LogSettings.vue b/frontend/src/views/components/LogSettings/LogSettings.vue index 54f8a90..9895c0c 100644 --- a/frontend/src/views/components/LogSettings/LogSettings.vue +++ b/frontend/src/views/components/LogSettings/LogSettings.vue @@ -58,10 +58,16 @@ {{ row.label }} - + + + + @@ -76,8 +82,12 @@
{{ timeParts(row.createdAt).time }}{{ timeParts(row.createdAt).date }}
- - + + @@ -113,6 +123,10 @@ 第 {{ runtimeDetail.sequence }} 条
+
触发操作
{{ runtimeDetail.action }}
+
操作对象
{{ runtimeDetail.object || '未记录' }}
+
操作结果
{{ resultLabel(runtimeDetail.result) }}
+
结果说明
{{ runtimeDetail.outcome }}
代码来源
{{ runtimeDetail.source || '未记录' }}