diff --git a/options.go b/options.go new file mode 100755 index 0000000..b2f7e6d --- /dev/null +++ b/options.go @@ -0,0 +1,52 @@ +package req + +import "git.fsdpf.net/go/db" + +type ResOptions struct { + Tx *db.TxDatabase + Alias string + Silent bool + // RolesScope 默认零值 ResScopeOff:不传 WithRolesScope,或显式传 ResScopeOff,都表示跳过 + // 权限过滤;只有显式传 ResScopeOn/ResScopeAll 才会按角色过滤。 + RolesScope ResScope +} + +type ResOption func(p *ResOptions) + +func WithTx(tx *db.TxDatabase) ResOption { + return func(p *ResOptions) { + p.Tx = tx + } +} + +func WithAlias(as string) ResOption { + return func(p *ResOptions) { + p.Alias = as + } +} + +// WithSilent 让这次写操作跳过变更通知(ResChangeEventFunc/ResWatcher),权限过滤、字段脱敏、 +// 字段规范化、类型转换、默认值填充等都不受影响。用于类似"记录登录 IP"这种高频写、但不需要广播 +// "资源变了"的场景,避免不必要的缓存刷新/watcher 触发。 +func WithSilent() ResOption { + return func(p *ResOptions) { + p.Silent = true + } +} + +// WithRolesScope 显式指定这次写/查操作的权限过滤范围。ResScopeOn/ResScopeAll 按角色过滤 +// (行级 WHERE 注入、字段级读脱敏、字段级写权限);ResScopeOff(或不调用这个 option)跳过 +// 权限过滤,变更通知、字段规范化、类型转换、默认值填充等不受影响。 +func WithRolesScope(s ResScope) ResOption { + return func(p *ResOptions) { + p.RolesScope = s + } +} + +type gParamsOpt func(g *gparams) + +func Session(session string) gParamsOpt { + return func(g *gparams) { + g.session = session + } +} diff --git a/resx/res_interceptor.go b/resx/res_interceptor.go new file mode 100644 index 0000000..1d520a8 --- /dev/null +++ b/resx/res_interceptor.go @@ -0,0 +1,928 @@ +package resx + +import ( + "database/sql" + "fmt" + "log" + "reflect" + "strings" + + "github.com/samber/do/v2" + "github.com/samber/lo" + "github.com/spf13/cast" + + "git.fsdpf.net/go/db" + "git.fsdpf.net/go/db/exec" + "git.fsdpf.net/go/db/exp" + "git.fsdpf.net/go/req" +) + +// changeRow 保存一行数据的主键、UPDATE/DELETE 前的快照,以及 INSERT/UPDATE 时写入的字段值 +type changeRow struct { + id int64 + snapshot map[string]any + changes map[string]any +} + +// dataProcessor 是挂在 db.SelectDataset/InsertDataset/UpdateDataset/DeleteDataset 上的读写钩子。 +// +// 职责: +// 1. 权限过滤:根据 DataInterceptor 返回的角色条件改写 SELECT/UPDATE/DELETE 的 WHERE。 +// 2. 数据规范化:INSERT/UPDATE 前统一清理系统保留字段、按字段类型转换保存值,并为 INSERT 填充默认值。 +// 3. 变更留痕:写操作完成后无条件调用 ResChangeEventFunc;当 DataInterceptor 判断需要时,写操作前 +// 抓取快照/写入值,写操作后额外调用 ResChangeRowFunc(由上层实现变更日志写入、ResWatcher +// 事件发布等)。 +// 4. 事务托底:当 onChange 会被调用、且调用方没有显式传入事务时,自动开一个事务包住这次写操作和 +// onChange 本身;onChange 失败就整体回滚,成功才提交。调用方显式传入的事务不受影响,提交/ +// 回滚仍由调用方自己负责。 +type dataProcessor struct { + res *resource + u req.User + tx *db.TxDatabase + ownsTx bool // tx 是否由本次写操作自动开启(而非调用方传入),决定 After 要不要 Commit/Rollback + onChange ResChangeRowFunc // 非 nil 表示本次写操作需要抓取快照/收集变更内容,由 Before 阶段的 DataInterceptor 决定 + rows []changeRow + silent bool // ResOptions.Silent:跳过变更通知,见 req.WithSilent 注释 + skipPermission bool // ResOptions.RolesScope 不是 ResScopeOn/ResScopeAll:跳过权限过滤/字段脱敏,见 req.WithRolesScope 注释 +} + +// UseTx 在 Before 之前调用:如果本次写操作会触发 onChange、且调用方没有显式传入事务,自动开一个 +// 事务包住写操作和 onChange。调用方已显式传入的事务(dp.tx != nil)不受影响。 +func (dp *dataProcessor) UseTx(dataset interface{}) (exec.QueryFactory, error) { + if dp.res.IsVirtual() || dp.tx != nil { + return nil, nil + } + + var category ResEventType + switch dataset.(type) { + case *db.InsertDataset: + category = ResEventInsert + case *db.UpdateDataset: + category = ResEventUpdate + case *db.DeleteDataset: + category = ResEventDelete + default: + return nil, nil + } + + dp.applyIntercept(category) + + if dp.onChange == nil { + return nil, nil + } + + tx, err := dp.res.DB().Begin() + if err != nil { + return nil, err + } + dp.tx = tx + dp.ownsTx = true + + return exec.NewQueryFactory(tx), nil +} + +// applyIntercept 调用容器里注册的 DataInterceptor,返回角色权限过滤条件,并记录本次写完后要不要调用、调用谁。 +// skipPermission 时丢弃行级权限过滤条件;silent 时不设置 onChange(写完后不会触发变更通知)。两者 +// 相互独立,可以只生效一个。 +func (dp *dataProcessor) applyIntercept(category ResEventType) (sub *db.SelectDataset, cond db.Expression) { + sub, cond, onChange := dp.res.intercept(dp.u, category) + if dp.skipPermission { + sub, cond = nil, nil + } + if !dp.silent { + dp.onChange = onChange + } + return sub, cond +} + +// captureSnapshot 在写操作执行前,查出受影响的行存入 rows +func (dp *dataProcessor) captureSnapshot(where exp.ExpressionList) { + sd := dp.res.GetDBTable(dp.u) + if where != nil && len(where.Expressions()) > 0 { + sd = sd.Where(where.Expressions()...) + } + snaps, err := sd.Executor().GetRecords() + if err != nil { + log.Printf("captureSnapshot err: %v", err) + log.Println(sd.Executor().ToSQL()) + return + } + dp.rows = make([]changeRow, len(snaps)) + for i, snap := range snaps { + dp.rows[i].snapshot = snap + if id, ok := snap["id"]; ok { + dp.rows[i].id = cast.ToInt64(id) + } + } +} + +// insertRowIds 计算本次 INSERT 各行的自增 id(MySQL: lastId 是第一条,SQLite: lastId 是最后一条) +func (dp *dataProcessor) insertRowIds(result sql.Result) []int64 { + lastId, _ := result.LastInsertId() + count, _ := result.RowsAffected() + + ids := make([]int64, count) + for i := int64(0); i < count; i++ { + rowId := lastId + i + if isLocalDB(dp.res.DB().Dialect()) { + rowId = lastId - count + 1 + i + } + ids[i] = rowId + } + return ids +} + +// finalizeInsertedRows 把计算好的自增 id 写回每一行;INSERT ... SELECT 场景 Before 阶段拿不到具体数据, +// 这里改为按 id 反查补全(见 fetchInsertedRows) +func (dp *dataProcessor) finalizeInsertedRows(result sql.Result) { + ids := dp.insertRowIds(result) + + if len(dp.rows) == 0 { + dp.fetchInsertedRows(ids) + return + } + + for i := range dp.rows { + if i < len(ids) { + dp.rows[i].id = ids[i] + } + } +} + +// fetchInsertedRows 用于 INSERT ... SELECT 场景:写入前拿不到具体行数据,写入后按 id 反查补全 changes +func (dp *dataProcessor) fetchInsertedRows(ids []int64) { + recs, err := dp.res.GetDBTable(dp.u).Where(db.C("id").In(ids)).Executor().GetRecords() + if err != nil { + log.Printf("fetchInsertedRows err: %v", err) + return + } + + byId := make(map[int64]map[string]any, len(recs)) + for _, rec := range recs { + if id, ok := rec["id"]; ok { + byId[cast.ToInt64(id)] = rec + } + } + + dp.rows = make([]changeRow, len(ids)) + for i, id := range ids { + dp.rows[i].id = id + if rec, ok := byId[id]; ok { + dp.rows[i].changes = dp.changesFromNewRow(rec) + } + } +} + +// buildChanges 将本次 UPDATE 实际写入的字段值记入每一行的 changes +func (dp *dataProcessor) buildChanges(newValues db.Record) { + if len(dp.rows) == 0 || len(newValues) == 0 { + return + } + + resolved := make(map[string]any, len(newValues)) + for k, v := range newValues { + resolved[k] = dp.resolveLiteralValue(v) + } + + for i := range dp.rows { + dp.rows[i].changes = resolved + } +} + +// resolveLiteralValue 从 db.V/db.L 构造的字面量表达式中还原真实值,用于变更对比与序列化 +func (dp *dataProcessor) resolveLiteralValue(v any) any { + lit, ok := v.(exp.LiteralExpression) + if !ok { + return v + } + if lit.Literal() == "?" { + if args := lit.Args(); len(args) == 1 { + return args[0] + } + } + return lit.Literal() +} + +// changesFromNewRow 还原 INSERT 行的最终保存值,作为 changes 直接记录 +func (dp *dataProcessor) changesFromNewRow(row map[string]any) map[string]any { + data := make(map[string]any, len(row)) + for k, v := range row { + data[k] = dp.resolveLiteralValue(v) + } + return data +} + +func (dp *dataProcessor) Before(dataset interface{}) *db.HookError { + if err := dp.before(dataset); err != nil { + return &db.HookError{Err: err} + } + return nil +} + +func (dp *dataProcessor) before(dataset interface{}) error { + switch d := dataset.(type) { + case *db.SelectDataset: + return dp.beforeSelectDataset(d) + case *db.InsertDataset: + return dp.beforeInsertDataset(d) + case *db.UpdateDataset: + return dp.beforeUpdateDataset(d) + case *db.DeleteDataset: + return dp.beforeDeleteDataset(d) + } + return nil +} + +func (dp *dataProcessor) After(dataset interface{}, result interface{}) *db.HookError { + if err := dp.after(dataset, result); err != nil { + return &db.HookError{Err: err} + } + return nil +} + +func (dp *dataProcessor) after(dataset interface{}, result interface{}) error { + if dp.res.IsVirtual() { + return nil + } + + var category ResEventType + switch dataset.(type) { + case *db.SelectDataset: + return nil + case *db.InsertDataset: + category = ResEventInsert + case *db.UpdateDataset: + category = ResEventUpdate + case *db.DeleteDataset: + category = ResEventDelete + default: + return nil + } + + r := result.(sql.Result) + + if !dp.silent { + if err := dp.res.changeEvent(NewResChangeEvent(category, dp.res, dp.u, r)); err != nil { + log.Printf("ResChangeEventFunc err: %v", err) + } + } + + // ResChangeRowFunc 由 Before 阶段 DataInterceptor 返回的 onChange 直接提供,非 nil 才调用 + var hookErr error + if dp.onChange != nil { + if category == ResEventInsert { + dp.finalizeInsertedRows(r) + } + rows := make([]ResChangeRow, len(dp.rows)) + for i, row := range dp.rows { + rows[i] = NewResChangeRow(category, dp.res, dp.u, row.id, WithOld(row.snapshot), WithNew(row.changes), WithTx(dp.tx)) + } + hookErr = dp.onChange(rows) + } + + if dp.ownsTx { + if hookErr != nil { + if rbErr := dp.tx.Rollback(); rbErr != nil { + log.Printf("dataProcessor tx rollback err: %v", rbErr) + } + } else if cmErr := dp.tx.Commit(); cmErr != nil { + hookErr = cmErr + } + } + + return hookErr +} + +func (dp *dataProcessor) beforeInsertDataset(id *db.InsertDataset) error { + // INSERT 不涉及权限过滤(新建的行无需按角色可见性过滤),只关心是否需要抓取变更内容 + dp.applyIntercept(ResEventInsert) + + switch true { + case id.GetClauses().HasRows(): + return dp.beforeInsertRows(id) + case id.GetClauses().HasVals(): + return dp.beforeInsertColsVals(id) + case id.GetClauses().HasFrom(): + return dp.beforeInsertFromQuery(id) + } + return nil +} + +func (dp *dataProcessor) beforeInsertRows(id *db.InsertDataset) error { + rows := id.GetClauses().Rows() + + for i := range rows { + rowValue := reflect.ValueOf(rows[i]) + if rowValue.Kind() == reflect.Ptr { + rowValue = rowValue.Elem() + } + if rowValue.Kind() == reflect.Struct { + if row, err := exp.NewRecordFromStruct(rowValue.Interface(), true, false); err != nil { + return err + } else { + rows[i] = row + } + } + } + + for i := range rows { + row, ok := rows[i].(map[string]any) + + if !ok { + return fmt.Errorf("row is %T, want map[string]any", row) + } + + if err := dp.normalizeSaveValue(row); err != nil { + return err + } + if err := dp.applyDefaultValue(row, true, false); err != nil { + return err + } + + if dp.onChange != nil { + dp.rows = append(dp.rows, changeRow{changes: dp.changesFromNewRow(row)}) + } + } + + return nil +} + +func (dp *dataProcessor) beforeInsertColsVals(id *db.InsertDataset) error { + cols := id.GetClauses().Cols() + vals := id.GetClauses().Vals() + + colsName := []string{} + for _, col := range cols.Columns() { + colsName = append(colsName, col.(exp.IdentifierExpression).GetCol().(string)) + } + + extraColsName := []string{} + colsVal := []any{} + for k, v := range lo.OmitByKeys(dp.getFieldsDefaultValue(true, false), colsName) { + cols = cols.Append(db.C(k)) + extraColsName = append(extraColsName, k) + colsVal = append(colsVal, v) + } + allColsName := append(append([]string{}, colsName...), extraColsName...) + + for i := 0; i < len(vals); i++ { + for j := 0; j < len(vals[i]); j++ { + if filed, ok := dp.res.GetField(colsName[j]); ok { + vals[i][j] = filed.ToValue(vals[i][j]) + } + } + vals[i] = append(vals[i], colsVal...) + + if dp.onChange != nil { + row := make(map[string]any, len(allColsName)) + for j, name := range allColsName { + if j < len(vals[i]) { + row[name] = vals[i][j] + } + } + dp.rows = append(dp.rows, changeRow{changes: dp.changesFromNewRow(row)}) + } + } + + *id = *id.Cols(cols) + return nil +} + +// beforeInsertFromQuery INSERT ... SELECT:写入内容来自子查询,写入前无法拿到具体行数据, +// changes 由 After 在写入完成后按 id 反查补全(见 fetchInsertedRows) +func (dp *dataProcessor) beforeInsertFromQuery(id *db.InsertDataset) error { + return nil +} + +// normalizeSaveValue 规范化保存数据: +// 1. 移除系统缺省字段, 如果是 insert 需要调用 applyDefaultValue +// 2. 移除系统中没有的字段 +// 3. 移除当前用户没有权限写的字段(ResField.GetRoles,语义同查询脱敏,见 hasFieldAccess)—— +// 静默丢弃而不是报错,跟"系统中没有的字段"一致:数据库里对应列保持原值不变。skipPermission +// 时跳过这条检查,字段规范化/类型转换本身不受影响。 +func (dp *dataProcessor) normalizeSaveValue(row db.Record) error { + for k, v := range row { + if k == "id" || k == "created_user" || k == "created_at" || k == "deleted_at" || k == "updated_at" { + delete(row, k) + continue + } + + field, hasField := dp.res.GetField(k) + if hasField && !dp.skipPermission && !hasFieldAccess(field, dp.u) { + delete(row, k) + continue + } + + if val, ok := v.(db.Expression); ok { + row[k] = val + } else if hasField { + row[k] = field.ToValue(v) + } else { + delete(row, k) + } + } + return nil +} + +// applyDefaultValue 填充默认数据 +func (dp *dataProcessor) applyDefaultValue(row db.Record, forInsert, forUpdate bool) error { + for k, v := range dp.getFieldsDefaultValue(forInsert, forUpdate) { + if _, ok := row[k]; !ok { + row[k] = v + } + } + return nil +} + +func (dp *dataProcessor) getFieldsDefaultValue(forInsert, forUpdate bool) map[string]db.Expression { + defs := map[string]db.Expression{} + + if forInsert { + for _, item := range dp.res.fields { + if item.GetCode() == "updated_at" || item.GetCode() == "created_user" || item.GetCode() == "owned_user" { + continue + } + raw := item.GetRawDefault() + if raw != nil { + if lit, ok := raw.(exp.LiteralExpression); ok && strings.HasPrefix(strings.ToLower(lit.Literal()), "sql:") { + defs[item.GetCode()] = raw + } else if item.GetDataType() == req.ResJson { + defs[item.GetCode()] = raw + } + } + } + + if _, ok := defs["owned_user"]; !ok { + defs["owned_user"] = db.V(dp.u.Uuid()) + } + defs["created_user"] = db.V(dp.u.Uuid()) + } + + if forUpdate && isLocalDB(dp.res.DB().Dialect()) { + defs["updated_at"] = db.L("CURRENT_TIMESTAMP") + } + + return defs +} + +func (dp *dataProcessor) beforeSelectDataset(sd *db.SelectDataset) error { + sub, ex := dp.applyIntercept(ResEventSelect) + if ex != nil { + *sd = *sd.Where(ex) + } else if sub != nil { + table := sd.GetClauses().From().Columns()[0] + if alias, ok := table.(exp.AliasedExpression); ok { + *sd = *sd.From(db.V(sub.Expression()).As(alias.GetAs())) + } else { + *sd = *sd.From(db.V(sub.Expression()).As(table)) + } + } + return dp.maskSelect(sd) +} + +// maskSelect 按字段级权限(ResField.GetRoles)替换 SELECT 列表里当前用户没有权限查看的字段: +// - 没有显式选择过列(裸的 db.Star()),或者显式选了限定到当前资源自己别名的 db.T(alias).All() +// ——两种写法效果一样,都是"选当前资源自己的全部列":展开成资源自己的全部字段,逐个按权限 +// 判断要不要替换成脱敏值。只覆盖当前资源自己的字段,JOIN 进来的字段没有被展开进来,覆盖不到。 +// - 其它情况下显式选择了列(通常来自 orm-v2 QueryField.ToSql()):只处理其中被 MaskField(...) +// 显式标记过的列,未标记的列一律原样保留——标记是显式的,不去猜哪一列对应哪个字段。这时候才 +// 真正解析 MaskField 标记的别名对应哪个资源、哪个字段(见 resolveLazyMask),解析失败说明 +// 调用方标记的表名/字段名写错了,是编程错误,直接报错而不是静默跳过。 +func (dp *dataProcessor) maskSelect(sd *db.SelectDataset) error { + if dp.res.IsVirtual() || dp.skipPermission { + return nil + } + + if !dp.hasRestrictedFields(sd) { + return nil + } + + if dp.isDefaultSelect(sd) { + if len(dp.res.fields) == 0 { + return nil + } + cols := make([]interface{}, len(dp.res.fields)) + changed := false + for i, field := range dp.res.fields { + if hasFieldAccess(field, dp.u) { + cols[i] = db.C(field.GetCode()) + } else { + cols[i] = dp.res.mask(field).As(field.GetCode()) + changed = true + } + } + if changed { + *sd = *sd.Select(cols...) + } + return nil + } + + old := sd.GetClauses().Select().Columns() + cols := make([]interface{}, len(old)) + changed := false + for i, c := range old { + resolved, colChanged, err := dp.resolveMaskedExpr(sd, c, true) + + if err != nil { + return err + } + cols[i] = resolved + if colChanged { + changed = true + } + } + + if changed { + *sd = *sd.Select(cols...) + } + + return nil +} + +// hasRestrictedFields 判断这次查询可能碰到的字段——当前资源自己的,加上 FROM/JOIN 子句里所有 +// 能解析到具体资源的——里有没有任何一个配置了 Roles。一个都没有的话,maskSelect 后面所有的工作 +// (SELECT * 展开、显式 MaskField 列解析)都不会有任何效果,可以在最前面直接跳过整个函数,不用 +// 再管是不是默认 SELECT、SELECT 里有没有标记过的列。 +// +// JOIN 的表如果解析不到具体资源(不是这套资源系统管理的表、容器没注册 req.GetResource、或者 +// 这张表在 FROM/JOIN 里的写法认不出真实表名),不能当成"没有受限字段"直接放过——万一这张表上 +// 真的有 MaskField(...) 标记,那本该在 resolveLazyField 里报错(表名/字段名配置错误),提前跳过 +// 就变成静默放过,比现在的报错行为更危险。这些拿不准的情况一律返回 true(不跳过),退回到老路径 +// 让后面该报错报错、该处理处理。 +// +// 有个前提:这里只看 sd 真实的 FROM/JOIN 子句。MaskField("alias.col") 里 alias 指向一个压根没 +// JOIN 进这次查询的资源时(resolveLazyField 会把 alias 当 code/table/uuid 兜底直接查一次),这 +// 里扫不到那个资源,如果它自己没有受限字段又恰好没被扫进来,会被误判成"整个查询不用脱敏"直接跳过 +// ——这种查询本来就没有实际意义(列不属于 FROM 里的任何一张表,真跑数据库会报错),MaskField 的 +// alias 必须对应查询里真实 JOIN 过的表,是这个快速跳过路径的前提。 +// +// 超级管理员(HasUserRoles() 不带参数调用,只命中超管角色那一支)对任何字段都有权限,不管查了 +// 哪些表、字段配置了什么 Roles,脱敏最终都不会生效(hasFieldAccess 对超管永远返回 true)——直接 +// 跳过整个解析过程,既是性能优化,也避免了系统自己在资源初始化阶段用系统用户递归发起查询时, +// req.GetResource 还没注册好导致的 DI 循环依赖报错(跟 framework-v2 的 res_scope.go 里 +// getResUserRolesCondition 对同类问题的处理是一个思路)。 +func (dp *dataProcessor) hasRestrictedFields(sd *db.SelectDataset) bool { + if dp.u.HasUserRoles() { + return false + } + + if dp.fieldsHaveRoles(dp.res.fields) { + return true + } + + getResource, err := do.Invoke[req.GetResource](dp.res.container) + if err != nil { + return true + } + + clauses := sd.GetClauses() + tables := make([]exp.Expression, 0, 1+len(clauses.Joins())) + if from := clauses.From().Columns(); len(from) > 0 { + tables = append(tables, from[0]) + } + for _, j := range clauses.Joins() { + tables = append(tables, j.Table()) + } + + for _, t := range tables { + aliased, ok := t.(exp.AliasedExpression) + if !ok { + return true + } + table, ok := dp.realTableOf(aliased.Aliased()) + if !ok { + return true + } + res, ok := getResource(table) + if !ok { + return true + } + if dp.fieldsHaveRoles(res.GetFields()) { + return true + } + } + + return false +} + +// fieldsHaveRoles 判断 fields 里有没有任何一个配置了 Roles(hasRestrictedFields 对当前资源自己 +// 和每个能解析到的 JOIN 资源都要做一次这个判断) +func (dp *dataProcessor) fieldsHaveRoles(fields []req.ResField) bool { + for _, field := range fields { + if len(field.GetRoles()) > 0 { + return true + } + } + return false +} + +// resolveMaskedExpr 递归处理 c 内部被 MaskField 标记的列,包括嵌套在 db.Func(...) 参数里的情况 +// (比如 db.Func("CONCAT", resx.MaskField("t.a"), resx.MaskField("t.b")))——顶层 SELECT 列表 +// 只按类型断言直接标记的列,标记被函数包一层之后顶层类型就变成了 SQLFunctionExpression,断言不 +// 出来,得递归进 Args() 里找。 +// +// topLevel 为 true 表示 c 本身就是一整个 SELECT 列,这时候没权限要换成 "mask值 AS 别名"; +// topLevel 为 false 表示 c 是嵌在别的表达式(目前只处理 db.Func 的参数)里面,这时候只能换成 +// 裸的 mask 值,不能带 AS——SQL 函数参数没法单独起别名。 +// +// 目前只递归处理 SQLFunctionExpression(db.Func(...))和包着它的 AliasedExpression +// (db.Func(...).As(...)):这是这个代码库里唯一会把 MaskField 包起来用的场景(聚合/计算字段), +// 没有覆盖 CastExpression/CaseExpression/WindowExpression 等目前用不到的包装类型。 +func (dp *dataProcessor) resolveMaskedExpr(sd *db.SelectDataset, c interface{}, topLevel bool) (interface{}, bool, error) { + if r, ok := c.(lazyMaskResolver); ok { + field, unresolved, err := dp.resolveLazyField(sd, r) + if err != nil { + return nil, false, err + } + + if unresolved { + // req.GetResource 本身解析失败(见 resolveLazyField 的说明),没有具体字段可参考 + // 数据类型,退到 db.V(nil) 兜底——跟 defaultMask 认不出数据类型时的兜底分支一致。 + if !topLevel { + return db.V(nil), true, nil + } + return db.V(nil).As(dp.fallbackAlias(c, r)), true, nil + } + + if hasFieldAccess(field, dp.u) { + return c, false, nil + } + if !topLevel { + return dp.res.mask(field), true, nil + } + return dp.res.mask(field).As(dp.fallbackAlias(c, r)), true, nil + } + + if fn, ok := c.(exp.SQLFunctionExpression); ok { + args := fn.Args() + newArgs := make([]interface{}, len(args)) + changed := false + for i, a := range args { + resolved, argChanged, err := dp.resolveMaskedExpr(sd, a, false) + if err != nil { + return nil, false, err + } + if argChanged { + changed = true + newArgs[i] = resolved + } else { + newArgs[i] = a + } + } + if !changed { + return c, false, nil + } + return db.Func(fn.Name(), newArgs...), true, nil + } + + if aliased, ok := c.(exp.AliasedExpression); ok { + inner, innerChanged, err := dp.resolveMaskedExpr(sd, aliased.Aliased(), false) + if err != nil { + return nil, false, err + } + if !innerChanged { + return c, false, nil + } + return inner.(exp.Aliaseable).As(aliased.GetAs()), true, nil + } + + return c, false, nil +} + +// fallbackAlias 在拿不到真正的 req.ResField(没法用 field.GetCode())时,给替换后的表达式起个 +// 别名:优先用调用方自己指定的别名(MaskField(...).As("xxx")),没有的话退到列名本身 +// (lazyMaskField() 的第二个返回值)。 +func (dp *dataProcessor) fallbackAlias(c interface{}, r lazyMaskResolver) string { + if aliased, ok := c.(exp.AliasedExpression); ok { + if col, ok := aliased.GetAs().GetCol().(string); ok && col != "" { + return col + } + } + _, code := r.lazyMaskField() + return code +} + +// resolveLazyField 把 MaskField(...) 标记的"待解析"列解析成真正的 req.ResField:codeResource +// 为空表示当前资源自己的字段,直接用 dp.res;否则把 codeResource 当 SQL 别名,在 sd 的 FROM/JOIN +// 子句里换成真实表名(见 resolveJoinTable),换不到就把它本身当 code/table/uuid 兜底试一次,再用 +// req.GetResource 查资源。 +// unresolved=true 表示 req.GetResource 本身解析失败——常见原因是资源系统自己还在初始化过程中 +// 递归触发了一次带 JOIN 的查询,形成真正的循环依赖(不是调用方标记写错了表名/字段名,那种情况 +// 会走下面别的报错分支)。这种情况不能让整个查询因为一次 DI 时序问题报错崩掉:调用方 +// resolveMaskedExpr 拿到 unresolved=true 后会保守地当成需要脱敏处理,宁可多脱敏一列,也不能 +// 打崩查询。 +func (dp *dataProcessor) resolveLazyField(sd *db.SelectDataset, r lazyMaskResolver) (field req.ResField, unresolved bool, err error) { + codeResource, code := r.lazyMaskField() + + var res req.Resource + if codeResource == "" || codeResource == dp.res.GetCode() || codeResource == dp.fromAlias(sd) { + // 裸列名,或者 codeResource 就是当前资源自己(不管是 dp.res 自己的 code,还是这次查询给它 + // 起的 FROM 别名):这两种情况都不是真的 JOIN 进来的字段,直接用 dp.res,完全不用碰 + // req.GetResource——DataListField 之类的配置记录里,CodeResource 对"master/自身资源"的 + // 字段本来就常常直接填资源自己的 code,不能一律当成外部资源硬查 DI,否则会把当前资源自己 + // 的所有字段都误判成"需要走 JOIN 解析",一旦 req.GetResource 暂时不可用,会把整行字段 + // 全部误脱敏,而不是只脱敏真的配置了 Roles 的那几个。 + res = dp.res + } else { + // 到这里 codeResource 已经不是资源自己的标识了,是这次查询里用的 SQL 别名,去 FROM/JOIN + // 子句里当别名搜——只有这个语境下才适合叫 alias。 + alias := codeResource + table, ok := dp.resolveJoinTable(sd, alias) + if !ok { + table = alias + } + + getResource, err := do.Invoke[req.GetResource](dp.res.container) + + if err != nil { + return nil, false, err + } + + found, ok := getResource(table) + if !ok { + return nil, false, fmt.Errorf("resx: MaskField 标记的列 %q 找不到对应的资源(别名/表名 %q)", code, table) + } + res = found + } + + field, ok := res.GetField(code) + if !ok { + return nil, false, fmt.Errorf("resx: 资源 %q 没有字段 %q", res.GetCode(), code) + } + + return field, false, nil +} + +// resolveJoinTable 在 sd 的 FROM/JOIN 子句里找别名是 alias 的那一个,返回它的真实表名。 +// MaskField("alias.col") 里的 alias 通常是资源自己的 code(约定,但不保证——比如同一个资源自 +// 连接时两个别名不可能都等于资源 code),只有找到真实表名,才能用 req.GetResource 的 table 匹配 +// 稳定查到资源,不用赌 alias 本身就是 code。 +func (dp *dataProcessor) resolveJoinTable(sd *db.SelectDataset, alias string) (string, bool) { + clauses := sd.GetClauses() + + if from := clauses.From().Columns(); len(from) > 0 { + if t, ok := dp.aliasedRealTable(from[0], alias); ok { + return t, true + } + } + + for _, j := range clauses.Joins() { + if t, ok := dp.aliasedRealTable(j.Table(), alias); ok { + return t, true + } + } + + return "", false +} + +// aliasIdentOf 从 AliasedExpression 里取出别名字符串,兼容两种起别名的路径(同 fromAlias 的说明: +// 落在 GetAs() 的 Table 字段还是 Col 字段,取决于当初是直接在 identifier 上调 As(),还是像 +// GetTableExpr() 那样先包一层字面量再 As())。 +func (dp *dataProcessor) aliasIdentOf(as exp.IdentifierExpression) string { + if t := as.GetTable(); t != "" { + return t + } + if c, ok := as.GetCol().(string); ok { + return c + } + return "" +} + +// aliasedRealTable 如果 e 是别名为 alias 的表引用,返回它包着的真实表名。 +func (dp *dataProcessor) aliasedRealTable(e exp.Expression, alias string) (string, bool) { + aliased, ok := e.(exp.AliasedExpression) + if !ok || dp.aliasIdentOf(aliased.GetAs()) != alias { + return "", false + } + return dp.realTableOf(aliased.Aliased()) +} + +// realTableOf 从"被起别名的那个表达式"里取出真实表名,不管别名是什么。兼容两种构造路径: +// db.T(table).As(alias) 时 Aliased() 直接就是原始 identifier;res.GetTableExpr().As(alias) +// (GetTableExpr 用 db.V(db.T(table)) 包了一层字面量)时 Aliased() 是字面量,真实的 identifier +// 在它的 Args()[0] 里。 +func (dp *dataProcessor) realTableOf(e exp.Expression) (string, bool) { + switch v := e.(type) { + case exp.IdentifierExpression: + return v.GetTable(), true + case exp.LiteralExpression: + args := v.Args() + if len(args) != 1 { + return "", false + } + if ident, ok := args[0].(exp.IdentifierExpression); ok { + return ident.GetTable(), true + } + } + return "", false +} + +// isDefaultSelect 判断 SELECT 列表是不是"选当前资源自己的全部列":要么是裸的 db.Star() +// (clauses.IsDefaultSelect()),要么是显式限定到当前资源自己别名的 db.T(alias).All()——两种 +// 写法效果一样,都应该走同一套"展开成显式字段列表"的逻辑。限定到别的(比如 JOIN 进来的)别名 +// 的 T().All() 不算,那是另一个资源的全部列,这里够不到。 +func (dp *dataProcessor) isDefaultSelect(sd *db.SelectDataset) bool { + clauses := sd.GetClauses() + if clauses.IsDefaultSelect() { + return true + } + + cols := clauses.Select().Columns() + if len(cols) != 1 { + return false + } + ident, ok := cols[0].(exp.IdentifierExpression) + if !ok { + return false + } + lit, ok := ident.GetCol().(exp.LiteralExpression) + if !ok || lit.Literal() != "*" { + return false + } + return ident.GetTable() == "" || ident.GetTable() == dp.fromAlias(sd) +} + +// fromAlias 取出 FROM 子句里当前资源自己的别名(GetDBTable 默认用资源 code 做别名,也可能被 +// ResOption 的 Alias 覆盖),取不到就返回空字符串。 +// +// 别名字符串具体落在 GetAs() 的 Table 字段还是 Col 字段,取决于当初是怎么 As() 出来的: +// GetTableExpr() 内部用 db.V(db.T(table)) 包了一层字面量,literal.As() 走的是 +// NewAliasExpression -> ParseIdentifier,单个词(不带 ".")会被解析成 col,不是 table;而 +// JOIN 场景常见的 db.T(code).As(alias) 是直接在 identifier 上调用 As(),走的是另一条路径, +// 会把别名存进 table 字段。两种都要认。 +func (dp *dataProcessor) fromAlias(sd *db.SelectDataset) string { + table := sd.GetClauses().From().Columns()[0] + aliased, ok := table.(exp.AliasedExpression) + if !ok { + return "" + } + return dp.aliasIdentOf(aliased.GetAs()) +} + +func (dp *dataProcessor) beforeUpdateDataset(ud *db.UpdateDataset) error { + if dp.res.IsVirtual() { + return nil + } + sub, ex := dp.applyIntercept(ResEventUpdate) + if ex != nil { + *ud = *ud.Where(ex) + } else if sub != nil { + table := ud.GetClauses().Table() + if alias, ok := table.(exp.AliasedExpression); ok { + *ud = *ud.Where(alias.GetAs().Col("id").In(sub.Select(db.T(dp.res.GetCode()).Col("id")))) + } else { + *ud = *ud.Where(db.L("?.id", table).In(sub.Select(db.T(dp.res.GetCode()).Col("id")))) + } + } + + if dp.onChange != nil { + dp.captureSnapshot(ud.GetClauses().Where()) + } + + if ud.GetClauses().HasSetValues() { + udv := ud.GetClauses().SetValues() + + var data db.Record + switch v := udv.(type) { + case map[string]any: + data = v + case db.Record: + data = v + default: + return fmt.Errorf("unsupported type: %T", udv) + } + + if err := dp.normalizeSaveValue(data); err != nil { + return err + } + + if dp.onChange != nil { + dp.buildChanges(data) + } + } + + return nil +} + +func (dp *dataProcessor) beforeDeleteDataset(dd *db.DeleteDataset) error { + if dp.res.IsVirtual() { + return nil + } + sub, ex := dp.applyIntercept(ResEventDelete) + if ex != nil { + *dd = *dd.Where(ex) + } else if sub != nil { + table := dd.GetClauses().From().Columns()[0] + if alias, ok := table.(exp.AliasedExpression); ok { + *dd = *dd.Where(alias.GetAs().Col("id").In(sub.Select(db.T(dp.res.GetCode()).Col("id")))) + } else { + *dd = *dd.Where(db.L("?.id", table).In(sub.Select(db.T(dp.res.GetCode()).Col("id")))) + } + } + + if dp.onChange != nil { + dp.captureSnapshot(dd.GetClauses().Where()) + } + + return nil +} diff --git a/resx/resource.go b/resx/resource.go new file mode 100644 index 0000000..1174a83 --- /dev/null +++ b/resx/resource.go @@ -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-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 + 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" +}