feat(错误日志): 添加异步处理错误日志功能

This commit is contained in:
piexlMax(奇淼
2025-11-12 18:02:50 +08:00
parent a469ea23f4
commit b9788e9b2a
12 changed files with 279 additions and 131 deletions

View File

@@ -1,15 +1,10 @@
package system
import (
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/model/common"
"github.com/goccy/go-json"
"io"
"strings"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/utils/request"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
@@ -108,48 +103,15 @@ func (autoApi *AutoCodeApi) GetColumn(c *gin.Context) {
func (autoApi *AutoCodeApi) LLMAuto(c *gin.Context) {
var llm common.JSONMap
err := c.ShouldBindJSON(&llm)
if err != nil {
if err := c.ShouldBindJSON(&llm); err != nil {
response.FailWithMessage(err.Error(), c)
return
}
if global.GVA_CONFIG.AutoCode.AiPath == "" {
response.FailWithMessage("请先前往插件市场个人中心获取AiPath并填入config.yaml中", c)
return
}
path := strings.ReplaceAll(global.GVA_CONFIG.AutoCode.AiPath, "{FUNC}", fmt.Sprintf("api/chat/%s", llm["mode"]))
res, err := request.HttpRequest(
path,
"POST",
nil,
nil,
llm,
)
data, err := autoCodeService.LLMAuto(c.Request.Context(), llm)
if err != nil {
global.GVA_LOG.Error("大模型生成失败!", zap.Error(err))
response.FailWithMessage("大模型生成失败"+err.Error(), c)
return
}
var resStruct response.Response
b, err := io.ReadAll(res.Body)
defer res.Body.Close()
if err != nil {
global.GVA_LOG.Error("大模型生成失败!", zap.Error(err))
response.FailWithMessage("大模型生成失败"+err.Error(), c)
return
}
err = json.Unmarshal(b, &resStruct)
if err != nil {
global.GVA_LOG.Error("大模型生成失败!", zap.Error(err))
response.FailWithMessage("大模型生成失败"+err.Error(), c)
return
}
if resStruct.Code == 7 {
global.GVA_LOG.Error("大模型生成失败!"+resStruct.Msg, zap.Error(err))
response.FailWithMessage("大模型生成失败"+resStruct.Msg, c)
return
}
response.OkWithData(resStruct.Data, c)
response.OkWithData(data, c)
}

View File

@@ -167,3 +167,33 @@ func (sysErrorApi *SysErrorApi) GetSysErrorList(c *gin.Context) {
PageSize: pageInfo.PageSize,
}, "获取成功", c)
}
// GetSysErrorSolution 触发错误日志的异步处理
// @Tags SysError
// @Summary 根据ID触发处理标记为处理中1分钟后自动改为处理完成
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param id query string true "错误日志ID"
// @Success 200 {object} response.Response{msg=string} "处理已提交"
// @Router /sysError/getSysErrorSolution [get]
func (sysErrorApi *SysErrorApi) GetSysErrorSolution(c *gin.Context) {
// 创建业务用Context
ctx := c.Request.Context()
// 兼容 id 与 ID 两种参数
ID := c.Query("id")
if ID == "" {
response.FailWithMessage("缺少参数: id", c)
return
}
err := sysErrorService.GetSysErrorSolution(ctx, ID)
if err != nil {
global.GVA_LOG.Error("处理触发失败!", zap.Error(err))
response.FailWithMessage("处理触发失败:"+err.Error(), c)
return
}
response.OkWithMessage("已提交至AI处理", c)
}

View File

@@ -1,11 +1,18 @@
package internal
import (
"github.com/flipped-aurora/gin-vue-admin/server/global"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
"time"
"context"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/service"
astutil "github.com/flipped-aurora/gin-vue-admin/server/utils/ast"
"github.com/flipped-aurora/gin-vue-admin/server/utils/stacktrace"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
"strings"
"time"
)
type ZapCore struct {
@@ -54,13 +61,75 @@ func (z *ZapCore) Check(entry zapcore.Entry, check *zapcore.CheckedEntry) *zapco
}
func (z *ZapCore) Write(entry zapcore.Entry, fields []zapcore.Field) error {
for i := 0; i < len(fields); i++ {
if fields[i].Key == "business" || fields[i].Key == "folder" || fields[i].Key == "directory" {
syncer := z.WriteSyncer(fields[i].String)
z.Core = zapcore.NewCore(global.GVA_CONFIG.Zap.Encoder(), syncer, z.level)
}
}
return z.Core.Write(entry, fields)
for i := 0; i < len(fields); i++ {
if fields[i].Key == "business" || fields[i].Key == "folder" || fields[i].Key == "directory" {
syncer := z.WriteSyncer(fields[i].String)
z.Core = zapcore.NewCore(global.GVA_CONFIG.Zap.Encoder(), syncer, z.level)
}
}
// 先写入原日志目标
err := z.Core.Write(entry, fields)
// 捕捉 Error 及以上级别日志并入库,且可提取 zap.Error(err) 的错误内容
if entry.Level >= zapcore.ErrorLevel {
// 避免与 GORM zap 写入互相递归:跳过由 gorm logger writer 触发的日志
if strings.Contains(entry.Caller.File, "gorm_logger_writer.go") {
return err
}
// 避免重复记录 panic 恢复日志panic 由 GinRecovery 单独捕捉入库
if strings.Contains(entry.Message, "[Recovery from panic]") {
return err
}
form := "后端"
level := entry.Level.String()
// 生成基础信息
info := entry.Message
// 提取 zap.Error(err) 内容
var errStr string
for i := 0; i < len(fields); i++ {
f := fields[i]
if f.Type == zapcore.ErrorType || f.Key == "error" || f.Key == "err" {
if f.Interface != nil {
errStr = fmt.Sprintf("%v", f.Interface)
} else if f.String != "" {
errStr = f.String
}
break
}
}
if errStr != "" {
info = fmt.Sprintf("%s | 错误: %s", info, errStr)
}
// 附加来源与堆栈信息
if entry.Caller.File != "" {
info = fmt.Sprintf("%s \n 源文件:%s:%d", info, entry.Caller.File, entry.Caller.Line)
}
stack := entry.Stack
if stack != "" {
info = fmt.Sprintf("%s \n 调用栈:%s", info, stack)
// 解析最终业务调用方,并提取其方法源码
if frame, ok := stacktrace.FindFinalCaller(stack); ok {
fnName, fnSrc, sLine, eLine, exErr := astutil.ExtractFuncSourceByPosition(frame.File, frame.Line)
if exErr == nil {
info = fmt.Sprintf("%s \n 最终调用方法:%s:%d (%s lines %d-%d)\n----- 产生日志的方法代码如下 -----\n%s", info, frame.File, frame.Line, fnName, sLine, eLine, fnSrc)
} else {
info = fmt.Sprintf("%s \n 最终调用方法:%s:%d (%s) | extract_err=%v", info, frame.File, frame.Line, fnName, exErr)
}
}
}
// 使用后台上下文,避免依赖 gin.Context
ctx := context.Background()
_ = service.ServiceGroupApp.SystemServiceGroup.SysErrorService.CreateSysError(ctx, &system.SysError{
Form: &form,
Info: &info,
Level: level,
})
}
return err
}
func (z *ZapCore) Sync() error {

View File

@@ -1,19 +1,13 @@
package core
import (
"context"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/core/internal"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
"github.com/flipped-aurora/gin-vue-admin/server/service"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
astutil "github.com/flipped-aurora/gin-vue-admin/server/utils/ast"
"github.com/flipped-aurora/gin-vue-admin/server/utils/stacktrace"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
"strings"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/core/internal"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/utils"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"os"
)
// Zap 获取 zap.Logger
@@ -30,53 +24,8 @@ func Zap() (logger *zap.Logger) {
core := internal.NewZapCore(levels[i])
cores = append(cores, core)
}
// 通过 Hooks 捕捉 Error 及以上级别日志,写入系统错误表
dbHook := zap.Hooks(func(entry zapcore.Entry) error {
// 仅处理 Error 及以上级别
if entry.Level < zapcore.ErrorLevel {
return nil
}
// 避免与 GORM zap 写入互相递归:跳过由 gorm logger writer 触发的日志
if strings.Contains(entry.Caller.File, "gorm_logger_writer.go") {
return nil
}
// 避免重复记录 panic 恢复日志panic 由 GinRecovery 单独捕捉入库
if strings.Contains(entry.Message, "[Recovery from panic]") {
return nil
}
form := "后端"
level := entry.Level.String()
// 尽可能携带来源与堆栈信息(使用 runtime 采集并过滤 zap 内部栈)
info := entry.Message
if entry.Caller.File != "" {
info = fmt.Sprintf("错误信息:%s", info)
}
stack := entry.Stack
if stack != "" {
info = fmt.Sprintf("%s \n 调用栈:%s", info, stack)
// 解析最终业务调用方,并提取其方法源码
if frame, ok := stacktrace.FindFinalCaller(stack); ok {
fnName, fnSrc, sLine, eLine, exErr := astutil.ExtractFuncSourceByPosition(frame.File, frame.Line)
if exErr == nil {
info = fmt.Sprintf("%s \n 最终调用方法:%s:%d (%s lines %d-%d)\n----- 产生日志的方法代码如下 -----\n%s", info, frame.File, frame.Line, fnName, sLine, eLine, fnSrc)
} else {
info = fmt.Sprintf("%s \n 最终调用方法:%s:%d (%s) | extract_err=%v", info, frame.File, frame.Line, fnName, exErr)
}
}
}
// 使用后台上下文,避免依赖 gin.Context
ctx := context.Background()
_ = service.ServiceGroupApp.SystemServiceGroup.SysErrorService.CreateSysError(ctx, &system.SysError{
Form: &form,
Info: &info,
Level: level,
})
return nil
})
logger = zap.New(zapcore.NewTee(cores...), dbHook)
// 构建基础 logger错误级别的入库逻辑已在自定义 ZapCore 中处理)
logger = zap.New(zapcore.NewTee(cores...))
// 启用 Error 及以上级别的堆栈捕捉,确保 entry.Stack 可用
opts := []zap.Option{zap.AddStacktrace(zapcore.ErrorLevel)}
if global.GVA_CONFIG.Zap.ShowLine {

View File

@@ -11,7 +11,8 @@ type SysError struct {
Form *string `json:"form" form:"form" gorm:"comment:错误来源;column:form;type:text;" binding:"required"` //错误来源
Info *string `json:"info" form:"info" gorm:"comment:错误内容;column:info;type:text;"` //错误内容
Level string `json:"level" form:"level" gorm:"comment:日志等级;column:level;"`
Solution *string `json:"solution" form:"solution" gorm:"comment:解决方案;column:solution;"` //解决方案
Solution *string `json:"solution" form:"solution" gorm:"comment:解决方案;column:solution;type:text"` //解决方案
Status string `json:"status" form:"status" gorm:"comment:处理状态;column:status;type:varchar(20);default:未处理;"` //处理状态:未处理/处理中/处理完成
}
// TableName 错误日志 SysError自定义表名 sys_error

View File

@@ -9,19 +9,20 @@ type SysErrorRouter struct{}
// InitSysErrorRouter 初始化 错误日志 路由信息
func (s *SysErrorRouter) InitSysErrorRouter(Router *gin.RouterGroup, PublicRouter *gin.RouterGroup) {
sysErrorRouter := Router.Group("sysError").Use(middleware.OperationRecord())
sysErrorRouterWithoutRecord := Router.Group("sysError")
sysErrorRouterWithoutAuth := PublicRouter.Group("sysError")
{
sysErrorRouter.DELETE("deleteSysError", sysErrorApi.DeleteSysError) // 删除错误日志
sysErrorRouter.DELETE("deleteSysErrorByIds", sysErrorApi.DeleteSysErrorByIds) // 批量删除错误日志
sysErrorRouter.PUT("updateSysError", sysErrorApi.UpdateSysError) // 更新错误日志
}
{
sysErrorRouterWithoutRecord.GET("findSysError", sysErrorApi.FindSysError) // 根据ID获取错误日志
sysErrorRouterWithoutRecord.GET("getSysErrorList", sysErrorApi.GetSysErrorList) // 获取错误日志列表
}
{
sysErrorRouterWithoutAuth.POST("createSysError", sysErrorApi.CreateSysError) // 新建错误日志
}
sysErrorRouter := Router.Group("sysError").Use(middleware.OperationRecord())
sysErrorRouterWithoutRecord := Router.Group("sysError")
sysErrorRouterWithoutAuth := PublicRouter.Group("sysError")
{
sysErrorRouter.DELETE("deleteSysError", sysErrorApi.DeleteSysError) // 删除错误日志
sysErrorRouter.DELETE("deleteSysErrorByIds", sysErrorApi.DeleteSysErrorByIds) // 批量删除错误日志
sysErrorRouter.PUT("updateSysError", sysErrorApi.UpdateSysError) // 更新错误日志
sysErrorRouter.GET("getSysErrorSolution", sysErrorApi.GetSysErrorSolution) // 触发错误日志处理
}
{
sysErrorRouterWithoutRecord.GET("findSysError", sysErrorApi.FindSysError) // 根据ID获取错误日志
sysErrorRouterWithoutRecord.GET("getSysErrorList", sysErrorApi.GetSysErrorList) // 获取错误日志列表
}
{
sysErrorRouterWithoutAuth.POST("createSysError", sysErrorApi.CreateSysError) // 新建错误日志
}
}

View File

@@ -0,0 +1,51 @@
package system
import (
"context"
"errors"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common"
commonResp "github.com/flipped-aurora/gin-vue-admin/server/model/common/response"
"github.com/flipped-aurora/gin-vue-admin/server/utils/request"
"github.com/goccy/go-json"
"io"
"strings"
)
// LLMAuto 调用大模型服务,返回生成结果数据
// 入参为通用 JSONMap需包含 mode例如 ai/butler/eye/painter 等)以及业务 prompt/payload
func (s *AutoCodeService) LLMAuto(ctx context.Context, llm common.JSONMap) (interface{}, error) {
if global.GVA_CONFIG.AutoCode.AiPath == "" {
return nil, errors.New("请先前往插件市场个人中心获取AiPath并填入config.yaml中")
}
// 构建调用路径:{AiPath} 中的 {FUNC} 由 mode 替换
mode := fmt.Sprintf("%v", llm["mode"]) // 统一转字符串,避免 nil 造成路径异常
path := strings.ReplaceAll(global.GVA_CONFIG.AutoCode.AiPath, "{FUNC}", fmt.Sprintf("api/chat/%s", mode))
res, err := request.HttpRequest(
path,
"POST",
nil,
nil,
llm,
)
if err != nil {
return nil, fmt.Errorf("大模型生成失败: %w", err)
}
defer res.Body.Close()
var resStruct commonResp.Response
b, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("读取大模型响应失败: %w", err)
}
if err = json.Unmarshal(b, &resStruct); err != nil {
return nil, fmt.Errorf("解析大模型响应失败: %w", err)
}
if resStruct.Code == 7 { // 业务约定7 表示模型生成失败
return nil, fmt.Errorf("大模型生成失败: %s", resStruct.Msg)
}
return resStruct.Data, nil
}

View File

@@ -2,7 +2,9 @@ package system
import (
"context"
"fmt"
"github.com/flipped-aurora/gin-vue-admin/server/global"
"github.com/flipped-aurora/gin-vue-admin/server/model/common"
"github.com/flipped-aurora/gin-vue-admin/server/model/system"
systemReq "github.com/flipped-aurora/gin-vue-admin/server/model/system/request"
)
@@ -75,3 +77,48 @@ func (sysErrorService *SysErrorService) GetSysErrorInfoList(ctx context.Context,
err = db.Find(&sysErrors).Error
return sysErrors, total, err
}
// GetSysErrorSolution 异步处理错误
// Author [yourname](https://github.com/yourname)
func (sysErrorService *SysErrorService) GetSysErrorSolution(ctx context.Context, ID string) (err error) {
// 立即更新为处理中
err = global.GVA_DB.WithContext(ctx).Model(&system.SysError{}).Where("id = ?", ID).Update("status", "处理中").Error
if err != nil {
return err
}
// 异步协程在一分钟后更新为处理完成
go func(id string) {
// 查询当前错误信息用于生成方案
var se system.SysError
_ = global.GVA_DB.Model(&system.SysError{}).Where("id = ?", id).First(&se).Error
// 构造 LLM 请求参数,使用管家模式(butler)根据错误信息生成解决方案
var form, info string
if se.Form != nil {
form = *se.Form
}
if se.Info != nil {
info = *se.Info
}
llmReq := common.JSONMap{
"mode": "solution",
"command": "solution",
"info": info,
"form": form,
}
// 调用服务层 LLMAuto忽略错误但尽量写入方案
var solution string
if data, err := (&AutoCodeService{}).LLMAuto(context.Background(), llmReq); err == nil {
solution = fmt.Sprintf("%v", data)
_ = global.GVA_DB.Model(&system.SysError{}).Where("id = ?", id).Updates(map[string]interface{}{"status": "处理完成", "solution": solution}).Error
} else {
// 即使生成失败也标记为完成,避免任务卡住
_ = global.GVA_DB.Model(&system.SysError{}).Where("id = ?", id).Update("status", "处理失败").Error
}
}(ID)
return nil
}

View File

@@ -184,6 +184,7 @@ func (i *initApi) InitializeData(ctx context.Context) (context.Context, error) {
{ApiGroup: "错误日志", Method: "PUT", Path: "/sysError/updateSysError", Description: "更新错误日志"},
{ApiGroup: "错误日志", Method: "GET", Path: "/sysError/findSysError", Description: "根据ID获取错误日志"},
{ApiGroup: "错误日志", Method: "GET", Path: "/sysError/getSysErrorList", Description: "获取错误日志列表"},
{ApiGroup: "错误日志", Method: "GET", Path: "/sysError/getSysErrorSolution", Description: "触发错误处理(异步)"},
{ApiGroup: "公告", Method: "POST", Path: "/info/createInfo", Description: "新建公告"},
{ApiGroup: "公告", Method: "DELETE", Path: "/info/deleteInfo", Description: "删除公告"},

View File

@@ -185,6 +185,7 @@ func (i *initCasbin) InitializeData(ctx context.Context) (context.Context, error
{Ptype: "p", V0: "888", V1: "/sysError/updateSysError", V2: "PUT"},
{Ptype: "p", V0: "888", V1: "/sysError/findSysError", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysError/getSysErrorList", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/sysError/getSysErrorSolution", V2: "GET"},
{Ptype: "p", V0: "888", V1: "/info/createInfo", V2: "POST"},
{Ptype: "p", V0: "888", V1: "/info/deleteInfo", V2: "DELETE"},

View File

@@ -108,3 +108,19 @@ export const getSysErrorPublic = () => {
method: 'get',
})
}
// @Tags SysError
// @Summary 触发错误处理(异步)
// @Security ApiKeyAuth
// @Accept application/json
// @Produce application/json
// @Param id query string true "错误日志ID"
// @Success 200 {string} string "{\"success\":true,\"data\":{},\"msg\":\"处理已提交\"}"
// @Router /sysError/getSysErrorSolution [get]
export const getSysErrorSolution = (params) => {
return service({
url: '/sysError/getSysErrorSolution',
method: 'get',
params
})
}

View File

@@ -112,6 +112,13 @@
width="120"
/>
<el-table-column
align="left"
label="处理状态"
prop="status"
width="120"
/>
<el-table-column
align="left"
label="错误内容"
@@ -136,10 +143,11 @@
>
<template #default="scope">
<el-button
v-if="scope.row.status !== '处理中'"
type="primary"
link
class="table-button"
@click="getDetails(scope.row)"
@click="getSolution(scope.row.ID)"
><el-icon><ai-gva /></el-icon
>方案</el-button
>
@@ -189,6 +197,9 @@
<el-descriptions-item label="错误等级">
{{ detailForm.level }}
</el-descriptions-item>
<el-descriptions-item label="处理状态">
{{ detailForm.status || '未处理' }}
</el-descriptions-item>
<el-descriptions-item label="错误内容" :span="2">
<pre class="whitespace-pre-wrap break-words">{{ detailForm.info }}</pre>
</el-descriptions-item>
@@ -205,7 +216,8 @@
deleteSysError,
deleteSysErrorByIds,
findSysError,
getSysErrorList
getSysErrorList,
getSysErrorSolution
} from '@/api/system/sysError'
import { formatDate } from '@/utils/format'
@@ -236,6 +248,14 @@
getTableData()
}
const getSolution = (id) =>{
getSysErrorSolution({ id }).then((res) => {
if (res.code === 0) {
ElMessage({ type: 'success', message: res.msg || '处理已提交1分钟后完成' })
getTableData()
}
})
}
// 搜索
const onSubmit = () => {
elSearchFormRef.value?.validate(async (valid) => {