feat: 新增 WithSilent/WithRolesScope 写操作选项

WithSilent 让写操作跳过变更通知(ResChangeEventFunc/ResWatcher),权限过滤/字段脱敏不受影响,用于类似"记录登录 IP"这种高频写但不需要广播"资源变了"的场景,避免不必要的缓存刷新。

WithRolesScope 显式指定权限过滤范围(ResScopeOn/All 才按角色过滤,Off 或不传都跳过),替代原来一直没接线生效的 Access 选项。两个选项相互独立,可以单独用也可以一起用。
This commit is contained in:
2026-07-21 18:01:05 +08:00
parent cc3f6d90e7
commit 7f74caead4
3 changed files with 1342 additions and 0 deletions
+362
View File
@@ -0,0 +1,362 @@
// Package resx 提供 req.Resource 接口的实现。
//
// 角色权限过滤与写操作后的处理(变更留痕、ResWatcher 事件发布等)依赖更上层的 contracts 包
// NewOrm/NewOrmJoin/GetOrmConditions/Queue 等),而 contracts 包本身依赖 req,为避免循环
// import,这几块能力通过 DataInterceptor/ResChangeEventFunc/ResChangeRowFunc 三个函数类型传入:
// 上层(如 framework)用 do.Provide 把它们分别注册到 DI 容器即可获得完整能力;容器里没有注册
// 时优雅降级为空操作,因此这个包在测试或不需要这些能力的场景下(如 orm 包的单元测试)也可以
// 直接使用。
//
// 这几个回调是 per-containerper-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
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 }
}
// WithVirtual 标记为虚拟资源
func WithVirtual(v bool) Option {
return func(r *resource) { r.isResVirtual = 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.LiteralExpression {
if this.isResVirtual {
return db.L("(" + this.table + ")")
}
return db.V(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))
}
skipPermission := op.RolesScope != req.ResScopeOn && op.RolesScope != req.ResScopeAll
sd.WithHook(&dataProcessor{res: this, u: u, tx: op.Tx, silent: op.Silent, skipPermission: skipPermission})
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"
}