Files
EasyGoAdmin_GoFrame_EleVue/internal/logic/base.go
T
2026-10-20 12:34:18 +08:00

420 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// +======================================================================
// | EasyGoAdmin 企业数字化开发平台 [ 赋能开发者,助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2019-2026 半城风雨团队 版权所有
// +----------------------------------------------------------------------
// | 开源协议: Apache-2.0
// | 重要声明: 未经书面许可,不得移除或修改本框架相关版权标识。
// +----------------------------------------------------------------------
// | 官方网站: https://www.easygoadmin.vip
// +----------------------------------------------------------------------
// | 研发团队: 小蚂蚁云团队 荣誉出品
// +======================================================================
// | 版权和免责声明:
// | 一、知识产权声明
// | 本团队对该软件框架产品(以下简称“本产品”)拥有完整知识产权,包括但不限于商标权、专利权、
// | 著作权、商业秘密等。前述权利均受中华人民共和国及相关国际法律法规保护。未经本团队书面授权,
// | 任何个人或单位不得基于本产品本身申请任何形式的知识产权。
// | 二、使用限制与禁止条款
// | 您理解并同意,禁止将本产品用于以下任何行为:
// | 1. 任何违反中华人民共和国法律法规的用途;
// | 2. 任何侵害他人合法权益(包括但不限于人身权、财产权、知识产权等)的行为;
// | 3. 任何危害国家安全、公共利益及社会道德风尚的行为。
// | 三、责任豁免
// | 因使用本产品而产生的任何损失或法律纠纷(包括但不限于直接损失、间接损失、附带损失、衍生损失
// | 以及因违约、侵权等引发的责任),本团队均不承担任何法律责任。若因违规使用本产品导致任何第三
// | 方提起索赔或行政机关进行调查,您应独立承担全部责任。
// | 四、违约与法律责任
// | 如发现任何违反本声明的行为,本团队有权:
// | 1. 无条件配合公安机关及其他有权机关的调查取证工作;
// | 2. 依法追究相关方的民事责任,并要求赔偿全部损失;
// | 3. 保留进一步采取刑事控告等法律措施的权利。
// | 五、许可范围说明
// | 1、被授权方应视本产品源代码为核心机密信息,负有最高标准的保密与保管义务。禁止以任何形式(包
// | 括但不限于直接或间接的分享、复制、传播、分发、转售、出租、开源、反向工程、反编译、反汇编或通
// | 过网络服务提供给任何第三方)泄露、披露或处置源代码的全部或部分内容。任何未经授权的复制、使用、
// | 传播行为均构成对本团队知识产权的严重侵权,本团队有权依法追究其全部法律责任,并要求赔偿因此造
// | 成的一切经济损失及商誉损害。
// | 2、本产品仅限于公司或个人在法律允许范围内,用于内部且合规的软件产品研发。具体权利义务以《软件
// | 许可协议》附件为准。
// +======================================================================
package logic
// ============================================================================
// logic 共享能力层:跨模块复用的运行时横切能力(组合式,非继承)
//
// 【架构约定 · 企业级纪律】
// 1. 本文件按能力分区:文件字段 / 富文本 / 枚举文本 / 唯一校验 / 事务 / 分块查询 / 树
// 2. 依赖方向单向:base 只允许依赖 dao / utility / 内部 consts,禁止引用任何模块文件
// 3. 导出函数上限 15 个:超出即按能力拆为 internal/logic/base/ 子包(防"万能工具箱"腐化)
// 4. CheckUnique 仅为 UX 快速报错(先查后插存在 TOCTOU 竞态),并发唯一性以数据库
// 唯一索引(uk_*)为准,调用点必须保证对应 uk 索引存在,冲突时以错误1062翻译提示
// 5. 富文本占位符 [IMG_URL] 是持久化契约(见 architecture.md),还原只能走 ExpandContentFields
// ============================================================================
import (
"context"
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strings"
"sync"
"time"
"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/internal/dao"
"easygoadmin/utility/upload"
)
// ===========================================================================
// 一、文件字段(保存前:临时文件转正式目录)
// 声明式用法(ModuleSpec 逃生舱手写 Add/Update 内联场景):
// FileFields: []string{"avatar"}, FileSaveDir: "user" // 字段名单 + 模块统一目录
// SaveFileFields(ctx, data, spec.FileFields, spec.FileSaveDir) // 临时 → 正式,原地更新
// ===========================================================================
// SaveFileFields 单文件字段批量处理
// 值为临时文件URL时移动到正式目录,其余原样保留
func SaveFileFields(ctx context.Context, data map[string]any, fields []string, saveDir string) {
for _, field := range fields {
if v, ok := data[field]; ok {
if s, ok2 := v.(string); ok2 && s != "" {
data[field] = upload.SaveFromTemp(ctx, s, saveDir)
}
}
}
}
// SaveMultiFileFields 多文件字段(逗号分隔)批量处理
// 各文件为临时文件URL时移动到正式目录,其余原样保留
func SaveMultiFileFields(ctx context.Context, data map[string]any, fields []string, saveDir string) {
for _, field := range fields {
if v, ok := data[field]; ok {
if s, ok2 := v.(string); ok2 && s != "" {
data[field] = upload.SaveFromTempMulti(ctx, s, saveDir)
}
}
}
}
// SaveFileField 单值便捷版
// 供模块内联场景使用,临时文件URL转正式目录
func SaveFileField(ctx context.Context, fileUrl, saveDir string) string {
return upload.SaveFromTemp(ctx, fileUrl, saveDir)
}
// SaveMultiFileField 逗号分隔多文件串便捷版
// 供模块内联场景使用,临时文件URL批量转正式目录
func SaveMultiFileField(ctx context.Context, fileUrls, saveDir string) string {
return upload.SaveFromTempMulti(ctx, fileUrls, saveDir)
}
// ===========================================================================
// 二、富文本(存库占位符化 / 读取还原)
// 存库契约:正文内本站文件引用统一写为 [IMG_URL]/{相对uploadDir路径} 占位符
// - temp 文件URL → 移动转正后写占位符
// - 本站正式文件 → 剥离域名/静态前缀写占位符
// - 外站 URL → 不处理(规避 SSRF)
// 读取契约:ExpandContentFields 是唯一还原出口(占位符 → 静态访问基址拼接)
// ===========================================================================
var (
contentSrcRe = regexp.MustCompile(`(?i)<(?:img|video|source|embed)[^>]+src=["']?([^"'>\s]+)`)
contentHrefRe = regexp.MustCompile(`(?i)<a[^>]+href=["']?([^"'>\s]+\.\w{2,5})["']?`)
)
// LocalizeContentFields 富文本存库前本地化
// 临时文件转正,并将本站URL统一写为 [IMG_URL] 占位符
func LocalizeContentFields(ctx context.Context, content, saveDir string) string {
if content == "" {
return content
}
urlMap := map[string]string{}
collect := func(re *regexp.Regexp) {
for _, m := range re.FindAllStringSubmatch(content, -1) {
if len(m) < 2 {
continue
}
url := m[1]
if _, seen := urlMap[url]; seen {
continue
}
if newURL := localizeOne(ctx, url, saveDir); newURL != "" {
urlMap[url] = newURL
}
}
}
collect(contentSrcRe)
collect(contentHrefRe)
for old, newV := range urlMap {
content = strings.ReplaceAll(content, old, newV)
}
return content
}
// localizeOne 单个URL本地化处理
// 临时文件转正为相对路径写占位符;本站正式文件剥离访问基址写占位符;其他返回空表示跳过
func localizeOne(ctx context.Context, url, saveDir string) string {
if strings.Contains(url, "/temp/") {
newURL := upload.SaveFromTemp(ctx, url, saveDir)
if newURL == "" || strings.HasPrefix(newURL, "http://") || strings.HasPrefix(newURL, "https://") {
return ""
}
return "[IMG_URL]/" + strings.TrimLeft(newURL, "/")
}
// 本站正式文件:剥离访问基址(domainUrl)后剩余的 "/" 开头路径即相对路径;
// 未剥离成功的(仍含协议头或未匹配基址)视为外站URL,跳过
rest := strings.TrimPrefix(url, upload.GetAccessBase(ctx))
if strings.HasPrefix(rest, "/") && !strings.Contains(rest, "://") {
return "[IMG_URL]" + rest
}
return ""
}
// ExpandContentFields 富文本读取侧还原
// 将 [IMG_URL]/{相对路径} 拼接访问基址,是唯一的还原出口
func ExpandContentFields(ctx context.Context, content string) string {
if content == "" {
return content
}
return strings.ReplaceAll(content, "[IMG_URL]", upload.GetAccessBase(ctx))
}
// ===========================================================================
// 三、枚举文本(字典值 → 显示名,详情/列表补 {field}Text)
// ===========================================================================
// dictTextCache 进程内字典文本缓存
// 读多写少,TTL 300 秒,修改字典后最迟 5 分钟生效
var dictTextCache sync.Map // key: {code}:{value} → dictTextEntry
// dictTextEntry 字典文本缓存条目
type dictTextEntry struct {
text string // 显示名
expires time.Time // 过期时间
}
// DictGetText 按字典编码与值获取显示名
// dict_item 查询为唯一真相,未命中时返回原值文本
func DictGetText(ctx context.Context, dictCode string, value any) string {
val := strings.TrimSpace(fmt.Sprintf("%v", value))
if dictCode == "" || val == "" {
return val
}
key := dictCode + ":" + val
if e, ok := dictTextCache.Load(key); ok {
ent := e.(dictTextEntry)
if time.Now().Before(ent.expires) {
return ent.text
}
}
dict, err := dao.Dict.Ctx(ctx).Where("code", dictCode).One()
if err != nil || len(dict) == 0 {
return val
}
item, err := dao.DictItem.Ctx(ctx).Where("dict_id", dict["id"].Int64()).Where("value", val).One()
if err != nil || len(item) == 0 {
return val
}
text := item["name"].String()
dictTextCache.Store(key, dictTextEntry{text: text, expires: time.Now().Add(300 * time.Second)})
return text
}
// SerializeMapOnRead 详情/列表读取后补 {field}Text 显示名
// 直接在传入的 map 上原地更新
func SerializeMapOnRead(ctx context.Context, data map[string]any, fieldDict map[string]string) {
for field, dictCode := range fieldDict {
if v, ok := data[field]; ok {
data[field+"Text"] = DictGetText(ctx, dictCode, v)
}
}
}
// LogTypeText 操作日志类型转中文名
//
// 该字段存储的是注解常量值(1=新增…40=解绑),与字典编码体系不一致,
// 故使用静态映射表而非字典;未知值回退原数字。
func LogTypeText(v int) string {
if n, ok := consts.LogTypeNames[v]; ok {
return n
}
return fmt.Sprintf("%d", v)
}
// ===========================================================================
// 四、唯一校验(TOCTOU 说明见文件头纪律4)
// m 传入 dao.X.Ctx(ctx)(自带 del_flag/租户过滤)
// ===========================================================================
// CheckUnique 唯一性快速校验
// label 为字段中文名,例如"角色编码"
func CheckUnique(ctx context.Context, m *gdb.Model, column string, value any, excludeId int64, label string) error {
mm := m.Where(column, value)
if excludeId > 0 {
mm = mm.WhereNot("id", excludeId)
}
n, err := mm.Count()
if err != nil {
return err
}
if n > 0 {
return gerror.NewCodef(consts.CodeRecordExists, "%s已存在", label)
}
return nil
}
// ===========================================================================
// 五、事务(gdb.WithTX 注入上下文,dao.Ctx(ctx) 自动走事务连接)
// 纪律:事务内不做 HTTP/文件IO(保持短事务)
// ===========================================================================
// WithTrans 事务包裹
// fn 返回错误时自动回滚
func WithTrans(ctx context.Context, fn func(ctx context.Context) error) error {
tx, err := g.DB().Begin(ctx)
if err != nil {
return err
}
if err = fn(gdb.WithTX(ctx, tx)); err != nil {
_ = tx.Rollback()
return err
}
return tx.Commit()
}
// ===========================================================================
// 六、分块查询(导出防OOM)
// build 每页重建查询(gdb Model 不可复用执行),快照语义:并发写入可能翻页漂移
// ===========================================================================
// ChunkQuery 分块遍历
// build(page, size) 返回当页模型,fn 处理每页结果
func ChunkQuery(ctx context.Context, build func(page, size int) *gdb.Model, size int, fn func(gdb.Result) error) error {
if size <= 0 {
size = 500
}
for page := 1; ; page++ {
rows, err := build(page, size).All()
if err != nil {
return err
}
if len(rows) == 0 {
return nil
}
if err = fn(rows); err != nil {
return err
}
if len(rows) < size {
return nil
}
}
}
// fillUserCount 按 user 表外键列统计引用数
// 批量填充 DTO 的 UserCount 字段,供 level、position 等带 userCount 展示的模块复用;
// list 元素需包含 Id 与 UserCount 字段
func fillUserCount(ctx context.Context, column string, list any) {
rv := reflect.ValueOf(list)
if rv.Kind() != reflect.Ptr || rv.Elem().Kind() != reflect.Slice {
return
}
slice := rv.Elem()
counts := make(map[int64]int)
rows, err := dao.User.Ctx(ctx).Fields("id, " + column).All()
if err == nil {
for _, r := range rows {
if v := r[column].Int64(); v > 0 {
counts[v]++
}
}
}
for i := 0; i < slice.Len(); i++ {
item := slice.Index(i)
for item.Kind() == reflect.Ptr {
item = item.Elem()
}
if item.Kind() != reflect.Struct {
continue
}
idField := item.FieldByName("Id")
cntField := item.FieldByName("UserCount")
if idField.IsValid() && cntField.IsValid() && cntField.CanSet() && cntField.Kind() == reflect.Int {
cntField.SetInt(int64(counts[idField.Int()]))
}
}
}
// ===========================================================================
// 七、树构建(泛型,供 menu/dept/category 等复用)
// ===========================================================================
// BuildTree 平铺列表转树
// 父节点不在集合内则挂根;childrenOf 返回子级切片指针;less 排序,为 nil 时保持原序
func BuildTree[T any](rows []T, idOf func(T) int64, pidOf func(T) int,
childrenOf func(*T) *[]*T, less func(a, b T) bool) []*T {
nodes := make(map[int64]*T, len(rows))
ids := make([]int64, 0, len(rows))
for _, r := range rows {
item := r
nodes[idOf(item)] = &item
ids = append(ids, idOf(item))
}
roots := make([]*T, 0, len(ids))
for _, idv := range ids {
node := nodes[idv]
if pid := pidOf(*node); pid != 0 {
if parent, ok := nodes[int64(pid)]; ok {
pc := childrenOf(parent)
*pc = append(*pc, node)
continue
}
}
roots = append(roots, node)
}
if less != nil {
sort.SliceStable(roots, func(i, j int) bool { return less(*roots[i], *roots[j]) })
}
return roots
}
// ===========================================================================
// 临时目录清理(企业级运营闭环:避免 temp 孤儿文件堆积)
// ===========================================================================
// CleanTemp 清理上传临时目录
// 删除 upload/temp 下超过 expireDays 天的日期子目录或散文件,返回删除数量
func CleanTemp(ctx context.Context, expireDays int) int {
if expireDays <= 0 {
expireDays = 7
}
base := upload.TempBaseDir(ctx)
entries, err := os.ReadDir(base)
if err != nil {
return 0
}
deadline := time.Now().AddDate(0, 0, -expireDays)
removed := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
// 目录名即日期(Yyyymmdd)
if day, e2 := time.Parse("20060102", e.Name()); e2 == nil && day.Before(deadline) {
if os.RemoveAll(filepath.Join(base, e.Name())) == nil {
removed++
}
}
}
return removed
}