feat(go-backend): implement complete API layer with handlers, services, and repositories

- Add DTOs for user, organization, target, engine, pagination, and response handling
- Implement repository layer for user, organization, target, and engine entities
- Implement service layer with business logic for all core modules
- Implement HTTP handlers for user, organization, target, and engine endpoints
- Add complete CRUD API routes with soft delete support for organizations and targets
- Add environment configuration file with database, Redis, and logging settings
- Add docker-compose.dev.yml for PostgreSQL and Redis development dependencies
- Add comprehensive README.md with migration progress, API endpoints, and tech stack
- Update main.go to wire repositories, services, and handlers with dependency injection
- Update config.go to support .env file loading with environment variable priority
- Update database.go to initialize all repositories and services
This commit is contained in:
yyhuni
2026-01-11 22:07:27 +08:00
parent 3946a53337
commit 4aa7b3d68a
24 changed files with 1801 additions and 4 deletions

24
go-backend/.env Normal file
View File

@@ -0,0 +1,24 @@
# Server Configuration
SERVER_PORT=8888
GIN_MODE=debug
# Database Configuration (PostgreSQL)
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=postgres
DB_NAME=xingrin
DB_SSLMODE=disable
DB_MAX_OPEN_CONNS=25
DB_MAX_IDLE_CONNS=5
DB_CONN_MAX_LIFETIME=300
# Redis Configuration
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Logging Configuration
LOG_LEVEL=debug
LOG_FORMAT=json

90
go-backend/README.md Normal file
View File

@@ -0,0 +1,90 @@
# Go Backend
Python Django 后端的 Go 重写版本。
## 技术栈
| 组件 | 选择 |
|------|------|
| Web 框架 | Gin |
| ORM | GORM |
| 配置 | Viper |
| 日志 | Zap |
| 认证 | JWT (bcrypt) |
## 迁移进度
### ✅ 已完成
| 模块 | 说明 |
|------|------|
| 项目基础 | 目录结构、配置管理、数据库连接、日志 |
| 数据模型 | 全部 33 个模型,含索引和约束 |
| JWT 认证 | 登录、刷新、中间件 |
| 用户 API | 创建、列表、修改密码 |
| 组织 API | 完整 CRUD软删除 |
| 目标 API | 完整 CRUD软删除、类型自动检测 |
| 引擎 API | 完整 CRUD |
### 🚧 待实现
| 模块 | 优先级 | 说明 |
|------|--------|------|
| Scan API | 高 | 扫描管理(发起、状态、结果) |
| Asset API | 高 | 资产查询(子域名、端口、漏洞等) |
| Worker | 高 | 扫描任务执行(核心逻辑) |
| 定时任务 | 中 | 定时扫描 |
| 通知 | 低 | 扫描完成通知 |
| 统计 | 低 | 资产统计 |
### ⏳ 技术债务
| 项目 | 说明 | 优先级 |
|------|------|--------|
| Context 传递 | Repository/Service 加 context 参数 | 中 |
| 单元测试 | Handler/Service 层测试 | 中 |
| 接口抽象 | Repository 接口化(便于 mock | 低 |
| 泛型重构 | 通用 Repository等模块多了再做 | 低 |
## 运行
```bash
# 开发
make run
# 测试
make test
# 构建
make build
```
## API 端点
```
POST /api/auth/login # 登录
POST /api/auth/refresh # 刷新 token
GET /api/auth/me # 当前用户
POST /api/users # 创建用户
GET /api/users # 用户列表
PUT /api/users/password # 修改密码
GET /api/organizations # 组织列表
POST /api/organizations # 创建组织
GET /api/organizations/:id # 获取组织
PUT /api/organizations/:id # 更新组织
DELETE /api/organizations/:id # 删除组织
GET /api/targets # 目标列表
POST /api/targets # 创建目标
GET /api/targets/:id # 获取目标
PUT /api/targets/:id # 更新目标
DELETE /api/targets/:id # 删除目标
GET /api/engines # 引擎列表
POST /api/engines # 创建引擎
GET /api/engines/:id # 获取引擎
PUT /api/engines/:id # 更新引擎
DELETE /api/engines/:id # 删除引擎
```

View File

@@ -17,6 +17,8 @@ import (
"github.com/xingrin/go-backend/internal/handler"
"github.com/xingrin/go-backend/internal/middleware"
"github.com/xingrin/go-backend/internal/pkg"
"github.com/xingrin/go-backend/internal/repository"
"github.com/xingrin/go-backend/internal/service"
"go.uber.org/zap"
)
@@ -95,9 +97,25 @@ func main() {
router.Use(middleware.Recovery())
router.Use(middleware.Logger())
// Create repositories
userRepo := repository.NewUserRepository(db)
orgRepo := repository.NewOrganizationRepository(db)
targetRepo := repository.NewTargetRepository(db)
engineRepo := repository.NewEngineRepository(db)
// Create services
userSvc := service.NewUserService(userRepo)
orgSvc := service.NewOrganizationService(orgRepo)
targetSvc := service.NewTargetService(targetRepo)
engineSvc := service.NewEngineService(engineRepo)
// Create handlers
healthHandler := handler.NewHealthHandler(db, redisClient)
authHandler := handler.NewAuthHandler(db, jwtManager)
userHandler := handler.NewUserHandler(userSvc)
orgHandler := handler.NewOrganizationHandler(orgSvc)
targetHandler := handler.NewTargetHandler(targetSvc)
engineHandler := handler.NewEngineHandler(engineSvc)
// Register health routes
router.GET("/health", healthHandler.Check)
@@ -118,7 +136,34 @@ func main() {
protected := api.Group("")
protected.Use(middleware.AuthMiddleware(jwtManager))
{
// Auth
protected.GET("/auth/me", authHandler.GetCurrentUser)
// Users
protected.POST("/users", userHandler.Create)
protected.GET("/users", userHandler.List)
protected.PUT("/users/password", userHandler.UpdatePassword)
// Organizations
protected.POST("/organizations", orgHandler.Create)
protected.GET("/organizations", orgHandler.List)
protected.GET("/organizations/:id", orgHandler.GetByID)
protected.PUT("/organizations/:id", orgHandler.Update)
protected.DELETE("/organizations/:id", orgHandler.Delete)
// Targets
protected.POST("/targets", targetHandler.Create)
protected.GET("/targets", targetHandler.List)
protected.GET("/targets/:id", targetHandler.GetByID)
protected.PUT("/targets/:id", targetHandler.Update)
protected.DELETE("/targets/:id", targetHandler.Delete)
// Engines
protected.POST("/engines", engineHandler.Create)
protected.GET("/engines", engineHandler.List)
protected.GET("/engines/:id", engineHandler.GetByID)
protected.PUT("/engines/:id", engineHandler.Update)
protected.DELETE("/engines/:id", engineHandler.Delete)
}
}

View File

@@ -0,0 +1,35 @@
# Go 后端开发环境依赖
# 启动: docker compose -f docker-compose.dev.yml up -d
# 停止: docker compose -f docker-compose.dev.yml down
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: xingrin
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
volumes:
postgres_data:

View File

@@ -57,14 +57,28 @@ type JWTConfig struct {
RefreshExpire time.Duration `mapstructure:"JWT_REFRESH_EXPIRE"`
}
// Load reads configuration from environment variables
// Load reads configuration from .env file and environment variables
// Priority: environment variables > .env file > defaults
func Load() (*Config, error) {
v := viper.New()
// Set default values
setDefaults(v)
// Read from environment variables
// Try to read .env file (optional, won't fail if not found)
v.SetConfigName(".env")
v.SetConfigType("env")
v.AddConfigPath(".") // Current directory (go-backend/)
v.AddConfigPath("./go-backend") // When running from project root
if err := v.ReadInConfig(); err != nil {
// .env file not found is OK, we'll use env vars or defaults
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// Only return error if it's not a "file not found" error
return nil, fmt.Errorf("error reading config file: %w", err)
}
}
// Environment variables override .env file
v.AutomaticEnv()
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))

View File

@@ -21,8 +21,6 @@ func NewDatabase(cfg *config.DatabaseConfig) (*gorm.DB, error) {
NamingStrategy: schema.NamingStrategy{
SingularTable: true, // Use singular table names (e.g., "target" not "targets")
},
// Disable auto-migration to prevent creating new tables
DisableForeignKeyConstraintWhenMigrating: true,
})
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)

View File

@@ -0,0 +1,24 @@
package dto
import "time"
// CreateEngineRequest represents create engine request
type CreateEngineRequest struct {
Name string `json:"name" binding:"required,max=200"`
Configuration string `json:"configuration"`
}
// UpdateEngineRequest represents update engine request
type UpdateEngineRequest struct {
Name string `json:"name" binding:"required,max=200"`
Configuration string `json:"configuration"`
}
// EngineResponse represents engine response
type EngineResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Configuration string `json:"configuration"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}

View File

@@ -0,0 +1,23 @@
package dto
import "time"
// CreateOrganizationRequest represents create organization request
type CreateOrganizationRequest struct {
Name string `json:"name" binding:"required,max=300"`
Description string `json:"description" binding:"max=1000"`
}
// UpdateOrganizationRequest represents update organization request
type UpdateOrganizationRequest struct {
Name string `json:"name" binding:"required,max=300"`
Description string `json:"description" binding:"max=1000"`
}
// OrganizationResponse represents organization response
type OrganizationResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"createdAt"`
}

View File

@@ -0,0 +1,49 @@
package dto
// PaginationQuery represents pagination query parameters
type PaginationQuery struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"pageSize" binding:"omitempty,min=1,max=100"`
}
// GetPage returns page number with default
func (p *PaginationQuery) GetPage() int {
if p.Page <= 0 {
return 1
}
return p.Page
}
// GetPageSize returns page size with default
func (p *PaginationQuery) GetPageSize() int {
if p.PageSize <= 0 {
return 20
}
if p.PageSize > 100 {
return 100
}
return p.PageSize
}
// GetOffset returns offset for database query
func (p *PaginationQuery) GetOffset() int {
return (p.GetPage() - 1) * p.GetPageSize()
}
// PaginatedResponse represents a paginated response
type PaginatedResponse struct {
Data interface{} `json:"data"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
// NewPaginatedResponse creates a new paginated response
func NewPaginatedResponse(data interface{}, total int64, page, pageSize int) *PaginatedResponse {
return &PaginatedResponse{
Data: data,
Total: total,
Page: page,
PageSize: pageSize,
}
}

View File

@@ -0,0 +1,62 @@
package dto
import (
"net/http"
"github.com/gin-gonic/gin"
)
// Response represents a standard API response
type Response struct {
Data interface{} `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
// Success sends a success response
func Success(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Response{
Data: data,
Message: "success",
})
}
// Created sends a created response
func Created(c *gin.Context, data interface{}) {
c.JSON(http.StatusCreated, Response{
Data: data,
Message: "created",
})
}
// NoContent sends a no content response
func NoContent(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// Error sends an error response
func Error(c *gin.Context, status int, message string) {
c.JSON(status, Response{
Error: message,
})
}
// BadRequest sends a bad request error
func BadRequest(c *gin.Context, message string) {
Error(c, http.StatusBadRequest, message)
}
// NotFound sends a not found error
func NotFound(c *gin.Context, message string) {
Error(c, http.StatusNotFound, message)
}
// InternalError sends an internal server error
func InternalError(c *gin.Context, message string) {
Error(c, http.StatusInternalServerError, message)
}
// Paginated sends a paginated response
func Paginated(c *gin.Context, data interface{}, total int64, page, pageSize int) {
c.JSON(http.StatusOK, NewPaginatedResponse(data, total, page, pageSize))
}

View File

@@ -0,0 +1,31 @@
package dto
import "time"
// CreateTargetRequest represents create target request
type CreateTargetRequest struct {
Name string `json:"name" binding:"required,max=300"`
Type string `json:"type" binding:"omitempty,oneof=domain ip cidr"`
}
// UpdateTargetRequest represents update target request
type UpdateTargetRequest struct {
Name string `json:"name" binding:"required,max=300"`
Type string `json:"type" binding:"omitempty,oneof=domain ip cidr"`
}
// TargetListQuery represents target list query parameters
type TargetListQuery struct {
PaginationQuery
Type string `form:"type" binding:"omitempty,oneof=domain ip cidr"`
Search string `form:"search"`
}
// TargetResponse represents target response
type TargetResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
CreatedAt time.Time `json:"createdAt"`
LastScannedAt *time.Time `json:"lastScannedAt"`
}

View File

@@ -0,0 +1,27 @@
package dto
import "time"
// CreateUserRequest represents create user request
type CreateUserRequest struct {
Username string `json:"username" binding:"required,min=3,max=150"`
Password string `json:"password" binding:"required,min=6"`
Email string `json:"email" binding:"omitempty,email"`
}
// UpdatePasswordRequest represents update password request
type UpdatePasswordRequest struct {
OldPassword string `json:"oldPassword" binding:"required"`
NewPassword string `json:"newPassword" binding:"required,min=6"`
}
// UserResponse represents user response
type UserResponse struct {
ID int `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
IsActive bool `json:"isActive"`
IsSuperuser bool `json:"isSuperuser"`
DateJoined time.Time `json:"dateJoined"`
LastLogin *time.Time `json:"lastLogin"`
}

View File

@@ -0,0 +1,165 @@
package handler
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/service"
)
// EngineHandler handles engine endpoints
type EngineHandler struct {
svc *service.EngineService
}
// NewEngineHandler creates a new engine handler
func NewEngineHandler(svc *service.EngineService) *EngineHandler {
return &EngineHandler{svc: svc}
}
// Create creates a new engine
// POST /api/engines
func (h *EngineHandler) Create(c *gin.Context) {
var req dto.CreateEngineRequest
if err := c.ShouldBindJSON(&req); err != nil {
dto.BadRequest(c, "Invalid request body")
return
}
engine, err := h.svc.Create(&req)
if err != nil {
if errors.Is(err, service.ErrEngineExists) {
dto.BadRequest(c, "Engine name already exists")
return
}
dto.InternalError(c, "Failed to create engine")
return
}
dto.Created(c, dto.EngineResponse{
ID: engine.ID,
Name: engine.Name,
Configuration: engine.Configuration,
CreatedAt: engine.CreatedAt,
UpdatedAt: engine.UpdatedAt,
})
}
// List returns paginated engines
// GET /api/engines
func (h *EngineHandler) List(c *gin.Context) {
var query dto.PaginationQuery
if err := c.ShouldBindQuery(&query); err != nil {
dto.BadRequest(c, "Invalid query parameters")
return
}
engines, total, err := h.svc.List(&query)
if err != nil {
dto.InternalError(c, "Failed to list engines")
return
}
var resp []dto.EngineResponse
for _, e := range engines {
resp = append(resp, dto.EngineResponse{
ID: e.ID,
Name: e.Name,
Configuration: e.Configuration,
CreatedAt: e.CreatedAt,
UpdatedAt: e.UpdatedAt,
})
}
dto.Paginated(c, resp, total, query.GetPage(), query.GetPageSize())
}
// GetByID returns an engine by ID
// GET /api/engines/:id
func (h *EngineHandler) GetByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid engine ID")
return
}
engine, err := h.svc.GetByID(id)
if err != nil {
if errors.Is(err, service.ErrEngineNotFound) {
dto.NotFound(c, "Engine not found")
return
}
dto.InternalError(c, "Failed to get engine")
return
}
dto.Success(c, dto.EngineResponse{
ID: engine.ID,
Name: engine.Name,
Configuration: engine.Configuration,
CreatedAt: engine.CreatedAt,
UpdatedAt: engine.UpdatedAt,
})
}
// Update updates an engine
// PUT /api/engines/:id
func (h *EngineHandler) Update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid engine ID")
return
}
var req dto.UpdateEngineRequest
if err := c.ShouldBindJSON(&req); err != nil {
dto.BadRequest(c, "Invalid request body")
return
}
engine, err := h.svc.Update(id, &req)
if err != nil {
if errors.Is(err, service.ErrEngineNotFound) {
dto.NotFound(c, "Engine not found")
return
}
if errors.Is(err, service.ErrEngineExists) {
dto.BadRequest(c, "Engine name already exists")
return
}
dto.InternalError(c, "Failed to update engine")
return
}
dto.Success(c, dto.EngineResponse{
ID: engine.ID,
Name: engine.Name,
Configuration: engine.Configuration,
CreatedAt: engine.CreatedAt,
UpdatedAt: engine.UpdatedAt,
})
}
// Delete deletes an engine
// DELETE /api/engines/:id
func (h *EngineHandler) Delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid engine ID")
return
}
err = h.svc.Delete(id)
if err != nil {
if errors.Is(err, service.ErrEngineNotFound) {
dto.NotFound(c, "Engine not found")
return
}
dto.InternalError(c, "Failed to delete engine")
return
}
dto.NoContent(c)
}

View File

@@ -0,0 +1,161 @@
package handler
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/service"
)
// OrganizationHandler handles organization endpoints
type OrganizationHandler struct {
svc *service.OrganizationService
}
// NewOrganizationHandler creates a new organization handler
func NewOrganizationHandler(svc *service.OrganizationService) *OrganizationHandler {
return &OrganizationHandler{svc: svc}
}
// Create creates a new organization
// POST /api/organizations
func (h *OrganizationHandler) Create(c *gin.Context) {
var req dto.CreateOrganizationRequest
if err := c.ShouldBindJSON(&req); err != nil {
dto.BadRequest(c, "Invalid request body")
return
}
org, err := h.svc.Create(&req)
if err != nil {
if errors.Is(err, service.ErrOrganizationExists) {
dto.BadRequest(c, "Organization name already exists")
return
}
dto.InternalError(c, "Failed to create organization")
return
}
dto.Created(c, dto.OrganizationResponse{
ID: org.ID,
Name: org.Name,
Description: org.Description,
CreatedAt: org.CreatedAt,
})
}
// List returns paginated organizations
// GET /api/organizations
func (h *OrganizationHandler) List(c *gin.Context) {
var query dto.PaginationQuery
if err := c.ShouldBindQuery(&query); err != nil {
dto.BadRequest(c, "Invalid query parameters")
return
}
orgs, total, err := h.svc.List(&query)
if err != nil {
dto.InternalError(c, "Failed to list organizations")
return
}
var resp []dto.OrganizationResponse
for _, o := range orgs {
resp = append(resp, dto.OrganizationResponse{
ID: o.ID,
Name: o.Name,
Description: o.Description,
CreatedAt: o.CreatedAt,
})
}
dto.Paginated(c, resp, total, query.GetPage(), query.GetPageSize())
}
// GetByID returns an organization by ID
// GET /api/organizations/:id
func (h *OrganizationHandler) GetByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid organization ID")
return
}
org, err := h.svc.GetByID(id)
if err != nil {
if errors.Is(err, service.ErrOrganizationNotFound) {
dto.NotFound(c, "Organization not found")
return
}
dto.InternalError(c, "Failed to get organization")
return
}
dto.Success(c, dto.OrganizationResponse{
ID: org.ID,
Name: org.Name,
Description: org.Description,
CreatedAt: org.CreatedAt,
})
}
// Update updates an organization
// PUT /api/organizations/:id
func (h *OrganizationHandler) Update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid organization ID")
return
}
var req dto.UpdateOrganizationRequest
if err := c.ShouldBindJSON(&req); err != nil {
dto.BadRequest(c, "Invalid request body")
return
}
org, err := h.svc.Update(id, &req)
if err != nil {
if errors.Is(err, service.ErrOrganizationNotFound) {
dto.NotFound(c, "Organization not found")
return
}
if errors.Is(err, service.ErrOrganizationExists) {
dto.BadRequest(c, "Organization name already exists")
return
}
dto.InternalError(c, "Failed to update organization")
return
}
dto.Success(c, dto.OrganizationResponse{
ID: org.ID,
Name: org.Name,
Description: org.Description,
CreatedAt: org.CreatedAt,
})
}
// Delete soft deletes an organization
// DELETE /api/organizations/:id
func (h *OrganizationHandler) Delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid organization ID")
return
}
err = h.svc.Delete(id)
if err != nil {
if errors.Is(err, service.ErrOrganizationNotFound) {
dto.NotFound(c, "Organization not found")
return
}
dto.InternalError(c, "Failed to delete organization")
return
}
dto.NoContent(c)
}

View File

@@ -0,0 +1,173 @@
package handler
import (
"errors"
"strconv"
"github.com/gin-gonic/gin"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/service"
)
// TargetHandler handles target endpoints
type TargetHandler struct {
svc *service.TargetService
}
// NewTargetHandler creates a new target handler
func NewTargetHandler(svc *service.TargetService) *TargetHandler {
return &TargetHandler{svc: svc}
}
// Create creates a new target
// POST /api/targets
func (h *TargetHandler) Create(c *gin.Context) {
var req dto.CreateTargetRequest
if err := c.ShouldBindJSON(&req); err != nil {
dto.BadRequest(c, "Invalid request body")
return
}
target, err := h.svc.Create(&req)
if err != nil {
if errors.Is(err, service.ErrTargetExists) {
dto.BadRequest(c, "Target name already exists")
return
}
if errors.Is(err, service.ErrInvalidTarget) {
dto.BadRequest(c, "Invalid target format")
return
}
dto.InternalError(c, "Failed to create target")
return
}
dto.Created(c, dto.TargetResponse{
ID: target.ID,
Name: target.Name,
Type: target.Type,
CreatedAt: target.CreatedAt,
LastScannedAt: target.LastScannedAt,
})
}
// List returns paginated targets
// GET /api/targets
func (h *TargetHandler) List(c *gin.Context) {
var query dto.TargetListQuery
if err := c.ShouldBindQuery(&query); err != nil {
dto.BadRequest(c, "Invalid query parameters")
return
}
targets, total, err := h.svc.List(&query)
if err != nil {
dto.InternalError(c, "Failed to list targets")
return
}
var resp []dto.TargetResponse
for _, t := range targets {
resp = append(resp, dto.TargetResponse{
ID: t.ID,
Name: t.Name,
Type: t.Type,
CreatedAt: t.CreatedAt,
LastScannedAt: t.LastScannedAt,
})
}
dto.Paginated(c, resp, total, query.GetPage(), query.GetPageSize())
}
// GetByID returns a target by ID
// GET /api/targets/:id
func (h *TargetHandler) GetByID(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid target ID")
return
}
target, err := h.svc.GetByID(id)
if err != nil {
if errors.Is(err, service.ErrTargetNotFound) {
dto.NotFound(c, "Target not found")
return
}
dto.InternalError(c, "Failed to get target")
return
}
dto.Success(c, dto.TargetResponse{
ID: target.ID,
Name: target.Name,
Type: target.Type,
CreatedAt: target.CreatedAt,
LastScannedAt: target.LastScannedAt,
})
}
// Update updates a target
// PUT /api/targets/:id
func (h *TargetHandler) Update(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid target ID")
return
}
var req dto.UpdateTargetRequest
if err := c.ShouldBindJSON(&req); err != nil {
dto.BadRequest(c, "Invalid request body")
return
}
target, err := h.svc.Update(id, &req)
if err != nil {
if errors.Is(err, service.ErrTargetNotFound) {
dto.NotFound(c, "Target not found")
return
}
if errors.Is(err, service.ErrTargetExists) {
dto.BadRequest(c, "Target name already exists")
return
}
if errors.Is(err, service.ErrInvalidTarget) {
dto.BadRequest(c, "Invalid target format")
return
}
dto.InternalError(c, "Failed to update target")
return
}
dto.Success(c, dto.TargetResponse{
ID: target.ID,
Name: target.Name,
Type: target.Type,
CreatedAt: target.CreatedAt,
LastScannedAt: target.LastScannedAt,
})
}
// Delete soft deletes a target
// DELETE /api/targets/:id
func (h *TargetHandler) Delete(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil {
dto.BadRequest(c, "Invalid target ID")
return
}
err = h.svc.Delete(id)
if err != nil {
if errors.Is(err, service.ErrTargetNotFound) {
dto.NotFound(c, "Target not found")
return
}
dto.InternalError(c, "Failed to delete target")
return
}
dto.NoContent(c)
}

View File

@@ -0,0 +1,115 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/middleware"
"github.com/xingrin/go-backend/internal/service"
)
// UserHandler handles user endpoints
type UserHandler struct {
svc *service.UserService
}
// NewUserHandler creates a new user handler
func NewUserHandler(svc *service.UserService) *UserHandler {
return &UserHandler{svc: svc}
}
// Create creates a new user
// POST /api/users
func (h *UserHandler) Create(c *gin.Context) {
var req dto.CreateUserRequest
if err := c.ShouldBindJSON(&req); err != nil {
dto.BadRequest(c, "Invalid request body")
return
}
user, err := h.svc.Create(&req)
if err != nil {
if errors.Is(err, service.ErrUsernameExists) {
dto.BadRequest(c, "Username already exists")
return
}
dto.InternalError(c, "Failed to create user")
return
}
dto.Created(c, dto.UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
IsActive: user.IsActive,
IsSuperuser: user.IsSuperuser,
DateJoined: user.DateJoined,
LastLogin: user.LastLogin,
})
}
// List returns paginated users
// GET /api/users
func (h *UserHandler) List(c *gin.Context) {
var query dto.PaginationQuery
if err := c.ShouldBindQuery(&query); err != nil {
dto.BadRequest(c, "Invalid query parameters")
return
}
users, total, err := h.svc.List(&query)
if err != nil {
dto.InternalError(c, "Failed to list users")
return
}
var resp []dto.UserResponse
for _, u := range users {
resp = append(resp, dto.UserResponse{
ID: u.ID,
Username: u.Username,
Email: u.Email,
IsActive: u.IsActive,
IsSuperuser: u.IsSuperuser,
DateJoined: u.DateJoined,
LastLogin: u.LastLogin,
})
}
dto.Paginated(c, resp, total, query.GetPage(), query.GetPageSize())
}
// UpdatePassword updates user password
// PUT /api/users/:id/password
func (h *UserHandler) UpdatePassword(c *gin.Context) {
// Get current user from context
claims, ok := middleware.GetUserClaims(c)
if !ok {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
var req dto.UpdatePasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
dto.BadRequest(c, "Invalid request body")
return
}
err := h.svc.UpdatePassword(claims.UserID, &req)
if err != nil {
if errors.Is(err, service.ErrUserNotFound) {
dto.NotFound(c, "User not found")
return
}
if errors.Is(err, service.ErrInvalidPassword) {
dto.BadRequest(c, "Invalid old password")
return
}
dto.InternalError(c, "Failed to update password")
return
}
dto.Success(c, gin.H{"message": "Password updated"})
}

View File

@@ -0,0 +1,65 @@
package repository
import (
"github.com/xingrin/go-backend/internal/model"
"gorm.io/gorm"
)
// EngineRepository handles scan engine database operations
type EngineRepository struct {
db *gorm.DB
}
// NewEngineRepository creates a new engine repository
func NewEngineRepository(db *gorm.DB) *EngineRepository {
return &EngineRepository{db: db}
}
// Create creates a new engine
func (r *EngineRepository) Create(engine *model.ScanEngine) error {
return r.db.Create(engine).Error
}
// FindByID finds an engine by ID
func (r *EngineRepository) FindByID(id int) (*model.ScanEngine, error) {
var engine model.ScanEngine
err := r.db.First(&engine, id).Error
if err != nil {
return nil, err
}
return &engine, nil
}
// FindAll finds all engines with pagination
func (r *EngineRepository) FindAll(offset, limit int) ([]model.ScanEngine, int64, error) {
var engines []model.ScanEngine
var total int64
if err := r.db.Model(&model.ScanEngine{}).Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.Offset(offset).Limit(limit).Order("created_at DESC").Find(&engines).Error
return engines, total, err
}
// Update updates an engine
func (r *EngineRepository) Update(engine *model.ScanEngine) error {
return r.db.Save(engine).Error
}
// Delete deletes an engine
func (r *EngineRepository) Delete(id int) error {
return r.db.Delete(&model.ScanEngine{}, id).Error
}
// ExistsByName checks if engine name exists
func (r *EngineRepository) ExistsByName(name string, excludeID ...int) (bool, error) {
var count int64
query := r.db.Model(&model.ScanEngine{}).Where("name = ?", name)
if len(excludeID) > 0 {
query = query.Where("id != ?", excludeID[0])
}
err := query.Count(&count).Error
return count > 0, err
}

View File

@@ -0,0 +1,70 @@
package repository
import (
"time"
"github.com/xingrin/go-backend/internal/model"
"gorm.io/gorm"
)
// OrganizationRepository handles organization database operations
type OrganizationRepository struct {
db *gorm.DB
}
// NewOrganizationRepository creates a new organization repository
func NewOrganizationRepository(db *gorm.DB) *OrganizationRepository {
return &OrganizationRepository{db: db}
}
// Create creates a new organization
func (r *OrganizationRepository) Create(org *model.Organization) error {
return r.db.Create(org).Error
}
// FindByID finds an organization by ID (excluding soft deleted)
func (r *OrganizationRepository) FindByID(id int) (*model.Organization, error) {
var org model.Organization
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&org).Error
if err != nil {
return nil, err
}
return &org, nil
}
// FindAll finds all organizations with pagination (excluding soft deleted)
func (r *OrganizationRepository) FindAll(offset, limit int) ([]model.Organization, int64, error) {
var orgs []model.Organization
var total int64
query := r.db.Model(&model.Organization{}).Where("deleted_at IS NULL")
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Offset(offset).Limit(limit).Order("created_at DESC").Find(&orgs).Error
return orgs, total, err
}
// Update updates an organization
func (r *OrganizationRepository) Update(org *model.Organization) error {
return r.db.Save(org).Error
}
// SoftDelete soft deletes an organization
func (r *OrganizationRepository) SoftDelete(id int) error {
now := time.Now()
return r.db.Model(&model.Organization{}).Where("id = ?", id).Update("deleted_at", now).Error
}
// ExistsByName checks if organization name exists (excluding soft deleted)
func (r *OrganizationRepository) ExistsByName(name string, excludeID ...int) (bool, error) {
var count int64
query := r.db.Model(&model.Organization{}).Where("name = ? AND deleted_at IS NULL", name)
if len(excludeID) > 0 {
query = query.Where("id != ?", excludeID[0])
}
err := query.Count(&count).Error
return count > 0, err
}

View File

@@ -0,0 +1,77 @@
package repository
import (
"time"
"github.com/xingrin/go-backend/internal/model"
"gorm.io/gorm"
)
// TargetRepository handles target database operations
type TargetRepository struct {
db *gorm.DB
}
// NewTargetRepository creates a new target repository
func NewTargetRepository(db *gorm.DB) *TargetRepository {
return &TargetRepository{db: db}
}
// Create creates a new target
func (r *TargetRepository) Create(target *model.Target) error {
return r.db.Create(target).Error
}
// FindByID finds a target by ID (excluding soft deleted)
func (r *TargetRepository) FindByID(id int) (*model.Target, error) {
var target model.Target
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&target).Error
if err != nil {
return nil, err
}
return &target, nil
}
// FindAll finds all targets with pagination and filters (excluding soft deleted)
func (r *TargetRepository) FindAll(offset, limit int, targetType, search string) ([]model.Target, int64, error) {
var targets []model.Target
var total int64
query := r.db.Model(&model.Target{}).Where("deleted_at IS NULL")
if targetType != "" {
query = query.Where("type = ?", targetType)
}
if search != "" {
query = query.Where("name ILIKE ?", "%"+search+"%")
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
err := query.Offset(offset).Limit(limit).Order("created_at DESC").Find(&targets).Error
return targets, total, err
}
// Update updates a target
func (r *TargetRepository) Update(target *model.Target) error {
return r.db.Save(target).Error
}
// SoftDelete soft deletes a target
func (r *TargetRepository) SoftDelete(id int) error {
now := time.Now()
return r.db.Model(&model.Target{}).Where("id = ?", id).Update("deleted_at", now).Error
}
// ExistsByName checks if target name exists (excluding soft deleted)
func (r *TargetRepository) ExistsByName(name string, excludeID ...int) (bool, error) {
var count int64
query := r.db.Model(&model.Target{}).Where("name = ? AND deleted_at IS NULL", name)
if len(excludeID) > 0 {
query = query.Where("id != ?", excludeID[0])
}
err := query.Count(&count).Error
return count > 0, err
}

View File

@@ -0,0 +1,66 @@
package repository
import (
"github.com/xingrin/go-backend/internal/model"
"gorm.io/gorm"
)
// UserRepository handles user database operations
type UserRepository struct {
db *gorm.DB
}
// NewUserRepository creates a new user repository
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
// Create creates a new user
func (r *UserRepository) Create(user *model.User) error {
return r.db.Create(user).Error
}
// FindByID finds a user by ID
func (r *UserRepository) FindByID(id int) (*model.User, error) {
var user model.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindByUsername finds a user by username
func (r *UserRepository) FindByUsername(username string) (*model.User, error) {
var user model.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// FindAll finds all users with pagination
func (r *UserRepository) FindAll(offset, limit int) ([]model.User, int64, error) {
var users []model.User
var total int64
if err := r.db.Model(&model.User{}).Count(&total).Error; err != nil {
return nil, 0, err
}
err := r.db.Offset(offset).Limit(limit).Order("id DESC").Find(&users).Error
return users, total, err
}
// Update updates a user
func (r *UserRepository) Update(user *model.User) error {
return r.db.Save(user).Error
}
// ExistsByUsername checks if username exists
func (r *UserRepository) ExistsByUsername(username string) (bool, error) {
var count int64
err := r.db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error
return count > 0, err
}

View File

@@ -0,0 +1,108 @@
package service
import (
"errors"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/model"
"github.com/xingrin/go-backend/internal/repository"
"gorm.io/gorm"
)
var (
ErrEngineNotFound = errors.New("engine not found")
ErrEngineExists = errors.New("engine name already exists")
)
// EngineService handles engine business logic
type EngineService struct {
repo *repository.EngineRepository
}
// NewEngineService creates a new engine service
func NewEngineService(repo *repository.EngineRepository) *EngineService {
return &EngineService{repo: repo}
}
// Create creates a new engine
func (s *EngineService) Create(req *dto.CreateEngineRequest) (*model.ScanEngine, error) {
exists, err := s.repo.ExistsByName(req.Name)
if err != nil {
return nil, err
}
if exists {
return nil, ErrEngineExists
}
engine := &model.ScanEngine{
Name: req.Name,
Configuration: req.Configuration,
}
if err := s.repo.Create(engine); err != nil {
return nil, err
}
return engine, nil
}
// List returns paginated engines
func (s *EngineService) List(query *dto.PaginationQuery) ([]model.ScanEngine, int64, error) {
return s.repo.FindAll(query.GetOffset(), query.GetPageSize())
}
// GetByID returns an engine by ID
func (s *EngineService) GetByID(id int) (*model.ScanEngine, error) {
engine, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEngineNotFound
}
return nil, err
}
return engine, nil
}
// Update updates an engine
func (s *EngineService) Update(id int, req *dto.UpdateEngineRequest) (*model.ScanEngine, error) {
engine, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrEngineNotFound
}
return nil, err
}
// Check name uniqueness if changed
if engine.Name != req.Name {
exists, err := s.repo.ExistsByName(req.Name, id)
if err != nil {
return nil, err
}
if exists {
return nil, ErrEngineExists
}
}
engine.Name = req.Name
engine.Configuration = req.Configuration
if err := s.repo.Update(engine); err != nil {
return nil, err
}
return engine, nil
}
// Delete deletes an engine
func (s *EngineService) Delete(id int) error {
_, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrEngineNotFound
}
return err
}
return s.repo.Delete(id)
}

View File

@@ -0,0 +1,109 @@
package service
import (
"errors"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/model"
"github.com/xingrin/go-backend/internal/repository"
"gorm.io/gorm"
)
var (
ErrOrganizationNotFound = errors.New("organization not found")
ErrOrganizationExists = errors.New("organization name already exists")
)
// OrganizationService handles organization business logic
type OrganizationService struct {
repo *repository.OrganizationRepository
}
// NewOrganizationService creates a new organization service
func NewOrganizationService(repo *repository.OrganizationRepository) *OrganizationService {
return &OrganizationService{repo: repo}
}
// Create creates a new organization
func (s *OrganizationService) Create(req *dto.CreateOrganizationRequest) (*model.Organization, error) {
exists, err := s.repo.ExistsByName(req.Name)
if err != nil {
return nil, err
}
if exists {
return nil, ErrOrganizationExists
}
org := &model.Organization{
Name: req.Name,
Description: req.Description,
}
if err := s.repo.Create(org); err != nil {
return nil, err
}
return org, nil
}
// List returns paginated organizations
func (s *OrganizationService) List(query *dto.PaginationQuery) ([]model.Organization, int64, error) {
return s.repo.FindAll(query.GetOffset(), query.GetPageSize())
}
// GetByID returns an organization by ID
func (s *OrganizationService) GetByID(id int) (*model.Organization, error) {
org, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOrganizationNotFound
}
return nil, err
}
return org, nil
}
// Update updates an organization
func (s *OrganizationService) Update(id int, req *dto.UpdateOrganizationRequest) (*model.Organization, error) {
org, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrOrganizationNotFound
}
return nil, err
}
// Check name uniqueness if changed
if org.Name != req.Name {
exists, err := s.repo.ExistsByName(req.Name, id)
if err != nil {
return nil, err
}
if exists {
return nil, ErrOrganizationExists
}
}
org.Name = req.Name
org.Description = req.Description
if err := s.repo.Update(org); err != nil {
return nil, err
}
return org, nil
}
// Delete soft deletes an organization
func (s *OrganizationService) Delete(id int) error {
// Check if exists
_, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrOrganizationNotFound
}
return err
}
return s.repo.SoftDelete(id)
}

View File

@@ -0,0 +1,166 @@
package service
import (
"errors"
"net"
"regexp"
"strings"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/model"
"github.com/xingrin/go-backend/internal/repository"
"gorm.io/gorm"
)
var (
ErrTargetNotFound = errors.New("target not found")
ErrTargetExists = errors.New("target name already exists")
ErrInvalidTarget = errors.New("invalid target format")
)
// TargetService handles target business logic
type TargetService struct {
repo *repository.TargetRepository
}
// NewTargetService creates a new target service
func NewTargetService(repo *repository.TargetRepository) *TargetService {
return &TargetService{repo: repo}
}
// Create creates a new target
func (s *TargetService) Create(req *dto.CreateTargetRequest) (*model.Target, error) {
exists, err := s.repo.ExistsByName(req.Name)
if err != nil {
return nil, err
}
if exists {
return nil, ErrTargetExists
}
// Auto-detect type if not provided
targetType := req.Type
if targetType == "" {
targetType = detectTargetType(req.Name)
if targetType == "" {
return nil, ErrInvalidTarget
}
}
target := &model.Target{
Name: req.Name,
Type: targetType,
}
if err := s.repo.Create(target); err != nil {
return nil, err
}
return target, nil
}
// List returns paginated targets
func (s *TargetService) List(query *dto.TargetListQuery) ([]model.Target, int64, error) {
return s.repo.FindAll(query.GetOffset(), query.GetPageSize(), query.Type, query.Search)
}
// GetByID returns a target by ID
func (s *TargetService) GetByID(id int) (*model.Target, error) {
target, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTargetNotFound
}
return nil, err
}
return target, nil
}
// Update updates a target
func (s *TargetService) Update(id int, req *dto.UpdateTargetRequest) (*model.Target, error) {
target, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrTargetNotFound
}
return nil, err
}
// Check name uniqueness if changed
if target.Name != req.Name {
exists, err := s.repo.ExistsByName(req.Name, id)
if err != nil {
return nil, err
}
if exists {
return nil, ErrTargetExists
}
}
// Auto-detect type if not provided
targetType := req.Type
if targetType == "" {
targetType = detectTargetType(req.Name)
if targetType == "" {
return nil, ErrInvalidTarget
}
}
target.Name = req.Name
target.Type = targetType
if err := s.repo.Update(target); err != nil {
return nil, err
}
return target, nil
}
// Delete soft deletes a target
func (s *TargetService) Delete(id int) error {
_, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrTargetNotFound
}
return err
}
return s.repo.SoftDelete(id)
}
// detectTargetType auto-detects target type from name
func detectTargetType(name string) string {
name = strings.TrimSpace(name)
// Check CIDR
if strings.Contains(name, "/") {
_, _, err := net.ParseCIDR(name)
if err == nil {
return "cidr"
}
}
// Check IP
if ip := net.ParseIP(name); ip != nil {
return "ip"
}
// Check domain
if isValidDomain(name) {
return "domain"
}
return ""
}
// isValidDomain validates domain format
func isValidDomain(domain string) bool {
if len(domain) == 0 || len(domain) > 253 {
return false
}
// Simple domain regex
domainRegex := regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
return domainRegex.MatchString(domain)
}

View File

@@ -0,0 +1,100 @@
package service
import (
"errors"
"github.com/xingrin/go-backend/internal/auth"
"github.com/xingrin/go-backend/internal/dto"
"github.com/xingrin/go-backend/internal/model"
"github.com/xingrin/go-backend/internal/repository"
"gorm.io/gorm"
)
var (
ErrUserNotFound = errors.New("user not found")
ErrUsernameExists = errors.New("username already exists")
ErrInvalidPassword = errors.New("invalid password")
)
// UserService handles user business logic
type UserService struct {
repo *repository.UserRepository
}
// NewUserService creates a new user service
func NewUserService(repo *repository.UserRepository) *UserService {
return &UserService{repo: repo}
}
// Create creates a new user
func (s *UserService) Create(req *dto.CreateUserRequest) (*model.User, error) {
// Check if username exists
exists, err := s.repo.ExistsByUsername(req.Username)
if err != nil {
return nil, err
}
if exists {
return nil, ErrUsernameExists
}
// Hash password
hashedPassword, err := auth.HashPassword(req.Password)
if err != nil {
return nil, err
}
user := &model.User{
Username: req.Username,
Password: hashedPassword,
Email: req.Email,
IsActive: true,
}
if err := s.repo.Create(user); err != nil {
return nil, err
}
return user, nil
}
// List returns paginated users
func (s *UserService) List(query *dto.PaginationQuery) ([]model.User, int64, error) {
return s.repo.FindAll(query.GetOffset(), query.GetPageSize())
}
// GetByID returns a user by ID
func (s *UserService) GetByID(id int) (*model.User, error) {
user, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrUserNotFound
}
return nil, err
}
return user, nil
}
// UpdatePassword updates user password
func (s *UserService) UpdatePassword(id int, req *dto.UpdatePasswordRequest) error {
user, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ErrUserNotFound
}
return err
}
// Verify old password
if !auth.VerifyPassword(req.OldPassword, user.Password) {
return ErrInvalidPassword
}
// Hash new password
hashedPassword, err := auth.HashPassword(req.NewPassword)
if err != nil {
return err
}
user.Password = hashedPassword
return s.repo.Update(user)
}