Procházet zdrojové kódy

feat(waf): 添加 WAF 日志导出功能并优化查询性能

- 新增 WAF 日志导出 API 和相关处理逻辑- 实现分页导出和导出数据总数查询功能
- 优化 WAF 日志查询性能,解决 N+1 查询问题- 更新数据库模型和查询方法以支持新功能
fusu před 4 dny
rodič
revize
3a419e36d4

+ 12 - 12
api/v1/admin/wafLog.go

@@ -39,18 +39,18 @@ type WafLogId struct {
 
 
 type ExportWafLog struct {
-	Id         int    `json:"id" form:"id" gorm:"column:id;primary_key;AUTO_INCREMENT;not null"`
-	Uid        int    `json:"uid" form:"uid" gorm:"column:uid;default:0;not null"`
-	Name       string `json:"name" form:"name" gorm:"column:name"`
-	RequestIp  string `json:"requestIp" form:"requestIp" gorm:"column:request_ip"`
-	RuleId     int    `json:"ruleId" form:"ruleId" gorm:"column:rule_id;default:0"`
-	HostIds     []int    `json:"hostIds" form:"hostIds" gorm:"column:host_id;default:0"`
-	UserAgent  string `json:"userAgent" form:"userAgent" gorm:"column:user_agent"`
-	Api        string `json:"api" form:"api" gorm:"column:api"`
-	ApiNames    []string `json:"apiNames" form:"apiNames" gorm:"column:api_name"`
-	ApiTypes    []string `json:"apiTypes" form:"apiTypes" gorm:"column:api_type"`
-	StartTime  string `json:"startTime" form:"startTime" gorm:"column:start_time"`
-	EndTime    string `json:"endTime" form:"endTime" gorm:"column:end_time"`
+	Id         int    `json:"id" form:"id"`
+	Uid        int    `json:"uid" form:"uid"`
+	Name       string `json:"name" form:"name"`
+	RequestIp  string `json:"requestIp" form:"requestIp"`
+	RuleId     int    `json:"ruleId" form:"ruleId"`
+	HostIds     []int   `json:"hostIds" form:"hostIds"`
+	UserAgent  string `json:"userAgent" form:"userAgent"`
+	Api        string `json:"api" form:"api"`
+	ApiNames    []string `json:"apiNames" form:"apiNames"`
+	ApiTypes    []string `json:"apiTypes" form:"apiTypes"`
+	StartTime  string `json:"startTime" form:"startTime"`
+	EndTime    string `json:"endTime" form:"endTime"`
 }
 
 type ExportWafLogRes struct {

+ 7 - 1
go.mod

@@ -32,11 +32,12 @@ require (
 	github.com/sony/sonyflake v1.1.0
 	github.com/spf13/cast v1.5.1
 	github.com/spf13/viper v1.8.1
-	github.com/stretchr/testify v1.8.4
+	github.com/stretchr/testify v1.10.0
 	github.com/swaggo/files v1.0.1
 	github.com/swaggo/gin-swagger v1.6.0
 	github.com/swaggo/swag v1.16.4
 	github.com/tidwall/gjson v1.18.0
+	github.com/xuri/excelize/v2 v2.9.1
 	go.mongodb.org/mongo-driver v1.17.4
 	go.uber.org/zap v1.26.0
 	golang.org/x/crypto v0.40.0
@@ -114,6 +115,8 @@ require (
 	github.com/pelletier/go-toml/v2 v2.0.9 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+	github.com/richardlehane/mscfb v1.0.4 // indirect
+	github.com/richardlehane/msoleps v1.0.4 // indirect
 	github.com/robfig/cron/v3 v3.0.1 // indirect
 	github.com/sanity-io/litter v1.5.5 // indirect
 	github.com/sergi/go-diff v1.0.0 // indirect
@@ -123,6 +126,7 @@ require (
 	github.com/subosito/gotenv v1.4.2 // indirect
 	github.com/tidwall/match v1.1.1 // indirect
 	github.com/tidwall/pretty v1.2.0 // indirect
+	github.com/tiendc/go-deepcopy v1.6.0 // indirect
 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
 	github.com/ugorji/go/codec v1.2.12 // indirect
 	github.com/valyala/bytebufferpool v1.0.0 // indirect
@@ -133,6 +137,8 @@ require (
 	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
 	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
+	github.com/xuri/efp v0.0.1 // indirect
+	github.com/xuri/nfp v0.0.1 // indirect
 	github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect
 	github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
 	github.com/yudai/gojsondiff v1.0.0 // indirect

+ 19 - 2
go.sum

@@ -465,6 +465,11 @@ github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDO
 github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
+github.com/richardlehane/mscfb v1.0.4/go.mod h1:YzVpcZg9czvAuhk9T+a3avCpcFPMUWm7gK3DypaEsUk=
+github.com/richardlehane/msoleps v1.0.1/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
+github.com/richardlehane/msoleps v1.0.4 h1:WuESlvhX3gH2IHcd8UqyCuFY5yiq/GR/yqaSM/9/g00=
+github.com/richardlehane/msoleps v1.0.4/go.mod h1:BWev5JBpU9Ko2WAgmZEuiz4/u3ZYTKbjLycmwiWUfWg=
 github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
 github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
 github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
@@ -498,8 +503,9 @@ github.com/spf13/viper v1.8.1 h1:Kq1fyeebqsBfbjZj4EL7gj2IO0mMaiyjYUWcUsl2O44=
 github.com/spf13/viper v1.8.1/go.mod h1:o0Pch8wJ9BVSWGQMbra6iw0oQ5oktSIBaujf1rJH9Ns=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
 github.com/stretchr/testify v0.0.0-20161117074351-18a02ba4a312/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
@@ -511,8 +517,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
 github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
 github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
+github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
 github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8=
 github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0=
@@ -529,6 +536,8 @@ github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
 github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
 github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
 github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tiendc/go-deepcopy v1.6.0 h1:0UtfV/imoCwlLxVsyfUd4hNHnB3drXsfle+wzSCA5Wo=
+github.com/tiendc/go-deepcopy v1.6.0/go.mod h1:toXoeQoUqXOOS/X4sKuiAoSk6elIdqc0pN7MTgOOo2I=
 github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
 github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
 github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -551,6 +560,12 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
 github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
 github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
+github.com/xuri/efp v0.0.1 h1:fws5Rv3myXyYni8uwj2qKjVaRP30PdjeYe2Y6FDsCL8=
+github.com/xuri/efp v0.0.1/go.mod h1:ybY/Jr0T0GTCnYjKqmdwxyxn2BQf2RcQIIvex5QldPI=
+github.com/xuri/excelize/v2 v2.9.1 h1:VdSGk+rraGmgLHGFaGG9/9IWu1nj4ufjJ7uwMDtj8Qw=
+github.com/xuri/excelize/v2 v2.9.1/go.mod h1:x7L6pKz2dvo9ejrRuD8Lnl98z4JLt0TGAwjhW+EiP8s=
+github.com/xuri/nfp v0.0.1 h1:MDamSGatIvp8uOmDP8FnmjuQpu90NzdJxo7242ANR9Q=
+github.com/xuri/nfp v0.0.1/go.mod h1:WwHg+CVyzlv/TX9xqBFXEZAuxOPxn2k1GNHwG41IIUQ=
 github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
 github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
 github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
@@ -631,6 +646,8 @@ golang.org/x/exp v0.0.0-20221208152030-732eee02a75a h1:4iLhBPcpqFmylhnkbY3W0ONLU
 golang.org/x/exp v0.0.0-20221208152030-732eee02a75a/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
 golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.25.0 h1:Y6uW6rH1y5y/LK1J8BPWZtr6yZ7hrsy6hFrXjgsc2fQ=
+golang.org/x/image v0.25.0/go.mod h1:tCAmOEGthTtkalusGp1g3xa2gke8J6c2N565dTyl9Rs=
 golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
 golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=

+ 23 - 0
internal/handler/admin/waflog.go

@@ -53,4 +53,27 @@ func (h *WafLogHandler) GetWafLogList(ctx *gin.Context) {
 		return
 	}
 	v1.HandleSuccess(ctx, res)
+}
+
+// ExportWafLog 导出WAF日志为Excel文件
+func (h *WafLogHandler) ExportWafLog(ctx *gin.Context) {
+	var req adminApi.ExportWafLog
+	if err := ctx.ShouldBind(&req); err != nil {
+		v1.HandleError(ctx, http.StatusBadRequest, v1.ErrBadRequest, err.Error())
+		return
+	}
+	defaults.SetDefaults(&req)
+	
+	// 调用服务层的智能导出方法
+	err := h.wafLogService.SmartExportWafLog(ctx, req, ctx.Writer)
+	if err != nil {
+		v1.HandleError(ctx, http.StatusInternalServerError, err, err.Error())
+		return
+	}
+}
+
+// GetApiDescriptions 获取API描述映射
+func (h *WafLogHandler) GetApiDescriptions(ctx *gin.Context) {
+	res := h.wafLogService.GetApiDescriptions(ctx)
+	v1.HandleSuccess(ctx, res)
 }

+ 123 - 2
internal/repository/admin/waflog.go

@@ -2,6 +2,7 @@ package admin
 
 import (
 	"context"
+	"fmt"
 	v1 "github.com/go-nunu/nunu-layout-advanced/api/v1"
 	adminApi "github.com/go-nunu/nunu-layout-advanced/api/v1/admin"
 	"github.com/go-nunu/nunu-layout-advanced/internal/model"
@@ -18,8 +19,14 @@ type WafLogRepository interface {
 	BatchAddWafLog(ctx context.Context, logs []*model.WafLog) error
 	// 导出日志
 	ExportWafLog(ctx context.Context, req adminApi.ExportWafLog) ([]model.WafLog, error)
+	// 支持分页的导出方法
+	ExportWafLogWithPagination(ctx context.Context, req adminApi.ExportWafLog, page, pageSize int) ([]model.WafLog, error)
+	// 获取导出数据总数
+	GetWafLogExportCount(ctx context.Context, req adminApi.ExportWafLog) (int, error)
 	// 获取网关组记录
 	GetWafLogGateWayIp(ctx context.Context, hostId int64, Uid int64,createdAt time.Time) (model.WafLog, error)
+	// 批量获取网关组记录
+	BatchGetWafLogGateWayIps(ctx context.Context, hostIds []int64, uids []int64, maxCreatedAt time.Time) (map[string]model.WafLog, error)
 }
 
 func NewWafLogRepository(
@@ -155,6 +162,11 @@ func (r *wafLogRepository) BatchAddWafLog(ctx context.Context, logs []*model.Waf
 }
 
 func (r *wafLogRepository) ExportWafLog(ctx context.Context, req adminApi.ExportWafLog) ([]model.WafLog, error) {
+	return r.ExportWafLogWithPagination(ctx, req, 0, 0)
+}
+
+// ExportWafLogWithPagination 支持分页的导出方法
+func (r *wafLogRepository) ExportWafLogWithPagination(ctx context.Context, req adminApi.ExportWafLog, page, pageSize int) ([]model.WafLog, error) {
 	var res []model.WafLog
 	query := r.DBWithName(ctx,"admin").Model(&model.WafLog{})
 	if  req.RequestIp != "" {
@@ -185,7 +197,7 @@ func (r *wafLogRepository) ExportWafLog(ctx context.Context, req adminApi.Export
 		query = query.Where("rule_id = ?", req.RuleId)
 	}
 
-	if len(req.HostIds) > 0 {
+		if len(req.HostIds) > 0 {
 		query = query.Where("host_id IN ?", req.HostIds)
 	}
 
@@ -227,6 +239,11 @@ func (r *wafLogRepository) ExportWafLog(ctx context.Context, req adminApi.Export
 		query = query.Where("create_at < ?", trimmedName)
 	}
 
+	// 添加分页逻辑
+	if page > 0 && pageSize > 0 {
+		offset := (page - 1) * pageSize
+		query = query.Offset(offset).Limit(pageSize)
+	}
 
 	result := query.Find(&res)
 	if result.Error != nil {
@@ -235,7 +252,111 @@ func (r *wafLogRepository) ExportWafLog(ctx context.Context, req adminApi.Export
 	return res, nil
 }
 
+// GetWafLogExportCount 获取导出数据总数
+func (r *wafLogRepository) GetWafLogExportCount(ctx context.Context, req adminApi.ExportWafLog) (int, error) {
+	var count int64
+	query := r.DBWithName(ctx,"admin").Model(&model.WafLog{})
+	
+	// 复用ExportWafLog的查询条件
+	if req.RequestIp != "" {
+		trimmedName := strings.TrimSpace(req.RequestIp)
+		query = query.Where("request_ip = ?", trimmedName)
+	}
+
+	if req.Uid != 0 {
+		query = query.Where("uid = ?", req.Uid)
+	}
+
+	if req.Api != "" {
+		trimmedName := strings.TrimSpace(req.Api)
+		query = query.Where("api = ?", trimmedName)
+	}
+
+	if req.Name != "" {
+		trimmedName := strings.TrimSpace(req.Name)
+		query = query.Where("name = ?", trimmedName)
+	}
+
+	if req.RuleId != 0 {
+		query = query.Where("rule_id = ?", req.RuleId)
+	}
+
+	if len(req.HostIds) > 0 {
+		query = query.Where("host_id IN ?", req.HostIds)
+	}
+
+	if req.UserAgent != "" {
+		trimmedName := strings.TrimSpace(req.UserAgent)
+		query = query.Where("user_agent = ?", trimmedName)
+	}
+
+	if len(req.ApiNames) > 0 {
+		trimmedNames := make([]string, len(req.ApiNames))
+		for _, apiName := range req.ApiNames {
+			trimmedNames = append(trimmedNames, strings.TrimSpace(apiName))
+		}
+		query = query.Where("api_name IN ?", trimmedNames)
+	}
+
+	if len(req.ApiTypes) > 0 {
+		query = query.Where("api_type IN ?", req.ApiTypes)
+	}
+
+	if req.StartTime != "" {
+		trimmedName := strings.TrimSpace(req.StartTime)
+		query = query.Where("create_at > ?", trimmedName)
+	}
+
+	if req.EndTime != "" {
+		trimmedName := strings.TrimSpace(req.EndTime)
+		query = query.Where("create_at < ?", trimmedName)
+	}
+
+	result := query.Count(&count)
+	if result.Error != nil {
+		return 0, result.Error
+	}
+	return int(count), nil
+}
+
 func (r *wafLogRepository) GetWafLogGateWayIp(ctx context.Context, hostId int64, Uid int64,createdAt time.Time) (model.WafLog, error) {
 	var res model.WafLog
-	return res, r.DBWithName(ctx,"admin").Where("host_id = ? and uid = ? and api_name = ? and created_at > ? ", hostId, Uid, "分配网关组", createdAt).First(&res).Error
+	return res, r.DBWithName(ctx,"admin").Where("host_id = ? and uid = ? and api_name = ? and created_at < ? ", hostId, Uid, "分配网关组", createdAt).First(&res).Error
+}
+
+// BatchGetWafLogGateWayIps 批量获取网关组记录,避免N+1查询问题
+func (r *wafLogRepository) BatchGetWafLogGateWayIps(ctx context.Context, hostIds []int64, uids []int64, maxCreatedAt time.Time) (map[string]model.WafLog, error) {
+	if len(hostIds) == 0 || len(uids) == 0 {
+		return make(map[string]model.WafLog), nil
+	}
+
+	var gateWayLogs []model.WafLog
+	
+	// 构建查询条件,获取所有相关的网关组记录
+	query := r.DBWithName(ctx, "admin").
+		Where("api_name = ?", "分配网关组").
+		Where("created_at < ?", maxCreatedAt)
+	
+	// 如果hostIds和uids数量较少,使用IN查询
+	if len(hostIds) <= 1000 && len(uids) <= 1000 {
+		query = query.Where("host_id IN ? AND uid IN ?", hostIds, uids)
+	}
+	
+	// 按创建时间倒序,确保获取最新的网关组配置
+	err := query.Order("created_at DESC").Find(&gateWayLogs).Error
+	if err != nil {
+		return nil, err
+	}
+
+	// 构建映射表,key为"hostId_uid",value为最新的网关组记录
+	result := make(map[string]model.WafLog)
+	for _, log := range gateWayLogs {
+		key := fmt.Sprintf("%d_%d", log.HostId, log.Uid)
+		// 由于已经按创建时间倒序排列,第一次遇到的就是最新的记录
+		if _, exists := result[key]; !exists {
+			result[key] = log
+		}
+	}
+
+	return result, nil
 }

+ 2 - 0
internal/server/http.go

@@ -194,6 +194,8 @@ func NewHTTPServer(
 
 			strictAuthRouter.GET("admin/wafLog/get", wafLogHandler.GetWafLog)
 			strictAuthRouter.GET("admin/wafLog/getList", wafLogHandler.GetWafLogList)
+			strictAuthRouter.POST("admin/wafLog/export", wafLogHandler.ExportWafLog)
+			strictAuthRouter.GET("admin/wafLog/getApiDescriptions", wafLogHandler.GetApiDescriptions)
 		}
 	}
 

+ 329 - 78
internal/service/admin/waflog.go

@@ -10,18 +10,64 @@ import (
 	adminRep "github.com/go-nunu/nunu-layout-advanced/internal/repository/admin"
 	"github.com/go-nunu/nunu-layout-advanced/internal/repository/api/waf"
 	"github.com/go-nunu/nunu-layout-advanced/internal/service"
+	"github.com/go-nunu/nunu-layout-advanced/pkg/excel"
 	"github.com/go-nunu/nunu-layout-advanced/pkg/rabbitmq"
 	amqp "github.com/rabbitmq/amqp091-go"
 	"go.uber.org/zap"
+	"net/http"
 	"strings"
+	"time"
 )
 
+// ApiDescriptionMap API描述映射
+var ApiDescriptionMap = map[string]string{
+	"/webForward/get":      "获取web详情",
+	"/webForward/getList":  "获取web列表",
+	"/webForward/add":      "添加web",
+	"/webForward/edit":     "修改web",
+	"/webForward/delete":   "删除web",
+
+	"/tcpForward/add":      "添加tcp",
+	"/tcpForward/edit":     "修改tcp",
+	"/tcpForward/delete":   "删除tcp",
+	"/tcpForward/getList":  "获取tcp列表",
+	"/tcpForward/get":      "获取tcp详情",
+
+	"/udpForward/add":      "添加udp",
+	"/udpForward/edit":     "修改udp",
+	"/udpForward/delete":   "删除udp",
+	"/udpForward/getList":  "获取udp列表",
+	"/udpForward/get":      "获取udp详情",
+
+	"/globalLimit/add":     "添加实例",
+	"/globalLimit/edit":    "修改实例",
+	"/globalLimit/delete":  "删除实例",
+
+	"/allowAndDeny/get":      "获取黑白名单详情",
+	"/allowAndDeny/getList":  "获取黑白名单列表",
+	"/allowAndDeny/add":      "添加黑白名单",
+	"/allowAndDeny/edit":     "修改黑白名单",
+	"/allowAndDeny/delete":   "删除黑白名单",
+
+	"/cc/getList":    "获取CC列表",
+	"/cc/editState":  "删除CC黑名单",
+
+	"/ccIpList/getList":  "获取CC白名单列表",
+	"/ccIpList/add":      "添加CC白名单",
+	"/ccIpList/edit":     "修改CC白名单",
+	"/ccIpList/delete":   "删除CC白名单",
+
+	"分配网关组": "分配网关组",
+}
+
 type WafLogService interface {
 	GetWafLog(ctx context.Context, id int64) (*model.WafLog, error)
 	GetWafLogList(ctx context.Context, req adminApi.SearchWafLogParams) (*v1.PaginatedResponse[model.WafLog], error)
 	AddWafLog(ctx context.Context, req adminApi.WafLog) error
 	BatchAddWafLog(ctx context.Context, reqs []*adminApi.WafLog) error
 	PublishIpWafLogTask(ctx context.Context, req adminApi.WafLog)
+	SmartExportWafLog(ctx context.Context, req adminApi.ExportWafLog, w http.ResponseWriter) error
+	GetApiDescriptions(ctx context.Context) map[string]string
 }
 func NewWafLogService(
     service *service.Service,
@@ -43,48 +89,6 @@ type wafLogService struct {
 	globalLimitRepository waf.GlobalLimitRepository
 	mq *rabbitmq.RabbitMQ
 }
-
-var ApiDescriptionMap = map[string]string{
-
-	"/webForward/get": "获取web详情",
-	"/webForward/getList" : "获取web列表",
-	"/webForward/add" : "添加web",
-	"/webForward/edit" : "修改web",
-	"/webForward/delete" : "删除web",
-
-	"/tcpForward/add" : "添加tcp",
-	"/tcpForward/edit" : "修改tcp",
-	"/tcpForward/delete" : "删除tcp",
-	"/tcpForward/getList" : "获取tcp列表",
-	"/tcpForward/get" : "获取tcp详情",
-
-	"/udpForward/add" : "添加udp",
-	"/udpForward/edit" : "修改udp",
-	"/udpForward/delete" : "删除udp",
-	"/udpForward/getList" : "获取udp列表",
-	"/udpForward/get" : "获取udp详情",
-
-	"/globalLimit/add" : "添加实例",
-	"/globalLimit/edit" : "修改实例",
-	"/globalLimit/delete" : "删除实例",
-
-	"/allowAndDeny/get" : "获取黑白名单详情",
-	"/allowAndDeny/getList" : "获取黑白名单列表",
-	"/allowAndDeny/add" : "添加黑白名单",
-	"/allowAndDeny/edit" : "修改黑白名单",
-	"/allowAndDeny/delete" : "删除黑白名单",
-
-	"/cc/getList" : "获取CC列表",
-	"/cc/editState" : "删除CC黑名单",
-
-	"/ccIpList/getList" : "获取CC白名单列表",
-	"/ccIpList/add" : "添加CC白名单",
-	"/ccIpList/edit" : "修改CC白名单",
-	"/ccIpList/delete" : "删除CC白名单",
-
-	"分配网关组" : "分配网关组",
-
-}
 func (s *wafLogService) getFirstPathSegment(path string) (segment []string, ok bool) {
 	// 1. 为了统一处理,先去掉路径最前面的 "/"
 	// 这样 "/v1/admin" 会变成 "v1/admin",而 "v1/admin" 保持不变
@@ -247,30 +251,262 @@ func (s *wafLogService) PublishIpWafLogTask(ctx context.Context, req adminApi.Wa
 }
 
 func (s *wafLogService) ExPortWafLog(ctx context.Context, req adminApi.ExportWafLog) ([]adminApi.ExportWafLogRes, error) {
+	// 获取原始数据
 	data, err := s.wafLogRepository.ExportWafLog(ctx, req)
 	if err != nil {
 		return nil, err
 	}
 
+	// 使用优化后的转换方法,避免N+1查询
+	return s.convertRawDataToExportResults(ctx, data)
+}
+
+// SmartExportWafLog 智能导出WAF日志为Excel
+func (s *wafLogService) SmartExportWafLog(ctx context.Context, req adminApi.ExportWafLog, w http.ResponseWriter) error {
+	// 1. 先获取总数量用于智能选择传输方式
+	count, err := s.wafLogRepository.GetWafLogExportCount(ctx, req)
+	if err != nil {
+		return fmt.Errorf("获取导出数据总数失败: %w", err)
+	}
+
+	// 2. 智能选择导出方式
+	// 估算每行数据大小约200字节(包含用户名、IP、API名称、域名等字段)
+	exportType := excel.SmartExport(count, 200)
+	
+	// 3. 设置Excel表头映射
+	headers := []string{"name", "request_ip", "host_id", "api_name", "addr_backend_list", "domain", "comment", "custom_host", "expose_addr", "created_at"}
+	headerMap := map[string]string{
+		"name":             "用户名",
+		"request_ip":       "请求IP",
+		"host_id":          "主机ID", 
+		"api_name":         "API名称",
+		"addr_backend_list": "后端地址",
+		"domain":           "域名",
+		"comment":          "备注",
+		"custom_host":      "自定义主机",
+		"expose_addr":      "暴露地址",
+		"created_at":       "创建时间",
+	}
+
+	// 4. 创建Excel生成器
+	generator := excel.NewExcelGenerator("WAF日志", headers, headerMap)
+	if err := generator.WriteHeaders(); err != nil {
+		return fmt.Errorf("写入Excel表头失败: %w", err)
+	}
+
+	// 5. 根据导出类型选择不同的处理方式
+	switch exportType {
+	case excel.ExportTypeNormal:
+		return s.normalExportWafLog(ctx, req, generator, w)
+	case excel.ExportTypeStream:
+		return s.streamExportWafLog(ctx, req, generator, w)
+	case excel.ExportTypeChunk:
+		return s.chunkExportWafLog(ctx, req, w, count)
+	default:
+		return s.normalExportWafLog(ctx, req, generator, w)
+	}
+}
+
+// normalExportWafLog 普通导出(小文件)
+func (s *wafLogService) normalExportWafLog(ctx context.Context, req adminApi.ExportWafLog, generator *excel.ExcelGenerator, w http.ResponseWriter) error {
+	// 获取所有数据(已经优化了批量查询)
+	exportData, err := s.ExPortWafLog(ctx, req)
+	if err != nil {
+		return fmt.Errorf("获取导出数据失败: %w", err)
+	}
+
+	// 转换数据格式
+	data := make([]map[string]interface{}, 0, len(exportData))
+	for _, item := range exportData {
+		row := map[string]interface{}{
+			"name":             item.Name,
+			"request_ip":       item.RequestIp,
+			"host_id":          item.HostId,
+			"api_name":         item.ApiName,
+			"addr_backend_list": s.formatBackendList(item.AddrBackendList),
+			"domain":           item.Domain,
+			"comment":          item.Comment,
+			"custom_host":      item.CustomHost,
+			"expose_addr":      s.formatExposeAddr(item.ExposeAddr),
+			"created_at":       item.CreatedAt,
+		}
+		data = append(data, row)
+	}
+
+	// 写入数据
+	if err := generator.WriteRows(data); err != nil {
+		return fmt.Errorf("写入Excel数据失败: %w", err)
+	}
+
+	// 普通导出
+	fileName := fmt.Sprintf("waf_logs_%s.xlsx", time.Now().Format("20060102_150405"))
+	return excel.NormalExport(generator, w, excel.TransferOption{
+		FileName:    fileName,
+		ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+	})
+}
+
+// streamExportWafLog 流式导出(大文件)
+func (s *wafLogService) streamExportWafLog(ctx context.Context, req adminApi.ExportWafLog, generator *excel.ExcelGenerator, w http.ResponseWriter) error {
+	fileName := fmt.Sprintf("waf_logs_%s.xlsx", time.Now().Format("20060102_150405"))
+	
+	// 设置响应头
+	w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
+	w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName))
+	w.Header().Set("Transfer-Encoding", "chunked")
+
+	// 分批处理数据,每批1000条
+	pageSize := 1000
+	page := 1
+
+	for {
+		// 使用分页导出方法
+		exportData, err := s.wafLogRepository.ExportWafLogWithPagination(ctx, req, page, pageSize)
+		if err != nil {
+			return fmt.Errorf("获取第%d页数据失败: %w", page, err)
+		}
+
+		// 转换为导出格式(复用原有的ExPortWafLog逻辑)
+		exportResults, err := s.convertRawDataToExportResults(ctx, exportData)
+		if err != nil {
+			return fmt.Errorf("转换导出数据失败: %w", err)
+		}
+
+		if len(exportResults) == 0 {
+			break // 没有更多数据
+		}
+
+		// 转换并写入当前批次数据
+		for _, item := range exportResults {
+			row := map[string]interface{}{
+				"name":             item.Name,
+				"request_ip":       item.RequestIp,
+				"host_id":          item.HostId,
+				"api_name":         item.ApiName,
+				"addr_backend_list": s.formatBackendList(item.AddrBackendList),
+				"domain":           item.Domain,
+				"comment":          item.Comment,
+				"custom_host":      item.CustomHost,
+				"expose_addr":      s.formatExposeAddr(item.ExposeAddr),
+				"created_at":       item.CreatedAt,
+			}
+			
+			if err := generator.WriteRow(row); err != nil {
+				return fmt.Errorf("写入第%d页数据失败: %w", page, err)
+			}
+		}
+
+		// 如果当前批次数据少于页大小,说明已经是最后一页
+		if len(exportResults) < pageSize {
+			break
+		}
+
+		page++
+	}
+
+	// 流式导出
+	return excel.StreamExport(generator, w, excel.TransferOption{
+		FileName:    fileName,
+		ContentType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+	})
+}
+
+// chunkExportWafLog 分块导出(超大文件)
+func (s *wafLogService) chunkExportWafLog(ctx context.Context, req adminApi.ExportWafLog, w http.ResponseWriter, totalRecords int) error {
+	fileName := fmt.Sprintf("waf_logs_%s.xlsx", time.Now().Format("20060102_150405"))
+	pageSize := 5000 // 每个分块5000条记录
+	
+	// 分块导出需要前端配合实现
+	excel.ChunkExport(w, excel.TransferOption{
+		FileName:    fileName,
+		ContentType: "application/json", // 返回分块信息
+	}, totalRecords, pageSize)
+	
+	return nil
+}
+
+// formatBackendList 格式化后端地址列表
+func (s *wafLogService) formatBackendList(backendList interface{}) string {
+	if backendList == nil {
+		return ""
+	}
+	
+	switch v := backendList.(type) {
+	case string:
+		return v
+	case []string:
+		return strings.Join(v, ", ")
+	default:
+		return fmt.Sprintf("%v", v)
+	}
+}
+
+// formatExposeAddr 格式化暴露地址
+func (s *wafLogService) formatExposeAddr(exposeAddr []string) string {
+	if len(exposeAddr) == 0 {
+		return ""
+	}
+	return strings.Join(exposeAddr, ", ")
+}
+
+
+
+// convertRawDataToExportResults 将原始数据转换为导出结果(复用原有的ExPortWafLog逻辑)
+func (s *wafLogService) convertRawDataToExportResults(ctx context.Context, rawData []model.WafLog) ([]adminApi.ExportWafLogRes, error) {
+	if len(rawData) == 0 {
+		return []adminApi.ExportWafLogRes{}, nil
+	}
+
+	// 收集所有需要查询的hostId和uid,用于批量获取网关组
+	hostIds := make([]int64, 0, len(rawData))
+	uids := make([]int64, 0, len(rawData))
+	maxCreatedAt := time.Time{}
+	
+	for _, v := range rawData {
+		hostIds = append(hostIds, int64(v.HostId))
+		uids = append(uids, int64(v.Uid))
+		if v.CreatedAt.After(maxCreatedAt) {
+			maxCreatedAt = v.CreatedAt
+		}
+	}
+
+	// 批量获取网关组数据
+	gatewayMap, err := s.wafLogRepository.BatchGetWafLogGateWayIps(ctx, hostIds, uids, maxCreatedAt)
+	if err != nil {
+		s.Logger.Warn("批量获取网关组失败,降级为单个查询", zap.Error(err))
+		gatewayMap = make(map[string]model.WafLog) // 空map,后续会降级处理
+	}
+
 	var res []adminApi.ExportWafLogRes
-	for _, v := range data {
+	for _, v := range rawData {
 		var AddrBackendList interface{}
 		var customHost string
 		var port string
 		var domain string
 		var comment string
 
-
 		var mapData map[string]interface{}
 		err := json.Unmarshal(v.ExtraData, &mapData)
 		if err != nil {
-			return nil, err
+			// 尝试解析为数组格式
+			var arrayData []interface{}
+			if arrayErr := json.Unmarshal(v.ExtraData, &arrayData); arrayErr != nil {
+				// 如果不是符合的JSON格式,直接把原始值作为字符串处理
+				s.Logger.Warn("额外数据不是有效JSON格式,使用原始值", zap.Error(err), zap.Int("id", v.Id), 
+					zap.String("extra_data", string(v.ExtraData)))
+				mapData = map[string]interface{}{
+					"raw_data": string(v.ExtraData),
+				}
+			} else {
+				// 如果是数组格式,将数组作为值存储
+				s.Logger.Warn("额外数据为数组格式,保存为数组值", zap.Int("id", v.Id))
+				mapData = map[string]interface{}{
+					"array_data": arrayData,
+				}
+			}
 		}
 
-
-
 		if strings.Contains(v.ApiName, "tcp") || strings.Contains(v.ApiName, "udp") || strings.Contains(v.ApiName, "web") {
-
 			if mapData["port"] != nil {
 				port = mapData["port"].(string)
 			}
@@ -278,22 +514,22 @@ func (s *wafLogService) ExPortWafLog(ctx context.Context, req adminApi.ExportWaf
 				domain = mapData["domain"].(string)
 			}
 			if mapData["backend_list"] != nil {
-
 				if strings.Contains(v.ApiName, "web") {
 					var backendList []map[string]interface{}
 					err := json.Unmarshal([]byte(mapData["backend_list"].(string)), &backendList)
 					if err != nil {
-						return nil, err
+						s.Logger.Error("解析后端列表失败", zap.Error(err))
+						continue
 					}
-					for _, v := range backendList {
-						if v["addr"] != nil {
-							AddrBackendList = v["addr"]
+					for _, backend := range backendList {
+						if backend["addr"] != nil {
+							AddrBackendList = backend["addr"]
 						}
-						if v["customHost"] != nil {
-							customHost = v["customHost"].(string)
+						if backend["customHost"] != nil {
+							customHost = backend["customHost"].(string)
 						}
 					}
-				}else {
+				} else {
 					AddrBackendList = mapData["backend_list"]
 				}
 			}
@@ -303,36 +539,51 @@ func (s *wafLogService) ExPortWafLog(ctx context.Context, req adminApi.ExportWaf
 			comment = mapData["comment"].(string)
 		}
 
-
-		gateWayIpModel, err := s.wafLogRepository.GetWafLogGateWayIp(ctx, int64(v.HostId), int64(v.Uid),v.CreatedAt)
-		if err != nil {
-			return nil, err
-		}
-		var gateWayIps []string
-		if err := json.Unmarshal(gateWayIpModel.ExtraData, &gateWayIps); err != nil {
-			return nil, err
-		}
+		// 优化:从批量获取的网关组数据中查找
 		var exposeAddr []string
-		if len(gateWayIps) > 0 {
-			for _, v := range gateWayIps {
-				exposeAddr = append(exposeAddr, v + ":" + port)
+		key := fmt.Sprintf("%d_%d", v.HostId, v.Uid)
+		if gatewayModel, exists := gatewayMap[key]; exists {
+			var gateWayIps []string
+			if err := json.Unmarshal(gatewayModel.ExtraData, &gateWayIps); err == nil {
+				if len(gateWayIps) > 0 && port != "" {
+					for _, ip := range gateWayIps {
+						exposeAddr = append(exposeAddr, ip+":"+port)
+					}
+				}
+			}
+		} else {
+			// 降级:单个查询
+			gateWayIpModel, err := s.wafLogRepository.GetWafLogGateWayIp(ctx, int64(v.HostId), int64(v.Uid), v.CreatedAt)
+			if err == nil {
+				var gateWayIps []string
+				if err := json.Unmarshal(gateWayIpModel.ExtraData, &gateWayIps); err == nil {
+					if len(gateWayIps) > 0 && port != "" {
+						for _, ip := range gateWayIps {
+							exposeAddr = append(exposeAddr, ip+":"+port)
+						}
+					}
+				}
 			}
 		}
 
 		res = append(res, adminApi.ExportWafLogRes{
-			Name:       v.Name,
-			RequestIp:  v.RequestIp,
-			HostId:     v.HostId,
-			ApiName:    v.ApiName,
-			AddrBackendList:  AddrBackendList,
-			Domain:     domain,
-			Comment: 	comment,
-			CustomHost: customHost,
-			ExposeAddr: exposeAddr,
-			CreatedAt:  v.CreatedAt,
+			Name:            v.Name,
+			RequestIp:       v.RequestIp,
+			HostId:          v.HostId,
+			ApiName:         v.ApiName,
+			AddrBackendList: AddrBackendList,
+			Domain:          domain,
+			Comment:         comment,
+			CustomHost:      customHost,
+			ExposeAddr:      exposeAddr,
+			CreatedAt:       v.CreatedAt,
 		})
 	}
 
 	return res, nil
+}
 
+// GetApiDescriptions 获取API描述映射
+func (s *wafLogService) GetApiDescriptions(ctx context.Context) map[string]string {
+	return ApiDescriptionMap
 }

+ 229 - 0
pkg/excel/excel.go

@@ -0,0 +1,229 @@
+package excel
+
+import (
+	"fmt"
+	"github.com/xuri/excelize/v2"
+	"io"
+	"math"
+	"net/http"
+	"strconv"
+	"time"
+)
+
+// ExcelGenerator 通用Excel生成器
+type ExcelGenerator struct {
+	file       *excelize.File
+	sheetName  string
+	headers    []string
+	headerMap  map[string]string // 表头映射(英文字段名->中文显示名)
+	currentRow int               // 当前行号
+}
+
+// NewExcelGenerator 创建新的Excel生成器
+func NewExcelGenerator(sheetName string, headers []string, headerMap map[string]string) *ExcelGenerator {
+	f := excelize.NewFile()
+	// 默认sheet名称是Sheet1,如果传入的是其他名称,则创建新sheet并删除默认sheet
+	defaultSheetName := "Sheet1"
+	if sheetName != defaultSheetName {
+		f.NewSheet(sheetName)
+		f.DeleteSheet(defaultSheetName)
+	}
+	
+	return &ExcelGenerator{
+		file:       f,
+		sheetName:  sheetName,
+		headers:    headers,
+		headerMap:  headerMap,
+		currentRow: 1, // 从第1行开始(通常第1行是表头)
+	}
+}
+
+// WriteHeaders 写入表头
+func (g *ExcelGenerator) WriteHeaders() error {
+	// 写入表头
+	for i, header := range g.headers {
+		// 获取对应的显示名,如果没有映射则使用原字段名
+		displayName, exists := g.headerMap[header]
+		if !exists {
+			displayName = header
+		}
+		
+		cell := fmt.Sprintf("%s%d", columnName(i), g.currentRow)
+		if err := g.file.SetCellValue(g.sheetName, cell, displayName); err != nil {
+			return err
+		}
+		
+		// 设置表头样式(加粗、居中等)
+		style, err := g.file.NewStyle(&excelize.Style{
+			Font: &excelize.Font{
+				Bold: true,
+			},
+			Alignment: &excelize.Alignment{
+				Horizontal: "center",
+				Vertical:   "center",
+			},
+		})
+		if err != nil {
+			return err
+		}
+		if err := g.file.SetCellStyle(g.sheetName, cell, cell, style); err != nil {
+			return err
+		}
+	}
+	
+	g.currentRow++ // 表头写完后,行号+1
+	return nil
+}
+
+// WriteRows 写入多行数据
+func (g *ExcelGenerator) WriteRows(data []map[string]interface{}) error {
+	for _, row := range data {
+		if err := g.WriteRow(row); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
+// WriteRow 写入单行数据
+func (g *ExcelGenerator) WriteRow(rowData map[string]interface{}) error {
+	for i, field := range g.headers {
+		value, exists := rowData[field]
+		if !exists {
+			value = "" // 如果数据中不存在该字段,则写入空值
+		}
+		
+		cell := fmt.Sprintf("%s%d", columnName(i), g.currentRow)
+		
+		// 根据不同的数据类型处理
+		switch v := value.(type) {
+		case time.Time:
+			// 时间格式化为 YYYY-MM-DD HH:MM:SS
+			if err := g.file.SetCellValue(g.sheetName, cell, v.Format("2006-01-02 15:04:05")); err != nil {
+				return err
+			}
+		default:
+			if err := g.file.SetCellValue(g.sheetName, cell, v); err != nil {
+				return err
+			}
+		}
+	}
+	
+	g.currentRow++ // 一行写完后,行号+1
+	return nil
+}
+
+// SaveToWriter 保存到io.Writer接口
+func (g *ExcelGenerator) SaveToWriter(w io.Writer) error {
+	return g.file.Write(w)
+}
+
+// SaveToBuffer 保存到内存
+func (g *ExcelGenerator) SaveToBuffer() ([]byte, error) {
+	buffer, err := g.file.WriteToBuffer()
+	if err != nil {
+		return nil, err
+	}
+	return buffer.Bytes(), nil
+}
+
+// columnName 将列索引转换为Excel列名(A, B, C, ... Z, AA, AB, ...)
+func columnName(colIndex int) string {
+	if colIndex < 26 {
+		return string('A' + colIndex)
+	}
+	
+	// 超过26列时需要用两个或更多字母表示
+	result := ""
+	for colIndex >= 0 {
+		remainder := colIndex % 26
+		result = string('A'+remainder) + result
+		colIndex = colIndex/26 - 1
+		if colIndex < 0 {
+			break
+		}
+	}
+	
+	return result
+}
+
+// TransferOption 传输选项
+type TransferOption struct {
+	FileName    string
+	ContentType string
+}
+
+// ExportType 导出类型枚举
+type ExportType int
+
+const (
+	// ExportTypeNormal 普通导出(小文件,加载到内存后直接传输)
+	ExportTypeNormal ExportType = iota
+	
+	// ExportTypeStream 流式导出(大文件,流式传输避免占用过多内存)
+	ExportTypeStream
+	
+	// ExportTypeChunk 分块导出(超大文件,分块处理并传输)
+	ExportTypeChunk
+)
+
+// SmartExport 智能选择导出方式
+// dataCount: 数据条数
+// rowSize: 每行数据的估计大小(字节数)
+func SmartExport(dataCount int, rowSize int) ExportType {
+	// 估算导出文件大小(表头+数据)
+	estimatedSize := (dataCount + 1) * rowSize
+	
+	// 根据估算大小选择不同的导出方式
+	switch {
+	case estimatedSize <= 5*1024*1024: // 5MB以下用普通导出
+		return ExportTypeNormal
+		
+	case estimatedSize <= 50*1024*1024: // 5MB-50MB用流式导出
+		return ExportTypeStream
+		
+	default: // 超过50MB用分块导出
+		return ExportTypeChunk
+	}
+}
+
+// NormalExport 普通导出(小文件,一次性加载到内存)
+func NormalExport(g *ExcelGenerator, w http.ResponseWriter, option TransferOption) error {
+	// 设置响应头
+	w.Header().Set("Content-Type", option.ContentType)
+	w.Header().Set("Content-Disposition", "attachment; filename="+option.FileName)
+	
+	// 直接写入响应
+	return g.SaveToWriter(w)
+}
+
+// StreamExport 流式导出(大文件,流式传输)
+func StreamExport(g *ExcelGenerator, w http.ResponseWriter, option TransferOption) error {
+	// 设置响应头
+	w.Header().Set("Content-Type", option.ContentType)
+	w.Header().Set("Content-Disposition", "attachment; filename="+option.FileName)
+	w.Header().Set("Transfer-Encoding", "chunked")
+	
+	// 流式写入
+	return g.SaveToWriter(w)
+}
+
+// ChunkExport 分块导出(超大文件)
+// 这个方法需要配合前端实现,例如通过分页API多次获取数据并合并
+func ChunkExport(w http.ResponseWriter, option TransferOption, totalRecords int, pageSize int) {
+	totalPages := int(math.Ceil(float64(totalRecords) / float64(pageSize)))
+	
+	// 设置响应头
+	w.Header().Set("Content-Type", "application/json")
+	w.Header().Set("X-Total-Pages", strconv.Itoa(totalPages))
+	w.Header().Set("X-Total-Records", strconv.Itoa(totalRecords))
+	w.Header().Set("X-Page-Size", strconv.Itoa(pageSize))
+	
+	// 返回分块导出信息
+	w.Write([]byte(fmt.Sprintf(`{
+		"message": "File is too large for direct download. Please use paginated export.",
+		"total_records": %d,
+		"total_pages": %d,
+		"page_size": %d
+	}`, totalRecords, totalPages, pageSize)))
+}

+ 18 - 0
web/src/api/waf/waflog.js

@@ -14,4 +14,22 @@ export function getWafLogInfo(id) {
     method: 'get',
     params: { id }
   })
+}
+
+// 获取API描述映射
+export function getApiDescriptions() {
+  return request({
+    url: '/v1/admin/wafLog/getApiDescriptions',
+    method: 'get'
+  })
+}
+
+// 导出WAF日志
+export function exportWafLog(params) {
+  return request({
+    url: '/v1/admin/wafLog/export',
+    method: 'post',
+    data: params,
+    responseType: 'blob'
+  })
 }

+ 228 - 1
web/src/pages/log/waflog.vue

@@ -53,6 +53,7 @@
               <span class="table-page-search-submitButtons">
                 <a-button type="primary" @click="handleSearch">查询</a-button>
                 <a-button style="margin-left: 8px" @click="resetSearch">重置</a-button>
+                <a-button type="default" style="margin-left: 8px" @click="showExportModal">导出</a-button>
               </span>
             </a-col>
           </a-row>
@@ -80,12 +81,129 @@
     
     <!-- WAF日志详情模态框 -->
     <waf-log-detail-modal ref="detailModalRef" />
+    
+    <!-- 导出弹窗 -->
+    <a-modal
+      v-model:open="exportModalVisible"
+      title="导出WAF日志"
+      :width="800"
+      @ok="handleExport"
+      @cancel="handleExportCancel"
+      :confirm-loading="exportLoading"
+    >
+      <a-form :model="exportForm" layout="vertical">
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="ID">
+              <a-input v-model:value="exportForm.id" placeholder="请输入ID" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="用户ID">
+              <a-input v-model:value="exportForm.uid" placeholder="请输入用户ID" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="名称">
+              <a-input v-model:value="exportForm.name" placeholder="请输入名称" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="请求IP">
+              <a-input v-model:value="exportForm.requestIp" placeholder="请输入请求IP" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="规则ID">
+              <a-input v-model:value="exportForm.ruleId" placeholder="请输入规则ID" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="User Agent">
+              <a-input v-model:value="exportForm.userAgent" placeholder="请输入User Agent" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        
+        <a-row :gutter="16">
+          <a-col :span="24">
+            <a-form-item label="实例ID列表">
+              <a-select
+                v-model:value="exportForm.hostIds"
+                mode="tags"
+                placeholder="请输入实例ID,支持多个"
+                :token-separators="[',']"
+              />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="API路径">
+              <a-input v-model:value="exportForm.api" placeholder="请输入API路径" />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="API类型">
+              <a-select v-model:value="exportForm.apiTypes" mode="multiple" placeholder="请选择API类型">
+                <a-select-option value="get">get</a-select-option>
+                <a-select-option value="add">add</a-select-option>
+                <a-select-option value="delete">delete</a-select-option>
+                <a-select-option value="edit">edit</a-select-option>
+              </a-select>
+            </a-form-item>
+          </a-col>
+        </a-row>
+        
+        <a-row :gutter="16">
+          <a-col :span="24">
+            <a-form-item label="API名称">
+              <a-checkbox-group v-model:value="exportForm.apiNames" :options="apiNameOptions" />
+            </a-form-item>
+          </a-col>
+        </a-row>
+        
+        <a-row :gutter="16">
+          <a-col :span="12">
+            <a-form-item label="开始时间">
+              <a-date-picker
+                v-model:value="exportForm.startTime"
+                show-time
+                format="YYYY-MM-DD HH:mm:ss"
+                placeholder="请选择开始时间"
+                style="width: 100%"
+              />
+            </a-form-item>
+          </a-col>
+          <a-col :span="12">
+            <a-form-item label="结束时间">
+              <a-date-picker
+                v-model:value="exportForm.endTime"
+                show-time
+                format="YYYY-MM-DD HH:mm:ss"
+                placeholder="请选择结束时间"
+                style="width: 100%"
+              />
+            </a-form-item>
+          </a-col>
+        </a-row>
+      </a-form>
+    </a-modal>
   </div>
 </template>
 
 <script setup>
 import { ref, onMounted } from 'vue';
-import { getWafLogList } from '~/api/waf/waflog.js';
+import { message } from 'ant-design-vue';
+import dayjs from 'dayjs';
+import { getWafLogList, getApiDescriptions, exportWafLog } from '~/api/waf/waflog.js';
 import WafLogDetailModal from './components/waf-log-detail-modal.vue';
 
 const loading = ref(false);
@@ -200,11 +318,120 @@ const resetSearch = () => {
 
 const detailModalRef = ref(null);
 
+// 导出相关
+const exportModalVisible = ref(false);
+const exportLoading = ref(false);
+const apiNameOptions = ref([]);
+const exportForm = ref({
+  id: '',
+  uid: '',
+  name: '',
+  requestIp: '',
+  ruleId: '',
+  hostIds: [],
+  userAgent: '',
+  api: '',
+  apiNames: [],
+  apiTypes: [],
+  startTime: null,
+  endTime: null
+});
+
 const goToInfo = (id) => {
   // 打开模态框而不是跳转页面
   detailModalRef.value?.open(id);
 };
 
+// 显示导出弹窗
+const showExportModal = async () => {
+  try {
+    // 获取API描述映射
+    const response = await getApiDescriptions();
+    if (response && response.code === 0 && response.data) {
+      // 将API描述映射转换为checkbox选项格式
+      apiNameOptions.value = Object.entries(response.data).map(([key, value]) => ({
+        label: `${value} (${key})`,
+        value: key
+      }));
+    }
+    
+    // 重置表单
+    exportForm.value = {
+      id: '',
+      uid: '',
+      name: '',
+      requestIp: '',
+      ruleId: '',
+      hostIds: [],
+      userAgent: '',
+      api: '',
+      apiNames: [],
+      apiTypes: [],
+      startTime: null,
+      endTime: null
+    };
+    
+    exportModalVisible.value = true;
+  } catch (error) {
+    console.error('获取API描述失败:', error);
+    message.error('获取API描述失败');
+  }
+};
+
+// 处理导出
+const handleExport = async () => {
+  try {
+    exportLoading.value = true;
+    
+    // 构造导出参数
+    const params = {
+      id: exportForm.value.id ? parseInt(exportForm.value.id) : 0,
+      uid: exportForm.value.uid ? parseInt(exportForm.value.uid) : 0,
+      name: exportForm.value.name || '',
+      requestIp: exportForm.value.requestIp || '',
+      ruleId: exportForm.value.ruleId ? parseInt(exportForm.value.ruleId) : 0,
+      hostIds: exportForm.value.hostIds.map(id => parseInt(id)).filter(id => !isNaN(id)),
+      userAgent: exportForm.value.userAgent || '',
+      api: exportForm.value.api || '',
+      apiNames: exportForm.value.apiNames || [],
+      apiTypes: exportForm.value.apiTypes || [],
+      startTime: exportForm.value.startTime ? dayjs(exportForm.value.startTime).format('YYYY-MM-DD HH:mm:ss') : '',
+      endTime: exportForm.value.endTime ? dayjs(exportForm.value.endTime).format('YYYY-MM-DD HH:mm:ss') : ''
+    };
+    
+    console.log('导出参数:', params);
+    
+    // 调用导出接口
+    const response = await exportWafLog(params);
+    
+    // 处理文件下载
+    const blob = new Blob([response], { 
+      type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' 
+    });
+    const url = window.URL.createObjectURL(blob);
+    const link = document.createElement('a');
+    link.href = url;
+    link.download = `waflog_export_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.xlsx`;
+    document.body.appendChild(link);
+    link.click();
+    document.body.removeChild(link);
+    window.URL.revokeObjectURL(url);
+    
+    message.success('导出成功');
+    exportModalVisible.value = false;
+  } catch (error) {
+    console.error('导出失败:', error);
+    message.error('导出失败');
+  } finally {
+    exportLoading.value = false;
+  }
+};
+
+// 取消导出
+const handleExportCancel = () => {
+  exportModalVisible.value = false;
+};
+
 onMounted(() => {
   fetchData();
 });