// Package resx 提供 req.Resource 接口的实现。 // // 角色权限过滤与写操作后的处理(变更留痕、ResWatcher 事件发布等)依赖更上层的 contracts 包 // (NewOrm/NewOrmJoin/GetOrmConditions/Queue 等),而 contracts 包本身依赖 req,为避免循环 // import,这几块能力通过 DataInterceptor/ResChangeEventFunc/ResChangeRowFunc 三个函数类型传入: // 上层(如 framework)用 do.Provide 把它们分别注册到 DI 容器即可获得完整能力;容器里没有注册 // 时优雅降级为空操作,因此这个包在测试或不需要这些能力的场景下(如 orm 包的单元测试)也可以 // 直接使用。 // // 这几个回调是 per-container(per-app)的概念而不是 per-resource 的:回调参数里已经带 res,可以 // 按资源分支处理,同一个容器里的所有资源理应共享同一套实现,所以不作为 resource 的字段通过 Option // 逐个设置,而是像 DB() 一样在使用时从 container 里解析(见 (*resource).intercept/changeEvent)。 package resx import ( "fmt" "reflect" "sync" "unicode" "github.com/samber/do/v2" "github.com/samber/lo" "git.fsdpf.net/go/db" "git.fsdpf.net/go/db/engine" "git.fsdpf.net/go/db/exp" "git.fsdpf.net/go/db/schema" "git.fsdpf.net/go/req" ) // DataInterceptor 每次读写操作前调用。由上层用 do.Provide 注册到 DI 容器;容器里没有注册时不过滤、 // 不抓取。返回值: // - sub:权限条件的子查询形式,非 nil 时用于重写 FROM(SELECT)或改写为 // `id` IN (子查询)(UPDATE/DELETE);与 cond 互斥,两者都为 nil 表示不过滤。例如 // SELECT 拼接后为: // SELECT * FROM (SELECT * FROM `users` AS `User` WHERE (`owned_user` = '...')) AS `User` // UPDATE/DELETE 拼接后为: // UPDATE `users` AS `User` SET ... WHERE (`id` IN ((SELECT `User`.`id` FROM `users` AS `User` WHERE (`owned_user` = '...')))) // - cond:权限条件的表达式形式,非 nil 时直接拼进 WHERE;与 sub 互斥。例如拼接后为: // SELECT * FROM `users` AS `User` WHERE (`owned_user` = '...') // - onChange:非 nil 表示本次写操作需要抓取快照/收集变更内容,写完后会调用它(写入变更 // 日志、发布 ResWatcher 事件等要用到);nil 表示不需要,也就完全不会有调用。跟 // ResChangeEventFunc 不是一回事:ResChangeEventFunc 不看这个返回值,任何写操作后都会 // 无条件调用。SELECT 场景不会用到 onChange,可以恒返回 nil type DataInterceptor func(res req.Resource, u req.User, category ResEventType) (sub *db.SelectDataset, cond db.Expression, onChange ResChangeRowFunc) // ResChangeEventFunc 写操作完成后无条件调用一次,不受 DataInterceptor 返回的 onChange 影响(典型 // 用途:发布粗粒度的"资源发生变更"事件,如清缓存)。event 已经自带 Type()/Res()/User()/ // Result(),不需要额外参数。由上层用 do.Provide 注册到 DI 容器;容器里没有注册时不做任何处理。 type ResChangeEventFunc func(event ResChangeEvent) error // ResChangeRowFunc 写操作完成后调用,由同一次写操作里 DataInterceptor 的 onChange 返回值直接 // 提供(典型用途:写入变更日志、发布 ResWatcher 事件),不是从 DI 容器解析的,因此可以按 // 资源/类别/用户各返回不同的处理逻辑。rows 里每一行已经自带 Type()/Res()/User()/ID()/Old()/ // New()/Cur()/Tx(),不需要额外参数。 type ResChangeRowFunc func(rows []ResChangeRow) error // MaskFunc 返回某个字段被脱敏后展示的哨兵值(用户没有权限查看 field 真实值时,SELECT 里这个 // 字段会换成这个值)。由上层用 do.Provide 注册到 DI 容器;容器里没有注册时按 field.GetDataType() // 走内置默认值:数字类(Integer/SmallInteger/Decimal)给一个明显不合理的负数,字符串类 // (String/Text/Enum)给一个"零宽空格-零宽空格"的三字符标记(视觉上只显示一个短横线,但跟真实 // 短横线不是同一个字符串),其它类型给 NULL——都是刻意选的、不会跟真实业务数据混淆的哨兵值, // 具体见 defaultMask。 type MaskFunc func(field req.ResField) exp.LiteralExpression // DefaultMaskInt 数字类字段的默认哨兵值:绝大多数业务字段(数量/价格/年龄/次数等)不可能出现 // 这个量级的负数,比 0 更不容易被误当成真实数据 var DefaultMaskInt = -999999999 // DefaultMaskString 字符串类字段的默认哨兵值:零宽空格 + 短横线 + 零宽空格(U+200B, '-', U+200B)。 // 用显式字节而不是直接在源码里放不可见字符,方便 diff/grep 时能看清楚具体是什么;视觉上只显示 // 一个"-",但实际是 3 个字符,跟调用方自己存的真实短横线字符串区分得开 var DefaultMaskString = string([]byte{0xE2, 0x80, 0x8B, '-', 0xE2, 0x80, 0x8B}) // defaultMask 内置的按数据类型分支的脱敏默认值,MaskFunc 未注册时使用 func defaultMask(field req.ResField) exp.LiteralExpression { switch field.GetDataType() { case req.ResInteger, req.ResSmallInteger, req.ResDecimal: return db.V(DefaultMaskInt) case req.ResString, req.ResText, req.ResEnum: return db.V(DefaultMaskString) default: return db.V(nil) } } type resource struct { container do.Injector initOnce *sync.Once uuid string code string name string description string conn string isResVirtual bool table string // virtualTable 只有虚拟资源(NewVirtualResource 构造的)才会设置,table 留空。GetTableExpr // 用它代替 table 拼子查询:db.V(sd.Expression()) 让方言自己生成/加括号,不用手写 "(" + sql + ")" // 拼字符串。 virtualTable exp.SQLExpression primarykey string historyRoles []string fields []req.ResField } // intercept 从容器里解析 DataInterceptor 并调用;容器里没有注册时返回 nil, nil, nil(不过滤、不抓取)。 func (this *resource) intercept(u req.User, category ResEventType) (sub *db.SelectDataset, cond db.Expression, onChange ResChangeRowFunc) { fn, err := do.Invoke[DataInterceptor](this.container) if err != nil { return nil, nil, nil } return fn(this, u, category) } // changeEvent 从容器里解析 ResChangeEventFunc 并调用;容器里没有注册时不做任何处理。 func (this *resource) changeEvent(event ResChangeEvent) error { fn, err := do.Invoke[ResChangeEventFunc](this.container) if err != nil { return nil } return fn(event) } // mask 从容器里解析 MaskFunc 并调用;容器里没有注册时走 defaultMask。 func (this *resource) mask(field req.ResField) exp.LiteralExpression { fn, err := do.Invoke[MaskFunc](this.container) if err != nil { return defaultMask(field) } return fn(field) } // Option 用于设置 New 里非必填的字段 type Option func(r *resource) // WithUuid 设置资源 uuid,不设置则为空字符串 func WithUuid(v string) Option { return func(r *resource) { r.uuid = v } } // WithName 设置资源名,不设置则默认与 code 相同 func WithName(v string) Option { return func(r *resource) { r.name = v } } // WithDescription 设置资源说明,不设置则为空字符串 func WithDescription(v string) Option { return func(r *resource) { r.description = v } } // WithConn 设置数据库连接名,不设置则为空字符串(引擎按空字符串键查找连接) func WithConn(v string) Option { return func(r *resource) { r.conn = v } } // WithPrimarykey 设置主键字段名,不设置则默认为 "id" func WithPrimarykey(v string) Option { return func(r *resource) { r.primarykey = v } } // WithHistoryRoles 设置变更留痕角色列表,不设置则不留痕 func WithHistoryRoles(roles ...string) Option { return func(r *resource) { r.historyRoles = roles } } // WithFields 设置资源字段,不设置则为空 func WithFields(fields ...req.ResField) Option { return func(r *resource) { r.fields = fields } } // New 构造一个 req.Resource。code/table 为必填,其余通过 Option 设置。 // 读写钩子(DataInterceptor/ResChangeEventFunc/ResChangeRowFunc)不通过 Option 设置,而是由上层用 // do.Provide 注册到 container,见包注释。 func New(container do.Injector, code, table string, opts ...Option) req.Resource { r := &resource{ container: container, uuid: code, code: code, name: code, table: table, primarykey: "id", initOnce: &sync.Once{}, } for _, opt := range opts { opt(r) } return r } func (this *resource) GetUuid() string { return this.uuid } func (this *resource) GetCode() string { return this.code } func (this *resource) GetName() string { return this.name } func (this *resource) GetDescription() string { return this.description } func (this *resource) GetConn() string { return this.conn } func (this *resource) GetPrimarykey() string { return this.primarykey } func (this *resource) GetHistoryRoles() []string { return this.historyRoles } func (this *resource) IsVirtual() bool { return this.isResVirtual } func (this *resource) IsSystem() bool { return this.conn == "service-support" } func (this *resource) GetFields() []req.ResField { return this.fields } func (this *resource) GetField(code string) (req.ResField, bool) { return lo.Find(this.fields, func(v req.ResField) bool { return v.GetCode() == code }) } func (this *resource) HasField(code string) bool { return lo.SomeBy(this.fields, func(v req.ResField) bool { return v.GetCode() == code }) } func (this *resource) BeginTransaction() (*db.TxDatabase, error) { return this.DB().Begin() } func (this *resource) DB() *db.Database { dbEngine := do.MustInvoke[engine.Engine](this.container) conn := dbEngine.Connection(this.conn) if isLocalDB(conn.Dialect()) && !this.isResVirtual && this.table != "" && this.initOnce != nil { this.initOnce.Do(func() { if err := this.autoCreateTable(conn); err != nil { panic(err) } }) } // vtable 资源:查询走 engine 中的 :memory: 连接("__"+Conn), // 原文件连接仅用于 _vtab_cache 持久化,两者互不阻塞。 if conn.Dialect() == "vtable" && this.table != "" && this.initOnce != nil { memConn := dbEngine.Connection("__" + this.conn) this.initOnce.Do(func() { if err := this.autoSetupVtab(memConn); err != nil { panic(err) } }) return memConn } return conn } // autoSetupVtab 执行 CREATE VIRTUAL TABLE;对应的 vtab 模块由上层通过 req.ResVirtualTableSetup // 提前注册完成(在资源缓存构建时对所有 vtable 连接的资源整体调用一遍,而不是在这里 lazy 触发) func (this *resource) autoSetupVtab(conn *db.Database) error { _, err := conn.Exec(fmt.Sprintf(`CREATE VIRTUAL TABLE IF NOT EXISTS %s USING %s_mod()`, this.table, this.table)) return err } // autoCreateTable 自动创建数据表 func (this *resource) autoCreateTable(conn *db.Database) error { sb := schema.New(conn) return sb.Create(this.table, func(bp *schema.Blueprint) { bp.Comment = this.name for _, field := range this.fields { field.ToBlueprint(bp) } }) } // GetTable 原始表名;虚拟资源没有物理表名,返回空字符串 func (this *resource) GetTable() string { return this.table } // GetTableExpr 获取资源对应的可用于 FROM/JOIN 的表引用表达式 func (this *resource) GetTableExpr() exp.Aliaseable { if this.isResVirtual { return db.L("?", this.virtualTable.Clone()) } return db.T(this.table) } // GetDBTable 获取资源对应的数据库连接 func (this *resource) GetDBTable(u req.User, opts ...req.ResOption) (sd *db.SelectDataset) { if u == nil { panic("GetDBTable: user cannot be nil") } op := &req.ResOptions{} for _, cb := range opts { cb(op) } alias := this.GetCode() if op.Alias != "" { alias = op.Alias } sd = this.DB().From(this.GetTableExpr().As(alias)) if op.Tx != nil { sd = op.Tx.From(this.GetTableExpr().As(alias)) } sd.WithHook(&dataProcessor{ res: this, u: u, tx: op.Tx, silent: op.Silent, skipRowFilter: op.Permission&req.ResRow == 0, skipFieldMask: op.Permission&req.ResMask == 0, }) return sd } func (this *resource) GetStruct(extends ...reflect.StructField) any { fields := []reflect.StructField{} for _, field := range this.fields { code := field.GetCode() if unicode.IsLetter(rune(code[0])) { fields = append(fields, field.ToStructField()) } } fields = lo.UniqBy(append(fields, extends...), func(v reflect.StructField) string { return v.Name }) t := reflect.StructOf(fields) return reflect.New(t).Interface() } func (this *resource) GetSliceStruct(extends ...reflect.StructField) any { t := reflect.TypeOf(this.GetStruct(extends...)) st := reflect.SliceOf(t.Elem()) return reflect.New(st).Interface() } // isLocalDB 判断是否为本地文件型数据库(LastInsertId 返回最后一条而非第一条) func isLocalDB(dialect string) bool { return dialect == "sqlite3" || dialect == "duckdb" }