chore: 并入 iotlcm-admin-svr 历史到 admin-svr/(原提交 49818c6)

This commit is contained in:
MiFaZhan
2026-11-02 13:27:59 +08:00
1250 changed files with 154111 additions and 0 deletions
@@ -0,0 +1,16 @@
// Package addons
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package addons
import (
"context"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
)
func ModuleSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
return
}
@@ -0,0 +1,141 @@
package allocationunits
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"strings"
)
// Fields 是日志展示契约,不增加数据库字段。
type Fields struct {
SourceDeptId int64 `json:"sourceDeptId" orm:"-"`
SourceDeptName string `json:"sourceDeptName" orm:"-"`
TargetDeptId int64 `json:"targetDeptId" orm:"-"`
TargetDeptName string `json:"targetDeptName" orm:"-"`
TargetDeptMode string `json:"targetDeptMode" orm:"-"`
}
type Unit struct {
Id int64 `orm:"dept_id"`
Key string `orm:"dept_key"`
Name string `orm:"dept_name"`
}
func Load(ctx context.Context, tenant string, ids []int64, tx ...gdb.TX) (map[int64]Unit, error) {
unique := map[int64]bool{}
for _, id := range ids {
if id > 0 {
unique[id] = true
}
}
result := map[int64]Unit{}
if len(unique) == 0 {
return result, nil
}
ids = nil
for id := range unique {
ids = append(ids, id)
}
var rows []Unit
if len(tx) > 0 {
result, err := tx[0].GetAll("SELECT dept_id,dept_key,dept_name FROM sys_dept WHERE tenant_id=? AND deleted_at IS NULL AND dept_id IN (?)", tenant, ids)
if err != nil {
return nil, gerror.Wrap(err, "读取划拨单位信息失败")
}
if err := result.Structs(&rows); err != nil {
return nil, err
}
units := map[int64]Unit{}
for _, row := range rows {
units[row.Id] = row
}
return units, nil
}
mod := dao.SysDept.Ctx(ctx)
if err := mod.WhereNull("deleted_at").Where("tenant_id", tenant).WhereIn("dept_id", ids).Fields("dept_id", "dept_key", "dept_name", "deleted_at").Scan(&rows); err != nil {
return nil, gerror.Wrap(err, "读取划拨单位信息失败")
}
for _, row := range rows {
result[row.Id] = row
}
return result, nil
}
// Snapshot 名称由服务端查询,仅在受理时记录,执行时不覆盖。
func Snapshot(ctx context.Context, tenant string, source, target int64, tx ...gdb.TX) (*gjson.Json, error) {
units, err := Load(ctx, tenant, []int64{source, target}, tx...)
if err != nil {
return nil, err
}
mode := "single"
if target <= 0 {
mode = "per_row"
}
return gjson.New(map[string]any{"sourceDeptId": source, "sourceDeptKey": units[source].Key, "sourceDeptName": units[source].Name, "targetDeptId": target, "targetDeptKey": units[target].Key, "targetDeptName": units[target].Name, "targetDeptMode": mode}), nil
}
func Resolve(info *gjson.Json, source, target int64) Fields {
f := Fields{SourceDeptId: source, TargetDeptId: target}
if info != nil {
if id := info.Get("sourceDeptId").Int64(); id > 0 {
f.SourceDeptId = id
}
if id := info.Get("targetDeptId").Int64(); id > 0 {
f.TargetDeptId = id
}
f.SourceDeptName = strings.TrimSpace(info.Get("sourceDeptName").String())
f.TargetDeptName = strings.TrimSpace(info.Get("targetDeptName").String())
f.TargetDeptMode = info.Get("targetDeptMode").String()
}
if f.TargetDeptMode == "" {
if f.TargetDeptId > 0 {
f.TargetDeptMode = "single"
} else {
f.TargetDeptMode = "per_row"
}
}
return f
}
// Fill 一页只补查一次旧数据的单位名称,不扫描卡片当前归属,不覆盖历史快照。
func Fill(ctx context.Context, tenant string, fields []*Fields) error {
ids := []int64{}
for _, f := range fields {
if f.SourceDeptName == "" {
ids = append(ids, f.SourceDeptId)
}
if f.TargetDeptName == "" {
ids = append(ids, f.TargetDeptId)
}
}
units, err := Load(ctx, tenant, ids)
if err != nil {
return err
}
for _, f := range fields {
if f.SourceDeptName == "" {
f.SourceDeptName = units[f.SourceDeptId].Name
}
if f.TargetDeptName == "" {
f.TargetDeptName = units[f.TargetDeptId].Name
}
}
return nil
}
func Merge(info, units *gjson.Json) *gjson.Json {
data := map[string]any{}
if info != nil {
for k, v := range info.Map() {
data[k] = v
}
}
if units != nil {
for k, v := range units.Map() {
data[k] = v
}
}
return gjson.New(data)
}
@@ -0,0 +1,55 @@
package allocationunits
import (
"context"
"encoding/json"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"strings"
"testing"
)
func TestSnapshotAndLegacyFallback(t *testing.T) {
ctx := context.Background()
if err := gdb.SetConfig(gdb.Config{"default": gdb.ConfigGroup{{Type: "sqlite", Link: "sqlite::@file(:memory:)", MaxOpenConnCount: 1}}}); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = g.DB().Close(ctx) })
for _, sql := range []string{"CREATE TABLE sys_dept(dept_id INTEGER,tenant_id TEXT,dept_key TEXT,dept_name TEXT,deleted_at TEXT)", "INSERT INTO sys_dept VALUES(1,'A','a','划拨单位',NULL),(2,'A','b','接收单位',NULL),(3,'B','other','其他租户',NULL),(4,'A','gone','已删除单位','2026-01-01')"} {
if _, err := g.DB().Exec(ctx, sql); err != nil {
t.Fatal(err)
}
}
snapshot, err := Snapshot(ctx, "A", 1, 2)
if err != nil {
t.Fatal(err)
}
if _, err := g.DB().Exec(ctx, "UPDATE sys_dept SET dept_name='新名称' WHERE dept_id=2"); err != nil {
t.Fatal(err)
}
frozen := Resolve(snapshot, 0, 0)
legacy := Resolve(nil, 1, 2)
foreign := Resolve(nil, 3, 4)
if err := Fill(ctx, "A", []*Fields{&frozen, &legacy, &foreign}); err != nil {
t.Fatal(err)
}
if frozen.TargetDeptName != "接收单位" || legacy.TargetDeptName != "新名称" || foreign.SourceDeptName != "" || foreign.TargetDeptName != "" {
t.Fatalf("快照/回退/隔离错误: %+v %+v %+v", frozen, legacy, foreign)
}
if f := Resolve(gjson.New(map[string]any{"sourceDeptName": "历史名称"}), 1, 0); f.TargetDeptMode != "per_row" {
t.Fatal(f)
}
encoded, err := json.Marshal(frozen)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(encoded), "sourceDeptName") {
t.Fatal("JSON契约缺少单位字段")
}
merged := Merge(gjson.New(map[string]any{"jobRole": "allocate_parent"}), snapshot)
if merged.Get("jobRole").String() != "allocate_parent" || merged.Get("sourceDeptName").String() != "划拨单位" {
t.Fatal(merged)
}
}
@@ -0,0 +1,204 @@
package batchimport
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
)
type PendingOptions struct {
PrepareRows func(ctx context.Context, rows []Row) error
}
const pendingDetailInsertBatchSize = 500
func CreatePendingUpload(ctx context.Context, req SubmitRequest, p PendingProcessor, options PendingOptions) (*Result, error) {
if err := batchmetadata.ValidateCreation(req.SourceChannel, req.ExecutionMode); err != nil {
return nil, err
}
cfg := p.Config()
if req.File == nil {
return nil, gerror.New("请选择上传文件")
}
if err := service.SysOss().CheckType(ctx, consts.SysUploadFileTypeFile, req.File); err != nil {
return nil, err
}
if req.File.Size > cfg.MaxFileSize {
return nil, gerror.Newf("上传文件大小超过限制,最大为%dM", cfg.MaxFileSize>>20)
}
batchNo, err := NextBatchNoContext(ctx, cfg.BatchNoPrefix)
if err != nil {
return nil, err
}
req.BatchNo = batchNo
content, err := readUploadContent(req.File)
if err != nil {
return nil, err
}
importOss, err := service.SysOss().SaveContent(ctx, &model.SysOssSaveContentParam{
Content: content,
FileName: batchNo + "-import.xlsx",
NewFileType: consts.SysUploadNewFileTypeUserFile,
SubDirName: cfg.ImportDir,
})
if err != nil {
return nil, gerror.Wrap(err, "保存原始Excel失败")
}
rows, err := p.ParseRows(ctx, importOss.Path)
if err != nil {
return nil, err
}
return createPending(ctx, &req, p, rows, importOss, options)
}
func CreatePendingRows(ctx context.Context, req SubmitRequest, p PendingProcessor, rows []Row, options PendingOptions) (*Result, error) {
if err := batchmetadata.ValidateCreation(req.SourceChannel, req.ExecutionMode); err != nil {
return nil, err
}
return createPending(ctx, &req, p, rows, nil, options)
}
func createPending(ctx context.Context, req *SubmitRequest, p PendingProcessor, rows []Row, importOss *model.SysOssSaveContentModel, options PendingOptions) (*Result, error) {
if len(rows) == 0 {
return nil, gerror.New("导入数据为空")
}
if err := validatePendingRowCount(len(rows), p.Config().MaxRows); err != nil {
return nil, err
}
if err := p.ValidateRows(ctx, rows); err != nil {
return nil, err
}
if options.PrepareRows != nil {
if err := options.PrepareRows(ctx, rows); err != nil {
return nil, err
}
}
if strings.TrimSpace(req.BatchNo) == "" {
batchNo, err := NextBatchNoContext(ctx, p.Config().BatchNoPrefix)
if err != nil {
return nil, err
}
req.BatchNo = batchNo
}
result := &Result{
ImportStatus: consts.ImportStatusSuccess,
BatchNo: req.BatchNo,
BatchType: p.Config().BatchType,
TotalRows: len(rows),
}
if importOss != nil {
result.ImportFileOssId = importOss.OssId
result.ImportFileUrl = importOss.Url
}
if err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
sourceBatchNo := strings.TrimSpace(req.SourceBatchNo)
if sourceBatchNo == "" {
sourceBatchNo = req.BatchNo
}
if req.BeforeSave != nil {
if err := req.BeforeSave(ctx, tx, req, rows); err != nil {
return err
}
}
if _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Data(do.BatchMain{
BatchNo: req.BatchNo,
OrderSn: "",
BatchType: p.Config().BatchType,
BatchAsyncStatus: consts.BatchAsyncStatusPending,
SourceType: req.SourceType,
SourceChannel: batchmetadata.Channel(req.SourceChannel),
ExecutionMode: batchmetadata.NormalizeMode(req.ExecutionMode),
SourceBatchNo: sourceBatchNo,
SourceOssId: ossIDValue(sourceOssID(result, importOss)),
IsCrossDept: 0,
TargetDeptId: req.TargetDeptId,
DeptId: req.Operator.DeptId,
TotalCount: len(rows),
ValidCount: 0,
SuccessCount: 0,
FailCount: 0,
RunResult: consts.BatchRunResultPending,
FailReason: "",
BatchInfo: p.BuildBatchInfo(ctx, *req, result, importOss, nil, 0),
TenantId: req.Operator.TenantId,
CreatedDept: req.Operator.DeptId,
CreatedBy: req.Operator.UserId,
UpdatedBy: req.Operator.UserId,
}).Insert(); err != nil {
return gerror.Wrap(err, "写入待处理批次失败")
}
details := make([]do.BatchDetail, 0, len(rows))
for _, row := range rows {
details = append(details, do.BatchDetail{
BatchNo: req.BatchNo,
BatchType: p.Config().BatchType,
DetailStatus: consts.BatchDetailStatusPending,
BlankOutStatus: consts.BlankOutStatusNo,
RowNo: row.GetRowNo(),
ItemId: row.GetItemID(),
ErrorCode: "",
ErrorReason: strings.Join(row.GetErrorReasons(), ";"),
Snapshot: row.Snapshot(),
TenantId: req.Operator.TenantId,
CreatedDept: req.Operator.DeptId,
CreatedBy: req.Operator.UserId,
UpdatedBy: req.Operator.UserId,
})
}
for start := 0; start < len(details); start += pendingDetailInsertBatchSize {
end := start + pendingDetailInsertBatchSize
if end > len(details) {
end = len(details)
}
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Data(details[start:end]).Insert(); err != nil {
return gerror.Wrapf(err, "写入待处理批次明细失败,范围:%d-%d", start+1, end)
}
}
return nil
}); err != nil {
return nil, err
}
return result, nil
}
func validatePendingRowCount(rowCount, maxRows int) error {
if maxRows > 0 && rowCount > maxRows {
return gerror.Newf("单批最多支持%d张卡,当前共%d张,请拆分后重试", maxRows, rowCount)
}
return nil
}
func SaveAsyncResultFiles(
ctx context.Context,
req SubmitRequest,
p Processor,
result *Result,
rows []Row,
importOss *model.SysOssSaveContentModel,
startTime, endTime *gtime.Time,
) (*model.SysOssSaveContentModel, int64, error) {
resultOss, downloadId, err := saveResultExcelAndDownload(ctx, req, p, result, rows, importOss, startTime, endTime)
if err != nil {
return nil, 0, err
}
result.ResultDownloadId = downloadId
result.ResultFileOssId = resultOss.OssId
result.ResultFileUrl = resultOss.Url
return resultOss, downloadId, nil
}
@@ -0,0 +1,117 @@
package batchimport
import (
"context"
"crypto/rand"
"fmt"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
)
const (
batchNoPrefixLength = 3
batchNoSequenceLength = 3
batchNoSequenceMax = 999
batchNoSequenceTTL = 48 * time.Hour
batchNoSequenceRedisPrefix = "iotlcm:batch:no:seq:"
randomNoSuffixLength = 8
randomNoAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
)
func NextBatchNo(prefix string) (string, error) {
return NextBatchNoContext(context.Background(), prefix)
}
func NextBatchNoContext(ctx context.Context, prefix string) (string, error) {
prefix, err := normalizeBatchNoPrefix(prefix)
if err != nil {
return "", err
}
if ctx == nil {
ctx = context.Background()
}
now := time.Now()
seq, err := nextDailyBatchSequence(ctx, now)
if err != nil {
return "", err
}
return formatBatchNo(prefix, now, seq)
}
func NextRandomNo(prefix string) (string, error) {
prefix = strings.TrimSpace(strings.ToUpper(prefix))
if prefix == "" {
return "", gerror.New("编号前缀不能为空")
}
random, err := randomNoSuffix(randomNoSuffixLength)
if err != nil {
return "", err
}
return fmt.Sprintf("%s%s%s", prefix, time.Now().Format("20060102150405"), random), nil
}
func normalizeBatchNoPrefix(prefix string) (string, error) {
prefix = strings.TrimSpace(strings.ToUpper(prefix))
if prefix == "" {
return "", gerror.New("批次号前缀不能为空")
}
if len(prefix) != batchNoPrefixLength {
return "", gerror.Newf("批次号前缀必须为%d位", batchNoPrefixLength)
}
return prefix, nil
}
func nextDailyBatchSequence(ctx context.Context, now time.Time) (int64, error) {
key := batchNoSequenceKey(now)
script := `
local v = redis.call("INCR", KEYS[1])
if v == 1 then
redis.call("EXPIRE", KEYS[1], ARGV[1])
end
return v`
res, err := g.Redis().Do(ctx, "EVAL", script, 1, key, int(batchNoSequenceTTL.Seconds()))
if err != nil {
return 0, gerror.Wrap(err, "生成批次号序号失败")
}
seq := res.Int64()
if seq <= 0 {
return 0, gerror.New("生成批次号序号失败")
}
if seq > batchNoSequenceMax {
return 0, gerror.Newf("当日批次号序号超过%03d", batchNoSequenceMax)
}
return seq, nil
}
func batchNoSequenceKey(now time.Time) string {
return batchNoSequenceRedisPrefix + now.Format("20060102")
}
func formatBatchNo(prefix string, now time.Time, seq int64) (string, error) {
if seq <= 0 || seq > batchNoSequenceMax {
return "", gerror.Newf("批次号序号必须在1-%03d之间", batchNoSequenceMax)
}
return fmt.Sprintf("%s%s%0*d", prefix, now.Format("20060102150405"), batchNoSequenceLength, seq), nil
}
func randomNoSuffix(length int) (string, error) {
if length <= 0 {
return "", gerror.New("随机码长度必须大于0")
}
buf := make([]byte, length)
randomBytes := make([]byte, length)
if _, err := rand.Read(randomBytes); err != nil {
return "", gerror.Wrap(err, "生成随机码失败")
}
alphabetLen := byte(len(randomNoAlphabet))
for i, b := range randomBytes {
buf[i] = randomNoAlphabet[b%alphabetLen]
}
return string(buf), nil
}
@@ -0,0 +1,663 @@
package batchimport
import (
"context"
"fmt"
"io"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/hash"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/iot_excelize"
"os"
"strings"
"github.com/gogf/gf/v2/crypto/gmd5"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
"github.com/xuri/excelize/v2"
)
func SubmitAndRun(ctx context.Context, req SubmitRequest, p Processor) (*Result, error) {
if err := batchmetadata.ValidateCreation(req.SourceChannel, req.ExecutionMode); err != nil {
return nil, err
}
cfg := p.Config()
if req.File == nil {
return nil, gerror.New("请选择上传文件")
}
if err := service.SysOss().CheckType(ctx, consts.SysUploadFileTypeFile, req.File); err != nil {
return nil, err
}
if req.File.Size > cfg.MaxFileSize {
return nil, gerror.Newf("上传文件大小超过限制,最大为%dM", cfg.MaxFileSize>>20)
}
batchNo, err := NextBatchNoContext(ctx, cfg.BatchNoPrefix)
if err != nil {
return nil, err
}
req.BatchNo = batchNo
result := &Result{
ImportStatus: consts.ImportStatusFailed,
BatchNo: batchNo,
}
content, err := readUploadContent(req.File)
if err != nil {
return nil, err
}
importOss, err := service.SysOss().SaveContent(ctx, &model.SysOssSaveContentParam{
Content: content,
FileName: batchNo + "-import.xlsx",
NewFileType: consts.SysUploadNewFileTypeUserFile,
SubDirName: cfg.ImportDir,
NotAddDate: 0,
})
if err != nil {
return nil, gerror.Wrap(err, "保存原始Excel失败")
}
result.ImportFileOssId = importOss.OssId
result.ImportFileUrl = importOss.Url
rows, err := p.ParseRows(ctx, importOss.Path)
if err != nil {
result.FailReason = userFacingFailReason(err)
if err = archiveFailedBatchAfterImport(ctx, req, p, result, nil, importOss); err != nil {
return nil, err
}
return result, nil
}
result.TotalRows = len(rows)
if err = p.ValidateRows(ctx, rows); err != nil {
for _, row := range rows {
row.AddErrorReason(userFacingFailReason(err))
}
}
fillResultCounts(result, rows)
startTime := gtime.Now()
endTime := gtime.Now()
if err = saveBatchAndExecute(ctx, &req, p, result, rows, importOss, startTime, endTime); err != nil {
markRowsFailed(result, rows, userFacingFailReason(err))
if err = saveBatchAndExecute(ctx, &req, p, result, rows, importOss, startTime, endTime); err != nil {
return nil, err
}
}
result.ExecutionResult = req.ExecutionResult
resultOss, downloadId, err := saveResultExcelAndDownload(ctx, req, p, result, rows, importOss, startTime, endTime)
if err != nil {
return nil, err
}
result.ResultDownloadId = downloadId
result.ResultFileOssId = resultOss.OssId
result.ResultFileUrl = resultOss.Url
if err = updateBatchResultFileInfo(ctx, req, p, result, importOss, resultOss, downloadId); err != nil {
return nil, err
}
return result, nil
}
func RunRows(ctx context.Context, req SubmitRequest, p Processor, rows []Row, sourceName string) (*Result, error) {
if err := batchmetadata.ValidateCreation(req.SourceChannel, req.ExecutionMode); err != nil {
return nil, err
}
cfg := p.Config()
batchNo, err := NextBatchNoContext(ctx, cfg.BatchNoPrefix)
if err != nil {
return nil, err
}
req.BatchNo = batchNo
result := &Result{
ImportStatus: consts.ImportStatusFailed,
BatchNo: batchNo,
TotalRows: len(rows),
}
if len(rows) == 0 {
result.FailReason = "导入数据为空"
} else if err = p.ValidateRows(ctx, rows); err != nil {
for _, row := range rows {
row.AddErrorReason(userFacingFailReason(err))
}
}
fillResultCounts(result, rows)
if result.FailReason != "" {
result.ImportStatus = consts.ImportStatusFailed
result.InsertedRows = 0
}
startTime := gtime.Now()
endTime := gtime.Now()
importOss := &model.SysOssSaveContentModel{SysOss: &entity.SysOss{}}
if sourceName != "" {
importOss.OriginalName = sourceName
}
if err = saveBatchAndExecute(ctx, &req, p, result, rows, importOss, startTime, endTime); err != nil {
markRowsFailed(result, rows, userFacingFailReason(err))
if err = saveBatchAndExecute(ctx, &req, p, result, rows, importOss, startTime, endTime); err != nil {
return nil, err
}
}
result.ExecutionResult = req.ExecutionResult
resultOss, downloadId, err := saveResultExcelAndDownload(ctx, req, p, result, rows, importOss, startTime, endTime)
if err != nil {
return nil, err
}
result.ResultDownloadId = downloadId
result.ResultFileOssId = resultOss.OssId
result.ResultFileUrl = resultOss.Url
if err = updateBatchResultFileInfo(ctx, req, p, result, importOss, resultOss, downloadId); err != nil {
return nil, err
}
return result, nil
}
func archiveFailedBatchAfterImport(ctx context.Context, req SubmitRequest, p Processor, result *Result, rows []Row, importOss *model.SysOssSaveContentModel) error {
result.ImportStatus = consts.ImportStatusFailed
result.InsertedRows = 0
if result.FailReason == "" {
result.FailReason = "导入失败"
}
startTime := gtime.Now()
endTime := gtime.Now()
if err := saveBatchAndExecute(ctx, &req, p, result, rows, importOss, startTime, endTime); err != nil {
return err
}
result.ExecutionResult = req.ExecutionResult
resultOss, downloadId, err := saveResultExcelAndDownload(ctx, req, p, result, rows, importOss, startTime, endTime)
if err != nil {
return err
}
result.ResultDownloadId = downloadId
result.ResultFileOssId = resultOss.OssId
result.ResultFileUrl = resultOss.Url
return updateBatchResultFileInfo(ctx, req, p, result, importOss, resultOss, downloadId)
}
func readUploadContent(file *ghttp.UploadFile) ([]byte, error) {
reader, err := file.Open()
if err != nil {
return nil, gerror.Wrap(err, "打开上传文件失败")
}
defer reader.Close()
content, err := io.ReadAll(reader)
if err != nil {
return nil, gerror.Wrap(err, "读取上传文件失败")
}
return content, nil
}
func fillResultCounts(result *Result, rows []Row) {
failCount := 0
for _, row := range rows {
if len(row.GetErrorReasons()) > 0 {
failCount++
}
}
result.ErrorRows = failCount
result.ValidRows = result.TotalRows - failCount
if result.ValidRows < 0 {
result.ValidRows = 0
}
if failCount == 0 {
result.ImportStatus = consts.ImportStatusSuccess
result.InsertedRows = len(rows)
return
}
result.ImportStatus = consts.ImportStatusFailed
result.InsertedRows = 0
}
func markRowsFailed(result *Result, rows []Row, reason string) {
if result == nil {
return
}
if reason == "" {
reason = "业务处理失败"
}
for _, row := range rows {
row.AddErrorReason(reason)
}
result.ImportStatus = consts.ImportStatusFailed
result.FailReason = reason
result.TotalRows = len(rows)
result.ValidRows = 0
result.ErrorRows = len(rows)
result.InsertedRows = 0
}
// saveBatchAndExecute writes stock data only when all rows pass validation.
// Failed validation keeps the batch and result file, but skips business inserts.
func saveBatchAndExecute(ctx context.Context, req *SubmitRequest, p Processor, result *Result, rows []Row, importOss *model.SysOssSaveContentModel, startTime, endTime *gtime.Time) error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
now := gtime.Now()
failReason := result.FailReason
if failReason == "" && result.ErrorRows > 0 {
failReason = "存在校验失败数据,本次未写入"
}
if result.ImportStatus == consts.ImportStatusFailed && result.ErrorRows > 0 {
markValidRowsNotExecuted(rows)
}
batchType := batchTypeByImportStatus(req, p, result.ImportStatus)
result.BatchType = batchType
if result.ImportStatus == consts.ImportStatusSuccess && len(rows) > 0 && req.BeforeSave != nil {
if err := req.BeforeSave(ctx, tx, req, rows); err != nil {
return err
}
}
sourceBatchNo := req.SourceBatchNo
if sourceBatchNo == "" {
sourceBatchNo = result.BatchNo
}
if _, err := tx.Ctx(ctx).Model(dao.BatchMain.Table()).Data(do.BatchMain{
BatchNo: result.BatchNo,
OrderSn: "",
BatchType: batchType,
BatchAsyncStatus: asyncStatusByImportStatus(result.ImportStatus),
BatchStatus: consts.BatchStatusNormal,
SourceType: req.SourceType,
SourceChannel: batchmetadata.Channel(req.SourceChannel),
ExecutionMode: batchmetadata.NormalizeMode(req.ExecutionMode),
SourceBatchNo: sourceBatchNo,
SourceOssId: ossIDValue(sourceOssID(result, importOss)),
IsCrossDept: 0,
TargetDeptId: req.TargetDeptId,
DeptId: req.Operator.DeptId,
TotalCount: result.TotalRows,
ValidCount: result.ValidRows,
SuccessCount: result.InsertedRows,
FailCount: result.ErrorRows,
RunResult: runResultByImportStatus(result.ImportStatus),
FailReason: failReason,
BatchInfo: p.BuildBatchInfo(ctx, *req, result, importOss, nil, 0),
StartTime: startTime,
EndTime: endTime,
TenantId: req.Operator.TenantId,
CreatedDept: req.Operator.DeptId,
CreatedBy: req.Operator.UserId,
CreatedAt: now,
UpdatedBy: req.Operator.UserId,
UpdatedAt: now,
}).Insert(); err != nil {
return gerror.Wrap(err, "写入批次主表失败")
}
details := make([]do.BatchDetail, 0, len(rows))
for _, row := range rows {
details = append(details, do.BatchDetail{
BatchNo: result.BatchNo,
BatchType: batchType,
DetailStatus: detailStatus(row),
BlankOutStatus: consts.BlankOutStatusNo,
RowNo: row.GetRowNo(),
ItemId: row.GetItemID(),
ErrorCode: "",
ErrorReason: strings.Join(row.GetErrorReasons(), ";"),
Snapshot: row.Snapshot(),
TenantId: req.Operator.TenantId,
CreatedDept: req.Operator.DeptId,
CreatedBy: req.Operator.UserId,
CreatedAt: now,
UpdatedBy: req.Operator.UserId,
UpdatedAt: now,
})
}
if len(details) > 0 {
if _, err := tx.Ctx(ctx).Model(dao.BatchDetail.Table()).Data(details).Insert(); err != nil {
return gerror.Wrap(err, "写入批次明细失败")
}
}
if result.ImportStatus == consts.ImportStatusSuccess && len(rows) > 0 && !req.SkipExecute {
if err := p.Execute(ctx, tx, req, rows); err != nil {
return err
}
}
return nil
})
}
func saveResultExcelAndDownload(ctx context.Context, req SubmitRequest, p Processor, result *Result, rows []Row, importOss *model.SysOssSaveContentModel, startTime, endTime *gtime.Time) (*model.SysOssSaveContentModel, int64, error) {
cfg := p.Config()
resultRows := make([]iot_excelize.BatchResultRow, 0, len(rows))
for _, row := range rows {
resultRows = append(resultRows, iot_excelize.BatchResultRow{
RowNo: row.GetRowNo(),
ItemID: row.GetItemID(),
Status: displayRowStatus(row),
ErrorReason: strings.Join(row.GetErrorReasons(), ";"),
})
}
importName := ""
importOssId := ""
if importOss != nil {
importName = importOss.OriginalName
if importOss.OssId > 0 {
importOssId = fmt.Sprintf("%d", importOss.OssId)
}
}
infos := []iot_excelize.BatchInfoItem{
{Field: "批次号", Value: result.BatchNo},
{Field: "批次类型", Value: taskTypeName(p)},
{Field: "处理结果", Value: displayImportStatus(result.ImportStatus)},
{Field: "总行数", Value: fmt.Sprintf("%d", result.TotalRows)},
{Field: "校验通过行数", Value: fmt.Sprintf("%d", result.ValidRows)},
{Field: "失败行数", Value: fmt.Sprintf("%d", result.ErrorRows)},
{Field: "实际写入行数", Value: fmt.Sprintf("%d", result.InsertedRows)},
{Field: "原始文件名", Value: importName},
{Field: "原始文件OSS ID", Value: importOssId},
{Field: "结果文件OSS ID", Value: ""},
{Field: "操作人", Value: fmt.Sprintf("%d", req.Operator.UserId)},
{Field: "开始时间", Value: formatGTime(startTime)},
{Field: "结束时间", Value: formatGTime(endTime)},
{Field: "备注", Value: resultRemark(result)},
}
f, err := iot_excelize.BuildBatchResultExcel(resultRows, infos)
if err != nil {
return nil, 0, err
}
defer f.Close()
reader, err := iot_excelize.ToReadSeeker(f)
if err != nil {
return nil, 0, err
}
content, err := io.ReadAll(reader)
if err != nil {
return nil, 0, err
}
resultOss, err := service.SysOss().SaveContent(ctx, &model.SysOssSaveContentParam{
Content: content,
FileName: result.BatchNo + "-result.xlsx",
NewFileType: consts.SysUploadNewFileTypeUserFile,
SubDirName: cfg.ResultDir,
NotAddDate: 0,
})
if err != nil {
return nil, 0, gerror.Wrap(err, "保存结果Excel失败")
}
if err = fillResultOssId(ctx, req, resultOss); err != nil {
return nil, 0, err
}
now := gtime.Now()
downloadId, err := dao.TResourceBatchDownload.Ctx(ctx).Data(do.TResourceBatchDownload{
BatchNo: result.BatchNo,
TaskName: cfg.TaskName,
ResourceType: consts.ResourceTypeExcel,
ExportScene: cfg.ExportScene,
TotalCount: result.TotalRows,
SuccessCount: result.InsertedRows,
FailCount: result.ErrorRows,
DownloadStatus: consts.DownloadStatusGenerated,
OssId: resultOss.OssId,
FileName: resultOss.FileName,
FileExt: resultOss.FileSuffix,
FileUrl: resultOss.Url,
FilePath: resultOss.Path,
FileSize: resultOss.FileSize,
FileMd5: resultOss.Md5,
DownloadCount: 0,
MaxDownloadCount: 0,
DeptId: req.Operator.DeptId,
StartTime: startTime,
FinishTime: now,
TenantId: req.Operator.TenantId,
CreatedDept: req.Operator.DeptId,
CreatedBy: req.Operator.UserId,
CreatedAt: now,
UpdatedBy: req.Operator.UserId,
UpdatedAt: now,
}).InsertAndGetId()
if err != nil {
return nil, 0, gerror.Wrap(err, "写入结果下载任务失败")
}
return resultOss, downloadId, nil
}
func fillResultOssId(ctx context.Context, req SubmitRequest, resultOss *model.SysOssSaveContentModel) error {
f, err := excelize.OpenFile(resultOss.Path)
if err != nil {
return gerror.Wrap(err, "打开结果Excel失败")
}
defer f.Close()
const batchInfoSheet = "批次信息"
rows, err := f.GetRows(batchInfoSheet)
if err != nil {
return gerror.Wrap(err, "读取结果Excel批次信息失败")
}
found := false
for i, row := range rows {
if len(row) == 0 || strings.TrimSpace(row[0]) != "结果文件OSS ID" {
continue
}
cell, err := excelize.CoordinatesToCellName(2, i+1)
if err != nil {
return err
}
if err = f.SetCellStr(batchInfoSheet, cell, fmt.Sprintf("%d", resultOss.OssId)); err != nil {
return err
}
found = true
break
}
if !found {
return gerror.New("结果Excel缺少结果文件OSS ID字段")
}
if err = f.SaveAs(resultOss.Path); err != nil {
return gerror.Wrap(err, "回写结果Excel OSS ID失败")
}
fileInfo, err := os.Stat(resultOss.Path)
if err != nil {
return gerror.Wrap(err, "读取结果Excel文件信息失败")
}
md5, _ := gmd5.EncryptFile(resultOss.Path)
crc16 := hash.Crc16ChecksumFile(resultOss.Path)
sum16 := hash.Sum16ChecksumFile(resultOss.Path)
resultOss.FileSize = int(fileInfo.Size())
resultOss.Md5 = md5
resultOss.FileCrc16 = int(crc16)
resultOss.FileSum = int(sum16)
_, err = dao.SysOss.Ctx(ctx).
Where(dao.SysOss.Columns().OssId, resultOss.OssId).
Data(do.SysOss{
Md5: resultOss.Md5,
FileSize: resultOss.FileSize,
FileCrc16: resultOss.FileCrc16,
FileSum: resultOss.FileSum,
UpdatedAt: gtime.Now(),
UpdatedBy: req.Operator.UserId,
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新结果Excel文件信息失败")
}
return nil
}
func updateBatchResultFileInfo(ctx context.Context, req SubmitRequest, p Processor, result *Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) error {
_, err := dao.BatchMain.Ctx(ctx).
Where(dao.BatchMain.Columns().BatchNo, result.BatchNo).
Data(do.BatchMain{
SourceOssId: ossIDValue(sourceOssID(result, importOss)),
ResultOssId: ossIDValue(resultOssID(result, resultOss)),
BatchInfo: p.BuildBatchInfo(ctx, req, result, importOss, resultOss, downloadId),
UpdatedBy: req.Operator.UserId,
UpdatedAt: gtime.Now(),
}).
Update()
if err != nil {
return gerror.Wrap(err, "更新批次结果文件信息失败")
}
return nil
}
func sourceOssID(result *Result, importOss *model.SysOssSaveContentModel) int64 {
if result != nil && result.ImportFileOssId > 0 {
return result.ImportFileOssId
}
if importOss != nil && importOss.OssId > 0 {
return importOss.OssId
}
return 0
}
func resultOssID(result *Result, resultOss *model.SysOssSaveContentModel) int64 {
if result != nil && result.ResultFileOssId > 0 {
return result.ResultFileOssId
}
if resultOss != nil && resultOss.OssId > 0 {
return resultOss.OssId
}
return 0
}
func ossIDValue(ossID int64) any {
if ossID <= 0 {
return nil
}
return ossID
}
func asyncStatusByImportStatus(importStatus string) int {
if importStatus == consts.ImportStatusSuccess {
return consts.BatchAsyncStatusSuccess
}
return consts.BatchAsyncStatusFailed
}
func runResultByImportStatus(importStatus string) int {
if importStatus == consts.ImportStatusSuccess {
return consts.BatchRunResultSuccess
}
return consts.BatchRunResultFailed
}
func batchTypeByImportStatus(req *SubmitRequest, p Processor, importStatus string) int {
if req == nil {
return p.Config().BatchType
}
if importStatus != consts.ImportStatusSuccess && req.FailureBatchType > 0 {
return req.FailureBatchType
}
if req.BatchType > 0 {
return req.BatchType
}
return p.Config().BatchType
}
const NotExecutedReasonPrefix = "\u672c\u6279\u5b58\u5728\u6821\u9a8c\u5931\u8d25\uff0c\u6574\u6279\u672a\u6267\u884c\uff1a"
func markValidRowsNotExecuted(rows []Row) {
for _, row := range rows {
if row != nil && len(row.GetErrorReasons()) == 0 {
row.AddErrorReason(NotExecutedReasonPrefix + "\u672c\u5361\u6821\u9a8c\u901a\u8fc7")
}
}
}
func IsNotExecuted(row Row) bool {
for _, reason := range row.GetErrorReasons() {
if strings.HasPrefix(reason, NotExecutedReasonPrefix) {
return true
}
}
return false
}
func detailStatus(row Row) int {
if IsNotExecuted(row) {
return consts.BatchDetailStatusNotExecuted
}
if len(row.GetErrorReasons()) > 0 {
return consts.BatchDetailStatusFailed
}
return consts.BatchDetailStatusSuccess
}
func displayRowStatus(row Row) string {
if IsNotExecuted(row) {
return "未执行"
}
if len(row.GetErrorReasons()) > 0 {
return "失败"
}
return "成功"
}
func displayImportStatus(status string) string {
if status == consts.ImportStatusSuccess {
return "成功"
}
return "失败"
}
func resultRemark(result *Result) string {
if result != nil && result.ImportStatus == consts.ImportStatusSuccess {
return "导入成功"
}
if result != nil && result.FailReason != "" {
return result.FailReason
}
return "存在校验失败数据,本次未写入"
}
func userFacingFailReason(err error) string {
if err == nil {
return ""
}
reason := strings.TrimSpace(err.Error())
if reason == "" {
return "导入失败"
}
const maxLen = 500
if len([]rune(reason)) <= maxLen {
return reason
}
runes := []rune(reason)
return string(runes[:maxLen])
}
func taskTypeName(p Processor) string {
taskName := p.Config().TaskName
return strings.TrimSuffix(taskName, "结果")
}
func formatGTime(t *gtime.Time) string {
if t == nil {
return ""
}
return t.Format("Y-m-d H:i:s")
}
@@ -0,0 +1,93 @@
package batchimport
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/net/ghttp"
)
type SubmitRequest struct {
SourceChannel string
// ExecutionMode is the semantic size of the operation. Zero means the caller did not specify it.
ExecutionMode int
File *ghttp.UploadFile
BizParams any
SourceType int
SourceBatchNo string
TargetDeptId int64
Operator OperatorContext
BatchNo string
BatchType int
FailureBatchType int
SkipExecute bool
BeforeSave func(ctx context.Context, tx gdb.TX, req *SubmitRequest, rows []Row) error
ExecutionResult any
}
type OperatorContext struct {
TenantId string
DeptId int64
UserId int64
}
type Result struct {
ImportStatus string
BatchNo string
BatchType int
FailReason string
ImportFileOssId int64
ImportFileUrl string
TotalRows int
ValidRows int
ErrorRows int
InsertedRows int
ResultDownloadId int64
ResultFileOssId int64
ResultFileUrl string
ExecutionResult any
}
type Row interface {
GetRowNo() int
GetItemID() string
GetErrorReasons() []string
AddErrorReason(reason string)
Snapshot() *gjson.Json
}
type ProcessorConfig struct {
Code string
BatchType int
BatchNoPrefix string
TaskName string
ExportScene int
ImportDir string
ResultDir string
MaxFileSize int64
MaxRows int
TemplateHeaders []string
RequiredHeaders []string
}
type Processor interface {
Config() ProcessorConfig
ParseRows(ctx context.Context, filePath string) ([]Row, error)
ValidateRows(ctx context.Context, rows []Row) error
Execute(ctx context.Context, tx gdb.TX, req *SubmitRequest, rows []Row) error
BuildBatchInfo(ctx context.Context, req SubmitRequest, result *Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json
}
type PendingProcessor interface {
Config() ProcessorConfig
ParseRows(ctx context.Context, filePath string) ([]Row, error)
ValidateRows(ctx context.Context, rows []Row) error
BuildBatchInfo(ctx context.Context, req SubmitRequest, result *Result, importOss *model.SysOssSaveContentModel, resultOss *model.SysOssSaveContentModel, downloadId int64) *gjson.Json
}
type Message struct {
BatchNo string `json:"batchNo"`
BatchType int `json:"batchType"`
}
@@ -0,0 +1,66 @@
package batchimport
import (
"context"
"strings"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/do"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
func SaveUpload(ctx context.Context, file *ghttp.UploadFile, fileName, subDirName string) (*model.SysOssSaveContentModel, error) {
if file == nil {
return nil, gerror.New("upload file is required")
}
result, err := service.SysOss().UploadLocalWithFileName(
ctx,
file,
consts.SysUploadNewFileTypeUserFile,
strings.TrimSpace(fileName),
subDirName,
0,
)
if err != nil {
return nil, err
}
now := gtime.Now()
id, err := g.DB().Model(dao.SysOss.Table()).Data(do.SysOss{
TenantId: contexts.GetTenantId(ctx),
FileName: result.Name,
OriginalName: result.OriginalName,
FileSuffix: result.Ext,
Path: result.Path,
Url: result.FullPath,
CreatedDept: contexts.GetDeptId(ctx),
CreatedAt: now,
CreatedBy: contexts.GetUserId(ctx),
UpdatedAt: now,
UpdatedBy: contexts.GetUserId(ctx),
Service: "local",
Md5: result.Md5,
FileSize: result.Size,
FileCrc16: result.Crc16,
FileSum: result.Sum16,
}).InsertAndGetId()
if err != nil {
return nil, gerror.Wrap(err, "save uploaded file metadata failed")
}
oss := &entity.SysOss{}
if err = g.DB().Model(dao.SysOss.Table()).Where(dao.SysOss.Columns().OssId, id).Scan(oss); err != nil {
return nil, gerror.Wrap(err, "load uploaded file metadata failed")
}
return &model.SysOssSaveContentModel{SysOss: oss}, nil
}
@@ -0,0 +1,78 @@
package excel
import (
"context"
"encoding/csv"
"io"
"os"
"github.com/gogf/gf/v2/errors/gerror"
)
func StreamCSVFile(ctx context.Context, filePath string, options ParseOptions, handler func(ParsedRow) error) (int, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, gerror.Wrap(err, "open CSV file failed")
}
defer file.Close()
return StreamCSVReader(ctx, file, options, handler)
}
func StreamCSVReader(ctx context.Context, reader io.Reader, options ParseOptions, handler func(ParsedRow) error) (int, error) {
if handler == nil {
return 0, gerror.New("CSV row handler cannot be nil")
}
csvReader := csv.NewReader(reader)
csvReader.FieldsPerRecord = -1
csvReader.ReuseRecord = true
headers, err := csvReader.Read()
if err != nil {
if err == io.EOF {
return 0, gerror.New("CSV file has no header")
}
return 0, gerror.Wrap(err, "read CSV header failed")
}
columnIndex, err := buildColumnIndex(headers)
if err != nil {
return 0, err
}
for _, requiredHeader := range options.RequiredHeaders {
if _, ok := columnIndex[normalizeHeader(requiredHeader)]; !ok {
return 0, gerror.Newf("CSV file is missing required column: %s", requiredHeader)
}
}
rowNo := 1
parsedCount := 0
for {
if err = ctx.Err(); err != nil {
return parsedCount, err
}
values, readErr := csvReader.Read()
if readErr == io.EOF {
break
}
if readErr != nil {
return parsedCount, gerror.Wrapf(readErr, "read CSV row %d failed", rowNo+1)
}
rowNo++
if isEmptyRow(values) {
continue
}
fields := make(map[string]string, len(columnIndex))
for columnName, index := range columnIndex {
fields[columnName] = cellValue(values, index)
}
if err = handler(ParsedRow{RowNo: rowNo, Fields: fields}); err != nil {
return parsedCount, err
}
parsedCount++
}
if parsedCount == 0 {
return 0, gerror.New("CSV file has no data rows")
}
return parsedCount, nil
}
@@ -0,0 +1,248 @@
package excel
import (
"context"
"io"
"os"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/xuri/excelize/v2"
)
type ParseOptions struct {
RequiredHeaders []string
SingleSheet bool
// MaxDataRows 限制单次导入的有效数据行数(不含表头、不含空行)。
// 0 表示不限制(向后兼容已有批量功能)。超过该值立即停止读取并返回错误,
// 避免超大 Excel 在解析阶段占用过多内存或解析完成后才报错。
MaxDataRows int
}
func ParseFile(ctx context.Context, filePath string, options ParseOptions) ([]ParsedRow, error) {
file, err := os.Open(filePath)
if err != nil {
return nil, gerror.Wrap(err, "open Excel file failed")
}
defer file.Close()
return ParseReader(ctx, file, options)
}
// StreamFile parses the first data sheet row by row and keeps only the current
// row in memory.
func StreamFile(ctx context.Context, filePath string, options ParseOptions, handler func(ParsedRow) error) (int, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, gerror.Wrap(err, "open Excel file failed")
}
defer file.Close()
return StreamReader(ctx, file, options, handler)
}
func StreamReader(ctx context.Context, reader io.Reader, options ParseOptions, handler func(ParsedRow) error) (int, error) {
if handler == nil {
return 0, gerror.New("Excel row handler cannot be nil")
}
f, err := excelize.OpenReader(reader)
if err != nil {
return 0, gerror.Wrap(err, "open Excel file failed")
}
defer func() { _ = f.Close() }()
sheetList := f.GetSheetList()
if len(sheetList) == 0 {
return 0, gerror.New("Excel file has no worksheet")
}
if options.SingleSheet {
if err = ensureSingleDataSheet(f, sheetList); err != nil {
return 0, err
}
}
rows, err := f.Rows(sheetList[0])
if err != nil {
return 0, gerror.Wrap(err, "read Excel rows failed")
}
defer rows.Close()
if !rows.Next() {
if err = rows.Error(); err != nil {
return 0, gerror.Wrap(err, "read Excel header failed")
}
return 0, gerror.New("Excel file has no header")
}
headers, err := rows.Columns()
if err != nil {
return 0, gerror.Wrap(err, "read Excel header failed")
}
colIndex, err := buildColumnIndex(headers)
if err != nil {
return 0, err
}
for _, col := range options.RequiredHeaders {
if _, ok := colIndex[normalizeHeader(col)]; !ok {
return 0, gerror.Newf("Excel file is missing required column: %s", col)
}
}
rowNo := 1
parsedCount := 0
for rows.Next() {
rowNo++
if err = ctx.Err(); err != nil {
return parsedCount, err
}
values, columnsErr := rows.Columns()
if columnsErr != nil {
return parsedCount, gerror.Wrapf(columnsErr, "read Excel row %d failed", rowNo)
}
if isEmptyRow(values) {
continue
}
fields := make(map[string]string, len(colIndex))
for colName, idx := range colIndex {
fields[colName] = cellValue(values, idx)
}
if err = handler(ParsedRow{RowNo: rowNo, Fields: fields}); err != nil {
return parsedCount, err
}
parsedCount++
}
if err = rows.Error(); err != nil {
return parsedCount, gerror.Wrap(err, "read Excel rows failed")
}
if parsedCount == 0 {
return 0, gerror.New("Excel file has no data rows")
}
return parsedCount, nil
}
func ParseReader(ctx context.Context, reader io.Reader, options ParseOptions) ([]ParsedRow, error) {
f, err := excelize.OpenReader(reader)
if err != nil {
return nil, gerror.Wrap(err, "open Excel file failed")
}
defer func() { _ = f.Close() }()
sheetList := f.GetSheetList()
if len(sheetList) == 0 {
return nil, gerror.New("Excel file has no worksheet")
}
if options.SingleSheet {
if err = ensureSingleDataSheet(f, sheetList); err != nil {
return nil, err
}
}
// 使用流式迭代器逐行读取,避免在超过 MaxDataRows 时仍把整张表读入内存。
rowsIter, err := f.Rows(sheetList[0])
if err != nil {
return nil, gerror.Wrap(err, "read Excel rows failed")
}
defer func() { _ = rowsIter.Close() }()
if !rowsIter.Next() {
return nil, gerror.New("Excel文件缺少表头,请检查文件内容")
}
header, err := rowsIter.Columns()
if err != nil {
return nil, gerror.Wrap(err, "读取Excel表头失败,请检查文件内容")
}
colIndex, err := buildColumnIndex(header)
if err != nil {
return nil, err
}
for _, col := range options.RequiredHeaders {
if _, ok := colIndex[normalizeHeader(col)]; !ok {
return nil, gerror.Newf("Excel file is missing required column: %s", col)
}
}
initCap := options.MaxDataRows
if initCap <= 0 || initCap > 100000 {
initCap = 1024
}
parsedRows := make([]ParsedRow, 0, initCap)
lineNo := 2
dataCount := 0
for rowsIter.Next() {
if err = ctx.Err(); err != nil {
return nil, err
}
row, err := rowsIter.Columns()
if err != nil {
return nil, gerror.Wrap(err, "读取Excel数据失败,请检查文件内容")
}
if isEmptyRow(row) {
lineNo++
continue
}
dataCount++
if options.MaxDataRows > 0 && dataCount > options.MaxDataRows {
return nil, gerror.Newf("单次导入最多支持%d张卡,请拆分后重试", options.MaxDataRows)
}
fields := make(map[string]string, len(colIndex))
for colName, idx := range colIndex {
fields[colName] = cellValue(row, idx)
}
parsedRows = append(parsedRows, ParsedRow{
RowNo: lineNo,
Fields: fields,
})
lineNo++
}
if len(parsedRows) == 0 {
return nil, gerror.New("Excel file has no data rows")
}
return parsedRows, nil
}
func buildColumnIndex(headers []string) (map[string]int, error) {
colIndex := make(map[string]int)
for i, col := range headers {
header := NormalizeHeader(col)
if header == "" {
continue
}
if _, exists := colIndex[header]; exists {
return nil, gerror.Newf("Excel file has duplicate column: %s", strings.TrimSpace(col))
}
colIndex[header] = i
}
return colIndex, nil
}
func ensureSingleDataSheet(f *excelize.File, sheetList []string) error {
for _, sheetName := range sheetList[1:] {
rows, err := f.GetRows(sheetName)
if err != nil {
return gerror.Wrap(err, "read Excel worksheet failed")
}
for _, row := range rows {
if !isEmptyRow(row) {
return gerror.New("only one non-empty worksheet is supported")
}
}
}
return nil
}
func cellValue(row []string, index int) string {
if index < 0 || index >= len(row) {
return ""
}
return strings.TrimSpace(row[index])
}
func isEmptyRow(row []string) bool {
for _, col := range row {
if strings.TrimSpace(col) != "" {
return false
}
}
return true
}
func normalizeHeader(header string) string {
return NormalizeHeader(header)
}
@@ -0,0 +1,15 @@
package excel
import "strings"
type ParsedRow struct {
RowNo int
Fields map[string]string
}
func Field(row ParsedRow, name string) string {
if row.Fields == nil {
return ""
}
return strings.TrimSpace(row.Fields[NormalizeHeader(name)])
}
@@ -0,0 +1,33 @@
package excel
type ColumnType string
const (
ColumnTypeString ColumnType = "string"
ColumnTypeInt ColumnType = "int"
ColumnTypePositiveInt ColumnType = "positive_int"
ColumnTypeNonNegativeInt ColumnType = "non_negative_int"
ColumnTypeBool ColumnType = "bool"
ColumnTypeDate ColumnType = "date"
ColumnTypeDateTime ColumnType = "datetime"
ColumnTypeAmount ColumnType = "amount"
)
type ColumnSpec struct {
Name string
Required bool
Type ColumnType
}
type ConsistencyRule struct {
Name string
Type ColumnType
AllowEmpty bool
}
type RowIssue struct {
RowNo int
Column string
Value string
Message string
}
@@ -0,0 +1,99 @@
package excel
import (
"fmt"
"strings"
)
func ValidateRequiredFields(rows []ParsedRow, specs []ColumnSpec) []RowIssue {
issues := make([]RowIssue, 0)
for _, row := range rows {
for _, spec := range specs {
if !spec.Required {
continue
}
value := Field(row, spec.Name)
if value != "" {
continue
}
issues = append(issues, RowIssue{
RowNo: row.RowNo,
Column: spec.Name,
Value: value,
Message: fmt.Sprintf("%s is required", spec.Name),
})
}
}
return issues
}
func ValidateRowTypes(rows []ParsedRow, specs []ColumnSpec) []RowIssue {
issues := make([]RowIssue, 0)
for _, row := range rows {
for _, spec := range specs {
value := Field(row, spec.Name)
if value == "" {
continue
}
if _, err := normalizedValue(value, spec.Type); err != nil {
issues = append(issues, RowIssue{
RowNo: row.RowNo,
Column: spec.Name,
Value: value,
Message: fmt.Sprintf("%s format is invalid", spec.Name),
})
}
}
}
return issues
}
func ValidateFileConsistency(rows []ParsedRow, rules []ConsistencyRule) []RowIssue {
issues := make([]RowIssue, 0)
for _, rule := range rules {
baseValue := ""
baseSet := false
for _, row := range rows {
raw := Field(row, rule.Name)
normalized := ""
if strings.TrimSpace(raw) == "" {
if !rule.AllowEmpty {
issues = append(issues, RowIssue{
RowNo: row.RowNo,
Column: rule.Name,
Value: raw,
Message: fmt.Sprintf("%s must not be empty", rule.Name),
})
continue
}
} else {
value, err := normalizedValue(raw, rule.Type)
if err != nil {
issues = append(issues, RowIssue{
RowNo: row.RowNo,
Column: rule.Name,
Value: raw,
Message: fmt.Sprintf("%s format is invalid", rule.Name),
})
continue
}
normalized = value
}
if !baseSet {
baseValue = normalized
baseSet = true
continue
}
if normalized != baseValue {
issues = append(issues, RowIssue{
RowNo: row.RowNo,
Column: rule.Name,
Value: raw,
Message: fmt.Sprintf("%s must be consistent", rule.Name),
})
}
}
}
return issues
}
@@ -0,0 +1,220 @@
package excel
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/os/gtime"
)
func NormalizeHeader(header string) string {
header = strings.ReplaceAll(header, "\uFEFF", "")
header = strings.TrimSpace(header)
header = strings.TrimRight(header, "**")
return strings.ToUpper(strings.TrimSpace(header))
}
func ParseString(value string) string {
return strings.TrimSpace(value)
}
func ParseInt(value string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, strconv.ErrSyntax
}
return strconv.Atoi(value)
}
func ParsePositiveInt(value string) (int, error) {
parsed, err := ParseInt(value)
if err != nil || parsed <= 0 {
return 0, strconv.ErrSyntax
}
return parsed, nil
}
func ParseNonNegativeInt(value string) (int, error) {
parsed, err := ParseInt(value)
if err != nil || parsed < 0 {
return 0, strconv.ErrSyntax
}
return parsed, nil
}
func ParseBool(value string) (bool, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1", "true", "yes", "y", "\u662f", "\u5df2\u5b8c\u6210", "\u5b8c\u6210":
return true, nil
case "0", "false", "no", "n", "\u5426", "\u672a\u5b8c\u6210":
return false, nil
default:
return false, strconv.ErrSyntax
}
}
func ParseDate(value string) (*gtime.Time, error) {
parsed, err := parseTimeValue(value)
if err != nil {
return nil, err
}
return gtime.NewFromTime(time.Date(
parsed.Year(),
time.Month(parsed.Month()),
parsed.Day(),
0,
0,
0,
0,
parsed.Location(),
)), nil
}
func ParseDateTime(value string) (*gtime.Time, error) {
return parseTimeValue(value)
}
func ParseAmount(value string) (float64, error) {
value = strings.TrimSpace(value)
if value == "" {
return 0, strconv.ErrSyntax
}
value = strings.ReplaceAll(value, ",", "")
parsed, err := strconv.ParseFloat(value, 64)
if err != nil || parsed < 0 {
return 0, strconv.ErrSyntax
}
return math.Round(parsed*100) / 100, nil
}
func normalizedValue(value string, typ ColumnType) (string, error) {
switch typ {
case "", ColumnTypeString:
return ParseString(value), nil
case ColumnTypeInt:
parsed, err := ParseInt(value)
if err != nil {
return "", err
}
return strconv.Itoa(parsed), nil
case ColumnTypePositiveInt:
parsed, err := ParsePositiveInt(value)
if err != nil {
return "", err
}
return strconv.Itoa(parsed), nil
case ColumnTypeNonNegativeInt:
parsed, err := ParseNonNegativeInt(value)
if err != nil {
return "", err
}
return strconv.Itoa(parsed), nil
case ColumnTypeBool:
parsed, err := ParseBool(value)
if err != nil {
return "", err
}
return strconv.FormatBool(parsed), nil
case ColumnTypeDate:
parsed, err := ParseDate(value)
if err != nil {
return "", err
}
return parsed.Format("Y-m-d"), nil
case ColumnTypeDateTime:
parsed, err := ParseDateTime(value)
if err != nil {
return "", err
}
return parsed.Format("Y-m-d H:i:s"), nil
case ColumnTypeAmount:
parsed, err := ParseAmount(value)
if err != nil {
return "", err
}
return fmt.Sprintf("%.2f", parsed), nil
default:
return "", gerror.Newf("unsupported column type: %s", typ)
}
}
func parseTimeValue(value string) (*gtime.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, strconv.ErrSyntax
}
if parsed, ok := parseCompactTime(value); ok {
return parsed, nil
}
if parsed, ok := parseLayoutTime(value); ok {
return parsed, nil
}
parsed, err := gtime.StrToTime(value)
if err != nil || parsed == nil || parsed.IsZero() || parsed.Year() <= 0 {
if err != nil {
return nil, err
}
return nil, strconv.ErrSyntax
}
return parsed, nil
}
func parseCompactTime(value string) (*gtime.Time, bool) {
if !isDigits(value) {
return nil, false
}
layout := ""
switch len(value) {
case 8:
layout = "20060102"
case 12:
layout = "200601021504"
case 14:
layout = "20060102150405"
default:
return nil, false
}
parsed, err := time.ParseInLocation(layout, value, time.Local)
if err != nil {
return nil, false
}
return gtime.NewFromTime(parsed), true
}
func parseLayoutTime(value string) (*gtime.Time, bool) {
layouts := []string{
time.RFC3339,
time.RFC3339Nano,
"2006-1-2 15:04:05",
"2006-1-2 15:04",
"2006/1/2 15:04:05",
"2006/1/2 15:04",
"2006.1.2 15:04:05",
"2006.1.2 15:04",
"2006-1-2T15:04:05",
"2006-1-2T15:04",
"2006-1-2",
"2006/1/2",
"2006.1.2",
}
for _, layout := range layouts {
parsed, err := time.ParseInLocation(layout, value, time.Local)
if err == nil {
return gtime.NewFromTime(parsed), true
}
}
return nil, false
}
func isDigits(value string) bool {
for _, r := range value {
if r < '0' || r > '9' {
return false
}
}
return value != ""
}
@@ -0,0 +1,131 @@
package numrange
import (
"context"
"fmt"
"math/big"
"strings"
"unicode"
"github.com/gogf/gf/v2/errors/gerror"
)
func Build(ctx context.Context, start, end, field string, maxRows int) ([]string, error) {
start = strings.TrimSpace(start)
end = strings.TrimSpace(end)
if start == "" || end == "" {
return nil, gerror.Newf("%s起止号不能为空", field)
}
if strings.EqualFold(strings.TrimSpace(field), "ICCID") {
return buildAlphaNumericICCIDRange(ctx, start, end, field, maxRows)
}
return buildNumericRange(ctx, start, end, field, maxRows)
}
func buildNumericRange(ctx context.Context, start, end, field string, maxRows int) ([]string, error) {
if !isDigits(start) || !isDigits(end) {
return nil, gerror.Newf("%s起止号必须为数字", field)
}
if len(start) != len(end) {
return nil, gerror.Newf("%s起止号长度必须一致", field)
}
startInt, ok := new(big.Int).SetString(start, 10)
if !ok {
return nil, gerror.Newf("%s起始号格式错误", field)
}
endInt, ok := new(big.Int).SetString(end, 10)
if !ok {
return nil, gerror.Newf("%s结束号格式错误", field)
}
if startInt.Cmp(endInt) > 0 {
return nil, gerror.Newf("%s起始号不能大于结束号", field)
}
count := new(big.Int).Sub(endInt, startInt)
count.Add(count, big.NewInt(1))
if maxRows > 0 && (!count.IsInt64() || count.Int64() > int64(maxRows)) {
return nil, gerror.Newf("%s号段数量不能超过%d条", field, maxRows)
}
size := int(count.Int64())
rows := make([]string, 0, size)
current := new(big.Int).Set(startInt)
one := big.NewInt(1)
for i := 0; i < size; i++ {
if err := ctx.Err(); err != nil {
return nil, err
}
rows = append(rows, fmt.Sprintf("%0*s", len(start), current.String()))
current.Add(current, one)
}
return rows, nil
}
func buildAlphaNumericICCIDRange(ctx context.Context, start, end, field string, maxRows int) ([]string, error) {
if !isAlphaNumeric(start) || !isAlphaNumeric(end) {
return nil, gerror.Newf("%s起止号只能包含字母和数字", field)
}
if len(start) != len(end) {
return nil, gerror.Newf("%s起止号长度必须一致", field)
}
if start == end {
return []string{start}, nil
}
startPrefix, startTail := splitTrailingDigits(start)
endPrefix, endTail := splitTrailingDigits(end)
if startTail == "" || endTail == "" {
return nil, gerror.Newf("%s起止号必须包含可递增的数字尾号", field)
}
if startPrefix != endPrefix {
return nil, gerror.Newf("%s起止号前缀必须一致", field)
}
tails, err := buildNumericRange(ctx, startTail, endTail, field, maxRows)
if err != nil {
return nil, err
}
rows := make([]string, 0, len(tails))
for _, tail := range tails {
rows = append(rows, startPrefix+tail)
}
return rows, nil
}
func isDigits(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if !unicode.IsDigit(r) {
return false
}
}
return true
}
func isAlphaNumeric(value string) bool {
if value == "" {
return false
}
for _, r := range value {
if (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') {
continue
}
return false
}
return true
}
func splitTrailingDigits(value string) (prefix, digits string) {
index := len(value)
for index > 0 {
ch := value[index-1]
if ch < '0' || ch > '9' {
break
}
index--
}
return value[:index], value[index:]
}
@@ -0,0 +1,59 @@
package text
import (
"context"
"errors"
"strings"
)
var ErrEmptyInput = errors.New("batch input content cannot be empty")
type Line struct {
RowNo int
Text string
}
type FieldLine struct {
RowNo int
Fields []string
}
func ParseNonEmptyLines(ctx context.Context, input string) ([]Line, error) {
lines := strings.Split(input, "\n")
result := make([]Line, 0, len(lines))
for index, line := range lines {
if err := ctx.Err(); err != nil {
return nil, err
}
value := strings.TrimSpace(line)
if value == "" {
continue
}
result = append(result, Line{
RowNo: index + 1,
Text: value,
})
}
if len(result) == 0 {
return nil, ErrEmptyInput
}
return result, nil
}
func ParseWhitespaceFields(ctx context.Context, input string) ([]FieldLine, error) {
lines, err := ParseNonEmptyLines(ctx, input)
if err != nil {
return nil, err
}
result := make([]FieldLine, 0, len(lines))
for _, line := range lines {
if err = ctx.Err(); err != nil {
return nil, err
}
result = append(result, FieldLine{
RowNo: line.RowNo,
Fields: strings.Fields(line.Text),
})
}
return result, nil
}
@@ -0,0 +1,52 @@
package batchmetadata
import "github.com/gogf/gf/v2/errors/gerror"
const (
Console = "console"
MiniProgram = "mini_program"
API = "api"
System = "system"
Legacy = "legacy"
Unknown = 0
Single = 1
Bulk = 2
)
// NormalizeMode validates the explicit execution size supplied by the business entry point.
// Execution size is request metadata and must not be inferred from context or result counts.
func NormalizeMode(requested int) int {
if requested == Single || requested == Bulk {
return requested
}
return Unknown
}
func ValidChannel(value string) bool {
switch value {
case Console, MiniProgram, API, System, Legacy:
return true
}
return false
}
func Channel(value string) string {
if ValidChannel(value) {
return value
}
return Legacy
}
func ValidMode(value int) bool { return value >= Unknown && value <= Bulk }
// ValidateCreation rejects missing attribution for new work; historical reads
// and resumed work continue to use Channel/NormalizeMode for compatibility.
func ValidateCreation(channel string, mode int) error {
if !ValidChannel(channel) || channel == Legacy {
return gerror.New("新建批次发起渠道无效")
}
if mode != Single && mode != Bulk {
return gerror.New("新建批次必须指定单卡或批量操作规模")
}
return nil
}
@@ -0,0 +1,52 @@
package batchmetadata
import "testing"
func TestValidateCreation(t *testing.T) {
for _, channel := range []string{Console, MiniProgram, API, System} {
for _, mode := range []int{Single, Bulk} {
if err := ValidateCreation(channel, mode); err != nil {
t.Fatal(channel, mode, err)
}
}
}
for _, channel := range []string{"", Legacy} {
if err := ValidateCreation(channel, Single); err == nil {
t.Fatal("missing new batch attribution accepted", channel)
}
}
if ValidateCreation("worker", Single) == nil {
t.Fatal("invalid source accepted")
}
for _, mode := range []int{-1, Unknown, 3} {
if ValidateCreation(Console, mode) == nil {
t.Fatal("invalid new execution size accepted", mode)
}
}
}
func TestMetadata(t *testing.T) {
if NormalizeMode(0) != Unknown {
t.Fatal("unspecified entry mode must remain unknown")
}
if NormalizeMode(Single) != Single {
t.Fatal("single entry mode lost")
}
if NormalizeMode(Bulk) != Bulk {
t.Fatal("bulk entry mode lost")
}
if NormalizeMode(99) != Unknown {
t.Fatal("invalid entry mode accepted")
}
for _, c := range []string{Console, MiniProgram, API, System, Legacy} {
if Channel(c) != c {
t.Fatal(c)
}
}
if Channel("worker") != Legacy || Channel("") != Legacy {
t.Fatal("unknown source was attributed")
}
if ValidMode(-1) || ValidMode(3) {
t.Fatal("invalid mode accepted")
}
}
@@ -0,0 +1,6 @@
// Package bcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package bcache
@@ -0,0 +1,77 @@
// Package bcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package bcache
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/cache"
"time"
"github.com/gogf/gf/v2/frame/g"
)
// 设置系统认证token缓存
func SetSysAuthToken(ctx context.Context, userId int64, uuid string, token string, timeout time.Duration) (err error) {
key := fmt.Sprintf(consts.KeySysAuthToken, userId, uuid)
err = cache.Instance().Set(ctx, key, token, timeout)
if err != nil {
return err
}
return nil
}
// 获取系统认证token缓存
func GetSysAuthToken(ctx context.Context, userId int64, uuid string) (token string, err error) {
key := fmt.Sprintf(consts.KeySysAuthToken, userId, uuid)
t, err := cache.Instance().Get(ctx, key)
if err != nil {
g.Log().Errorf(ctx, "bcache.GetSysAuthToken Get failed,error: %v, userId: %d, uuid: %s, key: %s", err, userId, uuid, key)
return "", err
}
return t.String(), nil
}
// 删除系统认证token缓存
func DelSysAuthToken(ctx context.Context, userId int64, uuid string) (err error) {
key := fmt.Sprintf(consts.KeySysAuthToken, userId, uuid)
_, err = cache.Instance().Remove(ctx, key)
if err != nil {
return err
}
return nil
}
// 设置系统认证token拒绝缓存
func SetSysAuthTokenReject(ctx context.Context, userId int64, uuid string, token string, timeout time.Duration) (err error) {
key := fmt.Sprintf(consts.KeySysAuthTokenReject, userId, uuid)
err = cache.Instance().Set(ctx, key, token, timeout)
if err != nil {
return err
}
return nil
}
// 获取系统认证token拒绝缓存
func GetSysAuthTokenReject(ctx context.Context, userId int64, uuid string) (token string, err error) {
key := fmt.Sprintf(consts.KeySysAuthTokenReject, userId, uuid)
t, err := cache.Instance().Get(ctx, key)
if err != nil {
return "", err
}
return t.String(), nil
}
// 删除系统认证token拒绝缓存
func DelSysAuthTokenReject(ctx context.Context, userId int64, uuid string) (err error) {
key := fmt.Sprintf(consts.KeySysAuthTokenReject, userId, uuid)
_, err = cache.Instance().Remove(ctx, key)
if err != nil {
return err
}
return nil
}
@@ -0,0 +1,30 @@
package bcache
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"github.com/gogf/gf/v2/frame/g"
)
// 存储mfa验证码 KeyUserMfaCode:{uuid}
func SetUserMfaCode(ctx context.Context, uuid string, code string) error {
mfaKey := fmt.Sprintf(consts.KeyUserMfaCode, uuid)
_, err := g.Redis().Set(ctx, mfaKey, code)
return err
}
// 获取mfa验证码 KeyUserMfaCode:{uuid}
func GetUserMfaCode(ctx context.Context, uuid string) (string, error) {
mfaKey := fmt.Sprintf(consts.KeyUserMfaCode, uuid)
code, err := g.Redis().Get(ctx, mfaKey)
return code.String(), err
}
// 删除mfa验证码 KeyUserMfaCode:{uuid}
func DeleteUserMfaCode(ctx context.Context, uuid string) error {
mfaKey := fmt.Sprintf(consts.KeyUserMfaCode, uuid)
_, err := g.Redis().Del(ctx, mfaKey)
return err
}
+60
View File
@@ -0,0 +1,60 @@
// Package cache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package cache
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/cache/file"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/os/gfile"
)
// cache 缓存驱动
var cacheVal *gcache.Cache
// Instance 缓存实例
func Instance() *gcache.Cache {
if cacheVal == nil {
panic("cache uninitialized.")
}
return cacheVal
}
// SetAdapter 设置缓存适配器
func SetAdapter(ctx context.Context) {
var cacheAdapter gcache.Adapter
adapter := g.Cfg().MustGet(ctx, "cache.adapter").String()
fileDir := g.Cfg().MustGet(ctx, "cache.fileDir").String()
switch adapter {
case "redis":
cacheAdapter = gcache.NewAdapterRedis(g.Redis())
case "file":
if fileDir == "" {
g.Log().Fatal(ctx, "file path must be configured for file caching.")
return
}
if !gfile.Exists(fileDir) {
if err := gfile.Mkdir(fileDir); err != nil {
g.Log().Fatalf(ctx, "failed to create the cache directory. procedure, err:%+v", err)
return
}
}
cacheAdapter = file.NewAdapterFile(fileDir)
default:
cacheAdapter = gcache.NewAdapterMemory()
}
// 数据库缓存,默认和通用缓冲驱动一致,如果你不想使用默认的,可以自行调整
g.DB().GetCache().SetAdapter(cacheAdapter)
// 通用缓存
cacheVal = gcache.New()
cacheVal.SetAdapter(cacheAdapter)
}
+440
View File
@@ -0,0 +1,440 @@
// Package file
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package file
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/os/gcache"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/util/gconv"
)
type (
// AdapterFile gcache适配器使用文件的实现
AdapterFile struct {
dir string
mutex sync.RWMutex // 添加互斥锁以支持并发操作
}
fileContent struct {
Duration int64 `json:"duration"`
Data interface{} `json:"data,omitempty"`
}
)
const perm = 0o666 //设置文件所有者、同用户组成员和其他用户都可以读写该文件,但都不能执。
var (
CacheExpiredErr = errors.New("cache expired")
)
// NewAdapterFile creates and returns a new memory cache object.
func NewAdapterFile(dir string) gcache.Adapter {
return &AdapterFile{
dir: dir,
}
}
func (c *AdapterFile) Set(ctx context.Context, key interface{}, value interface{}, lifeTime time.Duration) (err error) {
c.mutex.Lock() // 加锁
defer c.mutex.Unlock() // 解锁
fileKey := gconv.String(key)
if value == nil || lifeTime < 0 {
return c.Delete(fileKey)
}
return c.Save(fileKey, gconv.String(value), lifeTime)
}
// SetMap 批量设置多个键值对,并且每个键值对的有效期是一样的
func (c *AdapterFile) SetMap(ctx context.Context, data map[interface{}]interface{}, duration time.Duration) (err error) {
c.mutex.Lock()
defer c.mutex.Unlock()
for key, value := range data {
fileKey := gconv.String(key)
if err = c.Save(fileKey, gconv.String(value), duration); err != nil {
return err
}
}
return nil
}
// SetIfNotExist 在指定的键不存在时设置其值
func (c *AdapterFile) SetIfNotExist(ctx context.Context, key interface{}, value interface{}, duration time.Duration) (ok bool, err error) {
c.mutex.Lock()
defer c.mutex.Unlock()
fileKey := gconv.String(key)
if c.Has(fileKey) {
return false, nil
}
err = c.Save(fileKey, gconv.String(value), duration)
return true, err
}
// SetIfNotExistFunc 在指定的键不存在时,通过一个函数来生成值并设置它
func (c *AdapterFile) SetIfNotExistFunc(ctx context.Context, key interface{}, f gcache.Func, duration time.Duration) (ok bool, err error) {
c.mutex.Lock()
defer c.mutex.Unlock()
fileKey := gconv.String(key)
if c.Has(fileKey) {
return false, nil
}
value, err := f(ctx)
if err != nil {
return false, err
}
err = c.Save(fileKey, gconv.String(value), duration)
return true, err
}
// SetIfNotExistFuncLock 与 SetIfNotExistFunc 类似,但它在调用生成值的函数时提供了额外的锁机制,以避免在生成值期间的并发问题。
func (c *AdapterFile) SetIfNotExistFuncLock(ctx context.Context, key interface{}, f gcache.Func, duration time.Duration) (ok bool, err error) {
c.mutex.Lock()
defer c.mutex.Unlock()
fileKey := gconv.String(key)
if c.Has(fileKey) {
return false, nil
}
// 在这里加锁是为了确保在值生成期间不会有并发的写入操作
value, err := f(ctx)
if err != nil {
return false, err
}
err = c.Save(fileKey, gconv.String(value), duration)
return true, err
}
func (c *AdapterFile) Get(ctx context.Context, key interface{}) (*gvar.Var, error) {
fetch, err := c.Fetch(gconv.String(key))
if err != nil {
return nil, err
}
return gvar.New(fetch), nil
}
func (c *AdapterFile) GetOrSet(ctx context.Context, key interface{}, value interface{}, duration time.Duration) (result *gvar.Var, err error) {
result, err = c.Get(ctx, key)
if err != nil && !errors.Is(err, CacheExpiredErr) {
return nil, err
}
if result.IsNil() {
return gvar.New(value), c.Set(ctx, key, value, duration)
}
return
}
func (c *AdapterFile) GetOrSetFunc(ctx context.Context, key interface{}, f gcache.Func, duration time.Duration) (result *gvar.Var, err error) {
v, err := c.Get(ctx, key)
if err != nil && !errors.Is(err, CacheExpiredErr) {
return nil, err
}
if v.IsNil() {
value, err := f(ctx)
if err != nil {
return nil, err
}
if value == nil {
return nil, nil
}
return gvar.New(value), c.Set(ctx, key, value, duration)
} else {
return v, nil
}
}
func (c *AdapterFile) GetOrSetFuncLock(ctx context.Context, key interface{}, f gcache.Func, duration time.Duration) (result *gvar.Var, err error) {
return c.GetOrSetFunc(ctx, key, f, duration)
}
func (c *AdapterFile) Contains(ctx context.Context, key interface{}) (bool, error) {
return c.Has(gconv.String(key)), nil
}
func (c *AdapterFile) Size(ctx context.Context) (size int, err error) {
return 0, nil
}
// Data 获取所有缓存的键值对
func (c *AdapterFile) Data(ctx context.Context) (data map[interface{}]interface{}, err error) {
c.mutex.RLock()
defer c.mutex.RUnlock()
// 初始化返回的数据结构
data = make(map[interface{}]interface{})
// 读取文件目录中的所有缓存文件
files, err := os.ReadDir(c.dir)
if err != nil {
return nil, err
}
// 遍历缓存文件,提取数据
for _, file := range files {
content, err := c.read(file.Name())
if err != nil {
continue // 忽略无法读取的文件
}
data[file.Name()] = content.Data
}
return data, nil
}
// Keys 获取所有缓存的键
func (c *AdapterFile) Keys(ctx context.Context) (keys []interface{}, err error) {
c.mutex.RLock()
defer c.mutex.RUnlock()
files, err := os.ReadDir(c.dir)
if err != nil {
return nil, err
}
keys = make([]interface{}, 0, len(files))
for _, file := range files {
keys = append(keys, file.Name())
}
return keys, nil
}
// Values 获取所有缓存的值
func (c *AdapterFile) Values(ctx context.Context) (values []interface{}, err error) {
c.mutex.RLock()
defer c.mutex.RUnlock()
files, err := os.ReadDir(c.dir)
if err != nil {
return nil, err
}
values = make([]interface{}, 0, len(files))
for _, file := range files {
content, err := c.read(file.Name())
if err != nil {
continue // 忽略无法读取的文件
}
values = append(values, content.Data)
}
return values, nil
}
// Update 更新一个指定键的缓存值,如果这个键不存在,则返回错误
func (c *AdapterFile) Update(ctx context.Context, key interface{}, value interface{}) (oldValue *gvar.Var, exist bool, err error) {
c.mutex.Lock()
defer c.mutex.Unlock()
fileKey := gconv.String(key)
content, err := c.read(fileKey)
if err != nil {
return nil, false, err
}
if content == nil {
return nil, false, nil
}
oldValue = gvar.New(content.Data)
err = c.Save(fileKey, gconv.String(value), time.Duration(content.Duration)*time.Second)
return oldValue, true, err
}
func (c *AdapterFile) UpdateExpire(ctx context.Context, key interface{}, duration time.Duration) (oldDuration time.Duration, err error) {
var (
v *gvar.Var
oldTTL int64
fileKey = gconv.String(key)
)
// TTL.
expire, err := c.GetExpire(ctx, fileKey)
if err != nil {
return
}
oldTTL = int64(expire)
if oldTTL == -2 {
// It does not exist.
return
}
oldDuration = time.Duration(oldTTL) * time.Second
// DEL.
if duration < 0 {
err = c.Delete(fileKey)
return
}
v, err = c.Get(ctx, fileKey)
if err != nil {
return
}
err = c.Set(ctx, fileKey, v.Val(), duration)
return
}
func (c *AdapterFile) GetExpire(ctx context.Context, key interface{}) (time.Duration, error) {
content, err := c.read(gconv.String(key))
if err != nil {
return -1, nil
}
if content.Duration <= time.Now().Unix() {
return -1, nil
}
return time.Duration(time.Now().Unix()-content.Duration) * time.Second, nil
}
func (c *AdapterFile) Remove(ctx context.Context, keys ...interface{}) (lastValue *gvar.Var, err error) {
if len(keys) == 0 {
return nil, nil
}
// Retrieves the last key value.
if lastValue, err = c.Get(ctx, gconv.String(keys[len(keys)-1])); err != nil {
return nil, err
}
// Deletes all given keys.
err = c.DeleteMulti(gconv.Strings(keys)...)
return
}
func (c *AdapterFile) Clear(ctx context.Context) error {
return c.Flush()
}
func (c *AdapterFile) Close(ctx context.Context) error {
return nil
}
func (c *AdapterFile) createName(key string) string {
h := sha256.New()
_, _ = h.Write([]byte(key))
hash := hex.EncodeToString(h.Sum(nil))
return filepath.Join(c.dir, fmt.Sprintf("%s.cache", hash))
}
func (c *AdapterFile) read(key string) (*fileContent, error) {
rp := gfile.RealPath(c.createName(key))
if rp == "" {
return nil, nil
}
value, err := os.ReadFile(rp)
if err != nil {
return nil, err
}
content := &fileContent{}
if err := json.Unmarshal(value, content); err != nil {
return nil, err
}
if content.Duration == 0 {
return content, nil
}
if content.Duration <= time.Now().Unix() {
_ = c.Delete(key)
return nil, CacheExpiredErr
}
return content, nil
}
// Has checks if the cached key exists into the File storage
func (c *AdapterFile) Has(key string) bool {
fc, err := c.read(key)
return err == nil && fc != nil
}
// Delete the cached key from File storage
func (c *AdapterFile) Delete(key string) error {
_, err := os.Stat(c.createName(key))
if err != nil && os.IsNotExist(err) {
return nil
}
return os.Remove(c.createName(key))
}
// DeleteMulti the cached key from File storage
func (c *AdapterFile) DeleteMulti(keys ...string) (err error) {
for _, key := range keys {
if err = c.Delete(key); err != nil {
return
}
}
return
}
// Fetch retrieves the cached value from key of the File storage
func (c *AdapterFile) Fetch(key string) (interface{}, error) {
content, err := c.read(key)
if err != nil {
return nil, err
}
if content == nil {
return nil, nil
}
return content.Data, nil
}
// FetchMulti retrieve multiple cached values from keys of the File storage
func (c *AdapterFile) FetchMulti(keys []string) map[string]interface{} {
result := make(map[string]interface{})
for _, key := range keys {
if value, err := c.Fetch(key); err == nil {
result[key] = value
}
}
return result
}
// Flush removes all cached keys of the File storage
func (c *AdapterFile) Flush() error {
dir, err := os.Open(c.dir)
if err != nil {
return err
}
defer func() {
_ = dir.Close()
}()
names, _ := dir.Readdirnames(-1)
for _, name := range names {
_ = os.Remove(filepath.Join(c.dir, name))
}
return nil
}
// Save a value in File storage by key
func (c *AdapterFile) Save(key string, value string, lifeTime time.Duration) error {
duration := int64(0)
if lifeTime > 0 {
duration = time.Now().Unix() + int64(lifeTime.Seconds())
}
content := &fileContent{duration, value}
data, err := json.Marshal(content)
if err != nil {
return err
}
err = os.WriteFile(c.createName(key), data, perm)
return err
}
@@ -0,0 +1,199 @@
// Package contexts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package contexts
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"slices"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
type ContextKey struct {
name string
}
var contextKey = &ContextKey{
name: consts.ContextKey,
}
// Init初始化上下文对象指针到上下文对象中,以便后续的请求流程中可以修改。
func Init(r *ghttp.Request, customCtx *model.Context) {
r.SetCtxVar(contextKey, customCtx)
}
// Set设置本地上下文
func Set(ctx context.Context, customCtx *model.Context) context.Context {
ctx = context.WithValue(ctx, contextKey, customCtx)
return ctx
}
// Get 获取上下文变量;
func Get(ctx context.Context) *model.Context {
customCtx, ok := ctx.Value(contextKey).(*model.Context)
if !ok {
return nil
}
return customCtx
}
// Set 设置上下文变量
func SetUser(ctx context.Context, user *model.Identity) {
customCtx := Get(ctx)
customCtx.User = user
}
// GetUser 获取上下文变量中的用户信息
func GetUser(ctx context.Context) *model.Identity {
customCtx := Get(ctx)
if customCtx == nil {
return nil
}
return customCtx.User
}
// GetUserId 获取上下文变量中的用户ID
func GetUserId(ctx context.Context) int64 {
user := GetUser(ctx)
if user == nil {
return 0
}
return user.ID
}
// GetUserUuid 获取上下文变量中的用户UUID
func GetUserUuid(ctx context.Context) string {
user := GetUser(ctx)
if user == nil {
return ""
}
return user.UUID
}
// GetUserName 获取上下文变量中的用户名
func GetUserName(ctx context.Context) string {
user := GetUser(ctx)
if user == nil {
return ""
}
return user.Username
}
// GetNickName 获取上下文变量中的用户昵称
func GetNickName(ctx context.Context) string {
user := GetUser(ctx)
if user == nil {
return ""
}
return user.NickName
}
// GetDeptId 获取上下文变量中的部门ID
func GetDeptId(ctx context.Context) int64 {
user := GetUser(ctx)
if user == nil {
return 0
}
g.Log().Debug(ctx, user.DeptId)
return user.DeptId
}
// GetTenantId 获取上下文变量中的租户ID
func GetTenantId(ctx context.Context) string {
user := GetUser(ctx)
if user == nil {
return ""
}
return user.TenantId
}
// GetRoles 获取上下文变量中的角色列表
func GetRoles(ctx context.Context) []model.Role {
user := GetUser(ctx)
if user == nil {
return nil
}
return user.Roles
}
// GetLoginAt 获取上下文变量中的登录时间
func GetLoginAt(ctx context.Context) int64 {
user := GetUser(ctx)
if user == nil {
return 0
}
return user.LoginAt
}
// IsSystemTenant 判断是否是系统租户
func IsSystemTenant(ctx context.Context) bool {
tenantId := GetTenantId(ctx)
return tenantId == consts.DefaultSystemTenantCode
}
// IsSuperAdmin 判断是否是超级管理员角色
func IsSuperAdmin(ctx context.Context) bool {
roles := GetRoles(ctx)
return slices.ContainsFunc(roles, func(role model.Role) bool {
return role.RoleId == consts.SuperAdminRoleId
})
}
func IsWebSocket(ctx context.Context) bool {
customCtx := Get(ctx)
if customCtx == nil {
return false
}
return customCtx.IsWebSocket
}
func SetIsWebSocket(ctx context.Context, isWebSocket bool) {
customCtx := Get(ctx)
if customCtx == nil {
return
}
customCtx.IsWebSocket = isWebSocket
g.Log().Debugf(ctx, "SetIsWebSocket: %v", isWebSocket)
}
// GetAccessCodeList 获取上下文变量中的访问码列表
func GetAccessCodeList(ctx context.Context) []string {
user := GetUser(ctx)
if user == nil {
return nil
}
if len(user.AccessCodeList) == 0 {
return nil
}
return user.AccessCodeList
}
func GetDataValue(ctx context.Context, key string) interface{} {
customCtx := Get(ctx)
if customCtx == nil {
return nil
}
if customCtx.Data == nil {
return nil
}
value, ok := customCtx.Data[key]
if !ok {
return nil
}
return value
}
func SetDataValue(ctx context.Context, key string, value interface{}) {
customCtx := Get(ctx)
if customCtx == nil {
return
}
if customCtx.Data == nil {
customCtx.Data = make(g.Map)
}
customCtx.Data[key] = value
}
@@ -0,0 +1,75 @@
// Package event
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package event
import (
"context"
"sync"
)
type EventCallbackFunc func(ctx context.Context, eventKey string, args ...interface{})
type sEvent struct {
sync.Mutex
eventList map[string][]EventCallbackFunc // 事件列表 [事件Key]:回调函数
}
var (
instance *sEvent
once sync.Once
)
// EventsInstance 事件实例
func EventsInstance() *sEvent {
once.Do(func() {
instance = &sEvent{
eventList: make(map[string][]EventCallbackFunc),
}
})
return instance
}
// Register 注册事件
func (e *sEvent) Register(eventKey interface{}, callback EventCallbackFunc) {
e.Lock()
defer e.Unlock()
eventKeyStr, ok1 := eventKey.(string)
eventKeyList, ok2 := eventKey.([]string)
if !ok1 && !ok2 {
return
}
if ok1 {
e.eventList[eventKeyStr] = append(e.eventList[eventKeyStr], callback)
} else {
for _, eventKey := range eventKeyList {
e.eventList[eventKey] = append(e.eventList[eventKey], callback)
}
}
}
// Call 回调事件
func (e *sEvent) Call(ctx context.Context, eventKey string, args ...interface{}) {
if events, ok := e.eventList[eventKey]; ok {
for _, f := range events {
f(ctx, eventKey, args...)
}
}
}
// Emit 触发事件
func (e *sEvent) Emit(ctx context.Context, eventKey string, args ...interface{}) {
e.Call(ctx, eventKey, args...)
}
// Remove 删除事件
func (e *sEvent) Remove(eventKey string) {
delete(e.eventList, eventKey)
}
// Clear 清空事件列表
func (e *sEvent) Clear() {
e.eventList = make(map[string][]EventCallbackFunc)
}
@@ -0,0 +1,130 @@
package exportcenter
import (
"context"
"os"
"github.com/gogf/gf/v2/errors/gerror"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
)
const (
cleanupBatchSize = 200
cleanupInterval = 5 * time.Minute
)
// RemoveTaskFile 清理文件与OSS记录,保留任务历史。
// 文件删除不可事务回滚;数据库提交失败时保留索引,下次按文件不存在幂等重试。
func RemoveTaskFile(ctx context.Context, taskID int64, reason string, validate func(*entity.TExportTask) error) (affected int64, err error) {
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
columns := dao.TExportTask.Columns()
task := &entity.TExportTask{}
if err := dao.TExportTask.Ctx(ctx).TX(tx).Where(columns.Id, taskID).LockUpdate().Scan(task); err != nil {
return err
}
if task.Id == 0 {
return gerror.New("导出任务不存在")
}
if err := validate(task); err != nil {
return err
}
if task.DeletedTime != nil {
return nil
}
if task.OssId > 0 {
oss := &entity.SysOss{}
if err := dao.SysOss.Ctx(ctx).TX(tx).Where(dao.SysOss.Columns().OssId, task.OssId).LockUpdate().Scan(oss); err != nil {
return err
}
if oss.OssId > 0 {
if info, statErr := os.Stat(oss.Path); statErr == nil {
if !info.Mode().IsRegular() {
return gerror.New("导出文件路径不是普通文件")
}
if err := os.Remove(oss.Path); err != nil && !os.IsNotExist(err) {
return err
}
} else if !os.IsNotExist(statErr) {
return statErr
}
if _, err := dao.SysOss.Ctx(ctx).TX(tx).Where(dao.SysOss.Columns().OssId, oss.OssId).Delete(); err != nil {
return err
}
}
}
var updateErr error
affected, updateErr = dao.TExportTask.Ctx(ctx).TX(tx).Where(columns.Id, task.Id).WhereNull(columns.DeletedTime).
Data(g.Map{columns.DeletedTime: gtime.Now(), columns.DeleteReason: reason, columns.OssId: 0}).UpdateAndGetAffected()
if updateErr != nil {
return updateErr
}
if affected != 1 {
return gerror.New("任务状态已变更,请刷新后重试")
}
return nil
})
return affected, err
}
// StartCleanup 每五分钟清理过期任务和失败任务残留文件。
func StartCleanup(ctx context.Context) {
go func() {
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
cleanupOnce(gctx.New())
}
}
}()
}
func cleanupOnce(ctx context.Context) {
var lastID int64
for {
tasks := make([]*entity.TExportTask, 0, cleanupBatchSize)
if err := dao.TExportTask.Ctx(ctx).
WhereNull(dao.TExportTask.Columns().DeletedTime).
WhereIn(dao.TExportTask.Columns().Status, []int{consts.DownloadStatusGenerated, consts.DownloadStatusFailed}).
WhereLT(dao.TExportTask.Columns().ExpireTime, gtime.Now()).
WhereGT(dao.TExportTask.Columns().Id, lastID).
OrderAsc(dao.TExportTask.Columns().Id).
Limit(cleanupBatchSize).Scan(&tasks); err != nil {
g.Log().Errorf(ctx, "[exportcenter] 查询过期导出任务失败: %v", err)
return
}
progress := false
for _, task := range tasks {
affected, err := RemoveTaskFile(ctx, task.Id, "expired", func(locked *entity.TExportTask) error {
if (locked.Status != consts.DownloadStatusGenerated && locked.Status != consts.DownloadStatusFailed) || locked.ExpireTime == nil || !locked.ExpireTime.Before(gtime.Now()) {
return gerror.New("任务尚未过期或状态已变更")
}
return nil
})
if err != nil {
g.Log().Warningf(ctx, "[exportcenter] 清理过期任务失败 taskNo=%s err=%v", task.TaskNo, err)
} else if affected > 0 {
progress = true
}
}
if len(tasks) < cleanupBatchSize {
return
}
nextID := tasks[len(tasks)-1].Id
if !progress && nextID <= lastID {
return
}
lastID = nextID
}
}
@@ -0,0 +1,335 @@
package exportcenter
import (
"context"
"reflect"
"strings"
"sync"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/util/gconv"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
)
// fieldList 导出列字段名清单(json tag)。
type fieldList []string
// columnsFromRequest 从当前请求里读取导出列清单。
// 前端统一传 exportColumnsJson(JSON 字符串):导出接口多为 x-www-form-urlencoded,
// 数组套对象会在表单编码时被拆散,字符串形式不会。
// 这样各导出无需改参数结构即可支持「按页面显示列导出」。
func columnsFromRequest(ctx context.Context) []model.ExportColumn {
r := ghttp.RequestFromCtx(ctx)
if r == nil {
return nil
}
if raw := strings.TrimSpace(r.Get("exportColumnsJson").String()); raw != "" {
cols := make([]model.ExportColumn, 0)
if err := gjson.New(raw).Scan(&cols); err == nil && len(cols) > 0 {
return cols
}
}
// 兼容直接传数组的调用(如 JSON 请求体)
if v := r.Get("exportColumns"); v != nil && !v.IsNil() {
cols := make([]model.ExportColumn, 0)
if err := gconv.Structs(v.Interface(), &cols); err == nil && len(cols) > 0 {
return cols
}
}
return nil
}
// resolveColumns 生成表头。
// 有列清单时按清单顺序取 label 作表头,并按 field 逐行取值;
// 没有列清单时沿用调用方给的 Tags(兼容未改造的导出)。
func resolveColumns(req *Request) (header []interface{}, fields fieldList, err error) {
for _, c := range req.Columns {
field := strings.TrimSpace(c.Field)
if field == "" {
continue
}
label := strings.TrimSpace(c.Label)
if label == "" {
label = field
}
fields = append(fields, field)
header = append(header, label)
}
if len(fields) > 0 {
// 自定义取值的导出(ToRow)要按 RowFields 取列,字段名对不上会静默出空列,这里留一条日志。
if req.ToRow != nil && len(req.RowFields) > 0 {
if missing := missingRowFields(fields, req.RowFields); len(missing) > 0 {
g.Log().Warningf(context.Background(),
"[exportcenter] 导出列在 RowFields 中不存在,将输出空列 biz=%s fields=%v", req.BizKey, missing)
}
}
return header, fields, nil
}
tags, err := req.Tags()
if err != nil {
return nil, nil, err
}
header = make([]interface{}, len(tags))
for i, v := range tags {
header[i] = v
}
return header, nil, nil
}
// rowValues 按列清单取值;无列清单时保持原有转换方式。
func rowValues(v interface{}, fields fieldList, req *Request) []interface{} {
if len(fields) > 0 {
// 自定义取值的导出(ToRow)按 RowFields 声明的位置取列,避免绕过原有格式化。
if req.ToRow != nil && len(req.RowFields) > 0 {
values := pickRowFields(req.ToRow(v), req.RowFields, fields)
i := 0
for _, column := range req.Columns {
if strings.TrimSpace(column.Field) == "" {
continue
}
// 字典列/固定值映射列始终取列表模型的原值,避免对 ToRow 已翻译的中文再次查 key。
if (column.DictType != "" || len(column.ValueMap) > 0) && v != nil {
values[i] = valuesByFields(v, fieldList{column.Field})[0]
}
i++
}
return values
}
values := valuesByFields(v, fields)
i := 0
for _, column := range req.Columns {
if strings.TrimSpace(column.Field) == "" {
continue
}
if column.FallbackField != "" && (values[i] == nil || gconv.String(values[i]) == "") {
values[i] = valuesByFields(v, fieldList{column.FallbackField})[0]
}
i++
}
return values
}
if req.ToRow != nil {
return req.ToRow(v)
}
return structValues(v)
}
// loadColumnDicts 每个任务一次性加载列表列使用的字典。
func loadColumnDicts(ctx context.Context, columns []model.ExportColumn, load func(context.Context, []string) ([]model.SysDictDataListModel, error)) (map[string]map[string]string, error) {
types := make([]string, 0)
seen := make(map[string]bool)
for _, column := range columns {
typ := strings.TrimSpace(column.DictType)
if strings.TrimSpace(column.Field) != "" && typ != "" && !seen[typ] {
seen[typ] = true
types = append(types, typ)
}
}
result := make(map[string]map[string]string)
if len(types) == 0 {
return result, nil
}
items, err := load(ctx, types)
if err != nil {
return nil, err
}
for _, item := range items {
if result[item.DictType] == nil {
result[item.DictType] = make(map[string]string)
}
result[item.DictType][item.DictValue] = item.DictLabel
}
return result, nil
}
func translateRow(values []interface{}, columns []model.ExportColumn, dicts map[string]map[string]string) {
i := 0
for _, column := range columns {
if strings.TrimSpace(column.Field) == "" {
continue
}
if i >= len(values) {
return
}
value := values[i]
str := ""
if value != nil {
str = gconv.String(value)
}
// 固定值映射优先:页面用三元表达式渲染的列(非字典)直接出显示文案。
if len(column.ValueMap) > 0 {
if mapped, ok := column.ValueMap[str]; ok {
values[i] = mapped
i++
continue
}
if column.ValueDefault != "" {
values[i] = column.ValueDefault
i++
continue
}
}
if str != "" {
key := str
if normalized, ok := column.DictValueMap[key]; ok {
key = normalized
}
if label, ok := dicts[strings.TrimSpace(column.DictType)][key]; ok {
values[i] = label
}
}
i++
}
}
// missingRowFields 找出列清单里 RowFields 无法提供的字段。
func missingRowFields(fields fieldList, rowFields []string) []string {
known := make(map[string]bool, len(rowFields))
for _, name := range rowFields {
known[name] = true
}
missing := make([]string, 0)
for _, name := range fields {
if !known[name] {
missing = append(missing, name)
}
}
return missing
}
// pickRowFields 从自定义行数据里按字段名取列:row 与 rowFields 顺序一一对应。
func pickRowFields(row []interface{}, rowFields []string, fields fieldList) []interface{} {
index := make(map[string]int, len(rowFields))
for i, name := range rowFields {
index[name] = i
}
out := make([]interface{}, len(fields))
for i, name := range fields {
pos, ok := index[name]
if !ok || pos < 0 || pos >= len(row) {
out[i] = ""
continue
}
out[i] = row[pos]
}
return out
}
// jsonFieldIndexCache 缓存「结构体类型 -> json tag 到字段下标的映射」,
// 导出大结果集时避免每行重复解析 tag。
var jsonFieldIndexCache sync.Map
func jsonFieldIndexes(t reflect.Type) map[string]int {
if cached, ok := jsonFieldIndexCache.Load(t); ok {
return cached.(map[string]int)
}
indexes := make(map[string]int, t.NumField())
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.PkgPath != "" { // 未导出字段
continue
}
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name == "" || name == "-" {
continue
}
if _, exists := indexes[name]; !exists {
indexes[name] = i
}
}
jsonFieldIndexCache.Store(t, indexes)
return indexes
}
// valuesByFields 按字段名(json tag)从结构体取值,缺失字段留空。
// 支持 "createdBySumma.name" 这类嵌套路径(列表页常按嵌套字段显示)。
func valuesByFields(v interface{}, fields fieldList) []interface{} {
out := make([]interface{}, len(fields))
rv := reflect.ValueOf(v)
for rv.IsValid() && rv.Kind() == reflect.Ptr {
if rv.IsNil() {
return out
}
rv = rv.Elem()
}
if !rv.IsValid() || rv.Kind() != reflect.Struct {
return out
}
for i, name := range fields {
if value, ok := structFieldValue(rv, name); ok {
out[i] = exportCellValue(value)
} else {
out[i] = ""
}
}
return out
}
// structFieldValue 按 "a.b.c" 的 json tag 路径逐层取值。
func structFieldValue(rv reflect.Value, path string) (reflect.Value, bool) {
cur := rv
for _, name := range strings.Split(path, ".") {
for cur.IsValid() {
if (cur.Kind() == reflect.Ptr || cur.Kind() == reflect.Interface) && cur.IsNil() {
return reflect.Value{}, false
}
if cur.CanInterface() {
if object, ok := cur.Interface().(interface{ Map() map[string]interface{} }); ok {
cur = reflect.ValueOf(object.Map())
break
}
}
if cur.Kind() != reflect.Ptr && cur.Kind() != reflect.Interface {
break
}
cur = cur.Elem()
}
if !cur.IsValid() {
return reflect.Value{}, false
}
if cur.Kind() == reflect.Map && cur.Type().Key().Kind() == reflect.String {
cur = cur.MapIndex(reflect.ValueOf(name))
if !cur.IsValid() {
return reflect.Value{}, false
}
continue
}
if cur.Kind() != reflect.Struct {
return reflect.Value{}, false
}
pos, ok := jsonFieldIndexes(cur.Type())[name]
if !ok {
return reflect.Value{}, false
}
cur = cur.Field(pos)
}
return cur, true
}
// exportCellValue 把字段值转成可写入单元格的值。
// 指针为空、时间、嵌套结构都要处理,避免把内存地址写进 Excel。
func exportCellValue(v reflect.Value) interface{} {
for v.IsValid() && v.Kind() == reflect.Ptr {
if v.IsNil() {
return ""
}
v = v.Elem()
}
if !v.IsValid() || !v.CanInterface() {
return ""
}
switch v.Kind() {
case reflect.Struct, reflect.Map, reflect.Slice, reflect.Array:
// 时间等有 String() 的类型交给 gconv,其余复合类型同样转字符串。
if s, ok := v.Interface().(interface{ String() string }); ok {
return s.String()
}
return gconv.String(v.Interface())
default:
return v.Interface()
}
}
@@ -0,0 +1,268 @@
package exportcenter
import (
"context"
"errors"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/xuri/excelize/v2"
"reflect"
"strings"
"testing"
"time"
"github.com/gogf/gf/v2/os/gtime"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
)
func TestSubmitQueueBoundedBeforePersistence(t *testing.T) {
for len(slots) < cap(slots) {
slots <- struct{}{}
}
defer func() {
for len(slots) > 0 {
<-slots
}
}()
_, err := Submit(context.Background(), &Request{
BizKey: "test", BizName: "test", Tags: func() ([]string, error) { return nil, nil },
Fetch: func(context.Context, int, int) ([]interface{}, int, error) {
return []interface{}{1}, 1, nil
},
})
if err == nil || !strings.Contains(err.Error(), "排队已满") {
t.Fatalf("队列满时应拒绝提交: %v", err)
}
}
func TestColumnDictBatchAndExcel(t *testing.T) {
columns := []model.ExportColumn{{Field: "status", DictType: "enabled"}, {Field: "", DictType: "ignored"}, {Field: "other", DictType: "enabled"}, {Field: "inverse", DictType: "yes", DictValueMap: map[string]string{"0": "1", "1": "0"}}}
calls := 0
dicts, err := loadColumnDicts(context.Background(), columns, func(_ context.Context, types []string) ([]model.SysDictDataListModel, error) {
calls++
if !reflect.DeepEqual(types, []string{"enabled", "yes"}) {
t.Fatalf("字典未去重: %v", types)
}
return []model.SysDictDataListModel{{DictType: "enabled", DictValue: "0", DictLabel: "启用"}, {DictType: "enabled", DictValue: "1", DictLabel: "停用"}, {DictType: "yes", DictValue: "1", DictLabel: "否"}}, nil
})
if err != nil || calls != 1 {
t.Fatalf("批量加载失败: %v %d", err, calls)
}
for _, tc := range []struct{ input, want interface{} }{{0, "启用"}, {"0", "启用"}, {1, "停用"}, {"", ""}, {nil, nil}, {"未知", "未知"}, {"启用", "启用"}} {
values := []interface{}{tc.input, "保留", 0}
translateRow(values, columns, dicts)
if !reflect.DeepEqual(values, []interface{}{tc.want, "保留", "否"}) {
t.Fatalf("映射 %v 得到 %v", tc.input, values)
}
}
f := excelize.NewFile()
defer f.Close()
stream, err := f.NewStreamWriter("Sheet1")
if err != nil {
t.Fatal(err)
}
values := []interface{}{0, "未知", 0}
translateRow(values, columns, dicts)
if err = stream.SetRow("A1", values); err != nil {
t.Fatal(err)
}
if err = stream.Flush(); err != nil {
t.Fatal(err)
}
buffer, err := f.WriteToBuffer()
if err != nil {
t.Fatal(err)
}
read, err := excelize.OpenReader(buffer)
if err != nil {
t.Fatal(err)
}
defer read.Close()
rows, err := read.GetRows("Sheet1")
if err != nil || !reflect.DeepEqual(rows, [][]string{{"启用", "未知", "否"}}) {
t.Fatalf("xlsx 内容: %v %v", rows, err)
}
}
func TestColumnDictNoMetadataAndLoadFailure(t *testing.T) {
called := false
_, err := loadColumnDicts(context.Background(), nil, func(context.Context, []string) ([]model.SysDictDataListModel, error) { called = true; return nil, nil })
if called || err != nil {
t.Fatal("旧页面不应加载字典")
}
failure := errors.New("字典加载失败")
_, err = loadColumnDicts(context.Background(), []model.ExportColumn{{Field: "status", DictType: "x"}}, func(context.Context, []string) ([]model.SysDictDataListModel, error) { return nil, failure })
if !errors.Is(err, failure) {
t.Fatal("应传播字典加载错误")
}
}
func TestNestedDictFieldsAndFormattedRow(t *testing.T) {
row := struct {
BatchInfo *gjson.Json `json:"batchInfo"`
}{gjson.New(map[string]interface{}{"inboundOptions": map[string]interface{}{"status": 0}, "legacy": 1})}
columns := []model.ExportColumn{{Field: "batchInfo.inboundOptions.status"}, {Field: "batchInfo.inboundOptions.missing", FallbackField: "batchInfo.legacy"}}
req := &Request{Columns: columns}
_, fields, _ := resolveColumns(req)
values := rowValues(row, fields, req)
if !reflect.DeepEqual(values, []interface{}{0, 1}) {
t.Fatalf("JSON 嵌套与备用字段: %v", values)
}
req = &Request{Columns: []model.ExportColumn{{Field: "status", DictType: "x"}}, RowFields: []string{"status"}, ToRow: func(interface{}) []interface{} { return []interface{}{"已销户"} }}
values = rowValues(struct {
Status int `json:"status"`
}{9}, fieldList{"status"}, req)
translateRow(values, req.Columns, map[string]map[string]string{"x": {"9": "已销户"}})
if values[0] != "已销户" {
t.Fatalf("已中文行被改变: %v", values)
}
}
type columnSampleRow struct {
Iccid string `json:"iccid"`
DeptName string `json:"deptName"`
UsedFlowMb float64 `json:"usedFlowMb"`
ActiveAt *gtime.Time `json:"activeAt"`
Extra *string `json:"extra"`
}
func TestResolveColumnsUsesRequestedOrder(t *testing.T) {
req := &Request{
Columns: []model.ExportColumn{
{Field: "deptName", Label: "所属企业"},
{Field: "iccid"},
{Field: " ", Label: "空字段忽略"},
{Field: "usedFlowMb", Label: "已用流量(MB)"},
},
Tags: func() ([]string, error) {
t.Fatal("有列清单时不应回退到 Tags")
return nil, nil
},
}
header, fields, err := resolveColumns(req)
if err != nil {
t.Fatalf("resolveColumns error: %v", err)
}
wantHeader := []interface{}{"所属企业", "iccid", "已用流量(MB)"}
if len(header) != len(wantHeader) {
t.Fatalf("header len = %d, want %d", len(header), len(wantHeader))
}
for i := range wantHeader {
if header[i] != wantHeader[i] {
t.Fatalf("header[%d] = %v, want %v", i, header[i], wantHeader[i])
}
}
wantFields := fieldList{"deptName", "iccid", "usedFlowMb"}
if len(fields) != len(wantFields) {
t.Fatalf("fields len = %d, want %d", len(fields), len(wantFields))
}
for i := range wantFields {
if fields[i] != wantFields[i] {
t.Fatalf("fields[%d] = %q, want %q", i, fields[i], wantFields[i])
}
}
}
func TestResolveColumnsFallsBackToTags(t *testing.T) {
req := &Request{
Tags: func() ([]string, error) { return []string{"ICCID", "所属企业"}, nil },
}
header, fields, err := resolveColumns(req)
if err != nil {
t.Fatalf("resolveColumns error: %v", err)
}
if fields != nil {
t.Fatalf("fields should be nil when no columns, got %v", fields)
}
if len(header) != 2 || header[0] != "ICCID" || header[1] != "所属企业" {
t.Fatalf("unexpected header: %v", header)
}
}
func TestValuesByFields(t *testing.T) {
activeAt := gtime.NewFromTime(time.Date(2026, 10, 19, 15, 4, 5, 0, time.Local))
row := &columnSampleRow{
Iccid: "8986001",
DeptName: "测试企业",
UsedFlowMb: 12.5,
ActiveAt: activeAt,
}
values := valuesByFields(row, fieldList{"deptName", "missingField", "usedFlowMb", "activeAt", "extra"})
if len(values) != 5 {
t.Fatalf("values len = %d, want 5", len(values))
}
if values[0] != "测试企业" {
t.Fatalf("deptName = %v", values[0])
}
if values[1] != "" {
t.Fatalf("missing field should be empty, got %v", values[1])
}
if values[2] != 12.5 {
t.Fatalf("usedFlowMb = %v", values[2])
}
if values[3] != "2026-10-19 15:04:05" {
t.Fatalf("activeAt = %v", values[3])
}
if values[4] != "" {
t.Fatalf("nil pointer should be empty, got %v", values[4])
}
}
func TestValuesByFieldsKeepsRowOrder(t *testing.T) {
row := &columnSampleRow{Iccid: "A", DeptName: "B"}
values := valuesByFields(row, fieldList{"iccid", "deptName"})
if values[0] != "A" || values[1] != "B" {
t.Fatalf("unexpected values: %v", values)
}
}
// TestValueMapColumn 覆盖非字典的固定值映射(页面用三元表达式渲染的列)。
func TestValueMapColumn(t *testing.T) {
columns := []model.ExportColumn{
{
Field: "isFlowPool",
ValueMap: map[string]string{"1": "池卡", "0": "单卡"},
ValueDefault: "单卡",
},
{Field: "status", DictType: "common_status"},
{Field: "keep", ValueMap: map[string]string{"1": "是"}},
}
dicts := map[string]map[string]string{"common_status": {"1": "正常", "0": "停用"}}
// 1/0 命中映射;9 未命中走兜底;状态列仍走字典
values := []interface{}{1, 1, 1}
translateRow(values, columns, dicts)
if !reflect.DeepEqual(values, []interface{}{"池卡", "正常", "是"}) {
t.Fatalf("命中映射失败: %v", values)
}
// 0 必须映射,不能被当成空值跳过
values = []interface{}{0, "0", 1}
translateRow(values, columns, dicts)
if values[0] != "单卡" || values[1] != "停用" {
t.Fatalf("0 值映射失败: %v", values)
}
// 未命中且无兜底时保留原值
values = []interface{}{9, "", 7}
translateRow(values, columns, dicts)
if values[0] != "单卡" {
t.Fatalf("兜底失败: %v", values)
}
if values[2] != 7 {
t.Fatalf("无兜底应保留原值: %v", values)
}
}
// TestMissingRowFields 自定义 ToRow 导出:列 field 必须能在 RowFields 里找到。
func TestMissingRowFields(t *testing.T) {
rowFields := []string{"iccid", "createdBy"}
if missing := missingRowFields(fieldList{"iccid"}, rowFields); len(missing) != 0 {
t.Fatalf("不应有缺失: %v", missing)
}
if missing := missingRowFields(fieldList{"iccid", "createdBySumma"}, rowFields); !reflect.DeepEqual(missing, []string{"createdBySumma"}) {
t.Fatalf("缺失识别错误: %v", missing)
}
}
@@ -0,0 +1,498 @@
package exportcenter
import (
"context"
"os"
"path/filepath"
"reflect"
"runtime/debug"
"strings"
"time"
"github.com/gogf/gf/v2/crypto/gmd5"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"github.com/xuri/excelize/v2"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/hash"
)
// slots 在提交时预留容量,避免无限积压等待执行的 goroutine。
var slots = make(chan struct{}, MaxConcurrent+MaxQueued)
// sem 限制同时执行的导出任务数,避免把 DB 连接与磁盘打满。
var sem = make(chan struct{}, MaxConcurrent)
func run(taskNo string, req *Request) {
defer func() { <-slots }()
baseCtx := gctx.New()
var task *entity.TExportTask
var generatedPath string
defer func() {
if p := recover(); p != nil {
g.Log().Errorf(baseCtx, "[exportcenter] 导出协程异常 taskNo=%s panic=%v stack=%s", taskNo, p, debug.Stack())
if task == nil || task.Id == 0 {
task = &entity.TExportTask{}
if err := dao.TExportTask.Ctx(baseCtx).Where(dao.TExportTask.Columns().TaskNo, taskNo).Scan(task); err != nil {
g.Log().Errorf(baseCtx, "[exportcenter] panic 后加载任务失败 taskNo=%s err=%v", taskNo, err)
}
}
if task != nil && task.Id > 0 {
markFailed(baseCtx, task, gerror.Newf("导出任务执行异常: %v", p))
}
}
}()
sem <- struct{}{}
defer func() { <-sem }()
task = &entity.TExportTask{}
if err := dao.TExportTask.Ctx(baseCtx).
Where(dao.TExportTask.Columns().TaskNo, taskNo).
Scan(task); err != nil || task.Id <= 0 {
g.Log().Errorf(baseCtx, "[exportcenter] 加载任务失败 taskNo=%s err=%v", taskNo, err)
return
}
// CAS 抢占:只有把 0 改成 2 的协程才能执行
now := gtime.Now()
affected, err := dao.TExportTask.Ctx(baseCtx).
Data(g.Map{
dao.TExportTask.Columns().Status: consts.DownloadStatusRunning,
dao.TExportTask.Columns().StartTime: now,
}).
Where(dao.TExportTask.Columns().Id, task.Id).
WhereNull(dao.TExportTask.Columns().DeletedTime).
Where(dao.TExportTask.Columns().Status, consts.DownloadStatusPending).
UpdateAndGetAffected()
if err != nil {
g.Log().Errorf(baseCtx, "[exportcenter] 抢占任务失败 taskNo=%s err=%v", taskNo, err)
return
}
if affected == 0 {
return
}
execCtx, cancel := context.WithTimeout(restoreContext(baseCtx, task), TaskTimeout)
defer cancel()
markBatchRunning(execCtx, taskNo)
if err = generate(execCtx, task, req, &generatedPath); err != nil {
g.Log().Errorf(execCtx, "[exportcenter] 导出失败 taskNo=%s err=%v", taskNo, err)
markFailed(baseCtx, task, err)
}
}
// restoreContext 用提交时的身份快照还原 ctx,保证 FilterAuth 仍按提交人范围过滤。
func restoreContext(base context.Context, task *entity.TExportTask) context.Context {
snap := &model.ExportIdentitySnapshot{}
if task.IdentitySnapshot != nil {
if err := task.IdentitySnapshot.Scan(snap); err != nil {
g.Log().Warningf(base, "[exportcenter] 身份快照解析失败 taskNo=%s err=%v", task.TaskNo, err)
}
}
if snap.UserId <= 0 {
snap.UserId = task.OwnerUserId
snap.DeptId = task.DeptId
snap.TenantId = task.TenantId
}
identity := &model.Identity{}
identity.ID = snap.UserId
identity.DeptId = snap.DeptId
identity.TenantId = snap.TenantId
identity.Roles = snap.Roles
identity.AccessCodeList = snap.AccessCodeList
data := g.Map{}
if snap.CurrentRoleId != "" {
data["currentRoleId"] = snap.CurrentRoleId
}
if snap.CurrentRoleDataScope != "" {
data["currentRoleDataScope"] = snap.CurrentRoleDataScope
}
return contexts.Set(base, &model.Context{User: identity, Data: data})
}
func generate(ctx context.Context, task *entity.TExportTask, req *Request, generatedPath *string) (err error) {
header, fields, err := resolveColumns(req)
if err != nil {
return gerror.Wrap(err, "生成表头失败")
}
dicts, err := loadColumnDicts(ctx, req.Columns, func(ctx context.Context, types []string) ([]model.SysDictDataListModel, error) {
return service.SysDictData().GetDictListByTypes(ctx, types)
})
if err != nil {
return gerror.Wrap(err, "加载导出字典失败")
}
sheet := req.SheetName
if sheet == "" {
sheet = req.BizName
}
sheet = truncateSheetName(sheet)
f := excelize.NewFile()
defer f.Close()
if err = f.SetSheetName(f.GetSheetName(0), sheet); err != nil {
return gerror.Wrap(err, "设置工作表名称失败")
}
stream, err := f.NewStreamWriter(sheet)
if err != nil {
return gerror.Wrap(err, "创建工作表流失败")
}
if err = stream.SetRow("A1", header); err != nil {
return gerror.Wrap(err, "写入表头失败")
}
rowNum := 2
written := 0
for page := 1; ; page++ {
rows, total, fetchErr := req.Fetch(ctx, page, DefaultPageSize)
if fetchErr != nil {
// 页码只留给日志排查;对用户展示业务侧给出的可读原因,不暴露分页与驱动细节。
g.Log().Errorf(ctx, "[exportcenter] 第 %d 页取数失败 taskNo=%s err=%v", page, task.TaskNo, fetchErr)
return fetchErr
}
if len(rows) == 0 {
break
}
for _, v := range rows {
cell, _ := excelize.CoordinatesToCellName(1, rowNum)
values := rowValues(v, fields, req)
translateRow(values, req.Columns, dicts)
if err = stream.SetRow(cell, values); err != nil {
return gerror.Wrapf(err, "写入第 %d 行失败", rowNum)
}
rowNum++
written++
if written > MaxRows {
return gerror.Newf("导出行数超过上限 %d 行", MaxRows)
}
}
// 已取满或不足一页,结束
if len(rows) < DefaultPageSize || (total > 0 && written >= total) {
break
}
}
if err = stream.Flush(); err != nil {
return gerror.Wrap(err, "刷新工作表失败")
}
content, err := toBytes(f)
if err != nil {
return err
}
if int64(len(content)) > MaxFileSize {
return gerror.Newf("导出文件超过 %d MB 上限,请缩小筛选范围", MaxFileSize>>20)
}
oss, err := savePrivateExport(ctx, task, content)
if err != nil {
return gerror.Wrap(err, "保存导出文件失败")
}
*generatedPath = oss.Path
task.OssId = oss.OssId
if err = markSuccess(ctx, task, oss, written, req); err != nil {
return err
}
*generatedPath = ""
return nil
}
func savePrivateExport(ctx context.Context, task *entity.TExportTask, content []byte) (*model.SysOssSaveContentModel, error) {
uploadRoot := strings.TrimSpace(g.Cfg().MustGet(ctx, "system.upload.local.path").String())
if uploadRoot == "" {
return nil, gerror.New("上传路径未配置")
}
root, err := filepath.Abs(uploadRoot)
if err != nil {
return nil, err
}
// 私有目录必须与上传静态根目录隔离,不能生成可访问的 OSS URL。
path := filepath.Join(filepath.Dir(root), "private-export", task.TenantId, task.TaskNo+".xlsx")
if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, err
}
registered := false
defer func() {
if !registered {
if removeErr := os.Remove(path); removeErr != nil && !os.IsNotExist(removeErr) {
g.Log().Errorf(gctx.New(), "[exportcenter] 清理未登记文件失败 path=%s err=%v", path, removeErr)
}
}
}()
if err = os.WriteFile(path, content, 0600); err != nil {
return nil, err
}
md5, err := gmd5.EncryptFile(path)
if err != nil {
return nil, err
}
var ossId int64
// 这里仅登记元数据;通用 OSS API 拒绝导出文件,实际下载走下载中心。
err = g.DB().Transaction(gctx.New(), func(ctx context.Context, tx gdb.TX) error {
var insertErr error
ossId, insertErr = dao.SysOss.Ctx(ctx).TX(tx).Data(g.Map{
dao.SysOss.Columns().TenantId: task.TenantId,
dao.SysOss.Columns().FileName: task.FileName,
dao.SysOss.Columns().OriginalName: task.FileName,
dao.SysOss.Columns().FileSuffix: "xlsx",
dao.SysOss.Columns().Path: path,
dao.SysOss.Columns().Url: "",
dao.SysOss.Columns().CreatedDept: task.DeptId,
dao.SysOss.Columns().CreatedBy: task.OwnerUserId,
dao.SysOss.Columns().CreatedAt: gtime.Now(),
dao.SysOss.Columns().Service: "local",
dao.SysOss.Columns().Md5: md5,
dao.SysOss.Columns().FileSize: len(content),
dao.SysOss.Columns().FileCrc16: hash.Crc16ChecksumFile(path),
dao.SysOss.Columns().FileSum: hash.Sum16ChecksumFile(path),
}).InsertAndGetId()
if insertErr != nil {
return insertErr
}
affected, updateErr := dao.TExportTask.Ctx(ctx).TX(tx).
Data(g.Map{dao.TExportTask.Columns().OssId: ossId}).
Where(dao.TExportTask.Columns().Id, task.Id).
Where(dao.TExportTask.Columns().Status, consts.DownloadStatusRunning).UpdateAndGetAffected()
if updateErr != nil {
return updateErr
}
if affected != 1 {
return gerror.New("登记导出文件失败:任务状态已变更")
}
return nil
})
if err != nil {
return nil, err
}
registered = true
task.OssId = ossId
return &model.SysOssSaveContentModel{SysOss: &entity.SysOss{OssId: ossId, Path: path, FileSize: len(content), Md5: md5}}, nil
}
func structValues(v interface{}) []interface{} {
rv := reflect.ValueOf(v)
for rv.Kind() == reflect.Ptr {
if rv.IsNil() {
return nil
}
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
return []interface{}{v}
}
fields := rv.NumField()
out := make([]interface{}, fields)
for i := 0; i < fields; i++ {
out[i] = rv.Field(i).Interface()
}
return out
}
func toBytes(f *excelize.File) ([]byte, error) {
tmp, err := os.CreateTemp("", "iotlcm-export-*.xlsx")
if err != nil {
return nil, gerror.Wrap(err, "创建临时文件失败")
}
name := tmp.Name()
defer os.Remove(name)
defer tmp.Close()
if err = f.Write(tmp); err != nil {
return nil, gerror.Wrap(err, "写入临时文件失败")
}
if err = tmp.Sync(); err != nil {
return nil, gerror.Wrap(err, "刷新临时文件失败")
}
content, err := os.ReadFile(name)
if err != nil {
return nil, gerror.Wrap(err, "读取临时文件失败")
}
return content, nil
}
func truncateSheetName(name string) string {
if name == "" {
return "Sheet1"
}
rs := []rune(name)
if len(rs) > 31 {
rs = rs[:31]
}
return string(rs)
}
func markBatchRunning(ctx context.Context, taskNo string) {
_, err := dao.BatchMain.Ctx(ctx).
Data(g.Map{
dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusProcessing,
dao.BatchMain.Columns().RunResult: consts.BatchRunResultRunning,
dao.BatchMain.Columns().StartTime: gtime.Now(),
}).
Where(dao.BatchMain.Columns().BatchNo, taskNo).
Update()
if err != nil {
g.Log().Warningf(ctx, "[exportcenter] 更新执行日志开始状态失败 taskNo=%s err=%v", taskNo, err)
}
}
func markSuccess(ctx context.Context, task *entity.TExportTask, oss *model.SysOssSaveContentModel, written int, req *Request) error {
now := gtime.Now()
// 默认按全部成功回填;查询类导出可通过 Request.Result 改判为部分成功/失败并写汇总说明。
// 注意:即使判定为失败,文件本身仍已生成并可下载(文件里写明每条失败原因),
// 因此这里只改执行日志的 async_status/run_result,不改 t_export_task 的下载状态。
runResult := consts.BatchRunResultSuccess
asyncStatus := consts.BatchAsyncStatusSuccess
summary := ""
if req != nil && req.Result != nil {
if result, text := req.Result(written); result > 0 {
runResult = result
summary = text
switch result {
case consts.BatchRunResultPartialSuccess:
asyncStatus = consts.BatchAsyncStatusPartialSuccess
summary = text
case consts.BatchRunResultFailed:
asyncStatus = consts.BatchAsyncStatusFailed
summary = text
default:
asyncStatus = consts.BatchAsyncStatusSuccess
}
}
}
// 文件已写磁盘,两张表必须同一事务提交;失败保留 OSS 索引供过期清理重试。
return g.DB().Transaction(gctx.New(), func(ctx context.Context, tx gdb.TX) error {
affected, err := dao.TExportTask.Ctx(ctx).TX(tx).
Data(g.Map{
dao.TExportTask.Columns().Status: consts.DownloadStatusGenerated,
dao.TExportTask.Columns().OssId: oss.OssId,
dao.TExportTask.Columns().FileSize: oss.FileSize,
dao.TExportTask.Columns().FileMd5: oss.Md5,
dao.TExportTask.Columns().TotalCount: written,
dao.TExportTask.Columns().FinishTime: now,
dao.TExportTask.Columns().ExpireTime: now.AddDate(0, 0, ExpireDays),
}).
Where(dao.TExportTask.Columns().Id, task.Id).
Where(dao.TExportTask.Columns().Status, consts.DownloadStatusRunning).
UpdateAndGetAffected()
if err != nil {
return gerror.Wrap(err, "回填导出任务失败")
}
if affected != 1 {
return gerror.New("回填导出任务失败:任务状态已变更")
}
affected, err = dao.BatchMain.Ctx(ctx).TX(tx).
Data(g.Map{
dao.BatchMain.Columns().BatchAsyncStatus: asyncStatus,
dao.BatchMain.Columns().RunResult: runResult,
dao.BatchMain.Columns().FailReason: summary,
dao.BatchMain.Columns().ResultOssId: oss.OssId,
dao.BatchMain.Columns().TotalCount: written,
dao.BatchMain.Columns().EndTime: now,
}).
Where(dao.BatchMain.Columns().BatchNo, task.TaskNo).
UpdateAndGetAffected()
if err != nil {
return gerror.Wrap(err, "回填执行日志失败")
}
if affected != 1 {
return gerror.New("回填执行日志失败:记录不存在")
}
return nil
})
}
func markFailed(ctx context.Context, task *entity.TExportTask, cause error) {
// 失败也要落库,用独立 ctx 避免任务 ctx 已超时
ctx = gctx.New()
msg := cause.Error()
if len([]rune(msg)) > 1900 {
msg = string([]rune(msg)[:1900])
}
now := gtime.Now()
affected, err := dao.TExportTask.Ctx(ctx).
Data(g.Map{
dao.TExportTask.Columns().Status: consts.DownloadStatusFailed,
dao.TExportTask.Columns().ErrorMessage: msg,
dao.TExportTask.Columns().FinishTime: now,
}).
Where(dao.TExportTask.Columns().Id, task.Id).
WhereNull(dao.TExportTask.Columns().DeletedTime).
WhereIn(dao.TExportTask.Columns().Status, []int{consts.DownloadStatusPending, consts.DownloadStatusRunning}).
UpdateAndGetAffected()
if err != nil {
g.Log().Errorf(ctx, "[exportcenter] 回填失败状态失败 taskNo=%s err=%v", task.TaskNo, err)
return
}
if affected == 0 {
current := &entity.TExportTask{}
if err := dao.TExportTask.Ctx(ctx).Where(dao.TExportTask.Columns().Id, task.Id).Scan(current); err != nil {
g.Log().Errorf(ctx, "[exportcenter] 核对失败状态失败 taskNo=%s err=%v", task.TaskNo, err)
return
}
if current.Id == 0 || current.Status != consts.DownloadStatusFailed {
return
}
}
_, err = dao.BatchMain.Ctx(ctx).
Data(g.Map{
dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusFailed,
dao.BatchMain.Columns().RunResult: consts.BatchRunResultFailed,
dao.BatchMain.Columns().FailReason: msg,
dao.BatchMain.Columns().EndTime: now,
}).
Where(dao.BatchMain.Columns().BatchNo, task.TaskNo).
Update()
if err != nil {
g.Log().Errorf(ctx, "[exportcenter] 回填执行日志失败状态失败 taskNo=%s err=%v", task.TaskNo, err)
}
}
// RecoverOnStart 进程启动时把残留的「排队中/生成中」任务标记为失败。
// 进程内任务不跨重启恢复,失败后用户可在下载中心点重试重新提交。
func RecoverOnStart(ctx context.Context) {
now := time.Now()
_, err := dao.TExportTask.Ctx(ctx).
Data(g.Map{
dao.TExportTask.Columns().Status: consts.DownloadStatusFailed,
dao.TExportTask.Columns().ErrorMessage: "服务重启,任务已中断,请重新导出",
dao.TExportTask.Columns().FinishTime: now,
}).
WhereNull(dao.TExportTask.Columns().DeletedTime).
WhereIn(dao.TExportTask.Columns().Status, []int{consts.DownloadStatusPending, consts.DownloadStatusRunning}).
Update()
if err != nil {
g.Log().Errorf(ctx, "[exportcenter] 启动补偿失败: %v", err)
return
}
_, err = dao.BatchMain.Ctx(ctx).
Data(g.Map{
dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusFailed,
dao.BatchMain.Columns().RunResult: consts.BatchRunResultFailed,
dao.BatchMain.Columns().FailReason: "服务重启,任务已中断",
dao.BatchMain.Columns().EndTime: now,
}).
Where(dao.BatchMain.Columns().BatchType, consts.BatchTypeExport).
WhereIn(dao.BatchMain.Columns().BatchAsyncStatus, []int{consts.BatchAsyncStatusCreated, consts.BatchAsyncStatusProcessing}).
Update()
if err != nil {
g.Log().Errorf(ctx, "[exportcenter] 启动补偿执行日志失败: %v", err)
}
}
@@ -0,0 +1,173 @@
package exportcenter
import (
"context"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/encoding/gjson"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchimport"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/batchmetadata"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
)
func snapshot(ctx context.Context) (*model.ExportIdentitySnapshot, error) {
user := contexts.GetUser(ctx)
if user == nil || user.ID <= 0 {
return nil, gerror.New("登录状态已失效,请重新登录")
}
snap := &model.ExportIdentitySnapshot{
UserId: user.ID,
DeptId: user.DeptId,
TenantId: user.TenantId,
Roles: user.Roles,
AccessCodeList: user.AccessCodeList,
}
if v := contexts.GetDataValue(ctx, "currentRoleId"); v != nil {
snap.CurrentRoleId = gconv.String(v)
}
if v := contexts.GetDataValue(ctx, "currentRoleDataScope"); v != nil {
snap.CurrentRoleDataScope = gconv.String(v)
}
return snap, nil
}
// Submit 提交一次导出任务:预校验行数、落任务与执行日志、投递后台执行。
func Submit(ctx context.Context, req *Request) (res *SubmitResult, err error) {
if req == nil || req.Tags == nil || req.Fetch == nil {
return nil, gerror.New("导出配置不完整")
}
if req.BizKey == "" || req.BizName == "" {
return nil, gerror.New("导出业务标识不能为空")
}
// 提交前占用执行/排队容量,避免高峰期无限创建等待执行的 goroutine。
select {
case slots <- struct{}{}:
default:
return nil, gerror.New("导出任务排队已满,请稍后重试")
}
ownershipTransferred := false
defer func() {
if !ownershipTransferred {
<-slots
}
}()
snap, err := snapshot(ctx)
if err != nil {
return nil, failSubmit(ctx, "snapshot", req.BizKey, err)
}
// 预校验:只取 1 行,拿到筛选条件下的总行数。
// 业务侧可能返回 -1 表示「提交阶段还无法确定条数」(如按号段/按公司圈卡,
// 需要后台实际查库才知道命中多少),此时放行,最终条数以实际写出为准。
// total=0 也放行:空结果同样要产出文件(至少带表头与失败原因列),
// 让用户能拿到「查了但没命中」的凭据,而不是只看到一句报错。
_, total, err := req.Fetch(ctx, 1, 1)
if err != nil {
return nil, failSubmit(ctx, "count", req.BizKey, gerror.Wrap(err, "统计导出数据失败"))
}
if total > MaxRows {
return nil, gerror.Newf("本次导出 %d 行,超过单次上限 %d 行,请缩小筛选范围", total, MaxRows)
}
// 未知条数不写入任务记录,避免 -1 出现在列表与执行日志里。
recordedTotal := total
if recordedTotal < 0 {
recordedTotal = 0
}
taskNo, err := batchimport.NextBatchNo(consts.BatchNoPrefixExport)
if err != nil {
return nil, failSubmit(ctx, "batch_no", req.BizKey, gerror.Wrap(err, "生成任务编号失败"))
}
// 列清单直接影响导出内容,留痕便于排查「导出列与页面不一致」。
if len(req.Columns) == 0 {
req.Columns = columnsFromRequest(ctx)
}
g.Log().Infof(ctx, "[exportcenter] 提交导出 biz=%s user=%d columns=%d", req.BizKey, snap.UserId, len(req.Columns))
now := time.Now()
expire := now.AddDate(0, 0, ExpireDays)
fileName := taskNo + ".xlsx"
batchInfo := gjson.New(g.Map{
"exportBizKey": req.BizKey,
"exportBizName": req.BizName,
"permCode": req.PermCode,
})
var taskId int64
err = g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
id, insertErr := dao.TExportTask.Ctx(ctx).TX(tx).Data(g.Map{
dao.TExportTask.Columns().TaskNo: taskNo,
dao.TExportTask.Columns().BizKey: req.BizKey,
dao.TExportTask.Columns().BizName: req.BizName,
dao.TExportTask.Columns().PermCode: req.PermCode,
dao.TExportTask.Columns().QueryParams: gjson.New(req.QueryParam),
dao.TExportTask.Columns().IdentitySnapshot: gjson.New(snap),
dao.TExportTask.Columns().Status: consts.DownloadStatusPending,
dao.TExportTask.Columns().TotalCount: recordedTotal,
dao.TExportTask.Columns().FileName: fileName,
dao.TExportTask.Columns().OwnerUserId: snap.UserId,
dao.TExportTask.Columns().DeptId: snap.DeptId,
dao.TExportTask.Columns().TenantId: snap.TenantId,
dao.TExportTask.Columns().CreatedDept: snap.DeptId,
dao.TExportTask.Columns().CreatedBy: snap.UserId,
dao.TExportTask.Columns().CreatedAt: now,
dao.TExportTask.Columns().ExpireTime: expire,
}).InsertAndGetId()
if insertErr != nil {
return gerror.Wrap(insertErr, "创建导出任务失败")
}
taskId = id
_, insertErr = dao.BatchMain.Ctx(ctx).TX(tx).Data(g.Map{
dao.BatchMain.Columns().BatchNo: taskNo,
dao.BatchMain.Columns().BatchType: consts.BatchTypeExport,
dao.BatchMain.Columns().BatchStatus: consts.BatchStatusNormal,
dao.BatchMain.Columns().BatchAsyncStatus: consts.BatchAsyncStatusCreated,
dao.BatchMain.Columns().SourceChannel: batchmetadata.Console,
dao.BatchMain.Columns().ExecutionMode: batchmetadata.Bulk,
dao.BatchMain.Columns().RunResult: consts.BatchRunResultPending,
dao.BatchMain.Columns().TotalCount: recordedTotal,
dao.BatchMain.Columns().BatchInfo: batchInfo,
dao.BatchMain.Columns().TenantId: snap.TenantId,
dao.BatchMain.Columns().CreatedDept: snap.DeptId,
// dept_id 必须与 created_dept 一致。
// FilterAuth 对 batch_main 优先用 dept_id 过滤,留空(NUL)会让提交人自己也看不到
// 这条执行日志(只有全部数据权限的管理员能看到),表现为「导出了但日志里没有」。
dao.BatchMain.Columns().DeptId: snap.DeptId,
dao.BatchMain.Columns().CreatedBy: snap.UserId,
dao.BatchMain.Columns().CreatedAt: now,
}).Insert()
if insertErr != nil {
return gerror.Wrap(insertErr, "写导出执行日志失败")
}
return nil
})
if err != nil {
return nil, failSubmit(ctx, "persist", req.BizKey, err)
}
go run(taskNo, req)
ownershipTransferred = true
return &SubmitResult{TaskId: taskId, TaskNo: taskNo, Total: total}, nil
}
// failSubmit 统一记录提交失败的环节与原因。
// 框架会把未带错误码的 error 统一转成「内部服务器错误」,真实原因只能从日志看,
// 所以这里必须把 step 打出来,否则线上无法定位。
func failSubmit(ctx context.Context, step, bizKey string, err error) error {
g.Log().Errorf(ctx, "[exportcenter] 提交导出失败 step=%s biz=%s err=%+v", step, bizKey, err)
return err
}
@@ -0,0 +1,76 @@
// Package exportcenter 通用异步导出中心。
//
// 调用方在导出接口里只负责描述「怎么取一页数据」,提交、排队、生成文件、
// 落 OSS、写执行日志、失败回填都由本包完成。
//
// 注意事项:
// - 异步执行不持有 HTTP 上下文,必须靠提交时的身份快照还原 ctx,
// 否则 FilterAuth 取不到用户会漏掉部门数据权限。
// - 导出批次只写 batch_main,不写 batch_detail;执行日志中的运行结果
// 跟随导出任务状态更新,因此不会出现在批次管理页,只出现在执行日志页。
package exportcenter
import (
"context"
"time"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
)
const (
// DefaultPageSize 每次从业务侧取多少行
DefaultPageSize = 2000
// MaxRows 单任务行数上限(Excel 硬上限 104 万,留出余量)
MaxRows = 500000
// MaxFileSize 单文件大小上限
MaxFileSize int64 = 100 << 20
// MaxConcurrent 同时执行的导出任务数
MaxConcurrent = 2
// MaxQueued 允许等待执行的导出任务数(单实例)
MaxQueued = 20
// ExpireDays 文件保留天数
ExpireDays = 3
// TaskTimeout 单任务超时
TaskTimeout = 10 * time.Minute
)
// Request 一次导出请求的完整描述
type Request struct {
BizKey string // 业务标识,如 tSimCardInfo
BizName string // 业务名称,如 卡信息
PermCode string // 接口权限码快照,K1 用
SheetName string // Excel sheet 名,留空则用 BizName
QueryParam interface{} // 筛选条件快照,落库备查
// Columns 可选:前端传入的「当前列表显示列」,顺序即导出列顺序。
// 非空时按 Field(列表模型 json tag)逐行取值、Label 作表头;
// 为空时沿用 Tags + 结构体字段顺序(兼容尚未改造的导出)。
Columns []model.ExportColumn
// RowFields 可选:自定义 ToRow 取值时,声明 ToRow 返回值与字段名的对应顺序,
// 这样按列表列导出时仍能复用 ToRow 的格式化结果。为空则直接从结构体取值。
RowFields []string
// Tags 表头,通常 convert.GetEntityDescTags(model.XxxExportModel{})
Tags func() ([]string, error)
// Fetch 取一页数据。page 从 1 开始;返回的 total 必须为筛选条件下的总行数。
// rows 里每个元素应是结构体或结构体指针,字段顺序即列顺序。
Fetch func(ctx context.Context, page, pageSize int) (rows []interface{}, total int, err error)
// ToRow 可选:自定义「结构体 -> 行」的转换(如需要状态名、时间格式化)。
// 为空时按结构体字段顺序取值。
ToRow func(v interface{}) []interface{}
// Result 可选:文件生成完成后回填执行结果汇总。
// 用于「按输入逐条反馈」的查询类导出:全部命中记为成功,部分未命中记为部分成功。
// 返回的 runResult 取 consts.BatchRunResult*;summary 写入执行日志的失败原因/汇总说明。
// 为空时保持历史行为(一律成功)。
Result func(written int) (runResult int, summary string)
}
// SubmitResult 提交结果
type SubmitResult struct {
TaskId int64 `json:"taskId" dc:"任务ID"`
TaskNo string `json:"taskNo" dc:"任务编号"`
Total int `json:"total" dc:"预计导出行数"`
}
// Snapshot 由 ctx 生成身份快照
func Snapshot(ctx context.Context) (*model.ExportIdentitySnapshot, error) {
return snapshot(ctx)
}
@@ -0,0 +1,52 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
// 内存缓存管理
package mcache
import (
"context"
"sync"
"time"
"github.com/gogf/gf/v2/os/gcache"
)
// cache 缓存驱动
var (
mcache *gcache.Cache
cacheOnce sync.Once
)
// Instance 缓存实例
func Instance() *gcache.Cache {
cacheOnce.Do(func() {
mcache = gcache.NewWithAdapter(gcache.NewAdapterMemory())
})
return mcache
}
func Set(ctx context.Context, key string, value interface{}, timeout time.Duration) error {
return Instance().Set(ctx, key, value, timeout)
}
func Get(ctx context.Context, key string) (interface{}, error) {
return Instance().Get(ctx, key)
}
func Remove(ctx context.Context, key string) error {
_, err := Instance().Remove(ctx, key)
return err
}
// Clear invalidates every derived in-process cache entry. It is used after the
// authoritative SQLite memory database is rebuilt.
func Clear(ctx context.Context) error {
keys := Instance().MustKeys(ctx)
if len(keys) == 0 {
return nil
}
return Instance().Removes(ctx, keys)
}
@@ -0,0 +1,62 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/event"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"time"
"github.com/gogf/gf/v2/frame/g"
)
func init() {
event.EventsInstance().Register(consts.EventKeyUserLogout, func(ctx context.Context, eventKey string, args ...interface{}) {
if len(args) == 0 {
return
}
userId := args[0].(int64)
RemoveUserAccessCodeList(ctx, userId)
g.Log().Infof(ctx, "mcache_access_list.go EventKeyUserLogout userId: %d", userId)
})
}
var loadUserPermissions = func(ctx context.Context, userID int64) ([]string, []string, error) {
return service.SysAuth().GetUserAccessCodeList(ctx, userID)
}
func GetUserAccessCodeList(ctx context.Context, userId int64) ([]string, []string, error) {
codes, dataCodes, err := loadUserPermissions(ctx, userId)
if err != nil {
return nil, nil, err
}
if len(codes) == 0 {
codes = []string{"null"}
}
return codes, dataCodes, nil
}
func SetUserAccessCodeList(ctx context.Context, userId int64, accessCodeList []string) error {
return Instance().Set(ctx, fmt.Sprintf(consts.MemCacheUserAccessCodeList, userId), accessCodeList, time.Hour*24)
}
func SetUserRoleDataAccessCodeList(ctx context.Context, userId int64, roleDataAccessCodeList []string) error {
return Instance().Set(ctx, fmt.Sprintf(consts.MemCacheUserRoleDataAccessCodeList, userId), roleDataAccessCodeList, time.Hour*24)
}
func RemoveUserRoleDataAccessCodeList(ctx context.Context, userId int64) error {
err := Instance().Removes(ctx, []interface{}{fmt.Sprintf(consts.MemCacheUserRoleDataAccessCodeList, userId)})
return err
}
func RemoveUserAccessCodeList(ctx context.Context, userId int64) error {
err := Instance().Removes(ctx, []interface{}{fmt.Sprintf(consts.MemCacheUserAccessCodeList, userId)})
return err
}
@@ -0,0 +1,28 @@
package mcache
import (
"context"
"reflect"
"testing"
)
func TestPermissionsReloadWithoutRevisionTable(t *testing.T) {
original := loadUserPermissions
t.Cleanup(func() { loadUserPermissions = original })
loads := 0
loadUserPermissions = func(context.Context, int64) ([]string, []string, error) {
loads++
if loads == 1 {
return []string{"old"}, nil, nil
}
return []string{"new"}, nil, nil
}
ctx := context.Background()
if _, _, err := GetUserAccessCodeList(ctx, 99); err != nil {
t.Fatal(err)
}
codes, _, err := GetUserAccessCodeList(ctx, 99)
if err != nil || !reflect.DeepEqual(codes, []string{"new"}) || loads != 2 {
t.Fatalf("stale authorization: codes=%v loads=%d err=%v", codes, loads, err)
}
}
@@ -0,0 +1,77 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/event"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"time"
)
func init() {
event.EventsInstance().Register([]string{consts.EventKeyDBSysConfigUpdate, consts.EventKeyDBSysConfigCreate, consts.EventKeyDBSysConfigDelete}, func(ctx context.Context, eventKey string, args ...interface{}) {
if len(args) < 1 {
return
}
// tenantId := args[0].(string)
Instance().Remove(ctx, getKey(ctx))
})
}
func getKey(ctx context.Context) string {
return fmt.Sprintf(consts.MemCacheSystemConfig, contexts.GetTenantId(ctx))
}
func GetSystemConfig(ctx context.Context, key string, defaultValue string) (string, error) {
mapV, err := Instance().Get(ctx, getKey(ctx))
if err != nil {
data, _, err := service.SysConfig().List(ctx, &model.SysConfigListParam{
PageInfo: request.PageInfo{
Page: 1,
PageSize: 9999,
},
})
if err != nil {
return defaultValue, err
}
mapConfig := make(map[string]string)
for _, item := range data {
mapConfig[item.ConfigKey] = item.ConfigValue
}
Instance().Set(ctx, getKey(ctx), mapConfig, time.Hour*24)
return mapConfig[key], nil
}
mapConfig := mapV.MapStrStr()
if v, ok := mapConfig[key]; ok {
return v, nil
}
return defaultValue, nil
}
func SetSystemConfig(ctx context.Context, key string, value string) error {
mapV, err := Instance().Get(ctx, getKey(ctx))
if err != nil {
return err
}
mapConfig := mapV.MapStrStr()
mapConfig[key] = value
return Instance().Set(ctx, getKey(ctx), mapConfig, time.Hour*24)
}
func RemoveSystemConfig(ctx context.Context, key string) error {
mapV, err := Instance().Get(ctx, getKey(ctx))
if err != nil {
return err
}
mapConfig := mapV.MapStrStr()
delete(mapConfig, key)
return Instance().Set(ctx, getKey(ctx), mapConfig, time.Hour*24)
}
@@ -0,0 +1,49 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/event"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"time"
)
func init() {
event.EventsInstance().Register([]string{consts.EventKeyDBSysDeptUpdate,
consts.EventKeyDBSysDeptCreate,
consts.EventKeyDBSysDeptDelete}, func(ctx context.Context, eventKey string, args ...interface{}) {
if len(args) == 0 {
return
}
deptId := args[0].(int64)
RemoveDeptName(ctx, deptId)
})
}
func GetDeptName(ctx context.Context, deptId int64) (string, error) {
deptName, err := Instance().Get(ctx, fmt.Sprintf(consts.MemCacheDeptName, deptId))
if err != nil {
dept, err := service.SysDept().GetDeptById(ctx, deptId)
if err != nil {
return "", err
}
Instance().Set(ctx, fmt.Sprintf(consts.MemCacheDeptName, deptId), dept.DeptName, time.Hour*24)
return dept.DeptName, nil
}
return deptName.String(), nil
}
func SetDeptName(ctx context.Context, deptId int64, deptName string) error {
return Instance().Set(ctx, fmt.Sprintf(consts.MemCacheDeptName, deptId), deptName, time.Hour*24)
}
func RemoveDeptName(ctx context.Context, deptId int64) error {
_, err := Instance().Remove(ctx, fmt.Sprintf(consts.MemCacheDeptName, deptId))
return err
}
@@ -0,0 +1,58 @@
// Package mcache
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package mcache
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/event"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"time"
"github.com/gogf/gf/v2/util/gconv"
)
func init() {
event.EventsInstance().Register(consts.EventKeyUserUpdate, func(ctx context.Context, eventKey string, args ...interface{}) {
if len(args) == 0 {
return
}
userId := args[0].(int64)
RemoveUserInfo(ctx, userId)
})
}
func GetUserInfo(ctx context.Context, userId int64) (*model.SysUserMiniModel, error) {
userInfo, err := Instance().Get(ctx, fmt.Sprintf(consts.MemCacheUserInfo, userId))
if err != nil {
user, err := service.SysUser().GetUserById(ctx, userId)
if err != nil {
return nil, err
}
miniUser := &model.SysUserMiniModel{}
gconv.Struct(miniUser, user)
Instance().Set(ctx, fmt.Sprintf(consts.MemCacheUserInfo, userId), miniUser, time.Hour*24)
return miniUser, nil
}
miniUser := &model.SysUserMiniModel{}
err = userInfo.Struct(miniUser)
if err != nil {
return nil, err
}
return miniUser, nil
}
func SetUserInfo(ctx context.Context, userId int64, userInfo *model.SysUserMiniModel) error {
return Instance().Set(ctx, fmt.Sprintf(consts.MemCacheUserInfo, userId), userInfo, time.Hour*24)
}
func RemoveUserInfo(ctx context.Context, userId int64) error {
_, err := Instance().Remove(ctx, fmt.Sprintf(consts.MemCacheUserInfo, userId))
return err
}
@@ -0,0 +1,127 @@
package rabbitmq
import (
"context"
"encoding/json"
"net"
"strconv"
"time"
"github.com/gogf/gf/v2/errors/gerror"
amqp "github.com/rabbitmq/amqp091-go"
)
func PublishConfirmed(ctx context.Context, queue string, data any) error {
body, err := json.Marshal(data)
if err != nil {
return err
}
return publishConfirmedBody(ctx, queue, body, "", "", 0)
}
func PublishDelayedConfirmed(ctx context.Context, delayQueue, targetQueue string, delay time.Duration, data any) error {
body, err := json.Marshal(data)
if err != nil {
return err
}
return publishConfirmedBody(ctx, delayQueue, body, delayQueue, targetQueue, delay)
}
func publishConfirmedBody(ctx context.Context, queue string, body []byte, delayQueue, targetQueue string, delay time.Duration) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
cfg := GetConfig(ctx)
if !cfg.Enabled || cfg.Dsn == "" {
return gerror.New("rabbitmq is disabled or dsn is empty")
}
connection, cleanup, err := openConfirmedConnection(ctx, cfg.Dsn)
if err != nil {
return err
}
defer cleanup()
channel, err := connection.Channel()
if err != nil {
return err
}
if delayQueue != "" {
if err = declareQueue(channel, targetQueue); err != nil {
return err
}
if err = declareDelayQueue(channel, delayQueue, targetQueue); err != nil {
return err
}
} else if err = declareQueue(channel, queue); err != nil {
return err
}
if err = channel.Confirm(false); err != nil {
return err
}
confirmations := channel.NotifyPublish(make(chan amqp.Confirmation, 1))
returns := channel.NotifyReturn(make(chan amqp.Return, 1))
if err = channel.PublishWithContext(ctx, "", queue, true, false, amqp.Publishing{
ContentType: "application/json", DeliveryMode: amqp.Persistent, Timestamp: time.Now(), Body: body, Expiration: confirmedExpiration(delay),
}); err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case returned := <-returns:
return gerror.Newf("rabbitmq message unroutable: %s", returned.ReplyText)
case confirmation, ok := <-confirmations:
if !ok || !confirmation.Ack {
return gerror.New("rabbitmq message was not confirmed")
}
select {
case returned := <-returns:
return gerror.Newf("rabbitmq message unroutable: %s", returned.ReplyText)
default:
return nil
}
}
}
func openConfirmedConnection(ctx context.Context, dsn string) (*amqp.Connection, func(), error) {
if err := ctx.Err(); err != nil {
return nil, nil, err
}
var (
socket net.Conn
stop func() bool
)
cleanup := func() {
if stop != nil {
stop()
}
if socket != nil {
_ = socket.Close()
}
}
connection, err := amqp.DialConfig(dsn, amqp.Config{
Dial: func(network, address string) (net.Conn, error) {
var dialErr error
socket, dialErr = (&net.Dialer{}).DialContext(ctx, network, address)
if dialErr != nil {
return nil, dialErr
}
transport := socket
stop = context.AfterFunc(ctx, func() { _ = transport.Close() })
return socket, nil
},
})
if err != nil {
cleanup()
if ctx.Err() != nil {
return nil, nil, ctx.Err()
}
return nil, nil, gerror.Wrap(err, "rabbitmq confirmed publisher connect failed")
}
return connection, cleanup, nil
}
func confirmedExpiration(delay time.Duration) string {
if delay <= 0 {
return ""
}
return strconv.FormatInt(delay.Milliseconds(), 10)
}
@@ -0,0 +1,83 @@
package rabbitmq
import (
"context"
"errors"
"io"
"net"
"testing"
"time"
)
func TestConfirmedExpiration(t *testing.T) {
for _, item := range []struct {
delay time.Duration
want string
}{{0, ""}, {-time.Second, ""}, {time.Second, "1000"}, {1500 * time.Millisecond, "1500"}} {
if got := confirmedExpiration(item.delay); got != item.want {
t.Fatalf("expiration(%v)=%q, want %q", item.delay, got, item.want)
}
}
}
func TestConfirmedConnectionHandshakeTimeout(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
finished := make(chan struct{})
go func() {
defer close(finished)
connection, acceptErr := listener.Accept()
if acceptErr != nil {
return
}
defer connection.Close()
_ = connection.SetDeadline(time.Now().Add(3 * time.Second))
_, _ = io.Copy(io.Discard, connection)
}()
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
started := time.Now()
connection, cleanup, err := openConfirmedConnection(ctx, "amqp://guest:guest@"+listener.Addr().String()+"/")
if cleanup != nil {
cleanup()
}
if connection != nil || !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected handshake timeout, connection=%v err=%v", connection, err)
}
if time.Since(started) > 2*time.Second {
t.Fatal("handshake exceeded the context deadline")
}
select {
case <-finished:
case <-time.After(2 * time.Second):
t.Fatal("timed out socket was not closed")
}
}
func TestConfirmedConnectionCanceledContext(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
connection, cleanup, err := openConfirmedConnection(ctx, "invalid-dsn")
if connection != nil || cleanup != nil || !errors.Is(err, context.Canceled) {
t.Fatalf("expected cancellation before dialing, connection=%v err=%v", connection, err)
}
}
func TestConsumerCloserUnregister(t *testing.T) {
closerMu.Lock()
initial := len(closers)
closerMu.Unlock()
for attempt := 0; attempt < 1000; attempt++ {
unregister := registerCloser(func() error { return nil })
unregister()
unregister()
}
closerMu.Lock()
defer closerMu.Unlock()
if len(closers) != initial {
t.Fatalf("stale consumer closers retained: initial=%d actual=%d", initial, len(closers))
}
}
@@ -0,0 +1,359 @@
package rabbitmq
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
amqp "github.com/rabbitmq/amqp091-go"
)
type Config struct {
Enabled bool
Dsn string
}
// Handler 是 RabbitMQ 消费处理函数。
// 返回 nil 时消息 Ack;返回 error 时消息 Nack 且不重新入队。
type Handler func(ctx context.Context, body []byte) error
// JSONHandler 是 RabbitMQ JSON 消息的业务处理函数。
type JSONHandler[T any] func(ctx context.Context, message T) error
var (
conn *amqp.Connection
connMu sync.Mutex
closers = make(map[uint64]func() error)
closerID uint64
closerMu sync.Mutex
)
func GetConfig(ctx context.Context) Config {
return Config{
Enabled: g.Cfg().MustGet(ctx, "rabbitmq.enabled", false).Bool(),
Dsn: g.Cfg().MustGet(ctx, "rabbitmq.dsn", "").String(),
}
}
// Enabled 判断 RabbitMQ 是否启用且 DSN 是否已配置。
func Enabled(ctx context.Context) bool {
cfg := GetConfig(ctx)
return cfg.Enabled && cfg.Dsn != ""
}
// Conn 懒加载 RabbitMQ 连接,供多个生产者/消费者复用。
func Conn(ctx context.Context) (*amqp.Connection, error) {
cfg := GetConfig(ctx)
if !cfg.Enabled {
return nil, gerror.New("rabbitmq is disabled")
}
if cfg.Dsn == "" {
return nil, gerror.New("rabbitmq dsn is empty")
}
connMu.Lock()
defer connMu.Unlock()
if conn != nil && !conn.IsClosed() {
return conn, nil
}
c, err := amqp.Dial(cfg.Dsn)
if err != nil {
return nil, gerror.Wrap(err, "rabbitmq connect failed")
}
conn = c
return conn, nil
}
// Channel 基于共享连接创建独立 channel。
func Channel(ctx context.Context) (*amqp.Channel, error) {
c, err := Conn(ctx)
if err != nil {
return nil, err
}
ch, err := c.Channel()
if err != nil {
return nil, gerror.Wrap(err, "rabbitmq open channel failed")
}
return ch, nil
}
// DeclareQueue 声明一个持久化队列。
func DeclareQueue(ctx context.Context, queue string) error {
ch, err := Channel(ctx)
if err != nil {
return err
}
defer ch.Close()
return declareQueue(ch, queue)
}
// Publish 将结构体序列化为 JSON 后发布到指定队列。
func Publish(ctx context.Context, queue string, data any) error {
body, err := json.Marshal(data)
if err != nil {
return gerror.Wrap(err, "rabbitmq marshal message failed")
}
return PublishBytes(ctx, queue, body)
}
// PublishDelayed 将结构体序列化为 JSON 后发布到延迟队列。
// 消息在 delayQueue 中等待 delay 后,通过死信路由进入 targetQueue。
func PublishDelayed(ctx context.Context, delayQueue, targetQueue string, delay time.Duration, data any) error {
body, err := json.Marshal(data)
if err != nil {
return gerror.Wrap(err, "rabbitmq marshal delayed message failed")
}
return PublishDelayedBytes(ctx, delayQueue, targetQueue, delay, body)
}
// PublishBytes 发布原始消息体到指定队列。
func PublishBytes(ctx context.Context, queue string, body []byte) error {
ch, err := Channel(ctx)
if err != nil {
return err
}
defer ch.Close()
// 发布前也声明一次队列,避免生产者先启动时队列不存在。
if err = declareQueue(ch, queue); err != nil {
return err
}
return ch.PublishWithContext(ctx, "", queue, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Timestamp: time.Now(),
Body: body,
})
}
// PublishDelayedBytes 发布原始消息体到延迟队列。
func PublishDelayedBytes(ctx context.Context, delayQueue, targetQueue string, delay time.Duration, body []byte) error {
ch, err := Channel(ctx)
if err != nil {
return err
}
defer ch.Close()
if err = declareQueue(ch, targetQueue); err != nil {
return err
}
if err = declareDelayQueue(ch, delayQueue, targetQueue); err != nil {
return err
}
return ch.PublishWithContext(ctx, "", delayQueue, false, false, amqp.Publishing{
ContentType: "application/json",
DeliveryMode: amqp.Persistent,
Expiration: fmt.Sprintf("%d", delay.Milliseconds()),
Timestamp: time.Now(),
Body: body,
})
}
// Consume 启动消费者,使用手动 Ack,handler 成功才确认消息。
func Consume(ctx context.Context, queue, consumer string, handler Handler) error {
ch, err := Channel(ctx)
if err != nil {
return err
}
if err = declareQueue(ch, queue); err != nil {
_ = ch.Close()
return err
}
if err = ch.Qos(1, 0, false); err != nil {
_ = ch.Close()
return gerror.Wrap(err, "rabbitmq set qos failed")
}
// autoAck=false,业务处理成功后手动 Ack,处理失败时 Nack 且不重新入队。
deliveries, err := ch.Consume(queue, consumer, false, false, false, false, nil)
if err != nil {
_ = ch.Close()
return gerror.Wrap(err, "rabbitmq consume failed")
}
registerCloser(ch.Close)
go func() {
for {
select {
case <-ctx.Done():
_ = ch.Close()
return
case delivery, ok := <-deliveries:
if !ok {
return
}
if err = handler(ctx, delivery.Body); err != nil {
g.Log().Errorf(ctx, "rabbitmq handle message failed, queue:%s, err:%+v", queue, err)
_ = delivery.Nack(false, false)
continue
}
// handler 内部已经完成业务处理或重新发布下一次重试消息,这里只确认当前消息。
_ = delivery.Ack(false)
}
}
}()
return nil
}
// ConsumeJSON 启动 JSON 消费者,统一完成反序列化和 Ack/Nack 处理。
func ConsumeJSON[T any](ctx context.Context, queue, consumer string, handler JSONHandler[T]) error {
return Consume(ctx, queue, consumer, func(ctx context.Context, body []byte) error {
var message T
if err := json.Unmarshal(body, &message); err != nil {
g.Log().Errorf(ctx, "rabbitmq message dropped, invalid json, queue:%s, body:%s, err:%+v", queue, string(body), err)
return nil
}
return handler(ctx, message)
})
}
// Close 关闭消费者 channel 和共享连接。
func Close() error {
closerMu.Lock()
for _, closer := range closers {
_ = closer()
}
closers = make(map[uint64]func() error)
closerMu.Unlock()
connMu.Lock()
defer connMu.Unlock()
if conn == nil {
return nil
}
err := conn.Close()
conn = nil
return err
}
// declareQueue 统一声明持久化队列,生产者和消费者都会调用,保证队列存在。
func declareQueue(ch *amqp.Channel, queue string) error {
_, err := ch.QueueDeclare(queue, true, false, false, false, nil)
if err != nil {
return gerror.Wrapf(err, "rabbitmq declare queue failed, queue:%s", queue)
}
return nil
}
// declareDelayQueue 声明 TTL 延迟队列,到期后死信转发到目标队列。
func declareDelayQueue(ch *amqp.Channel, delayQueue, targetQueue string) error {
_, err := ch.QueueDeclare(delayQueue, true, false, false, false, amqp.Table{
"x-dead-letter-exchange": "",
"x-dead-letter-routing-key": targetQueue,
})
if err != nil {
return gerror.Wrapf(err, "rabbitmq declare delay queue failed, queue:%s", delayQueue)
}
return nil
}
// registerCloser 记录消费者 channel,服务退出时统一关闭。
func registerCloser(closer func() error) func() {
closerMu.Lock()
defer closerMu.Unlock()
closerID++
id := closerID
closers[id] = closer
return func() {
closerMu.Lock()
defer closerMu.Unlock()
delete(closers, id)
}
}
func ConsumeJSONRequeue[T any](ctx context.Context, queue, consumer string, handler JSONHandler[T]) error {
ctx, cancel := context.WithCancel(ctx)
open := func() (*amqp.Channel, <-chan amqp.Delivery, error) {
channel, err := Channel(ctx)
if err != nil {
return nil, nil, err
}
if err = declareQueue(channel, queue); err == nil {
err = channel.Qos(1, 0, false)
}
if err != nil {
_ = channel.Close()
return nil, nil, err
}
deliveries, err := channel.Consume(queue, consumer, false, false, false, false, nil)
if err != nil {
_ = channel.Close()
return nil, nil, err
}
return channel, deliveries, nil
}
channel, deliveries, err := open()
if err != nil {
cancel()
return err
}
var channelMu sync.Mutex
unregister := registerCloser(func() error {
cancel()
channelMu.Lock()
defer channelMu.Unlock()
return channel.Close()
})
go func() {
defer func() {
cancel()
unregister()
_ = channel.Close()
}()
for {
select {
case <-ctx.Done():
return
case delivery, ok := <-deliveries:
if !ok {
_ = channel.Close()
for {
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second):
}
next, stream, reconnectErr := open()
if reconnectErr != nil {
g.Log().Errorf(ctx, "role sync consumer reconnect failed, queue:%s, err:%v", queue, reconnectErr)
continue
}
channelMu.Lock()
if ctx.Err() != nil {
channelMu.Unlock()
_ = next.Close()
return
}
channel, deliveries = next, stream
channelMu.Unlock()
break
}
continue
}
var message T
if decodeErr := json.Unmarshal(delivery.Body, &message); decodeErr != nil {
g.Log().Errorf(ctx, "rabbitmq invalid role sync message, queue:%s, err:%v", queue, decodeErr)
_ = delivery.Ack(false)
continue
}
if handleErr := handler(ctx, message); handleErr != nil {
g.Log().Errorf(ctx, "role sync message retained, queue:%s, err:%v", queue, handleErr)
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
}
_ = delivery.Nack(false, true)
continue
}
_ = delivery.Ack(false)
}
}
}()
return nil
}
@@ -0,0 +1,69 @@
package rolepolicy
import (
"context"
"slices"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"github.com/gogf/gf/v2/errors/gerror"
)
func WithMenuAncestors(ctx context.Context, ids []int64) ([]int64, error) {
var (
seen = make(map[int64]bool, len(ids))
queue = slices.Clone(ids)
)
for _, id := range ids {
seen[id] = true
}
for len(queue) > 0 {
var menus []*entity.SysMenu
if err := dao.SysMenu.Ctx(ctx).Master().WhereIn("menu_id", queue).Where("status", consts.SysMenuStatusNormal).Scan(&menus); err != nil {
return nil, err
}
queue = nil
for _, menu := range menus {
if menu.ParentId > 0 && !seen[menu.ParentId] {
seen[menu.ParentId] = true
queue = append(queue, menu.ParentId)
}
}
}
result := make([]int64, 0, len(seen))
for id := range seen {
result = append(result, id)
}
slices.Sort(result)
return result, nil
}
func NormalizeAssignableMenus(ctx context.Context, tenantID string, deptID, userID int64, ids []int64) ([]int64, error) {
allowed, err := RestrictAssignableMenus(ctx, tenantID, deptID, userID, ids)
if err != nil {
return nil, err
}
if len(allowed) > 0 {
var menus []*entity.SysMenu
if err := dao.SysMenu.Ctx(ctx).Master().WhereIn("menu_id", allowed).Where("status", consts.SysMenuStatusNormal).Scan(&menus); err != nil {
return nil, err
}
allowed = make([]int64, 0, len(menus))
for _, menu := range menus {
allowed = append(allowed, menu.MenuId)
}
}
visible, err := WithMenuAncestors(ctx, allowed)
if err != nil {
return nil, err
}
for _, id := range ids {
if !slices.Contains(visible, id) {
return nil, gerror.Newf("角色授权超出机构或模板范围,或菜单已停用(菜单ID:%d),请刷新授权列表后重试", id)
}
}
slices.Sort(allowed)
return slices.Compact(allowed), nil
}
@@ -0,0 +1,113 @@
package rolepolicy
import (
"context"
"reflect"
"strings"
"testing"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
func TestNormalizeAssignableMenus(t *testing.T) {
cases := []struct {
name string
ids []int64
mutation string
want []int64
wantError bool
}{
{name: "补齐多级父节点但不把父权限写入角色", ids: []int64{1, 10, 100}, want: []int64{100}},
{name: "重复选择去重", ids: []int64{100, 100}, want: []int64{100}},
{name: "空选择不自动授权", ids: []int64{}, want: []int64{}},
{name: "无关菜单仍拒绝", ids: []int64{100, 200}, wantError: true},
{name: "父节点单独不能获得权限", ids: []int64{10}, wantError: true},
{name: "不存在菜单仍拒绝", ids: []int64{999}, wantError: true},
{name: "停用菜单不能继续下发", ids: []int64{100}, mutation: "UPDATE sys_menu SET status='1' WHERE menu_id=100", wantError: true},
{name: "模板保存前后不裁剪已下发权限", ids: []int64{100}, mutation: "DELETE FROM sys_role_menu WHERE role_id=82 AND menu_id=100", want: []int64{100}},
{name: "上级角色调整不裁剪本机构权限", ids: []int64{100}, mutation: "DELETE FROM sys_role_menu WHERE role_id=120 AND menu_id=100", want: []int64{100}},
{name: "本机构默认角色撤权后不能继续下放", ids: []int64{100}, mutation: "DELETE FROM sys_role_menu WHERE role_id=121", wantError: true},
{name: "操作员无权限不能借机构权限授权", ids: []int64{100}, mutation: "DELETE FROM sys_role_menu WHERE role_id=122", wantError: true},
{name: "操作员角色停用不能授权", ids: []int64{100}, mutation: "UPDATE sys_role SET status='1' WHERE role_id=122", wantError: true},
{name: "菜单循环不会无限遍历", ids: []int64{1, 10, 100}, mutation: "UPDATE sys_menu SET parent_id=100 WHERE menu_id=1", want: []int64{100}},
}
for _, test := range cases {
t.Run(test.name, func(t *testing.T) {
setupAssignableTestDB(t)
ctx := context.Background()
if test.mutation != "" {
if _, err := g.DB().Exec(ctx, test.mutation); err != nil {
t.Fatal(err)
}
}
got, err := NormalizeAssignableMenus(ctx, "000000", 199, 162, test.ids)
if test.wantError {
if err == nil || !strings.Contains(err.Error(), "菜单ID") {
t.Fatalf("expected actionable denial, got %v, %v", got, err)
}
return
}
if err != nil || !reflect.DeepEqual(got, test.want) {
t.Fatalf("got %v, %v; want %v", got, err, test.want)
}
})
}
}
func setupAssignableTestDB(t *testing.T) {
t.Helper()
if err := gdb.SetConfig(gdb.Config{"default": {{Type: "sqlite", Link: "sqlite::@file(:memory:)"}}}); err != nil {
t.Fatal(err)
}
ctx := context.Background()
t.Cleanup(func() { _ = g.DB().Close(ctx) })
for _, statement := range []string{
"CREATE TABLE sys_config (tenant_id TEXT, config_key TEXT, config_value TEXT)",
"CREATE TABLE sys_role (role_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, created_dept INTEGER, status TEXT, data_scope TEXT, deleted_at DATETIME)",
"CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, parent_id INTEGER, dept_role INTEGER, status TEXT, deleted_at DATETIME)",
"CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)",
"CREATE TABLE sys_user_role (tenant_id TEXT, user_id INTEGER, role_id INTEGER)",
"CREATE TABLE sys_menu (menu_id INTEGER PRIMARY KEY, parent_id INTEGER, status TEXT, perms TEXT)",
"INSERT INTO sys_config VALUES ('000000','agent.role.template.role_id','82')",
"INSERT INTO sys_dept VALUES (100,'000000',0,1,'0',NULL),(198,'000000',100,120,'0',NULL),(199,'000000',198,121,'0',NULL)",
"INSERT INTO sys_role VALUES (82,'000000',100,100,'0','4',NULL),(120,'000000',198,198,'0','4',NULL),(121,'000000',199,199,'0','4',NULL),(122,'000000',199,199,'0','4',NULL)",
"INSERT INTO sys_menu VALUES (1,0,'0',''),(10,1,'0','system:parent:list'),(100,10,'0','system:child:view'),(200,1,'0','system:other:view')",
"INSERT INTO sys_role_menu VALUES ('000000',82,100,'0'),('000000',120,100,'0'),('000000',121,100,'0'),('000000',122,100,'0')",
"INSERT INTO sys_user_role VALUES ('000000',162,122)",
} {
if _, err := g.DB().Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
}
// Runtime permissions come from the user's roles; delegation additionally uses
// the organization's current default role, never the template's menu list.
func TestIndividualRolePermissionsAndDelegation(t *testing.T) {
setupAssignableTestDB(t)
ctx := context.Background()
for _, statement := range []string{
"INSERT INTO sys_role_menu VALUES ('000000',122,200,'0')",
"DELETE FROM sys_role_menu WHERE role_id IN (82,120) AND menu_id=100",
} {
if _, err := g.DB().Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
got, err := RestrictMenus(ctx, "000000", 199, []int64{100, 200})
if err != nil || !reflect.DeepEqual(got, []int64{100, 200}) {
t.Fatalf("individual role expansion must survive runtime checks: %v %v", got, err)
}
if _, err := NormalizeAssignableMenus(ctx, "000000", 199, 162, []int64{200}); err == nil {
t.Fatal("operator cannot delegate outside local default role")
}
if _, err := g.DB().Exec(ctx, "INSERT INTO sys_role_menu VALUES ('000000',121,200,'0')"); err != nil {
t.Fatal(err)
}
got, err = NormalizeAssignableMenus(ctx, "000000", 199, 162, []int64{200})
if err != nil || !reflect.DeepEqual(got, []int64{200}) {
t.Fatalf("platform expansion of local default role must become delegable: %v %v", got, err)
}
}
@@ -0,0 +1,54 @@
package rolepolicy
import (
"context"
"slices"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
)
// ValidateUserRoleAssignment prevents local operators from obtaining platform-only
// permissions by binding a more privileged role instead of editing its menus.
// Existing bindings may be retained when editing unrelated user information.
func ValidateUserRoleAssignment(ctx context.Context, tenantID string, deptID int64, requested, existing []int64) error {
if contexts.IsSuperAdmin(ctx) {
return nil
}
template, err := Template(ctx)
if err != nil {
return err
}
if template == nil || tenantID != template.TenantId {
return nil
}
if contexts.GetTenantId(ctx) != tenantID {
return gerror.New("不能跨租户分配角色")
}
for _, roleID := range requested {
if roleID == consts.SuperAdminRoleId {
return gerror.New("不能分配平台超级管理员角色")
}
if slices.Contains(existing, roleID) {
continue
}
var role *entity.SysRole
if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Where("role_id", roleID).Where("status", "0").Scan(&role); err != nil {
return err
}
if role == nil {
return gerror.Newf("角色%d不存在、已停用或不属于用户所在机构", roleID)
}
menus, err := MenuIDs(ctx, roleID)
if err != nil {
return err
}
if _, err := NormalizeAssignableMenus(ctx, tenantID, contexts.GetDeptId(ctx), contexts.GetUserId(ctx), menus); err != nil {
return gerror.Wrapf(err, "角色%d包含超出当前账号可分配范围的权限,请联系平台管理员绑定", roleID)
}
}
return nil
}
@@ -0,0 +1,48 @@
package rolepolicy
import (
"context"
"testing"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
)
func TestUserRoleAssignmentCannotBypassDelegation(t *testing.T) {
for _, test := range []struct {
name string
requested, existing []int64
super, denied bool
}{
{name: "可分配的普通角色", requested: []int64{124}},
{name: "不能通过绑定特殊角色扩权", requested: []int64{123}, denied: true},
{name: "修改资料允许保留平台已有授权", requested: []int64{123}, existing: []int64{123}},
{name: "平台可以单独绑定特殊角色", requested: []int64{123}, super: true},
{name: "本地账号不能授予超级管理员角色", requested: []int64{1}, denied: true},
{name: "不接受其他机构角色", requested: []int64{120}, denied: true},
{name: "可以移除已有角色", requested: []int64{}, existing: []int64{123}},
} {
t.Run(test.name, func(t *testing.T) {
setupAssignableTestDB(t)
actor := &model.Identity{BaseClaims: model.BaseClaims{ID: 162, TenantId: "000000", DeptId: 199}}
if test.super {
actor.Roles = []model.Role{{RoleId: consts.SuperAdminRoleId}}
}
ctx := contexts.Set(context.Background(), &model.Context{User: actor})
for _, statement := range []string{
"INSERT INTO sys_role VALUES(123,'000000',199,199,'0','4',NULL),(124,'000000',199,199,'0','4',NULL)",
"INSERT INTO sys_role_menu VALUES('000000',123,200,'0'),('000000',124,100,'0')",
} {
if _, err := g.DB().Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
err := ValidateUserRoleAssignment(ctx, "000000", 199, test.requested, test.existing)
if (err != nil) != test.denied {
t.Fatalf("denied=%v, error=%v", test.denied, err)
}
})
}
}
@@ -0,0 +1,242 @@
package rolepolicy
import (
"context"
"github.com/gogf/gf/v2/errors/gerror"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"strconv"
"strings"
)
const TemplateConfigKey = "agent.role.template.role_id"
func TemplateForRoles(ctx context.Context, roleIDs ...int64) (*entity.SysRole, error) {
var configs []*entity.SysConfig
if err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", TemplateConfigKey).Scan(&configs); err != nil {
return nil, err
}
for _, config := range configs {
roleID, err := strconv.ParseInt(strings.TrimSpace(config.ConfigValue), 10, 64)
if err != nil || roleID <= 0 {
continue
}
for _, targetID := range roleIDs {
if targetID != roleID {
continue
}
if len(configs) != 1 {
return nil, gerror.New("代理模板角色系统参数重复")
}
var role *entity.SysRole
if err := dao.SysRole.Ctx(ctx).Master().Where("role_id", roleID).Where("tenant_id", consts.DefaultSystemTenantCode).Scan(&role); err != nil {
return nil, err
}
if role == nil {
role = &entity.SysRole{RoleId: roleID, TenantId: consts.DefaultSystemTenantCode}
}
return role, nil
}
}
return nil, nil
}
func Template(ctx context.Context) (*entity.SysRole, error) {
var configs []*entity.SysConfig
if err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", TemplateConfigKey).Scan(&configs); err != nil {
return nil, err
}
if len(configs) == 0 {
return nil, nil
}
if len(configs) != 1 {
return nil, gerror.New("代理模板角色系统参数重复")
}
return ValidateTemplate(ctx, configs[0].ConfigValue)
}
func ValidateTemplate(ctx context.Context, value string) (*entity.SysRole, error) {
roleID, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil || roleID <= 0 || roleID == consts.SuperAdminRoleId {
return nil, gerror.New("代理模板参数必须填写非超级管理员的有效角色ID")
}
var role *entity.SysRole
if err = dao.SysRole.Ctx(ctx).Master().Where("role_id", roleID).Where("tenant_id", consts.DefaultSystemTenantCode).Scan(&role); err != nil {
return nil, err
}
if role == nil || role.Status != "0" || (role.CreatedDept > 0 && role.CreatedDept != role.DeptId) || role.DeptId <= 0 {
return nil, gerror.New("代理模板参数对应角色须正常启用、绑定本级机构且非上级继承角色")
}
if role.DataScope != consts.SysRoleDataScopeDeptAndBelow {
return nil, gerror.New("代理模板数据范围必须为本机构及下级")
}
var dept *entity.SysDept
if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", role.TenantId).Where("dept_id", role.DeptId).Where("status", "0").Scan(&dept); err != nil {
return nil, err
}
if dept == nil {
return nil, gerror.New("代理模板所属机构不存在或已停用")
}
invalidScopes, err := dao.SysRoleMenu.Ctx(ctx).Master().Where("tenant_id", role.TenantId).Where("role_id", role.RoleId).WhereIn("data_scope", []string{consts.SysRoleDataScopeAll, consts.SysRoleDataScopeCustom}).Count()
if err != nil {
return nil, err
}
if invalidScopes > 0 {
return nil, gerror.New("代理模板按钮不允许全部或自定义数据范围")
}
return role, nil
}
func MenuIDs(ctx context.Context, roleID int64) ([]int64, error) {
var menus []*entity.SysRoleMenu
if err := dao.SysRoleMenu.Ctx(ctx).Master().Where("role_id", roleID).Scan(&menus); err != nil {
return nil, err
}
ids := make([]int64, 0, len(menus))
for _, menu := range menus {
ids = append(ids, menu.MenuId)
}
return ids, nil
}
func Intersect(left, right []int64) []int64 {
allowed := make(map[int64]bool, len(right))
for _, id := range right {
allowed[id] = true
}
result := make([]int64, 0, len(left))
for _, id := range left {
if allowed[id] {
result = append(result, id)
}
}
return result
}
func CurrentSuperAdmin(ctx context.Context, userID int64) (bool, error) {
if userID <= 0 {
return false, nil
}
var relations []*entity.SysUserRole
if err := dao.SysUserRole.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("user_id", userID).Where("role_id", consts.SuperAdminRoleId).Scan(&relations); err != nil {
return false, err
}
if len(relations) == 0 {
return false, nil
}
var role *entity.SysRole
if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("role_id", consts.SuperAdminRoleId).Where("status", "0").Scan(&role); err != nil {
return false, err
}
return role != nil, nil
}
// RestrictMenus validates organization membership and role bindings. Templates are
// overwrite sources, not runtime permission ceilings: platform role edits must
// remain effective until the next explicit template distribution.
func RestrictMenus(ctx context.Context, tenantID string, deptID int64, ids []int64) ([]int64, error) {
template, err := Template(ctx)
if err != nil {
return nil, err
}
if template == nil {
return ids, nil
}
seen := make(map[int64]bool)
for deptID > 0 {
if seen[deptID] {
return nil, gerror.New("机构上下级存在循环")
}
seen[deptID] = true
var dept *entity.SysDept
if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Scan(&dept); err != nil {
return nil, err
}
if dept == nil || dept.Status != "0" {
return []int64{}, nil
}
if tenantID == template.TenantId && deptID == template.DeptId {
break
}
if dept.DeptRole == consts.SuperAdminRoleId {
return nil, gerror.Newf("机构%d不能使用超级管理员角色作为代理权限上限,请核对机构角色绑定", dept.DeptId)
}
if dept.DeptRole <= 0 {
return nil, gerror.Newf("机构%d未关联默认角色,请联系平台管理员核对机构角色绑定", dept.DeptId)
}
var role *entity.SysRole
if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Where("role_id", dept.DeptRole).Where("status", "0").Scan(&role); err != nil {
return nil, err
}
if role == nil {
return nil, gerror.Newf("机构%d默认角色不存在、已停用或归属不匹配,请联系平台管理员核对", dept.DeptId)
}
deptID = dept.ParentId
}
if tenantID == template.TenantId && !seen[template.DeptId] {
return []int64{}, nil
}
return ids, nil
}
func RestrictAssignableMenus(ctx context.Context, tenantID string, deptID, userID int64, ids []int64) ([]int64, error) {
allowed, err := RestrictMenus(ctx, tenantID, deptID, ids)
if err != nil {
return nil, err
}
// Local administrators can delegate only their organization's current default
// role permissions and their own active permissions. Ancestor/template menus
// do not cap a platform-authorized individual expansion.
template, err := Template(ctx)
if err != nil {
return nil, err
}
if template != nil && (tenantID != template.TenantId || deptID != template.DeptId) {
var dept *entity.SysDept
if err := dao.SysDept.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("dept_id", deptID).Scan(&dept); err != nil {
return nil, err
}
if dept == nil {
return []int64{}, nil
}
menus, err := MenuIDs(ctx, dept.DeptRole)
if err != nil {
return nil, err
}
allowed = Intersect(allowed, menus)
}
var relations []*entity.SysUserRole
if err := dao.SysUserRole.Ctx(ctx).Master().Where("tenant_id", tenantID).Where("user_id", userID).Scan(&relations); err != nil {
return nil, err
}
roleIDs := make([]int64, 0, len(relations))
for _, relation := range relations {
if relation.RoleId != consts.SuperAdminRoleId {
roleIDs = append(roleIDs, relation.RoleId)
}
}
if len(roleIDs) == 0 {
return []int64{}, nil
}
var roles []*entity.SysRole
if err := dao.SysRole.Ctx(ctx).Master().Where("tenant_id", tenantID).WhereIn("role_id", roleIDs).Where("status", "0").Scan(&roles); err != nil {
return nil, err
}
roleIDs = roleIDs[:0]
for _, role := range roles {
roleIDs = append(roleIDs, role.RoleId)
}
if len(roleIDs) == 0 {
return []int64{}, nil
}
var menus []*entity.SysRoleMenu
if err := dao.SysRoleMenu.Ctx(ctx).Master().Where("tenant_id", tenantID).WhereIn("role_id", roleIDs).Scan(&menus); err != nil {
return nil, err
}
owned := make([]int64, 0, len(menus))
for _, menu := range menus {
owned = append(owned, menu.MenuId)
}
return Intersect(allowed, owned), nil
}
@@ -0,0 +1,74 @@
package rolesync
import (
"context"
"time"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/google/uuid"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rabbitmq"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/rolepolicy"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
)
type deferRecordingContextKey struct{}
func DeferRecording(ctx context.Context) context.Context {
return context.WithValue(ctx, deferRecordingContextKey{}, true)
}
func EnqueueRole(ctx context.Context, roleID, actorID int64) error {
if deferred, _ := ctx.Value(deferRecordingContextKey{}).(bool); deferred {
return nil
}
if gdb.TXFromCtx(ctx, g.DB().GetGroup()) != nil {
return gerror.New("权限下发必须在数据库事务提交后入队")
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
var role *entity.SysRole
if err := dao.SysRole.Ctx(ctx).Master().Where("role_id", roleID).Scan(&role); err != nil {
return err
}
if role == nil {
return nil
}
target, err := rolepolicy.TemplateForRoles(ctx, roleID)
if err != nil {
return gerror.Wrap(err, "角色已保存,但无法识别代理模板,请检查系统参数后重新保存")
}
if target == nil {
if role.IsPass != 1 {
return nil
}
configured, err := dao.SysConfig.Ctx(ctx).Master().Where("tenant_id", consts.DefaultSystemTenantCode).Where("config_key", rolepolicy.TemplateConfigKey).Count()
if err != nil {
return gerror.Wrap(err, "角色已保存,但无法识别代理模板,请稍后重新保存")
}
if configured > 0 {
return nil
}
}
template, err := rolepolicy.Template(ctx)
if err != nil {
return gerror.Wrap(err, "角色已保存,但代理模板配置异常,无法下发;请修复模板后重新保存")
}
message := &model.AgentRoleSyncMessage{
TaskId: uuid.NewString(), TenantId: role.TenantId, SourceRoleId: role.RoleId,
SourceDeptId: role.DeptId, ActorId: actorID,
TemplateRole: template != nil && template.RoleId == role.RoleId,
}
if err := publishRoleSync(ctx, message); err != nil {
return gerror.Wrapf(err, "角色已保存,但下级权限下发任务入队失败,请稍后重新保存(任务ID:%s)", message.TaskId)
}
return nil
}
var publishRoleSync = func(ctx context.Context, message *model.AgentRoleSyncMessage) error {
return rabbitmq.PublishConfirmed(ctx, consts.QueueAgentRoleSync, message)
}
@@ -0,0 +1,113 @@
package rolesync
import (
"context"
"errors"
"strings"
"testing"
"time"
_ "github.com/gogf/gf/contrib/drivers/sqlite/v2"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
)
func TestEnqueueRoleOnlyAfterCommit(t *testing.T) {
if err := gdb.SetConfig(gdb.Config{"default": {{Type: "sqlite", Link: "sqlite::@file(:memory:)"}}}); err != nil {
t.Fatal(err)
}
ctx := context.Background()
t.Cleanup(func() { _ = g.DB().Close(ctx) })
for _, statement := range []string{
"CREATE TABLE sys_role (role_id INTEGER PRIMARY KEY, tenant_id TEXT, dept_id INTEGER, is_pass INTEGER, status TEXT DEFAULT '0', data_scope TEXT DEFAULT '4', created_dept INTEGER DEFAULT 100, deleted_at DATETIME)",
"CREATE TABLE sys_config (tenant_id TEXT, config_key TEXT, config_value TEXT)",
"CREATE TABLE sys_dept (dept_id INTEGER PRIMARY KEY, tenant_id TEXT, status TEXT)",
"CREATE TABLE sys_role_menu (tenant_id TEXT, role_id INTEGER, menu_id INTEGER, data_scope TEXT)",
"INSERT INTO sys_dept VALUES(100,'000000','0')",
"INSERT INTO sys_role(role_id,tenant_id,dept_id,is_pass) VALUES (18,'000000',100,1),(19,'000000',100,0)",
} {
if _, err := g.DB().Exec(ctx, statement); err != nil {
t.Fatal(err)
}
}
original := publishRoleSync
t.Cleanup(func() { publishRoleSync = original })
var messages []*model.AgentRoleSyncMessage
publishRoleSync = func(_ context.Context, message *model.AgentRoleSyncMessage) error {
messages = append(messages, message)
return nil
}
if err := g.DB().Transaction(ctx, func(txCtx context.Context, _ gdb.TX) error {
return EnqueueRole(txCtx, 18, 7)
}); err == nil {
t.Fatal("must reject publishing before commit")
}
if len(messages) != 0 {
t.Fatal("published inside transaction")
}
if err := EnqueueRole(ctx, 18, 7); err != nil {
t.Fatal(err)
}
if len(messages) != 1 || messages[0].TaskId == "" || messages[0].SourceRoleId != 18 {
t.Fatalf("unexpected message: %+v", messages)
}
if err := EnqueueRole(DeferRecording(ctx), 18, 7); err != nil {
t.Fatal(err)
}
if err := EnqueueRole(ctx, 19, 7); err != nil {
t.Fatal(err)
}
if len(messages) != 1 {
t.Fatal("non-inheritable or deferred role published")
}
publishRoleSync = func(context.Context, *model.AgentRoleSyncMessage) error { return errors.New("broker offline") }
if err := EnqueueRole(ctx, 18, 7); err == nil || !strings.Contains(err.Error(), "角色已保存") {
t.Fatalf("must report committed save and failed publication: %v", err)
}
publishRoleSync = func(_ context.Context, message *model.AgentRoleSyncMessage) error {
messages = append(messages, message)
return nil
}
if _, err := g.DB().Exec(ctx, "INSERT INTO sys_config VALUES('000000','agent.role.template.role_id','19')"); err != nil {
t.Fatal(err)
}
if err := EnqueueRole(ctx, 18, 7); err != nil {
t.Fatal(err)
}
if len(messages) != 1 {
t.Fatal("legacy local role must not overwrite configured hierarchy")
}
if err := EnqueueRole(ctx, 19, 7); err != nil {
t.Fatal(err)
}
if len(messages) != 2 || !messages[1].TemplateRole || messages[1].SourceRoleId != 19 {
t.Fatalf("configured template with is_pass=0 was not queued: %+v", messages)
}
t.Run("提交后请求取消不丢失下发且保留有限超时", func(t *testing.T) {
type requestKey struct{}
requestCtx, cancel := context.WithCancel(context.WithValue(ctx, requestKey{}, "actor"))
cancel()
published := false
publishRoleSync = func(publishCtx context.Context, message *model.AgentRoleSyncMessage) error {
if err := publishCtx.Err(); err != nil {
t.Fatalf("committed publication inherited cancellation: %v", err)
}
deadline, ok := publishCtx.Deadline()
if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 15*time.Second {
t.Fatalf("publication must have bounded timeout: %v %v", deadline, ok)
}
if publishCtx.Value(requestKey{}) != "actor" || message.ActorId != 7 {
t.Fatal("request values or actor lost")
}
published = true
return nil
}
if err := EnqueueRole(requestCtx, 19, 7); err != nil {
t.Fatal(err)
}
if !published {
t.Fatal("committed role was not published")
}
})
}
@@ -0,0 +1,61 @@
package tdengine
import (
"context"
"testing"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
)
func TestGetConfigDatabaseRouting(t *testing.T) {
previous := g.Cfg().GetAdapter()
t.Cleanup(func() { g.Cfg().SetAdapter(previous) })
for _, test := range []struct {
name string
fields string
fallback string
daily string
monthly string
}{
{"defaults", "", "iotlcm_daily", "iotlcm_daily", "iotlcm_daily"},
{"legacy", " dbName: legacy\n", "legacy", "legacy", "legacy"},
{"split", " dbName: legacy\n dailyDbName: iotlcm_daily\n monthlyDbName: iotlcm_monthly\n", "legacy", "iotlcm_daily", "iotlcm_monthly"},
{"daily_only", " dbName: legacy\n dailyDbName: iotlcm_daily\n", "legacy", "iotlcm_daily", "legacy"},
{"monthly_only", " dbName: legacy\n monthlyDbName: iotlcm_monthly\n", "legacy", "legacy", "iotlcm_monthly"},
} {
t.Run(test.name, func(t *testing.T) {
adapter, err := gcfg.NewAdapterContent("tsdb:\n database: TdEngine\n tdengine:\n batchSize: 20\n" + test.fields)
if err != nil {
t.Fatal(err)
}
g.Cfg().SetAdapter(adapter)
config := GetConfig(context.Background())
if config.DbName != test.fallback {
t.Fatalf("fallback=%q want=%q", config.DbName, test.fallback)
}
for _, stable := range []string{config.DailyStable, config.PoolDailyStable, config.PoolCardCountDailyStable} {
if got := config.DbNameFor(stable); got != test.daily {
t.Errorf("%s database=%q want=%q", stable, got, test.daily)
}
}
for _, stable := range []string{config.MonthlyStable, config.PoolMonthlyStable, config.PoolCardCountMonthlyStable} {
if got := config.DbNameFor(stable); got != test.monthly {
t.Errorf("%s database=%q want=%q", stable, got, test.monthly)
}
}
if got := config.DbNameFor("other_stable"); got != test.fallback {
t.Errorf("unknown stable database=%q", got)
}
})
}
}
func TestDatabaseRoutingEmptySplitNames(t *testing.T) {
config := Config{DbName: "legacy", DailyStable: "daily", MonthlyStable: "monthly"}
for _, stable := range []string{"daily", "monthly", "other"} {
if got := config.DbNameFor(stable); got != "legacy" {
t.Errorf("%s database=%q", stable, got)
}
}
}
@@ -0,0 +1,205 @@
package tdengine
import (
"context"
"database/sql"
"sync"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
_ "github.com/taosdata/driver-go/v3/taosRestful"
_ "github.com/taosdata/driver-go/v3/taosWS"
)
const (
// dailyStable 和 monthlyStable 是 TDengine 中已经提前创建好的超级表名称。
// 业务启动时只连接和读写,不负责自动建库建表。
dailyStable = "t_sim_card_usage_daily"
monthlyStable = "t_sim_card_usage_monthly"
poolDailyStable = "t_flow_pool_usage_daily"
poolMonthlyStable = "t_flow_pool_usage_monthly"
poolCardCountDailyStable = "t_flow_pool_card_count_daily"
poolCardCountMonthlyStable = "t_flow_pool_card_count_monthly"
defaultBatchSize = 200
// defaultDailyDbName / defaultMonthlyDbName 是配置缺失时的兑底库名。
// 日流量与月流量分库(duration 不同,而 duration 只能建库时确定)。
defaultDailyDbName = "iotlcm_daily"
defaultMonthlyDbName = "iotlcm_monthly"
)
// Config 是项目内 TDengine 连接和流量超级表配置。
//
// 日流量与月流量分库存储(iotlcm_daily / iotlcm_monthly):两库的 duration 需要
// 不同——日流量按天分片、月流量按月分片——而 TDengine 的 duration 只能建库时确定、
// 事后无法 alter。DbName 保留为兼容位,指向不支持分库的流量池等表的落库位置。
type Config struct {
Enabled bool
DriverName string
Dsn string
DbName string
DailyDbName string
MonthlyDbName string
DailyStable string
MonthlyStable string
PoolDailyStable string
PoolMonthlyStable string
PoolCardCountDailyStable string
PoolCardCountMonthlyStable string
MaxOpenConns int
MaxIdleConns int
BatchSize int
}
// pools 按库名缓存连接池。DSN 不携带库名(形如 ws(host:6041)/),
// 因此同一个 DSN 可以服务多个 database,日/月分库无需两套连接串。
var (
poolsMu sync.Mutex
pools = map[string]*sql.DB{}
)
// GetConfig 从 GoFrame 配置中读取 TDengine 配置。
// 表名保持在代码中固定,避免业务配置文件里暴露过多 TDengine 内部结构。
func GetConfig(ctx context.Context) Config {
database := g.Cfg().MustGet(ctx, "tsdb.database", "TdEngine").String()
// dbName 只是连接池的键名与兑底值,不决定实际访问哪个库:
// DSN 末尾留空、库名由 SQL 里的 库名.表名 指定。
// 默认值取日库,避免兑底到一个不存在的库名。
dbName := g.Cfg().MustGet(ctx, "tsdb.tdengine.dbName", defaultDailyDbName).String()
// dailyDbName / monthlyDbName 未配置时回退到 dbName。
dailyDbName := g.Cfg().MustGet(ctx, "tsdb.tdengine.dailyDbName", dbName).String()
monthlyDbName := g.Cfg().MustGet(ctx, "tsdb.tdengine.monthlyDbName", dbName).String()
return Config{
Enabled: database == "TdEngine",
DriverName: g.Cfg().MustGet(ctx, "tsdb.tdengine.type", "taosWS").String(),
Dsn: g.Cfg().MustGet(ctx, "tsdb.tdengine.dsn", "").String(),
DbName: dbName,
DailyDbName: dailyDbName,
MonthlyDbName: monthlyDbName,
DailyStable: dailyStable,
MonthlyStable: monthlyStable,
PoolDailyStable: poolDailyStable,
PoolMonthlyStable: poolMonthlyStable,
PoolCardCountDailyStable: poolCardCountDailyStable,
PoolCardCountMonthlyStable: poolCardCountMonthlyStable,
MaxOpenConns: g.Cfg().MustGet(ctx, "tsdb.tdengine.maxOpenConns", 100).Int(),
MaxIdleConns: g.Cfg().MustGet(ctx, "tsdb.tdengine.maxIdleConns", 10).Int(),
BatchSize: g.Cfg().MustGet(ctx, "tsdb.tdengine.batchSize", defaultBatchSize).Int(),
}
}
// DbNameFor 返回指定超级表应该写入的 database 名。
//
// 日流量与月流量分库:两库的 TDengine duration 不同(日库按天分片、月库按月分片),
// 而 duration 只能建库时确定。未单独配置 dailyDbName/monthlyDbName 时回退到 DbName。
func (c Config) DbNameFor(stable string) string {
switch stable {
case c.DailyStable, c.PoolDailyStable, c.PoolCardCountDailyStable:
if c.DailyDbName != "" {
return c.DailyDbName
}
case c.MonthlyStable, c.PoolMonthlyStable, c.PoolCardCountMonthlyStable:
if c.MonthlyDbName != "" {
return c.MonthlyDbName
}
}
return c.DbName
}
// Enabled 只判断当前项目是否启用 TDengine,并且 DSN 是否已经配置。
func Enabled(ctx context.Context) bool {
cfg := GetConfig(ctx)
return cfg.Enabled && cfg.Dsn != ""
}
// DB 懒加载并复用默认库(DbName)的连接池。
// 注意:这里不会创建 database/stable,库表需要先通过 SQL 脚本准备好。
func DB(ctx context.Context) (*sql.DB, error) {
return DBForDb(ctx, GetConfig(ctx).DbName)
}
// DBForDb 返回指定 database 的连接池;同一库名复用同一个池。
func DBForDb(ctx context.Context, dbName string) (*sql.DB, error) {
cfg := GetConfig(ctx)
if !cfg.Enabled {
return nil, gerror.New("tdengine is disabled")
}
if cfg.Dsn == "" {
return nil, gerror.New("tdengine dsn is empty")
}
if dbName == "" {
dbName = cfg.DbName
}
poolsMu.Lock()
defer poolsMu.Unlock()
if pool, ok := pools[dbName]; ok {
return pool, nil
}
driverName := cfg.DriverName
if driverName == "" {
driverName = "taosWS"
}
pool, err := sql.Open(driverName, cfg.Dsn)
if err != nil {
return nil, err
}
pool.SetMaxIdleConns(cfg.MaxIdleConns)
pool.SetMaxOpenConns(cfg.MaxOpenConns)
if err := pool.PingContext(ctx); err != nil {
_ = pool.Close()
return nil, err
}
pools[dbName] = pool
return pool, nil
}
// Exec 执行 TDengine 默认库的写入、更新、删除类 SQL。
func Exec(ctx context.Context, query string, args ...any) (sql.Result, error) {
conn, err := DB(ctx)
if err != nil {
return nil, err
}
return conn.ExecContext(ctx, query, args...)
}
// ExecForDb 在指定 database 的连接池上执行写入类 SQL。
func ExecForDb(ctx context.Context, dbName, query string, args ...any) (sql.Result, error) {
conn, err := DBForDb(ctx, dbName)
if err != nil {
return nil, err
}
return conn.ExecContext(ctx, query, args...)
}
// Query 执行 TDengine 查询 SQL,调用方负责关闭返回的 rows。
func Query(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
conn, err := DB(ctx)
if err != nil {
return nil, err
}
return conn.QueryContext(ctx, query, args...)
}
// QueryRow 执行 TDengine 单行查询 SQL。
func QueryRow(ctx context.Context, query string, args ...any) (*sql.Row, error) {
conn, err := DB(ctx)
if err != nil {
return nil, err
}
return conn.QueryRowContext(ctx, query, args...), nil
}
// Close 在应用退出时关闭所有 TDengine 连接池。
func Close() error {
poolsMu.Lock()
defer poolsMu.Unlock()
var firstErr error
for name, pool := range pools {
if err := pool.Close(); err != nil && firstErr == nil {
firstErr = err
}
delete(pools, name)
}
return firstErr
}
@@ -0,0 +1,613 @@
package utils
import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/tealeg/xlsx"
"github.com/gogf/gf/v2/crypto/gmd5"
"github.com/gogf/gf/v2/encoding/gcharset"
"github.com/gogf/gf/v2/encoding/gurl"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
)
// EncryptPassword 密码加密
func EncryptPassword(password, salt string) string {
return gmd5.MustEncryptString(gmd5.MustEncryptString(password) + gmd5.MustEncryptString(salt))
}
// GetDomain 获取当前请求接口域名
func GetDomain(ctx context.Context) string {
r := g.RequestFromCtx(ctx)
pathInfo, err := gurl.ParseURL(r.GetUrl(), -1)
if err != nil {
g.Log().Error(ctx, "GetDomain gurl.ParseURL", err)
return ""
}
return fmt.Sprintf("%s://%s:%s/", pathInfo["scheme"], pathInfo["host"], pathInfo["port"])
}
// GetClientIp 获取客户端IP
func GetClientIp(ctx context.Context) string {
return g.RequestFromCtx(ctx).GetClientIp()
}
// GetLocalIP 获取服务器内网IP
func GetLocalIP() (string, error) {
var localIP string
interfaces, err := net.Interfaces()
if err != nil {
return "", err
}
// 遍历所有网卡
for _, i := range interfaces {
if i.Flags&net.FlagUp == 0 {
continue // 网卡未开启
}
if i.Flags&net.FlagLoopback != 0 {
continue // 网卡为loopback地址
}
addrs, err := i.Addrs()
if err != nil {
return "", err
}
// 遍历网卡上的所有地址
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil || ip.IsLoopback() {
continue
}
ip = ip.To4()
if ip == nil {
continue // 不是ipv4地址
}
localIP = ip.String()
return localIP, nil
}
}
return "", fmt.Errorf("no local IP address found")
}
// GetPublicIP 获取公网IP
func GetPublicIP() (ip string, err error) {
return utility.GetPublicIP()
}
// GetUserAgent 获取user-agent
func GetUserAgent(ctx context.Context) string {
return ghttp.RequestFromCtx(ctx).Header.Get("User-Agent")
}
// GetCityByIp 获取ip所属城市
func GetCityByIp(ip string) string {
return utility.GetCityByIp(ip)
}
func RemoveRepeatedElementAndEmpty(arr []int) []int {
newArr := make([]int, 0)
for _, item := range arr {
repeat := false
if len(newArr) > 0 {
for _, v := range newArr {
if v == item {
repeat = true
break
}
}
}
if repeat {
continue
}
newArr = append(newArr, item)
}
return newArr
}
// RemoveDuplicationMap 数组去重
func RemoveDuplicationMap(arr []string) []string {
set := make(map[string]struct{}, len(arr))
j := 0
for _, v := range arr {
_, ok := set[v]
if ok {
continue
}
set[v] = struct{}{}
arr[j] = v
j++
}
return arr[:j]
}
// Decimal 保留两位小数
func Decimal(value float64) float64 {
value, _ = strconv.ParseFloat(fmt.Sprintf("%.2f", value), 64)
return value
}
// InArray 判断字符串是否存在数组中
func InArray(target string, strArray []string) bool {
sort.Strings(strArray)
index := sort.SearchStrings(strArray, target)
if index < len(strArray) && strArray[index] == target {
return true
}
return false
}
// FileSize 字节的单位转换 保留两位小数
func FileSize(fileSize int64) string {
units := []string{"B", "KB", "MB", "GB", "TB", "EB"}
var size = float64(fileSize)
var i int
for i = 0; size > 1024; i++ {
size /= 1024
}
return fmt.Sprintf("%.2f %s", size, units[i])
}
type fileInfo struct {
name string
size int64
}
// WalkDir 获取目录下文件的名称和大小
func WalkDir(dirname string) ([]fileInfo, error) {
var fileInfos []fileInfo
err := filepath.Walk(dirname, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
fileInfos = append(fileInfos, fileInfo{name: path, size: info.Size()})
}
return nil
})
return fileInfos, err
}
// DirSize 获取目录下所有文件大小
func DirSize(dirname string) string {
var (
s int64
files, _ = WalkDir(dirname)
)
for _, n := range files {
s += n.size
}
return FileSize(s)
}
func ConvertToStringSlice(data []interface{}) []string {
result := make([]string, len(data))
for i, v := range data {
str, ok := v.(string)
if !ok {
// 如果类型断言失败,可以在此处进行相应的错误处理
// 这里简单地将该元素转换为空字符串
str = ""
}
result[i] = str
}
return result
}
// 删除文件
func DeleteFile(name string) error {
//判断文件是否存在
if _, err := os.Stat(name); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
//打开文件
file, err := os.Open(name)
if err != nil {
return err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
fmt.Println("DeleteFile", err)
}
}(file)
//删除文件
err = os.Remove(name)
if err != nil {
return err
}
return nil
}
// ReverseReadLines 从文件末尾开始逆序高效地读取行。
func ReverseReadLines(name string) ([]string, error) {
// 打开文件。
file, err := os.Open(name)
if err != nil {
return nil, err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
g.Log().Error(context.Background(), "ReverseReadLines", err)
}
}(file)
// 获取文件大小。
fs, err := file.Stat()
if err != nil {
return nil, err
}
fileSize := fs.Size()
// 定义合理的缓冲区大小进行读取。这个值可以根据预期的文件大小和行长度进行调整。
const bufferSize = 1024 * 20
// 创建一个切片用于保存行。
var lines []string
// 从文件末尾开始分块读取。
for offset := fileSize; offset > 0; offset -= bufferSize {
// 计算读取的大小。
size := GetMin(bufferSize, offset)
buffer := make([]byte, size)
// 定位并读取这一块。
_, err := file.ReadAt(buffer, offset-size)
if err != nil {
return nil, err
}
// 将缓冲区分割成行,并以逆序添加它们。
scanner := bufio.NewScanner(bufio.NewReaderSize(file, int(size)))
var chunkLines []string
for scanner.Scan() {
chunkLines = append([]string{scanner.Text()}, chunkLines...)
}
// 将这一块的行添加到总行中。
lines = append(chunkLines, lines...)
if err := scanner.Err(); err != nil {
return nil, err
}
}
return lines, nil
}
func ReverseRead(name string, lineNum uint) ([]string, error) {
//打开文件
file, err := os.Open(name)
if err != nil {
return nil, err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
g.Log().Error(context.Background(), "ReverseRead file.Close", err)
}
}(file)
//获取文件大小
fs, err := file.Stat()
if err != nil {
return nil, err
}
fileSize := fs.Size()
var offset int64 = -1 //偏移量,初始化为-1,若为0则会读到EOF
char := make([]byte, 1) //用于读取单个字节
lineStr := "" //存放一行的数据
buff := make([]string, 0, 100)
for (-offset) <= fileSize {
//通过Seek函数从末尾移动游标然后每次读取一个字节
_, err := file.Seek(offset, io.SeekEnd)
if err != nil {
return nil, err
}
_, err = file.Read(char)
if err != nil {
return buff, err
}
if char[0] == '\n' {
offset-- //windows跳过'\r'
lineNum-- //到此读取完一行
buff = append(buff, lineStr)
lineStr = ""
if lineNum == 0 {
return buff, nil
}
} else {
lineStr = string(char) + lineStr
}
offset--
}
buff = append(buff, lineStr)
//使用mahonia解码
for i := 0; i < len(buff); i++ {
buff[i], err = gcharset.ToUTF8("UTF-8", buff[i])
}
return buff, nil
}
// 文件一块一块的读取
func ReadBlock(filePath string) {
start1 := time.Now()
file, err := os.Open(filePath)
if err != nil {
g.Log().Error(context.Background(), "ReadBlock Open", err)
return
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
g.Log().Error(context.Background(), "ReadBlock file.Close", err)
}
}(file)
// 设置每次读取字节数
buffer := make([]byte, 1024*4)
for {
n, err := file.Read(buffer)
// 控制条件,根据实际调整
if err != nil && err != io.EOF {
g.Log().Error(context.Background(), "ReadBlock Read", err)
}
if n == 0 {
break
}
// 如下代码打印出每次读取的文件块(字节数)
//fmt.Println(string(buffer[:n]))
}
fmt.Println("readBolck spend : ", time.Now().Sub(start1))
}
const (
kilobyte = 1024
megabyte = 1024 * kilobyte
gigabyte = 1024 * megabyte
terabyte = 1024 * gigabyte
petabyte = 1024 * terabyte
)
// FormatSize 格式化文件大小。
func FormatSize(size int64) string {
switch {
case size < kilobyte:
return strconv.Itoa(int(size)) + "B"
case size < megabyte:
return fmt.Sprintf("%.2fK", float64(size)/kilobyte)
case size < gigabyte:
return fmt.Sprintf("%.2fM", float64(size)/megabyte)
case size < terabyte:
return fmt.Sprintf("%.2fG", float64(size)/gigabyte)
case size < petabyte:
return fmt.Sprintf("%.2fT", float64(size)/terabyte)
default:
return fmt.Sprintf("%.2fP", float64(size)/petabyte)
}
}
func ValidatePassword(password string, minimumLength int, requireComplexity int, requireDigit int, requireLowercase int, requireUppercase int) (flag bool, err error) {
//初始化返回结果
flag = true
//判断密码长度
if len(password) < minimumLength {
err = gerror.New(fmt.Sprintf(g.I18n().T(context.TODO(), "{#utilsValidatePwLen}"), minimumLength))
flag = false
return
}
//是否有复杂字符
if requireComplexity == 1 && !hasComplexCharacters(password) {
err = gerror.New(g.I18n().T(context.TODO(), "{#utilsValidatePwChar}"))
flag = false
return
}
//是否有数字
if requireDigit == 1 && !hasDigit(password) {
err = gerror.New(g.I18n().T(context.TODO(), "{#utilsValidatePwDigit}"))
flag = false
return
}
//是否有小写字母
if requireLowercase == 1 && !hasLowercaseLetter(password) {
err = gerror.New(g.I18n().T(context.TODO(), "{#utilsValidatePwLower}"))
flag = false
return
}
//是否有大写字母
if requireUppercase == 1 && !hasUppercaseLetter(password) {
err = gerror.New(g.I18n().T(context.TODO(), "{#utilsValidatePwUpper}"))
flag = false
return
}
return
}
// hasComplexCharacters:检查字符串中是否有复杂字符(特殊字符)
func hasComplexCharacters(str string) bool {
specialCharacters := "!@#$%^&*()_+-=[]{}|;:,.<>?~"
for _, char := range str {
if contains(specialCharacters, string(char)) {
return true
}
}
return false
}
// hasDigit:检查字符串中是否有数字
func hasDigit(str string) bool {
for _, char := range str {
if isDigit(string(char)) {
return true
}
}
return false
}
// hasLowercaseLetter:检查字符串中是否有小写字母
func hasLowercaseLetter(str string) bool {
for _, char := range str {
if isLowercase(string(char)) {
return true
}
}
return false
}
// hasUppercaseLetter:检查字符串中是否有大写字母
func hasUppercaseLetter(str string) bool {
for _, char := range str {
if isUppercase(string(char)) {
return true
}
}
return false
}
// isDigit:判断字符是否是数字
func isDigit(c string) bool {
return c >= "0" && c <= "9"
}
// isLowercase:判断字符是否是小写字母
func isLowercase(c string) bool {
return c >= "a" && c <= "z"
}
// isUppercase:判断字符是否是大写字母
func isUppercase(c string) bool {
return c >= "A" && c <= "Z"
}
// contains:判断字符串是否包含指定字符
func contains(str, char string) bool {
for _, c := range str {
if string(c) == char {
return true
}
}
return false
}
// GetMin 返回两个整数中的较小值
func GetMin(a, b int64) int64 {
if a < b {
return a
}
return b
}
// 返回自定义错误码
func NewGError(code gcode.Code) error {
return gerror.NewCode(code, code.Message())
}
// 返回自定义错误码
func NewGErrorS(code gcode.Code, text string) error {
return gerror.NewCode(code, text)
}
// ToExcel 生成io.ReadSeeker 参数 titleList 为Excel表头,dataList 为数据
func ToExcel(dataList []interface{}) (content io.ReadSeeker) {
// 生成一个新的文件
file := xlsx.NewFile()
// 添加sheet页
sheet, _ := file.AddSheet("Sheet1")
// 插入表头
titleRow := sheet.AddRow()
//获取表头
objType := reflect.TypeOf(dataList[0])
elem := objType.Elem()
var titleList []string
if elem.Kind() == reflect.Struct {
for i := 1; i <= elem.NumField(); i++ {
field := elem.Field(i - 1)
if field.Name != "PageReq" {
if field.Tag != "" && field.Tag.Get("description") != "" {
titleList = append(titleList, g.I18n().T(context.TODO(), field.Tag.Get("description")))
} else {
titleList = append(titleList, g.I18n().T(context.TODO(), field.Name))
}
}
}
}
for _, v := range titleList {
cell := titleRow.AddCell()
cell.Value = v
//表头字体颜色
cell.GetStyle().Font.Color = "000000"
cell.GetStyle().Fill.BgColor = "cfe2f3"
//居中显示
cell.GetStyle().Alignment.Horizontal = "center"
cell.GetStyle().Alignment.Vertical = "center"
}
// 插入内容
for _, v := range dataList {
row := sheet.AddRow()
row.WriteStruct(v, -1)
}
var buffer bytes.Buffer
_ = file.Write(&buffer)
content = bytes.NewReader(buffer.Bytes())
return
}
// 解析时间
func ParseDuration(d string) (time.Duration, error) {
d = strings.TrimSpace(d)
dr, err := time.ParseDuration(d)
if err == nil {
return dr, nil
}
if strings.Contains(d, "d") {
index := strings.Index(d, "d")
hour, _ := strconv.Atoi(d[:index])
dr = time.Hour * 24 * time.Duration(hour)
ndr, err := time.ParseDuration(d[index+1:])
if err != nil {
return dr, nil
}
return dr + ndr, nil
}
dv, err := strconv.ParseInt(d, 10, 64)
return time.Duration(dv), err
}
@@ -0,0 +1,156 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
import (
"context"
"fmt"
"runtime/debug"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/uuid32"
"github.com/gogf/gf/v2/container/garray"
"github.com/gogf/gf/v2/frame/g"
"github.com/gorilla/websocket"
)
const (
// 用户连接超时时间
heartbeatExpirationTime = 6 * 60
)
// 用户登录
type login struct {
UserId uint64
Client *Client
}
// GetKey 读取客户端数据
func (l *login) GetKey() (key string) {
key = GetUserKey(l.UserId)
return
}
// Client 客户端连接
type Client struct {
Addr string // 客户端地址
ID string // 连接唯一标识
Socket *websocket.Conn // 用户连接
Send chan *WResponse // 待发送的数据
SendClose bool // 发送是否关闭
UserId uint64 // 用户ID,用户登录以后才有
FirstTime uint64 // 首次连接事件
HeartbeatTime uint64 // 用户上次心跳时间
LoginTime uint64 // 登录时间 登录以后才有
isApp bool // 是否是app
tags *garray.StrArray // 标签
Ctx context.Context // 上下文
closeSignal chan struct{} // 关闭信号
}
// NewClient 初始化
func NewClient(addr string, socket *websocket.Conn, firstTime uint64, ctx context.Context) (client *Client) {
client = &Client{
Addr: addr,
ID: uuid32.New(),
Socket: socket,
Send: make(chan *WResponse, 100),
SendClose: false,
FirstTime: firstTime,
HeartbeatTime: firstTime,
Ctx: ctx,
closeSignal: make(chan struct{}, 1),
tags: garray.NewStrArray(true),
}
return
}
// 读取客户端数据
func (c *Client) read() {
defer func() {
if r := recover(); r != nil {
fmt.Println("write stop", string(debug.Stack()), r)
}
}()
defer func() {
c.close()
}()
for {
_, message, err := c.Socket.ReadMessage()
if err != nil {
g.Log().Info(c.Ctx, "websocket Client.read failed.", err)
return
}
// 处理程序
// fmt.Println(message)
ProcessData(c, message)
}
}
// 向客户端写数据
func (c *Client) write() {
defer func() {
if r := recover(); r != nil {
fmt.Println("write stop", string(debug.Stack()), r)
}
}()
defer func() {
clientManager.Unregister <- c
_ = c.Socket.Close()
}()
for {
select {
case <-c.closeSignal:
g.Log().Infof(mctx, "websocket client exit, user:%+v", c.UserId)
return
case message, ok := <-c.Send:
if !ok {
g.Log().Info(c.Ctx, "websocket Client.write failed.")
// 发送数据错误 关闭连接
return
}
_ = c.Socket.WriteJSON(message)
}
}
}
// SendMsg 发送数据
func (c *Client) SendMsg(msg *WResponse) {
if c == nil || c.SendClose {
return
}
defer func() {
if r := recover(); r != nil {
fmt.Println("SendMsg stop:", r, string(debug.Stack()))
}
}()
c.Send <- msg
}
// Heartbeat 心跳更新
func (c *Client) Heartbeat(currentTime uint64) {
c.HeartbeatTime = currentTime
}
// IsHeartbeatTimeout 心跳是否超时
func (c *Client) IsHeartbeatTimeout(currentTime uint64) (timeout bool) {
if c.HeartbeatTime+heartbeatExpirationTime <= currentTime {
timeout = true
}
return
}
// 关闭客户端
func (c *Client) close() {
g.Log().Info(c.Ctx, "websocket Client.close")
if c.SendClose {
return
}
c.SendClose = true
c.closeSignal <- struct{}{}
}
@@ -0,0 +1,384 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
import (
"context"
"fmt"
"runtime/debug"
"sync"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcron"
"github.com/gogf/gf/v2/os/gtime"
)
// ClientManager 客户端管理
type ClientManager struct {
Clients map[*Client]bool // 全部的连接
ClientsLock sync.RWMutex // 读写锁
Users map[string]*Client // 登录的用户 // uuid
UserLock sync.RWMutex // 读写锁
Register chan *Client // 连接连接处理
Login chan *login // 用户登录处理
Unregister chan *Client // 断开连接处理程序
Broadcast chan *WResponse // 广播 向全部成员发送数据
ClientBroadcast chan *ClientWResponse // 广播 向某个客户端发送数据
TagBroadcast chan *TagWResponse // 广播 向某个标签成员发送数据
UserBroadcast chan *UserWResponse // 广播 向某个用户的所有链接发送数据
TagCallbackMap map[string]func(client *Client, tag string) // 注册标签回调
closeSignal chan struct{} // 关闭信号
TagCountMap map[string]int // 标签map
TagLock sync.RWMutex // 标签读写锁
}
func NewClientManager() (clientManager *ClientManager) {
clientManager = &ClientManager{
Clients: make(map[*Client]bool),
Users: make(map[string]*Client),
Register: make(chan *Client, 1000),
Unregister: make(chan *Client, 1000),
Broadcast: make(chan *WResponse, 1000),
TagBroadcast: make(chan *TagWResponse, 1000),
UserBroadcast: make(chan *UserWResponse, 1000),
TagCallbackMap: make(map[string]func(client *Client, tag string)),
closeSignal: make(chan struct{}, 1),
TagCountMap: make(map[string]int),
TagLock: sync.RWMutex{},
}
return
}
// GetUserKey 获取用户key
func GetUserKey(userId uint64) (key string) {
key = fmt.Sprintf("%s_%d", "ws", userId)
return
}
// InClient 客户端是否存在
func (manager *ClientManager) InClient(client *Client) (ok bool) {
manager.ClientsLock.RLock()
defer manager.ClientsLock.RUnlock()
_, ok = manager.Clients[client]
return
}
// GetClients 获取所有客户端
func (manager *ClientManager) GetClients() (clients map[*Client]bool) {
clients = make(map[*Client]bool)
manager.ClientsRange(func(client *Client, value bool) (result bool) {
clients[client] = value
return true
})
return
}
// ClientsRange 遍历
func (manager *ClientManager) ClientsRange(f func(client *Client, value bool) bool) {
manager.ClientsLock.RLock()
defer func() {
manager.ClientsLock.RUnlock()
}()
for key, value := range manager.Clients {
r := f(key, value)
if !r {
break
}
}
}
// GetClientsLen 获取客户端总数
func (manager *ClientManager) GetClientsLen() (clientsLen int) {
clientsLen = len(manager.Clients)
return
}
// AddClients 添加客户端
func (manager *ClientManager) AddClients(client *Client) {
manager.ClientsLock.Lock()
defer manager.ClientsLock.Unlock()
manager.Clients[client] = true
}
// DelClients 删除客户端
func (manager *ClientManager) DelClients(client *Client) {
manager.ClientsLock.Lock()
defer manager.ClientsLock.Unlock()
if _, ok := manager.Clients[client]; ok {
// 删除标签
client.tags.Iterator(func(k int, name string) bool {
DelTagCount(name)
return true
})
delete(manager.Clients, client)
}
}
// GetUserClient 获取用户的连接
func (manager *ClientManager) GetUserClient(userId uint64) (client *Client) {
manager.UserLock.RLock()
defer manager.UserLock.RUnlock()
userKey := GetUserKey(userId)
if value, ok := manager.Users[userKey]; ok {
client = value
}
return
}
// AddUsers 添加用户
func (manager *ClientManager) AddUsers(key string, client *Client) {
manager.UserLock.Lock()
defer manager.UserLock.Unlock()
manager.Users[key] = client
}
// DelUsers 删除用户
func (manager *ClientManager) DelUsers(client *Client) (result bool) {
manager.UserLock.Lock()
defer manager.UserLock.Unlock()
key := GetUserKey(client.UserId)
if value, ok := manager.Users[key]; ok {
// 判断是否为相同的用户
if value.Addr != client.Addr {
return
}
delete(manager.Users, key)
result = true
}
return
}
// GetUsersLen 已登录用户数
func (manager *ClientManager) GetUsersLen() (userLen int) {
userLen = len(manager.Users)
return
}
// EventRegister 用户建立连接事件
func (manager *ClientManager) EventRegister(client *Client) {
manager.AddClients(client)
//发送当前客户端标识
client.SendMsg(&WResponse{Event: "connected", Data: g.Map{
"ID": client.ID,
}})
if client.UserId > 0 {
manager.AddUsers(GetUserKey(client.UserId), client)
}
g.Log().Info(client.Ctx, "websocket EventRegister.", client.UserId)
}
// EventLogin 用户登录事件
func (manager *ClientManager) EventLogin(login *login) {
client := login.Client
if manager.InClient(client) {
userKey := login.GetKey()
manager.AddUsers(userKey, login.Client)
}
}
// EventUnregister 用户断开连接事件
func (manager *ClientManager) EventUnregister(client *Client) {
g.Log().Info(client.Ctx, "websocket EventUnregister.", client.UserId)
manager.DelClients(client)
// 删除用户连接
deleteResult := manager.DelUsers(client)
if !deleteResult {
// 不是当前连接的客户端
return
}
// 关闭 chan
// close(client.Send)
client.close()
}
// ClearTimeoutConnections 定时清理超时连接
func (manager *ClientManager) clearTimeoutConnections() {
currentTime := uint64(gtime.Now().Unix())
clients := clientManager.GetClients()
for client := range clients {
if client.IsHeartbeatTimeout(currentTime) {
//fmt.Println("心跳时间超时 关闭连接", client.Addr, client.UserId, client.LoginTime, client.HeartbeatTime)
_ = client.Socket.Close()
}
}
}
// WebsocketPing 心跳处理
func (manager *ClientManager) ping(ctx context.Context) {
//定时任务,发送心跳包
_, _ = gcron.Add(ctx, "0 */1 * * * *", func(ctx context.Context) {
res := &WResponse{
Event: Ping,
Data: g.Map{},
}
SendToAll(res)
})
// 定时任务,清理超时连接
_, _ = gcron.Add(ctx, "*/30 * * * * *", func(ctx context.Context) {
manager.clearTimeoutConnections()
})
}
// 管道处理程序
func (manager *ClientManager) start() {
defer func() {
if r := recover(); r != nil {
g.Log().Warningf(mctx, "websocket start recover:%+v, stack:%+v", r, string(debug.Stack()))
return
}
}()
for {
select {
case conn := <-manager.Register:
// 建立连接事件
manager.EventRegister(conn)
case login := <-manager.Login:
// 用户登录
manager.EventLogin(login)
case conn := <-manager.Unregister:
// 断开连接事件
manager.EventUnregister(conn)
case message := <-manager.Broadcast:
// 全部客户端广播事件
clients := manager.GetClients()
for conn := range clients {
conn.SendMsg(message)
}
case message := <-manager.TagBroadcast:
// 标签广播事件
clients := manager.GetClients()
// find := false
for conn := range clients {
if conn.tags.Contains(message.Tag) {
conn.SendMsg(message.WResponse)
// find = true
}
}
// g.Log().Debugf(context.TODO(), "client_manager.go 标签广播事件 find:%v msg:%v", find, message)
case message := <-manager.UserBroadcast:
// 用户广播事件
clients := manager.GetClients()
for conn := range clients {
if conn.UserId == message.UserID {
conn.SendMsg(message.WResponse)
}
}
case message := <-manager.ClientBroadcast:
// 单个客户端广播事件
clients := manager.GetClients()
for conn := range clients {
if conn.ID == message.ID {
conn.SendMsg(message.WResponse)
}
}
case <-manager.closeSignal:
g.Log().Debug(mctx, "websocket closeSignal exit.")
return
}
}
}
// SendToAll 发送全部客户端
func SendToAll(response *WResponse) {
clientManager.Broadcast <- response
}
// SendToClientID 发送单个客户端
func SendToClientID(id string, response *WResponse) {
clientRes := &ClientWResponse{
ID: id,
WResponse: response,
}
clientManager.ClientBroadcast <- clientRes
}
// SendToUser 发送单个用户
func SendToUser(userID uint64, response *WResponse) {
userRes := &UserWResponse{
UserID: userID,
WResponse: response,
}
clientManager.UserBroadcast <- userRes
// g.Log().Debugf(context.Background(), "websocket SendToUser userID:%v response:%+v", userID, response)
}
// SendToTag 发送某个标签
func SendToTag(tag string, response *WResponse) {
count := GetTagCount(tag)
if count == 0 {
return
}
tagRes := &TagWResponse{
Tag: tag,
WResponse: response,
}
clientManager.TagBroadcast <- tagRes
}
// RegisterTagCallback 注册标签回调
func RegisterTagCallback(tag string, callback func(client *Client, tag string)) {
clientManager.TagLock.Lock()
defer clientManager.TagLock.Unlock()
clientManager.TagCallbackMap[tag] = callback
}
// UnregisterTagCallback 注销标签回调
func UnregisterTagCallback(tag string) {
clientManager.TagLock.Lock()
defer clientManager.TagLock.Unlock()
delete(clientManager.TagCallbackMap, tag)
}
// 添加标签订阅计数
func AddTagCount(tag string) {
clientManager.TagLock.Lock()
defer clientManager.TagLock.Unlock()
if _, ok := clientManager.TagCountMap[tag]; !ok {
clientManager.TagCountMap[tag] = 1
} else {
clientManager.TagCountMap[tag]++
}
}
// 删除标签订阅计数
func DelTagCount(tag string) {
clientManager.TagLock.Lock()
defer clientManager.TagLock.Unlock()
count, ok := clientManager.TagCountMap[tag]
if ok {
if count > 0 {
clientManager.TagCountMap[tag]--
} else {
delete(clientManager.TagCountMap, tag)
}
}
}
// GetTagCount 获取标签订阅计数
func GetTagCount(tag string) (count int) {
clientManager.TagLock.RLock()
defer clientManager.TagLock.RUnlock()
count, ok := clientManager.TagCountMap[tag]
if ok {
return count
}
return 0
}
// 变量Tag Count
func RangeTagCount(callback func(tag string, count int)) {
clientManager.TagLock.RLock()
defer clientManager.TagLock.RUnlock()
for tag, count := range clientManager.TagCountMap {
callback(tag, count)
}
}
@@ -0,0 +1,125 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
import (
"fmt"
"strings"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/util/gconv"
)
// LoginController 用户登录
func LoginController(client *Client, req *request) {
l := &WsLoginDataReq{}
gconv.Struct(req.Data, &l)
userId := gconv.Uint64(l.UserID)
client.UserId = userId
// 用户登录
login := &login{
UserId: userId,
Client: client,
}
clientManager.Login <- login
client.SendMsg(&WResponse{
Event: Login,
Data: "success",
})
}
func IsAppController(client *Client) {
client.isApp = true
}
// JoinController 加入
func JoinController(client *Client, req *request) {
name := gconv.String(req.Data["name"])
if !client.tags.Contains(name) {
client.tags.Append(name)
for k, v := range clientManager.TagCallbackMap {
if strings.HasPrefix(name, k) {
if v != nil {
v(client, name)
}
}
}
AddTagCount(name)
}
client.SendMsg(&WResponse{
Event: Join,
Data: client.tags.Slice(),
})
}
// QuitController 退出
func QuitController(client *Client, req *request) {
name := gconv.String(req.Data["name"])
if client.tags.Contains(name) {
client.tags.RemoveValue(name)
DelTagCount(name)
}
client.SendMsg(&WResponse{
Event: Quit,
Data: client.tags.Slice(),
})
}
func PingController(client *Client) {
currentTime := uint64(gtime.Now().Unix())
client.Heartbeat(currentTime)
}
// JoinsController 加入
func JoinsController(client *Client, req *request) {
if req == nil || req.Data == nil {
fmt.Println("websocket.JoinsController req is nil or req.Data is nil")
return
}
// fmt.Println("websocket.JoinsController req.Data", req.Data)
names, ok := req.Data["names"]
if !ok || names == nil {
fmt.Println("websocket.JoinsController names is nil or names is empty")
return
}
// fmt.Println("websocket.JoinsController names", names)
for _, name := range gconv.Strings(names) {
if name == "" {
continue
}
if !client.tags.Contains(name) {
client.tags.Append(name)
for k, v := range clientManager.TagCallbackMap {
if strings.HasPrefix(name, k) {
if v != nil {
v(client, name)
}
}
}
AddTagCount(name)
}
}
client.SendMsg(&WResponse{
Event: Joins,
Data: client.tags.Slice(),
})
}
// QuitController 退出
func QuitsController(client *Client, req *request) {
for _, name := range gconv.Strings(req.Data["names"]) {
if client.tags.Contains(name) {
client.tags.RemoveValue(name)
DelTagCount(name)
}
}
client.SendMsg(&WResponse{
Event: Quits,
Data: client.tags.Slice(),
})
}
@@ -0,0 +1,63 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"net/http"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gorilla/websocket"
)
var (
mctx = gctx.GetInitCtx() // 上下文
clientManager = NewClientManager() // 管理者
)
var upGrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func StartWebSocket(ctx context.Context) {
g.Log().Info(ctx, "启动:WebSocket")
go clientManager.start()
go clientManager.ping(ctx)
}
func StopWebSocket(ctx context.Context) {
g.Log().Info(ctx, "停止:WebSocket")
clientManager.closeSignal <- struct{}{}
}
func WsPage(r *ghttp.Request) {
g.Log().Infof(r.GetCtx(), "websocket.WsPage Begin. UserID:%v ", contexts.GetUserId(r.GetCtx()))
// r.Response.Header().Set("Content-Type", "text/event-stream")
contexts.SetIsWebSocket(r.GetCtx(), true)
conn, err := upGrader.Upgrade(r.Response.Writer, r.Request, nil)
if err != nil {
r.Response.Write(err.Error())
return
}
currentTime := uint64(gtime.Now().Unix())
client := NewClient(conn.RemoteAddr().String(), conn, currentTime, r.GetCtx())
client.UserId = uint64(contexts.GetUserId(r.GetCtx()))
go client.read()
go client.write()
// 用户连接事件
clientManager.Register <- client
g.Log().Infof(r.GetCtx(), "websocket.WsPage End. UserID:%v", client.UserId)
}
@@ -0,0 +1,39 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
import "github.com/gogf/gf/v2/frame/g"
// 当前输入对象
type request struct {
Event string `json:"e"` //事件名称
Data g.Map `json:"d"` //数据
}
// WResponse 输出对象
type WResponse struct {
Event string `json:"e"` //事件名称
Data interface{} `json:"d"` //数据
}
type TagWResponse struct {
Tag string
WResponse *WResponse
}
type UserWResponse struct {
UserID uint64
WResponse *WResponse
}
type ClientWResponse struct {
ID string
WResponse *WResponse
}
type WsLoginDataReq struct {
UserID uint `json:"userId" `
}
@@ -0,0 +1,62 @@
// Package websocket
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package websocket
import (
"fmt"
"github.com/gogf/gf/v2/util/gconv"
)
const (
Error = "error"
Login = "login"
Join = "join"
Quit = "quit"
IsApp = "is_app"
Ping = "ping"
Joins = "joins"
Quits = "quits"
)
// ProcessData 处理数据
func ProcessData(client *Client, message []byte) {
defer func() {
if r := recover(); r != nil {
fmt.Println("websocket.ProcessData 处理数据 stop", r, "message=", message)
}
}()
request := &request{}
err := gconv.Struct(message, request)
if err != nil {
fmt.Println("websocket.ProcessData 数据解析失败:", err)
return
}
fmt.Println("websocket.ProcessData request", request)
switch request.Event {
case Login:
LoginController(client, request)
break
case Join:
JoinController(client, request)
break
case Quit:
QuitController(client, request)
break
case IsApp:
IsAppController(client)
break
case Ping:
PingController(client)
break
case Joins:
JoinsController(client, request)
break
case Quits:
QuitsController(client, request)
break
}
}
@@ -0,0 +1,225 @@
// Package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
import (
"context"
"time"
"github.com/gogf/gf/v2/frame/g"
)
type Payload struct {
IsAggregate bool `json:"isAggregate"`
Group string `json:"group"`
Uid string `json:"uid"`
Payload []byte `json:"payload"`
}
type AggregatorOptions struct {
GroupGracePeriod int // 组的优雅延迟时间(秒)
GroupMaxDelay int // 组的最大延迟时间(秒)
GroupMaxSize int // 组的最大大小
}
type WorkerOptions struct {
group string //任务处理器的组名
redisAddr string //redis地址
redisUser string //redis用户名
redisPassword string //redis密码
redisDB int //redis数据库
handler func(ctx context.Context, p Payload) error //任务处理函数
maxRetry int //任务最大重试次数
clearArchived int //清除已归档任务的时间间隔
timeout int //任务超时时间
redisPeriodKey string //redis周期任务键
aggregator *AggregatorOptions //聚合器选项
}
func GetDefaultWorkerOptions(ops *WorkerOptions) *WorkerOptions {
if ops != nil {
return ops
}
addr := g.Cfg().MustGet(context.Background(), "queue.asynq.redis.address").String()
pass := g.Cfg().MustGet(context.Background(), "queue.asynq.redis.pass").String()
db := g.Cfg().MustGet(context.Background(), "queue.asynq.redis.db").Int()
user := g.Cfg().MustGet(context.Background(), "queue.asynq.redis.user").String()
if user == "" {
user = "default"
}
return &WorkerOptions{
group: "task",
redisAddr: addr,
redisPassword: pass,
redisDB: db,
redisUser: user,
}
}
func WithWorkerGroup(group string) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).group = group
}
}
func WithWorkerRedisAddr(addr string) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).redisAddr = addr
}
}
func WithWorkerRedisUser(user string) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).redisUser = user
}
}
func WithWorkerRedisPassword(pass string) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).redisPassword = pass
}
}
func WithWorkerRedisDB(db int) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).redisDB = db
}
}
func WithWorkerHandler(handler func(ctx context.Context, p Payload) error) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).handler = handler
}
}
func WithWorkerMaxRetry(maxRetry int) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).maxRetry = maxRetry
}
}
func WithWorkerClearArchived(clearArchived int) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).clearArchived = clearArchived
}
}
func WithWorkerTimeout(timeout int) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).timeout = timeout
}
}
func WithWorkerRedisPeriodKey(redisPeriodKey string) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).redisPeriodKey = redisPeriodKey
}
}
func WithWorkerAggregator(aggregator *AggregatorOptions) func(*WorkerOptions) {
return func(o *WorkerOptions) {
GetDefaultWorkerOptions(o).aggregator = aggregator
}
}
type TaskOptions struct {
uid string
group string
payload []byte
expr string // 仅周期任务
in *time.Duration // 仅一次性任务 当前时间的延迟
at *time.Time // 仅一次性任务 指定时间
now bool // 仅一次性任务 当前时间
retention int // 仅一次性任务 任务保留时间 在这个时间内任务会被保留为已完成状态,过了这个时间后,任务将被自动删除。
replace bool // 仅一次性任务 是否替换已存在的任务
ctx context.Context // 仅一次性任务
maxRetry int
timeout int
}
func GetDefaultTaskOptions(opt *TaskOptions) *TaskOptions {
if opt != nil {
return opt
}
return &TaskOptions{
group: "group",
maxRetry: 3,
timeout: 120,
}
}
func WithTaskUid(uid string) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).uid = uid
}
}
func WithTaskGroup(group string) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).group = group
}
}
func WithTaskPayload(payload []byte) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).payload = payload
}
}
func WithTaskExpr(expr string) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).expr = expr
}
}
func WithTaskIn(in *time.Duration) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).in = in
}
}
func WithTaskAt(at *time.Time) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).at = at
}
}
func WithTaskNow(now bool) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).now = now
}
}
func WithTaskRetention(retention int) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).retention = retention
}
}
func WithTaskReplace(replace bool) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).replace = replace
}
}
func WithTaskCtx(ctx context.Context) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).ctx = ctx
}
}
func WithTaskMaxRetry(maxRetry int) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).maxRetry = maxRetry
}
}
func WithTaskTimeout(timeout int) func(*TaskOptions) {
return func(o *TaskOptions) {
GetDefaultTaskOptions(o).timeout = timeout
}
}
@@ -0,0 +1,84 @@
// package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/uuid32"
"github.com/gogf/gf/v2/frame/g"
)
type Queue struct {
topic string
w *Worker
}
type Process interface {
GetTopic() string // 获取消费主题
Handle(ctx context.Context, p Payload) (err error) // 处理过程的方法
GetAggregator() *AggregatorOptions // 获取聚合器配置,返回nil不使用聚合
}
func RegisterQueueProcess(p Process) (q *Queue) {
q = &Queue{
topic: p.GetTopic(),
w: New(WithWorkerGroup(p.GetTopic()), WithWorkerHandler(p.Handle), WithWorkerAggregator(p.GetAggregator())),
}
return
}
func (q *Queue) Push(ctx context.Context, topic string, data []byte, timeout int) (err error) {
err = q.w.Once(
WithTaskUid(uuid32.New()),
WithTaskGroup(topic),
WithTaskPayload(data),
WithTaskTimeout(timeout),
)
if err != nil {
g.Log().Debug(ctx, "Run Queue TaskWorker %s Error: %v", topic, err)
}
return nil
}
func (q *Queue) Stop() {
if q.w != nil {
q.w.Stop()
}
}
type Scheduled struct {
topic string
w *Worker
}
func RegisterScheduledProcess(p Process) (q *Queue) {
q = &Queue{
topic: p.GetTopic(),
w: New(WithWorkerGroup(p.GetTopic()), WithWorkerHandler(p.Handle), WithWorkerAggregator(p.GetAggregator())),
}
return
}
func (s *Scheduled) Cron(ctx context.Context, topic, cronExpr string, data []byte) (entryID string, err error) {
s.topic = topic
entryID, err = s.w.Cron(
1,
WithTaskUid(uuid32.S()),
WithTaskGroup(topic),
WithTaskExpr(cronExpr),
WithTaskPayload(data),
)
if err != nil {
g.Log().Debug(ctx, "Run Cron TaskWorker %s Error: %v", topic, err)
}
return
}
func (s *Scheduled) Remove(entryID string) (err error) {
err = s.w.RemoveCron(entryID)
return
}
@@ -0,0 +1,307 @@
// package worker
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package worker
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/uuid32"
"github.com/dromara/carbon/v2"
"github.com/gogf/gf/v2/frame/g"
"github.com/xiujiecn/asynq"
)
type Worker struct {
ops *WorkerOptions
client *asynq.Client
inspector *asynq.Inspector
scheduler *asynq.Scheduler
svr *asynq.Server
Error error
}
type periodTask struct {
Expr string `json:"expr"` // asynq
Group string `json:"group"`
Uid string `json:"uid"`
Payload []byte `json:"payload"`
Next int64 `json:"next"` // next schedule unix timestamp
Processed int64 `json:"processed"` // run times
MaxRetry int `json:"maxRetry"`
Timeout int `json:"timeout"`
}
func (p *periodTask) String() (str string) {
bs, _ := json.Marshal(p)
str = string(bs)
return
}
func (p *periodTask) FromString(str string) {
err := json.Unmarshal([]byte(str), p)
if err != nil {
return
}
return
}
func aggregate(group string, tasks []*asynq.Task) *asynq.Task {
// g.Log().Infof(context.Background(), "server/internal/library/worker/worker.go aggregate group %s, tasks %d", group, len(tasks))
list := make([][]byte, 0)
for _, t := range tasks {
list = append(list, t.Payload())
}
payload, _ := json.Marshal(list)
return asynq.NewTask(group+".aggregate", payload, asynq.TaskID(uuid32.S()))
}
func New(options ...func(*WorkerOptions)) *Worker {
ops := GetDefaultWorkerOptions(nil)
for _, op := range options {
op(ops)
}
rs := asynq.RedisClientOpt{
Addr: ops.redisAddr,
Password: ops.redisPassword,
DB: ops.redisDB,
}
w := &Worker{}
if ops.redisAddr == "" {
w.Error = errors.New("redisAddr is nil")
return w
}
w.ops = ops
w.client = asynq.NewClient(rs)
w.inspector = asynq.NewInspector(rs)
if ops.redisPeriodKey != "" {
w.scheduler = asynq.NewScheduler(rs, &asynq.SchedulerOpts{
Location: time.Now().Location(),
LogLevel: asynq.LogLevel(g.Cfg().MustGet(context.Background(), "queue.asynq.logLevel").Int()), // 日志级别 DebugLevel 1, InfoLevel 2, WarnLevel 3, ErrorLevel 4, FatalLevel 5
})
go func() {
if err := w.scheduler.Start(); err != nil {
g.Log().Error(context.Background(), "server/internal/library/worker/worker.go New 启动调度器失败", err)
panic(err)
}
g.Log().Info(context.Background(), "server/internal/library/worker/worker.go New 启动调度器成功", "group", w.ops.group)
}()
}
conf := asynq.Config{
Concurrency: g.Cfg().MustGet(context.Background(), "queue.asynq.concurrency").Int(), // 最大同时执行的任务数量
Queues: map[string]int{ops.group: 10}, // 队列名称和优先级
LogLevel: asynq.LogLevel(g.Cfg().MustGet(context.Background(), "queue.asynq.logLevel").Int()), // 日志级别 DebugLevel 1, InfoLevel 2, WarnLevel 3, ErrorLevel 4, FatalLevel 5
}
if w.ops.aggregator != nil {
conf.GroupAggregator = asynq.GroupAggregatorFunc(aggregate)
conf.GroupGracePeriod = time.Duration(w.ops.aggregator.GroupGracePeriod) * time.Second
conf.GroupMaxDelay = time.Duration(w.ops.aggregator.GroupMaxDelay) * time.Second
conf.GroupMaxSize = w.ops.aggregator.GroupMaxSize
g.Log().Infof(context.Background(), "server/internal/library/worker/worker.go New 聚合器配置 group %s, gracePeriod %d, maxDelay %d, maxSize %d",
w.ops.group, w.ops.aggregator.GroupGracePeriod, w.ops.aggregator.GroupMaxDelay, w.ops.aggregator.GroupMaxSize)
}
svr := asynq.NewServer(rs, conf)
h := &taskHandlerBase{w: w}
mux := asynq.NewServeMux()
if w.ops.aggregator != nil {
mux.HandleFunc(w.ops.group+".aggregate", h.ProcessTask)
} else {
mux.HandleFunc(w.ops.group+".once", h.ProcessTask)
mux.HandleFunc(w.ops.group+".cron", h.ProcessTask)
}
if err := svr.Start(mux); err != nil {
panic(err)
}
w.svr = svr
if w.ops.clearArchived > 0 {
go func() {
time.Sleep(time.Duration(w.ops.clearArchived) * time.Second)
w.clearArchived()
}()
}
g.Log().Debug(context.Background(), "server/internal/library/worker/worker.go New", "group", w.ops.group)
return w
}
func (w *Worker) Once(options ...func(*TaskOptions)) (err error) {
ops := GetDefaultTaskOptions(nil)
for _, op := range options {
op(ops)
}
if ops.uid == "" {
err = errors.New("uid is nil")
return
}
t := asynq.NewTask(ops.group+".once", ops.payload, asynq.TaskID(ops.uid), asynq.Group(ops.group))
taskOpts := []asynq.Option{
asynq.Queue(w.ops.group),
asynq.MaxRetry(w.ops.maxRetry),
asynq.Timeout(time.Duration(ops.timeout) * time.Second),
}
if ops.maxRetry > 0 {
taskOpts = append(taskOpts, asynq.MaxRetry(ops.maxRetry))
}
if ops.retention > 0 {
taskOpts = append(taskOpts, asynq.Retention(time.Duration(ops.retention)*time.Second))
} else {
taskOpts = append(taskOpts, asynq.Retention(time.Duration(w.ops.timeout)*time.Second))
}
if ops.in != nil {
taskOpts = append(taskOpts, asynq.ProcessIn(*ops.in))
} else if ops.at != nil {
taskOpts = append(taskOpts, asynq.ProcessAt(*ops.at))
} else if ops.now {
taskOpts = append(taskOpts, asynq.ProcessIn(time.Millisecond))
}
_, err = w.client.Enqueue(t, taskOpts...)
if ops.replace && errors.Is(err, asynq.ErrTaskIDConflict) {
// 错误表示由于给定的任务ID已经存在,因此无法将该任务加入队列。这可能是因为任务已经存在,或者任务ID冲突。
err = w.inspector.DeleteTask(w.ops.group, ops.uid)
if err != nil {
return
}
_, err = w.client.Enqueue(t, taskOpts...)
}
return
}
// Cron 注册一个cron任务, 如果concurrent为0,则不限制并发,否则限制并发
func (w *Worker) Cron(concurrent int, options ...func(*TaskOptions)) (entryID string, err error) {
ops := GetDefaultTaskOptions(nil)
for _, op := range options {
op(ops)
}
if ops.expr == "" {
err = errors.New("expr is nil")
return
}
if concurrent == 0 {
// 不限制并发, 不传递任务id,任务ID随机,可并发
entryID, err = w.scheduler.Register(ops.expr, asynq.NewTask(ops.group+".cron", ops.payload), asynq.Queue(w.ops.group))
} else {
// 限制并发, 传递任务id,任务ID固定,不可并发
entryID, err = w.scheduler.Register(ops.expr, asynq.NewTask(ops.group+".cron", ops.payload, asynq.TaskID(ops.uid)), asynq.Queue(w.ops.group))
}
if err != nil {
return
}
g.Log().Debug(context.Background(), "server/internal/library/worker/worker.go Cron", "group", w.ops.group, "entryID", entryID, "expr", ops.expr, "concurrent", concurrent)
return
}
func (w *Worker) Remove(uid string) (err error) {
entries, err := w.inspector.SchedulerEntries()
if err != nil {
return
}
for _, entry := range entries {
payload := string(entry.Task.Payload())
g.Log().Debug(context.Background(), "server/internal/library/worker/worker.go Remove", "group", w.ops.group, "entryID", entry.ID, "payload", payload)
if strings.Contains(string(entry.Task.Payload()), "\""+uid+"\"") {
g.Log().Debug(context.Background(), "strings.Contains(string(entry.Task.Payload()), uid) ")
err = w.scheduler.Unregister(entry.ID)
}
}
return
}
func (w *Worker) GetTaskInfo(uid string) (task *asynq.TaskInfo, err error) {
task, err = w.inspector.GetTaskInfo(w.ops.group, uid)
if err != nil {
return
}
return task, nil
}
func (w *Worker) RemoveCron(entryID string) (err error) {
err = w.scheduler.Unregister(entryID)
return
}
// 获取带有默认超时的上下文
func (w *Worker) getDefaultTimeoutCtx() context.Context {
c, _ := context.WithTimeout(context.Background(), time.Duration(w.ops.timeout)*time.Second)
return c
}
// 清除已归档任务
func (w *Worker) clearArchived() {
list, err := w.inspector.ListArchivedTasks(w.ops.group, asynq.Page(1), asynq.PageSize(100))
if err != nil {
return
}
// ctx := w.getDefaultTimeoutCtx()
for _, item := range list {
last := carbon.CreateFromStdTime(item.LastFailedAt)
if !last.IsZero() && item.Retried < item.MaxRetry {
continue
}
uid := item.ID
var flag bool
if strings.HasSuffix(item.Type, ".cron") {
if carbon.Now().Gt(last.AddMinutes(5)) {
flag = true
}
} else {
if carbon.Now().Gt(last.AddMinutes(5)) {
flag = true
}
}
if flag {
err := w.inspector.DeleteTask(w.ops.group, uid)
if err != nil {
return
}
}
}
}
type taskHandlerBase struct {
w *Worker
}
func (h *taskHandlerBase) ProcessTask(ctx context.Context, task *asynq.Task) (err error) {
var isAggregate bool
if strings.Contains(task.Type(), ".aggregate") {
isAggregate = true
}
group := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(task.Type(), ".aggregate"), ".once"), ".cron")
if h.w.ops.handler == nil {
g.Log().Errorf(ctx, "server/internal/library/worker/worker.go ProcessTask handler is nil, group: %s, uid: %s, payload: %s", group, task.ResultWriter().TaskID(), string(task.Payload()))
return errors.New("handler is nil")
}
err = h.w.ops.handler(ctx, Payload{
IsAggregate: isAggregate,
Group: group,
Uid: task.ResultWriter().TaskID(),
Payload: task.Payload(),
})
return
}
func (w *Worker) Stop() {
if w.scheduler != nil {
w.scheduler.Shutdown()
}
if w.client != nil {
w.client.Close()
}
if w.inspector != nil {
w.inspector.Close()
}
if w.svr != nil {
w.svr.Shutdown()
}
}
@@ -0,0 +1,86 @@
// package genconsts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genconsts
// 生成类型值定义
const (
GenCodesTypeCurd = 0
GenCodesTypeTree = 1
)
// 生成类型标签映射
var GenCodesTypeNameMap = map[int]string{
GenCodesTypeCurd: "增删改查列表",
GenCodesTypeTree: "关系树列表",
}
// 生成类型配置映射
var GenCodesTypeConfMap = map[int]string{
GenCodesTypeCurd: "crud",
GenCodesTypeTree: "tree",
}
// 生成代码状态
const (
GenCodesStatusOk = "0" // 生成成功
GenCodesStatusWait = "1" // 未生成
GenCodesStatusFail = "2" // 生成失败
)
var GenCodesStatusNameMap = map[string]string{
GenCodesStatusOk: "生成成功",
GenCodesStatusWait: "未生成",
GenCodesStatusFail: "生成失败",
}
// 生成代码关联表方式
const (
GenCodesJoinLeft = 1 // 左关联
GenCodesJoinRight = 2 // 右关联
GenCodesJoinInner = 3 // 内关联
)
var GenCodesJoinNameMap = map[int]string{
GenCodesJoinLeft: "左关联",
GenCodesJoinRight: "右关联",
GenCodesJoinInner: "内关联",
}
var GenCodesJoinLinkMap = map[int]string{
GenCodesJoinLeft: "LeftJoin",
GenCodesJoinRight: "RightJoin",
GenCodesJoinInner: "InnerJoin",
}
// 生成代码的生成方式
const (
GenCodesBuildMethCreate = 1 // 创建
GenCodesBuildMethCover = 2 // 覆盖
GenCodesBuildMethSkip = 3 // 跳过
GenCodesBuildIgnore = 4 // 不生成
)
var GenCodesBuildMethNameMap = map[int]string{
GenCodesBuildMethCreate: "创建文件",
GenCodesBuildMethCover: "强制覆盖",
GenCodesBuildMethSkip: "已存在跳过",
GenCodesBuildIgnore: "不生成",
}
const (
GenCodesIndexPK = "PRI" // 主键索引
GenCodesIndexUNI = "UNI" // 唯一索引
)
const (
GenCodesTreeStyleTypeNormal = 1 // 普通树表格
GenCodesTreeStyleTypeOption = 2 // 选项式树表
)
var GenCodesTreeStyleTypeNameMap = map[int]string{
GenCodesTreeStyleTypeNormal: "普通树表格",
GenCodesTreeStyleTypeOption: "选项式树表",
}
@@ -0,0 +1,87 @@
// package genconsts
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genconsts
import "github.com/gogf/gf/v2/util/gconv"
// 配置数据类型
const (
ConfigTypeString = "string"
ConfigTypeInt = "int"
ConfigTypeInt8 = "int8"
ConfigTypeInt16 = "int16"
ConfigTypeInt32 = "int32"
ConfigTypeInt64 = "int64"
ConfigTypeUint = "uint"
ConfigTypeUint8 = "uint8"
ConfigTypeUint16 = "uint16"
ConfigTypeUint32 = "uint32"
ConfigTypeUint64 = "uint64"
ConfigTypeFloat32 = "float32"
ConfigTypeFloat64 = "float64"
ConfigTypeBool = "bool"
ConfigTypeDate = "date"
ConfigTypeDateTime = "datetime"
ConfigTypeSliceString = "[]string"
ConfigTypeSliceInt = "[]int"
ConfigTypeSliceInt64 = "[]int64"
)
var ConfigTypes = []string{ConfigTypeString,
ConfigTypeInt, ConfigTypeInt8, ConfigTypeInt16, ConfigTypeInt32, ConfigTypeInt64,
ConfigTypeUint, ConfigTypeUint8, ConfigTypeUint16, ConfigTypeUint32, ConfigTypeUint64,
ConfigTypeFloat32, ConfigTypeFloat64,
ConfigTypeBool,
ConfigTypeDate, ConfigTypeDateTime,
ConfigTypeSliceString, ConfigTypeSliceInt, ConfigTypeSliceInt64,
}
// ConvType 类型转换
func ConvType(val interface{}, t string) interface{} {
switch t {
case ConfigTypeString:
val = gconv.String(val)
case ConfigTypeInt:
val = gconv.Int(val)
case ConfigTypeInt8:
val = gconv.Int8(val)
case ConfigTypeInt16:
val = gconv.Int16(val)
case ConfigTypeInt32:
val = gconv.Int32(val)
case ConfigTypeInt64:
val = gconv.Int64(val)
case ConfigTypeUint:
val = gconv.Uint(val)
case ConfigTypeUint8:
val = gconv.Uint8(val)
case ConfigTypeUint16:
val = gconv.Uint16(val)
case ConfigTypeUint32:
val = gconv.Uint32(val)
case ConfigTypeUint64:
val = gconv.Uint64(val)
case ConfigTypeFloat32:
val = gconv.Float32(val)
case ConfigTypeFloat64:
val = gconv.Float64(val)
case ConfigTypeBool:
val = gconv.Bool(val)
case ConfigTypeDate:
val = gconv.Time(val, "Y-m-d")
case ConfigTypeDateTime:
val = gconv.Time(val, "Y-m-d H:i:s")
case ConfigTypeSliceInt:
val = gconv.SliceInt(val)
case ConfigTypeSliceInt64:
val = gconv.SliceInt64(val)
case ConfigTypeSliceString:
val = gconv.SliceStr(val)
default:
val = gconv.String(val)
}
return val
}
@@ -0,0 +1,63 @@
// package gendao
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gendao
import (
"context"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gcfg"
"github.com/gogf/gf/v2/util/gconv"
)
var (
daoConfigList = make([]*genmodel.GenDaoConfig, 0)
cliConfigPath = `hack/config.yaml`
cliConfig *gcfg.Config
defaultGenServiceConfig = genmodel.GenServiceConfig{
SrcFolder: "internal/logic",
DstFolder: "internal/service",
DstFileNameCase: "Snake",
StPattern: `s([A-Z]\w+)`,
Clear: false,
}
serviceConfig = genmodel.GenServiceConfig{}
)
func GetDaoConfig(ctx context.Context, group string) (output *genmodel.GenDaoConfig, err error) {
for _, v := range daoConfigList {
if v == nil {
continue
}
if v.Group == group {
output = v
return
}
}
return
}
func Init(ctx context.Context) (err error) {
adapter, err := gcfg.NewAdapterFile(cliConfigPath)
if err != nil {
g.Log().Fatalf(ctx, "get cli configuration file:%v, err:%+v", cliConfigPath, err)
return
}
cliConfig = gcfg.NewWithAdapter(adapter)
cliConfig.MustGet(ctx, "gfcli.gen.dao").Scan(&daoConfigList)
cliConfig.MustGet(ctx, "gfcli.gen.service").Scan(&serviceConfig)
return
}
func GetServiceConfig() *genmodel.GenServiceConfig {
conf := &genmodel.GenServiceConfig{}
_ = gconv.Scan(defaultGenServiceConfig, &conf)
_ = gconv.Scan(serviceConfig, &conf)
return conf
}
@@ -0,0 +1,146 @@
// package genmodel
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genmodel
import "github.com/gogf/gf/v2/encoding/gjson"
type SelectItemModel struct {
Value interface{} `json:"value"` // 选择项值
Label string `json:"label"` // 选择项标签
Name string `json:"name"` // 选择项名称
Disabled bool `json:"disabled"` // 是否禁用
}
// 生成类型选择项
type SelectGenTypeModel struct {
SelectItemModel
Templates []*SelectGenTemplateModel `json:"templates"` // 选择项模板
}
// 生成模板选择项
type SelectGenTemplateModel struct {
SelectItemModel
IsAddon bool `json:"isAddon"` // 是否为插件
}
type SelectsModel struct {
GenType []*SelectGenTypeModel `json:"genType"` // 生成类型 0:增删改查列表 1:关系树列表
Db []*SelectItemModel `json:"db"` // 数据库选择项
Status []*SelectItemModel `json:"status"` // 状态选择项 0:生成成功 1:未生产 2:生成失败
LinkMode []*SelectItemModel `json:"linkMode"` // 链接模式选择项 1左关联 2右关联 3内关联
BuildMethod []*SelectItemModel `json:"buildMethod"` // 构建标志选择项 1:创建文件 2强制覆盖 3已存在跳过 4不生成
FormMode []*SelectItemModel `json:"formMode"` // 表单项类型选择项
FormRole []*SelectItemModel `json:"formRole"` // 表单项校验规则选择项
DictMode []*SelectItemModel `json:"dictMode"` // 字典模式选择项
WhereMode []*SelectItemModel `json:"whereMode"` // 查询条件选择项
Addons []*SelectItemModel `json:"addons"` // 插件选择项
TableAlign []*SelectItemModel `json:"tableAlign"` // 表格对齐方式选择项
TreeStyleType []*SelectItemModel `json:"treeStyleType"` // 树形结构选择项
}
// GenCodesColumn 生成表字段属性
type GenCodesColumn struct {
// 表属性
Id int64 `json:"id" dc:"序号"`
Name string `json:"name" dc:"字段列名"`
Dc string `json:"dc" dc:"字段描述"`
DataType string `json:"dataType" dc:"字段类型"`
SqlType string `json:"sqlType" dc:"物理类型"`
Length int64 `json:"length" dc:"字段长度"`
IsAllowNull string `json:"isAllowNull" dc:"是否允许为空"`
DefaultValue interface{} `json:"defaultValue" dc:"默认值"`
Index string `json:"index" dc:"索引"`
Extra string `json:"extra" dc:"额外选项"`
// 自定义生成属性
// Alias string `json:"alias" dc:"字段别名"`
GoName string `json:"goName" dc:"Go属性"`
GoType string `json:"goType" dc:"Go类型"`
TsName string `json:"tsName" dc:"Ts属性"`
TsType string `json:"tsType" dc:"Ts类型"`
IsList bool `json:"isList" dc:"列表"`
IsExport bool `json:"isExport" dc:"导出"`
IsSort bool `json:"isSort" dc:"排序"`
IsQuery bool `json:"isQuery" dc:"查询"`
QueryWhere string `json:"queryWhere" dc:"查询条件"`
IsEdit bool `json:"isEdit" dc:"编辑"`
Required bool `json:"required" dc:"必填"`
Unique bool `json:"unique" dc:"唯一性"`
FormMode string `json:"formMode" dc:"表单组件"`
FormRole string `json:"formRole" dc:"表单验证"`
FormGridSpan int `json:"formGridSpan" dc:"栅格占位数量"`
DictType string `json:"dictType" dc:"字典类型ID"`
Align string `json:"align" dc:"排列方式"`
Width int64 `json:"width" dc:"列宽"`
}
type GenCodesTableSelectParam struct {
DbGroup string `json:"dbGroup"`
}
type GenCodesTableSelectModel struct {
Value string `json:"value"`
Label string `json:"label"`
Name string `json:"name"`
DaoName string `json:"daoName" dc:"orm模型名称"`
DefVarName string `json:"defVarName" dc:"默认实体名称"`
DefAlias string `json:"defAlias" dc:"默认关联表别名"`
DefTableComment string `json:"defTableComment" dc:"默认菜单名称"`
}
type GenCodesColumnSelectParam struct {
DbGroup string `json:"dbGroup"`
TableName string `json:"tableName"`
}
type GenCodesColumnSelectModel struct {
Value string `json:"value"`
Label string `json:"label"`
Name string `json:"name"`
}
type GenCodesColumnListParam struct {
DbGroup string `json:"dbGroup" dc:"数据库配置名称"`
TableName string `json:"tableName" dc:"表名称"`
IsLink int64 `json:"isLink" dc:"是否是关联表"`
Alias string `json:"alias" dc:"关联表别名"`
}
type GenCodesColumnListModel struct {
GenCodesColumn
}
type GenCodesPreviewParam struct {
TableId int64 `json:"tableId" orm:"table_id" description:"生成ID"`
GenType uint `json:"genType" orm:"gen_type" description:"生成类型"`
GenTemplate int `json:"genTemplate" orm:"gen_template" description:"生成模板"`
VarName string `json:"varName" orm:"var_name" description:"实体命名"`
Options *gjson.Json `json:"options" orm:"options" description:"配置选项"`
DbName string `json:"dbName" orm:"db_name" description:"数据库名称"`
TableName string `json:"tableName" orm:"table_name" description:"主表名称"`
TableComment string `json:"tableComment" orm:"table_comment" description:"主表注释"`
DaoName string `json:"daoName" orm:"dao_name" description:"主表dao模型"`
MasterColumns *gjson.Json `json:"masterColumns" orm:"master_columns" description:"主表字段"`
AddonName string `json:"addonName" orm:"addon_name" description:"插件名称"`
Status int `json:"status" orm:"status" description:"生成状态"`
}
// GenFile 生成文件配置
type GenFile struct {
Content string `json:"content" dc:"页面内容"`
Path string `json:"path" dc:"生成路径"`
Meth int `json:"meth" dc:"生成方式"`
Required bool `json:"required" dc:"是否是必要构建文件"`
}
type GenCodesPreviewModel struct {
Config *GenCodesConfig `json:"config"`
Views map[string]*GenFile `json:"views" dc:"页面"`
}
type GenCodesBuildParam = GenCodesPreviewParam
type GenCodesBuildModel struct {
}
@@ -0,0 +1,126 @@
// package genmodel
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genmodel
import (
genconsts "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_consts"
"github.com/gogf/gf/v2/util/gconv"
)
// 模板配置 config/config.yaml xgen.application.crud.templates
type GenCodesConfigCrudTemplate struct {
Group string `name:"group" json:"group"`
IsAddon bool `name:"isAddon" json:"isAddon"`
MasterPackage string `name:"masterPackage" json:"masterPackage"`
TemplatePath string `name:"templatePath" json:"templatePath"`
ApiPath string `name:"apiPath" json:"apiPath"`
InputPath string `name:"inputPath" json:"inputPath"`
ControllerPath string `name:"controllerPath" json:"controllerPath"`
LogicPath string `name:"logicPath" json:"logicPath"`
RouterPath string `name:"routerPath" json:"routerPath"`
SqlPath string `name:"sqlPath" json:"sqlPath"`
WebApiPath string `name:"webApiPath" json:"webApiPath"`
WebViewsPath string `name:"webViewsPath" json:"webViewsPath"`
}
type GenCodesConfigTreeTemplate struct {
Group string `json:"group"`
TemplatePath string `json:"templatePath"`
}
type GenCodesConfigBuildAddon struct {
SrcPath string `json:"srcPath"`
WebApiPath string `json:"webApiPath"`
WebViewsPath string `json:"webViewsPath"`
}
// 工具生成配置 hack/config.yaml gfcli.gen.dao
type GenDaoConfig struct {
Group string `name:"group" json:"group"`
Link string `name:"link" json:"link"`
DbName string `name:"dbName" json:"dbName"`
TableName string `name:"tableName" json:"tableName"`
JsonCase string `name:"jsonCase" json:"jsonCase"`
StdTime bool `name:"stdTime" json:"stdTime"`
GJsonSupport bool `name:"gJsonSupport" json:"gJsonSupport"`
}
// 工具生成配置 hack/config.yaml gfcli.gen.service
type GenServiceConfig struct {
SrcFolder string `name:"srcFolder"`
DstFolder string `name:"dstFolder"`
DstFileNameCase string `name:"dstFileNameCase"`
WatchFile string `name:"watchFile" `
StPattern string `name:"stPattern" `
Packages []string `name:"packages" `
ImportPrefix string `name:"importPrefix" `
Clear bool `name:"clear" `
}
// 模板配置 config/config.yaml xgen
type GenCodesConfig struct {
AllowedIPs []string `json:"allowedIPs"`
Application struct {
Crud struct {
Templates []*GenCodesConfigCrudTemplate `json:"templates"`
} `json:"crud"`
Tree struct {
Templates []*GenCodesConfigTreeTemplate `json:"templates"`
} `json:"tree"`
} `json:"application"`
Delimiters []string `json:"delimiters"`
DevPath string `json:"devPath"`
DisableTables []string `json:"disableTables"`
SelectDbs []string `json:"selectDbs"`
Addon *GenCodesConfigBuildAddon `json:"addon"`
}
// ConvType 类型转换
func ConvType(val interface{}, t string) interface{} {
switch t {
case genconsts.ConfigTypeString:
val = gconv.String(val)
case genconsts.ConfigTypeInt:
val = gconv.Int(val)
case genconsts.ConfigTypeInt8:
val = gconv.Int8(val)
case genconsts.ConfigTypeInt16:
val = gconv.Int16(val)
case genconsts.ConfigTypeInt32:
val = gconv.Int32(val)
case genconsts.ConfigTypeInt64:
val = gconv.Int64(val)
case genconsts.ConfigTypeUint:
val = gconv.Uint(val)
case genconsts.ConfigTypeUint8:
val = gconv.Uint8(val)
case genconsts.ConfigTypeUint16:
val = gconv.Uint16(val)
case genconsts.ConfigTypeUint32:
val = gconv.Uint32(val)
case genconsts.ConfigTypeUint64:
val = gconv.Uint64(val)
case genconsts.ConfigTypeFloat32:
val = gconv.Float32(val)
case genconsts.ConfigTypeFloat64:
val = gconv.Float64(val)
case genconsts.ConfigTypeBool:
val = gconv.Bool(val)
case genconsts.ConfigTypeDate:
val = gconv.Time(val, "Y-m-d")
case genconsts.ConfigTypeDateTime:
val = gconv.Time(val, "Y-m-d H:i:s")
case genconsts.ConfigTypeSliceInt:
val = gconv.SliceInt(val)
case genconsts.ConfigTypeSliceInt64:
val = gconv.SliceInt64(val)
case genconsts.ConfigTypeSliceString:
val = gconv.SliceStr(val)
default:
val = gconv.String(val)
}
return val
}
@@ -0,0 +1,138 @@
// package genmodel
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genmodel
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gview"
)
// ImportModel 导包 - model.ts
type ImportModel struct {
NaiveUI []string
UtilsIs []string
UtilsUrl []string
UtilsDate []string
UtilsValidate []string
UtilsIndex []string
}
// CurdStep 生成选项
type CurdStep struct {
HasMaxSort bool // 最大排序
HasAdd bool // 表单添加
HasBatchDel bool // 批量删除
HasExport bool // 表格导出
HasNotFilterAuth bool // 不过滤认证权限
HasEdit bool // 表单编辑
HasDel bool // 删除
HasView bool // 查看详情
HasStatus bool // 修改状态
HasSwitch bool // 数值开关
HasCheck bool // 勾选列
HasMenu bool // 菜单权限
IsTreeTable bool // 树型列表
IsOptionTreeTable bool // 选项式树型列表
HasRules bool // 表单验证规则
HasRulesValidator bool // 表单验证器
HasSearchForm bool // 列表搜索
HasDict bool // 字典
HasFuncDict bool // 注册方法字典
HasQueryMemberSummary bool // 查询用户摘要
HasHookMemberSummary bool // hook用户摘要
ImportModel ImportModel // 公用导包 - model.ts
ActionColumnWidth int64 // 列表操作栏宽度
IsAddon bool // 是否是插件
IsEditModal bool // 新增修改模态框模式
}
type FuncDict struct {
ValueColumn string // 选项值
LabelColumn string // 选项名称
Value *GenCodesColumnListModel
Label *GenCodesColumnListModel
}
// PresetStep 预设生成流程参数
type PresetStep struct {
FormGridCols int `json:"formGridCols" dc:"表单显示的栅格数量"`
}
// OptionsTree 树形结构选项
type OptionsTree struct {
TitleColumn string `json:"titleColumn"`
StyleType int `json:"styleType"`
TitleField *GenCodesColumnListModel
}
// CurdOptionsMenu 菜单选项
type CurdOptionsMenu struct {
Icon string `json:"icon"`
Pid int `json:"pid"`
Sort int `json:"sort"`
}
// CurdOptionsJoin 关联表选项
type CurdOptionsJoin struct {
Uuid string `json:"uuid"`
LinkTable string `json:"linkTable"`
Alias string `json:"alias"`
LinkMode int `json:"linkMode"`
Field string `json:"field"`
MasterField string `json:"masterField"`
DaoName string `json:"daoName"`
Columns []*GenCodesColumnListModel `json:"columns"`
}
type OptionsSchemasField struct {
Field string
Type string
}
type CurdOptionsDict struct {
Has bool
Types []string
Schemas []*OptionsSchemasField
}
// CurdOptions 生成选项
type CurdOptions struct {
AutoOps []string `json:"autoOps"`
ColumnOps []string `json:"columnOps"`
HeadOps []string `json:"headOps"`
Join []*CurdOptionsJoin `json:"join"`
Menu *CurdOptionsMenu `json:"menu"`
Tree *OptionsTree `json:"tree"`
TemplateGroup string `json:"templateGroup"`
ApiPrefix string `json:"apiPrefix"`
ImportWebApi string `json:"importWebApi"`
FuncDict *FuncDict `json:"funcDict"`
PresetStep *PresetStep `json:"presetStep"`
Step *CurdStep // 转换后的流程控制条件
DictOps CurdOptionsDict // 字典选项
DictMap g.Map // 字典选项 -> 字段映射关系
}
type CurdPreviewParam struct {
In *GenCodesPreviewParam // 提交参数
DaoConfig *GenDaoConfig // 生成dao配置
Config *GenCodesConfig // 生成配置
View *gview.View // 视图模板
Content *GenCodesPreviewModel // 页面代码
MasterFields []*GenCodesColumnListModel // 主表字段属性
Pk *GenCodesColumnListModel // 主键属性
Options *CurdOptions // 生成选项
}
type CurdBuildEvent map[string]func(ctx context.Context) (err error)
type CurdBuildParam struct {
PreviewIn *CurdPreviewParam // 预览参数
BeforeEvent CurdBuildEvent // 前置事件
AfterEvent CurdBuildEvent // 后置事件
}
@@ -0,0 +1,253 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"context"
gendao "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_dao"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/text/gregex"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/v2/util/gconv"
)
func getJsonTagFromCase(str, caseStr string) string {
switch gstr.ToLower(caseStr) {
case gstr.ToLower("Camel"):
return gstr.CaseCamel(str)
case gstr.ToLower("CamelLower"):
return gstr.CaseCamelLower(str)
case gstr.ToLower("Kebab"):
return gstr.CaseKebab(str)
case gstr.ToLower("KebabScreaming"):
return gstr.CaseKebabScreaming(str)
case gstr.ToLower("Snake"):
return gstr.CaseSnake(str)
case gstr.ToLower("SnakeFirstUpper"):
return gstr.CaseSnakeFirstUpper(str)
case gstr.ToLower("SnakeScreaming"):
return gstr.CaseSnakeScreaming(str)
}
return str
}
func CheckLocalTypeForField(ctx context.Context, fieldType string, fieldValue interface{}) (string, error) {
var (
typeName string
typePattern string
)
match, _ := gregex.MatchString(`(.+?)\((.+)\)`, fieldType)
if len(match) == 3 {
typeName = gstr.Trim(match[1])
typePattern = gstr.Trim(match[2])
} else {
typeName = gstr.Split(fieldType, " ")[0]
}
typeName = strings.ToLower(typeName)
switch typeName {
case
"binary",
"varbinary",
"blob",
"tinyblob",
"mediumblob",
"longblob":
return string(gdb.LocalTypeBytes), nil
case
"int",
"tinyint",
"small_int",
"smallint",
"medium_int",
"mediumint",
"serial":
if gstr.ContainsI(fieldType, "unsigned") {
return string(gdb.LocalTypeUint), nil
}
return string(gdb.LocalTypeInt), nil
case
"big_int",
"bigint",
"bigserial":
if gstr.ContainsI(fieldType, "unsigned") {
return string(gdb.LocalTypeUint64), nil
}
return string(gdb.LocalTypeInt64), nil
case
"real":
return string(gdb.LocalTypeFloat32), nil
case
"float",
"double",
"decimal",
"money",
"numeric",
"smallmoney":
return string(gdb.LocalTypeFloat64), nil
case
"bit":
// It is suggested using bit(1) as boolean.
if typePattern == "1" {
return string(gdb.LocalTypeBool), nil
}
s := gconv.String(fieldValue)
// mssql is true|false string.
if strings.EqualFold(s, "true") || strings.EqualFold(s, "false") {
return string(gdb.LocalTypeBool), nil
}
if gstr.ContainsI(fieldType, "unsigned") {
return string(gdb.LocalTypeUint64Bytes), nil
}
return string(gdb.LocalTypeInt64Bytes), nil
case
"bool":
return string(gdb.LocalTypeBool), nil
case
"date":
return string(gdb.LocalTypeDate), nil
case
"datetime",
"timestamp",
"timestamptz":
return string(gdb.LocalTypeDatetime), nil
case
"json":
return string(gdb.LocalTypeJson), nil
case
"jsonb":
return string(gdb.LocalTypeJsonb), nil
default:
// Auto-detect field type, using key match.
switch {
case strings.Contains(typeName, "text") || strings.Contains(typeName, "char") || strings.Contains(typeName, "character"):
return string(gdb.LocalTypeString), nil
case strings.Contains(typeName, "float") || strings.Contains(typeName, "double") || strings.Contains(typeName, "numeric"):
return string(gdb.LocalTypeFloat64), nil
case strings.Contains(typeName, "bool"):
return string(gdb.LocalTypeBool), nil
case strings.Contains(typeName, "binary") || strings.Contains(typeName, "blob"):
return string(gdb.LocalTypeBytes), nil
case strings.Contains(typeName, "int"):
if gstr.ContainsI(fieldType, "unsigned") {
return string(gdb.LocalTypeUint), nil
}
return string(gdb.LocalTypeInt), nil
case strings.Contains(typeName, "time"):
return string(gdb.LocalTypeDatetime), nil
case strings.Contains(typeName, "date"):
return string(gdb.LocalTypeDatetime), nil
default:
return string(gdb.LocalTypeString), nil
}
}
}
// GenGotype 生成字段的go类型
func GenGotype(ctx context.Context, field *genmodel.GenCodesColumnListModel, in *genmodel.GenDaoConfig) (goName, typeName, tsName string, tsType string) {
var err error
tsName = getJsonTagFromCase(field.Name, in.JsonCase)
goName = gstr.CaseCamel(field.Name)
typeName, err = CheckLocalTypeForField(ctx, field.DataType, nil)
if err != nil {
panic(err)
}
switch gdb.LocalType(typeName) {
case gdb.LocalTypeDate, gdb.LocalTypeDatetime:
if in.StdTime {
typeName = "time.Time"
} else {
typeName = "*gtime.Time"
}
case gdb.LocalTypeInt64Bytes:
typeName = "int64"
case gdb.LocalTypeUint64Bytes:
typeName = "uint64"
// Special type handle.
case gdb.LocalTypeJson, gdb.LocalTypeJsonb:
if in.GJsonSupport {
typeName = "*gjson.Json"
} else {
typeName = "string"
}
}
tsType = ShiftMap[typeName]
return
}
func CustomLinkAttributes(ctx context.Context, alias string, field *genmodel.GenCodesColumnListModel, in *genmodel.GenDaoConfig) {
field.GoName, field.GoType, field.TsName, field.TsType = GenGotype(ctx, field, in)
field.GoName = gstr.UcFirst(alias + field.GoName)
field.TsName = gstr.LcFirst(field.GoName)
setDefaultQueryWhere(field)
setDefaultValue(field)
}
func CustomAttributes(ctx context.Context, field *genmodel.GenCodesColumnListModel, in *genmodel.GenDaoConfig) {
field.GoName, field.GoType, field.TsName, field.TsType = GenGotype(ctx, field, in)
setDefault(field)
}
func TableColumnList(ctx context.Context, params *genmodel.GenCodesColumnListParam) (output []*genmodel.GenCodesColumnListModel, err error) {
sql := "select ORDINAL_POSITION as `id`, COLUMN_NAME as `name`, COLUMN_COMMENT as `dc`, DATA_TYPE as `dataType`, COLUMN_TYPE as `sqlType`, CHARACTER_MAXIMUM_LENGTH as `length`, IS_NULLABLE as `isAllowNull`, COLUMN_DEFAULT as `defaultValue`, COLUMN_KEY as `index`, EXTRA as `extra` from information_schema.COLUMNS where TABLE_SCHEMA = ? and TABLE_NAME = ? ORDER BY `id` ASC"
dbConfig := g.DB(params.DbGroup).GetConfig()
daoConfig, err := gendao.GetDaoConfig(ctx, params.DbGroup)
if err != nil {
return
}
output = make([]*genmodel.GenCodesColumnListModel, 0)
err = g.DB(params.DbGroup).Ctx(ctx).Raw(sql, dbConfig.Name, params.TableName).Scan(&output)
if err != nil {
return
}
if len(output) == 0 {
return
}
for _, v := range output {
if params.IsLink == 1 {
CustomLinkAttributes(ctx, params.Alias, v, daoConfig)
} else {
CustomAttributes(ctx, v, daoConfig)
}
}
return
}
@@ -0,0 +1,341 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
genconsts "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_consts"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"github.com/gogf/gf/v2/text/gstr"
)
// 默认表单组件映射 Ts -> 表单组件
var defaultFormModeMap = map[string]string{
TsTypeString: FMInput,
TsTypeNumber: FMInputNumber,
TsTypeBoolean: FMInputNumber,
TsTypeArray: FMInputDynamic,
TsTypeTuple: FMInputDynamic,
TsTypeAny: FMInput,
}
var defaultEditFields = map[string]bool{
"id": false,
"created_dept": false,
"created_by": false,
"created_at": false,
"updated_by": false,
"updated_at": false,
"deleted_by": false,
"deleted_at": false,
}
var defaultEditSwitch = map[string]bool{
"id": false,
"level": false,
"tree": false,
"created_by": false,
"updated_by": false,
"created_dept": false,
"created_at": false,
"updated_at": false,
"deleted_at": false,
}
var defaultListSwitch = map[string]bool{
"level": false,
"tree": false,
"deleted_at": false,
}
var defaultExportSwitch = map[string]bool{
"level": false,
"tree": false,
"deleted_at": false,
}
var defaultQuerySwitch = map[string]bool{
"level": false,
"tree": false,
"deleted_at": false,
}
var defaultSort = map[string]bool{
"id": true,
"sort": true,
}
var defaultTreeFields = []string{"pid", "level", "tree"}
// 默认表单验证映射 物理类型命名识别
var defaultFormRoleMap = map[string]string{
"mobile": FRPhone,
"qq": FRQq,
"email": FREmail,
"id_card": FRIdCard,
"bank_card": FRBankCard,
"password": FRPassword,
"pass": FRPassword,
"price": FRAmount,
}
// 默认查询条件映射 go类型识别
var defaultWhereModeMap = map[string]string{
GoTypeString: WMLike,
GoTypeDate: WMEq,
GoTypeDatetime: WMEq,
GoTypeInt: WMEq,
GoTypeUint: WMEq,
GoTypeInt64: WMEq,
GoTypeUint64: WMEq,
GoTypeIntSlice: WMIn,
GoTypeInt64Slice: WMIn,
GoTypeUint64Slice: WMIn,
GoTypeFloat32: WMEq,
GoTypeFloat64: WMEq,
GoTypeBytes: WMEq,
GoTypeTime: WMEq,
GoTypeGTime: WMEq,
GoTypeJson: WMJsonContains,
}
// IsIndexPK 是否是主键
func IsIndexPK(index string) bool {
return gstr.ToUpper(index) == gstr.ToUpper(genconsts.GenCodesIndexPK)
}
// IsIndexUNI 是否是唯一索引
func IsIndexUNI(index string) bool {
return gstr.ToUpper(index) == gstr.ToUpper(genconsts.GenCodesIndexUNI)
}
// setDefault 设置默认属性
func setDefault(field *genmodel.GenCodesColumnListModel) {
setDefaultEdit(field)
setDefaultFormMode(field)
setDefaultFormRole(field)
setDefaultDictType(field)
setDefaultList(field)
setDefaultExport(field)
setDefaultQuery(field)
setDefaultQueryWhere(field)
setDefaultValue(field)
if field.IsAllowNull == "NO" {
field.Required = true
}
if IsIndexUNI(field.Index) {
field.Unique = true
}
if df, ok := defaultSort[field.Name]; ok {
field.IsSort = df
}
if field.Dc == "" {
field.Dc = field.Name
}
}
// setDefaultEdit 设置默认编辑
func setDefaultEdit(field *genmodel.GenCodesColumnListModel) {
field.IsEdit = true
if IsIndexPK(field.Index) {
field.IsEdit = false
return
}
if df, ok := defaultEditFields[field.Name]; ok {
field.IsEdit = df
}
if df, ok := defaultEditSwitch[field.Name]; ok {
field.IsEdit = df
}
}
// setDefaultFormMode 设置默认表单组件
func setDefaultFormMode(field *genmodel.GenCodesColumnListModel) {
field.FormMode = FMInput
if df, ok := defaultFormModeMap[field.TsType]; ok {
field.FormMode = df
}
if gstr.HasSuffix(field.GoName, "Status") && IsNumberType(field.GoType) {
field.FormMode = FMSelect
return
}
if field.GoName == "CreatedAt" {
field.FormMode = FMDateRange
return
}
if (field.GoName == "ProvinceId" || field.GoName == "CityId") && IsNumberType(field.GoType) {
field.FormMode = FMCitySelector
return
}
if field.DataType == "datetime" || field.DataType == "timestamp" || field.DataType == "timestamptz" {
field.FormMode = FMTime
return
}
if field.DataType == "date" {
field.FormMode = FMDate
return
}
if field.GoType == GoTypeString && field.Length >= 256 && field.Length <= 512 {
field.FormMode = FMInputTextarea
return
}
if field.GoType == GoTypeString && field.Length > 512 {
field.FormMode = FMInputEditor
return
}
}
// setDefaultFormRole 设置默认表单验证
func setDefaultFormRole(field *genmodel.GenCodesColumnListModel) {
field.FormRole = FRNone
switch field.GoType {
case GoTypeUint, GoTypeUint64:
field.FormRole = FRNum
return
}
if df, ok := defaultFormRoleMap[field.Name]; ok {
field.FormRole = df
}
}
// setDefaultDictType 设置默认字典类型
func setDefaultDictType(field *genmodel.GenCodesColumnListModel) {
if gstr.HasSuffix(field.GoName, "Status") && IsNumberType(field.GoType) {
field.DictType = "sys_common_status" // 默认系统状态ID
return
}
}
// setDefaultList 设置默认列表
func setDefaultList(field *genmodel.GenCodesColumnListModel) {
field.IsList = true
switch field.GoType {
case GoTypeIntSlice, GoTypeInt64Slice, GoTypeUint64Slice, GoTypeBytes, GoTypeJson:
field.IsList = false
return
}
if field.Length >= 500 {
field.IsList = false
return
}
if df, ok := defaultListSwitch[field.Name]; ok {
field.IsList = df
}
}
// setDefaultExport 设置默认导出
func setDefaultExport(field *genmodel.GenCodesColumnListModel) {
field.IsExport = true
switch field.GoType {
case GoTypeIntSlice, GoTypeInt64Slice, GoTypeUint64Slice, GoTypeBytes, GoTypeJson:
field.IsExport = false
return
}
if field.Length >= 500 {
field.IsExport = false
return
}
if df, ok := defaultExportSwitch[field.Name]; ok {
field.IsExport = df
}
}
// setDefaultQuery 设置默认查询
func setDefaultQuery(field *genmodel.GenCodesColumnListModel) {
field.IsQuery = false
if IsIndexPK(field.Index) {
field.IsQuery = true
return
}
if gstr.HasSuffix(field.GoName, "Status") && IsNumberType(field.GoType) {
field.IsQuery = true
return
}
if field.GoName == "CreatedAt" {
field.IsQuery = true
return
}
if df, ok := defaultQuerySwitch[field.Name]; ok {
field.IsQuery = df
}
}
// setDefaultQueryWhere 设置默认查询条件
func setDefaultQueryWhere(field *genmodel.GenCodesColumnListModel) {
field.QueryWhere = WMEq
if field.GoName == "CreatedAt" {
field.QueryWhere = WMBetween
return
}
if field.Length >= 500 {
field.QueryWhere = WMLikeAll
return
}
if df, ok := defaultWhereModeMap[field.GoType]; ok {
field.QueryWhere = df
}
}
// setDefaultValue 设置默认value
func setDefaultValue(field *genmodel.GenCodesColumnListModel) {
var value interface{}
if field.DefaultValue == nil {
switch field.GoType {
case GoTypeString, GoTypeBytes, GoTypeDate, GoTypeDatetime, GoTypeTime, GoTypeGTime:
value = ""
case GoTypeIntSlice, GoTypeInt64Slice, GoTypeUint64Slice, GoTypeJson:
value = nil
case GoTypeInt, GoTypeUint, GoTypeInt64, GoTypeUint64:
value = 0
case GoTypeBool:
value = false
}
} else {
value = genconsts.ConvType(field.DefaultValue, field.GoType)
}
// 时间类型不做默认值处理
if field.GoType == GoTypeGTime {
value = ""
}
field.DefaultValue = value
}
@@ -0,0 +1,339 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"github.com/gogf/gf/v2/text/gstr"
)
// 表单组件
const (
FMInput = "Input" // 文本输入
FMInputNumber = "InputNumber" // 数字输入
FMInputTextarea = "InputTextarea" // 文本域
FMInputEditor = "InputEditor" // 富文本
FMInputDynamic = "InputDynamic" // 动态键值对
FMDate = "Date" // 日期选择(Y-M-D)
FMDateRange = "DateRange" // 日期范围选择
FMTime = "Time" // 时间选择(Y-M-D H:i:s)
FMTimeRange = "TimeRange" // 时间范围选择
FMRadio = "Radio" // 单选按钮
FMCheckbox = "Checkbox" // 复选按钮
FMSelect = "Select" // 单选下拉框
FMSelectMultiple = "SelectMultiple" // 多选下拉框
FMTreeSelect = "TreeSelect" // 树型选择
FMCascader = "Cascader" // 级联选择
FMUploadImage = "UploadImage" // 单图上传
FMUploadImages = "UploadImages" // 多图上传
FMUploadFile = "UploadFile" // 单文件上传
FMUploadFiles = "UploadFiles" // 多文件上传
FMSwitch = "Switch" // 开关
FMRate = "Rate" // 评分
FMCitySelector = "CitySelector" // 省市区选择
FMPidTreeSelect = "PidTreeSelect" // 树型上级选择,树表生成专用
)
var FMs = []string{
FMInput, FMInputNumber, FMInputTextarea, FMInputEditor, FMInputDynamic,
FMDate, FMDateRange, FMTime, FMTimeRange,
FMRadio, FMCheckbox, FMSelect, FMSelectMultiple, FMTreeSelect, FMCascader,
FMUploadImage, FMUploadImages, FMUploadFile, FMUploadFiles,
FMSwitch,
FMRate,
FMCitySelector,
}
var FMMap = map[string]string{
FMInput: "文本输入",
FMInputNumber: "数字输入",
FMInputTextarea: "文本域",
FMInputEditor: "富文本",
FMInputDynamic: "动态键值对",
FMDate: "日期选择(Y-M-D)",
FMDateRange: "日期范围选择",
FMTime: "时间选择(Y-M-D H:i:s)",
FMTimeRange: "时间范围选择",
FMRadio: "单选按钮",
FMCheckbox: "复选按钮",
FMSelect: "单选下拉框",
FMSelectMultiple: "多选下拉框",
FMTreeSelect: "树型选择",
FMCascader: "级联选择",
FMUploadImage: "单图上传",
FMUploadImages: "多图上传",
FMUploadFile: "单文件上传",
FMUploadFiles: "多文件上传",
FMSwitch: "开关",
FMRate: "评分",
FMCitySelector: "省市区选择",
}
// 表单验证
const (
FRNone = "none"
FRIp = "ip"
FRPercentage = "percentage"
FRTel = "tel"
FRPhone = "phone"
FRQq = "qq"
FREmail = "email"
FRIdCard = "idCard"
FRNum = "num"
FRBankCard = "bankCard"
FRWeibo = "weibo"
FRUserName = "userName"
FRAccount = "account"
FRPassword = "password"
FRAmount = "amount"
)
var FRMap = map[string]string{
FRNone: "不验证",
FRIp: "Ipv4或Ipv6",
FRPercentage: "0-100百分比",
FRTel: "固话格式",
FRPhone: "手机号",
FRQq: "QQ号码",
FREmail: "邮箱",
FRIdCard: "身份证",
FRNum: "非零正整数",
FRBankCard: "银行卡",
FRWeibo: "微博号",
FRUserName: "用户名",
FRAccount: "账号",
FRPassword: "密码",
FRAmount: "金额",
}
// 查询条件
const (
WMEq = "=" // =
WMNeq = "!=" // !=
WMGt = ">" // >
WMGte = ">=" // >=
WMLt = "<" // <
WMLte = "<=" // <=
WMIn = "IN" // IN (...)
WMNotIn = "NOT IN" // NOT IN (...)
WMBetween = "BETWEEN" // BETWEEN
WMNotBetween = "NOT BETWEEN" // NOT BETWEEN
WMLike = "LIKE" // LIKE
WMLikeAll = "LIKE %...%" // LIKE %...%
WMNotLike = "NOT LIKE" // NOT LIKE
WMJsonContains = "JSON_CONTAINS(doc, val)" // JSON_CONTAINS(json_doc, val[, path]) // 判断是否包含某个json值
)
var WMs = []string{WMEq,
WMNeq, WMGt, WMGte, WMLt, WMLte,
WMIn, WMNotIn,
WMBetween, WMNotBetween,
WMLike, WMLikeAll, WMNotLike,
WMJsonContains,
}
// 表格列的排序方式
const (
TableAlignLeft = "left"
TableAlignRight = "right"
TableAlignCenter = "center"
)
var TableAligns = []string{TableAlignLeft, TableAlignRight, TableAlignCenter}
var TableAlignMap = map[string]string{
TableAlignLeft: "居左",
TableAlignRight: "居右",
TableAlignCenter: "居中",
}
// 是否是数字类型
func IsNumberType(goType string) bool {
switch goType {
case GoTypeInt, GoTypeUint, GoTypeInt64, GoTypeUint64:
return true
case GoTypeFloat32, GoTypeFloat64:
return true
}
return false
}
// IsSelectFormModel 是否是选择器组件
func IsSelectFormModel(FM string) bool {
switch FM {
case FMRadio, FMCheckbox, FMSelect, FMSelectMultiple, FMCitySelector, FMTreeSelect, FMCascader:
return true
}
return false
}
// 字段映射关系
// go类型
const (
GoTypeString = "string"
GoTypeDate = "date"
GoTypeDatetime = "datetime"
GoTypeInt = "int"
GoTypeUint = "uint"
GoTypeInt64 = "int64"
GoTypeUint64 = "uint64"
GoTypeIntSlice = "[]int"
GoTypeInt64Slice = "[]int64"
GoTypeUint64Slice = "[]uint64"
GoTypeFloat32 = "float32"
GoTypeFloat64 = "float64"
GoTypeBytes = "[]byte"
GoTypeBool = "bool"
GoTypeTime = "time.Time"
GoTypeGTime = "*gtime.Time"
GoTypeJson = "*gjson.Json"
)
var GoTypeNameMap = map[string]string{
GoTypeString: GoTypeString,
GoTypeDate: GoTypeDate,
GoTypeDatetime: GoTypeDatetime,
GoTypeInt: GoTypeInt,
GoTypeUint: GoTypeUint,
GoTypeInt64: GoTypeInt64,
GoTypeUint64: GoTypeUint64,
GoTypeIntSlice: GoTypeIntSlice,
GoTypeInt64Slice: GoTypeInt64Slice,
GoTypeUint64Slice: GoTypeUint64Slice,
GoTypeFloat32: GoTypeFloat32,
GoTypeFloat64: GoTypeFloat64,
GoTypeBytes: GoTypeBytes,
GoTypeBool: GoTypeBool,
GoTypeTime: GoTypeTime,
GoTypeGTime: GoTypeGTime,
GoTypeJson: GoTypeJson,
}
// ts类型
const (
TsTypeString = "string"
TsTypeNumber = "number"
TsTypeBoolean = "boolean"
TsTypeArray = "array"
TsTypeTuple = "tuple"
TsTypeAny = "any"
)
var TsTypeNameMap = map[string]string{
TsTypeString: TsTypeString,
TsTypeNumber: TsTypeNumber,
TsTypeBoolean: TsTypeBoolean,
TsTypeArray: TsTypeArray,
TsTypeTuple: TsTypeTuple,
TsTypeAny: TsTypeAny,
}
// ShiftMap Go -> Ts 类型转换
var ShiftMap = map[string]string{
GoTypeString: TsTypeString,
GoTypeDate: TsTypeString,
GoTypeDatetime: TsTypeString,
GoTypeInt: TsTypeNumber,
GoTypeUint: TsTypeNumber,
GoTypeInt64: TsTypeNumber,
GoTypeUint64: TsTypeNumber,
GoTypeIntSlice: TsTypeArray,
GoTypeInt64Slice: TsTypeArray,
GoTypeUint64Slice: TsTypeArray,
GoTypeFloat32: TsTypeNumber,
GoTypeFloat64: TsTypeNumber,
GoTypeBytes: TsTypeString,
GoTypeBool: TsTypeBoolean,
GoTypeTime: TsTypeString,
GoTypeGTime: TsTypeString,
GoTypeJson: TsTypeAny,
}
func HasColumn(fields []*genmodel.GenCodesColumnListModel, column string) bool {
for _, field := range fields {
if field.GoName == column {
return true
}
}
return false
}
func HasColumnWithFormMode(fields []*genmodel.GenCodesColumnListModel, formMode string) bool {
for _, field := range fields {
if field.FormMode == formMode {
return true
}
}
return false
}
func HasMaxSort(fields []*genmodel.GenCodesColumnListModel) bool {
return HasColumn(fields, "Sort")
}
func HasStatus(headOps []string, fields []*genmodel.GenCodesColumnListModel) bool {
if !gstr.InArray(headOps, "status") {
return false
}
return HasColumn(fields, "Status")
}
func HasSwitch(fields []*genmodel.GenCodesColumnListModel) bool {
return HasColumnWithFormMode(fields, FMSwitch)
}
func HasHookMemberSummary(fields []*genmodel.GenCodesColumnListModel) bool {
for _, field := range fields {
if IsMemberSummaryField(field.Name) {
if field.IsList {
return true
}
}
}
return false
}
func HasQueryMemberSummary(fields []*genmodel.GenCodesColumnListModel) bool {
for _, field := range fields {
if IsMemberSummaryField(field.Name) {
if field.IsQuery {
return true
}
}
}
return false
}
func IsMemberSummaryField(name string) bool {
switch name {
case "created_by", "updated_by", "deleted_by":
return true
}
return false
}
// ReviseFields 校正字段值,兼容版本升级前的老数据格式
func ReviseFields(fields []*genmodel.GenCodesColumnListModel) []*genmodel.GenCodesColumnListModel {
for _, field := range fields {
if !gstr.InArray(TableAligns, field.Align) {
field.Align = TableAlignLeft
}
if field.Width < 1 {
field.Width = -1
}
if field.Width > 2000 {
field.Width = 2000
}
if field.FormGridSpan < 1 {
field.FormGridSpan = 1
}
}
return fields
}
@@ -0,0 +1,960 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
genconsts "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_consts"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/tree"
version "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/version"
"runtime"
"slices"
"strings"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/os/gtime"
"github.com/gogf/gf/v2/os/gview"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/v2/util/gutil"
)
var Curd = gCurd{}
type gCurd struct{}
func (l *gCurd) DoBuild(ctx context.Context, in *genmodel.CurdBuildParam) (err error) {
st := gtime.Now()
preview, err := l.DoPreview(ctx, in.PreviewIn)
if err != nil {
return
}
db, err := g.DB().Open(ParseDBConfigNodeLink(&gdb.ConfigNode{Link: in.PreviewIn.DaoConfig.Link}))
if err != nil {
err = gerror.Newf("连接数据库失败,请检查配置文件[server/hack/config.yaml]数据库配置是否正确!err:%v", err.Error())
return err
}
defer db.Close()
if err = db.Ping(); err != nil {
err = gerror.Newf("数据库访问异常,请检查配置文件[server/hack/config.yaml]数据库配置是否正确!err:%v", err.Error())
return
}
// 前置操作
if len(in.BeforeEvent) > 0 {
for name, f := range in.BeforeEvent {
if gstr.InArray(in.PreviewIn.Options.AutoOps, name) {
if err = f(ctx); err != nil {
return gerror.Newf("in doBuild operation beforeEvent to '%s' failed:%v", name, err)
}
}
}
}
// 处理sql文件
handleSqlFile := func(vi *genmodel.GenFile) (err error) {
// 无需生成
if vi.Meth != genconsts.GenCodesBuildMethCreate && vi.Meth != genconsts.GenCodesBuildMethCover {
return
}
if err = gfile.PutContents(vi.Path, strings.TrimSpace(vi.Content)); err != nil {
return gerror.Newf("writing content to '%s' failed: %v", vi.Path, err)
}
// 导入失败,将sql文件删除
if err = ImportSql(ctx, vi.Path); err != nil {
_ = gfile.Remove(vi.Path)
}
return
}
// 将sql文件提取出来优先处理
// sql执行过程出错是高概率事件,后期在执行前要进行预效验,尽量减少在执行过程中出错的可能性
sqlGenFile, ok := preview.Views["source.sql"]
if ok {
delete(preview.Views, "source.sql")
if err = handleSqlFile(sqlGenFile); err != nil {
return
}
}
for _, vi := range preview.Views {
// 无需生成
if vi.Meth != genconsts.GenCodesBuildMethCreate && vi.Meth != genconsts.GenCodesBuildMethCover {
continue
}
if err = gfile.PutContents(vi.Path, strings.TrimSpace(vi.Content)); err != nil {
return gerror.Newf("writing content to '%s' failed: %v", vi.Path, err)
}
}
// 后置操作
if len(in.AfterEvent) > 0 {
for name, f := range in.AfterEvent {
if gstr.InArray(in.PreviewIn.Options.AutoOps, name) {
if err = f(ctx); err != nil {
return gerror.Newf("in doBuild operation afterEvent to '%s' failed:%v", name, err)
}
}
}
}
g.Log().Debugf(ctx, "generate code operation completed, %vms", gtime.Now().Sub(st).Milliseconds())
return
}
func (l *gCurd) DoPreview(ctx context.Context, in *genmodel.CurdPreviewParam) (res *genmodel.GenCodesPreviewModel, err error) {
// 初始化
if err = l.initInput(ctx, in); err != nil {
return nil, err
}
// 加载模板
if err = l.loadView(ctx, in); err != nil {
return nil, err
}
if err = l.generateApiContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateInputContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateControllerContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateLogicContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateRouterContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateWebApiContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateWebModelContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateWebIndexContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateWebEditContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateWebViewContent(ctx, in); err != nil {
return nil, err
}
if err = l.generateSqlContent(ctx, in); err != nil {
return nil, err
}
in.Content.Config = in.Config
res = in.Content
return
}
func (l *gCurd) initInput(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
in.Content = new(genmodel.GenCodesPreviewModel)
in.Content.Views = make(map[string]*genmodel.GenFile)
// 初始化生成选项
if err = initOptions(in); err != nil {
return err
}
// 初始化表字段配置
if err = initTableField(ctx, in); err != nil {
return err
}
// 初始化树表
if err = initTableTree(in); err != nil {
return err
}
// 初始化生成选型
initStep(in)
// 初始化方法字典
if err = initFuncDict(in); err != nil {
return err
}
// 初始化生成模板
if err = initTemplate(in); err != nil {
return err
}
return
}
func initOptions(in *genmodel.CurdPreviewParam) (err error) {
if err = in.In.Options.Scan(&in.Options); err != nil {
return
}
in.Options.DictMap = make(g.Map)
return
}
func initTableField(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
// 加载主表配置
if err = in.In.MasterColumns.Scan(&in.MasterFields); err != nil {
return
}
if len(in.MasterFields) == 0 {
if in.MasterFields, err = TableColumnList(ctx, &genmodel.GenCodesColumnListParam{DbGroup: in.In.DbName, TableName: in.In.TableName}); err != nil {
return
}
}
// 主键属性
in.Pk = getPkField(in)
if in.Pk == nil {
return gerror.New("initInput no primary key is set in the table!")
}
in.MasterFields = ReviseFields(in.MasterFields)
// 检查表命名
var names = []string{in.In.DaoName}
for _, v := range in.Options.Join {
v.Columns = ReviseFields(v.Columns)
names = append(names, v.DaoName)
g.Log().Infof(ctx, "initTableField in.Options.Join: %+v", v)
}
if err = CheckIllegalName("数据库表名", names...); err != nil {
return
}
if err = CheckIllegalName("实体命名", in.In.VarName); err != nil {
return
}
return
}
// getPkField 获取主键
func getPkField(in *genmodel.CurdPreviewParam) *genmodel.GenCodesColumnListModel {
if len(in.MasterFields) == 0 {
panic("getPkField masterFields uninitialized.")
}
for _, field := range in.MasterFields {
if IsIndexPK(field.Index) {
return field
}
}
return nil
}
func initTableTree(in *genmodel.CurdPreviewParam) (err error) {
// 检查树表字段
if in.In.GenType == genconsts.GenCodesTypeTree {
if err = CheckTreeTableFields(in.MasterFields); err != nil {
return err
}
// 解析选项树名称字段
has := false
for _, field := range in.MasterFields {
if in.Options.Tree.TitleColumn == field.Name {
in.Options.Tree.TitleField = field
has = true
break
}
}
if !has {
err = gerror.New("请选择一个有效的树名称字段")
return
}
}
return err
}
// CheckTreeTableFields 检查树表字段
func CheckTreeTableFields(columns []*genmodel.GenCodesColumnListModel) (err error) {
var fields = gutil.Copy(defaultTreeFields).([]string)
for _, v := range columns {
if gstr.InArray(fields, v.Name) {
fields = slices.DeleteFunc(fields, func(item string) bool {
return item == v.Name
})
}
}
if len(fields) > 0 {
err = gerror.Newf("树表必须包含[%v]字段", strings.Join(fields, "、"))
return err
}
return
}
func initStep(in *genmodel.CurdPreviewParam) {
in.Options.Step = new(genmodel.CurdStep)
in.Options.Step.HasMaxSort = HasMaxSort(in.MasterFields)
in.Options.Step.HasAdd = gstr.InArray(in.Options.HeadOps, "add")
in.Options.Step.HasBatchDel = gstr.InArray(in.Options.HeadOps, "batchDel") && gstr.InArray(in.Options.ColumnOps, "check")
in.Options.Step.HasExport = gstr.InArray(in.Options.HeadOps, "export")
in.Options.Step.HasNotFilterAuth = gstr.InArray(in.Options.ColumnOps, "notFilterAuth")
in.Options.Step.HasEdit = gstr.InArray(in.Options.ColumnOps, "edit")
in.Options.Step.HasDel = gstr.InArray(in.Options.ColumnOps, "del")
in.Options.Step.HasView = gstr.InArray(in.Options.ColumnOps, "view")
in.Options.Step.HasStatus = HasStatus(in.Options.ColumnOps, in.MasterFields)
in.Options.Step.HasSwitch = HasSwitch(in.MasterFields)
in.Options.Step.HasCheck = gstr.InArray(in.Options.ColumnOps, "check")
in.Options.Step.HasMenu = gstr.InArray(in.Options.AutoOps, "genMenuPermissions")
in.Options.Step.HasQueryMemberSummary = HasQueryMemberSummary(in.MasterFields)
in.Options.Step.HasHookMemberSummary = HasHookMemberSummary(in.MasterFields)
in.Options.Step.IsTreeTable = in.In.GenType == genconsts.GenCodesTypeTree
if in.Options.Step.IsTreeTable {
in.Options.Step.IsOptionTreeTable = in.Options.Tree.StyleType == genconsts.GenCodesTreeStyleTypeOption
}
in.Options.Step.HasFuncDict = gstr.InArray(in.Options.AutoOps, "genFuncDict")
in.Options.Step.IsAddon = in.Config.Application.Crud.Templates[in.In.GenTemplate].IsAddon
if in.Options.PresetStep.FormGridCols < 1 {
in.Options.PresetStep.FormGridCols = 1
}
in.Options.Step.IsEditModal = gstr.InArray(in.Options.AutoOps, "genEditModal")
}
func initFuncDict(in *genmodel.CurdPreviewParam) (err error) {
g.Log().Infof(context.Background(), "server/internal/library/xgen/gen_curd.go initFuncDict in.Options.Step.HasFuncDict:%v in.Options.FuncDict:%+v", in.Options.Step.HasFuncDict, in.Options.FuncDict)
if !in.Options.Step.HasFuncDict || in.Options.FuncDict == nil {
return
}
if len(in.Options.FuncDict.LabelColumn) == 0 || len(in.Options.FuncDict.ValueColumn) == 0 {
err = gerror.New("生成字典选项必须设置选项值和选项名称")
return err
}
for _, field := range in.MasterFields {
if field.Name == in.Options.FuncDict.ValueColumn {
in.Options.FuncDict.Value = field
}
if field.Name == in.Options.FuncDict.LabelColumn {
in.Options.FuncDict.Label = field
}
}
g.Log().Infof(context.Background(), "server/internal/library/xgen/gen_curd.go initFuncDict in.Options.Step.HasFuncDict:%v in.Options.FuncDict:%+v", in.Options.Step.HasFuncDict, in.Options.FuncDict)
return
}
func initTemplate(in *genmodel.CurdPreviewParam) (err error) {
if len(in.Config.Application.Crud.Templates)-1 < in.In.GenTemplate {
return gerror.New("没有找到生成模板的配置,请检查!")
}
// api前缀
apiPrefix := gstr.LcFirst(in.In.VarName)
if in.Config.Application.Crud.Templates[in.In.GenTemplate].IsAddon {
apiPrefix = in.In.AddonName + "/" + apiPrefix
}
in.Options.ApiPrefix = apiPrefix
if err = checkCurdPath(in.Config.Application.Crud.Templates[in.In.GenTemplate], in.In.AddonName); err != nil {
return
}
in.Options.TemplateGroup = in.Config.Application.Crud.Templates[in.In.GenTemplate].MasterPackage
return
}
func checkCurdPath(temp *genmodel.GenCodesConfigCrudTemplate, addonName string) (err error) {
if temp == nil {
return gerror.New("生成模板配置不能为空")
}
if temp.IsAddon {
temp.TemplatePath = gstr.Replace(temp.TemplatePath, "{$name}", addonName)
temp.ApiPath = gstr.Replace(temp.ApiPath, "{$name}", addonName)
temp.InputPath = gstr.Replace(temp.InputPath, "{$name}", addonName)
temp.ControllerPath = gstr.Replace(temp.ControllerPath, "{$name}", addonName)
temp.LogicPath = gstr.Replace(temp.LogicPath, "{$name}", addonName)
temp.RouterPath = gstr.Replace(temp.RouterPath, "{$name}", addonName)
temp.SqlPath = gstr.Replace(temp.SqlPath, "{$name}", addonName)
temp.WebApiPath = gstr.Replace(temp.WebApiPath, "{$name}", addonName)
temp.WebViewsPath = gstr.Replace(temp.WebViewsPath, "{$name}", addonName)
}
tip := `生成模板配置参数'%s'路径不存在,请先创建路径:%s`
if !gfile.Exists(temp.TemplatePath) {
return gerror.Newf(tip, "TemplatePath", temp.TemplatePath)
}
if !gfile.Exists(temp.ApiPath) {
return gerror.Newf(tip, "ApiPath", temp.ApiPath)
}
if !gfile.Exists(temp.InputPath) {
return gerror.Newf(tip, "InputPath", temp.InputPath)
}
if !gfile.Exists(temp.ControllerPath) {
return gerror.Newf(tip, "ControllerPath", temp.ControllerPath)
}
if !gfile.Exists(temp.LogicPath) {
return gerror.Newf(tip, "LogicPath", temp.LogicPath)
}
if !gfile.Exists(temp.RouterPath) {
return gerror.Newf(tip, "RouterPath", temp.RouterPath)
}
if !gfile.Exists(temp.SqlPath) {
return gerror.Newf(tip, "SqlPath", temp.SqlPath)
}
if !gfile.Exists(temp.WebApiPath) {
return gerror.Newf(tip, "WebApiPath", temp.WebApiPath)
}
if !gfile.Exists(temp.WebViewsPath) {
return gerror.Newf(tip, "WebViewsPath", temp.WebViewsPath)
}
return
}
func (l *gCurd) loadView(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
temp := in.Config.Application.Crud.Templates[in.In.GenTemplate]
view := gview.New()
err = view.SetConfigWithMap(g.Map{
"Paths": temp.TemplatePath,
"Delimiters": in.Config.Delimiters,
})
if err != nil {
return
}
now := gtime.Now()
view.BindFuncMap(g.Map{
"NowYear": now.Year, // 当前年
"ToLower": strings.ToLower, // 全部小写
"LcFirst": gstr.LcFirst, // 首字母小写
"UcFirst": gstr.UcFirst, // 首字母大写
"ToTSArray": ToTSArray, // 转为ts数组格式
"DictToTSArray": DictToTSArray, // 转为ts数组格式
})
if err = l.generateWebModelDictOptions(ctx, in); err != nil {
return
}
modName, err := GetModName(ctx)
if err != nil {
return
}
importApi := gstr.Replace(temp.ApiPath, "./", modName+"/") + "/" + strings.ToLower(in.In.VarName) + "/v1"
importInput := gstr.Replace(temp.InputPath, "./", modName+"/")
importController := gstr.Replace(temp.ControllerPath, "./", modName+"/")
importService := "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
if temp.IsAddon {
importService = "git.5iot.com/iotlcm-public/iotlcm-admin-svr/addons/" + in.In.AddonName + "/service"
}
in.Options.ImportWebApi = "#/api/gen/" + gstr.LcFirst(in.In.VarName)
if temp.IsAddon {
in.Options.ImportWebApi = "#/api/gen/addons/" + in.In.AddonName + "/" + gstr.LcFirst(in.In.VarName)
}
componentPrefix := gstr.LcFirst(in.In.VarName)
if temp.IsAddon {
componentPrefix = "addons/" + in.In.AddonName + "/" + componentPrefix
}
nowTime := now.Format("Y-m-d H:i:s")
view.Assigns(gview.Params{
"templateGroup": in.Options.TemplateGroup, // 生成模板分组名称
"servFunName": l.parseServFunName(in.Options.TemplateGroup, in.In.VarName), // 业务服务名称
"nowTime": nowTime, // 当前时间
"version": runtime.Version(), // GO 版本
"xjVersion": version.BuildVersion, // XJ 版本
"varName": in.In.VarName, // 实体名称
"tableComment": in.In.TableComment, // 对外名称
"daoName": in.In.DaoName, // ORM模型
"masterFields": in.MasterFields, // 主表字段
"pk": in.Pk, // 主键属性
"options": in.Options, // 提交选项
"dictOptions": in.Options.DictOps, // web字典选项
"importApi": importApi, // 导入goApi包
"importInput": importInput, // 导入input包
"importController": importController, // 导入控制器包
"importService": importService, // 导入业务服务
"importWebApi": in.Options.ImportWebApi, // 导入webApi
"apiPrefix": in.Options.ApiPrefix, // api前缀
"componentPrefix": componentPrefix, // vue子组件前缀
})
in.View = view
return
}
func (l *gCurd) generateApiContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "api.go"
tplData = g.Map{}
genFile = new(genmodel.GenFile)
)
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content, err = FormatGo(ctx, name, genFile.Content)
if err != nil {
return err
}
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].ApiPath, strings.ToLower(in.In.VarName), "v1", strings.ToLower(in.In.VarName)+".go")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateInputContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "input.go"
genFile = new(genmodel.GenFile)
)
tplData, err := l.inputTplData(ctx, in)
if err != nil {
return err
}
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content, err = FormatGo(ctx, name, genFile.Content)
if err != nil {
return err
}
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].InputPath, CamelCaseToUnderline(in.In.VarName)+".go")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateControllerContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "controller.go"
tplData = g.Map{}
genFile = new(genmodel.GenFile)
)
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content, err = FormatGo(ctx, name, genFile.Content)
if err != nil {
return err
}
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].ControllerPath, CamelCaseToUnderline(in.In.VarName)+".go")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateLogicContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "logic.go"
genFile = new(genmodel.GenFile)
)
tplData, err := l.logicTplData(ctx, in)
if err != nil {
return err
}
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content, err = FormatGo(ctx, name, genFile.Content)
if err != nil {
return err
}
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].LogicPath, CamelCaseToUnderline(in.In.VarName)+".go")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateRouterContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "router.go"
tplData = g.Map{}
genFile = new(genmodel.GenFile)
)
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content, err = FormatGo(ctx, name, genFile.Content)
if err != nil {
return err
}
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].RouterPath, CamelCaseToUnderline(in.In.VarName)+".go")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateWebApiContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "web.api.ts"
tplData = g.Map{}
genFile = new(genmodel.GenFile)
)
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content = FormatTs(genFile.Content)
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].WebApiPath, gstr.LcFirst(in.In.VarName), "index.ts")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateWebModelContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "web.model.ts"
genFile = new(genmodel.GenFile)
)
tplData, err := l.webModelTplData(ctx, in)
if err != nil {
return
}
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return
}
genFile.Content = FormatTs(genFile.Content)
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].WebViewsPath, gstr.LcFirst(in.In.VarName), "model.tsx")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateWebIndexContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "web.index.vue"
genFile = new(genmodel.GenFile)
)
tplData, err := l.webIndexTplData(ctx, in)
if err != nil {
return err
}
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content = FormatVue(genFile.Content)
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].WebViewsPath, gstr.LcFirst(in.In.VarName), "index.vue")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateWebEditContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "web.edit.vue"
genFile = new(genmodel.GenFile)
)
tplData, err := l.webEditTplData(ctx, in)
if err != nil {
return err
}
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content = FormatVue(genFile.Content)
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].WebViewsPath, gstr.LcFirst(in.In.VarName), "edit.vue")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
if !in.Options.Step.HasEdit {
genFile.Meth = genconsts.GenCodesBuildIgnore
genFile.Required = false
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateWebViewContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "web.view.vue"
genFile = new(genmodel.GenFile)
)
tplData, err := l.webViewTplData(ctx, in)
if err != nil {
return err
}
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
genFile.Content = FormatVue(genFile.Content)
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].WebViewsPath, gstr.LcFirst(in.In.VarName), "view.vue")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if genFile.Meth == genconsts.GenCodesBuildMethSkip && gstr.InArray(in.Options.AutoOps, "forcedCover") {
genFile.Meth = genconsts.GenCodesBuildMethCover
}
if !in.Options.Step.HasView {
genFile.Meth = genconsts.GenCodesBuildIgnore
genFile.Required = false
}
in.Content.Views[name] = genFile
return
}
func (l *gCurd) generateSqlContent(ctx context.Context, in *genmodel.CurdPreviewParam) (err error) {
var (
name = "source.sql"
config = g.DB("default").GetConfig()
tplData = g.Map{
"dbName": config.Name,
"menuTable": config.Prefix + "sys_menu",
"mainComponent": "LAYOUT",
}
genFile = new(genmodel.GenFile)
)
menus, err := service.SysMenu().GetFastList(ctx)
if err != nil {
return err
}
tplData["dirPid"], tplData["dirLevel"], tplData["dirTree"], err = xgorm.AutoUpdateTree(ctx, &dao.SysMenu, 0, int64(in.Options.Menu.Pid), &xgorm.TreeFiledOption{
IdField: "menu_id",
PidField: "parent_id",
LevelField: "level",
TreeField: "tree",
})
if err != nil {
return err
}
tplData["listLevel"] = tplData["dirLevel"].(int) + 1
tplData["btnLevel"] = tplData["dirLevel"].(int) + 2
tplData["sortLevel"] = tplData["dirLevel"].(int) + 3
pageRedirect := ""
if in.Options.Menu.Pid > 0 {
tplData["mainComponent"] = "ParentLayout"
menu, ok := menus[int64(in.Options.Menu.Pid)]
if !ok {
err = gerror.New("选择的上级菜单不存在")
return
}
for _, id := range tree.GetIds(menu.Tree) {
if v, ok2 := menus[id]; ok2 {
if !gstr.HasSuffix(pageRedirect, "/") && !gstr.HasPrefix(v.Path, "/") {
pageRedirect += "/"
}
pageRedirect += v.Path
}
}
if !gstr.HasSuffix(pageRedirect, "/") && !gstr.HasPrefix(menu.Path, "/") {
pageRedirect += "/"
}
pageRedirect += menu.Path
}
pageRedirect += "/" + gstr.LcFirst(in.In.VarName) + "/index"
tplData["pageRedirect"] = pageRedirect
genFile.Path = utility.MergeAbs(in.Config.Application.Crud.Templates[in.In.GenTemplate].SqlPath, CamelCaseToUnderline(in.In.VarName)+"_menu.sql")
genFile.Meth = genconsts.GenCodesBuildMethCreate
if gfile.Exists(genFile.Path) {
genFile.Meth = genconsts.GenCodesBuildMethSkip
}
genFile.Required = true
if !in.Options.Step.HasMenu {
genFile.Meth = genconsts.GenCodesBuildIgnore
genFile.Required = false
}
// 需要生成时,检查菜单命名是否存在
if genFile.Meth == genconsts.GenCodesBuildMethCreate {
menuNamePrefix := gstr.LcFirst(in.In.VarName)
menuNames := []string{menuNamePrefix, menuNamePrefix + "Index"}
if in.Options.Step.HasEdit {
menuNames = append(menuNames, menuNamePrefix+"Edit")
menuNames = append(menuNames, menuNamePrefix+"View")
}
if in.Options.Step.HasView {
menuNames = append(menuNames, menuNamePrefix+"View")
}
if in.Options.Step.HasMaxSort {
menuNames = append(menuNames, menuNamePrefix+"MaxSort")
}
if in.Options.Step.HasDel {
menuNames = append(menuNames, menuNamePrefix+"Delete")
}
if in.Options.Step.HasStatus {
menuNames = append(menuNames, menuNamePrefix+"Status")
}
if in.Options.Step.HasSwitch {
menuNames = append(menuNames, menuNamePrefix+"Switch")
}
if in.Options.Step.HasExport {
menuNames = append(menuNames, menuNamePrefix+"Export")
}
if in.Options.Step.IsTreeTable {
menuNames = append(menuNames, menuNamePrefix+"TreeOption")
}
menuNames = utility.UniqueSlice(menuNames)
hasMenus, err := service.SysMenu().Model(ctx).Fields("path").WhereIn("path", menuNames).Array()
if err != nil {
return err
}
if len(hasMenus) > 0 {
err = gerror.Newf("要生成的菜单中有已存在的路由别名,请检查并删除:%v", strings.Join(gvar.New(hasMenus).Strings(), `、`))
return err
}
}
tplData["generatePath"] = genFile.Path
genFile.Content, err = in.View.Parse(ctx, name+".template", tplData)
if err != nil {
return err
}
in.Content.Views[name] = genFile
return
}
@@ -0,0 +1,409 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"bytes"
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/text/gstr"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
)
const (
InputTypeListInp = 1 // 列表输入
InputTypeListModel = 2 // 列表输出
InputTypeExportModel = 3 // 列表导出
InputTypeEditInpValidator = 4 // 添加&编辑验证器
InputTypeUpdateFields = 5 // 编辑修改过滤字段
InputTypeInsertFields = 6 // 编辑新增过滤字段
InputTypeTreeOptionFields = 7 // 关系树查询字段
InputTypeEditParamFields = 8 // 编辑参数过滤字段
EditInpValidatorGenerally = "if err := g.Validator().Rules(\"%s\").Data(in.%s).Messages(\"%s\").Run(ctx); err != nil {\n\t\treturn err.Current()\n\t}\n"
)
func (l *gCurd) inputTplData(ctx context.Context, in *genmodel.CurdPreviewParam) (data g.Map, err error) {
data = make(g.Map)
data["listInpColumns"] = l.genInputListColumns(ctx, in, InputTypeListInp)
data["listModelColumns"] = l.genInputListColumns(ctx, in, InputTypeListModel)
data["exportModelColumns"] = l.genInputListColumns(ctx, in, InputTypeExportModel)
data["editInpValidator"] = l.genInputListColumns(ctx, in, InputTypeEditInpValidator)
data["updateFieldsColumns"] = l.genInputListColumns(ctx, in, InputTypeUpdateFields)
data["insertFieldsColumns"] = l.genInputListColumns(ctx, in, InputTypeInsertFields)
data["editParamFieldsColumns"] = l.genInputListColumns(ctx, in, InputTypeEditParamFields)
data["viewModelColumns"] = l.genInputViewColumns(ctx, in)
if in.Options.Step.IsTreeTable {
data["treeOptionFields"] = l.genInputListColumns(ctx, in, InputTypeTreeOptionFields)
}
return
}
func (l *gCurd) genInputViewColumns(ctx context.Context, in *genmodel.CurdPreviewParam) string {
buffer := bytes.NewBuffer(nil)
index := 0
array := make([][]string, 1000)
// 主表
for _, field := range in.MasterFields {
// 查询用户摘要
if field.IsList && in.Options.Step.HasHookMemberSummary && IsMemberSummaryField(field.Name) {
tagKey := "`"
descriptionTag := gstr.Replace(formatComment(field.Dc)+"摘要信息", `"`, `\"`)
result := []string{" #" + field.GoName + "Summa"}
result = append(result, " #*hook.MemberSumma")
result = append(result, " #"+fmt.Sprintf(tagKey+`json:"%s"`, field.TsName+"Summa"))
result = append(result, " #"+fmt.Sprintf(`dc:"%s"`+tagKey, descriptionTag))
array[index] = result
index++
}
}
table := tablewriter.NewTable(buffer)
table.Options(tablewriter.WithRendition(tw.Rendition{
Borders: tw.Border{
Top: tw.Fail,
Bottom: tw.Fail,
Left: tw.Success,
Right: tw.Success,
},
Settings: tw.Settings{
Lines: tw.Lines{
ShowTop: tw.Fail,
ShowBottom: tw.Fail,
ShowHeaderLine: tw.Fail,
ShowFooterLine: tw.Fail,
},
},
}), tablewriter.WithRowConfig(tw.CellConfig{
Formatting: tw.CellFormatting{
AutoWrap: tw.WrapNone,
},
}), tablewriter.WithSymbols(tw.NewSymbols(tw.StyleDefault)))
// tw.SetBorder(false)
// tw.SetRowLine(false)
// tw.SetAutoWrapText(false)
// tw.SetColumnSeparator("")
table.Bulk(array)
table.Render()
stContent := buffer.String()
// Let's do this hack of table writer for indent!
stContent = gstr.Replace(stContent, "│#", " ")
stContent = gstr.Replace(stContent, "│", " ")
stContent = gstr.Replace(stContent, " #", " ")
stContent = gstr.Replace(stContent, "` ", "`")
stContent = gstr.Replace(stContent, "``", "")
stContent = removeEndWrap(stContent)
buffer.Reset()
buffer.WriteString(stContent)
return "\tentity." + in.In.DaoName + "\n" + buffer.String()
}
func (l *gCurd) genInputListColumns(ctx context.Context, in *genmodel.CurdPreviewParam, inputType int) string {
buffer := bytes.NewBuffer(nil)
index := 0
array := make([][]string, 1000)
// 主表
for _, field := range in.MasterFields {
row := l.genStructFieldDefinition(in, field, inputType, true)
if row == nil {
continue
}
array[index] = row
index++
switch inputType {
case InputTypeListModel:
// 查询用户摘要
if field.IsList && in.Options.Step.HasHookMemberSummary && IsMemberSummaryField(field.Name) {
tagKey := "`"
descriptionTag := gstr.Replace(formatComment(field.Dc)+"摘要信息", `"`, `\"`)
result := []string{" #" + field.GoName + "Summa"}
result = append(result, " #*hook.MemberSumma")
result = append(result, " #"+fmt.Sprintf(tagKey+`json:"%s"`, field.TsName+"Summa"))
result = append(result, " #"+fmt.Sprintf(`dc:"%s"`+tagKey, descriptionTag))
array[index] = result
index++
}
}
}
// 关联表
if len(in.Options.Join) > 0 {
for _, v := range in.Options.Join {
if !isEffectiveJoin(v) {
g.Log().Infof(ctx, "genInputListColumns isEffectiveJoin: %+v", v)
continue
}
g.Log().Infof(ctx, "genInputListColumns v: %+v", v)
for _, field := range v.Columns {
row := l.genStructFieldDefinition(in, field, inputType, false)
g.Log().Infof(ctx, "genInputListColumns field: %+v row: %+v", field, row)
if row != nil {
array[index] = row
index++
}
}
}
}
g.Log().Infof(ctx, "genInputListColumns in.Options.Join: %+v", in.Options.Join)
table := tablewriter.NewWriter(buffer)
table.Options(tablewriter.WithRendition(tw.Rendition{
Borders: tw.Border{
Top: tw.Fail,
Bottom: tw.Fail,
Left: tw.Success,
Right: tw.Success,
},
Settings: tw.Settings{
Lines: tw.Lines{
ShowTop: tw.Fail,
ShowBottom: tw.Fail,
ShowHeaderLine: tw.Fail,
ShowFooterLine: tw.Fail,
},
},
}), tablewriter.WithRowConfig(tw.CellConfig{
Formatting: tw.CellFormatting{
AutoWrap: tw.WrapNone,
},
}), tablewriter.WithSymbols(tw.NewSymbols(tw.StyleDefault)))
// tw.SetBorder(false)
// tw.SetRowLine(false)
// tw.SetAutoWrapText(false)
// tw.SetColumnSeparator("")
table.Bulk(array)
table.Render()
stContent := buffer.String()
// Let's do this hack of table writer for indent!
stContent = gstr.Replace(stContent, "│#", " ")
stContent = gstr.Replace(stContent, "│", " ")
stContent = gstr.Replace(stContent, " #", " ")
stContent = gstr.Replace(stContent, "` ", "`")
stContent = gstr.Replace(stContent, "``", "")
stContent = removeEndWrap(stContent)
buffer.Reset()
buffer.WriteString(stContent)
return buffer.String()
}
// genStructFieldForModel gens and returns the attribute definition for specified field.
func (l *gCurd) genStructFieldDefinition(in *genmodel.CurdPreviewParam, field *genmodel.GenCodesColumnListModel, inputType int, isMaster bool) []string {
var (
tagKey = "`"
result = []string{" #" + field.GoName}
descriptionTag = gstr.Replace(formatComment(field.Dc), `"`, `\"`)
)
addResult := func() []string {
if inputType == InputTypeEditParamFields && !IsIndexPK(field.Index) && !strings.HasPrefix(field.GoType, "*") {
result = append(result, " #*"+field.GoType)
} else {
result = append(result, " #"+field.GoType)
}
result = append(result, " #"+fmt.Sprintf(tagKey+`json:"%s"`, field.TsName))
result = append(result, " #"+fmt.Sprintf(`dc:"%s"`+tagKey, descriptionTag))
return result
}
isUpdateOrInsertInputFields := func() bool {
if !isMaster {
return field.IsEdit
}
if field.IsEdit {
return true
}
// 树表内部维护字段
if in.Options.Step.IsTreeTable && gstr.InArray(defaultTreeFields, field.Name) {
return true
}
// 修改人
if inputType == InputTypeUpdateFields && field.GoName == "UpdatedBy" {
return true
}
if inputType == InputTypeUpdateFields && field.GoName == "UpdatedAt" {
return true
}
// 创建人
if inputType == InputTypeInsertFields && field.GoName == "CreatedBy" {
return true
}
if inputType == InputTypeInsertFields && field.GoName == "CreatedAt" {
return true
}
if inputType == InputTypeInsertFields && field.GoName == "CreatedDept" {
return true
}
if inputType == InputTypeInsertFields && field.GoName == "TenantId" {
return true
}
return false
}
isQuery := false
switch inputType {
case InputTypeListInp:
if in.Options.Step.IsTreeTable && IsPidName(field.Name) {
isQuery = true
field.QueryWhere = WMEq
}
if !field.IsQuery && !isQuery {
return nil
}
if field.QueryWhere == WMBetween {
result = append(result, " #[]"+field.GoType)
} else {
// 查询用户摘要时,固定接收字符串类型
if field.IsQuery && in.Options.Step.HasQueryMemberSummary && IsMemberSummaryField(field.Name) {
result = append(result, " #string")
} else {
result = append(result, " #"+field.GoType)
}
}
result = append(result, " #"+fmt.Sprintf(tagKey+`json:"%s"`, field.TsName))
result = append(result, " #"+fmt.Sprintf(`dc:"%s"`+tagKey, descriptionTag))
case InputTypeListModel:
// 主表的主键
if IsIndexPK(field.Index) && isMaster {
addResult()
// 树表的pid字段
} else if in.Options.Step.IsTreeTable && IsPidName(field.Name) {
addResult()
} else if field.IsList {
addResult()
} else {
return nil
}
case InputTypeExportModel:
if !field.IsExport {
return nil
}
addResult()
case InputTypeEditInpValidator:
if !field.IsEdit {
return nil
}
if !field.Required && (field.FormRole == "none" || field.FormRole == "") {
return nil
}
rule := "// 验证" + field.Dc + "\n"
if field.Required && (field.FormRole == FRNone || field.FormRole == "") {
field.FormRole = "required"
}
if err, s := makeValidatorFunc(field); err != nil {
return nil
} else {
rule += s
}
result = []string{rule}
case InputTypeUpdateFields:
if !isUpdateOrInsertInputFields() {
return nil
}
addResult()
case InputTypeInsertFields:
if !isUpdateOrInsertInputFields() {
return nil
}
addResult()
case InputTypeTreeOptionFields:
if IsIndexPK(field.Index) {
return addResult()
}
if IsPidName(field.Name) {
return addResult()
}
if in.Options.Tree.TitleColumn == field.Name {
return addResult()
}
return nil
case InputTypeEditParamFields:
return addResult()
default:
panic("inputType is invalid")
}
return result
}
func makeValidatorFunc(field *genmodel.GenCodesColumnListModel) (err error, rule string) {
if field.FormRole == "required" {
rule = fmt.Sprintf(EditInpValidatorGenerally, "required", field.GoName, field.Dc+"不能为空")
} else if field.FormRole == FRIp {
rule = fmt.Sprintf(EditInpValidatorGenerally, "ip", field.GoName, field.Dc+"必须为IPV4或IPV6")
} else if field.FormRole == FRPercentage {
rule = fmt.Sprintf(EditInpValidatorGenerally, "min:0|max:100", field.GoName, field.Dc+"必须0-100之间")
} else if field.FormRole == FRTel {
rule = fmt.Sprintf(EditInpValidatorGenerally, "telephone", field.GoName, field.Dc+"不是座机号码")
} else if field.FormRole == FRPhone {
rule = fmt.Sprintf(EditInpValidatorGenerally, "phone", field.GoName, field.Dc+"不是手机号码")
} else if field.FormRole == FRQq {
rule = fmt.Sprintf(EditInpValidatorGenerally, "qq", field.GoName, field.Dc+"不是QQ号码")
} else if field.FormRole == FREmail {
rule = fmt.Sprintf(EditInpValidatorGenerally, "email", field.GoName, field.Dc+"不是邮箱地址")
} else if field.FormRole == FRIdCard {
rule = fmt.Sprintf(EditInpValidatorGenerally, "resident-id", field.GoName, field.Dc+"不是身份证号码")
} else if field.FormRole == FRNum {
rule = fmt.Sprintf(EditInpValidatorGenerally, "min:1", field.GoName, field.Dc+"必须大于0")
} else if field.FormRole == FRBankCard {
rule = fmt.Sprintf(EditInpValidatorGenerally, "bank-card", field.GoName, field.Dc+"不是银行卡号")
} else if field.FormRole == FRWeibo {
rule = fmt.Sprintf(EditInpValidatorGenerally, "regex:^[0-9a-zA-Z\\u4e00-\\u9fa5_-]*$", field.GoName, field.Dc+"不是微博号")
} else if field.FormRole == FRUserName {
rule = fmt.Sprintf(EditInpValidatorGenerally, "regex:^[0-9a-zA-Z]{6,16}$", field.GoName, field.Dc+"必须为6-16位由字母和数字组成")
} else if field.FormRole == FRAccount {
rule = fmt.Sprintf(EditInpValidatorGenerally, "regex:^[\\w_\\d]{6,16}$", field.GoName, field.Dc+"必须为6-16位由字母、数字或下划线组成")
} else if field.FormRole == FRPassword {
rule = fmt.Sprintf(EditInpValidatorGenerally, "regex:^(?![0-9]+$)(?![a-zA-Z]+$)[0-9A-Za-z]{6,18}$", field.GoName, field.Dc+"必须包含6-18为字母和数字")
} else if field.FormRole == FRAmount {
rule = fmt.Sprintf(EditInpValidatorGenerally, "regex:(^[0-9]{1,10}$)|(^[0-9]{1,10}[\\\\.]{1}[0-9]{1,2}$)", field.GoName, field.Dc+"最多允许输入10位整数及2位小数")
} else {
err = gerror.New("not support")
}
// 生成验证字典
if field.DictType != "" {
var (
ctx = context.Background()
valueType gdb.Value
resultValue gdb.Result
)
valueType, err = dao.SysDictType.Ctx(ctx).Fields(dao.SysDictType.Columns().DictType).Where(dao.SysDictType.Columns().DictId, field.DictType).Value()
if err != nil {
return
}
resultValue, err = dao.SysDictData.Ctx(context.Background()).Fields(dao.SysDictData.Columns().DictValue).Where(dao.SysDictData.Columns().DictType, valueType.String()).All()
if err != nil {
return
}
if resultValue.Len() > 0 {
names := make([]string, 0)
for _, item := range resultValue {
names = append(names, item["value"].String())
}
dictRule := "in:" + strings.Join(names, ",")
rule += fmt.Sprintf(EditInpValidatorGenerally, dictRule, field.GoName, field.Dc+"值不正确")
err = nil
}
}
return
}
@@ -0,0 +1,316 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"bytes"
"context"
"fmt"
genconsts "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_consts"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/text/gstr"
)
const (
LogicWhereComments = "\n\t// 查询%s\n"
LogicWhereNoSupport = "\t// TODO 暂不支持生成[ %s ]查询方式,请自行补充此处代码!"
LogicEditUpdate = "\tif _, err = s.Model(ctx%s).\n\t\t\tFields(%sin.%sUpdateFields{}).\n\t\t\tWherePri(in.%s).Data(in).Update(); err != nil {\n\t\t\terr = gerror.Wrap(err, \"修改%s失败,请稍后重试!\")\n\t\t}\n\t\treturn"
LogicEditInsert = "\tif _, err = s.Model(ctx, &handler.Option{FilterAuth: false}).\n\t\tFields(%sin.%sInsertFields{}).\n\t\tData(in).Insert(); err != nil {\n\t\terr = gerror.Wrap(err, \"新增%s失败,请稍后重试!\")\n\t}"
LogicEditUnique = "\t// 验证'%s'唯一\n\tif err = xgorm.IsUnique(ctx, &dao.%s, g.Map{dao.%s.Columns().%s: in.%s}, \"%s已存在\", in.%s); err != nil {\n\t\treturn\n\t}\n"
LogicSwitchUpdate = "g.Map{\n\t\tin.Key: in.Value,\n%s}"
LogicStatusUpdate = "g.Map{\n\t\tdao.%s.Columns().Status: in.Status,\n%s}"
LogicDeletedUpdate = "g.Map{\n%s}"
)
func (l *gCurd) logicTplData(ctx context.Context, in *genmodel.CurdPreviewParam) (data g.Map, err error) {
g.Log().Infof(ctx, "logicTplData in.Options.Join: %+v", in.Options.Join)
data = make(g.Map)
data["listWhere"] = l.genLogicListWhere(ctx, in)
data["listJoin"] = l.genLogicListJoin(ctx, in)
data["listFields"] = l.genLogicListFields(ctx, in)
data["listOrder"] = l.genLogicListOrder(ctx, in)
data["edit"] = l.genLogicEdit(ctx, in)
data["switchFields"] = l.genLogicSwitchFields(ctx, in)
data["switchUpdate"] = l.genLogicSwitchUpdate(ctx, in)
data["statusUpdate"] = l.genLogicStatusUpdate(ctx, in)
data["deletedUpdate"] = l.genLogicDeletedUpdate(ctx, in)
return
}
func (l *gCurd) genLogicDeletedUpdate(ctx context.Context, in *genmodel.CurdPreviewParam) string {
isDestroy := false
var update string
for _, field := range in.MasterFields {
if field.GoName == "DeletedBy" {
update += "\t\tdao." + in.In.DaoName + ".Columns().DeletedBy: contexts.GetUserId(ctx),\n"
isDestroy = true
}
if field.GoName == "DeletedAt" {
update += "\t\tdao." + in.In.DaoName + ".Columns().DeletedAt: gtime.Now(),\n"
}
}
if !isDestroy {
return ""
}
update += "\t"
return fmt.Sprintf(LogicDeletedUpdate, update)
}
func (l *gCurd) genLogicStatusUpdate(ctx context.Context, in *genmodel.CurdPreviewParam) string {
var update string
for _, field := range in.MasterFields {
if field.GoName == "UpdatedBy" {
update += "\t\tdao." + in.In.DaoName + ".Columns().UpdatedBy: contexts.GetUserId(ctx),\n"
}
}
update += "\t"
return fmt.Sprintf(LogicStatusUpdate, in.In.DaoName, update)
}
func (l *gCurd) genLogicSwitchUpdate(ctx context.Context, in *genmodel.CurdPreviewParam) string {
var update string
for _, field := range in.MasterFields {
if field.GoName == "UpdatedBy" {
update += "\t\tdao." + in.In.DaoName + ".Columns().UpdatedBy: contexts.GetUserId(ctx),\n"
}
}
update += "\t"
return fmt.Sprintf(LogicSwitchUpdate, update)
}
func (l *gCurd) genLogicSwitchFields(ctx context.Context, in *genmodel.CurdPreviewParam) string {
buffer := bytes.NewBuffer(nil)
if in.Options.Step.HasSwitch {
for _, field := range in.MasterFields {
if field.FormMode == "Switch" {
buffer.WriteString("\t\tdao." + in.In.DaoName + ".Columns()." + field.GoName + ",\n")
}
}
}
return buffer.String()
}
func (l *gCurd) genLogicEdit(ctx context.Context, in *genmodel.CurdPreviewParam) g.Map {
var (
data = make(g.Map)
updateBuffer = bytes.NewBuffer(nil)
insertBuffer = bytes.NewBuffer(nil)
uniqueBuffer = bytes.NewBuffer(nil)
)
for _, field := range in.MasterFields {
if field.GoName == "TenantId" {
insertBuffer.WriteString("\t\ttenantId := contexts.GetTenantId(ctx)\n\t\tin.TenantId = &tenantId\n")
}
if field.GoName == "CreatedDept" {
insertBuffer.WriteString("\t\tcreatedDept := contexts.GetDeptId(ctx)\n\t\tin.CreatedDept = &createdDept\n")
}
if field.GoName == "CreatedAt" {
insertBuffer.WriteString("\t\tin.CreatedAt = gtime.Now()\n")
}
if field.GoName == "CreatedBy" {
insertBuffer.WriteString("\t\tcreatedBy := contexts.GetUserId(ctx)\n\t\tin.CreatedBy = &createdBy\n")
}
if field.GoName == "UpdatedBy" {
updateBuffer.WriteString("\t\tupdatedBy := contexts.GetUserId(ctx)\n\t\tin.UpdatedBy = &updatedBy\n")
}
if field.GoName == "UpdatedAt" {
updateBuffer.WriteString("\t\tin.UpdatedAt = gtime.Now()\n")
}
if field.Unique {
uniqueBuffer.WriteString(fmt.Sprintf(LogicEditUnique, field.GoName, in.In.DaoName, in.In.DaoName, field.GoName, field.GoName, field.Dc, in.Pk.GoName))
}
}
notFilterAuth := ""
if gstr.InArray(in.Options.ColumnOps, "notFilterAuth") {
notFilterAuth = ", &handler.Option{FilterAuth: false}"
}
updateBuffer.WriteString(fmt.Sprintf(LogicEditUpdate, notFilterAuth, in.Options.TemplateGroup, in.In.VarName, in.Pk.GoName, in.In.TableComment))
insertBuffer.WriteString(fmt.Sprintf(LogicEditInsert, in.Options.TemplateGroup, in.In.VarName, in.In.TableComment))
data["update"] = updateBuffer.String()
data["insert"] = insertBuffer.String()
data["unique"] = uniqueBuffer.String()
return data
}
func (l *gCurd) genLogicListOrder(ctx context.Context, in *genmodel.CurdPreviewParam) string {
statement := ""
if hasEffectiveJoins(in.Options.Join) {
statement = "dao." + in.In.DaoName + ".Table() + \".\" +"
}
buffer := bytes.NewBuffer(nil)
if in.Options.Step.HasMaxSort {
buffer.WriteString("OrderAsc(" + statement + "dao." + in.In.DaoName + ".Columns().Sort).")
}
buffer.WriteString("OrderDesc(" + statement + "dao." + in.In.DaoName + ".Columns()." + in.Pk.GoName + ")")
return buffer.String()
}
func (l *gCurd) genLogicListJoin(ctx context.Context, in *genmodel.CurdPreviewParam) (link string) {
connector := `"="`
if hasEffectiveJoins(in.Options.Join) {
linkBuffer := bytes.NewBuffer(nil)
for _, join := range in.Options.Join {
if isEffectiveJoin(join) {
linkBuffer.WriteString("\tmod = mod." + genconsts.GenCodesJoinLinkMap[join.LinkMode] + "OnFields(dao." + join.DaoName + ".Table(), dao." + in.In.DaoName + ".Columns()." + gstr.CaseCamel(join.MasterField) + "," + connector + ", dao." + join.DaoName + ".Columns()." + gstr.CaseCamel(join.Field) + ")\n")
}
}
link = linkBuffer.String()
}
return
}
func (l *gCurd) genLogicListFields(ctx context.Context, in *genmodel.CurdPreviewParam) (fields string) {
selectBuffer := bytes.NewBuffer(nil)
if hasEffectiveJoins(in.Options.Join) {
selectBuffer.WriteString("mod = mod.FieldsPrefix(dao." + in.In.DaoName + ".Table(), " + in.Options.TemplateGroup + "in." + in.In.VarName + "ListModel{})\n")
for _, join := range in.Options.Join {
if isEffectiveJoin(join) {
selectBuffer.WriteString("mod = mod.Fields(xgorm.JoinFields(ctx, " + in.Options.TemplateGroup + "in." + in.In.VarName + "ListModel{}, &dao." + join.DaoName + ", \"" + join.Alias + "\"))\n")
}
}
fields = selectBuffer.String()
} else {
fields = fmt.Sprintf("mod = mod.Fields(%sin.%sListModel{})", in.Options.TemplateGroup, in.In.VarName)
}
return
}
func (l *gCurd) genLogicListWhere(ctx context.Context, in *genmodel.CurdPreviewParam) string {
buffer := bytes.NewBuffer(nil)
// 主表
l.genLogicListWhereEach(buffer, in, in.MasterFields, in.In.DaoName, "")
// 关联表
g.Log().Infof(ctx, "genLogicListWhere in.Options.Join: %+v", in.Options.Join)
if hasEffectiveJoins(in.Options.Join) {
for _, v := range in.Options.Join {
if isEffectiveJoin(v) {
l.genLogicListWhereEach(buffer, in, v.Columns, v.DaoName, v.Alias)
}
}
}
return buffer.String()
}
func (l *gCurd) genLogicListWhereEach(buffer *bytes.Buffer, in *genmodel.CurdPreviewParam, fields []*genmodel.GenCodesColumnListModel, daoName string, alias string) {
isLink := false
if alias != "" {
alias = `"` + alias + `."+`
isLink = true
}
tablePrefix := ""
wherePrefix := "Where"
if isLink {
wherePrefix = "WherePrefix"
tablePrefix = "dao." + daoName + ".Table(), "
}
for _, field := range fields {
isQuery := false
// 树表查询上级
if in.Options.Step.IsTreeTable && IsPidName(field.Name) {
isQuery = true
field.QueryWhere = WMEq
}
if (!field.IsQuery && !isQuery) || field.QueryWhere == "" {
continue
}
buffer.WriteString(fmt.Sprintf(LogicWhereComments, field.Dc))
var (
linkMode string
whereTag string
columnName string
)
// 查询用户摘要
if field.IsQuery && in.Options.Step.HasQueryMemberSummary && IsMemberSummaryField(field.Name) {
servicePackName := "service"
if in.Options.Step.IsAddon {
servicePackName = "isc"
}
buffer.WriteString(fmt.Sprintf("if in.%v != \"\" {\n\t\t\t\tids, err := %v.AdminMember().GetIdsByKeyword(ctx, in.%v)\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn nil, 0, err\n\t\t\t\t}\n\t\t\t\tmod = mod.WhereIn(dao.%v.Columns().%v, ids)\n\t\t\t}\n", field.GoName, servicePackName, field.GoName, in.In.DaoName, field.GoName))
continue
}
if IsNumberType(field.GoType) {
linkMode = `in.` + field.GoName + ` > 0`
} else if field.GoType == GoTypeGTime {
linkMode = `in.` + field.GoName + ` != nil`
} else if field.GoType == GoTypeJson {
linkMode = `!in.` + field.GoName + `.IsNil()`
} else {
linkMode = `in.` + field.GoName + ` != ""`
}
if field.QueryWhere == WMBetween || field.QueryWhere == WMNotBetween {
linkMode = `len(in.` + field.GoName + `) == 2`
}
// 如果是关联表重新转换字段
columnName = field.GoName
if isLink {
columnName = gstr.CaseCamel(field.Name)
}
if field.FormMode == FMDateRange {
tmp := "\tif " + linkMode + " {\n\t\tin." + field.GoName + "[1] = in." + field.GoName + "[1].EndOfDay()\n\t}\n"
buffer.WriteString(tmp)
}
switch field.QueryWhere {
case WMEq:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMNeq:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "Not(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMGt:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "GT(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMGte:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "GTE(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMLt:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "LT(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMLte:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "LTE(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMIn:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "In(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMNotIn:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "NotIn(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMBetween:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "Between(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + "[0], in." + field.GoName + "[1])\n\t}"
case WMNotBetween:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "NotBetween(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + "[0], in." + field.GoName + "[1])\n\t}"
case WMLike:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "Like(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMLikeAll:
val := `"%"+in.` + field.GoName + `+"%"`
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "Like(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", " + val + ")\n\t}"
case WMNotLike:
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "NotLike(" + tablePrefix + "dao." + daoName + ".Columns()." + columnName + ", in." + field.GoName + ")\n\t}"
case WMJsonContains:
val := tablePrefix + `"JSON_CONTAINS("+dao.` + daoName + `.Columns().` + columnName + `+",?)", in.` + field.GoName
whereTag = "\tif " + linkMode + " {\n\t\tmod = mod." + wherePrefix + "(" + val + ")\n\t}"
default:
buffer.WriteString(fmt.Sprintf(LogicWhereNoSupport, field.QueryWhere))
}
buffer.WriteString(whereTag + "\n")
}
}
@@ -0,0 +1,854 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"bytes"
"context"
"fmt"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
type StateItem struct {
Name string
DefaultValue interface{}
Dc string
}
func (l *gCurd) webModelTplData(ctx context.Context, in *genmodel.CurdPreviewParam) (data g.Map, err error) {
data = make(g.Map)
data["stateItems"] = l.genWebModelStateItems(ctx, in)
data["rules"] = l.genWebModelRules(ctx, in)
data["formSchema"] = l.genWebModelFormSchema(ctx, in)
if data["columns"], err = l.genWebModelColumns(ctx, in); err != nil {
return nil, err
}
if data["columnsInterface"], err = l.genWebModelColumnsInterface(ctx, in); err != nil {
return nil, err
}
data["viewSchema"] = l.genWebModelViewSchema(ctx, in)
data["editSchema"] = l.genWebModelEditSchema(ctx, in)
// 根据表单生成情况,按需导包
data["import"], data["const"] = l.genWebModelImport(ctx, in)
return
}
func (l *gCurd) genWebModelImport(ctx context.Context, in *genmodel.CurdPreviewParam) (string, string) {
importBuffer := bytes.NewBuffer(nil)
constBuffer := bytes.NewBuffer(nil)
importBuffer.WriteString(`import { h, ref } from 'vue';
import { Tag } from 'ant-design-vue';
import type { VxeGridProps } from '#/adapter/vxe-table';
import { getPopupContainer } from '@vben/utils';
import type { DescItem } from '#/components/description';
`)
// 导入基础组件
if len(in.Options.Step.ImportModel.NaiveUI) > 0 {
importBuffer.WriteString("import " + ImportWebMethod(in.Options.Step.ImportModel.NaiveUI) + " from 'ant-design-vue';\n")
}
importBuffer.WriteString("import { cloneDeep } from 'lodash-es';\n")
// 导入表单搜索
if in.Options.Step.HasSearchForm {
importBuffer.WriteString("import type { VbenFormSchema } from '@vben/common-ui';\n")
}
// 导入工具类
if len(in.Options.Step.ImportModel.UtilsIs) > 0 {
importBuffer.WriteString("import " + ImportWebMethod(in.Options.Step.ImportModel.UtilsIs) + " from '#/utils/is';\n")
}
if len(in.Options.Step.ImportModel.UtilsUrl) > 0 {
importBuffer.WriteString("import " + ImportWebMethod(in.Options.Step.ImportModel.UtilsUrl) + " from '#/utils/urlUtils';\n")
}
if len(in.Options.Step.ImportModel.UtilsDate) > 0 {
// importBuffer.WriteString("import " + ImportWebMethod(in.Options.Step.ImportModel.UtilsDate) + " from '@/utils/dateUtil';\n")
importBuffer.WriteString("import dayjs from 'dayjs';\n")
}
if in.Options.Step.HasRulesValidator {
importBuffer.WriteString("import { z } from '@vben/common-ui';\n")
}
if len(in.Options.Step.ImportModel.UtilsIndex) > 0 {
importBuffer.WriteString("import " + ImportWebMethod(in.Options.Step.ImportModel.UtilsIndex) + " from '#/utils';\n")
}
// 导入api
var importApiMethod []string
if in.Options.Step.HasSwitch {
importApiMethod = append(importApiMethod, "Switch")
}
if in.Options.Step.IsTreeTable {
importApiMethod = append(importApiMethod, "TreeOption")
}
if len(importApiMethod) > 0 {
importBuffer.WriteString("import " + ImportWebMethod(importApiMethod) + " from '" + in.Options.ImportWebApi + "';\n")
}
// 导入字典选项
if in.Options.DictOps.Has {
importBuffer.WriteString(`import { DictEnum } from '@vben/constants';
import { getDictOptions } from '#/utils/dict';`)
constBuffer.WriteString("\n")
}
if in.Options.Step.HasSwitch {
importBuffer.WriteString("import { usePermission } from '@/hooks/web/usePermission';\n")
constBuffer.WriteString("const { hasPermission } = usePermission();\n")
constBuffer.WriteString("const $message = window['$message'];\n")
}
return importBuffer.String(), constBuffer.String()
}
func (l *gCurd) genWebModelStateItems(ctx context.Context, in *genmodel.CurdPreviewParam) (items []*StateItem) {
for _, field := range in.MasterFields {
var value = field.DefaultValue
if value == nil {
value = "null"
}
if value == "" {
value = `''`
}
if value == "NULL" {
value = "null"
}
// 选项组件默认值调整
if gconv.Int(value) == 0 && IsSelectFormModel(field.FormMode) {
value = "null"
}
if field.Name == "status" {
value = 0
}
if field.FormMode == FMSwitch {
value = 0
}
if field.FormMode == FMInputDynamic {
value = "[]"
}
items = append(items, &StateItem{
Name: field.TsName,
DefaultValue: value,
Dc: field.Dc,
})
// 查询用户摘要
if field.IsList && in.Options.Step.HasHookMemberSummary && IsMemberSummaryField(field.Name) {
items = append(items, &StateItem{
Name: field.TsName + "Summa?: null | MemberSumma",
DefaultValue: "null",
Dc: field.Dc + "摘要信息",
})
}
}
return
}
func (l *gCurd) genWebModelDictOptions(ctx context.Context, in *genmodel.CurdPreviewParam) error {
type DictType struct {
DictType string `json:"dictType"`
DictName string `json:"dictName"`
}
var (
dictTypeIds []string
dictTypeList []*DictType
builtinDictTypeIds []int64
builtinDictTypeList []*DictType
)
for _, field := range in.MasterFields {
if field.DictType != "" {
dictTypeIds = append(dictTypeIds, field.DictType)
}
// if field.DictType < 0 {
// builtinDictTypeIds = append(builtinDictTypeIds, field.DictType)
// }
}
dictTypeIds = utility.UniqueSlice(dictTypeIds)
builtinDictTypeIds = utility.UniqueSlice(builtinDictTypeIds)
if len(dictTypeIds) == 0 && len(builtinDictTypeIds) == 0 {
return nil
}
if len(dictTypeIds) > 0 {
err := g.Model("sys_dict_type").Ctx(ctx).
Fields("dict_type", "dict_name").
WhereIn("dict_type", dictTypeIds).
Scan(&dictTypeList)
if err != nil {
return err
}
}
// if len(builtinDictTypeIds) > 0 {
// for _, id := range builtinDictTypeIds {
// typ, err := dict.GetTypeById(ctx, id)
// if err != nil && !errors.Is(err, dict.NotExistKeyError) {
// return err
// }
// if len(typ) > 0 {
// row := new(DictType)
// row.Id = id
// row.Type = typ
// builtinDictTypeList = append(builtinDictTypeList, row)
// }
// }
// }
if len(dictTypeList) == 0 && len(builtinDictTypeList) == 0 {
return nil
}
if len(builtinDictTypeList) > 0 {
dictTypeList = append(dictTypeList, builtinDictTypeList...)
}
in.Options.DictOps.Has = true
for _, v := range dictTypeList {
// 字段映射字典
for _, field := range in.MasterFields {
if field.DictType != "" && v.DictType == field.DictType {
in.Options.DictMap[field.TsName] = v.DictType
in.Options.DictOps.Schemas = append(in.Options.DictOps.Schemas, &genmodel.OptionsSchemasField{
Field: field.TsName,
Type: v.DictType,
})
}
}
in.Options.DictOps.Types = append(in.Options.DictOps.Types, v.DictName)
}
return nil
}
func (l *gCurd) genWebModelRules(ctx context.Context, in *genmodel.CurdPreviewParam) string {
// buffer := bytes.NewBuffer(nil)
// buffer.WriteString("export const rules = {\n")
// for _, field := range in.MasterFields {
// if !field.IsEdit || (!field.Required && (field.FormRole == "" || field.FormRole == FRNone)) {
// continue
// }
// in.Options.Step.HasRules = true
// if field.FormRole == "" || field.FormRole == FRNone || field.FormRole == "required" {
// buffer.WriteString(fmt.Sprintf(" %s: {\n required: %v,\n trigger: ['blur', 'input'],\n type: '%s',\n message: '请输入%s',\n },\n", field.TsName, field.Required, field.TsType, field.Dc))
// } else {
// in.Options.Step.HasRulesValidator = true
// buffer.WriteString(fmt.Sprintf(" %s: {\n required: %v,\n trigger: ['blur', 'input'],\n type: '%s',\n validator: validate.%v,\n },\n", field.TsName, field.Required, field.TsType, field.FormRole))
// }
// }
// buffer.WriteString("};\n")
// return buffer.String()
return ""
}
func (l *gCurd) genWebModelFormSchema(ctx context.Context, in *genmodel.CurdPreviewParam) string {
buffer := bytes.NewBuffer(nil)
buffer.WriteString("export const querySchema: VbenFormSchema[] = [\n")
// 主表
l.genWebModelFormSchemaEach(buffer, in.MasterFields, in, false)
// 关联表
if len(in.Options.Join) > 0 {
for _, v := range in.Options.Join {
if !isEffectiveJoin(v) {
continue
}
l.genWebModelFormSchemaEach(buffer, v.Columns, in, false)
}
}
buffer.WriteString("];\n")
return buffer.String()
}
// genWebDictOption 生产字典选项
func (l *gCurd) genWebDictOption(typ any) string {
return fmt.Sprintf(`getDictOptions('%v')`, typ)
}
func (l *gCurd) genWebModelFormSchemaEach(buffer *bytes.Buffer, fields []*genmodel.GenCodesColumnListModel, in *genmodel.CurdPreviewParam, isEdit bool) {
for _, field := range fields {
if isEdit {
if !field.IsEdit && field.Name != in.Pk.Name {
continue
}
if field.Name == in.Pk.Name {
buffer.WriteString(fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
dependencies: { show: () => false, triggerFields: [''], },
componentProps: {
placeholder: '',
onUpdateValue: (e: any) => {
console.log(e);
},
},
formItemClass: 'col-span-%d',
},`, field.TsName, "Input", field.Dc, field.FormGridSpan))
continue
}
} else {
if !field.IsQuery {
continue
}
}
rules := "null"
if field.IsEdit {
if field.Required {
if field.FormMode == FMInputNumber {
rules = fmt.Sprintf("z.number({required_error: '请输入%s', invalid_type_error: '无效数字'})", field.Dc)
in.Options.Step.HasRulesValidator = true
} else if field.FormMode == FMSelect || field.FormMode == FMSelectMultiple || field.FormMode == FMCheckbox || field.FormMode == FMDate || field.FormMode == FMDateRange || field.FormMode == FMTime || field.FormMode == FMTimeRange {
rules = "'selectRequired'"
} else {
rules = "'required'"
}
}
}
if field.FormRole != "" {
in.Options.Step.HasRulesValidator = true
if field.FormRole == FRNum {
rules = fmt.Sprintf("z.number({required_error: '请输入%s', invalid_type_error: '无效数字'} )", field.Dc)
}
if field.FormRole == FRIp {
rules = "z.string().ip({message: '无效IP格式'})"
}
if field.FormRole == FRPhone {
rules = "z.string().regex(/^1[3-9]\\d{9}$/, '无效手机号格式')"
}
if field.FormRole == FRTel {
rules = "z.string().regex(/^(?:\\+?\\d{1,3}[-.\\s]?)?(?:$?\\d{1,4}$?[-.\\s]?)?\\d{1,4}[-.\\s]?\\d{1,4}[-.\\s]?\\d{1,9}$/, '无效手机号格式')"
}
if field.FormRole == FRPercentage {
rules = fmt.Sprintf("z.number({required_error: '请输入%s', invalid_type_error: '无效百分比'}).min(0).max(100)", field.Dc)
}
if field.FormRole == FRQq {
rules = "z.string().regex(/^[1-9]\\d{4,}$/, '无效QQ号格式')"
}
if field.FormRole == FRAmount {
rules = "z.number({required_error: '必填', invalid_type_error: '无效金额'} ).min(0)"
}
if field.FormRole == FRBankCard {
rules = "z.string().regex(/^[1-9]\\d{4,}$/, '无效银行卡号格式')"
}
if field.FormRole == FRWeibo {
rules = "z.string().regex(/^[1-9]\\d{4,}$/, '无效微博号格式')"
}
if field.FormRole == FRUserName {
rules = "z.string().regex(/^[a-zA-Z0-9_]+$/, '无效用户名格式')"
}
if field.FormRole == FRPassword {
rules = "z.string().regex(/^[a-zA-Z0-9_]+$/, '无效密码格式')"
}
if field.FormRole == FRAccount {
rules = "z.string().regex(/^[a-zA-Z0-9_]+$/, '无效账号格式')"
}
if field.FormRole == FRIdCard {
rules = "z.string().regex(/^[1-9]\\d{4,}$/, '无效身份证号格式')"
}
if field.FormRole == FREmail {
rules = "z.string().email('无效邮箱格式')"
}
}
in.Options.Step.HasSearchForm = true
// 查询用户摘要
if field.IsQuery && in.Options.Step.HasQueryMemberSummary && IsMemberSummaryField(field.Name) {
buffer.WriteString(fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
placeholder: '请输入ID|用户名|姓名|手机号',
onUpdateValue: (e: any) => {
console.log(e);
},
},
rules:%v,
formItemClass: 'col-span-%d',
},`, field.TsName, "Input", field.Dc, rules, field.FormGridSpan))
continue
}
var (
defaultComponent = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
placeholder: '请输入%s',
onUpdateValue: (e: any) => {
console.log(e);
},
},
rules:%v,
formItemClass: 'col-span-%d',
},`, field.TsName, "Input", field.Dc, field.Dc, rules, field.FormGridSpan)
component string
)
// 这里根据编辑表单组件来进行推断,如果没有则使用默认input,这可能会导致和查询条件所需参数不符的情况
switch field.FormMode {
case FMInput, FMInputTextarea, FMInputEditor:
component = defaultComponent
case FMInputNumber:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
placeholder: '请输入%s',
onUpdateValue: (e: any) => {
console.log(e);
},
}, rules:%v,
formItemClass: 'col-span-%d',
},`, field.TsName, "InputNumber", field.Dc, field.Dc, rules, field.FormGridSpan)
case FMDate:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
type: '%s',
clearable: true,
onUpdateValue: (e: any) => {
console.log(e);
},
}, rules:%v,
formItemClass: 'col-span-%d',
},`, field.TsName, "DatePicker", field.Dc, "date", rules, field.FormGridSpan)
case FMDateRange:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
type: '%s',
clearable: true,
valueFormat: %s,
onUpdateValue: (e: any) => {
console.log(e);
},
}, rules:%v,
formItemClass: 'col-span-%d',
},`, field.TsName, "RangePicker", field.Dc, "datetimerange", "'YYYY-MM-DD HH:mm:ss'", rules, field.FormGridSpan)
case FMTime:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
type: '%s',
clearable: true,
showTime: true,
onUpdateValue: (e: any) => {
console.log(e);
},
}, rules:%v,
formItemClass: 'col-span-%d',
},`, field.TsName, "DatePicker", field.Dc, "datetime", rules, field.FormGridSpan)
case FMTimeRange:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
type: '%s',
clearable: true,
showTime: true,
onUpdateValue: (e: any) => {
console.log(e);
},
}, rules:%v,
formItemClass: 'col-span-%d',
},`, field.TsName, "RangePicker", field.Dc, "datetimerange", rules, field.FormGridSpan)
case FMSwitch:
fallthrough
case FMRadio:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
options: %v
},
rules:%v,
formItemClass: 'col-span-%d',
},
`, field.TsName, "RadioGroup", field.Dc, l.genWebDictOption(in.Options.DictMap[field.TsName]), rules, field.FormGridSpan)
case FMCheckbox:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
componentProps: {
placeholder: '请选择%s',
options: %v
},
rules:%v,
formItemClass: 'col-span-%d',
},
`, field.TsName, "CheckboxGroup", field.Dc, field.Dc,
l.genWebDictOption(in.Options.DictMap[field.TsName]), rules, field.FormGridSpan)
case FMSelect:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
defaultValue: null,
componentProps: {
placeholder: '请选择%s',
options: %v,
onUpdateValue: (e: any) => {
console.log(e);
},
},
rules:%v,
formItemClass: 'col-span-%d',
},
`, field.TsName, "Select", field.Dc, field.Dc, l.genWebDictOption(in.Options.DictMap[field.TsName]), rules, field.FormGridSpan)
case FMSelectMultiple:
component = fmt.Sprintf(` {
fieldName: '%s',
component: '%s',
label: '%s',
defaultValue: null,
componentProps: {
mode: 'multiple',
placeholder: '请选择%s',
options: %v,
onUpdateValue: (e: any) => {
console.log(e);
},
},
rules:%v,
formItemClass: 'col-span-%d',
},
`, field.TsName, "Select", field.Dc, field.Dc, l.genWebDictOption(in.Options.DictMap[field.TsName]), rules, field.FormGridSpan)
default:
component = defaultComponent
}
buffer.WriteString(component)
}
}
func (l *gCurd) genWebModelColumns(ctx context.Context, in *genmodel.CurdPreviewParam) (string, error) {
buffer := bytes.NewBuffer(nil)
buffer.WriteString("export const columns: VxeGridProps['columns'] = [\n")
// 主表
if err := l.genWebModelColumnsEach(buffer, in, in.MasterFields); err != nil {
return "", err
}
// 关联表
if len(in.Options.Join) > 0 {
for _, v := range in.Options.Join {
if !isEffectiveJoin(v) {
continue
}
if err := l.genWebModelColumnsEach(buffer, in, v.Columns); err != nil {
return "", err
}
}
}
buffer.WriteString(" { title: '操作', width: 120, slots: { default: 'action' } },\n")
buffer.WriteString("];\n")
return buffer.String(), nil
}
func (l *gCurd) genWebModelColumnsInterface(ctx context.Context, in *genmodel.CurdPreviewParam) (string, error) {
buffer := bytes.NewBuffer(nil)
buffer.WriteString("export interface RowType {\n")
// 主表
for _, field := range in.MasterFields {
buffer.WriteString(fmt.Sprintf(" %s: %s;\n", field.TsName, field.TsType))
}
// 关联表
if len(in.Options.Join) > 0 {
for _, v := range in.Options.Join {
if !isEffectiveJoin(v) {
continue
}
for _, field := range v.Columns {
buffer.WriteString(fmt.Sprintf(" %s: %s;\n", field.TsName, field.TsType))
}
}
}
buffer.WriteString("};\n")
return buffer.String(), nil
}
func (l *gCurd) genWebModelViewSchema(ctx context.Context, in *genmodel.CurdPreviewParam) string {
buffer := bytes.NewBuffer(nil)
buffer.WriteString("export const viewSchema: DescItem[] = [\n")
// 主表
for _, field := range in.MasterFields {
l.genWebModelViewSchemaEach(buffer, []*genmodel.GenCodesColumnListModel{field}, in)
}
// 关联表
if len(in.Options.Join) > 0 {
for _, v := range in.Options.Join {
if !isEffectiveJoin(v) {
continue
}
for _, field := range v.Columns {
l.genWebModelViewSchemaEach(buffer, []*genmodel.GenCodesColumnListModel{field}, in)
}
}
}
buffer.WriteString("];\n")
return buffer.String()
}
func (l *gCurd) genWebModelEditSchema(ctx context.Context, in *genmodel.CurdPreviewParam) string {
buffer := bytes.NewBuffer(nil)
buffer.WriteString("export const editSchema: VbenFormSchema[] = [\n")
// 主表
for _, field := range in.MasterFields {
l.genWebModelFormSchemaEach(buffer, []*genmodel.GenCodesColumnListModel{field}, in, true)
}
// 关联表
if len(in.Options.Join) > 0 {
for _, v := range in.Options.Join {
if !isEffectiveJoin(v) {
continue
}
for _, field := range v.Columns {
l.genWebModelFormSchemaEach(buffer, []*genmodel.GenCodesColumnListModel{field}, in, true)
}
}
}
buffer.WriteString("];\n")
return buffer.String()
}
func (l *gCurd) genWebModelColumnsEach(buffer *bytes.Buffer, in *genmodel.CurdPreviewParam, fields []*genmodel.GenCodesColumnListModel) (err error) {
for _, field := range fields {
if !field.IsList {
continue
}
if field.Name == in.Pk.Name {
buffer.WriteString(fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n type: 'checkbox',\n },\n", field.Dc, field.TsName, field.Align, field.Width))
continue
}
var (
defaultComponent = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n },\n", field.Dc, field.TsName, field.Align, field.Width)
component string
)
// 查询用户摘要
if in.Options.Step.HasHookMemberSummary && IsMemberSummaryField(field.Name) {
buffer.WriteString(fmt.Sprintf(" {\n title: '%v',\n field: '%v',\n align: '%v',\n width: %v,\n slots: {\n default: ({ row }) => {\n return renderPopoverMemberSumma(row.%vSumma);\n },\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, field.TsName))
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, []string{"renderPopoverMemberSumma", "type MemberSumma"}...)
continue
}
// 这里根据编辑表单组件来进行推断,如果没有则使用默认input,这可能会导致和查询条件所需参数不符的情况
switch field.FormMode {
case FMDate:
component = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n render(row: State) {\n return formatDateTime(row.%s);\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, field.TsName)
in.Options.Step.ImportModel.UtilsDate = append(in.Options.Step.ImportModel.UtilsDate, "formatToDate")
case FMRadio:
fallthrough
case FMSelect:
if g.IsEmpty(in.Options.DictMap[field.TsName]) {
g.Log().Infof(context.Background(), "server/internal/library/xgen/gen_curd_model.go genWebModelColumnsEach in.Options.DictMap:%+v", in.Options.DictMap)
err = gerror.Newf("设置单选下拉框选项时,必须选择字典类型,字段名称:%v", field.Name)
return
}
component = fmt.Sprintf(` {
title: '%s', field: '%s', align: '%v', width: %v,
slots: {
default: ({ row }) => {
return renderDict(row.%v, '%v');
}
},
},
`,
field.Dc, field.TsName, field.Align, field.Width, field.TsName, in.Options.DictMap[field.TsName])
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderDict")
case FMSelectMultiple:
if g.IsEmpty(in.Options.DictMap[field.TsName]) {
err = gerror.Newf("设置多选下拉框选项时,必须选择字典类型,字段名称:%v", field.Name)
return
}
component = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n render(row: State) {\n if (isNullObject(row.%s) || !isArray(row.%s)) {\n return ``;\n }\n return row.%s.map((tagKey) => {\n return renderOptionTag('%s', row.tagKey)\n });\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, field.TsName, field.TsName, field.TsName, in.Options.DictMap[field.TsName])
in.Options.Step.ImportModel.NaiveUI = append(in.Options.Step.ImportModel.NaiveUI, "NTag")
in.Options.Step.ImportModel.UtilsIs = append(in.Options.Step.ImportModel.UtilsIs, "isNullObject")
case FMUploadImage:
component = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n render(row: State) {\n return renderImage(row.%v);\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, field.TsName)
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderImage")
case FMUploadImages:
component = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n render(row: State) {\n return renderImageGroup(row.%v);\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, field.TsName)
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderImageGroup")
case FMUploadFile:
component = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n render(row: State) {\n return renderFile(row.%v);\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, field.TsName)
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderFile")
case FMUploadFiles:
component = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n render(row: State) {\n return renderFileGroup(row.%v);\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, field.TsName)
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderFileGroup")
case FMSwitch:
component = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n render(row: State) {\n return h(%s, {\n value: row.%s === 1,\n checked: '开启',\n unchecked: '关闭',\n disabled: !hasPermission(['%s']),\n onUpdateValue: function (e) {\n console.log('onUpdateValue e:' + JSON.stringify(e));\n row.%s = e ? 1 : 2;\n Switch({ %s: row.%s, key: '%s', value: row.%s }).then((_res) => {\n $message.success('操作成功');\n });\n },\n });\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, "NSwitch", field.TsName, "/"+in.Options.ApiPrefix+"/switch", field.TsName, in.Pk.TsName, in.Pk.TsName, CamelCaseToUnderline(field.TsName), field.TsName)
in.Options.Step.ImportModel.NaiveUI = append(in.Options.Step.ImportModel.NaiveUI, "NSwitch")
case FMRate:
component = fmt.Sprintf(" {\n title: '%s',\n field: '%s',\n align: '%v',\n width: %v,\n render(row: State) {\n return h(%s, {\n allowHalf: true,\n readonly: true,\n defaultValue: row.%s,\n });\n },\n },\n", field.Dc, field.TsName, field.Align, field.Width, "NRate", field.TsName)
in.Options.Step.ImportModel.NaiveUI = append(in.Options.Step.ImportModel.NaiveUI, "NRate")
default:
component = defaultComponent
}
buffer.WriteString(component)
}
return
}
func (l *gCurd) genWebModelViewSchemaEach(buffer *bytes.Buffer, fields []*genmodel.GenCodesColumnListModel, in *genmodel.CurdPreviewParam) {
for _, field := range fields {
if field.Name == in.Pk.Name {
tmp := fmt.Sprintf(" { field: '%s', label: '%s'},\n", field.TsName, field.Dc)
buffer.WriteString(tmp)
continue
}
// 查询用户摘要
if field.IsQuery && in.Options.Step.HasQueryMemberSummary && IsMemberSummaryField(field.Name) {
tmp := fmt.Sprintf(" { field: '%s', label: '%s'},\n", field.TsName, field.Dc)
buffer.WriteString(tmp)
continue
}
var (
defaultComponent = fmt.Sprintf(" { field: '%s', label: '%s'},\n", field.TsName, field.Dc)
component string
)
// 这里根据编辑表单组件来进行推断,如果没有则使用默认input,这可能会导致和查询条件所需参数不符的情况
switch field.FormMode {
case FMInput, FMInputTextarea, FMInputEditor:
component = defaultComponent
case FMInputNumber:
component = fmt.Sprintf(" { field: '%s', label: '%s'},\n", field.TsName, field.Dc)
case FMDate:
component = fmt.Sprintf(" { field: '%s', label: '%s'},\n", field.TsName, field.Dc)
case FMDateRange:
component = fmt.Sprintf(" { field: '%s', label: '%s'},\n", field.TsName, field.Dc)
case FMTime:
component = fmt.Sprintf(" { field: '%s', label: '%s'},\n", field.TsName, field.Dc)
case FMTimeRange:
component = fmt.Sprintf(" { field: '%s', label: '%s'},\n", field.TsName, field.Dc)
case FMSwitch:
fallthrough
case FMRadio:
component = fmt.Sprintf(` {
field: '%s',
label: '%s',
render(row: any) {
return renderDict(row.%s, '%v');
},
},
`, field.TsName, field.Dc, field.TsName, in.Options.DictMap[field.TsName])
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderDict")
case FMCheckbox:
component = fmt.Sprintf(` {
field: '%s',
label: '%s',
render(row: any) {
return renderDictTags(row.%s, '%v');
},
},
`, field.TsName, field.Dc, field.TsName, in.Options.DictMap[field.TsName])
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderDictTags")
case FMSelect:
component = fmt.Sprintf(` {
field: '%s',
label: '%s',
render(row: any) {
return renderDict(row.%s, '%v');
},
},
`, field.TsName, field.Dc, field.TsName, in.Options.DictMap[field.TsName])
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderDict")
case FMSelectMultiple:
component = fmt.Sprintf(` {
field: '%s',
label: '%s',
render(row: any) {
return renderDictTags(row.%s, '%v');
},
},
`, field.TsName, field.Dc, field.TsName, in.Options.DictMap[field.TsName])
in.Options.Step.ImportModel.UtilsIndex = append(in.Options.Step.ImportModel.UtilsIndex, "renderDictTags")
default:
component = defaultComponent
}
buffer.WriteString(component)
}
}
@@ -0,0 +1,219 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"bytes"
"context"
"fmt"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/text/gstr"
)
func (l *gCurd) webEditTplData(ctx context.Context, in *genmodel.CurdPreviewParam) (data g.Map, err error) {
data = make(g.Map)
data["formItem"] = l.genWebEditFormItem(ctx, in)
data["script"] = l.genWebEditScript(ctx, in)
data["isEditModal"] = in.Options.Step.IsEditModal
return
}
func (l *gCurd) genWebEditFormItem(ctx context.Context, in *genmodel.CurdPreviewParam) string {
buffer := bytes.NewBuffer(nil)
for _, field := range in.MasterFields {
if !field.IsEdit {
continue
}
if IsIndexPK(field.Index) {
continue
}
var (
defaultComponent = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-input placeholder=\"请输入%s\" v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.Dc, field.TsName)
component string
)
if in.Options.Step.IsTreeTable && IsPidName(field.Name) {
field.FormMode = FMPidTreeSelect
}
switch field.FormMode {
case FMInput:
component = defaultComponent
case FMInputNumber:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-input-number placeholder=\"请输入%s\" v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.Dc, field.TsName)
case FMInputTextarea:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-input type=\"textarea\" placeholder=\"%s\" v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.Dc, field.TsName)
case FMInputEditor:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <Editor style=\"height: 450px\" id=\"%s\" v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName, field.TsName)
case FMInputDynamic:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-dynamic-input\n v-model:value=\"formValue.%s\"\n preset=\"pair\"\n key-placeholder=\"键名\"\n value-placeholder=\"键值\"\n />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
case FMDate:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <DatePicker v-model:formValue=\"formValue.%s\" type=\"date\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
// case FMDateRange: // 必须要有两个字段,后面优化下
case FMTime:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <DatePicker v-model:formValue=\"formValue.%s\" type=\"datetime\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
// case FMTimeRange: // 必须要有两个字段,后面优化下
case FMRadio:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-radio-group v-model:value=\"formValue.%s\" name=\"%s\">\n <n-radio-button\n v-for=\"%s in dict.getOptionUnRef('%s')\"\n :key=\"%s.value\"\n :value=\"%s.value\"\n :label=\"%s.label\"\n />\n </n-radio-group>\n </n-form-item>", field.Dc, field.TsName, field.TsName, field.TsName, field.TsName, in.Options.DictMap[field.TsName], field.TsName, field.TsName, field.TsName)
case FMCheckbox:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-checkbox-group v-model:value=\"formValue.%s\">\n <n-space>\n <n-checkbox\n v-for=\"item in dict.getOptionUnRef('%s')\"\n :key=\"item.value\"\n :value=\"item.value\"\n :label=\"item.label\"\n />\n </n-space>\n </n-checkbox-group>\n </n-form-item>", field.Dc, field.TsName, field.TsName, in.Options.DictMap[field.TsName])
case FMSelect:
if in.Options.DictMap[field.TsName] != nil {
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-select v-model:value=\"formValue.%s\" :options=\"dict.getOptionUnRef('%s')\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName, in.Options.DictMap[field.TsName])
} else {
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-select v-model:value=\"formValue.%s\" options=\"\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
}
case FMSelectMultiple:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-select multiple v-model:value=\"formValue.%s\" :options=\"dict.getOptionUnRef('%s')\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName, in.Options.DictMap[field.TsName])
case FMUploadImage:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <UploadImage :maxNumber=\"1\" v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
case FMUploadImages:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <UploadImage :maxNumber=\"10\" v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
case FMUploadFile:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <UploadFile :maxNumber=\"1\" v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
case FMUploadFiles:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <UploadFile :maxNumber=\"10\" v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
case FMSwitch:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-switch :unchecked-value=\"2\" :checked-value=\"1\" v-model:value=\"formValue.%s\"\n />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
case FMRate:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <n-rate allow-half :default-value=\"formValue.%s\" :on-update:value=\"update%s\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName, field.GoName)
case FMCitySelector:
component = fmt.Sprintf("<n-form-item label=\"%s\" path=\"%s\">\n <CitySelector v-model:value=\"formValue.%s\" />\n </n-form-item>", field.Dc, field.TsName, field.TsName)
case FMPidTreeSelect:
component = fmt.Sprintf(`<n-form-item label="%v" path="pid">
<n-tree-select
:options="treeOption"
v-model:value="formValue.pid"
key-field="%v"
label-field="%v"
clearable
filterable
default-expand-all
show-path
/>
</n-form-item>`, field.Dc, in.Pk.TsName, in.Options.Tree.TitleField.TsName)
case FMTreeSelect:
component = fmt.Sprintf(`<n-form-item label="%v" path="%v">
<n-tree-select
placeholder="请选择%v"
v-model:value="formValue.%v"
:options="[{ label: 'AA', key: 1, children: [{ label: 'BB', key: 2 }] }]"
clearable
filterable
default-expand-all
/>
</n-form-item>`, field.Dc, field.TsName, field.Dc, field.TsName)
case FMCascader:
component = fmt.Sprintf(`<n-form-item label="%v" path="%v">
<n-cascader
placeholder="请选择%v"
v-model:value="formValue.%v"
:options="[{ label: 'AA', value: 1, children: [{ label: 'BB', value: 2 }] }]"
clearable
filterable
/>
</n-form-item>`, field.Dc, field.TsName, field.Dc, field.TsName)
default:
component = defaultComponent
}
buffer.WriteString(fmt.Sprintf("<n-gi span=\"%v\">%v</n-gi>\n\n", field.FormGridSpan, component))
}
return buffer.String()
}
func (l *gCurd) genWebEditScript(ctx context.Context, in *genmodel.CurdPreviewParam) g.Map {
var (
data = make(g.Map)
importBuffer = bytes.NewBuffer(nil)
setupBuffer = bytes.NewBuffer(nil)
)
importBuffer.WriteString(" import { ref, computed } from 'vue';\n")
// 导入字典
if in.Options.DictOps.Has {
importBuffer.WriteString(" import { useDictStore } from '@/store/modules/dict';\n")
}
// 导入api
var importApiMethod = []string{"Edit", "View"}
if in.Options.Step.HasMaxSort {
importApiMethod = append(importApiMethod, "MaxSort")
}
importBuffer.WriteString(" import " + ImportWebMethod(importApiMethod) + " from '" + in.Options.ImportWebApi + "';\n")
// 导入model
var importModelMethod = []string{"State", "newState"}
if in.Options.Step.IsTreeTable {
importModelMethod = append(importModelMethod, []string{"treeOption", "loadTreeOption"}...)
}
if in.Options.Step.HasRules {
importModelMethod = append(importModelMethod, "rules")
}
importBuffer.WriteString(" import " + ImportWebMethod(importModelMethod) + " from './model';\n")
for _, field := range in.MasterFields {
if !field.IsEdit {
continue
}
switch field.FormMode {
case FMDate, FMDateRange, FMTime, FMTimeRange:
if !gstr.Contains(importBuffer.String(), `import DatePicker`) {
importBuffer.WriteString(" import DatePicker from '@/components/DatePicker/datePicker.vue';\n")
}
case FMInputEditor:
if !gstr.Contains(importBuffer.String(), `import Editor`) {
importBuffer.WriteString(" import Editor from '@/components/Editor/editor.vue';\n")
}
case FMUploadImage, FMUploadImages:
if !gstr.Contains(importBuffer.String(), `import UploadImage`) {
importBuffer.WriteString(" import UploadImage from '@/components/Upload/uploadImage.vue';\n")
}
case FMUploadFile, FMUploadFiles:
if !gstr.Contains(importBuffer.String(), `import UploadFile`) {
importBuffer.WriteString(" import UploadFile from '@/components/Upload/uploadFile.vue';\n")
}
case FMRate:
setupBuffer.WriteString(fmt.Sprintf(" function update%s(num) {\n formValue.value.%s = num;\n }\n", field.GoName, field.TsName))
case FMCitySelector:
if !gstr.Contains(importBuffer.String(), `import CitySelector`) {
importBuffer.WriteString(" import CitySelector from '@/components/CitySelector/citySelector.vue';\n")
}
}
}
data["import"] = importBuffer.String()
data["setup"] = setupBuffer.String()
if in.Options.PresetStep != nil {
data["formGridSpan"] = in.Options.PresetStep.FormGridCols
}
return data
}
@@ -0,0 +1,179 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"bytes"
"context"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"github.com/gogf/gf/v2/frame/g"
)
func (l *gCurd) webIndexTplData(ctx context.Context, in *genmodel.CurdPreviewParam) (g.Map, error) {
var (
data = make(g.Map)
importBuffer = bytes.NewBuffer(nil)
importVueMethod = []string{"h", "reactive", "ref", "computed"}
importApiMethod = []string{"List"}
importModelMethod = []string{"columns", "querySchema", "type RowType"}
importUtilsMethod = []string{"adaTableScrollX"}
importIcons []string
actionWidth int64 = 72
)
// 添加 MdiPlus, MdiDelete,MdiExport
if in.Options.Step.HasAdd {
importIcons = append(importIcons, "MdiPlus")
}
// 编辑
if in.Options.Step.HasEdit {
in.Options.Step.ActionColumnWidth += actionWidth
if in.Options.Step.IsTreeTable && !in.Options.Step.IsOptionTreeTable {
in.Options.Step.ActionColumnWidth += actionWidth
}
if in.Options.Step.IsOptionTreeTable {
importIcons = append(importIcons, "MdiEdit")
}
}
// 导出
if in.Options.Step.HasExport {
importIcons = append(importIcons, "MdiExport")
importApiMethod = append(importApiMethod, "Export")
}
// 删除
if in.Options.Step.HasDel {
importApiMethod = append(importApiMethod, "Delete")
in.Options.Step.ActionColumnWidth += actionWidth
}
// 批量删除
if in.Options.Step.HasBatchDel {
importIcons = append(importIcons, "MdiDelete")
importApiMethod = append(importApiMethod, "Delete")
}
// 修改状态
if in.Options.Step.HasStatus {
importApiMethod = append(importApiMethod, "Status")
in.Options.Step.ActionColumnWidth += actionWidth
}
// 更多
// 查看详情
if in.Options.Step.HasView {
in.Options.Step.ActionColumnWidth += actionWidth
}
// 展开树
if in.Options.Step.IsTreeTable {
// importIcons = append(importIcons, "MdiExport")
}
// 存在字典数据选项
if in.Options.DictOps.Has {
importVueMethod = append(importVueMethod, "onMounted")
// importModelMethod = append(importModelMethod, "loadOptions")
}
// 普通树表
if in.Options.Step.IsTreeTable && !in.Options.Step.IsOptionTreeTable {
importUtilsMethod = append(importUtilsMethod, "convertListToTree")
}
// 选项式树表
if in.Options.Step.IsOptionTreeTable {
importVueMethod = append(importVueMethod, []string{"onMounted", "unref"}...)
// importIcons = append(importIcons, []string{"FormOutlined", "SearchOutlined"}...)
importApiMethod = append(importApiMethod, "TreeOption")
importUtilsMethod = append(importUtilsMethod, "getTreeKeys")
importModelMethod = append(importModelMethod, []string{"loadTreeOption", "treeOption", "State"}...)
}
// 操作按钮宽度最小值
if in.Options.Step.ActionColumnWidth > 0 && in.Options.Step.ActionColumnWidth < actionWidth*2 {
in.Options.Step.ActionColumnWidth = 100
}
// 导入基础包
importBuffer.WriteString(" import " + ImportWebMethod(importVueMethod) + " from 'vue';\n")
importBuffer.WriteString(" import { Button, message,Tag, Modal, Popconfirm,Switch } from 'ant-design-vue';\n")
importBuffer.WriteString(" import type { VbenFormProps } from '#/adapter/form';\n")
importBuffer.WriteString(" import type { VxeTableGridOptions, VxeGridListeners } from '#/adapter/vxe-table';\n")
importBuffer.WriteString(" import type { DeepPartial } from '@vben/types';\n")
importBuffer.WriteString(" import { getVxePopupContainer } from '@vben/utils';\n")
importBuffer.WriteString(" import { Page, useVbenDrawer, useVbenModal } from '@vben/common-ui';\n")
importBuffer.WriteString(" import { useVbenVxeGrid } from '#/adapter/vxe-table';\n")
importBuffer.WriteString(" import { AccessControl, useAccess } from '@vben/access';\n")
importBuffer.WriteString(" const { hasAccessByCodes } = useAccess();\n")
if in.Options.Step.HasExport {
importBuffer.WriteString(" import { commonDownloadExcel } from '#/utils/file/download';\n")
}
// 导入字典
if in.Options.DictOps.Has {
// importBuffer.WriteString(" import { useDictStore } from '@/store/modules/dict';\n")
}
// 导入api
importBuffer.WriteString(" import " + ImportWebMethod(importApiMethod) + " from '" + in.Options.ImportWebApi + "';\n")
// 导入icons
if len(importIcons) > 0 {
importBuffer.WriteString(" import " + ImportWebMethod(importIcons) + " from '@vben/icons';\n")
}
// 导入model
if in.Options.Step.IsTreeTable {
importModelMethod = append(importModelMethod, "newState")
}
importBuffer.WriteString(" import " + ImportWebMethod(importModelMethod) + " from './model';\n")
// 导入utils
if len(importUtilsMethod) > 0 {
// importBuffer.WriteString(" import " + ImportWebMethod(importUtilsMethod) + " from '@/utils/xjadmin';\n")
}
// 导入edit组件
if in.Options.Step.HasEdit {
importBuffer.WriteString(" import editDrawer from './edit.vue';\n")
}
// 导入view组件
if in.Options.Step.HasView {
importBuffer.WriteString(" import viewDrawer from './view.vue';\n")
}
// 没有需要查询的字段则隐藏搜索表单
isSearchForm := false
for _, field := range in.MasterFields {
if field.IsQuery {
isSearchForm = true
break
}
}
if !isSearchForm {
if len(in.Options.Join) > 0 {
LoopOut:
for _, v := range in.Options.Join {
for _, column := range v.Columns {
if column.IsQuery {
isSearchForm = true
break LoopOut
}
}
}
}
}
data["isSearchForm"] = isSearchForm
data["import"] = importBuffer.String()
data["isEditModal"] = in.Options.Step.IsEditModal
return data, nil
}
@@ -0,0 +1,97 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"context"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
"github.com/gogf/gf/v2/frame/g"
)
func (l *gCurd) generateWebModelDictOptions(ctx context.Context, in *genmodel.CurdPreviewParam) error {
type DictType struct {
DictType string `json:"dictType"`
DictName string `json:"dictName"`
}
var (
dictTypeIds []string
dictTypeList []*DictType
builtinDictTypeIds []int64
builtinDictTypeList []*DictType
)
for _, field := range in.MasterFields {
if field.DictType != "" {
dictTypeIds = append(dictTypeIds, field.DictType)
g.Log().Debugf(context.Background(), "curd_generate_web_model.go generateWebModelDictOptions in.masterFields:%+v", field)
}
// if field.DictType < 0 {
// builtinDictTypeIds = append(builtinDictTypeIds, field.DictType)
// }
}
g.Log().Debugf(context.Background(), "curd_generate_web_model.go generateWebModelDictOptions dictTypeIds:%+v", dictTypeIds)
dictTypeIds = utility.UniqueSlice(dictTypeIds)
builtinDictTypeIds = utility.UniqueSlice(builtinDictTypeIds)
g.Log().Debugf(context.Background(), "curd_generate_web_model.go generateWebModelDictOptions dictTypeIds:%+v", dictTypeIds)
// g.Log().Debugf(context.Background(), "curd_generate_web_model.go generateWebModelDictOptions builtinDictTypeIds:%+v", builtinDictTypeIds)
if len(dictTypeIds) == 0 && len(builtinDictTypeIds) == 0 {
return nil
}
if len(dictTypeIds) > 0 {
err := g.Model("sys_dict_type").Ctx(ctx).
Fields("dict_type", "dict_name").
WhereIn("dict_type", dictTypeIds).
Scan(&dictTypeList)
if err != nil {
return err
}
}
g.Log().Debugf(context.Background(), "curd_generate_web_model.go generateWebModelDictOptions dictTypeList:%+v", dictTypeList)
// if len(builtinDictTypeIds) > 0 {
// for _, id := range builtinDictTypeIds {
// typ, err := dict.GetTypeById(ctx, id)
// if err != nil && !errors.Is(err, dict.NotExistKeyError) {
// return err
// }
// if len(typ) > 0 {
// row := new(DictType)
// row.Id = id
// row.Type = typ
// builtinDictTypeList = append(builtinDictTypeList, row)
// }
// }
// }
if len(dictTypeList) == 0 && len(builtinDictTypeList) == 0 {
return nil
}
if len(builtinDictTypeList) > 0 {
dictTypeList = append(dictTypeList, builtinDictTypeList...)
}
in.Options.DictOps.Has = true
for _, v := range dictTypeList {
// 字段映射字典
for _, field := range in.MasterFields {
if field.DictType != "" && v.DictType == field.DictType {
in.Options.DictMap[field.TsName] = v.DictType
in.Options.DictOps.Schemas = append(in.Options.DictOps.Schemas, &genmodel.OptionsSchemasField{
Field: field.TsName,
Type: v.DictType,
})
}
}
in.Options.DictOps.Types = append(in.Options.DictOps.Types, v.DictName)
}
return nil
}
@@ -0,0 +1,79 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"bytes"
"context"
"fmt"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"github.com/gogf/gf/v2/frame/g"
)
func (l *gCurd) webViewTplData(ctx context.Context, in *genmodel.CurdPreviewParam) (data g.Map, err error) {
data = make(g.Map)
data["item"] = l.generateWebViewItem(ctx, in)
return
}
func (l *gCurd) generateWebViewItem(ctx context.Context, in *genmodel.CurdPreviewParam) string {
buffer := bytes.NewBuffer(nil)
for _, field := range in.MasterFields {
if !field.IsEdit {
continue
}
var (
defaultComponent = fmt.Sprintf("<n-descriptions-item>\n <template #label>%s</template>\n {{ formValue.%s }}\n </n-descriptions-item>", field.Dc, field.TsName)
component string
)
switch field.FormMode {
case FMInputTextarea, FMInputEditor:
component = fmt.Sprintf("<n-descriptions-item>\n <template #label>%s</template>\n <span v-html=\"formValue.%s\"></span></n-descriptions-item>", field.Dc, field.TsName)
case FMInputDynamic:
component = defaultComponent
case FMDate:
component = defaultComponent
case FMTime:
component = defaultComponent
case FMRadio, FMSelect:
component = fmt.Sprintf("<n-descriptions-item label=\"%s\">\n <n-tag :type=\"dict.getType('%s', formValue.%s)\" size=\"small\" class=\"min-left-space\">{{ dict.getLabel('%s', formValue.%s) }}</n-tag>\n </n-descriptions-item>", field.Dc, in.Options.DictMap[field.TsName], field.TsName, in.Options.DictMap[field.TsName], field.TsName)
case FMCheckbox, FMSelectMultiple:
component = fmt.Sprintf("<n-descriptions-item label=\"%s\">\n <template v-for=\"(item, key) in formValue.%s\" :key=\"key\">\n <n-tag :type=\"dict.getType('%s', item)\" size=\"small\" class=\"min-left-space\">{{ dict.getLabel('%s', item) }}</n-tag>\n </template>\n </n-descriptions-item>", field.Dc, field.TsName, in.Options.DictMap[field.TsName], in.Options.DictMap[field.TsName])
case FMUploadImage:
component = fmt.Sprintf("<n-descriptions-item>\n <template #label>%s</template>\n <n-image style=\"margin-left: 10px; height: 100px; width: 100px\" :src=\"formValue.%s\"/></n-descriptions-item>", field.Dc, field.TsName)
case FMUploadImages:
component = fmt.Sprintf("<n-descriptions-item>\n <template #label>%s</template>\n <n-image-group>\n <n-space>\n <span v-for=\"(item, key) in formValue.%s\" :key=\"key\">\n <n-image style=\"margin-left: 10px; height: 100px; width: 100px\" :src=\"item\" />\n </span>\n </n-space>\n </n-image-group>\n </n-descriptions-item>", field.Dc, field.TsName)
case FMUploadFile:
component = fmt.Sprintf("<n-descriptions-item>\n <template #label>%s</template>\n <div class=\"upload-card\" v-show=\"formValue.%s !== ''\" @click=\"download(formValue.%s)\">\n <div class=\"upload-card-item\" style=\"height: 100px; width: 100px\">\n <div class=\"upload-card-item-info\">\n <div class=\"img-box\">\n <n-avatar :style=\"fileAvatarCSS\">{{ getFileExt(formValue.%s) }}</n-avatar>\n </div>\n </div>\n </div>\n </div>\n </n-descriptions-item>", field.Dc, field.TsName, field.TsName, field.TsName)
case FMUploadFiles:
component = fmt.Sprintf("<n-descriptions-item>\n <template #label>%s</template>\n <div class=\"upload-card\">\n <n-space style=\"gap: 0px 0px\">\n <div\n class=\"upload-card-item\"\n style=\"height: 100px; width: 100px\"\n v-for=\"(item, key) in formValue.%s\"\n :key=\"key\"\n >\n <div class=\"upload-card-item-info\">\n <div class=\"img-box\">\n <n-avatar :style=\"fileAvatarCSS\" @click=\"download(item)\">{{\n getFileExt(item)\n }}</n-avatar>\n </div>\n </div>\n </div>\n </n-space>\n </div>\n </n-descriptions-item>", field.Dc, field.TsName)
case FMSwitch:
component = fmt.Sprintf("<n-descriptions-item label=\"%s\">\n <n-switch v-model:value=\"formValue.%s\" :unchecked-value=\"2\" :checked-value=\"1\" :disabled=\"true\"/></n-descriptions-item>", field.Dc, field.TsName)
case FMRate:
component = fmt.Sprintf("<n-descriptions-item label=\"%s\"\n ><n-rate readonly :default-value=\"formValue.%s\"\n /></n-descriptions-item>", field.Dc, field.TsName)
default:
component = defaultComponent
}
buffer.WriteString(" " + component + "\n\n")
}
return buffer.String()
}
@@ -0,0 +1,334 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package genview
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"unicode"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/dao"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/mcache"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_view/gohtml"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
version "git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/version"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/text/gregex"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/v2/util/gconv"
"golang.org/x/tools/imports"
)
func replaceEmptyLinesWithSpace(input string) string {
re := regexp.MustCompile(`\n\s*\n`)
result := re.ReplaceAllString(input, "\n\n")
return result
}
func FormatTs(code string) string {
code = replaceEmptyLinesWithSpace(code)
return code + "\n"
}
func ToTSArray(vs []string) string {
formattedStrings := make([]string, len(vs))
for i, str := range vs {
formattedStrings[i] = fmt.Sprintf("'%s'", str)
}
return fmt.Sprintf("[%s]", strings.Join(formattedStrings, ", "))
}
func DictToTSArray(vs []string) string {
formattedStrings := make([]string, len(vs))
formattedObjStrings := make([]string, len(vs))
for i, str := range vs {
formattedStrings[i] = fmt.Sprintf("'%s'", str)
formattedObjStrings[i] = fmt.Sprintf("dict_%s", str)
}
return fmt.Sprintf("[%s],[%s]", strings.Join(formattedStrings, ", "), strings.Join(formattedObjStrings, ", "))
}
func RemoveSlice[K comparable](src []K, sub K) []K {
for k, v := range src {
if v == sub {
copy(src[k:], src[k+1:])
return src[:len(src)-1]
}
}
return src
}
// CheckIllegalName 检查命名是否合理
func CheckIllegalName(errPrefix string, names ...string) (err error) {
reg, _ := regexp.Compile("^[a-z_][a-z0-9_]*$")
for _, name := range names {
name = strings.ToLower(name)
match := reg.MatchString(name)
if !match {
err = gerror.Newf("%v存在格式不正确,必须全部小写且由字母、数字和下划线组成:%v", errPrefix, name)
return
}
if strings.HasSuffix(name, "test") {
err = gerror.Newf("%v当中不能以`test`结尾:%v", errPrefix, name)
return
}
if StartsWithDigit(name) {
err = gerror.Newf("%v当中不能以阿拉伯数字开头:%v", errPrefix, name)
return
}
}
return
}
func StartsWithDigit(s string) bool {
r := []rune(s)
if len(r) > 0 {
return unicode.IsDigit(r[0])
}
return false
}
// GetModName 获取主包名
func GetModName(ctx context.Context) (modName string, err error) {
if !gfile.Exists("go.mod") {
err = gerror.New("go.mod does not exist in current working directory")
return
}
var (
goModContent = gfile.GetContents("go.mod")
match, _ = gregex.MatchString(`^module\s+(.+)\s*`, goModContent)
)
if len(match) > 1 {
modName = gstr.Trim(match[1])
} else {
err = gerror.New("module name does not found in go.mod")
return
}
return
}
// parseServFunName 解析业务服务名称
func (l *gCurd) parseServFunName(templateGroup, varName string) string {
templateGroup = gstr.UcFirst(templateGroup)
if gstr.HasPrefix(varName, templateGroup) && varName != templateGroup {
return varName
}
return templateGroup + varName
}
func GetTempGeneratePath(ctx context.Context) string {
return gfile.Abs(gfile.Temp() + "/tmp_xjgen/" + version.AppName(ctx))
}
func FormatGo(ctx context.Context, name, code string) (string, error) {
path := GetTempGeneratePath(ctx) + "/" + name
if err := gfile.PutContents(path, code); err != nil {
return "", err
}
res, err := imports.Process(path, []byte(code), nil)
if err != nil {
err = gerror.Newf(`FormatGo error format "%s" go files: %v`, path, err)
return "", err
}
return string(res), nil
}
func formatComment(comment string) string {
comment = gstr.ReplaceByArray(comment, g.SliceStr{
"\n", " ",
"\r", " ",
})
comment = gstr.Replace(comment, `\n`, " ")
comment = gstr.Trim(comment)
return comment
}
func removeEndWrap(comment string) string {
if len(comment) > 2 && comment[len(comment)-2:] == " \n" {
comment = comment[:len(comment)-2]
}
return comment
}
func isEffectiveJoin(join *genmodel.CurdOptionsJoin) bool {
return join.Alias != "" && join.Field != "" && join.LinkTable != "" && join.MasterField != "" && join.DaoName != "" && join.LinkMode > 0
}
func IsPidName(name string) bool {
return name == "pid"
}
// CamelCaseToUnderline 驼峰单词转下划线单词
func CamelCaseToUnderline(s string) string {
var output []rune
for i, r := range s {
if i == 0 {
output = append(output, unicode.ToLower(r))
} else {
if unicode.IsUpper(r) {
output = append(output, '_')
}
output = append(output, unicode.ToLower(r))
}
}
return string(output)
}
// hasEffectiveJoin 存在有效的关联表
func hasEffectiveJoins(joins []*genmodel.CurdOptionsJoin) bool {
for _, join := range joins {
if isEffectiveJoin(join) {
return true
}
}
return false
}
// ImportWebMethod 导入前端方法
func ImportWebMethod(vs []string) string {
vs = utility.UniqueSlice(vs)
str := "{ " + strings.Join(vs, ", ") + " }"
str = strings.TrimSuffix(str, ", ")
return str
}
func FormatVue(code string) string {
endTag := `</template>`
vueLen := gstr.PosR(code, endTag)
vueCode := code[:vueLen+len(endTag)]
tsCode := code[vueLen+len(endTag):]
vueCode = gohtml.Format(vueCode)
tsCode = FormatTs(tsCode)
return vueCode + tsCode
}
// ImportSql 导出sql文件
func ImportSql(ctx context.Context, path string) error {
rows, err := os.ReadFile(path)
if err != nil {
return err
}
beforeMaxMenuId, _ := dao.SysMenu.Ctx(ctx).Max(dao.SysMenu.Columns().MenuId)
sqlArr := strings.Split(string(rows), "\n")
for _, sql := range sqlArr {
sql = strings.TrimSpace(sql)
if sql == "" || strings.HasPrefix(sql, "--") {
continue
}
g.Log().Infof(ctx, "views.ImportSql sql:%v", sql)
exec, err := g.DB().Exec(ctx, sql)
g.Log().Infof(ctx, "views.ImportSql sql:%v, exec:%+v, err:%+v", sql, exec, err)
if err != nil {
return err
}
}
if err = syncGeneratedMenus(ctx, gconv.Int64(beforeMaxMenuId)); err != nil {
return err
}
return nil
}
func syncGeneratedMenus(ctx context.Context, beforeMaxMenuId int64) error {
var menuIds []int64
if err := dao.SysMenu.Ctx(ctx).
Fields(dao.SysMenu.Columns().MenuId).
WhereGT(dao.SysMenu.Columns().MenuId, beforeMaxMenuId).
OrderAsc(dao.SysMenu.Columns().MenuId).
Scan(&menuIds); err != nil {
return err
}
if len(menuIds) == 0 {
return nil
}
for _, menuId := range menuIds {
if err := service.MemDBSysMenu().LoadData(ctx, menuId); err != nil {
return err
}
}
var roleIds []int64
if err := dao.SysRoleMenu.Ctx(ctx).
Fields(dao.SysRoleMenu.Columns().RoleId).
Where(dao.SysRoleMenu.Columns().TenantId, contexts.GetTenantId(ctx)).
WhereIn(dao.SysRoleMenu.Columns().MenuId, menuIds).
Group(dao.SysRoleMenu.Columns().RoleId).
Scan(&roleIds); err != nil {
return err
}
if len(roleIds) == 0 {
return nil
}
var userIds []int64
if err := dao.SysUserRole.Ctx(ctx).
Fields(dao.SysUserRole.Columns().UserId).
Where(dao.SysUserRole.Columns().TenantId, contexts.GetTenantId(ctx)).
WhereIn(dao.SysUserRole.Columns().RoleId, roleIds).
Group(dao.SysUserRole.Columns().UserId).
Scan(&userIds); err != nil {
return err
}
for _, userId := range userIds {
_ = mcache.RemoveUserAccessCodeList(ctx, userId)
_ = mcache.RemoveUserRoleDataAccessCodeList(ctx, userId)
}
return nil
}
// ParseDBConfigNodeLink 解析数据库连接配置
func ParseDBConfigNodeLink(node *gdb.ConfigNode) *gdb.ConfigNode {
const linkPattern = `(\w+):([\w\-\$]*):(.*?)@(\w+?)\((.+?)\)/{0,1}([^\?]*)\?{0,1}(.*)`
const defaultCharset = `utf8`
const defaultProtocol = `tcp`
var match []string
if node.Link != "" {
match, _ = gregex.MatchString(linkPattern, node.Link)
if len(match) > 5 {
node.Type = match[1]
node.User = match[2]
node.Pass = match[3]
node.Protocol = match[4]
array := gstr.Split(match[5], ":")
if len(array) == 2 && node.Protocol != "file" {
node.Host = array[0]
node.Port = array[1]
node.Name = match[6]
} else {
node.Name = match[5]
}
if len(match) > 6 && match[7] != "" {
node.Extra = match[7]
}
node.Link = ""
}
}
if node.Extra != "" {
if m, _ := gstr.Parse(node.Extra); len(m) > 0 {
_ = gconv.Struct(m, &node)
}
}
// Default value checks.
if node.Charset == "" {
node.Charset = defaultCharset
}
if node.Protocol == "" {
node.Protocol = defaultProtocol
}
return node
}
@@ -0,0 +1,12 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
const (
defaultIndentString = " "
startIndent = 0
defaultLastElement = "</html>"
)
@@ -0,0 +1,6 @@
// package genview
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
@@ -0,0 +1,12 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
// An element represents an HTML element.
type element interface {
isInline() bool
write(*formattedBuffer, bool) bool
}
@@ -0,0 +1,41 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
import (
"bytes"
"strconv"
"strings"
)
// Format parses the input HTML string, formats it and returns the result.
func Format(s string) string {
return parse(strings.NewReader(s)).html()
}
// FormatBytes parses input HTML as bytes, formats it and returns the result.
func FormatBytes(b []byte) []byte {
return parse(bytes.NewReader(b)).bytes()
}
// Format parses the input HTML string, formats it and returns the result with line no.
func FormatWithLineNo(s string) string {
return AddLineNo(Format(s))
}
func AddLineNo(s string) string {
lines := strings.Split(s, "\n")
maxLineNoStrLen := len(strconv.Itoa(len(lines)))
bf := &bytes.Buffer{}
for i, line := range lines {
lineNoStr := strconv.Itoa(i + 1)
if i > 0 {
bf.WriteString("\n")
}
bf.WriteString(strings.Repeat(" ", maxLineNoStrLen-len(lineNoStr)) + lineNoStr + " " + line)
}
return bf.String()
}
@@ -0,0 +1,59 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
import (
"bytes"
"regexp"
)
// Column to wrap lines to (disabled by default)
var LineWrapColumn = 0
// Maxmimum characters a long word can extend past LineWrapColumn without wrapping
var LineWrapMaxSpillover = 5
// An htmlDocument represents an HTML document.
type htmlDocument struct {
elements []element
}
// html generates an HTML source code and returns it.
func (htmlDoc *htmlDocument) html() string {
str := string(htmlDoc.bytes())
str = replaceMultipleNewlinesWithSpaceAndTabs(str)
return str
}
func replaceMultipleNewlinesWithSpaceAndTabs(input string) string {
re := regexp.MustCompile(`\n\s*\n+`)
formattedString := re.ReplaceAllString(input, "\n")
return formattedString
}
// bytes reads from htmlDocument's internal array of elements and returns HTML source code
func (htmlDoc *htmlDocument) bytes() []byte {
bf := &formattedBuffer{
buffer: &bytes.Buffer{},
lineWrapColumn: LineWrapColumn,
lineWrapMaxSpillover: LineWrapMaxSpillover,
indentString: defaultIndentString,
indentLevel: startIndent,
}
isPreviousNodeInline := true
for _, child := range htmlDoc.elements {
isPreviousNodeInline = child.write(bf, isPreviousNodeInline)
}
return bf.buffer.Bytes()
}
// append appends an element to the htmlDocument.
func (htmlDoc *htmlDocument) append(e element) {
htmlDoc.elements = append(htmlDoc.elements, e)
}
@@ -0,0 +1,133 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
import (
"io"
"regexp"
"strings"
"golang.org/x/net/html"
)
// parse parses a stirng and converts it into an html.
func parse(r io.Reader) *htmlDocument {
htmlDoc := &htmlDocument{}
tokenizer := html.NewTokenizer(r)
for {
if errorToken, _, _ := parseToken(tokenizer, htmlDoc, nil); errorToken {
break
}
}
return htmlDoc
}
// Function that identifies which tags will be treated as containing preformatted
// content. Such tags will have the formatting of all its contents preserved
// unchanged.
// The opening tag html.Token is passed to this function.
// By default, only <pre> and <textarea> tags are considered preformatted.
var IsPreformatted = func(token html.Token) bool {
return token.Data == "pre" || token.Data == "textarea"
}
func parseToken(tokenizer *html.Tokenizer, htmlDoc *htmlDocument, parent *tagElement) (bool, bool, string) {
tokenType := tokenizer.Next()
raw := string(tokenizer.Raw())
switch tokenType {
case html.ErrorToken:
return true, false, ""
case html.TextToken:
text := string(tokenizer.Raw())
if strings.TrimSpace(text) == "" && (parent == nil || !parent.isRaw) {
break
}
textElement := &textElement{text: text, parent: parent}
appendElement(htmlDoc, parent, textElement)
case html.StartTagToken:
raw := string(tokenizer.Raw())
token := tokenizer.Token()
tagElement := &tagElement{
tagName: string(token.Data),
startTagRaw: raw,
isRaw: IsPreformatted(token) || (parent != nil && parent.isRaw),
parent: parent,
}
appendElement(htmlDoc, parent, tagElement)
for {
errorToken, parentEnded, unsetEndTag := parseToken(tokenizer, htmlDoc, tagElement)
if errorToken {
return true, false, ""
}
if parentEnded {
if unsetEndTag != "" {
return false, false, unsetEndTag
}
break
}
if unsetEndTag != "" {
tagName := setEndTagRaw(tokenizer, tagElement, unsetEndTag)
return false, false, tagName
}
}
case html.EndTagToken:
tagName := setEndTagRaw(tokenizer, parent, getTagName(tokenizer))
return false, true, tagName
case html.DoctypeToken, html.CommentToken:
tagElement := &tagElement{
tagName: getTagName(tokenizer),
startTagRaw: string(tokenizer.Raw()),
isRaw: parent != nil && parent.isRaw,
parent: parent,
}
appendElement(htmlDoc, parent, tagElement)
case html.SelfClosingTagToken:
tagElement := &tagElement{
tagName: getTagName(tokenizer),
startTagRaw: raw,
isRaw: parent != nil && parent.isRaw,
parent: parent,
}
appendElement(htmlDoc, parent, tagElement)
}
return false, false, ""
}
// appendElement appends the element to the htmlDocument or parent tagElement.
func appendElement(htmlDoc *htmlDocument, parent *tagElement, e element) {
if parent != nil {
parent.appendChild(e)
} else {
htmlDoc.append(e)
}
}
// getTagName gets a tagName from tokenizer.
func getTagName(tokenizer *html.Tokenizer) string {
tagName, _ := tokenizer.TagName()
return string(tagName)
}
// setEndTagRaw sets an endTagRaw to the parent.
func setEndTagRaw(_ *html.Tokenizer, parent *tagElement, tagName string) string {
if parent != nil && parent.tagName == tagName {
parent.endTagRaw = `</` + fMustCompile(parent.startTagRaw) + `>` // string(tokenizer.Raw())
return ""
}
return tagName
}
func fMustCompile(input string) (result string) {
re := regexp.MustCompile(`<([A-Za-z-]+)[\s\S]*?>`)
match := re.FindStringSubmatch(input)
if len(match) > 1 {
result = match[1]
} else {
result = input
}
return
}
@@ -0,0 +1,150 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
import (
"bytes"
)
// A tagElement represents a tag element of an HTML document.
type tagElement struct {
tagName string
startTagRaw string
endTagRaw string
parent *tagElement
children []element
isRaw bool
isChildrenInlineCache *bool
}
// Enable condensing a tag with only inline children onto a single line, or
// completely inlining it with sibling nodes.
// Tags to be treated as inline can be set in `InlineTags`.
// Only inline tags will be completely inlined, while other condensable tags
// will be given their own dedicated (single) line.
var Condense bool
// Tags that are considered inline tags.
// Note: Text nodes are always considered to be inline
var InlineTags = map[string]bool{
"a": true,
"code": true,
"em": true,
"span": true,
"strong": true,
}
// Maximum length of an opening inline tag before it's un-inlined
var InlineTagMaxLength = 40
func (e *tagElement) isInline() bool {
if e.isRaw || !InlineTags[e.tagName] || len(e.startTagRaw) > InlineTagMaxLength {
return false
}
return e.isChildrenInline()
}
func (e *tagElement) isChildrenInline() bool {
if !Condense {
return false
}
if e.isChildrenInlineCache != nil {
return *e.isChildrenInlineCache
}
isInline := true
for _, child := range e.children {
isInline = isInline && child.isInline()
}
e.isChildrenInlineCache = &isInline
return isInline
}
// write writes a tag to the buffer.
func (e *tagElement) write(bf *formattedBuffer, isPreviousNodeInline bool) bool {
if e.isRaw {
if e.parent != nil && !e.parent.isRaw {
bf.writeLineFeed()
bf.writeIndent()
bf.rawMode = true
defer func() {
bf.rawMode = false
}()
}
bf.writeToken(e.startTagRaw, formatterTokenType_Tag)
for _, child := range e.children {
child.write(bf, true)
}
bf.writeToken(e.endTagRaw, formatterTokenType_Tag)
return false
}
if e.isChildrenInline() && (e.endTagRaw != "" || e.isInline()) {
// Write the condensed output to a separate buffer, in case it doesn't work out
condensedBuffer := *bf
condensedBuffer.buffer = &bytes.Buffer{}
if bf.buffer.Len() > 0 && (!isPreviousNodeInline || !e.isInline()) {
condensedBuffer.writeLineFeed()
}
condensedBuffer.writeToken(e.startTagRaw, formatterTokenType_Tag)
if !isPreviousNodeInline && e.endTagRaw != "" {
condensedBuffer.indentLevel++
}
for _, child := range e.children {
child.write(&condensedBuffer, true)
}
if e.endTagRaw != "" {
condensedBuffer.writeToken(e.endTagRaw, formatterTokenType_Tag)
if !isPreviousNodeInline {
condensedBuffer.indentLevel--
}
}
if e.isInline() || bytes.ContainsAny(condensedBuffer.buffer.Bytes()[1:], "\n") {
// If we're an inline tag, or there were no newlines were in the buffer,
// replace the original with the condensed version
condensedBuffer.buffer = bytes.NewBuffer(bytes.Join([][]byte{
bf.buffer.Bytes(), condensedBuffer.buffer.Bytes(),
}, []byte{}))
*bf = condensedBuffer
return e.isInline()
}
}
if bf.buffer.Len() > 0 {
bf.writeLineFeed()
}
bf.writeToken(e.startTagRaw, formatterTokenType_Tag)
if e.endTagRaw != "" {
bf.indentLevel++
}
isPreviousNodeInline = false
for _, child := range e.children {
isPreviousNodeInline = child.write(bf, isPreviousNodeInline)
}
if e.endTagRaw != "" {
if len(e.children) > 0 {
bf.writeLineFeed()
}
bf.indentLevel--
bf.writeToken(e.endTagRaw, formatterTokenType_Tag)
}
return false
}
// appendChild append an element to the element's children.
func (e *tagElement) appendChild(child element) {
e.children = append(e.children, child)
}
@@ -0,0 +1,48 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
import (
"regexp"
"strings"
)
// A textElement represents a text element of an HTML document.
type textElement struct {
text string
parent *tagElement
}
func (e *textElement) isInline() bool {
// Text nodes are always considered to be inline
return true
}
// write writes a text to the buffer.
func (e *textElement) write(bf *formattedBuffer, isPreviousNodeInline bool) bool {
text := unifyLineFeed(e.text)
if e.parent != nil && e.parent.isRaw {
bf.writeToken(text, formatterTokenType_Text)
return true
}
if !isPreviousNodeInline {
bf.writeLineFeed()
}
// Collapse leading and trailing spaces
text = regexp.MustCompile(`^\s+|\s+$`).ReplaceAllString(text, " ")
lines := strings.Split(text, "\n")
for l, line := range lines {
if l > 0 {
bf.writeLineFeed()
}
for _, word := range strings.Split(line, " ") {
bf.writeToken(word, formatterTokenType_Text)
}
}
return true
}
@@ -0,0 +1,106 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
import (
"bytes"
"strings"
"unicode"
)
type formatterTokenType int
const (
formatterTokenType_Nothing formatterTokenType = iota
formatterTokenType_Tag
formatterTokenType_Text
)
type formattedBuffer struct {
buffer *bytes.Buffer
rawMode bool
indentString string
indentLevel int
lineWrapColumn int
lineWrapMaxSpillover int
curLineLength int
prevTokenType formatterTokenType
}
func (bf *formattedBuffer) writeLineFeed() {
if !bf.rawMode {
// Strip trailing newlines
bf.buffer = bytes.NewBuffer(bytes.TrimRightFunc(
bf.buffer.Bytes(),
func(r rune) bool {
return r != '\n' && unicode.IsSpace(r)
},
))
}
bf.buffer.WriteString("\n")
bf.curLineLength = 0
bf.prevTokenType = formatterTokenType_Nothing
}
func (bf *formattedBuffer) writeIndent() {
bf.buffer.WriteString(strings.Repeat(bf.indentString, bf.indentLevel))
bf.curLineLength += len(bf.indentString) * bf.indentLevel
}
func (bf *formattedBuffer) writeToken(token string, kind formatterTokenType) {
if bf.rawMode {
bf.buffer.WriteString(token)
bf.curLineLength += len(token)
return
}
if bf.prevTokenType == formatterTokenType_Nothing && strings.TrimSpace(token) == "" {
// It's a whitespace token, but we already have indentation which functions
// the same, so we ignore it
return
}
toWrite := token
if kind == formatterTokenType_Text && bf.prevTokenType == formatterTokenType_Text {
toWrite = " " + token
}
if bf.prevTokenType != formatterTokenType_Nothing && bf.lineWrapColumn > 0 {
switch {
case bf.curLineLength > bf.lineWrapColumn:
// Current line is too long
fallthrough
case bf.curLineLength+len(toWrite) > bf.lineWrapColumn+bf.lineWrapMaxSpillover:
// Current line + new token is too long even with allowed spillover
fallthrough
case bf.curLineLength+len(toWrite) > bf.lineWrapColumn &&
bf.curLineLength > bf.lineWrapColumn-bf.lineWrapMaxSpillover:
// Current line + new token is too long and doesn't quality for spillover
bf.writeLineFeed()
bf.writeToken(token, kind)
return
}
}
if bf.curLineLength == 0 {
bf.writeIndent()
}
bf.buffer.WriteString(toWrite)
bf.curLineLength += len(toWrite)
bf.prevTokenType = kind
}
// unifyLineFeed unifies line feeds.
func unifyLineFeed(s string) string {
return strings.ReplaceAll(strings.ReplaceAll(s, "\r\n", "\n"), "\r", "\n")
}
@@ -0,0 +1,38 @@
// package gohtml
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gohtml
import (
"bytes"
"io"
)
// A Writer represents a formatted HTML source codes writer.
type Writer struct {
writer io.Writer
lastElement string
bf *bytes.Buffer
}
// SetLastElement set the lastElement to the Writer.
func (wr *Writer) SetLastElement(lastElement string) *Writer {
wr.lastElement = lastElement
return wr
}
// Write writes the parameter.
func (wr *Writer) Write(p []byte) (n int, err error) {
n, _ = wr.bf.Write(p) // (*bytes.Buffer).Write never produces an error
if bytes.HasSuffix(p, []byte(wr.lastElement)) {
_, err = wr.writer.Write([]byte(Format(wr.bf.String()) + "\n"))
}
return n, err
}
// NewWriter generates a Writer and returns it.
func NewWriter(wr io.Writer) *Writer {
return &Writer{writer: wr, lastElement: defaultLastElement, bf: &bytes.Buffer{}}
}
@@ -0,0 +1,471 @@
// package xgen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package xgen
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/addons"
genconsts "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_consts"
gendao "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_dao"
genmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_model"
genview "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_view"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/request"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/service"
"sort"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gctx"
"github.com/gogf/gf/v2/os/gproc"
"github.com/gogf/gf/v2/text/gregex"
"github.com/gogf/gf/v2/text/gstr"
)
func GenTypeSelects(ctx context.Context) (output []*genmodel.SelectGenTypeModel, err error) {
output = make([]*genmodel.SelectGenTypeModel, 0)
for k, v := range genconsts.GenCodesTypeNameMap {
item := &genmodel.SelectGenTypeModel{
SelectItemModel: genmodel.SelectItemModel{
Value: k,
Name: v,
Label: v,
Disabled: false,
},
Templates: make([]*genmodel.SelectGenTemplateModel, 0),
}
confName, ok := genconsts.GenCodesTypeConfMap[k]
if !ok {
continue
}
confTemplates := make([]*genmodel.GenCodesConfigCrudTemplate, 0)
err = g.Cfg().MustGet(ctx, "xgen.application."+confName+".templates").Scan(&confTemplates)
if err != nil {
return
}
for index, v := range confTemplates {
item.Templates = append(item.Templates, &genmodel.SelectGenTemplateModel{
SelectItemModel: genmodel.SelectItemModel{
Value: index,
Label: v.Group,
Name: v.Group,
Disabled: false,
},
IsAddon: v.IsAddon,
})
}
sort.Slice(item.Templates, func(i, j int) bool {
return item.Templates[i].Value.(int) < item.Templates[j].Value.(int)
})
output = append(output, item)
}
return
}
func DbSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
dbs := g.Cfg().MustGet(ctx, "xgen.selectDbs")
if len(dbs.Strings()) == 0 {
return
}
for _, v := range dbs.Strings() {
output = append(output, &genmodel.SelectItemModel{
Value: v,
Label: v,
Name: v,
Disabled: false,
})
}
return
}
func StatusSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
for k, v := range genconsts.GenCodesStatusNameMap {
output = append(output, &genmodel.SelectItemModel{
Value: k,
Label: v,
Name: v,
Disabled: false,
})
}
sort.Slice(output, func(i, j int) bool {
return output[i].Value.(string) < output[j].Value.(string)
})
return
}
func LinkModeSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
for k, v := range genconsts.GenCodesJoinNameMap {
output = append(output, &genmodel.SelectItemModel{
Value: k,
Label: v,
Name: v,
Disabled: false,
})
}
sort.Slice(output, func(i, j int) bool {
return output[i].Value.(int) < output[j].Value.(int)
})
return
}
func BuildMethodSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
for k, v := range genconsts.GenCodesBuildMethNameMap {
output = append(output, &genmodel.SelectItemModel{
Value: k,
Label: v,
Name: v,
Disabled: false,
})
}
sort.Slice(output, func(i, j int) bool {
return output[i].Value.(int) < output[j].Value.(int)
})
return
}
func FormModeSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
for _, v := range genview.FMs {
output = append(output, &genmodel.SelectItemModel{
Value: v,
Label: genview.FMMap[v],
Name: genview.FMMap[v],
Disabled: false,
})
}
return
}
func FormRoleSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
for k, v := range genview.FRMap {
output = append(output, &genmodel.SelectItemModel{
Value: k,
Label: v,
Name: v,
Disabled: false,
})
}
return
}
func DictModeSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
dictMode, _, err := service.SysDictType().List(ctx, &model.SysDictTypeListParam{
PageInfo: request.PageInfo{
Page: 1,
PageSize: 2000,
},
})
if err != nil {
return
}
for _, v := range dictMode {
output = append(output, &genmodel.SelectItemModel{
Value: v.DictType,
Label: v.DictName + "(" + v.DictType + ")",
Name: v.DictName,
})
}
return
}
func WhereModeSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
for _, v := range genview.WMs {
output = append(output, &genmodel.SelectItemModel{
Value: v,
Label: v,
Name: v,
Disabled: false,
})
}
return
}
func TableAlignSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
for _, v := range genview.TableAligns {
output = append(output, &genmodel.SelectItemModel{
Value: v,
Label: genview.TableAlignMap[v],
Name: genview.TableAlignMap[v],
Disabled: false,
})
}
return
}
func TreeStyleTypeSelects(ctx context.Context) (output []*genmodel.SelectItemModel, err error) {
output = make([]*genmodel.SelectItemModel, 0)
for k, v := range genconsts.GenCodesTreeStyleTypeNameMap {
output = append(output, &genmodel.SelectItemModel{
Value: k,
Label: v,
Name: v,
Disabled: false,
})
}
sort.Slice(output, func(i, j int) bool {
return output[i].Value.(int) < output[j].Value.(int)
})
return
}
func GenCodesSelects(ctx context.Context) (output *genmodel.SelectsModel, err error) {
output = &genmodel.SelectsModel{}
output.GenType, err = GenTypeSelects(ctx)
if err != nil {
return
}
output.Db, err = DbSelects(ctx)
if err != nil {
return
}
output.Status, err = StatusSelects(ctx)
if err != nil {
return
}
output.LinkMode, err = LinkModeSelects(ctx)
if err != nil {
return
}
output.BuildMethod, err = BuildMethodSelects(ctx)
if err != nil {
return
}
output.FormMode, err = FormModeSelects(ctx)
if err != nil {
return
}
output.WhereMode, err = WhereModeSelects(ctx)
if err != nil {
return
}
output.FormRole, err = FormRoleSelects(ctx)
if err != nil {
return
}
output.DictMode, err = DictModeSelects(ctx)
if err != nil {
return
}
output.Addons, err = addons.ModuleSelects(ctx)
if err != nil {
return
}
output.TableAlign, err = TableAlignSelects(ctx)
if err != nil {
return
}
output.TreeStyleType, err = TreeStyleTypeSelects(ctx)
if err != nil {
return
}
return
}
func GenCodesTableSelect(ctx context.Context, params *genmodel.GenCodesTableSelectParam) (output []*genmodel.GenCodesTableSelectModel, err error) {
if params == nil || params.DbGroup == "" {
return
}
sql := "SELECT TABLE_NAME as value, TABLE_COMMENT as label FROM information_schema.`TABLES` WHERE TABLE_SCHEMA = ?"
config := g.DB(params.DbGroup).GetConfig()
disableTables := g.Cfg().MustGet(ctx, "xgen.disableTables").Strings()
lists := make([]*genmodel.GenCodesTableSelectModel, 0)
err = g.DB(params.DbGroup).Ctx(ctx).Raw(sql, config.Name).Scan(&lists)
if err != nil {
return
}
patternStr := `addon_(\w+)_`
repStr := ``
for _, v := range lists {
if gstr.InArray(disableTables, v.Value) {
continue
}
newValue := v.Value
if config.Prefix != "" {
newValue = gstr.SubStrFromEx(v.Value, config.Prefix)
}
// g.Log().Infof(ctx, "server/internal/library/xgen/xgen_curd.go GenCodesTableSelect newValue:%v config.Prefix:%v", newValue, config.Prefix)
if newValue == "" {
continue
}
// 插件移除掉插件表前缀
bt, err := gregex.Replace(patternStr, []byte(repStr), []byte(newValue))
if err != nil {
continue
}
// g.Log().Infof(ctx, "server/internal/library/xgen/xgen_curd.go GenCodesTableSelect newValue:%v string(bt):%v", newValue, string(bt))
row := v
row.DefTableComment = v.Label
row.DaoName = gstr.CaseCamel(newValue)
row.DefVarName = gstr.CaseCamel(string(bt))
row.DefAlias = gstr.CaseCamelLower(newValue)
row.Name = fmt.Sprintf("%s (%s)", v.Value, v.Label)
row.Label = row.Name
output = append(output, row)
}
return
}
func GenCodesColumnSelect(ctx context.Context, params *genmodel.GenCodesColumnSelectParam) (output []*genmodel.GenCodesColumnSelectModel, err error) {
sql := "SELECT COLUMN_NAME as value, COLUMN_COMMENT as label FROM information_schema.`COLUMNS` WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?"
dbConfig := g.DB(params.DbGroup).GetConfig()
lists := make([]*genmodel.GenCodesColumnSelectModel, 0)
err = g.DB(params.DbGroup).Ctx(ctx).Raw(sql, dbConfig.Name, params.TableName).Scan(&lists)
if err != nil {
return
}
if len(lists) == 0 {
err = gerror.New("表不存在")
return
}
for _, v := range lists {
output = append(output, &genmodel.GenCodesColumnSelectModel{
Value: v.Value,
Label: v.Label,
Name: fmt.Sprintf("%s (%s)", v.Value, v.Label),
})
}
return
}
func GenCodesColumnList(ctx context.Context, params *genmodel.GenCodesColumnListParam) (output []*genmodel.GenCodesColumnListModel, err error) {
output, err = genview.TableColumnList(ctx, params)
return
}
// GetLoadGenerate 获取本地生成配置
func GetLoadGenerate(ctx context.Context) (conf *genmodel.GenCodesConfig, err error) {
err = g.Cfg().MustGet(ctx, "xgen").Scan(&conf)
return
}
func GenCodesPreview(ctx context.Context, params *genmodel.GenCodesPreviewParam) (output *genmodel.GenCodesPreviewModel, err error) {
genConfig, err := GetLoadGenerate(ctx)
if err != nil {
return nil, err
}
daoConfig, err := gendao.GetDaoConfig(ctx, params.DbName)
if err != nil {
return nil, err
}
switch params.GenType {
case genconsts.GenCodesTypeCurd, genconsts.GenCodesTypeTree:
return genview.Curd.DoPreview(ctx, &genmodel.CurdPreviewParam{
In: params,
DaoConfig: daoConfig,
Config: genConfig,
})
default:
err = gerror.Newf("生成类型暂不支持!")
return
}
return
}
// Build 提交生成
func GenCodesBuild(ctx context.Context, params *genmodel.GenCodesBuildParam) (output *genmodel.GenCodesBuildModel, err error) {
output = &genmodel.GenCodesBuildModel{}
genConfig, err := GetLoadGenerate(ctx)
if err != nil {
return nil, err
}
daoConfig, err := gendao.GetDaoConfig(ctx, params.DbName)
if err != nil {
return nil, err
}
switch params.GenType {
case genconsts.GenCodesTypeCurd, genconsts.GenCodesTypeTree:
pin := params
return output, genview.Curd.DoBuild(ctx, &genmodel.CurdBuildParam{
PreviewIn: &genmodel.CurdPreviewParam{
In: pin,
DaoConfig: daoConfig,
Config: genConfig,
},
BeforeEvent: genmodel.CurdBuildEvent{"runDao": Dao},
AfterEvent: genmodel.CurdBuildEvent{"runService": func(ctx context.Context) (err error) {
cfg := gendao.GetServiceConfig()
// 插件模块,切换到插件下运行gen service
if genConfig.Application.Crud.Templates[pin.GenTemplate].IsAddon {
// 依然使用配置中的参数,只是将生成路径指向插件模块路径
cfg.SrcFolder = "addons/" + pin.AddonName + "/logic"
cfg.DstFolder = "addons/" + pin.AddonName + "/service"
}
err = ServiceWithCfg(ctx, cfg)
return
}},
})
default:
err = gerror.Newf("生成类型暂不支持!")
return
}
}
// ServiceWithCfg 生成业务接口
func ServiceWithCfg(ctx context.Context, cfg ...*genmodel.GenServiceConfig) (err error) {
c := gendao.GetServiceConfig()
if len(cfg) > 0 {
c = cfg[0]
}
cmd := `gf gen service`
if c.SrcFolder != "" {
cmd += ` -s ` + c.SrcFolder
}
if c.DstFolder != "" {
cmd += ` -d ` + c.DstFolder
}
if c.DstFileNameCase != "" {
cmd += ` -f ` + c.DstFileNameCase
}
if c.WatchFile != "" {
cmd += ` -w ` + c.WatchFile
}
if c.StPattern != "" {
cmd += ` -a ` + c.StPattern
}
if c.Clear {
cmd += ` -l`
}
if c.ImportPrefix != "" {
cmd += ` -i ` + c.ImportPrefix
}
if c.Packages != nil {
cmd += ` -p ` + strings.Join(c.Packages, ",")
}
r, err := gproc.ShellExec(gctx.New(), cmd)
if err != nil {
return err
}
g.Log().Info(ctx, "生成业务接口 Stdout", r)
return
}
// Dao 生成数据库实体
func Dao(ctx context.Context) (err error) {
cmd := `gf gen dao`
r, err := gproc.ShellExec(gctx.New(), cmd)
if err != nil {
g.Log().Error(ctx, "生成数据库实体 Stderr", r)
return err
}
g.Log().Info(ctx, "生成数据库实体 Stdout", r)
return
}
@@ -0,0 +1,19 @@
// package xgen
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package xgen
import (
"context"
gendao "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgen/gen_dao"
)
func Init(ctx context.Context) (err error) {
err = gendao.Init(ctx)
if err != nil {
return
}
return
}
@@ -0,0 +1,197 @@
// package xgorm
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package xgorm
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
"slices"
"reflect"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/text/gstr"
)
var (
descTags = []string{"description", "dc", "json"} // 实体描述标签
fieldTags = []string{"json"} // 实体字段名称映射
)
// GetFieldsToSlice 获取dao实例中的所有字段
func GetFieldsToSlice(ctx context.Context, dao daoInstance) ([]string, error) {
fields, err := dao.Ctx(ctx).TableFields(dao.Table())
if err != nil {
return nil, err
}
if len(fields) == 0 {
return nil, gerror.New("field not found")
}
var keys []string
for _, field := range fields {
keys = append(keys, field.Name)
}
return keys, nil
}
// GetPkField 获取dao实例中的主键名称
func GetPkField(ctx context.Context, dao daoInstance) (string, error) {
fields, err := dao.Ctx(ctx).TableFields(dao.Table())
if err != nil {
return "", err
}
if len(fields) == 0 {
return "", gerror.New("field not found")
}
for _, field := range fields {
if field.Key == "PRI" {
return field.Name, nil
}
}
return "", gerror.New("no primary key")
}
// IsUnique 是否唯一
func IsUnique(ctx context.Context, dao daoInstance, where g.Map, message string, pkId ...interface{}) error {
if len(where) == 0 {
return gerror.New("where condition cannot be empty")
}
m := dao.Ctx(ctx).Where(where)
if len(pkId) > 0 {
field, err := GetPkField(ctx, dao)
if err != nil {
return err
}
m = m.WhereNot(field, pkId[0])
}
count, err := m.Count(1)
if err != nil {
return err
}
if count > 0 {
if message == "" {
for k := range where {
message = fmt.Sprintf("in the table:%s, %v not uniqued", dao.Table(), where[k])
break
}
}
return gerror.New(message)
}
return nil
}
func JoinFields(ctx context.Context, entity interface{}, dao daoInstance, as string) (fs string) {
entityFs, err := GetEntityFieldTags(entity)
if err != nil {
g.Log().Errorf(ctx, "JoinFields err: %v", err)
return
}
if len(entityFs) == 0 {
g.Log().Errorf(ctx, "JoinFields entityFs is empty")
return ""
}
fields, err := dao.Ctx(ctx).TableFields(dao.Table())
if err != nil {
g.Log().Errorf(ctx, "JoinFields err: %v", err)
return
}
var columns []string
for _, v := range entityFs {
if !gstr.HasPrefix(v, as) {
g.Log().Infof(ctx, "JoinFields gstr.HasPrefix: %+v, %+v", v, as)
continue
}
g.Log().Infof(ctx, "JoinFields gstr.HasPrefix2: %+v, %+v", v, as)
field := gstr.CaseSnakeFirstUpper(gstr.StrEx(v, as))
if _, ok := fields[field]; ok {
columns = append(columns, fmt.Sprintf("`%s`.`%s` as `%s`", dao.Table(), field, v))
}
g.Log().Infof(ctx, "JoinFields field: %+v fields: %+v", field, fields)
}
if len(columns) > 0 {
g.Log().Infof(ctx, "JoinFields columns: %+v", columns)
return gstr.Implode(",", utility.UniqueSlice(columns))
}
return
}
// GetEntityFieldTags 获取实体中的字段名称
func GetEntityFieldTags(entity interface{}) (tags []string, err error) {
var formRef = reflect.TypeOf(entity)
for i := 0; i < formRef.NumField(); i++ {
field := formRef.Field(i)
if field.Type.Kind() == reflect.Struct {
addTags, err := reflectTag(field.Type, fieldTags, []string{})
if err != nil {
return nil, err
}
tags = append(tags, addTags...)
continue
}
tags = append(tags, reflectTagName(field, fieldTags, true))
}
return
}
// reflectTag 层级递增解析tag
func reflectTag(reflectType reflect.Type, filterTags []string, tags []string) ([]string, error) {
if reflectType.Kind() == reflect.Ptr {
return nil, gerror.Newf("reflect type do not support reflect.Ptr yet, reflectType:%+v", reflectType)
}
if reflectType.Kind() != reflect.Struct {
return nil, nil
}
for i := 0; i < reflectType.NumField(); i++ {
tag := reflectTagName(reflectType.Field(i), filterTags, false)
if tag == "" {
addTags, err := reflectTag(reflectType.Field(i).Type, filterTags, tags)
if err != nil {
return nil, err
}
tags = append(tags, addTags...)
continue
}
tags = append(tags, tag)
}
return tags, nil
}
// reflectTagName 解析实体中的描述标签,优先级:description > dc > json > Name
func reflectTagName(field reflect.StructField, filterTags []string, isDef bool) string {
if slices.Contains(filterTags, "description") {
if description, ok := field.Tag.Lookup("description"); ok && description != "" {
return description
}
}
if slices.Contains(filterTags, "dc") {
if dc, ok := field.Tag.Lookup("dc"); ok && dc != "" {
return dc
}
}
if slices.Contains(filterTags, "json") {
if jsonName, ok := field.Tag.Lookup("json"); ok && jsonName != "" {
return jsonName
}
}
if !isDef {
return ""
}
return field.Name
}
@@ -0,0 +1,195 @@
// package xgorm
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package xgorm
import (
"context"
gormmodel "git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/xgorm/gorm_model"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/tree"
"slices"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
)
const (
idDefaulField = "id"
pidDefaulField = "pid"
levelDefaulField = "level"
treeDefaulField = "tree"
)
type TreeFiledOption struct {
IdField string
PidField string
LevelField string
TreeField string
}
type daoInstance interface {
Table() string
Ctx(ctx context.Context) *gdb.Model
}
func CustomTreeFiledToDefault(old map[string]interface{}, option *TreeFiledOption) *gormmodel.DefaultTree {
if option == nil {
option = &TreeFiledOption{
IdField: idDefaulField,
PidField: pidDefaulField,
LevelField: levelDefaulField,
TreeField: treeDefaulField,
}
}
pd := &gormmodel.DefaultTree{}
if id, ok := old[option.IdField]; ok {
pd.Id = gconv.Int64(id)
}
if pid, ok := old[option.PidField]; ok {
pd.Pid = gconv.Int64(pid)
}
if level, ok := old[option.LevelField]; ok {
pd.Level = gconv.Int(level)
}
if tree, ok := old[option.TreeField]; ok {
pd.Tree = gconv.String(tree)
}
return pd
}
// AutoUpdateTree 自动更新关系树
func AutoUpdateTree(ctx context.Context, dao daoInstance, id, pid int64, option *TreeFiledOption) (newPid int64, newLevel int, newTree string, err error) {
if option == nil {
option = &TreeFiledOption{
IdField: idDefaulField,
PidField: pidDefaulField,
LevelField: levelDefaulField,
TreeField: treeDefaulField,
}
}
if err = CheckTreeTable(ctx, dao, option); err != nil {
return
}
if pid <= 0 {
newPid = 0
newLevel = 1
newTree = ""
} else {
var pdMap map[string]interface{}
row, err := dao.Ctx(ctx).Fields(option.IdField, option.PidField, option.LevelField, option.TreeField).WherePri(pid).One()
if err != nil {
return 0, 0, "", err
}
if row != nil {
pdMap = row.Map()
}
if len(pdMap) == 0 {
return 0, 0, "", gerror.New("未查询到树表上级信息,请检查!")
}
pd := CustomTreeFiledToDefault(pdMap, option)
if id > 0 && slices.Contains(tree.GetIds(pd.Tree), id) {
return 0, 0, "", gerror.New("上级不能设为自己当前的子级!")
}
newPid = pid
newLevel = pd.Level + 1
newTree = tree.GenLabel(pd.Tree, pid)
}
if id > 0 {
if pid == id {
return 0, 0, "", gerror.New("上级不能是自己!")
}
var models *gormmodel.DefaultTree
if err = dao.Ctx(ctx).WherePri(id).Scan(&models); err != nil {
return 0, 0, "", err
}
if models == nil {
return 0, 0, "", gerror.New("树表信息不存在,请检查!")
}
// g.Log().Infof(ctx, "models: %+v, pid: %v, newPid: %v, newLevel: %v, newTree: %v", models, pid, newPid, newLevel, newTree)
// 上级发生变化时,遍历修改其所有的下级关系树
if models.Pid != pid {
if err = updateChildrenTree(ctx, dao, models.Id, newLevel, newTree, option); err != nil {
return
}
}
}
return
}
// CheckTreeTable 检查树表
func CheckTreeTable(ctx context.Context, dao daoInstance, option *TreeFiledOption) (err error) {
fields, err := GetFieldsToSlice(ctx, dao)
if err != nil {
return err
}
if !slices.Contains(fields, option.PidField) {
return gerror.New("树表必须包含`pid`字段")
}
if !slices.Contains(fields, option.LevelField) {
return gerror.New("树表必须包含`level`字段")
}
if !slices.Contains(fields, option.TreeField) {
return gerror.New("树表必须包含`tree`字段")
}
return
}
// updateChildrenTree 更新下级关系树
func updateChildrenTree(ctx context.Context, dao daoInstance, pid int64, pLevel int, pTree string, option *TreeFiledOption) (err error) {
var list []*gormmodel.DefaultTree
// var pdMaps []map[string]interface{}
pdMaps, err := dao.Ctx(ctx).Fields(option.IdField, option.PidField, option.LevelField, option.TreeField).Where(option.PidField, pid).All()
if err != nil {
return err
}
for _, v := range pdMaps {
if v == nil {
continue
}
pb := CustomTreeFiledToDefault(v.Map(), option)
if pb.Id == 0 {
g.Log().Warningf(ctx, "updateChildrenTree pb.Id is 0, pb: %+v, option: %+v, v: %+v, pdMaps: %+v", pb, option, v, pdMaps)
continue
}
// g.Log().Debugf(ctx, "updateChildrenTree pb: %+v", pb)
list = append(list, pb)
}
if len(list) == 0 {
return
}
newLevel := pLevel + 1
newTree := tree.GenLabel(pTree, pid)
var updateIds []int64
for _, v := range list {
updateIds = append(updateIds, v.Id)
if err = updateChildrenTree(ctx, dao, v.Id, newLevel, newTree, option); err != nil {
return
}
}
if len(updateIds) > 0 {
update := g.Map{
option.LevelField: newLevel,
option.TreeField: newTree,
}
_, err = dao.Ctx(ctx).WhereIn(option.IdField, updateIds).Data(update).Update()
}
return
}
@@ -0,0 +1,14 @@
// package gormmodel
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package gormmodel
// DefaultTree 默认树表字段
type DefaultTree struct {
Id int64 `json:"id" description:"ID"`
Pid int64 `json:"pid" description:"父ID"`
Level int `json:"level" description:"关系树等级"`
Tree string `json:"tree" description:"关系树"`
}
@@ -0,0 +1,231 @@
// package handler
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package handler
import (
"context"
"fmt"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/consts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/model/entity"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/text/gstr"
"github.com/gogf/gf/v2/util/gconv"
)
// FilterAuth 过滤数据权限
// 通过上下文中的用户角色权限和表中是否含有需要过滤的字段附加查询条件
func FilterAuth(m *gdb.Model) *gdb.Model {
var (
ctx = m.GetCtx()
fields = convert.EscapeFieldsToSlice(m.GetFieldsStr())
)
// 超管拥有全部权限
if contexts.IsSuperAdmin(ctx) {
return m
}
// 获取用户信息
user := contexts.GetUser(ctx)
if user == nil {
return m
}
if len(user.Roles) == 0 {
return m.Where("1 = 0")
}
deptIds := make([]int64, 0)
userIds := make([]int64, 0)
// 是否进入了非「全部数据」的受限范围;解析结果为空时必须拒绝,不能漏过滤
hasRestrictedScope := false
currentRoleId := ""
currentRoleDataScope := ""
currentRoleIdIf := contexts.GetDataValue(ctx, "currentRoleId")
if currentRoleIdIf != nil {
currentRoleId = currentRoleIdIf.(string)
}
currentRoleDataScopeIf := contexts.GetDataValue(ctx, "currentRoleDataScope")
if currentRoleDataScopeIf != nil {
currentRoleDataScope = currentRoleDataScopeIf.(string)
}
if len(currentRoleDataScope) > 0 && len(currentRoleId) > 0 {
dataScope := currentRoleDataScope
roleId := gconv.Int64(currentRoleId)
g.Log().Infof(ctx, "uid:%d, currentRoleId: %d, currentRoleDataScope: %s", user.ID, roleId, dataScope)
if dataScope == consts.SysRoleDataScopeAll { // 角色数据范围: 1全部数据权限
return m
}
hasRestrictedScope = true
if dataScope == consts.SysRoleDataScopeCustom { // 角色数据范围: 2自定数据权限
var roleDepts []*entity.SysRoleDept
err := g.Model("sys_role_dept").Where("tenant_id", contexts.GetTenantId(ctx)).Where("role_id", roleId).Scan(&roleDepts)
if err != nil {
g.Log().Panicf(ctx, "failed to get role dept data err:%+v", err)
}
for _, roleDept := range roleDepts {
deptIds = append(deptIds, roleDept.DeptId)
}
} else if dataScope == consts.SysRoleDataScopeDept { // 角色数据范围: 3本部门数据权限
deptIds = append(deptIds, user.DeptId)
} else if dataScope == consts.SysRoleDataScopeDeptAndBelow { // 角色数据范围: 4本部门及以下数据权限
deptIds = append(deptIds, GetDeptAndSub(ctx, user.DeptId)...)
} else if dataScope == consts.SysRoleDataScopePersonal { // 角色数据范围: 5仅本人数据权限
userIds = append(userIds, user.ID)
} else if dataScope == consts.SysRoleDataScopeDeptAndBelowOrPersonal { // 角色数据范围: 6本部门及以下或本人数据权限
deptIds = append(deptIds, GetDeptAndSub(ctx, user.DeptId)...)
userIds = append(userIds, user.ID)
} else if dataScope == consts.SysRoleDataScopeOrgAndNextLevel { // 角色数据范围: 7本组织及本组织下一级数据权限
deptIds = append(deptIds, GetOrgAndNextLevelDeptIds(ctx, user.DeptId)...)
} else if dataScope == consts.SysRoleDataScopeOrgNextLevel { // 角色数据范围: 8本组织下一级数据权限
deptIds = append(deptIds, GetOrgNextLevelDeptIds(ctx, user.DeptId)...)
}
} else {
for _, role := range user.Roles {
dataScope := role.DataScope
roleId := role.RoleId
if dataScope == consts.SysRoleDataScopeAll { // 角色数据范围: 1全部数据权限
return m
} else if dataScope == consts.SysRoleDataScopeCustom { // 角色数据范围: 2自定数据权限
hasRestrictedScope = true
var roleDepts []*entity.SysRoleDept
err := g.Model("sys_role_dept").Where("tenant_id", contexts.GetTenantId(ctx)).Where("role_id", roleId).Scan(&roleDepts)
if err != nil {
g.Log().Panicf(ctx, "failed to get role dept data err:%+v", err)
}
for _, roleDept := range roleDepts {
deptIds = append(deptIds, roleDept.DeptId)
}
} else if dataScope == consts.SysRoleDataScopeDept { // 角色数据范围: 3本部门数据权限
hasRestrictedScope = true
deptIds = append(deptIds, user.DeptId)
} else if dataScope == consts.SysRoleDataScopeDeptAndBelow { // 角色数据范围: 4本部门及以下数据权限
hasRestrictedScope = true
deptIds = append(deptIds, GetDeptAndSub(ctx, user.DeptId)...)
} else if dataScope == consts.SysRoleDataScopePersonal { // 角色数据范围: 5仅本人数据权限
hasRestrictedScope = true
userIds = append(userIds, user.ID)
} else if dataScope == consts.SysRoleDataScopeDeptAndBelowOrPersonal { // 角色数据范围: 6本部门及以下或本人数据权限
hasRestrictedScope = true
deptIds = append(deptIds, GetDeptAndSub(ctx, user.DeptId)...)
userIds = append(userIds, user.ID)
} else if dataScope == consts.SysRoleDataScopeOrgAndNextLevel { // 角色数据范围: 7本组织及本组织下一级数据权限
hasRestrictedScope = true
deptIds = append(deptIds, GetOrgAndNextLevelDeptIds(ctx, user.DeptId)...)
} else if dataScope == consts.SysRoleDataScopeOrgNextLevel { // 角色数据范围: 8本组织下一级数据权限
hasRestrictedScope = true
deptIds = append(deptIds, GetOrgNextLevelDeptIds(ctx, user.DeptId)...)
}
}
}
deptFilterField := ""
if gstr.InArray(fields, "dept_id") {
deptFilterField = "dept_id"
} else if gstr.InArray(fields, "created_dept") {
deptFilterField = "created_dept"
}
userFilterField := ""
if gstr.InArray(fields, "user_id") {
userFilterField = "user_id"
} else if gstr.InArray(fields, "created_by") {
userFilterField = "created_by"
}
if len(deptIds) > 0 && len(userIds) > 0 {
if deptFilterField != "" && userFilterField != "" {
m = m.Where(deptFilterField+" IN (?) OR "+userFilterField+" IN (?)", deptIds, userIds)
} else if deptFilterField != "" {
m = m.WhereIn(deptFilterField, deptIds)
} else if userFilterField != "" {
m = m.WhereIn(userFilterField, userIds)
}
} else if len(deptIds) > 0 && deptFilterField != "" {
m = m.WhereIn(deptFilterField, deptIds)
} else if len(userIds) > 0 && userFilterField != "" {
m = m.WhereIn(userFilterField, userIds)
} else if hasRestrictedScope && (deptFilterField != "" || userFilterField != "") {
// 例:范围8无下级部门、范围2未配自定义部门 → 可见集合为空,禁止放行全表
g.Log().Warningf(ctx, "FilterAuth empty scope result, deny all data. uid:%d, deptIds:%v, userIds:%v", user.ID, deptIds, userIds)
m = m.Where("1 = 0")
}
return m
}
// GetParentCompanyDeptId 获取用户所属上级公司组织ID
func GetParentCompanyDeptId(ctx context.Context, deptId int64) int64 {
if deptId <= 0 {
return deptId
}
var dept entity.SysDept
err := g.Model("sys_dept").Where("dept_id", deptId).Scan(&dept)
if err != nil || dept.DeptId == 0 {
return deptId
}
if dept.ParentId == 0 || dept.DeptType == 1 {
return dept.DeptId
}
return GetParentCompanyDeptId(ctx, dept.ParentId)
}
// GetDeptDirectSub 获取指定部门的直接下级部门ID
func GetDeptDirectSub(ctx context.Context, deptId int64) (ids []int64) {
array, err := g.Model("sys_dept").
Where("parent_id", deptId).
Fields("dept_id").
Array()
if err != nil {
g.Log().Panicf(ctx, "GetDeptDirectSub err:%+v", err)
return
}
for _, v := range array {
ids = append(ids, v.Int64())
}
return
}
// GetOrgAndNextLevelDeptIds 获取本组织及本组织下一级数据权限范围:所属公司组织及直接下级
func GetOrgAndNextLevelDeptIds(ctx context.Context, deptId int64) (ids []int64) {
orgDeptId := GetParentCompanyDeptId(ctx, deptId)
ids = append(ids, orgDeptId)
ids = append(ids, GetDeptDirectSub(ctx, orgDeptId)...)
return
}
// GetOrgNextLevelDeptIds 获取本组织下一级数据权限范围:所属公司组织的直接下级
func GetOrgNextLevelDeptIds(ctx context.Context, deptId int64) (ids []int64) {
orgDeptId := GetParentCompanyDeptId(ctx, deptId)
return GetDeptDirectSub(ctx, orgDeptId)
}
// GetDeptAndSub 获取指定部门的所有下级,含本部门
//
// ancestors 的平台约定格式以逗号结尾(如 "0,1,172,"),但历史数据里存在
// 由代理建企业路径写入的缺尾逗号形态(如 "0,1,172")。两种都要匹配,
// 否则这些机构的下级会在「本机构及以下」数据权限展开时被整体漏掉,
// 表现为上级机构看不到下级(例:一级代理选不到划拨目标客户)。
func GetDeptAndSub(ctx context.Context, deptId int64) (ids []int64) {
// likeTail = "%,172"(机构在祖先链末尾)、likeInner = "%,172,%"(机构在祖先链中间)
likeTail := fmt.Sprintf("%%,%d", deptId)
array, err := g.Model("sys_dept").
Where("(ancestors LIKE ? OR ancestors LIKE ?)", likeTail+",%", likeTail).
Fields("dept_id").
Array()
if err != nil {
g.Log().Panicf(ctx, "GetDeptAndSub err:%+v", err)
return
}
for _, v := range array {
ids = append(ids, v.Int64())
}
ids = append(ids, deptId)
return
}
@@ -0,0 +1,13 @@
// package handler
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package handler
import "github.com/gogf/gf/v2/database/gdb"
// ForceCache 强制缓存
func ForceCache(m *gdb.Model) *gdb.Model {
return m.Cache(gdb.CacheOption{Duration: 0, Force: true})
}
@@ -0,0 +1,40 @@
// package handler
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package handler
import "github.com/gogf/gf/v2/database/gdb"
// Option 预处理选项
type Option struct {
FilterAuth bool // 过滤权限
ForceCache bool // 强制缓存
FilterTenant bool // 过滤多租户权限
}
// DefaultOption 默认预处理选项
var DefaultOption = &Option{
FilterAuth: true,
FilterTenant: true,
}
func Model(m *gdb.Model, opt ...*Option) *gdb.Model {
var option *Option
if len(opt) > 0 {
option = opt[0]
} else {
option = DefaultOption
}
if option.FilterAuth {
m = m.Handler(FilterAuth)
}
if option.ForceCache {
m = m.Handler(ForceCache)
}
if option.FilterTenant {
m = m.Handler(FilterTenant)
}
return m
}
@@ -0,0 +1,27 @@
// package handler
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package handler
import (
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/internal/library/contexts"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility/convert"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/text/gstr"
)
// FilterTenant 过滤多租户数据权限
func FilterTenant(m *gdb.Model) *gdb.Model {
var (
ctx = m.GetCtx()
fields = convert.EscapeFieldsToSlice(m.GetFieldsStr())
)
// 如果包含tenant_id字段,则过滤多租户数据权限
if gstr.InArray(fields, "tenant_id") {
m = m.Where("tenant_id", contexts.GetTenantId(ctx))
}
return m
}
@@ -0,0 +1,118 @@
// package hook
// @Link https://github.com/xiujiecn/xiu-admin
// @Copyright Copyright (c) 2025 LiXiujie
// @Author Lxj <xxx@example.com>
// @License https://github.com/xiujiecn/xiu-admin/blob/master/LICENSE
package hook
import (
"context"
"git.5iot.com/iotlcm-public/iotlcm-admin-svr/utility"
"github.com/gogf/gf/v2/container/gvar"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
)
type MemberSumma struct {
UserId int64 `json:"userId" orm:"user_id" description:"用户ID"`
UserName string `json:"userName" orm:"user_name" description:"用户账号"`
NickName string `json:"nickName" orm:"nick_name" description:"用户昵称"`
Avatar string `json:"avatar" orm:"avatar" description:"头像地址"`
TenantId string `json:"tenantId" orm:"tenant_id" description:"租户编号"`
DeptId int64 `json:"deptId" orm:"dept_id" description:"部门ID"`
}
// MemberSummary 操作人摘要信息
var MemberSummary = gdb.HookHandler{
Select: func(ctx context.Context, in *gdb.HookSelectInput) (result gdb.Result, err error) {
result, err = in.Next(ctx)
if err != nil {
return
}
var (
createdByIds []int64
updatedByIds []int64
deletedByIds []int64
memberIds []int64
)
for _, record := range result {
if record["created_by"].Int64() > 0 {
createdByIds = append(createdByIds, record["created_by"].Int64())
}
if record["updated_by"].Int64() > 0 {
updatedByIds = append(updatedByIds, record["updated_by"].Int64())
}
if record["deleted_by"].Int64() > 0 {
deletedByIds = append(deletedByIds, record["deleted_by"].Int64())
}
if record["user_id"].Int64() > 0 {
memberIds = append(memberIds, record["user_id"].Int64())
}
}
memberIds = append(memberIds, createdByIds...)
memberIds = append(memberIds, updatedByIds...)
memberIds = append(memberIds, deletedByIds...)
memberIds = utility.UniqueSlice(memberIds)
if len(memberIds) == 0 {
return
}
var members []*MemberSumma
if err = g.Model("sys_user").Ctx(ctx).WhereIn("user_id", memberIds).Scan(&members); err != nil {
g.Log().Errorf(ctx, "[MemberSummary] 查询用户信息失败: %v", err)
return nil, err
}
if len(members) == 0 {
g.Log().Warningf(ctx, "[MemberSummary] 没有找到任何用户信息,用户IDs: %v", memberIds)
return
}
findMember := func(id *gvar.Var) *MemberSumma {
userId := id.Int64()
for _, v := range members {
if v.UserId == userId {
return v
}
}
// 只在找不到用户时记录警告(避免日志过多)
g.Log().Warningf(ctx, "[MemberSummary] 未找到用户ID: %d", userId)
return nil
}
for _, record := range result {
if record["created_by"].Int64() > 0 {
member := findMember(record["created_by"])
if member != nil {
record["createdBySumma"] = gvar.New(member)
}
}
if record["updated_by"].Int64() > 0 {
member := findMember(record["updated_by"])
if member != nil {
record["updatedBySumma"] = gvar.New(member)
}
}
if record["deleted_by"].Int64() > 0 {
member := findMember(record["deleted_by"])
if member != nil {
record["deletedBySumma"] = gvar.New(member)
}
}
if record["user_id"].Int64() > 0 {
member := findMember(record["user_id"])
if member != nil {
record["memberBySumma"] = gvar.New(member)
}
}
}
return
},
}