Compare commits

1 Commits
Author SHA1 Message Date
what e5b5c18123 docs: 新增 README,说明 v1-legacy 分支的能力范围与安装方式
记录这条老线跟 master 的接口差异,以及依赖方(如 zg-estate)应该怎么锁定/更新
到这个分支,避免误跑 go get -u 拉到 master 上的破坏性改动。
2026-08-20 16:26:14 +08:00
29 changed files with 401 additions and 4943 deletions
+28 -73
View File
@@ -1,84 +1,39 @@
# git.fsdpf.net/go/reqmaster # git.fsdpf.net/go/reqv1-legacy
资源系统的核心抽象与默认实现,配合 `framework-v2`/`contracts-v2`/`orm-v2` 等 "-v2" 系列项目使用。 老架构下资源系统的核心接口定义。只有接口,没有默认实现——具体实现放在配套的老版
`framework`/`contracts` 仓库里,本仓库只提供类型契约,供它们和使用方共同依赖。
## 核心接口(仓库根目录) ## 这个分支有什么
- `Resource`一个业务资源的完整描述——字段列表、主键、变更留痕角色、数据库连接、以及 - `Resource`资源接口,`GetDBTable(params ...any) *db.Builder`/`GetAuthDBTable(u User, params ...any) *db.Builder`
`GetDBTable(u User, opts ...ResOption) *db.SelectDataset` 这个统一的查询入口。 基于老版 `db.Builder`(不是 `db.SelectDataset`)构造查询;`WithRolesCondition(b *db.Builder, t string, u User) error`
- `ResField`:单个字段的描述(code、数据类型、默认值、是否虚拟计算列、`GetRoles()` 权限角色等)。 直接挂在接口上做行级权限过滤,不是靠外部 DI 注入的钩子;`ResAuthDB``ResAuthOff`/`ResAuthOn`/
- `QueryField``ResField` 之上再包一层查询专用信息(别名、是否表达式、omitempty、忽略), `ResAuthAll`)控制要不要做权限过滤、要不要扩展到关联资源。
给 API 层按请求参数动态拼查询列表用。 - `ResField`/`QueryField`:字段/查询字段接口,`ToValue`/`ToBlueprint`/`ToQueryField` 等方法签名
- `User`:当前操作者(角色列表、匿名判断等),贯穿权限判断的所有环节。 跟现在 `master` 分支的不兼容(比如 `GetRawDefault(driver string)` 现在改成了不带 `driver`
- `GetResource`/`MustResource`:按 code/table/uuid 查资源的 DI 注入函数类型,由上层 参数的版本)。
`framework-v2`)注册实现,`resx` 包只依赖类型,不关心具体实现 - `User`:用户接口,角色、匿名判断、`HasUserRoles` 等,这部分跟 `master` 基本一致
- `ResFlags``ResRow`/`ResRowRelations`/`ResMask`/`ResMaskRelations`/`ResAll`):`GetDBTable` - `RouteCtx`/`RouteMethod`/`HttpResponse`/`GlobalParams` 等 HTTP 路由和响应相关的类型
`WithPermission` 选项用的位标志,见下文"权限开关"。 `routing.go`/`response.go`/`global_params.go`):这些在 `master` 分支上已经不在这个仓库
的范围内了,是这条老线特有的。
## resx 包:默认实现 ## 这个分支的定位
### 构造资源 给还在维护、但还没迁移到 `master` 分支新架构(`resx` 默认实现、字段级脱敏、`ResFlags` 权限
开关、虚拟资源等)的老项目用,比如 `zg-estate`(通过 `framework`/`contracts` 间接依赖)。
只接受独立于新架构的 bug 修复,不会引入 `master` 上的新能力。
- `resx.New(container do.Injector, code, table string, opts ...Option) req.Resource`:构造一个 ## 依赖方怎么安装/更新
绑定物理表的资源。`container``samber/do/v2` 的 DI 容器,资源自己的读写钩子
`DataInterceptor`/`ResChangeEventFunc`/`MaskFunc`)都是运行时从这个容器里解析的,容器里没
注册时优雅降级为空操作,所以 `resx` 在测试或不需要这些能力的场景下也能独立使用。可选项:
`WithUuid`/`WithName`/`WithDescription`/`WithConn`/`WithPrimarykey`/`WithHistoryRoles`/
`WithFields`
- `resx.NewVirtualResource(parent req.Resource, code string, table exp.SQLExpression, opts ...Option) req.Resource`
基于一个已有资源(复用它的 `container`/`conn`/`historyRoles`)和一段查询表达式(子查询/窗口
函数等)构造只读虚拟资源,`table` 不是物理表名,而是 `GetTableExpr()` 直接拿来拼 FROM 子句的
表达式。虚拟资源会跳过行级权限过滤和写操作相关的钩子编排(本来就是只读的)。
- `resx.NewResField(code, codeResource string, opts ...ResFieldOption)`:构造字段,`ResField`
内部字段全私有,只能通过 `FieldWith*``FieldWithName`/`FieldWithDataType`/`FieldWithRoles`/
`FieldWithVirtual` 等)设置。
- `resx.NewQueryField(rField req.ResField, t req.RouteParamType, opts ...QueryFieldOption)`
`QueryField` 嵌入的是 `req.ResField` **接口**而不是具体结构体,调用方手上已有的
`req.ResField` 可以直接传入,不用现造一个。
### 字段级权限脱敏 Go modules 不会记住某个版本是从哪个分支解析出来的,`go.mod`/`go.sum` 里存的只是一次性解析出
的 commit 伪版本号,分支上有新提交也不会自动同步。
- `resx.MaskField("table.col")` / `resx.MaskField("col")`:在 SELECT 列表里显式标记一列需要按 **首次锁定,或者要拿这个分支上新提交的更新**,都执行同一条命令:
权限脱敏。**只有标记过的列才会被处理**,未标记的列即使字段本身配置了 `Roles` 也不会被动过
——标记是显式的,`dataProcessor` 不会替调用方去猜哪一列对应哪个字段。
- 标记的列在查询真正执行前(`exec.Hooks.Before`,这时候完整的 FROM/JOIN 子句才拼好)才懒解析:
裸列名或者别名就是当前资源自己时直接用当前资源,不碰 DI;别名对应 JOIN 进来的另一个资源时,
先在 FROM/JOIN 子句里把别名换成真实表名,再用 `req.GetResource` 按真实表名查到对应资源。
按角色(`ResField.GetRoles()`)判断当前用户有没有权限看到真实值,没有的话换成哨兵值——数字类
默认 `resx.DefaultMaskInt``-999999999`),字符串类默认 `resx.DefaultMaskString`(三个不可见
字符拼的短横线,跟真实短横线区分得开),也可以在容器里注册 `resx.MaskFunc` 自定义脱敏值。
- `SELECT *`(或者显式选中当前资源自己别名的全部列)会先判断有没有任何字段配置了 `Roles`,有
的话才展开成显式字段列表逐个按权限脱敏,没有的话原样保留 `SELECT *`,避免不必要的改写。
- 写操作(INSERT/UPDATE)里,没有写权限的字段会被 `normalizeSaveValue` 静默丢弃,不会出现在
最终的 SQL 里。
### 权限开关(`req.WithPermission` ```
go get git.fsdpf.net/go/req@v1-legacy
`GetDBTable(u, opts...)` 默认(不传 `WithPermission`)**行级过滤和字段脱敏都跳过**——这是零值
语义,两个开关相互独立:
```go
res.GetDBTable(u, req.WithPermission(req.ResRow)) // 只开行级过滤
res.GetDBTable(u, req.WithPermission(req.ResMask)) // 只开字段脱敏
res.GetDBTable(u, req.WithPermission(req.ResAll)) // 两个都开(ResRow|ResMask
``` ```
### 读写钩子编排 **禁止**跑裸的 `go get -u`(或者 `go get git.fsdpf.net/go/req@latest`)——这个仓库没有打
语义化 tagGo 对"最新版本"的解析规则是退回到仓库默认分支(`master`)的 HEAD,会把新架构的
三个函数类型,由上层用 `do.Provide` 注册到容器,`resx` 只依赖类型本身: 破坏性改动一起拉进来,而不是停留在这个分支上。
- `resx.DataInterceptor`:每次读写前调用,返回行级权限过滤条件(`sub` 子查询或 `cond`
表达式,二选一)、以及这次写操作要不要在写完后回调(`onChange`,只对 INSERT/UPDATE/DELETE
有意义,SELECT 恒为 nil)。
- `resx.ResChangeEventFunc`:写操作完成后无条件调用一次(典型用途:发布"资源变了"的粗粒度事件,
比如清缓存)。
- `resx.ResChangeRowFunc`:由 `DataInterceptor` 返回的 `onChange` 提供,需要抓取变更快照时才
调用(典型用途:写变更日志、发布 ResWatcher 事件)。`onChange` 非 nil 时写操作会自动包一层
事务,保证写入和 `onChange` 本身是同一个事务。
容器里没注册对应钩子时,三个都优雅降级为空操作,不影响基本读写。
## 分支状态
持续开发中,**不保证跟旧 API 兼容**。老项目(还没迁移到上面这些新能力的)不要直接跟着这个分支
走,参见 `v1-legacy` 分支。
+55
View File
@@ -0,0 +1,55 @@
package req
import (
"time"
)
type GlobalParams interface {
User() User
// 获取路径内容
Get(p string) GlobalParams
// 路径包裹
Wrapped(p string) GlobalParams
// 追加到新 json
AppendTo(root, p string) GlobalParams
// 通过路径设置参数
Set(p string, v any) bool
// 通过路径设置原始参数
SetRaw(p string, v string) bool
// 删除路径参数
Delete(p string) bool
// 获取原始JOSN字符串
Raw() string
// 转数组 GlobalParams
Array() []GlobalParams
// 判断内容是否存在
Exists() bool
Value() any
Bool() bool
Time() time.Time
TimeInDefaultLocation(l *time.Location) time.Time
Float64() float64
Float32() float32
Int64() int64
Int32() int32
Int16() int16
Int8() int8
Int() int
Uint() uint
Uint64() uint64
Uint32() uint32
Uint16() uint16
Uint8() uint8
String() string
StringMapString() map[string]string
StringMapStringSlice() map[string][]string
StringMapBool() map[string]bool
StringMapInt() map[string]int
StringMapInt64() map[string]int64
StringMap() map[string]any
Slice() []any
BoolSlice() []bool
StringSlice() []string
IntSlice() []int
}
+231
View File
@@ -0,0 +1,231 @@
package req
import (
"time"
"github.com/samber/lo"
"github.com/spf13/cast"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type iGlobalParams struct {
user *User
gjson gjson.Result
}
// 获取用户信息
func (this iGlobalParams) User() User {
return *this.user
}
// 获取指定路径的 value
func (this iGlobalParams) Get(key string) GlobalParams {
return &iGlobalParams{
user: this.user,
gjson: this.gjson.Get(key),
}
}
func (this iGlobalParams) Array() []GlobalParams {
return lo.Map(this.gjson.Array(), func(item gjson.Result, _ int) GlobalParams {
return &iGlobalParams{user: this.user, gjson: gjson.Parse(item.Raw)}
})
}
// 获取golang原始类型
func (this iGlobalParams) Value() any {
return this.gjson.Value()
}
func (this iGlobalParams) Raw() string {
return this.gjson.Raw
}
// 设置json值
func (this *iGlobalParams) Set(p string, v any) bool {
if s, err := sjson.Set(this.Raw(), p, v); err != nil {
return false
} else {
this.gjson = gjson.Parse(s)
}
return true
}
// 设置json原始值
func (this *iGlobalParams) SetRaw(p, v string) bool {
if s, err := sjson.SetRaw(this.Raw(), p, v); err != nil {
return false
} else {
this.gjson = gjson.Parse(s)
}
return true
}
// 删除路径内容
func (this *iGlobalParams) Delete(p string) bool {
if s, err := sjson.Delete(this.Raw(), p); err != nil {
return false
} else {
this.gjson = gjson.Parse(s)
}
return true
}
// 路径包裹
func (this *iGlobalParams) Wrapped(p string) GlobalParams {
return this.AppendTo("", p)
}
// 包裹内容
func (this *iGlobalParams) AppendTo(root, p string) GlobalParams {
if root != "" && !gjson.Valid(root) {
root = ""
}
json := lo.Must(sjson.SetRaw(root, p, this.Raw()))
return &iGlobalParams{
user: this.user,
gjson: gjson.Parse(json),
}
}
// 判断值是否存在
func (this iGlobalParams) Exists() bool {
return this.gjson.Exists()
}
// to Bool
func (this iGlobalParams) Bool() bool {
return cast.ToBool(this.Value())
}
// to Time
func (this iGlobalParams) Time() time.Time {
return cast.ToTime(this.Value())
}
// to Location Time
func (this iGlobalParams) TimeInDefaultLocation(location *time.Location) time.Time {
return cast.ToTimeInDefaultLocation(this, location)
}
// to float64
func (this iGlobalParams) Float64() float64 {
return cast.ToFloat64(this.Value())
}
// to float32
func (this iGlobalParams) Float32() float32 {
return cast.ToFloat32(this.Value())
}
// to int64
func (this iGlobalParams) Int64() int64 {
return cast.ToInt64(this.Value())
}
// to int32
func (this iGlobalParams) Int32() int32 {
return cast.ToInt32(this.Value())
}
// to int16
func (this iGlobalParams) Int16() int16 {
return cast.ToInt16(this.Value())
}
// to int8
func (this iGlobalParams) Int8() int8 {
return cast.ToInt8(this.Value())
}
// to int
func (this iGlobalParams) Int() int {
return cast.ToInt(this.Value())
}
// to uint
func (this iGlobalParams) Uint() uint {
return cast.ToUint(this.Value())
}
// to uint64
func (this iGlobalParams) Uint64() uint64 {
return cast.ToUint64(this.Value())
}
// to uint32
func (this iGlobalParams) Uint32() uint32 {
return cast.ToUint32(this.Value())
}
// to uint16
func (this iGlobalParams) Uint16() uint16 {
return cast.ToUint16(this.Value())
}
// to uint8
func (this iGlobalParams) Uint8() uint8 {
return cast.ToUint8(this.Value())
}
// to string
func (this iGlobalParams) String() string {
return cast.ToString(this.Value())
}
// to map[string]string
func (this iGlobalParams) StringMapString() map[string]string {
return cast.ToStringMapString(this.Value())
}
// to map[string][]string
func (this iGlobalParams) StringMapStringSlice() map[string][]string {
return cast.ToStringMapStringSlice(this.Value())
}
// to map[string]bool
func (this iGlobalParams) StringMapBool() map[string]bool {
return cast.ToStringMapBool(this.Value())
}
// to map[string]int
func (this iGlobalParams) StringMapInt() map[string]int {
return cast.ToStringMapInt(this.Value())
}
// to map[string]int64
func (this iGlobalParams) StringMapInt64() map[string]int64 {
return cast.ToStringMapInt64(this.Value())
}
// to map[string]any
func (this iGlobalParams) StringMap() map[string]any {
return cast.ToStringMap(this.Value())
}
// to []any
func (this iGlobalParams) Slice() []any {
return cast.ToSlice(this.Value())
}
// to []bool
func (this iGlobalParams) BoolSlice() []bool {
return cast.ToBoolSlice(this.Value())
}
// to []string
func (this iGlobalParams) StringSlice() []string {
return cast.ToStringSlice(this.Value())
}
// to []int
func (this iGlobalParams) IntSlice() []int {
return cast.ToIntSlice(this.Value())
}
func NewGlobalParam(data string, user User) GlobalParams {
return &iGlobalParams{user: &user, gjson: gjson.Parse(data)}
}
Executable → Regular
+7 -17
View File
@@ -1,30 +1,20 @@
module git.fsdpf.net/go/req module git.fsdpf.net/go/req
go 1.25.5 go 1.21
require ( require (
git.fsdpf.net/go/db v0.0.0-20260820090948-9c6bb5a83508 git.fsdpf.net/go/db v0.0.0-20230731125324-11651ea6640b
git.fsdpf.net/go/reflux v0.0.0-20260820092857-1f48635da3f1
github.com/DATA-DOG/go-sqlmock v1.5.0
github.com/go-chi/chi/v5 v5.0.12 github.com/go-chi/chi/v5 v5.0.12
github.com/samber/do/v2 v2.0.0 github.com/samber/lo v1.39.0
github.com/samber/lo v1.49.1 github.com/spf13/cast v1.6.0
github.com/spf13/cast v1.10.0
github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.17.1 github.com/tidwall/gjson v1.17.1
github.com/tidwall/sjson v1.2.5 github.com/tidwall/sjson v1.2.5
) )
require ( require (
github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect github.com/mattn/go-sqlite3 v1.14.16 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/samber/go-type-to-string v1.8.0 // indirect
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect github.com/tidwall/pretty v1.2.0 // indirect
golang.org/x/text v0.25.0 // indirect golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )
Executable → Regular
+16 -42
View File
@@ -1,46 +1,25 @@
git.fsdpf.net/go/db v0.0.0-20260820090948-9c6bb5a83508 h1:xv2SV/Qb0TKJFnzfBtSKSeXwa6NZPRBVoGfWDahsezo= git.fsdpf.net/go/db v0.0.0-20230731125324-11651ea6640b h1:fRgWNOQ9dAYuUZHQ24oi1XqRbJIcJvZGbnQDaAKI7IY=
git.fsdpf.net/go/db v0.0.0-20260820090948-9c6bb5a83508/go.mod h1:oDPmvCdZn/vTpbuPYPYkjdrhpis/XtMIi3eni4sYRvM= git.fsdpf.net/go/db v0.0.0-20230731125324-11651ea6640b/go.mod h1:397Sdx1cJS0OlHtTX1bVl//9k3Xn0Klnc6jC4MAkb6w=
git.fsdpf.net/go/reflux v0.0.0-20260820092857-1f48635da3f1 h1:TRn++7oWwtANQM5JWTQGdW6fAkWMIbIsZX6tzmnsrLQ=
git.fsdpf.net/go/reflux v0.0.0-20260820092857-1f48635da3f1/go.mod h1:8OLCLMUwdsZ8u2sRI9y/qaE4fTf5d0SrCJ6Zn+lttFY=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s= github.com/go-chi/chi/v5 v5.0.12 h1:9euLV5sTrTNTRUU9POmDUvfxyj6LAABLUcEWO+JJb4s=
github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/chi/v5 v5.0.12/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI= github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/samber/do/v2 v2.0.0 h1:tnunwWaoqSfJ9hxVIaJawIo7JXHQlqT9d9YBXlE9Keg=
github.com/samber/do/v2 v2.0.0/go.mod h1:ZSBCE7Xr6nTNIOVo4DBrkl2+ydUbIOzJjjdV8En5XO4=
github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w=
github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U= github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
@@ -50,10 +29,5 @@ github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-285
View File
@@ -1,285 +0,0 @@
package req
import (
"reflect"
"git.fsdpf.net/go/reflux/valuex"
"github.com/samber/lo"
"github.com/spf13/cast"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
type GParams = GlobalParams
// GlobalParams 为全局参数访问与操作接口
type GlobalParams interface {
UserAccessor
Session() string
// 通过路径获取值
Get(path string) GlobalParams
// 路径包裹
Wrapped(p string) GlobalParams
// 追加到新 json
AppendTo(root, p string) GlobalParams
// 通过路径设置参数
Set(p string, v any) bool
// 通过路径设置原始参数
SetValue(p string, v string) bool
// 删除路径参数
Delete(p string) bool
// 转数组 GlobalParams
Array() []GlobalParams
// 判断内容是否存在
Exists() bool
}
type gparams struct {
session string
user *User
gjson gjson.Result
}
// 获取用户信息
func (this gparams) Session() string {
return this.session
}
// 获取用户信息
func (this gparams) User() User {
return *this.user
}
// 获取指定路径的 value
func (this gparams) Get(path string) GlobalParams {
return &gparams{
user: this.user,
session: this.session,
gjson: this.gjson.Get(path),
}
}
func (this gparams) Array() []GlobalParams {
return lo.Map(this.gjson.Array(), func(item gjson.Result, _ int) GlobalParams {
return &gparams{user: this.user, gjson: item, session: this.session}
})
}
func (this gparams) Lookup(path string) (valuex.Accessor, bool) {
v := this.gjson.Get(path)
if v.Exists() {
return this.Get(path), true
}
return valuex.Nil, false
}
func (this gparams) MustLookup(path string) valuex.Accessor {
if accessor, ok := this.Lookup(path); ok {
return accessor
}
return valuex.Nil
}
func (this gparams) Any() any {
return this.gjson.Value()
}
func (this gparams) Ptr() any {
return nil
}
func (this gparams) Raw() reflect.Value {
return reflect.ValueOf(this.gjson)
}
func (this gparams) Value() string {
return this.gjson.Raw
}
// 设置json值
func (this *gparams) Set(p string, v any) bool {
if s, err := sjson.Set(this.Value(), p, v); err != nil {
return false
} else {
this.gjson = gjson.Parse(s)
}
return true
}
// 设置json原始值
func (this *gparams) SetValue(p, v string) bool {
if s, err := sjson.SetRaw(this.Value(), p, v); err != nil {
return false
} else {
this.gjson = gjson.Parse(s)
}
return true
}
// 删除路径内容
func (this *gparams) Delete(p string) bool {
if s, err := sjson.Delete(this.Value(), p); err != nil {
return false
} else {
this.gjson = gjson.Parse(s)
}
return true
}
// 路径包裹
func (this *gparams) Wrapped(p string) GlobalParams {
return this.AppendTo("", p)
}
// 包裹内容
func (this *gparams) AppendTo(root, p string) GlobalParams {
if root != "" && !gjson.Valid(root) {
root = ""
}
json := lo.Must(sjson.SetRaw(root, p, this.Value()))
return &gparams{
user: this.user,
session: this.session,
gjson: gjson.Parse(json),
}
}
// 判断值是否存在
func (this gparams) Exists() bool {
return this.gjson.Exists()
}
// to Bool
func (this gparams) Bool() bool {
return cast.ToBool(this.Any())
}
// to float64
func (this gparams) Float64() float64 {
return cast.ToFloat64(this.Any())
}
// to float32
func (this gparams) Float32() float32 {
return cast.ToFloat32(this.Any())
}
// to int64
func (this gparams) Int64() int64 {
return cast.ToInt64(this.Any())
}
// to int32
func (this gparams) Int32() int32 {
return cast.ToInt32(this.Any())
}
// to int16
func (this gparams) Int16() int16 {
return cast.ToInt16(this.Any())
}
// to int8
func (this gparams) Int8() int8 {
return cast.ToInt8(this.Any())
}
// to int
func (this gparams) Int() int {
return cast.ToInt(this.Any())
}
// to uint
func (this gparams) Uint() uint {
return cast.ToUint(this.Any())
}
// to uint64
func (this gparams) Uint64() uint64 {
return cast.ToUint64(this.Any())
}
// to uint32
func (this gparams) Uint32() uint32 {
return cast.ToUint32(this.Any())
}
// to uint16
func (this gparams) Uint16() uint16 {
return cast.ToUint16(this.Any())
}
// to uint8
func (this gparams) Uint8() uint8 {
return cast.ToUint8(this.Any())
}
// to string
func (this gparams) String() string {
return cast.ToString(this.Any())
}
// to map[string]string
func (this gparams) StringMapString() map[string]string {
return cast.ToStringMapString(this.Any())
}
// to map[string][]string
func (this gparams) StringMapStringSlice() map[string][]string {
return cast.ToStringMapStringSlice(this.Any())
}
// to map[string]bool
func (this gparams) StringMapBool() map[string]bool {
return cast.ToStringMapBool(this.Any())
}
// to map[string]int
func (this gparams) StringMapInt() map[string]int {
return cast.ToStringMapInt(this.Any())
}
// to map[string]int64
func (this gparams) StringMapInt64() map[string]int64 {
return cast.ToStringMapInt64(this.Any())
}
// to map[string]any
func (this gparams) StringMap() map[string]any {
return cast.ToStringMap(this.Any())
}
// to []any
func (this gparams) Slice() []any {
return cast.ToSlice(this.Any())
}
// to []bool
func (this gparams) BoolSlice() []bool {
return cast.ToBoolSlice(this.Any())
}
// to []string
func (this gparams) StringSlice() []string {
return cast.ToStringSlice(this.Any())
}
// to []int
func (this gparams) IntSlice() []int {
return cast.ToIntSlice(this.Any())
}
// NewGlobalParams 创建一个新的 GlobalParams 实例
// data 为 JSON 字符串, user 为当前用户
// 可选的 opts 用于设置会话等附加信息
func NewGlobalParams(data string, user User, opts ...gParamsOpt) GlobalParams {
g := &gparams{user: &user, gjson: gjson.Parse(data)}
for _, opt := range opts {
opt(g)
}
return g
}
-52
View File
@@ -1,52 +0,0 @@
package req
import "git.fsdpf.net/go/db"
type ResOptions struct {
Tx *db.TxDatabase
Alias string
Silent bool
// Permission 默认零值 0:不传 WithPermission 时,行级过滤和字段脱敏都跳过;显式传
// ResRow/ResMask(或组合 ResAll)才会开启对应的检查,两者相互独立。
Permission ResFlags
}
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
}
}
// WithPermission 显式指定这次写/查操作要开启哪些权限检查。ResRow 开启行级过滤(WHERE 注入),
// ResMask 开启字段级脱敏(读脱敏、写权限),两者可以按位组合传,也可以只传一个;不调用这个 option
// 时两者都跳过。变更通知、字段规范化、类型转换、默认值填充等不受这个 option 影响。
func WithPermission(f ResFlags) ResOption {
return func(p *ResOptions) {
p.Permission = f
}
}
type gParamsOpt func(g *gparams)
func Session(session string) gParamsOpt {
return func(g *gparams) {
g.session = session
}
}
Executable → Regular
+15 -80
View File
@@ -4,30 +4,19 @@ import (
"reflect" "reflect"
"git.fsdpf.net/go/db" "git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/exp"
"git.fsdpf.net/go/db/schema" "git.fsdpf.net/go/db/schema"
"git.fsdpf.net/go/reflux"
) )
type ResDataType string type ResDataType string
type ResAuthDB int
// ResFlags 是一组可以按位组合的权限检查开关,零值表示什么检查都不做。行级过滤
// (ResRow)和字段脱敏(ResMask)是两个独立维度,各自的 xxxRelations 位表示"连关联
// 资源一起处理",目前只保留位定义,还没接入判断逻辑。
type ResFlags int
const ( const (
// ResRow 行级权限过滤:按角色过滤能看到/操作的行,只处理当前资源自己 // 关闭权限过滤
ResRow ResFlags = 1 << iota ResAuthOff ResAuthDB = iota
// ResRowRelations 行级权限过滤扩展到关联资源(依附于 ResRow,单独设置无效果,尚未实现) // 开启权限过滤, 不包括关联资源
ResRowRelations ResAuthOn
// ResMask 字段级脱敏:按 ResField.GetRoles() 脱敏无权限查看/写入的字段,只处理当前资源自己 // 开启权限过滤, 包括关联资源
ResMask ResAuthAll
// ResMaskRelations 字段级脱敏扩展到关联资源(依附于 ResMask,单独设置无效果,尚未实现)
ResMaskRelations
// ResAll 常用组合:行级过滤 + 字段脱敏都开启(不含关联资源)
ResAll = ResRow | ResMask
) )
const ( const (
@@ -42,7 +31,6 @@ const (
ResTimestamp ResDataType = "timestamp" ResTimestamp ResDataType = "timestamp"
ResDate ResDataType = "date" ResDate ResDataType = "date"
ResDatetime ResDataType = "dateTime" ResDatetime ResDataType = "dateTime"
ResVector ResDataType = "vector"
) )
const ( const (
@@ -55,27 +43,22 @@ type Resource interface {
GetUuid() string GetUuid() string
GetName() string GetName() string
GetCode() string GetCode() string
GetDescription() string
GetPrimarykey() string GetPrimarykey() string
GetConn() string
GetFields() []ResField GetFields() []ResField
GetField(string) (ResField, bool) GetField(string) (ResField, bool)
HasField(string) bool HasField(string) bool
// GetTable 原始表名
GetTable() string
// GetTableExpr 可用于 FROM/JOIN 的表引用表达式
GetTableExpr() exp.Aliaseable
DB() *db.Database BeginTransaction() (*db.Transaction, error)
BeginTransaction() (*db.TxDatabase, error)
GetDBTable(User, ...ResOption) *db.SelectDataset
GetTable() db.Expression
GetDBConn() *db.Connection
GetDBBuilder() *db.Builder
GetDBTable(params ...any) *db.Builder
GetAuthDBTable(u User, params ...any) *db.Builder
GetStruct(extends ...reflect.StructField) any GetStruct(extends ...reflect.StructField) any
GetSliceStruct(extends ...reflect.StructField) any GetSliceStruct(extends ...reflect.StructField) any
// GetHistoryRoles 配置了变更留痕的角色列表,为空表示不留痕 WithRolesCondition(b *db.Builder, t string, u User) error
GetHistoryRoles() []string
// 是否虚拟资源 // 是否虚拟资源
IsVirtual() bool IsVirtual() bool
@@ -83,65 +66,17 @@ type Resource interface {
IsSystem() bool IsSystem() bool
} }
// ResVirtualTable 虚拟表接口,由业务层实现,框架通过 SQLite vtab 机制调用。
// Select 提供列表查询,Fetch 提供单条完整查询(含 Detail 补充字段),
// Insert/Update/Delete 支持写操作并自动同步缓存。
type ResVirtualTable interface {
GetResource() Resource
// Fetch 按主键获取单条完整数据,内部负责调用远端接口并补充 Detail 专属字段。
// 框架在 item cache miss 时调用,结果写入 item cache 供后续查询复用。
Fetch(pk any) (map[string]any, error)
// Detail 对已有的 item 数据进行补充,填充列表接口不返回的详情字段。
// changed=true 时框架会将修改后的数据回写 item cache。
Detail(item reflux.R) (changed bool, err error)
// Select 查询列表数据,filter 为过滤条件,pagesize/page 控制分页。
// 返回的 items 每项为 map[string]anytotal 为总记录数。
Select(filter reflux.R, pagesize, page int) (items []any, total int64, err error)
// Insert 新增一条记录,返回新记录的 rowid。
Insert(item reflux.R) (rowid int64, err error)
// Update 按 rowid 更新记录。
Update(rowid any, item reflux.R) error
// Delete 按 rowid 删除记录。
Delete(rowid any) error
}
// ResVirtualTableSetup 由 framework 在 sqlite_vtable build tag 下注册到容器,负责向 SQLite
// 注册虚拟表模块({res.GetTable()}_mod)。当前由上层在资源缓存构建时提前对所有 vtable 连接的资源
type ResVirtualTableSetup func(res Resource) error
// GetResource 按 code 或 uuid 查找已注册的资源,找不到返回 ok=false。放在 req 而不是 contracts
// 是为了让 req 自己的子包(比如 resource 包)也能通过 DI 拿到查找能力,不用反过来 import
// contractscontracts 依赖 reqimport 反过来会成环)。contracts.GetResource 是它的类型别名,
// 兼容原有引用。
type GetResource func(code string) (Resource, bool)
// MustResource 按 code 或 uuid 查找已注册的资源,找不到时由具体实现负责报错(通常是 panic)。
type MustResource func(code string) Resource
type ResField interface { type ResField interface {
GetName() string GetName() string
GetCode() string GetCode() string
GetCodeResource() string GetCodeResource() string
GetDataType() ResDataType GetDataType() ResDataType
GetQueryDataType() RouteParamType GetQueryDataType() RouteParamType
GetRawDefault() db.Expression GetRawDefault(driver string) db.Expression
ToStructField(tags ...string) reflect.StructField ToStructField(tags ...string) reflect.StructField
ToValue(any) any ToValue(any) any
ToBlueprint(table *schema.Blueprint) *schema.ColumnDefinition ToBlueprint(table *schema.Blueprint) *schema.ColumnDefinition
ToQueryField(t RouteParamType, alias string, options byte) QueryField ToQueryField(t RouteParamType, alias string, options byte) QueryField
IsVirtual() bool
// GetRoles 配置了"能读写该字段真实值"的角色列表,为空表示不限制(所有角色都能读写真实值);
// 非空时,用户所属角色只要有一个在列表里就有权限,否则:查询时这个字段会被替换成脱敏哨兵值
// (见 resource 包的 MaskFunc),写入时这个字段会被静默丢弃。注意这跟 GetHistoryRoles 等
// "空=没人有权限"的既有约定相反:这是后加的限制能力,必须保证未配置时不影响任何现有字段的
// 读写行为。
GetRoles() []string
} }
type QueryField interface { type QueryField interface {
-126
View File
@@ -1,126 +0,0 @@
package resx
import (
"database/sql"
"github.com/samber/lo"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/reflux"
"git.fsdpf.net/go/req"
)
// ResEventType 资源操作类型:读(SELECT+ 写(INSERT/UPDATE/DELETE
type ResEventType string
const (
ResEventSelect ResEventType = "SELECT"
ResEventInsert ResEventType = "INSERT"
ResEventUpdate ResEventType = "UPDATE"
ResEventDelete ResEventType = "DELETE"
)
// ResChangeEvent 描述一次写操作触发的变更:类型、资源、用户、操作结果,不对应具体某一行数据。
// 只对应写操作,Type() 恒为 INSERT/UPDATE/DELETE 之一,不会是 ResEventSelect。
type ResChangeEvent interface {
Type() ResEventType // INSERT | UPDATE | DELETE
Res() req.Resource // 触发这次变更的资源
User() req.User // 触发这次变更的用户
Result() sql.Result // 本次写操作的执行结果
}
// resChangeEvent 是 ResChangeEvent 的具体实现
type resChangeEvent struct {
typ ResEventType
res req.Resource
user req.User
result sql.Result
}
func (e resChangeEvent) Type() ResEventType { return e.typ }
func (e resChangeEvent) Res() req.Resource { return e.res }
func (e resChangeEvent) User() req.User { return e.user }
func (e resChangeEvent) Result() sql.Result { return e.result }
// NewResChangeEvent 构造一个 ResChangeEvent
func NewResChangeEvent(typ ResEventType, res req.Resource, u req.User, result sql.Result) ResChangeEvent {
return resChangeEvent{typ: typ, res: res, user: u, result: result}
}
// ResChangeRow 描述一次资源写操作中某一行的变更内容。只对应写操作,Type() 恒为
// INSERT/UPDATE/DELETE 之一,不会是 ResEventSelect。
type ResChangeRow interface {
Type() ResEventType // INSERT | UPDATE | DELETE
Res() req.Resource // 触发这次变更的资源
User() req.User // 触发这次变更的用户
ID() int64 // 行主键(INSERT 时也是已经算好的自增 id)
Old() reflux.R // 变更前快照,INSERT 时为 reflux.Nil
New() reflux.R // 本次写入值,DELETE 时为 reflux.Nil
Cur() reflux.R // Old 与 New 合并后的视图(New 覆盖 Old 同名字段)
// Tx 与原始写操作共享的事务,仅当调用方要写入的资源与原始资源使用同一个数据库连接时才可复用。
Tx() *db.TxDatabase
}
type resChangeRow struct {
typ ResEventType
res req.Resource
user req.User
id int64
old map[string]any
new map[string]any
tx *db.TxDatabase
}
func (r resChangeRow) Type() ResEventType { return r.typ }
func (r resChangeRow) Res() req.Resource { return r.res }
func (r resChangeRow) User() req.User { return r.user }
func (r resChangeRow) ID() int64 { return r.id }
func (r resChangeRow) Tx() *db.TxDatabase { return r.tx }
func (r resChangeRow) Old() reflux.R {
if len(r.old) == 0 {
return reflux.Nil
}
return reflux.New(r.old)
}
func (r resChangeRow) New() reflux.R {
if len(r.new) == 0 {
return reflux.Nil
}
return reflux.New(r.new)
}
func (r resChangeRow) Cur() reflux.R {
if len(r.old) == 0 && len(r.new) == 0 {
return reflux.Nil
}
return reflux.New(lo.Assign(r.old, r.new))
}
// ResChangeRowOption 用于设置 NewResChangeRow 里非必填的字段
type ResChangeRowOption func(r *resChangeRow)
// WithOld 设置变更前快照,不设置则为 reflux.Nil
func WithOld(old map[string]any) ResChangeRowOption {
return func(r *resChangeRow) { r.old = old }
}
// WithNew 设置本次写入值,不设置则为 reflux.Nil
func WithNew(new map[string]any) ResChangeRowOption {
return func(r *resChangeRow) { r.new = new }
}
// WithTx 设置与原始写操作共享的事务,不设置则为 nil
func WithTx(tx *db.TxDatabase) ResChangeRowOption {
return func(r *resChangeRow) { r.tx = tx }
}
// NewResChangeRow 构造一个 ResChangeRow
func NewResChangeRow(typ ResEventType, res req.Resource, u req.User, id int64, opts ...ResChangeRowOption) ResChangeRow {
r := resChangeRow{typ: typ, res: res, user: u, id: id}
for _, opt := range opts {
opt(&r)
}
return r
}
-355
View File
@@ -1,355 +0,0 @@
package resx
import (
"encoding/json"
"fmt"
"reflect"
"strconv"
"strings"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/schema"
"git.fsdpf.net/go/req"
"github.com/spf13/cast"
)
// ResField 是 req.ResField 的具体实现,也是 QueryField 的底子(QueryField 匿名嵌入它)。
// 原来放在 contracts-v2/base,这里搬过来是因为 resx 自己(MaskField 的懒解析、QueryField.ToSql
// 的字段级脱敏判断)需要用到它,而 resx 不能反过来 import contracts/basecontracts/base 依赖
// contracts 根包,根包又依赖 resx,会成环)。contracts-v2/base.ResField 现在是指向这里的类型
// 别名,兼容原有引用。
//
// 字段全部私有,只能通过 NewResField(opts...) + FieldWith* 构造——避免调用方绕开
// GetRoles()/ToValue() 等方法直接摆弄底层数据。JSON 序列化/反序列化(资源缓存从数据库
// JSON_ARRAYAGG 结果里加载字段配置,见 framework-v2 的 init_res_cache.go)靠手写的
// MarshalJSON/UnmarshalJSON 维持原来的 wire 格式,不依赖 encoding/json 对导出字段的默认反射。
type ResField struct {
uuid string
name string
code string
codeResource string
dataType req.ResDataType
length string
comment string
defaultValue string
virtual bool
virtualExpr string
// roles 配置了"能读写该字段真实值"的角色列表,为空表示不限制。见 req.ResField.GetRoles 注释。
roles []string
}
// NewResField 创建一个 ResField。code/codeResource 是必填的位置参数(每一个字段都要设置,不适合
// 当可选项);name 不显式用 FieldWithName 设置的话默认等于 codeuuid 不显式用 FieldWithUuid 设置
// 的话默认是 codeResource+code——这两个字段在测试和一些一次性计算字段的场景里经常不关心具体取值,
// 只是不能为空,给个确定性的默认值省得每次都要传。dataType 放进 FieldWithDataType 而不是位置参数:
// 很多调用点(临时拼一个查询字段、只关心 Code/CodeResource)并不知道或不关心具体数据类型,当位置
// 参数的话每次都要传个占位值,不如做成可选项。FieldWith* 选项定义在 res_field_option.go。
func NewResField(code, codeResource string, opts ...ResFieldOption) req.ResField {
f := ResField{
code: code,
codeResource: codeResource,
name: code,
uuid: codeResource + code,
}
for _, opt := range opts {
opt(&f)
}
return f
}
func (this ResField) ToStructField(tags ...string) reflect.StructField {
var typ reflect.Type
fCode := this.code
fTag := `db:"` + fCode + `" json:"` + fCode + `"`
if len(tags) > 0 {
fTag = strings.Join(tags, " ") + " " + fTag
}
switch this.dataType {
case req.ResString, req.ResText, req.ResEnum,
req.ResTimestamp, req.ResDate, req.ResDatetime:
typ = reflect.TypeOf(string(""))
case req.ResInteger, req.ResSmallInteger:
typ = reflect.TypeOf(int64(0))
case req.ResDecimal:
typ = reflect.TypeOf(float64(0))
case req.ResBoolean:
typ = reflect.TypeOf(true)
case req.ResJson:
if this.defaultValue != "" && this.defaultValue[0:1] == "[" {
typ = reflect.TypeOf([]any{})
} else {
typ = reflect.TypeOf(map[string]any{})
}
case req.ResVector:
typ = reflect.TypeOf([]float64{})
}
return reflect.StructField{
Name: strings.ToUpper(fCode[:1]) + fCode[1:],
Tag: reflect.StructTag(fTag),
Type: typ,
}
}
func (this ResField) IsVirtual() bool {
return this.virtual
}
func (this ResField) GetRoles() []string {
return this.roles
}
func (this ResField) GetUuid() string {
return this.uuid
}
func (this ResField) GetCode() string {
return this.code
}
func (this ResField) GetCodeResource() string {
return this.codeResource
}
func (this ResField) GetName() string {
return this.name
}
func (this ResField) GetDataType() req.ResDataType {
return this.dataType
}
func (this ResField) GetLength() string {
return this.length
}
func (this ResField) GetComment() string {
return this.comment
}
func (this ResField) GetDefault() string {
return this.defaultValue
}
func (this ResField) GetVirtualExpr() string {
return this.virtualExpr
}
func (this ResField) GetQueryDataType() req.RouteParamType {
switch this.GetDataType() {
case req.ResEnum,
req.ResTimestamp, req.ResDate,
req.ResDatetime, req.ResString,
req.ResText:
return req.ReqString
case req.ResInteger, req.ResSmallInteger:
return req.ReqInteger
case req.ResDecimal:
return req.ReqFloat
case req.ResBoolean:
return req.ReqBool
case req.ResJson:
if this.defaultValue != "" && this.defaultValue[0:1] == "[" {
return req.ReqArray
}
return req.ReqJson
case req.ResVector:
return req.ReqArray
}
return req.ReqString
}
func (this ResField) ToValue(v any) any {
if this.dataType == req.ResJson {
if v == nil {
if this.defaultValue != "" && this.defaultValue[0:1] == "[" {
return db.V("[]")
} else if this.defaultValue != "" && this.defaultValue[0:1] == "{" {
return db.V("{}")
} else if this.defaultValue == "" {
return db.V("{}")
}
return this.GetRawDefault()
}
if str, ok := v.(string); ok {
return str
} else if raw, ok := v.(db.Expression); ok {
return raw
} else if b, err := json.Marshal(v); err == nil {
return string(b)
} else {
panic(fmt.Sprintf("%s, 类型转换错误, %s", this.code, err))
}
}
if this.dataType == req.ResVector {
if v == nil {
return db.V(nil)
}
var parts []string
switch floats := v.(type) {
case []float32:
parts = make([]string, len(floats))
for i, f := range floats {
parts[i] = strconv.FormatFloat(float64(f), 'g', -1, 32)
}
case []float64:
parts = make([]string, len(floats))
for i, f := range floats {
parts[i] = strconv.FormatFloat(f, 'g', -1, 64)
}
default:
return v
}
return db.L("[" + strings.Join(parts, ", ") + "]")
}
return v
}
func (this ResField) GetRawDefault() db.Expression {
if this.dataType == req.ResJson {
if this.defaultValue != "" && this.defaultValue[0:1] == "[" {
return db.V("[]")
} else if this.defaultValue != "" && this.defaultValue[0:1] == "{" {
return db.V("{}")
} else if this.defaultValue == "" {
return db.V("{}")
}
} else if this.dataType == req.ResBoolean {
if v, _ := strconv.ParseBool(this.defaultValue); v {
return db.V(true)
}
return db.V(false)
}
if len(this.defaultValue) > 4 && strings.ToLower(this.defaultValue[0:4]) == "sql:" {
return db.L(this.defaultValue[4:])
}
if this.defaultValue == "" {
if this.GetDataType() == req.ResDate || this.GetDataType() == req.ResDatetime {
return db.V(nil)
}
return db.V("")
}
if strings.ToUpper(this.defaultValue) == "CURRENT_TIMESTAMP" {
return db.L(this.defaultValue)
}
return db.V(this.defaultValue)
}
func (this ResField) ToBlueprint(table *schema.Blueprint) (temp *schema.ColumnDefinition) {
switch this.code {
case "id":
return table.BigIncrements("id").AutoIncrement().Comment("ID")
case "enabled":
return table.Boolean("enabled").Default("1").Comment("是否有效")
case "created_user":
return table.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("创建者")
case "owned_user":
return table.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者")
case "created_at":
return table.Timestamp("created_at").UseCurrent().Comment("创建时间")
case "updated_at":
return table.Timestamp("updated_at").UseCurrent().Default(db.L("ON UPDATE CURRENT_TIMESTAMP")).Comment("更新时间")
case "deleted_at":
return table.DateTime("deleted_at").Nullable().Comment("删除时间")
}
isNull := false
comment := this.name
def := any(this.defaultValue)
if this.comment != "" {
comment += " [ " + strings.Trim(this.comment, `' "`) + " ]"
}
switch this.dataType {
case "string":
len := 255
if v, err := strconv.Atoi(strings.Trim(this.length, `' "`)); err == nil {
len = v
}
temp = table.String(this.code, len)
case "smallInteger":
// integer 默认长度 4
temp = table.SmallInteger(this.code)
case "boolean":
// integer 默认长度 1
temp = table.Boolean(this.code)
case "integer":
// integer 默认长度 11
temp = table.Integer(this.code)
case "date", "dateTime", "timestamp":
if this.dataType == "date" {
temp = table.Date(this.code)
} else {
temp = table.DateTime(this.code)
}
if strings.ToUpper(this.defaultValue) == "SQL:CURRENT_TIMESTAMP" {
def = db.L("CURRENT_TIMESTAMP")
} else if def == "" {
isNull = true
}
case "decimal":
allowed := strings.SplitN(this.length, ",", 2)
total := 8
places := 2
if v, err := strconv.Atoi(strings.Trim(allowed[0], `' "`)); err == nil {
total = v
}
if v, err := strconv.Atoi(strings.Trim(allowed[1], `' "`)); err == nil {
places = v
}
temp = table.Decimal(this.code, total, places)
case "enum":
allowed := []string{}
for _, v := range strings.Split(this.length, ",") {
allowed = append(allowed, strings.Trim(v, `' "`))
}
temp = table.Enum(this.code, allowed)
case "json":
temp = table.Json(this.code)
isNull = true
case "text":
temp = table.Text(this.code)
isNull = true
case "vector":
temp = table.Vector(this.code, cast.ToInt(this.length))
isNull = false
default:
panic(fmt.Sprintf("Unknown type: %s", this.dataType))
}
if this.IsVirtual() {
temp.VirtualAs(this.virtualExpr)
}
if isNull {
temp.Nullable()
} else {
temp.Default(def)
}
temp.Comment(comment)
return temp
}
func (this ResField) ToQueryField(t req.RouteParamType, alias string, options byte) req.QueryField {
o := &QueryField{
ResField: this,
typ: t,
alias: alias,
}
return o.SetOptions(options)
}
-51
View File
@@ -1,51 +0,0 @@
package resx
import "git.fsdpf.net/go/req"
// ResFieldOption 是 NewResField 的选项函数类型。
type ResFieldOption func(*ResField)
// FieldWithUuid 设置字段的 uuid,不设置默认是 codeResource+code。
func FieldWithUuid(v string) ResFieldOption {
return func(f *ResField) { f.uuid = v }
}
// FieldWithName 设置字段的显示名称,不设置默认等于 code。
func FieldWithName(v string) ResFieldOption {
return func(f *ResField) { f.name = v }
}
// FieldWithDataType 设置字段的数据类型。
func FieldWithDataType(v req.ResDataType) ResFieldOption {
return func(f *ResField) { f.dataType = v }
}
// FieldWithLength 设置字段的长度/精度配置(含义因 DataType 而异,见 ToBlueprint)。
func FieldWithLength(v string) ResFieldOption {
return func(f *ResField) { f.length = v }
}
// FieldWithComment 设置字段的数据库列注释。
func FieldWithComment(v string) ResFieldOption {
return func(f *ResField) { f.comment = v }
}
// FieldWithDefault 设置字段的默认值原始配置("sql:xxx" 前缀表示 SQL 表达式,见 GetRawDefault)。
func FieldWithDefault(v string) ResFieldOption {
return func(f *ResField) { f.defaultValue = v }
}
// FieldWithVirtual 设置字段是否是虚拟列(VirtualAs)。
func FieldWithVirtual(v bool) ResFieldOption {
return func(f *ResField) { f.virtual = v }
}
// FieldWithVirtualExpr 设置虚拟列的表达式(仅在 FieldWithVirtual(true) 时有意义)。
func FieldWithVirtualExpr(v string) ResFieldOption {
return func(f *ResField) { f.virtualExpr = v }
}
// FieldWithRoles 设置"能读写该字段真实值"的角色列表,见 req.ResField.GetRoles 注释。
func FieldWithRoles(roles ...string) ResFieldOption {
return func(f *ResField) { f.roles = roles }
}
-929
View File
@@ -1,929 +0,0 @@
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 注释
skipRowFilter bool // ResOptions.Permission 不含 ResRow:跳过行级权限过滤,见 req.WithPermission 注释
skipFieldMask bool // ResOptions.Permission 不含 ResMask:跳过字段级脱敏/写权限,见 req.WithPermission 注释
}
// 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,返回角色权限过滤条件,并记录本次写完后要不要调用、调用谁。
// skipRowFilter 时丢弃行级权限过滤条件;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.skipRowFilter {
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 各行的自增 idMySQL: 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.skipFieldMask && !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.skipFieldMask {
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 里有没有任何一个配置了 RoleshasRestrictedFields 对当前资源自己
// 和每个能解析到的 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 函数参数没法单独起别名。
//
// 目前只递归处理 SQLFunctionExpressiondb.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.ResFieldcodeResource
// 为空表示当前资源自己的字段,直接用 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() 直接就是原始 identifierres.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
}
-699
View File
@@ -1,699 +0,0 @@
package resx_test
import (
"database/sql"
"errors"
"fmt"
"testing"
"time"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/reflux"
"git.fsdpf.net/go/req"
"git.fsdpf.net/go/req/resx"
"git.fsdpf.net/go/req/userx"
"github.com/DATA-DOG/go-sqlmock"
"github.com/samber/do/v2"
"github.com/stretchr/testify/suite"
_ "git.fsdpf.net/go/db/dialect/mysql"
_ "git.fsdpf.net/go/db/dialect/sqlite3"
_ "git.fsdpf.net/go/db/schema/dialect/sqlite3"
)
// hooksTest 用 sqlmock 验证 dataProcessor 的钩子编排:原来的 RolesConditionFunc(权限过滤)、
// NeedsChangeCaptureFunc(是否需要抓取)、AfterChangeFunc(变更内容)、OnWriteFunc(无条件触发)
// 四块能力现在是 DataInterceptor(权限过滤 + 是否需要抓取)+ ResChangeEventFunc(无条件触发)+
// ResChangeRowFunc(变更内容,仅需要抓取时调用),这里按原来的四块能力分别验证。
type hooksTest struct {
suite.Suite
}
func TestResourceHooks(t *testing.T) {
suite.Run(t, new(hooksTest))
}
// newResource 构造一个绑定了 sqlmock 的 User 资源,interceptor/event 为 nil 表示不注册对应钩子。
func (t *hooksTest) newResource(
interceptor resx.DataInterceptor,
event resx.ResChangeEventFunc,
) (req.Resource, sqlmock.Sqlmock) {
mockDB, mock, err := sqlmock.New()
t.Require().NoError(err)
app := do.New()
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
if interceptor != nil {
do.Provide(app, func(i do.Injector) (resx.DataInterceptor, error) {
return interceptor, nil
})
}
if event != nil {
do.Provide(app, func(i do.Injector) (resx.ResChangeEventFunc, error) {
return event, nil
})
}
res := resx.New(app, "User", "users",
resx.WithUuid("4bf3a311-cbe1-4236-bdda-c64ab04ae9b1"),
resx.WithConn("default"),
resx.WithFields(
resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
),
)
return res, mock
}
// -------- 原 RolesConditionFunc:权限过滤 --------
// TestOnBefore_RolesCondition_Cond 权限条件直接拼进 WHERE
func (t *hooksTest) TestOnBefore_RolesCondition_Cond() {
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
t.Equal(resx.ResEventSelect, category, "SELECT 场景的 category 是 ResEventSelect")
return nil, db.C("owned_user").Eq(u.Uuid()), nil
},
nil,
)
// 实际生成的 SQL
// SELECT * FROM `users` AS `User` WHERE (`owned_user` = '00000000-0000-0000-0000-000000000000')
sql, _, _ := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "owned_user")
t.Contains(sql, "00000000-0000-0000-0000-000000000000")
mock.ExpectQuery("SELECT").
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "张三"))
rows, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Select(db.Star()).Executor().GetRecords()
t.NoError(err)
t.Len(rows, 1)
t.NoError(mock.ExpectationsWereMet())
}
// TestOnBefore_RolesCondition_Sub 权限条件以子查询的形式重写 FROM
func (t *hooksTest) TestOnBefore_RolesCondition_Sub() {
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
sub := res.DB().From(res.GetTableExpr().As(res.GetCode())).
Select(db.Star()).
Where(db.C("owned_user").Eq(u.Uuid()))
return sub, nil, nil
},
nil,
)
// 实际生成的 SQL
// SELECT * FROM (SELECT * FROM `users` AS `User` WHERE (`owned_user` = '00000000-0000-0000-0000-000000000000')) AS `User`
sql, _, _ := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "owned_user")
mock.ExpectQuery("SELECT").
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(2, "李四"))
rows, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Select(db.Star()).Executor().GetRecords()
t.NoError(err)
t.Len(rows, 1)
t.NoError(mock.ExpectationsWereMet())
}
// -------- 原 NeedsChangeCaptureFunc:是否需要抓取快照/变更内容 --------
// TestOnBefore_NeedsCapture_True 判定需要抓取时,UPDATE 前会多一次快照查询,ResChangeRowFunc 拿到完整的变更行;
// onChange 非 nil 时写操作还会被自动包一层事务(见 UseTx)
func (t *hooksTest) TestOnBefore_NeedsCapture_True() {
var gotRows []resx.ResChangeRow
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
if category != resx.ResEventUpdate {
return nil, nil, nil
}
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
},
nil,
)
// onChange 非 nil,写操作会被自动包一层事务
mock.ExpectBegin()
// 抓取快照的前置 SELECT,实际生成的 SQL:
// SELECT * FROM `users` AS `User` WHERE (`id` = 1)
mock.ExpectQuery("SELECT").
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "旧名字"))
// 实际 UPDATE,实际生成的 SQL:
// UPDATE `users` AS `User` SET `name`='新名字' WHERE (`id` = 1)
mock.ExpectExec("UPDATE").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Update().
Set(db.Record{"name": "新名字"}).
Where(db.C("id").Eq(1)).
Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.Require().Len(gotRows, 1)
t.Equal(resx.ResEventUpdate, gotRows[0].Type())
t.EqualValues(1, gotRows[0].ID())
t.Equal("旧名字", gotRows[0].Old().Get("name").String())
t.Equal("新名字", gotRows[0].New().Get("name").String())
}
// TestOnBefore_NeedsCapture_False 判定不需要抓取时,不会有额外的快照查询,也不会自动开事务;
// ResChangeEventFunc 依然无条件调用,但 ResChangeRowFunc 完全不会被调用
func (t *hooksTest) TestOnBefore_NeedsCapture_False() {
var eventCalled bool
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
return nil, nil, nil
},
func(event resx.ResChangeEvent) error {
eventCalled = true
return nil
},
)
// 没有安排快照查询、没有 Begin:如果 Before 阶段多发起一次 SELECT 或事务,这里会因为顺序对不上而报错。
// 实际生成的 SQLUPDATE `users` AS `User` SET `name`='新名字' WHERE (`id` = 1)
mock.ExpectExec("UPDATE").WillReturnResult(sqlmock.NewResult(0, 1))
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Update().
Set(db.Record{"name": "新名字"}).
Where(db.C("id").Eq(1)).
Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.True(eventCalled, "ResChangeEventFunc 不受 needsCapture 影响,应该无条件调用")
}
// -------- 原 AfterChangeFunc:变更内容 --------
// TestOnAfter_RowsContent_Insert 验证 ResChangeRowFunc 收到的 INSERT 变更行内容:Type/Res/ID/Old/New/Tx
func (t *hooksTest) TestOnAfter_RowsContent_Insert() {
var gotRows []resx.ResChangeRow
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
if category != resx.ResEventInsert {
return nil, nil, nil
}
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
},
nil,
)
// onChange 非 nil,写操作会被自动包一层事务
mock.ExpectBegin()
// 实际生成的 SQL
// INSERT INTO `users` (`created_user`, `name`, `owned_user`) VALUES ('00000000-0000-0000-0000-000000000000', '张三', '00000000-0000-0000-0000-000000000000')
mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(10, 1))
mock.ExpectCommit()
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().
Rows(db.Record{"name": "张三"}).
Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.Require().Len(gotRows, 1)
row := gotRows[0]
t.Equal(resx.ResEventInsert, row.Type())
t.Equal("User", row.Res().GetCode())
t.EqualValues(10, row.ID())
t.Equal(reflux.Nil, row.Old(), "INSERT 没有变更前快照")
t.Equal("张三", row.New().Get("name").String())
t.NotNil(row.Tx(), "onChange 非 nil 时会自动开启事务,Tx() 应该能拿到它")
}
// -------- 原 OnWriteFunc:无条件触发 --------
// TestOnAfter_UnconditionalOnWrite ResChangeEventFunc 不受 needsCapture 门槛限制,每次写操作成功后都会调用
func (t *hooksTest) TestOnAfter_UnconditionalOnWrite() {
var afterCalled bool
var gotResult sql.Result
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
return nil, nil, nil // 不需要留痕
},
func(event resx.ResChangeEvent) error {
afterCalled = true
gotResult = event.Result()
return nil
},
)
// 实际生成的 SQLDELETE FROM `users` AS `User` WHERE (`id` = 1)
mock.ExpectExec("DELETE").WillReturnResult(sqlmock.NewResult(0, 3))
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Delete().
Where(db.C("id").Eq(1)).
Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.True(afterCalled, "ResChangeEventFunc 应该无条件触发,不受 needsCapture 门槛限制")
affected, _ := gotResult.RowsAffected()
t.EqualValues(3, affected)
}
// -------- INSERT ... SELECT:写入前拿不到具体行数据,写入后按 id 反查补全 --------
// TestInsertFromQuery_FetchesInsertedRowsAfterward beforeInsertFromQuery 是空实现,
// 真正的内容在 After 阶段:rh.rows 为空触发 fetchInsertedRows,按写入后拿到的自增 id 反查补全。
// fetchInsertedRows 走的是独立的 GetDBTable 连接,不在自动开启的事务里,所以顺序是
// Begin -> INSERT -> 反查 SELECT -> Commit。
func (t *hooksTest) TestInsertFromQuery_FetchesInsertedRowsAfterward() {
var gotRows []resx.ResChangeRow
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
if category != resx.ResEventInsert {
return nil, nil, nil
}
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
},
nil,
)
sub := res.DB().From(db.T("legacy_users")).Select(db.Star())
mock.ExpectBegin()
// 实际生成的 SQLINSERT INTO `users` SELECT * FROM `legacy_users`
mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(10, 2))
// finalizeInsertedRows 反查补全,实际生成的 SQL:
// SELECT * FROM `users` AS `User` WHERE (`id` IN (10, 11))
mock.ExpectQuery("SELECT").WillReturnRows(
sqlmock.NewRows([]string{"id", "name"}).AddRow(10, "张三").AddRow(11, "李四"),
)
mock.ExpectCommit()
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().FromQuery(sub).Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.Require().Len(gotRows, 2)
ids := []int64{gotRows[0].ID(), gotRows[1].ID()}
t.ElementsMatch([]int64{10, 11}, ids)
names := []string{gotRows[0].New().Get("name").String(), gotRows[1].New().Get("name").String()}
t.ElementsMatch([]string{"张三", "李四"}, names)
}
// -------- 批量 INSERT 的自增 id 分配 --------
// TestBatchInsertIds_Mysql mysql: lastId 是第一条,后续行 id 依次递增
func (t *hooksTest) TestBatchInsertIds_Mysql() {
var gotRows []resx.ResChangeRow
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
},
nil,
)
mock.ExpectBegin()
// 实际生成的 SQL
// INSERT INTO `users` (`created_user`, `name`, `owned_user`) VALUES
// ('00000000-0000-0000-0000-000000000000', 'A', '00000000-0000-0000-0000-000000000000'),
// ('00000000-0000-0000-0000-000000000000', 'B', '00000000-0000-0000-0000-000000000000'),
// ('00000000-0000-0000-0000-000000000000', 'C', '00000000-0000-0000-0000-000000000000')
mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(100, 3))
mock.ExpectCommit()
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().Rows(
db.Record{"name": "A"}, db.Record{"name": "B"}, db.Record{"name": "C"},
).Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.Require().Len(gotRows, 3)
t.EqualValues(100, gotRows[0].ID())
t.EqualValues(101, gotRows[1].ID())
t.EqualValues(102, gotRows[2].ID())
}
// TestBatchInsertIds_LocalDB sqlite3/duckdb 等本地库:lastId 是最后一条,需要倒推前面几条的 id。
// 这里用真实的内存 sqlite3 而不是 sqlmockDB() 现在会自动建表(见 resx.go 的
// autoCreateTable),sqlmock 没法真的执行 CREATE TABLE/INSERT,所以改用真实 SQLite 让建表和
// 插入都按真实语义跑一遍。连接名用独立的(不用 "default"),避免和其它用例共享 engine 包内部
// 的全局连接缓存。onChange 非 nil 会自动 Begin/Commit 一个真实事务,无需额外 mock。
func (t *hooksTest) TestBatchInsertIds_LocalDB() {
// engine 的连接缓存是包级单例,按名字缓存 *sql.DB;连接名必须每次唯一,
// 否则 -count=N 重跑时会复用上一轮建好表、插过数据的同一个内存库,自增 id 对不上
connName := fmt.Sprintf("test_batch_insert_local_db_%d", time.Now().UnixNano())
app := do.New()
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
return engine.Open(map[string]engine.DBConfig{
connName: engine.NewDBConfig("sqlite3", engine.WithSQLiteFile(":memory:")),
}), nil
})
var gotRows []resx.ResChangeRow
do.Provide(app, func(i do.Injector) (resx.DataInterceptor, error) {
return func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
}, nil
})
// id/created_user/owned_user 是 autoCreateTable 建表、以及 applyDefaultValue 无条件填充
// 用到的系统字段,真实建表场景下必须显式声明,否则真实 INSERT 会报"列不存在"
res := resx.New(app, "User", "users",
resx.WithConn(connName),
resx.WithFields(
resx.NewResField("id", "User", resx.FieldWithName("ID"), resx.FieldWithDataType(req.ResInteger)),
resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("created_user", "User", resx.FieldWithName("创建者"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("owned_user", "User", resx.FieldWithName("拥有者"), resx.FieldWithDataType(req.ResString)),
),
)
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().Rows(
db.Record{"name": "A"}, db.Record{"name": "B"}, db.Record{"name": "C"},
).Executor().Exec()
t.Require().NoError(err)
// 真实自增:一条 INSERT 里的 lastId 是最后一条(3),insertRowIds 按 isLocalDB 分支倒推出 1/2/3
t.Require().Len(gotRows, 3)
t.EqualValues(1, gotRows[0].ID())
t.EqualValues(2, gotRows[1].ID())
t.EqualValues(3, gotRows[2].ID())
}
// -------- UPDATE/DELETE 权限过滤的子查询改写 --------
// TestUpdate_RolesConditionSub 权限条件以子查询形式重写 WHEREUser.id IN (子查询)
func (t *hooksTest) TestUpdate_RolesConditionSub() {
res, _ := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
sub := res.DB().From(res.GetTableExpr().As(res.GetCode())).
Select(db.Star()).
Where(db.C("owned_user").Eq(u.Uuid()))
return sub, nil, nil
},
nil,
)
// 实际生成的 SQL
// UPDATE `users` AS `User` SET `name`='新名字' WHERE ((`id` = 1) AND (`id` IN ((SELECT `User`.`id` FROM `users` AS `User` WHERE (`owned_user` = '00000000-0000-0000-0000-000000000000')))))
sql, _, _ := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Update().
Set(db.Record{"name": "新名字"}).
Where(db.C("id").Eq(1)).
Executor().ToSQL()
t.Contains(sql, "`id` IN ((SELECT")
t.Contains(sql, "owned_user")
// 原有的 WHERE 条件依然保留
t.Contains(sql, "`id` = 1")
}
// TestDelete_RolesConditionSub DELETE 场景下同样会被改写成子查询形式
func (t *hooksTest) TestDelete_RolesConditionSub() {
res, _ := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
sub := res.DB().From(res.GetTableExpr().As(res.GetCode())).
Select(db.Star()).
Where(db.C("owned_user").Eq(u.Uuid()))
return sub, nil, nil
},
nil,
)
// 实际生成的 SQL
// DELETE FROM `users` AS `User` WHERE ((`id` = 1) AND (`id` IN ((SELECT `User`.`id` FROM `users` AS `User` WHERE (`owned_user` = '00000000-0000-0000-0000-000000000000')))))
sql, _, _ := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Delete().
Where(db.C("id").Eq(1)).
Executor().ToSQL()
t.Contains(sql, "`id` IN ((SELECT")
t.Contains(sql, "owned_user")
t.Contains(sql, "`id` = 1")
}
// -------- DELETE 的快照捕获 --------
// TestDelete_CapturesSnapshot 判定需要抓取时,DELETE 前会先查一次快照,ResChangeRowFunc 能拿到被删行的 Old()
func (t *hooksTest) TestDelete_CapturesSnapshot() {
var gotRows []resx.ResChangeRow
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
if category != resx.ResEventDelete {
return nil, nil, nil
}
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
},
nil,
)
mock.ExpectBegin()
// 抓取快照的前置 SELECT,实际生成的 SQL:
// SELECT * FROM `users` AS `User` WHERE (`id` = 5)
mock.ExpectQuery("SELECT").
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(5, "旧值"))
// 实际 DELETE,实际生成的 SQLDELETE FROM `users` AS `User` WHERE (`id` = 5)
mock.ExpectExec("DELETE").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Delete().
Where(db.C("id").Eq(5)).
Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.Require().Len(gotRows, 1)
t.Equal(resx.ResEventDelete, gotRows[0].Type())
t.EqualValues(5, gotRows[0].ID())
t.Equal("旧值", gotRows[0].Old().Get("name").String())
}
// -------- Cols/Vals 插入路径 --------
// TestInsertColsVals_Capture Cols/Vals 路径同样支持 capture,能拿到本次写入的字段值
func (t *hooksTest) TestInsertColsVals_Capture() {
var gotRows []resx.ResChangeRow
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
if category != resx.ResEventInsert {
return nil, nil, nil
}
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
},
nil,
)
mock.ExpectBegin()
// 实际生成的 SQL
// INSERT INTO `users` (`name`, `owned_user`, `created_user`) VALUES ('赵六', '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000')
mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectCommit()
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().Cols("name").Vals(
db.Vals{"赵六"},
).Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.Require().Len(gotRows, 1)
t.Equal("赵六", gotRows[0].New().Get("name").String())
}
// TestInsertColsVals_DoesNotStripSystemColumns Cols/Vals 路径不会调用 normalizeSaveValue
// 调用方显式把 created_user 放进 Cols 时不会像 Rows() 路径那样被剔除/改写——这是两条路径
// 目前存在的行为差异,用测试把它钉住,避免以后被误当成两条路径完全等价。
func (t *hooksTest) TestInsertColsVals_DoesNotStripSystemColumns() {
res, _ := t.newResource(nil, nil)
// 实际生成的 SQLcreated_user 没有被剔除/改写):
// INSERT INTO `users` (`name`, `created_user`, `owned_user`) VALUES ('赵六', 'attacker-uuid', '00000000-0000-0000-0000-000000000000')
sql, _, _ := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().Cols("name", "created_user").Vals(
db.Vals{"赵六", "attacker-uuid"},
).Executor().ToSQL()
t.Contains(sql, "attacker-uuid")
}
// -------- 虚拟资源短路 --------
// TestVirtualResource_SkipsRolesConditionAndOnAfter 虚拟资源的 UPDATE/DELETE 不做权限过滤,
// 写操作完成后也不会触发 ResChangeEventFunc/ResChangeRowFunc,也不会自动开事务
func (t *hooksTest) TestVirtualResource_SkipsRolesConditionAndOnAfter() {
var eventCalled, changeCalled bool
mockDB, mock, err := sqlmock.New()
t.Require().NoError(err)
app := do.New()
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
do.Provide(app, func(i do.Injector) (resx.DataInterceptor, error) {
return func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
return nil, db.C("owned_user").Eq(u.Uuid()), func(rows []resx.ResChangeRow) error {
changeCalled = true
return nil
}
}, nil
})
do.Provide(app, func(i do.Injector) (resx.ResChangeEventFunc, error) {
return func(event resx.ResChangeEvent) error {
eventCalled = true
return nil
}, nil
})
baseRes := resx.New(app, "User", "users", resx.WithConn("default"))
res := resx.NewVirtualResource(baseRes, "User", db.From(db.T("users")),
resx.WithFields(resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString))),
)
// 实际生成的 SQL(虚拟资源拿子查询拼表名,且没有 owned_user 权限条件):
// UPDATE (SELECT * FROM `users`) AS `User` SET `name`='新名字' WHERE (`id` = 1)
sql, _, _ := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Update().
Set(db.Record{"name": "新名字"}).
Where(db.C("id").Eq(1)).
Executor().ToSQL()
t.NotContains(sql, "owned_user", "虚拟资源不应该走权限过滤改写")
// 没有安排 Begin:虚拟资源在 UseTx 里直接短路,不会自动开事务
mock.ExpectExec("UPDATE").WillReturnResult(sqlmock.NewResult(0, 1))
_, err = res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Update().
Set(db.Record{"name": "新名字"}).
Where(db.C("id").Eq(1)).
Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.False(eventCalled, "虚拟资源写操作后不应该触发 ResChangeEventFunc")
t.False(changeCalled, "虚拟资源写操作后不应该触发 ResChangeRowFunc")
}
// -------- 变更内容里字面量表达式的还原 --------
// TestBuildChanges_ResolvesNonPlaceholderLiteral resolveLiteralValue 对形如 db.L("NOW()") 这种
// 没有占位符的字面量表达式,应该还原成它的原始文本,而不是占位符对应的参数
func (t *hooksTest) TestBuildChanges_ResolvesNonPlaceholderLiteral() {
var gotRows []resx.ResChangeRow
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
if category != resx.ResEventUpdate {
return nil, nil, nil
}
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
},
nil,
)
mock.ExpectBegin()
// 抓取快照的前置 SELECT,实际生成的 SQL:
// SELECT * FROM `users` AS `User` WHERE (`id` = 1)
mock.ExpectQuery("SELECT").
WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "旧名字"))
// 实际 UPDATE,注意 NOW() 是原样拼进 SQL 的字面量,不是占位符参数:
// UPDATE `users` AS `User` SET `name`=NOW() WHERE (`id` = 1)
mock.ExpectExec("UPDATE").WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectCommit()
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Update().
Set(db.Record{"name": db.L("NOW()")}).
Where(db.C("id").Eq(1)).
Executor().Exec()
t.NoError(err)
t.NoError(mock.ExpectationsWereMet())
t.Require().Len(gotRows, 1)
t.Equal("NOW()", gotRows[0].New().Get("name").String())
}
// -------- 事务托底:UseTx 自动开事务 + Commit/Rollback --------
// TestUseTx_RollsBackOnChangeError onChange 失败时,自动开启的事务要整体回滚,连带这次写操作
// 本身一起撤销——而不是"数据写成功了、只是留痕/watcher 失败"
func (t *hooksTest) TestUseTx_RollsBackOnChangeError() {
onChangeErr := errors.New("write change log failed")
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
return nil, nil, func(rows []resx.ResChangeRow) error {
return onChangeErr
}
},
nil,
)
mock.ExpectBegin()
mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectRollback()
_, err := res.GetDBTable(userx.GetAnonymous(), req.WithPermission(req.ResAll)).Insert().
Rows(db.Record{"name": "赵六"}).
Executor().Exec()
t.Error(err)
var hookErr *db.HookError
t.True(errors.As(err, &hookErr), "error 应该能用 errors.As 解出 db.HookError")
t.ErrorIs(hookErr.Unwrap(), onChangeErr)
t.NoError(mock.ExpectationsWereMet(), "应该是 Begin -> INSERT -> Rollback,而不是 Commit")
}
// TestUseTx_ReusesCallerProvidedTx 调用方已经显式传入事务时,UseTx 不会再自动开一个新的,
// After 也不会自动 Commit/Rollback 它——事务的生命周期仍然由调用方自己负责
func (t *hooksTest) TestUseTx_ReusesCallerProvidedTx() {
var gotRows []resx.ResChangeRow
res, mock := t.newResource(
func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
return nil, nil, func(rows []resx.ResChangeRow) error {
gotRows = rows
return nil
}
},
nil,
)
// 模拟调用方自己已经开了一个事务
mock.ExpectBegin()
tx, err := res.DB().Begin()
t.Require().NoError(err)
// 这里不再有第二次 ExpectBegin:证明 UseTx 发现 dp.tx 已经非 nil 就不会再自己开一个
mock.ExpectExec("INSERT").WillReturnResult(sqlmock.NewResult(1, 1))
_, err = res.GetDBTable(userx.GetAnonymous(), req.WithTx(tx), req.WithPermission(req.ResAll)).Insert().
Rows(db.Record{"name": "赵六"}).
Executor().Exec()
t.NoError(err)
// After 不会自动提交,调用方自己提交
mock.ExpectCommit()
t.NoError(tx.Commit())
t.NoError(mock.ExpectationsWereMet())
t.Require().Len(gotRows, 1)
t.Same(tx, gotRows[0].Tx(), "row.Tx() 应该是调用方传入的那个事务,不是 UseTx 自动开的")
}
-83
View File
@@ -1,83 +0,0 @@
package resx
import (
"fmt"
"git.fsdpf.net/go/db/exp"
"git.fsdpf.net/go/req"
)
// MaskField 用 "table.col" 或裸列名 "col" 标记一个列需要按对应 req.ResField 的 Roles 做字段级
// 权限脱敏。dataProcessor.maskSelect 只处理带这个标记的列,未标记的列(包括看起来像同名字段的
// 普通列)一律不处理——标记是显式的,不靠猜。
//
// 不带表名的裸列名(比如 "salary")表示当前资源自己的字段;带表名的(比如 "Department.salary"
// 里的 table 部分通常是查询里用的别名(当前资源自己的默认别名,或者 JOIN 进来的资源用的别名)。
// 这里不会立即去查是哪个资源、哪个字段:MaskField 被调用的时候,SELECT 列表所在的 SelectDataset
// 可能还没拼完(FROM/JOIN 子句可能还没加上去),而且别名也不一定等于资源自己的 code(比如同一个
// 资源自连接查询,两个别名不可能都等于资源自己的 code)——这两种情况都得等查询真正执行前
// dataProcessor.maskSelect,在 exec.Hooks.Before 里跑,那时候才有完整的 FROM/JOIN 子句)才能
// 正确解析出别名对应的真实表名,再去 container 里的 req.GetResource 按 code/table/uuid 查资源、
// 按 col 查字段。
//
// 用法:resx.MaskField("salary").As("salary")、resx.MaskField("Department.salary").As("salary")
func MaskField(ident string) exp.IdentifierExpression {
parsed := exp.ParseIdentifier(ident)
code, isStringCode := parsed.GetCol().(string)
if !isStringCode || code == "" {
panic(fmt.Errorf("resx.MaskField: ident %q 不是合法的列名", ident))
}
return lazyMaskedIdent{IdentifierExpression: parsed, codeResource: parsed.GetTable(), code: code}
}
// lazyMaskResolver 是 lazyMaskedIdent/lazyMaskedAliased 共用的类型断言接口:dataProcessor.maskSelect
// 靠它从 SELECT 列表里挑出被 MaskField 标记过、还没解析成具体 req.ResField 的列。codeResource 为
// 空表示裸列名,对应当前资源自己的字段。
//
// 这里特意不叫 aliasMaskField("table.col") 里 table 这部分,语义上对应的是
// req.ResField.GetCodeResource()(哪个资源),不是 SQL 里"起别名"的 alias(比如 .As(...) 里的
// 输出列名)——这个包里两种"alias"都会出现(resolveJoinTable 是在拿它去 FROM/JOIN 子句里当 SQL
// 别名搜,那里用 alias 这个词没问题;但站在 MaskField 被构造出来这一刻,它就是资源标识,叫
// codeResource 才不会跟 .As(...) 那个"别名"混在一起)。
type lazyMaskResolver interface {
lazyMaskField() (codeResource, code string)
}
// lazyMaskedIdent 包装一个 exp.IdentifierExpression,同时打上"待解析"标记(只有资源标识+字段
// code,还没查出 req.ResField)。除 As() 外的其它方法(Eq()/In()/Table() 等)都是内嵌接口自动
// 透传,不用手写。
type lazyMaskedIdent struct {
exp.IdentifierExpression
codeResource string
code string
}
func (m lazyMaskedIdent) lazyMaskField() (string, string) { return m.codeResource, m.code }
// As 必须单独实现:IdentifierExpression.As() 返回的是 exp.AliasedExpression,是另一个类型,
// 如果不单独处理,直接透传内嵌值的 As() 会丢掉标记(调用方几乎总会紧跟着调用 As() 给列起别名)。
func (m lazyMaskedIdent) As(val interface{}) exp.AliasedExpression {
return lazyMaskedAliased{AliasedExpression: m.IdentifierExpression.As(val), codeResource: m.codeResource, code: m.code}
}
// lazyMaskedAliased 包装一个 exp.AliasedExpressionMaskField(...).As(...) 链式调用之后的结果),
// 同时保留"待解析"标记
type lazyMaskedAliased struct {
exp.AliasedExpression
codeResource string
code string
}
func (m lazyMaskedAliased) lazyMaskField() (string, string) { return m.codeResource, m.code }
// hasFieldAccess 判断 u 是否有权限读写 field 的真实值:field.GetRoles() 为空表示不限制,非空时
// u 的角色只要有一个在列表里就算有权限(复用 req.User.HasUserRoles,同时拿到超级管理员的豁免)
func hasFieldAccess(field req.ResField, u req.User) bool {
roles := field.GetRoles()
if len(roles) == 0 {
return true
}
return u.HasUserRoles(roles...)
}
-590
View File
@@ -1,590 +0,0 @@
package resx_test
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/samber/do/v2"
"github.com/samber/lo"
"github.com/stretchr/testify/suite"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/db/exp"
"git.fsdpf.net/go/req"
"git.fsdpf.net/go/req/resx"
_ "git.fsdpf.net/go/db/dialect/mysql"
)
// maskFieldTest 验证字段级权限(ResField.GetRoles):SELECT 时按权限把字段替换成 MaskFunc 给的
// 哨兵值(resx.MaskField 显式标记的列才处理,未标记的列即使字段本身受限也不处理),INSERT/
// UPDATE 时把没有写权限的字段静默丢弃(见 normalizeSaveValue)。
type maskFieldTest struct {
suite.Suite
}
func TestMaskField(t *testing.T) {
suite.Run(t, new(maskFieldTest))
}
// testUser 是一个可以自定义角色的 req.User 测试替身;contracts-v2/base 的 user 类型是私有的,
// 没有导出"构造一个自定义角色用户"的方式,这里按同样的语义(含超级管理员豁免)自己实现一个。
type testUser struct {
roles []string
}
func (u testUser) ID() int64 { return 1 }
func (u testUser) Uuid() string { return "test-user-uuid" }
func (u testUser) Username() string { return "test" }
func (u testUser) Nickname() string { return "测试用户" }
func (u testUser) GetUserInfo() map[string]any { return nil }
func (u testUser) Roles() []string { return u.roles }
func (u testUser) IsAnonymous() bool { return false }
func (u testUser) Runtime() req.UserRuntime { return req.NewUserRuntime() }
func (u testUser) HasUserRoles(roles ...string) bool {
return lo.Contains(u.roles, "ffffffff-ffff-ffff-ffff-ffffffffffff") || len(lo.Intersect(u.roles, roles)) > 0
}
// newApp 构造一个绑定了 sqlmock 的容器,同时注册 req.GetResource——用同一个容器构造出来的资源,
// 按 code/table 都能查到(MaskField 解析带表名的别名要用它)。register 用来把资源加进查找表,
// 调用方在 resx.New(...) 之后手动调一次。
func (t *maskFieldTest) newApp() (do.Injector, sqlmock.Sqlmock, func(req.Resource)) {
mockDB, mock, err := sqlmock.New()
t.Require().NoError(err)
app := do.New()
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
registry := map[string]req.Resource{}
do.Provide(app, func(i do.Injector) (req.GetResource, error) {
return func(code string) (req.Resource, bool) {
r, ok := registry[code]
return r, ok
}, nil
})
register := func(res req.Resource) {
registry[res.GetCode()] = res
registry[res.GetTable()] = res
}
return app, mock, register
}
// newResource 构造一个绑定了 sqlmock 的 User 资源,字段列表自定义
func (t *maskFieldTest) newResource(fields ...req.ResField) (req.Resource, sqlmock.Sqlmock) {
app, mock, register := t.newApp()
res := resx.New(app, "User", "users",
resx.WithConn("default"),
resx.WithFields(fields...),
)
register(res)
return res, mock
}
var (
nameField = resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString))
salaryField = resx.NewResField("salary", "User", resx.FieldWithName("薪资"), resx.FieldWithRoles("hr"), resx.FieldWithDataType(req.ResInteger))
deptBudgetFld = resx.NewResField("budget", "Department", resx.FieldWithName("预算"), resx.FieldWithRoles("finance"), resx.FieldWithDataType(req.ResInteger))
categorySecretFld = resx.NewResField("secret", "Category", resx.FieldWithName("秘密"), resx.FieldWithRoles("admin"), resx.FieldWithDataType(req.ResInteger))
companyBudgetFld = resx.NewResField("budget", "Company", resx.FieldWithName("预算"), resx.FieldWithRoles("finance"), resx.FieldWithDataType(req.ResInteger))
managerSalaryFld = resx.NewResField("salary", "Manager", resx.FieldWithName("薪资"), resx.FieldWithRoles("hr"), resx.FieldWithDataType(req.ResInteger))
)
// newJoinedApp 构造两个互相独立的资源(User -> users、Department -> departments),都注册进同一个
// 容器的 req.GetResource,方便下面几个 JOIN 相关用例复用
func (t *maskFieldTest) newJoinedApp() (user, dept req.Resource) {
app, _, register := t.newApp()
dept = resx.New(app, "Department", "departments",
resx.WithConn("default"),
resx.WithFields(deptBudgetFld),
)
register(dept)
user = resx.New(app, "User", "users",
resx.WithConn("default"),
resx.WithFields(nameField),
)
register(user)
return user, dept
}
// newChainedJoinApp 构造管理结构里的链式关联(User -> Department -> Company):Company 不是直接
// 挂在 User 下面,是通过 Department 关联的第二个 JOIN 子句——用来验证 resolveJoinTable 遍历
// clauses.Joins() 时不是只认第一个 JOIN,后面链上的也能正确解析出别名对应的真实表。
func (t *maskFieldTest) newChainedJoinApp() (user, dept, company req.Resource) {
app, _, register := t.newApp()
company = resx.New(app, "Company", "companies",
resx.WithConn("default"),
resx.WithFields(companyBudgetFld),
)
register(company)
dept = resx.New(app, "Department", "departments",
resx.WithConn("default"),
resx.WithFields(deptBudgetFld),
)
register(dept)
user = resx.New(app, "User", "users",
resx.WithConn("default"),
resx.WithFields(nameField),
)
register(user)
return user, dept, company
}
// newStarJoinApp 构造管理结构里的并列关联(User -> Department、User -> Manager):Department 和
// Manager 都直接挂在 User 下面,是两条独立的 JOIN 子句,不是链式关联——用来验证同一个查询里,
// 两个不同 JOIN 别名各自独立解析,互不干扰。
func (t *maskFieldTest) newStarJoinApp() (user, dept, manager req.Resource) {
app, _, register := t.newApp()
dept = resx.New(app, "Department", "departments",
resx.WithConn("default"),
resx.WithFields(deptBudgetFld),
)
register(dept)
manager = resx.New(app, "Manager", "managers",
resx.WithConn("default"),
resx.WithFields(managerSalaryFld),
)
register(manager)
user = resx.New(app, "User", "users",
resx.WithConn("default"),
resx.WithFields(nameField),
)
register(user)
return user, dept, manager
}
// -------- SELECT * 默认展开 --------
// TestDefaultSelect_MasksRestrictedField 没有权限时,SELECT * 展开后受限字段被替换成哨兵值
func (t *maskFieldTest) TestDefaultSelect_MasksRestrictedField() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "`name`")
t.Contains(sql, "-999999999 AS `salary`", "受限字段应该被替换成内置的数字哨兵值,而不是裸列")
}
// TestDefaultSelect_RoleMatchShowsRealColumn 用户角色命中 Roles 时,字段按真实列查询,不脱敏
func (t *maskFieldTest) TestDefaultSelect_RoleMatchShowsRealColumn() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}, req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL()
// 没有字段需要脱敏,直接维持原来的 SELECT *,不会被改写成展开的列表
t.Contains(sql, "SELECT *")
t.NotContains(sql, "-999999999")
}
// TestDefaultSelect_NoRestrictedFieldsLeavesWildcard 没有任何字段配置 Roles 时,SELECT * 不展开、不改写
func (t *maskFieldTest) TestDefaultSelect_NoRestrictedFieldsLeavesWildcard() {
res, _ := t.newResource(nameField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "SELECT *")
}
// -------- 显式列:只处理 resx.MaskField 标记过的列 --------
// TestExplicitColumn_MaskFieldMarkedColumnGetsMasked 标记过的列,没权限时会被替换
func (t *maskFieldTest) TestExplicitColumn_MaskFieldMarkedColumnGetsMasked() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(db.C("name"), resx.MaskField("salary").As("salary")).
Executor().ToSQL()
t.Contains(sql, "`name`")
t.Contains(sql, "-999999999 AS `salary`")
}
// TestExplicitColumn_UnmarkedColumnNeverMasked 没有用 MaskField 标记的列,即使字段本身受限也
// 原样保留——标记是显式的,dataProcessor 不会替调用方去猜哪一列对应哪个字段
func (t *maskFieldTest) TestExplicitColumn_UnmarkedColumnNeverMasked() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(db.C("salary")).
Executor().ToSQL()
t.Contains(sql, "`salary`")
t.NotContains(sql, "-999999999")
}
// TestQualifiedWildcard_OwnAliasExpandsAndMasks db.T(alias).All()(限定到某个别名的全部列)如果
// 限定的别名就是当前资源自己(GetDBTable 默认用资源 code 当别名),效果等价于裸的 db.Star(),
// 同样会展开成显式字段列表、按权限脱敏——不是只有 db.Star() 才会触发展开逻辑
func (t *maskFieldTest) TestQualifiedWildcard_OwnAliasExpandsAndMasks() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(db.T("User").All()).
Executor().ToSQL()
t.Contains(sql, "`name`")
t.Contains(sql, "-999999999 AS `salary`")
}
// TestQualifiedWildcard_OtherAliasNeverMasked db.T(alias).All() 如果限定的是别的别名(比如 JOIN
// 进来的另一个资源),不会被当成"选当前资源自己的全部列",不会展开、不会脱敏——这属于"JOIN 进来
// 的字段够不到"的已知限制,不是这里要解决的问题
func (t *maskFieldTest) TestQualifiedWildcard_OtherAliasNeverMasked() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(db.T("Department").All()).
Executor().ToSQL()
t.NotContains(sql, "-999999999")
}
// TestExplicitColumn_CustomAliasPreserved MaskField(...).As(alias) 替换后仍然用调用方指定的别名,
// 不是恒用字段码
func (t *maskFieldTest) TestExplicitColumn_CustomAliasPreserved() {
res, _ := t.newResource(salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(resx.MaskField("salary").As("employee_salary")).
Executor().ToSQL()
t.Contains(sql, "AS `employee_salary`")
t.NotContains(sql, "AS `salary`")
}
// TestExplicitColumn_JoinedResourceFieldMasked MaskField("Department.budget") 这种带表名的形式,
// 在 JOIN 查询里能正确解析成 Department 资源的 budget 字段——别名 "Department" 换成真实表名
// "departments",再用 req.GetResource 按 table 查到资源,不是靠"别名就是资源 code"硬编码猜的。
// JOIN 用的是 res.GetTableExpr().As(alias)(跟 orm-v2 实际 JOIN 构造方式一致),命中的是
// aliasedRealTable 里"字面量包了一层 identifier"那条分支。
func (t *maskFieldTest) TestExplicitColumn_JoinedResourceFieldMasked() {
user, dept := t.newJoinedApp()
sql, _, _ := user.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
Select(db.C("name"), resx.MaskField("Department.budget").As("budget")).
Executor().ToSQL()
t.Contains(sql, "`name`")
t.Contains(sql, "-999999999 AS `budget`")
}
// TestExplicitColumn_JoinedResourceFieldRoleMatchNotMasked 同样是 JOIN 进来的字段,用户角色命中
// Department.budget 的 Roles 时不脱敏,按真实列查询——JOIN 场景下权限判断跟当前资源自己的字段
// 走的是同一套 hasFieldAccess,不因为是"别的资源的字段"就被跳过或者反而更严格
func (t *maskFieldTest) TestExplicitColumn_JoinedResourceFieldRoleMatchNotMasked() {
user, dept := t.newJoinedApp()
sql, _, _ := user.GetDBTable(testUser{roles: []string{"finance"}}, req.WithPermission(req.ResAll)).
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
Select(db.C("name"), resx.MaskField("Department.budget").As("budget")).
Executor().ToSQL()
t.Contains(sql, "`budget`")
t.NotContains(sql, "-999999999")
}
// TestExplicitColumn_DirectIdentifierAliasJoinResolves JOIN 用 db.T(table).As(alias) 直接在
// identifier 上起别名(不经过 GetTableExpr 那层字面量包装)时,一样能正确解析——命中的是
// aliasedRealTable 里"Aliased() 直接就是 identifier"那条分支,跟上面字面量包装的分支不是同一条
// 代码路径,两条都要覆盖到
func (t *maskFieldTest) TestExplicitColumn_DirectIdentifierAliasJoinResolves() {
user, _ := t.newJoinedApp()
sql, _, _ := user.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
LeftJoin(db.T("departments").As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
Select(db.C("name"), resx.MaskField("Department.budget").As("budget")).
Executor().ToSQL()
t.Contains(sql, "-999999999 AS `budget`")
}
// TestExplicitColumn_SelfJoinDifferentAliasesResolveIndependently 同一个资源自连接时,两个别名
// (Child 是当前资源自己默认别名的覆盖,Parent 是 JOIN 进来的第二份自己)都不等于资源自己的
// code"Category")——这正是当初不能直接"别名当 code 用"、必须找真实表名再查的原因,这里验证两个
// 别名各自独立解析都是对的,互不影响
func (t *maskFieldTest) TestExplicitColumn_SelfJoinDifferentAliasesResolveIndependently() {
app, _, register := t.newApp()
category := resx.New(app, "Category", "categories",
resx.WithConn("default"),
resx.WithFields(categorySecretFld),
)
register(category)
sql, _, _ := category.GetDBTable(testUser{}, req.WithAlias("Child"), req.WithPermission(req.ResAll)).
LeftJoin(category.GetTableExpr().As("Parent"), db.On(db.I("Child.pid").Eq(db.I("Parent.id")))).
Select(
resx.MaskField("Child.secret").As("child_secret"),
resx.MaskField("Parent.secret").As("parent_secret"),
).
Executor().ToSQL()
t.Contains(sql, "-999999999 AS `child_secret`")
t.Contains(sql, "-999999999 AS `parent_secret`")
}
// TestExplicitColumn_ChainedJoinResolvesThirdTable User -> Department -> Company 链式 JOIN
// Company 挂在第二个 JOIN 子句上(不是第一个):验证 resolveJoinTable 能找到它,进而查到 Company
// 资源上配置了 Roles 的字段并正确脱敏
func (t *maskFieldTest) TestExplicitColumn_ChainedJoinResolvesThirdTable() {
user, dept, company := t.newChainedJoinApp()
sql, _, _ := user.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
LeftJoin(company.GetTableExpr().As("Company"), db.On(db.I("Department.company_id").Eq(db.I("Company.id")))).
Select(db.C("name"), resx.MaskField("Company.budget").As("budget")).
Executor().ToSQL()
t.Contains(sql, "`name`")
t.Contains(sql, "-999999999 AS `budget`")
}
// TestExplicitColumn_StarJoinResolvesEachBranchIndependently User -> Department、User -> Manager
// 两条并列的 JOIN(都直接挂在 User 下面,不是链式关联):命中 Department.budget 的 finance 角色
// 和没命中 Manager.salary 的 hr 角色分开判断,验证同一次查询里两个 JOIN 分支各自独立解析、互不
// 串扰
func (t *maskFieldTest) TestExplicitColumn_StarJoinResolvesEachBranchIndependently() {
user, dept, manager := t.newStarJoinApp()
sql, _, _ := user.GetDBTable(testUser{roles: []string{"finance"}}, req.WithPermission(req.ResAll)).
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
LeftJoin(manager.GetTableExpr().As("Manager"), db.On(db.I("User.manager_id").Eq(db.I("Manager.id")))).
Select(
db.C("name"),
resx.MaskField("Department.budget").As("dept_budget"),
resx.MaskField("Manager.salary").As("mgr_salary"),
).
Executor().ToSQL()
t.Contains(sql, "`dept_budget`")
t.NotContains(sql, "-999999999 AS `dept_budget`")
t.Contains(sql, "-999999999 AS `mgr_salary`")
}
// TestExplicitColumn_FieldNotFoundOnResourceErrors 别名能解析到资源,但资源上没有这个字段
// (字段名写错了)——报错而不是静默不脱敏
func (t *maskFieldTest) TestExplicitColumn_FieldNotFoundOnResourceErrors() {
user, dept := t.newJoinedApp()
_, _, err := user.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
Select(db.C("name"), resx.MaskField("Department.no_such_field").As("x")).
Executor().ToSQL()
t.Error(err)
}
// TestExplicitColumn_UnresolvableAliasErrors 标记的表名在查询里根本不存在(也不是任何资源的
// code/table/uuid),说明调用方写错了标记——报错而不是静默不脱敏
func (t *maskFieldTest) TestExplicitColumn_UnresolvableAliasErrors() {
res, _ := t.newResource(nameField, salaryField)
_, _, err := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(db.C("name"), resx.MaskField("NoSuchResource.budget").As("budget")).
Executor().ToSQL()
t.Error(err)
}
// TestExplicitColumn_GetResourceUnavailableErrors 容器里根本没注册 req.GetResource(不是"查不到
// 这个资源",是查找能力本身都拿不到)时,跟其它解析失败的场景(TestExplicitColumn_
// UnresolvableAliasErrors 等)一样报错,而不是静默脱敏或放行——查不到就是查不到,不能因为查不到
// 的原因是 DI 而不是资源本身,就换一套更宽松的处理方式
func (t *maskFieldTest) TestExplicitColumn_GetResourceUnavailableErrors() {
mockDB, _, err := sqlmock.New()
t.Require().NoError(err)
container := do.New()
do.Provide(container, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
// 故意不注册 req.GetResource
res := resx.New(container, "User", "users",
resx.WithConn("default"),
resx.WithFields(nameField),
)
_, _, sqlErr := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
LeftJoin(db.T("departments").As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
Select(db.C("name"), resx.MaskField("Department.budget").As("budget")).
Executor().ToSQL()
t.ErrorContains(sqlErr, "req.GetResource")
}
// TestExplicitColumn_OwnAliasSkipsGetResourceEntirely MaskField("User.salary")——别名不是空的,
// 而是显式写了当前资源自己的 code(DataListField 这类配置记录里,master/自身资源的字段经常这么
// 存,不是留空)——这种情况必须直接走 dp.res,完全不碰 req.GetResource,哪怕容器里根本没注册
// req.GetResource 也要能正确解析:没配置 Roles 的字段原样通过,配置了的字段才脱敏,不能因为
// "别名非空"就误当成 JOIN 字段一律走 DI、进而在 req.GetResource 不可用时把没配置权限的字段也
// 一起误脱敏
func (t *maskFieldTest) TestExplicitColumn_OwnAliasSkipsGetResourceEntirely() {
mockDB, _, err := sqlmock.New()
t.Require().NoError(err)
container := do.New()
do.Provide(container, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
// 故意不注册 req.GetResource——如果 resolveLazyField 把 "User" 这个别名误当成需要走 DI 的
// JOIN 别名,这个测试就会失败(要么报错,要么两个字段全被误脱敏成 NULL)
res := resx.New(container, "User", "users",
resx.WithConn("default"),
resx.WithFields(nameField, salaryField),
)
sql, _, sqlErr := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(resx.MaskField("User.name").As("name"), resx.MaskField("User.salary").As("salary")).
Executor().ToSQL()
t.NoError(sqlErr)
t.Contains(sql, "`name`")
t.NotContains(sql, "NULL AS `name`")
t.Contains(sql, "-999999999 AS `salary`")
}
// -------- MaskField 嵌在 db.Func(...) 参数里 --------
// TestExplicitColumn_MaskFieldInsideFuncArgGetsMasked 顶层列是 db.Func(...)MaskField 标记的是
// 它其中一个参数(不是顶层列本身):resolveMaskedExpr 得递归进 Args() 才能找到并替换,替换后的
// mask 值不带 AS(函数参数不能单独起别名),外层函数调用整体保留原来的别名
func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideFuncArgGetsMasked() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'")).As("computed")).
Executor().ToSQL()
t.Contains(sql, "CONCAT(-999999999, ' extra') AS `computed`")
}
// TestExplicitColumn_MaskFieldInsideFuncArgRoleMatchNotMasked 函数参数里的 MaskField 标记,用户
// 角色命中时跟顶层列一样不脱敏,函数调用整体原样保留
func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideFuncArgRoleMatchNotMasked() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}, req.WithPermission(req.ResAll)).
Select(db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'")).As("computed")).
Executor().ToSQL()
t.Contains(sql, "CONCAT(`salary`, ' extra') AS `computed`")
t.NotContains(sql, "-999999999")
}
// TestExplicitColumn_MaskFieldInsideNestedFuncArgGetsMasked MaskField 嵌套两层函数调用
// db.Func("UPPER", db.Func("CONCAT", ...)))也能递归找到并替换——resolveMaskedExpr 对
// SQLFunctionExpression 是递归调用自己,不是只展开一层
func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideNestedFuncArgGetsMasked() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
Select(db.Func("UPPER", db.Func("CONCAT", resx.MaskField("salary"), db.L("' extra'"))).As("computed")).
Executor().ToSQL()
t.Contains(sql, "UPPER(CONCAT(-999999999, ' extra')) AS `computed`")
}
// TestExplicitColumn_MaskFieldInsideJoinedFuncArgGetsMasked 函数参数里的 MaskField 也支持带别名
// 的 JOIN 字段,跟顶层列的解析逻辑(resolveJoinTable 换真实表名)共用同一套
func (t *maskFieldTest) TestExplicitColumn_MaskFieldInsideJoinedFuncArgGetsMasked() {
user, dept := t.newJoinedApp()
sql, _, _ := user.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).
LeftJoin(dept.GetTableExpr().As("Department"), db.On(db.I("User.dept_id").Eq(db.I("Department.id")))).
Select(db.Func("CONCAT", resx.MaskField("Department.budget"), db.L("' extra'")).As("computed")).
Executor().ToSQL()
t.Contains(sql, "CONCAT(-999999999, ' extra') AS `computed`")
}
// TestCustomMaskFunc_Override 容器里注册了 MaskFunc 时,脱敏值用注册的而不是内置默认值
func (t *maskFieldTest) TestCustomMaskFunc_Override() {
mockDB, _, err := sqlmock.New()
t.Require().NoError(err)
container := do.New()
do.Provide(container, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
do.Provide(container, func(i do.Injector) (resx.MaskFunc, error) {
return func(field req.ResField) exp.LiteralExpression {
return db.V("REDACTED")
}, nil
})
customRes := resx.New(container, "User", "users",
resx.WithConn("default"),
resx.WithFields(salaryField),
)
sql, _, _ := customRes.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "REDACTED")
t.NotContains(sql, "-999999999")
}
// -------- 写权限:normalizeSaveValue 静默丢弃没有权限的字段 --------
// TestInsert_DropsFieldWithoutWriteAccess 没有权限时,INSERT 里这个字段被静默丢弃
func (t *maskFieldTest) TestInsert_DropsFieldWithoutWriteAccess() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).Insert().
Rows(db.Record{"name": "张三", "salary": 999999}).
Executor().ToSQL()
t.Contains(sql, "张三")
t.NotContains(sql, "999999", "没有写权限的字段应该被静默丢弃,不应该出现在 INSERT 里")
}
// TestInsert_KeepsFieldWithWriteAccess 有权限时,INSERT 正常写入真实值
func (t *maskFieldTest) TestInsert_KeepsFieldWithWriteAccess() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}, req.WithPermission(req.ResAll)).Insert().
Rows(db.Record{"name": "张三", "salary": 999999}).
Executor().ToSQL()
t.Contains(sql, "999999")
}
// TestUpdate_DropsFieldWithoutWriteAccess UPDATE 场景下同样会被静默丢弃
func (t *maskFieldTest) TestUpdate_DropsFieldWithoutWriteAccess() {
res, _ := t.newResource(nameField, salaryField)
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).Update().
Set(db.Record{"name": "李四", "salary": 888888}).
Where(db.C("id").Eq(1)).
Executor().ToSQL()
t.Contains(sql, "李四")
t.NotContains(sql, "888888")
}
-234
View File
@@ -1,234 +0,0 @@
package resx_test
import (
"strings"
"testing"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/req"
"git.fsdpf.net/go/req/resx"
"git.fsdpf.net/go/req/userx"
"github.com/DATA-DOG/go-sqlmock"
"github.com/samber/do/v2"
"github.com/stretchr/testify/suite"
_ "git.fsdpf.net/go/db/dialect/mysql"
)
// normalizeTest 针对 dataProcessor 的 normalizeSaveValue / applyDefaultValue 两个私有方法做黑盒验证:
// 由于它们只在 INSERT/UPDATE 前被调用,行为都会体现在最终生成的 SQL 上,所以借助 ToSQL() 而不必真正
// 执行 Exec,也不需要 sqlmock 的 Expect* 断言。
type normalizeTest struct {
suite.Suite
}
func TestResourceNormalize(t *testing.T) {
suite.Run(t, new(normalizeTest))
}
const anonUUID = "00000000-0000-0000-0000-000000000000"
// newNormalizeResource 构造一个绑定了 sqlmock 的 User 资源,不设置 onBefore/onAfter
// normalizeSaveValue/applyDefaultValue 在 INSERT/UPDATE 前无条件执行,不依赖这两个钩子。
func (t *normalizeTest) newNormalizeResource(fields ...req.ResField) req.Resource {
mockDB, _, err := sqlmock.New()
t.Require().NoError(err)
app := do.New()
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
return resx.New(app, "User", "users",
resx.WithConn("default"),
resx.WithFields(fields...),
)
}
// -------- normalizeSaveValue --------
// TestStripsSystemFields id/created_at/deleted_at/updated_at 无条件剔除,created_user 被强制改写为当前用户
func (t *normalizeTest) TestStripsSystemFields() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{
"id": 999,
"name": "张三",
"created_at": "2020-01-01",
"deleted_at": "2020-01-01",
"updated_at": "2020-01-01",
"created_user": "attacker-uuid",
},
).Executor().ToSQL()
t.NotContains(sql, "attacker-uuid")
t.NotContains(sql, "`id`")
t.NotContains(sql, "`created_at`")
t.NotContains(sql, "`deleted_at`")
t.NotContains(sql, "`updated_at`")
t.Contains(sql, "`created_user`")
// created_user、owned_user 都会落到当前用户 uuid 上
t.GreaterOrEqual(strings.Count(sql, anonUUID), 2)
}
// TestDropsUndeclaredFields 未在 WithFields 中声明的字段会被静默丢弃
func (t *normalizeTest) TestDropsUndeclaredFields() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{"name": "李四", "hacker_field": "evil"},
).Executor().ToSQL()
t.NotContains(sql, "hacker_field")
t.NotContains(sql, "evil")
t.Contains(sql, "李四")
}
// TestExpressionValuePassesThroughEvenIfUndeclared db.Expression 类型的值会原样保留,
// 既不会被 ToValue 转换,也不受"未声明字段丢弃"规则约束
func (t *normalizeTest) TestExpressionValuePassesThroughEvenIfUndeclared() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{"name": "王五", "login_at": db.L("NOW()")},
).Executor().ToSQL()
t.Contains(sql, "`login_at`")
t.Contains(sql, "NOW()")
}
// TestJSONFieldNilUsesFieldDefault 显式传 nil 的 json 字段会被 field.ToValue 换成该字段的默认值
func (t *normalizeTest) TestJSONFieldNilUsesFieldDefault() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("fields", "", resx.FieldWithName("字段"), resx.FieldWithDefault("[]"), resx.FieldWithDataType(req.ResJson)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{"name": "A", "fields": nil},
).Executor().ToSQL()
t.Contains(sql, "`fields`")
t.Contains(sql, "'[]'")
}
// -------- applyDefaultValue --------
// TestJSONFieldDefaultFilledWhenOmitted 完全不传 json 字段时,applyDefaultValue 会按字段默认值补全
func (t *normalizeTest) TestJSONFieldDefaultFilledWhenOmitted() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("fields", "", resx.FieldWithName("字段"), resx.FieldWithDefault("{}"), resx.FieldWithDataType(req.ResJson)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{"name": "B"},
).Executor().ToSQL()
t.Contains(sql, "`fields`")
t.Contains(sql, "'{}'")
}
// TestNonJSONFieldDefaultNotAutoFilled 非 json 类型字段的 Default 不会被 applyDefaultValue 自动补全
// getFieldsDefaultValue 只处理 json 字段和 sql: 前缀的默认值),完全依赖数据库表结构自身的默认值
func (t *normalizeTest) TestNonJSONFieldDefaultNotAutoFilled() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDefault("匿名"), resx.FieldWithDataType(req.ResString)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{},
).Executor().ToSQL()
t.NotContains(sql, "`name`")
t.NotContains(sql, "匿名")
}
// TestOwnedUserDefaultsToCurrentUserWhenUndeclared owned_user 未声明为资源字段时,
// 调用方传入的值会被 normalizeSaveValue 当作未知字段丢弃,再由 applyDefaultValue 强制填充为当前用户
func (t *normalizeTest) TestOwnedUserDefaultsToCurrentUserWhenUndeclared() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{"name": "C", "owned_user": "someone-else-uuid"},
).Executor().ToSQL()
t.NotContains(sql, "someone-else-uuid")
t.Contains(sql, "`owned_user`")
t.Contains(sql, anonUUID)
}
// TestOwnedUserPreservedWhenDeclaredAsField 一旦把 owned_user 声明成资源字段,
// 调用方传入的值会被 field.ToValue 保留,applyDefaultValue 见 row 里已有值就不会覆盖
func (t *normalizeTest) TestOwnedUserPreservedWhenDeclaredAsField() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("owned_user", "", resx.FieldWithName("拥有者"), resx.FieldWithDataType(req.ResString)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{"name": "D", "owned_user": "someone-else-uuid"},
).Executor().ToSQL()
t.Contains(sql, "someone-else-uuid")
}
// TestCreatedUserAlwaysForcedToCurrentUser created_user 属于系统字段,无论是否声明为资源字段,
// 都会被 normalizeSaveValue 无条件剔除,再由 applyDefaultValue 强制写入当前用户
func (t *normalizeTest) TestCreatedUserAlwaysForcedToCurrentUser() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("created_user", "", resx.FieldWithName("创建者"), resx.FieldWithDataType(req.ResString)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{"name": "E", "created_user": "someone-else-uuid"},
).Executor().ToSQL()
t.NotContains(sql, "someone-else-uuid")
t.Contains(sql, "`created_user`")
t.Contains(sql, anonUUID)
}
// -------- UPDATE 场景下的 normalizeSaveValue --------
// TestNormalizeSaveValue_Update Set() 传入的数据同样会经过 normalizeSaveValue
// 剔除系统字段、丢弃未声明字段、对声明字段做 ToValue 转换
func (t *normalizeTest) TestNormalizeSaveValue_Update() {
res := t.newNormalizeResource(
resx.NewResField("name", "", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("fields", "", resx.FieldWithName("字段"), resx.FieldWithDefault("{}"), resx.FieldWithDataType(req.ResJson)),
)
sql, _, _ := res.GetDBTable(userx.GetAnonymous()).Update().
Set(db.Record{
"id": 1,
"name": "F",
"fields": map[string]any{"x": 1},
"hacker": "y",
"updated_at": "ignored",
}).
Where(db.C("id").Eq(1)).
Executor().ToSQL()
t.NotContains(sql, "`hacker`")
t.NotContains(sql, "ignored")
t.NotContains(sql, "`updated_at`=")
t.NotContains(sql, "`id`=")
t.Contains(sql, "`name`='F'")
t.Contains(sql, "`fields`='{\\\"x\\\":1}'")
// WHERE 条件里的 id 不受影响
t.Contains(sql, "WHERE (`id` = 1)")
}
-130
View File
@@ -1,130 +0,0 @@
package resx_test
import (
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/samber/do/v2"
"github.com/stretchr/testify/suite"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/req"
"git.fsdpf.net/go/req/resx"
_ "git.fsdpf.net/go/db/dialect/mysql"
)
// permissionTest 验证 req.WithPermission(req.ResFlags) 各个取值(不传/传 0/单独 ResRow/单独
// ResMask/ResAll)分别独立生效:ResRow 控制行级过滤条件要不要拼进查询,ResMask 控制字段级脱敏
// 要不要生效,两者互不影响彼此。GetDBTable 不传 WithPermission 时按文档约定的零值处理(两者都
// 跳过),这里也显式测出来,作为这个默认行为的回归防护——之后如果谁想改这个默认值,这些测试会
// 先失败提醒。
type permissionTest struct {
suite.Suite
}
func TestPermission(t *testing.T) {
suite.Run(t, new(permissionTest))
}
// newPermissionResource 构造一个既配置了受限字段(salary,Roles=hr)、又注册了行级过滤条件
// owned_user = 当前用户)的资源,用同一份数据分别验证 ResRow/ResMask 两个开关。
func (t *permissionTest) newPermissionResource() (req.Resource, sqlmock.Sqlmock) {
mockDB, mock, err := sqlmock.New()
t.Require().NoError(err)
app := do.New()
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
do.Provide(app, func(i do.Injector) (resx.DataInterceptor, error) {
return func(res req.Resource, u req.User, category resx.ResEventType) (*db.SelectDataset, db.Expression, resx.ResChangeRowFunc) {
return nil, db.C("owned_user").Eq(u.Uuid()), nil
}, nil
})
res := resx.New(app, "User", "users",
resx.WithConn("default"),
resx.WithFields(nameField, salaryField),
)
return res, mock
}
// TestNoPermissionOption_SkipsBothRowFilterAndMask 不传 WithPermissionPermission 零值,行级
// 过滤和字段脱敏都跳过——这是 options.go 里 Permission 字段注释明确写的默认行为
func (t *permissionTest) TestNoPermissionOption_SkipsBothRowFilterAndMask() {
res, _ := t.newPermissionResource()
sql, _, _ := res.GetDBTable(testUser{}).Select(db.Star()).Executor().ToSQL()
t.NotContains(sql, "owned_user", "不传 WithPermission 时行级过滤应该被跳过")
t.NotContains(sql, "-999999999", "不传 WithPermission 时字段脱敏应该被跳过")
}
// TestExplicitZeroPermission_SkipsBothRowFilterAndMask 显式传 WithPermission(0),效果跟不传一样
func (t *permissionTest) TestExplicitZeroPermission_SkipsBothRowFilterAndMask() {
res, _ := t.newPermissionResource()
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(0)).Select(db.Star()).Executor().ToSQL()
t.NotContains(sql, "owned_user")
t.NotContains(sql, "-999999999")
}
// TestResRowOnly_EnablesRowFilterOnlyNotMask 只传 ResRow:行级过滤生效,字段脱敏依旧跳过——两个
// 开关相互独立,不是绑在一起的
func (t *permissionTest) TestResRowOnly_EnablesRowFilterOnlyNotMask() {
res, _ := t.newPermissionResource()
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResRow)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "owned_user", "传了 ResRow,行级过滤应该生效")
t.NotContains(sql, "-999999999", "没传 ResMask,字段脱敏不该生效")
}
// TestResMaskOnly_EnablesMaskOnlyNotRowFilter 只传 ResMask:字段脱敏生效,行级过滤依旧跳过
func (t *permissionTest) TestResMaskOnly_EnablesMaskOnlyNotRowFilter() {
res, _ := t.newPermissionResource()
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResMask)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "-999999999 AS `salary`", "传了 ResMask,字段脱敏应该生效")
t.NotContains(sql, "owned_user", "没传 ResRow,行级过滤不该生效")
}
// TestResAll_EnablesBothRowFilterAndMask 传 ResAll:行级过滤和字段脱敏都生效
func (t *permissionTest) TestResAll_EnablesBothRowFilterAndMask() {
res, _ := t.newPermissionResource()
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResAll)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "owned_user")
t.Contains(sql, "-999999999 AS `salary`")
}
// TestResRowOrResMaskBitwiseCombo_SameAsResAll 手动按位组合 ResRow|ResMask,效果跟直接传 ResAll
// 一样——ResAll 只是这个组合的一个别名,不是什么特殊值
func (t *permissionTest) TestResRowOrResMaskBitwiseCombo_SameAsResAll() {
res, _ := t.newPermissionResource()
sql, _, _ := res.GetDBTable(testUser{}, req.WithPermission(req.ResRow|req.ResMask)).Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "owned_user")
t.Contains(sql, "-999999999 AS `salary`")
}
// TestUserHasRole_ResMaskStillOnButFieldPassesThrough ResMask 开启时,用户角色命中字段 Roles 的话
// 字段本身按真实值查询——ResMask 只是"开启检查",不是"无条件脱敏",命中权限时检查结果是放行
func (t *permissionTest) TestUserHasRole_ResMaskStillOnButFieldPassesThrough() {
res, _ := t.newPermissionResource()
sql, _, _ := res.GetDBTable(testUser{roles: []string{"hr"}}, req.WithPermission(req.ResMask)).
Select(db.Star()).Executor().ToSQL()
t.Contains(sql, "SELECT *", "没有字段需要脱敏时不会展开成显式列表")
t.NotContains(sql, "-999999999")
}
-168
View File
@@ -1,168 +0,0 @@
package resx
import (
"reflect"
"strings"
"unicode"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/exp"
"git.fsdpf.net/go/req"
)
// QueryField 是 req.QueryField 的具体实现。原来放在 orm-v2,这里搬过来跟 ResField 是同一个原因:
// ToSql() 需要用 MaskField 按字段的 Roles 做脱敏标记,放在 resx 里可以直接调用,不用 orm 包再反过来
// import resx。orm-v2/contracts-v2 base 的 QueryField 现在都是指向这里的类型别名,兼容原有引用。
//
// 嵌入的是 req.ResField 接口而不是具体的 ResField 结构体:调用方经常手上已经有一个真实的
// req.ResField(比如从 resource.GetField(code) 查出来的),这时候直接传它本身即可,不用先转换成
// 具体类型(也不用为了绕开"没有的字段"再拼一个大部分为空的 ResField 出来)。QueryField 自己的方法
// 全程只通过 req.ResField 接口访问底层字段,不需要具体类型独有的能力。
type QueryField struct {
req.ResField
typ req.RouteParamType `db:"dataType"`
alias string `db:"alias"`
isExpr bool `db:"isExpr"`
isOmitempty bool
ignored bool
}
// Alias 返回字段的别名。
func (this QueryField) Alias() string {
return this.alias
}
// Ignored 返回字段是否被忽略。
func (this QueryField) Ignored() bool {
return this.ignored
}
// IsExpr 返回字段是否为表达式。
func (this QueryField) IsExpr() bool {
return this.isExpr
}
// IsOmitempty 返回字段是否在为空时忽略。
func (this QueryField) IsOmitempty() bool {
return this.isOmitempty
}
// Type 返回字段的数据类型。
func (this QueryField) Type() req.RouteParamType {
return this.typ
}
// SetOptions 设置字段选项(表达式、忽略空值、忽略等)。
func (this *QueryField) SetOptions(options byte) req.QueryField {
if options&req.IsExpr != 0 {
this.isExpr = true
}
if options&req.IsOmitempty != 0 {
this.isOmitempty = true
}
if options&req.Ignored != 0 {
this.ignored = true
}
return this
}
// GetCodeOrAlias 返回字段的别名(如果有),否则返回字段代码。
func (this QueryField) GetCodeOrAlias() string {
if this.Alias() != "" {
return this.Alias()
}
return this.GetCode()
}
// ToSql 将查询字段转换为SQL表达式。
func (this *QueryField) ToSql() db.Expression {
if this.Alias() != "" {
if this.IsExpr() {
if this.GetCode() == "" {
return db.V(nil).As(this.Alias())
}
return db.L("(?)", db.L(this.GetCode())).As(this.Alias())
}
return this.col().As(this.Alias())
}
return this.col()
}
// col 构造这个字段对应的列引用,一律用 MaskField(...) 标记。这里不能靠 this.GetRoles() 提前判断
// 要不要标记——构造 QueryField 的这个 ResField 经常是从 DataListField/ResApiField 之类的配置记录
// 现造出来的,根本不带 Roles(这些配置结构体压根没有 Roles 这一列),如果在这里用"当前手上这个
// ResField 有没有 Roles"来决定要不要包 MaskField,等于默认永远不脱敏,绕过了字段级权限。
// 真正的判断交给 dataProcessor.maskSelect:查询真正执行前,从 SELECT 列表所在的 FROM/JOIN 子句里
// 把 GetCodeResource() 这个别名解析成真实表名,再查真实资源上对应字段的真实 Roles——这一步不需要
// 构造 QueryField 时就知道 Roles。
func (this *QueryField) col() exp.IdentifierExpression {
return MaskField(this.GetCodeResource() + "." + this.GetCode())
}
// ToStructField 将查询字段转换为反射结构体字段定义。
func (this *QueryField) ToStructField(tags ...string) reflect.StructField {
var typ reflect.Type
fCode := this.GetCodeOrAlias()
// 字段规则
if !unicode.IsLetter(rune(fCode[0])) {
panic("struct field name invalid. " + fCode)
}
fTag := `db:"` + fCode
if this.ignored {
fTag = fTag + `" json:"-"`
} else if this.isOmitempty {
fTag = fTag + `" json:"` + fCode + `,omitempty"`
} else {
fTag = fTag + `" json:"` + fCode + `"`
}
if len(tags) > 0 {
fTag = strings.Join(tags, " ") + " " + fTag
}
switch this.typ {
case req.ReqString:
typ = reflect.TypeOf("")
case req.ReqNumber:
typ = reflect.TypeOf(float64(0))
case req.ReqInteger:
typ = reflect.TypeOf(int64(0))
case req.ReqFloat:
typ = reflect.TypeOf(float64(0))
case req.ReqBool:
typ = reflect.TypeOf(true)
case req.ReqArray:
typ = reflect.TypeOf([]any{})
case req.ReqJson:
typ = reflect.TypeOf(map[string]any{})
}
return reflect.StructField{
Name: strings.ToUpper(fCode[:1]) + fCode[1:],
Tag: reflect.StructTag(fTag),
Type: typ,
}
}
// NewQueryField 创建并返回一个新的查询字段实例。
// 参数 rField 是资源字段,t 是字段类型,opts 是可选的配置选项。
func NewQueryField(rField req.ResField, t req.RouteParamType, opts ...QueryFieldOption) req.QueryField {
qf := &QueryField{
ResField: rField,
typ: t,
}
for _, opt := range opts {
opt(qf)
}
return qf
}
-32
View File
@@ -1,32 +0,0 @@
package resx
// QueryFieldOption 定义查询字段的选项函数类型。
type QueryFieldOption func(*QueryField)
// WithAlias 设置查询字段的别名。
func WithAlias(alias string) QueryFieldOption {
return func(qf *QueryField) {
qf.alias = alias
}
}
// WithIsExpr 设置查询字段是否为表达式。
func WithIsExpr(isExpr bool) QueryFieldOption {
return func(qf *QueryField) {
qf.isExpr = isExpr
}
}
// WithOmitEmpty 设置查询字段是否在为空时忽略。
func WithOmitEmpty(isOmitempty bool) QueryFieldOption {
return func(qf *QueryField) {
qf.isOmitempty = isOmitempty
}
}
// WithIgnore 设置查询字段是否被忽略。
func WithIgnore(ignored bool) QueryFieldOption {
return func(qf *QueryField) {
qf.ignored = ignored
}
}
-87
View File
@@ -1,87 +0,0 @@
package resx
import (
"reflect"
"sync"
)
// 本文件为 GetStruct/GetSliceStruct 提供结果类型缓存。
//
// 动机: reflect.StructOf 即使命中 runtime 自己的类型缓存, 仍然要重新构造
// []StructField、算签名、加锁查表。实测 12 字段的 struct 约
//
// reflect.StructOf(相同字段, 已缓存) 2349 ns / 2549 B / 31 allocs
// reflect.TypeOf(已知类型) 2.3 ns / 0 B / 0 allocs
//
// 相差三个数量级。而资源的 fields 在构造之后不再变化, 相同的 extends 必然
// 得到同一个类型, 所以完全可以缓存下来复用。
// resStructCache 按 extends 缓存 GetStruct 的结果类型。
//
// 用切片线性扫描而不是 map: 键是 []reflect.StructField, 要做 map key 得先拼
// 字符串, 那笔开销反而可能盖过 StructOf 本身。而 extends 的取值组合来自关联
// 配置, 数量很少(通常个位数), 逐项比对最省 —— reflect.StructField 的
// Name/Type/Tag 三者都可比较。
type resStructCache struct {
mu sync.RWMutex
base reflect.Type // extends 为空时的类型(最常见的调用形态)
entries []resStructCacheEntry
}
type resStructCacheEntry struct {
extends []reflect.StructField
typ reflect.Type
}
// resStructCacheLimit 限制缓存条目数。正常配置下远远用不满; 万一调用方拿运行时
// 生成的字段做 extends, 超过上限后退回每次现算, 保证内存有界。
const resStructCacheLimit = 64
// sameExtends 判断两组 extends 是否等价。
func sameExtends(a, b []reflect.StructField) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i].Name != b[i].Name || a[i].Type != b[i].Type || a[i].Tag != b[i].Tag {
return false
}
}
return true
}
// load 查缓存, 未命中返回 nil。
func (c *resStructCache) load(extends []reflect.StructField) reflect.Type {
c.mu.RLock()
defer c.mu.RUnlock()
if len(extends) == 0 {
return c.base
}
for i := range c.entries {
if sameExtends(c.entries[i].extends, extends) {
return c.entries[i].typ
}
}
return nil
}
// store 写入缓存。
//
// extends 会复制一份再存: 调用方常把它当临时缓冲区复用(append 到同一个底层
// 数组上), 直接持有会让缓存键在之后被改写, 变成命中错误类型的隐蔽 bug。
func (c *resStructCache) store(extends []reflect.StructField, t reflect.Type) {
c.mu.Lock()
defer c.mu.Unlock()
if len(extends) == 0 {
c.base = t
return
}
if len(c.entries) >= resStructCacheLimit {
return
}
key := make([]reflect.StructField, len(extends))
copy(key, extends)
c.entries = append(c.entries, resStructCacheEntry{extends: key, typ: t})
}
-175
View File
@@ -1,175 +0,0 @@
//go:build sqlite_vtable || vtable
package resx_test
import (
"reflect"
"testing"
"github.com/DATA-DOG/go-sqlmock"
"github.com/samber/do/v2"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/req"
"git.fsdpf.net/go/req/resx"
)
func newCacheTestResource(t testing.TB) req.Resource {
t.Helper()
mockDB, _, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
app := do.New()
do.Provide(app, func(i do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
return resx.New(app, "User", "users",
resx.WithConn("default"),
resx.WithFields(
resx.NewResField("id", "User", resx.FieldWithName("Id"), resx.FieldWithDataType(req.ResInteger)),
resx.NewResField("name", "User", resx.FieldWithName("Name"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("email", "User", resx.FieldWithName("Email"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("status", "User", resx.FieldWithName("Status"), resx.FieldWithDataType(req.ResInteger)),
),
)
}
// 缓存只应影响速度, 不影响结果: 多次调用必须拿到同一个类型, 但仍是新实例。
func TestGetStructTypeIsStable(t *testing.T) {
res := newCacheTestResource(t)
t1 := reflect.TypeOf(res.GetStruct())
t2 := reflect.TypeOf(res.GetStruct())
if t1 != t2 {
t.Fatalf("无 extends 时两次 GetStruct 类型不一致: %v vs %v", t1, t2)
}
if t1.Kind() != reflect.Ptr || t1.Elem().Kind() != reflect.Struct {
t.Fatalf("GetStruct 应返回 *struct, 得到 %v", t1)
}
if res.GetStruct() == res.GetStruct() {
t.Fatal("GetStruct 应该每次返回新实例, 只复用类型")
}
// 带 extends
ext := reflect.StructField{Name: "Extra", Type: reflect.TypeOf("")}
e1 := reflect.TypeOf(res.GetStruct(ext))
e2 := reflect.TypeOf(res.GetStruct(ext))
if e1 != e2 {
t.Fatalf("带 extends 时两次类型不一致: %v vs %v", e1, e2)
}
if e1 == t1 {
t.Fatal("带 extends 的类型不应与不带 extends 的相同")
}
if _, ok := e1.Elem().FieldByName("Extra"); !ok {
t.Fatal("extends 字段没有出现在结果类型里")
}
// 不同的 extends 必须得到不同的类型, 且互不污染
ext2 := reflect.StructField{Name: "Other", Type: reflect.TypeOf(0)}
o1 := reflect.TypeOf(res.GetStruct(ext2))
if o1 == e1 {
t.Fatal("不同 extends 却拿到同一个类型, 缓存键有问题")
}
if _, ok := o1.Elem().FieldByName("Other"); !ok {
t.Fatal("第二组 extends 的字段没有出现")
}
if reflect.TypeOf(res.GetStruct(ext)) != e1 {
t.Fatal("缓存被后续调用污染了")
}
}
// 调用方复用 extends 底层数组时, 缓存键不能跟着被改写。
func TestGetStructExtendsAliasing(t *testing.T) {
res := newCacheTestResource(t)
buf := make([]reflect.StructField, 0, 4)
buf = append(buf, reflect.StructField{Name: "A", Type: reflect.TypeOf("")})
tA := reflect.TypeOf(res.GetStruct(buf...))
// 复用同一个底层数组换成另一组字段
buf = buf[:0]
buf = append(buf, reflect.StructField{Name: "B", Type: reflect.TypeOf("")})
tB := reflect.TypeOf(res.GetStruct(buf...))
if tA == tB {
t.Fatal("复用底层数组导致缓存键被改写, 拿到了错误的类型")
}
if _, ok := tA.Elem().FieldByName("A"); !ok {
t.Fatal("第一次的类型被污染了")
}
if _, ok := tB.Elem().FieldByName("B"); !ok {
t.Fatal("第二次的类型不对")
}
}
func TestGetSliceStructType(t *testing.T) {
res := newCacheTestResource(t)
st := reflect.TypeOf(res.GetSliceStruct())
if st.Kind() != reflect.Ptr || st.Elem().Kind() != reflect.Slice {
t.Fatalf("GetSliceStruct 应返回 *[]struct, 得到 %v", st)
}
if st.Elem().Elem() != reflect.TypeOf(res.GetStruct()).Elem() {
t.Fatal("GetSliceStruct 的元素类型应与 GetStruct 的一致")
}
if reflect.TypeOf(res.GetSliceStruct()) != st {
t.Fatal("两次 GetSliceStruct 类型不一致")
}
}
// 并发调用不能出现数据竞争, 且拿到的类型一致(配合 -race 跑)。
func TestGetStructConcurrent(t *testing.T) {
res := newCacheTestResource(t)
want := reflect.TypeOf(res.GetStruct())
const n = 16
done := make(chan reflect.Type, n)
for i := 0; i < n; i++ {
go func() {
var got reflect.Type
for j := 0; j < 100; j++ {
got = reflect.TypeOf(res.GetStruct())
}
done <- got
}()
}
for i := 0; i < n; i++ {
if got := <-done; got != want {
t.Fatalf("并发下拿到了不同的类型: %v vs %v", got, want)
}
}
}
func BenchmarkGetStruct(b *testing.B) {
res := newCacheTestResource(b)
res.GetStruct() // 预热
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = res.GetStruct()
}
}
func BenchmarkGetStructWithExtends(b *testing.B) {
res := newCacheTestResource(b)
ext := reflect.StructField{Name: "Extra", Type: reflect.TypeOf("")}
res.GetStruct(ext)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = res.GetStruct(ext)
}
}
func BenchmarkGetSliceStruct(b *testing.B) {
res := newCacheTestResource(b)
res.GetSliceStruct()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = res.GetSliceStruct()
}
}
-380
View File
@@ -1,380 +0,0 @@
// 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
// structCache 缓存 GetStruct 构造出来的类型,见 res_struct_cache.go
structCache resStructCache
}
// 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 {
return reflect.New(this.structType(extends)).Interface()
}
func (this *resource) GetSliceStruct(extends ...reflect.StructField) any {
return reflect.New(reflect.SliceOf(this.structType(extends))).Interface()
}
// structType 返回资源字段(可选叠加 extends)对应的 struct 类型。
//
// 结果按 extends 缓存: fields 在资源构造之后不再变化, 相同的 extends 必然得到
// 同一个类型, 没必要每次都走一遍 reflect.StructOf —— 它即使命中 runtime 的类型
// 缓存也要 2μs 上下并产生几十次分配, 详见 res_struct_cache.go。
func (this *resource) structType(extends []reflect.StructField) reflect.Type {
if t := this.structCache.load(extends); t != nil {
return t
}
fields := make([]reflect.StructField, 0, len(this.fields)+len(extends))
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)
this.structCache.store(extends, t)
return t
}
// isLocalDB 判断是否为本地文件型数据库(LastInsertId 返回最后一条而非第一条)
func isLocalDB(dialect string) bool {
return dialect == "sqlite3" || dialect == "duckdb"
}
-88
View File
@@ -1,88 +0,0 @@
package resx_test
import (
"testing"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/req"
"git.fsdpf.net/go/req/resx"
"git.fsdpf.net/go/req/userx"
"github.com/DATA-DOG/go-sqlmock"
"github.com/samber/do/v2"
"github.com/stretchr/testify/suite"
_ "git.fsdpf.net/go/db/dialect/mysql"
)
type resourceTest struct {
suite.Suite
res req.Resource
}
func TestResource(t *testing.T) {
suite.Run(t, new(resourceTest))
}
func (t *resourceTest) SetupSuite() {
mockDB, _, err := sqlmock.New()
t.Require().NoError(err)
app := do.New()
do.Provide(app, func(container do.Injector) (engine.Engine, error) {
return engine.Mock(map[string]engine.MockDBConfig{
"default": {Driver: "mysql", Mock: mockDB},
}), nil
})
t.res = resx.New(app, "User", "users",
resx.WithUuid("4bf3a311-cbe1-4236-bdda-c64ab04ae9b1"),
resx.WithName("用户"),
resx.WithConn("default"),
resx.WithFields(
resx.NewResField("name", "User", resx.FieldWithName("姓名"), resx.FieldWithDataType(req.ResString)),
resx.NewResField("fields", "User", resx.FieldWithName("字段"), resx.FieldWithDefault("{}"), resx.FieldWithDataType(req.ResJson)),
),
)
}
func (t *resourceTest) TestInsertRows() {
type Temp struct {
Name string `db:"name"`
Fields map[string]any `db:"fields"`
}
result := "INSERT INTO `users` (`created_user`, `fields`, `name`, `owned_user`) " +
"VALUES ('00000000-0000-0000-0000-000000000000', '{\\\"aa\\\":1}', '张三', '00000000-0000-0000-0000-000000000000')"
sql, _, _ := t.res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
db.Record{"name": "张三", "id": 1, "fields": map[string]any{"aa": 1}},
).Executor().ToSQL()
t.Equal(result, sql)
// 注意:db-v2 的 exp.NewRecordFromStruct 传入指针 struct 时会 panic
// util.SafeGetFieldByIndex 未对指针做 reflect.Indirect),此处暂不测试指针形式
sql, _, _ = t.res.GetDBTable(userx.GetAnonymous()).Insert().Rows(
Temp{Name: "张三", Fields: map[string]any{"aa": 1}},
).Executor().ToSQL()
t.Equal(result, sql)
}
func (t *resourceTest) TestInsertColsVals() {
result := "INSERT INTO `users` (`name`, `fields`, `created_user`, `owned_user`) VALUES " +
"('李四', '{\\\"aa\\\":1}', '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000'), " +
"('王五', '{\\\"aa\\\":3}', '00000000-0000-0000-0000-000000000000', '00000000-0000-0000-0000-000000000000')"
sql, _, _ := t.res.GetDBTable(userx.GetAnonymous()).Insert().Cols("name", "fields").Vals(
db.Vals{"李四", map[string]any{"aa": 1}},
db.Vals{"王五", map[string]any{"aa": 3}},
).Executor().ToSQL()
_ = result
_ = sql
// t.Equal(result, sql)
}
func (*resourceTest) TestInsertColsFromQuery() {}
-33
View File
@@ -1,33 +0,0 @@
package resx
import (
"sync"
"git.fsdpf.net/go/db/exp"
"git.fsdpf.net/go/req"
)
func NewVirtualResource(res req.Resource, code string, table exp.SQLExpression, opts ...Option) req.Resource {
pRes, ok := res.(*resource)
if !ok {
panic("resx.NewVirtualResource: res 必须是 resx.New 构造出来的 req.Resource")
}
r := &resource{
container: pRes.container,
conn: pRes.conn,
historyRoles: pRes.historyRoles,
initOnce: &sync.Once{},
uuid: code,
code: code,
name: code,
table: code,
isResVirtual: true,
virtualTable: table,
primarykey: "id",
}
for _, opt := range opts {
opt(r)
}
return r
}
+16 -48
View File
@@ -7,6 +7,7 @@ import (
) )
type RouteMethod string type RouteMethod string
type RouteCategory string
type RouteService string type RouteService string
type RouteParamType string type RouteParamType string
type RouteParamCategory string type RouteParamCategory string
@@ -20,21 +21,21 @@ const (
POST RouteMethod = "POST" POST RouteMethod = "POST"
PUT RouteMethod = "PUT" PUT RouteMethod = "PUT"
DELETE RouteMethod = "DELETE" DELETE RouteMethod = "DELETE"
WS RouteMethod = "WS"
SSE RouteMethod = "SSE"
GRPC RouteMethod = "GRPC"
MCP RouteMethod = "MCP"
STRUCTURE RouteMethod = "STRUCTURE"
) )
const ( const (
// resource_apis WS RouteCategory = "ws"
FUNC RouteService = "func" GRPC RouteCategory = "grpc"
SHOW RouteService = "show" FUNC RouteCategory = "func"
QUERY RouteService = "query" SHOW RouteCategory = "show"
STORE RouteService = "store" QUERY RouteCategory = "query"
DESTROY RouteService = "destroy" STORE RouteCategory = "store"
// system DESTROY RouteCategory = "destroy"
STRUCTURE RouteCategory = "structure"
)
const (
API RouteService = "api"
FSM RouteService = "fsm" FSM RouteService = "fsm"
LIST RouteService = "list" LIST RouteService = "list"
XLSX RouteService = "xlsx" XLSX RouteService = "xlsx"
@@ -79,20 +80,13 @@ type RouteMiddleware interface {
} }
type Router interface { type Router interface {
Call(r *http.Request, code string, params map[string]any, opts ...RouteMatchOption) (HttpResponse, error) Call(r *http.Request, code string, params map[string]any, category ...RouteCategory) (HttpResponse, error)
// CallWithUser 与 Call 相同,但内部转发请求时冒充成 u 的身份,而不是沿用 r 自带的身份 Get(uuid string, category ...RouteCategory) (Route, bool)
// (比如需要以系统用户身份发起内部调用)。
CallWithUser(r *http.Request, u User, code string, params map[string]any, opts ...RouteMatchOption) (HttpResponse, error)
Get(uuid string, opts ...RouteMatchOption) (Route, bool)
Register(cr chi.Router) Register(cr chi.Router)
RefreshRoutes() error RefreshRoutes() error
// Shutdown 关闭路由层管理的所有长连接(如 MCP SSE),
// 应通过 http.Server.RegisterOnShutdown 注册,在 HTTP 服务关闭时调用。
Shutdown()
} }
type Route interface { type Route interface {
Method() RouteMethod
GetUuid() string GetUuid() string
GetCode() string GetCode() string
GetPrimaryKey() string GetPrimaryKey() string
@@ -100,10 +94,10 @@ type Route interface {
GetUris() []string GetUris() []string
GetParamValues(*http.Request, ...RouteParam) (GlobalParams, error) GetParamValues(*http.Request, ...RouteParam) (GlobalParams, error)
GetResource() Resource GetResource() Resource
GetCategory() RouteCategory
GetService() RouteService GetService() RouteService
GetRoles() []string GetRoles() []string
MakeRequest(r *http.Request, params map[string]any) (*http.Request, error) MakeRequest(r *http.Request, params map[string]any) (*http.Request, error)
Version() string
} }
type RouteParam interface { type RouteParam interface {
@@ -113,29 +107,3 @@ type RouteParam interface {
IsRequired() bool IsRequired() bool
InjectRequestToGlobalParams(*http.Request, GlobalParams) error InjectRequestToGlobalParams(*http.Request, GlobalParams) error
} }
type RouteMatchOption func(opts *RouteMatches)
type RouteMatches struct {
methods []RouteMethod
services []RouteService
}
func (this RouteMatches) RouteMethods() []RouteMethod {
return this.methods
}
func (this RouteMatches) RouteServices() []RouteService {
return this.services
}
func RouteMatch[T RouteMethod | RouteService](v T) RouteMatchOption {
return func(opt *RouteMatches) {
switch value := any(v).(type) {
case RouteMethod:
opt.methods = append(opt.methods, value)
case RouteService:
opt.services = append(opt.services, value)
}
}
}
Executable → Regular
+29
View File
@@ -19,3 +19,32 @@ type User interface {
// 是否匿名用户 // 是否匿名用户
IsAnonymous() bool IsAnonymous() bool
} }
type UserRuntime struct {
platform string
saas string
}
// 账号运行平台
func (this UserRuntime) Platform() string {
return this.platform
}
// 账号运行租户
func (this UserRuntime) SaaS() string {
return this.saas
}
type UserRuntimeOption func(option *UserRuntime)
func UserRuntimePlatform(value string) UserRuntimeOption {
return func(option *UserRuntime) {
option.platform = value
}
}
func UserRuntimeSaaS(value string) UserRuntimeOption {
return func(option *UserRuntime) {
option.saas = value
}
}
-26
View File
@@ -1,26 +0,0 @@
package req
import "git.fsdpf.net/go/reflux/valuex"
type UserAccessor interface {
valuex.Accessor
User() User
}
type uAccessor struct {
valuex.Accessor
user User
}
func (u uAccessor) User() User {
return u.user
}
// NewUserAccessor 创建一个 UserAccessor 实例
// accessor 为 valuex.Accessor, user 为当前用户
func NewUserAccessor(accessor valuex.Accessor, user User) UserAccessor {
return &uAccessor{
Accessor: accessor,
user: user,
}
}
-77
View File
@@ -1,77 +0,0 @@
package req
import "context"
// UserRuntime 请求级别的运行时信息,随 req.User 一起传递
type UserRuntime interface {
// Platform 账号运行平台
Platform() string
// SaaS 账号运行租户
SaaS() string
// TraceId 请求追踪ID
TraceId() string
// Ctx 请求级别的上下文,用于挂载不确定/未来才需要的跨切面数据(如 ResWatcher 级联深度),
// 不用于传递 platform/saas/traceId 这类已知的核心参数
Ctx() context.Context
}
type uRuntime struct {
platform string
saas string
traceId string
ctx context.Context
}
func (this *uRuntime) Platform() string {
return this.platform
}
func (this *uRuntime) SaaS() string {
return this.saas
}
func (this *uRuntime) TraceId() string {
return this.traceId
}
func (this *uRuntime) Ctx() context.Context {
if this.ctx == nil {
this.ctx = context.Background()
}
return this.ctx
}
type UserRuntimeOption func(option *uRuntime)
func UserRuntimePlatform(value string) UserRuntimeOption {
return func(option *uRuntime) {
option.platform = value
}
}
func UserRuntimeSaaS(value string) UserRuntimeOption {
return func(option *uRuntime) {
option.saas = value
}
}
func UserRuntimeTraceId(value string) UserRuntimeOption {
return func(option *uRuntime) {
option.traceId = value
}
}
func UserRuntimeCtx(ctx context.Context) UserRuntimeOption {
return func(option *uRuntime) {
option.ctx = ctx
}
}
// NewUserRuntime 构造一个 UserRuntime
func NewUserRuntime(opts ...UserRuntimeOption) UserRuntime {
rt := &uRuntime{}
for _, opt := range opts {
opt(rt)
}
return rt
}
-79
View File
@@ -1,79 +0,0 @@
// Package userx 提供 req.User 的默认实现,跟 resx 之于 req.Resource 是同一个思路:接口留在
// req 包里保持依赖最小,具体实现(包括内置的匿名用户/系统用户)放在子包里,避免 req 包本身
// 引入实现所需的依赖(这里是 github.com/samber/lo)。
package userx
import (
"github.com/samber/lo"
"git.fsdpf.net/go/req"
)
type user struct {
id int64
uuid string
username string
nickname string
roles []string
runtime req.UserRuntime
}
func (this user) ID() int64 {
return this.id
}
func (this user) Uuid() string {
return this.uuid
}
func (this user) Username() string {
return this.username
}
func (this user) Nickname() string {
return this.nickname
}
func (this user) GetUserInfo() map[string]any {
return nil
}
func (this user) Roles() (roles []string) {
return this.roles
}
func (this user) HasUserRoles(roles ...string) bool {
return lo.Contains(this.Roles(), "ffffffff-ffff-ffff-ffff-ffffffffffff") || len(lo.Intersect(this.Roles(), roles)) > 0
}
func (this user) IsAnonymous() bool {
return this.id == 0
}
func (this user) Runtime() req.UserRuntime {
return this.runtime
}
// GetAnonymous 获取匿名用户
func GetAnonymous(opts ...req.UserRuntimeOption) req.User {
return user{
id: 0,
uuid: "00000000-0000-0000-0000-000000000000",
username: "anonymous",
nickname: "匿名者",
roles: []string{"00000000-0000-0000-0000-000000000000"},
runtime: req.NewUserRuntime(opts...),
}
}
// GetSystemUser 系统用户
func GetSystemUser(opts ...req.UserRuntimeOption) req.User {
return user{
id: -1,
uuid: "ffffffff-ffff-ffff-ffff-ffffffffffff",
username: "system",
nickname: "系统",
roles: []string{"ffffffff-ffff-ffff-ffff-ffffffffffff"},
runtime: req.NewUserRuntime(opts...),
}
}