feat: 新增业务字段路径读写工具
新增 TakeBusinessFields、WriteBusinessFields、SetByPath 与 GetByPath 等工具,支持按映射路径写入请求体与解析响应,并更新相关依赖。
This commit is contained in:
@@ -0,0 +1,605 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
// ============================================================
|
||||
// 业务字段读写:TakeBusinessFields 把 businessParams 按映射解析为写入路径,
|
||||
// WriteBusinessFields 按路径写入最终请求体(路径语法见 SetByPath)。
|
||||
// ============================================================
|
||||
|
||||
// TakeBusinessFields 把业务参数(businessParams)按映射解析为写入路径:
|
||||
// - 调用方按业务字段名(RequestBusinessFieldMapping 的 key)传值,这里是独立的 businessParams map,
|
||||
// 不再与模板字段混在 requestParams 中
|
||||
// - 业务字段名未配置映射 → 返回错误(不静默忽略)
|
||||
// - 解包 {type,value} 包裹格式为原始值
|
||||
// - 跳过空值(空串/空数组),避免写入请求体污染
|
||||
//
|
||||
// 返回 map[映射路径]原始值,构建完成后由 WriteBusinessFields 按路径写入请求体。
|
||||
func TakeBusinessFields(businessParams map[string]any, bizMapping map[string]string) (map[string]any, error) {
|
||||
if len(businessParams) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
keyToPath := make(map[string]string, len(bizMapping))
|
||||
for key, path := range bizMapping {
|
||||
if key != "" && path != "" {
|
||||
keyToPath[key] = path
|
||||
}
|
||||
}
|
||||
bizValues := make(map[string]any)
|
||||
for key, raw := range businessParams {
|
||||
path, isBiz := keyToPath[key]
|
||||
if !isBiz {
|
||||
return nil, fmt.Errorf("业务字段 [%s] 未配置映射(RequestBusinessFieldMapping 中不存在该业务字段名)", key)
|
||||
}
|
||||
v := unwrapBizValue(raw)
|
||||
if isEmptyBizValue(v) {
|
||||
continue
|
||||
}
|
||||
bizValues[path] = v
|
||||
}
|
||||
return bizValues, nil
|
||||
}
|
||||
|
||||
// unwrapBizValue 解包模板包裹格式 {type, value},返回原始值;非包裹格式原样返回
|
||||
func unwrapBizValue(v any) any {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
if _, hasType := m["type"]; hasType {
|
||||
if val, hasVal := m["value"]; hasVal {
|
||||
return val
|
||||
}
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// isEmptyBizValue 判断业务字段值是否为空(空值不写入请求体)
|
||||
func isEmptyBizValue(v any) bool {
|
||||
switch t := v.(type) {
|
||||
case nil:
|
||||
return true
|
||||
case string:
|
||||
return t == ""
|
||||
case []any:
|
||||
return len(t) == 0
|
||||
case map[string]any:
|
||||
return len(t) == 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 统一字段路径语法(读/写共用,见 NormalizeFieldPath):
|
||||
//
|
||||
// a.b.c 普通点号路径
|
||||
// a[*].b [*] 表示数组段
|
||||
// a[*].b[*]?k=v&t=# 单层选择器:在数组中按 k==v 匹配元素,值/读取目标为 t
|
||||
// a[*]?k=v&b[*]?k2=v2&t=# 多级选择器:选择器体内可再嵌 [*]?选择器,级数不限。
|
||||
// 每级 k=v 既是匹配条件(命中已存在元素时),
|
||||
// 也是新建元素时写入该元素的字段(如 role=user 直接落为 role 字段);
|
||||
// 只有带 t=# 的那级是叶子目标(写值/读值的位置)。
|
||||
//
|
||||
// SetByPath(写,构建请求体)与 GetByPath(读,解析响应)共用 parsePath;
|
||||
// 读方向语义:数组段非末段取第 0 个元素继续下钻,[*] 为末段返回整个数组,选择器定位匹配元素;
|
||||
// 写方向语义:数组段非末段作用于最后一个元素,末段追加,选择器 upsert(命中更新/未命中新建),
|
||||
// 多值([]any)仅在叶子选择器展开为多个独立元素(多个参考图/视频等)。
|
||||
// ============================================================
|
||||
|
||||
// SetByPath 按业务字段映射路径把值写入请求结构(请求侧构建)。
|
||||
// 路径语法与 BuildSchemaMapping 输出一致(干净形态,无需 attrs 剔除)。
|
||||
//
|
||||
// 写入语义:
|
||||
// - 目标字段已存在且是数组 → 追加
|
||||
// - 目标字段已存在且非数组 → 覆盖(普通叶子路径)
|
||||
// - 目标字段不存在 → 新建
|
||||
// - 数组段/选择器段目标字段不是数组(如字符串 content)→ 返回错误,不覆盖已有值
|
||||
// - 中间路径遇到非对象字段 → 返回错误
|
||||
// - 数组段无选择器且非末尾 → 作用于最后一个元素(追加语义),数组为空则补一个空元素
|
||||
// - 数组段带选择器 → 命中则更新目标字段,未命中则按选择器字段构造新元素追加;
|
||||
// 选择器段即使未标 [*] 也按数组处理(如 input.media?type=first_frame&url=#)
|
||||
// - 多级选择器 → 递归:中间级选择器定位/新建容器元素并继续下钻,叶子选择器写值
|
||||
// - 值本身是数组 → 叶子选择器逐值追加;普通数组段/点号路径按 appendValues 追加
|
||||
func SetByPath(root map[string]any, path string, value any) error {
|
||||
steps := parsePath(NormalizeFieldPath(path))
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
return setBySteps(root, steps, value)
|
||||
}
|
||||
|
||||
// setBySteps 按步骤序列写入;选择器步骤(可能带嵌套)递归处理,非选择器步骤逐层下钻
|
||||
func setBySteps(cur map[string]any, steps []step, value any) error {
|
||||
first := steps[0]
|
||||
last := len(steps) == 1
|
||||
if first.sel != nil {
|
||||
// 选择器段:目标字段按数组处理(upsert),路径段未标 [*] 也按数组匹配
|
||||
arr, err := existingArray(cur, first.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newArr, err := upsertStep(arr, first, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur[first.key] = newArr
|
||||
return nil
|
||||
}
|
||||
if !first.isArray {
|
||||
if last {
|
||||
setLeaf(cur, first.key, value)
|
||||
return nil
|
||||
}
|
||||
next, err := ensureMap(cur, first.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return setBySteps(next, steps[1:], value)
|
||||
}
|
||||
// 数组段(无选择器)
|
||||
arr, err := existingArray(cur, first.key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if last {
|
||||
cur[first.key] = appendValues(arr, value)
|
||||
return nil
|
||||
}
|
||||
// 无选择器数组段:作用于最后一个元素(追加语义)
|
||||
if len(arr) == 0 {
|
||||
arr = append(arr, map[string]any{})
|
||||
cur[first.key] = arr
|
||||
}
|
||||
lastElem, ok := arr[len(arr)-1].(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("字段 [%s] 数组元素不是对象,无法继续下钻(当前类型 %T)", first.key, arr[len(arr)-1])
|
||||
}
|
||||
return setBySteps(lastElem, steps[1:], value)
|
||||
}
|
||||
|
||||
// WriteBusinessFields 把业务字段值写入最终请求体。
|
||||
// bizValues 的键为映射路径(如 input.media?type=reference_video&url=#),值由调用方按路径传入。
|
||||
// 按字典序升序写入:父路径是子路径的前缀(短者靠前),保证容器先写、子路径再 upsert,
|
||||
// 避免子路径先建出的结构被父路径整体覆盖(如 messages 容器与 messages[*].content[*] 内嵌目标并存)。
|
||||
// 任一路径写入失败(如数组段目标不是数组)→ 返回错误,由调用方拒绝本次请求。
|
||||
func WriteBusinessFields(out map[string]any, bizValues map[string]any) error {
|
||||
paths := make([]string, 0, len(bizValues))
|
||||
for path := range bizValues {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
for _, path := range paths {
|
||||
if err := SetByPath(out, path, bizValues[path]); err != nil {
|
||||
return fmt.Errorf("业务字段写入失败 [%s]: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByPath 按字段路径读取响应值(与 SetByPath 同一套路径语法,读方向语义):
|
||||
// - 普通段:逐层进入对象取字段
|
||||
// - 数组段 [*]:非末段取数组第 0 个元素继续下钻;[*] 为末段返回整个数组
|
||||
// - 选择器段 ?k=v&t=#:定位 k==v 的元素,返回该元素 t 字段的值;多级选择器递归下钻
|
||||
//
|
||||
// 未命中(路径缺失 / 中间类型不符)返回 (nil, nil),不视为错误;语法错误返回 error。
|
||||
func GetByPath(root map[string]any, path string) (any, error) {
|
||||
steps := parsePath(NormalizeFieldPath(path))
|
||||
if len(steps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return getBySteps(root, steps)
|
||||
}
|
||||
|
||||
// getBySteps 按步骤序列读取;选择器步骤(可能带嵌套)递归处理
|
||||
func getBySteps(cur any, steps []step) (any, error) {
|
||||
if len(steps) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
first := steps[0]
|
||||
rest := steps[1:]
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
// 选择器段:定位匹配元素,返回叶子目标或递归嵌套下钻
|
||||
if first.sel != nil {
|
||||
arr, ok := m[first.key].([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
for _, e := range arr {
|
||||
em, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if matchFilters(em, first.sel) {
|
||||
return getSelValue(em, first.sel, rest)
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
v, ok := m[first.key]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if first.isArray {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return arr, nil
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return getBySteps(arr[0], rest)
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return v, nil
|
||||
}
|
||||
return getBySteps(v, rest)
|
||||
}
|
||||
|
||||
// getSelValue 选择器命中元素后取值:有嵌套路径则递归下钻,否则取叶子目标字段
|
||||
// (target 可为点号路径,如 image_url.url=#,按 parseSteps 拆级下钻)
|
||||
func getSelValue(em map[string]any, sel *selNode, rest []step) (any, error) {
|
||||
if len(sel.nested) > 0 {
|
||||
return getBySteps(em, append(sel.nested, rest...))
|
||||
}
|
||||
if sel.target != "" {
|
||||
return getBySteps(em, append(parseSteps(sel.target), rest...))
|
||||
}
|
||||
return getBySteps(em, rest)
|
||||
}
|
||||
|
||||
// GetByPathValue 读取路径值,未命中或出错返回 nil(免去调用方处理双返回值)
|
||||
func GetByPathValue(root map[string]any, path string) any {
|
||||
v, err := GetByPath(root, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// GetByPathAll 按字段路径读取响应值(与 GetByPath 同一套语法),返回路径下**所有**命中值。
|
||||
// 与 GetByPath 的区别:GetByPath 命中即返回第一个匹配;GetByPathAll 遍历数组段/选择器段的全部
|
||||
// 元素并展开收集。适用于通配路径(messages[*]...[*]...)取全部匹配值(如收集所有图片 url)。
|
||||
// 无命中返回 nil。
|
||||
func GetByPathAll(root map[string]any, path string) []any {
|
||||
steps := parsePath(NormalizeFieldPath(path))
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
return getAllBySteps(root, steps)
|
||||
}
|
||||
|
||||
// getAllBySteps 按步骤序列收集全部匹配值;数组段/选择器段遍历所有元素展开,普通段单值包裹返回
|
||||
func getAllBySteps(cur any, steps []step) []any {
|
||||
if len(steps) == 0 {
|
||||
return nil
|
||||
}
|
||||
first := steps[0]
|
||||
rest := steps[1:]
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// 选择器段:遍历命中元素收集
|
||||
if first.sel != nil {
|
||||
arr, ok := m[first.key].([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var out []any
|
||||
for _, e := range arr {
|
||||
em, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if matchFilters(em, first.sel) {
|
||||
out = append(out, getSelValueAll(em, first.sel, rest)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
v, ok := m[first.key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if first.isArray {
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return arr
|
||||
}
|
||||
var out []any
|
||||
for _, e := range arr {
|
||||
out = append(out, getAllBySteps(e, rest)...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return []any{v}
|
||||
}
|
||||
return getAllBySteps(v, rest)
|
||||
}
|
||||
|
||||
// getSelValueAll 选择器命中元素后收集:有嵌套路径递归下钻,否则取叶子目标字段(全部)
|
||||
func getSelValueAll(em map[string]any, sel *selNode, rest []step) []any {
|
||||
if len(sel.nested) > 0 {
|
||||
return getAllBySteps(em, append(sel.nested, rest...))
|
||||
}
|
||||
if sel.target != "" {
|
||||
return getAllBySteps(em, append(parseSteps(sel.target), rest...))
|
||||
}
|
||||
return getAllBySteps(em, rest)
|
||||
}
|
||||
|
||||
// step 路径段;sel 非空表示该段带选择器(按数组处理)
|
||||
type step struct {
|
||||
key string
|
||||
isArray bool
|
||||
sel *selNode
|
||||
}
|
||||
|
||||
// selNode 选择器:
|
||||
// - filters:k=v 匹配条件,新建元素时也作为字段写入
|
||||
// - target:叶子目标字段(k=#),值/读取目标;target 为空且 nested 非空时为中间级选择器
|
||||
// - nested:下钻子路径(多级嵌套选择器,级数不限)
|
||||
type selNode struct {
|
||||
filters [][2]string
|
||||
target string
|
||||
nested []step
|
||||
}
|
||||
|
||||
// parsePath 解析路径为步骤序列。选择器体挂到最后一个步骤上;选择器体中的嵌套 [*]?选择器
|
||||
// 递归解析为 nested(级数不限)。
|
||||
func parsePath(p string) []step {
|
||||
base, suffix := p, ""
|
||||
if i := strings.Index(p, "?"); i >= 0 {
|
||||
base, suffix = p[:i], p[i+1:]
|
||||
}
|
||||
steps := parseSteps(base)
|
||||
if suffix != "" {
|
||||
parseSelector(suffix, &steps)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// parseSteps 解析点号分隔的普通步骤(含 [*] 数组段)
|
||||
func parseSteps(s string) []step {
|
||||
var steps []step
|
||||
for _, raw := range strings.Split(s, ".") {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
st := step{}
|
||||
if strings.HasSuffix(raw, "[*]") {
|
||||
st.key = strings.TrimSuffix(raw, "[*]")
|
||||
st.isArray = true
|
||||
} else {
|
||||
st.key = raw
|
||||
}
|
||||
steps = append(steps, st)
|
||||
}
|
||||
return steps
|
||||
}
|
||||
|
||||
// parseSelector 解析选择器体(? 之后的内容)并挂到最后一个步骤上。
|
||||
// 元素用顶层 & 分隔(? 之后的 & 属于嵌套选择器);k=v 为过滤/写入对,k=# 为叶子目标,
|
||||
// 含 [*] 或路径的块为嵌套下钻子路径(递归 parsePath)。
|
||||
func parseSelector(selStr string, steps *[]step) {
|
||||
if len(*steps) == 0 {
|
||||
return
|
||||
}
|
||||
sel := &selNode{}
|
||||
var nested []step
|
||||
for _, el := range splitTopLevel(selStr) {
|
||||
if isPair(el) {
|
||||
k, v, _ := strings.Cut(el, "=")
|
||||
if v == "#" {
|
||||
sel.target = k
|
||||
} else {
|
||||
sel.filters = append(sel.filters, [2]string{k, v})
|
||||
}
|
||||
continue
|
||||
}
|
||||
// 嵌套路径(含自己的选择器):级数不限,递归解析
|
||||
nested = append(nested, parsePath(el)...)
|
||||
}
|
||||
if len(sel.filters) == 0 && sel.target == "" && len(nested) == 0 {
|
||||
return
|
||||
}
|
||||
last := &(*steps)[len(*steps)-1]
|
||||
if last.sel == nil {
|
||||
last.sel = sel
|
||||
}
|
||||
if len(nested) > 0 {
|
||||
last.sel.nested = nested
|
||||
}
|
||||
}
|
||||
|
||||
// splitTopLevel 按顶层 & 拆分选择器体;? 之后的 & 属于嵌套选择器,不在此层拆分
|
||||
func splitTopLevel(s string) []string {
|
||||
var elems []string
|
||||
var cur strings.Builder
|
||||
inNested := false
|
||||
for _, ch := range s {
|
||||
if ch == '?' {
|
||||
inNested = true
|
||||
}
|
||||
if ch == '&' && !inNested {
|
||||
elems = append(elems, cur.String())
|
||||
cur.Reset()
|
||||
continue
|
||||
}
|
||||
cur.WriteRune(ch)
|
||||
}
|
||||
if cur.Len() > 0 {
|
||||
elems = append(elems, cur.String())
|
||||
}
|
||||
return elems
|
||||
}
|
||||
|
||||
// isPair 判断元素是否为 k=v 对:= 出现在任何 [ ? 之前则是 pair,否则为嵌套路径。
|
||||
// 目标字段 k 本身可以是点号路径(image_url.url=#),故 . 不参与判别。
|
||||
func isPair(el string) bool {
|
||||
for i := 0; i < len(el); i++ {
|
||||
switch el[i] {
|
||||
case '=':
|
||||
return true
|
||||
case '[', '?':
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// existingArray 返回数组字段的当前数组:
|
||||
// - 字段不存在 → 空数组(允许按追加语义新建)
|
||||
// - 字段是数组 → 原样
|
||||
// - 字段是其他类型(如字符串 content)→ 返回错误,调用方拒绝写入,不覆盖已有值
|
||||
func existingArray(cur map[string]any, key string) ([]any, error) {
|
||||
v, ok := cur[key]
|
||||
if !ok {
|
||||
return []any{}, nil
|
||||
}
|
||||
if arr, ok := v.([]any); ok {
|
||||
return arr, nil
|
||||
}
|
||||
return nil, fmt.Errorf("字段 [%s] 不是数组,无法按数组路径写入(当前类型 %T)", key, v)
|
||||
}
|
||||
|
||||
// upsertStep 选择器 upsert,返回追加后的数组:
|
||||
// - 叶子选择器(有 target)且值为数组 → 每个值追加一个独立元素
|
||||
// - 命中(所有 filters 匹配)→ 把值写入现有元素(叶子写 target,中间级递归 nested)
|
||||
// - 未命中 → 按选择器字段构造新元素并追加
|
||||
//
|
||||
// 返回新切片(append 可能重新分配底层数组),调用方需用返回值覆盖写回。
|
||||
func upsertStep(arr []any, st step, value any) ([]any, error) {
|
||||
sel := st.sel
|
||||
// 叶子选择器:多值逐个展开为独立元素(多个参考图/视频等)
|
||||
if sel.target != "" {
|
||||
if vals, ok := value.([]any); ok && len(vals) > 0 {
|
||||
for _, v := range vals {
|
||||
elem, err := buildStepElement(sel, v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
arr = append(arr, elem)
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
}
|
||||
for _, e := range arr {
|
||||
m, ok := e.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if matchFilters(m, sel) {
|
||||
if err := writeStepValue(m, sel, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return arr, nil
|
||||
}
|
||||
}
|
||||
elem, err := buildStepElement(sel, value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append(arr, elem), nil
|
||||
}
|
||||
|
||||
// matchFilters 判断元素是否匹配选择器全部过滤条件;无过滤条件时命中第一个元素
|
||||
func matchFilters(m map[string]any, sel *selNode) bool {
|
||||
if len(sel.filters) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, f := range sel.filters {
|
||||
if gconv.String(m[f[0]]) != f[1] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// writeStepValue 把值写入已定位元素:中间级递归 nested 下钻,叶子写 target 字段
|
||||
func writeStepValue(m map[string]any, sel *selNode, value any) error {
|
||||
if len(sel.nested) > 0 {
|
||||
return setBySteps(m, sel.nested, value)
|
||||
}
|
||||
if sel.target != "" {
|
||||
return setLeafPath(m, sel.target, value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildStepElement 按选择器构造新元素:{filterKey: filterVal, ...} + 叶子写 target / 中间级递归 nested
|
||||
func buildStepElement(sel *selNode, value any) (map[string]any, error) {
|
||||
elem := make(map[string]any, len(sel.filters)+1)
|
||||
for _, f := range sel.filters {
|
||||
elem[f[0]] = f[1]
|
||||
}
|
||||
if err := writeStepValue(elem, sel, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return elem, nil
|
||||
}
|
||||
|
||||
// setLeafPath 在对象内按点号路径写入值(叶子用 setLeaf 语义)
|
||||
func setLeafPath(m map[string]any, path string, value any) error {
|
||||
cur := m
|
||||
segs := strings.Split(path, ".")
|
||||
for i, k := range segs {
|
||||
if i == len(segs)-1 {
|
||||
setLeaf(cur, k, value)
|
||||
return nil
|
||||
}
|
||||
next, err := ensureMap(cur, k)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setLeaf 叶子写入:目标已是数组 → 追加;非数组/不存在 → 覆盖/新建
|
||||
func setLeaf(parent map[string]any, key string, value any) {
|
||||
if existing, ok := parent[key]; ok {
|
||||
if arr, isArr := existing.([]any); isArr {
|
||||
parent[key] = appendValues(arr, value)
|
||||
return
|
||||
}
|
||||
}
|
||||
parent[key] = value
|
||||
}
|
||||
|
||||
// appendValues 追加值到数组;value 为数组时逐个追加
|
||||
func appendValues(arr []any, value any) []any {
|
||||
if vals, ok := value.([]any); ok {
|
||||
return append(arr, vals...)
|
||||
}
|
||||
return append(arr, value)
|
||||
}
|
||||
|
||||
// ensureMap 确保键对应 map,不存在则新建;已存在但非对象 → 返回错误
|
||||
func ensureMap(parent map[string]any, key string) (map[string]any, error) {
|
||||
if v, ok := parent[key]; ok {
|
||||
if m, isMap := v.(map[string]any); isMap {
|
||||
return m, nil
|
||||
}
|
||||
return nil, fmt.Errorf("字段 [%s] 不是对象,无法按路径写入(当前类型 %T)", key, v)
|
||||
}
|
||||
m := map[string]any{}
|
||||
parent[key] = m
|
||||
return m, nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// IsFlatMap 递归判断 map 是否扁平化
|
||||
func IsFlatMap(m map[string]interface{}) bool {
|
||||
for _, v := range m {
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
return false
|
||||
case []interface{}:
|
||||
for _, item := range val {
|
||||
if _, ok := item.(map[string]interface{}); ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// UnFlatBySjson 将扁平路径映射还原为嵌套 JSON
|
||||
func UnFlatBySjson(flatMap map[string]interface{}) (map[string]interface{}, error) {
|
||||
raw := "{}"
|
||||
for path, val := range flatMap {
|
||||
var err error
|
||||
raw, err = sjson.Set(raw, path, val)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sjson set path %s failed: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||
return nil, fmt.Errorf("parse final json failed: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/gogf/gf/v2/os/grpool"
|
||||
)
|
||||
|
||||
var (
|
||||
defaultPool atomic.Pointer[grpool.Pool]
|
||||
once sync.Once
|
||||
wg sync.WaitGroup
|
||||
)
|
||||
|
||||
const DefaultWorkerNum = 100
|
||||
|
||||
// Init 初始化全局协程池,首次调用生效,后续调用忽略。
|
||||
func Init(workerNum int) {
|
||||
once.Do(func() {
|
||||
if workerNum <= 0 {
|
||||
workerNum = DefaultWorkerNum
|
||||
}
|
||||
defaultPool.Store(grpool.New(workerNum))
|
||||
})
|
||||
}
|
||||
|
||||
// Submit 提交异步任务,上下文透传至 grpool。
|
||||
// Submit 也可在 Init 前调用(自动 Init),但 Shutdown 后返回 ErrPoolClosed。
|
||||
func Submit(ctx context.Context, task func(ctx context.Context)) error {
|
||||
p := defaultPool.Load()
|
||||
if p == nil {
|
||||
Init(DefaultWorkerNum)
|
||||
p = defaultPool.Load()
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
err := p.AddWithRecover(ctx, func(ctx context.Context) {
|
||||
defer wg.Done()
|
||||
task(ctx)
|
||||
}, nil)
|
||||
if err != nil {
|
||||
wg.Done()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown 优雅关闭:停止新任务,等待全部已完成/排队任务完成。
|
||||
func Shutdown() {
|
||||
p := defaultPool.Swap(nil)
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
wg.Wait()
|
||||
p.Close()
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
)
|
||||
|
||||
var (
|
||||
// 匹配 [数字]
|
||||
regNumIndex = regexp.MustCompile(`\[\d+\]`)
|
||||
// 匹配 .attrs
|
||||
regAttrs = regexp.MustCompile(`\.attrs`)
|
||||
)
|
||||
|
||||
// NormalizeFieldPath 归一化字段路径到统一语法([*] 数组段):
|
||||
// - 移除模板残留 .attrs
|
||||
// - [数字] 下标 → [*](choices[0] → choices[*])
|
||||
// - 兼容 gjson 风格 .# / .数字 下标 → [*](choices.#、choices.0 → choices[*])
|
||||
//
|
||||
// 统一语法见 business_fields.go 的 SetByPath / GetByPath:
|
||||
//
|
||||
// a.b.c 普通点号路径
|
||||
// a[*].b [*] 表示数组段
|
||||
// a[*].b[*]?k=v&t=# 选择器:数组元素按 k==v 定位,值/读取目标为 t
|
||||
// a[*]?k=v&b[*]?k2=v2&t=# 多级选择器:级数不限,中间级定位容器元素,叶子写值
|
||||
//
|
||||
// 正则归一(.attrs / [数字] / .#)作用于整个路径(含多级选择器中的数组段);
|
||||
// 纯数字段(gjson 下标)归一只作用于首个 ? 之前的 base 路径。
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// usage.attrs.total_tokens → usage.total_tokens
|
||||
// choices.attrs[0].attrs.message.attrs.content → choices[*].message.content
|
||||
// choices.#.message.content → choices[*].message.content
|
||||
// choices.0.message.content → choices[*].message.content
|
||||
func NormalizeFieldPath(path string) string {
|
||||
s := regAttrs.ReplaceAllString(path, "")
|
||||
s = regNumIndex.ReplaceAllString(s, "[*]")
|
||||
s = strings.ReplaceAll(s, ".#", "[*]")
|
||||
base, suffix := s, ""
|
||||
if i := strings.Index(s, "?"); i >= 0 {
|
||||
base, suffix = s[:i], s[i:]
|
||||
}
|
||||
// 逐段把纯数字段(gjson 下标)归一为 [*]:附着到前一段字段(choices.0 → choices[*]),
|
||||
// 避免误伤数字开头的字段名;选择器体用 # 作目标、不用数字段下标,故只归一 base
|
||||
segs := strings.Split(base, ".")
|
||||
var out []string
|
||||
for _, seg := range segs {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if isAllDigits(seg) {
|
||||
if len(out) > 0 {
|
||||
out[len(out)-1] += "[*]"
|
||||
} else {
|
||||
out = append(out, "[*]")
|
||||
}
|
||||
continue
|
||||
}
|
||||
out = append(out, seg)
|
||||
}
|
||||
return strings.Join(out, ".") + suffix
|
||||
}
|
||||
|
||||
// isAllDigits 判断字符串是否全部为数字字符
|
||||
func isAllDigits(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// CleanFieldPath 清理字段路径(等价于 NormalizeFieldPath,保留旧名兼容)
|
||||
func CleanFieldPath(path string) string {
|
||||
return NormalizeFieldPath(path)
|
||||
}
|
||||
|
||||
// CleanMapFieldPath 清理字段路径(Map)
|
||||
func CleanMapFieldPath(m map[string]string) map[string]string {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
newMap := make(map[string]string, len(m))
|
||||
for k, _ := range m {
|
||||
newMap[k] = CleanFieldPath(k)
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
|
||||
// ParseConfigTemplate 解析配置模板生成简化请求结构
|
||||
//
|
||||
// 输入: config 模板(含 type/value/defaultValue/attrs/enumValues 等元数据字段)
|
||||
// 输出: 简化后的请求结构体
|
||||
//
|
||||
// 规则:
|
||||
// - 标量字段(string/number/boolean): value 非零则用 value,为空则跳过(不再取 defaultValue)
|
||||
// - 对象字段(object): 递归处理 attrs
|
||||
// - 数组字段(array): 遍历 enumValues,每个 enumValue 独立判断是否产出元素
|
||||
// - 数组展开: enumValue 内某叶子字段 value 为数组时,按数组元素展开为多个项
|
||||
func ParseConfigTemplate(cfg map[string]interface{}) map[string]interface{} {
|
||||
var flattenJSON map[string]interface{}
|
||||
flatMap := IsFlatMap(cfg)
|
||||
if flatMap {
|
||||
var err error
|
||||
flattenJSON, err = UnFlatBySjson(cfg)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
flattenJSON = cfg
|
||||
}
|
||||
result := make(map[string]interface{})
|
||||
for key, val := range flattenJSON {
|
||||
field, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v := resolveField(field); v != nil {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveField 按 type 分发解析
|
||||
func resolveField(field map[string]interface{}) interface{} {
|
||||
fieldType, _ := field["type"].(string)
|
||||
switch fieldType {
|
||||
case TypeString, TypeBool, TypeNumber:
|
||||
return resolveScalar(field)
|
||||
case TypeObject:
|
||||
return resolveObject(field)
|
||||
case TypeArray:
|
||||
return resolveArray(field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveScalar 解析标量字段: value 非空则用 value,否则回落 defaultValue
|
||||
// (模板只声明结构、值由业务字段给出时,defaultValue 生效)
|
||||
func resolveScalar(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if vv != "" {
|
||||
return vv
|
||||
}
|
||||
case float64:
|
||||
if vv != 0 {
|
||||
return vv
|
||||
}
|
||||
case bool:
|
||||
return vv
|
||||
default:
|
||||
return vv
|
||||
}
|
||||
}
|
||||
if d, has := field["defaultValue"]; has && d != nil {
|
||||
switch dv := d.(type) {
|
||||
case string:
|
||||
if dv != "" {
|
||||
return dv
|
||||
}
|
||||
case float64:
|
||||
if dv != 0 {
|
||||
return dv
|
||||
}
|
||||
case bool:
|
||||
return dv
|
||||
default:
|
||||
return dv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveObject 解析对象字段,递归处理 attrs
|
||||
//
|
||||
// 特殊处理「参数定义」结构:当 attrs 含 default 字段时,说明该对象是一个
|
||||
// 参数定义(含 default/description/min/max/type/enum/required 等元数据),
|
||||
// 此时只提取 default 的值作为该参数的值,其余元数据字段忽略。
|
||||
func resolveObject(field map[string]interface{}) interface{} {
|
||||
attrs, ok := field["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 参数定义:只提取 default 值,跳过元数据
|
||||
if defaultField, hasDefault := attrs["default"]; hasDefault {
|
||||
if df, ok := defaultField.(map[string]interface{}); ok {
|
||||
return extractRawValueKeepZero(df)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 普通对象:递归处理所有 attrs
|
||||
result := make(map[string]interface{})
|
||||
for key, val := range attrs {
|
||||
subField, ok := val.(map[string]interface{})
|
||||
if !ok {
|
||||
result[key] = val // 纯值字段原样保留
|
||||
continue
|
||||
}
|
||||
if subType, _ := subField["type"].(string); subType == "" {
|
||||
result[key] = val // 无 type 键的纯对象原样保留
|
||||
continue
|
||||
}
|
||||
if v := resolveField(subField); v != nil {
|
||||
result[key] = v
|
||||
}
|
||||
}
|
||||
if len(result) == 0 {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveArray 解析数组字段,遍历 enumValues 或 attrs 生成元素列表
|
||||
func resolveArray(field map[string]interface{}) []interface{} {
|
||||
// 实际数据在 value(schema-editor 数据存放处),直接返回
|
||||
if v, has := field["value"]; has {
|
||||
if arr, ok := v.([]interface{}); ok && len(arr) > 0 {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
|
||||
enumValues, ok := field["enumValues"].([]interface{})
|
||||
if ok {
|
||||
var result []interface{}
|
||||
for _, ev := range enumValues {
|
||||
evMap, ok := ev.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
items := resolveEnumObject(evMap)
|
||||
result = append(result, items...)
|
||||
}
|
||||
if len(result) > 0 {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// enumValues 取不到或为空时,尝试从 attrs(数组)中取
|
||||
attrs, ok := field["attrs"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var result []interface{}
|
||||
for _, item := range attrs {
|
||||
itemMap, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if v := resolveField(itemMap); v != nil {
|
||||
result = append(result, v)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// resolveEnumObject 解析 enumValue 对象,支持数组展开
|
||||
func resolveEnumObject(ev map[string]interface{}) []interface{} {
|
||||
attrs, ok := ev["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// 将 enumValue 级别的 value 注入 attrs.type.value(如果 type.value 为空)
|
||||
if evVal, has := ev["value"]; has && evVal != nil {
|
||||
if s, ok := evVal.(string); ok && s != "" {
|
||||
if typeField, has := attrs["type"]; has {
|
||||
if typeMap, ok := typeField.(map[string]interface{}); ok {
|
||||
if existing, has := typeMap["value"]; !has || existing == nil || existing == "" {
|
||||
typeMap["value"] = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveAttrs(attrs)
|
||||
}
|
||||
|
||||
// resolveAttrs 递归解析 attrs map,支持字段级数组展开
|
||||
func resolveAttrs(attrs map[string]interface{}) []interface{} {
|
||||
currentItems := []map[string]interface{}{{}}
|
||||
hasValue := false
|
||||
|
||||
for key, val := range attrs {
|
||||
subField, isMap := val.(map[string]interface{})
|
||||
var subType string
|
||||
if isMap {
|
||||
subType, _ = subField["type"].(string)
|
||||
}
|
||||
|
||||
var nextItems []map[string]interface{}
|
||||
|
||||
// 非包裹字段(纯值/纯对象,无 type 键):原样保留,数组值仍参与展开
|
||||
if !isMap || subType == "" {
|
||||
raw := val
|
||||
if raw == nil {
|
||||
nextItems = currentItems
|
||||
currentItems = nextItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
if arr, ok := raw.([]interface{}); ok && len(arr) > 0 {
|
||||
for _, item := range currentItems {
|
||||
for _, elem := range arr {
|
||||
cp := copyMap(item)
|
||||
cp[key] = elem
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = raw
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
currentItems = nextItems
|
||||
continue
|
||||
}
|
||||
|
||||
switch subType {
|
||||
case TypeString, TypeBool, TypeNumber:
|
||||
raw := extractRawValue(subField)
|
||||
if raw == nil {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
if arr, ok := raw.([]interface{}); ok && len(arr) > 0 {
|
||||
for _, item := range currentItems {
|
||||
for _, elem := range arr {
|
||||
cp := copyMap(item)
|
||||
cp[key] = elem
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = raw
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
|
||||
case TypeObject:
|
||||
subAttrs, ok := subField["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
subItems := resolveAttrs(subAttrs)
|
||||
if len(subItems) == 0 {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
for _, item := range currentItems {
|
||||
for _, subI := range subItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = subI
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
}
|
||||
|
||||
case TypeArray:
|
||||
items := resolveArray(subField)
|
||||
if len(items) == 0 {
|
||||
nextItems = currentItems
|
||||
continue
|
||||
}
|
||||
hasValue = true
|
||||
for _, item := range currentItems {
|
||||
cp := copyMap(item)
|
||||
cp[key] = items
|
||||
nextItems = append(nextItems, cp)
|
||||
}
|
||||
|
||||
default:
|
||||
nextItems = currentItems
|
||||
}
|
||||
|
||||
currentItems = nextItems
|
||||
}
|
||||
|
||||
if !hasValue {
|
||||
return nil
|
||||
}
|
||||
result := make([]interface{}, len(currentItems))
|
||||
for i, item := range currentItems {
|
||||
result[i] = item
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// extractRawValue 提取原始值(保留数组值供上层展开)
|
||||
func extractRawValue(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
if vv != "" {
|
||||
return vv
|
||||
}
|
||||
case float64:
|
||||
if vv != 0 {
|
||||
return vv
|
||||
}
|
||||
case bool:
|
||||
return vv
|
||||
case []interface{}:
|
||||
if len(vv) > 0 {
|
||||
return vv
|
||||
}
|
||||
default:
|
||||
return vv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractRawValueKeepZero 同 extractRawValue,但不过滤零值
|
||||
// 在参数定义场景下,default 可能是 false/0/"",需要保留
|
||||
func extractRawValueKeepZero(field map[string]interface{}) interface{} {
|
||||
if v, has := field["value"]; has && v != nil {
|
||||
switch vv := v.(type) {
|
||||
case string:
|
||||
return vv
|
||||
case float64:
|
||||
return vv
|
||||
case bool:
|
||||
return vv
|
||||
case []interface{}:
|
||||
if len(vv) > 0 {
|
||||
return vv
|
||||
}
|
||||
return vv
|
||||
default:
|
||||
return vv
|
||||
}
|
||||
}
|
||||
if dv, has := field["defaultValue"]; has && dv != nil {
|
||||
switch dvv := dv.(type) {
|
||||
case string:
|
||||
if field["type"] == TypeBool {
|
||||
if dvv == "true" {
|
||||
return true
|
||||
}
|
||||
if dvv == "false" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return dvv
|
||||
case float64:
|
||||
return dvv
|
||||
case bool:
|
||||
return dvv
|
||||
case []interface{}:
|
||||
if len(dvv) > 0 {
|
||||
return dvv
|
||||
}
|
||||
return dvv
|
||||
default:
|
||||
return dvv
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyMap 浅拷贝 map
|
||||
func copyMap(src map[string]interface{}) map[string]interface{} {
|
||||
dst := make(map[string]interface{}, len(src))
|
||||
for k, v := range src {
|
||||
dst[k] = v
|
||||
}
|
||||
return dst
|
||||
}
|
||||
|
||||
// CoerceBodyTypes 按模板声明的 type 递归归一请求体字段值类型:
|
||||
// - string → gconv.String;number → gconv.Float64;boolean → gconv.Bool
|
||||
// - object → 按模板 attrs 递归子字段;array → 按元素模板逐个递归
|
||||
// - 模板未声明的字段(业务字段写入且超出模板的部分)保持原样
|
||||
//
|
||||
// 用于构建请求体后统一修正:模板字段 value 与业务字段写入的值都可能携带与声明
|
||||
// 类型不一致的 Go 类型(如 number 字段 value 为字符串 "0.7"),在此统一转成模型
|
||||
// API 期望的 JSON 类型。仅做类型归一,不增删字段。
|
||||
func CoerceBodyTypes(out map[string]interface{}, templateParams map[string]interface{}) map[string]interface{} {
|
||||
if len(templateParams) == 0 {
|
||||
return out
|
||||
}
|
||||
for key, raw := range out {
|
||||
if tmplNode, has := templateParams[key]; has {
|
||||
out[key] = coerceNode(raw, tmplNode)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// coerceNode 按单个模板节点归一值类型
|
||||
func coerceNode(value interface{}, tmplNode interface{}) interface{} {
|
||||
tmplMap, ok := tmplNode.(map[string]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
fieldType, _ := tmplMap["type"].(string)
|
||||
switch fieldType {
|
||||
case TypeString:
|
||||
return gconv.String(value)
|
||||
case TypeNumber, TypeNumberInt, TypeNumberFloat:
|
||||
return gconv.Float64(value)
|
||||
case TypeBool:
|
||||
return gconv.Bool(value)
|
||||
case TypeObject:
|
||||
sub, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
if attrs, ok := tmplMap["attrs"].(map[string]interface{}); ok {
|
||||
return coerceObject(sub, attrs)
|
||||
}
|
||||
return value
|
||||
case TypeArray:
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return value
|
||||
}
|
||||
proto := arrayElementTemplate(tmplMap)
|
||||
if proto == nil {
|
||||
return value
|
||||
}
|
||||
out := make([]interface{}, len(arr))
|
||||
for i, elem := range arr {
|
||||
out[i] = coerceNode(elem, proto)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
// coerceObject 按对象模板 attrs 归一对象子字段类型
|
||||
func coerceObject(sub, attrs map[string]interface{}) map[string]interface{} {
|
||||
for key, raw := range sub {
|
||||
if tmplNode, has := attrs[key]; has {
|
||||
sub[key] = coerceNode(raw, tmplNode)
|
||||
}
|
||||
}
|
||||
return sub
|
||||
}
|
||||
|
||||
// arrayElementTemplate 从数组模板节点提取元素模板(attrs 优先,其次 enumValues)。
|
||||
// 与 arrayElementPrototype 语义一致,但直接工作在原始模板 map 上,供类型归一使用。
|
||||
func arrayElementTemplate(field map[string]interface{}) map[string]interface{} {
|
||||
if attrs, ok := field["attrs"].([]interface{}); ok && len(attrs) > 0 {
|
||||
if m, ok := attrs[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
if evs, ok := field["enumValues"].([]interface{}); ok && len(evs) > 0 {
|
||||
if m, ok := evs[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"model-gateway/model/dto"
|
||||
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
"github.com/gogf/gf/v2/util/gconv"
|
||||
"github.com/gogf/gf/v2/util/gutil"
|
||||
)
|
||||
|
||||
// 数据类型常量
|
||||
const (
|
||||
TypeString = "string"
|
||||
TypeBool = "boolean"
|
||||
TypeNumber = "number"
|
||||
TypeNumberInt = "integer"
|
||||
TypeNumberFloat = "float"
|
||||
TypeNull = "null"
|
||||
TypeObject = "object"
|
||||
TypeArray = "array"
|
||||
)
|
||||
|
||||
// CheckParams 校验用户入参并回填默认值:
|
||||
// 用户只传 key/value,约束参数(type/required/constraint)全部取模板定义。
|
||||
// 模板定义必填的字段,用户未传或传空值都报错;用户值为空时用模板 defaultValue 回填。
|
||||
// 严格模式:未知字段报错。
|
||||
func CheckParams(userParams map[string]interface{}, templateParams map[string]interface{}) error {
|
||||
return checkParams(userParams, templateParams, true, true)
|
||||
}
|
||||
|
||||
// CheckBody 校验构建完成的请求体(ParseConfigTemplate + WriteBusinessFields 之后):
|
||||
// 业务字段按映射写入的路径可能超出模板声明,未知字段不报错;默认值已在构建期处理,不做回填。
|
||||
// 仍按模板约束校验必填/长度/范围。
|
||||
func CheckBody(body map[string]interface{}, templateParams map[string]interface{}) error {
|
||||
return checkParams(body, templateParams, false, true)
|
||||
}
|
||||
|
||||
// checkParams 按模板校验请求结构。strictUnknown:未知字段是否报错;backfill:空值是否回填 defaultValue。
|
||||
func checkParams(userParams map[string]interface{}, templateParams map[string]interface{}, strictUnknown, backfill bool) error {
|
||||
// 兼容扁平路径入参:还原为嵌套结构
|
||||
orig := userParams
|
||||
if IsFlatMap(userParams) {
|
||||
nested, err := UnFlatBySjson(userParams)
|
||||
if err != nil {
|
||||
return fmt.Errorf("无法解析用户参数: %w", err)
|
||||
}
|
||||
orig = nested
|
||||
}
|
||||
// 顶层未知字段检查
|
||||
if strictUnknown {
|
||||
for key := range orig {
|
||||
if _, has := templateParams[key]; !has {
|
||||
return fmt.Errorf("非法字段: %s 模板中不存在该字段", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, tmplNode := range templateParams {
|
||||
if err := validateNode(orig, key, tmplNode, key, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNode 按模板节点校验用户值,空值回填 defaultValue。
|
||||
// parent 为用户原始结构(模板格式 {type,value/attrs} 或纯值),key 为字段名;回填写回 parent[key]。
|
||||
func validateNode(parent map[string]interface{}, key string, tmplNode interface{}, path string, strictUnknown, backfill bool) error {
|
||||
raw, hasRaw := parent[key]
|
||||
|
||||
tmplMap, ok := tmplNode.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil // 模板节点不是对象,无约束可校验
|
||||
}
|
||||
var tmpl dto.Template
|
||||
if err := gconv.Struct(tmplMap, &tmpl); err != nil {
|
||||
return fmt.Errorf("字段 [%s] 模板解析错误: %w", path, err)
|
||||
}
|
||||
|
||||
label := tmpl.Label
|
||||
if label == "" {
|
||||
label = path
|
||||
}
|
||||
|
||||
switch tmpl.Type {
|
||||
case TypeObject:
|
||||
userMap, hasUser := userObjectValue(raw, hasRaw)
|
||||
if !hasUser {
|
||||
if tmpl.Required {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label)
|
||||
}
|
||||
return nil // 未传对象且非必填:跳过
|
||||
}
|
||||
attrs, ok := tmpl.Attrs.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
// 未知子字段检查
|
||||
if strictUnknown {
|
||||
for k := range userMap {
|
||||
if _, has := attrs[k]; !has {
|
||||
return fmt.Errorf("非法字段: %s 模板中不存在该字段", path+"."+k)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 递归子字段(即使对象未传,子字段必填校验仍生效)
|
||||
for subKey, subTmpl := range attrs {
|
||||
if err := validateNode(userMap, subKey, subTmpl, path+"."+subKey, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
case TypeArray:
|
||||
// 枚举项:逐项校验请求 enumValue.attrs 子字段并回填默认值
|
||||
if err := validateEnumValues(raw, hasRaw, path, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
userArr, hasUser := userArrayValue(raw, hasRaw)
|
||||
if !hasUser || len(userArr) == 0 {
|
||||
// 先回填 defaultValue(必填字段也可由默认值兜底),回填后重新判空
|
||||
if backfill {
|
||||
backfillDefault(parent, key, raw, hasRaw, &tmpl)
|
||||
}
|
||||
if tmpl.Required && isValueEmptyByType(&tmpl) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// 数组数量约束:上限取 Constraint.UploadTotalMaxCount 或各 UploadRule.MaxCount 之和(schema 构建时配置)
|
||||
if limit := maxArrayCount(&tmpl); limit > 0 && len(userArr) > limit {
|
||||
return fmt.Errorf("字段 [%s] 数量 %d 超过限制 %d", label, len(userArr), limit)
|
||||
}
|
||||
proto := arrayElementPrototype(&tmpl)
|
||||
if proto == nil {
|
||||
return nil
|
||||
}
|
||||
var protoTmpl dto.Template
|
||||
if err := gconv.Struct(proto, &protoTmpl); err != nil {
|
||||
return nil
|
||||
}
|
||||
switch protoTmpl.Type {
|
||||
case TypeObject:
|
||||
// 对象元素:以元素 attrs 为容器递归校验子字段(模板对象节点 {type:object,attrs:{...}} 的
|
||||
// 子字段藏在 attrs 下;纯对象 map 直接以自身为容器)
|
||||
if attrs, ok := protoTmpl.Attrs.(map[string]interface{}); ok {
|
||||
for i, elem := range userArr {
|
||||
elemMap, isMap := elem.(map[string]interface{})
|
||||
if !isMap {
|
||||
continue
|
||||
}
|
||||
container, has := userObjectValue(elemMap, true)
|
||||
if !has {
|
||||
continue
|
||||
}
|
||||
for subKey, subTmpl := range attrs {
|
||||
subPath := fmt.Sprintf("%s[%d].%s", path, i, subKey)
|
||||
if err := validateNode(container, subKey, subTmpl, subPath, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
// 标量元素:逐元素校验(数组内元素不参与整体必填)
|
||||
for i, elem := range userArr {
|
||||
pt := protoTmpl
|
||||
pt.Value = elem
|
||||
pt.Required = false
|
||||
if err := checkScalar(&pt, fmt.Sprintf("%s[%d]", path, i)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
default:
|
||||
// 标量类型:先回填 defaultValue(必填字段也可由默认值兜底),回填后重新判空,再校验必填/约束
|
||||
tmpl.Value = userScalarValue(raw, hasRaw)
|
||||
if isValueEmptyByType(&tmpl) {
|
||||
if backfill {
|
||||
backfillDefault(parent, key, raw, hasRaw, &tmpl)
|
||||
tmpl.Value = tmpl.DefaultValue
|
||||
}
|
||||
if isValueEmptyByType(&tmpl) {
|
||||
if tmpl.Required {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return checkScalar(&tmpl, label)
|
||||
}
|
||||
}
|
||||
|
||||
// checkScalar 校验标量值:必填 + 约束
|
||||
func checkScalar(tmpl *dto.Template, label string) error {
|
||||
if isValueEmptyByType(tmpl) {
|
||||
if tmpl.Required {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch tmpl.Type {
|
||||
case TypeString:
|
||||
return checkStringTmpl(tmpl)
|
||||
case TypeNumber:
|
||||
return checkNumberTmpl(tmpl)
|
||||
case TypeBool:
|
||||
return checkBoolTmpl(tmpl)
|
||||
case TypeNull:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("字段 [%s] 不支持的模板类型: %s", label, tmpl.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// userObjectValue 从原始请求节点提取对象值(模板格式取 attrs/value,纯值直接返回 map)
|
||||
func userObjectValue(raw interface{}, hasRaw bool) (map[string]interface{}, bool) {
|
||||
if !hasRaw || raw == nil {
|
||||
return nil, false
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if _, isTpl := m["type"]; isTpl {
|
||||
if v, has := m["attrs"]; has {
|
||||
if sub, ok := v.(map[string]interface{}); ok {
|
||||
return sub, true
|
||||
}
|
||||
}
|
||||
if v, has := m["value"]; has {
|
||||
if sub, ok := v.(map[string]interface{}); ok {
|
||||
return sub, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return m, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// userArrayValue 从原始请求节点提取数组值
|
||||
func userArrayValue(raw interface{}, hasRaw bool) ([]interface{}, bool) {
|
||||
if !hasRaw || raw == nil {
|
||||
return nil, false
|
||||
}
|
||||
if arr, ok := raw.([]interface{}); ok {
|
||||
return arr, true
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if _, isTpl := m["type"]; isTpl {
|
||||
if v, has := m["value"]; has {
|
||||
if sub, ok := v.([]interface{}); ok {
|
||||
return sub, true
|
||||
}
|
||||
}
|
||||
v1, has1 := m["attrs"]
|
||||
v2, has2 := m["enumValues"]
|
||||
if has1 || has2 {
|
||||
sub1, ok1 := v1.([]interface{})
|
||||
sub2, ok2 := v2.([]interface{})
|
||||
if ok1 {
|
||||
if ok2 {
|
||||
return sub2, true
|
||||
}
|
||||
return sub1, true
|
||||
}
|
||||
if ok2 {
|
||||
return sub2, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// userScalarValue 从原始请求节点提取标量值
|
||||
func userScalarValue(raw interface{}, hasRaw bool) interface{} {
|
||||
if !hasRaw {
|
||||
return nil
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if _, isTpl := m["type"]; isTpl {
|
||||
return m["value"]
|
||||
}
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
// backfillDefault 空值回填 defaultValue:
|
||||
// 模板格式节点写 value 键;纯值直接覆盖;字段缺失则补一个模板格式节点供下游产出默认值。
|
||||
func backfillDefault(parent map[string]interface{}, key string, raw interface{}, hasRaw bool, tmpl *dto.Template) {
|
||||
if tmpl.DefaultValue == nil {
|
||||
return
|
||||
}
|
||||
if m, ok := raw.(map[string]interface{}); ok {
|
||||
if _, isTpl := m["type"]; isTpl {
|
||||
m["value"] = tmpl.DefaultValue
|
||||
return
|
||||
}
|
||||
}
|
||||
if hasRaw {
|
||||
parent[key] = tmpl.DefaultValue
|
||||
return
|
||||
}
|
||||
parent[key] = map[string]interface{}{
|
||||
"type": tmpl.Type,
|
||||
"value": tmpl.DefaultValue,
|
||||
}
|
||||
}
|
||||
|
||||
// validateEnumValues 校验数组枚举项:逐项取请求 enumValue.attrs 作为字段容器,
|
||||
// 递归校验每个子字段(必填/约束)并回填空值的 defaultValue。与旧 checkArrayTmpl 行为对齐。
|
||||
func validateEnumValues(raw interface{}, hasRaw bool, path string, strictUnknown, backfill bool) error {
|
||||
if !hasRaw {
|
||||
return nil
|
||||
}
|
||||
rawMap, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
evs, ok := rawMap["enumValues"].([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for i, ev := range evs {
|
||||
evMap, ok := ev.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
attrs, ok := evMap["attrs"].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for subKey, subTmpl := range attrs {
|
||||
subPath := fmt.Sprintf("%s.enumValues[%d].%s", path, i, subKey)
|
||||
if err := validateNode(attrs, subKey, subTmpl, subPath, strictUnknown, backfill); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// maxArrayCount 取数组字段的数量上限:UploadTotalMaxCount 优先,其次各 UploadRule.MaxCount 之和;未配置返回 0
|
||||
func maxArrayCount(tmpl *dto.Template) int {
|
||||
if tmpl.Constraint.UploadTotalMaxCount > 0 {
|
||||
return tmpl.Constraint.UploadTotalMaxCount
|
||||
}
|
||||
total := 0
|
||||
for _, rule := range tmpl.Constraint.UploadRules {
|
||||
total += rule.MaxCount
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// arrayElementPrototype 取数组元素模板原型(attrs 优先,其次 enumValues)
|
||||
func arrayElementPrototype(tmpl *dto.Template) map[string]interface{} {
|
||||
if attrs, ok := tmpl.Attrs.([]interface{}); ok && len(attrs) > 0 {
|
||||
if m, ok := attrs[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
if len(tmpl.EnumValues) > 0 {
|
||||
if m, ok := tmpl.EnumValues[0].(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValueEmptyByType 按 tmpl.Type 判断是否为"业务空值"
|
||||
func isValueEmptyByType(tmpl *dto.Template) bool {
|
||||
switch tmpl.Type {
|
||||
case TypeString:
|
||||
return g.IsEmpty(gconv.String(tmpl.Value))
|
||||
case TypeNumber:
|
||||
return g.IsEmpty(gconv.Float64(tmpl.Value))
|
||||
case TypeBool:
|
||||
return tmpl.Value == nil
|
||||
case TypeObject:
|
||||
return g.IsEmpty(gconv.Map(tmpl.Value))
|
||||
case TypeArray:
|
||||
return g.IsEmpty(gconv.SliceAny(tmpl.Value))
|
||||
case TypeNull:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// checkStringTmpl 字符串类型校验
|
||||
func checkStringTmpl(tmpl *dto.Template) error {
|
||||
val := gconv.String(tmpl.Value)
|
||||
if tmpl.Required && gutil.IsEmpty(val) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label)
|
||||
}
|
||||
ct := tmpl.Constraint
|
||||
if gutil.IsEmpty(ct) {
|
||||
return nil
|
||||
}
|
||||
if tmpl.FieldType == "string" || tmpl.FieldType == "textarea" {
|
||||
if ct.MinLength > 0 && len(val) < ct.MinLength {
|
||||
return fmt.Errorf("字段 [%s] 长度应大于等于 %d,当前长度 %d", tmpl.Label, ct.MinLength, len(val))
|
||||
}
|
||||
if ct.MaxLength > 0 && len(val) > ct.MaxLength {
|
||||
return fmt.Errorf("字段 [%s] 长度应小于等于 %d,当前长度 %d", tmpl.Label, ct.MaxLength, len(val))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkNumberTmpl 数字类型校验
|
||||
func checkNumberTmpl(tmpl *dto.Template) error {
|
||||
ct := tmpl.Constraint
|
||||
if gutil.IsEmpty(ct) {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch ct.NumberType {
|
||||
case TypeNumberInt:
|
||||
val := gconv.Int(tmpl.Value)
|
||||
if tmpl.Required && gutil.IsEmpty(val) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label)
|
||||
}
|
||||
minVal := gconv.Int(ct.Min)
|
||||
maxVal := gconv.Int(ct.Max)
|
||||
if !g.IsEmpty(minVal) && val < minVal {
|
||||
return fmt.Errorf("字段 [%s] 值 %d 不应小于 最小值 %d", tmpl.Label, val, minVal)
|
||||
}
|
||||
if !g.IsEmpty(maxVal) && val > maxVal {
|
||||
return fmt.Errorf("字段 [%s] 值 %d 不应大于 最大值 %d", tmpl.Label, val, maxVal)
|
||||
}
|
||||
|
||||
case TypeNumberFloat:
|
||||
val := gconv.Float64(tmpl.Value)
|
||||
if tmpl.Required && gutil.IsEmpty(val) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label)
|
||||
}
|
||||
minVal := gconv.Float64(ct.Min)
|
||||
maxVal := gconv.Float64(ct.Max)
|
||||
if !g.IsEmpty(minVal) && val < minVal {
|
||||
return fmt.Errorf("字段 [%s] 值 %.2f 不应小于 最小值 %.2f", tmpl.Label, val, minVal)
|
||||
}
|
||||
if !g.IsEmpty(maxVal) && val > maxVal {
|
||||
return fmt.Errorf("字段 [%s] 值 %.2f 不应大于 最大值 %.2f", tmpl.Label, val, maxVal)
|
||||
}
|
||||
|
||||
default:
|
||||
return fmt.Errorf("字段 [%s] 数字类型 [%s] 错误,仅支持 int/float", tmpl.Label, ct.NumberType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkBoolTmpl 布尔类型校验
|
||||
func checkBoolTmpl(tmpl *dto.Template) error {
|
||||
val := gconv.Bool(tmpl.Value)
|
||||
if tmpl.Required && gutil.IsEmpty(val) {
|
||||
return fmt.Errorf("字段 [%s] 为必填项,但未提供有效值", tmpl.Label)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user