Files
req/resx/resource.go
T
what 5cdf1cc00d 重构: 虚拟资源改用 NewVirtualResource 显式构造,移除 WithVirtual
WithVirtual(bool) 只是给 New() 打一个标记位,虚拟资源需要的表达式(子查询)没有
承载的地方,之前借用 table string 字段硬拼字符串。改成 NewVirtualResource(res,
code, table, opts...) 专门构造:table 参数直接接收 exp.SQLExpression,
GetTableExpr 对虚拟资源直接用它生成子查询表达式,不用再手工拼括号和字符串;
container/conn/historyRoles 从传入的父资源上直接复用。

GetTableExpr 返回值类型从 exp.LiteralExpression 放宽成 exp.Aliaseable,
兼容 db.T(...)(IdentifierExpression)和 db.L(...)(LiteralExpression)两种
返回值。
2026-08-20 15:58:03 +08:00

368 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// 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"
}