598 lines
17 KiB
Go
598 lines
17 KiB
Go
package admin
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
|
||
"github.com/gogf/gf/v2/errors/gerror"
|
||
"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。
|
||
// 注意:该函数使用审计数据库连接(common.GetAuditDB())查询审计表。
|
||
// 分开调用 Count 和 All 避免 ScanAndCount 对 map[string]any 的 bug。
|
||
func queryPage(ctx context.Context, table, fields, where, orderBy string, page, pageSize int, args ...any) ([]map[string]any, int, error) {
|
||
auditDB := common.GetAuditDB()
|
||
m := auditDB.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
|
||
}
|
||
|
||
// 重新构建查询(Count 会消耗 Model 状态)
|
||
q := auditDB.Ctx(ctx).Model(table).Safe()
|
||
if fields != "" {
|
||
q = q.Fields(fields)
|
||
}
|
||
if where != "" {
|
||
q = q.Where(where, args...)
|
||
}
|
||
if orderBy != "" {
|
||
q = q.Order(orderBy)
|
||
}
|
||
|
||
result, err := q.Page(page, pageSize).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)
|
||
}
|
||
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
|
||
}
|
||
|
||
// LogSensitiveAccess inserts a sensitive data access log entry.
|
||
func LogSensitiveAccess(ctx context.Context, userID int64, userType, resourceType string, resourceID int64, action, reason, ip, userAgent string) error {
|
||
_, err := common.AuditModelCtx(ctx, "aud_sensitive_access_logs").Data(do.AudSensitiveAccessLogs{
|
||
UserId: userID,
|
||
UserType: userType,
|
||
ResourceType: resourceType,
|
||
ResourceId: resourceID,
|
||
Action: action,
|
||
Reason: reason,
|
||
IpAddress: ip,
|
||
UserAgent: userAgent,
|
||
}).Insert()
|
||
if err != nil {
|
||
return gerror.Wrapf(err, "记录敏感数据访问日志失败")
|
||
}
|
||
return 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.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.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, 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 ")
|
||
|
||
// Step 1: 从审计库查询审计记录(不带 JOIN)
|
||
auditDB := common.GetAuditDB()
|
||
countM := auditDB.Ctx(ctx).Model("aud_request_logs").Safe()
|
||
if where != "" {
|
||
countM = countM.Where(where, args...)
|
||
}
|
||
total, err := countM.Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if total == 0 {
|
||
return &v1.RequestAuditLogListRes{
|
||
List: []map[string]any{}, Total: 0, Page: page, PageSize: pageSize,
|
||
}, nil
|
||
}
|
||
|
||
dataM := auditDB.Ctx(ctx).Model("aud_request_logs").Safe().
|
||
Fields("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").
|
||
OrderDesc("created_at").
|
||
Page(page, pageSize)
|
||
if where != "" {
|
||
dataM = dataM.Where(where, args...)
|
||
}
|
||
result, err := dataM.All()
|
||
if err != nil {
|
||
return nil, 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)
|
||
}
|
||
|
||
// Step 2: 从主库批量查询关联信息并合并
|
||
common.EnrichAuditRecords(ctx, items)
|
||
|
||
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("审计日志")
|
||
}
|
||
b, _ := json.Marshal(record)
|
||
var detail map[string]any
|
||
_ = json.Unmarshal(b, &detail)
|
||
return &v1.RequestAuditLogDetailRes{Data: detail}, 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 := common.GetAuditDB().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 ")
|
||
|
||
// Step 1: 从审计库查询审计记录(不带 JOIN)
|
||
auditDB := common.GetAuditDB()
|
||
fields := "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"
|
||
|
||
countM := auditDB.Ctx(ctx).Model("aud_content_filter_logs").Safe()
|
||
if where != "" {
|
||
countM = countM.Where(where, args...)
|
||
}
|
||
total, err := countM.Count()
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if total == 0 {
|
||
return &v1.ContentFilterLogListRes{
|
||
List: []map[string]any{}, Total: 0, Page: page, PageSize: pageSize,
|
||
}, nil
|
||
}
|
||
|
||
dataM := auditDB.Ctx(ctx).Model("aud_content_filter_logs").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, 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)
|
||
}
|
||
|
||
// Step 2: 从主库批量查询关联信息并合并
|
||
common.EnrichAuditRecords(ctx, items)
|
||
|
||
return &v1.ContentFilterLogListRes{
|
||
List: items,
|
||
Total: total,
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
}, nil
|
||
}
|