feat: init project
This commit is contained in:
41
.air.toml
Normal file
41
.air.toml
Normal file
@@ -0,0 +1,41 @@
|
||||
# Config file for [Air](https://github.com/cosmtrek/air) in TOML format
|
||||
|
||||
# Working directory
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
# Just plain old shell command. You could use `make` as well.
|
||||
args_bin = []
|
||||
bin = "./bin/database-render"
|
||||
cmd = "make build"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "node_modules", "web/static", "dist"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html", "yaml", "yml", "json"]
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
send_interrupt = false
|
||||
stop_on_root = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
55
.dockerignore
Normal file
55
.dockerignore
Normal file
@@ -0,0 +1,55 @@
|
||||
# Build artifacts
|
||||
bin/
|
||||
dist/
|
||||
tmp/
|
||||
|
||||
# Dependencies
|
||||
vendor/
|
||||
node_modules/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git/
|
||||
.gitignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
docs/
|
||||
|
||||
# Test files
|
||||
*_test.go
|
||||
.coverage/
|
||||
coverage.out
|
||||
coverage.html
|
||||
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Database files (for development)
|
||||
data/*.db
|
||||
data/*.sqlite
|
||||
data/*.sqlite3
|
||||
|
||||
# Configuration (keep example configs)
|
||||
config/config.yaml
|
||||
config/local.yaml
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -25,3 +25,4 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
bin/
|
||||
|
||||
57
Dockerfile
Normal file
57
Dockerfile
Normal file
@@ -0,0 +1,57 @@
|
||||
# Multi-stage build for Database Render Application
|
||||
FROM golang:1.21-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apk add --no-cache git ca-certificates tzdata
|
||||
|
||||
# Copy go mod files
|
||||
COPY go.mod go.sum ./
|
||||
|
||||
# Download dependencies
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o database-render ./cmd/server
|
||||
|
||||
# Final stage
|
||||
FROM alpine:latest
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1000 -S appgroup && \
|
||||
adduser -u 1000 -S appuser -G appgroup
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/database-render .
|
||||
|
||||
# Copy configuration files
|
||||
COPY --from=builder /app/config ./config
|
||||
COPY --from=builder /app/web ./web
|
||||
|
||||
# Create data directory for SQLite database
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R appuser:appgroup /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8080
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
|
||||
|
||||
# Run the application
|
||||
CMD ["./database-render"]
|
||||
240
Makefile
Normal file
240
Makefile
Normal file
@@ -0,0 +1,240 @@
|
||||
# Database Render Application Makefile
|
||||
|
||||
# Variables
|
||||
BINARY_NAME=database-render
|
||||
APP_NAME=database-render
|
||||
VERSION?=latest
|
||||
BUILD_TIME=$(shell date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
GIT_COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
LDFLAGS=-ldflags "-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME) -X main.GitCommit=$(GIT_COMMIT)"
|
||||
|
||||
# Go parameters
|
||||
GOCMD=go
|
||||
GOBUILD=$(GOCMD) build
|
||||
GOCLEAN=$(GOCMD) clean
|
||||
GOTEST=$(GOCMD) test
|
||||
GOGET=$(GOCMD) get
|
||||
GOMOD=$(GOCMD) mod
|
||||
GOFMT=gofmt
|
||||
GOFMT_CHECK=$(GOFMT) -l
|
||||
GOFMT_FMT=$(GOFMT) -w
|
||||
|
||||
# Directories
|
||||
BIN_DIR=bin
|
||||
DIST_DIR=dist
|
||||
CONFIG_DIR=config
|
||||
WEB_DIR=web
|
||||
TEMPLATES_DIR=web/templates
|
||||
STATIC_DIR=web/static
|
||||
|
||||
# Default target
|
||||
.PHONY: all
|
||||
all: clean build
|
||||
|
||||
# Build the application
|
||||
.PHONY: build
|
||||
build: deps
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@mkdir -p $(BIN_DIR)
|
||||
CGO_ENABLED=1 $(GOBUILD) $(LDFLAGS) -o $(BIN_DIR)/$(BINARY_NAME) ./cmd/server
|
||||
@echo "Build completed: $(BIN_DIR)/$(BINARY_NAME)"
|
||||
|
||||
# Build for multiple platforms
|
||||
.PHONY: build-all
|
||||
build-all: clean deps
|
||||
@echo "Building for multiple platforms..."
|
||||
@mkdir -p $(DIST_DIR)
|
||||
# Linux AMD64 with CGO
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/server
|
||||
# Linux ARM64 with CGO
|
||||
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-linux-arm64 ./cmd/server
|
||||
# macOS AMD64 with CGO
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/server
|
||||
# macOS ARM64 with CGO
|
||||
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/server
|
||||
# Windows AMD64 with CGO (requires mingw-w64)
|
||||
CGO_ENABLED=1 GOOS=windows GOARCH=amd64 CC=x86_64-w64-mingw32-gcc $(GOBUILD) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/server
|
||||
@echo "Multi-platform build completed in $(DIST_DIR)/"
|
||||
|
||||
# Install dependencies
|
||||
.PHONY: deps
|
||||
deps:
|
||||
@echo "Installing dependencies..."
|
||||
$(GOMOD) tidy
|
||||
$(GOMOD) download
|
||||
|
||||
# Clean build artifacts
|
||||
.PHONY: clean
|
||||
clean:
|
||||
@echo "Cleaning..."
|
||||
$(GOCLEAN)
|
||||
rm -rf $(BIN_DIR)
|
||||
rm -rf $(DIST_DIR)
|
||||
@echo "Clean completed"
|
||||
|
||||
# Run tests
|
||||
.PHONY: test
|
||||
test:
|
||||
@echo "Running tests..."
|
||||
$(GOTEST) -v ./...
|
||||
|
||||
# Run tests with coverage
|
||||
.PHONY: test-coverage
|
||||
test-coverage:
|
||||
@echo "Running tests with coverage..."
|
||||
$(GOTEST) -v -coverprofile=coverage.out ./...
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
# Format code
|
||||
.PHONY: fmt
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
$(GOFMT_FMT) .
|
||||
|
||||
# Check code formatting
|
||||
.PHONY: fmt-check
|
||||
fmt-check:
|
||||
@echo "Checking code formatting..."
|
||||
@if [ -n "$$($(GOFMT_CHECK) .)" ]; then \
|
||||
echo "Code is not formatted. Please run 'make fmt' to format the code."; \
|
||||
exit 1; \
|
||||
fi
|
||||
@echo "Code formatting is correct"
|
||||
|
||||
# Lint code
|
||||
.PHONY: lint
|
||||
lint:
|
||||
@echo "Running linter..."
|
||||
@if command -v golangci-lint >/dev/null 2>&1; then \
|
||||
golangci-lint run; \
|
||||
else \
|
||||
echo "golangci-lint not found. Please install it: https://golangci-lint.run/"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Security scan
|
||||
.PHONY: security
|
||||
security:
|
||||
@echo "Running security scan..."
|
||||
@if command -v gosec >/dev/null 2>&1; then \
|
||||
gosec ./...; \
|
||||
else \
|
||||
echo "gosec not found. Please install it: go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Run the application
|
||||
.PHONY: run
|
||||
run: build
|
||||
@echo "Starting $(BINARY_NAME)..."
|
||||
./$(BIN_DIR)/$(BINARY_NAME)
|
||||
|
||||
# Run with development configuration
|
||||
.PHONY: dev
|
||||
dev: build
|
||||
@echo "Starting $(BINARY_NAME) in development mode..."
|
||||
CONFIG_FILE=config/config.yaml ./$(BIN_DIR)/$(BINARY_NAME)
|
||||
|
||||
# Create sample configuration
|
||||
.PHONY: init-config
|
||||
init-config:
|
||||
@echo "Creating sample configuration..."
|
||||
@mkdir -p $(CONFIG_DIR)
|
||||
@if [ ! -f $(CONFIG_DIR)/config.yaml ]; then \
|
||||
cp config/config.example.yaml $(CONFIG_DIR)/config.yaml; \
|
||||
echo "Sample configuration created at $(CONFIG_DIR)/config.yaml"; \
|
||||
else \
|
||||
echo "Configuration file already exists at $(CONFIG_DIR)/config.yaml"; \
|
||||
fi
|
||||
|
||||
# Generate database schema example
|
||||
.PHONY: init-db
|
||||
init-db:
|
||||
@echo "Creating sample database schema..."
|
||||
@mkdir -p $(CONFIG_DIR)
|
||||
@if [ ! -f $(CONFIG_DIR)/schema.sql ]; then \
|
||||
cp config/schema.example.sql $(CONFIG_DIR)/schema.sql; \
|
||||
echo "Sample database schema created at $(CONFIG_DIR)/schema.sql"; \
|
||||
else \
|
||||
echo "Database schema file already exists at $(CONFIG_DIR)/schema.sql"; \
|
||||
fi
|
||||
|
||||
# Initialize project
|
||||
.PHONY: init
|
||||
init: init-config init-db deps
|
||||
@echo "Project initialized successfully!"
|
||||
@echo "Please edit $(CONFIG_DIR)/config.yaml to configure your database connection"
|
||||
@echo "Then run: make run"
|
||||
|
||||
# Docker commands
|
||||
.PHONY: docker-build
|
||||
docker-build:
|
||||
@echo "Building Docker image..."
|
||||
docker build -t $(APP_NAME):$(VERSION) .
|
||||
|
||||
.PHONY: docker-run
|
||||
docker-run:
|
||||
@echo "Running Docker container..."
|
||||
docker run -p 8080:8080 -v $(PWD)/config:/app/config $(APP_NAME):$(VERSION)
|
||||
|
||||
.PHONY: docker-compose-up
|
||||
docker-compose-up:
|
||||
@echo "Starting with Docker Compose..."
|
||||
docker-compose up --build
|
||||
|
||||
.PHONY: docker-compose-down
|
||||
docker-compose-down:
|
||||
@echo "Stopping Docker Compose..."
|
||||
docker-compose down
|
||||
|
||||
# Development helpers
|
||||
.PHONY: watch
|
||||
watch:
|
||||
@echo "Watching for changes..."
|
||||
@if command -v air >/dev/null 2>&1; then \
|
||||
air; \
|
||||
else \
|
||||
echo "air not found. Please install it: go install github.com/cosmtrek/air@latest"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Check system health
|
||||
.PHONY: health
|
||||
health:
|
||||
@echo "Checking system health..."
|
||||
@curl -f http://localhost:8080/health || echo "Server not running"
|
||||
|
||||
# Show help
|
||||
.PHONY: help
|
||||
help:
|
||||
@echo "Database Render Application - Available Commands:"
|
||||
@echo ""
|
||||
@echo "Development:"
|
||||
@echo " make init - Initialize project with sample config and database"
|
||||
@echo " make run - Build and run the application"
|
||||
@echo " make dev - Run in development mode"
|
||||
@echo " make watch - Run with hot reload (requires air)"
|
||||
@echo ""
|
||||
@echo "Building:"
|
||||
@echo " make build - Build the application"
|
||||
@echo " make build-all - Build for multiple platforms"
|
||||
@echo " make clean - Clean build artifacts"
|
||||
@echo ""
|
||||
@echo "Quality:"
|
||||
@echo " make test - Run tests"
|
||||
@echo " make test-coverage - Run tests with coverage"
|
||||
@echo " make fmt - Format code"
|
||||
@echo " make fmt-check - Check code formatting"
|
||||
@echo " make lint - Run linter"
|
||||
@echo " make security - Run security scan"
|
||||
@echo ""
|
||||
@echo "Docker:"
|
||||
@echo " make docker-build - Build Docker image"
|
||||
@echo " make docker-run - Run Docker container"
|
||||
@echo " make docker-compose-up - Start with Docker Compose"
|
||||
@echo " make docker-compose-down - Stop Docker Compose"
|
||||
@echo ""
|
||||
@echo "Utilities:"
|
||||
@echo " make health - Check application health"
|
||||
@echo " make help - Show this help message"
|
||||
155
TODO.md
Normal file
155
TODO.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# Database Render Application - 后续任务清单
|
||||
|
||||
## 已完成 ✅
|
||||
|
||||
### 核心系统
|
||||
|
||||
- [x] 项目基础结构和 go.mod 配置
|
||||
- [x] 配置管理系统 (Viper + YAML)
|
||||
- [x] 数据库连接和 ORM 层 (GORM + 多数据库支持)
|
||||
- [x] HTTP 处理器层 (Fiber 框架)
|
||||
- [x] 模板渲染系统 (Go templates)
|
||||
- [x] 前端模板和静态资源 (TailwindCSS + 响应式设计)
|
||||
- [x] 主应用入口点
|
||||
- [x] Makefile 和构建脚本
|
||||
- [x] Docker 配置
|
||||
- [x] 开发工具脚本
|
||||
|
||||
### 文档和配置
|
||||
|
||||
- [x] Makefile (包含所有常用命令)
|
||||
- [x] Docker 配置 (Dockerfile + docker-compose.yml)
|
||||
- [x] 示例配置文件
|
||||
- [x] 示例数据库 schema
|
||||
- [x] 构建脚本 (scripts/build.sh)
|
||||
- [x] 开发脚本 (scripts/dev.sh)
|
||||
- [x] Air 热重载配置
|
||||
|
||||
## 待完成 📋
|
||||
|
||||
### 测试相关
|
||||
|
||||
- [ ] 单元测试 (repository 层)
|
||||
- [ ] 单元测试 (service 层)
|
||||
- [ ] 单元测试 (handler 层)
|
||||
- [ ] 集成测试 (API 端点)
|
||||
- [ ] 端到端测试 (UI 测试)
|
||||
- [ ] 测试数据工厂
|
||||
- [ ] Mock 数据生成器
|
||||
|
||||
### 功能增强
|
||||
|
||||
- [ ] 导出功能 (CSV, JSON, Excel)
|
||||
- [ ] 批量操作 (删除, 状态更新)
|
||||
- [ ] 高级搜索 (过滤器, 排序)
|
||||
- [ ] 数据可视化图表
|
||||
- [ ] 用户认证系统
|
||||
- [ ] 权限管理
|
||||
- [ ] 审计日志
|
||||
- [ ] 数据备份/恢复
|
||||
|
||||
### 性能优化
|
||||
|
||||
- [ ] 数据库连接池配置优化
|
||||
- [ ] 缓存层 (Redis)
|
||||
- [ ] 分页性能优化
|
||||
- [ ] 静态资源缓存
|
||||
- [ ] 数据库索引优化
|
||||
- [ ] 查询性能分析
|
||||
|
||||
### 前端增强
|
||||
|
||||
- [ ] 暗黑模式切换
|
||||
- [ ] 移动端优化
|
||||
- [ ] 表格视图 (可选)
|
||||
- [ ] 数据编辑界面
|
||||
- [ ] 图片上传和预览
|
||||
- [ ] 富文本编辑器
|
||||
|
||||
### 部署和运维
|
||||
|
||||
- [ ] CI/CD 配置 (GitHub Actions)
|
||||
- [ ] 生产环境配置模板
|
||||
- [ ] 监控和告警
|
||||
- [ ] 日志轮转配置
|
||||
- [ ] 健康检查增强
|
||||
- [ ] 容器编排 (K8s manifests)
|
||||
|
||||
### 文档完善
|
||||
|
||||
- [ ] 项目 README
|
||||
- [ ] API 文档 (OpenAPI/Swagger)
|
||||
- [ ] 部署指南
|
||||
- [ ] 配置说明文档
|
||||
- [ ] 开发指南
|
||||
- [ ] 故障排除指南
|
||||
|
||||
### 安全增强
|
||||
|
||||
- [ ] 输入验证和清理
|
||||
- [ ] SQL 注入防护检查
|
||||
- [ ] XSS 防护
|
||||
- [ ] CSRF 保护
|
||||
- [ ] 安全头部配置
|
||||
- [ ] 依赖安全扫描
|
||||
|
||||
## 立即行动项 🚀
|
||||
|
||||
### 启动应用测试
|
||||
|
||||
```bash
|
||||
# 初始化项目
|
||||
./scripts/dev.sh init-db
|
||||
./scripts/build.sh build
|
||||
./scripts/dev.sh start
|
||||
|
||||
# 或者使用Makefile
|
||||
make init
|
||||
make run
|
||||
```
|
||||
|
||||
### 测试验证
|
||||
|
||||
1. 访问 http://localhost:8080
|
||||
2. 验证数据库连接
|
||||
3. 测试数据展示
|
||||
4. 检查搜索功能
|
||||
5. 验证分页功能
|
||||
|
||||
### 下一步开发建议
|
||||
|
||||
1. **优先完成测试**:为现有功能添加测试覆盖
|
||||
2. **功能增强**:从导出功能开始,逐步增加实用性功能
|
||||
3. **性能优化**:添加 Redis 缓存提升查询性能
|
||||
4. **安全加固**:实施基本的输入验证和安全防护
|
||||
|
||||
### 快速开始命令
|
||||
|
||||
```bash
|
||||
# 完整开发环境启动
|
||||
./scripts/dev.sh start
|
||||
|
||||
# 热重载开发
|
||||
./scripts/dev.sh watch
|
||||
|
||||
# 多平台构建
|
||||
./scripts/build.sh build-all
|
||||
|
||||
# Docker环境
|
||||
make docker-compose-up
|
||||
```
|
||||
|
||||
## 技术债务
|
||||
|
||||
- [ ] 代码注释完善
|
||||
- [ ] 错误处理统一
|
||||
- [ ] 日志格式标准化
|
||||
- [ ] 配置验证增强
|
||||
- [ ] 代码重构优化
|
||||
|
||||
## 版本规划
|
||||
|
||||
- **v1.0.0**: 基础功能完成,稳定运行
|
||||
- **v1.1.0**: 增加导出和批量操作
|
||||
- **v1.2.0**: 添加用户认证和权限
|
||||
- **v2.0.0**: 架构优化,性能提升
|
||||
51
cmd/root.go
Normal file
51
cmd/root.go
Normal file
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
|
||||
|
||||
*/
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
||||
|
||||
// rootCmd represents the base command when called without any subcommands
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "database_render",
|
||||
Short: "A brief description of your application",
|
||||
Long: `A longer description that spans multiple lines and likely contains
|
||||
examples and usage of using your application. For example:
|
||||
|
||||
Cobra is a CLI library for Go that empowers applications.
|
||||
This application is a tool to generate the needed files
|
||||
to quickly create a Cobra application.`,
|
||||
// Uncomment the following line if your bare application
|
||||
// has an action associated with it:
|
||||
// Run: func(cmd *cobra.Command, args []string) { },
|
||||
}
|
||||
|
||||
// Execute adds all child commands to the root command and sets flags appropriately.
|
||||
// This is called by main.main(). It only needs to happen once to the rootCmd.
|
||||
func Execute() {
|
||||
err := rootCmd.Execute()
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Here you will define your flags and configuration settings.
|
||||
// Cobra supports persistent flags, which, if defined here,
|
||||
// will be global for your application.
|
||||
|
||||
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.database_render.yaml)")
|
||||
|
||||
// Cobra also supports local flags, which will only run
|
||||
// when this action is called directly.
|
||||
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
|
||||
}
|
||||
|
||||
|
||||
137
cmd/server/main.go
Normal file
137
cmd/server/main.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/cors"
|
||||
"github.com/gofiber/fiber/v3/middleware/recover"
|
||||
"github.com/rogeecn/database_render/internal/config"
|
||||
"github.com/rogeecn/database_render/internal/database"
|
||||
"github.com/rogeecn/database_render/internal/handler"
|
||||
"github.com/rogeecn/database_render/internal/repository"
|
||||
"github.com/rogeecn/database_render/internal/service"
|
||||
"github.com/rogeecn/database_render/internal/template"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize structured logging
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
slog.SetDefault(logger)
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.LoadConfig("")
|
||||
if err != nil {
|
||||
slog.Error("failed to load configuration", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize database
|
||||
db, err := database.NewConnectionManager(cfg)
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize database", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() {
|
||||
if err := db.Close(); err != nil {
|
||||
slog.Error("failed to close database", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize repository and services
|
||||
repo := repository.NewDataRepository(db, cfg)
|
||||
svc := service.NewDataService(repo, cfg)
|
||||
if err := svc.ValidateConfiguration(); err != nil {
|
||||
slog.Error("configuration validation failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize renderer
|
||||
renderer, err := template.NewRenderer(svc, cfg)
|
||||
if err != nil {
|
||||
slog.Error("failed to initialize renderer", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
dataHandler := handler.NewDataHandler(svc)
|
||||
|
||||
// Initialize Fiber app
|
||||
app := fiber.New(fiber.Config{
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
ErrorHandler: renderer.ErrorHandler,
|
||||
Views: nil, // Using custom renderer
|
||||
})
|
||||
|
||||
// Setup middleware
|
||||
app.Use(recover.New())
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: []string{"*"},
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"Origin", "Content-Type", "Accept"},
|
||||
}))
|
||||
|
||||
// Setup routes
|
||||
// API routes
|
||||
dataHandler.SetupRoutes(app)
|
||||
|
||||
// Template routes
|
||||
app.Get("/", func(c fiber.Ctx) error {
|
||||
tableName := c.Query("table")
|
||||
if tableName == "" {
|
||||
return renderer.RenderIndex(c)
|
||||
}
|
||||
return renderer.RenderList(c, tableName)
|
||||
})
|
||||
|
||||
// Setup static file serving
|
||||
renderer.ServeStatic(app)
|
||||
|
||||
// Health check endpoint
|
||||
app.Get("/health", func(c fiber.Ctx) error {
|
||||
return c.JSON(map[string]string{
|
||||
"status": "ok",
|
||||
"timestamp": time.Now().Format(time.RFC3339),
|
||||
})
|
||||
})
|
||||
|
||||
// Start server
|
||||
port := cfg.App.Port
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
|
||||
// Start server in a goroutine
|
||||
go func() {
|
||||
slog.Info("starting server", "port", port)
|
||||
if err := app.Listen(fmt.Sprintf(":%d", port)); err != nil {
|
||||
slog.Error("server error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for interrupt signal to gracefully shutdown the server
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
|
||||
slog.Info("shutting down server...")
|
||||
|
||||
// Shutdown server
|
||||
if err := app.Shutdown(); err != nil {
|
||||
slog.Error("server shutdown error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("server gracefully stopped")
|
||||
}
|
||||
147
config/config.example.yaml
Normal file
147
config/config.example.yaml
Normal file
@@ -0,0 +1,147 @@
|
||||
# Database Render Application Configuration
|
||||
# Copy this file to config.yaml and modify as needed
|
||||
|
||||
app:
|
||||
name: "Database Render"
|
||||
port: 8080
|
||||
debug: false
|
||||
|
||||
database:
|
||||
# Database type: sqlite, mysql, postgres
|
||||
type: "sqlite"
|
||||
|
||||
# SQLite configuration (used when type is sqlite)
|
||||
path: "data/app.db"
|
||||
|
||||
# MySQL configuration (used when type is mysql)
|
||||
# host: "localhost"
|
||||
# port: 3306
|
||||
# user: "root"
|
||||
# password: "password"
|
||||
# db_name: "database_render"
|
||||
|
||||
# PostgreSQL configuration (used when type is postgres)
|
||||
# host: "localhost"
|
||||
# port: 5432
|
||||
# user: "postgres"
|
||||
# password: "password"
|
||||
# db_name: "database_render"
|
||||
# dsn: "host=localhost port=5432 user=postgres password=password dbname=database_render sslmode=disable"
|
||||
|
||||
# Table configurations
|
||||
tables:
|
||||
- name: "posts"
|
||||
alias: "文章管理"
|
||||
description: "博客文章管理"
|
||||
default: true
|
||||
layout: "cards" # cards or list
|
||||
columns:
|
||||
- name: "id"
|
||||
alias: "ID"
|
||||
type: "int"
|
||||
primary: true
|
||||
hidden: true
|
||||
- name: "title"
|
||||
alias: "标题"
|
||||
type: "string"
|
||||
searchable: true
|
||||
- name: "content"
|
||||
alias: "内容"
|
||||
type: "text"
|
||||
render_type: "markdown"
|
||||
searchable: true
|
||||
- name: "category"
|
||||
alias: "分类"
|
||||
type: "string"
|
||||
searchable: true
|
||||
render_type: "tag"
|
||||
values:
|
||||
technology:
|
||||
label: "技术"
|
||||
color: "#3b82f6"
|
||||
life:
|
||||
label: "生活"
|
||||
color: "#10b981"
|
||||
work:
|
||||
label: "工作"
|
||||
color: "#f59e0b"
|
||||
- name: "tags"
|
||||
alias: "标签"
|
||||
type: "string"
|
||||
searchable: true
|
||||
- name: "created_at"
|
||||
alias: "创建时间"
|
||||
type: "datetime"
|
||||
render_type: "time"
|
||||
- name: "updated_at"
|
||||
alias: "更新时间"
|
||||
type: "datetime"
|
||||
render_type: "time"
|
||||
|
||||
- name: "users"
|
||||
alias: "用户管理"
|
||||
description: "系统用户管理"
|
||||
layout: "list"
|
||||
columns:
|
||||
- name: "id"
|
||||
alias: "ID"
|
||||
type: "int"
|
||||
primary: true
|
||||
hidden: true
|
||||
- name: "username"
|
||||
alias: "用户名"
|
||||
type: "string"
|
||||
searchable: true
|
||||
- name: "email"
|
||||
alias: "邮箱"
|
||||
type: "string"
|
||||
searchable: true
|
||||
- name: "full_name"
|
||||
alias: "姓名"
|
||||
type: "string"
|
||||
searchable: true
|
||||
- name: "status"
|
||||
alias: "状态"
|
||||
type: "string"
|
||||
render_type: "tag"
|
||||
values:
|
||||
active:
|
||||
label: "激活"
|
||||
color: "#10b981"
|
||||
inactive:
|
||||
label: "未激活"
|
||||
color: "#ef4444"
|
||||
pending:
|
||||
label: "待审核"
|
||||
color: "#f59e0b"
|
||||
- name: "created_at"
|
||||
alias: "创建时间"
|
||||
type: "datetime"
|
||||
render_type: "time"
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
level: "info" # debug, info, warn, error
|
||||
format: "json" # json, text
|
||||
output: "stdout" # stdout, stderr, file
|
||||
file: "logs/app.log" # Only used when output is file
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
read_timeout: 30s
|
||||
write_timeout: 30s
|
||||
idle_timeout: 60s
|
||||
|
||||
# Security configuration
|
||||
security:
|
||||
cors:
|
||||
enabled: true
|
||||
allow_origins: ["*"]
|
||||
allow_methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
allow_headers: ["Origin", "Content-Type", "Accept", "Authorization"]
|
||||
|
||||
# Cache configuration
|
||||
cache:
|
||||
enabled: false
|
||||
ttl: 5m
|
||||
max_size: 100
|
||||
230
config/config.yaml
Normal file
230
config/config.yaml
Normal file
@@ -0,0 +1,230 @@
|
||||
# Database Render Application Configuration
|
||||
|
||||
app:
|
||||
name: "Database Render"
|
||||
port: 9080
|
||||
debug: false
|
||||
|
||||
database:
|
||||
type: "sqlite"
|
||||
path: "data/app.db"
|
||||
|
||||
# Table configurations
|
||||
tables:
|
||||
posts:
|
||||
alias: "文章管理"
|
||||
description: "博客文章管理"
|
||||
page_size: 10
|
||||
fields:
|
||||
id:
|
||||
type: "int"
|
||||
alias: "ID"
|
||||
hidden: true
|
||||
title:
|
||||
type: "string"
|
||||
alias: "标题"
|
||||
searchable: true
|
||||
size: "large"
|
||||
content:
|
||||
type: "text"
|
||||
alias: "内容"
|
||||
hidden: true # Hide from list view, show in detail
|
||||
markdown: true
|
||||
category:
|
||||
type: "string"
|
||||
alias: "分类"
|
||||
searchable: true
|
||||
colors:
|
||||
Technology: "#3b82f6"
|
||||
Programming: "#8b5cf6"
|
||||
"Web Development": "#10b981"
|
||||
DevOps: "#f59e0b"
|
||||
"Data Science": "#ef4444"
|
||||
Lifestyle: "#ec4899"
|
||||
tags:
|
||||
type: "string"
|
||||
alias: "标签"
|
||||
searchable: true
|
||||
status:
|
||||
type: "string"
|
||||
alias: "状态"
|
||||
searchable: true
|
||||
colors:
|
||||
published: "#10b981"
|
||||
draft: "#6b7280"
|
||||
pending: "#f59e0b"
|
||||
view_count:
|
||||
type: "int"
|
||||
alias: "浏览量"
|
||||
created_at:
|
||||
type: "time"
|
||||
alias: "创建时间"
|
||||
format: "2006-01-02 15:04:05"
|
||||
updated_at:
|
||||
type: "time"
|
||||
alias: "更新时间"
|
||||
format: "2006-01-02 15:04:05"
|
||||
|
||||
users:
|
||||
alias: "用户管理"
|
||||
description: "系统用户管理"
|
||||
page_size: 10
|
||||
fields:
|
||||
id:
|
||||
type: "int"
|
||||
alias: "ID"
|
||||
hidden: true
|
||||
username:
|
||||
type: "string"
|
||||
alias: "用户名"
|
||||
searchable: true
|
||||
email:
|
||||
type: "string"
|
||||
alias: "邮箱"
|
||||
searchable: true
|
||||
full_name:
|
||||
type: "string"
|
||||
alias: "姓名"
|
||||
searchable: true
|
||||
bio:
|
||||
type: "text"
|
||||
alias: "简介"
|
||||
max_length: 200
|
||||
status:
|
||||
type: "string"
|
||||
alias: "状态"
|
||||
searchable: true
|
||||
colors:
|
||||
active: "#10b981"
|
||||
inactive: "#ef4444"
|
||||
pending: "#f59e0b"
|
||||
role:
|
||||
type: "string"
|
||||
alias: "角色"
|
||||
searchable: true
|
||||
colors:
|
||||
admin: "#ef4444"
|
||||
user: "#3b82f6"
|
||||
last_login:
|
||||
type: "time"
|
||||
alias: "最后登录"
|
||||
format: "2006-01-02 15:04:05"
|
||||
created_at:
|
||||
type: "time"
|
||||
alias: "创建时间"
|
||||
format: "2006-01-02 15:04:05"
|
||||
updated_at:
|
||||
type: "time"
|
||||
alias: "更新时间"
|
||||
format: "2006-01-02 15:04:05"
|
||||
|
||||
categories:
|
||||
alias: "分类管理"
|
||||
description: "文章分类管理"
|
||||
page_size: 10
|
||||
fields:
|
||||
id:
|
||||
type: "int"
|
||||
alias: "ID"
|
||||
hidden: true
|
||||
name:
|
||||
type: "string"
|
||||
alias: "名称"
|
||||
searchable: true
|
||||
slug:
|
||||
type: "string"
|
||||
alias: "别名"
|
||||
searchable: true
|
||||
description:
|
||||
type: "text"
|
||||
alias: "描述"
|
||||
max_length: 200
|
||||
color:
|
||||
type: "string"
|
||||
alias: "颜色"
|
||||
render_type: "color"
|
||||
sort_order:
|
||||
type: "int"
|
||||
alias: "排序"
|
||||
is_active:
|
||||
type: "bool"
|
||||
alias: "激活"
|
||||
render_type: "bool"
|
||||
created_at:
|
||||
type: "time"
|
||||
alias: "创建时间"
|
||||
format: "2006-01-02 15:04:05"
|
||||
updated_at:
|
||||
type: "time"
|
||||
alias: "更新时间"
|
||||
format: "2006-01-02 15:04:05"
|
||||
|
||||
comments:
|
||||
alias: "评论管理"
|
||||
description: "文章评论管理"
|
||||
page_size: 15
|
||||
fields:
|
||||
id:
|
||||
type: "int"
|
||||
alias: "ID"
|
||||
hidden: true
|
||||
post_id:
|
||||
type: "int"
|
||||
alias: "文章ID"
|
||||
user_id:
|
||||
type: "int"
|
||||
alias: "用户ID"
|
||||
author_name:
|
||||
type: "string"
|
||||
alias: "作者"
|
||||
searchable: true
|
||||
author_email:
|
||||
type: "string"
|
||||
alias: "邮箱"
|
||||
searchable: true
|
||||
content:
|
||||
type: "text"
|
||||
alias: "内容"
|
||||
max_length: 500
|
||||
status:
|
||||
type: "string"
|
||||
alias: "状态"
|
||||
searchable: true
|
||||
colors:
|
||||
approved: "#10b981"
|
||||
pending: "#f59e0b"
|
||||
rejected: "#ef4444"
|
||||
created_at:
|
||||
type: "time"
|
||||
alias: "创建时间"
|
||||
format: "2006-01-02 15:04:05"
|
||||
updated_at:
|
||||
type: "time"
|
||||
alias: "更新时间"
|
||||
format: "2006-01-02 15:04:05"
|
||||
|
||||
# Logging configuration
|
||||
logging:
|
||||
level: "info"
|
||||
format: "json"
|
||||
output: "stdout"
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
read_timeout: 30s
|
||||
write_timeout: 30s
|
||||
idle_timeout: 60s
|
||||
|
||||
# Security configuration
|
||||
security:
|
||||
cors:
|
||||
enabled: true
|
||||
allow_origins: ["*"]
|
||||
allow_methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
|
||||
allow_headers: ["Origin", "Content-Type", "Accept"]
|
||||
|
||||
# Cache configuration
|
||||
cache:
|
||||
enabled: false
|
||||
ttl: 5m
|
||||
max_size: 100
|
||||
346
config/schema.example.sql
Normal file
346
config/schema.example.sql
Normal file
@@ -0,0 +1,346 @@
|
||||
-- Database Render Application - Sample Database Schema
|
||||
-- This file contains sample database tables for testing the application
|
||||
|
||||
-- Posts table (blog posts/articles)
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
category VARCHAR(50),
|
||||
tags VARCHAR(255),
|
||||
author_id INTEGER,
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
view_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
full_name VARCHAR(100),
|
||||
password_hash VARCHAR(255),
|
||||
avatar_url VARCHAR(255),
|
||||
bio TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
role VARCHAR(20) DEFAULT 'user',
|
||||
last_login DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Categories table
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tags table
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
slug VARCHAR(50) UNIQUE NOT NULL,
|
||||
color VARCHAR(7),
|
||||
description TEXT,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Post-Tag relationship table
|
||||
CREATE TABLE IF NOT EXISTS post_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
|
||||
UNIQUE(post_id, tag_id)
|
||||
);
|
||||
|
||||
-- Comments table
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
user_id INTEGER,
|
||||
author_name VARCHAR(100),
|
||||
author_email VARCHAR(255),
|
||||
content TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Media/Attachments table
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_filename VARCHAR(255),
|
||||
file_path VARCHAR(500),
|
||||
file_size INTEGER,
|
||||
mime_type VARCHAR(100),
|
||||
alt_text VARCHAR(255),
|
||||
title VARCHAR(255),
|
||||
description TEXT,
|
||||
uploaded_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Sample data insertion
|
||||
-- Insert sample users
|
||||
INSERT OR IGNORE INTO users (username, email, full_name, bio, role) VALUES
|
||||
('admin', 'admin@example.com', 'System Administrator', 'System administrator account', 'admin'),
|
||||
('john_doe', 'john@example.com', 'John Doe', 'Technology enthusiast and blogger', 'user'),
|
||||
('jane_smith', 'jane@example.com', 'Jane Smith', 'Content creator and writer', 'user'),
|
||||
('bob_wilson', 'bob@example.com', 'Bob Wilson', 'Software developer and tech reviewer', 'user');
|
||||
|
||||
-- Insert sample categories
|
||||
INSERT OR IGNORE INTO categories (name, slug, description, color) VALUES
|
||||
('Technology', 'technology', 'Latest technology trends and developments', '#3b82f6'),
|
||||
('Programming', 'programming', 'Programming tutorials and best practices', '#8b5cf6'),
|
||||
('Web Development', 'web-development', 'Web development articles and tutorials', '#10b981'),
|
||||
('DevOps', 'devops', 'DevOps practices and tools', '#f59e0b'),
|
||||
('Data Science', 'data-science', 'Data science and machine learning', '#ef4444'),
|
||||
('Lifestyle', 'lifestyle', 'Personal development and lifestyle', '#ec4899');
|
||||
|
||||
-- Insert sample tags
|
||||
INSERT OR IGNORE INTO tags (name, slug, color, description) VALUES
|
||||
('Go', 'go', '#00add8', 'Go programming language'),
|
||||
('Python', 'python', '#3776ab', 'Python programming language'),
|
||||
('JavaScript', 'javascript', '#f7df1e', 'JavaScript programming language'),
|
||||
('Docker', 'docker', '#2496ed', 'Docker containerization'),
|
||||
('Kubernetes', 'kubernetes', '#326ce5', 'Kubernetes orchestration'),
|
||||
('React', 'react', '#61dafb', 'React framework'),
|
||||
('Vue.js', 'vuejs', '#4fc08d', 'Vue.js framework'),
|
||||
('Database', 'database', '#4479a1', 'Database technologies'),
|
||||
('API', 'api', '#68d391', 'API development'),
|
||||
('Security', 'security', '#f56565', 'Security practices');
|
||||
|
||||
-- Insert sample posts
|
||||
INSERT OR IGNORE INTO posts (title, content, category, tags, author_id, status) VALUES
|
||||
('Getting Started with Go and Fiber Framework', '# Getting Started with Go and Fiber
|
||||
|
||||
This is a comprehensive guide to building web applications with Go and the Fiber framework.
|
||||
|
||||
## Introduction
|
||||
|
||||
Fiber is an Express.js inspired web framework written in Go. It provides a robust set of features for building web applications and APIs.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/gofiber/fiber/v2
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
func main() {
|
||||
app := fiber.New()
|
||||
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
return c.SendString("Hello, World!")
|
||||
})
|
||||
|
||||
app.Listen(":3000")
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Fiber provides an excellent foundation for building high-performance web applications in Go.', 'Programming', 'Go,Web Development,API', 2, 'published'),
|
||||
|
||||
('Modern Database Design Best Practices', '# Modern Database Design Best Practices
|
||||
|
||||
Learn the essential principles of designing scalable and maintainable databases.
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Normalization**: Reduce data redundancy
|
||||
2. **Indexing**: Improve query performance
|
||||
3. **Data Types**: Choose appropriate data types
|
||||
4. **Constraints**: Ensure data integrity
|
||||
|
||||
## Example Schema
|
||||
|
||||
Here''s an example of a well-designed user table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
- Use appropriate indexes
|
||||
- Consider partitioning for large tables
|
||||
- Regular database maintenance', 'Technology', 'Database,Design,Best Practices', 3, 'published'),
|
||||
|
||||
('Container Orchestration with Kubernetes', '# Container Orchestration with Kubernetes
|
||||
|
||||
Master the art of container orchestration with Kubernetes.
|
||||
|
||||
## What is Kubernetes?
|
||||
|
||||
Kubernetes is an open-source platform designed to automate deploying, scaling, and operating application containers.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Service Discovery**: Automatic container discovery
|
||||
- **Load Balancing**: Distribute traffic across containers
|
||||
- **Storage Orchestration**: Mount storage systems
|
||||
- **Self-Healing**: Automatic container restarts
|
||||
- **Secret Management**: Secure sensitive data
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Start a local cluster
|
||||
minikube start
|
||||
|
||||
# Deploy an application
|
||||
kubectl create deployment hello-node --image=k8s.gcr.io/echoserver:1.4
|
||||
|
||||
# Expose the deployment
|
||||
kubectl expose deployment hello-node --type=LoadBalancer --port=8080
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use namespaces for organization
|
||||
- Implement health checks
|
||||
- Monitor resource usage
|
||||
- Use ConfigMaps for configuration', 'DevOps', 'Kubernetes,Docker,Containerization', 4, 'published'),
|
||||
|
||||
('Building RESTful APIs with Best Practices', '# Building RESTful APIs with Best Practices
|
||||
|
||||
Create robust and scalable RESTful APIs following industry standards.
|
||||
|
||||
## REST Principles
|
||||
|
||||
- **Stateless**: Each request contains all necessary information
|
||||
- **Resource-Based**: URLs represent resources
|
||||
- **HTTP Methods**: Use appropriate HTTP verbs (GET, POST, PUT, DELETE)
|
||||
- **Status Codes**: Return appropriate HTTP status codes
|
||||
|
||||
## API Design Guidelines
|
||||
|
||||
### 1. Version Your API
|
||||
```
|
||||
/api/v1/users
|
||||
/api/v2/users
|
||||
```
|
||||
|
||||
### 2. Use Plural Nouns
|
||||
```
|
||||
GET /users
|
||||
POST /users
|
||||
GET /users/123
|
||||
```
|
||||
|
||||
### 3. Filtering and Pagination
|
||||
```
|
||||
GET /users?page=1&limit=10&sort=name&order=asc
|
||||
```
|
||||
|
||||
### 4. Error Handling
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Email is required",
|
||||
"details": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Use HTTPS
|
||||
- Implement rate limiting
|
||||
- Validate input data
|
||||
- Use authentication and authorization', 'Programming', 'API,REST,Security', 2, 'published'),
|
||||
|
||||
('React State Management in 2024', '# React State Management in 2024
|
||||
|
||||
Explore modern state management solutions for React applications.
|
||||
|
||||
## State Management Options
|
||||
|
||||
### 1. React Context API
|
||||
Built-in solution for sharing state across components.
|
||||
|
||||
### 2. Redux Toolkit
|
||||
Predictable state container with great developer tools.
|
||||
|
||||
### 3. Zustand
|
||||
Lightweight and simple state management.
|
||||
|
||||
### 4. Jotai
|
||||
Atomic approach to state management.
|
||||
|
||||
## Choosing the Right Solution
|
||||
|
||||
| Solution | Best For | Bundle Size |
|
||||
|----------|----------|-------------|
|
||||
| Context | Simple apps | Small |
|
||||
| Redux | Complex apps | Large |
|
||||
| Zustand | Medium apps | Small |
|
||||
| Jotai | Atomic state | Small |
|
||||
|
||||
## Code Example
|
||||
|
||||
```javascript
|
||||
// Using Zustand
|
||||
import { create } from ''zustand''
|
||||
|
||||
const useStore = create((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||
}))
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
- Use React.memo for expensive components
|
||||
- Implement proper memoization
|
||||
- Consider code splitting for large state', 'Web Development', 'React,JavaScript,State Management', 3, 'published');
|
||||
|
||||
-- Insert sample comments
|
||||
INSERT OR IGNORE INTO comments (post_id, user_id, content, status) VALUES
|
||||
(1, 3, 'Great tutorial! Very helpful for getting started with Fiber.', 'approved'),
|
||||
(1, 4, 'Thanks for sharing this. The examples are really clear.', 'approved'),
|
||||
(2, 2, 'Excellent overview of database design principles!', 'approved'),
|
||||
(3, 1, 'Kubernetes can be overwhelming, but this guide makes it approachable.', 'approved'),
|
||||
(4, 3, 'RESTful API best practices are so important. Great article!', 'approved');
|
||||
|
||||
-- Insert post-tag relationships
|
||||
INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES
|
||||
(1, 1), (1, 9), (1, 10),
|
||||
(2, 8), (2, 9),
|
||||
(3, 4), (3, 5),
|
||||
(4, 9), (4, 10),
|
||||
(5, 6), (5, 7), (5, 10);
|
||||
346
config/schema.sql
Normal file
346
config/schema.sql
Normal file
@@ -0,0 +1,346 @@
|
||||
-- Database Render Application - Sample Database Schema
|
||||
-- This file contains sample database tables for testing the application
|
||||
|
||||
-- Posts table (blog posts/articles)
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
content TEXT,
|
||||
category VARCHAR(50),
|
||||
tags VARCHAR(255),
|
||||
author_id INTEGER,
|
||||
status VARCHAR(20) DEFAULT 'draft',
|
||||
view_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Users table
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
full_name VARCHAR(100),
|
||||
password_hash VARCHAR(255),
|
||||
avatar_url VARCHAR(255),
|
||||
bio TEXT,
|
||||
status VARCHAR(20) DEFAULT 'active',
|
||||
role VARCHAR(20) DEFAULT 'user',
|
||||
last_login DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Categories table
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(100) UNIQUE NOT NULL,
|
||||
slug VARCHAR(100) UNIQUE NOT NULL,
|
||||
description TEXT,
|
||||
color VARCHAR(7),
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active BOOLEAN DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Tags table
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name VARCHAR(50) UNIQUE NOT NULL,
|
||||
slug VARCHAR(50) UNIQUE NOT NULL,
|
||||
color VARCHAR(7),
|
||||
description TEXT,
|
||||
usage_count INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Post-Tag relationship table
|
||||
CREATE TABLE IF NOT EXISTS post_tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
|
||||
UNIQUE(post_id, tag_id)
|
||||
);
|
||||
|
||||
-- Comments table
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
post_id INTEGER NOT NULL,
|
||||
user_id INTEGER,
|
||||
author_name VARCHAR(100),
|
||||
author_email VARCHAR(255),
|
||||
content TEXT NOT NULL,
|
||||
parent_id INTEGER,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (post_id) REFERENCES posts(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,
|
||||
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- Media/Attachments table
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_filename VARCHAR(255),
|
||||
file_path VARCHAR(500),
|
||||
file_size INTEGER,
|
||||
mime_type VARCHAR(100),
|
||||
alt_text VARCHAR(255),
|
||||
title VARCHAR(255),
|
||||
description TEXT,
|
||||
uploaded_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (uploaded_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Sample data insertion
|
||||
-- Insert sample users
|
||||
INSERT OR IGNORE INTO users (username, email, full_name, bio, role) VALUES
|
||||
('admin', 'admin@example.com', 'System Administrator', 'System administrator account', 'admin'),
|
||||
('john_doe', 'john@example.com', 'John Doe', 'Technology enthusiast and blogger', 'user'),
|
||||
('jane_smith', 'jane@example.com', 'Jane Smith', 'Content creator and writer', 'user'),
|
||||
('bob_wilson', 'bob@example.com', 'Bob Wilson', 'Software developer and tech reviewer', 'user');
|
||||
|
||||
-- Insert sample categories
|
||||
INSERT OR IGNORE INTO categories (name, slug, description, color) VALUES
|
||||
('Technology', 'technology', 'Latest technology trends and developments', '#3b82f6'),
|
||||
('Programming', 'programming', 'Programming tutorials and best practices', '#8b5cf6'),
|
||||
('Web Development', 'web-development', 'Web development articles and tutorials', '#10b981'),
|
||||
('DevOps', 'devops', 'DevOps practices and tools', '#f59e0b'),
|
||||
('Data Science', 'data-science', 'Data science and machine learning', '#ef4444'),
|
||||
('Lifestyle', 'lifestyle', 'Personal development and lifestyle', '#ec4899');
|
||||
|
||||
-- Insert sample tags
|
||||
INSERT OR IGNORE INTO tags (name, slug, color, description) VALUES
|
||||
('Go', 'go', '#00add8', 'Go programming language'),
|
||||
('Python', 'python', '#3776ab', 'Python programming language'),
|
||||
('JavaScript', 'javascript', '#f7df1e', 'JavaScript programming language'),
|
||||
('Docker', 'docker', '#2496ed', 'Docker containerization'),
|
||||
('Kubernetes', 'kubernetes', '#326ce5', 'Kubernetes orchestration'),
|
||||
('React', 'react', '#61dafb', 'React framework'),
|
||||
('Vue.js', 'vuejs', '#4fc08d', 'Vue.js framework'),
|
||||
('Database', 'database', '#4479a1', 'Database technologies'),
|
||||
('API', 'api', '#68d391', 'API development'),
|
||||
('Security', 'security', '#f56565', 'Security practices');
|
||||
|
||||
-- Insert sample posts
|
||||
INSERT OR IGNORE INTO posts (title, content, category, tags, author_id, status) VALUES
|
||||
('Getting Started with Go and Fiber Framework', '# Getting Started with Go and Fiber
|
||||
|
||||
This is a comprehensive guide to building web applications with Go and the Fiber framework.
|
||||
|
||||
## Introduction
|
||||
|
||||
Fiber is an Express.js inspired web framework written in Go. It provides a robust set of features for building web applications and APIs.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/gofiber/fiber/v2
|
||||
```
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "github.com/gofiber/fiber/v2"
|
||||
|
||||
func main() {
|
||||
app := fiber.New()
|
||||
|
||||
app.Get("/", func(c *fiber.Ctx) error {
|
||||
return c.SendString("Hello, World!")
|
||||
})
|
||||
|
||||
app.Listen(":3000")
|
||||
}
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
|
||||
Fiber provides an excellent foundation for building high-performance web applications in Go.', 'Programming', 'Go,Web Development,API', 2, 'published'),
|
||||
|
||||
('Modern Database Design Best Practices', '# Modern Database Design Best Practices
|
||||
|
||||
Learn the essential principles of designing scalable and maintainable databases.
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **Normalization**: Reduce data redundancy
|
||||
2. **Indexing**: Improve query performance
|
||||
3. **Data Types**: Choose appropriate data types
|
||||
4. **Constraints**: Ensure data integrity
|
||||
|
||||
## Example Schema
|
||||
|
||||
Here''s an example of a well-designed user table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id SERIAL PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
email VARCHAR(255) UNIQUE NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
- Use appropriate indexes
|
||||
- Consider partitioning for large tables
|
||||
- Regular database maintenance', 'Technology', 'Database,Design,Best Practices', 3, 'published'),
|
||||
|
||||
('Container Orchestration with Kubernetes', '# Container Orchestration with Kubernetes
|
||||
|
||||
Master the art of container orchestration with Kubernetes.
|
||||
|
||||
## What is Kubernetes?
|
||||
|
||||
Kubernetes is an open-source platform designed to automate deploying, scaling, and operating application containers.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Service Discovery**: Automatic container discovery
|
||||
- **Load Balancing**: Distribute traffic across containers
|
||||
- **Storage Orchestration**: Mount storage systems
|
||||
- **Self-Healing**: Automatic container restarts
|
||||
- **Secret Management**: Secure sensitive data
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
# Start a local cluster
|
||||
minikube start
|
||||
|
||||
# Deploy an application
|
||||
kubectl create deployment hello-node --image=k8s.gcr.io/echoserver:1.4
|
||||
|
||||
# Expose the deployment
|
||||
kubectl expose deployment hello-node --type=LoadBalancer --port=8080
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use namespaces for organization
|
||||
- Implement health checks
|
||||
- Monitor resource usage
|
||||
- Use ConfigMaps for configuration', 'DevOps', 'Kubernetes,Docker,Containerization', 4, 'published'),
|
||||
|
||||
('Building RESTful APIs with Best Practices', '# Building RESTful APIs with Best Practices
|
||||
|
||||
Create robust and scalable RESTful APIs following industry standards.
|
||||
|
||||
## REST Principles
|
||||
|
||||
- **Stateless**: Each request contains all necessary information
|
||||
- **Resource-Based**: URLs represent resources
|
||||
- **HTTP Methods**: Use appropriate HTTP verbs (GET, POST, PUT, DELETE)
|
||||
- **Status Codes**: Return appropriate HTTP status codes
|
||||
|
||||
## API Design Guidelines
|
||||
|
||||
### 1. Version Your API
|
||||
```
|
||||
/api/v1/users
|
||||
/api/v2/users
|
||||
```
|
||||
|
||||
### 2. Use Plural Nouns
|
||||
```
|
||||
GET /users
|
||||
POST /users
|
||||
GET /users/123
|
||||
```
|
||||
|
||||
### 3. Filtering and Pagination
|
||||
```
|
||||
GET /users?page=1&limit=10&sort=name&order=asc
|
||||
```
|
||||
|
||||
### 4. Error Handling
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Email is required",
|
||||
"details": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Use HTTPS
|
||||
- Implement rate limiting
|
||||
- Validate input data
|
||||
- Use authentication and authorization', 'Programming', 'API,REST,Security', 2, 'published'),
|
||||
|
||||
('React State Management in 2024', '# React State Management in 2024
|
||||
|
||||
Explore modern state management solutions for React applications.
|
||||
|
||||
## State Management Options
|
||||
|
||||
### 1. React Context API
|
||||
Built-in solution for sharing state across components.
|
||||
|
||||
### 2. Redux Toolkit
|
||||
Predictable state container with great developer tools.
|
||||
|
||||
### 3. Zustand
|
||||
Lightweight and simple state management.
|
||||
|
||||
### 4. Jotai
|
||||
Atomic approach to state management.
|
||||
|
||||
## Choosing the Right Solution
|
||||
|
||||
| Solution | Best For | Bundle Size |
|
||||
|----------|----------|-------------|
|
||||
| Context | Simple apps | Small |
|
||||
| Redux | Complex apps | Large |
|
||||
| Zustand | Medium apps | Small |
|
||||
| Jotai | Atomic state | Small |
|
||||
|
||||
## Code Example
|
||||
|
||||
```javascript
|
||||
// Using Zustand
|
||||
import { create } from ''zustand''
|
||||
|
||||
const useStore = create((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||
}))
|
||||
```
|
||||
|
||||
## Performance Tips
|
||||
|
||||
- Use React.memo for expensive components
|
||||
- Implement proper memoization
|
||||
- Consider code splitting for large state', 'Web Development', 'React,JavaScript,State Management', 3, 'published');
|
||||
|
||||
-- Insert sample comments
|
||||
INSERT OR IGNORE INTO comments (post_id, user_id, content, status) VALUES
|
||||
(1, 3, 'Great tutorial! Very helpful for getting started with Fiber.', 'approved'),
|
||||
(1, 4, 'Thanks for sharing this. The examples are really clear.', 'approved'),
|
||||
(2, 2, 'Excellent overview of database design principles!', 'approved'),
|
||||
(3, 1, 'Kubernetes can be overwhelming, but this guide makes it approachable.', 'approved'),
|
||||
(4, 3, 'RESTful API best practices are so important. Great article!', 'approved');
|
||||
|
||||
-- Insert post-tag relationships
|
||||
INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES
|
||||
(1, 1), (1, 9), (1, 10),
|
||||
(2, 8), (2, 9),
|
||||
(3, 4), (3, 5),
|
||||
(4, 9), (4, 10),
|
||||
(5, 6), (5, 7), (5, 10);
|
||||
BIN
data/app.db
Normal file
BIN
data/app.db
Normal file
Binary file not shown.
27
database_render/.gitignore
vendored
Normal file
27
database_render/.gitignore
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||
*.out
|
||||
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# env file
|
||||
.env
|
||||
|
||||
75
docker-compose.yml
Normal file
75
docker-compose.yml
Normal file
@@ -0,0 +1,75 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# Database Render Application
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./config:/app/config
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- CONFIG_FILE=/app/config/config.yaml
|
||||
depends_on:
|
||||
- mysql
|
||||
- postgres
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
# MySQL Database
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: mysql-db
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: rootpassword
|
||||
MYSQL_DATABASE: database_render
|
||||
MYSQL_USER: appuser
|
||||
MYSQL_PASSWORD: apppassword
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ./config/schema.example.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: postgres-db
|
||||
environment:
|
||||
POSTGRES_DB: database_render
|
||||
POSTGRES_USER: appuser
|
||||
POSTGRES_PASSWORD: apppassword
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./config/schema.example.sql:/docker-entrypoint-initdb.d/01-schema.sql
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
# SQLite Database (for development)
|
||||
sqlite-dev:
|
||||
image: alpine:latest
|
||||
container_name: sqlite-dev
|
||||
volumes:
|
||||
- ./data:/data
|
||||
command: tail -f /dev/null
|
||||
networks:
|
||||
- app-network
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
postgres-data:
|
||||
|
||||
networks:
|
||||
app-network:
|
||||
driver: bridge
|
||||
56
go.mod
Normal file
56
go.mod
Normal file
@@ -0,0 +1,56 @@
|
||||
module github.com/rogeecn/database_render
|
||||
|
||||
go 1.24.1
|
||||
|
||||
require (
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.5
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/spf13/viper v1.18.2
|
||||
gorm.io/driver/mysql v1.5.2
|
||||
gorm.io/driver/postgres v1.5.4
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||
github.com/go-sql-driver/mysql v1.7.1 // indirect
|
||||
github.com/gofiber/schema v1.6.0 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.0-beta.13 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
|
||||
github.com/jackc/pgx/v5 v5.4.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/magiconair/properties v1.8.7 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.19 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||
github.com/spf13/afero v1.11.0 // indirect
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.64.0 // indirect
|
||||
go.uber.org/atomic v1.9.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/crypto v0.40.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
138
go.sum
Normal file
138
go.sum
Normal file
@@ -0,0 +1,138 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrtU8EI=
|
||||
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.5 h1:MSGbiQZEYiYOqti2Ip2zMRkN4VvZw7Vo7dwZBa1Qjk8=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.5/go.mod h1:XmI2Agulde26YcQrA2n8X499I1p98/zfCNbNObVUeP8=
|
||||
github.com/gofiber/schema v1.6.0 h1:rAgVDFwhndtC+hgV7Vu5ItQCn7eC2mBA4Eu1/ZTiEYY=
|
||||
github.com/gofiber/schema v1.6.0/go.mod h1:WNZWpQx8LlPSK7ZaX0OqOh+nQo/eW2OevsXs1VZfs/s=
|
||||
github.com/gofiber/utils/v2 v2.0.0-beta.13 h1:dlpbGFLveQ9OduL2UHw4dtu4lXE+Gb3bHMc+8Yxp/dk=
|
||||
github.com/gofiber/utils/v2 v2.0.0-beta.13/go.mod h1:qEZ175nSOkl5xciHmqxwNDsWzwiB39gB8RgU1d3U4mQ=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
|
||||
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
|
||||
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.19 h1:fhGleo2h1p8tVChob4I9HpmVFIAkKGpiukdrgQbWfGI=
|
||||
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI=
|
||||
github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
||||
github.com/shamaton/msgpack/v2 v2.2.3 h1:uDOHmxQySlvlUYfQwdjxyybAOzjlQsD1Vjy+4jmO9NM=
|
||||
github.com/shamaton/msgpack/v2 v2.2.3/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
|
||||
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.64.0 h1:QBygLLQmiAyiXuRhthf0tuRkqAFcrC42dckN2S+N3og=
|
||||
github.com/valyala/fasthttp v1.64.0/go.mod h1:dGmFxwkWXSK0NbOSJuF7AMVzU+lkHz0wQVvVITv2UQA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
|
||||
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
|
||||
gorm.io/driver/postgres v1.5.4 h1:Iyrp9Meh3GmbSuyIAGyjkN+n9K+GHX9b9MqsTL4EJCo=
|
||||
gorm.io/driver/postgres v1.5.4/go.mod h1:Bgo89+h0CRcdA33Y6frlaHHVuTdOf87pmyzwW9C/BH0=
|
||||
gorm.io/driver/sqlite v1.5.4 h1:IqXwXi8M/ZlPzH/947tn5uik3aYQslP9BVveoax0nV0=
|
||||
gorm.io/driver/sqlite v1.5.4/go.mod h1:qxAuCol+2r6PannQDpOP1FP6ag3mKi4esLnB/jHed+4=
|
||||
gorm.io/gorm v1.25.2-0.20230530020048-26663ab9bf55/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k=
|
||||
gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls=
|
||||
gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
236
internal/config/config.go
Normal file
236
internal/config/config.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Config represents the application configuration
|
||||
type Config struct {
|
||||
App AppConfig `mapstructure:"app"`
|
||||
Database DatabaseConfig `mapstructure:"database"`
|
||||
Tables map[string]TableConfig `mapstructure:"tables"`
|
||||
}
|
||||
|
||||
// AppConfig holds application-level configuration
|
||||
type AppConfig struct {
|
||||
Name string `mapstructure:"name"`
|
||||
Theme string `mapstructure:"theme"`
|
||||
Language string `mapstructure:"language"`
|
||||
Port int `mapstructure:"port"`
|
||||
}
|
||||
|
||||
// DatabaseConfig holds database connection settings
|
||||
type DatabaseConfig struct {
|
||||
Type string `mapstructure:"type"`
|
||||
Host string `mapstructure:"host"`
|
||||
Port int `mapstructure:"port"`
|
||||
User string `mapstructure:"user"`
|
||||
Password string `mapstructure:"password"`
|
||||
DBName string `mapstructure:"dbname"`
|
||||
Path string `mapstructure:"path"`
|
||||
DSN string `mapstructure:"dsn"`
|
||||
}
|
||||
|
||||
// TableConfig holds table-specific configuration
|
||||
type TableConfig struct {
|
||||
Alias string `mapstructure:"alias"`
|
||||
Layout string `mapstructure:"layout"`
|
||||
PageSize int `mapstructure:"page_size"`
|
||||
Fields map[string]FieldConfig `mapstructure:"fields"`
|
||||
Features map[string]interface{} `mapstructure:"features"`
|
||||
Options map[string]interface{} `mapstructure:"options"`
|
||||
}
|
||||
|
||||
// FieldConfig holds field-specific configuration
|
||||
type FieldConfig struct {
|
||||
Type string `mapstructure:"type"`
|
||||
Hidden bool `mapstructure:"hidden"`
|
||||
Searchable bool `mapstructure:"searchable"`
|
||||
MaxLength int `mapstructure:"max_length"`
|
||||
Length int `mapstructure:"length"`
|
||||
Markdown bool `mapstructure:"markdown"`
|
||||
Excerpt int `mapstructure:"excerpt"`
|
||||
Size string `mapstructure:"size"`
|
||||
Fit string `mapstructure:"fit"`
|
||||
Format string `mapstructure:"format"`
|
||||
Relative bool `mapstructure:"relative"`
|
||||
Colors map[string]string `mapstructure:"colors"`
|
||||
Separator string `mapstructure:"separator"`
|
||||
Primary bool `mapstructure:"primary"`
|
||||
AvatarField string `mapstructure:"avatar_field"`
|
||||
Suffix string `mapstructure:"suffix"`
|
||||
Prefix string `mapstructure:"prefix"`
|
||||
Options map[string]interface{} `mapstructure:"options"`
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration from file and environment variables
|
||||
func LoadConfig(configPath string) (*Config, error) {
|
||||
logger := slog.With("component", "config")
|
||||
|
||||
// Set default values
|
||||
viper.SetDefault("app.name", "Database Render")
|
||||
viper.SetDefault("app.theme", "modern")
|
||||
viper.SetDefault("app.language", "zh-CN")
|
||||
viper.SetDefault("app.port", 8080)
|
||||
|
||||
// Database defaults
|
||||
viper.SetDefault("database.type", "sqlite")
|
||||
viper.SetDefault("database.host", "localhost")
|
||||
viper.SetDefault("database.port", 3306)
|
||||
viper.SetDefault("database.dbname", "testdb")
|
||||
|
||||
// Set config file
|
||||
if configPath != "" {
|
||||
viper.SetConfigFile(configPath)
|
||||
} else {
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
viper.AddConfigPath(".")
|
||||
viper.AddConfigPath("./config")
|
||||
viper.AddConfigPath("/etc/database-render")
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
viper.SetEnvPrefix("DR")
|
||||
viper.AutomaticEnv()
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
|
||||
// Read config
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
logger.Error("failed to read config file", "error", err)
|
||||
return nil, fmt.Errorf("failed to read config: %w", err)
|
||||
}
|
||||
logger.Warn("config file not found, using defaults")
|
||||
}
|
||||
|
||||
// Parse database DSN if provided
|
||||
if dsn := viper.GetString("database"); dsn != "" {
|
||||
if err := parseDSN(dsn); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal config
|
||||
var config Config
|
||||
if err := viper.Unmarshal(&config); err != nil {
|
||||
logger.Error("failed to unmarshal config", "error", err)
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Validate config
|
||||
if err := validateConfig(&config); err != nil {
|
||||
logger.Error("config validation failed", "error", err)
|
||||
return nil, fmt.Errorf("config validation failed: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("configuration loaded successfully",
|
||||
"config_file", viper.ConfigFileUsed(),
|
||||
"tables_count", len(config.Tables))
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// parseDSN parses database connection string
|
||||
func parseDSN(dsn string) error {
|
||||
if strings.HasPrefix(dsn, "sqlite://") {
|
||||
path := strings.TrimPrefix(dsn, "sqlite://")
|
||||
viper.Set("database.type", "sqlite")
|
||||
viper.Set("database.path", path)
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dsn, "mysql://") {
|
||||
dsn = strings.TrimPrefix(dsn, "mysql://")
|
||||
parts := strings.Split(dsn, "@")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid mysql dsn format")
|
||||
}
|
||||
|
||||
userPass := strings.Split(parts[0], ":")
|
||||
if len(userPass) != 2 {
|
||||
return fmt.Errorf("invalid mysql user:pass format")
|
||||
}
|
||||
|
||||
hostPort := strings.Split(parts[1], "/")
|
||||
if len(hostPort) != 2 {
|
||||
return fmt.Errorf("invalid mysql host/db format")
|
||||
}
|
||||
|
||||
host := strings.Split(hostPort[0], ":")
|
||||
if len(host) == 2 {
|
||||
viper.Set("database.port", host[1])
|
||||
}
|
||||
|
||||
viper.Set("database.type", "mysql")
|
||||
viper.Set("database.user", userPass[0])
|
||||
viper.Set("database.password", userPass[1])
|
||||
viper.Set("database.host", host[0])
|
||||
viper.Set("database.dbname", hostPort[1])
|
||||
return nil
|
||||
}
|
||||
|
||||
if strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://") {
|
||||
viper.Set("database.type", "postgres")
|
||||
viper.Set("database.dsn", dsn)
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported database dsn format")
|
||||
}
|
||||
|
||||
// validateConfig validates the loaded configuration
|
||||
func validateConfig(config *Config) error {
|
||||
if len(config.Tables) == 0 {
|
||||
return fmt.Errorf("no tables configured")
|
||||
}
|
||||
|
||||
for name, table := range config.Tables {
|
||||
if name == "" {
|
||||
return fmt.Errorf("table name cannot be empty")
|
||||
}
|
||||
if table.Alias == "" {
|
||||
table.Alias = name
|
||||
}
|
||||
if table.Layout == "" {
|
||||
table.Layout = "card"
|
||||
}
|
||||
if table.PageSize == 0 {
|
||||
table.PageSize = 12
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTableConfig returns configuration for a specific table
|
||||
func (c *Config) GetTableConfig(tableName string) (*TableConfig, bool) {
|
||||
config, exists := c.Tables[tableName]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
return &config, true
|
||||
}
|
||||
|
||||
// GetDatabaseDSN returns the database connection string
|
||||
func (c *Config) GetDatabaseDSN() string {
|
||||
switch c.Database.Type {
|
||||
case "sqlite":
|
||||
return c.Database.Path
|
||||
case "mysql":
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
c.Database.User, c.Database.Password, c.Database.Host, c.Database.Port, c.Database.DBName)
|
||||
case "postgres":
|
||||
if c.Database.DSN != "" {
|
||||
return c.Database.DSN
|
||||
}
|
||||
return fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
c.Database.Host, c.Database.Port, c.Database.User, c.Database.Password, c.Database.DBName)
|
||||
default:
|
||||
return c.Database.DSN
|
||||
}
|
||||
}
|
||||
482
internal/database/connection.go
Normal file
482
internal/database/connection.go
Normal file
@@ -0,0 +1,482 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rogeecn/database_render/internal/config"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// ConnectionManager manages database connections
|
||||
type ConnectionManager struct {
|
||||
db *gorm.DB
|
||||
sqlDB *sql.DB
|
||||
config *config.DatabaseConfig
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewConnectionManager creates a new database connection manager
|
||||
func NewConnectionManager(config *config.Config) (*ConnectionManager, error) {
|
||||
logger := slog.With("component", "database")
|
||||
|
||||
cm := &ConnectionManager{
|
||||
config: &config.Database,
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
if err := cm.connect(); err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
if err := cm.configure(); err != nil {
|
||||
return nil, fmt.Errorf("failed to configure database: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("database connection established",
|
||||
"type", config.Database.Type,
|
||||
"host", config.Database.Host,
|
||||
"database", config.Database.DBName,
|
||||
)
|
||||
|
||||
return cm, nil
|
||||
}
|
||||
|
||||
// connect establishes the database connection
|
||||
func (cm *ConnectionManager) connect() error {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
switch cm.config.Type {
|
||||
case "sqlite":
|
||||
dialector = sqlite.Open(cm.config.Path)
|
||||
case "mysql":
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
cm.config.User, cm.config.Password, cm.config.Host, cm.config.Port, cm.config.DBName)
|
||||
dialector = mysql.Open(dsn)
|
||||
case "postgres":
|
||||
if cm.config.DSN != "" {
|
||||
dialector = postgres.Open(cm.config.DSN)
|
||||
} else {
|
||||
dsn := fmt.Sprintf("host=%s port=%d user=%s password=%s dbname=%s sslmode=disable",
|
||||
cm.config.Host, cm.config.Port, cm.config.User, cm.config.Password, cm.config.DBName)
|
||||
dialector = postgres.Open(dsn)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported database type: %s", cm.config.Type)
|
||||
}
|
||||
|
||||
gormConfig := &gorm.Config{
|
||||
Logger: logger.Default.LogMode(logger.Info),
|
||||
NowFunc: func() time.Time {
|
||||
return time.Now().Local()
|
||||
},
|
||||
}
|
||||
|
||||
db, err := gorm.Open(dialector, gormConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
cm.db = db
|
||||
|
||||
// Get underlying sql.DB for connection pooling
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sql.DB: %w", err)
|
||||
}
|
||||
cm.sqlDB = sqlDB
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configure sets up connection pool settings
|
||||
func (cm *ConnectionManager) configure() error {
|
||||
if cm.sqlDB == nil {
|
||||
return fmt.Errorf("sql.DB is nil")
|
||||
}
|
||||
|
||||
// Connection pool settings
|
||||
cm.sqlDB.SetMaxIdleConns(10)
|
||||
cm.sqlDB.SetMaxOpenConns(100)
|
||||
cm.sqlDB.SetConnMaxLifetime(time.Hour)
|
||||
|
||||
// Ping to verify connection
|
||||
if err := cm.sqlDB.Ping(); err != nil {
|
||||
return fmt.Errorf("failed to ping database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetDB returns the GORM database instance
|
||||
func (cm *ConnectionManager) GetDB() *gorm.DB {
|
||||
return cm.db
|
||||
}
|
||||
|
||||
// GetSQLDB returns the underlying SQL database instance
|
||||
func (cm *ConnectionManager) GetSQLDB() *sql.DB {
|
||||
return cm.sqlDB
|
||||
}
|
||||
|
||||
// Close closes the database connection
|
||||
func (cm *ConnectionManager) Close() error {
|
||||
if cm.sqlDB != nil {
|
||||
return cm.sqlDB.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Health checks the database health
|
||||
func (cm *ConnectionManager) Health() error {
|
||||
if cm.sqlDB == nil {
|
||||
return fmt.Errorf("database not initialized")
|
||||
}
|
||||
return cm.sqlDB.Ping()
|
||||
}
|
||||
|
||||
// GetTableNames returns all table names in the database
|
||||
func (cm *ConnectionManager) GetTableNames() ([]string, error) {
|
||||
var tableNames []string
|
||||
|
||||
switch cm.config.Type {
|
||||
case "sqlite":
|
||||
rows, err := cm.db.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'").Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tableNames = append(tableNames, name)
|
||||
}
|
||||
|
||||
case "mysql":
|
||||
rows, err := cm.db.Raw("SHOW TABLES").Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tableNames = append(tableNames, name)
|
||||
}
|
||||
|
||||
case "postgres":
|
||||
rows, err := cm.db.Raw("SELECT tablename FROM pg_tables WHERE schemaname = 'public'").Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tableNames = append(tableNames, name)
|
||||
}
|
||||
}
|
||||
|
||||
return tableNames, nil
|
||||
}
|
||||
|
||||
// GetTableColumns returns column information for a table
|
||||
func (cm *ConnectionManager) GetTableColumns(tableName string) ([]ColumnInfo, error) {
|
||||
var columns []ColumnInfo
|
||||
|
||||
switch cm.config.Type {
|
||||
case "sqlite":
|
||||
rows, err := cm.db.Raw(fmt.Sprintf("PRAGMA table_info(%s)", tableName)).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var col ColumnInfo
|
||||
var notused interface{}
|
||||
if err := rows.Scan(&col.Position, &col.Name, &col.Type, &col.NotNull, ¬used, &col.DefaultValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col.DatabaseType = cm.config.Type
|
||||
columns = append(columns, col)
|
||||
}
|
||||
|
||||
case "mysql":
|
||||
rows, err := cm.db.Raw(fmt.Sprintf("DESCRIBE %s", tableName)).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var col ColumnInfo
|
||||
var key, extra, nullStr string
|
||||
if err := rows.Scan(&col.Name, &col.Type, &nullStr, &key, &col.DefaultValue, &extra); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col.NotNull = nullStr == "NO"
|
||||
col.DatabaseType = cm.config.Type
|
||||
columns = append(columns, col)
|
||||
}
|
||||
|
||||
case "postgres":
|
||||
query := `
|
||||
SELECT
|
||||
column_name,
|
||||
data_type,
|
||||
is_nullable,
|
||||
column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = $1
|
||||
ORDER BY ordinal_position
|
||||
`
|
||||
rows, err := cm.db.Raw(query, tableName).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var col ColumnInfo
|
||||
var nullable string
|
||||
if err := rows.Scan(&col.Name, &col.Type, &nullable, &col.DefaultValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
col.NotNull = nullable == "NO"
|
||||
col.DatabaseType = cm.config.Type
|
||||
columns = append(columns, col)
|
||||
}
|
||||
}
|
||||
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// ColumnInfo represents database column information
|
||||
type ColumnInfo struct {
|
||||
Name string
|
||||
Type string
|
||||
NotNull bool
|
||||
DefaultValue interface{}
|
||||
Position int
|
||||
DatabaseType string
|
||||
}
|
||||
|
||||
// GetTableData retrieves paginated data from a table
|
||||
func (cm *ConnectionManager) GetTableData(
|
||||
tableName string,
|
||||
page, pageSize int,
|
||||
search string,
|
||||
sortField string,
|
||||
sortOrder string,
|
||||
) ([]map[string]interface{}, int64, error) {
|
||||
var total int64
|
||||
var data []map[string]interface{}
|
||||
|
||||
// Build count query for pagination
|
||||
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName)
|
||||
if search != "" {
|
||||
// For search, we need to get column names first
|
||||
columns, err := cm.GetTableColumns(tableName)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Build WHERE clause for text columns
|
||||
var whereConditions []string
|
||||
for _, col := range columns {
|
||||
if cm.isSearchableColumn(col.Type) {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("%s LIKE '%%%s%%'", col.Name, search))
|
||||
}
|
||||
}
|
||||
|
||||
if len(whereConditions) > 0 {
|
||||
countQuery += " WHERE " + strings.Join(whereConditions, " OR ")
|
||||
}
|
||||
}
|
||||
|
||||
if err := cm.db.Raw(countQuery).Scan(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Build data query
|
||||
dataQuery := fmt.Sprintf("SELECT * FROM %s", tableName)
|
||||
if search != "" {
|
||||
columns, err := cm.GetTableColumns(tableName)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
var whereConditions []string
|
||||
for _, col := range columns {
|
||||
if cm.isSearchableColumn(col.Type) {
|
||||
whereConditions = append(whereConditions, fmt.Sprintf("%s LIKE '%%%s%%'", col.Name, search))
|
||||
}
|
||||
}
|
||||
|
||||
if len(whereConditions) > 0 {
|
||||
dataQuery += " WHERE " + strings.Join(whereConditions, " OR ")
|
||||
}
|
||||
}
|
||||
|
||||
// Add sorting
|
||||
if sortField != "" {
|
||||
order := "ASC"
|
||||
if sortOrder == "desc" {
|
||||
order = "DESC"
|
||||
}
|
||||
dataQuery += fmt.Sprintf(" ORDER BY %s %s", sortField, order)
|
||||
}
|
||||
|
||||
// Add pagination
|
||||
offset := (page - 1) * pageSize
|
||||
dataQuery += fmt.Sprintf(" LIMIT %d OFFSET %d", pageSize, offset)
|
||||
|
||||
// Execute query
|
||||
rows, err := cm.db.Raw(dataQuery).Rows()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// Get column names
|
||||
columnNames, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Scan data
|
||||
for rows.Next() {
|
||||
values := make([]interface{}, len(columnNames))
|
||||
valuePtrs := make([]interface{}, len(columnNames))
|
||||
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columnNames {
|
||||
val := values[i]
|
||||
|
||||
// Handle NULL values
|
||||
if val == nil {
|
||||
row[col] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
// Convert []byte to string for JSON compatibility
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
|
||||
data = append(data, row)
|
||||
}
|
||||
|
||||
return data, total, nil
|
||||
}
|
||||
|
||||
// isSearchableColumn determines if a column type is searchable
|
||||
func (cm *ConnectionManager) isSearchableColumn(columnType string) bool {
|
||||
searchableTypes := []string{
|
||||
"VARCHAR", "TEXT", "CHAR", "STRING",
|
||||
"varchar", "text", "char", "string",
|
||||
}
|
||||
|
||||
for _, t := range searchableTypes {
|
||||
if strings.Contains(strings.ToUpper(columnType), strings.ToUpper(t)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// GetTableDataByID retrieves a single record by ID
|
||||
func (cm *ConnectionManager) GetTableDataByID(tableName string, id interface{}) (map[string]interface{}, error) {
|
||||
// Find primary key column
|
||||
columns, err := cm.GetTableColumns(tableName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var primaryKey string
|
||||
for _, col := range columns {
|
||||
// Assume 'id' is the primary key if it exists
|
||||
if col.Name == "id" || col.Name == "ID" {
|
||||
primaryKey = col.Name
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if primaryKey == "" {
|
||||
// Fallback to first column
|
||||
primaryKey = columns[0].Name
|
||||
}
|
||||
|
||||
query := fmt.Sprintf("SELECT * FROM %s WHERE %s = ?", tableName, primaryKey)
|
||||
|
||||
rows, err := cm.db.Raw(query, id).Rows()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
columnNames, err := rows.Columns()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if rows.Next() {
|
||||
values := make([]interface{}, len(columnNames))
|
||||
valuePtrs := make([]interface{}, len(columnNames))
|
||||
|
||||
for i := range values {
|
||||
valuePtrs[i] = &values[i]
|
||||
}
|
||||
|
||||
if err := rows.Scan(valuePtrs...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
row := make(map[string]interface{})
|
||||
for i, col := range columnNames {
|
||||
val := values[i]
|
||||
|
||||
if val == nil {
|
||||
row[col] = nil
|
||||
continue
|
||||
}
|
||||
|
||||
if b, ok := val.([]byte); ok {
|
||||
row[col] = string(b)
|
||||
} else {
|
||||
row[col] = val
|
||||
}
|
||||
}
|
||||
|
||||
return row, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("record not found")
|
||||
}
|
||||
161
internal/handler/data_handler.go
Normal file
161
internal/handler/data_handler.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/rogeecn/database_render/internal/service"
|
||||
)
|
||||
|
||||
// DataHandler handles HTTP requests for data operations
|
||||
type DataHandler struct {
|
||||
service *service.DataService
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewDataHandler creates a new data handler
|
||||
func NewDataHandler(service *service.DataService) *DataHandler {
|
||||
return &DataHandler{
|
||||
service: service,
|
||||
logger: slog.With("component", "handler"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTables returns all configured tables
|
||||
func (h *DataHandler) GetTables(c fiber.Ctx) error {
|
||||
tables, err := h.service.GetTables()
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get tables", "error", err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": "Failed to get tables",
|
||||
})
|
||||
}
|
||||
|
||||
h.logger.Debug("retrieved tables", "count", len(tables))
|
||||
return c.JSON(fiber.Map{
|
||||
"tables": tables,
|
||||
})
|
||||
}
|
||||
|
||||
// GetTableData returns paginated data for a table
|
||||
func (h *DataHandler) GetTableData(c fiber.Ctx) error {
|
||||
tableName := c.Params("table")
|
||||
if tableName == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Table name is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
page, err := strconv.Atoi(c.Query("page", "1"))
|
||||
if err != nil || page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
|
||||
pageSize, err := strconv.Atoi(c.Query("per_page", "10"))
|
||||
if err != nil || pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
search := c.Query("search", "")
|
||||
sortField := c.Query("sort", "")
|
||||
sortOrder := c.Query("order", "asc")
|
||||
|
||||
// Validate sort order
|
||||
if sortOrder != "asc" && sortOrder != "desc" {
|
||||
sortOrder = "asc"
|
||||
}
|
||||
|
||||
// Get table data
|
||||
data, err := h.service.GetTableData(tableName, page, pageSize, search, sortField, sortOrder)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get table data",
|
||||
"table", tableName,
|
||||
"page", page,
|
||||
"error", err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": fmt.Sprintf("Failed to get table data: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(data)
|
||||
}
|
||||
|
||||
// GetTableDetail returns a single record detail
|
||||
func (h *DataHandler) GetTableDetail(c fiber.Ctx) error {
|
||||
tableName := c.Params("table")
|
||||
if tableName == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Table name is required",
|
||||
})
|
||||
}
|
||||
|
||||
id := c.Params("id")
|
||||
if id == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "ID is required",
|
||||
})
|
||||
}
|
||||
|
||||
// Get table detail
|
||||
detail, err := h.service.GetTableDetail(tableName, id)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get table detail",
|
||||
"table", tableName,
|
||||
"id", id,
|
||||
"error", err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": fmt.Sprintf("Failed to get table detail: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(detail)
|
||||
}
|
||||
|
||||
// GetTableConfig returns the configuration for a specific table
|
||||
func (h *DataHandler) GetTableConfig(c fiber.Ctx) error {
|
||||
tableName := c.Params("table")
|
||||
if tableName == "" {
|
||||
return c.Status(http.StatusBadRequest).JSON(fiber.Map{
|
||||
"error": "Table name is required",
|
||||
})
|
||||
}
|
||||
|
||||
config, err := h.service.GetTableConfig(tableName)
|
||||
if err != nil {
|
||||
h.logger.Error("failed to get table config",
|
||||
"table", tableName,
|
||||
"error", err)
|
||||
return c.Status(http.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": fmt.Sprintf("Failed to get table config: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
return c.JSON(config)
|
||||
}
|
||||
|
||||
// Health returns the health status
|
||||
func (h *DataHandler) Health(c fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"status": "ok",
|
||||
"message": "Service is healthy",
|
||||
})
|
||||
}
|
||||
|
||||
// SetupRoutes sets up all the routes for the data handler
|
||||
func (h *DataHandler) SetupRoutes(app *fiber.App) {
|
||||
// API routes
|
||||
api := app.Group("/api")
|
||||
|
||||
// Table routes
|
||||
api.Get("/tables", h.GetTables)
|
||||
api.Get("/data/:table", h.GetTableData)
|
||||
api.Get("/data/:table/detail/:id", h.GetTableDetail)
|
||||
api.Get("/config/:table", h.GetTableConfig)
|
||||
|
||||
// Health check
|
||||
api.Get("/health", h.Health)
|
||||
}
|
||||
69
internal/model/table_config.go
Normal file
69
internal/model/table_config.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package model
|
||||
|
||||
// TableConfig represents the configuration for a database table
|
||||
type TableConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Alias string `json:"alias" yaml:"alias"`
|
||||
PageSize int `json:"page_size" yaml:"page_size"`
|
||||
Columns []ColumnConfig `json:"columns" yaml:"columns"`
|
||||
Filters []FilterConfig `json:"filters" yaml:"filters"`
|
||||
SortFields []string `json:"sort_fields" yaml:"sort_fields"`
|
||||
Options map[string]interface{} `json:"options" yaml:"options"`
|
||||
}
|
||||
|
||||
// ColumnConfig represents the configuration for a table column
|
||||
type ColumnConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Alias string `json:"alias" yaml:"alias"`
|
||||
RenderType string `json:"render_type" yaml:"render_type"`
|
||||
Sortable bool `json:"sortable" yaml:"sortable"`
|
||||
Searchable bool `json:"searchable" yaml:"searchable"`
|
||||
ShowInList bool `json:"show_in_list" yaml:"show_in_list"`
|
||||
IsPrimaryContent bool `json:"is_primary_content" yaml:"is_primary_content"`
|
||||
MaxLength int `json:"max_length" yaml:"max_length"`
|
||||
Width string `json:"width" yaml:"width"`
|
||||
Format string `json:"format" yaml:"format"`
|
||||
Values map[string]TagValue `json:"values" yaml:"values"`
|
||||
Options map[string]interface{} `json:"options" yaml:"options"`
|
||||
}
|
||||
|
||||
// FilterConfig represents the configuration for a table filter
|
||||
type FilterConfig struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Options []interface{} `json:"options" yaml:"options"`
|
||||
}
|
||||
|
||||
// TagValue represents a tag value with label and color
|
||||
type TagValue struct {
|
||||
Label string `json:"label" yaml:"label"`
|
||||
Color string `json:"color" yaml:"color"`
|
||||
}
|
||||
|
||||
// DataResponse represents the API response structure
|
||||
type DataResponse struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PerPage int `json:"per_page"`
|
||||
Pages int `json:"pages"`
|
||||
Table string `json:"table"`
|
||||
Columns []ColumnConfig `json:"columns"`
|
||||
Filters []FilterConfig `json:"filters"`
|
||||
}
|
||||
|
||||
// TableResponse represents the table list response
|
||||
type TableResponse struct {
|
||||
Tables []TableInfo `json:"tables"`
|
||||
}
|
||||
|
||||
// TableInfo represents basic table information
|
||||
type TableInfo struct {
|
||||
Name string `json:"name"`
|
||||
Alias string `json:"alias"`
|
||||
}
|
||||
|
||||
// DetailResponse represents a single record detail response
|
||||
type DetailResponse struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
192
internal/repository/data_repository.go
Normal file
192
internal/repository/data_repository.go
Normal file
@@ -0,0 +1,192 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/rogeecn/database_render/internal/config"
|
||||
"github.com/rogeecn/database_render/internal/database"
|
||||
"github.com/rogeecn/database_render/internal/model"
|
||||
)
|
||||
|
||||
// DataRepository handles data access operations
|
||||
type DataRepository struct {
|
||||
db *database.ConnectionManager
|
||||
config *config.Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewDataRepository creates a new data repository
|
||||
func NewDataRepository(db *database.ConnectionManager, cfg *config.Config) *DataRepository {
|
||||
return &DataRepository{
|
||||
db: db,
|
||||
config: cfg,
|
||||
logger: slog.With("component", "repository"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTables returns all configured tables
|
||||
func (r *DataRepository) GetTables() ([]model.TableInfo, error) {
|
||||
var tables []model.TableInfo
|
||||
|
||||
for name, tableConfig := range r.config.Tables {
|
||||
tables = append(tables, model.TableInfo{
|
||||
Name: name,
|
||||
Alias: tableConfig.Alias,
|
||||
})
|
||||
}
|
||||
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// GetTableConfig returns the configuration for a specific table
|
||||
func (r *DataRepository) GetTableConfig(tableName string) (*model.TableConfig, error) {
|
||||
tableConfig, exists := r.config.Tables[tableName]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("table %s not found in configuration", tableName)
|
||||
}
|
||||
|
||||
// Convert internal config to model config
|
||||
config := &model.TableConfig{
|
||||
Name: tableName,
|
||||
Alias: tableConfig.Alias,
|
||||
PageSize: tableConfig.PageSize,
|
||||
Columns: []model.ColumnConfig{},
|
||||
Filters: []model.FilterConfig{},
|
||||
Options: tableConfig.Options,
|
||||
}
|
||||
|
||||
// Convert field configurations
|
||||
for fieldName, fieldConfig := range tableConfig.Fields {
|
||||
column := model.ColumnConfig{
|
||||
Name: fieldName,
|
||||
Alias: fieldName,
|
||||
RenderType: fieldConfig.Type,
|
||||
Sortable: true, // Default to true
|
||||
Searchable: fieldConfig.Searchable,
|
||||
ShowInList: !fieldConfig.Hidden,
|
||||
IsPrimaryContent: fieldConfig.Markdown,
|
||||
MaxLength: fieldConfig.MaxLength,
|
||||
Width: fieldConfig.Size,
|
||||
Format: fieldConfig.Format,
|
||||
Values: make(map[string]model.TagValue),
|
||||
Options: fieldConfig.Options,
|
||||
}
|
||||
|
||||
// Handle tag values if colors are provided
|
||||
if len(fieldConfig.Colors) > 0 {
|
||||
for key, color := range fieldConfig.Colors {
|
||||
label := key
|
||||
column.Values[key] = model.TagValue{
|
||||
Label: label,
|
||||
Color: color,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.Columns = append(config.Columns, column)
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// GetTableData retrieves paginated data from a table
|
||||
func (r *DataRepository) GetTableData(tableName string, page, pageSize int, search string, sortField string, sortOrder string) ([]map[string]interface{}, int64, error) {
|
||||
// Validate table exists in config
|
||||
_, exists := r.config.Tables[tableName]
|
||||
if !exists {
|
||||
return nil, 0, fmt.Errorf("table %s not found in configuration", tableName)
|
||||
}
|
||||
|
||||
// Get data from database
|
||||
data, total, err := r.db.GetTableData(tableName, page, pageSize, search, sortField, sortOrder)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get table data",
|
||||
"table", tableName,
|
||||
"page", page,
|
||||
"error", err)
|
||||
return nil, 0, fmt.Errorf("failed to get table data: %w", err)
|
||||
}
|
||||
|
||||
r.logger.Debug("retrieved table data",
|
||||
"table", tableName,
|
||||
"page", page,
|
||||
"pageSize", pageSize,
|
||||
"total", total,
|
||||
"records", len(data))
|
||||
|
||||
return data, total, nil
|
||||
}
|
||||
|
||||
// GetTableDataByID retrieves a single record by ID
|
||||
func (r *DataRepository) GetTableDataByID(tableName string, id interface{}) (map[string]interface{}, error) {
|
||||
// Validate table exists in config
|
||||
_, exists := r.config.Tables[tableName]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("table %s not found in configuration", tableName)
|
||||
}
|
||||
|
||||
// Get data from database
|
||||
data, err := r.db.GetTableDataByID(tableName, id)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get table data by ID",
|
||||
"table", tableName,
|
||||
"id", id,
|
||||
"error", err)
|
||||
return nil, fmt.Errorf("failed to get table data by ID: %w", err)
|
||||
}
|
||||
|
||||
r.logger.Debug("retrieved single record",
|
||||
"table", tableName,
|
||||
"id", id)
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// GetTableColumns returns column information for a table
|
||||
func (r *DataRepository) GetTableColumns(tableName string) ([]database.ColumnInfo, error) {
|
||||
// Validate table exists in config
|
||||
_, exists := r.config.Tables[tableName]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("table %s not found in configuration", tableName)
|
||||
}
|
||||
|
||||
// Get column information from database
|
||||
columns, err := r.db.GetTableColumns(tableName)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get table columns",
|
||||
"table", tableName,
|
||||
"error", err)
|
||||
return nil, fmt.Errorf("failed to get table columns: %w", err)
|
||||
}
|
||||
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// ValidateTableConfig validates if the configured tables exist in the database
|
||||
func (r *DataRepository) ValidateTableConfig() error {
|
||||
// Get all table names from database
|
||||
dbTables, err := r.db.GetTableNames()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get table names from database: %w", err)
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
dbTableMap := make(map[string]bool)
|
||||
for _, table := range dbTables {
|
||||
dbTableMap[table] = true
|
||||
}
|
||||
|
||||
// Check if all configured tables exist
|
||||
for tableName := range r.config.Tables {
|
||||
if !dbTableMap[tableName] {
|
||||
return fmt.Errorf("table %s not found in database", tableName)
|
||||
}
|
||||
}
|
||||
|
||||
r.logger.Info("table configuration validation completed",
|
||||
"configured_tables", len(r.config.Tables),
|
||||
"database_tables", len(dbTables))
|
||||
|
||||
return nil
|
||||
}
|
||||
204
internal/service/data_service.go
Normal file
204
internal/service/data_service.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/rogeecn/database_render/internal/config"
|
||||
"github.com/rogeecn/database_render/internal/database"
|
||||
"github.com/rogeecn/database_render/internal/model"
|
||||
"github.com/rogeecn/database_render/internal/repository"
|
||||
)
|
||||
|
||||
// DataService handles business logic for data operations
|
||||
type DataService struct {
|
||||
repo *repository.DataRepository
|
||||
config *config.Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewDataService creates a new data service
|
||||
func NewDataService(repo *repository.DataRepository, cfg *config.Config) *DataService {
|
||||
return &DataService{
|
||||
repo: repo,
|
||||
config: cfg,
|
||||
logger: slog.With("component", "service"),
|
||||
}
|
||||
}
|
||||
|
||||
// GetTables returns all configured tables
|
||||
func (s *DataService) GetTables() ([]model.TableInfo, error) {
|
||||
tables, err := s.repo.GetTables()
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get tables", "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
s.logger.Debug("retrieved tables", "count", len(tables))
|
||||
return tables, nil
|
||||
}
|
||||
|
||||
// GetTableData returns paginated data for a table
|
||||
func (s *DataService) GetTableData(tableName string, page, pageSize int, search string, sortField string, sortOrder string) (*model.DataResponse, error) {
|
||||
// Validate table exists
|
||||
tableConfig, err := s.repo.GetTableConfig(tableName)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get table config", "table", tableName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Use configured page size if not provided
|
||||
if pageSize <= 0 {
|
||||
pageSize = tableConfig.PageSize
|
||||
}
|
||||
|
||||
// Validate page and page size
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
if pageSize > 100 {
|
||||
pageSize = 100 // Limit maximum page size
|
||||
}
|
||||
|
||||
// Get data from repository
|
||||
data, total, err := s.repo.GetTableData(tableName, page, pageSize, search, sortField, sortOrder)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get table data",
|
||||
"table", tableName,
|
||||
"page", page,
|
||||
"pageSize", pageSize,
|
||||
"search", search,
|
||||
"error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Calculate total pages
|
||||
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
|
||||
|
||||
response := &model.DataResponse{
|
||||
Data: data,
|
||||
Total: total,
|
||||
Page: page,
|
||||
PerPage: pageSize,
|
||||
Pages: totalPages,
|
||||
Table: tableName,
|
||||
Columns: tableConfig.Columns,
|
||||
Filters: tableConfig.Filters,
|
||||
}
|
||||
|
||||
s.logger.Info("retrieved table data",
|
||||
"table", tableName,
|
||||
"page", page,
|
||||
"pageSize", pageSize,
|
||||
"total", total,
|
||||
"pages", totalPages)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetTableDetail returns a single record detail
|
||||
func (s *DataService) GetTableDetail(tableName string, id interface{}) (*model.DetailResponse, error) {
|
||||
// Validate table exists
|
||||
_, err := s.repo.GetTableConfig(tableName)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get table config", "table", tableName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get data from repository
|
||||
data, err := s.repo.GetTableDataByID(tableName, id)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get table detail",
|
||||
"table", tableName,
|
||||
"id", id,
|
||||
"error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := &model.DetailResponse{
|
||||
Data: data,
|
||||
}
|
||||
|
||||
s.logger.Debug("retrieved table detail",
|
||||
"table", tableName,
|
||||
"id", id)
|
||||
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// GetTableColumns returns column information for a table
|
||||
func (s *DataService) GetTableColumns(tableName string) ([]database.ColumnInfo, error) {
|
||||
// Validate table exists
|
||||
_, err := s.repo.GetTableConfig(tableName)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get table config", "table", tableName, "error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get column information from repository
|
||||
columns, err := s.repo.GetTableColumns(tableName)
|
||||
if err != nil {
|
||||
s.logger.Error("failed to get table columns",
|
||||
"table", tableName,
|
||||
"error", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return columns, nil
|
||||
}
|
||||
|
||||
// ValidateConfiguration validates the entire configuration
|
||||
func (s *DataService) ValidateConfiguration() error {
|
||||
// Validate tables configuration
|
||||
if len(s.config.Tables) == 0 {
|
||||
return fmt.Errorf("no tables configured")
|
||||
}
|
||||
|
||||
// Validate table existence in database
|
||||
if err := s.repo.ValidateTableConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Validate individual table configurations
|
||||
for tableName, tableConfig := range s.config.Tables {
|
||||
if tableConfig.Alias == "" {
|
||||
return fmt.Errorf("table %s has empty alias", tableName)
|
||||
}
|
||||
|
||||
if tableConfig.PageSize <= 0 {
|
||||
return fmt.Errorf("table %s has invalid page size", tableName)
|
||||
}
|
||||
|
||||
// Validate field configurations
|
||||
for fieldName, fieldConfig := range tableConfig.Fields {
|
||||
if fieldConfig.Type == "" {
|
||||
return fmt.Errorf("field %s in table %s has empty type", fieldName, tableName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("configuration validation completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTableConfig returns the configuration for a specific table
|
||||
func (s *DataService) GetTableConfig(tableName string) (*model.TableConfig, error) {
|
||||
return s.repo.GetTableConfig(tableName)
|
||||
}
|
||||
|
||||
// GetDefaultTable returns the first configured table name
|
||||
func (s *DataService) GetDefaultTable() (string, error) {
|
||||
if len(s.config.Tables) == 0 {
|
||||
return "", fmt.Errorf("no tables configured")
|
||||
}
|
||||
|
||||
// Return the first table name
|
||||
for tableName := range s.config.Tables {
|
||||
return tableName, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("no tables configured")
|
||||
}
|
||||
250
internal/template/renderer.go
Normal file
250
internal/template/renderer.go
Normal file
@@ -0,0 +1,250 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"github.com/gofiber/fiber/v3/middleware/static"
|
||||
"github.com/rogeecn/database_render/internal/config"
|
||||
"github.com/rogeecn/database_render/internal/service"
|
||||
)
|
||||
|
||||
// Renderer handles template rendering
|
||||
type Renderer struct {
|
||||
templates *template.Template
|
||||
service *service.DataService
|
||||
config *config.Config
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
// NewRenderer creates a new template renderer
|
||||
func NewRenderer(service *service.DataService, cfg *config.Config) (*Renderer, error) {
|
||||
r := &Renderer{
|
||||
service: service,
|
||||
config: cfg,
|
||||
logger: slog.With("component", "renderer"),
|
||||
}
|
||||
|
||||
if err := r.loadTemplates(); err != nil {
|
||||
return nil, fmt.Errorf("failed to load templates: %w", err)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// loadTemplates loads all templates from the templates directory
|
||||
func (r *Renderer) loadTemplates() error {
|
||||
// Define template functions
|
||||
funcMap := template.FuncMap{
|
||||
"dict": func(values ...interface{}) map[string]interface{} {
|
||||
dict := make(map[string]interface{})
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
if i+1 < len(values) {
|
||||
key := values[i].(string)
|
||||
dict[key] = values[i+1]
|
||||
}
|
||||
}
|
||||
return dict
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"mul": func(a, b int) int { return a * b },
|
||||
"min": func(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
},
|
||||
"max": func(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
},
|
||||
"json": func(v interface{}) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
},
|
||||
"split": func(s string, sep string) []string {
|
||||
return strings.Split(s, sep)
|
||||
},
|
||||
"formatTime": func(t interface{}) string {
|
||||
switch v := t.(type) {
|
||||
case time.Time:
|
||||
return v.Format("2006-01-02 15:04:05")
|
||||
case string:
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
},
|
||||
"renderField": func(value interface{}, renderType string, column interface{}) template.HTML {
|
||||
switch renderType {
|
||||
case "time":
|
||||
return template.HTML(r.formatTime(value))
|
||||
case "tag":
|
||||
if columnMap, ok := column.(map[string]interface{}); ok {
|
||||
if values, ok := columnMap["values"].(map[string]interface{}); ok {
|
||||
if tag, ok := values[fmt.Sprintf("%v", value)].(map[string]interface{}); ok {
|
||||
color := tag["color"].(string)
|
||||
label := tag["label"].(string)
|
||||
return template.HTML(fmt.Sprintf(
|
||||
` <span class="tag" style="background-color: %s; color: white;">%s</span> `,
|
||||
color, label,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return template.HTML(fmt.Sprintf("%v", value))
|
||||
case "markdown":
|
||||
// Return raw content for client-side markdown rendering
|
||||
return template.HTML(fmt.Sprintf("%v", value))
|
||||
default:
|
||||
return template.HTML(fmt.Sprintf("%v", value))
|
||||
}
|
||||
},
|
||||
"truncate": func(s string, length int) string {
|
||||
if len(s) <= length {
|
||||
return s
|
||||
}
|
||||
if length > 3 {
|
||||
return s[:length-3] + "..."
|
||||
}
|
||||
return s[:length]
|
||||
},
|
||||
"eq": func(a, b interface{}) bool {
|
||||
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
||||
},
|
||||
}
|
||||
|
||||
// Load templates
|
||||
templateDir := "web/templates"
|
||||
pattern := filepath.Join(templateDir, "*.html")
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseGlob(pattern)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse templates: %w", err)
|
||||
}
|
||||
|
||||
r.templates = tmpl
|
||||
r.logger.Info("templates loaded successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatTime formats time values for display
|
||||
func (r *Renderer) formatTime(value interface{}) string {
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
return v.Format("2006-01-02 15:04:05")
|
||||
case string:
|
||||
// Try to parse as time
|
||||
if t, err := time.Parse("2006-01-02T15:04:05Z", v); err == nil {
|
||||
return t.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderList renders the list view for a table
|
||||
func (r *Renderer) RenderList(c fiber.Ctx, tableName string) error {
|
||||
// Parse query parameters
|
||||
page, _ := strconv.Atoi(c.Query("page", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.Query("per_page", "10"))
|
||||
search := c.Query("search", "")
|
||||
sortField := c.Query("sort", "")
|
||||
sortOrder := c.Query("order", "asc")
|
||||
|
||||
// Get table data
|
||||
data, err := r.service.GetTableData(tableName, page, pageSize, search, sortField, sortOrder)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get table data", "error", err)
|
||||
return c.Status(http.StatusInternalServerError).SendString("Failed to load table data")
|
||||
}
|
||||
|
||||
// Get all tables for navigation
|
||||
tables, err := r.service.GetTables()
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get tables", "error", err)
|
||||
return c.Status(http.StatusInternalServerError).SendString("Failed to load tables")
|
||||
}
|
||||
|
||||
// Get table alias from config
|
||||
tableConfig, err := r.service.GetTableConfig(tableName)
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get table config", "error", err)
|
||||
return c.Status(http.StatusInternalServerError).SendString("Failed to load table configuration")
|
||||
}
|
||||
|
||||
// Prepare template data
|
||||
templateData := map[string]interface{}{
|
||||
"Table": tableName,
|
||||
"TableAlias": tableConfig.Alias,
|
||||
"Columns": data.Columns,
|
||||
"Data": data.Data,
|
||||
"Total": data.Total,
|
||||
"Page": data.Page,
|
||||
"PerPage": data.PerPage,
|
||||
"Pages": data.Pages,
|
||||
"Search": search,
|
||||
"SortField": sortField,
|
||||
"SortOrder": sortOrder,
|
||||
"Tables": tables,
|
||||
"CurrentPath": c.Path(),
|
||||
}
|
||||
|
||||
// set content-type html
|
||||
c.Response().Header.Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
// Render template
|
||||
return r.templates.ExecuteTemplate(c.Response().BodyWriter(), "list.html", templateData)
|
||||
}
|
||||
|
||||
// RenderIndex renders the index page
|
||||
func (r *Renderer) RenderIndex(c fiber.Ctx) error {
|
||||
// Get default table
|
||||
defaultTable, err := r.service.GetDefaultTable()
|
||||
if err != nil {
|
||||
r.logger.Error("failed to get default table", "error", err)
|
||||
return c.Status(http.StatusInternalServerError).SendString("No tables configured")
|
||||
}
|
||||
|
||||
// Redirect to default table
|
||||
return c.Redirect().To(fmt.Sprintf("/?table=%s", defaultTable))
|
||||
}
|
||||
|
||||
// ServeStatic serves static files
|
||||
func (r *Renderer) ServeStatic(app *fiber.App) {
|
||||
// Serve static files
|
||||
app.Use("/static/*", static.New("web/static"))
|
||||
app.Use("/css/*", static.New("web/static/css"))
|
||||
app.Use("/js/*", static.New("web/static/js"))
|
||||
app.Use("/images/*", static.New("web/static/images"))
|
||||
}
|
||||
|
||||
// NotFoundHandler handles 404 errors
|
||||
func (r *Renderer) NotFoundHandler(c fiber.Ctx) error {
|
||||
return c.Status(http.StatusNotFound).SendString("Page not found")
|
||||
}
|
||||
|
||||
// ErrorHandler handles errors
|
||||
func (r *Renderer) ErrorHandler(c fiber.Ctx, err error) error {
|
||||
code := fiber.StatusInternalServerError
|
||||
if e, ok := err.(*fiber.Error); ok {
|
||||
code = e.Code
|
||||
}
|
||||
|
||||
r.logger.Error("request error", "error", err, "code", code)
|
||||
|
||||
return c.Status(code).JSON(fiber.Map{
|
||||
"error": err.Error(),
|
||||
})
|
||||
}
|
||||
10
main.go
Normal file
10
main.go
Normal file
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
|
||||
*/
|
||||
package main
|
||||
|
||||
import "github.com/rogeecn/database_render/cmd"
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
}
|
||||
470
requirements.md
Normal file
470
requirements.md
Normal file
@@ -0,0 +1,470 @@
|
||||
# 数据库动态渲染系统 - 完整需求文档
|
||||
|
||||
## 项目概述
|
||||
|
||||
构建一个可配置的数据渲染应用,通过读取 YAML 配置文件连接到指定数据库,根据配置查询不同数据表的内容,并按照预设的格式和模板进行处理,最终通过通用的列表页面向前端用户分页展示数据。
|
||||
|
||||
## 技术栈与框架选型
|
||||
|
||||
### 后端技术栈
|
||||
|
||||
- **HTTP 框架**: `github.com/gofiber/fiber/v3` - 高性能、零内存分配的 Go Web 框架
|
||||
- **数据库 ORM**: `gorm.io/gorm` - 强大的数据库 ORM,支持多种数据库
|
||||
- **日志系统**: `log/slog` - Go 官方结构化日志库
|
||||
- **配置管理**: `github.com/spf13/viper` - 支持动态监听配置文件修改
|
||||
- **依赖注入**: `github.com/google/wire` - Google 的依赖注入工具
|
||||
|
||||
### 数据库支持
|
||||
|
||||
- **SQLite**: `github.com/mattn/go-sqlite3` - 轻量级嵌入式数据库
|
||||
- **MySQL**: `github.com/go-sql-driver/mysql` - MySQL 驱动
|
||||
- **PostgreSQL**: `github.com/lib/pq` - PostgreSQL 驱动
|
||||
|
||||
### 前端技术栈
|
||||
|
||||
- **HTML 模板**: Go 标准库 `html/template` - 服务端模板渲染
|
||||
- **CSS 框架**: TailwindCSS - 实用优先的 CSS 框架
|
||||
- **JavaScript**: 原生 JavaScript (ES6+) - 轻量级交互
|
||||
- **Markdown 渲染**: `marked.js` - Markdown 转 HTML
|
||||
|
||||
## 核心功能需求
|
||||
|
||||
### 1. 配置系统 (YAML)
|
||||
|
||||
#### 1.1 配置文件结构
|
||||
|
||||
```yaml
|
||||
database:
|
||||
type: sqlite
|
||||
path: ./data/content.db
|
||||
# MySQL配置示例
|
||||
# type: mysql
|
||||
# host: localhost
|
||||
# port: 3306
|
||||
# user: root
|
||||
# password: password
|
||||
# dbname: testdb
|
||||
|
||||
tables:
|
||||
- name: "articles"
|
||||
alias: "技术文章"
|
||||
page_size: 15
|
||||
columns:
|
||||
- name: "id"
|
||||
alias: "ID"
|
||||
render_type: "raw"
|
||||
sortable: true
|
||||
width: "80px"
|
||||
|
||||
- name: "title"
|
||||
alias: "标题"
|
||||
render_type: "raw"
|
||||
searchable: true
|
||||
max_length: 50
|
||||
|
||||
- name: "content"
|
||||
alias: "内容"
|
||||
render_type: "markdown"
|
||||
is_primary_content: true # 弹窗展示全文
|
||||
show_in_list: false
|
||||
|
||||
- name: "category"
|
||||
alias: "分类"
|
||||
render_type: "category"
|
||||
|
||||
- name: "tags"
|
||||
alias: "标签"
|
||||
render_type: "tag"
|
||||
values:
|
||||
1: { label: "Go", color: "#00ADD8" }
|
||||
2: { label: "JavaScript", color: "#f7df1e" }
|
||||
|
||||
- name: "created_at"
|
||||
alias: "发布时间"
|
||||
render_type: "time"
|
||||
format: "2006-01-02 15:04:05"
|
||||
sortable: true
|
||||
|
||||
filters:
|
||||
- name: "category"
|
||||
type: "select"
|
||||
options: ["全部", "技术", "生活"]
|
||||
|
||||
- name: "logs"
|
||||
alias: "系统日志"
|
||||
page_size: 50
|
||||
columns:
|
||||
- name: "id"
|
||||
alias: "ID"
|
||||
render_type: "raw"
|
||||
|
||||
- name: "level"
|
||||
alias: "级别"
|
||||
render_type: "tag"
|
||||
values:
|
||||
1: { label: "INFO", color: "#52c41a" }
|
||||
2: { label: "WARN", color: "#faad14" }
|
||||
3: { label: "ERROR", color: "#f5222d" }
|
||||
|
||||
- name: "message"
|
||||
alias: "日志信息"
|
||||
render_type: "raw"
|
||||
is_primary_content: true
|
||||
|
||||
- name: "timestamp"
|
||||
alias: "时间"
|
||||
render_type: "time"
|
||||
format: "2006-01-02 15:04:05"
|
||||
```
|
||||
|
||||
### 2. 字段渲染类型规范
|
||||
|
||||
| 类型 | 描述 | 配置参数 | 示例 |
|
||||
| ---------- | ------------- | ------------------ | ------------------- |
|
||||
| `raw` | 原始文本 | 无 | 普通文本显示 |
|
||||
| `markdown` | Markdown 渲染 | `max_length` | 文章内容 |
|
||||
| `time` | 时间格式化 | `format` | 2024-01-01 12:00:00 |
|
||||
| `tag` | 标签样式 | `values`, `colors` | 状态标签 |
|
||||
| `category` | 分类显示 | 无 | 分类名称 |
|
||||
| `html` | HTML 内容 | `sanitize` | 富文本内容 |
|
||||
|
||||
### 3. 后端 API 接口
|
||||
|
||||
#### 3.1 获取表列表
|
||||
|
||||
```http
|
||||
GET /api/tables
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"tables": ["技术文章", "系统日志"]
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.2 获取分页数据
|
||||
|
||||
```http
|
||||
GET /api/data/{table_alias}?page=1&per_page=20&search=keyword&sort=created_at&order=desc
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "Go语言入门",
|
||||
"category": "技术",
|
||||
"tags": "Go",
|
||||
"created_at": "2024-01-01 12:00:00"
|
||||
}
|
||||
],
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"pages": 5
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Go 模板引擎规范
|
||||
|
||||
#### 4.1 通用列表模板
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{{.TableAlias}} - 数据管理</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
.tag {
|
||||
@apply inline-block px-2 py-1 text-xs font-medium rounded-full;
|
||||
}
|
||||
.modal {
|
||||
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden;
|
||||
}
|
||||
.modal-content {
|
||||
@apply bg-white rounded-lg max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- 表选择器 -->
|
||||
<div class="mb-6">
|
||||
<select
|
||||
id="tableSelector"
|
||||
class="border rounded px-3 py-2"
|
||||
onchange="changeTable(this.value)"
|
||||
>
|
||||
{{range .Tables}}
|
||||
<option value="{{.}}" {{if eq . $.CurrentTable}}selected{{end}}>
|
||||
{{.}}
|
||||
</option>
|
||||
{{end}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{{range .Columns}} {{if .ShowInList}}
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
{{.Alias}}
|
||||
</th>
|
||||
{{end}} {{end}}
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider"
|
||||
>
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{{range .Data}}
|
||||
<tr>
|
||||
{{range $.Columns}} {{if .ShowInList}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{template "render_field" dict "Value" (index $.Data (printf
|
||||
"%s" .Name)) "Type" .RenderType "Column" .}}
|
||||
</td>
|
||||
{{end}} {{end}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onclick="showDetail('{{.ID}}')"
|
||||
class="text-blue-600 hover:text-blue-900"
|
||||
>
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 分页 -->
|
||||
<div class="mt-4 flex justify-between items-center">
|
||||
<div class="text-sm text-gray-700">
|
||||
共 {{.Total}} 条记录,第 {{.Page}} / {{.Pages}} 页
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
{{if gt .Page 1}}
|
||||
<a
|
||||
href="?page={{sub .Page 1}}&per_page={{.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm"
|
||||
>上一页</a
|
||||
>
|
||||
{{end}} {{if lt .Page .Pages}}
|
||||
<a
|
||||
href="?page={{add .Page 1}}&per_page={{.PerPage}}"
|
||||
class="px-3 py-1 border rounded text-sm"
|
||||
>下一页</a
|
||||
>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<div id="detailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="p-6">
|
||||
<h3 class="text-lg font-medium mb-4">详情信息</h3>
|
||||
<div id="detailContent"></div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button
|
||||
onclick="closeModal()"
|
||||
class="px-4 py-2 bg-gray-300 rounded"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 渲染字段模板函数
|
||||
function renderField(value, type, column) {
|
||||
switch(type) {
|
||||
case 'time':
|
||||
return new Date(value).toLocaleString('zh-CN');
|
||||
case 'tag':
|
||||
const tagValue = column.values[value];
|
||||
return `<span class="tag" style="background-color: ${tagValue.color}; color: white;">${tagValue.label}</span>`;
|
||||
case 'markdown':
|
||||
return marked.parse(value || '');
|
||||
default:
|
||||
return value || '';
|
||||
}
|
||||
}
|
||||
|
||||
// 显示详情
|
||||
function showDetail(id) {
|
||||
fetch(`/api/data/{{.CurrentTable}}/detail/${id}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
let content = '';
|
||||
{{range .Columns}}
|
||||
{{if .IsPrimaryContent}}
|
||||
content += `<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700">{{.Alias}}</label>
|
||||
<div class="mt-1 text-sm text-gray-900">${renderField(data.{{.Name}}, '{{.RenderType}}', {{. | json}})}</div>
|
||||
</div>`;
|
||||
{{end}}
|
||||
{{end}}
|
||||
document.getElementById('detailContent').innerHTML = content;
|
||||
document.getElementById('detailModal').classList.remove('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// 切换表格
|
||||
function changeTable(tableAlias) {
|
||||
window.location.href = `/?table=${tableAlias}&page=1`;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
#### 4.2 模板辅助函数
|
||||
|
||||
```go
|
||||
// 模板函数注册
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
"dict": func(values ...interface{}) map[string]interface{} {
|
||||
dict := make(map[string]interface{})
|
||||
for i := 0; i < len(values); i += 2 {
|
||||
key := values[i].(string)
|
||||
dict[key] = values[i+1]
|
||||
}
|
||||
return dict
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"sub": func(a, b int) int { return a - b },
|
||||
"json": func(v interface{}) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
},
|
||||
"renderField": func(value interface{}, renderType string, column interface{}) template.HTML {
|
||||
switch renderType {
|
||||
case "time":
|
||||
if t, ok := value.(time.Time); ok {
|
||||
return template.HTML(t.Format("2006-01-02 15:04:05"))
|
||||
}
|
||||
return template.HTML(fmt.Sprintf("%v", value))
|
||||
case "tag":
|
||||
if columnMap, ok := column.(map[string]interface{}); ok {
|
||||
if values, ok := columnMap["values"].(map[string]interface{}); ok {
|
||||
if tag, ok := values[fmt.Sprintf("%v", value)].(map[string]interface{}); ok {
|
||||
color := tag["color"].(string)
|
||||
label := tag["label"].(string)
|
||||
return template.HTML(fmt.Sprintf(
|
||||
`<span class="tag" style="background-color: %s; color: white;">%s</span>`,
|
||||
color, label,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
return template.HTML(fmt.Sprintf("%v", value))
|
||||
case "markdown":
|
||||
// 服务端渲染Markdown
|
||||
return template.HTML(fmt.Sprintf("%v", value))
|
||||
default:
|
||||
return template.HTML(fmt.Sprintf("%v", value))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
database-render/
|
||||
├── cmd/
|
||||
│ └── server/
|
||||
│ └── main.go
|
||||
├── internal/
|
||||
│ ├── config/
|
||||
│ │ └── config.go
|
||||
│ ├── database/
|
||||
│ │ └── connection.go
|
||||
│ ├── handler/
|
||||
│ │ └── data_handler.go
|
||||
│ ├── model/
|
||||
│ │ └── table_config.go
|
||||
│ ├── repository/
|
||||
│ │ └── data_repository.go
|
||||
│ ├── service/
|
||||
│ │ └── data_service.go
|
||||
│ └── template/
|
||||
│ └── renderer.go
|
||||
├── web/
|
||||
│ ├── static/
|
||||
│ │ ├── css/
|
||||
│ │ ├── js/
|
||||
│ │ └── images/
|
||||
│ └── templates/
|
||||
│ └── list.html
|
||||
├── config/
|
||||
│ └── config.yaml
|
||||
├── migrations/
|
||||
├── scripts/
|
||||
├── tests/
|
||||
├── Makefile
|
||||
├── Dockerfile
|
||||
├── go.mod
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 构建与部署
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
go mod tidy
|
||||
|
||||
# 运行开发服务器
|
||||
make dev
|
||||
|
||||
# 运行测试
|
||||
make test
|
||||
```
|
||||
|
||||
### 生产部署
|
||||
|
||||
```bash
|
||||
# 构建二进制
|
||||
make build
|
||||
|
||||
# 构建Docker镜像
|
||||
docker build -t database-render .
|
||||
|
||||
# 运行容器
|
||||
docker run -p 8080:8080 -v ./config:/app/config database-render
|
||||
```
|
||||
|
||||
## 环境要求
|
||||
|
||||
- **开发环境**: Go 1.21+, Node.js 18+, SQLite3
|
||||
- **生产环境**: Linux/Windows/macOS, Docker, SQLite/MySQL 5.7+/PostgreSQL 12+
|
||||
345
scripts/build.sh
Executable file
345
scripts/build.sh
Executable file
@@ -0,0 +1,345 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Database Render Application - Build Script
|
||||
# This script provides comprehensive build functionality for the application
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
BINARY_NAME="database-render"
|
||||
DIST_DIR="$PROJECT_DIR/dist"
|
||||
BIN_DIR="$PROJECT_DIR/bin"
|
||||
|
||||
# Functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if Go is installed
|
||||
check_go() {
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
log_error "Go is not installed. Please install Go first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local go_version=$(go version | awk '{print $3}')
|
||||
log_info "Go version: $go_version"
|
||||
}
|
||||
|
||||
# Check if required tools are installed
|
||||
check_tools() {
|
||||
local tools=("go")
|
||||
|
||||
for tool in "${tools[@]}"; do
|
||||
if command -v "$tool" >/dev/null 2>&1; then
|
||||
log_success "$tool is available"
|
||||
else
|
||||
log_error "$tool is not installed"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# Clean build artifacts
|
||||
clean() {
|
||||
log_info "Cleaning build artifacts..."
|
||||
|
||||
if [ -d "$BIN_DIR" ]; then
|
||||
rm -rf "$BIN_DIR"
|
||||
log_success "Removed $BIN_DIR"
|
||||
fi
|
||||
|
||||
if [ -d "$DIST_DIR" ]; then
|
||||
rm -rf "$DIST_DIR"
|
||||
log_success "Removed $DIST_DIR"
|
||||
fi
|
||||
|
||||
go clean
|
||||
log_success "Clean completed"
|
||||
}
|
||||
|
||||
# Install dependencies
|
||||
install_deps() {
|
||||
log_info "Installing dependencies..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
go mod tidy
|
||||
go mod download
|
||||
|
||||
log_success "Dependencies installed"
|
||||
}
|
||||
|
||||
# Build for current platform
|
||||
build_current() {
|
||||
log_info "Building for current platform..."
|
||||
|
||||
mkdir -p "$BIN_DIR"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
local build_time=$(date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
local git_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
|
||||
go build \
|
||||
-ldflags "-X main.Version=dev -X main.BuildTime=$build_time -X main.GitCommit=$git_commit" \
|
||||
-o "$BIN_DIR/$BINARY_NAME" \
|
||||
./cmd/server
|
||||
|
||||
log_success "Built for current platform: $BIN_DIR/$BINARY_NAME"
|
||||
}
|
||||
|
||||
# Build for multiple platforms
|
||||
build_all() {
|
||||
log_info "Building for multiple platforms..."
|
||||
|
||||
mkdir -p "$DIST_DIR"
|
||||
cd "$PROJECT_DIR"
|
||||
|
||||
local build_time=$(date -u '+%Y-%m-%d_%H:%M:%S')
|
||||
local git_commit=$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")
|
||||
local ldflags="-X main.Version=dev -X main.BuildTime=$build_time -X main.GitCommit=$git_commit"
|
||||
|
||||
# Build targets
|
||||
local targets=(
|
||||
"linux/amd64"
|
||||
"linux/arm64"
|
||||
"darwin/amd64"
|
||||
"darwin/arm64"
|
||||
"windows/amd64"
|
||||
)
|
||||
|
||||
for target in "${targets[@]}"; do
|
||||
local os=$(echo "$target" | cut -d'/' -f1)
|
||||
local arch=$(echo "$target" | cut -d'/' -f2)
|
||||
local ext=""
|
||||
|
||||
if [ "$os" = "windows" ]; then
|
||||
ext=".exe"
|
||||
fi
|
||||
|
||||
log_info "Building for $os/$arch..."
|
||||
|
||||
GOOS=$os GOARCH=$arch go build \
|
||||
-ldflags "$ldflags" \
|
||||
-o "$DIST_DIR/${BINARY_NAME}-${os}-${arch}${ext}" \
|
||||
./cmd/server
|
||||
|
||||
log_success "Built: ${BINARY_NAME}-${os}-${arch}${ext}"
|
||||
done
|
||||
|
||||
log_success "Multi-platform build completed in $DIST_DIR/"
|
||||
}
|
||||
|
||||
# Run tests
|
||||
run_tests() {
|
||||
log_info "Running tests..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
go test -v ./...
|
||||
|
||||
log_success "Tests completed"
|
||||
}
|
||||
|
||||
# Run tests with coverage
|
||||
run_tests_coverage() {
|
||||
log_info "Running tests with coverage..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
go test -v -coverprofile=coverage.out ./...
|
||||
go tool cover -html=coverage.out -o coverage.html
|
||||
|
||||
log_success "Tests completed with coverage report: coverage.html"
|
||||
}
|
||||
|
||||
# Format code
|
||||
format_code() {
|
||||
log_info "Formatting code..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
go fmt ./...
|
||||
|
||||
log_success "Code formatted"
|
||||
}
|
||||
|
||||
# Check code formatting
|
||||
check_format() {
|
||||
log_info "Checking code formatting..."
|
||||
|
||||
cd "$PROJECT_DIR"
|
||||
local unformatted=$(gofmt -l .)
|
||||
|
||||
if [ -n "$unformatted" ]; then
|
||||
log_error "Code is not properly formatted. Run 'go fmt ./...' to fix."
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
else
|
||||
log_success "Code formatting is correct"
|
||||
fi
|
||||
}
|
||||
|
||||
# Run linter
|
||||
run_linter() {
|
||||
if command -v golangci-lint >/dev/null 2>&1; then
|
||||
log_info "Running linter..."
|
||||
cd "$PROJECT_DIR"
|
||||
golangci-lint run
|
||||
log_success "Linter completed"
|
||||
else
|
||||
log_warning "golangci-lint not found. Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"
|
||||
fi
|
||||
}
|
||||
|
||||
# Security scan
|
||||
security_scan() {
|
||||
if command -v gosec >/dev/null 2>&1; then
|
||||
log_info "Running security scan..."
|
||||
cd "$PROJECT_DIR"
|
||||
gosec ./...
|
||||
log_success "Security scan completed"
|
||||
else
|
||||
log_warning "gosec not found. Install with: go install github.com/securecodewarrior/gosec/v2/cmd/gosec@latest"
|
||||
fi
|
||||
}
|
||||
|
||||
# Initialize project
|
||||
init_project() {
|
||||
log_info "Initializing project..."
|
||||
|
||||
# Create directories
|
||||
mkdir -p "$PROJECT_DIR/config"
|
||||
mkdir -p "$PROJECT_DIR/data"
|
||||
mkdir -p "$PROJECT_DIR/logs"
|
||||
|
||||
# Copy example configuration if it doesn't exist
|
||||
if [ ! -f "$PROJECT_DIR/config/config.yaml" ]; then
|
||||
cp "$PROJECT_DIR/config/config.example.yaml" "$PROJECT_DIR/config/config.yaml"
|
||||
log_success "Created config.yaml from example"
|
||||
fi
|
||||
|
||||
# Copy schema if it doesn't exist
|
||||
if [ ! -f "$PROJECT_DIR/config/schema.sql" ]; then
|
||||
cp "$PROJECT_DIR/config/schema.example.sql" "$PROJECT_DIR/config/schema.sql"
|
||||
log_success "Created schema.sql from example"
|
||||
fi
|
||||
|
||||
install_deps
|
||||
|
||||
log_success "Project initialized successfully!"
|
||||
log_info "Please edit config/config.yaml to configure your database connection"
|
||||
log_info "Then run: $0 build-current"
|
||||
}
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
echo "Database Render Application - Build Script"
|
||||
echo ""
|
||||
echo "Usage: $0 [command]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " init - Initialize project with sample configuration"
|
||||
echo " deps - Install dependencies"
|
||||
echo " clean - Clean build artifacts"
|
||||
echo " build - Build for current platform"
|
||||
echo " build-all - Build for multiple platforms"
|
||||
echo " test - Run tests"
|
||||
echo " test-cov - Run tests with coverage"
|
||||
echo " fmt - Format code"
|
||||
echo " fmt-check - Check code formatting"
|
||||
echo " lint - Run linter"
|
||||
echo " security - Run security scan"
|
||||
echo " all - Run full build pipeline (clean, deps, test, build)"
|
||||
echo " help - Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 init"
|
||||
echo " $0 build"
|
||||
echo " $0 test-cov"
|
||||
}
|
||||
|
||||
# Main function
|
||||
main() {
|
||||
case "${1:-help}" in
|
||||
"init")
|
||||
check_go
|
||||
init_project
|
||||
;;
|
||||
"deps")
|
||||
check_go
|
||||
install_deps
|
||||
;;
|
||||
"clean")
|
||||
clean
|
||||
;;
|
||||
"build")
|
||||
check_go
|
||||
check_tools
|
||||
install_deps
|
||||
build_current
|
||||
;;
|
||||
"build-all")
|
||||
check_go
|
||||
check_tools
|
||||
install_deps
|
||||
build_all
|
||||
;;
|
||||
"test")
|
||||
check_go
|
||||
run_tests
|
||||
;;
|
||||
"test-cov")
|
||||
check_go
|
||||
run_tests_coverage
|
||||
;;
|
||||
"fmt")
|
||||
check_go
|
||||
format_code
|
||||
;;
|
||||
"fmt-check")
|
||||
check_go
|
||||
check_format
|
||||
;;
|
||||
"lint")
|
||||
check_go
|
||||
run_linter
|
||||
;;
|
||||
"security")
|
||||
security_scan
|
||||
;;
|
||||
"all")
|
||||
check_go
|
||||
check_tools
|
||||
clean
|
||||
install_deps
|
||||
check_format
|
||||
run_tests
|
||||
build_current
|
||||
log_success "Full build pipeline completed!"
|
||||
;;
|
||||
"help"|*)
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Run main function with all arguments
|
||||
main "$@"
|
||||
360
scripts/dev.sh
Executable file
360
scripts/dev.sh
Executable file
@@ -0,0 +1,360 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Database Render Application - Development Script
|
||||
# This script provides development utilities for the application
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Configuration
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
BINARY_NAME="database-render"
|
||||
CONFIG_FILE="$PROJECT_DIR/config/config.yaml"
|
||||
|
||||
# Functions
|
||||
log_info() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
log_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if service is running
|
||||
check_service() {
|
||||
local port=${1:-8080}
|
||||
if curl -sf http://localhost:$port/health >/dev/null; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Wait for service to be ready
|
||||
wait_for_service() {
|
||||
local port=${1:-8080}
|
||||
local max_attempts=30
|
||||
local attempt=1
|
||||
|
||||
log_info "Waiting for service to be ready on port $port..."
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
if check_service $port; then
|
||||
log_success "Service is ready!"
|
||||
return 0
|
||||
fi
|
||||
|
||||
log_info "Attempt $attempt/$max_attempts - waiting..."
|
||||
sleep 2
|
||||
((attempt++))
|
||||
done
|
||||
|
||||
log_error "Service failed to start within $max_attempts attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Initialize SQLite database
|
||||
init_sqlite() {
|
||||
local db_file="$PROJECT_DIR/data/app.db"
|
||||
|
||||
if [ ! -f "$db_file" ]; then
|
||||
log_info "Creating SQLite database at $db_file..."
|
||||
mkdir -p "$(dirname "$db_file")"
|
||||
sqlite3 "$db_file" < "$PROJECT_DIR/config/schema.example.sql"
|
||||
log_success "SQLite database initialized"
|
||||
else
|
||||
log_info "SQLite database already exists at $db_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Generate sample data
|
||||
generate_data() {
|
||||
local db_file="$PROJECT_DIR/data/app.db"
|
||||
|
||||
if [ -f "$db_file" ]; then
|
||||
log_info "Generating sample data..."
|
||||
sqlite3 "$db_file" <<EOF
|
||||
-- Insert sample data if tables are empty
|
||||
INSERT OR IGNORE INTO users (username, email, full_name, role) VALUES
|
||||
('admin', 'admin@example.com', 'System Administrator', 'admin'),
|
||||
('developer', 'dev@example.com', 'Developer User', 'user'),
|
||||
('editor', 'editor@example.com', 'Content Editor', 'user');
|
||||
|
||||
INSERT OR IGNORE INTO posts (title, content, category, tags, author_id, status) VALUES
|
||||
('Welcome to Database Render', '# Welcome to Database Render
|
||||
|
||||
This is your first post created by the development script!
|
||||
|
||||
## Features
|
||||
|
||||
- Easy database visualization
|
||||
- Modern card-based UI
|
||||
- Responsive design
|
||||
- Multiple database support
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Configure your database connection in config/config.yaml
|
||||
2. Run the application
|
||||
3. Start exploring your data!', 'Technology', 'welcome,getting-started', 1, 'published'),
|
||||
|
||||
('Development Tips', '# Development Tips
|
||||
|
||||
Here are some useful development tips:
|
||||
|
||||
## Hot Reload
|
||||
|
||||
Use air for hot reloading during development:
|
||||
```bash
|
||||
make watch
|
||||
```
|
||||
|
||||
## Database Setup
|
||||
|
||||
The development script can initialize your SQLite database automatically.
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit config/config.yaml to customize your setup.', 'Programming', 'development,tips', 2, 'published');
|
||||
EOF
|
||||
log_success "Sample data generated"
|
||||
else
|
||||
log_error "Database file not found: $db_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Start development server
|
||||
start_dev() {
|
||||
log_info "Starting development server..."
|
||||
|
||||
# Ensure config exists
|
||||
if [ ! -f "$CONFIG_FILE" ]; then
|
||||
log_info "Creating development configuration..."
|
||||
cp "$PROJECT_DIR/config/config.example.yaml" "$CONFIG_FILE"
|
||||
|
||||
# Update config for development
|
||||
sed -i.bak 's/path: "data\/app.db"/path: "data\/app.db"/' "$CONFIG_FILE"
|
||||
sed -i.bak 's/debug: false/debug: true/' "$CONFIG_FILE"
|
||||
sed -i.bak 's/level: "info"/level: "debug"/' "$CONFIG_FILE"
|
||||
rm -f "$CONFIG_FILE.bak"
|
||||
|
||||
log_success "Development configuration created"
|
||||
fi
|
||||
|
||||
# Initialize database
|
||||
init_sqlite
|
||||
|
||||
# Generate sample data
|
||||
generate_data
|
||||
|
||||
# Build and run
|
||||
cd "$PROJECT_DIR"
|
||||
if [ -f "Makefile" ]; then
|
||||
log_info "Building with Makefile..."
|
||||
make build
|
||||
|
||||
if [ -f "$PROJECT_DIR/bin/$BINARY_NAME" ]; then
|
||||
log_success "Starting application..."
|
||||
CONFIG_FILE="$CONFIG_FILE" "$PROJECT_DIR/bin/$BINARY_NAME"
|
||||
else
|
||||
log_error "Binary not found: $PROJECT_DIR/bin/$BINARY_NAME"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
log_error "Makefile not found"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Start with hot reload
|
||||
start_hot_reload() {
|
||||
log_info "Starting with hot reload..."
|
||||
|
||||
# Check if air is installed
|
||||
if command -v air >/dev/null 2>&1; then
|
||||
log_info "Using air for hot reload..."
|
||||
cd "$PROJECT_DIR"
|
||||
air
|
||||
else
|
||||
log_warning "air not found. Installing..."
|
||||
go install github.com/cosmtrek/air@latest
|
||||
|
||||
if command -v air >/dev/null 2>&1; then
|
||||
cd "$PROJECT_DIR"
|
||||
air
|
||||
else
|
||||
log_error "Failed to install air"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Database utilities
|
||||
reset_db() {
|
||||
local db_file="$PROJECT_DIR/data/app.db"
|
||||
|
||||
if [ -f "$db_file" ]; then
|
||||
log_info "Resetting database..."
|
||||
rm -f "$db_file"
|
||||
init_sqlite
|
||||
generate_data
|
||||
log_success "Database reset completed"
|
||||
else
|
||||
log_info "Database not found, creating new..."
|
||||
init_sqlite
|
||||
generate_data
|
||||
fi
|
||||
}
|
||||
|
||||
# Health check
|
||||
health_check() {
|
||||
local port=${1:-8080}
|
||||
|
||||
if check_service $port; then
|
||||
log_success "Service is healthy on port $port"
|
||||
curl -s http://localhost:$port/health | jq . 2>/dev/null || curl -s http://localhost:$port/health
|
||||
else
|
||||
log_error "Service is not responding on port $port"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Show logs
|
||||
tail_logs() {
|
||||
local log_file="$PROJECT_DIR/logs/app.log"
|
||||
|
||||
if [ -f "$log_file" ]; then
|
||||
log_info "Tailing application logs..."
|
||||
tail -f "$log_file"
|
||||
else
|
||||
log_info "Log file not found, checking stdout..."
|
||||
log_info "Application is logging to stdout/stderr"
|
||||
fi
|
||||
}
|
||||
|
||||
# Open browser
|
||||
open_browser() {
|
||||
local port=${1:-8080}
|
||||
local url="http://localhost:$port"
|
||||
|
||||
log_info "Opening browser at $url..."
|
||||
|
||||
case "$(uname -s)" in
|
||||
Darwin*)
|
||||
open "$url"
|
||||
;;
|
||||
Linux*)
|
||||
if command -v xdg-open >/dev/null 2>&1; then
|
||||
xdg-open "$url"
|
||||
elif command -v gnome-open >/dev/null 2>&1; then
|
||||
gnome-open "$url"
|
||||
else
|
||||
log_info "Please open your browser and navigate to: $url"
|
||||
fi
|
||||
;;
|
||||
CYGWIN*|MINGW*|MSYS*)
|
||||
start "$url"
|
||||
;;
|
||||
*)
|
||||
log_info "Please open your browser and navigate to: $url"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Show help
|
||||
show_help() {
|
||||
echo "Database Render Application - Development Script"
|
||||
echo ""
|
||||
echo "Usage: $0 [command] [options]"
|
||||
echo ""
|
||||
echo "Commands:"
|
||||
echo " start - Start development server"
|
||||
echo " watch - Start with hot reload (using air)"
|
||||
echo " init-db - Initialize SQLite database"
|
||||
echo " reset-db - Reset database with sample data"
|
||||
echo " health - Check service health"
|
||||
echo " logs - Tail application logs"
|
||||
echo " open - Open browser to application"
|
||||
echo " help - Show this help message"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " --port PORT - Specify port (default: 8080)"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 start"
|
||||
echo " $0 watch"
|
||||
echo " $0 reset-db"
|
||||
echo " $0 health --port 8080"
|
||||
echo " $0 open --port 8080"
|
||||
}
|
||||
|
||||
# Parse command line arguments
|
||||
parse_args() {
|
||||
local command="$1"
|
||||
local port=8080
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--port)
|
||||
port="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
case "$command" in
|
||||
"start")
|
||||
start_dev
|
||||
;;
|
||||
"watch")
|
||||
start_hot_reload
|
||||
;;
|
||||
"init-db")
|
||||
init_sqlite
|
||||
generate_data
|
||||
;;
|
||||
"reset-db")
|
||||
reset_db
|
||||
;;
|
||||
"health")
|
||||
health_check $port
|
||||
;;
|
||||
"logs")
|
||||
tail_logs
|
||||
;;
|
||||
"open")
|
||||
open_browser $port
|
||||
;;
|
||||
"help"|*)
|
||||
show_help
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Main execution
|
||||
main() {
|
||||
cd "$PROJECT_DIR"
|
||||
parse_args "$@"
|
||||
}
|
||||
|
||||
# Run main function
|
||||
main "$@"
|
||||
278
web/templates/list.html
Normal file
278
web/templates/list.html
Normal file
@@ -0,0 +1,278 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{.TableAlias}} - 数据管理系统</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
<style>
|
||||
.tag {
|
||||
@apply inline-block px-2 py-1 text-xs font-medium rounded-full;
|
||||
}
|
||||
.modal {
|
||||
@apply fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden;
|
||||
}
|
||||
.modal-content {
|
||||
@apply bg-white rounded-lg max-w-4xl w-full mx-4 max-h-[80vh] overflow-y-auto;
|
||||
}
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow;
|
||||
}
|
||||
.card-body {
|
||||
@apply p-6;
|
||||
}
|
||||
.loading {
|
||||
@apply animate-pulse;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-50">
|
||||
<div class="min-h-screen">
|
||||
<!-- Header -->
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center py-4">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">数据管理系统</h1>
|
||||
<p class="text-sm text-gray-600">{{.TableAlias}}</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<select id="tableSelector" class="border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="changeTable(this.value)">
|
||||
{{range .Tables}}
|
||||
<option value="{{.Name}}" {{if eq .Name $.Table}}selected{{end}}>{{.Alias}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<div class="text-sm text-gray-600">
|
||||
共 {{.Total}} 条记录
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Search and Filters -->
|
||||
<div class="mb-6 bg-white p-4 rounded-lg shadow">
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
<div class="flex-1">
|
||||
<input type="text" id="searchInput" placeholder="搜索..." value="{{.Search}}"
|
||||
class="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="performSearch()" class="px-4 py-2 bg-blue-600 text-white rounded-md text-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
搜索
|
||||
</button>
|
||||
<button onclick="clearSearch()" class="px-4 py-2 bg-gray-300 text-gray-700 rounded-md text-sm hover:bg-gray-400 focus:outline-none focus:ring-2 focus:ring-gray-500">
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Display -->
|
||||
<div class="grid gap-6">
|
||||
<!-- Table View -->
|
||||
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
{{range $col := .Columns}}
|
||||
{{if $col.ShowInList}}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{$col.Alias}}
|
||||
</th>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
{{range $row := .Data}}
|
||||
<tr class="hover:bg-gray-50">
|
||||
{{range $col := $.Columns}}
|
||||
{{if $col.ShowInList}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{if eq $col.RenderType "text"}}
|
||||
{{truncate (index $row $col.Name) 50}}
|
||||
{{else}}
|
||||
{{index $row $col.Name}}
|
||||
{{end}}
|
||||
</td>
|
||||
{{end}}
|
||||
{{end}}
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
<button onclick="showDetail('{{index $row "id"}}')" class="text-blue-600 hover:text-blue-800">
|
||||
查看详情
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{{if gt .Pages 1}}
|
||||
<div class="mt-6 bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div class="flex-1 flex justify-between sm:hidden">
|
||||
{{if gt .Page 1}}
|
||||
<a href="?table={{.Table}}&page={{sub .Page 1}}&per_page={{.PerPage}}&search={{.Search}}&sort={{.SortField}}&order={{.SortOrder}}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
上一页
|
||||
</a>
|
||||
{{end}}
|
||||
{{if lt .Page .Pages}}
|
||||
<a href="?table={{.Table}}&page={{add .Page 1}}&per_page={{.PerPage}}&search={{.Search}}&sort={{.SortField}}&order={{.SortOrder}}"
|
||||
class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||
下一页
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
<div class="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-700">
|
||||
显示第 <span class="font-medium">{{add (mul (sub .Page 1) .PerPage) 1}}</span>
|
||||
到 <span class="font-medium">{{if lt (mul .Page .PerPage) .Total}}{{mul .Page .PerPage}}{{else}}{{.Total}}{{end}}</span>
|
||||
条,共 <span class="font-medium">{{.Total}}</span> 条记录
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<nav class="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
{{if gt .Page 1}}
|
||||
<a href="?table={{.Table}}&page={{sub .Page 1}}&per_page={{.PerPage}}&search={{.Search}}&sort={{.SortField}}&order={{.SortOrder}}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
上一页
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
{{$start := sub .Page 2}}
|
||||
{{$end := add .Page 2}}
|
||||
{{if lt $start 1}}
|
||||
{{$start = 1}}
|
||||
{{$end = min 5 .Pages}}
|
||||
{{end}}
|
||||
{{if gt $end .Pages}}
|
||||
{{$end = .Pages}}
|
||||
{{$start = max 1 (sub .Pages 4)}}
|
||||
{{end}}
|
||||
|
||||
{{range $i := $start | $end}}
|
||||
{{if eq $i .Page}}
|
||||
<span class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-blue-50 text-sm font-medium text-blue-600">
|
||||
{{$i}}
|
||||
</span>
|
||||
{{else}}
|
||||
<a href="?table={{.Table}}&page={{$i}}&per_page={{.PerPage}}&search={{.Search}}&sort={{.SortField}}&order={{.SortOrder}}"
|
||||
class="relative inline-flex items-center px-4 py-2 border border-gray-300 bg-white text-sm font-medium text-gray-700 hover:bg-gray-50">
|
||||
{{$i}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if lt .Page .Pages}}
|
||||
<a href="?table={{.Table}}&page={{add .Page 1}}&per_page={{.PerPage}}&search={{.Search}}&sort={{.SortField}}&order={{.SortOrder}}"
|
||||
class="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50">
|
||||
下一页
|
||||
</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Detail Modal -->
|
||||
<div id="detailModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<div class="p-6">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium">详情信息</h3>
|
||||
<button onclick="closeModal()" class="text-gray-400 hover:text-gray-600">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="detailContent" class="space-y-4">
|
||||
<!-- Content will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Utility functions
|
||||
function changeTable(tableName) {
|
||||
window.location.href = `/?table=${tableName}&page=1`;
|
||||
}
|
||||
|
||||
function performSearch() {
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const table = new URLSearchParams(window.location.search).get('table') || '{{.Table}}';
|
||||
window.location.href = `/?table=${table}&page=1&search=${encodeURIComponent(search)}`;
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
const table = new URLSearchParams(window.location.search).get('table') || '{{.Table}}';
|
||||
document.getElementById('searchInput').value = '';
|
||||
window.location.href = `/?table=${table}&page=1`;
|
||||
}
|
||||
|
||||
function showDetail(id) {
|
||||
const table = new URLSearchParams(window.location.search).get('table') || '{{.Table}}';
|
||||
fetch(`/api/data/${table}/detail/${id}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
let content = '';
|
||||
for (const [key, value] of Object.entries(data.data)) {
|
||||
content += `
|
||||
<div class="border-b pb-3">
|
||||
<label class="block text-sm font-medium text-gray-700">${key}</label>
|
||||
<div class="mt-1 text-sm text-gray-900">${value || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
document.getElementById('detailContent').innerHTML = content;
|
||||
document.getElementById('detailModal').classList.remove('hidden');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading detail:', error);
|
||||
alert('加载详情失败');
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('detailModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Event listeners
|
||||
document.getElementById('searchInput').addEventListener('keypress', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
performSearch();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('detailModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Add enter key support for search
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user