Files
team-api/internal/logic/admin/audit.go
T

602 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/common"
do "github.com/qianfree/team-api/internal/model/do"
"github.com/qianfree/team-api/internal/model/entity"
"github.com/qianfree/team-api/internal/utility/export"
)
// audit levels — 委托给 common 包的常量
const (
AuditLevelFull = common.AuditLevelFull
AuditLevelFullText = common.AuditLevelFullText
AuditLevelMasked = common.AuditLevelMasked
AuditLevelQuestionOnly = common.AuditLevelQuestionOnly
AuditLevelNone = common.AuditLevelNone
)
// validAuditLevels defines the set of allowed audit level values.
var validAuditLevels = map[string]bool{
AuditLevelFull: true,
AuditLevelFullText: true,
AuditLevelMasked: true,
AuditLevelQuestionOnly: true,
AuditLevelNone: true,
}
// GetGlobalAuditConfig retrieves the global audit level from sys_options.
// Package-level wrapper for use by tenant package.
func GetGlobalAuditConfig(ctx context.Context) (string, error) {
return common.GetAuditLevel(ctx), nil
}
// GetAuditConfig retrieves the global audit level from sys_options.
func (s *sAdmin) GetAuditConfig(ctx context.Context, _ *v1.AuditConfigGetReq) (*v1.AuditConfigGetRes, error) {
return &v1.AuditConfigGetRes{AuditLevel: common.GetAuditLevel(ctx)}, nil
}
// UpdateAuditConfig updates the global audit level.
func (s *sAdmin) UpdateAuditConfig(ctx context.Context, req *v1.AuditConfigUpdateReq) (*v1.AuditConfigUpdateRes, error) {
if !validAuditLevels[req.AuditLevel] {
return nil, common.NewBadRequestError("无效的审计级别,可选值:full, full_text, masked, question_only, none")
}
// Upsert into sys_options
count, err := dao.SysOptions.Ctx(ctx).
Where("key", "audit_level").Count()
if err != nil {
return nil, err
}
if count > 0 {
_, err = dao.SysOptions.Ctx(ctx).
Where("key", "audit_level").
Data(do.SysOptions{
Value: req.AuditLevel,
}).
Update()
} else {
_, err = dao.SysOptions.Ctx(ctx).
Data(do.SysOptions{
Key: "audit_level",
Value: req.AuditLevel,
Description: "全局审计级别",
Category: "security",
IsPublic: false,
}).
Insert()
}
if err != nil {
return nil, err
}
// Invalidate cache
common.Config().SetOption(ctx, "audit_level", req.AuditLevel)
return nil, nil
}
// queryPage 使用 GoFrame ORM 分页查询,返回 []map[string]any。
// 使用主库查询审计表(aud_request_logs 之外的审计表数据量小,无需独立库)。
func queryPage(ctx context.Context, table, fields, where, orderBy string, page, pageSize int, args ...any) ([]map[string]any, int, error) {
m := g.DB().Ctx(ctx).Model(table).Safe()
if where != "" {
m = m.Where(where, args...)
}
total, err := m.Count()
if err != nil {
return nil, 0, err
}
if total == 0 {
return []map[string]any{}, 0, nil
}
if fields != "" {
m = m.Fields(fields)
}
if orderBy != "" {
m = m.Order(orderBy)
}
result, err := m.Page(page, pageSize).All()
if err != nil {
return nil, 0, err
}
return toMapList(result), total, nil
}
// toMapList converts database records to JSON-safe maps.
func toMapList(result gdb.Result) []map[string]any {
if len(result) == 0 {
return []map[string]any{}
}
list := make([]map[string]any, 0, len(result))
for _, row := range result {
list = append(list, row.Map())
}
return list
}
// queryAuditPage 查询审计表分页数据,自动计数、分页、转换并批量填充关联名称。
// 用于 ListRequestAuditLogs 和 ContentFilterLogList 的公共分页逻辑。
func queryAuditPage(ctx context.Context, db gdb.DB, table, fields, where string, args []any, page, pageSize int) ([]map[string]any, int, error) {
countM := db.Ctx(ctx).Model(table).Safe()
if where != "" {
countM = countM.Where(where, args...)
}
total, err := countM.Count()
if err != nil {
return nil, 0, err
}
if total == 0 {
return []map[string]any{}, 0, nil
}
dataM := db.Ctx(ctx).Model(table).Safe().Fields(fields).OrderDesc("created_at").Page(page, pageSize)
if where != "" {
dataM = dataM.Where(where, args...)
}
result, err := dataM.All()
if err != nil {
return nil, 0, err
}
items := make([]map[string]any, 0, len(result))
for _, row := range result {
m := make(map[string]any, len(row))
for k, v := range row {
m[k] = v.Val()
}
items = append(items, m)
}
common.EnrichAuditRecords(ctx, items)
return items, total, nil
}
// ListOperationLogs retrieves a paginated list of operation logs with optional filters.
func (s *sAdmin) ListOperationLogs(ctx context.Context, req *v1.OperationLogListReq) (*v1.OperationLogListRes, error) {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
var conditions []string
var args []any
if req.UserID > 0 {
conditions = append(conditions, "user_id = ?")
args = append(args, int64(req.UserID))
}
if req.UserType != "" {
conditions = append(conditions, "user_type = ?")
args = append(args, req.UserType)
}
if req.Action != "" {
conditions = append(conditions, "action LIKE ?")
args = append(args, "%"+req.Action+"%")
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := strings.Join(conditions, " AND ")
items, total, err := queryPage(ctx,
"aud_operation_logs",
"id, tenant_id, user_id, user_type, action, resource_type, resource_id, detail, changes_json, ip_address, created_at",
where, "created_at DESC", page, pageSize, args...)
if err != nil {
return nil, err
}
return &v1.OperationLogListRes{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// ListSensitiveAccessLogs retrieves a paginated list of sensitive data access logs.
func (s *sAdmin) ListSensitiveAccessLogs(ctx context.Context, req *v1.SensitiveLogListReq) (*v1.SensitiveLogListRes, error) {
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
var conditions []string
var args []any
if req.UserID > 0 {
conditions = append(conditions, "user_id = ?")
args = append(args, int64(req.UserID))
}
if req.ResourceType != "" {
conditions = append(conditions, "resource_type = ?")
args = append(args, req.ResourceType)
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := strings.Join(conditions, " AND ")
items, total, err := queryPage(ctx,
"aud_sensitive_access_logs",
"id, user_id, user_type, resource_type, resource_id, action, reason, ip_address, user_agent, created_at",
where, "created_at DESC", page, pageSize, args...)
if err != nil {
return nil, err
}
return &v1.SensitiveLogListRes{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// MaskSensitiveData 委托给 common.MaskSensitiveData
func MaskSensitiveData(data string) string {
return common.MaskSensitiveData(data)
}
// ListRequestAuditLogs 分页查询请求审计日志(不返回 request_body/response_body 以优化性能)
// 审计数据从审计库查询,关联信息(用户名、租户名等)从主库批量查询后在应用层合并。
func (s *sAdmin) ListRequestAuditLogs(ctx context.Context, req *v1.RequestAuditLogListReq) (*v1.RequestAuditLogListRes, error) {
if err := common.ValidateDateTimeParam(req.StartDate, "开始时间"); err != nil {
return nil, err
}
if err := common.ValidateDateTimeParam(req.EndDate, "结束时间"); err != nil {
return nil, err
}
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
var conditions []string
var args []any
if req.ID > 0 {
conditions = append(conditions, "id = ?")
args = append(args, req.ID)
}
if req.TenantID > 0 {
conditions = append(conditions, "tenant_id = ?")
args = append(args, int64(req.TenantID))
}
if req.UserID > 0 {
conditions = append(conditions, "user_id = ?")
args = append(args, req.UserID)
}
if req.ApiKeyID > 0 {
conditions = append(conditions, "api_key_id = ?")
args = append(args, int64(req.ApiKeyID))
}
// 用户名筛选:先从主库查匹配的 user_id 集合,转为 IN 条件
if req.Username != "" {
type userRow struct {
Id int64 `json:"id"`
TenantId int64 `json:"tenant_id"`
}
var users []userRow
err := g.DB().Ctx(ctx).Model("tnt_users").
Where("username LIKE ?", "%"+req.Username+"%").
Fields("id, tenant_id").
Scan(&users)
if err != nil {
return nil, err
}
if len(users) == 0 {
return &v1.RequestAuditLogListRes{
List: []map[string]any{}, Total: 0, Page: page, PageSize: pageSize,
}, nil
}
// 构建 (tenant_id, user_id) IN 条件
userConditions := make([]string, 0, len(users))
for _, u := range users {
userConditions = append(userConditions, fmt.Sprintf("(tenant_id = %d AND user_id = %d)", u.TenantId, u.Id))
}
conditions = append(conditions, "("+strings.Join(userConditions, " OR ")+")")
}
if req.RequestId != "" {
conditions = append(conditions, "request_id = ?")
args = append(args, req.RequestId)
}
if req.TaskId != "" {
conditions = append(conditions, "task_id = ?")
args = append(args, req.TaskId)
}
if req.Method != "" {
conditions = append(conditions, "method = ?")
args = append(args, req.Method)
}
if req.Path != "" {
conditions = append(conditions, "path LIKE ?")
args = append(args, "%"+req.Path+"%")
}
if req.StatusCode > 0 {
conditions = append(conditions, "status_code = ?")
args = append(args, req.StatusCode)
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, common.StartOfRange(req.StartDate))
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, common.EndOfRange(req.EndDate))
}
where := strings.Join(conditions, " AND ")
auditFields := "id, tenant_id, user_id, api_key_id, project_id, request_id, method, path, query_params, status_code, client_ip, user_agent, latency_ms, first_token_ms, audit_level, created_at"
items, total, err := queryAuditPage(ctx, common.GetAuditDB(), "aud_request_logs", auditFields, where, args, page, pageSize)
if err != nil {
return nil, err
}
return &v1.RequestAuditLogListRes{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// GetRequestAuditLogDetail 查询单条请求审计日志详情(含完整 request_body 和 response_body)
func (s *sAdmin) GetRequestAuditLogDetail(ctx context.Context, req *v1.RequestAuditLogDetailReq) (*v1.RequestAuditLogDetailRes, error) {
var record *entity.AudRequestLogs
err := common.AuditModelCtx(ctx, "aud_request_logs").
Where("id", req.Id).
Scan(&record)
if err != nil {
return nil, err
}
if record == nil {
return nil, common.NewNotFoundError("审计日志")
}
// 详情含请求/响应体(full 级别下为明文对话内容),属敏感读取,落留痕
common.LogSensitiveAccess(ctx, common.GetCtxUserID(ctx), "admin", "request_log", req.Id, "view", "查看请求审计日志详情")
b, _ := json.Marshal(record)
var detail map[string]any
_ = json.Unmarshal(b, &detail)
return &v1.RequestAuditLogDetailRes{Data: detail}, nil
}
// ForwardingTraceGet 按 request_id 查询请求转发路径追踪(仅管理员,用量日志详情懒加载用)。
// 从审计库查询 aud_request_logs(可能独立于主库),仅取 forwarding_trace 与 audit_level 两列。
// 一个 request_id 仅对应一条审计记录(任务完成审计为 UPDATE 非 INSERT),OrderDesc 取最新为防御性兜底。
func (s *sAdmin) ForwardingTraceGet(ctx context.Context, req *v1.ForwardingTraceGetReq) (*v1.ForwardingTraceGetRes, error) {
var row struct {
ForwardingTrace string `orm:"forwarding_trace"`
AuditLevel string `orm:"audit_level"`
}
err := common.AuditModelCtx(ctx, "aud_request_logs").
Where("request_id", req.RequestId).
OrderDesc("created_at").
Fields("forwarding_trace, audit_level").
Scan(&row)
// Scan(&struct) 无匹配记录时返回 sql.ErrNoRows,归一化为 nil,
// 让下方「无审计记录 → found=false」分支生效,而不是把 NoRows 当错误返回。
if err = common.IgnoreScanNoRows(err); err != nil {
return nil, err
}
// 无审计记录(两级审计级别均为 none 时不会写入)→ found=false,前端据此展示「未开启审计」
if row.ForwardingTrace == "" && row.AuditLevel == "" {
return &v1.ForwardingTraceGetRes{Found: false}, nil
}
res := &v1.ForwardingTraceGetRes{Found: true, AuditLevel: row.AuditLevel}
// forwarding_trace 为 "null" 字符串(JSONB NULL 序列化)或空时不解析
if row.ForwardingTrace != "" && row.ForwardingTrace != "null" {
var parsed map[string]any
if json.Unmarshal([]byte(row.ForwardingTrace), &parsed) == nil {
res.ForwardingTrace = parsed
}
}
return res, nil
}
// ExportOperationLogs exports operation logs to CSV or Excel.
func (s *sAdmin) ExportOperationLogs(ctx context.Context, req *v1.OperationLogExportReq) (*v1.OperationLogExportRes, error) {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
buildOpLogWhere := func() (string, []any) {
var conditions []string
var args []any
if req.UserID > 0 {
conditions = append(conditions, "user_id = ?")
args = append(args, int64(req.UserID))
}
if req.UserType != "" {
conditions = append(conditions, "user_type = ?")
args = append(args, req.UserType)
}
if req.Action != "" {
conditions = append(conditions, "action LIKE ?")
args = append(args, "%"+req.Action+"%")
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
where := ""
if len(conditions) > 0 {
where = strings.Join(conditions, " AND ")
}
return where, args
}
selectFields := "id, user_id, user_type, action, resource_type, resource_id, ip_address, detail, created_at"
config := export.Config{
Format: req.Format,
Filename: "操作日志_" + gtime.Now().Format("Ymd_His"),
Columns: []export.Column{
{Field: "id", Header: "ID"},
{Field: "user_id", Header: "用户ID"},
{Field: "user_type", Header: "用户类型"},
{Field: "action", Header: "操作"},
{Field: "resource_type", Header: "目标类型"},
{Field: "resource_id", Header: "目标ID"},
{Field: "ip_address", Header: "IP地址"},
{Field: "detail", Header: "详情"},
{Field: "created_at", Header: "创建时间"},
},
}
return nil, export.GenericExport(ctx, config, func(yield func(map[string]any) bool) {
offset := 0
for {
where, args := buildOpLogWhere()
q := g.DB().Ctx(ctx).Model("aud_operation_logs").Fields(selectFields).OrderDesc("created_at").Offset(offset).Limit(1000)
if where != "" {
q = q.Where(where, args...)
}
result, err := q.All()
if err != nil {
return
}
items := make([]map[string]any, 0, len(result))
for _, row := range result {
m := make(map[string]any, len(row))
for k, v := range row {
m[k] = v.Val()
}
items = append(items, m)
}
if err != nil {
return
}
for _, item := range items {
createdAt := ""
if v, ok := item["created_at"]; ok {
createdAt = fmt.Sprintf("%v", v)
}
if !yield(map[string]any{
"id": item["id"],
"user_id": item["user_id"],
"user_type": item["user_type"],
"action": item["action"],
"resource_type": item["resource_type"],
"resource_id": item["resource_id"],
"ip_address": item["ip_address"],
"detail": item["detail"],
"created_at": createdAt,
}) {
return
}
}
if len(items) < 1000 {
break
}
offset += 1000
}
})
}
// ContentFilterLogList returns a paginated list of content filter interception logs.
// 审计数据从审计库查询,关联信息从主库批量查询后在应用层合并。
func (s *sAdmin) ContentFilterLogList(ctx context.Context, req *v1.ContentFilterLogListReq) (*v1.ContentFilterLogListRes, error) {
if err := common.ValidateDateParam(req.StartDate, "开始日期"); err != nil {
return nil, err
}
if err := common.ValidateDateParam(req.EndDate, "结束日期"); err != nil {
return nil, err
}
page, pageSize := common.NormalizePagination(req.Page, req.PageSize)
var conditions []string
var args []any
if req.TenantID > 0 {
conditions = append(conditions, "tenant_id = ?")
args = append(args, int64(req.TenantID))
}
if req.Mode != "" {
conditions = append(conditions, "filter_mode = ?")
args = append(args, req.Mode)
}
if req.Blocked == "true" {
conditions = append(conditions, "blocked = true")
} else if req.Blocked == "false" {
conditions = append(conditions, "blocked = false")
}
if req.StartDate != "" {
conditions = append(conditions, "created_at >= ?")
args = append(args, req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
conditions = append(conditions, "created_at <= ?")
args = append(args, req.EndDate+" 23:59:59")
}
if req.Keyword != "" {
conditions = append(conditions, "(matched_words::text ILIKE ? OR path ILIKE ?)")
kw := "%" + req.Keyword + "%"
args = append(args, kw, kw)
}
where := strings.Join(conditions, " AND ")
cfFields := "id, tenant_id, user_id, api_key_id, project_id, request_id, method, path, client_ip, filter_mode, matched_words, original_snippet, blocked, created_at"
items, total, err := queryAuditPage(ctx, g.DB(), "aud_content_filter_logs", cfFields, where, args, page, pageSize)
if err != nil {
return nil, err
}
return &v1.ContentFilterLogListRes{
List: items,
Total: total,
Page: page,
PageSize: pageSize,
}, nil
}
// ContentFilterLogClear 硬删除全部内容过滤拦截日志。
// 拦截日志为追加型审计流水,堆积后用 TRUNCATE 秒级清空并立即归还磁盘空间
// (DELETE 需等 VACUUM 回收)。TRUNCATE 不返回行数,故先取删除前条数作为反馈。
// 表无数据库级外键,TRUNCATE 安全。
func (s *sAdmin) ContentFilterLogClear(ctx context.Context, _ *v1.ContentFilterLogClearReq) (*v1.ContentFilterLogClearRes, error) {
count, err := dao.AudContentFilterLogs.Ctx(ctx).Count()
if err != nil {
return nil, err
}
if _, err := g.DB().Ctx(ctx).Exec(ctx, "TRUNCATE TABLE aud_content_filter_logs RESTART IDENTITY"); err != nil {
return nil, err
}
return &v1.ContentFilterLogClearRes{Deleted: int64(count)}, nil
}