签入版本

This commit is contained in:
小蚂蚁云
2026-10-20 19:07:36 +08:00
commit 1b9bdac12f
882 changed files with 106953 additions and 0 deletions
+865
View File
@@ -0,0 +1,865 @@
// +======================================================================
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
// +----------------------------------------------------------------------
// | 开源协议: Apache-2.0
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
// +----------------------------------------------------------------------
// | 官方网站: https://www.easygoadmin.vip
// +----------------------------------------------------------------------
// | 研发团队: 小蚂蚁云团队 荣誉出品
// +======================================================================
// | 版权和免责声明:
// | 一、知识产权声明
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
// | 二、使用限制与禁止条款
// | 您理解并同意,禁止将本产品用于以下任何行为:
// | 1. 任何违反中华人民共和国法律法规的用途;
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
// | 三、责任豁免
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
// | 四、违约与法律责任
// | 如发现任何违反本声明的行为,本团队有权:
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
// | 3. 保留进一步采取刑事控告等法律措施的权利。
// | 五、许可范围说明
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
// | 成的一切经济损失及商誉损害。
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
// | 许可协议》附件为准。
// +======================================================================
package logic
// ============================================================================
// ModuleSpec 泛型 CRUD 基座(声明式)
//
// 设计原则(纪律):
// 1. 基座禁止出现"按模块分支"——差异只进声明字段与钩子;写不进钩子 → 该方法逃逸手写(合法逃生舱)
// 2. 钩子/Filter 使用具体函数类型;req any 一律经 mustAs[T] 断言(失败=编程错误,快速 panic)
// 3. 钩子内:可修改元素/data,禁止 append、禁止 HTTP/文件 IO(文件迁移统一在 saveTransform)
// 4. applyFilters 顺序固定:LikeFields → EqFields → KeywordFields → Filter → DataScope;
// 租户/软删隔离由 dao.Ctx(hooks.ApplyCtx)先于本链注入
// 5. ToData:Add=全量必填;Update=全量表单语义(前端 Detail 回显 → 全量 PUT,零值即用户所见)
// 6. 错误:钩子错误记 stage 日志后原样上抛(保留 gerror 语义码,不包装)
// ============================================================================
import (
"context"
"fmt"
"reflect"
"strings"
"sync"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"easygoadmin/internal/consts"
"easygoadmin/utility/upload"
)
// ============================== 声明类型 ==============================
// UniqueRule 唯一性规则
// Group 为空表示全局唯一;非空表示分组内唯一,例如按租户分组
type UniqueRule struct {
Field string // 唯一字段
Group string // 分组字段
}
// DataScopeFunc 角色数据权限
// 共享工厂为 ApplyDataScope(data_scope.go);为 nil 时不做过滤
type DataScopeFunc func(ctx context.Context, m *gdb.Model, req any) *gdb.Model
// ModuleSpec 模块声明
//
// Item 为分页行类型,LItem 为列表行类型(可与 Item 相同),DRes 为详情类型。
// 裁剪说明:List 不挂 AfterPageList,因精简列表结构无冗余列与 Text 字段,树形构建在 LItem 上。
type ModuleSpec[Item any, LItem any, DRes any] struct {
// ─── 数据源与查询声明(applyFilters 按字段顺序执行 1→5)───
Model func(ctx context.Context) *gdb.Model // 1 数据源(租户/软删已由 dao.Ctx 注入)
LikeFields []string // 2 逐列 LIKE(值取 req 同名列,空跳过)
EqFields []string // 3 等值(列名→驼峰 json 取值,零值跳过)
KeywordFields []string // 4 关键词列(req.keyword,多列 OR 组内配对)
Filter func(ctx context.Context, m *gdb.Model, req any) *gdb.Model // 5 复杂条件(禁重复 2-4 已覆盖者)
OrderBy string // 默认排序(硬编码,如 "sort, id")
ListOrderBy string // List 排序(空=沿用 OrderBy)
OrderableFields []string // 前端排序白名单(空=忽略 orderField/orderType 入参)
// ─── 输出声明 ───
IsTree bool // 树形(仅影响 List)
ParentOf func(*LItem) int64 // IsTree 必填(缺失 fail-fast)
ChildSetter func(*LItem, []*LItem) // 可选;缺省反射写 LItem 的 Children
SerializeMaps map[string]string // {field}Text 来自 dict_item
FileFields []string // 单文件字段名单(保存 temp→正式、读侧补域名)
MultiFileFields []string // 逗号多文件字段名单
ContentFields []string // 富文本字段(存 [IMG_URL] 占位,读自动还原)
FileSaveDir string // 本模块文件统一转正目录(单文件/多文件/富文本三类共用)
FieldLabels map[string]string // 唯一校验提示的显式覆盖
// ─── 业务规则 ───
UniqueFields []UniqueRule
DataScope DataScopeFunc // 角色数据权限(nil 跳过;见 ApplyDataScope)
// ─── 钩子(具体签名;错误原样上抛)───
BeforeAdd func(ctx context.Context, data map[string]any, req any) error
AfterAdd func(ctx context.Context, id int64, req any) error
BeforeUpdate func(ctx context.Context, data map[string]any, id int64, req any) error
AfterUpdate func(ctx context.Context, id int64, req any) error
BeforeDelete func(ctx context.Context, id int64) error
AfterDelete func(ctx context.Context, id int64) error
BeforeBatchDelete func(ctx context.Context, ids []int64) error
AfterBatchDelete func(ctx context.Context, ids []int64) error
AfterPageList func(ctx context.Context, list []*Item, req any) error // 可改元素或就地排序,禁 append
AfterDetail func(ctx context.Context, item *DRes, req any) error
// ─── 数据装配 ───
ToData func(req any) map[string]any // Add=全量必填;Update=全量表单语义
}
// ============================================================================
// 通用助手
// ============================================================================
// mustAs 请求类型断言
//
// 支持指针与值互转;配置错误属于编程错误,直接 panic 暴露
func mustAs[T any](req any) T {
var zero T
if v, ok := req.(T); ok {
return v
}
rv := reflect.ValueOf(req)
tv := reflect.TypeOf(zero)
if rv.IsValid() {
// T=值类型、req=*T:解引用转换
if tv.Kind() != reflect.Ptr && rv.Kind() == reflect.Ptr && !rv.IsNil() && rv.Type().Elem() == tv {
return rv.Elem().Interface().(T)
}
// T=*T指针、req=值:装箱新指针
if tv.Kind() == reflect.Ptr && rv.Kind() != reflect.Ptr && rv.Type() == tv.Elem() {
p := reflect.New(tv.Elem())
p.Elem().Set(rv)
return p.Interface().(T)
}
}
panic(fmt.Sprintf("ModuleSpec: req 类型断言失败,期望 %T,实际 %T", zero, req))
}
// reqFieldCache 请求结构字段索引缓存
var reqFieldCache sync.Map // reflect.Type → map[jsonName]fieldIndex
// reqValueByColumn 按列名从请求对象取值
//
// 列名 dept_id 映射到 json 标签 deptId,再通过反射读取字段,找不到时返回 nil
func reqValueByColumn(req any, column string) any {
if req == nil {
return nil
}
rv := reflect.ValueOf(req)
for rv.Kind() == reflect.Ptr {
if rv.IsNil() {
return nil
}
rv = rv.Elem()
}
rt := rv.Type()
idxMap, _ := reqFieldCache.LoadOrStore(rt, buildReqFieldIndex(rt))
fields, _ := idxMap.(map[string]int)
jsonName := camelLower(column)
idx, ok := fields[jsonName]
if !ok {
return nil
}
return rv.Field(idx).Interface()
}
// buildReqFieldIndex 构建 json 标签名到字段索引的映射
func buildReqFieldIndex(rt reflect.Type) map[string]int {
m := make(map[string]int)
for i := 0; i < rt.NumField(); i++ {
tag := rt.Field(i).Tag.Get("json")
if tag == "" {
continue
}
name := strings.Split(tag, ",")[0]
if name == "" || name == "-" {
continue
}
m[name] = i
}
return m
}
// builtinLabels 常见列名的内置中文标签
var (
builtinLabels = map[string]string{
"username": "用户名", "realname": "姓名", "name": "名称", "code": "编码",
"mobile": "手机号", "email": "邮箱", "password": "密码", "title": "标题",
"status": "状态", "sort": "排序", "note": "备注", "remark": "备注",
"address": "地址", "avatar": "头像", "type": "类型", "content": "内容",
}
)
// fieldLabel 字段标签三级回退
//
// 依次取 FieldLabels、内置常见列名、字段名原文;
// 凡声明 UniqueFields 的模块必须显式写 FieldLabels,提示语以此为准,不依赖数据库列注释
func (s *ModuleSpec[Item, LItem, DRes]) fieldLabel(field string) string {
if s.FieldLabels != nil {
if v, ok := s.FieldLabels[field]; ok && v != "" {
return v
}
}
if v, ok := builtinLabels[field]; ok {
return v
}
return field
}
// textPlanCache 文本填充计划缓存
var textPlanCache sync.Map // reflect.Type → []textFillPlan
// textFillPlan 文本填充计划
// 描述源字段索引、目标文本字段索引与字典编码
type textFillPlan struct {
srcIdx, dstIdx int
code string
}
// FillTextFields 批量填充结构体中已声明的 {Field}Text 字段
//
// 声明即生效:结构里存在 Text 字段就一定被填充;反射计划按类型缓存。
// target 支持 *[]*Item、*Item、*DRes。
func FillTextFields(ctx context.Context, target any, maps map[string]string) {
if len(maps) == 0 || target == nil {
return
}
rv := reflect.ValueOf(target)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return
}
switch rv.Elem().Kind() {
case reflect.Slice:
s := rv.Elem()
for i := 0; i < s.Len(); i++ {
fillOneText(ctx, s.Index(i), maps)
}
default:
fillOneText(ctx, rv.Elem(), maps)
}
}
// fillOneText 填充单个结构体的文本字段
func fillOneText(ctx context.Context, v reflect.Value, maps map[string]string) {
// 解引用
for v.Kind() == reflect.Ptr {
if v.IsNil() {
return
}
v = v.Elem()
}
if v.Kind() != reflect.Struct {
return
}
plans := plansForText(v.Type(), maps)
for _, p := range plans {
src := v.Field(p.srcIdx)
dst := v.Field(p.dstIdx)
if !dst.CanSet() || dst.Type().Kind() != reflect.String {
continue
}
dst.SetString(DictGetText(ctx, p.code, src.Interface()))
}
}
// plansForText 生成或读取文本填充计划
func plansForText(rt reflect.Type, maps map[string]string) []textFillPlan {
key := rt.String()
if v, ok := textPlanCache.Load(key); ok {
if plans, ok2 := v.([]textFillPlan); ok2 {
return plans
}
}
var plans []textFillPlan
for field, code := range maps {
base := pascal(field)
dst, ok := rt.FieldByName(base + "Text")
if !ok || dst.Type.Kind() != reflect.String || len(dst.Index) != 1 {
continue
}
src, ok := rt.FieldByName(base)
if !ok || len(src.Index) != 1 {
continue
}
plans = append(plans, textFillPlan{srcIdx: src.Index[0], dstIdx: dst.Index[0], code: code})
}
textPlanCache.Store(key, plans)
return plans
}
// hookWarn 钩子错误仅记日志
// 原样返回错误,保留 gerror 语义码
func hookWarn(ctx context.Context, stage string, err error) error {
if err != nil {
g.Log().Warningf(ctx, "ModuleSpec %s: %v", stage, err)
}
return err
}
// ============================================================================
// 查询流水线
// ============================================================================
// applyFilters 应用查询条件
//
// 固定顺序:LikeFields → EqFields → KeywordFields → Filter → DataScope
func (s *ModuleSpec[Item, LItem, DRes]) applyFilters(ctx context.Context, m *gdb.Model, req any) *gdb.Model {
// 2) 逐列 LIKE(req 同名或驼峰列取值)
for _, col := range s.LikeFields {
if v := reqValueByColumn(req, col); v != nil {
if str, ok := v.(string); ok && str != "" {
m = m.WhereLike(col, "%"+str+"%")
}
}
}
// 3) 等值(零值跳过)
for _, col := range s.EqFields {
if v := reqValueByColumn(req, col); v != nil {
rv := reflect.ValueOf(v)
if rv.IsValid() && !rv.IsZero() {
m = m.Where(col, v)
}
}
}
// 4) 关键词多列 OR(参数化条件,整体作为一个 AND 项,避免 OR 优先级问题)
if len(s.KeywordFields) > 0 {
if kw, ok := reqValueByColumn(req, "keyword").(string); ok && kw != "" {
likes := make([]string, 0, len(s.KeywordFields))
args := make([]any, 0, len(s.KeywordFields))
for _, col := range s.KeywordFields {
likes = append(likes, col+" LIKE ?")
args = append(args, "%"+kw+"%")
}
m = m.Where("("+strings.Join(likes, " OR ")+")", args...)
}
}
// 5) 复杂条件
if s.Filter != nil {
m = s.Filter(ctx, m, req)
}
// 6) 数据权限(nil 跳过)
if s.DataScope != nil {
m = s.DataScope(ctx, m, req)
}
return m
}
// applyOrder 应用排序
// 入参 orderField 与 orderType 仅在 OrderableFields 白名单内生效
func (s *ModuleSpec[Item, LItem, DRes]) applyOrder(m *gdb.Model, req any) *gdb.Model {
if len(s.OrderableFields) > 0 {
field, _ := reqValueByColumn(req, "order_field").(string)
if field != "" {
for _, allowed := range s.OrderableFields {
if allowed == field {
dir := "desc"
if t, ok := reqValueByColumn(req, "order_type").(string); ok && strings.EqualFold(t, "asc") {
dir = "asc"
}
return m.Order(field, dir)
}
}
}
}
if s.OrderBy != "" {
return m.Order(s.OrderBy)
}
return m
}
// readTransform 读侧转换
//
// 依次执行文件字段补域名、富文本还原、枚举文本填充;
// FileFields 与 MultiFileFields 声明后读侧自动补可访问 URL
func (s *ModuleSpec[Item, LItem, DRes]) readTransform(ctx context.Context, target any) {
for _, field := range s.FileFields {
fillStringField(target, field, func(v string) string { return upload.GetFileUrl(ctx, v) })
}
for _, field := range s.MultiFileFields {
fillStringField(target, field, func(v string) string {
if v == "" {
return v
}
parts := strings.Split(v, ",")
for i, p := range parts {
parts[i] = upload.GetFileUrl(ctx, strings.TrimSpace(p))
}
return strings.Join(parts, ",")
})
}
for _, f := range s.ContentFields {
fillStringField(target, f, func(v string) string { return ExpandContentFields(ctx, v) })
}
FillTextFields(ctx, target, s.SerializeMaps)
}
// saveTransform 写侧转换
//
// 单文件、多文件与富文本字段由临时目录转正至统一目录
func (s *ModuleSpec[Item, LItem, DRes]) saveTransform(ctx context.Context, data map[string]any) {
for _, field := range s.FileFields {
if str, ok := data[field].(string); ok && str != "" {
data[field] = upload.SaveFromTemp(ctx, str, s.FileSaveDir)
}
}
for _, field := range s.MultiFileFields {
if str, ok := data[field].(string); ok && str != "" {
data[field] = upload.SaveFromTempMulti(ctx, str, s.FileSaveDir)
}
}
for _, field := range s.ContentFields {
if str, ok := data[field].(string); ok && str != "" {
data[field] = LocalizeContentFields(ctx, str, s.FileSaveDir)
}
}
}
// checkUniqueRules 唯一性批量校验
// 提示语按字段标签四级回退
func (s *ModuleSpec[Item, LItem, DRes]) checkUniqueRules(ctx context.Context, data map[string]any, excludeId int64) error {
for _, rule := range s.UniqueFields {
v, ok := data[rule.Field]
if !ok || v == nil {
continue
}
if str, isStr := v.(string); isStr && str == "" {
continue
}
m := s.Model(ctx).Where(rule.Field, v)
if rule.Group != "" {
if gv, ok := data[rule.Group]; ok {
m = m.Where(rule.Group, gv)
}
}
if excludeId > 0 {
m = m.WhereNot("id", excludeId)
}
n, err := m.Count()
if err != nil {
return err
}
if n > 0 {
return gerror.NewCodef(consts.CodeRecordExists, "%s已存在", s.fieldLabel(rule.Field))
}
}
return nil
}
// fillStringField 按 json 字段名对结构或指针结构应用字符串变换
func fillStringField(target any, jsonName string, fn func(string) string) {
rv := reflect.ValueOf(target)
for rv.Kind() == reflect.Ptr {
if rv.IsNil() {
return
}
rv = rv.Elem()
}
applyOnStruct := func(sv reflect.Value) {
if sv.Kind() != reflect.Struct {
return
}
rt := sv.Type()
for i := 0; i < rt.NumField(); i++ {
tag := strings.Split(rt.Field(i).Tag.Get("json"), ",")[0]
if tag == jsonName && rt.Field(i).Type.Kind() == reflect.String && sv.Field(i).CanSet() {
sv.Field(i).SetString(fn(sv.Field(i).String()))
}
}
}
switch rv.Kind() {
case reflect.Slice:
for i := 0; i < rv.Len(); i++ {
elem := rv.Index(i)
for elem.Kind() == reflect.Ptr {
elem = elem.Elem()
}
applyOnStruct(elem)
}
default:
applyOnStruct(rv)
}
}
// ============================================================================
// CRUD 流水线
// ============================================================================
// Page 分页查询
// 流水线:过滤 → 计数 → 分页 → 映射 → 读转换 → 钩子
func (s *ModuleSpec[Item, LItem, DRes]) Page(ctx context.Context, req any, pageNo, pageSize int) ([]*Item, int, error) {
m := s.applyFilters(ctx, s.Model(ctx), req)
total, err := m.Count()
if err != nil {
return nil, 0, err
}
rows, err := s.applyOrder(m, req).Page(pageNo, pageSize).All()
if err != nil {
return nil, 0, err
}
list, err := structRows[Item](rows)
if err != nil {
return nil, 0, err
}
s.readTransform(ctx, &list)
if err = hookWarn(ctx, "AfterPageList", s.callAfterPageList(ctx, list, req)); err != nil {
return nil, 0, err
}
return list, total, nil
}
// List 全量列表
//
// 使用 LItem 类型;IsTree 为真时在末尾建树;
// 不挂 AfterPageList,因精简结构无冗余列
func (s *ModuleSpec[Item, LItem, DRes]) List(ctx context.Context, req any) ([]*LItem, error) {
m := s.applyFilters(ctx, s.Model(ctx), req)
if s.ListOrderBy != "" {
m = m.Order(s.ListOrderBy)
} else {
m = s.applyOrder(m, req)
}
// 条数上限(req.limit,零值表示不限)
if v := reqValueByColumn(req, "limit"); v != nil {
if n, ok := v.(int); ok && n > 0 {
m = m.Limit(n)
}
}
rows, err := m.All()
if err != nil {
return nil, err
}
list, err := structRows[LItem](rows)
if err != nil {
return nil, err
}
s.readTransform(ctx, &list)
if s.IsTree {
s.checkTreeDeclared()
return s.buildTree(list), nil
}
return list, nil
}
// Detail 详情查询
// 流水线:查询 → 映射 → 读转换 → 钩子
func (s *ModuleSpec[Item, LItem, DRes]) Detail(ctx context.Context, id int64, req any) (*DRes, error) {
one, err := s.Model(ctx).Where("id", id).One()
if err != nil {
return nil, err
}
if len(one) == 0 {
return nil, gerror.NewCode(consts.CodeRecordNotFound)
}
var item DRes
if err = one.Struct(&item); err != nil {
return nil, err
}
s.readTransform(ctx, &item)
if err = hookWarn(ctx, "AfterDetail", s.callAfterDetail(ctx, &item, req)); err != nil {
return nil, err
}
return &item, nil
}
// Add 新增
// 流水线:ToData → BeforeAdd → 文件三类 → 唯一校验 → Insert → AfterAdd
func (s *ModuleSpec[Item, LItem, DRes]) Add(ctx context.Context, req any) (int64, error) {
data := s.ToData(req)
if err := hookWarn(ctx, "BeforeAdd", callBeforeAdd(s.BeforeAdd, ctx, data, req)); err != nil {
return 0, err
}
s.saveTransform(ctx, data)
if err := s.checkUniqueRules(ctx, data, 0); err != nil {
return 0, err
}
result, err := s.Model(ctx).Data(data).Insert()
if err != nil {
return 0, err
}
id, _ := result.LastInsertId()
if err = hookWarn(ctx, "AfterAdd", callAfterAdd(s.AfterAdd, ctx, id, req)); err != nil {
return 0, err
}
return id, nil
}
// Update 更新
// 全量表单语义,仅更新 data 中出现的键
func (s *ModuleSpec[Item, LItem, DRes]) Update(ctx context.Context, id int64, req any) error {
data := s.ToData(req)
if err := hookWarn(ctx, "BeforeUpdate", callBeforeUpdate(s.BeforeUpdate, ctx, data, id, req)); err != nil {
return err
}
s.saveTransform(ctx, data)
if err := s.checkUniqueRules(ctx, data, id); err != nil {
return err
}
if _, err := s.Model(ctx).Where("id", id).Data(data).Update(); err != nil {
return err
}
return hookWarn(ctx, "AfterUpdate", callAfterUpdate(s.AfterUpdate, ctx, id, req))
}
// Delete 删除
// 存在性与守卫由模块 BeforeDelete 决定,随后软删并触发 AfterDelete
func (s *ModuleSpec[Item, LItem, DRes]) Delete(ctx context.Context, id int64) error {
if err := hookWarn(ctx, "BeforeDelete", callBeforeDelete(s.BeforeDelete, ctx, id)); err != nil {
return err
}
if _, err := s.Model(ctx).Where("id", id).Delete(); err != nil {
return err
}
return hookWarn(ctx, "AfterDelete", callAfterDelete(s.AfterDelete, ctx, id))
}
// BatchDelete 批量删除
// 流程:BeforeBatchDelete → 逐条执行 BeforeDelete 与软删 → AfterBatchDelete
func (s *ModuleSpec[Item, LItem, DRes]) BatchDelete(ctx context.Context, ids []int64) error {
if err := hookWarn(ctx, "BeforeBatchDelete", callBeforeBatch(s.BeforeBatchDelete, ctx, ids)); err != nil {
return err
}
for _, id := range ids {
if err := hookWarn(ctx, "BeforeDelete", callBeforeDelete(s.BeforeDelete, ctx, id)); err != nil {
return err
}
if _, err := s.Model(ctx).Where("id", id).Delete(); err != nil {
return err
}
}
return hookWarn(ctx, "AfterBatchDelete", callAfterBatch(s.AfterBatchDelete, ctx, ids))
}
// MergeOld 全量覆盖辅助
//
// 将 data 中缺失或零值的键用库中旧值补齐,供 BeforeUpdate 内使用
func (s *ModuleSpec[Item, LItem, DRes]) MergeOld(ctx context.Context, data map[string]any, id int64) {
one, err := s.Model(ctx).Where("id", id).One()
if err != nil || len(one) == 0 {
return
}
for k, oldV := range one {
if v, ok := data[k]; !ok || v == nil || v == "" || v == 0 {
data[k] = oldV
}
}
}
// ─── 内部:结构映射与树 ────────────────────────────────────────────
// structRows 数据库行转换为指针切片
// 通过 orm 与 json 标签映射
func structRows[Item any](rows gdb.Result) ([]*Item, error) {
list := make([]*Item, 0, len(rows))
for _, row := range rows {
var item Item
if err := row.Struct(&item); err != nil {
return nil, err
}
list = append(list, &item)
}
return list, nil
}
// callAfterPageList 调用分页后钩子
func (s *ModuleSpec[Item, LItem, DRes]) callAfterPageList(ctx context.Context, list []*Item, req any) error {
if s.AfterPageList == nil {
return nil
}
return s.AfterPageList(ctx, list, req)
}
// callAfterDetail 调用详情后钩子
func (s *ModuleSpec[Item, LItem, DRes]) callAfterDetail(ctx context.Context, item *DRes, req any) error {
if s.AfterDetail == nil {
return nil
}
return s.AfterDetail(ctx, item, req)
}
// 钩子 nil 安全包装(字段已是具体类型,直接判空调用)
// callBeforeAdd 调用新增前钩子
func callBeforeAdd(f func(context.Context, map[string]any, any) error, ctx context.Context, data map[string]any, req any) error {
if f == nil {
return nil
}
return f(ctx, data, req)
}
// callAfterAdd 调用新增后钩子
func callAfterAdd(f func(context.Context, int64, any) error, ctx context.Context, id int64, req any) error {
if f == nil {
return nil
}
return f(ctx, id, req)
}
// callBeforeUpdate 调用更新前钩子
func callBeforeUpdate(f func(context.Context, map[string]any, int64, any) error, ctx context.Context, data map[string]any, id int64, req any) error {
if f == nil {
return nil
}
return f(ctx, data, id, req)
}
// callAfterUpdate 调用更新后钩子
func callAfterUpdate(f func(context.Context, int64, any) error, ctx context.Context, id int64, req any) error {
if f == nil {
return nil
}
return f(ctx, id, req)
}
// callBeforeDelete 调用删除前钩子
func callBeforeDelete(f func(context.Context, int64) error, ctx context.Context, id int64) error {
if f == nil {
return nil
}
return f(ctx, id)
}
// callAfterDelete 调用删除后钩子
func callAfterDelete(f func(context.Context, int64) error, ctx context.Context, id int64) error {
if f == nil {
return nil
}
return f(ctx, id)
}
// callBeforeBatch 调用批量删除前钩子
func callBeforeBatch(f func(context.Context, []int64) error, ctx context.Context, ids []int64) error {
if f == nil {
return nil
}
return f(ctx, ids)
}
// callAfterBatch 调用批量删除后钩子
func callAfterBatch(f func(context.Context, []int64) error, ctx context.Context, ids []int64) error {
if f == nil {
return nil
}
return f(ctx, ids)
}
// ─── 树 ──────────────────────────────────────────────────────────
// checkTreeDeclared 校验树形声明的完整性
func (s *ModuleSpec[Item, LItem, DRes]) checkTreeDeclared() {
if s.ParentOf == nil {
// 泛型类型名作调试标识
var zero LItem
panic(fmt.Sprintf("ModuleSpec: IsTree=true 但未声明 ParentOf(%s)", reflect.TypeOf(&zero).Elem().Name()))
}
}
// childrenIdxCache 子节点字段索引缓存
var childrenIdxCache sync.Map // reflect.Type → children field index(-1 表示无)
// childrenSetter 获取子节点写入函数
//
// 优先使用声明的 ChildSetter,缺省时反射写入 DTO 的 Children 字段
func (s *ModuleSpec[Item, LItem, DRes]) childrenSetter() func(*LItem, []*LItem) {
if s.ChildSetter != nil {
return s.ChildSetter
}
// 缺省:反射写 DTO 的 Children 字段(命名约定)
var sample LItem
rt := reflect.TypeOf(sample)
v, _ := childrenIdxCache.LoadOrStore(rt, func() any {
f, ok := rt.FieldByName("Children")
if !ok || f.Type.Kind() != reflect.Slice || len(f.Index) != 1 {
return -1
}
return f.Index[0]
}()) // 注意:LoadOrStore 存值不存函数,需立即调用
idx, ok := v.(int)
if !ok {
idx = -1
}
if idx < 0 {
panic(fmt.Sprintf("ModuleSpec: IsTree 需要 ChildSetter 或 DTO 的 Children 字段(%s)", rt.Name()))
}
return func(it *LItem, children []*LItem) {
rv := reflect.ValueOf(it).Elem().Field(idx)
rv.Set(reflect.ValueOf(children))
}
}
// buildTree 平铺列表转换为树
//
// 先按 ParentOf 分组,再写入各父节点的 Children;
// 输入顺序稳定,缺少父节点的元素挂到根
func (s *ModuleSpec[Item, LItem, DRes]) buildTree(list []*LItem) []*LItem {
return buildTreeBy(list, s.ParentOf, s.childrenSetter())
}
// buildTreeBy 通用树构建
// idOf 约定 DTO 包含 Id 字段,父节点不在集合内时挂根
func buildTreeBy[T any](list []*T, parentOf func(*T) int64, setChildren func(*T, []*T)) []*T {
// idOf:约定 DTO 含 Id 字段(int64/int 均可,reflect.Int 读取)
idOf := func(it *T) int64 {
f := reflect.ValueOf(it).Elem().FieldByName("Id")
if !f.IsValid() {
panic("ModuleSpec: 树形 DTO 必须包含 Id 字段")
}
return f.Int()
}
nodes := make(map[int64]*T, len(list))
children := make(map[int64][]*T, len(list))
ids := make([]int64, 0, len(list))
for _, it := range list {
setChildren(it, []*T{}) // 预置空(叶子 JSON 输出 [] 而非 null)
idv := idOf(it)
nodes[idv] = it
ids = append(ids, idv)
}
roots := make([]*T, 0, len(ids))
for _, idv := range ids {
node := nodes[idv]
pid := parentOf(node)
if pid != 0 {
if _, ok := nodes[pid]; ok {
children[pid] = append(children[pid], node)
continue
}
}
roots = append(roots, node) // 顶级或缺父挂根
}
for pid, ch := range children {
setChildren(nodes[pid], ch)
}
return roots
}
// derefSlice 指针切片转换为值切片
// 统一响应值语义:PageData[T] 的 T 为值类型
func derefSlice[T any](in []*T) []T {
out := make([]T, 0, len(in))
for _, p := range in {
out = append(out, *p)
}
return out
}