签入版本
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user