添加操作记录支持

This commit is contained in:
Yavolte 2025-06-17 18:26:34 +08:00
parent 56d50536fc
commit 7098529421
9 changed files with 727 additions and 179 deletions

189
activity.go 100644
View File

@ -0,0 +1,189 @@
package aeusadmin
import (
"context"
"encoding/json"
"time"
"git.nobla.cn/golang/aeus-admin/models"
"git.nobla.cn/golang/rest"
"git.nobla.cn/golang/rest/types"
"gorm.io/gorm"
)
type (
recorder struct {
Domain string `json:"domain"`
User string `json:"user"`
Module string `json:"module"`
Table string `json:"table"`
Action string `json:"action"`
PrimaryKey any `json:"primary_key"`
Diffs []*types.DiffAttr `json:"diffs"`
}
)
type ActivityRecorder struct {
db *gorm.DB
ctx context.Context
batchTimeout time.Duration
BatchSize int
ch chan *models.Activity
}
func (s *ActivityRecorder) onAfterCreate(ctx context.Context, tx *gorm.DB, model any, diff []*types.DiffAttr) {
v := ctx.Value(rest.RuntimeScopeKey)
if v == nil {
return
}
runtimeScope, ok := v.(*types.RuntimeScope)
if !ok {
return
}
data := &models.Activity{}
data.Uid = runtimeScope.User
data.Module = runtimeScope.ModuleName
data.Table = runtimeScope.TableName
data.Action = types.ScenarioCreate
if buf, err := json.Marshal(diff); err == nil {
data.Data = string(buf)
}
select {
case s.ch <- data:
default:
}
}
func (s *ActivityRecorder) onAfterUpdate(ctx context.Context, tx *gorm.DB, model any, diff []*types.DiffAttr) {
v := ctx.Value(rest.RuntimeScopeKey)
if v == nil {
return
}
runtimeScope, ok := v.(*types.RuntimeScope)
if !ok {
return
}
data := &models.Activity{}
data.Uid = runtimeScope.User
data.Module = runtimeScope.ModuleName
data.Table = runtimeScope.TableName
data.Action = types.ScenarioUpdate
if diff == nil {
diff = make([]*types.DiffAttr, 0)
}
diff = append(diff, &types.DiffAttr{
Column: "primary_key",
NewValue: runtimeScope.PrimaryKeyValue,
})
if buf, err := json.Marshal(diff); err == nil {
data.Data = string(buf)
}
select {
case s.ch <- data:
default:
}
}
func (s *ActivityRecorder) onAfterDelete(ctx context.Context, tx *gorm.DB, model any) {
v := ctx.Value(rest.RuntimeScopeKey)
if v == nil {
return
}
runtimeScope, ok := v.(*types.RuntimeScope)
if !ok {
return
}
data := &models.Activity{}
data.Uid = runtimeScope.User
data.Module = runtimeScope.ModuleName
data.Table = runtimeScope.TableName
data.Action = types.ScenarioDelete
diff := make([]*types.DiffAttr, 0, 1)
diff = append(diff, &types.DiffAttr{
Column: "primary_key",
NewValue: runtimeScope.PrimaryKeyValue,
})
if buf, err := json.Marshal(diff); err == nil {
data.Data = string(buf)
}
select {
case s.ch <- data:
default:
}
}
func (s *ActivityRecorder) batchWrite(values []*models.Activity) {
var (
err error
)
if len(values) <= 0 {
return
}
if err = s.db.Create(values).Error; err != nil {
for _, row := range values {
s.db.Create(row)
}
}
}
func (s *ActivityRecorder) writeLoop() {
var (
values []*models.Activity
)
timer := time.NewTimer(s.batchTimeout)
defer func() {
timer.Stop()
if len(values) > 0 {
s.batchWrite(values)
}
}()
for {
select {
case <-s.ctx.Done():
return
case <-timer.C:
if len(values) > 0 {
s.batchWrite(values)
values = nil
}
timer.Reset(s.batchTimeout)
case msg, ok := <-s.ch:
if ok {
values = append(values, msg)
if len(values) > s.BatchSize {
s.batchWrite(values)
values = nil
timer.Reset(s.batchTimeout)
}
}
}
}
}
func (s *ActivityRecorder) Recoder(scenes ...string) {
if len(scenes) == 0 {
scenes = []string{types.ScenarioCreate, types.ScenarioUpdate, types.ScenarioDelete}
}
for _, str := range scenes {
switch str {
case types.ScenarioCreate:
rest.OnAfterCreate(s.onAfterCreate)
case types.ScenarioUpdate:
rest.OnAfterUpdate(s.onAfterUpdate)
case types.ScenarioDelete:
rest.OnAfterDelete(s.onAfterDelete)
}
}
}
func NewActivityRecorder(ctx context.Context, db *gorm.DB) *ActivityRecorder {
s := &ActivityRecorder{
db: db,
ctx: ctx,
batchTimeout: time.Second,
BatchSize: 50,
ch: make(chan *models.Activity, 100),
}
go s.writeLoop()
return s
}

View File

@ -34,6 +34,9 @@ func (t *ZH) Menu(model *rest.Model, label string) string {
if _, ok := model.Value().Interface().(models.Setting); ok { if _, ok := model.Value().Interface().(models.Setting); ok {
return "参数设置" return "参数设置"
} }
if _, ok := model.Value().Interface().(models.Activity); ok {
return "操作记录"
}
return label return label
} }

View File

@ -32,6 +32,10 @@ type (
Setting struct { Setting struct {
pb.SettingModel pb.SettingModel
} }
Activity struct {
pb.ActivityModel
}
) )
func (m *User) GetMenu() *types.Menu { func (m *User) GetMenu() *types.Menu {
@ -123,3 +127,22 @@ func (m *Setting) GetMenu() *types.Menu {
func (m *Setting) ModuleName() string { func (m *Setting) ModuleName() string {
return "system" return "system"
} }
func (m *Activity) GetMenu() *types.Menu {
return &types.Menu{
Name: "SystemActivity",
Parent: "System",
}
}
func (m *Activity) Scenario() []string {
return []string{restTypes.ScenarioList}
}
func (m *Activity) ModuleName() string {
return "system"
}
func (m *Activity) GetPermission(s string) string {
return "system:activity:list"
}

File diff suppressed because it is too large Load Diff

View File

@ -1188,6 +1188,128 @@ var _ interface {
ErrorName() string ErrorName() string
} = SettingValidationError{} } = SettingValidationError{}
// Validate checks the field values on Activity with the rules defined in the
// proto definition for this message. If any rules are violated, the first
// error encountered is returned, or nil if there are no violations.
func (m *Activity) Validate() error {
return m.validate(false)
}
// ValidateAll checks the field values on Activity with the rules defined in
// the proto definition for this message. If any rules are violated, the
// result is a list of violation errors wrapped in ActivityMultiError, or nil
// if none found.
func (m *Activity) ValidateAll() error {
return m.validate(true)
}
func (m *Activity) validate(all bool) error {
if m == nil {
return nil
}
var errors []error
// no validation rules for Id
// no validation rules for CreatedAt
if l := utf8.RuneCountInString(m.GetUid()); l < 5 || l > 20 {
err := ActivityValidationError{
field: "Uid",
reason: "value length must be between 5 and 20 runes, inclusive",
}
if !all {
return err
}
errors = append(errors, err)
}
// no validation rules for Action
// no validation rules for Module
// no validation rules for Table
// no validation rules for Data
if len(errors) > 0 {
return ActivityMultiError(errors)
}
return nil
}
// ActivityMultiError is an error wrapping multiple validation errors returned
// by Activity.ValidateAll() if the designated constraints aren't met.
type ActivityMultiError []error
// Error returns a concatenation of all the error messages it wraps.
func (m ActivityMultiError) Error() string {
msgs := make([]string, 0, len(m))
for _, err := range m {
msgs = append(msgs, err.Error())
}
return strings.Join(msgs, "; ")
}
// AllErrors returns a list of validation violation errors.
func (m ActivityMultiError) AllErrors() []error { return m }
// ActivityValidationError is the validation error returned by
// Activity.Validate if the designated constraints aren't met.
type ActivityValidationError struct {
field string
reason string
cause error
key bool
}
// Field function returns field value.
func (e ActivityValidationError) Field() string { return e.field }
// Reason function returns reason value.
func (e ActivityValidationError) Reason() string { return e.reason }
// Cause function returns cause value.
func (e ActivityValidationError) Cause() error { return e.cause }
// Key function returns key value.
func (e ActivityValidationError) Key() bool { return e.key }
// ErrorName returns error name.
func (e ActivityValidationError) ErrorName() string { return "ActivityValidationError" }
// Error satisfies the builtin error interface
func (e ActivityValidationError) Error() string {
cause := ""
if e.cause != nil {
cause = fmt.Sprintf(" | caused by: %v", e.cause)
}
key := ""
if e.key {
key = "key for "
}
return fmt.Sprintf(
"invalid %sActivity.%s: %s%s",
key,
e.field,
e.reason,
cause)
}
var _ error = ActivityValidationError{}
var _ interface {
Field() string
Reason() string
Key() bool
Cause() error
ErrorName() string
} = ActivityValidationError{}
// Validate checks the field values on LabelValue with the rules defined in the // Validate checks the field values on LabelValue with the rules defined in the
// proto definition for this message. If any rules are violated, the first // proto definition for this message. If any rules are violated, the first
// error encountered is returned, or nil if there are no violations. // error encountered is returned, or nil if there are no violations.

View File

@ -103,7 +103,7 @@ message Login {
}; };
int64 id = 1 [(aeus.field) = {gorm:"primaryKey",comment:"ID"}]; int64 id = 1 [(aeus.field) = {gorm:"primaryKey",comment:"ID"}];
int64 created_at = 2 [(aeus.field)={scenarios:"list;search;view;export",comment:"登录时间"}]; int64 created_at = 2 [(aeus.field)={scenarios:"list;search;view;export",comment:"登录时间"}];
string uid = 4 [(aeus.field)={gorm:"index;size:20",rule:"required",props:"readonly:update",format:"user",comment: "用户工号"},(validate.rules).string = {min_len: 5, max_len: 20}]; string uid = 4 [(aeus.field)={gorm:"index;size:20",rule:"required",props:"readonly:update",format:"user",comment: "用户"},(validate.rules).string = {min_len: 5, max_len: 20}];
string ip = 5 [(aeus.field)={gorm:"size:128",scenarios:"list;search;view;export",comment: "登录地址"}]; string ip = 5 [(aeus.field)={gorm:"size:128",scenarios:"list;search;view;export",comment: "登录地址"}];
string browser = 6 [(aeus.field)={gorm:"size:128",scenarios:"list;view;export",comment: "浏览器"}]; string browser = 6 [(aeus.field)={gorm:"size:128",scenarios:"list;view;export",comment: "浏览器"}];
string os = 7 [(aeus.field)={gorm:"size:128",scenarios:"list;view;export",comment: "操作系统"}]; string os = 7 [(aeus.field)={gorm:"size:128",scenarios:"list;view;export",comment: "操作系统"}];
@ -126,6 +126,20 @@ message Setting {
} }
// Activity
message Activity {
option (aeus.rest) = {
table: "activities"
};
int64 id = 1 [(aeus.field) = {gorm:"primaryKey",comment:"ID"}];
int64 created_at = 2 [(aeus.field)={scenarios:"search;search;view;export",comment:"创建时间"}];
string uid = 3 [(aeus.field)={gorm:"index;size:20",rule:"required",props:"readonly:update",format:"user",comment: "用户"},(validate.rules).string = {min_len: 5, max_len: 20}];
string action = 4 [(aeus.field)={props:"match:exactly",gorm:"index;size:20;not null;default:''",comment:"行为",enum:"create:新建#198754;update:更新#f09d00;delete:删除#e63757",scenarios:"search;list;create;update;view;export"}];
string module = 5 [(aeus.field)={gorm:"size:60;not null;default:''",comment:"模块",scenarios:"search;list;create;update;view;export"}];
string table = 6 [(aeus.field)={gorm:"size:60;not null;default:''",comment:"数据",scenarios:"list;create;update;view;export"}];
string data = 7 [(aeus.field)={gorm:"size:10240;not null;default:''",comment:"内容",scenarios:"list;create;update;view;export"}];
}
message LabelValue { message LabelValue {
string label = 1; string label = 1;
string value = 2; string value = 2;

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-aeus. DO NOT EDIT. // Code generated by protoc-gen-go-aeus. DO NOT EDIT.
// source: organize.proto // source: organize.proto
// date: 2025-06-17 15:01:15 // date: 2025-06-17 18:12:28
package pb package pb

View File

@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-aeus. DO NOT EDIT. // Code generated by protoc-gen-go-aeus. DO NOT EDIT.
// source: organize.proto // source: organize.proto
// date: 2025-06-17 15:01:15 // date: 2025-06-17 18:12:28
package pb package pb
@ -431,7 +431,7 @@ func NewDepartmentModel() *DepartmentModel {
type LoginModel struct { type LoginModel struct {
Id int64 `json:"id" yaml:"id" xml:"id" gorm:"primaryKey" comment:"ID"` Id int64 `json:"id" yaml:"id" xml:"id" gorm:"primaryKey" comment:"ID"`
CreatedAt int64 `json:"created_at" yaml:"createdAt" xml:"createdAt" comment:"登录时间" scenarios:"list;search;view;export"` CreatedAt int64 `json:"created_at" yaml:"createdAt" xml:"createdAt" comment:"登录时间" scenarios:"list;search;view;export"`
Uid string `json:"uid" yaml:"uid" xml:"uid" gorm:"index;size:20" comment:"用户工号" format:"user" props:"readonly:update" rule:"required"` Uid string `json:"uid" yaml:"uid" xml:"uid" gorm:"index;size:20" comment:"用户" format:"user" props:"readonly:update" rule:"required"`
Ip string `json:"ip" yaml:"ip" xml:"ip" gorm:"size:128" comment:"登录地址" scenarios:"list;search;view;export"` Ip string `json:"ip" yaml:"ip" xml:"ip" gorm:"size:128" comment:"登录地址" scenarios:"list;search;view;export"`
Browser string `json:"browser" yaml:"browser" xml:"browser" gorm:"size:128" comment:"浏览器" scenarios:"list;view;export"` Browser string `json:"browser" yaml:"browser" xml:"browser" gorm:"size:128" comment:"浏览器" scenarios:"list;view;export"`
Os string `json:"os" yaml:"os" xml:"os" gorm:"size:128" comment:"操作系统" scenarios:"list;view;export"` Os string `json:"os" yaml:"os" xml:"os" gorm:"size:128" comment:"操作系统" scenarios:"list;view;export"`
@ -566,3 +566,71 @@ func (m *SettingModel) FindAll(db *gorm.DB, query any, args ...any) (err error)
func NewSettingModel() *SettingModel { func NewSettingModel() *SettingModel {
return &SettingModel{} return &SettingModel{}
} }
type ActivityModel struct {
Id int64 `json:"id" yaml:"id" xml:"id" gorm:"primaryKey" comment:"ID"`
CreatedAt int64 `json:"created_at" yaml:"createdAt" xml:"createdAt" comment:"创建时间" scenarios:"search;search;view;export"`
Uid string `json:"uid" yaml:"uid" xml:"uid" gorm:"index;size:20" comment:"用户" format:"user" props:"readonly:update" rule:"required"`
Action string `json:"action" yaml:"action" xml:"action" gorm:"index;size:20;not null;default:''" comment:"行为" scenarios:"search;list;create;update;view;export" props:"match:exactly" enum:"create:新建#198754;update:更新#f09d00;delete:删除#e63757"`
Module string `json:"module" yaml:"module" xml:"module" gorm:"size:60;not null;default:''" comment:"模块" scenarios:"search;list;create;update;view;export"`
Table string `json:"table" yaml:"table" xml:"table" gorm:"size:60;not null;default:''" comment:"数据" scenarios:"list;create;update;view;export"`
Data string `json:"data" yaml:"data" xml:"data" gorm:"size:10240;not null;default:''" comment:"内容" scenarios:"list;create;update;view;export"`
}
func (m *ActivityModel) TableName() string {
return "activities"
}
func (m *ActivityModel) FromValue(x *Activity) {
m.Id = x.Id
m.CreatedAt = x.CreatedAt
m.Uid = x.Uid
m.Action = x.Action
m.Module = x.Module
m.Table = x.Table
m.Data = x.Data
}
func (m *ActivityModel) ToValue() (x *Activity) {
x = &Activity{}
x.Id = m.Id
x.CreatedAt = m.CreatedAt
x.Uid = m.Uid
x.Action = m.Action
x.Module = m.Module
x.Table = m.Table
x.Data = m.Data
return x
}
func (m *ActivityModel) Create(db *gorm.DB) (err error) {
return db.Create(m).Error
}
func (m *ActivityModel) UpdateColumn(db *gorm.DB, column string, value any) (err error) {
return db.Model(m).UpdateColumn(column, value).Error
}
func (m *ActivityModel) Save(db *gorm.DB) (err error) {
return db.Save(m).Error
}
func (m *ActivityModel) Delete(db *gorm.DB) (err error) {
return db.Delete(m).Error
}
func (m *ActivityModel) Find(db *gorm.DB, pk any) (err error) {
return db.Where("data=?", pk).First(m).Error
}
func (m *ActivityModel) FindOne(db *gorm.DB, query any, args ...any) (err error) {
return db.Where(query, args...).First(m).Error
}
func (m *ActivityModel) FindAll(db *gorm.DB, query any, args ...any) (err error) {
return db.Where(query, args...).Find(m).Error
}
func NewActivityModel() *ActivityModel {
return &ActivityModel{}
}

View File

@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"html/template" "html/template"
httpkg "net/http"
"os" "os"
"path" "path"
"reflect" "reflect"
@ -14,6 +15,7 @@ import (
"git.nobla.cn/golang/aeus-admin/models" "git.nobla.cn/golang/aeus-admin/models"
"git.nobla.cn/golang/aeus-admin/pkg/dbcache" "git.nobla.cn/golang/aeus-admin/pkg/dbcache"
adminTypes "git.nobla.cn/golang/aeus-admin/types" adminTypes "git.nobla.cn/golang/aeus-admin/types"
"git.nobla.cn/golang/aeus/middleware/auth"
"git.nobla.cn/golang/aeus/pkg/errors" "git.nobla.cn/golang/aeus/pkg/errors"
"git.nobla.cn/golang/aeus/pkg/pool" "git.nobla.cn/golang/aeus/pkg/pool"
"git.nobla.cn/golang/aeus/transport/http" "git.nobla.cn/golang/aeus/transport/http"
@ -35,6 +37,7 @@ func getModels() []any {
&models.Permission{}, &models.Permission{},
&models.RolePermission{}, &models.RolePermission{},
&models.Setting{}, &models.Setting{},
&models.Activity{},
} }
} }
@ -207,6 +210,19 @@ func generateVueFile(prefix string, apiPrefix string, mv *rest.Model) (err error
return os.WriteFile(filename, writer.Bytes(), 0644) return os.WriteFile(filename, writer.Bytes(), 0644)
} }
// restValueLookup 特殊字段获取方式
func restValueLookup(column string, w httpkg.ResponseWriter, r *httpkg.Request) string {
switch column {
case "user":
// 从授权信息里面获取用户的ID
if t, ok := auth.FromContext(r.Context()); ok {
uid, _ := t.GetSubject()
return uid
}
}
return r.Header.Get(column)
}
// initREST 初始化REST模块 // initREST 初始化REST模块
func initREST(ctx context.Context, o *options) (err error) { func initREST(ctx context.Context, o *options) (err error) {
tx := o.db tx := o.db
@ -216,6 +232,7 @@ func initREST(ctx context.Context, o *options) (err error) {
opts := make([]rest.Option, 0) opts := make([]rest.Option, 0)
opts = append(opts, o.restOpts...) opts = append(opts, o.restOpts...)
opts = append(opts, rest.WithDB(tx)) opts = append(opts, rest.WithDB(tx))
opts = append(opts, rest.WithValueLookup(restValueLookup))
if o.apiPrefix != "" { if o.apiPrefix != "" {
opts = append(opts, rest.WithUriPrefix(o.apiPrefix)) opts = append(opts, rest.WithUriPrefix(o.apiPrefix))
} }