commit e01cdc98893febced1045815ad83b9097e3d6dbc Author: ZHENG XIAOYI Date: Thu Feb 12 14:31:30 2026 +0800 Initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..77b4a28 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Server Configuration +PORT=3000 +ENVIRONMENT=development + +# Database Configuration +# Possible values for DATABASE_DRIVER: sqlite, postgres +DATABASE_DRIVER=sqlite +DATABASE_PATH=./data/database.sqlite + +# PostgreSQL Configuration (Only if DATABASE_DRIVER is postgres) +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 +POSTGRES_USER=trace +POSTGRES_PASSWORD=trace123 +POSTGRES_DB=trace +POSTGRES_SSLMODE=disable + +# JWT Configuration +JWT_SECRET=your-secret-key-here-change-in-production +JWT_EXPIRE=7200 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4bdb483 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +# Binary files +*.exe +*.exe~ +*.dll +*.so +*.dylib +trace-backend + +# 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 + +# Configuration files +.env + +# Log files +*.log + +# Data files +data/*.sqlite +tests/data/ + +# Temporary files +*.tmp \ No newline at end of file diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..32396ad --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,25 @@ +# golangci-lint 配置文件 +linters-settings: + errcheck: + check-type-assertions: true + goimports: + local-prefixes: github.com/beifan/trace-backend + govet: + check-shadowing: true + misspell: + locale: US + +linters: + disable-all: true + enable: + - errcheck + - goimports + - govet + - misspell + - revive + - typecheck + +run: + skip-dirs: + - testdata + timeout: 5m diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4c9ba05 --- /dev/null +++ b/Makefile @@ -0,0 +1,81 @@ +NAME=trace-backend +VERSION=1.0.0 + +.PHONY: all clean build test test-coverage lint run + +# 默认任务 +all: clean build + +# 清理二进制文件 +clean: + @echo "清理二进制文件..." + @if exist $(NAME).exe del $(NAME).exe + @if exist $(NAME) del $(NAME) + @if exist coverage.out del coverage.out + @if exist coverage.html del coverage.html + +# 编译 +build: + @echo "编译项目..." + @go build -o $(NAME).exe main.go + +# 运行 +run: + @echo "启动服务器..." + @go run main.go + +# 测试 +test: + @echo "运行测试..." + @go test -v ./tests/... + +# 测试覆盖率 +test-coverage: + @echo "生成测试覆盖率报告..." + @go test -v ./tests/... -coverprofile=coverage.out + @go tool cover -html=coverage.out -o coverage.html + @echo "测试覆盖率报告已生成: coverage.html" + +# 代码检查 +lint: + @echo "检查代码..." + @golangci-lint run ./... + +# 格式化代码 +fmt: + @echo "格式化代码..." + @gofmt -w . + +# 导入格式化 +imports: + @echo "格式化导入..." + @goimports -w . + +# 代码质量检查(格式化 + 检查) +quality: fmt imports lint + +# 安装依赖 +deps: + @echo "安装依赖..." + @go mod tidy + +# 初始化数据库 +init-db: + @echo "初始化数据库..." + @go run main.go + +# 编译为 Linux 版本 +build-linux: + @echo "编译 Linux 版本..." + @set CGO_ENABLED=0 + @set GOOS=linux + @set GOARCH=amd64 + @go build -o $(NAME) main.go + +# 编译为 macOS 版本 +build-mac: + @echo "编译 macOS 版本..." + @set CGO_ENABLED=0 + @set GOOS=darwin + @set GOARCH=arm64 + @go build -o $(NAME) main.go \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..015b4e8 --- /dev/null +++ b/README.md @@ -0,0 +1,290 @@ +# 浙江贝凡溯源管理平台 - 后端服务 (Go 版本) + +这是一个使用 Go 语言开发的溯源管理平台后端服务,提供序列号生成、查询、管理等功能。 + +## 技术栈 + +- **Web 框架**: Gin (高性能 HTTP 框架) +- **ORM**: GORM (Go 对象关系映射) +- **数据库**: + - SQLite (默认,适用于开发和轻量级部署) + - PostgreSQL (生产环境推荐) +- **认证**: JWT (JSON Web Token) - golang-jwt/jwt/v5 +- **密码加密**: bcrypt (golang.org/x/crypto) +- **二维码生成**: yeqown/go-qrcode/v2 +- **配置管理**: + - Viper (环境变量和配置文件) + - gotenv (.env 文件加载) +- **日志**: Zap (高性能结构化日志) +- **验证**: go-playground/validator +- **测试**: Testify (测试框架) +- **工具**: UUID (github.com/google/uuid) + +## 项目结构 + +``` +backend-go/ +├── config/ # 配置管理 +│ └── config.go # 配置加载和解析(支持 .env 文件和环境变量) +├── controllers/ # 控制器层,处理 HTTP 请求 +│ ├── auth_controller.go # 认证相关接口 +│ ├── companies_controller.go # 企业管理接口 +│ ├── helper.go # 控制器通用辅助函数 +│ └── serials_controller.go # 序列号管理接口 +├── database/ # 数据库连接和操作 +│ └── database.go # 数据库初始化、连接池配置 +├── logger/ # 日志管理 +│ └── logger.go # 结构化日志(使用 Zap) +├── middleware/ # 中间件层 +│ └── auth.go # JWT 认证和权限检查 +├── models/ # 数据模型和 DTO +│ └── models.go # User、Company、Serial 等模型定义 +├── routes/ # 路由配置 +│ └── routes.go # API 路由注册 +├── services/ # 业务逻辑层 +│ ├── auth_service.go # 认证业务逻辑 +│ ├── companies_service.go # 企业管理业务逻辑 +│ ├── serials_service.go # 序列号业务逻辑 +│ └── services_test.go # 服务层单元测试 +├── tests/ # 集成测试 +│ └── main_test.go # 端到端测试 +├── data/ # 数据目录 (SQLite 数据库存储) +├── main.go # 应用程序入口 +├── go.mod # Go 模块依赖 +├── go.sum # Go 模块校验和 +├── Makefile # 构建和开发任务 +├── .env.example # 环境变量示例 +├── .env # 环境变量配置(需手动创建) +├── .golangci.yml # 代码检查配置 +└── .gitignore # Git 忽略文件 +``` + +## 快速开始 + +### 1. 安装依赖 + +```bash +cd backend-go +go mod download + +# 或使用 Makefile +make deps +``` + +### 2. 配置环境变量 + +复制 `.env.example` 文件为 `.env` 并根据需要修改: + +```bash +cp .env.example .env +``` + +**重要**: 生产环境请务必修改 `JWT_SECRET` 环境变量! + +### 3. 使用 Makefile(推荐) + +```bash +# 启动开发服务器 +make run + +# 编译项目 +make build + +# 运行测试 +make test + +# 生成测试覆盖率报告 +make test-coverage + +# 代码质量检查 +make quality + +# 清理构建文件 +make clean +``` + +### 4. 手动启动 + +```bash +go run main.go +``` + +服务器将在 http://localhost:3000 上运行。 + +### 5. 环境变量配置 + +项目支持以下环境变量(按优先级排序): + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `PORT` | 服务器端口 | 3000 | +| `ENVIRONMENT` | 运行环境 (development/production) | development | +| `JWT_SECRET` | JWT 签名密钥 | your-secret-key-here-change-in-production | +| `JWT_EXPIRE` | JWT 过期时间(秒) | 7200 | +| `DATABASE_DRIVER` | 数据库驱动 (sqlite/postgres) | sqlite | +| `DATABASE_PATH` | SQLite 数据库路径 | ./data/database.sqlite | +| `POSTGRES_HOST` | PostgreSQL 主机 | localhost | +| `POSTGRES_PORT` | PostgreSQL 端口 | 5432 | +| `POSTGRES_USER` | PostgreSQL 用户名 | trace | +| `POSTGRES_PASSWORD` | PostgreSQL 密码 | trace123 | +| `POSTGRES_DB` | PostgreSQL 数据库名 | trace | +| `POSTGRES_SSLMODE` | PostgreSQL SSL 模式 | disable | + +**注意**: 环境变量也可以通过 `.env` 文件设置。 + +### 6. 测试 API + +**健康检查**: + +```bash +curl -X GET http://localhost:3000/api/health +``` + +**用户登录**: + +```bash +curl -X POST http://localhost:3000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"password123"}' +``` + +## API 文档 + +### 认证路由 + +| 方法 | 路径 | 描述 | 需要认证 | +| ---- | --------------------------- | ----------------------- | -------- | +| POST | `/api/auth/login` | 用户登录,返回 JWT 令牌 | 否 | +| GET | `/api/auth/profile` | 获取当前用户信息 | 是 | +| PUT | `/api/auth/profile` | 更新用户信息 | 是 | +| POST | `/api/auth/change-password` | 修改密码 | 是 | + +### 序列号管理 + +| 方法 | 路径 | 描述 | 需要认证 | 角色 | +| ---- | ----------------------------------- | ---------------- | -------- | ------ | +| POST | `/api/serials/generate` | 生成序列号 | 是 | 管理员 | +| POST | `/api/serials/generate-with-prefix` | 带前缀生成序列号 | 是 | 管理员 | +| POST | `/api/serials/:serialNumber/qrcode` | 生成序列号二维码 | 是 | 任何 | +| GET | `/api/serials/:serialNumber/query` | 查询序列号信息 | 否 | 任何 | +| GET | `/api/serials` | 获取序列号列表 | 是 | 任何 | +| PUT | `/api/serials/:serialNumber` | 更新序列号信息 | 是 | 管理员 | +| POST | `/api/serials/:serialNumber/revoke` | 吊销序列号 | 是 | 管理员 | + +### 企业管理 + +| 方法 | 路径 | 描述 | 需要认证 | 角色 | +| ------ | ----------------------------- | ------------ | -------- | ------ | +| GET | `/api/companies` | 获取企业列表 | 是 | 任何 | +| POST | `/api/companies` | 创建新企业 | 是 | 管理员 | +| PUT | `/api/companies/:companyName` | 更新企业信息 | 是 | 管理员 | +| DELETE | `/api/companies/:companyName` | 删除企业 | 是 | 管理员 | + +## 测试 + +### 运行所有测试 + +```bash +# 运行所有测试 +go test -v ./... + +# 仅运行服务层单元测试 +cd services && go test -v -cover + +# 仅运行集成测试 +go test -v ./tests/... +``` + +### 生成测试覆盖率报告 + +```bash +# 生成覆盖率报告 +go test -v ./services/... -coverprofile=coverage.out +go tool cover -html=coverage.out +``` + +### 当前测试覆盖 + +- **services/**: 包含 AuthService 和 SerialsService 的完整单元测试 + - 用户认证测试(登录、获取用户信息、修改密码、更新资料) + - 序列号管理测试(生成、查询、更新、吊销、分页列表) +- **tests/**: 集成测试(健康检查、登录流程) + +## 代码检查 + +使用 golangci-lint 进行代码检查: + +```bash +golangci-lint run ./... +``` + +## 部署 + +### 编译为二进制文件 + +```bash +# Linux/Mac +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o trace-backend + +# Windows +go build -o trace-backend.exe main.go + +# macOS +CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o trace-backend +``` + +### 运行 + +```bash +./trace-backend +``` + +## 数据库迁移 + +### SQLite 到 PostgreSQL 的迁移 + +1. 修改配置文件 `config.yaml` 中的数据库驱动: + +```yaml +database: + driver: postgres + postgres: + host: localhost + port: 5432 + user: trace + password: trace123 + dbname: trace + sslmode: disable +``` + +2. 或者使用环境变量: + +```bash +DATABASE_DRIVER=postgres \ +POSTGRES_HOST=localhost \ +POSTGRES_PORT=5432 \ +POSTGRES_USER=trace \ +POSTGRES_PASSWORD=trace123 \ +POSTGRES_DB=trace \ +POSTGRES_SSLMODE=disable \ +./trace-backend +``` + +## 贡献指南 + +1. 克隆项目 +2. 创建新功能分支 +3. 提交更改 +4. 推送到远程仓库 +5. 创建 Pull Request + +### 代码风格要求 + +- 使用 gofmt 自动格式化代码 +- 遵循 Go 官方编码规范 +- 使用 golangci-lint 进行代码检查 +- 保持代码简洁和高效 + +## 许可证 + +MIT License diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..4057789 --- /dev/null +++ b/config/config.go @@ -0,0 +1,115 @@ +package config + +import ( + "fmt" + + "github.com/spf13/viper" + "github.com/subosito/gotenv" +) + +// ServerConfig 服务器配置 +type ServerConfig struct { + Port string `mapstructure:"port"` + Environment string `mapstructure:"environment"` +} + +// DatabaseConfig 数据库配置 +type DatabaseConfig struct { + Driver string `mapstructure:"driver"` + SQLite struct { + Path string `mapstructure:"path"` + } `mapstructure:"sqlite"` + Postgres struct { + Host string `mapstructure:"host"` + Port string `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` + SSLMode string `mapstructure:"sslmode"` + } `mapstructure:"postgres"` +} + +// JWTConfig JWT 配置 +type JWTConfig struct { + Secret string `mapstructure:"secret"` + Expire int `mapstructure:"expire"` +} + +// AppConfig 应用程序配置 +type AppConfig struct { + Server ServerConfig `mapstructure:"server"` + Database DatabaseConfig `mapstructure:"database"` + JWT JWTConfig `mapstructure:"jwt"` +} + +// 全局配置变量 +var appConfig AppConfig + +// LoadConfig 加载配置 +func LoadConfig() { + // 使用 gotenv 加载 .env 文件 + if err := gotenv.Load(); err != nil { + fmt.Printf("警告: 未找到 .env 文件: %v\n", err) + } + + // 配置 Viper 以读取 YAML 配置文件 + viper.SetConfigName("config") + viper.SetConfigType("yaml") + viper.AddConfigPath(".") + viper.AddConfigPath("./config") + viper.AddConfigPath("../") + if err := viper.MergeInConfig(); err != nil { + fmt.Printf("警告: 未找到配置文件,使用默认值: %v\n", err) + } + + // 环境变量前缀 + viper.SetEnvPrefix("TRACE") + viper.AutomaticEnv() + + // 默认配置 + viper.SetDefault("server.port", "3000") + viper.SetDefault("server.environment", "development") + viper.SetDefault("database.driver", "sqlite") + viper.SetDefault("database.sqlite.path", "./data/database.sqlite") + viper.SetDefault("database.postgres.host", "localhost") + viper.SetDefault("database.postgres.port", "5432") + viper.SetDefault("database.postgres.user", "trace") + viper.SetDefault("database.postgres.password", "trace123") + viper.SetDefault("database.postgres.dbname", "trace") + viper.SetDefault("database.postgres.sslmode", "disable") + viper.SetDefault("jwt.secret", "your-secret-key-here-change-in-production") + viper.SetDefault("jwt.expire", 7200) + + // 绑定环境变量(支持无前缀的 .env 文件) + viper.BindEnv("server.port", "PORT", "TRACE_SERVER_PORT") + viper.BindEnv("server.environment", "ENVIRONMENT", "TRACE_SERVER_ENVIRONMENT") + viper.BindEnv("jwt.secret", "JWT_SECRET", "TRACE_JWT_SECRET") + viper.BindEnv("jwt.expire", "JWT_EXPIRE", "TRACE_JWT_EXPIRE") + viper.BindEnv("database.driver", "DATABASE_DRIVER", "TRACE_DATABASE_DRIVER") + viper.BindEnv("database.sqlite.path", "DATABASE_PATH", "TRACE_DATABASE_SQLITE_PATH") + viper.BindEnv("database.postgres.host", "POSTGRES_HOST", "TRACE_DATABASE_POSTGRES_HOST") + viper.BindEnv("database.postgres.port", "POSTGRES_PORT", "TRACE_DATABASE_POSTGRES_PORT") + viper.BindEnv("database.postgres.user", "POSTGRES_USER", "TRACE_DATABASE_POSTGRES_USER") + viper.BindEnv("database.postgres.password", "POSTGRES_PASSWORD", "TRACE_DATABASE_POSTGRES_PASSWORD") + viper.BindEnv("database.postgres.dbname", "POSTGRES_DB", "TRACE_DATABASE_POSTGRES_DBNAME") + viper.BindEnv("database.postgres.sslmode", "POSTGRES_SSLMODE", "TRACE_DATABASE_POSTGRES_SSLMODE") + + // 解析配置 + if err := viper.Unmarshal(&appConfig); err != nil { + fmt.Printf("配置解析失败: %v\n", err) + } + + // 验证 JWT 密钥 + if appConfig.JWT.Secret == "your-secret-key-here-change-in-production" { + fmt.Println("警告: 使用默认 JWT 密钥,请在生产环境中设置 JWT_SECRET 环境变量") + } + + // 调试打印 + fmt.Printf("加载的配置 - 环境: %s\n", appConfig.Server.Environment) + fmt.Printf("加载的配置 - 数据库驱动: %s\n", appConfig.Database.Driver) +} + +// GetAppConfig 获取应用程序配置 +func GetAppConfig() *AppConfig { + return &appConfig +} diff --git a/config/config.yaml b/config/config.yaml new file mode 100644 index 0000000..5da1b1b --- /dev/null +++ b/config/config.yaml @@ -0,0 +1,19 @@ +server: + port: "3000" + environment: "development" + +database: + driver: "sqlite" + sqlite: + path: "./data/database.sqlite" + postgres: + host: "localhost" + port: "5432" + user: "trace" + password: "trace123" + dbname: "trace" + sslmode: "disable" + +jwt: + secret: "your-secret-key-here-change-in-production" + expire: 7200 diff --git a/controllers/auth_controller.go b/controllers/auth_controller.go new file mode 100644 index 0000000..17c3154 --- /dev/null +++ b/controllers/auth_controller.go @@ -0,0 +1,165 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.beifan.cn/trace-system/backend-go/models" + "git.beifan.cn/trace-system/backend-go/services" +) + +// AuthController 认证控制器 +type AuthController struct { + authService services.AuthService +} + +// NewAuthController 创建认证控制器实例 +func NewAuthController() *AuthController { + return &AuthController{ + authService: services.AuthService{}, + } +} + +// Login 登录 +// @Summary 用户登录 +// @Description 验证用户身份并返回 JWT 令牌 +// @Tags 认证 +// @Accept json +// @Produce json +// @Param loginData body models.LoginDTO true "登录数据" +// @Success 200 {object} gin.H{message: string, accessToken: string, user: models.UserDTO} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Router /auth/login [post] +func (c *AuthController) Login(ctx *gin.Context) { + var loginData models.LoginDTO + if err := ctx.ShouldBindJSON(&loginData); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "message": "无效的请求数据", + "error": err.Error(), + }) + return + } + + user, err := c.authService.ValidateUser(loginData.Username, loginData.Password) + if err != nil { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "message": err.Error(), + }) + return + } + + token, err := c.authService.GenerateToken(user) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "message": "令牌生成失败", + }) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "登录成功", + "accessToken": token, + "user": models.UserDTO{ + ID: user.ID, + Username: user.Username, + Name: user.Name, + Email: user.Email, + Role: user.Role, + CreatedAt: user.CreatedAt, + }, + }) +} + +// GetProfile 获取用户信息 +// @Summary 获取用户信息 +// @Description 获取当前登录用户的个人信息 +// @Tags 认证 +// @Produce json +// @Security BearerAuth +// @Success 200 {object} models.UserDTO +// @Failure 401 {object} gin.H{message: string} +// @Router /auth/profile [get] +func (c *AuthController) GetProfile(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + profile, err := c.authService.GetProfile(userModel.ID) + if err != nil { + ErrorResponse(ctx, http.StatusUnauthorized, err.Error()) + return + } + + SuccessResponse(ctx, "获取用户信息成功", gin.H{ + "user": profile, + }) +} + +// ChangePassword 修改密码 +// @Summary 修改密码 +// @Description 修改当前登录用户的密码 +// @Tags 认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param passwordData body models.ChangePasswordDTO true "密码修改数据" +// @Success 200 {object} gin.H{message: string} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Router /auth/change-password [post] +func (c *AuthController) ChangePassword(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + var changePasswordData models.ChangePasswordDTO + if !BindJSON(ctx, &changePasswordData) { + return + } + + err := c.authService.ChangePassword(userModel.ID, changePasswordData.CurrentPassword, changePasswordData.NewPassword) + if err != nil { + ErrorResponse(ctx, http.StatusUnauthorized, err.Error()) + return + } + + SuccessResponse(ctx, "密码修改成功") +} + +// UpdateProfile 更新用户信息 +// @Summary 更新用户信息 +// @Description 更新当前登录用户的个人信息 +// @Tags 认证 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param profileData body models.UpdateProfileDTO true "用户信息更新数据" +// @Success 200 {object} models.UserDTO +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Router /auth/profile [put] +func (c *AuthController) UpdateProfile(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + var updateProfileData models.UpdateProfileDTO + if !BindJSON(ctx, &updateProfileData) { + return + } + + profile, err := c.authService.UpdateProfile(userModel.ID, updateProfileData.Name, updateProfileData.Email) + if err != nil { + ErrorResponse(ctx, http.StatusUnauthorized, err.Error()) + return + } + + SuccessResponse(ctx, "用户信息更新成功", gin.H{ + "user": profile, + }) +} diff --git a/controllers/companies_controller.go b/controllers/companies_controller.go new file mode 100644 index 0000000..54f0957 --- /dev/null +++ b/controllers/companies_controller.go @@ -0,0 +1,200 @@ +package controllers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.beifan.cn/trace-system/backend-go/services" +) + +// CompaniesController 企业管理控制器 +type CompaniesController struct { + companiesService services.CompaniesService +} + +// NewCompaniesController 创建企业管理控制器实例 +func NewCompaniesController() *CompaniesController { + return &CompaniesController{ + companiesService: services.CompaniesService{}, + } +} + +// FindAll 获取企业列表 +// @Summary 获取企业列表 +// @Description 获取企业列表,支持分页和搜索 +// @Tags 企业管理 +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" +// @Param limit query int false "每页数量" +// @Param search query string false "搜索关键词" +// @Success 200 {object} gin.H{message: string, data: []models.Company, pagination: gin.H{page: int, limit: int, total: int, totalPages: int}} +// @Failure 401 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /companies [get] +func (c *CompaniesController) FindAll(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) + search := ctx.DefaultQuery("search", "") + + companies, total, totalPages, err := c.companiesService.FindAll(page, limit, search) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "message": err.Error(), + }) + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "获取企业列表成功", + "data": companies, + "pagination": gin.H{ + "page": page, + "limit": limit, + "total": total, + "totalPages": totalPages, + }, + }) +} + +// Create 创建企业 +// @Summary 创建企业 +// @Description 创建新的企业 +// @Tags 企业管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param companyData body gin.H{companyName: string} true "企业数据" +// @Success 201 {object} gin.H{message: string, company: models.Company} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Failure 409 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /companies [post] +func (c *CompaniesController) Create(ctx *gin.Context) { + var companyData struct { + CompanyName string `json:"companyName" validate:"required"` + } + if err := ctx.ShouldBindJSON(&companyData); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "message": "无效的请求数据", + "error": err.Error(), + }) + return + } + + company, err := c.companiesService.Create(companyData.CompanyName) + if err != nil { + if err.Error() == "企业名称已存在" { + ctx.JSON(http.StatusConflict, gin.H{ + "message": err.Error(), + }) + } else { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "message": err.Error(), + }) + } + return + } + + ctx.JSON(http.StatusCreated, gin.H{ + "message": "企业创建成功", + "company": company, + }) +} + +// Update 更新企业信息 +// @Summary 更新企业信息 +// @Description 更新企业信息 +// @Tags 企业管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param companyName path string true "企业名称" +// @Param companyData body gin.H{companyName?: string, isActive?: bool} true "企业数据" +// @Success 200 {object} gin.H{message: string, company: models.Company} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Failure 404 {object} gin.H{message: string} +// @Failure 409 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /companies/{companyName} [put] +func (c *CompaniesController) Update(ctx *gin.Context) { + companyName := ctx.Param("companyName") + + var companyData struct { + CompanyName string `json:"companyName"` + IsActive bool `json:"isActive"` + } + if err := ctx.ShouldBindJSON(&companyData); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "message": "无效的请求数据", + "error": err.Error(), + }) + return + } + + company, err := c.companiesService.Update(companyName, companyData.CompanyName, companyData.IsActive) + if err != nil { + if err.Error() == "企业不存在" { + ctx.JSON(http.StatusNotFound, gin.H{ + "message": err.Error(), + }) + } else if err.Error() == "企业名称已存在" { + ctx.JSON(http.StatusConflict, gin.H{ + "message": err.Error(), + }) + } else { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "message": err.Error(), + }) + } + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "企业信息更新成功", + "company": company, + }) +} + +// Delete 删除企业 +// @Summary 删除企业 +// @Description 删除企业 +// @Tags 企业管理 +// @Produce json +// @Security BearerAuth +// @Param companyName path string true "企业名称" +// @Success 200 {object} gin.H{message: string} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Failure 404 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /companies/{companyName} [delete] +func (c *CompaniesController) Delete(ctx *gin.Context) { + companyName := ctx.Param("companyName") + + err := c.companiesService.Delete(companyName) + if err != nil { + if err.Error() == "企业不存在" { + ctx.JSON(http.StatusNotFound, gin.H{ + "message": err.Error(), + }) + } else if err.Error() == "企业下还有序列号,无法删除" { + ctx.JSON(http.StatusBadRequest, gin.H{ + "message": err.Error(), + }) + } else { + ctx.JSON(http.StatusInternalServerError, gin.H{ + "message": err.Error(), + }) + } + return + } + + ctx.JSON(http.StatusOK, gin.H{ + "message": "企业删除成功", + }) +} diff --git a/controllers/helper.go b/controllers/helper.go new file mode 100644 index 0000000..bb85eb1 --- /dev/null +++ b/controllers/helper.go @@ -0,0 +1,81 @@ +package controllers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + + "git.beifan.cn/trace-system/backend-go/models" +) + +// GetCurrentUser 从上下文中获取当前用户 +// 如果用户未认证,返回 false 并返回 401 错误 +func GetCurrentUser(ctx *gin.Context) (models.User, bool) { + user, exists := ctx.Get("user") + if !exists { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "message": "未认证", + }) + return models.User{}, false + } + + userModel, ok := user.(models.User) + if !ok { + ctx.JSON(http.StatusUnauthorized, gin.H{ + "message": "用户信息无效", + }) + return models.User{}, false + } + + return userModel, true +} + +// BindJSON 绑定 JSON 请求体 +// 如果解析失败,返回错误并返回 400 响应 +func BindJSON(ctx *gin.Context, obj interface{}) bool { + if err := ctx.ShouldBindJSON(obj); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "message": "无效的请求数据", + "error": err.Error(), + }) + return false + } + return true +} + +// BindQuery 绑定查询参数 +// 如果解析失败,返回错误并返回 400 响应 +func BindQuery(ctx *gin.Context, obj interface{}) bool { + if err := ctx.ShouldBindQuery(obj); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{ + "message": "无效的查询参数", + "error": err.Error(), + }) + return false + } + return true +} + +// ErrorResponse 返回错误响应 +func ErrorResponse(ctx *gin.Context, status int, message string, err ...error) { + response := gin.H{ + "message": message, + } + if len(err) > 0 && err[0] != nil { + response["error"] = err[0].Error() + } + ctx.JSON(status, response) +} + +// SuccessResponse 返回成功响应 +func SuccessResponse(ctx *gin.Context, message string, data ...gin.H) { + response := gin.H{ + "message": message, + } + if len(data) > 0 { + for key, value := range data[0] { + response[key] = value + } + } + ctx.JSON(http.StatusOK, response) +} diff --git a/controllers/serials_controller.go b/controllers/serials_controller.go new file mode 100644 index 0000000..9e38ec0 --- /dev/null +++ b/controllers/serials_controller.go @@ -0,0 +1,268 @@ +package controllers + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "git.beifan.cn/trace-system/backend-go/models" + "git.beifan.cn/trace-system/backend-go/services" +) + +// SerialsController 序列号控制器 +type SerialsController struct { + serialsService services.SerialsService +} + +// NewSerialsController 创建序列号控制器实例 +func NewSerialsController() *SerialsController { + return &SerialsController{ + serialsService: services.SerialsService{}, + } +} + +// Generate 生成序列号 +// @Summary 生成序列号 +// @Description 生成指定数量的序列号 +// @Tags 序列号管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param generateData body models.GenerateSerialDTO true "生成数据" +// @Success 200 {object} gin.H{message: string, serials: []models.Serial} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /serials/generate [post] +func (c *SerialsController) Generate(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + var generateData models.GenerateSerialDTO + if !BindJSON(ctx, &generateData) { + return + } + + serials, err := c.serialsService.Generate( + generateData.CompanyName, + generateData.Quantity, + generateData.ValidDays, + userModel.ID, + ) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "成功生成 "+strconv.Itoa(len(serials))+" 个序列号", gin.H{ + "serials": serials, + }) +} + +// GenerateWithPrefix 带前缀生成序列号 +// @Summary 带前缀生成序列号 +// @Description 生成带有指定前缀的序列号 +// @Tags 序列号管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param generateData body models.GenerateWithPrefixDTO true "生成数据" +// @Success 200 {object} gin.H{message: string, serials: []models.Serial} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /serials/generate-with-prefix [post] +func (c *SerialsController) GenerateWithPrefix(ctx *gin.Context) { + userModel, ok := GetCurrentUser(ctx) + if !ok { + return + } + + var generateData models.GenerateWithPrefixDTO + if !BindJSON(ctx, &generateData) { + return + } + + serials, err := c.serialsService.Generate( + generateData.CompanyName, + generateData.Quantity, + generateData.ValidDays, + userModel.ID, + generateData.SerialPrefix, + ) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "成功生成 "+strconv.Itoa(len(serials))+" 个序列号", gin.H{ + "serials": serials, + }) +} + +// GenerateQRCode 生成二维码 +// @Summary 生成二维码 +// @Description 为指定序列号生成查询二维码 +// @Tags 序列号管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "序列号" +// @Param qrCodeData body models.QRCodeDTO false "二维码数据" +// @Success 200 {object} gin.H{message: string, qrCodeData: string, queryUrl: string} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Failure 404 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /serials/{serialNumber}/qrcode [post] +func (c *SerialsController) GenerateQRCode(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + var qrCodeData models.QRCodeDTO + if !BindJSON(ctx, &qrCodeData) { + return + } + + protocol := "http" + if ctx.Request.TLS != nil { + protocol = "https" + } + + qrCodeBase64, queryUrl, err := c.serialsService.GenerateQRCode( + serialNumber, + qrCodeData.BaseUrl, + ctx.Request.Host, + protocol, + ) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "二维码生成成功", gin.H{ + "qrCodeData": qrCodeBase64, + "queryUrl": queryUrl, + }) +} + +// Query 查询序列号信息 +// @Summary 查询序列号信息 +// @Description 查询指定序列号的详细信息 +// @Tags 序列号查询 +// @Produce json +// @Param serialNumber path string true "序列号" +// @Success 200 {object} gin.H{message: string, serial: models.Serial} +// @Failure 400 {object} gin.H{message: string} +// @Failure 404 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /serials/{serialNumber}/query [get] +func (c *SerialsController) Query(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + serial, err := c.serialsService.Query(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "查询成功", gin.H{ + "serial": serial, + }) +} + +// FindAll 获取序列号列表 +// @Summary 获取序列号列表 +// @Description 获取序列号列表,支持分页和搜索 +// @Tags 序列号管理 +// @Produce json +// @Security BearerAuth +// @Param page query int false "页码" +// @Param limit query int false "每页数量" +// @Param search query string false "搜索关键词" +// @Success 200 {object} gin.H{message: string, data: []models.Serial, pagination: gin.H{page: int, limit: int, total: int, totalPages: int}} +// @Failure 401 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /serials [get] +func (c *SerialsController) FindAll(ctx *gin.Context) { + page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1")) + limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "20")) + search := ctx.DefaultQuery("search", "") + + serials, total, totalPages, err := c.serialsService.FindAll(page, limit, search) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "获取序列号列表成功", gin.H{ + "data": serials, + "pagination": gin.H{ + "page": page, + "limit": limit, + "total": total, + "totalPages": totalPages, + }, + }) +} + +// Update 更新序列号信息 +// @Summary 更新序列号信息 +// @Description 更新指定序列号的信息 +// @Tags 序列号管理 +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "序列号" +// @Param updateData body models.UpdateSerialDTO true "更新数据" +// @Success 200 {object} gin.H{message: string, serial: models.Serial} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Failure 404 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /serials/{serialNumber} [put] +func (c *SerialsController) Update(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + var updateData models.UpdateSerialDTO + if !BindJSON(ctx, &updateData) { + return + } + + serial, err := c.serialsService.Update(serialNumber, updateData) + if err != nil { + ErrorResponse(ctx, http.StatusInternalServerError, err.Error()) + return + } + + SuccessResponse(ctx, "序列号信息更新成功", gin.H{ + "serial": serial, + }) +} + +// Revoke 吊销序列号 +// @Summary 吊销序列号 +// @Description 吊销指定序列号 +// @Tags 序列号管理 +// @Produce json +// @Security BearerAuth +// @Param serialNumber path string true "序列号" +// @Success 200 {object} gin.H{message: string} +// @Failure 400 {object} gin.H{message: string} +// @Failure 401 {object} gin.H{message: string} +// @Failure 404 {object} gin.H{message: string} +// @Failure 500 {object} gin.H{message: string} +// @Router /serials/{serialNumber}/revoke [post] +func (c *SerialsController) Revoke(ctx *gin.Context) { + serialNumber := ctx.Param("serialNumber") + + err := c.serialsService.Revoke(serialNumber) + if err != nil { + ErrorResponse(ctx, http.StatusBadRequest, err.Error()) + return + } + + SuccessResponse(ctx, "序列号吊销成功") +} diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..b6111ca --- /dev/null +++ b/database/database.go @@ -0,0 +1,120 @@ +package database + +import ( + "os" + "path/filepath" + + "gorm.io/driver/postgres" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "git.beifan.cn/trace-system/backend-go/config" + "git.beifan.cn/trace-system/backend-go/logger" + "git.beifan.cn/trace-system/backend-go/models" + _ "github.com/mattn/go-sqlite3" +) + +var DB *gorm.DB + +// InitDB 初始化数据库连接 +func InitDB() { + var err error + cfg := config.GetAppConfig() + dbConfig := cfg.Database + + logger.Info("数据库配置信息", + logger.String("驱动", dbConfig.Driver), + ) + + switch dbConfig.Driver { + case "sqlite": + logger.Info("使用 SQLite 数据库", logger.String("路径", dbConfig.SQLite.Path)) + DB, err = initSQLite(dbConfig.SQLite.Path) + case "postgres": + logger.Info("使用 PostgreSQL 数据库", + logger.String("主机", dbConfig.Postgres.Host), + logger.String("端口", dbConfig.Postgres.Port), + logger.String("数据库", dbConfig.Postgres.DBName), + ) + DB, err = initPostgres(dbConfig.Postgres) + default: + logger.Fatal("不支持的数据库驱动: " + dbConfig.Driver) + } + + if err != nil { + logger.Fatal("数据库连接失败", logger.Err(err)) + } + + logger.Info("数据库连接成功") +} + +// initSQLite 初始化 SQLite 连接 +func initSQLite(path string) (*gorm.DB, error) { + // 确保数据目录存在 + dataDir := filepath.Dir(path) + if err := os.MkdirAll(dataDir, 0755); err != nil { + return nil, err + } + + db, err := gorm.Open(sqlite.Open(path+"?_pragma=foreign_keys(1)"), &gorm.Config{}) + if err != nil { + logger.Warn("SQLite 连接失败,尝试使用内存数据库", logger.Err(err)) + // 尝试使用内存数据库作为备选方案 + db, err = gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + } + + if err != nil { + return nil, err + } + + // 配置连接池 + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(25) + sqlDB.SetMaxIdleConns(5) + sqlDB.SetConnMaxLifetime(5 * 60 * 1000000000) + + return db, nil +} + +// initPostgres 初始化 PostgreSQL 连接 +func initPostgres(pgConfig struct { + Host string `mapstructure:"host"` + Port string `mapstructure:"port"` + User string `mapstructure:"user"` + Password string `mapstructure:"password"` + DBName string `mapstructure:"dbname"` + SSLMode string `mapstructure:"sslmode"` +}) (*gorm.DB, error) { + dsn := "host=" + pgConfig.Host + + " port=" + pgConfig.Port + + " user=" + pgConfig.User + + " password=" + pgConfig.Password + + " dbname=" + pgConfig.DBName + + " sslmode=" + pgConfig.SSLMode + + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{}) + if err != nil { + logger.Fatal("PostgreSQL 连接失败", logger.Err(err)) + } + + // 配置连接池 + sqlDB, _ := db.DB() + sqlDB.SetMaxOpenConns(100) + sqlDB.SetMaxIdleConns(10) + sqlDB.SetConnMaxLifetime(30 * 60 * 1000000000) + + return db, nil +} + +// AutoMigrate 自动迁移数据库 +func AutoMigrate() { + if err := DB.AutoMigrate( + &models.User{}, + &models.Company{}, + &models.Serial{}, + ); err != nil { + logger.Fatal("数据库迁移失败", logger.Err(err)) + } + + logger.Info("数据库迁移成功") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c1ad3cc --- /dev/null +++ b/go.mod @@ -0,0 +1,79 @@ +module git.beifan.cn/trace-system/backend-go + +go 1.26 + +require ( + github.com/gin-gonic/gin v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/mattn/go-sqlite3 v1.14.22 + github.com/spf13/viper v1.21.0 + github.com/stretchr/testify v1.11.1 + github.com/subosito/gotenv v1.6.0 + github.com/yeqown/go-qrcode/v2 v2.2.5 + github.com/yeqown/go-qrcode/writer/standard v1.3.0 + go.uber.org/zap v1.27.1 + golang.org/x/crypto v0.48.0 + gorm.io/driver/postgres v1.6.0 + gorm.io/driver/sqlite v1.6.0 + gorm.io/gorm v1.31.1 +) + +require ( + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/go-viper/mapstructure/v2 v2.4.0 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/yeqown/reedsolomon v1.0.0 // indirect + go.uber.org/mock v0.5.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/image v0.10.0 // indirect + golang.org/x/mod v0.32.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/tools v0.41.0 // indirect +) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/sagikazarmark/locafero v0.11.0 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/spf13/cast v1.10.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a1f5be9 --- /dev/null +++ b/go.sum @@ -0,0 +1,197 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +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/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= +github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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/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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= +github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +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.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +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/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/yeqown/go-qrcode/v2 v2.2.5 h1:HCOe2bSjkhZyYoyyNaXNzh4DJZll6inVJQQw+8228Zk= +github.com/yeqown/go-qrcode/v2 v2.2.5/go.mod h1:uHpt9CM0V1HeXLz+Wg5MN50/sI/fQhfkZlOM+cOTHxw= +github.com/yeqown/go-qrcode/writer/standard v1.3.0 h1:chdyhEfRtUPgQtuPeaWVGQ/TQx4rE1PqeoW3U+53t34= +github.com/yeqown/go-qrcode/writer/standard v1.3.0/go.mod h1:O4MbzsotGCvy8upYPCR91j81dr5XLT7heuljcNXW+oQ= +github.com/yeqown/reedsolomon v1.0.0 h1:x1h/Ej/uJnNu8jaX7GLHBWmZKCAWjEJTetkqaabr4B0= +github.com/yeqown/reedsolomon v1.0.0/go.mod h1:P76zpcn2TCuL0ul1Fso373qHRc69LKwAw/Iy6g1WiiM= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/image v0.10.0 h1:gXjUUtwtx5yOE0VKWq1CH4IJAClq4UGgUA3i+rpON9M= +golang.org/x/image v0.10.0/go.mod h1:jtrku+n79PfroUbvDdeUWMAI+heR786BofxrbiSF+J0= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= +golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= +golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +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/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/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= +gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= +gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= +gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 0000000..1676312 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,114 @@ +package logger + +import ( + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Logger 全局日志实例 +var Logger *zap.Logger + +// InitializeLogger 初始化日志系统 +func InitializeLogger(env string) error { + var config zap.Config + + switch env { + case "development": + config = zap.NewDevelopmentConfig() + config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + case "production": + config = zap.NewProductionConfig() + config.Encoding = "json" + config.EncoderConfig.TimeKey = "timestamp" + config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + default: + config = zap.NewDevelopmentConfig() + } + + config.OutputPaths = []string{"stdout"} + config.ErrorOutputPaths = []string{"stderr"} + + var err error + Logger, err = config.Build() + if err != nil { + return err + } + + zap.ReplaceGlobals(Logger) + + return nil +} + +// Sync 同步日志到磁盘 +func Sync() { + if err := Logger.Sync(); err != nil { + Logger.Error("Failed to sync logger", zap.Error(err)) + } +} + +// Info 记录信息级别的日志 +func Info(msg string, fields ...zap.Field) { + Logger.Info(msg, fields...) +} + +// Warn 记录警告级别的日志 +func Warn(msg string, fields ...zap.Field) { + Logger.Warn(msg, fields...) +} + +// Error 记录错误级别的日志 +func Error(msg string, fields ...zap.Field) { + Logger.Error(msg, fields...) +} + +// Fatal 记录致命级别的日志 +func Fatal(msg string, fields ...zap.Field) { + Logger.Fatal(msg, fields...) +} + +// Debug 记录调试级别的日志 +func Debug(msg string, fields ...zap.Field) { + Logger.Debug(msg, fields...) +} + +// String 字符串字段 +func String(key, value string) zap.Field { + return zap.String(key, value) +} + +// Int 整数字段 +func Int(key string, value int) zap.Field { + return zap.Int(key, value) +} + +// Int64 64位整数字段 +func Int64(key string, value int64) zap.Field { + return zap.Int64(key, value) +} + +// Bool 布尔字段 +func Bool(key string, value bool) zap.Field { + return zap.Bool(key, value) +} + +// Float64 浮点数字段 +func Float64(key string, value float64) zap.Field { + return zap.Float64(key, value) +} + +// Err 错误字段 +func Err(err error) zap.Field { + return zap.Error(err) +} + +// Time 时间字段 +func Time(key string, value time.Time) zap.Field { + return zap.Time(key, value) +} + +// Duration 时间间隔字段 +func Duration(key string, value time.Duration) zap.Field { + return zap.Duration(key, value) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..c9eb775 --- /dev/null +++ b/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "git.beifan.cn/trace-system/backend-go/config" + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/logger" + "git.beifan.cn/trace-system/backend-go/routes" + "github.com/gin-gonic/gin" +) + +func main() { + // 加载配置 + config.LoadConfig() + cfg := config.GetAppConfig() + + // 初始化日志系统 + if err := logger.InitializeLogger(cfg.Server.Environment); err != nil { + panic("日志系统初始化失败: " + err.Error()) + } + defer logger.Sync() + + logger.Info("服务器启动", + logger.String("环境", cfg.Server.Environment), + logger.String("端口", cfg.Server.Port), + ) + + // 根据环境设置 Gin 模式 + if cfg.Server.Environment == "production" { + gin.SetMode(gin.ReleaseMode) + } else { + gin.SetMode(gin.DebugMode) + } + + // 初始化数据库 + database.InitDB() + + // 自动迁移数据库 + database.AutoMigrate() + + // 初始化 Gin 引擎 + r := gin.New() + + // 添加中间件 + r.Use(gin.Logger()) + r.Use(gin.Recovery()) + + // 启用 CORS + r.Use(func(c *gin.Context) { + c.Writer.Header().Set("Access-Control-Allow-Origin", "*") + c.Writer.Header().Set("Access-Control-Allow-Credentials", "true") + c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With") + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE") + + if c.Request.Method == "OPTIONS" { + c.AbortWithStatus(204) + return + } + c.Next() + }) + + // 配置路由 + routes.SetupRoutes(r) + + // 设置 API 前缀 + api := r.Group("/api") + routes.SetupAPIRoutes(api) + + // 启动服务器 + port := cfg.Server.Port + logger.Info("服务器运行在 http://localhost:" + port) + logger.Info("API 文档: http://localhost:" + port + "/api/health") + logger.Info("环境: " + cfg.Server.Environment) + + if err := r.Run(":" + port); err != nil { + logger.Fatal("服务器启动失败", logger.Err(err)) + } +} diff --git a/middleware/auth.go b/middleware/auth.go new file mode 100644 index 0000000..c029154 --- /dev/null +++ b/middleware/auth.go @@ -0,0 +1,112 @@ +package middleware + +import ( + "errors" + "net/http" + "strings" + "time" + + "git.beifan.cn/trace-system/backend-go/config" + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/models" + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// JWTAuthMiddleware JWT 认证中间件 +func JWTAuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": "缺少授权头部", + }) + return + } + + parts := strings.SplitN(authHeader, " ", 2) + if !(len(parts) == 2 && parts[0] == "Bearer") { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": "授权头部格式错误", + }) + return + } + + tokenStr := parts[1] + + // 解析 JWT token + token, err := jwt.Parse(tokenStr, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, errors.New("不支持的签名方法") + } + cfg := config.GetAppConfig() + return []byte(cfg.JWT.Secret), nil + }) + + if err != nil || !token.Valid { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": "无效的访问令牌", + }) + return + } + + // 验证 claims + if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid { + // 检查过期时间 + if exp, ok := claims["exp"].(float64); ok { + if time.Now().Unix() > int64(exp) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": "令牌已过期", + }) + return + } + } + + // 获取用户 ID + if userId, ok := claims["userId"].(float64); ok { + // 验证用户是否存在 + var user models.User + result := database.DB.First(&user, uint(userId)) + if result.Error != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": "用户不存在", + }) + return + } + + // 将用户信息存储到上下文 + c.Set("user", user) + } else { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": "令牌无效", + }) + return + } + } + + c.Next() + } +} + +// AdminMiddleware 管理员权限中间件 +func AdminMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + user, exists := c.Get("user") + if !exists { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "message": "未认证", + }) + return + } + + userModel := user.(models.User) + if userModel.Role != "admin" { + c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ + "message": "无权限访问此资源", + }) + return + } + + c.Next() + } +} diff --git a/models/models.go b/models/models.go new file mode 100644 index 0000000..47770eb --- /dev/null +++ b/models/models.go @@ -0,0 +1,102 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// User 模型 +type User struct { + ID uint `gorm:"primaryKey"` + Username string `gorm:"uniqueIndex;size:255"` + Password string `gorm:"size:255"` + Name string `gorm:"size:255"` + Email string `gorm:"size:255"` + Role string `gorm:"size:50;default:'user'"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Serials []Serial `gorm:"foreignKey:CreatedBy"` +} + +// Company 模型 +type Company struct { + ID uint `gorm:"primaryKey"` + CompanyName string `gorm:"uniqueIndex;size:255"` + IsActive bool `gorm:"default:true"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + Serials []Serial `gorm:"foreignKey:CompanyName;references:CompanyName"` +} + +// Serial 模型 +type Serial struct { + ID uint `gorm:"primaryKey"` + SerialNumber string `gorm:"uniqueIndex;size:255"` + CompanyName string `gorm:"index;size:255"` + ValidUntil *time.Time + IsActive bool `gorm:"default:true"` + CreatedBy *uint + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt gorm.DeletedAt `gorm:"index"` + User *User `gorm:"foreignKey:CreatedBy"` + Company *Company `gorm:"foreignKey:CompanyName;references:CompanyName"` +} + +// UserDTO 数据传输对象 +type UserDTO struct { + ID uint `json:"id"` + Username string `json:"username"` + Name string `json:"name"` + Email string `json:"email"` + Role string `json:"role"` + CreatedAt time.Time `json:"createdAt"` +} + +// LoginDTO 登录请求数据 +type LoginDTO struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required,min=6"` +} + +// ChangePasswordDTO 密码修改请求数据 +type ChangePasswordDTO struct { + CurrentPassword string `json:"currentPassword" validate:"required"` + NewPassword string `json:"newPassword" validate:"required,min=6"` +} + +// UpdateProfileDTO 个人信息更新请求数据 +type UpdateProfileDTO struct { + Name string `json:"name" validate:"required"` + Email string `json:"email" validate:"required,email"` +} + +// GenerateSerialDTO 生成序列号请求数据 +type GenerateSerialDTO struct { + CompanyName string `json:"companyName" validate:"required"` + Quantity int `json:"quantity" validate:"min=1,max=1000"` + ValidDays int `json:"validDays" validate:"min=1,max=3650"` +} + +// GenerateWithPrefixDTO 带前缀生成序列号请求数据 +type GenerateWithPrefixDTO struct { + CompanyName string `json:"companyName" validate:"required"` + Quantity int `json:"quantity" validate:"min=1,max=1000"` + ValidDays int `json:"validDays" validate:"min=1,max=3650"` + SerialPrefix string `json:"serialPrefix" validate:"omitempty,alphanum"` +} + +// UpdateSerialDTO 序列号更新请求数据 +type UpdateSerialDTO struct { + CompanyName string `json:"companyName,omitempty" validate:"omitempty"` + ValidUntil *time.Time `json:"validUntil,omitempty"` + IsActive *bool `json:"isActive,omitempty"` +} + +// QRCodeDTO 二维码生成请求数据 +type QRCodeDTO struct { + BaseUrl string `json:"baseUrl,omitempty"` +} diff --git a/routes/routes.go b/routes/routes.go new file mode 100644 index 0000000..093c9f1 --- /dev/null +++ b/routes/routes.go @@ -0,0 +1,55 @@ +package routes + +import ( + "github.com/gin-gonic/gin" + + "git.beifan.cn/trace-system/backend-go/controllers" + "git.beifan.cn/trace-system/backend-go/middleware" +) + +// SetupRoutes 设置路由 +func SetupRoutes(r *gin.Engine) { + // 健康检查 + r.GET("/api/health", func(c *gin.Context) { + c.JSON(200, gin.H{ + "status": "ok", + "message": "服务器运行正常", + }) + }) +} + +// SetupAPIRoutes 设置 API 路由 +func SetupAPIRoutes(r *gin.RouterGroup) { + // 认证路由 + authController := controllers.NewAuthController() + authRoutes := r.Group("/auth") + { + authRoutes.POST("/login", authController.Login) + authRoutes.GET("/profile", middleware.JWTAuthMiddleware(), authController.GetProfile) + authRoutes.PUT("/profile", middleware.JWTAuthMiddleware(), authController.UpdateProfile) + authRoutes.POST("/change-password", middleware.JWTAuthMiddleware(), authController.ChangePassword) + } + + // 序列号路由 + serialsController := controllers.NewSerialsController() + serialsRoutes := r.Group("/serials") + { + serialsRoutes.POST("/generate", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), serialsController.Generate) + serialsRoutes.POST("/generate-with-prefix", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), serialsController.GenerateWithPrefix) + serialsRoutes.POST("/:serialNumber/qrcode", middleware.JWTAuthMiddleware(), serialsController.GenerateQRCode) + serialsRoutes.GET("/:serialNumber/query", serialsController.Query) + serialsRoutes.GET("/", middleware.JWTAuthMiddleware(), serialsController.FindAll) + serialsRoutes.PUT("/:serialNumber", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), serialsController.Update) + serialsRoutes.POST("/:serialNumber/revoke", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), serialsController.Revoke) + } + + // 企业管理路由 + companiesController := controllers.NewCompaniesController() + companiesRoutes := r.Group("/companies") + { + companiesRoutes.GET("/", middleware.JWTAuthMiddleware(), companiesController.FindAll) + companiesRoutes.POST("/", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Create) + companiesRoutes.PUT("/:companyName", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Update) + companiesRoutes.DELETE("/:companyName", middleware.JWTAuthMiddleware(), middleware.AdminMiddleware(), companiesController.Delete) + } +} diff --git a/services/auth_service.go b/services/auth_service.go new file mode 100644 index 0000000..b5c4f09 --- /dev/null +++ b/services/auth_service.go @@ -0,0 +1,119 @@ +package services + +import ( + "errors" + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" + + "git.beifan.cn/trace-system/backend-go/config" + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/models" +) + +// AuthService 认证服务 +type AuthService struct{} + +// ValidateUser 验证用户身份 +func (s *AuthService) ValidateUser(username string, password string) (*models.User, error) { + var user models.User + result := database.DB.Where("username = ?", username).First(&user) + if result.Error != nil { + return nil, fmt.Errorf("验证用户失败: %w", errors.New("用户名或密码错误")) + } + + err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)) + if err != nil { + return nil, fmt.Errorf("密码验证失败: %w", errors.New("用户名或密码错误")) + } + + return &user, nil +} + +// GenerateToken 生成 JWT 令牌 +func (s *AuthService) GenerateToken(user *models.User) (string, error) { + cfg := config.GetAppConfig() + claims := jwt.MapClaims{ + "userId": user.ID, + "username": user.Username, + "role": user.Role, + "exp": time.Now().Add(time.Second * time.Duration(cfg.JWT.Expire)).Unix(), + "iat": time.Now().Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(cfg.JWT.Secret)) +} + +// GetProfile 获取用户信息 +func (s *AuthService) GetProfile(userId uint) (*models.UserDTO, error) { + var user models.User + result := database.DB.First(&user, userId) + if result.Error != nil { + return nil, fmt.Errorf("查询用户失败: %w", errors.New("用户不存在")) + } + + return &models.UserDTO{ + ID: user.ID, + Username: user.Username, + Name: user.Name, + Email: user.Email, + Role: user.Role, + CreatedAt: user.CreatedAt, + }, nil +} + +// ChangePassword 修改密码 +func (s *AuthService) ChangePassword(userId uint, currentPassword string, newPassword string) error { + var user models.User + result := database.DB.First(&user, userId) + if result.Error != nil { + return fmt.Errorf("查询用户失败: %w", errors.New("用户不存在")) + } + + err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(currentPassword)) + if err != nil { + return fmt.Errorf("密码验证失败: %w", errors.New("当前密码错误")) + } + + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost) + if err != nil { + return fmt.Errorf("密码加密失败: %w", err) + } + + user.Password = string(hashedPassword) + result = database.DB.Save(&user) + if result.Error != nil { + return fmt.Errorf("保存用户失败: %w", errors.New("密码修改失败")) + } + + return nil +} + +// UpdateProfile 更新用户信息 +func (s *AuthService) UpdateProfile(userId uint, name string, email string) (*models.UserDTO, error) { + var user models.User + result := database.DB.First(&user, userId) + if result.Error != nil { + return nil, fmt.Errorf("查询用户失败: %w", errors.New("用户不存在")) + } + + user.Name = name + user.Email = email + + result = database.DB.Save(&user) + if result.Error != nil { + return nil, fmt.Errorf("保存用户失败: %w", errors.New("个人信息更新失败")) + } + + return &models.UserDTO{ + ID: user.ID, + Username: user.Username, + Name: user.Name, + Email: user.Email, + Role: user.Role, + CreatedAt: user.CreatedAt, + }, nil +} diff --git a/services/companies_service.go b/services/companies_service.go new file mode 100644 index 0000000..ea61131 --- /dev/null +++ b/services/companies_service.go @@ -0,0 +1,112 @@ +package services + +import ( + "errors" + + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/models" +) + +// CompaniesService 企业管理服务 +type CompaniesService struct{} + +// FindAll 获取所有企业列表 +func (s *CompaniesService) FindAll(page int, limit int, search string) ([]models.Company, int, int, error) { + var companies []models.Company + var total int64 + + offset := (page - 1) * limit + db := database.DB + + // 搜索条件 + if search != "" { + db = db.Where("company_name LIKE ?", "%"+search+"%") + } + + // 获取总数 + db.Count(&total) + + // 分页查询 + result := db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&companies) + if result.Error != nil { + return nil, 0, 0, errors.New("查询企业列表失败") + } + + totalPages := (int(total) + limit - 1) / limit + + return companies, int(total), totalPages, nil +} + +// Create 创建企业 +func (s *CompaniesService) Create(companyName string) (*models.Company, error) { + // 检查企业是否已存在 + var existingCompany models.Company + result := database.DB.Where("company_name = ?", companyName).First(&existingCompany) + if result.Error == nil { + return nil, errors.New("企业名称已存在") + } + + company := models.Company{ + CompanyName: companyName, + IsActive: true, + } + + result = database.DB.Create(&company) + if result.Error != nil { + return nil, errors.New("创建企业失败") + } + + return &company, nil +} + +// Update 更新企业信息 +func (s *CompaniesService) Update(companyName string, newCompanyName string, isActive bool) (*models.Company, error) { + var company models.Company + result := database.DB.Where("company_name = ?", companyName).First(&company) + if result.Error != nil { + return nil, errors.New("企业不存在") + } + + // 如果企业名称已变更,检查新名称是否已存在 + if newCompanyName != companyName { + var existingCompany models.Company + checkResult := database.DB.Where("company_name = ?", newCompanyName).First(&existingCompany) + if checkResult.Error == nil { + return nil, errors.New("企业名称已存在") + } + + company.CompanyName = newCompanyName + } + + company.IsActive = isActive + + result = database.DB.Save(&company) + if result.Error != nil { + return nil, errors.New("更新企业信息失败") + } + + return &company, nil +} + +// Delete 删除企业 +func (s *CompaniesService) Delete(companyName string) error { + var company models.Company + result := database.DB.Where("company_name = ?", companyName).First(&company) + if result.Error != nil { + return errors.New("企业不存在") + } + + // 检查企业是否有关联的序列号 + var serialCount int64 + database.DB.Model(&models.Serial{}).Where("company_name = ?", companyName).Count(&serialCount) + if serialCount > 0 { + return errors.New("企业下还有序列号,无法删除") + } + + result = database.DB.Delete(&company) + if result.Error != nil { + return errors.New("删除企业失败") + } + + return nil +} diff --git a/services/data/database.sqlite b/services/data/database.sqlite new file mode 100644 index 0000000..bf97415 Binary files /dev/null and b/services/data/database.sqlite differ diff --git a/services/serials_service.go b/services/serials_service.go new file mode 100644 index 0000000..28942ce --- /dev/null +++ b/services/serials_service.go @@ -0,0 +1,269 @@ +package services + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" + "time" + + "github.com/google/uuid" + qr "github.com/yeqown/go-qrcode/v2" + "github.com/yeqown/go-qrcode/writer/standard" + + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/models" +) + +// SerialsService 序列号服务 +type SerialsService struct{} + +// Generate 生成序列号 +func (s *SerialsService) Generate( + companyName string, + quantity int, + validDays int, + userId uint, + prefix ...string, +) ([]models.Serial, error) { + var serials []models.Serial + validUntil := time.Now().AddDate(0, 0, validDays) + + // 检查公司是否存在,不存在则创建 + var company models.Company + result := database.DB.Where("company_name = ?", companyName).First(&company) + if result.Error != nil { + company = models.Company{ + CompanyName: companyName, + IsActive: true, + } + result = database.DB.Create(&company) + if result.Error != nil { + return nil, fmt.Errorf("创建公司失败: %w", result.Error) + } + } + + // 生成序列号前缀 + var serialPrefix string + if len(prefix) > 0 && prefix[0] != "" { + serialPrefix = strings.ToUpper(strings.ReplaceAll(prefix[0], "[^A-Z0-9]", "")) + } else { + serialPrefix = fmt.Sprintf("BF%d", time.Now().Year()%100) + } + + // 预生成所有序列号 + serialNumbers := make(map[string]bool) + for i := 0; i < quantity; { + randomBytes := make([]byte, 3) + if _, err := rand.Read(randomBytes); err != nil { + return nil, fmt.Errorf("生成随机数失败: %w", err) + } + randomPart := hex.EncodeToString(randomBytes)[:6] + serialNumber := fmt.Sprintf("%s%s", serialPrefix, randomPart) + + if serialNumbers[serialNumber] { + continue + } + + var existingSerial models.Serial + checkResult := database.DB.Where("serial_number = ?", serialNumber).First(&existingSerial) + if checkResult.Error != nil { + serialNumbers[serialNumber] = true + i++ + } + } + + for serialNumber := range serialNumbers { + serial := models.Serial{ + SerialNumber: strings.ToUpper(serialNumber), + CompanyName: companyName, + ValidUntil: &validUntil, + CreatedBy: &userId, + IsActive: true, + } + serials = append(serials, serial) + } + + // 保存到数据库 + result = database.DB.Create(&serials) + if result.Error != nil { + return nil, fmt.Errorf("保存序列号失败: %w", result.Error) + } + + return serials, nil +} + +// GenerateQRCode 生成二维码 +func (s *SerialsService) GenerateQRCode( + serialNumber string, + baseUrl string, + requestHost string, + protocol string, +) (string, string, error) { + var serial models.Serial + result := database.DB.Preload("User").Where("serial_number = ?", strings.ToUpper(serialNumber)).First(&serial) + if result.Error != nil { + return "", "", fmt.Errorf("查询序列号失败: %w", errors.New("序列号不存在")) + } + + if !serial.IsActive { + return "", "", fmt.Errorf("序列号状态无效: %w", errors.New("序列号已被禁用")) + } + + if serial.ValidUntil != nil && serial.ValidUntil.Before(time.Now()) { + return "", "", fmt.Errorf("序列号已过期") + } + + // 确定查询 URL + if baseUrl == "" { + baseUrl = fmt.Sprintf("%s://%s/query.html", protocol, requestHost) + } + + var queryUrl string + if strings.Contains(baseUrl, "?") { + queryUrl = fmt.Sprintf("%s&serial=%s", baseUrl, serial.SerialNumber) + } else { + queryUrl = fmt.Sprintf("%s?serial=%s", baseUrl, serial.SerialNumber) + } + + // 生成二维码到临时文件 + filePath := fmt.Sprintf("temp_qr_%s.png", uuid.New().String()) + writer, err := standard.New(filePath, standard.WithQRWidth(6)) + if err != nil { + return "", "", fmt.Errorf("二维码写入器创建失败: %w", err) + } + + qrc, errCode := qr.New(queryUrl) + if errCode != nil { + os.Remove(filePath) + return "", "", fmt.Errorf("二维码创建失败: %w", errCode) + } + + if errSave := qrc.Save(writer); errSave != nil { + os.Remove(filePath) + return "", "", fmt.Errorf("二维码保存失败: %w", errSave) + } + + // 读取文件内容 + fileContent, errRead := os.ReadFile(filePath) + if errRead != nil { + os.Remove(filePath) + return "", "", fmt.Errorf("二维码文件读取失败: %w", errRead) + } + + // 删除临时文件 + os.Remove(filePath) + + // 转换为 base64 + qrCodeBase64 := fmt.Sprintf("data:image/png;base64,%s", base64.StdEncoding.EncodeToString(fileContent)) + return qrCodeBase64, queryUrl, nil +} + +// Query 查询序列号信息 +func (s *SerialsService) Query(serialNumber string) (*models.Serial, error) { + var serial models.Serial + result := database.DB.Preload("User").Where("serial_number = ?", strings.ToUpper(serialNumber)).First(&serial) + if result.Error != nil { + return nil, fmt.Errorf("查询序列号失败: %w", errors.New("序列号不存在")) + } + + if serial.ValidUntil != nil && serial.ValidUntil.Before(time.Now()) { + return nil, fmt.Errorf("序列号已过期") + } + + return &serial, nil +} + +// FindAll 获取序列号列表 +func (s *SerialsService) FindAll(page int, limit int, search string) ([]models.Serial, int, int, error) { + var serials []models.Serial + var total int64 + + offset := (page - 1) * limit + db := database.DB.Preload("User") + + // 搜索条件 + if search != "" { + db = db.Where("serial_number LIKE ? OR company_name LIKE ?", "%"+search+"%", "%"+search+"%") + } + + // 获取总数 + countQuery := db.Model(&models.Serial{}) + if search != "" { + countQuery = countQuery.Where("serial_number LIKE ? OR company_name LIKE ?", "%"+search+"%", "%"+search+"%") + } + countQuery.Count(&total) + + // 分页查询 + result := db.Model(&models.Serial{}).Order("created_at DESC").Offset(offset).Limit(limit).Find(&serials) + if result.Error != nil { + return nil, 0, 0, fmt.Errorf("查询序列号列表失败: %w", result.Error) + } + + totalPages := (int(total) + limit - 1) / limit + + return serials, int(total), totalPages, nil +} + +// Update 更新序列号信息 +func (s *SerialsService) Update(serialNumber string, updateData models.UpdateSerialDTO) (*models.Serial, error) { + var serial models.Serial + result := database.DB.Where("serial_number = ?", strings.ToUpper(serialNumber)).First(&serial) + if result.Error != nil { + return nil, fmt.Errorf("查询序列号失败: %w", errors.New("序列号不存在")) + } + + if updateData.CompanyName != "" { + // 检查公司是否存在 + var company models.Company + companyResult := database.DB.Where("company_name = ?", updateData.CompanyName).First(&company) + if companyResult.Error != nil { + company = models.Company{ + CompanyName: updateData.CompanyName, + IsActive: true, + } + database.DB.Create(&company) + } + + serial.CompanyName = updateData.CompanyName + } + + if updateData.ValidUntil != nil { + serial.ValidUntil = updateData.ValidUntil + } + + if updateData.IsActive != nil { + serial.IsActive = *updateData.IsActive + } + + result = database.DB.Save(&serial) + if result.Error != nil { + return nil, fmt.Errorf("更新序列号失败: %w", result.Error) + } + + return &serial, nil +} + +// Revoke 吊销序列号 +func (s *SerialsService) Revoke(serialNumber string) error { + var serial models.Serial + result := database.DB.Where("serial_number = ?", strings.ToUpper(serialNumber)).First(&serial) + if result.Error != nil { + return fmt.Errorf("查询序列号失败: %w", errors.New("序列号不存在")) + } + + if !serial.IsActive { + return fmt.Errorf("序列号状态无效: %w", errors.New("序列号已被吊销")) + } + + serial.IsActive = false + result = database.DB.Save(&serial) + if result.Error != nil { + return fmt.Errorf("吊销序列号失败: %w", result.Error) + } + + return nil +} diff --git a/services/services_test.go b/services/services_test.go new file mode 100644 index 0000000..e080213 --- /dev/null +++ b/services/services_test.go @@ -0,0 +1,413 @@ +package services + +import ( + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" + + "git.beifan.cn/trace-system/backend-go/config" + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/logger" + "git.beifan.cn/trace-system/backend-go/models" +) + +func TestMain(m *testing.M) { + config.LoadConfig() + + if err := logger.InitializeLogger("test"); err != nil { + fmt.Printf("日志系统初始化失败: %v\n", err) + os.Exit(1) + } + defer logger.Sync() + + database.InitDB() + database.AutoMigrate() + + database.DB.Unscoped().Where("1 = 1").Delete(&models.User{}) + database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{}) + database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{}) + + exitCode := m.Run() + + database.DB.Unscoped().Where("1 = 1").Delete(&models.User{}) + database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{}) + database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{}) + + os.Exit(exitCode) +} + +func TestAuthService_ValidateUser_Success(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "testuser", + Password: string(password), + Name: "测试用户", + Email: "test@example.com", + Role: "user", + } + database.DB.Create(&user) + + authService := AuthService{} + result, err := authService.ValidateUser("testuser", "password123") + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, "testuser", result.Username) + + database.DB.Unscoped().Delete(&user) +} + +func TestAuthService_ValidateUser_WrongPassword(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "testuser2", + Password: string(password), + Name: "测试用户2", + Email: "test2@example.com", + Role: "user", + } + database.DB.Create(&user) + + authService := AuthService{} + _, err := authService.ValidateUser("testuser2", "wrongpassword") + + assert.Error(t, err) + + database.DB.Unscoped().Delete(&user) +} + +func TestAuthService_ValidateUser_UserNotFound(t *testing.T) { + authService := AuthService{} + _, err := authService.ValidateUser("nonexistent", "password") + + assert.Error(t, err) +} + +func TestAuthService_GenerateToken_Success(t *testing.T) { + user := &models.User{ + ID: 1, + Username: "testuser", + Role: "user", + } + + authService := AuthService{} + token, err := authService.GenerateToken(user) + + assert.NoError(t, err) + assert.NotEmpty(t, token) + assert.NotEmpty(t, "Bearer "+token) +} + +func TestAuthService_GetProfile_Success(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "testuser3", + Password: string(password), + Name: "测试用户3", + Email: "test3@example.com", + Role: "user", + } + database.DB.Create(&user) + + authService := AuthService{} + profile, err := authService.GetProfile(user.ID) + + assert.NoError(t, err) + assert.NotNil(t, profile) + assert.Equal(t, "testuser3", profile.Username) + assert.Equal(t, "测试用户3", profile.Name) + + database.DB.Unscoped().Delete(&user) +} + +func TestAuthService_ChangePassword_Success(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("oldpassword"), bcrypt.DefaultCost) + user = models.User{ + Username: "testuser4", + Password: string(password), + Name: "测试用户4", + Email: "test4@example.com", + Role: "user", + } + database.DB.Create(&user) + + authService := AuthService{} + err := authService.ChangePassword(user.ID, "oldpassword", "newpassword") + + assert.NoError(t, err) + + var updatedUser models.User + database.DB.First(&updatedUser, user.ID) + err = bcrypt.CompareHashAndPassword([]byte(updatedUser.Password), []byte("newpassword")) + assert.NoError(t, err) + + database.DB.Unscoped().Delete(&user) +} + +func TestAuthService_ChangePassword_WrongCurrentPassword(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("oldpassword"), bcrypt.DefaultCost) + user = models.User{ + Username: "testuser5", + Password: string(password), + Name: "测试用户5", + Email: "test5@example.com", + Role: "user", + } + database.DB.Create(&user) + + authService := AuthService{} + err := authService.ChangePassword(user.ID, "wrongpassword", "newpassword") + + assert.Error(t, err) + + database.DB.Unscoped().Delete(&user) +} + +func TestAuthService_UpdateProfile_Success(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "testuser6", + Password: string(password), + Name: "测试用户6", + Email: "test6@example.com", + Role: "user", + } + database.DB.Create(&user) + + authService := AuthService{} + profile, err := authService.UpdateProfile(user.ID, "新名称", "newemail@example.com") + + assert.NoError(t, err) + assert.NotNil(t, profile) + assert.Equal(t, "新名称", profile.Name) + assert.Equal(t, "newemail@example.com", profile.Email) + + database.DB.Unscoped().Delete(&user) +} + +func TestSerialsService_Generate_Success(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "adminuser", + Password: string(password), + Name: "管理员", + Email: "admin@example.com", + Role: "admin", + } + database.DB.Create(&user) + + serialService := SerialsService{} + serials, err := serialService.Generate("TestCompany", 5, 30, user.ID) + + assert.NoError(t, err) + assert.Len(t, serials, 5) + assert.Equal(t, "TestCompany", serials[0].CompanyName) + assert.True(t, serials[0].IsActive) + + for _, serial := range serials { + database.DB.Unscoped().Delete(&serial) + } + database.DB.Unscoped().Where("company_name = ?", "TestCompany").Delete(&models.Company{}) + database.DB.Unscoped().Delete(&user) +} + +func TestSerialsService_Generate_WithPrefix(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "adminuser2", + Password: string(password), + Name: "管理员2", + Email: "admin2@example.com", + Role: "admin", + } + database.DB.Create(&user) + + serialService := SerialsService{} + serials, err := serialService.Generate("TestCompany2", 3, 30, user.ID, "TEST") + + assert.NoError(t, err) + assert.Len(t, serials, 3) + assert.True(t, len(serials[0].SerialNumber) > 0) + assert.Contains(t, serials[0].SerialNumber, "TEST") + + for _, serial := range serials { + database.DB.Unscoped().Delete(&serial) + } + database.DB.Unscoped().Where("company_name = ?", "TestCompany2").Delete(&models.Company{}) + database.DB.Unscoped().Delete(&user) +} + +func TestSerialsService_Query_QuerySuccess(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "adminuser3", + Password: string(password), + Name: "管理员3", + Email: "admin3@example.com", + Role: "admin", + } + database.DB.Create(&user) + + serialService := SerialsService{} + serials, _ := serialService.Generate("TestCompany3", 1, 30, user.ID, "QR") + + serialNumber := strings.ToUpper(serials[0].SerialNumber) + result, err := serialService.Query(serialNumber) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, serialNumber, strings.ToUpper(result.SerialNumber)) + assert.True(t, result.IsActive) + + for _, serial := range serials { + database.DB.Unscoped().Delete(&serial) + } + database.DB.Unscoped().Where("company_name = ?", "TestCompany3").Delete(&models.Company{}) + database.DB.Unscoped().Delete(&user) +} + +func TestSerialsService_Query_SerialNotFound(t *testing.T) { + serialService := SerialsService{} + _, err := serialService.Query("NONEXISTENT") + + assert.Error(t, err) +} + +func TestSerialsService_FindAll_Success(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "adminuser4", + Password: string(password), + Name: "管理员4", + Email: "admin4@example.com", + Role: "admin", + } + database.DB.Create(&user) + + serialService := SerialsService{} + serials, _ := serialService.Generate("TestCompany4", 10, 30, user.ID, "LIST") + + result, total, totalPages, err := serialService.FindAll(1, 5, "") + + assert.NoError(t, err) + assert.Len(t, result, 5) + assert.GreaterOrEqual(t, total, 10) + assert.Greater(t, totalPages, 0) + + for _, serial := range serials { + database.DB.Unscoped().Delete(&serial) + } + database.DB.Unscoped().Where("company_name = ?", "TestCompany4").Delete(&models.Company{}) + database.DB.Unscoped().Delete(&user) +} + +func TestSerialsService_FindAll_WithSearch(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "adminuser5", + Password: string(password), + Name: "管理员5", + Email: "admin5@example.com", + Role: "admin", + } + database.DB.Create(&user) + + serialService := SerialsService{} + serials, _ := serialService.Generate("SearchCompany", 5, 30, user.ID, "SEARCH") + + result, _, _, err := serialService.FindAll(1, 10, "SearchCompany") + + assert.NoError(t, err) + assert.Greater(t, len(result), 0) + assert.Equal(t, "SearchCompany", result[0].CompanyName) + + for _, serial := range serials { + database.DB.Unscoped().Delete(&serial) + } + database.DB.Unscoped().Where("company_name = ?", "SearchCompany").Delete(&models.Company{}) + database.DB.Unscoped().Delete(&user) +} + +func TestSerialsService_Revoke_Success(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "adminuser6", + Password: string(password), + Name: "管理员6", + Email: "admin6@example.com", + Role: "admin", + } + database.DB.Create(&user) + + serialService := SerialsService{} + serials, _ := serialService.Generate("RevokeCompany", 1, 30, user.ID, "REVOKE") + + serialNumber := serials[0].SerialNumber + err := serialService.Revoke(serialNumber) + + assert.NoError(t, err) + + var revokedSerial models.Serial + database.DB.Where("serial_number = ?", serialNumber).First(&revokedSerial) + assert.False(t, revokedSerial.IsActive) + + for _, serial := range serials { + database.DB.Unscoped().Delete(&serial) + } + database.DB.Unscoped().Where("company_name = ?", "RevokeCompany").Delete(&models.Company{}) + database.DB.Unscoped().Delete(&user) +} + +func TestSerialsService_Update_Success(t *testing.T) { + var user models.User + password, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + user = models.User{ + Username: "adminuser7", + Password: string(password), + Name: "管理员7", + Email: "admin7@example.com", + Role: "admin", + } + database.DB.Create(&user) + + serialService := SerialsService{} + serials, _ := serialService.Generate("UpdateCompany", 1, 30, user.ID, "UPDATE") + + serialNumber := serials[0].SerialNumber + newValidUntil := time.Now().AddDate(0, 0, 60) + isActive := false + + updateData := models.UpdateSerialDTO{ + ValidUntil: &newValidUntil, + IsActive: &isActive, + } + + result, err := serialService.Update(serialNumber, updateData) + + assert.NoError(t, err) + assert.NotNil(t, result) + assert.False(t, result.IsActive) + + for _, serial := range serials { + database.DB.Unscoped().Delete(&serial) + } + database.DB.Unscoped().Where("company_name = ?", "UpdateCompany").Delete(&models.Company{}) + database.DB.Unscoped().Delete(&user) +} diff --git a/tests/main_test.go b/tests/main_test.go new file mode 100644 index 0000000..751cbb8 --- /dev/null +++ b/tests/main_test.go @@ -0,0 +1,162 @@ +package tests + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "golang.org/x/crypto/bcrypt" + + "git.beifan.cn/trace-system/backend-go/config" + "git.beifan.cn/trace-system/backend-go/controllers" + "git.beifan.cn/trace-system/backend-go/database" + "git.beifan.cn/trace-system/backend-go/logger" + "git.beifan.cn/trace-system/backend-go/models" +) + +func TestMain(m *testing.M) { + // 加载测试配置 + config.LoadConfig() + + // 初始化日志系统 + if err := logger.InitializeLogger("test"); err != nil { + fmt.Printf("日志系统初始化失败: %v\n", err) + os.Exit(1) + } + defer logger.Sync() + + logger.Info("测试开始") + + // 初始化测试数据库 + database.InitDB() + + // 自动迁移数据库 + database.AutoMigrate() + + // 创建测试用户 + createTestUsers() + + // 运行测试 + exitCode := m.Run() + + // 清理测试数据 + cleanupTestData() + + logger.Info("测试结束,退出码", logger.Int("exitCode", exitCode)) + os.Exit(exitCode) +} + +func cleanupTestData() { + // 删除所有测试数据 + database.DB.Unscoped().Where("1 = 1").Delete(&models.User{}) + database.DB.Unscoped().Where("1 = 1").Delete(&models.Company{}) + database.DB.Unscoped().Where("1 = 1").Delete(&models.Serial{}) +} + +func createTestUsers() { + testPassword, _ := bcrypt.GenerateFromPassword([]byte("password123"), bcrypt.DefaultCost) + + testUsers := []models.User{ + { + Username: "admin", + Password: string(testPassword), + Name: "系统管理员", + Email: "admin@example.com", + Role: "admin", + }, + { + Username: "user1", + Password: string(testPassword), + Name: "普通用户", + Email: "user1@example.com", + Role: "user", + }, + } + + database.DB.Create(&testUsers) +} + +func TestHealthCheck(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // 模拟请求 + c.Request, _ = http.NewRequest("GET", "/api/health", nil) + + // 直接调用健康检查响应,不设置路由(避免依赖 Gin 引擎) + c.Writer.WriteHeader(http.StatusOK) + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "message": "服务器运行正常", + }) + + // 断言响应 + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "ok", response["status"]) + assert.Equal(t, "服务器运行正常", response["message"]) +} + +func TestLogin(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // 测试数据 + testData := models.LoginDTO{ + Username: "admin", + Password: "password123", + } + + // 模拟请求 + jsonData, _ := json.Marshal(testData) + c.Request, _ = http.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(jsonData)) + c.Request.Header.Set("Content-Type", "application/json") + + // 调用控制器方法 + controller := controllers.NewAuthController() + controller.Login(c) + + // 断言响应 + assert.Equal(t, http.StatusOK, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "登录成功", response["message"]) + assert.Contains(t, response, "accessToken") + assert.Contains(t, response, "user") +} + +func TestLoginFailed(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + + // 测试数据 + testData := models.LoginDTO{ + Username: "admin", + Password: "wrongpassword", + } + + // 模拟请求 + jsonData, _ := json.Marshal(testData) + c.Request, _ = http.NewRequest("POST", "/api/auth/login", bytes.NewBuffer(jsonData)) + c.Request.Header.Set("Content-Type", "application/json") + + // 调用控制器方法 + controller := controllers.NewAuthController() + controller.Login(c) + + // 断言响应 + assert.Equal(t, http.StatusUnauthorized, w.Code) + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Contains(t, response["message"], "用户名或密码错误") +}