补齐三类日志的触发操作和结果并完善运行事件展示
Some checks are pending
Security Checks / security (push) Waiting to run

This commit is contained in:
smail 2026-09-09 02:48:07 +08:00
parent b50e5bdfa2
commit dd6258c98b
34 changed files with 734 additions and 87 deletions

View File

@ -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)
}

View File

@ -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,

View File

@ -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
}

View File

@ -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")

View File

@ -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")

View File

@ -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("关闭运行日志失败")

View File

@ -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)
}

View File

@ -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)

View File

@ -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,
})

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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():

View File

@ -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():

View File

@ -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

View File

@ -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
}
}

View File

@ -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"
}

View File

@ -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 取消独立配置审计
- [✓] 需求确认:取消配置审计分类、维护菜单及查询导出;配置修改仅在现有运行日志文件记录简短结果,操作记录与收发信存储方式不变。

View File

@ -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;

View File

@ -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
}
}

View File

@ -58,10 +58,16 @@
<el-tag :type="row.type" size="small" effect="light" class="log-level-tag">{{ row.label }}</el-tag>
</template>
</el-table-column>
<el-table-column label="日志内容" min-width="170">
<el-table-column label="操作与结果说明" min-width="170">
<template #default="{ row }">
<div class="log-message">{{ row.summary }}</div>
<div v-if="row.sourceShort" class="log-source">{{ row.sourceShort }}</div>
<strong class="log-action">{{ row.action }}</strong>
<div class="log-message">{{ row.outcome }}</div>
<div v-if="row.object" class="log-source">对象:{{ row.object }}</div>
</template>
</el-table-column>
<el-table-column label="结果" width="92" align="center">
<template #default="{ row }">
<el-tag :type="resultType(row.result)" size="small" class="log-level-tag">{{ resultLabel(row.result) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="详情" width="60" align="center">
@ -76,8 +82,12 @@
<div class="log-time"><strong>{{ timeParts(row.createdAt).time }}</strong><span>{{ timeParts(row.createdAt).date }}</span></div>
</template>
</el-table-column>
<el-table-column label="操作" min-width="125" show-overflow-tooltip>
<template #default="{ row }">{{ actionLabel(row.action) }}</template>
<el-table-column label="操作与结果说明" min-width="170">
<template #default="{ row }">
<strong class="log-action">{{ actionLabel(row.action) }}</strong>
<div class="log-message">{{ displayOutcome(row) }}</div>
<div class="log-source">{{ displayObject(row) }}</div>
</template>
</el-table-column>
<el-table-column label="操作者" min-width="85" show-overflow-tooltip>
<template #default="{ row }">{{ displayActor(row) }}</template>
@ -113,6 +123,10 @@
<span class="runtime-detail-sequence">第 {{ runtimeDetail.sequence }} 条</span>
</div>
<dl class="log-detail-grid runtime-detail-meta">
<dt>触发操作</dt><dd>{{ runtimeDetail.action }}</dd>
<dt>操作对象</dt><dd>{{ runtimeDetail.object || '未记录' }}</dd>
<dt>操作结果</dt><dd>{{ resultLabel(runtimeDetail.result) }}</dd>
<dt>结果说明</dt><dd>{{ runtimeDetail.outcome }}</dd>
<dt>代码来源</dt><dd>{{ runtimeDetail.source || '未记录' }}</dd>
<template v-if="runtimeDetail.requestId">
<dt>请求标识</dt><dd>{{ runtimeDetail.requestId }}</dd>
@ -129,15 +143,23 @@
<el-dialog :model-value="Boolean(detail)" title="日志详情" width="min(760px, 92vw)" append-to-body @close="detail = null">
<template v-if="detail">
<p>{{ formatTime(detail.createdAt) }} · {{ displayActor(detail) }} · {{ actionLabel(detail.action) }}</p>
<dl class="log-detail-grid">
<dt>触发操作</dt><dd>{{ actionLabel(detail.action) }}</dd>
<dt>触发来源</dt><dd>{{ details.trigger || '未记录' }}</dd>
<dt>{{ category === 'mail' ? '处理阶段' : '操作对象' }}</dt><dd>{{ displayObject(detail) }}</dd>
<dt>操作结果</dt><dd>{{ resultLabel(detail.result) }}</dd>
<dt>结果说明</dt><dd>{{ displayOutcome(detail) }}</dd>
</dl>
<dl v-if="category === 'mail'" class="log-detail-grid">
<dt>发件人</dt><dd>{{ details.sender || '—' }}</dd>
<dt>收件人数</dt><dd>{{ details.recipientCount }}</dd>
<dt>邮件大小</dt><dd>{{ details.bytes }} 字节</dd>
<dt>结果</dt><dd>{{ resultLabel(detail.result) }}</dd>
<dt>对端接收人数</dt><dd>{{ details.acceptedCount ?? '未记录' }}</dd>
</dl>
<dl v-else class="log-detail-grid">
<dt>权限角色</dt><dd>{{ details.actorRole || '—' }}</dd>
<dt>结果代码</dt><dd>{{ details.errorCode ?? '—' }}</dd>
<dt>HTTP 状态</dt><dd>{{ details.httpStatus || '未记录' }}</dd>
</dl>
</template>
</el-dialog>
@ -152,7 +174,7 @@ const {
hasMore, actorId, dateRange, detail, details, isRuntime,
runtimeDetail, runtimeRows, runtimeSummary, pageRange,
currentCategory, resultType, runtimeRowClass, timeParts,
formatTime, resultLabel, actionLabel, displayActor, search, recent, next, previous
formatTime, resultLabel, actionLabel, displayActor, displayObject, displayOutcome, search, recent, next, previous
} = useLogs()
</script>

View File

@ -53,8 +53,24 @@ export function parseRuntimeLine(line, sequence) {
// 历史启动信息只简化摘要,详情仍保留完整消息和原始记录。
const summary = legacyStartup ? '服务启动' : separator ? '启动分隔线' : message.trim() || '(空消息)'
const sourceShort = source.replace(/^git\.pcux\.cn\/xuans\/Smail\//, '')
let event = {}
let content = summary
const boundary = message.lastIndexOf(' @event ')
if (boundary >= 0) {
try {
const parsed = JSON.parse(message.slice(boundary + 8))
if (parsed?.logSchema === 1 && typeof parsed.action === 'string' && typeof parsed.result === 'string') {
event = parsed
content = message.slice(0, boundary).trim() || '(无补充说明)'
}
} catch {
// 旧日志或截断记录保留原文,不能猜测结果。
}
}
return {
sequence, raw, createdAt, level, source, sourceShort, requestId, message, summary,
action: event.action || '历史运行事件', result: event.result || 'unknown',
object: event.object || event.section || '', outcome: event.outcome || content,
...timeParts(createdAt), ...levels[level]
}
}

View File

@ -1,6 +1,8 @@
package config
import (
"context"
"encoding/json"
"errors"
"runtime"
"strings"
@ -33,3 +35,31 @@ func TestLogFormatterPreservesStructuredEvidence(t *testing.T) {
}
}
}
func TestRuntimeEventMetadataNeverInventsSuccessfulCompletion(t *testing.T) {
for _, item := range []struct {
message, result string
data log.Fields
}{
{"包含成功文字但未提供完成信号", "recorded", nil},
{"开始启动", "started", log.Fields{"action": "启动服务", "result": "started"}},
{"连接失败", "failure", log.Fields{"action": "连接数据库", "result": "failure"}},
} {
entry := &log.Entry{Time: time.Now(), Level: log.WarnLevel, Context: context.Background(), Message: item.message, Data: item.data}
data, err := (&logFormatter{}).Format(entry)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(string(data), " @event ")
var fields map[string]any
if len(parts) != 2 || json.Unmarshal([]byte(parts[1]), &fields) != nil {
t.Fatalf("无效元数据:%s", data)
}
if fields["result"] != item.result || fields["action"] == "" || fields["logSchema"] != float64(1) {
t.Fatalf("结果错误:%+v", fields)
}
if _, changed := item.data["logSchema"]; changed {
t.Fatal("格式器修改了调用者字段")
}
}
}

View File

@ -0,0 +1,45 @@
package logstore
import (
"context"
"encoding/json"
"errors"
"net"
"net/textproto"
"strings"
"testing"
)
func TestMailLogKeepsTriggerStageAndOutcomeWithoutRemoteSecrets(t *testing.T) {
s := testStore(t)
runtime := Start(s)
defer runtime.Stop()
for _, item := range []struct{ result, trigger, stage string }{
{"accepted", "网页写信", "对外投递"},
{"partial", "邮件规则转发", "对外投递"},
{"failure", "SMTP 收信", "解析邮件"},
{"discarded", "SMTP 收信", "收信策略"},
} {
MailResult(42, "outbound", "sender@example.com", 3, 100, item.result,
MailDetails{Trigger: item.trigger, Stage: item.stage, AcceptedCount: 1})
entries, err := s.Query(context.Background(), Query{Kind: "mail", Limit: 1})
if err != nil || len(entries) != 1 {
t.Fatal(err)
}
var payload map[string]any
if json.Unmarshal(entries[0].Payload, &payload) != nil {
t.Fatal("无效邮件载荷")
}
if entries[0].Result != item.result || payload["trigger"] != item.trigger || payload["stage"] != item.stage || payload["outcome"] == "" || payload["acceptedCount"] != float64(1) {
t.Fatalf("缺少操作或结果:%+v", payload)
}
}
for _, cause := range []error{
errors.New("private-token"), &textproto.Error{Code: 550, Msg: "private-token"},
&net.DNSError{Err: "private-token", Name: "private-domain", IsNotFound: true},
} {
if reason := MailFailureReason(cause); reason == "" || strings.Contains(reason, "private-") {
t.Fatal("远端敏感内容泄露")
}
}
}

View File

@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/db/migration"
@ -69,8 +70,61 @@ func TestOperationLogUsesCompletedIdentityWithoutSensitivePayload(t *testing.T)
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
t.Fatal(err)
}
if entry.Result != "success" || payload["actorName"] != "audit-probe" || len(payload) != 3 {
if entry.Result != "success" || payload["actorName"] != "audit-probe" || len(payload) != 7 || payload["outcome"] != "操作已完成" || payload["trigger"] == "" || payload["object"] == "" || strings.Contains(string(entry.Payload), "must-not-store") {
t.Fatalf("incorrect or excessive audit metadata: %+v", payload)
}
}
}
func TestOperationResponseDistinguishesPendingHTTPFailureAndUnknown(t *testing.T) {
database, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "results.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
migrations := migration.DefaultMigrations()
for _, statement := range migrations[len(migrations)-1].StatementsFor(migration.DialectSQLite) {
if _, err := database.Exec(statement); err != nil {
t.Fatal(err)
}
}
store, err := logstore.New(database, migration.DialectSQLite)
if err != nil {
t.Fatal(err)
}
runtime := logstore.Start(store)
defer runtime.Stop()
for _, item := range []struct {
name, want string
write func(http.ResponseWriter)
}{
{"pending", "pending", func(w http.ResponseWriter) {
response.NewSuccessResponse(map[string]any{"pending": true, "challenge_id": "private-challenge"}).FPrint(w)
}},
{"failure", "failure", func(w http.ResponseWriter) {
response.NewErrorResponse(response.ParamsError, "private-detail", "private-code").FPrint(w)
}},
{"http", "failure", func(w http.ResponseWriter) { http.Error(w, "private-detail", http.StatusForbidden) }},
{"unknown", "unknown", func(w http.ResponseWriter) { _, _ = w.Write([]byte("not-a-business-response")) }},
} {
ctx := &appcontext.Context{UserID: 42, UserAccount: item.name}
writer, finish := operationLog(ctx, httptest.NewRequest("POST", "/api/v1/login?password=private-query", strings.NewReader("private-request")), httptest.NewRecorder())
item.write(writer)
finish()
entries, err := store.Query(context.Background(), logstore.Query{Kind: "operation", Limit: 1})
if err != nil || len(entries) != 1 {
t.Fatal(err)
}
entry := entries[0]
if entry.Result != item.want || strings.Contains(string(entry.Payload), "private-") || strings.Contains(string(entry.Payload), "not-a-business-response") {
t.Fatalf("结果错误或泄露请求:%s %+v", item.name, entry)
}
}
before, _ := store.Query(context.Background(), logstore.Query{Kind: "operation"})
_, finish := operationLog(&appcontext.Context{}, httptest.NewRequest("POST", "/api/v1/email/list", nil), httptest.NewRecorder())
finish()
after, _ := store.Query(context.Background(), logstore.Query{Kind: "operation"})
if len(before) != len(after) {
t.Fatal("只读查询不能新增操作记录")
}
}

View File

@ -0,0 +1,23 @@
package send
import "testing"
func TestDeliveryLogResultUsesConfirmedRecipientCount(t *testing.T) {
for _, item := range []struct {
accepted, total int
failed bool
want string
}{
{3, 3, false, "accepted"},
{1, 3, true, "partial"},
{1, 3, false, "partial"},
{3, 3, true, "partial"},
{0, 3, true, "failure"},
{0, 3, false, "unknown"},
{0, 0, false, "unknown"},
} {
if got := deliveryLogResult(item.accepted, item.total, item.failed); got != item.want {
t.Fatalf("接收人数 %d/%d,失败=%v:得到 %s,期望 %s", item.accepted, item.total, item.failed, got, item.want)
}
}
}

View File

@ -0,0 +1,67 @@
package smtp_server
import (
"bytes"
"context"
"encoding/json"
"errors"
"net"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/services/logstore"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
type failedMailReader struct{}
func (failedMailReader) Read([]byte) (int, error) { return 0, errors.New("private-reader-error") }
func TestSMTPDataLogDistinguishesDiscardedMailAndReadFailure(t *testing.T) {
engine, _ := newAuditTestEngine(t)
config.Instance.SpamFilterLevel = 3
migrations := migration.DefaultMigrations()
for _, statement := range migrations[len(migrations)-1].StatementsFor(migration.DialectSQLite) {
if _, err := engine.Exec(statement); err != nil {
t.Fatal(err)
}
}
store, err := logstore.New(engine.DB().DB, migration.DialectSQLite)
if err != nil {
t.Fatal(err)
}
runtime := logstore.Start(store)
defer runtime.Stop()
session := &Session{
Ctx: &appcontext.Context{Context: context.Background()},
From: "sender@example.net", To: []string{"absent@example.com"},
RemoteAddress: &net.TCPAddr{IP: net.IPv4(192, 168, 1, 2), Port: 25},
}
message := "From: sender@example.net\r\nTo: absent@example.com\r\nSubject: private-subject\r\n\r\nprivate-body"
if err := session.Data(bytes.NewBufferString(message)); err != nil {
t.Fatal(err)
}
assertAuditCounts(t, engine, 0, 0)
for _, result := range []string{"discarded", "failure"} {
if result == "failure" && session.Data(failedMailReader{}) == nil {
t.Fatal("读取失败未返回错误")
}
entries, err := store.Query(context.Background(), logstore.Query{Kind: "mail", Limit: 1})
if err != nil || len(entries) != 1 {
t.Fatal("缺少收信处理记录", err)
}
entry := entries[0]
var details logstore.MailDetails
if err := json.Unmarshal(entry.Payload, &details); err != nil {
t.Fatal(err)
}
if entry.Result != result || details.Trigger != "SMTP 收信" || details.Stage == "" || details.Outcome == "" {
t.Fatalf("收信状态错误:%+v", entry)
}
if strings.Contains(string(entry.Payload), "private-") {
t.Fatal("收信日志泄露内容或原始错误")
}
}
}

View File

@ -0,0 +1,76 @@
package verification
import (
"context"
"database/sql"
"encoding/json"
"errors"
"path/filepath"
"strings"
"testing"
"git.pcux.cn/xuans/Smail/db/migration"
"git.pcux.cn/xuans/Smail/services/logstore"
_ "modernc.org/sqlite"
)
func TestRelayMailLogRecordsResultWithoutChallengeSecrets(t *testing.T) {
database, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "relay-logs.db"))
if err != nil {
t.Fatal(err)
}
defer database.Close()
database.SetMaxOpenConns(1)
migrations := migration.DefaultMigrations()
for _, statement := range migrations[len(migrations)-1].StatementsFor(migration.DialectSQLite) {
if _, err := database.Exec(statement); err != nil {
t.Fatal(err)
}
}
store, err := logstore.New(database, migration.DialectSQLite)
if err != nil {
t.Fatal(err)
}
runtime := logstore.Start(store)
defer runtime.Stop()
settings := SMTPSettings{Host: "localhost", Port: 25, Security: SecurityPlain, From: "sender@example.com", Username: "private-user", Password: "private-password"}
for _, item := range []struct {
cause error
result string
count int
}{{nil, "accepted", 1}, {errors.New("private-remote-error"), "failure", 0}} {
delivery, err := NewSMTPDelivery(settings)
if err != nil {
t.Fatal(err)
}
var message []byte
delivery.send = func(_ SMTPSettings, _ string, data []byte) error {
message = data
return item.cause
}
err = delivery.Delivery()(Challenge{Email: "private-recipient@example.com", ID: "private-challenge", Purpose: PurposeLogin}, "private-code")
if !errors.Is(err, item.cause) {
t.Fatal("发送结果被改变")
}
for _, value := range message {
if value != 0 {
t.Fatal("验证码邮件缓冲未清理")
}
}
entries, err := store.Query(context.Background(), logstore.Query{Kind: "mail", Limit: 1})
if err != nil || len(entries) != 1 {
t.Fatal("缺少中继发送记录", err)
}
entry := entries[0]
var payload map[string]any
if err := json.Unmarshal(entry.Payload, &payload); err != nil {
t.Fatal(err)
}
if entry.Result != item.result || payload["acceptedCount"] != float64(item.count) || payload["stage"] != "SMTP 中继投递" || payload["trigger"] != "系统安全验证邮件" || payload["outcome"] == "" {
t.Fatalf("中继日志结果不完整:%+v", entry)
}
if strings.Contains(string(entry.Payload), "private-") {
t.Fatal("中继日志包含敏感信息")
}
}
}

View File

@ -212,3 +212,28 @@ test('本页级别统计准确,压缩文件大小和空页区间明确', () =>
assert.match(read('LogSettings.vue'), /本页日志统计/)
assert.doesNotMatch(read('LogSettings.vue'), /lines\.join/)
})
test('运行日志明确展示结构化操作和结果,不把级别或开始事件当作成功', () => {
const header = '[2026-09-08T14:55:23Z] [warning][2026-09-08 22:55:23][app.go:39]'
const data = { logSchema: 1, action: '启动服务', result: 'started', object: 'HTTP' }
const raw = `${header}开始启动 @event ${JSON.stringify(data)}`
const row = parseRuntimeLine(raw, 1)
assert.equal(row.action, '启动服务')
assert.equal(row.result, 'started')
assert.equal(row.outcome, '开始启动')
assert.equal(row.object, 'HTTP')
assert.equal(row.raw, raw)
for (const message of ['旧启动成功', '正文 {"result":"success"}', '截断 @event {"logSchema":1', '内容 @event null']) {
const old = parseRuntimeLine(header + message, 1)
assert.equal(old.result, 'unknown')
assert.equal(old.outcome, message)
}
const { page } = fixture(async () => ({ errorNo: 0, data: {} }))
assert.equal(page.resultLabel('pending'), '待验证')
assert.equal(page.resultLabel('accepted'), '对端接收')
assert.equal(page.resultLabel('partial'), '部分接收')
assert.equal(page.displayOutcome({ payload: {} }), '历史记录未提供结果说明')
assert.equal(page.displayObject({ payload: { stage: '解析邮件' } }), '解析邮件')
assert.match(read('LogSettings.vue'), /runtimeDetail\.action/)
assert.match(read('LogSettings.vue'), /runtimeDetail\.result/)
})

View File

@ -54,6 +54,10 @@ if ((suite === 'all' || suite === 'backend') && result === 0) {
'backend/controllers/login_audit_test.go': 'test/backend/login_audit_test.go',
'backend/controllers/logs_api_test.go': 'test/backend/logs_api_test.go',
'backend/services/logstore/log_store_test.go': 'test/backend/log_store_test.go',
'backend/services/logstore/mail_log_result_test.go': 'test/backend/mail_log_result_test.go',
'backend/services/verification/mail_log_test.go': 'test/backend/verification_mail_log_test.go',
'backend/utils/send/log_result_test.go': 'test/backend/send_log_result_test.go',
'backend/listen/smtp_server/mail_log_test.go': 'test/backend/smtp_mail_log_test.go',
'backend/services/logstore/log_store_integration_test.go': 'test/backend/log_store_integration_test.go',
'backend/services/runtimelog/runtime_logs_test.go': 'test/backend/runtime_logs_test.go',
'backend/services/runtimelog/migration_test.go': 'test/backend/runtime_log_migration_test.go',