重构: base 包资源核心类型迁移到 req/resx,新增 res_watcher/res_api_param

base/resource.go、resource_hooks.go、resource_test.go、query_field.go 删除,ResField 等具体实现搬到 req/resx(见 res_field.go 里的类型别名)。res_listener.go 替换成 res_watcher.go,对应资源变更监听概念改名。新增 res_api_param.go 及配套测试(ResApi 参数建模,给 MCP tool 的 JSON Schema 生成用)。
This commit is contained in:
2026-07-22 09:13:05 +08:00
parent 53169264da
commit 9446571363
20 changed files with 2882 additions and 1427 deletions
+19 -19
View File
@@ -1,25 +1,25 @@
package base package base
type GridLayout struct { type GridLayout struct {
Uuid string `db:"uuid"` Uuid string `db:"uuid"`
ResourceUuid string `db:"resource_uuid"` // 所属资源UUID ResourceUuid string `db:"resource_uuid"` // 所属资源UUID
Code string `db:"code"` // 布局标识 Code string `db:"code"` // 布局标识
Name string `db:"name"` // 布局名称 Name string `db:"name"` // 布局名称
Type int `db:"type"` // 布局类型 [0=普通布局, 1=辅助布局] Type int `db:"type"` // 布局类型 [0=普通布局, 1=辅助布局]
PrimaryKey string `db:"primaryKey"` // 主键 PrimaryKey string `db:"primaryKey"` // 主键
MarginX int `db:"marginX"` // 栅格 x 间距 ItemMargin []any `db:"itemMargin"` // 栅格间距
MarginY int `db:"marginY"` // 栅格 y 间 ContainerPadding []any `db:"containerPadding"` // 容器内边
Cols int `db:"cols"` // 网格列基数 Cols int `db:"cols"` // 网格列基数
RowHeight float32 `db:"rowHeight"` // 栅格行高 RowHeight float32 `db:"rowHeight"` // 栅格行高
Css map[string]any `db:"css"` // 布局CSS Css map[string]any `db:"css"` // 布局CSS
Props []any `db:"props"` // 布局PROPS Props []any `db:"props"` // 布局PROPS
Groups []map[string]any `db:"groups"` // 布局分组 Groups []map[string]any `db:"groups"` // 布局分组
Theme string `db:"theme"` // 布局样式 Theme string `db:"theme"` // 布局样式
ThemeConfig map[string]any `db:"themeConfig"` // 布局样式配置 ThemeConfig map[string]any `db:"themeConfig"` // 布局样式配置
Platform string `db:"platform"` Platform string `db:"platform"`
Roles []string `db:"roles"` Roles []string `db:"roles"`
UpdatedAt string `db:"updated_at"` UpdatedAt string `db:"updated_at"`
CreatedAt string `db:"created_at"` CreatedAt string `db:"created_at"`
} }
type GetGridLayout func(code string) (GridLayout, bool) type GetGridLayout func(code string) (GridLayout, bool)
-124
View File
@@ -1,124 +0,0 @@
package base
import (
"reflect"
"strings"
"unicode"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/req"
)
type QueryField struct {
ResField
typ req.RouteParamType `db:"dataType"`
alias string `db:"alias"`
isExpr bool `db:"isExpr"`
isOmitempty bool
ignored bool
}
func (this QueryField) Alias() string {
return this.alias
}
func (this QueryField) Ignored() bool {
return this.ignored
}
func (this QueryField) IsExpr() bool {
return this.isExpr
}
func (this QueryField) IsOmitempty() bool {
return this.isOmitempty
}
func (this QueryField) Type() req.RouteParamType {
return this.typ
}
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
}
func (this QueryField) GetCodeOrAlias() string {
if this.Alias() != "" {
return this.Alias()
}
return this.Code
}
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(this.GetCode()).As(this.Alias())
}
return db.T(this.GetCodeResource()).Col(this.GetCode()).As(this.Alias())
}
return db.T(this.GetCodeResource()).Col(this.GetCode())
}
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(string(""))
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,
}
}
+5 -8
View File
@@ -1,22 +1,17 @@
package base package base
type ResApiParam struct {
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
Category string `db:"category" json:"category"`
DataType string `db:"type" json:"type"`
IsRequired bool `db:"isRequired" json:"isRequired"`
}
type ResApi struct { type ResApi struct {
Uuid string `db:"uuid"` Uuid string `db:"uuid"`
PUuid string `db:"pUuid"`
Code string `db:"code"` Code string `db:"code"`
UriPrefix string `db:"uri_prefix"` UriPrefix string `db:"uri_prefix"`
Name string `db:"name"` Name string `db:"name"`
Desc string `db:"desc"`
PrimaryKey string `db:"primaryKey"` PrimaryKey string `db:"primaryKey"`
ResourceUuid string `db:"resource_uuid"` ResourceUuid string `db:"resource_uuid"`
Method string `db:"method"` Method string `db:"method"`
Action string `db:"action"` Action string `db:"action"`
Version string `db:"version"`
Params []ResApiParam `db:"params"` Params []ResApiParam `db:"params"`
Roles []string `db:"roles"` Roles []string `db:"roles"`
IsAuthDB int `db:"is_auth_db"` // 启用权限过滤 IsAuthDB int `db:"is_auth_db"` // 启用权限过滤
@@ -25,3 +20,5 @@ type ResApi struct {
} }
type GetResApi func(code string) (ResApi, bool) type GetResApi func(code string) (ResApi, bool)
type GetResApiChilds func(code string) []ResApi
+423
View File
@@ -0,0 +1,423 @@
package base
import (
"encoding/json"
"sort"
"strconv"
jschema "github.com/google/jsonschema-go/jsonschema"
"github.com/spf13/cast"
)
type ResApiParam struct {
ID int64 `db:"id" json:"id"`
PID int64 `db:"pid" json:"pid"`
Code string `db:"code" json:"code"`
Name string `db:"name" json:"name"`
Category string `db:"category" json:"category"`
DataType string `db:"type" json:"type"`
Desc string `db:"desc" json:"desc"`
IsRequired bool `db:"isRequired" json:"isRequired"`
Constraints map[string]any `db:"constraints" json:"constraints"`
Rank int `db:"rank" json:"rank"`
RefID int64 `db:"ref_id" json:"ref_id"`
DefaultVal string `db:"df_val" json:"df_val"`
}
type ResApiParams []ResApiParam
// ToJSONSchema 将扁平参数列表还原为 JSON Schema 对象
func (params ResApiParams) ToJSONSchema() *jschema.Schema {
return params.buildObject(0)
}
// ResApiParamsFromJSONSchema 将 JSON Schema 对象解析为扁平参数列表
func ResApiParamsFromJSONSchema(schema *jschema.Schema) ResApiParams {
var params ResApiParams
var seq int64
params.parseObject(schema, 0, &seq)
return params
}
func (params ResApiParams) childrenOf(pid int64) ResApiParams {
var result ResApiParams
for _, p := range params {
if p.PID == pid {
result = append(result, p)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].Rank < result[j].Rank
})
return result
}
func (params ResApiParams) buildObject(pid int64) *jschema.Schema {
s := &jschema.Schema{Type: "object"}
props := map[string]*jschema.Schema{}
for _, p := range params.childrenOf(pid) {
if p.Code == "items" || p.Code == "oneOf" {
continue
}
props[p.Code] = params.buildProperty(p)
if p.IsRequired {
s.Required = append(s.Required, p.Code)
}
}
if len(props) > 0 {
s.Properties = props
}
sort.Strings(s.Required)
return s
}
func (params ResApiParams) findByID(id int64) (ResApiParam, bool) {
for _, p := range params {
if p.ID == id {
return p, true
}
}
return ResApiParam{}, false
}
// pathOf 计算参数在生成的 JSON Schema 中的路径
func (params ResApiParams) pathOf(id int64) string {
p, ok := params.findByID(id)
if !ok {
return ""
}
if p.PID == 0 {
return "#/properties/" + p.Code
}
parent := params.pathOf(p.PID)
switch p.Code {
case "items":
return parent + "/items"
case "oneOf":
return parent + "/items/oneOf/" + strconv.Itoa(p.Rank)
default:
return parent + "/properties/" + p.Code
}
}
func (params ResApiParams) buildProperty(p ResApiParam) *jschema.Schema {
if p.RefID != 0 {
if _, ok := params.findByID(p.RefID); ok {
return &jschema.Schema{Ref: params.pathOf(p.RefID), Description: p.Desc}
}
}
// any 类型:不设 type,从 oneOf 子节点构建
if p.DataType == "any" {
s := &jschema.Schema{Title: p.Name, Description: p.Desc}
if p.DefaultVal != "" {
var tmp any
if err := json.Unmarshal([]byte(p.DefaultVal), &tmp); err == nil {
s.Default = json.RawMessage(p.DefaultVal)
} else {
s.Default, _ = json.Marshal(p.DefaultVal)
}
}
for _, child := range params.childrenOf(p.ID) {
c := child
if child.Code == "oneOf" {
s.OneOf = append(s.OneOf, params.buildProperty(c))
}
}
params.applyConstraints(s, p.Constraints)
return s
}
// 归一化 DataTypejson → object;空字符串根据子节点推断
dataType := p.DataType
switch dataType {
case "json":
dataType = "object"
case "bool":
dataType = "boolean"
case "":
for _, c := range params.childrenOf(p.ID) {
if c.Code == "oneOf" || c.Code == "items" {
dataType = "array"
break
}
}
if dataType == "" && len(params.childrenOf(p.ID)) > 0 {
dataType = "object"
}
}
s := &jschema.Schema{
Type: dataType,
Title: p.Name,
Description: p.Desc,
}
if p.DefaultVal != "" {
var tmp any
if err := json.Unmarshal([]byte(p.DefaultVal), &tmp); err == nil {
s.Default = json.RawMessage(p.DefaultVal)
} else {
s.Default, _ = json.Marshal(p.DefaultVal)
}
}
params.applyConstraints(s, p.Constraints)
switch dataType {
case "object":
nested := params.buildObject(p.ID)
s.Properties = nested.Properties
s.Required = nested.Required
case "array":
var oneOfSchemas []*jschema.Schema
var itemsChild *ResApiParam
for _, child := range params.childrenOf(p.ID) {
c := child
switch child.Code {
case "oneOf":
oneOfSchemas = append(oneOfSchemas, params.buildProperty(c))
case "items":
itemsChild = &c
}
}
if len(oneOfSchemas) > 0 {
s.Items = &jschema.Schema{OneOf: oneOfSchemas}
} else if itemsChild != nil {
s.Items = params.buildItems(*itemsChild)
}
}
return s
}
func (params ResApiParams) buildItems(p ResApiParam) *jschema.Schema {
if ref, ok := p.Constraints["$ref"].(string); ok {
return &jschema.Schema{Ref: ref}
}
if p.RefID != 0 {
if ref, ok := params.findByID(p.RefID); ok {
path := params.pathOf(p.RefID)
if ref.DataType == "array" {
path += "/items"
}
return &jschema.Schema{Ref: path}
}
}
itemType := p.DataType
if itemType == "json" {
itemType = "object"
} else if itemType == "" && len(params.childrenOf(p.ID)) > 0 {
itemType = "object"
}
s := &jschema.Schema{
Type: itemType,
Title: p.Name,
Description: p.Desc,
}
if s.Description == "" && s.Title != "" {
s.Description = s.Title
}
if itemType == "object" {
nested := params.buildObject(p.ID)
s.Properties = nested.Properties
s.Required = nested.Required
}
params.applyConstraints(s, p.Constraints)
return s
}
func (params ResApiParams) applyConstraints(s *jschema.Schema, c map[string]any) {
for k, v := range c {
switch k {
case "$ref":
if ref, ok := v.(string); ok {
s.Ref = ref
}
case "enum":
if arr, ok := v.([]any); ok {
s.Enum = arr
}
case "format":
if f, ok := v.(string); ok {
s.Format = f
}
case "pattern":
if p, ok := v.(string); ok {
s.Pattern = p
}
case "minLength":
s.MinLength = jschema.Ptr(cast.ToInt(v))
case "maxLength":
s.MaxLength = jschema.Ptr(cast.ToInt(v))
case "minimum":
s.Minimum = jschema.Ptr(cast.ToFloat64(v))
case "maximum":
s.Maximum = jschema.Ptr(cast.ToFloat64(v))
case "multipleOf":
s.MultipleOf = jschema.Ptr(cast.ToFloat64(v))
case "minItems":
s.MinItems = jschema.Ptr(cast.ToInt(v))
case "maxItems":
s.MaxItems = jschema.Ptr(cast.ToInt(v))
case "minProperties":
s.MinProperties = jschema.Ptr(cast.ToInt(v))
case "maxProperties":
s.MaxProperties = jschema.Ptr(cast.ToInt(v))
case "additionalProperties":
switch val := v.(type) {
case bool:
if val {
s.AdditionalProperties = &jschema.Schema{}
} else {
s.AdditionalProperties = &jschema.Schema{Not: &jschema.Schema{}}
}
}
}
}
}
func (params *ResApiParams) parseObject(schema *jschema.Schema, pid int64, seq *int64) {
requiredSet := make(map[string]bool, len(schema.Required))
for _, r := range schema.Required {
requiredSet[r] = true
}
rank := 0
for code, prop := range schema.Properties {
*seq++
id := *seq
p := ResApiParam{
ID: id,
PID: pid,
Code: code,
DataType: prop.Type,
Name: prop.Title,
Desc: prop.Description,
IsRequired: requiredSet[code],
DefaultVal: params.rawToStr(prop.Default),
Rank: rank,
Constraints: params.extractConstraints(prop),
}
*params = append(*params, p)
rank++
switch prop.Type {
case "object":
params.parseObject(prop, id, seq)
case "array":
if prop.Items == nil {
break
}
// items 为纯 oneOf schema:建立 oneOf 子行,不建 items 子行
if len(prop.Items.OneOf) > 0 {
for oneOfRank, sub := range prop.Items.OneOf {
*seq++
oneOfID := *seq
itemType := sub.Type
if itemType == "" {
itemType = "object"
}
*params = append(*params, ResApiParam{
ID: oneOfID,
PID: id,
Code: "oneOf",
DataType: itemType,
Name: sub.Title,
Desc: sub.Description,
Rank: oneOfRank,
Constraints: params.extractConstraints(sub),
})
if itemType == "object" {
params.parseObject(sub, oneOfID, seq)
}
}
break
}
*seq++
itemID := *seq
itemType := prop.Items.Type
if itemType == "" {
itemType = "object"
}
*params = append(*params, ResApiParam{
ID: itemID,
PID: id,
Code: "items",
DataType: itemType,
Name: prop.Items.Title,
Desc: prop.Items.Description,
Constraints: params.extractConstraints(prop.Items),
})
if itemType == "object" {
params.parseObject(prop.Items, itemID, seq)
}
}
}
}
func (params ResApiParams) extractConstraints(s *jschema.Schema) map[string]any {
c := map[string]any{}
if s.Ref != "" {
c["$ref"] = s.Ref
}
if len(s.Enum) > 0 {
c["enum"] = s.Enum
}
if s.Format != "" {
c["format"] = s.Format
}
if s.Pattern != "" {
c["pattern"] = s.Pattern
}
if s.MinLength != nil {
c["minLength"] = *s.MinLength
}
if s.MaxLength != nil {
c["maxLength"] = *s.MaxLength
}
if s.Minimum != nil {
c["minimum"] = *s.Minimum
}
if s.Maximum != nil {
c["maximum"] = *s.Maximum
}
if s.MultipleOf != nil {
c["multipleOf"] = *s.MultipleOf
}
if s.MinItems != nil {
c["minItems"] = *s.MinItems
}
if s.MaxItems != nil {
c["maxItems"] = *s.MaxItems
}
if s.MinProperties != nil {
c["minProperties"] = *s.MinProperties
}
if s.MaxProperties != nil {
c["maxProperties"] = *s.MaxProperties
}
if s.AdditionalProperties != nil {
c["additionalProperties"] = s.AdditionalProperties.Not == nil
}
if len(c) == 0 {
return nil
}
return c
}
func (params ResApiParams) rawToStr(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var s string
if err := json.Unmarshal(raw, &s); err == nil {
return s
}
return string(raw)
}
+446
View File
@@ -0,0 +1,446 @@
package base_test
import (
"encoding/json"
"reflect"
"testing"
"git.fsdpf.net/go/contracts/base"
jschema "github.com/google/jsonschema-go/jsonschema"
)
// queryParams 查询接口的扁平参数列表,包含 conditions(过滤条件)和 select(查询字段)
var queryParams = base.ResApiParams{
// resource:目标资源
{
ID: -1, PID: 0, Code: "resource", DataType: "string", Name: "资源", Rank: 0,
Desc: "查询目标资源编码(resource_code",
},
{
ID: 1, PID: 0, Code: "conditions", DataType: "object", Name: "过滤条件", Rank: 1,
Desc: `WHERE 条件树。
exprs 每项生成一段比较:[{columnSqlFunc}(]{columnResource}.{column}[)] {operator} {value},同层多项以 type(and/or) 连接;
children 为同结构子节点,递归嵌套实现复杂过滤。`,
},
{
ID: 2, PID: 1, Code: "type", DataType: "string", Name: "逻辑运算符", Rank: 0,
Desc: "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接",
DefaultVal: "and",
Constraints: map[string]any{"enum": []any{"and", "or"}},
},
{
ID: 3, PID: 1, Code: "exprs", DataType: "array", Name: "条件表达式列表", Rank: 1,
Desc: "当前层的叶子比较列表,每项生成一个 column {operator} value 片段,同层多项以 type 连接",
},
{ID: 4, PID: 3, Code: "items", DataType: "object", Rank: 0},
{
ID: 5, PID: 4, Code: "column", DataType: "string", Name: "字段", Rank: 0,
Desc: "字段编码", IsRequired: true,
},
{
ID: 6, PID: 4, Code: "columnResource", DataType: "string", Name: "字段资源", Rank: 1,
Desc: "字段所属资源标识,取值为顶层 resource 或 relations[].code",
},
{
ID: 7, PID: 4, Code: "operator", DataType: "string", Name: "运算符", Rank: 2,
Desc: `标准 SQL 比较运算符:
= / != / > / >= / < / <=
LIKE:模糊匹配(%keyword%
IN:包含,value 逗号分隔多值
IS NULL / IS NOT NULL:空值判断,无需 value
REGEXP:正则匹配`,
IsRequired: true,
DefaultVal: "=",
},
{
ID: 8, PID: 4, Code: "value", DataType: "string", Name: "比较值", Rank: 3,
Desc: "比较目标,内容由 valueType 决定。operator 为 IS NULL/IS NOT NULL 时可省略",
},
{
ID: 9, PID: 4, Code: "valueType", DataType: "string", Name: "值类型", Rank: 4,
Desc: `先选类型再填 value
stringvalue 填字面量如 '2024-01-01'
funcvalue 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS
paramvalue 填请求参数路径如 body.status
sqlvalue 填原始SQL如 CURRENT_DATE
默认 string`,
Constraints: map[string]any{"enum": []any{"string", "func", "sql", "param"}},
},
{
ID: 10, PID: 4, Code: "columnSqlFunc", DataType: "string", Name: "列SQL函数", Rank: 5,
Desc: `对列应用 SQL 函数后再参与比较,拼接为 {func}({columnResource}.{column}) {operator} {value}。
支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。
需要第二参数的函数(如 ROUND、CAST)配合 columnSqlFuncParam 使用。`,
},
{
ID: 13, PID: 4, Code: "columnSqlFuncParam", DataType: "string", Name: "SQL函数参数", Rank: 6,
Desc: `columnSqlFunc 的附加参数,按函数类型用途不同:
通用函数(有 param 时):{func}({columnResource}.{column}, {columnSqlFuncParam}) {operator} {value}
示例:ROUND(col, 2) → columnSqlFuncParam="2"
SUBSTRING(col, 1, 10) → columnSqlFuncParam="1, 10"
JSON 函数(param 为 JSON 路径表达式):
json_member_ofvalue 是否是列 JSON 数组的成员):
无 paramJSON_CONTAINS({value}, JSON_ARRAY({column}))
有 paramJSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({column}))
json_contains(列 JSON 是否包含 value):
无 paramJSON_CONTAINS({column}, JSON_ARRAY({value}))
有 paramJSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({value}))`,
},
{
ID: 14, PID: 4, Code: "ignoreEmptyParam", DataType: "boolean", Name: "忽略空参数", Rank: 7,
Desc: "当 valueType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项,默认 false",
},
{
ID: 11, PID: 1, Code: "children", DataType: "array", Name: "子条件", Rank: 2,
Desc: "子条件节点,递归结构",
},
{ID: 12, PID: 11, Code: "items", DataType: "object", RefID: 1, Rank: 0},
// fields 用 oneOf 子行表示
{
ID: 15, PID: 0, Code: "fields", DataType: "array", Name: "查询字段", Rank: 2,
Desc: "查询字段,不传则返回所有字段",
},
{
ID: 55, PID: 15, Code: "oneOf", DataType: "string", Name: "字段", Rank: 0,
Desc: "直接填字段编码,默认归属于顶层 resource。等价于 {column: \"field\", columnResource: resource}",
},
{ID: 16, PID: 15, Code: "oneOf", DataType: "object", Name: "字段引用", Rank: 1},
{ID: 44, PID: 16, Code: "column", DataType: "string", Name: "字段", Rank: 0, IsRequired: true},
{
ID: 46, PID: 16, Code: "columnResource", DataType: "string", Name: "字段资源", Rank: 1,
Desc: "字段所属资源标识,取值为顶层 resource 或 relations[].code",
},
{ID: 45, PID: 16, Code: "alias", DataType: "string", Name: "结果别名", Rank: 2},
{ID: 17, PID: 15, Code: "oneOf", DataType: "object", Name: "表达式字段", Rank: 2},
{ID: 18, PID: 17, Code: "expr", DataType: "string", Name: "SQL 表达式", Rank: 0, IsRequired: true,
Desc: "{column} / {columnResource}.{column} / SQL 表达式"},
{ID: 19, PID: 17, Code: "alias", DataType: "string", Name: "结果别名", Rank: 1},
// orderByitems 用 oneOf 表示三种形式,每个变体含 direction
{
ID: 20, PID: 0, Code: "orderBy", DataType: "array", Name: "排序规则", Rank: 3,
Desc: "排序规则列表,多项按顺序依次应用",
},
{ID: 48, PID: 20, Code: "oneOf", DataType: "object", Name: "资源字段排序", Rank: 1},
{ID: 49, PID: 48, Code: "column", DataType: "string", Name: "字段", Rank: 0, IsRequired: true},
{
ID: 50, PID: 48, Code: "columnResource", DataType: "string", Name: "字段资源", Rank: 1,
Desc: "字段所属资源标识,取值为顶层 resource 或 relations[].code",
},
{
ID: 53, PID: 48, Code: "direction", RefID: 56,
},
{ID: 51, PID: 20, Code: "oneOf", DataType: "object", Name: "SQL 表达式排序", Rank: 2},
{ID: 52, PID: 51, Code: "expr", DataType: "string", Name: "字段表达式", Rank: 0, IsRequired: true},
{
ID: 54, PID: 51, Code: "direction", RefID: 56,
},
{
ID: 57, PID: 20, Code: "oneOf", DataType: "string", Name: "SQL 表达式 (ASC)", Rank: 0,
Desc: "直接填字段编码,默认升序(asc)",
},
// groupBy:分组字段
{
ID: 24, PID: 0, Code: "groupBy", DataType: "array", Name: "分组规则", Rank: 4,
Desc: "GROUP BY 字段列表,配合 fields 中的聚合字段使用",
},
{
ID: 58, PID: 24, Code: "oneOf", DataType: "string", Name: "分组表达式", Desc: "{column} / {columnResource}.{column} / SQL 表达式", Rank: 0,
},
{
ID: 59, PID: 24, Code: "oneOf", DataType: "object", Name: "资源字段分组", Rank: 0,
},
{ID: 60, PID: 59, Code: "column", DataType: "string", Name: "字段编码", Rank: 0, IsRequired: true},
{
ID: 61, PID: 59, Code: "columnResource", DataType: "string", Name: "所属资源", Rank: 1,
Desc: "字段所属资源标识,取值为顶层 resource 或 relations[].code",
},
// limit / offset:分页
{
ID: 26, PID: 0, Code: "limit", DataType: "integer", Name: "每页条数", Rank: 5,
Desc: "返回的最大记录数,默认 30", DefaultVal: "30", Constraints: map[string]any{"maximum": 50},
},
{
ID: 27, PID: 0, Code: "offset", DataType: "integer", Name: "偏移量", Rank: 6,
Desc: "跳过的记录数,默认 0",
},
// relations:资源关联
{
ID: 28, PID: 0, Code: "relations", DataType: "array", Name: "资源关联", Rank: 7,
Desc: "关联配置列表。\ninner / left / rightSQL JOIN,要求关联资源与主资源在同一数据库连接,将关联字段合并到主查询行。\nhasOne / hasMany:独立子查询,支持跨数据库连接,结果聚合为 JSON 字段(hasOne 为单对象,hasMany 为数组)。\n跨库关联必须使用 hasOne 或 hasMany,不可使用 JOIN 类型。",
},
{ID: 29, PID: 28, Code: "items", DataType: "object", Rank: 0},
{
ID: 30, PID: 29, Code: "code", DataType: "string", Name: "关联标识", Rank: 0,
Desc: "关联唯一标识,同时作为别名:JOIN 时为表别名(AS code),子查询时为结果 JSON 字段的键名",
IsRequired: true,
},
{
ID: 38, PID: 29, Code: "name", DataType: "string", Name: "关联名称", Rank: 1,
Desc: "关联的显示名称",
},
{
ID: 39, PID: 29, Code: "type", DataType: "string", Name: "关联类型", Rank: 2,
Desc: `关联模式:
inner / left / rightSQL JOIN,要求与主资源同一数据库连接,将关联字段合并到主查询行
hasOne:独立子查询,支持跨库,结果聚合为单个对象(result[code] = {}
hasMany:独立子查询,支持跨库,结果聚合为对象数组(result[code] = [...]
跨数据库连接的关联必须使用 hasOne 或 hasMany`,
Constraints: map[string]any{"enum": []any{"inner", "left", "right", "hasOne", "hasMany"}},
},
{ID: 40, PID: 29, Code: "actuallyResource", DataType: "string", Name: "实际资源", Rank: 3,
Desc: "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}"},
{ID: 41, PID: 29, Code: "actuallyField", DataType: "string", Name: "实际资源字段", Rank: 4,
Desc: "目标资源(code)参与 ON 条件的字段"},
{ID: 42, PID: 29, Code: "relationResource", DataType: "string", Name: "被关联资源", Rank: 5,
Desc: "ON 条件对端的资源编码(通常为主资源或父关联资源)"},
{ID: 43, PID: 29, Code: "relationField", DataType: "string", Name: "被关联字段", Rank: 6,
Desc: "ON 条件对端资源的字段"},
{ID: 31, PID: 29, Code: "conditions", RefID: 1, Rank: 7, Desc: "关联的过滤条件"},
{ID: 33, PID: 29, Code: "orderBy", RefID: 20, Rank: 8, Desc: "关联排序,仅 hasOne/hasMany 有效"},
{ID: 34, PID: 29, Code: "groupBy", RefID: 24, Rank: 9, Desc: "关联分组,仅 hasOne/hasMany 有效"},
{ID: 35, PID: 29, Code: "limit", RefID: 26, Rank: 10, Desc: "关联条数限制,仅 hasOne/hasMany 有效"},
{ID: 36, PID: 29, Code: "offset", RefID: 27, Rank: 11, Desc: "关联偏移量,仅 hasOne/hasMany 有效"},
}
func TestResApiParams_PrintSchema(t *testing.T) {
b, _ := json.MarshalIndent(queryParams.ToJSONSchema(), "", " ")
t.Log("\n" + string(b))
}
func TestResApiParams_ToJSONSchema(t *testing.T) {
schema := queryParams.ToJSONSchema()
if schema.Type != "object" {
t.Fatalf("expected type=object, got %s", schema.Type)
}
cond, ok := schema.Properties["conditions"]
if !ok {
t.Fatal("missing property: conditions")
}
if cond.Type != "object" {
t.Errorf("conditions: expected type=object, got %s", cond.Type)
}
if cond.Description != "WHERE 条件树。\nexprs 每项生成一段比较:[{columnSqlFunc}(]{columnResource}.{column}[)] {operator} {value},同层多项以 type(and/or) 连接;\nchildren 为同结构子节点,递归嵌套实现复杂过滤。" {
t.Errorf("conditions: unexpected description: %s", cond.Description)
}
// conditions.type 枚举和默认值
typeProp, ok := cond.Properties["type"]
if !ok {
t.Fatal("missing property: conditions.type")
}
if len(typeProp.Enum) != 2 {
t.Errorf("conditions.type: expected 2 enum values, got %d", len(typeProp.Enum))
}
var defaultVal string
if err := json.Unmarshal(typeProp.Default, &defaultVal); err != nil || defaultVal != "and" {
t.Errorf("conditions.type: expected default=and, got %s", typeProp.Default)
}
// conditions.exprs 是 arrayitems 是 object
exprsProp, ok := cond.Properties["exprs"]
if !ok {
t.Fatal("missing property: conditions.exprs")
}
if exprsProp.Type != "array" {
t.Errorf("conditions.exprs: expected type=array, got %s", exprsProp.Type)
}
if exprsProp.Items == nil {
t.Fatal("conditions.exprs: missing items")
}
if exprsProp.Items.Type != "object" {
t.Errorf("conditions.exprs.items: expected type=object, got %s", exprsProp.Items.Type)
}
// exprs.items required: column, operator
requiredSet := map[string]bool{}
for _, r := range exprsProp.Items.Required {
requiredSet[r] = true
}
if !requiredSet["column"] || !requiredSet["operator"] {
t.Errorf("conditions.exprs.items: expected required=[column, operator], got %v", exprsProp.Items.Required)
}
// children.items $ref
childrenProp, ok := cond.Properties["children"]
if !ok {
t.Fatal("missing property: conditions.children")
}
if childrenProp.Items == nil {
t.Fatal("conditions.children: missing items")
}
if childrenProp.Items.Ref != "#/properties/conditions" {
t.Errorf("conditions.children.items: expected $ref=#/properties/conditions, got %s", childrenProp.Items.Ref)
}
}
func TestResApiParamsFromJSONSchema(t *testing.T) {
original := queryParams.ToJSONSchema()
params := base.ResApiParamsFromJSONSchema(original)
if len(params) == 0 {
t.Fatal("ResApiParamsFromJSONSchema returned empty params")
}
// 转回 schema 后结构应一致
rebuilt := params.ToJSONSchema()
// 将 original 和 rebuilt 都序列化为 map[string]any 后 DeepEqual 比较
toMap := func(s *jschema.Schema) map[string]any {
b, _ := json.Marshal(s)
var m map[string]any
json.Unmarshal(b, &m)
return m
}
originalMap := toMap(original)
rebuiltMap := toMap(rebuilt)
if !reflect.DeepEqual(originalMap, rebuiltMap) {
ob, _ := json.MarshalIndent(originalMap, "", " ")
rb, _ := json.MarshalIndent(rebuiltMap, "", " ")
t.Errorf("schema mismatch:\noriginal: %s\nrebuilt: %s", ob, rb)
}
}
// createParams res-create 接口参数,data 字段为 any 类型(oneOf object | array<object>
var createParams = base.ResApiParams{
{ID: 315, PID: 0, Code: "resource", DataType: "string", Name: "主资源", Rank: 1,
Desc: "目标资源编码,数据将写入该资源对应的表"},
{ID: 316, PID: 0, Code: "data", DataType: "any", Name: "创建数据", Rank: 2,
Desc: "单条记录传对象,批量创建传对象数组"},
// oneOf 分支1:单条记录(object + additionalProperties: true
{ID: 317, PID: 316, Code: "oneOf", DataType: "json", Name: "单条记录", Rank: 1,
Desc: "创建单条记录,key 为字段编码,value 为字段值",
Constraints: map[string]any{"additionalProperties": true}},
// oneOf 分支2:批量记录(arrayitems 为 object + additionalProperties: true
{ID: 318, PID: 316, Code: "oneOf", DataType: "array", Name: "批量记录", Rank: 2,
Desc: "批量创建,每个元素为一条记录对象"},
{ID: 319, PID: 318, Code: "items", DataType: "json", Name: "记录对象", Rank: 1,
Desc: "批量创建,每个元素为一条记录对象",
Constraints: map[string]any{"additionalProperties": true}},
}
func TestResApiParams_AnyType(t *testing.T) {
schema := createParams.ToJSONSchema()
// data 字段存在
data, ok := schema.Properties["data"]
if !ok {
t.Fatal("missing property: data")
}
// any 类型不应有 type
if data.Type != "" {
t.Errorf("data: expected empty type for any, got %q", data.Type)
}
// oneOf 应有 2 个分支
if len(data.OneOf) != 2 {
t.Fatalf("data.oneOf: expected 2 branches, got %d", len(data.OneOf))
}
// 分支1object
branch1 := data.OneOf[0]
if branch1.Type != "object" {
t.Errorf("data.oneOf[0]: expected type=object, got %q", branch1.Type)
}
if branch1.AdditionalProperties == nil {
t.Error("data.oneOf[0]: expected additionalProperties to be set")
}
// 分支2arrayitems 为 object
branch2 := data.OneOf[1]
if branch2.Type != "array" {
t.Errorf("data.oneOf[1]: expected type=array, got %q", branch2.Type)
}
if branch2.Items == nil {
t.Fatal("data.oneOf[1]: missing items")
}
if branch2.Items.Type != "object" {
t.Errorf("data.oneOf[1].items: expected type=object, got %q", branch2.Items.Type)
}
if branch2.Items.AdditionalProperties == nil {
t.Error("data.oneOf[1].items: expected additionalProperties to be set")
}
}
func TestResApiParams_AdditionalProperties(t *testing.T) {
params := base.ResApiParams{
{ID: 1, PID: 0, Code: "open", DataType: "json", Name: "开放对象",
Constraints: map[string]any{"additionalProperties": true}},
{ID: 2, PID: 0, Code: "strict", DataType: "json", Name: "严格对象",
Constraints: map[string]any{"additionalProperties": false}},
}
schema := params.ToJSONSchema()
open := schema.Properties["open"]
if open.AdditionalProperties == nil {
t.Fatal("open: expected additionalProperties to be set")
}
// true → Not == nil
if open.AdditionalProperties.Not != nil {
t.Error("open: additionalProperties should be true (Not == nil)")
}
strict := schema.Properties["strict"]
if strict.AdditionalProperties == nil {
t.Fatal("strict: expected additionalProperties to be set")
}
// false → Not != nil
if strict.AdditionalProperties.Not == nil {
t.Error("strict: additionalProperties should be false (Not != nil)")
}
}
func TestResApiParams_AnyType_Print(t *testing.T) {
b, _ := json.MarshalIndent(createParams.ToJSONSchema(), "", " ")
t.Log("\n" + string(b))
}
func TestResApiParams_RoundTrip(t *testing.T) {
input := &jschema.Schema{
Type: "object",
Properties: map[string]*jschema.Schema{
"name": {
Type: "string",
Description: "姓名",
MinLength: jschema.Ptr(1),
MaxLength: jschema.Ptr(100),
},
"age": {
Type: "integer",
Minimum: jschema.Ptr(float64(0)),
Maximum: jschema.Ptr(float64(150)),
},
"tags": {
Type: "array",
Items: &jschema.Schema{Type: "string"},
},
},
Required: []string{"name"},
}
params := base.ResApiParamsFromJSONSchema(input)
output := params.ToJSONSchema()
if _, ok := output.Properties["name"]; !ok {
t.Error("round-trip: missing property name")
}
if _, ok := output.Properties["age"]; !ok {
t.Error("round-trip: missing property age")
}
tags, ok := output.Properties["tags"]
if !ok {
t.Error("round-trip: missing property tags")
} else if tags.Items == nil || tags.Items.Type != "string" {
t.Errorf("round-trip: tags.items expected type=string, got %+v", tags.Items)
}
}
+344
View File
@@ -0,0 +1,344 @@
{
"tools": [
{
"name": "res-query",
"description": "Query records with filters, joins, aggregates, ordering, and pagination.\rThe main resource and each hasOne/hasMany relation are independent query contexts; inner/left/right are JOINs within their parent context.\rTop-level conditions / groupBy / orderBy / limit / offset apply to the main resource SQL. Each relation has its own conditions / groupBy / orderBy / limit that apply to that relation's SQL.\rIMPORTANT:\r(1) All field codes MUST come from res-schema results already in this conversation — never guess or infer field names.\r(2) Fields belonging to a hasOne/hasMany context must use object form with codeResource set to that relation's code.\r(3) Call res-schema first if the resource schema has not been fetched yet.",
"inputSchema": {
"type": "object",
"properties": {
"conditions": {
"type": "object",
"properties": {
"children": {
"type": "array",
"items": {
"$ref": "#/properties/conditions"
},
"title": "子条件",
"description": "子条件节点,递归结构"
},
"exprs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"field": {
"type": "string",
"title": "字段编码",
"description": "必须来自 res-schema 返回的字段列表,禁止猜测字段名"
},
"fieldResource": {
"type": "string",
"title": "所属资源",
"description": "字段所属资源标识,取值为主资源编码或当前查询上下文(conditions 所在层级)对应的 relations[].code"
},
"fieldSqlFunc": {
"type": "string",
"title": "列SQL函数",
"description": "对列应用 SQL 函数后再参与比较,拼接为 {func}({fieldResource}.{field}) {operator} {token}。\r\n支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。\r\n需要第二参数的函数(如 ROUND、CAST)配合 fieldSqlFuncParam 使用。"
},
"fieldSqlFuncParam": {
"type": "string",
"title": "列SQL函数参数",
"description": "fieldSqlFunc 的附加参数,按函数类型用途不同:\r\r通用函数(有 param 时):{func}({fieldResource}.{field}, {fieldSqlFuncParam}) {operator} {token}\r 示例:ROUND(col, 2) → fieldSqlFuncParam=\\\"2\\\"\r SUBSTRING(col, 1, 10) → fieldSqlFuncParam=\\\"1, 10\\\""
},
"ignoreEmptyParam": {
"type": "boolean",
"title": "忽略空参数",
"description": "当 tokenType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项",
"default": false
},
"operator": {
"type": "string",
"title": "运算符",
"description": "标准 SQL 比较运算符:\r\n= / != / > / >= / < / <=\r\nLIKE:模糊匹配(%keyword%\r\nIN:包含,value 逗号分隔多值\r\nIS NULL / IS NOT NULL:空值判断,无需 value\r\nREGEXP:正则匹配",
"default": "="
},
"token": {
"type": "string",
"title": "比较值",
"description": "比较目标,内容由 tokenType 决定。operator 为 IS NULL/IS NOT NULL 时可省略"
},
"tokenType": {
"type": "string",
"title": "比较值类型",
"description": "先选类型再填 token\r\nstringtoken 填字面量如 '2024-01-01'\r\nfunctoken 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS\r\nparamtoken 填请求参数路径如 body.status\r\nsqltoken 填原始SQL如 CURRENT_DATE",
"default": "string",
"enum": [
"string",
"func"
]
}
},
"title": "条件项",
"description": "单条比较表达式,生成 [{fieldSqlFunc}(]{fieldResource}.{field}[)] {operator} {token} 片段",
"required": [
"field",
"operator"
]
},
"title": "条件表达式列表",
"description": "当前层的叶子比较列表,每项生成一个 {fieldResource}.{field} {operator} {token} 片段,同层多项以 type 连接"
},
"type": {
"type": "string",
"title": "逻辑连接符",
"description": "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接",
"default": "and",
"enum": [
"and",
"or"
]
}
},
"title": "树形查询条件",
"description": "WHERE 条件树。\rexprs 每项生成一段比较:[{fieldSqlFunc}(]{fieldResource}.{field}[)] {operator} {token},同层多项以 type(and/or) 连接;\rchildren 为同结构子节点,递归嵌套实现复杂过滤。",
"required": [
"exprs",
"type"
]
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"alias": {
"type": "string",
"title": "别名",
"description": "输出字段别名,对应 SQL AS。isExpr=true 时必填,否则结果列无法被引用;普通字段不填则默认使用 code 值"
},
"code": {
"type": "string",
"title": "字段编码",
"description": "两种互斥用法,每个对象只能选其一:\r1. 字段编码,格式为 {Resource}.{field}Resource 取值为主资源编码或 relations[].code,如 User.amount / Order.priceisExpr 保持默认 false\r2. 原始 SQL 表达式(如 COUNT(*) / SUM(amount)),必须同时设置 isExpr = true,且 alias 必填\r注意:SELECT 中混入聚合表达式与非聚合字段时,需配合 groupBy 使用,否则结果不符合预期\r重要:字段编码必须来自 res-schema 返回的字段列表,禁止猜测或推断字段名"
},
"codeResource": {
"type": "string",
"title": "所属资源",
"description": "字段所属的表标识,取值为主资源编码或 relations[].code"
},
"dataType": {
"type": "string",
"title": "数据类型",
"description": "指定字段的返回数据类型,影响序列化方式;不填则由资源字段定义决定",
"default": "string",
"enum": [
"string",
"bool",
"number",
"array",
"json",
"integer",
"float"
]
},
"isExpr": {
"type": "boolean",
"title": "是否表达式",
"description": "设为 true 时,code 内容作为原始 SQL 表达式拼入 SELECT(如聚合函数 COUNT(*) / SUM(amount)),而非普通字段编码。isExpr=true 时 alias 为必填项",
"default": false
}
},
"title": "资源字段",
"description": "结构化字段,支持指定所属资源、输出别名、聚合表达式及数据类型",
"required": [
"code",
"codeResource"
]
},
"title": "查询字段",
"description": "SELECT 字段列表,涵盖所有查询上下文(主资源 + 所有 hasOne/hasMany),通过 codeResource 路由到对应的独立 SQL。不传则返回所有字段。\r主资源和每个 hasOne/hasMany 各是一个独立查询上下文,inner/left/right 只是其所在上下文内的 JOIN。\r仅查询聚合结果(如 COUNT(*) / SUM(amount))时,fields 只传聚合字段,不要混入普通字段,除非同时提供 groupBy。\r重要:所有字段编码必须先通过 res-schema 确认,禁止凭名称语义猜测"
},
"groupBy": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"title": "字段编码",
"description": "格式必须为 {Resource}.{field},如 User.status。\r{Resource} 取值为主资源编码或 relations[].code"
},
{
"type": "object",
"properties": {
"expr": {
"type": "string",
"title": "表达式",
"description": "type=field 时填 {Resource}.{field}(如 User.status\rtype=sql 时填原始 SQL 表达式(如 DATE(created_at)"
},
"type": {
"type": "string",
"title": "表达式类型",
"description": "field:按资源字段分组,expr 填 {Resource}.{field},如 User.status\rsql:按原始 SQL 表达式分组,expr 填任意合法 SQL,如 DATE(created_at)",
"default": "sql",
"enum": [
"field",
"sql"
]
}
},
"title": "分组表达式",
"description": "结构化分组项,type=field 用于资源字段,type=sql 用于原始 SQL 表达式",
"required": [
"expr",
"type"
]
}
]
},
"title": "分组规则",
"description": "GROUP BY 列表,配合 fields 中的聚合表达式使用。\r字段必须带资源前缀({Resource}.{field});聚合函数(COUNT / SUM 等)放在 fields 中,不属于此处"
},
"limit": {
"type": "integer",
"title": "每页条数",
"description": "当前查询上下文返回的最大记录数(顶层时限制主资源结果,relations 内时限制该关联结果)",
"default": 30
},
"offset": {
"type": "integer",
"title": "偏移量",
"description": "主资源查询跳过的记录数,仅顶层有效,relations 内不支持",
"default": 0
},
"orderBy": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"title": "字段编码",
"description": "格式必须为 {Resource}.{field},如 User.created_at,默认升序(ASC)。\r需要降序或使用 SQL 表达式时使用对象形式"
},
{
"type": "object",
"properties": {
"direction": {
"type": "string",
"title": "排序方向",
"default": "asc",
"enum": [
"asc",
"desc"
]
},
"expr": {
"type": "string",
"title": "表达式",
"description": "type=field 时填 {Resource}.{field}(如 User.created_at);type=sql 时填原始 SQL 表达式(如 COUNT(*) / DATE(created_at)"
},
"type": {
"type": "string",
"title": "表达式类型",
"description": "field:按资源字段排序,expr 填 {Resource}.{field},如 User.created_at\rsql:按原始 SQL 表达式排序,expr 填任意合法 SQL,如 COUNT(*) / DATE(created_at)",
"default": "field",
"enum": [
"field",
"sql"
]
}
},
"title": "排序表达式",
"description": "结构化排序项,type=field 用于资源字段,type=sql 用于原始 SQL 表达式",
"required": [
"type",
"expr"
]
}
]
},
"title": "排序规则",
"description": "ORDER BY 列表,多项按顺序依次应用。\r字段必须带资源前缀({Resource}.{field});需要降序或 SQL 表达式排序时使用对象形式"
},
"relations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"actuallyField": {
"type": "string",
"title": "实际资源字段",
"description": "目标资源(code)参与 ON 条件的字段"
},
"actuallyResource": {
"type": "string",
"title": "实际资源",
"description": "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}"
},
"code": {
"type": "string",
"title": "关联标识",
"description": "关联唯一标识,同时作为别名:JOIN 时为表别名(AS code),子查询时为结果 JSON 字段的键名。\r通常与 actuallyResource 保持一致;仅当多个关联引用同一资源导致冲突时,才需要重命名加以区分"
},
"conditions": {
"$ref": "#/properties/conditions",
"description": "该关联的过滤条件,结构与顶层 conditions 相同。hasOne/hasMany 作用于子查询 WHEREinner/left/right 作用于 JOIN ON 或主查询 WHERE"
},
"groupBy": {
"$ref": "#/properties/groupBy",
"description": "该关联的 GROUP BY,仅 hasOne/hasMany 子查询生效,结构与顶层 groupBy 相同"
},
"limit": {
"$ref": "#/properties/limit",
"description": "该关联的最大返回条数,仅 hasOne/hasMany 子查询生效,结构与顶层 limit 相同"
},
"name": {
"type": "string",
"title": "关联名称",
"description": "关联的显示名称"
},
"orderBy": {
"$ref": "#/properties/orderBy",
"description": "该关联的 ORDER BY,仅 hasOne/hasMany 子查询生效,结构与顶层 orderBy 相同"
},
"relationField": {
"type": "string",
"title": "被关联资源字段",
"description": "ON 条件对端资源的字段"
},
"relationResource": {
"type": "string",
"title": "被关联资源",
"description": "ON 条件对端的资源编码(通常为主资源或父关联资源)"
},
"type": {
"type": "string",
"title": "关联类型",
"description": "关联模式:\r\ninner / left / rightSQL JOIN,要求与主资源同一数据库连接,将关联字段合并到主查询行\r\nhasOne:独立子查询,支持跨库,结果聚合为单个对象(result[code] = {}\r\nhasMany:独立子查询,支持跨库,结果聚合为对象数组(result[code] = [...]\r\n跨数据库连接的关联必须使用 hasOne 或 hasMany",
"enum": [
"inner",
"left",
"right",
"hasOne",
"hasMany"
]
}
},
"required": [
"actuallyField",
"actuallyResource",
"code",
"relationField",
"relationResource",
"type"
]
},
"title": "资源关联",
"description": "关联配置列表。\rinner / left / rightSQL JOIN,要求关联资源与主资源在同一数据库连接,将关联字段合并到主查询行。\rhasOne / hasMany:独立子查询,支持跨数据库连接,结果聚合为 JSON 字段(hasOne 为单对象,hasMany 为数组)。\r跨库关联必须使用 hasOne 或 hasMany,不可使用 JOIN 类型。"
},
"resource": {
"type": "string",
"title": "主资源",
"description": "主资源编码(resource_code),作为本次查询的根上下文,relations 中的关联资源均以此为基础展开"
}
},
"required": [
"fields",
"resource"
]
},
"annotations": {}
}
]
}
+475
View File
@@ -0,0 +1,475 @@
{
"tools": [
{
"name": "res-query",
"description": "Query records with filters, joins, aggregates, ordering, pagination, and recursive tree traversal.\rAll relations are SQL JOINs within a single query; the main resource is the primary table.\rPrefer JOIN over multiple queries: when the needed data spans several resources, combine same-connection resources into one query using relations, then make separate queries only for resources on different connections.\rWRONG: 3 queries — User → UserRole → Role, even though User and UserRole share the same connection.\rRIGHT: 1 query joining User + UserRole (same connection), then 1 separate query for Role (different connection).\rIMPORTANT:\r(1) Never guess field names — only use codes from res-schema results in this conversation.\r(2) Call res-schema first if the resource schema has not been fetched yet.\r(3) Use recursive to traverse all descendants of hierarchical data (e.g. categories, menus, org charts); returns a flat list, not a nested tree.",
"inputSchema": {
"type": "object",
"properties": {
"conditions": {
"type": "object",
"properties": {
"children": {
"type": "array",
"items": {
"$ref": "#/properties/conditions"
},
"title": "子条件",
"description": "子条件节点,递归结构"
},
"exprs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"field": {
"type": "string",
"title": "字段编码",
"description": "必须来自 res-schema 返回的字段列表,禁止猜测字段名"
},
"fieldResource": {
"type": "string",
"title": "所属资源",
"description": "字段所属的表标识,取值为主资源编码或 relations[].code"
},
"fieldSqlFunc": {
"type": "string",
"title": "列SQL函数",
"description": "对列应用 SQL 函数后再参与比较,拼接为 {func}({fieldResource}.{field}) {operator} {token}。\r\n支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。\r\n需要第二参数的函数(如 ROUND、CAST)配合 fieldSqlFuncParam 使用。"
},
"fieldSqlFuncParam": {
"type": "string",
"title": "列SQL函数参数",
"description": "fieldSqlFunc 的附加参数,按函数类型用途不同:\r\r通用函数(有 param 时):{func}({fieldResource}.{field}, {fieldSqlFuncParam}) {operator} {token}\r 示例:ROUND(col, 2) → fieldSqlFuncParam=\"2\"\r SUBSTRING(col, 1, 10) → fieldSqlFuncParam=\"1, 10\"\r\rJSON 函数(param 为 JSON 路径表达式):\r json_member_oftoken 是否是列 JSON 数组的成员):\r 无 paramJSON_CONTAINS({token}, JSON_ARRAY({field}))\r 有 paramJSON_CONTAINS({fieldSqlFuncParam}, JSON_ARRAY({field}))\r json_contains(列 JSON 是否包含 token):\r 无 paramJSON_CONTAINS({field}, JSON_ARRAY({token}))\r 有 paramJSON_CONTAINS({fieldSqlFuncParam}, JSON_ARRAY({token}))"
},
"ignoreEmptyParam": {
"type": "boolean",
"title": "忽略空参数",
"description": "当 tokenType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项",
"default": false
},
"operator": {
"type": "string",
"title": "运算符",
"description": "标准 SQL 比较运算符:\r\n= / != / > / >= / < / <=\r\nLIKE:模糊匹配(%keyword%\r\nIN:包含,token 逗号分隔多值\r\nIS NULL / IS NOT NULL:空值判断,无需 token\r\nREGEXP:正则匹配",
"default": "="
},
"token": {
"type": "string",
"title": "比较值",
"description": "比较目标,内容由 tokenType 决定。operator 为 IS NULL/IS NOT NULL 时可省略"
},
"tokenType": {
"type": "string",
"title": "比较值类型",
"description": "先选类型再填 token\r\nstringtoken 填字面量如 '2024-01-01'\r\nfunctoken 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS\r\nparamtoken 填请求参数路径如 body.status\r\nsqltoken 填原始SQL如 CURRENT_DATE",
"default": "string",
"enum": [
"string",
"func"
]
}
},
"title": "条件项",
"description": "单条比较表达式,生成 [{fieldSqlFunc}(]{fieldResource}.{field}[)] {operator} {token} 片段",
"required": [
"field",
"operator"
]
},
"title": "条件表达式列表",
"description": "当前层的叶子比较列表,每项生成一个 {fieldResource}.{field} {operator} {token} 片段,同层多项以 type 连接"
},
"type": {
"type": "string",
"title": "逻辑连接符",
"description": "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接",
"default": "and",
"enum": [
"and",
"or"
]
}
},
"title": "树形查询条件",
"description": "WHERE 条件树。\rexprs 每项生成一段比较:[{fieldSqlFunc}(]{fieldResource}.{field}[)] {operator} {token},同层多项以 type(and/or) 连接;\rchildren 为同结构子节点,递归嵌套实现复杂过滤。",
"required": [
"exprs",
"type"
]
},
"fields": {
"type": "array",
"items": {
"type": "object",
"properties": {
"alias": {
"type": "string",
"title": "别名",
"description": "输出字段别名,对应 SQL AS。isExpr=true 时必填;普通字段在多表 JOIN 存在同名冲突时填写,其余情况默认使用 code 值"
},
"code": {
"type": "string",
"title": "字段编码",
"description": "两种互斥用法,每个对象只能选其一:\r1. 普通字段编码,只填字段名本身如 amount,不含资源前缀,所属表由 codeResource 单独指定,isExpr 保持默认 false\r2. 原始 SQL 表达式(如 COUNT(*) / SUM(User.amount)),可含 {Resource}.{field} 引用,必须同时设置 isExpr = true 且 alias 必填\r注意:SELECT 中混入聚合表达式与非聚合字段时,需配合 groupBy 使用,否则结果不符合预期\r重要:字段编码必须来自 res-schema 返回的字段列表,禁止猜测或推断字段名"
},
"codeResource": {
"type": "string",
"title": "所属资源",
"description": "字段所属的表标识,取值为主资源编码或 relations[].code"
},
"dataType": {
"type": "string",
"title": "数据类型",
"description": "指定字段的返回数据类型,影响序列化方式;不填则由资源字段定义决定",
"default": "string",
"enum": [
"string",
"bool",
"number",
"array",
"json",
"integer",
"float"
]
},
"isExpr": {
"type": "boolean",
"title": "是否表达式",
"description": "设为 true 时,code 内容作为原始 SQL 表达式拼入 SELECT(如聚合函数 COUNT(*) / SUM(amount)),而非普通字段编码。isExpr=true 时 alias 为必填项",
"default": false
}
},
"title": "资源字段",
"description": "结构化字段,支持指定所属表、输出别名、聚合表达式及数据类型",
"required": [
"code"
]
},
"title": "查询字段",
"description": "SELECT 字段列表,不传则返回所有字段(仅主资源字段,不含 JOIN 表)。\r使用 relations 时,JOIN 表的字段不会自动出现在结果中,必须在 fields 里显式列出需要的 JOIN 表字段,否则 JOIN 无意义。\r普通字段:code 填字段名,codeResource 指定所属表;聚合/表达式:isExpr=truecode 填 SQL 表达式,alias 必填。\r仅查询聚合结果时,fields 只传聚合字段,不要混入普通字段,除非同时提供 groupBy。\r重要:所有字段编码必须先通过 res-schema 确认,禁止凭名称语义猜测"
},
"groupBy": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"title": "字段编码",
"description": "格式必须为 {Resource}.{field},如 User.status。\r{Resource} 取值为主资源编码或 relations[].code"
},
{
"type": "object",
"properties": {
"expr": {
"type": "string",
"title": "表达式",
"description": "type=field 时填 {Resource}.{field}(如 User.status\rtype=sql 时填原始 SQL 表达式(如 DATE(created_at)"
},
"type": {
"type": "string",
"title": "表达式类型",
"description": "field:按资源字段分组,expr 填 {Resource}.{field},如 User.status\rsql:按原始 SQL 表达式分组,expr 填任意合法 SQL,如 DATE(created_at)",
"default": "field",
"enum": [
"field",
"sql"
]
}
},
"title": "分组表达式",
"description": "结构化分组项,type=field 用于资源字段,type=sql 用于原始 SQL 表达式",
"required": [
"type",
"expr"
]
}
]
},
"title": "分组规则",
"description": "GROUP BY 列表,配合 fields 中的聚合表达式使用。\r字段必须带资源前缀({Resource}.{field});聚合函数(COUNT / SUM 等)放在 fields 中,不属于此处"
},
"limit": {
"type": "integer",
"title": "每页条数",
"description": "返回的最大记录数",
"default": 30
},
"offset": {
"type": "integer",
"title": "偏移量",
"description": "跳过的记录数",
"default": 0
},
"orderBy": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"title": "字段编码",
"description": "格式必须为 {Resource}.{field},如 User.created_at,默认升序(ASC)。\r需要降序或使用 SQL 表达式时使用对象形式"
},
{
"type": "object",
"properties": {
"direction": {
"type": "string",
"title": "排序方向",
"default": "asc",
"enum": [
"asc",
"desc"
]
},
"expr": {
"type": "string",
"title": "表达式",
"description": "type=field 时填 {Resource}.{field}(如 User.created_at);type=sql 时填原始 SQL 表达式(如 COUNT(*) / DATE(created_at)"
},
"type": {
"type": "string",
"title": "表达式类型",
"description": "field:按资源字段排序,expr 填 {Resource}.{field},如 User.created_at\rsql:按原始 SQL 表达式排序,expr 填任意合法 SQL,如 COUNT(*) / DATE(created_at)",
"default": "field",
"enum": [
"field",
"sql"
]
}
},
"title": "排序表达式",
"description": "结构化排序项,type=field 用于资源字段,type=sql 用于原始 SQL 表达式",
"required": [
"type",
"expr"
]
}
]
},
"title": "排序规则",
"description": "ORDER BY 列表,多项按顺序依次应用。\r字段必须带资源前缀({Resource}.{field});需要降序或 SQL 表达式排序时使用对象形式"
},
"relations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"actuallyField": {
"type": "string",
"title": "实际资源字段",
"description": "目标资源(code)参与 ON 条件的字段"
},
"actuallyResource": {
"type": "string",
"title": "实际资源",
"description": "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}"
},
"code": {
"type": "string",
"title": "关联标识",
"description": "JOIN 表别名(AS code),同时作为 fields/conditions/groupBy/orderBy 中 {Resource} 的取值。\r通常与 actuallyResource 保持一致;仅当多个关联引用同一资源导致冲突时,才需要重命名"
},
"conditions": {
"$ref": "#/properties/conditions",
"description": "该 JOIN 的附加过滤条件,作用于 JOIN ON 或主查询 WHERE,结构与顶层 conditions 相同"
},
"name": {
"type": "string",
"title": "关联名称",
"description": "关联的显示名称"
},
"relationField": {
"type": "string",
"title": "被关联资源字段",
"description": "ON 条件对端资源的字段"
},
"relationResource": {
"type": "string",
"title": "被关联资源",
"description": "ON 条件对端的资源编码(通常为主资源或父关联资源)"
},
"type": {
"type": "string",
"title": "关联类型",
"description": "SQL JOIN 类型(仅支持同库连接,不可跨库):\rinner:内连接,只返回两表均匹配的行\rleft:左连接,保留主表所有行\rright:右连接,保留关联表所有行",
"enum": [
"inner",
"left",
"right"
]
}
},
"required": [
"actuallyField",
"actuallyResource",
"code",
"relationField",
"relationResource",
"type"
]
},
"title": "资源关联",
"description": "SQL JOIN 列表,所有关联在同一条 SQL 中执行。\r关联资源的字段通过 code(表别名)在 fields / conditions / groupBy / orderBy 中引用。\r重要:所有关联资源必须与主资源在同一数据库连接。res-schema 返回的资源名称后面标有连接标识(如 · default / · service-support),建立 JOIN 前必须确认主资源与所有关联资源的连接标识完全一致,不一致则不可使用 JOIN,需分次查询后在应用层合并。"
},
"resource": {
"type": "string",
"title": "主资源",
"description": "主资源编码(resource),作为本次查询的主表,relations 中的关联资源均以此为基础 JOIN"
},
"recursive": {
"type": "object",
"title": "递归查询",
"description": "树形/层级数据的递归查询配置。配置后会以 pField=root 的记录为根节点,向下递归遍历所有子孙节点,返回平铺的记录列表(非嵌套树)。",
"properties": {
"pField": {
"type": "string",
"title": "父节点字段",
"description": "存储父节点 ID 的字段编码,格式为 {Resource}.{field}。{Resource} 取值为主资源编码或 relations[].code,如 Category.pid"
},
"cField": {
"type": "string",
"title": "当前节点字段",
"description": "存储自身 ID 的字段编码,格式为 {Resource}.{field}。{Resource} 取值为主资源编码或 relations[].code,如 Category.id"
},
"root": {
"type": "string",
"title": "根节点值",
"description": "递归起点,pField 等于此值的记录作为根节点,通常为 0 或空字符串"
},
"depth": {
"type": "integer",
"title": "递归深度",
"description": "最大递归层数,防止循环引用导致无限递归,不传则不限制"
}
},
"required": ["pField", "cField", "root"]
}
},
"required": [
"fields",
"resource"
]
},
"annotations": {}
},
{
"name": "res-create",
"description": "Create one or multiple new records for the specified resource. Returns all fields of the created record(s).\rCall res-schema first to get the field list before building the data object.\rIMPORTANT: Never guess field names — only use codes from res-schema results.",
"inputSchema": {
"type": "object",
"properties": {
"resource": {
"type": "string",
"title": "主资源",
"description": "目标资源编码,数据将写入该资源对应的表"
},
"data": {
"title": "创建数据",
"description": "单条记录传对象,批量创建传对象数组。\rkey 为字段编码(必须来自 res-schema 返回的字段列表,禁止猜测),value 为写入值。\r有默认值的字段可以不传,系统会自动填充。\r批量创建(array)时,所有记录必须包含完全相同的字段集合,不可部分记录有某字段而其他记录没有。\r系统保留字段禁止传入,包括:id、created_at、updated_at、created_user、owned_user、enabled 及其他由系统自动维护的字段",
"oneOf": [
{
"type": "object",
"title": "单条记录",
"description": "创建单条记录,key 为字段编码,value 为字段值",
"additionalProperties": true
},
{
"type": "array",
"title": "批量记录",
"description": "批量创建,每个元素为一条记录对象",
"items": {
"type": "object",
"additionalProperties": true
}
}
]
}
},
"required": ["resource", "data"]
},
"annotations": {}
},
{
"name": "res-update",
"description": "Update records by their IDs. Returns only the updated fields of the modified records.\rBefore calling this tool, ALWAYS use res-query to retrieve and show the user the records that will be affected. Only call res-update after the user has reviewed and confirmed.\rPass __confirm__: true when the user has approved the update.\rIMPORTANT: Never guess field names — only use codes from res-schema results.",
"inputSchema": {
"type": "object",
"properties": {
"resource": {
"type": "string",
"title": "主资源",
"description": "目标资源编码"
},
"ids": {
"type": "array",
"title": "记录ID列表",
"description": "必填,指定要更新的记录 ID 列表。必须通过 res-query 预先查询获得,禁止手动构造。",
"items": {
"type": "integer"
}
},
"data": {
"type": "object",
"title": "更新数据",
"description": "只传需要修改的字段,未传字段保持不变。\rkey 为字段编码(必须来自 res-schema 返回的字段列表,禁止猜测),value 为新值。\r系统保留字段禁止传入,包括:id、created_at、updated_at、created_user、owned_user、enabled 及其他由系统自动维护的字段",
"additionalProperties": true
},
"__confirm__": {
"type": "boolean",
"title": "确认执行",
"description": "首次调用不传。仅当服务端返回需要确认的提示时,重新调用并传入 true 以执行更新。不可主动传入,否则将跳过安全确认步骤。",
"default": false
}
},
"required": ["resource", "ids", "data"]
},
"annotations": {}
},
{
"name": "res-delete",
"description": "Hard-delete records by ID (physical removal, unrecoverable).\rWARNING: Prefer soft delete — use res-update to set deleted_at=CURRENT_TIMESTAMP and enabled=0 instead.\rOnly call res-delete when the user explicitly requests permanent/physical deletion.\rBefore calling, ALWAYS use res-query to show the user the affected records and obtain confirmation.\rPass __confirm__: true after the user has confirmed.",
"inputSchema": {
"type": "object",
"properties": {
"resource": {
"type": "string",
"title": "主资源",
"description": "目标资源编码"
},
"ids": {
"type": "array",
"title": "记录ID列表",
"description": "必填,指定要删除的记录 ID 列表。必须通过 res-query 预先查询获得,禁止手动构造。",
"items": {
"type": "integer"
}
},
"__confirm__": {
"type": "boolean",
"title": "确认执行",
"description": "首次调用不传。仅当服务端返回需要确认的提示时,重新调用并传入 true 以执行删除。不可主动传入,否则将跳过安全确认步骤。",
"default": false
}
},
"required": ["resource", "ids"]
},
"annotations": {}
},
{
"name": "res-schema",
"description": "Look up available resources and their field definitions. Without keywords: lists all resources. With keywords: returns matched resources with their field list, types, and constraints. Keywords match against resource code, name, or table name; supports Chinese fuzzy matching; use | to separate multiple terms (e.g. \"用户|权限\"). Call this before using res-query / res-update / res-delete if the resource schema is not already in the current conversation.",
"inputSchema": {
"type": "object",
"properties": {
"keywords": {
"type": "string",
"title": "查找关键字",
"description": "搜索关键词,用于筛选资源列表。支持多个关键词,用 | 分隔。\r对每条资源的编码(code)、名称(name)、数据表名(table)进行匹配,\r支持中文分词模糊匹配,结果按匹配相关度从高到低排序。\r示例:\"用户\" 或 \"用户|权限\""
}
}
},
"annotations": {}
}
]
}
+758
View File
@@ -0,0 +1,758 @@
[
{
"id": 271,
"pid": 0,
"code": "fields",
"name": "查询字段",
"category": "param",
"type": "array",
"desc": "查询字段,不传则返回所有字段",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 282,
"pid": 0,
"code": "groupBy",
"name": "分组规则",
"category": "param",
"type": "array",
"desc": "GROUP BY 字段列表,配合 fields 中的聚合字段使用",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 287,
"pid": 0,
"code": "orderBy",
"name": "排序规则",
"category": "param",
"type": "array",
"desc": "排序规则列表,多项按顺序依次应用",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 299,
"pid": 0,
"code": "relations",
"name": "资源关联",
"category": "param",
"type": "array",
"desc": "关联配置列表,支持 JOINinner / left / right)将关联字段合并到主查询,或子查询(hasOne / hasMany)将关联数据聚合为独立 JSON 字段",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 258,
"pid": 0,
"code": "conditions",
"name": "树形查询条件",
"category": "param",
"type": "json",
"desc": "WHERE 条件树。\rexprs 每项生成一段比较:[{columnSqlFunc}(]{columnResource}.{column}[)] {operator} {value},同层多项以 type(and/or) 连接;\rchildren 为同结构子节点,递归嵌套实现复杂过滤。",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 297,
"pid": 0,
"code": "limit",
"name": "每页条数",
"category": "param",
"type": "integer",
"desc": "返回的最大记录数",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": "30"
},
{
"id": 298,
"pid": 0,
"code": "offset",
"name": "偏移量",
"category": "param",
"type": "integer",
"desc": "跳过的记录数",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": "0"
},
{
"id": 259,
"pid": 258,
"code": "type",
"name": "逻辑连接符",
"category": "param",
"type": "string",
"desc": "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接",
"isRequired": true,
"constraints": {
"enum": [
"and",
"or"
]
},
"rank": 0,
"ref_id": 0,
"df_val": "and"
},
{
"id": 261,
"pid": 258,
"code": "children",
"name": "子条件",
"category": "param",
"type": "array",
"desc": "子条件节点,递归结构",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 260,
"pid": 258,
"code": "exprs",
"name": "条件表达式列表",
"category": "param",
"type": "array",
"desc": "当前层的叶子比较列表,每项生成一个 column {operator} value 片段,同层多项以 type 连接",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 262,
"pid": 260,
"code": "items",
"name": "_items",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 312,
"pid": 261,
"code": "items",
"name": "_items",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 258,
"df_val": ""
},
{
"id": 263,
"pid": 262,
"code": "column",
"name": "字段编码",
"category": "param",
"type": "string",
"desc": "",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 264,
"pid": 262,
"code": "columnResource",
"name": "所属资源",
"category": "param",
"type": "string",
"desc": "字段所属资源标识,取值为顶层 resource 或 relations[].code",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 268,
"pid": 262,
"code": "columnSqlFunc",
"name": "列SQL函数",
"category": "param",
"type": "string",
"desc": "对列应用 SQL 函数后再参与比较,拼接为 {func}({columnResource}.{column}) {operator} {value}。\r\n支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。\r\n需要第二参数的函数(如 ROUND、CAST)配合 columnSqlFuncParam 使用。",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 269,
"pid": 262,
"code": "columnSqlFuncParam",
"name": "列SQL函数参数",
"category": "param",
"type": "string",
"desc": "columnSqlFunc 的附加参数,按函数类型用途不同:\r\r通用函数(有 param 时):{func}({columnResource}.{column}, {columnSqlFuncParam}) {operator} {value}\r 示例:ROUND(col, 2) → columnSqlFuncParam=\"2\"\r SUBSTRING(col, 1, 10) → columnSqlFuncParam=\"1, 10\"\r\rJSON 函数(param 为 JSON 路径表达式):\r json_member_ofvalue 是否是列 JSON 数组的成员):\r 无 paramJSON_CONTAINS({value}, JSON_ARRAY({column}))\r 有 paramJSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({column}))\r json_contains(列 JSON 是否包含 value):\r 无 paramJSON_CONTAINS({column}, JSON_ARRAY({value}))\r 有 paramJSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({value}))",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 265,
"pid": 262,
"code": "operator",
"name": "运算符",
"category": "param",
"type": "string",
"desc": "标准 SQL 比较运算符:\r\n= / != / \u003e / \u003e= / \u003c / \u003c=\r\nLIKE:模糊匹配(%keyword%\r\nIN:包含,value 逗号分隔多值\r\nIS NULL / IS NOT NULL:空值判断,无需 value\r\nREGEXP:正则匹配",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": "="
},
{
"id": 266,
"pid": 262,
"code": "value",
"name": "比较值",
"category": "param",
"type": "string",
"desc": "比较目标,内容由 valueType 决定。operator 为 IS NULL/IS NOT NULL 时可省略",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 267,
"pid": 262,
"code": "valueType",
"name": "比较值类型",
"category": "param",
"type": "string",
"desc": "先选类型再填 value\r\nstringvalue 填字面量如 '2024-01-01'\r\nfuncvalue 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS\r\nparamvalue 填请求参数路径如 body.status\r\nsqlvalue 填原始SQL如 CURRENT_DATE",
"isRequired": false,
"constraints": {
"enum": [
"string",
"func"
]
},
"rank": 0,
"ref_id": 0,
"df_val": "string"
},
{
"id": 270,
"pid": 262,
"code": "ignoreEmptyParam",
"name": "忽略空参数",
"category": "param",
"type": "bool",
"desc": "当 valueType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": "false"
},
{
"id": 272,
"pid": 271,
"code": "oneOf",
"name": "字段编码",
"category": "param",
"type": "string",
"desc": "字段引用,格式:{columnResource}.{column} 或 {column}",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 279,
"pid": 271,
"code": "oneOf",
"name": "表达式字段",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 275,
"pid": 271,
"code": "oneOf",
"name": "资源字段",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 278,
"pid": 275,
"code": "alias",
"name": "别名",
"category": "param",
"type": "string",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 276,
"pid": 275,
"code": "column",
"name": "字段编码",
"category": "param",
"type": "string",
"desc": "",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 277,
"pid": 275,
"code": "columnResource",
"name": "所属资源",
"category": "param",
"type": "string",
"desc": "字段所属资源标识,取值为顶层 resource 或 relations[].code",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 281,
"pid": 279,
"code": "alias",
"name": "别名",
"category": "param",
"type": "string",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 280,
"pid": 279,
"code": "expr",
"name": "SQL 表达式",
"category": "param",
"type": "string",
"desc": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 283,
"pid": 282,
"code": "oneOf",
"name": "分组表达式",
"category": "param",
"type": "string",
"desc": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 284,
"pid": 282,
"code": "oneOf",
"name": "资源字段",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 285,
"pid": 284,
"code": "column",
"name": "字段编码",
"category": "param",
"type": "string",
"desc": "",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 286,
"pid": 284,
"code": "columnResource",
"name": "所属资源",
"category": "param",
"type": "string",
"desc": "字段所属资源标识,取值为顶层 resource 或 relations[].code",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 288,
"pid": 287,
"code": "oneOf",
"name": "SQL 表达式 (ASC)",
"category": "param",
"type": "string",
"desc": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用\r默认升序(asc)",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 289,
"pid": 287,
"code": "oneOf",
"name": "SQL 表达式排序",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 292,
"pid": 287,
"code": "oneOf",
"name": "资源字段排序",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 291,
"pid": 289,
"code": "direction",
"name": "排序方向",
"category": "param",
"type": "string",
"desc": "",
"isRequired": false,
"constraints": {
"enum": [
"asc",
"desc"
]
},
"rank": 0,
"ref_id": 0,
"df_val": "asc"
},
{
"id": 290,
"pid": 289,
"code": "expr",
"name": "SQL 表达式",
"category": "param",
"type": "string",
"desc": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 293,
"pid": 292,
"code": "column",
"name": "字段编码",
"category": "param",
"type": "string",
"desc": "",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 295,
"pid": 292,
"code": "columnResource",
"name": "所属资源",
"category": "param",
"type": "string",
"desc": "",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 296,
"pid": 292,
"code": "direction",
"name": "排序方向",
"category": "param",
"type": "string",
"desc": "",
"isRequired": false,
"constraints": {
"enum": [
"asc",
"desc"
]
},
"rank": 0,
"ref_id": 0,
"df_val": "asc"
},
{
"id": 300,
"pid": 299,
"code": "items",
"name": "",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 305,
"pid": 300,
"code": "actuallyField",
"name": "实际资源字段",
"category": "param",
"type": "string",
"desc": "目标资源(code)参与 ON 条件的字段",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 304,
"pid": 300,
"code": "actuallyResource",
"name": "实际资源",
"category": "param",
"type": "string",
"desc": "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 303,
"pid": 300,
"code": "code",
"name": "关联标识",
"category": "param",
"type": "string",
"desc": "关联唯一标识,同时作为别名:JOIN 时为表别名(AS code),子查询时为结果 JSON 字段的键名",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 302,
"pid": 300,
"code": "name",
"name": "关联名称",
"category": "param",
"type": "string",
"desc": "关联的显示名称",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 307,
"pid": 300,
"code": "relationField",
"name": "被关联资源字段",
"category": "param",
"type": "string",
"desc": "ON 条件对端资源的字段",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 306,
"pid": 300,
"code": "relationResource",
"name": "被关联资源",
"category": "param",
"type": "string",
"desc": "ON 条件对端的资源编码(通常为主资源或父关联资源)",
"isRequired": true,
"constraints": {},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 301,
"pid": 300,
"code": "type",
"name": "关联类型",
"category": "param",
"type": "string",
"desc": "关联模式:\r\ninner / left / rightSQL JOIN,将关联资源字段合并到主查询行\r\nhasOne:子查询,关联结果聚合为单个对象(result[code] = {}\r\nhasMany:子查询,关联结果聚合为对象数组(result[code] = [...]",
"isRequired": true,
"constraints": {
"enum": [
"inner",
"left",
"right",
"hasOne",
"hasMany"
]
},
"rank": 0,
"ref_id": 0,
"df_val": ""
},
{
"id": 309,
"pid": 300,
"code": "groupBy",
"name": "关联分组,仅 hasOne/hasMany 有效",
"category": "param",
"type": "array",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 282,
"df_val": ""
},
{
"id": 310,
"pid": 300,
"code": "orderBy",
"name": "关联排序,仅 hasOne/hasMany 有效",
"category": "param",
"type": "array",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 287,
"df_val": ""
},
{
"id": 308,
"pid": 300,
"code": "conditions",
"name": "关联的过滤条件",
"category": "param",
"type": "json",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 258,
"df_val": ""
},
{
"id": 311,
"pid": 300,
"code": "limit",
"name": "关联条数限制,仅 hasOne/hasMany 有效",
"category": "param",
"type": "integer",
"desc": "",
"isRequired": false,
"constraints": {},
"rank": 0,
"ref_id": 297,
"df_val": ""
}
]
+324
View File
@@ -0,0 +1,324 @@
{
"type": "object",
"properties": {
"conditions": {
"type": "object",
"properties": {
"children": {
"type": "array",
"items": {
"$ref": "#/properties/conditions"
},
"title": "子条件",
"description": "子条件节点,递归结构"
},
"exprs": {
"type": "array",
"items": {
"type": "object",
"properties": {
"column": {
"type": "string",
"title": "字段编码"
},
"columnResource": {
"type": "string",
"title": "所属资源",
"description": "字段所属资源标识,取值为顶层 resource 或 relations[].code"
},
"columnSqlFunc": {
"type": "string",
"title": "列SQL函数",
"description": "对列应用 SQL 函数后再参与比较,拼接为 {func}({columnResource}.{column}) {operator} {value}。\r\n支持 SQL-92 / SQL:1999 / SQL:2003 标准函数及数据库方言函数,如 DATE、UPPER、ROUND。\r\n需要第二参数的函数(如 ROUND、CAST)配合 columnSqlFuncParam 使用。"
},
"columnSqlFuncParam": {
"type": "string",
"title": "列SQL函数参数",
"description": "columnSqlFunc 的附加参数,按函数类型用途不同:\r\r通用函数(有 param 时):{func}({columnResource}.{column}, {columnSqlFuncParam}) {operator} {value}\r 示例:ROUND(col, 2) → columnSqlFuncParam=\"2\"\r SUBSTRING(col, 1, 10) → columnSqlFuncParam=\"1, 10\"\r\rJSON 函数(param 为 JSON 路径表达式):\r json_member_ofvalue 是否是列 JSON 数组的成员):\r 无 paramJSON_CONTAINS({value}, JSON_ARRAY({column}))\r 有 paramJSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({column}))\r json_contains(列 JSON 是否包含 value):\r 无 paramJSON_CONTAINS({column}, JSON_ARRAY({value}))\r 有 paramJSON_CONTAINS({columnSqlFuncParam}, JSON_ARRAY({value}))"
},
"ignoreEmptyParam": {
"type": "boolean",
"title": "忽略空参数",
"description": "当 valueType=param 且请求参数值为空(空字符串或 null)时,跳过该条件不生成 SQL。用于实现可选过滤项",
"default": false
},
"operator": {
"type": "string",
"title": "运算符",
"description": "标准 SQL 比较运算符:\r\n= / != / \u003e / \u003e= / \u003c / \u003c=\r\nLIKE:模糊匹配(%keyword%\r\nIN:包含,value 逗号分隔多值\r\nIS NULL / IS NOT NULL:空值判断,无需 value\r\nREGEXP:正则匹配",
"default": "="
},
"value": {
"type": "string",
"title": "比较值",
"description": "比较目标,内容由 valueType 决定。operator 为 IS NULL/IS NOT NULL 时可省略"
},
"valueType": {
"type": "string",
"title": "比较值类型",
"description": "先选类型再填 value\r\nstringvalue 填字面量如 '2024-01-01'\r\nfuncvalue 填内置函数名 UserID|UserUuid|UserRolesUuid|UserPlatform|UserSaaS\r\nparamvalue 填请求参数路径如 body.status\r\nsqlvalue 填原始SQL如 CURRENT_DATE",
"default": "string",
"enum": [
"string",
"func"
]
}
},
"title": "_items",
"description": "_items",
"required": [
"column",
"operator"
]
},
"title": "条件表达式列表",
"description": "当前层的叶子比较列表,每项生成一个 column {operator} value 片段,同层多项以 type 连接"
},
"type": {
"type": "string",
"title": "逻辑连接符",
"description": "当前节点的逻辑运算符,将 exprs 各项与 children 各组以 AND 或 OR 连接",
"default": "and",
"enum": [
"and",
"or"
]
}
},
"title": "树形查询条件",
"description": "WHERE 条件树。\rexprs 每项生成一段比较:[{columnSqlFunc}(]{columnResource}.{column}[)] {operator} {value},同层多项以 type(and/or) 连接;\rchildren 为同结构子节点,递归嵌套实现复杂过滤。",
"required": [
"exprs",
"type"
]
},
"fields": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"title": "字段编码",
"description": "字段引用,格式:{columnResource}.{column} 或 {column}"
},
{
"type": "object",
"properties": {
"alias": {
"type": "string",
"title": "别名"
},
"expr": {
"type": "string",
"title": "SQL 表达式",
"description": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用"
}
},
"title": "表达式字段"
},
{
"type": "object",
"properties": {
"alias": {
"type": "string",
"title": "别名"
},
"column": {
"type": "string",
"title": "字段编码"
},
"columnResource": {
"type": "string",
"title": "所属资源",
"description": "字段所属资源标识,取值为顶层 resource 或 relations[].code"
}
},
"title": "资源字段",
"required": [
"column"
]
}
]
},
"title": "查询字段",
"description": "查询字段,不传则返回所有字段"
},
"groupBy": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"title": "分组表达式",
"description": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用"
},
{
"type": "object",
"properties": {
"column": {
"type": "string",
"title": "字段编码"
},
"columnResource": {
"type": "string",
"title": "所属资源",
"description": "字段所属资源标识,取值为顶层 resource 或 relations[].code"
}
},
"title": "资源字段",
"required": [
"column"
]
}
]
},
"title": "分组规则",
"description": "GROUP BY 字段列表,配合 fields 中的聚合字段使用"
},
"limit": {
"type": "integer",
"title": "每页条数",
"description": "返回的最大记录数",
"default": 30
},
"offset": {
"type": "integer",
"title": "偏移量",
"description": "跳过的记录数",
"default": 0
},
"orderBy": {
"type": "array",
"items": {
"oneOf": [
{
"type": "string",
"title": "SQL 表达式 (ASC)",
"description": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用\r默认升序(asc)"
},
{
"type": "object",
"properties": {
"direction": {
"type": "string",
"title": "排序方向",
"default": "asc",
"enum": [
"asc",
"desc"
]
},
"expr": {
"type": "string",
"title": "SQL 表达式",
"description": "SQL 表达式:如 COUNT(*) / SUM(amount)\r{column}:字段编码,默认归属顶层 resource\r{columnResource}.{column}:显式指定资源的字段引用"
}
},
"title": "SQL 表达式排序"
},
{
"type": "object",
"properties": {
"column": {
"type": "string",
"title": "字段编码"
},
"columnResource": {
"type": "string",
"title": "所属资源"
},
"direction": {
"type": "string",
"title": "排序方向",
"default": "asc",
"enum": [
"asc",
"desc"
]
}
},
"title": "资源字段排序",
"required": [
"column",
"columnResource"
]
}
]
},
"title": "排序规则",
"description": "排序规则列表,多项按顺序依次应用"
},
"relations": {
"type": "array",
"items": {
"type": "object",
"properties": {
"actuallyField": {
"type": "string",
"title": "实际资源字段",
"description": "目标资源(code)参与 ON 条件的字段"
},
"actuallyResource": {
"type": "string",
"title": "实际资源",
"description": "被关联的目标资源编码(相当于 SQL 表名)。JOIN 拼接为:{type} JOIN {actuallyResource} AS {code} ON {code}.{actuallyField} = {relationResource}.{relationField}"
},
"code": {
"type": "string",
"title": "关联标识",
"description": "关联唯一标识,同时作为别名:JOIN 时为表别名(AS code),子查询时为结果 JSON 字段的键名"
},
"conditions": {
"$ref": "#/properties/conditions"
},
"groupBy": {
"$ref": "#/properties/groupBy"
},
"limit": {
"$ref": "#/properties/limit"
},
"name": {
"type": "string",
"title": "关联名称",
"description": "关联的显示名称"
},
"orderBy": {
"$ref": "#/properties/orderBy"
},
"relationField": {
"type": "string",
"title": "被关联资源字段",
"description": "ON 条件对端资源的字段"
},
"relationResource": {
"type": "string",
"title": "被关联资源",
"description": "ON 条件对端的资源编码(通常为主资源或父关联资源)"
},
"type": {
"type": "string",
"title": "关联类型",
"description": "关联模式:\r\ninner / left / rightSQL JOIN,将关联资源字段合并到主查询行\r\nhasOne:子查询,关联结果聚合为单个对象(result[code] = {}\r\nhasMany:子查询,关联结果聚合为对象数组(result[code] = [...]",
"enum": [
"inner",
"left",
"right",
"hasOne",
"hasMany"
]
}
},
"required": [
"actuallyField",
"actuallyResource",
"code",
"relationField",
"relationResource",
"type"
]
},
"title": "资源关联",
"description": "关联配置列表,支持 JOINinner / left / right)将关联字段合并到主查询,或子查询(hasOne / hasMany)将关联数据聚合为独立 JSON 字段"
}
}
}
+21 -3
View File
@@ -1,6 +1,8 @@
package base package base
import ( import (
"encoding/json"
"git.fsdpf.net/go/reflux" "git.fsdpf.net/go/reflux"
"git.fsdpf.net/go/reflux/valuex" "git.fsdpf.net/go/reflux/valuex"
) )
@@ -30,19 +32,35 @@ type ResConfigure struct {
func (rc *ResConfigure) GetValueByRoles(roles ...string) (valuex.Accessor, bool) { func (rc *ResConfigure) GetValueByRoles(roles ...string) (valuex.Accessor, bool) {
// 如果没有角色值配置或没有传入角色,返回默认值 // 如果没有角色值配置或没有传入角色,返回默认值
if len(rc.RoleValues) == 0 || len(roles) == 0 { if len(rc.RoleValues) == 0 || len(roles) == 0 {
return reflux.New(rc.Value), false return reflux.New(rc.toTyped(rc.Value)), true
} }
for _, rv := range rc.RoleValues { for _, rv := range rc.RoleValues {
for _, role := range roles { for _, role := range roles {
if value, exists := rv[role]; exists { if value, exists := rv[role]; exists {
return reflux.New(value), true return reflux.New(rc.toTyped(value)), true
} }
} }
} }
// 没有找到匹配的角色值,返回默认值 // 没有找到匹配的角色值,返回默认值
return reflux.New(rc.Value), false return reflux.New(rc.toTyped(rc.Value)), false
}
func (rc *ResConfigure) toTyped(s string) any {
switch rc.Type {
case "json":
var v map[string]any
if json.Unmarshal([]byte(s), &v) == nil {
return v
}
case "array":
var v []any
if json.Unmarshal([]byte(s), &v) == nil {
return v
}
}
return s
} }
type GetResConfigure func(key string, roles ...string) (valuex.Accessor, bool) type GetResConfigure func(key string, roles ...string) (valuex.Accessor, bool)
+4 -269
View File
@@ -1,274 +1,9 @@
package base package base
import ( import (
"encoding/json" "git.fsdpf.net/go/req/resx"
"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 资源字段 // ResField 的实现搬到了 git.fsdpf.net/go/req/resxresx 自己需要用它做字段级脱敏,
type ResField struct { // 不能反过来 import contracts/base,见 resx.ResField 的注释),这里保留类型别名兼容原有引用。
Uuid string `db:"uuid" json:"uuid"` type ResField = resx.ResField
Name string `db:"name" json:"name"`
Code string `db:"code" json:"code"`
CodeResource string `db:"codeResource" json:"codeResource"`
DataType req.ResDataType `db:"data_type" json:"data_type"`
Length string `db:"length" json:"length"`
Comment string `db:"comment" json:"comment"`
Default string `db:"default" json:"default"`
Virtual bool `db:"virtual" json:"virtual"`
VirtualExpr string `db:"virtual_expr" json:"virtual_expr"`
}
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.Default != "" && this.Default[0:1] == "[" {
typ = reflect.TypeOf([]any{})
} else {
typ = reflect.TypeOf(map[string]any{})
}
}
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) 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) 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.Default != "" && this.Default[0:1] == "[" {
return req.ReqArray
}
return req.ReqJson
}
return req.ReqString
}
func (this ResField) ToValue(v any) any {
if this.DataType == req.ResJson {
if v == nil {
if this.Default != "" && this.Default[0:1] == "[" {
return db.V("[]")
} else if this.Default != "" && this.Default[0:1] == "{" {
return db.V("{}")
} else if this.Default == "" {
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))
}
}
return v
}
func (this ResField) GetRawDefault() db.Expression {
if this.DataType == req.ResJson {
if this.Default != "" && this.Default[0:1] == "[" {
return db.V("[]")
} else if this.Default != "" && this.Default[0:1] == "{" {
return db.V("{}")
} else if this.Default == "" {
return db.V("{}")
}
} else if this.DataType == req.ResBoolean {
if v, _ := strconv.ParseBool(this.Default); v {
return db.V(true)
}
return db.V(false)
}
if len(this.Default) > 4 && strings.ToLower(this.Default[0:4]) == "sql:" {
return db.L(this.Default[4:])
}
if this.Default == "" {
if this.GetDataType() == req.ResDate || this.GetDataType() == req.ResDatetime {
return db.V(nil)
}
return db.V("")
}
if strings.ToUpper(this.Default) == "CURRENT_TIMESTAMP" {
return db.L(this.Default)
}
return db.V(this.Default)
}
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.Default)
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.Default) == "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)
}
+2 -10
View File
@@ -5,6 +5,7 @@ import (
"git.fsdpf.net/go/db/schema" "git.fsdpf.net/go/db/schema"
"git.fsdpf.net/go/req" "git.fsdpf.net/go/req"
"git.fsdpf.net/go/req/resx"
_ "git.fsdpf.net/go/db/schema/dialect/mysql" _ "git.fsdpf.net/go/db/schema/dialect/mysql"
_ "git.fsdpf.net/go/db/schema/dialect/sqlite3" _ "git.fsdpf.net/go/db/schema/dialect/sqlite3"
@@ -14,16 +15,7 @@ func TestResFieldToBlueprint(t *testing.T) {
t.Run("id", func(t *testing.T) { t.Run("id", func(t *testing.T) {
table := schema.NewBlueprint("users") table := schema.NewBlueprint("users")
id := ResField{ id := resx.NewResField("id", "User", resx.FieldWithUuid("00000000-0000-0000-0000-000000000000"), resx.FieldWithName("ID"), resx.FieldWithLength("20"), resx.FieldWithDefault("sql:AUTO_INCREMENT"), resx.FieldWithDataType(req.ResInteger))
Uuid: "00000000-0000-0000-0000-000000000000",
Name: "ID",
Code: "id",
CodeResource: "User",
DataType: req.ResInteger,
Length: "20",
Comment: "",
Default: "sql:AUTO_INCREMENT",
}
id.ToBlueprint(table) id.ToBlueprint(table)
}) })
+36 -2
View File
@@ -1,14 +1,48 @@
package base package base
import "time"
type ResJob struct { type ResJob struct {
Uuid string `db:"uuid"` Uuid string `db:"uuid"`
Code string `db:"code"` Code string `db:"code"`
Name string `db:"name"` Name string `db:"name"`
ResourceUuid string `db:"resource_uuid"` ResourceUuid string `db:"resource_uuid"`
Replay int64 `db:"replay"` MaxAttempts int `db:"maxAttempts"` // 最大重试次数
IsRecord bool `db:"isRecord"` Concurrent int `db:"concurrent"` // 同类 job 最大并发数,0 表示不限制
Timeout int `db:"timeout"` // 单次执行超时秒数,0 表示不限制
Retry int `db:"retry"` // 重试间隔秒数,0 表示不重试,>0 表示固定间隔秒数
UpdatedAt string `db:"updated_at"` UpdatedAt string `db:"updated_at"`
CreatedAt string `db:"created_at"` CreatedAt string `db:"created_at"`
} }
type GetResJob func(code string) (ResJob, bool) type GetResJob func(code string) (ResJob, bool)
const (
FailedJobStatusResolved = 0 // 已解决(成功完成)
FailedJobStatusPending = 1 // 待重试
FailedJobStatusExceeded = 2 // 已超出最大重试次数
FailedJobStatusInterrupted = 3 // shutdown 中断
)
type ResFailedJob struct {
ID int64 `db:"id"`
Queue string `db:"queue"` // 投递队列(ResJob.Code
Payload string `db:"payload"` // JSON
Platform string `db:"platform"`
Saas string `db:"saas"`
TraceId string `db:"trace_id"`
Exception string `db:"exception"`
Attempts int `db:"attempts"`
MaxAttempts int `db:"maxAttempts"`
Status int `db:"status"`
RetryAt *time.Time `db:"retry_at"`
RetriedAt *time.Time `db:"retried_at"`
OwnedUser string `db:"owned_user"` // 触发任务的用户 uuid
CreatedAt string `db:"created_at"`
UpdatedAt string `db:"updated_at"`
}
// CanRetry 是否还可以重试
func (j ResFailedJob) CanRetry() bool {
return j.Status == FailedJobStatusPending && j.Attempts < j.MaxAttempts
}
-13
View File
@@ -1,13 +0,0 @@
package base
type ResListener struct {
Uuid string `db:"uuid"`
Code string `db:"code"`
Name string `db:"name"`
ResourceUuid string `db:"resource_uuid"`
Event []any `db:"event"`
UpdatedAt string `db:"updated_at"`
CreatedAt string `db:"created_at"`
}
type GetResListens func(categoryUuid string, event ...string) []ResListener
+20
View File
@@ -0,0 +1,20 @@
package base
// ResWatcher 描述一条"资源写操作监听"注册记录:Code 对应一个实现了 contracts.ResWatcher 的处理器,
// ResourceUuids 是它关心的资源(支持 1-n 个)
type ResWatcher struct {
Uuid string `db:"uuid"`
Code string `db:"code"`
Name string `db:"name"`
ResourceUuids []string `db:"resource_uuid"`
OwnedUser string `db:"owned_user"` // Handle 执行时使用的用户身份(同 Cron/Mqtt 约定)
MaxDepth int `db:"max_depth"` // 允许级联触发的最大深度,用于防止 watcher 之间循环触发
UpdatedAt string `db:"updated_at"`
CreatedAt string `db:"created_at"`
}
// GetResWatcher 按 code 查询单条 ResWatcher 注册记录
type GetResWatcher func(code string) (ResWatcher, bool)
// GetResWatchers 按资源 uuid 反查关心该资源的 ResWatcher 注册记录
type GetResWatchers func(resourceUuid string) []ResWatcher
-437
View File
@@ -1,437 +0,0 @@
package base
import (
"database/sql"
"fmt"
"log"
"reflect"
"sync"
"unicode"
"github.com/samber/do/v2"
"github.com/samber/lo"
"git.fsdpf.net/go/condition"
"git.fsdpf.net/go/contracts"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/db/exec"
"git.fsdpf.net/go/db/exp"
"git.fsdpf.net/go/db/schema"
"git.fsdpf.net/go/req"
)
// ResChangeEventTopic 资源变更事件
const ResChangeEventTopic = "res-change-event-topic"
// ResChangeRecordTopic 资源变更数据
const ResChangeRecordTopic = "res-change-record-topic"
type ResChangeEventTopicPayload struct {
Type string // insert | delete | update
Res Resource // 变更资源
Result sql.Result // 执行结果
}
type ResChangeRecordTopicPayload struct {
Type string // insert | delete | update
User req.User // 操作用户
Res Resource // 变更资源
Result sql.Result // 执行结果
Old []map[string]any // 旧数据
New []map[string]any // 新数据
}
// ResVirtualTableSetup 由 framework-v2 在 sqlite_vtable build tag 下注册到容器。
// resource.DB() 通过 do.Invoke 取出后调用,完成 vtab 模块注册和 CREATE VIRTUAL TABLE。
// res 提供 Code / Table / Fields 信息,vt 是 ResVirtualTable 的具体实现。
type ResVirtualTableSetup func(res Resource) error
// Resource 资源
type Resource struct {
container do.Injector
initOnce *sync.Once
Uuid string `db:"uuid"`
PUuid string `db:"pUuid"`
Code string `db:"code"`
Name string `db:"name"`
Conn string `db:"conn"`
IsResVirtual bool `db:"isVirtual"`
Table string `db:"table"`
Primarykey string `db:"primaryKey"`
HistoryRoles []string `db:"historyRoles"`
Fields []ResField `db:"fields"`
Roles map[string]any `db:"roles"`
UpdatedAt string `db:"updated_at"`
CreatedAt string `db:"created_at"`
}
func (this *Resource) InitContainer(container do.Injector) {
this.container = container
this.initOnce = &sync.Once{}
}
// GetUuid 资源UUID
func (this Resource) GetUuid() string {
return this.Uuid
}
// GetCode 资源CODE
func (this Resource) GetCode() string {
return this.Code
}
// GetName 资源名
func (this Resource) GetName() string {
return this.Name
}
// GetConn 数据库连接
func (this Resource) GetConn() string {
return this.Conn
}
// GetPrimarykey 主键
func (this Resource) GetPrimarykey() string {
return this.Primarykey
}
// IsVirtual 是否虚拟资源
func (this Resource) IsVirtual() bool {
return this.IsResVirtual
}
// IsSystem 是否系统资源
func (this Resource) IsSystem() bool {
return this.Conn == "service-support"
}
// GetFields 资源字段
func (this Resource) GetFields() (result []req.ResField) {
for _, item := range this.Fields {
result = append(result, item)
}
return result
}
// GetField 资源字段
func (this Resource) GetField(code string) (req.ResField, bool) {
return lo.Find(this.GetFields(), func(v req.ResField) bool {
return v.GetCode() == code
})
}
// HasField 判断资源字段
func (this Resource) HasField(code string) bool {
return lo.SomeBy(this.GetFields(), func(v req.ResField) bool {
return v.GetCode() == code
})
}
// BeginTransaction 开启事物
func (this Resource) BeginTransaction() (*db.TxDatabase, error) {
return this.DB().Begin()
}
// DB 获取资源链接
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 通过容器获取 ResVirtualTableSetup(由 framework-v2 在 sqlite_vtable tag 下注册),
// 再从 App 取出当前资源对应的 ResVirtualTable 实现,完成 vtab 注册和 CREATE VIRTUAL TABLE。
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() exp.LiteralExpression {
if this.IsResVirtual {
return db.L("(" + this.Table + ")")
}
return db.V(db.T(this.Table))
}
// GetDBTable 获取资源对应的数据库连接
func (this Resource) GetDBTable(u req.User, opts ...req.ResOption) (sd *db.SelectDataset) {
if u == nil {
panic("GetDBTable: user cannot be nil")
}
op := &req.ResOptions{}
for _, cb := range opts {
cb(op)
}
alias := this.GetCode()
if op.Alias != "" {
alias = op.Alias
}
sd = this.DB().From(this.GetTable().As(alias))
if op.Tx != nil {
sd = op.Tx.From(this.GetTable().As(alias))
}
sd.WithHook(&ResourceHooks{res: this, u: u, tx: op.Tx})
return sd
}
func (this Resource) GetRolesCondition(u req.User) (*db.SelectDataset, db.Expression) {
isFullRight := false
isFullNot := false
if u == nil {
u = GetAnonymous()
}
// 如果是系统用户, 则不做校验
if u.Uuid() == "ffffffff-ffff-ffff-ffff-ffffffffffff" {
return nil, nil
}
// 这里不能放在 u.Uuid() == "ffffffff-ffff-ffff-ffff-ffffffffffff" 前面
// 会导致初始化数据时循环依赖
GetResRoles, err := do.Invoke[GetResRoles](this.container)
if err != nil {
return nil, nil
}
items := GetResRoles(this.GetUuid(), u.Roles()...)
if len(items) == 0 {
return nil, nil
}
GetResource := do.MustInvoke[contracts.GetResource](this.container)
NewOrm := do.MustInvoke[contracts.NewOrm](this.container)
NewOrmModel := do.MustInvoke[contracts.NewOrmModel](this.container)
NewOrmJoin := do.MustInvoke[contracts.NewOrmJoin](this.container)
GetResRelationResource := do.MustInvoke[GetResRelationResource](this.container)
GetResRelations := do.MustInvoke[GetResRelations](this.container)
GetOrmConditions := do.MustInvoke[contracts.GetOrmConditions](this.container)
sub := lo.Reduce(items, func(carry *db.SelectDataset, item ResRole, _ int) *db.SelectDataset {
sd := this.DB().From(this.GetTable().As(this.GetCode())).Select(db.T(this.GetCode()).All())
joins := lo.Filter(GetResRelations(item.Uuid), func(item ResRelation, _ int) bool {
return item.Type == "inner" || item.Type == "left" || item.Type == "right"
})
for i := 0; i < len(joins); i++ {
oResource, ok := GetResource(joins[i].ResourceCode)
if !ok {
continue
}
rResource, ok := GetResRelationResource(joins[i])
if !ok {
continue
}
join := NewOrmJoin(contracts.RelationType(joins[i].Type), oResource, joins[i].Code, joins[i].RelationResource, joins[i].RelationField, joins[i].RelationForeignKey)
// 关联扩展条件
join.SetCondition(GetOrmConditions(joins[i].Uuid, condition.Describe("关联扩展条件")))
join.Inject(sd, NewOrmModel(rResource, rResource.GetCode(), rResource.GetName()))
}
conditions := GetOrmConditions(item.Uuid, condition.Describe("关联扩展条件"))
if len(joins) == 0 && conditions.IsEmpty() {
// 无权限, 直接跳过这个 unoin 语句
if carry != nil {
return carry
}
// 第一个无权限除外, 避免所有用户所属角色都是无权限
sd = sd.Where(db.V(false))
isFullNot = true
} else if len(joins) == 0 && conditions.IsNotEmpty() && conditions.IsAlwaysRight() /* 1=1 的这种条件*/ {
// 只要有1个满权限, 直接返回单条语句
isFullRight = true
return sd
} else if conditions.IsNotEmpty() {
oOrm := NewOrm(this, nil)
oOrm.SetGlobalParams(req.NewGlobalParams("{}", u))
sd = sd.Where(conditions.ToSql(oOrm.GetModel()))
// 如果前面是无权限的sql查看, 这直接返回本次查询
if isFullNot {
isFullNot = false
return sd
}
}
if carry != nil {
return carry.Union(sd)
}
return sd
}, nil)
if isFullRight {
return nil, nil
}
if isFullNot {
return nil, db.V(false)
}
return sub, nil
}
func (this Resource) GetStruct(extends ...reflect.StructField) any {
fields := []reflect.StructField{}
for _, field := range this.Fields {
if unicode.IsLetter(rune(field.Code[0])) {
fields = append(fields, field.ToStructField())
} else {
log.Printf("资源字段错误, 必须以字母开头 <- %s", field.Code)
}
}
fields = lo.UniqBy(append(fields, extends...), func(v reflect.StructField) string {
return v.Name
})
t := reflect.StructOf(fields)
return reflect.New(t).Interface()
}
func (this Resource) GetSliceStruct(extends ...reflect.StructField) any {
t := reflect.TypeOf(this.GetStruct(extends...))
st := reflect.SliceOf(t.Elem())
return reflect.New(st).Interface()
}
// onResEvent 资源事件
func (this Resource) onResEvent(t string, result sql.Result) {
if num, err := result.RowsAffected(); num == 0 || err != nil {
return
}
// 有些地方可能没有注册 contracts.Queue
if qu, err := do.Invoke[contracts.Queue](this.container); err == nil {
// 全局触发器
// 1. 清除系统缓存
if err := qu.Publish(ResChangeEventTopic, ResChangeEventTopicPayload{
Type: t,
Res: this,
Result: result,
}); err != nil {
log.Println("Queue Publish Err:", ResChangeEventTopic, err)
}
}
}
// onUserEvent 用户事件
func (this Resource) onUserEvent(builder exec.QueryExecutor, user req.User) {
// old := []map[string]any{}
// builder.Before(func(b *db.Builder, t string, data ...map[string]any) {
// if t != db.TYPE_UPDATE && t != db.TYPE_DELETE {
// return
// }
// // 查询保存之前的数据
// if _, err := b.Get(&old); err != nil {
// panic(err)
// }
// })
// builder.After(func(b *db.Builder, t string, result sql.Result, err error, data ...map[string]any) {
// if err != nil || t == db.TYPE_SELECT {
// return
// } else if num, err := result.RowsAffected(); num == 0 || err != nil {
// return
// }
// if user == nil {
// user = GetAnonymous()
// }
// // 触发消息队列
// if err := do.MustInvoke[contracts.Queue](this.container).Publish(ResChangeRecordTopic, ResChangeRecordTopicPayload{
// Type: t,
// User: user,
// Res: this,
// Old: old,
// New: data,
// Result: result,
// }); err != nil {
// log.Println("Queue Publish Err:", ResChangeRecordTopic, err)
// }
// })
}
func NewVirtualResource(pRes req.Resource, code, name string, sd *db.SelectDataset, fields []ResField) req.Resource {
fieldsCopy := make([]ResField, len(fields))
copy(fieldsCopy, fields)
for i := 0; i < len(fieldsCopy); i++ {
fieldsCopy[i].CodeResource = code
}
sql, _, err := sd.WithDialect(pRes.DB().Dialect()).ToSQL()
if err != nil {
panic(err)
}
return &Resource{
Uuid: code,
PUuid: pRes.GetUuid(),
Code: code,
Name: name,
IsResVirtual: true,
Conn: pRes.GetConn(),
Table: sql,
Fields: fieldsCopy,
Roles: nil,
}
}
-426
View File
@@ -1,426 +0,0 @@
package base
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"reflect"
"strconv"
"strings"
"sync"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/exp"
"git.fsdpf.net/go/db/schema"
"git.fsdpf.net/go/req"
"github.com/samber/lo"
)
var changeLogOnce sync.Map // key: Conn,每个数据库连接只建一次 _change_log 表
// isLocalDB 判断是否为本地文件型数据库(LastInsertId 返回最后一条而非第一条)
func isLocalDB(dialect string) bool {
return dialect == "sqlite3" || dialect == "duckdb"
}
type ResourceHooks struct {
res Resource
u req.User
tx *db.TxDatabase
snapshots []map[string]any // UPDATE/DELETE 前预查的行数据
}
func (rh *ResourceHooks) isTracked() bool {
if len(rh.res.HistoryRoles) == 0 || rh.res.IsVirtual() {
return false
}
if lo.Contains(rh.res.HistoryRoles, "00000000-0000-0000-0000-000000000000") {
return true
}
return lo.Some(rh.res.HistoryRoles, rh.u.Roles())
}
// autoCreateChangeLog 在当前连接上建 _change_log 表,每个连接只执行一次
func (rh *ResourceHooks) autoCreateChangeLog() error {
conn := rh.res.DB()
v, _ := changeLogOnce.LoadOrStore(rh.res.Conn, &sync.Once{})
var err error
v.(*sync.Once).Do(func() {
sb := schema.New(conn)
err = sb.Create("_change_log", func(bp *schema.Blueprint) {
bp.Comment = "资源数据变更记录"
bp.BigIncrements("id").AutoIncrement().Comment("ID")
bp.Boolean("enabled").Default("1").Comment("是否有效")
bp.Char("created_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("操作用户")
bp.Char("owned_user", 36).Default("00000000-0000-0000-0000-000000000000").Comment("拥有者")
bp.Timestamp("created_at").UseCurrent().Comment("操作时间")
bp.Timestamp("updated_at").UseCurrent().Default(db.L("ON UPDATE CURRENT_TIMESTAMP")).Comment("更新时间")
bp.DateTime("deleted_at").Nullable().Comment("删除时间")
bp.Char("resource_uuid", 36).Default("").Comment("资源UUID")
bp.String("category", 10).Default("").Comment("操作类型 INSERT/UPDATE/DELETE")
bp.Char("trace_id", 36).Default("").Comment("请求追踪ID")
bp.Integer("row_id").Default("0").Comment("受影响行ID")
bp.Json("snapshot").Nullable().Comment("操作前数据快照")
})
})
return err
}
// captureSnapshot 在写操作执行前,查出受影响的行存入 snapshots
func (rh *ResourceHooks) captureSnapshot(where exp.ExpressionList) {
sd := rh.res.DB().From(rh.res.GetTable())
if where != nil && len(where.Expressions()) > 0 {
sd = sd.Where(where.Expressions()...)
}
rows, err := sd.Executor().GetRecords()
if err != nil {
log.Printf("_change_log captureSnapshot err: %v", err)
return
}
rh.snapshots = rows
}
// writeChangeLogs 将变更写入 _change_log
func (rh *ResourceHooks) writeChangeLogs(category string, result sql.Result) {
if err := rh.autoCreateChangeLog(); err != nil {
log.Printf("_change_log init err: %v", err)
return
}
traceId := rh.u.Runtime().TraceId()
opUser := rh.u.Uuid()
var rows []any
switch category {
case "INSERT":
lastId, _ := result.LastInsertId()
count, _ := result.RowsAffected()
for i := int64(0); i < count; i++ {
rowId := lastId + i // MySQL: lastId 是第一条
if isLocalDB(rh.res.DB().Dialect()) {
rowId = lastId - count + 1 + i // SQLite: lastId 是最后一条
}
rows = append(rows, map[string]any{
"enabled": true,
"created_user": opUser,
"owned_user": opUser,
"resource_uuid": rh.res.Uuid,
"category": category,
"trace_id": traceId,
"row_id": rowId,
"snapshot": nil,
})
}
case "UPDATE", "DELETE":
for _, snap := range rh.snapshots {
rowId := int64(0)
if id, ok := snap["id"]; ok {
rowId = toChangeLogInt64(id)
}
snapJSON, _ := json.Marshal(snap)
rows = append(rows, map[string]any{
"enabled": true,
"created_user": opUser,
"owned_user": opUser,
"resource_uuid": rh.res.Uuid,
"category": category,
"trace_id": traceId,
"row_id": rowId,
"snapshot": string(snapJSON),
})
}
}
if len(rows) == 0 {
return
}
inserter := rh.res.DB().Insert("_change_log")
if rh.tx != nil {
inserter = rh.tx.Insert("_change_log")
}
if _, err := inserter.Rows(rows...).Executor().Exec(); err != nil {
log.Printf("_change_log write err: %v", err)
}
}
func toChangeLogInt64(v any) int64 {
switch val := v.(type) {
case int64:
return val
case int:
return int64(val)
case int32:
return int64(val)
case float64:
return int64(val)
case string:
n, _ := strconv.ParseInt(val, 10, 64)
return n
}
return 0
}
func (rh *ResourceHooks) Before(dataset interface{}) error {
switch d := dataset.(type) {
case *db.SelectDataset:
return rh.beforeSelectDataset(d)
case *db.InsertDataset:
return rh.beforeInsertDataset(d)
case *db.UpdateDataset:
return rh.beforeUpdateDataset(d)
case *db.DeleteDataset:
return rh.beforeDeleteDataset(d)
}
return nil
}
func (rh *ResourceHooks) After(dataset interface{}, result interface{}) error {
if rh.res.IsVirtual() {
// 返回鉴权后的 DB Builder
return nil
}
// 用户事件
// if rh.res.IsHistoryRecord {
// rh.res.onUserEvent(builder, user)
// }
switch dataset.(type) {
case *db.SelectDataset:
case *db.InsertDataset:
r := result.(sql.Result)
rh.res.onResEvent("INSERT", r)
if rh.isTracked() {
rh.writeChangeLogs("INSERT", r)
}
case *db.UpdateDataset:
r := result.(sql.Result)
rh.res.onResEvent("UPDATE", r)
if rh.isTracked() {
rh.writeChangeLogs("UPDATE", r)
}
case *db.DeleteDataset:
r := result.(sql.Result)
rh.res.onResEvent("DELETE", r)
if rh.isTracked() {
rh.writeChangeLogs("DELETE", r)
}
}
return nil
}
func (rh *ResourceHooks) beforeInsertDataset(id *db.InsertDataset) error {
switch true {
case id.GetClauses().HasRows():
return rh.beforeInsertRows(id)
case id.GetClauses().HasVals():
return rh.beforeInsertColsVals(id)
case id.GetClauses().HasFrom():
return rh.beforeInsertFromQuery(id)
}
return nil
}
func (rh *ResourceHooks) 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 := rows[i].(map[string]any)
// 格式化保存数据
if err := rh.normalizeSaveValue(row); err != nil {
return err
}
// 填充默认数据
if err := rh.applyDefaultValue(row, true, false); err != nil {
return err
}
}
return nil
}
func (rh *ResourceHooks) 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))
}
colsVal := []any{}
for k, v := range lo.OmitByKeys(rh.getFieldsDefaultValue(true, false), colsName) {
cols = cols.Append(db.C(k))
colsVal = append(colsVal, v)
}
for i := 0; i < len(vals); i++ {
for j := 0; j < len(vals[i]); j++ {
if filed, ok := rh.res.GetField(colsName[j]); ok {
vals[i][j] = filed.ToValue(vals[i][j])
}
}
vals[i] = append(vals[i], colsVal...)
}
*id = *id.Cols(cols)
return nil
}
func (rh *ResourceHooks) beforeInsertFromQuery(id *db.InsertDataset) error {
return nil
}
// 规范化保存数据
// 1. 移除系统缺省字段, 如果是 insert 需要调用 applyDefaultValue
// 2. 移除系统中没有的字段
func (rh *ResourceHooks) 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)
} else if val, ok := v.(db.Expression); ok {
row[k] = val
} else if field, ok := rh.res.GetField(k); ok {
row[k] = field.ToValue(v)
} else {
delete(row, k)
}
}
return nil
}
// 填充默认数据
func (rh *ResourceHooks) applyDefaultValue(row db.Record, forInsert, forUpdate bool) error {
// 填充默认数据
for k, v := range rh.getFieldsDefaultValue(forInsert, forUpdate) {
if _, ok := row[k]; !ok {
row[k] = v
}
}
return nil
}
func (rh *ResourceHooks) getFieldsDefaultValue(forInsert, forUpdate bool) map[string]db.Expression {
defs := map[string]db.Expression{}
if forInsert {
for _, item := range rh.res.Fields {
if item.GetCode() == "updated_at" || item.GetCode() == "created_user" || item.GetCode() == "owned_user" {
continue
}
if len(item.Default) > 4 && strings.ToLower(item.Default[0:4]) == "sql:" {
defs[item.GetCode()] = item.GetRawDefault()
} else if item.DataType == req.ResJson {
defs[item.GetCode()] = item.GetRawDefault()
}
}
if _, ok := defs["owned_user"]; !ok {
defs["owned_user"] = db.V(rh.u.Uuid())
}
defs["created_user"] = db.V(rh.u.Uuid())
}
if forUpdate && isLocalDB(rh.res.DB().Dialect()) {
defs["updated_at"] = db.L("CURRENT_TIMESTAMP")
}
return defs
}
func (rh *ResourceHooks) beforeSelectDataset(sd *db.SelectDataset) error {
sub, ex := rh.res.GetRolesCondition(rh.u)
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 nil
}
func (rh *ResourceHooks) beforeUpdateDataset(ud *db.UpdateDataset) error {
// 虚拟资源暂时不考虑鉴权
if rh.res.IsVirtual() {
return nil
}
sub, ex := rh.res.GetRolesCondition(rh.u)
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(rh.res.GetCode()).Col("id"))))
} else {
*ud = *ud.Where(db.L("?.id", table).In(sub.Select(db.T(rh.res.GetCode()).Col("id"))))
}
}
// 记录变更前快照(role 条件已应用,WHERE 与实际 UPDATE 一致)
if rh.isTracked() {
rh.captureSnapshot(ud.GetClauses().Where())
}
// 格式化保存数据
if ud.GetClauses().HasSetValues() {
udv := ud.GetClauses().SetValues()
switch data := udv.(type) {
case map[string]any:
return rh.normalizeSaveValue(data)
case db.Record:
return rh.normalizeSaveValue(data)
default:
return fmt.Errorf("unsupported type: %T", udv)
}
}
return nil
}
func (rh *ResourceHooks) beforeDeleteDataset(dd *db.DeleteDataset) error {
// 虚拟资源暂时不考虑鉴权
if rh.res.IsVirtual() {
return nil
}
sub, ex := rh.res.GetRolesCondition(rh.u)
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(rh.res.GetCode()).Col("id"))))
} else {
*dd = *dd.Where(db.L("?.id", table).In(sub.Select(db.T(rh.res.GetCode()).Col("id"))))
}
}
// 记录变更前快照(role 条件已应用,WHERE 与实际 DELETE 一致)
if rh.isTracked() {
rh.captureSnapshot(dd.GetClauses().Where())
}
return nil
}
-100
View File
@@ -1,100 +0,0 @@
package base_test
import (
"os"
"testing"
"git.fsdpf.net/go/contracts/base"
"git.fsdpf.net/go/db"
"git.fsdpf.net/go/db/engine"
"git.fsdpf.net/go/req"
"github.com/samber/do/v2"
"github.com/stretchr/testify/suite"
_ "git.fsdpf.net/go/db/schema/dialect/mysql"
_ "git.fsdpf.net/go/db/schema/dialect/sqlite3"
)
type resourceTest struct {
suite.Suite
res *base.Resource
}
func TestResource(t *testing.T) {
suite.Run(t, new(resourceTest))
}
func (t *resourceTest) SetupSuite() {
app := do.New()
do.Provide(app, func(container do.Injector) (engine.Engine, error) {
return engine.Open(map[string]engine.DBConfig{
"default": engine.NewDBConfig("mysql",
engine.WithHost(os.Getenv("MYSQL_HOST")),
engine.WithPort(os.Getenv("MYSQL_PORT")),
engine.WithDatabase(os.Getenv("MYSQL_DB")),
engine.WithUsername(os.Getenv("MYSQL_USER")),
engine.WithPassword(os.Getenv("MYSQL_PASSWD")),
),
}), nil
})
fields := []base.ResField{
{Name: "字段", Code: "fields", DataType: req.ResJson, Default: "{}"},
}
res := &base.Resource{
Uuid: "4bf3a311-cbe1-4236-bdda-c64ab04ae9b1",
Code: "User",
Name: "用户",
Table: "users",
Primarykey: "id",
Fields: fields,
}
res.InitContainer(app)
t.res = res
}
func (t *resourceTest) TestInsertRows() {
type Temp struct {
Name string `db:"name"`
Fields map[string]any `db:"fields"`
}
result := "INSERT INTO `users` AS `User` (`created_user`, `fields`, `name`, `owned_user`) " +
"VALUES ('00000000-0000-0000-0000-000000000000', '{\\\"aa\\\":1}', '张三', '00000000-0000-0000-0000-000000000000')"
sql, _, _ := t.res.GetDBTable(base.GetAnonymous()).Insert().Rows(
db.Record{"name": "张三", "id": 1, "fields": map[string]any{"aa": 1}},
).Executor().ToSQL()
t.Equal(result, sql)
sql, _, _ = t.res.GetDBTable(base.GetAnonymous()).Insert().Rows(
&Temp{Name: "张三", Fields: map[string]any{"aa": 1}},
).Executor().ToSQL()
t.Equal(result, sql)
sql, _, _ = t.res.GetDBTable(base.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` AS `User` (`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(base.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() {}
+4 -16
View File
@@ -53,36 +53,24 @@ func (this user) Runtime() req.UserRuntime {
// GetAnonymous 获取匿名用户 // GetAnonymous 获取匿名用户
func GetAnonymous(opts ...req.UserRuntimeOption) req.User { func GetAnonymous(opts ...req.UserRuntimeOption) req.User {
u := user{ return user{
id: 0, id: 0,
uuid: "00000000-0000-0000-0000-000000000000", uuid: "00000000-0000-0000-0000-000000000000",
username: "anonymous", username: "anonymous",
nickname: "匿名者", nickname: "匿名者",
roles: []string{"00000000-0000-0000-0000-000000000000"}, roles: []string{"00000000-0000-0000-0000-000000000000"},
runtime: req.UserRuntime{}, runtime: req.NewUserRuntime(opts...),
} }
for _, opt := range opts {
opt(&u.runtime)
}
return u
} }
// GetSystemUser 系统用户 // GetSystemUser 系统用户
func GetSystemUser(opts ...req.UserRuntimeOption) req.User { func GetSystemUser(opts ...req.UserRuntimeOption) req.User {
u := user{ return user{
id: -1, id: -1,
uuid: "ffffffff-ffff-ffff-ffff-ffffffffffff", uuid: "ffffffff-ffff-ffff-ffff-ffffffffffff",
username: "system", username: "system",
nickname: "系统", nickname: "系统",
roles: []string{"ffffffff-ffff-ffff-ffff-ffffffffffff"}, roles: []string{"ffffffff-ffff-ffff-ffff-ffffffffffff"},
runtime: req.UserRuntime{}, runtime: req.NewUserRuntime(opts...),
} }
for _, opt := range opts {
opt(&u.runtime)
}
return u
} }
+1
View File
@@ -8,6 +8,7 @@ type UserAsset struct {
ID int64 `db:"id"` ID int64 `db:"id"`
Name string `db:"name"` Name string `db:"name"`
FileName string `db:"filename"` FileName string `db:"filename"`
FileUrl string `db:"file_url"`
File string `db:"file"` File string `db:"file"`
Mime string `db:"mime"` Mime string `db:"mime"`
Size int64 `db:"size"` Size int64 `db:"size"`