package system import ( "context" "encoding/json" "errors" "fmt" "kra/internal/biz/system" "strconv" "strings" "time" "gorm.io/gorm" "gorm.io/gorm/clause" ) type exportTemplatePO struct { ID uint `gorm:"primaryKey"` CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` DBName string Name string DBTableName string `gorm:"column:table_name"` TemplateID string TemplateInfo string `gorm:"type:text"` SQL string `gorm:"type:text"` ImportSQL string `gorm:"type:text"` Limit *int Order string } func (exportTemplatePO) TableName() string { return "sys_export_templates" } type exportConditionPO struct { ID uint `gorm:"primaryKey"` CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` TemplateID string `gorm:"index"` From string Column string Operator string } func (exportConditionPO) TableName() string { return "sys_export_template_condition" } type exportJoinPO struct { ID uint `gorm:"primaryKey"` CreatedAt time.Time UpdatedAt time.Time DeletedAt gorm.DeletedAt `gorm:"index"` TemplateID string `gorm:"index"` Join string `gorm:"column:joins"` Table string On string `gorm:"column:on"` } func (exportJoinPO) TableName() string { return "sys_export_template_join" } type exportRepo struct{ data Provider } func NewExportRepo(data Provider) system.ExportRepo { return &exportRepo{data: data} } func exportFromPO(po exportTemplatePO, conditions []exportConditionPO, joins []exportJoinPO) *system.ExportTemplate { v := &system.ExportTemplate{ID: po.ID, CreatedAt: po.CreatedAt, UpdatedAt: po.UpdatedAt, DBName: po.DBName, Name: po.Name, TableName: po.DBTableName, TemplateID: po.TemplateID, TemplateInfo: po.TemplateInfo, SQL: po.SQL, ImportSQL: po.ImportSQL, Limit: po.Limit, Order: po.Order} if conditions != nil { v.Conditions = make([]system.ExportCondition, 0, len(conditions)) } for _, x := range conditions { v.Conditions = append(v.Conditions, system.ExportCondition{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, From: x.From, Column: x.Column, Operator: x.Operator}) } if joins != nil { v.Joins = make([]system.ExportJoin, 0, len(joins)) } for _, x := range joins { v.Joins = append(v.Joins, system.ExportJoin{ID: x.ID, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: x.TemplateID, Join: x.Join, Table: x.Table, On: x.On}) } return v } func exportToPO(v *system.ExportTemplate) exportTemplatePO { return exportTemplatePO{ID: v.ID, DBName: v.DBName, Name: v.Name, DBTableName: v.TableName, TemplateID: v.TemplateID, TemplateInfo: v.TemplateInfo, SQL: v.SQL, ImportSQL: v.ImportSQL, Limit: v.Limit, Order: v.Order} } func (r *exportRepo) saveRelations(tx *gorm.DB, v *system.ExportTemplate, resetIDs, replace, forceTemplateID bool) error { if replace { if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportConditionPO{}).Error; err != nil { return err } if err := tx.Where("template_id = ?", v.TemplateID).Delete(&exportJoinPO{}).Error; err != nil { return err } } conditions := make([]exportConditionPO, 0, len(v.Conditions)) for _, x := range v.Conditions { id := x.ID if resetIDs { id = 0 } templateID := x.TemplateID // GORM's has-many association on Create always writes the parent's // TemplateID into the child foreign key (references:TemplateID). // Preserve that observable behavior for creates; updates intentionally // keep the request's value after the compatible update deletes and // recreates relations. if forceTemplateID || templateID == "" { templateID = v.TemplateID } conditions = append(conditions, exportConditionPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, From: x.From, Column: x.Column, Operator: x.Operator}) } if len(conditions) > 0 { create := tx if !resetIDs { // GORM saves Create associations with ON CONFLICT DO NOTHING. // Preserve that behavior for copied templates carrying relation IDs. create = create.Clauses(clause.OnConflict{DoNothing: true}) } if err := create.Create(&conditions).Error; err != nil { return err } } joins := make([]exportJoinPO, 0, len(v.Joins)) for _, x := range v.Joins { id := x.ID if resetIDs { id = 0 } templateID := x.TemplateID if forceTemplateID || templateID == "" { templateID = v.TemplateID } joins = append(joins, exportJoinPO{ID: id, CreatedAt: x.CreatedAt, UpdatedAt: x.UpdatedAt, TemplateID: templateID, Join: x.Join, Table: x.Table, On: x.On}) } if len(joins) > 0 { create := tx if !resetIDs { create = create.Clauses(clause.OnConflict{DoNothing: true}) } return create.Create(&joins).Error } return nil } func (r *exportRepo) CreateExportTemplate(ctx context.Context, v *system.ExportTemplate) error { return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { po := exportToPO(v) if err := tx.Create(&po).Error; err != nil { return err } v.ID = po.ID return r.saveRelations(tx, v, false, false, true) }) } func (r *exportRepo) UpdateExportTemplate(ctx context.Context, v *system.ExportTemplate) error { return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { po := exportToPO(v) if err := tx.Model(&exportTemplatePO{}).Where("id = ?", v.ID).Updates(&po).Error; err != nil { return err } return r.saveRelations(tx, v, true, true, false) }) } func (r *exportRepo) DeleteExportTemplates(ctx context.Context, ids []uint) error { return r.data.DB().WithContext(ctx).Delete(&[]exportTemplatePO{}, "id IN ?", ids).Error } func (r *exportRepo) FindExportTemplate(ctx context.Context, id uint, tid string) (*system.ExportTemplate, error) { var po exportTemplatePO db := r.data.DB().WithContext(ctx) var err error if tid != "" { err = db.Where("template_id = ?", tid).First(&po).Error } else { err = db.Where("id = ?", id).First(&po).Error } if err != nil { return nil, err } var conditions []exportConditionPO var joins []exportJoinPO if err = db.Where("template_id = ?", po.TemplateID).Find(&conditions).Error; err != nil { return nil, err } if err = db.Where("template_id = ?", po.TemplateID).Find(&joins).Error; err != nil { return nil, err } return exportFromPO(po, conditions, joins), nil } func (r *exportRepo) ListExportTemplates(ctx context.Context, page, size int, q *system.ExportTemplate) ([]*system.ExportTemplate, int64, error) { db := r.data.DB().WithContext(ctx).Model(&exportTemplatePO{}) if q != nil { if q.StartCreatedAt != nil && q.EndCreatedAt != nil { db = db.Where("created_at BETWEEN ? AND ?", q.StartCreatedAt, q.EndCreatedAt) } if q.Name != "" { db = db.Where("name LIKE ?", "%"+q.Name+"%") } if q.TableName != "" { db = db.Where("table_name = ?", q.TableName) } if q.TemplateID != "" { db = db.Where("template_id = ?", q.TemplateID) } } var total int64 if err := db.Count(&total).Error; err != nil { return nil, 0, err } var pos []exportTemplatePO // This module applies pageSize and its raw offset formula verbatim instead // of using the shared PageInfo normalization. if size != 0 { db = db.Limit(size).Offset(size * (page - 1)) } if err := db.Find(&pos).Error; err != nil { return nil, 0, err } out := make([]*system.ExportTemplate, 0, len(pos)) for _, po := range pos { out = append(out, exportFromPO(po, nil, nil)) } return out, total, nil } func (r *exportRepo) QueryExport(ctx context.Context, t *system.ExportTemplate, params map[string]string) ([]map[string]any, string, error) { selected, err := r.data.Database(t.DBName) if err != nil { return nil, "", err } db := selected.WithContext(ctx) var rows []map[string]any if t.SQL != "" { args := map[string]any{} for k, v := range params { args[k] = v } err := db.Raw(t.SQL, args).Scan(&rows).Error return rows, t.SQL, err } columns := make([]string, 0) if err := json.Unmarshal([]byte(t.TemplateInfo), &map[string]string{}); err != nil { return nil, "", err } decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo)) _, _ = decoder.Token() for decoder.More() { key, _ := decoder.Token() columns = append(columns, key.(string)) var ignored any _ = decoder.Decode(&ignored) } if len(columns) == 0 { return nil, "", errors.New("模板列为空") } query := db.Table(t.TableName).Select(strings.Join(columns, ",")) for _, j := range t.Joins { query = query.Joins(j.Join + " " + j.Table + " ON " + j.On) } if params["filterDeleted"] == "true" { query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", t.TableName)) for _, join := range t.Joins { if db.Migrator().HasColumn(join.Table, "deleted_at") { query = query.Where(fmt.Sprintf("%s.deleted_at IS NULL", join.Table)) } } } for _, condition := range t.Conditions { operator := condition.Operator value := params[condition.From] switch operator { case "LIKE": if value != "" { query = query.Where(condition.Column+" LIKE ?", "%"+value+"%") } case "IN", "NOT IN": if value != "" { query = query.Where(condition.Column+" "+operator+" (?)", value) } case "BETWEEN": start, end := params["start"+condition.From], params["end"+condition.From] if start != "" && end != "" { query = query.Where(condition.Column+" BETWEEN ? AND ?", start, end) } default: if value != "" { query = query.Where(condition.Column+" "+operator+" ?", value) } } } limit := 0 if raw := params["limit"]; raw != "" { limit, _ = strconv.Atoi(raw) } else if t.Limit != nil { limit = *t.Limit } if limit > 0 { query = query.Limit(limit) } if raw := params["offset"]; raw != "" { if offset, parseErr := strconv.Atoi(raw); parseErr == nil { query = query.Offset(offset) } } order := params["order"] if order == "" { order = t.Order } if order != "" { parts := strings.Split(order, " ") fields := map[string]bool{} columnTypes, columnErr := db.Migrator().ColumnTypes(t.TableName) if columnErr != nil { return nil, "", columnErr } for _, column := range columnTypes { fields[column.Name()] = true } if !fields[parts[0]] { return nil, "", fmt.Errorf("order by %s is not in the fields", order) } orderSQL := parts[0] if len(parts) > 1 { if parts[1] != "asc" && parts[1] != "desc" { return nil, "", fmt.Errorf("order by %s is not secure", order) } orderSQL += " " + parts[1] } query = query.Order(orderSQL) } preview := query.ToSQL(func(tx *gorm.DB) *gorm.DB { return tx.Find(&[]map[string]any{}) }) err = query.Find(&rows).Error return rows, preview, err } func (r *exportRepo) PreviewExport(ctx context.Context, t *system.ExportTemplate, params map[string]string) (string, error) { columns := make([]string, 0) decoder := json.NewDecoder(strings.NewReader(t.TemplateInfo)) if token, err := decoder.Token(); err != nil || token != json.Delim('{') { return "", err } for decoder.More() { key, err := decoder.Token() if err != nil { return "", err } columns = append(columns, key.(string)) var ignored any if err = decoder.Decode(&ignored); err != nil { return "", err } } var builder strings.Builder builder.WriteString("SELECT ") builder.WriteString(strings.Join(columns, ", ")) builder.WriteString(" FROM ") builder.WriteString(t.TableName) for _, join := range t.Joins { builder.WriteString(" " + join.Join + " " + join.Table + " ON " + join.On) } wheres := make([]string, 0) if params["filterDeleted"] == "true" { wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", t.TableName)) selected, err := r.data.Database(t.DBName) if err != nil { return "", err } db := selected.WithContext(ctx) for _, join := range t.Joins { if db.Migrator().HasColumn(join.Table, "deleted_at") { wheres = append(wheres, fmt.Sprintf("%s.deleted_at IS NULL", join.Table)) } } } for _, condition := range t.Conditions { op, column, value := strings.ToUpper(strings.TrimSpace(condition.Operator)), strings.TrimSpace(condition.Column), params[condition.From] switch op { case "BETWEEN": start, end := params["start"+condition.From], params["end"+condition.From] if start != "" && end != "" { wheres = append(wheres, fmt.Sprintf("%s BETWEEN '%s' AND '%s'", column, start, end)) } else { wheres = append(wheres, fmt.Sprintf("%s BETWEEN {start%s} AND {end%s}", column, condition.From, condition.From)) } case "IN", "NOT IN": if value != "" { parts := strings.Split(value, ",") for index := range parts { parts[index] = strings.TrimSpace(parts[index]) } wheres = append(wheres, fmt.Sprintf("%s %s ('%s')", column, op, strings.Join(parts, "','"))) } else { wheres = append(wheres, fmt.Sprintf("%s %s ({%s})", column, op, condition.From)) } case "LIKE": if value != "" { wheres = append(wheres, fmt.Sprintf("%s LIKE '%%%s%%'", column, value)) } else { wheres = append(wheres, fmt.Sprintf("%s LIKE {%%%s%%}", column, condition.From)) } default: if value != "" { wheres = append(wheres, fmt.Sprintf("%s %s '%s'", column, op, value)) } else { wheres = append(wheres, fmt.Sprintf("%s %s {%s}", column, op, condition.From)) } } } if len(wheres) > 0 { builder.WriteString(" WHERE " + strings.Join(wheres, " AND ")) } order := params["order"] if order == "" { order = t.Order } if order != "" { builder.WriteString(" ORDER BY " + order) } limitRaw, offsetRaw := params["limit"], params["offset"] if limitRaw == "" && t.Limit != nil && *t.Limit != 0 { limitRaw = strconv.Itoa(*t.Limit) } limit, _ := strconv.Atoi(limitRaw) offset, _ := strconv.Atoi(offsetRaw) if limit > 0 { builder.WriteString(" LIMIT " + strconv.Itoa(limit)) if offset > 0 { builder.WriteString(" OFFSET " + strconv.Itoa(offset)) } } else if offset > 0 { builder.WriteString(" OFFSET " + strconv.Itoa(offset)) } return builder.String(), nil } func parseTemplateColumns(raw string) map[string]string { out := map[string]string{} parts := strings.Split(strings.Trim(strings.TrimSpace(raw), "{}"), ",") for _, part := range parts { pair := strings.SplitN(part, ":", 2) if len(pair) == 2 { out[strings.Trim(strings.TrimSpace(pair[0]), `"`)] = strings.Trim(strings.TrimSpace(pair[1]), `"`) } } return out } func (r *exportRepo) ImportExportRows(ctx context.Context, t *system.ExportTemplate, rows []map[string]any) error { // ImportSQL is checked verbatim. In particular, a whitespace-only value // is still treated as custom SQL and is allowed to return the driver's // native error instead of silently falling back to GORM insertion. sql := t.ImportSQL selected, err := r.data.Database(t.DBName) if err != nil { return err } return selected.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if sql != "" { for _, row := range rows { if err := tx.Exec(sql, row).Error; err != nil { return err } } return nil } needCreated := tx.Migrator().HasColumn(t.TableName, "created_at") needUpdated := tx.Migrator().HasColumn(t.TableName, "updated_at") for _, row := range rows { if row["created_at"] == nil && needCreated { row["created_at"] = time.Now() } if row["updated_at"] == nil && needUpdated { row["updated_at"] = time.Now() } } return tx.Table(t.TableName).CreateInBatches(&rows, 1000).Error }) }