feat: add audit logs and system configs

This commit is contained in:
2026-01-15 20:07:36 +08:00
parent 914df9edf2
commit b3fc226bbe
25 changed files with 3325 additions and 108 deletions

View File

@@ -3,18 +3,38 @@ package services
import (
"context"
"quyun/v2/database/models"
"github.com/sirupsen/logrus"
)
// @provider
type audit struct{}
func (s *audit) Log(ctx context.Context, operatorID int64, action, targetID, detail string) {
func (s *audit) Log(ctx context.Context, tenantID, operatorID int64, action, targetID, detail string) {
logrus.WithFields(logrus.Fields{
"audit": true,
"tenant": tenantID,
"operator": operatorID,
"action": action,
"target": targetID,
"detail": detail,
}).Info("Audit Log")
entry := &models.AuditLog{
TenantID: tenantID,
OperatorID: operatorID,
Action: action,
TargetID: targetID,
Detail: detail,
}
if err := models.AuditLogQuery.WithContext(ctx).Create(entry); err != nil {
logrus.WithFields(logrus.Fields{
"audit": true,
"tenant": tenantID,
"operator": operatorID,
"action": action,
"target": targetID,
}).WithError(err).Warn("Audit log persist failed")
}
}

View File

@@ -1019,7 +1019,7 @@ func (s *super) ReviewCreatorApplication(ctx context.Context, operatorID, tenant
if strings.TrimSpace(form.Reason) != "" {
detail += ",原因:" + strings.TrimSpace(form.Reason)
}
Audit.Log(ctx, operatorID, "review_creator_application", cast.ToString(tenant.ID), detail)
Audit.Log(ctx, tenant.ID, operatorID, "review_creator_application", cast.ToString(tenant.ID), detail)
}
return nil
@@ -1317,7 +1317,7 @@ func (s *super) RemovePayoutAccount(ctx context.Context, operatorID, id int64) e
}
if Audit != nil {
Audit.Log(ctx, operatorID, "remove_payout_account", cast.ToString(account.ID), "Removed payout account")
Audit.Log(ctx, account.TenantID, operatorID, "remove_payout_account", cast.ToString(account.ID), "Removed payout account")
}
return nil
}
@@ -2376,7 +2376,7 @@ func (s *super) ReviewContent(ctx context.Context, operatorID, contentID int64,
_ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail)
}
if Audit != nil {
Audit.Log(ctx, operatorID, "review_content", cast.ToString(contentID), detail)
Audit.Log(ctx, content.TenantID, operatorID, "review_content", cast.ToString(contentID), detail)
}
return nil
}
@@ -2465,7 +2465,7 @@ func (s *super) BatchReviewContents(ctx context.Context, operatorID int64, form
_ = Notification.Send(ctx, content.TenantID, content.UserID, string(consts.NotificationTypeAudit), title, detail)
}
if Audit != nil {
Audit.Log(ctx, operatorID, "review_content", cast.ToString(content.ID), detail)
Audit.Log(ctx, content.TenantID, operatorID, "review_content", cast.ToString(content.ID), detail)
}
}
@@ -3318,6 +3318,431 @@ func (s *super) CreateNotificationTemplate(ctx context.Context, form *super_dto.
return item, nil
}
func (s *super) ListAuditLogs(ctx context.Context, filter *super_dto.SuperAuditLogListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperAuditLogListFilter{}
}
tbl, q := models.AuditLogQuery.QueryContext(ctx)
if filter.ID != nil && *filter.ID > 0 {
q = q.Where(tbl.ID.Eq(*filter.ID))
}
if filter.TenantID != nil && *filter.TenantID > 0 {
q = q.Where(tbl.TenantID.Eq(*filter.TenantID))
}
if filter.OperatorID != nil && *filter.OperatorID > 0 {
q = q.Where(tbl.OperatorID.Eq(*filter.OperatorID))
}
if filter.Action != nil && strings.TrimSpace(*filter.Action) != "" {
q = q.Where(tbl.Action.Eq(strings.TrimSpace(*filter.Action)))
}
if filter.TargetID != nil && strings.TrimSpace(*filter.TargetID) != "" {
q = q.Where(tbl.TargetID.Eq(strings.TrimSpace(*filter.TargetID)))
}
if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" {
keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%"
q = q.Where(field.Or(tbl.Detail.Like(keyword), tbl.Action.Like(keyword), tbl.TargetID.Like(keyword)))
}
// 跨租户筛选:根据租户编码/名称定位租户ID。
tenantIDs, tenantFilter, err := s.lookupTenantIDs(ctx, filter.TenantCode, filter.TenantName)
if err != nil {
return nil, err
}
if tenantFilter {
if len(tenantIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.TenantID.In(tenantIDs...))
}
}
// 根据操作者昵称/用户名定位用户ID。
operatorIDs, operatorFilter, err := s.lookupUserIDs(ctx, filter.OperatorName)
if err != nil {
return nil, err
}
if operatorFilter {
if len(operatorIDs) == 0 {
q = q.Where(tbl.ID.Eq(-1))
} else {
q = q.Where(tbl.OperatorID.In(operatorIDs...))
}
}
if filter.CreatedAtFrom != nil {
from, err := s.parseFilterTime(filter.CreatedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.CreatedAt.Gte(*from))
}
}
if filter.CreatedAtTo != nil {
to, err := s.parseFilterTime(filter.CreatedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.CreatedAt.Lte(*to))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "created_at":
q = q.Order(tbl.CreatedAt)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.CreatedAt.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
if len(list) == 0 {
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: []super_dto.SuperAuditLogItem{},
}, nil
}
tenantSet := make(map[int64]struct{})
operatorSet := make(map[int64]struct{})
for _, log := range list {
if log.TenantID > 0 {
tenantSet[log.TenantID] = struct{}{}
}
if log.OperatorID > 0 {
operatorSet[log.OperatorID] = struct{}{}
}
}
tenantMap := make(map[int64]*models.Tenant, len(tenantSet))
if len(tenantSet) > 0 {
ids := make([]int64, 0, len(tenantSet))
for id := range tenantSet {
ids = append(ids, id)
}
tenantTbl, tenantQuery := models.TenantQuery.QueryContext(ctx)
tenants, err := tenantQuery.Where(tenantTbl.ID.In(ids...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, tenant := range tenants {
tenantMap[tenant.ID] = tenant
}
}
operatorMap := make(map[int64]*models.User, len(operatorSet))
if len(operatorSet) > 0 {
ids := make([]int64, 0, len(operatorSet))
for id := range operatorSet {
ids = append(ids, id)
}
userTbl, userQuery := models.UserQuery.QueryContext(ctx)
users, err := userQuery.Where(userTbl.ID.In(ids...)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
for _, user := range users {
operatorMap[user.ID] = user
}
}
items := make([]super_dto.SuperAuditLogItem, 0, len(list))
for _, log := range list {
item := super_dto.SuperAuditLogItem{
ID: log.ID,
TenantID: log.TenantID,
OperatorID: log.OperatorID,
Action: log.Action,
TargetID: log.TargetID,
Detail: log.Detail,
CreatedAt: s.formatTime(log.CreatedAt),
}
if tenant := tenantMap[log.TenantID]; tenant != nil {
item.TenantCode = tenant.Code
item.TenantName = tenant.Name
}
if operator := operatorMap[log.OperatorID]; operator != nil {
item.OperatorName = operator.Username
} else if log.OperatorID > 0 {
item.OperatorName = "ID:" + strconv.FormatInt(log.OperatorID, 10)
}
items = append(items, item)
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) ListSystemConfigs(ctx context.Context, filter *super_dto.SuperSystemConfigListFilter) (*requests.Pager, error) {
if filter == nil {
filter = &super_dto.SuperSystemConfigListFilter{}
}
tbl, q := models.SystemConfigQuery.QueryContext(ctx)
if filter.ConfigKey != nil && strings.TrimSpace(*filter.ConfigKey) != "" {
q = q.Where(tbl.ConfigKey.Eq(strings.TrimSpace(*filter.ConfigKey)))
}
if filter.Keyword != nil && strings.TrimSpace(*filter.Keyword) != "" {
keyword := "%" + strings.TrimSpace(*filter.Keyword) + "%"
q = q.Where(field.Or(tbl.ConfigKey.Like(keyword), tbl.Description.Like(keyword)))
}
if filter.CreatedAtFrom != nil {
from, err := s.parseFilterTime(filter.CreatedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.CreatedAt.Gte(*from))
}
}
if filter.CreatedAtTo != nil {
to, err := s.parseFilterTime(filter.CreatedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.CreatedAt.Lte(*to))
}
}
if filter.UpdatedAtFrom != nil {
from, err := s.parseFilterTime(filter.UpdatedAtFrom)
if err != nil {
return nil, err
}
if from != nil {
q = q.Where(tbl.UpdatedAt.Gte(*from))
}
}
if filter.UpdatedAtTo != nil {
to, err := s.parseFilterTime(filter.UpdatedAtTo)
if err != nil {
return nil, err
}
if to != nil {
q = q.Where(tbl.UpdatedAt.Lte(*to))
}
}
orderApplied := false
if filter.Desc != nil && strings.TrimSpace(*filter.Desc) != "" {
switch strings.TrimSpace(*filter.Desc) {
case "id":
q = q.Order(tbl.ID.Desc())
case "config_key":
q = q.Order(tbl.ConfigKey.Desc())
case "updated_at":
q = q.Order(tbl.UpdatedAt.Desc())
case "created_at":
q = q.Order(tbl.CreatedAt.Desc())
}
orderApplied = true
} else if filter.Asc != nil && strings.TrimSpace(*filter.Asc) != "" {
switch strings.TrimSpace(*filter.Asc) {
case "id":
q = q.Order(tbl.ID)
case "config_key":
q = q.Order(tbl.ConfigKey)
case "updated_at":
q = q.Order(tbl.UpdatedAt)
case "created_at":
q = q.Order(tbl.CreatedAt)
}
orderApplied = true
}
if !orderApplied {
q = q.Order(tbl.UpdatedAt.Desc())
}
filter.Pagination.Format()
total, err := q.Count()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
list, err := q.Offset(int(filter.Pagination.Offset())).Limit(int(filter.Pagination.Limit)).Find()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
if len(list) == 0 {
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: []super_dto.SuperSystemConfigItem{},
}, nil
}
items := make([]super_dto.SuperSystemConfigItem, 0, len(list))
for _, cfg := range list {
items = append(items, super_dto.SuperSystemConfigItem{
ID: cfg.ID,
ConfigKey: cfg.ConfigKey,
Value: json.RawMessage(cfg.Value),
Description: cfg.Description,
CreatedAt: s.formatTime(cfg.CreatedAt),
UpdatedAt: s.formatTime(cfg.UpdatedAt),
})
}
return &requests.Pager{
Pagination: filter.Pagination,
Total: total,
Items: items,
}, nil
}
func (s *super) CreateSystemConfig(ctx context.Context, operatorID int64, form *super_dto.SuperSystemConfigCreateForm) (*super_dto.SuperSystemConfigItem, error) {
if operatorID == 0 {
return nil, errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if form == nil {
return nil, errorx.ErrBadRequest.WithMsg("配置参数不能为空")
}
key := strings.TrimSpace(form.ConfigKey)
if key == "" {
return nil, errorx.ErrBadRequest.WithMsg("配置Key不能为空")
}
if len(form.Value) == 0 || !json.Valid(form.Value) {
return nil, errorx.ErrBadRequest.WithMsg("配置值必须是合法JSON")
}
desc := strings.TrimSpace(form.Description)
if desc == "" {
return nil, errorx.ErrBadRequest.WithMsg("配置说明不能为空")
}
// 配置Key唯一重复提交直接提示。
tbl, q := models.SystemConfigQuery.QueryContext(ctx)
_, err := q.Where(tbl.ConfigKey.Eq(key)).First()
if err == nil {
return nil, errorx.ErrRecordDuplicated.WithMsg("配置Key已存在")
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
cfg := &models.SystemConfig{
ConfigKey: key,
Value: types.JSON(form.Value),
Description: desc,
}
if err := q.Create(cfg); err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
if Audit != nil {
Audit.Log(ctx, 0, operatorID, "create_system_config", cfg.ConfigKey, "创建系统配置")
}
return &super_dto.SuperSystemConfigItem{
ID: cfg.ID,
ConfigKey: cfg.ConfigKey,
Value: json.RawMessage(cfg.Value),
Description: cfg.Description,
CreatedAt: s.formatTime(cfg.CreatedAt),
UpdatedAt: s.formatTime(cfg.UpdatedAt),
}, nil
}
func (s *super) UpdateSystemConfig(ctx context.Context, operatorID, id int64, form *super_dto.SuperSystemConfigUpdateForm) (*super_dto.SuperSystemConfigItem, error) {
if operatorID == 0 {
return nil, errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
if id == 0 {
return nil, errorx.ErrBadRequest.WithMsg("配置ID不能为空")
}
if form == nil {
return nil, errorx.ErrBadRequest.WithMsg("配置参数不能为空")
}
tbl, q := models.SystemConfigQuery.QueryContext(ctx)
cfg, err := q.Where(tbl.ID.Eq(id)).First()
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errorx.ErrRecordNotFound.WithMsg("配置不存在")
}
return nil, errorx.ErrDatabaseError.WithCause(err)
}
updates := make(map[string]interface{}, 3)
if form.Value != nil {
if len(*form.Value) == 0 || !json.Valid(*form.Value) {
return nil, errorx.ErrBadRequest.WithMsg("配置值必须是合法JSON")
}
updates["value"] = types.JSON(*form.Value)
}
if form.Description != nil {
desc := strings.TrimSpace(*form.Description)
if desc == "" {
return nil, errorx.ErrBadRequest.WithMsg("配置说明不能为空")
}
updates["description"] = desc
}
if len(updates) == 0 {
return nil, errorx.ErrBadRequest.WithMsg("请至少更新一项配置")
}
updates["updated_at"] = time.Now()
if _, err := q.Where(tbl.ID.Eq(id)).Updates(updates); err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
cfg, err = q.Where(tbl.ID.Eq(id)).First()
if err != nil {
return nil, errorx.ErrDatabaseError.WithCause(err)
}
if Audit != nil {
details := make([]string, 0, 2)
if form.Value != nil {
details = append(details, "更新配置值")
}
if form.Description != nil {
details = append(details, "更新配置说明")
}
detail := strings.Join(details, "")
Audit.Log(ctx, 0, operatorID, "update_system_config", cfg.ConfigKey, detail)
}
return &super_dto.SuperSystemConfigItem{
ID: cfg.ID,
ConfigKey: cfg.ConfigKey,
Value: json.RawMessage(cfg.Value),
Description: cfg.Description,
CreatedAt: s.formatTime(cfg.CreatedAt),
UpdatedAt: s.formatTime(cfg.UpdatedAt),
}, nil
}
func (s *super) ListOrders(ctx context.Context, filter *super_dto.SuperOrderListFilter) (*requests.Pager, error) {
tbl, q := models.OrderQuery.QueryContext(ctx)
@@ -5523,7 +5948,7 @@ func (s *super) UpdateCouponStatus(ctx context.Context, operatorID, couponID int
}
if Audit != nil {
Audit.Log(ctx, operatorID, "freeze_coupon", cast.ToString(coupon.ID), "Freeze coupon")
Audit.Log(ctx, coupon.TenantID, operatorID, "freeze_coupon", cast.ToString(coupon.ID), "Freeze coupon")
}
return nil
}
@@ -6077,7 +6502,7 @@ func (s *super) ApproveWithdrawal(ctx context.Context, operatorID, id int64) err
UpdatedAt: time.Now(),
})
if err == nil && Audit != nil {
Audit.Log(ctx, operatorID, "approve_withdrawal", cast.ToString(id), "Approved withdrawal")
Audit.Log(ctx, o.TenantID, operatorID, "approve_withdrawal", cast.ToString(id), "Approved withdrawal")
}
return err
}
@@ -6404,11 +6829,13 @@ func (s *super) RejectWithdrawal(ctx context.Context, operatorID, id int64, reas
if operatorID == 0 {
return errorx.ErrUnauthorized.WithMsg("缺少操作者信息")
}
tenantID := int64(0)
err := models.Q.Transaction(func(tx *models.Query) error {
o, err := tx.Order.WithContext(ctx).Where(tx.Order.ID.Eq(id)).First()
if err != nil {
return errorx.ErrRecordNotFound
}
tenantID = o.TenantID
if o.Status != consts.OrderStatusCreated {
return errorx.ErrStatusConflict.WithMsg("订单状态不正确")
}
@@ -6448,7 +6875,7 @@ func (s *super) RejectWithdrawal(ctx context.Context, operatorID, id int64, reas
})
if err == nil && Audit != nil {
Audit.Log(ctx, operatorID, "reject_withdrawal", cast.ToString(id), "Rejected: "+reason)
Audit.Log(ctx, tenantID, operatorID, "reject_withdrawal", cast.ToString(id), "Rejected: "+reason)
}
return err
}