From ac07e27908bafeb17e07ef0cb89ace91ec87c6a1 Mon Sep 17 00:00:00 2001 From: skyle1995 Date: Fri, 24 Oct 2025 00:09:45 +0800 Subject: [PATCH] New warehouse --- .DS_Store | Bin 0 -> 6148 bytes .gitignore | 5 + .vscode/launch.json | 17 + cmd/root.go | 111 + cmd/server.go | 149 + config/config.go | 79 + config/validator.go | 410 + constants/status.go | 41 + controllers/admin/app.go | 388 + controllers/admin/auth.go | 314 + controllers/admin/card.go | 650 + controllers/admin/card_stats.go | 181 + controllers/admin/card_type.go | 428 + controllers/admin/handlers.go | 91 + controllers/admin/login_type.go | 394 + controllers/admin/settings.go | 147 + controllers/admin/user.go | 238 + controllers/home/home.go | 73 + database/database.go | 125 + database/migrate.go | 172 + database/seed_settings.go | 116 + database/seed_user.go | 54 + go.mod | 48 + go.sum | 113 + main.go | 9 + middleware/middleware.go | 133 + models/api.go | 97 + models/app.go | 63 + models/card.go | 27 + models/card_type.go | 24 + models/login_type.go | 24 + models/settings.go | 19 + models/user.go | 16 + server/admin.go | 99 + server/home.go | 13 + server/routes.go | 37 + services/query.go | 121 + services/settings.go | 105 + utils/common.go | 80 + utils/crypto.go | 323 + utils/database.go | 327 + utils/errors.go | 269 + utils/logger/http.go | 59 + utils/logger/logger.go | 112 + utils/logger/server.go | 26 + utils/timeutil/server.go | 44 + web/assets/logo.svg | 11 + web/assets/themes.json | 355 + web/public.go | 67 + web/static/css/admin.css | 104 + web/static/css/home.css | 178 + web/static/js/admin.js | 330 + web/static/lib/README.md | 83 + web/static/lib/colorMode.js | 191 + web/static/lib/drawer/drawer.css | 317 + web/static/lib/drawer/drawer.js | 200 + web/static/lib/include.js | 142 + web/static/lib/less.js | 11360 ++++++++++++++++ web/static/src/css-variables.css | 202 + web/static/src/layui-theme-dark-selector.css | 1899 +++ .../src/layui-theme-dark-selector.css.map | 1 + web/static/src/layui-theme-dark.css | 738 + web/static/src/layui-theme-dark.css.map | 1 + web/static/src/override.css | 534 + web/static/tpl/theme.html | 240 + web/template/admin/apps.html | 412 + web/template/admin/card_types.html | 415 + web/template/admin/cards.html | 771 ++ web/template/admin/dashboard.html | 236 + web/template/admin/layout.html | 76 + web/template/admin/login.html | 259 + web/template/admin/login_types.html | 422 + web/template/admin/settings.html | 277 + web/template/admin/user.html | 256 + web/template/index.html | 366 + 75 files changed, 26814 insertions(+) create mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 .vscode/launch.json create mode 100644 cmd/root.go create mode 100644 cmd/server.go create mode 100644 config/config.go create mode 100644 config/validator.go create mode 100644 constants/status.go create mode 100644 controllers/admin/app.go create mode 100644 controllers/admin/auth.go create mode 100644 controllers/admin/card.go create mode 100644 controllers/admin/card_stats.go create mode 100644 controllers/admin/card_type.go create mode 100644 controllers/admin/handlers.go create mode 100644 controllers/admin/login_type.go create mode 100644 controllers/admin/settings.go create mode 100644 controllers/admin/user.go create mode 100644 controllers/home/home.go create mode 100644 database/database.go create mode 100644 database/migrate.go create mode 100644 database/seed_settings.go create mode 100644 database/seed_user.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 middleware/middleware.go create mode 100644 models/api.go create mode 100644 models/app.go create mode 100644 models/card.go create mode 100644 models/card_type.go create mode 100644 models/login_type.go create mode 100644 models/settings.go create mode 100644 models/user.go create mode 100644 server/admin.go create mode 100644 server/home.go create mode 100644 server/routes.go create mode 100644 services/query.go create mode 100644 services/settings.go create mode 100644 utils/common.go create mode 100644 utils/crypto.go create mode 100644 utils/database.go create mode 100644 utils/errors.go create mode 100644 utils/logger/http.go create mode 100644 utils/logger/logger.go create mode 100644 utils/logger/server.go create mode 100644 utils/timeutil/server.go create mode 100644 web/assets/logo.svg create mode 100644 web/assets/themes.json create mode 100644 web/public.go create mode 100644 web/static/css/admin.css create mode 100644 web/static/css/home.css create mode 100755 web/static/js/admin.js create mode 100644 web/static/lib/README.md create mode 100755 web/static/lib/colorMode.js create mode 100644 web/static/lib/drawer/drawer.css create mode 100755 web/static/lib/drawer/drawer.js create mode 100755 web/static/lib/include.js create mode 100755 web/static/lib/less.js create mode 100644 web/static/src/css-variables.css create mode 100644 web/static/src/layui-theme-dark-selector.css create mode 100644 web/static/src/layui-theme-dark-selector.css.map create mode 100644 web/static/src/layui-theme-dark.css create mode 100644 web/static/src/layui-theme-dark.css.map create mode 100644 web/static/src/override.css create mode 100644 web/static/tpl/theme.html create mode 100644 web/template/admin/apps.html create mode 100644 web/template/admin/card_types.html create mode 100644 web/template/admin/cards.html create mode 100644 web/template/admin/dashboard.html create mode 100644 web/template/admin/layout.html create mode 100644 web/template/admin/login.html create mode 100644 web/template/admin/login_types.html create mode 100644 web/template/admin/settings.html create mode 100644 web/template/admin/user.html create mode 100644 web/template/index.html diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..cfcce94820866ab5c3d2969074a345bef4053d26 GIT binary patch literal 6148 zcmeHK!AiqG5S_JEw-%uX1-&hJE!bG3ikDFJ2aM=Jr6wk5FlI}V+CwSiu0Q0D_&v_- zZjoAxHxauNX5Q@XOu{_a-3$P*?jUpk8UVmi39D;pz7bj{oslIoQ$=Lz9T`YS@QEOs ziMA7#0n5N&V}SN<9qw@7rcmIn{oKEKFwFZwA_I(g3;Ic%7p>MiTdGu-S62Bduk#!4 zK@PpbF9vzn&#q{6E@cu-d_TB|qhYVHaV*oqkJBhq32_)<$n|BMhH}`IgEY)luBR7x zjn{gO?a^reu-z0+#~C-p$Z0#Bra0K&8;@&zb7%MHtoN8arSe7d$?*75vTkq!ujtGv z@7c@JM5gx`LD?Ka@R0%^0@NI$|5E-;VP6WLP-St^7%-cL*B7mDTd!rnGVmhW||LX zzRXOA!qnIC{X&HUb2VCP8L$jYGf>gP2HpRs^ZWnlBs;PUSO)$T1FU-DopiAzbGObE wM|Z72y+tLVxKg7^L1V6C< 65535 { + return fmt.Errorf("无效的端口号: %d,端口号必须在1-65535之间", config.Port) + } + + // 验证运行模式 + validModes := []string{"debug", "release", "test"} + if !contains(validModes, config.Mode) { + return fmt.Errorf("无效的运行模式: %s,支持的模式: %s", config.Mode, strings.Join(validModes, ", ")) + } + + return nil +} + +// validateDatabaseConfig 验证数据库配置 +func validateDatabaseConfig(config *DatabaseConfig) error { + // 验证数据库类型 + validTypes := []string{"mysql", "sqlite"} + if !contains(validTypes, config.Type) { + return fmt.Errorf("不支持的数据库类型: %s,支持的类型: %s", config.Type, strings.Join(validTypes, ", ")) + } + + // 根据类型验证具体配置 + switch config.Type { + case "mysql": + return validateMySQLConfig(&config.MySQL) + case "sqlite": + return validateSQLiteConfig(&config.SQLite) + } + + return nil +} + +// validateMySQLConfig 验证MySQL配置 +func validateMySQLConfig(config *MySQLConfig) error { + if config.Host == "" { + return errors.New("MySQL主机地址不能为空") + } + if config.Port < 1 || config.Port > 65535 { + return fmt.Errorf("无效的MySQL端口号: %d", config.Port) + } + if config.Username == "" { + return errors.New("MySQL用户名不能为空") + } + if config.Database == "" { + return errors.New("MySQL数据库名不能为空") + } + if config.MaxIdleConns < 0 { + return errors.New("MySQL最大空闲连接数不能为负数") + } + if config.MaxOpenConns < 0 { + return errors.New("MySQL最大打开连接数不能为负数") + } + return nil +} + +// validateSQLiteConfig 验证SQLite配置 +func validateSQLiteConfig(config *SQLiteConfig) error { + if config.Path == "" { + return errors.New("SQLite数据库路径不能为空") + } + + // 检查目录是否存在,不存在则创建 + dir := filepath.Dir(config.Path) + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("创建SQLite数据库目录失败: %w", err) + } + } + + return nil +} + +// validateRedisConfig 验证Redis配置 +func validateRedisConfig(config *RedisConfig) error { + if config.Host == "" { + return errors.New("Redis主机地址不能为空") + } + if config.Port < 1 || config.Port > 65535 { + return fmt.Errorf("无效的Redis端口号: %d", config.Port) + } + if config.DB < 0 || config.DB > 15 { + return fmt.Errorf("无效的Redis数据库索引: %d,必须在0-15之间", config.DB) + } + return nil +} + +// validateLogConfig 验证日志配置 +func validateLogConfig(config *LogConfig) error { + // 验证日志级别 + validLevels := []string{"trace", "debug", "info", "warn", "error", "fatal", "panic"} + if !contains(validLevels, config.Level) { + return fmt.Errorf("无效的日志级别: %s,支持的级别: %s", config.Level, strings.Join(validLevels, ", ")) + } + + // 检查日志文件目录(仅当日志文件路径不为空时) + if config.File != "" { + dir := filepath.Dir(config.File) + if _, err := os.Stat(dir); os.IsNotExist(err) { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("创建日志目录失败: %w", err) + } + } + } + // 当日志文件路径为空时,不进行目录检查和创建 + + // 验证日志轮转配置 + if config.MaxSize <= 0 { + return errors.New("日志文件最大大小必须大于0") + } + if config.MaxBackups < 0 { + return errors.New("日志备份文件数量不能为负数") + } + if config.MaxAge < 0 { + return errors.New("日志文件保留天数不能为负数") + } + + return nil +} + +// validateSecurityConfig 验证安全配置 +func validateSecurityConfig(config *SecurityConfig) error { + if len(config.JWTSecret) < 16 { + return errors.New("JWT密钥长度不能少于16个字符") + } + + if len(config.EncryptionKey) < 16 { + return errors.New("加密密钥长度不能少于16个字符") + } + + if config.JWTRefreshThresholdHours < 1 || config.JWTRefreshThresholdHours > 23 { + return errors.New("JWT令牌刷新阈值必须在1-23小时之间") + } + + // 检查是否使用默认值(生产环境警告) + if strings.Contains(config.JWTSecret, "default") { + log.Warn("检测到使用默认JWT密钥,生产环境请更换为安全的密钥") + } + + if strings.Contains(config.EncryptionKey, "default") { + log.Warn("检测到使用默认加密密钥,生产环境请更换为安全的密钥") + } + + return nil +} + +// contains 检查切片是否包含指定元素 +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + +// GetConfigValue 获取配置值,支持类型转换和默认值 +func GetConfigValue[T any](key string, defaultValue T) T { + if !viper.IsSet(key) { + return defaultValue + } + + value := viper.Get(key) + if result, ok := value.(T); ok { + return result + } + + // 尝试类型转换 + if converted, err := convertValue[T](value); err == nil { + return converted + } + + return defaultValue +} + +// convertValue 尝试类型转换 +func convertValue[T any](value interface{}) (T, error) { + var zero T + str := fmt.Sprintf("%v", value) + + switch any(zero).(type) { + case int: + if i, err := strconv.Atoi(str); err == nil { + return any(i).(T), nil + } + case string: + return any(str).(T), nil + case bool: + if b, err := strconv.ParseBool(str); err == nil { + return any(b).(T), nil + } + } + + return zero, fmt.Errorf("无法转换类型") +} diff --git a/constants/status.go b/constants/status.go new file mode 100644 index 0000000..60a7541 --- /dev/null +++ b/constants/status.go @@ -0,0 +1,41 @@ +package constants + + + +// 卡密状态常量 +// CardStatus 定义卡密的状态 +const ( + // CardStatusUnused 未使用 + CardStatusUnused = 0 + // CardStatusUsed 已使用 + CardStatusUsed = 1 + // CardStatusDisabled 禁用 + CardStatusDisabled = 2 +) + +// 登录类型状态常量 +// LoginTypeStatus 定义登录类型的状态 +const ( + // LoginTypeStatusDisabled 禁用 + LoginTypeStatusDisabled = 0 + // LoginTypeStatusEnabled 启用 + LoginTypeStatusEnabled = 1 +) + +// 卡密类型状态常量 +// CardTypeStatus 定义卡密类型的状态 +const ( + // CardTypeStatusDisabled 禁用 + CardTypeStatusDisabled = 0 + // CardTypeStatusEnabled 启用 + CardTypeStatusEnabled = 1 +) + +// 验证码类型常量 +// VerificationCodeType 定义验证码的类型 +const ( + // VerificationCodeTypeText 文本验证码 + VerificationCodeTypeText = 1 + // VerificationCodeTypeImage 图片验证码 + VerificationCodeTypeImage = 2 +) diff --git a/controllers/admin/app.go b/controllers/admin/app.go new file mode 100644 index 0000000..773f6ce --- /dev/null +++ b/controllers/admin/app.go @@ -0,0 +1,388 @@ +package admin + +import ( + "crypto/rand" + "encoding/hex" + "encoding/json" + "net/http" + "networkDev/database" + "networkDev/models" + "networkDev/utils" + "strconv" + "strings" + + "github.com/google/uuid" + "github.com/sirupsen/logrus" +) + +// AppsFragmentHandler 应用列表页面片段处理器 +func AppsFragmentHandler(w http.ResponseWriter, r *http.Request) { + utils.RenderTemplate(w, "apps.html", map[string]interface{}{ + "Title": "应用管理", + }) +} + +// AppsListHandler 应用列表API处理器 +func AppsListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // 获取分页参数 + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + if page <= 0 { + page = 1 + } + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + if limit <= 0 { + limit = 10 + } + + // 获取搜索参数 + search := strings.TrimSpace(r.URL.Query().Get("search")) + + // 构建查询 + db, err := database.GetDB() + if err != nil { + logrus.WithError(err).Error("Failed to get database connection") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + var apps []models.App + var total int64 + + query := db.Model(&models.App{}) + + // 如果有搜索条件 + if search != "" { + query = query.Where("name LIKE ? OR uuid LIKE ?", "%"+search+"%", "%"+search+"%") + } + + // 获取总数 + if err := query.Count(&total).Error; err != nil { + logrus.WithError(err).Error("Failed to count apps") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // 分页查询 + offset := (page - 1) * limit + if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&apps).Error; err != nil { + logrus.WithError(err).Error("Failed to query apps") + http.Error(w, "Internal server error", http.StatusInternalServerError) + return + } + + // 返回结果 + response := map[string]interface{}{ + "code": 0, + "msg": "success", + "count": total, + "data": apps, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// AppCreateHandler 创建应用API处理器 +func AppCreateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Name string `json:"name"` + Version string `json:"version"` + Status int `json:"status"` + DownloadType int `json:"download_type"` + ForceUpdate int `json:"force_update"` + DownloadURL string `json:"download_url"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + logrus.WithError(err).Error("Failed to decode JSON request") + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + // 验证必填字段 + if strings.TrimSpace(req.Name) == "" { + logrus.Error("App name is empty") + http.Error(w, "应用名称不能为空", http.StatusBadRequest) + return + } + + // 设置默认值 + if req.Version == "" { + req.Version = "1.0.0" + } + + logrus.WithFields(logrus.Fields{ + "name": req.Name, + "version": req.Version, + "status": req.Status, + "download_type": req.DownloadType, + "download_url": req.DownloadURL, + "force_update": req.ForceUpdate, + }).Info("Received app create request") + + // 创建应用 + app := models.App{ + Name: strings.TrimSpace(req.Name), + Version: req.Version, + Status: req.Status, + DownloadType: req.DownloadType, + DownloadURL: strings.TrimSpace(req.DownloadURL), + ForceUpdate: req.ForceUpdate, + } + + // 确保UUID和Secret被设置(虽然BeforeCreate钩子应该处理这些,但为了保险起见) + if app.UUID == "" { + app.UUID = uuid.New().String() + } + if app.Secret == "" { + // 生成32位大写16进制随机字符 + bytes := make([]byte, 16) // 16字节 = 32位16进制字符 + rand.Read(bytes) + app.Secret = strings.ToUpper(hex.EncodeToString(bytes)) + } + + db, err := database.GetDB() + if err != nil { + logrus.WithError(err).Error("Failed to get database connection") + http.Error(w, "数据库连接失败", http.StatusInternalServerError) + return + } + + if err := db.Create(&app).Error; err != nil { + logrus.WithError(err).Error("Failed to create app") + http.Error(w, "创建应用失败", http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "code": 0, + "msg": "创建成功", + "data": app, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// AppUpdateHandler 更新应用API处理器 +func AppUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + ID uint `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Status int `json:"status"` + DownloadType int `json:"download_type"` + DownloadURL string `json:"download_url"` + ForceUpdate int `json:"force_update"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + // 验证必填字段 + if req.ID == 0 { + http.Error(w, "应用ID不能为空", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Name) == "" { + http.Error(w, "应用名称不能为空", http.StatusBadRequest) + return + } + + db, err := database.GetDB() + if err != nil { + logrus.WithError(err).Error("Failed to get database connection") + http.Error(w, "数据库连接失败", http.StatusInternalServerError) + return + } + + // 查找应用 + var app models.App + if err := db.First(&app, req.ID).Error; err != nil { + http.Error(w, "应用不存在", http.StatusNotFound) + return + } + + // 更新字段 + app.Name = strings.TrimSpace(req.Name) + app.Version = req.Version + app.Status = req.Status + app.DownloadType = req.DownloadType + app.DownloadURL = strings.TrimSpace(req.DownloadURL) + app.ForceUpdate = req.ForceUpdate + + if err := db.Save(&app).Error; err != nil { + logrus.WithError(err).Error("Failed to update app") + http.Error(w, "更新应用失败", http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "code": 0, + "msg": "更新成功", + "data": app, + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// AppDeleteHandler 删除应用API处理器 +func AppDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + ID uint `json:"id"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if req.ID == 0 { + http.Error(w, "应用ID不能为空", http.StatusBadRequest) + return + } + + db, err := database.GetDB() + if err != nil { + logrus.WithError(err).Error("Failed to get database connection") + http.Error(w, "数据库连接失败", http.StatusInternalServerError) + return + } + + // 删除应用 + if err := db.Delete(&models.App{}, req.ID).Error; err != nil { + logrus.WithError(err).Error("Failed to delete app") + http.Error(w, "删除应用失败", http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "code": 0, + "msg": "删除成功", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// AppsBatchDeleteHandler 批量删除应用API处理器 +func AppsBatchDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + IDs []uint `json:"ids"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if len(req.IDs) == 0 { + http.Error(w, "请选择要删除的应用", http.StatusBadRequest) + return + } + + db, err := database.GetDB() + if err != nil { + logrus.WithError(err).Error("Failed to get database connection") + http.Error(w, "数据库连接失败", http.StatusInternalServerError) + return + } + + // 批量删除 + if err := db.Delete(&models.App{}, req.IDs).Error; err != nil { + logrus.WithError(err).Error("Failed to batch delete apps") + http.Error(w, "批量删除失败", http.StatusInternalServerError) + return + } + + response := map[string]interface{}{ + "code": 0, + "msg": "批量删除成功", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} + +// AppsBatchUpdateStatusHandler 批量更新应用状态API处理器 +func AppsBatchUpdateStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + IDs []uint `json:"ids"` + Status int `json:"status"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid JSON", http.StatusBadRequest) + return + } + + if len(req.IDs) == 0 { + http.Error(w, "请选择要更新的应用", http.StatusBadRequest) + return + } + + if req.Status != 0 && req.Status != 1 { + http.Error(w, "状态值无效", http.StatusBadRequest) + return + } + + db, err := database.GetDB() + if err != nil { + logrus.WithError(err).Error("Failed to get database connection") + http.Error(w, "数据库连接失败", http.StatusInternalServerError) + return + } + + // 批量更新状态 + if err := db.Model(&models.App{}).Where("id IN ?", req.IDs).Update("status", req.Status).Error; err != nil { + logrus.WithError(err).Error("Failed to batch update app status") + http.Error(w, "批量更新状态失败", http.StatusInternalServerError) + return + } + + statusText := "禁用" + if req.Status == 1 { + statusText = "启用" + } + + response := map[string]interface{}{ + "code": 0, + "msg": "批量" + statusText + "成功", + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) +} diff --git a/controllers/admin/auth.go b/controllers/admin/auth.go new file mode 100644 index 0000000..23024af --- /dev/null +++ b/controllers/admin/auth.go @@ -0,0 +1,314 @@ +package admin + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "networkDev/database" + "networkDev/models" + "networkDev/utils" + + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/viper" +) + +// LoginPageHandler 管理员登录页渲染处理器 +// - 如果已登录则重定向到 /admin +// - 否则渲染 web/template/admin/login.html 模板 +func LoginPageHandler(w http.ResponseWriter, r *http.Request) { + // 已登录直接跳转到后台布局 + if IsAdminAuthenticated(r) { + http.Redirect(w, r, "/admin", http.StatusFound) + return + } + + data := utils.GetDefaultTemplateData() + data["Title"] = "管理员登录" + + utils.RenderTemplate(w, "login.html", data) +} + +// LoginHandler 管理员登录接口 +// - 接收JSON: {username, password} +// - 验证用户存在与密码正确性 +// - 仅允许 Role=0 的管理员登录 +// - 成功后设置简单的会话Cookie(后续可切换为JWT或更完善的Session) +func LoginHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求参数错误", nil) + return + } + if body.Username == "" || body.Password == "" { + utils.JsonResponse(w, http.StatusBadRequest, false, "用户名和密码不能为空", nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + var user models.User + dbErr := db.Where("username = ?", body.Username).First(&user).Error + if dbErr != nil { + utils.JsonResponse(w, http.StatusUnauthorized, false, "用户不存在或密码错误", nil) + return + } + if user.Role != 0 { + utils.JsonResponse(w, http.StatusForbidden, false, "非管理员账号不可登录后台", nil) + return + } + + // 使用盐值验证密码 + if !utils.VerifyPasswordWithSalt(body.Password, user.PasswordSalt, user.Password) { + utils.JsonResponse(w, http.StatusUnauthorized, false, "用户不存在或密码错误", nil) + return + } + + // 生成JWT令牌 + token, err := generateJWTToken(user) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "生成令牌失败", nil) + return + } + + // 设置JWT Cookie(HttpOnly,安全) + cookie := &http.Cookie{ + Name: "admin_session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: false, // 生产环境应设置为true(HTTPS) + MaxAge: 24 * 60 * 60, // 24小时 + } + http.SetCookie(w, cookie) + + utils.JsonResponse(w, http.StatusOK, true, "登录成功", map[string]interface{}{ + "redirect": "/admin", + }) +} + +// LogoutHandler 管理员登出 +// - 清理JWT Cookie会话 +// - 确保令牌完全失效 +func LogoutHandler(w http.ResponseWriter, r *http.Request) { + // 清理JWT Cookie + cookie := &http.Cookie{ + Name: "admin_session", + Value: "", + Path: "/", + HttpOnly: true, + Secure: false, // 生产环境应设置为true + MaxAge: -1, // 立即失效 + Expires: time.Unix(0, 0), // 确保过期 + } + http.SetCookie(w, cookie) + + // 可选:将JWT令牌加入黑名单(需要Redis或数据库支持) + // 这里可以实现JWT黑名单机制 + + utils.JsonResponse(w, http.StatusOK, true, "已退出登录", map[string]interface{}{ + "redirect": "/admin/login", + }) +} + +// JWT密钥(生产环境应从配置文件或环境变量读取) +var jwtSecret = []byte(viper.GetString("security.jwt_secret")) + +// JWTClaims JWT载荷结构 +type JWTClaims struct { + UserID uint `json:"user_id"` + Username string `json:"username"` + Role int `json:"role"` + jwt.RegisteredClaims +} + +// generateJWTToken 生成JWT令牌 +// - 包含用户ID、用户名、角色信息 +// - 设置24小时过期时间 +// - 使用HMAC-SHA256签名 +func generateJWTToken(user models.User) (string, error) { + claims := JWTClaims{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + NotBefore: jwt.NewNumericDate(time.Now()), + Issuer: "凌动技术", + Subject: strconv.Itoa(int(user.ID)), + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString(jwtSecret) +} + +// parseJWTToken 解析并验证JWT令牌 +// - 验证签名有效性 +// - 检查过期时间 +// - 返回用户信息 +func parseJWTToken(tokenString string) (*JWTClaims, error) { + token, err := jwt.ParseWithClaims(tokenString, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return jwtSecret, nil + }) + + if err != nil { + return nil, err + } + + if claims, ok := token.Claims.(*JWTClaims); ok && token.Valid { + return claims, nil + } + + return nil, fmt.Errorf("invalid token") +} + +// IsAdminAuthenticated 判断管理员是否已认证(导出) +// - 检查admin_session Cookie中的JWT令牌 +// - 验证令牌签名、过期时间和用户角色 +func IsAdminAuthenticated(r *http.Request) bool { + cookie, err := r.Cookie("admin_session") + if err != nil || cookie.Value == "" { + return false + } + + // 解析并验证JWT令牌 + claims, err := parseJWTToken(cookie.Value) + if err != nil { + return false + } + + // 验证用户角色(只允许管理员角色=0) + if claims.Role != 0 { + return false + } + + // 可选:进一步验证用户是否仍然存在且有效 + // 这里可以添加数据库查询来验证用户状态 + + return true +} + +// GetCurrentAdminUser 获取当前登录的管理员用户信息 +// - 从JWT令牌中提取用户信息 +// - 自动刷新接近过期的令牌(剩余时间少于6小时时刷新) +// - 返回用户ID、用户名和角色 +func GetCurrentAdminUser(r *http.Request) (*JWTClaims, error) { + cookie, err := r.Cookie("admin_session") + if err != nil { + return nil, fmt.Errorf("未找到会话信息") + } + + claims, err := parseJWTToken(cookie.Value) + if err != nil { + return nil, fmt.Errorf("无效的会话信息") + } + + if claims.Role != 0 { + return nil, fmt.Errorf("权限不足") + } + + return claims, nil +} + +// GetCurrentAdminUserWithRefresh 获取当前登录的管理员用户信息并自动刷新令牌 +// - 从JWT令牌中提取用户信息 +// - 自动刷新接近过期的令牌(剩余时间少于6小时时刷新) +// - 返回用户ID、用户名、角色和是否刷新了令牌 +func GetCurrentAdminUserWithRefresh(w http.ResponseWriter, r *http.Request) (*JWTClaims, bool, error) { + cookie, err := r.Cookie("admin_session") + if err != nil { + return nil, false, fmt.Errorf("未找到会话信息") + } + + claims, err := parseJWTToken(cookie.Value) + if err != nil { + return nil, false, fmt.Errorf("无效的会话信息") + } + + if claims.Role != 0 { + return nil, false, fmt.Errorf("权限不足") + } + + // 检查是否需要刷新令牌(根据配置的阈值) + refreshed := false + refreshThreshold := time.Duration(viper.GetInt("security.jwt_refresh_threshold_hours")) * time.Hour + if time.Until(claims.ExpiresAt.Time) < refreshThreshold { + // 生成新的JWT令牌 + user := models.User{ + ID: claims.UserID, + Username: claims.Username, + Role: claims.Role, + } + newToken, err := generateJWTToken(user) + if err == nil { + // 更新Cookie + newCookie := &http.Cookie{ + Name: "admin_session", + Value: newToken, + Path: "/", + HttpOnly: true, + Secure: false, // 生产环境应设置为true(HTTPS) + MaxAge: 24 * 60 * 60, // 24小时 + } + http.SetCookie(w, newCookie) + refreshed = true + + // 更新claims的过期时间 + claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(24 * time.Hour)) + claims.IssuedAt = jwt.NewNumericDate(time.Now()) + } + } + + return claims, refreshed, nil +} + +// AdminAuthRequired 管理员认证拦截中间件 +// - 未登录:重定向到 /admin/login +// - 已登录:自动刷新接近过期的令牌,然后放行到后续处理器 +func AdminAuthRequired(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // 尝试获取用户信息并自动刷新令牌 + claims, refreshed, err := GetCurrentAdminUserWithRefresh(w, r) + if err != nil { + // 中文注释:区分普通页面请求与AJAX/JSON请求 + // - 对 AJAX/JSON:直接返回 401 JSON,便于前端处理(如提示重新登录) + // - 对普通页面:保持原有重定向到登录页 + accept := r.Header.Get("Accept") + xrw := strings.ToLower(strings.TrimSpace(r.Header.Get("X-Requested-With"))) + if strings.Contains(accept, "application/json") || xrw == "xmlhttprequest" { + utils.JsonResponse(w, http.StatusUnauthorized, false, "未登录或会话已过期", nil) + return + } + http.Redirect(w, r, "/admin/login", http.StatusFound) + return + } + + // 如果令牌被刷新,可以在这里记录日志(可选) + if refreshed { + // 可以添加日志记录令牌刷新事件 + _ = claims // 避免未使用变量警告 + } + + next(w, r) + } +} diff --git a/controllers/admin/card.go b/controllers/admin/card.go new file mode 100644 index 0000000..b057e37 --- /dev/null +++ b/controllers/admin/card.go @@ -0,0 +1,650 @@ +package admin + +import ( + "crypto/rand" + // 移除 CSV 导出,改为自定义分隔符文本导出 + // "encoding/csv" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "networkDev/database" + "networkDev/models" + "networkDev/utils" + "strconv" + "strings" + "time" + + "github.com/sirupsen/logrus" +) + +// 生成指定长度的十六进制随机字符串 +// 入参 n 表示需要的随机字符数(非字节数);返回小写十六进制字符串 +func genRandomHex(n int) string { + if n <= 0 { + return "" + } + // 由于 hex 每个字节会转成 2 个字符,因此需要 (n+1)/2 个字节 + byteLen := (n + 1) / 2 + b := make([]byte, byteLen) + if _, err := rand.Read(b); err != nil { + return "" + } + s := hex.EncodeToString(b) + if len(s) > n { + s = s[:n] + } + return s +} + +// 根据前缀和总长度构建卡号 +// - totalLen <= 0 时按 18 处理 +// - 若前缀长度 >= totalLen,则自动扩展为 前缀长度+18 +// - uppercase=true 表示最终结果转为大写;false 表示小写 +func buildCardNumber(prefix string, totalLen int, uppercase bool) string { + if totalLen <= 0 { + totalLen = 18 + } + if len(prefix) >= totalLen { + totalLen = len(prefix) + 18 + } + rest := totalLen - len(prefix) + s := prefix + genRandomHex(rest) + if uppercase { + return strings.ToUpper(s) + } + return strings.ToLower(s) +} + +// CardsFragmentHandler 卡密管理片段渲染 +// - 渲染 cards.html 列表与表单界面 +func CardsFragmentHandler(w http.ResponseWriter, r *http.Request) { + utils.RenderTemplate(w, "cards.html", map[string]interface{}{}) +} + +// CardsListHandler 获取卡密列表 +// - 支持GET +// - 支持分页查询参数:page、page_size +// - 支持筛选参数:card_type_id、status、batch、keyword(卡号/备注/批次模糊匹配) +// - 返回卡密列表和分页信息 +func CardsListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // 获取查询参数 + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) + cardTypeIDStr := r.URL.Query().Get("card_type_id") + statusStr := r.URL.Query().Get("status") + batch := r.URL.Query().Get("batch") + // 中文注释:keyword 支持在 card_number、remark、batch 三个字段上进行模糊匹配 + keyword := strings.TrimSpace(r.URL.Query().Get("keyword")) + + // 设置默认分页参数 + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 构建查询条件(去除无效的 Preload,前端已通过 card_type_id 自行映射类型名称) + query := db.Model(&models.Card{}) + + // 筛选条件 + if cardTypeIDStr != "" { + if cardTypeID, err := strconv.Atoi(cardTypeIDStr); err == nil && cardTypeID > 0 { + query = query.Where("card_type_id = ?", cardTypeID) + } + } + if statusStr != "" { + if status, err := strconv.Atoi(statusStr); err == nil { + query = query.Where("status = ?", status) + } + } + if batch != "" { + query = query.Where("batch LIKE ?", "%"+batch+"%") + } + // 中文注释:当提供 keyword 时,在卡号、备注、批次三个字段上进行 OR 模糊匹配 + if keyword != "" { + kw := "%" + keyword + "%" + query = query.Where("(card_number LIKE ? OR remark LIKE ? OR batch LIKE ?)", kw, kw, kw) + } + + // 计算总数 + var total int64 + if err := query.Count(&total).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "统计总数失败", nil) + return + } + + // 分页查询 + var cards []models.Card + offset := (page - 1) * pageSize + if err := query.Order("id desc").Offset(offset).Limit(pageSize).Find(&cards).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil) + return + } + + // 中文注释:为每条卡记录补充类型名称,避免前端依赖异步类型映射导致显示“未知类型” + // 1) 先查询类型列表并构建 id->name 的映射表 + var cardTypeList []models.CardType + _ = db.Model(&models.CardType{}).Find(&cardTypeList).Error + typeNameMap := make(map[uint]string, len(cardTypeList)) + for _, t := range cardTypeList { + typeNameMap[t.ID] = t.Name + } + // 2) 将卡列表转换为通用 map 列表,并附加 card_type_name 字段 + items := make([]map[string]interface{}, 0, len(cards)) + for _, c := range cards { + items = append(items, map[string]interface{}{ + "id": c.ID, + "card_number": c.CardNumber, + "card_type_id": c.CardTypeID, + "card_type_name": typeNameMap[c.CardTypeID], + "status": c.Status, + "batch": c.Batch, + "remark": c.Remark, + "used_at": c.UsedAt, + "created_at": c.CreatedAt, + }) + } + + // 返回分页数据 + result := map[string]interface{}{ + "items": items, + "total": total, + "page": page, + "page_size": pageSize, + "pages": (total + int64(pageSize) - 1) / int64(pageSize), + } + utils.JsonResponse(w, http.StatusOK, true, "ok", result) +} + +// CardCreateHandler 新增卡密 +// - 接收JSON: {card_type_id, status, remark, prefix, length, uppercase, count} +// - card_number 与 batch 不再由前端传入,后端将自动生成: +// 1. 卡号:按 prefix 与 length 生成随机十六进制字符串,支持大小写控制(uppercase,默认小写) +// 2. 批次:基于设置表 card_batch_counter 自增,格式为 YYYYMMDD-000001 +// 3. 生成数量:通过 count 控制一次生成的数量,默认1,最大500 +func CardCreateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + type reqBody struct { + CardTypeID uint `json:"card_type_id"` + Status int `json:"status"` + Remark string `json:"remark"` + Prefix string `json:"prefix"` + Length int `json:"length"` + Uppercase bool `json:"uppercase"` + Count int `json:"count"` + } + var body reqBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil) + return + } + if body.CardTypeID == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型ID不能为空", nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 检查卡密类型是否存在且启用 + var cardType models.CardType + if err := db.First(&cardType, body.CardTypeID).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型不存在", nil) + return + } + // 检查卡密类型是否被禁用 + if cardType.Status != 1 { + utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型已被禁用,无法创建卡密", nil) + return + } + + // 规范化长度与大小写、生成数量参数 + if body.Length <= 0 { + body.Length = 18 + } + if body.Count <= 0 { + body.Count = 1 + } + if body.Count > 500 { + body.Count = 500 + } + + // 生成批次(基于设置表 card_batch_counter 自增) + // 格式:YYYYMMDD-000001(每天不重置,仅简单自增计数) + var batch string + var setting models.Settings + if err := db.Where("name = ?", "card_batch_counter").First(&setting).Error; err != nil { + // 若不存在该设置项,则创建并从 1 开始 + setting = models.Settings{Name: "card_batch_counter", Value: "1", Description: "卡密批次号计数器(用于记录上次生成批次号的序号,自增使用)"} + if e := db.Create(&setting).Error; e != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "初始化批次计数器失败", nil) + return + } + batch = time.Now().Format("20060102") + "-" + fmt.Sprintf("%06d", 1) + } else { + cnt, _ := strconv.Atoi(setting.Value) + cnt++ + newVal := strconv.Itoa(cnt) + if e := db.Model(&models.Settings{}).Where("id = ?", setting.ID).Update("value", newVal).Error; e != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "更新批次计数器失败", nil) + return + } + batch = time.Now().Format("20060102") + "-" + fmt.Sprintf("%06d", cnt) + } + + // 中文注释:计算合法状态值(1=已使用,2=禁用,其它按未使用0处理) + safeStatus := body.Status + if safeStatus != 1 && safeStatus != 2 { + safeStatus = 0 + } + + // 中文注释:循环生成 count 条卡密,若单条创建失败则重试最多5次 + success := 0 + for i := 0; i < body.Count; i++ { + card := models.Card{ + CardNumber: buildCardNumber(body.Prefix, body.Length, body.Uppercase), + CardTypeID: body.CardTypeID, + Status: safeStatus, + Batch: batch, + Remark: body.Remark, + } + var createErr error + for j := 0; j < 5; j++ { + createErr = db.Create(&card).Error + if createErr == nil { + success++ + break + } + // 失败则重新生成一次卡号后重试 + card.CardNumber = buildCardNumber(body.Prefix, body.Length, body.Uppercase) + } + } + + if success == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "创建失败,可能是卡密号码重复", nil) + return + } + + result := map[string]interface{}{ + "created": success, + "batch": batch, + } + utils.JsonResponse(w, http.StatusOK, true, fmt.Sprintf("创建成功:%d条", success), result) +} + +// CardUpdateHandler 更新卡密 +// - 接收JSON: {id, card_number(可选), card_type_id(可选), status, batch(可选), remark} +// - 说明:card_number 与 batch 若未提供或为空,则不会更新对应字段 +func CardUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + type reqBody struct { + ID uint `json:"id"` + CardNumber string `json:"card_number"` + CardTypeID uint `json:"card_type_id"` + Status int `json:"status"` + Batch string `json:"batch"` + Remark string `json:"remark"` + } + var body reqBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil) + return + } + if body.ID == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "缺少ID", nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 检查卡密类型是否存在且启用(如果提供了新的卡密类型ID) + if body.CardTypeID > 0 { + var cardType models.CardType + if err := db.First(&cardType, body.CardTypeID).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型不存在", nil) + return + } + // 检查卡密类型是否被禁用 + if cardType.Status != 1 { + utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型已被禁用,无法更新为此类型", nil) + return + } + } + + // 中文注释:若尝试将状态置为未使用(0),则直接允许 + if body.Status == 0 { + var existing models.Card + if err := db.First(&existing, body.ID).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "卡密不存在", nil) + return + } + } + + // 构建更新字段 + updates := map[string]interface{}{} + if body.CardNumber != "" { + updates["card_number"] = body.CardNumber + } + if body.CardTypeID > 0 { + updates["card_type_id"] = body.CardTypeID + } + updates["status"] = body.Status + // 仅当提供非空 batch 时才更新,防止被清空 + if strings.TrimSpace(body.Batch) != "" { + updates["batch"] = body.Batch + } + updates["remark"] = body.Remark + + if err := db.Model(&models.Card{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "更新失败,可能是卡密号码重复", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "更新成功", nil) +} + +// CardDeleteHandler 删除单个卡密 +// - 接收JSON: {id} +func CardDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ID uint `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + if err := db.Delete(&models.Card{}, body.ID).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "删除失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "删除成功", nil) +} + +// CardsBatchDeleteHandler 批量删除卡密 +// - 接收JSON: {ids: []} +func CardsBatchDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + IDs []uint `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + if err := db.Delete(&models.Card{}, body.IDs).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "批量删除失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "批量删除成功", nil) +} + +// CardsBatchUpdateStatusHandler 批量更新卡密状态 +// - 接收JSON: {ids: [], status: int} +// - status: 0=未使用,1=已使用,2=禁用 +func CardsBatchUpdateStatusHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + IDs []uint `json:"ids"` + Status int `json:"status"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + // 中文注释:允许批量重置为未使用(0) + if err := db.Model(&models.Card{}).Where("id IN ?", body.IDs).Update("status", body.Status).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "批量更新失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "操作成功", nil) +} + +// GetCardTypesHandler 获取卡密类型列表(供前端下拉选择) +// - 仅支持GET请求 +// - 只返回启用状态的卡密类型,用于前端下拉选择 +func GetCardTypesHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + var cardTypes []models.CardType + // 中文注释:根据可选参数 all 决定是否仅返回启用类型 + // - 未提供或为其它值:仅返回启用(status=1) + // - all=1/true/yes:返回所有状态的类型(用于筛选下拉场景) + q := db.Model(&models.CardType{}) + all := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("all"))) + if !(all == "1" || all == "true" || all == "yes") { + q = q.Where("status = ?", 1) + } + if err := q.Order("id asc").Find(&cardTypes).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "ok", cardTypes) +} + +// CardsExportHandler 导出卡密为文本文件 +// - 支持GET +// - 筛选参数:card_type_id、status、batch、remark +// - 导出字段(按顺序):卡号、状态、创建时间;使用“----”分隔 +func CardsExportHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // 解析筛选参数 + cardTypeIDStr := strings.TrimSpace(r.URL.Query().Get("card_type_id")) + statusStr := strings.TrimSpace(r.URL.Query().Get("status")) + batch := strings.TrimSpace(r.URL.Query().Get("batch")) + remark := strings.TrimSpace(r.URL.Query().Get("remark")) + + db, err := database.GetDB() + if err != nil { + http.Error(w, "数据库连接失败", http.StatusInternalServerError) + return + } + + // 构建查询 + query := db.Model(&models.Card{}) + if cardTypeIDStr != "" { + if id, err := strconv.Atoi(cardTypeIDStr); err == nil && id > 0 { + query = query.Where("card_type_id = ?", id) + } + } + if statusStr != "" { + if s, err := strconv.Atoi(statusStr); err == nil { + query = query.Where("status = ?", s) + } + } + if batch != "" { + query = query.Where("batch LIKE ?", "%"+batch+"%") + } + if remark != "" { + query = query.Where("remark LIKE ?", "%"+remark+"%") + } + + // 查询数据(按ID倒序) + var cards []models.Card + if err := query.Order("id desc").Find(&cards).Error; err != nil { + http.Error(w, "查询失败", http.StatusInternalServerError) + return + } + + // 设置响应头(文本下载) + now := time.Now().Format("20060102150405") + filename := fmt.Sprintf("cards_%s.txt", now) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + + // 写入UTF-8 BOM,避免Excel/记事本中文乱码 + _, _ = w.Write([]byte{0xEF, 0xBB, 0xBF}) + + // 写入表头 + _, _ = w.Write([]byte("卡号----状态----创建时间\n")) + + // 时间格式 + const tf = "2006-01-02 15:04:05" + + // 状态转文字 + statusText := func(s int) string { + switch s { + case 0: + return "未使用" + case 1: + return "已使用" + default: + return "禁用" + } + } + + // 写入数据行(以“----”分隔) + for _, c := range cards { + record := []string{ + c.CardNumber, + statusText(c.Status), + c.CreatedAt.Format(tf), + } + line := strings.Join(record, "----") + "\n" + if _, err := w.Write([]byte(line)); err != nil { + continue + } + } +} + +// CardsExportSelectedHandler 导出选中的卡密为文本文件 +// - 支持GET +// - 参数:ids(逗号分隔的卡密ID列表) +// - 导出字段(按顺序):卡号、状态、创建时间;使用"----"分隔 +func CardsExportSelectedHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // 解析选中的卡密ID列表 + idsStr := strings.TrimSpace(r.URL.Query().Get("ids")) + if idsStr == "" { + http.Error(w, "请提供要导出的卡密ID列表", http.StatusBadRequest) + return + } + + // 解析ID列表 + idStrings := strings.Split(idsStr, ",") + var ids []uint + for _, idStr := range idStrings { + if id, err := strconv.Atoi(strings.TrimSpace(idStr)); err == nil && id > 0 { + ids = append(ids, uint(id)) + } + } + + if len(ids) == 0 { + http.Error(w, "无效的卡密ID列表", http.StatusBadRequest) + return + } + + db, err := database.GetDB() + if err != nil { + http.Error(w, "数据库连接失败", http.StatusInternalServerError) + return + } + + // 查询选中的卡密数据(按ID倒序) + var cards []models.Card + if err := db.Where("id IN ?", ids).Order("id desc").Find(&cards).Error; err != nil { + logrus.WithError(err).Error("查询选中卡密失败") + http.Error(w, "查询卡密数据失败", http.StatusInternalServerError) + return + } + + if len(cards) == 0 { + http.Error(w, "未找到指定的卡密数据", http.StatusNotFound) + return + } + + // 设置响应头,触发下载 + filename := fmt.Sprintf("selected_cards_%s.txt", time.Now().Format("20060102_150405")) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename)) + + // 写入数据 + tf := "2006-01-02 15:04:05" + for _, c := range cards { + // 状态转换 + var statusText string + switch c.Status { + case 0: + statusText = "未使用" + case 1: + statusText = "已使用" + default: + statusText = "禁用" + } + + // 格式:卡号----状态----创建时间 + record := []string{ + c.CardNumber, + statusText, + c.CreatedAt.Format(tf), + } + line := strings.Join(record, "----") + "\n" + if _, err := w.Write([]byte(line)); err != nil { + continue + } + } +} diff --git a/controllers/admin/card_stats.go b/controllers/admin/card_stats.go new file mode 100644 index 0000000..3bc2526 --- /dev/null +++ b/controllers/admin/card_stats.go @@ -0,0 +1,181 @@ +package admin + +import ( + "net/http" + "networkDev/constants" + "networkDev/database" + "networkDev/models" + "networkDev/utils" + "time" +) + +// CardStatsOverviewHandler 卡密统计概览API +// - 返回当日和所有卡密的统计信息 +// - 包括:总数、使用/未使用/禁用状态分布 +func CardStatsOverviewHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 获取当日统计 + today := time.Now().Format("2006-01-02") + todayStart := today + " 00:00:00" + todayEnd := today + " 23:59:59" + + // 当日卡密统计 + var todayTotal int64 + var todayByStatus = make(map[int]int64) + + // 当日总数 + db.Model(&models.Card{}).Where("created_at >= ? AND created_at <= ?", todayStart, todayEnd).Count(&todayTotal) + + // 当日按状态分布 + var todayStatusCounts []struct { + Status int `json:"status"` + Count int64 `json:"count"` + } + db.Model(&models.Card{}). + Select("status, count(*) as count"). + Where("created_at >= ? AND created_at <= ?", todayStart, todayEnd). + Group("status"). + Find(&todayStatusCounts) + + for _, sc := range todayStatusCounts { + todayByStatus[sc.Status] = sc.Count + } + + // 所有卡密统计 + var allTotal int64 + var allByStatus = make(map[int]int64) + + // 总数 + db.Model(&models.Card{}).Count(&allTotal) + + // 按状态分布 + var allStatusCounts []struct { + Status int `json:"status"` + Count int64 `json:"count"` + } + db.Model(&models.Card{}). + Select("status, count(*) as count"). + Group("status"). + Find(&allStatusCounts) + + for _, sc := range allStatusCounts { + allByStatus[sc.Status] = sc.Count + } + + // 构建响应数据 + data := map[string]interface{}{ + "today": map[string]interface{}{ + "total": todayTotal, + "by_status": todayByStatus, + }, + "all": map[string]interface{}{ + "total": allTotal, + "by_status": allByStatus, + }, + } + + utils.JsonResponse(w, http.StatusOK, true, "获取成功", data) +} + +// CardStatsTrend30DaysHandler 卡密30天趋势API +// - 返回近30天的卡密创建和使用趋势 +func CardStatsTrend30DaysHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 生成近30天的日期列表 + var dates []string + var totalCounts []int64 + var usedCounts []int64 + var unusedCounts []int64 + + for i := 29; i >= 0; i-- { + date := time.Now().AddDate(0, 0, -i).Format("2006-01-02") + dates = append(dates, date) + + dayStart := date + " 00:00:00" + dayEnd := date + " 23:59:59" + + // 当天创建的卡密总数 + var totalCount int64 + db.Model(&models.Card{}).Where("created_at >= ? AND created_at <= ?", dayStart, dayEnd).Count(&totalCount) + totalCounts = append(totalCounts, totalCount) + + // 当天创建且已使用的卡密数 + var usedCount int64 + db.Model(&models.Card{}). + Where("created_at >= ? AND created_at <= ? AND status = ?", dayStart, dayEnd, constants.CardStatusUsed). + Count(&usedCount) + usedCounts = append(usedCounts, usedCount) + + // 当天创建且未使用的卡密数 + var unusedCount int64 + db.Model(&models.Card{}). + Where("created_at >= ? AND created_at <= ? AND status = ?", dayStart, dayEnd, constants.CardStatusUnused). + Count(&unusedCount) + unusedCounts = append(unusedCounts, unusedCount) + } + + // 构建响应数据 + data := map[string]interface{}{ + "dates": dates, + "total": totalCounts, + "used": usedCounts, + "unused": unusedCounts, + } + + utils.JsonResponse(w, http.StatusOK, true, "获取成功", data) +} + +// CardStatsSimpleHandler 简单卡密统计API +// - 返回卡密的基本统计信息:总数、已使用、未使用、禁用 +func CardStatsSimpleHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 统计各状态的卡密数量 + var total int64 + var used int64 + var unused int64 + var disabled int64 + + db.Model(&models.Card{}).Count(&total) + db.Model(&models.Card{}).Where("status = ?", constants.CardStatusUsed).Count(&used) + db.Model(&models.Card{}).Where("status = ?", constants.CardStatusUnused).Count(&unused) + db.Model(&models.Card{}).Where("status = ?", constants.CardStatusDisabled).Count(&disabled) + + data := map[string]interface{}{ + "total": total, + "used": used, + "unused": unused, + "disabled": disabled, + } + + utils.JsonResponse(w, http.StatusOK, true, "获取成功", data) +} \ No newline at end of file diff --git a/controllers/admin/card_type.go b/controllers/admin/card_type.go new file mode 100644 index 0000000..fa9a3de --- /dev/null +++ b/controllers/admin/card_type.go @@ -0,0 +1,428 @@ +package admin + +import ( + "encoding/json" + "net/http" + "networkDev/database" + "networkDev/models" + "networkDev/utils" + "strconv" + "strings" +) + +// CardTypesFragmentHandler 卡密类型管理片段渲染 +// - 渲染 card_types.html 列表与表单界面 +func CardTypesFragmentHandler(w http.ResponseWriter, r *http.Request) { + utils.RenderTemplate(w, "card_types.html", map[string]interface{}{}) +} + +// CardTypesListHandler 获取卡密类型列表 +// - 支持GET +// - 支持分页和筛选 +func CardTypesListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // 获取查询参数 + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) + keyword := r.URL.Query().Get("keyword") + statusStr := r.URL.Query().Get("status") + + // 设置默认分页参数 + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 构建查询条件 + query := db.Model(&models.CardType{}) + + // 筛选条件 + if keyword != "" { + query = query.Where("name LIKE ?", "%"+keyword+"%") + } + if statusStr != "" { + if status, err := strconv.Atoi(statusStr); err == nil { + query = query.Where("status = ?", status) + } + } + + // 计算总数 + var total int64 + if err := query.Count(&total).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "统计总数失败", nil) + return + } + + // 分页查询 + var items []models.CardType + offset := (page - 1) * pageSize + if err := query.Order("id asc").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil) + return + } + + // 返回分页数据 + result := map[string]interface{}{ + "items": items, + "total": total, + "page": page, + "page_size": pageSize, + "pages": (total + int64(pageSize) - 1) / int64(pageSize), + } + utils.JsonResponse(w, http.StatusOK, true, "ok", result) +} + +// CardTypeCreateHandler 新增卡密类型 +// - 接收JSON: {name, status, login_types} +// - Name 必填且唯一 +func CardTypeCreateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + type reqBody struct { + Name string `json:"name"` + Status int `json:"status"` + LoginTypes string `json:"login_types"` + } + var body reqBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil) + return + } + if body.Name == "" { + utils.JsonResponse(w, http.StatusBadRequest, false, "名称不能为空", nil) + return + } + + // 校验登录方式ID是否存在 + if errMsg := validateLoginTypes(body.LoginTypes); errMsg != "" { + utils.JsonResponse(w, http.StatusBadRequest, false, errMsg, nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + item := models.CardType{ + Name: body.Name, + Status: body.Status, + LoginTypes: body.LoginTypes, + } + if item.Status != 0 { + item.Status = 1 + } + if err := db.Create(&item).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "创建失败,可能是名称重复", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "创建成功", item) +} + +// checkCardTypeInUse 检查卡密类型是否被卡密使用 +// - 通过 cards 表中 card_type_id 外键计数 +// - 返回是否被使用以及被使用的数量 +func checkCardTypeInUse(cardTypeID uint) (bool, int64, error) { + db, err := database.GetDB() + if err != nil { + return false, 0, err + } + var count int64 + if err := db.Model(&models.Card{}).Where("card_type_id = ?", cardTypeID).Count(&count).Error; err != nil { + return false, 0, err + } + return count > 0, count, nil +} + +// CardTypeUpdateHandler 更新卡密类型 +// - 接收JSON: {id, name, status, login_types} +func CardTypeUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + type reqBody struct { + ID uint `json:"id"` + Name string `json:"name"` + Status int `json:"status"` + LoginTypes string `json:"login_types"` + } + var body reqBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil) + return + } + if body.ID == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "缺少ID", nil) + return + } + + // 校验登录方式名称是否存在且未被禁用 + if errMsg := validateLoginTypes(body.LoginTypes); errMsg != "" { + utils.JsonResponse(w, http.StatusBadRequest, false, errMsg, nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 查询原始记录,便于后续在用校验(重命名/禁用) + var original models.CardType + if err := db.First(&original, body.ID).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "卡密类型不存在", nil) + return + } + + // 如果名称发生变化且该卡密类型已被卡密使用,则不允许修改名称 + if body.Name != "" && body.Name != original.Name { + inUse, count, err := checkCardTypeInUse(body.ID) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil) + return + } + if inUse { + utils.JsonResponse(w, http.StatusBadRequest, false, "该卡密类型已被卡密使用(数量:"+strconv.FormatInt(count, 10)+"),无法修改名称", nil) + return + } + } + + // 当尝试禁用(status=0)且原状态不是禁用时,如该类型已被卡密使用则禁止禁用 + if body.Status == 0 && original.Status != 0 { + inUse, count, err := checkCardTypeInUse(body.ID) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil) + return + } + if inUse { + utils.JsonResponse(w, http.StatusBadRequest, false, "该卡密类型已被卡密使用(数量:"+strconv.FormatInt(count, 10)+"),无法禁用", nil) + return + } + } + + // 构建更新字段 + updates := map[string]interface{}{} + if body.Name != "" { + updates["name"] = body.Name + } + updates["status"] = body.Status + updates["login_types"] = body.LoginTypes + if err := db.Model(&models.CardType{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "更新失败,可能是名称重复", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "更新成功", nil) +} + +// CardTypeDeleteHandler 删除单个卡密类型 +// - 接收JSON: {id} +func CardTypeDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ID uint `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 在用校验 + inUse, count, err := checkCardTypeInUse(body.ID) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil) + return + } + if inUse { + utils.JsonResponse(w, http.StatusBadRequest, false, "该卡密类型已被卡密使用(数量:"+strconv.FormatInt(count, 10)+"),无法删除", nil) + return + } + + if err := db.Delete(&models.CardType{}, body.ID).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "删除失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "删除成功", nil) +} + +// CardTypesBatchDeleteHandler 批量删除卡密类型 +// - 接收JSON: {ids: []} +func CardTypesBatchDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + IDs []uint `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 批量在用校验 + var blocking []string + for _, id := range body.IDs { + inUse, count, err := checkCardTypeInUse(id) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil) + return + } + if inUse { + var ct models.CardType + if db.First(&ct, id).Error == nil { + blocking = append(blocking, ct.Name+"(数量:"+strconv.FormatInt(count, 10)+")") + } else { + blocking = append(blocking, strconv.FormatUint(uint64(id), 10)+"(数量:"+strconv.FormatInt(count, 10)+")") + } + } + } + if len(blocking) > 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "以下卡密类型已被卡密使用,无法删除:"+strings.Join(blocking, ";"), nil) + return + } + + if err := db.Delete(&models.CardType{}, body.IDs).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "批量删除失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "批量删除成功", nil) +} + +// validateLoginTypes 校验登录方式名称是否存在且未被禁用 +// - 接收逗号分隔的登录方式名称字符串 +// - 检查登录方式是否存在且状态为启用(status=1) +// - 返回错误信息,如果所有名称都存在且启用则返回空字符串 +func validateLoginTypes(loginTypesStr string) string { + if loginTypesStr == "" { + return "" + } + + // 分割登录方式名称字符串 + nameStrs := strings.Split(loginTypesStr, ",") + var names []string + + // 去重并清理空格 + nameSet := make(map[string]bool) + for _, nameStr := range nameStrs { + nameStr = strings.TrimSpace(nameStr) + if nameStr == "" { + continue + } + nameSet[nameStr] = true + } + + // 转换为切片 + for name := range nameSet { + names = append(names, name) + } + + if len(names) == 0 { + return "" + } + + // 查询数据库检查名称是否存在 + db, err := database.GetDB() + if err != nil { + return "数据库连接失败" + } + + // 查询所有匹配的登录方式,包括状态信息 + var loginTypes []models.LoginType + if err := db.Where("name IN ?", names).Find(&loginTypes).Error; err != nil { + return "查询登录方式失败" + } + + // 检查是否有不存在的名称和被禁用的登录方式 + existingSet := make(map[string]bool) + disabledNames := []string{} + for _, loginType := range loginTypes { + existingSet[loginType.Name] = true + // 检查登录方式是否被禁用 (status != 1 表示禁用) + if loginType.Status != 1 { + disabledNames = append(disabledNames, loginType.Name) + } + } + + // 检查不存在的名称 + var notFoundNames []string + for _, name := range names { + if !existingSet[name] { + notFoundNames = append(notFoundNames, name) + } + } + + // 返回错误信息 + if len(notFoundNames) > 0 { + return "以下登录方式名称不存在: " + strings.Join(notFoundNames, ", ") + } + if len(disabledNames) > 0 { + return "以下登录方式已被禁用,无法使用: " + strings.Join(disabledNames, ", ") + } + + return "" +} + +// CardTypesBatchEnableHandler 批量启用 +// - 接收JSON: {ids: []} +func CardTypesBatchEnableHandler(w http.ResponseWriter, r *http.Request) { + batchUpdateStatus(w, r, 1) +} + +// CardTypesBatchDisableHandler 批量禁用 +// - 接收JSON: {ids: []} +func CardTypesBatchDisableHandler(w http.ResponseWriter, r *http.Request) { + batchUpdateStatus(w, r, 0) +} + +// batchUpdateStatus 批量更新状态的通用函数 +// - status: 1 启用,0 禁用 +func batchUpdateStatus(w http.ResponseWriter, r *http.Request, status int) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + IDs []uint `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + if err := db.Model(&models.CardType{}).Where("id IN ?", body.IDs).Update("status", status).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "批量更新失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "操作成功", nil) +} diff --git a/controllers/admin/handlers.go b/controllers/admin/handlers.go new file mode 100644 index 0000000..79b4aa6 --- /dev/null +++ b/controllers/admin/handlers.go @@ -0,0 +1,91 @@ +package admin + +import ( + "net/http" + "networkDev/database" + "networkDev/services" + "networkDev/utils" + "networkDev/utils/timeutil" + + "github.com/spf13/viper" +) + +// AdminIndexHandler /admin 与 /admin/ 根路径入口 +// - 未登录:重定向到 /admin/login +// - 已登录:渲染后台布局页(或重定向到 /admin/layout) +func AdminIndexHandler(w http.ResponseWriter, r *http.Request) { + if IsAdminAuthenticated(r) { + // 直接渲染布局页,保持URL为 /admin + AdminLayoutHandler(w, r) + return + } + http.Redirect(w, r, "/admin/login", http.StatusFound) +} + +// AdminLayoutHandler 后台布局页渲染 +// - 渲染 layout.html,包含顶部导航、侧边栏与动态内容容器 +func AdminLayoutHandler(w http.ResponseWriter, r *http.Request) { + data := utils.GetDefaultTemplateData() + + // 从数据库读取站点标题 + db, err := database.GetDB() + if err != nil { + data["Title"] = "凌动技术" + } else { + siteTitle, err := services.FindSettingByName("site_title", db) + if err != nil || siteTitle == nil { + data["Title"] = "凌动技术" + } else { + data["Title"] = siteTitle.Value + } + } + + utils.RenderTemplate(w, "layout.html", data) +} + +// DashboardFragmentHandler 仪表盘片段渲染 +// - 展示系统信息:版本、运行模式、数据库类型、启动时长 +func DashboardFragmentHandler(w http.ResponseWriter, r *http.Request) { + version := "1.0.0" + mode := viper.GetString("server.mode") + dbType := viper.GetString("database.type") + if dbType == "" { + dbType = "sqlite" + } + uptime := timeutil.GetServerUptimeString() + + data := map[string]interface{}{ + "Version": version, + "Mode": mode, + "DBType": dbType, + "Uptime": uptime, + } + + utils.RenderTemplate(w, "dashboard.html", data) +} + +// SystemInfoHandler 系统信息API接口 +// - 返回系统运行状态的JSON数据,用于前端定时刷新 +func SystemInfoHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + version := "1.0.0" + mode := viper.GetString("server.mode") + dbType := viper.GetString("database.type") + if dbType == "" { + dbType = "sqlite" + } + uptime := timeutil.GetServerUptimeString() + + data := map[string]interface{}{ + "version": version, + "mode": mode, + "db_type": dbType, + "uptime": uptime, + } + + utils.JsonResponse(w, http.StatusOK, true, "ok", data) +} diff --git a/controllers/admin/login_type.go b/controllers/admin/login_type.go new file mode 100644 index 0000000..97e31e8 --- /dev/null +++ b/controllers/admin/login_type.go @@ -0,0 +1,394 @@ +package admin + +import ( + "encoding/json" + "net/http" + "networkDev/database" + "networkDev/models" + "networkDev/utils" + "strconv" + "strings" +) + +// LoginTypesFragmentHandler 登录方式管理片段渲染 +// - 渲染 login_types.html 列表与表单界面 +func LoginTypesFragmentHandler(w http.ResponseWriter, r *http.Request) { + utils.RenderTemplate(w, "login_types.html", map[string]interface{}{}) +} + +// LoginTypesListHandler 获取登录方式列表 +// - 支持GET +// - 支持分页和筛选 +func LoginTypesListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // 获取查询参数 + page, _ := strconv.Atoi(r.URL.Query().Get("page")) + pageSize, _ := strconv.Atoi(r.URL.Query().Get("page_size")) + keyword := r.URL.Query().Get("keyword") + statusStr := r.URL.Query().Get("status") + + // 设置默认分页参数 + if page <= 0 { + page = 1 + } + if pageSize <= 0 || pageSize > 100 { + pageSize = 20 + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 构建查询条件 + query := db.Model(&models.LoginType{}) + + // 筛选条件 + if keyword != "" { + query = query.Where("name LIKE ?", "%"+keyword+"%") + } + if statusStr != "" { + if status, err := strconv.Atoi(statusStr); err == nil { + query = query.Where("status = ?", status) + } + } + + // 计算总数 + var total int64 + if err := query.Count(&total).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "统计总数失败", nil) + return + } + + // 分页查询 + var items []models.LoginType + offset := (page - 1) * pageSize + if err := query.Order("id asc").Offset(offset).Limit(pageSize).Find(&items).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil) + return + } + + // 返回分页数据 + result := map[string]interface{}{ + "items": items, + "total": total, + "page": page, + "page_size": pageSize, + "pages": (total + int64(pageSize) - 1) / int64(pageSize), + } + utils.JsonResponse(w, http.StatusOK, true, "ok", result) +} + +// LoginTypeCreateHandler 新增登录方式 +// - 接收JSON: {name, description, status} +// - Name 必填且唯一 +func LoginTypeCreateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + type reqBody struct { + Name string `json:"name"` + VerifyTypes string `json:"verify_types"` + Status int `json:"status"` + } + var body reqBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil) + return + } + if body.Name == "" { + utils.JsonResponse(w, http.StatusBadRequest, false, "名称不能为空", nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + item := models.LoginType{ + Name: body.Name, + Status: body.Status, + VerifyTypes: body.VerifyTypes, + } + if item.Status != 0 { + item.Status = 1 + } + if err := db.Create(&item).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "创建失败,可能是名称重复", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "创建成功", item) +} + +// checkLoginTypeInUse 检查登录类型是否被卡密类型使用 +// - 检查 card_types 表中的 login_types 字段是否包含该登录类型名称 +// - 返回是否被使用和使用该登录类型的卡密类型名称列表 +func checkLoginTypeInUse(loginTypeName string) (bool, []string, error) { + db, err := database.GetDB() + if err != nil { + return false, nil, err + } + + var cardTypes []models.CardType + // 查询包含该登录类型名称的卡密类型 + if err := db.Where("login_types LIKE ?", "%"+loginTypeName+"%").Find(&cardTypes).Error; err != nil { + return false, nil, err + } + + var usingCardTypes []string + for _, cardType := range cardTypes { + // 精确匹配登录类型名称(避免部分匹配) + loginTypes := strings.Split(cardType.LoginTypes, ",") + for _, lt := range loginTypes { + if strings.TrimSpace(lt) == loginTypeName { + usingCardTypes = append(usingCardTypes, cardType.Name) + break + } + } + } + + return len(usingCardTypes) > 0, usingCardTypes, nil +} + +// checkLoginTypesByIDsInUse 批量检查登录类型ID是否被使用 +// - 先查询登录类型ID对应的名称,再检查是否被使用 +func checkLoginTypesByIDsInUse(loginTypeIDs []uint) (bool, map[uint][]string, error) { + db, err := database.GetDB() + if err != nil { + return false, nil, err + } + + // 查询登录类型名称 + var loginTypes []models.LoginType + if err := db.Where("id IN ?", loginTypeIDs).Find(&loginTypes).Error; err != nil { + return false, nil, err + } + + hasUsage := false + usageMap := make(map[uint][]string) + + for _, loginType := range loginTypes { + inUse, usingCardTypes, err := checkLoginTypeInUse(loginType.Name) + if err != nil { + return false, nil, err + } + if inUse { + hasUsage = true + usageMap[loginType.ID] = usingCardTypes + } + } + + return hasUsage, usageMap, nil +} + +// LoginTypeUpdateHandler 更新登录方式 +// - 接收JSON: {id, name, description, status} +func LoginTypeUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + type reqBody struct { + ID uint `json:"id"` + Name string `json:"name"` + VerifyTypes string `json:"verify_types"` + Status int `json:"status"` + } + var body reqBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil) + return + } + if body.ID == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "缺少ID", nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 始终查询原始记录,便于后续校验(重命名/禁用) + var originalLoginType models.LoginType + if err := db.First(&originalLoginType, body.ID).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "登录类型不存在", nil) + return + } + + // 如果名称发生变化,检查原名称是否被使用(与删除逻辑一致) + if body.Name != "" && originalLoginType.Name != body.Name { + inUse, usingCardTypes, err := checkLoginTypeInUse(originalLoginType.Name) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil) + return + } + if inUse { + utils.JsonResponse(w, http.StatusBadRequest, false, "该登录类型正在被以下卡密类型使用,无法修改名称:"+strings.Join(usingCardTypes, "、"), nil) + return + } + } + + // 当尝试禁用(status=0)时,如被卡密类型使用则禁止禁用 + if body.Status == 0 && originalLoginType.Status != 0 { + inUse, usingCardTypes, err := checkLoginTypeInUse(originalLoginType.Name) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil) + return + } + if inUse { + utils.JsonResponse(w, http.StatusBadRequest, false, "该登录类型正在被以下卡密类型使用,无法禁用:"+strings.Join(usingCardTypes, "、"), nil) + return + } + } + + updates := map[string]interface{}{} + if body.Name != "" { + updates["name"] = body.Name + } + updates["status"] = body.Status + updates["verify_types"] = body.VerifyTypes + if err := db.Model(&models.LoginType{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "更新失败,可能是名称重复", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "更新成功", nil) +} + +// LoginTypeDeleteHandler 删除单个登录方式 +// - 接收JSON: {id} +func LoginTypeDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + ID uint `json:"id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.ID == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 查询登录类型名称 + var loginType models.LoginType + if dbErr := db.First(&loginType, body.ID).Error; dbErr != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "登录类型不存在", nil) + return + } + + // 检查是否被卡密类型使用 + inUse, usingCardTypes, err := checkLoginTypeInUse(loginType.Name) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil) + return + } + if inUse { + utils.JsonResponse(w, http.StatusBadRequest, false, "该登录类型正在被以下卡密类型使用,无法删除:"+strings.Join(usingCardTypes, "、"), nil) + return + } + + if err := db.Delete(&models.LoginType{}, body.ID).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "删除失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "删除成功", nil) +} + +// LoginTypesBatchDeleteHandler 批量删除登录方式 +// - 接收JSON: {ids: []} +func LoginTypesBatchDeleteHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + IDs []uint `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + + // 检查批量删除的登录类型是否被使用 + hasUsage, usageMap, err := checkLoginTypesByIDsInUse(body.IDs) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查使用状态失败", nil) + return + } + if hasUsage { + // 构建详细的错误信息 + var errorMessages []string + db, _ := database.GetDB() + for loginTypeID, usingCardTypes := range usageMap { + var loginType models.LoginType + if db.First(&loginType, loginTypeID).Error == nil { + errorMessages = append(errorMessages, loginType.Name+"(被"+strings.Join(usingCardTypes, "、")+"使用)") + } + } + utils.JsonResponse(w, http.StatusBadRequest, false, "以下登录类型正在被使用,无法删除:"+strings.Join(errorMessages, ";"), nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + if err := db.Delete(&models.LoginType{}, body.IDs).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "批量删除失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "批量删除成功", nil) +} + +// LoginTypesBatchEnableHandler 批量启用 +// - 接收JSON: {ids: []} +func LoginTypesBatchEnableHandler(w http.ResponseWriter, r *http.Request) { + batchUpdateLoginTypeStatus(w, r, 1) +} + +// LoginTypesBatchDisableHandler 批量禁用 +// - 接收JSON: {ids: []} +func LoginTypesBatchDisableHandler(w http.ResponseWriter, r *http.Request) { + batchUpdateLoginTypeStatus(w, r, 0) +} + +// batchUpdateLoginTypeStatus 批量更新登录方式状态的通用函数 +// - status: 1 启用,0 禁用 +func batchUpdateLoginTypeStatus(w http.ResponseWriter, r *http.Request, status int) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + var body struct { + IDs []uint `json:"ids"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.IDs) == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "参数错误", nil) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + if err := db.Model(&models.LoginType{}).Where("id IN ?", body.IDs).Update("status", status).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "批量更新失败", nil) + return + } + utils.JsonResponse(w, http.StatusOK, true, "操作成功", nil) +} diff --git a/controllers/admin/settings.go b/controllers/admin/settings.go new file mode 100644 index 0000000..8282b59 --- /dev/null +++ b/controllers/admin/settings.go @@ -0,0 +1,147 @@ +package admin + +import ( + "encoding/json" + "fmt" + "net/http" + "networkDev/database" + "networkDev/models" + "networkDev/utils" + + // 新增:用于刷新内存缓存 + "networkDev/services" + // 新增:用于RedisDel上下文 + "context" + + "github.com/sirupsen/logrus" +) + +// SettingsFragmentHandler 设置片段渲染 +// - 渲染设置表单(通过前端JS调用API加载/保存) +func SettingsFragmentHandler(w http.ResponseWriter, r *http.Request) { + utils.RenderTemplate(w, "settings.html", map[string]interface{}{}) +} + +// SettingsQueryHandler 设置查询API +// - 返回所有设置项的 name:value 映射 +func SettingsQueryHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + var list []models.Settings + if err := db.Find(&list).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "查询失败", nil) + return + } + res := map[string]string{} + for _, s := range list { + res[s.Name] = s.Value + } + utils.JsonResponse(w, http.StatusOK, true, "ok", res) +} + +// SettingsUpdateHandler 更新系统设置处理器 +// - 接收JSON格式的设置数据,支持两种格式: +// 1. 直接字段格式: {"site_title": "值", "site_keywords": "值"} +// 2. 嵌套格式: {"settings": {"site_title": "值", "site_keywords": "值"}} +// +// - 自动创建不存在的设置项 +// - 更新已存在的设置项 +// - 更新完成后: +// 1. 删除对应的Redis缓存键,确保后续读取走数据库并重建缓存 +// 2. 刷新SettingsService内存缓存 +func SettingsUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + // 先尝试解析为直接字段格式 + var directBody map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&directBody); err != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求体错误", nil) + return + } + + // 提取设置数据 + var settingsData map[string]string + + // 检查是否为嵌套格式(包含settings字段) + if settings, exists := directBody["settings"]; exists { + if settingsMap, ok := settings.(map[string]interface{}); ok { + settingsData = make(map[string]string) + for k, v := range settingsMap { + if str, ok := v.(string); ok { + settingsData[k] = str + } + } + } else { + utils.JsonResponse(w, http.StatusBadRequest, false, "settings字段格式错误", nil) + return + } + } else { + // 直接字段格式 + settingsData = make(map[string]string) + for k, v := range directBody { + if str, ok := v.(string); ok { + settingsData[k] = str + } else if v != nil { + // 转换其他类型为字符串 + settingsData[k] = fmt.Sprintf("%v", v) + } + } + } + + if len(settingsData) == 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "无设置项", nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 记录需要失效的缓存键,统一删除,减少与Redis交互次数 + keysToDel := make([]string, 0, len(settingsData)) + + // 批量处理设置项 + for k, v := range settingsData { + var s models.Settings + if err := db.Where("name = ?", k).First(&s).Error; err != nil { + // 不存在则创建 + s = models.Settings{Name: k, Value: v} + if err := db.Create(&s).Error; err != nil { + logrus.WithError(err).WithField("setting_name", k).Error("创建设置失败") + utils.JsonResponse(w, http.StatusInternalServerError, false, fmt.Sprintf("保存设置 %s 失败", k), nil) + return + } + + } else { + // 存在则更新 + if err := db.Model(&models.Settings{}).Where("id = ?", s.ID).Update("value", v).Error; err != nil { + logrus.WithError(err).WithField("setting_name", k).Error("更新设置失败") + utils.JsonResponse(w, http.StatusInternalServerError, false, fmt.Sprintf("更新设置 %s 失败", k), nil) + return + } + + } + // 收集对应的Redis缓存键(与services/query.go中的键命名保持一致) + keysToDel = append(keysToDel, fmt.Sprintf("setting:%s", k)) + } + + // 删除Redis缓存键(如果Redis不可用则静默跳过) + _ = utils.RedisDel(context.Background(), keysToDel...) + + // 刷新内存中的设置缓存,保证后续读取一致 + services.GetSettingsService().RefreshCache() + + utils.JsonResponse(w, http.StatusOK, true, "保存成功", nil) +} diff --git a/controllers/admin/user.go b/controllers/admin/user.go new file mode 100644 index 0000000..0b025b2 --- /dev/null +++ b/controllers/admin/user.go @@ -0,0 +1,238 @@ +package admin + +import ( + "encoding/json" + "net/http" + "networkDev/database" + "networkDev/models" + "networkDev/utils" + "strings" +) + +// UserFragmentHandler 个人资料片段渲染 +// - 渲染个人资料与修改密码表单 +func UserFragmentHandler(w http.ResponseWriter, r *http.Request) { + utils.RenderTemplate(w, "user.html", map[string]interface{}{}) +} + +// UserProfileQueryHandler 查询当前登录管理员的基本信息 +// - 返回 id/username/role 三个字段 +// - 自动刷新接近过期的JWT令牌 +func UserProfileQueryHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + claims, _, err := GetCurrentAdminUserWithRefresh(w, r) + if err != nil { + utils.JsonResponse(w, http.StatusUnauthorized, false, "未登录或会话已过期", nil) + return + } + + utils.JsonResponse(w, http.StatusOK, true, "ok", map[string]interface{}{ + "id": claims.UserID, + "username": claims.Username, + "role": claims.Role, + }) +} + +// UserPasswordUpdateHandler 修改当前登录管理员的密码 +// - 接收 JSON: {old_password, new_password, confirm_password} +// - 校验旧密码正确性、新密码与确认一致性 +// - 成功后更新密码哈希 +// - 自动刷新接近过期的JWT令牌 +func UserPasswordUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + claims, _, err := GetCurrentAdminUserWithRefresh(w, r) + if err != nil { + utils.JsonResponse(w, http.StatusUnauthorized, false, "未登录或会话已过期", nil) + return + } + + var body struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + ConfirmPassword string `json:"confirm_password"` + } + var decodeErr error + if decodeErr = json.NewDecoder(r.Body).Decode(&body); decodeErr != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求参数错误", nil) + return + } + + // 基础校验 + if body.OldPassword == "" || body.NewPassword == "" || body.ConfirmPassword == "" { + utils.JsonResponse(w, http.StatusBadRequest, false, "旧密码/新密码/确认密码均不能为空", nil) + return + } + if len(body.NewPassword) < 6 { + utils.JsonResponse(w, http.StatusBadRequest, false, "新密码长度不能少于6位", nil) + return + } + if body.NewPassword != body.ConfirmPassword { + utils.JsonResponse(w, http.StatusBadRequest, false, "两次输入的新密码不一致", nil) + return + } + if body.NewPassword == body.OldPassword { + utils.JsonResponse(w, http.StatusBadRequest, false, "新密码不能与旧密码相同", nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 查询当前用户 + var user models.User + if dbErr := db.First(&user, claims.UserID).Error; dbErr != nil { + utils.JsonResponse(w, http.StatusNotFound, false, "用户不存在", nil) + return + } + + // 校验旧密码(使用盐值验证) + if !utils.VerifyPasswordWithSalt(body.OldPassword, user.PasswordSalt, user.Password) { + utils.JsonResponse(w, http.StatusUnauthorized, false, "旧密码不正确", nil) + return + } + + // 生成新的密码盐值 + newSalt, err := utils.GenerateRandomSalt() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "生成密码盐失败", nil) + return + } + + // 使用新盐值生成密码哈希 + hash, err := utils.HashPasswordWithSalt(body.NewPassword, newSalt) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "生成密码哈希失败", nil) + return + } + + // 更新密码和盐值 + if err := db.Model(&models.User{}).Where("id = ?", claims.UserID).Updates(map[string]interface{}{ + "password": hash, + "password_salt": newSalt, + }).Error; err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "更新密码失败", nil) + return + } + + // 可选:安全起见,通知前端跳转到登录页 + utils.JsonResponse(w, http.StatusOK, true, "密码修改成功,请重新登录", map[string]interface{}{ + "redirect": "/admin/login", + }) +} + +// UserProfileUpdateHandler 修改当前登录管理员的用户名 +// - 接收 JSON: {username} +// - 校验用户名非空、长度与唯一性 +// - 更新数据库后重新签发JWT并写入 Cookie,保持前端展示的一致性 +// - 自动刷新接近过期的JWT令牌 +func UserProfileUpdateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + claims, _, err := GetCurrentAdminUserWithRefresh(w, r) + if err != nil { + utils.JsonResponse(w, http.StatusUnauthorized, false, "未登录或会话已过期", nil) + return + } + + var body struct { + Username string `json:"username"` + OldPassword string `json:"old_password"` + } + if decodeErr := json.NewDecoder(r.Body).Decode(&body); decodeErr != nil { + utils.JsonResponse(w, http.StatusBadRequest, false, "请求参数错误", nil) + return + } + + username := strings.TrimSpace(body.Username) + if username == "" { + utils.JsonResponse(w, http.StatusBadRequest, false, "用户名不能为空", nil) + return + } + if len(username) > 64 { + utils.JsonResponse(w, http.StatusBadRequest, false, "用户名长度不能超过64字符", nil) + return + } + + db, err := database.GetDB() + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "数据库连接失败", nil) + return + } + + // 检查唯一性:排除当前用户ID + var cnt int64 + if dbErr := db.Model(&models.User{}).Where("username = ? AND id <> ?", username, claims.UserID).Count(&cnt).Error; dbErr != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "检查用户名唯一性失败", nil) + return + } + if cnt > 0 { + utils.JsonResponse(w, http.StatusBadRequest, false, "用户名已存在,请更换", nil) + return + } + + // 如果未变化则直接返回成功(无需校验旧密码) + if strings.EqualFold(username, claims.Username) { + utils.JsonResponse(w, http.StatusOK, true, "保存成功", map[string]interface{}{ + "username": username, + }) + return + } + + // 修改用户名需要进行当前密码校验 + if strings.TrimSpace(body.OldPassword) == "" { + utils.JsonResponse(w, http.StatusBadRequest, false, "修改用户名需要提供当前密码", nil) + return + } + // 查询当前用户并校验旧密码 + var user models.User + if dbErr := db.First(&user, claims.UserID).Error; dbErr != nil { + utils.JsonResponse(w, http.StatusNotFound, false, "用户不存在", nil) + return + } + // 使用盐值验证当前密码 + if !utils.VerifyPasswordWithSalt(body.OldPassword, user.PasswordSalt, user.Password) { + utils.JsonResponse(w, http.StatusUnauthorized, false, "当前密码不正确", nil) + return + } + + // 执行更新 + if dbErr := db.Model(&models.User{}).Where("id = ?", claims.UserID).Update("username", username).Error; dbErr != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "更新用户名失败", nil) + return + } + + // 重新签发JWT并写入Cookie + newUser := models.User{ID: claims.UserID, Username: username, Role: claims.Role} + token, err := generateJWTToken(newUser) + if err != nil { + utils.JsonResponse(w, http.StatusInternalServerError, false, "生成新令牌失败", nil) + return + } + cookie := &http.Cookie{ + Name: "admin_session", + Value: token, + Path: "/", + HttpOnly: true, + Secure: false, + MaxAge: 24 * 60 * 60, + } + http.SetCookie(w, cookie) + + utils.JsonResponse(w, http.StatusOK, true, "保存成功", map[string]interface{}{ + "username": username, + }) +} diff --git a/controllers/home/home.go b/controllers/home/home.go new file mode 100644 index 0000000..dfbec1c --- /dev/null +++ b/controllers/home/home.go @@ -0,0 +1,73 @@ +package home + +import ( + "net/http" + "networkDev/database" + "networkDev/models" + "networkDev/services" + "networkDev/utils" +) + +// RootHandler 主页处理器 +func RootHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + // 获取数据库连接 + db, err := database.GetDB() + if err != nil { + http.Error(w, "数据库连接失败", http.StatusInternalServerError) + return + } + + // 从数据库获取站点标题和页脚文本 + siteTitle, err := services.FindSettingByName("site_title", db) + if err != nil { + siteTitle = &models.Settings{Value: "凌动技术"} + } + + footerText, err := services.FindSettingByName("footer_text", db) + if err != nil { + footerText = &models.Settings{Value: "© 2025 凌动技术 保留所有权利"} + } + + // 从数据库获取备案信息 + icpRecord, err := services.FindSettingByName("icp_record", db) + if err != nil { + icpRecord = &models.Settings{Value: ""} + } + + icpRecordLink, err := services.FindSettingByName("icp_record_link", db) + if err != nil { + icpRecordLink = &models.Settings{Value: "https://beian.miit.gov.cn"} + } + + // 从数据库获取公安备案信息 + psbRecord, err := services.FindSettingByName("psb_record", db) + if err != nil { + psbRecord = &models.Settings{Value: ""} + } + + psbRecordLink, err := services.FindSettingByName("psb_record_link", db) + if err != nil { + psbRecordLink = &models.Settings{Value: "https://www.beian.gov.cn"} + } + + // 准备模板数据 + data := map[string]interface{}{ + "SystemName": siteTitle.Value, + "FooterText": footerText.Value, + "ICPRecord": icpRecord.Value, + "ICPRecordLink": icpRecordLink.Value, + "PSBRecord": psbRecord.Value, + "PSBRecordLink": psbRecordLink.Value, + "title": "主页", + } + + if err := utils.RenderTemplate(w, "index.html", data); err != nil { + http.Error(w, "页面加载失败", http.StatusInternalServerError) + return + } +} diff --git a/database/database.go b/database/database.go new file mode 100644 index 0000000..c102713 --- /dev/null +++ b/database/database.go @@ -0,0 +1,125 @@ +package database + +import ( + "fmt" + "sync" + + "networkDev/utils" + + "github.com/glebarez/sqlite" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "gorm.io/driver/mysql" + "gorm.io/gorm" +) + +var ( + // dbInstance 全局 *gorm.DB 实例,使用单例确保全局复用 + dbInstance *gorm.DB + // once 确保初始化只执行一次 + once sync.Once +) + +// Init 初始化数据库连接(根据配置自动选择驱动) +// - 默认使用 SQLite(github.com/glebarez/sqlite) +// - 生产环境支持 MySQL(gorm.io/driver/mysql) +func Init() (*gorm.DB, error) { + var initErr error + once.Do(func() { + dbType := viper.GetString("database.type") + switch dbType { + case "mysql": + initErr = initMySQL() + default: + initErr = initSQLite() + } + + // 如果数据库初始化成功,配置连接池和启动健康检查 + if initErr == nil && dbInstance != nil { + // 加载数据库配置 + var configPrefix string + if dbType == "mysql" { + configPrefix = "database.mysql" + } else { + configPrefix = "database.sqlite" + } + + dbConfig := utils.LoadDatabaseConfig(configPrefix) + + // 验证配置 + if err := utils.ValidateDatabaseConfig(dbConfig); err != nil { + logrus.WithError(err).Warn("数据库配置验证失败,使用默认配置") + dbConfig = utils.GetDefaultDatabaseConfig() + } + + // 配置连接池 + if err := utils.ConfigureConnectionPool(dbInstance, dbConfig); err != nil { + logrus.WithError(err).Error("配置数据库连接池失败") + } + + // 启动健康检查 + utils.StartHealthCheck(dbInstance, dbConfig) + } + }) + return dbInstance, initErr +} + +// GetDB 获取全局 *gorm.DB 实例 +// 如果未初始化,会尝试初始化一次 +func GetDB() (*gorm.DB, error) { + if dbInstance != nil { + return dbInstance, nil + } + return Init() +} + +// initSQLite 初始化 SQLite 数据库 +// 使用 viper 中的 database.sqlite.path 作为数据库文件路径 +func initSQLite() error { + path := viper.GetString("database.sqlite.path") + if path == "" { + path = "./recharge.db" + } + dsn := fmt.Sprintf("file:%s?cache=shared&_busy_timeout=5000&_fk=1", path) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + logrus.WithError(err).Error("SQLite 初始化失败") + return err + } + + // SQLite 连接池配置(SQLite 对连接池支持有限,但仍可设置基本参数) + if sqlDB, err := db.DB(); err == nil { + // SQLite 通常使用单连接,但可以设置一些基本参数 + sqlDB.SetMaxOpenConns(1) // SQLite 建议使用单连接 + sqlDB.SetMaxIdleConns(1) + } + + dbInstance = db + logrus.WithField("path", path).Info("SQLite 连接已建立") + return nil +} + +// initMySQL 初始化 MySQL 数据库 +// 从 viper 读取 database.mysql.* 配置构建 DSN +func initMySQL() error { + host := viper.GetString("database.mysql.host") + port := viper.GetInt("database.mysql.port") + user := viper.GetString("database.mysql.username") + pass := viper.GetString("database.mysql.password") + dbname := viper.GetString("database.mysql.database") + charset := viper.GetString("database.mysql.charset") + if charset == "" { + charset = "utf8mb4" + } + + dsn := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=True&loc=Local", user, pass, host, port, dbname, charset) + db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) + if err != nil { + logrus.WithError(err).Error("MySQL 初始化失败") + return err + } + + dbInstance = db + logrus.WithField("host", host).WithField("database", dbname).Info("MySQL 连接已建立") + return nil +} diff --git a/database/migrate.go b/database/migrate.go new file mode 100644 index 0000000..760d426 --- /dev/null +++ b/database/migrate.go @@ -0,0 +1,172 @@ +package database + +import ( + "fmt" + "networkDev/models" + "strings" + + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +// AutoMigrate 自动迁移数据库模型 +// - 会确保必要的数据表结构存在 +// - 不会破坏已有数据 +func AutoMigrate() error { + db, err := GetDB() + if err != nil { + return err + } + if err := db.AutoMigrate(&models.User{}, &models.Settings{}, &models.LoginType{}, &models.CardType{}, &models.Card{}, &models.App{}, &models.API{}); err != nil { + logrus.WithError(err).Error("AutoMigrate 执行失败") + return err + } + + // 兼容迁移:如果 users.password_salt 列长度 < 64,则扩大到 64 + if err := ensureUserPasswordSaltLength(db); err != nil { + logrus.WithError(err).Error("调整 users.password_salt 列长度失败") + return err + } + + // 兼容迁移:确保 tasks.verification_code 字段类型为 LONGTEXT 以支持大图片数据 + if err := ensureVerificationCodeType(db); err != nil { + logrus.WithError(err).Error("调整 tasks.verification_code 字段类型失败") + return err + } + + logrus.Info("AutoMigrate 执行完成") + return nil +} + +// ensureVerificationCodeType 确保tasks.verification_code字段类型为LONGTEXT以支持大图片数据 +// 中文注释:检查并修改verification_code字段类型,支持Base64编码的大图片数据存储 +func ensureVerificationCodeType(db *gorm.DB) error { + // 获取数据库方言类型 + dialector := db.Dialector.Name() + + // 根据不同数据库类型执行不同的检查逻辑 + switch dialector { + case "mysql": + // MySQL/MariaDB使用INFORMATION_SCHEMA + var result struct { + ColumnName string `gorm:"column:COLUMN_NAME"` + ColumnType string `gorm:"column:COLUMN_TYPE"` + } + + err := db.Raw("SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1", + "tasks", "verification_code").Scan(&result).Error + + if err != nil { + return nil // 查询失败则跳过 + } + + // 检查列类型,如果不是LONGTEXT则修改 + if !strings.Contains(strings.ToLower(result.ColumnType), "longtext") { + alterSQL := "ALTER TABLE tasks MODIFY COLUMN verification_code LONGTEXT" + if err := db.Exec(alterSQL).Error; err != nil { + return fmt.Errorf("修改verification_code字段类型失败: %v", err) + } + logrus.Info("verification_code字段类型已更新为LONGTEXT") + } + case "sqlite": + // SQLite使用pragma_table_info检查列信息 + var columns []struct { + CID int `gorm:"column:cid"` + Name string `gorm:"column:name"` + Type string `gorm:"column:type"` + NotNull int `gorm:"column:notnull"` + DfltValue *string `gorm:"column:dflt_value"` + PK int `gorm:"column:pk"` + } + + err := db.Raw("PRAGMA table_info(tasks)").Scan(&columns).Error + if err != nil { + return nil // 查询失败则跳过 + } + + // 查找verification_code列 + for _, col := range columns { + if col.Name == "verification_code" { + // SQLite中,如果列类型不是TEXT,需要重建表 + if !strings.Contains(strings.ToLower(col.Type), "text") { + // SQLite不支持直接修改列类型,但GORM的AutoMigrate会处理这种情况 + logrus.Info("SQLite检测到verification_code字段类型需要更新,依赖GORM AutoMigrate处理") + } + break + } + } + default: + // 其他数据库类型暂不处理 + logrus.Infof("数据库类型 %s 暂不支持verification_code字段类型检查", dialector) + } + + return nil +} + +// ensureUserPasswordSaltLength 确保users.password_salt列长度至少为64 +// 中文注释:检查并修改password_salt列长度,兼容32字节(64十六进制字符)的盐值 +func ensureUserPasswordSaltLength(db *gorm.DB) error { + // 获取数据库方言类型 + dialector := db.Dialector.Name() + + // 根据不同数据库类型执行不同的检查逻辑 + switch dialector { + case "mysql": + // MySQL/MariaDB使用INFORMATION_SCHEMA + var result struct { + ColumnName string `gorm:"column:COLUMN_NAME"` + ColumnType string `gorm:"column:COLUMN_TYPE"` + } + + err := db.Raw("SELECT COLUMN_NAME, COLUMN_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_NAME = ? AND COLUMN_NAME = ? LIMIT 1", + "users", "password_salt").Scan(&result).Error + + if err != nil { + return nil // 查询失败则跳过 + } + + // 检查列类型,如果长度小于64则修改 + if strings.Contains(strings.ToLower(result.ColumnType), "varchar") { + if strings.Contains(result.ColumnType, "(32)") || strings.Contains(result.ColumnType, "(16)") { + alterSQL := "ALTER TABLE users MODIFY COLUMN password_salt VARCHAR(64)" + if err := db.Exec(alterSQL).Error; err != nil { + return fmt.Errorf("修改password_salt列长度失败: %v", err) + } + logrus.Info("password_salt列长度已更新为64") + } + } + case "sqlite": + // SQLite使用pragma_table_info检查列信息 + var columns []struct { + CID int `gorm:"column:cid"` + Name string `gorm:"column:name"` + Type string `gorm:"column:type"` + NotNull int `gorm:"column:notnull"` + DfltValue *string `gorm:"column:dflt_value"` + PK int `gorm:"column:pk"` + } + + err := db.Raw("PRAGMA table_info(users)").Scan(&columns).Error + if err != nil { + return nil // 查询失败则跳过 + } + + // 查找password_salt列 + for _, col := range columns { + if col.Name == "password_salt" { + // SQLite中,如果列类型包含长度限制且小于64,需要重建表 + if strings.Contains(strings.ToLower(col.Type), "varchar(32)") || + strings.Contains(strings.ToLower(col.Type), "varchar(16)") { + // SQLite不支持直接修改列类型,但GORM的AutoMigrate会处理这种情况 + logrus.Info("SQLite检测到password_salt列长度需要更新,依赖GORM AutoMigrate处理") + } + break + } + } + default: + // 其他数据库类型暂不处理 + logrus.Infof("数据库类型 %s 暂不支持password_salt列长度检查", dialector) + } + + return nil +} diff --git a/database/seed_settings.go b/database/seed_settings.go new file mode 100644 index 0000000..11d92ff --- /dev/null +++ b/database/seed_settings.go @@ -0,0 +1,116 @@ +package database + +import ( + "networkDev/models" + + "github.com/sirupsen/logrus" +) + +// SeedDefaultSettings 初始化默认系统设置 +// - 检查各项设置是否已存在,如不存在则创建默认值 +// - 包含站点基本信息、SEO设置等常用配置项 +func SeedDefaultSettings() error { + db, err := GetDB() + if err != nil { + return err + } + + // 定义默认设置项 + defaultSettings := []models.Settings{ + { + Name: "site_title", + Value: "凌动技术", + Description: "网站标题,显示在浏览器标题栏和页面顶部", + }, + { + Name: "site_keywords", + Value: "验证,网络,管理系统,网络验证,账户管理", + Description: "网站关键词,用于SEO优化,多个关键词用逗号分隔", + }, + { + Name: "site_description", + Value: "专业的网络验证管理系统,提供便捷的在线网络验证服务和账户管理功能", + Description: "网站描述,用于SEO优化和社交媒体分享", + }, + { + Name: "site_logo", + Value: "/assets/logo.png", + Description: "网站Logo图片路径", + }, + { + Name: "contact_email", + Value: "admin@example.com", + Description: "联系邮箱,用于客服和业务咨询", + }, + { + Name: "max_upload_size", + Value: "10485760", + Description: "文件上传最大尺寸(字节),默认10MB", + }, + { + Name: "default_user_role", + Value: "1", + Description: "新用户默认角色,0=管理员,1=普通用户", + }, + { + Name: "session_timeout", + Value: "3600", + Description: "会话超时时间(秒),默认1小时", + }, + { + Name: "maintenance_mode", + Value: "0", + Description: "系统开关,0=开启系统,1=关闭系统", + }, + // ===== 页脚与备案相关默认项 ===== + { + Name: "footer_text", + Value: "Copyright © 2025 凌动技术. All Rights Reserved.", + Description: "页脚展示的版权或说明信息", + }, + { + Name: "icp_record", + Value: "京ICP备12345678号", + Description: "ICP备案号,留空则不显示", + }, + { + Name: "icp_record_link", + Value: "https://beian.miit.gov.cn", + Description: "工信部ICP备案查询链接,留空则不显示", + }, + { + Name: "psb_record", + Value: "京公网安备 11000002000001号", + Description: "公安备案号,留空则不显示", + }, + { + Name: "psb_record_link", + Value: "https://www.beian.gov.cn/portal/registerSystemInfo?recordcode=11000002000001", + Description: "公安备案查询链接,留空则不显示", + }, + { + Name: "card_batch_counter", + Value: "0", + Description: "卡密批次号计数器(用于记录上次生成批次号的序号,自增使用)", + }, + } + + // 逐个检查并创建不存在的设置项 + for _, setting := range defaultSettings { + var count int64 + if err := db.Model(&models.Settings{}).Where("name = ?", setting.Name).Count(&count).Error; err != nil { + return err + } + + if count == 0 { + if err := db.Create(&setting).Error; err != nil { + logrus.WithError(err).WithField("name", setting.Name).Error("创建默认设置失败") + return err + } + logrus.WithField("name", setting.Name).WithField("value", setting.Value).Info("创建默认设置项") + } + } + + logrus.Info("默认系统设置初始化完成") + return nil +} diff --git a/database/seed_user.go b/database/seed_user.go new file mode 100644 index 0000000..668ca63 --- /dev/null +++ b/database/seed_user.go @@ -0,0 +1,54 @@ +package database + +import ( + "networkDev/models" + "networkDev/utils" + + "github.com/sirupsen/logrus" +) + +// SeedDefaultAdmin 初始化默认管理员账号 +// - 如果用户名为 admin 的用户已存在,则跳过 +// - 如不存在,则创建用户名为 admin、密码为 admin123(以 bcrypt 哈希存储)、角色 Role=0 的管理员 +// - 根据需求:默认 admin 用户的 ID 固定为 10000 +func SeedDefaultAdmin() error { + db, err := GetDB() + if err != nil { + return err + } + + // 检查是否存在用户名为 admin 的用户 + var count int64 + if dbErr := db.Model(&models.User{}).Where("username = ?", "admin").Count(&count).Error; dbErr != nil { + return dbErr + } + if count > 0 { + return nil + } + + // 生成密码盐值 + salt, err := utils.GenerateRandomSalt() + if err != nil { + return err + } + + // 使用盐值生成密码哈希(不存明文) + hash, err := utils.HashPasswordWithSalt("admin123", salt) + if err != nil { + return err + } + + // 创建默认管理员(指定固定 ID=10000) + admin := models.User{ + ID: 10000, + Username: "admin", + Password: hash, + PasswordSalt: salt, + Role: 0, // 0=管理员 + } + if err := db.Create(&admin).Error; err != nil { + return err + } + logrus.WithField("username", "admin").WithField("id", admin.ID).Info("默认管理员创建成功") + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d29662f --- /dev/null +++ b/go.mod @@ -0,0 +1,48 @@ +module networkDev + +go 1.24.1 + +require ( + github.com/glebarez/sqlite v1.11.0 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/google/uuid v1.6.0 + github.com/redis/go-redis/v9 v9.13.0 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.9.1 + github.com/spf13/viper v1.20.1 + golang.org/x/crypto v0.41.0 + gorm.io/driver/mysql v1.6.0 + gorm.io/gorm v1.30.1 +) + +require ( + filippo.io/edwards25519 v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fsnotify/fsnotify v1.8.0 // indirect + github.com/glebarez/go-sqlite v1.21.2 // indirect + github.com/go-sql-driver/mysql v1.8.1 // indirect + github.com/go-viper/mapstructure/v2 v2.2.1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/mattn/go-isatty v0.0.17 // indirect + github.com/pelletier/go-toml/v2 v2.2.3 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/sagikazarmark/locafero v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spf13/afero v1.12.0 // indirect + github.com/spf13/cast v1.7.1 // indirect + github.com/spf13/pflag v1.0.6 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + go.uber.org/atomic v1.9.0 // indirect + go.uber.org/multierr v1.9.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.28.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + modernc.org/libc v1.22.5 // indirect + modernc.org/mathutil v1.5.0 // indirect + modernc.org/memory v1.5.0 // indirect + modernc.org/sqlite v1.23.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..671a5d8 --- /dev/null +++ b/go.sum @@ -0,0 +1,113 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= +github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= +github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= +github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= +github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= +github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= +github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss= +github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= +github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= +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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +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/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/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= +github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= +github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.13.0 h1:PpmlVykE0ODh8P43U0HqC+2NXHXwG+GUtQyz+MPKGRg= +github.com/redis/go-redis/v9 v9.13.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw= +github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= +github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= +github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= +github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= +github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= +github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= +github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= +github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= +github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= +go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= +gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= +gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= +gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= +modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE= +modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY= +modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ= +modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E= +modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds= +modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU= +modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM= +modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk= diff --git a/main.go b/main.go new file mode 100644 index 0000000..5cec096 --- /dev/null +++ b/main.go @@ -0,0 +1,9 @@ +package main + +import "networkDev/cmd" + +// main 是程序的入口点 +// 调用Cobra命令执行器来处理命令行参数和子命令 +func main() { + cmd.Execute() +} diff --git a/middleware/middleware.go b/middleware/middleware.go new file mode 100644 index 0000000..e6db2af --- /dev/null +++ b/middleware/middleware.go @@ -0,0 +1,133 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "networkDev/utils/logger" +) + +// LoggingMiddleware HTTP请求日志中间件 +// 记录每个HTTP请求的详细信息,包括方法、路径、状态码和响应时间 +type LoggingMiddleware struct { + logger *logger.Logger +} + +// NewLoggingMiddleware 创建新的日志中间件实例 +func NewLoggingMiddleware(logger *logger.Logger) *LoggingMiddleware { + return &LoggingMiddleware{ + logger: logger, + } +} + +// responseWriter 包装http.ResponseWriter以捕获状态码 +type responseWriter struct { + http.ResponseWriter + statusCode int + written bool +} + +// newResponseWriter 创建新的响应写入器包装器 +func newResponseWriter(w http.ResponseWriter) *responseWriter { + return &responseWriter{ + ResponseWriter: w, + statusCode: http.StatusOK, // 默认状态码 + } +} + +// WriteHeader 重写WriteHeader方法以捕获状态码 +func (rw *responseWriter) WriteHeader(code int) { + if !rw.written { + rw.statusCode = code + rw.written = true + rw.ResponseWriter.WriteHeader(code) + } +} + +// Write 重写Write方法以确保状态码被设置 +func (rw *responseWriter) Write(b []byte) (int, error) { + if !rw.written { + rw.WriteHeader(http.StatusOK) + } + return rw.ResponseWriter.Write(b) +} + +// Handler 中间件处理器函数 +// 包装HTTP处理器以添加请求日志记录功能 +func (lm *LoggingMiddleware) Handler(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + + // 包装响应写入器以捕获状态码 + wrapped := newResponseWriter(w) + + // 调用下一个处理器 + next.ServeHTTP(wrapped, r) + + // 计算响应时间 + duration := time.Since(start) + + // 记录请求日志 + lm.logger.LogRequestWithHeaders( + r.Method, + r.URL.Path, + getClientIP(r), + wrapped.statusCode, + duration, + "-", + r.Header.Get("User-Agent"), + ) + }) +} + +// getClientIP 获取客户端真实IP地址 +// 优先从X-Forwarded-For、X-Real-IP等头部获取,最后使用RemoteAddr +func getClientIP(r *http.Request) string { + // 检查X-Forwarded-For头部 + xForwardedFor := r.Header.Get("X-Forwarded-For") + if xForwardedFor != "" { + // X-Forwarded-For可能包含多个IP,取第一个 + ips := strings.Split(xForwardedFor, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // 检查X-Real-IP头部 + xRealIP := r.Header.Get("X-Real-IP") + if xRealIP != "" { + return xRealIP + } + + // 检查X-Forwarded头部 + xForwarded := r.Header.Get("X-Forwarded") + if xForwarded != "" { + return xForwarded + } + + // 使用RemoteAddr + remoteAddr := r.RemoteAddr + if strings.Contains(remoteAddr, ":") { + // 移除端口号 + if idx := strings.LastIndex(remoteAddr, ":"); idx != -1 { + return remoteAddr[:idx] + } + } + + return remoteAddr +} + +// WrapHandler 包装HTTP处理器以添加日志记录功能 +// 使用全局日志记录器创建日志中间件 +func WrapHandler(handler http.Handler) http.Handler { + logger := logger.GetLogger() + middleware := NewLoggingMiddleware(logger) + return middleware.Handler(handler) +} + +// WrapHandlerFunc 包装HTTP处理器函数以添加日志记录功能 +// 将HandlerFunc转换为Handler并添加日志中间件 +func WrapHandlerFunc(handlerFunc http.HandlerFunc) http.Handler { + return WrapHandler(handlerFunc) +} diff --git a/models/api.go b/models/api.go new file mode 100644 index 0000000..56d7f16 --- /dev/null +++ b/models/api.go @@ -0,0 +1,97 @@ +package models + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// API 接口表模型 +// 用于管理API接口的配置信息 +// 包含加密算法配置、密钥管理等功能 +// 支持多种加密算法:不加密、RC4、RSA、RSA(动态) + +type API struct { + // ID:主键,自增 + ID uint `gorm:"primaryKey;comment:API接口ID,自增主键" json:"id"` + + // API类型(int型) + APIType int `gorm:"not null;comment:API类型" json:"api_type"` + + // API密钥 + APIKey string `gorm:"size:255;not null;uniqueIndex;comment:API密钥,唯一标识" json:"api_key"` + + // 应用UUID,关联到App表 + AppUUID string `gorm:"size:36;not null;index;comment:关联的应用UUID" json:"app_uuid"` + + // 接口状态(1=启用,0=禁用) + Status int `gorm:"default:1;not null;comment:接口状态,1=启用,0=禁用" json:"status"` + + // 接口提交算法 + // 支持的算法:0=不加密,1=RC4,2=RSA,3=RSA(动态) + SubmitAlgorithm int `gorm:"default:0;not null;comment:提交算法,0=不加密,1=RC4,2=RSA,3=RSA动态" json:"submit_algorithm"` + + // 接口返回算法 + // 支持的算法:0=不加密,1=RC4,2=RSA,3=RSA(动态) + ReturnAlgorithm int `gorm:"default:0;not null;comment:返回算法,0=不加密,1=RC4,2=RSA,3=RSA动态" json:"return_algorithm"` + + // 提交算法公钥(base64编码存储) + SubmitPublicKey string `gorm:"type:text;comment:提交算法公钥,base64编码" json:"submit_public_key"` + + // 提交算法私钥(base64编码存储) + SubmitPrivateKey string `gorm:"type:text;comment:提交算法私钥,base64编码" json:"submit_private_key"` + + // 返回算法公钥(base64编码存储) + ReturnPublicKey string `gorm:"type:text;comment:返回算法公钥,base64编码" json:"return_public_key"` + + // 返回算法私钥(base64编码存储) + ReturnPrivateKey string `gorm:"type:text;comment:返回算法私钥,base64编码" json:"return_private_key"` + + // 时间字段 + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` +} + +// BeforeCreate 在创建记录前自动生成API密钥 +func (api *API) BeforeCreate(tx *gorm.DB) error { + if api.APIKey == "" { + // 生成唯一的API密钥 + api.APIKey = "api_" + uuid.New().String() + } + return nil +} + +// TableName 指定表名 +func (API) TableName() string { + return "apis" +} + +// 算法类型常量 +const ( + AlgorithmNone = 0 // 不加密 + AlgorithmRC4 = 1 // RC4 + AlgorithmRSA = 2 // RSA + AlgorithmRSADynamic = 3 // RSA(动态) +) + +// GetAlgorithmName 获取算法名称 +func GetAlgorithmName(algorithm int) string { + switch algorithm { + case AlgorithmNone: + return "不加密" + case AlgorithmRC4: + return "RC4" + case AlgorithmRSA: + return "RSA" + case AlgorithmRSADynamic: + return "RSA(动态)" + default: + return "未知算法" + } +} + +// IsValidAlgorithm 验证算法类型是否有效 +func IsValidAlgorithm(algorithm int) bool { + return algorithm >= AlgorithmNone && algorithm <= AlgorithmRSADynamic +} \ No newline at end of file diff --git a/models/app.go b/models/app.go new file mode 100644 index 0000000..b61f681 --- /dev/null +++ b/models/app.go @@ -0,0 +1,63 @@ +package models + +import ( + "crypto/rand" + "encoding/hex" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// App 应用表模型 +// 用于管理应用程序的基本信息 +// UUID 为应用的唯一标识符,自动生成 +// Status 为应用状态(1:启用 0:禁用),默认为1 +// Name 为应用名称 +// Secret 为应用密钥,用于API认证 +// Version 为应用版本号 +// CreatedAt/UpdatedAt 由 GORM 自动维护 + +type App struct { + // ID:主键,自增,同时通过 json 标签保证前端接收为 id + ID uint `gorm:"primaryKey;comment:应用ID,自增主键" json:"id"` + // UUID:应用唯一标识符,自动生成 + UUID string `gorm:"uniqueIndex;size:36;not null;comment:应用UUID,唯一标识符" json:"uuid"` + // Status:状态(1=启用,0=禁用);json 名称与前端一致 + Status int `gorm:"default:1;not null;comment:应用状态,1=启用,0=禁用" json:"status"` + // Name:应用名称;json 名称与前端一致 + Name string `gorm:"size:100;not null;comment:应用名称" json:"name"` + // Secret:应用密钥,用于API认证 + Secret string `gorm:"size:255;not null;comment:应用密钥,用于API认证" json:"secret"` + // Version:应用版本号 + Version string `gorm:"size:50;default:'1.0.0';comment:应用版本号" json:"version"` + // ForceUpdate:强制更新(0=不开启,1=开启) + ForceUpdate int `gorm:"default:0;not null;comment:强制更新,0=不开启,1=开启" json:"force_update"` + // DownloadType:下载方式(0=不启用更新,1=自动更新,2=手动下载) + DownloadType int `gorm:"default:0;not null;comment:更新方式,0=不启用更新,1=自动更新,2=手动下载" json:"download_type"` + // DownloadURL:下载地址 + DownloadURL string `gorm:"size:500;comment:下载地址" json:"download_url"` + // CreatedAt/UpdatedAt:时间字段,返回为 created_at/updated_at,便于前端展示 + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` +} + +// BeforeCreate 在创建记录前自动生成UUID和密钥 +func (app *App) BeforeCreate(tx *gorm.DB) error { + if app.UUID == "" { + app.UUID = uuid.New().String() + } + if app.Secret == "" { + // 生成32位大写16进制随机字符 + bytes := make([]byte, 16) // 16字节 = 32位16进制字符 + rand.Read(bytes) + app.Secret = strings.ToUpper(hex.EncodeToString(bytes)) + } + return nil +} + +// TableName 指定表名 +func (App) TableName() string { + return "apps" +} diff --git a/models/card.go b/models/card.go new file mode 100644 index 0000000..8a8ab11 --- /dev/null +++ b/models/card.go @@ -0,0 +1,27 @@ +package models + +import ( + "time" +) + +// Card 卡密模型 +// 用于存储和管理系统中的卡密信息,包括卡密号码、状态、使用情况等 +type Card struct { + // ID:主键,自增 + ID uint `gorm:"primaryKey;comment:卡密ID,自增主键" json:"id"` + // CardNumber:卡密号码,唯一且非空 + CardNumber string `gorm:"size:200;not null;comment:卡密号码(十六进制字符串)" json:"card_number"` + // CardTypeID:所属卡密类型ID(外键) + CardTypeID uint `gorm:"not null;index;comment:所属卡密类型ID(外键)" json:"card_type_id"` + // Status:状态(0=未使用,1=已使用,2=禁用) + Status int `gorm:"default:0;not null;comment:状态,0=未使用,1=已使用,2=禁用" json:"status"` + // Batch:批次标识,用于区分导入或生成批次 + Batch string `gorm:"size:100;comment:批次标识" json:"batch"` + // Remark:备注信息 + Remark string `gorm:"size:255;comment:备注信息" json:"remark"` + // UsedAt:使用时间,未使用为NULL(调整到创建时间前面,以便前端展示顺序一致) + UsedAt *time.Time `gorm:"comment:使用时间" json:"used_at"` + // CreatedAt/UpdatedAt:时间字段 + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` +} diff --git a/models/card_type.go b/models/card_type.go new file mode 100644 index 0000000..919e043 --- /dev/null +++ b/models/card_type.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// CardType 卡密类型表模型 +// 用于管理不同类型的卡密(如:ChatGPT、Claude、Suno、Grok等) +// ID 为自增主键 +// Name 为卡密类型名称,唯一索引 +// Status 为状态(1:启用 0:禁用),默认为1 +// CreatedAt/UpdatedAt 由 GORM 自动维护 + +type CardType struct { + // ID:主键,自增,同时通过 json 标签保证前端接收为 id + ID uint `gorm:"primaryKey;comment:卡密类型ID,自增主键" json:"id"` + // Name:名称,唯一;json 名称与前端一致 + Name string `gorm:"uniqueIndex;size:100;not null;comment:卡密类型名称,唯一索引" json:"name"` + // Status:状态(1=启用,0=禁用);json 名称与前端一致 + Status int `gorm:"default:1;not null;comment:状态,1=启用,0=禁用" json:"status"` + // LoginTypes:登录方式(逗号分隔);json 使用 login_types + LoginTypes string `gorm:"type:varchar(500);default:'';comment:登录方式,多个用逗号分隔" json:"login_types"` + // CreatedAt/UpdatedAt:时间字段,返回为 created_at/updated_at,便于前端展示 + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` +} diff --git a/models/login_type.go b/models/login_type.go new file mode 100644 index 0000000..bd70c9d --- /dev/null +++ b/models/login_type.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// LoginType 登录类型表模型 +// 用于管理不同的登录方式,如直登、Google、Microsoft、Apple等 +// ID 为自增主键 +// Name 为登录类型名称,唯一索引 +// Status 为状态(1:启用 0:禁用),默认为1 +// CreatedAt/UpdatedAt 由 GORM 自动维护 + +type LoginType struct { + // ID:主键,自增,同时通过 json 标签保证前端接收为 id + ID uint `gorm:"primaryKey;comment:登录类型ID,自增主键" json:"id"` + // Name:名称,唯一;json 名称与前端一致 + Name string `gorm:"uniqueIndex;size:100;not null;comment:登录类型名称,唯一索引" json:"name"` + // Status:状态(1=启用,0=禁用);json 名称与前端一致 + Status int `gorm:"default:1;not null;comment:状态,1=启用,0=禁用" json:"status"` + // VerifyTypes:验证方式(逗号分隔);json 使用 verify_types;用于记录多种验证方式,输入内容用多个用逗号分隔 + VerifyTypes string `gorm:"type:varchar(500);default:'';comment:验证方式,输入内容用多个用逗号分隔" json:"verify_types"` + // CreatedAt/UpdatedAt:时间字段,返回为 created_at/updated_at,便于前端展示 + CreatedAt time.Time `gorm:"comment:创建时间" json:"created_at"` + UpdatedAt time.Time `gorm:"comment:更新时间" json:"updated_at"` +} diff --git a/models/settings.go b/models/settings.go new file mode 100644 index 0000000..246f2f7 --- /dev/null +++ b/models/settings.go @@ -0,0 +1,19 @@ +package models + +import "time" + +// Settings 系统设置表模型 +// 用于存储应用的配置参数 +// Name 为配置项名称,唯一索引 +// Value 为配置项的值 +// Description 为配置项描述说明 +// CreatedAt/UpdatedAt 由 GORM 自动维护 + +type Settings struct { + ID uint `gorm:"primaryKey;comment:设置ID,自增主键"` + Name string `gorm:"uniqueIndex;size:64;not null;comment:配置项名称,唯一索引"` + Value string `gorm:"type:text;comment:配置项的值"` + Description string `gorm:"size:255;comment:配置项描述说明"` + CreatedAt time.Time `gorm:"comment:创建时间"` + UpdatedAt time.Time `gorm:"comment:更新时间"` +} diff --git a/models/user.go b/models/user.go new file mode 100644 index 0000000..b6895ff --- /dev/null +++ b/models/user.go @@ -0,0 +1,16 @@ +package models + +import "time" + +// User 用户表模型 +// 说明:PasswordSalt 使用 32 字节随机盐(以 16 进制存储为 64 个字符),因此列长度设置为 64 + +type User struct { + ID uint `gorm:"primaryKey;comment:用户ID,自增主键"` + Username string `gorm:"uniqueIndex;size:64;not null;comment:用户名,唯一索引"` + Password string `gorm:"size:255;not null;comment:密码哈希值"` + PasswordSalt string `gorm:"size:64;not null;comment:密码加密盐值"` + Role int `gorm:"not null;comment:用户角色,0=管理员,1=普通用户"` + CreatedAt time.Time `gorm:"comment:创建时间"` + UpdatedAt time.Time `gorm:"comment:更新时间"` +} diff --git a/server/admin.go b/server/admin.go new file mode 100644 index 0000000..e884f36 --- /dev/null +++ b/server/admin.go @@ -0,0 +1,99 @@ +package server + +import ( + "net/http" + adminctl "networkDev/controllers/admin" +) + +// RegisterAdminRoutes 注册管理员后台相关路由 +// - /admin/login: 支持GET渲染登录页、POST提交登录 +// - /admin/logout: 管理员退出登录 +// - /admin/dashboard: 管理员仪表盘(示例) +// - /admin/fragment/*: 布局内动态片段加载 +// - /admin/api/settings*: 设置接口(查询/更新) +func RegisterAdminRoutes(mux *http.ServeMux) { + // /admin 根与前缀统一入口:根据是否登录跳转 + mux.HandleFunc("/admin", adminctl.AdminIndexHandler) + mux.HandleFunc("/admin/", adminctl.AdminIndexHandler) + + // Admin 认证相关路由 + mux.HandleFunc("/admin/login", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + adminctl.LoginPageHandler(w, r) + return + } + if r.Method == http.MethodPost { + adminctl.LoginHandler(w, r) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + }) + + // 退出登录(无需拦截,幂等清理) + mux.HandleFunc("/admin/logout", adminctl.LogoutHandler) + + // 后台布局页(需要管理员认证) + mux.HandleFunc("/admin/layout", adminctl.AdminAuthRequired(adminctl.AdminLayoutHandler)) + + // 片段路由(需要管理员认证) + mux.HandleFunc("/admin/dashboard", adminctl.AdminAuthRequired(adminctl.DashboardFragmentHandler)) + mux.HandleFunc("/admin/user", adminctl.AdminAuthRequired(adminctl.UserFragmentHandler)) + mux.HandleFunc("/admin/settings", adminctl.AdminAuthRequired(adminctl.SettingsFragmentHandler)) + mux.HandleFunc("/admin/apps", adminctl.AdminAuthRequired(adminctl.AppsFragmentHandler)) + mux.HandleFunc("/admin/logintypes", adminctl.AdminAuthRequired(adminctl.LoginTypesFragmentHandler)) + mux.HandleFunc("/admin/cardtypes", adminctl.AdminAuthRequired(adminctl.CardTypesFragmentHandler)) + mux.HandleFunc("/admin/cards", adminctl.AdminAuthRequired(adminctl.CardsFragmentHandler)) + + // 个人资料API + mux.HandleFunc("/admin/api/user/profile", adminctl.AdminAuthRequired(adminctl.UserProfileQueryHandler)) + mux.HandleFunc("/admin/api/user/profile/update", adminctl.AdminAuthRequired(adminctl.UserProfileUpdateHandler)) + mux.HandleFunc("/admin/api/user/password", adminctl.AdminAuthRequired(adminctl.UserPasswordUpdateHandler)) + // 设置API(需要管理员认证) + mux.HandleFunc("/admin/api/settings", adminctl.AdminAuthRequired(adminctl.SettingsQueryHandler)) + mux.HandleFunc("/admin/api/settings/update", adminctl.AdminAuthRequired(adminctl.SettingsUpdateHandler)) + + // 供前端下拉选择卡密类型 + mux.HandleFunc("/admin/api/cards/types", adminctl.AdminAuthRequired(adminctl.GetCardTypesHandler)) + // 应用管理API + mux.HandleFunc("/admin/api/apps/list", adminctl.AdminAuthRequired(adminctl.AppsListHandler)) + mux.HandleFunc("/admin/api/apps/create", adminctl.AdminAuthRequired(adminctl.AppCreateHandler)) + mux.HandleFunc("/admin/api/apps/update", adminctl.AdminAuthRequired(adminctl.AppUpdateHandler)) + mux.HandleFunc("/admin/api/apps/delete", adminctl.AdminAuthRequired(adminctl.AppDeleteHandler)) + mux.HandleFunc("/admin/api/apps/batch_delete", adminctl.AdminAuthRequired(adminctl.AppsBatchDeleteHandler)) + mux.HandleFunc("/admin/api/apps/batch_update_status", adminctl.AdminAuthRequired(adminctl.AppsBatchUpdateStatusHandler)) + // 登录方式管理API + mux.HandleFunc("/admin/api/login_types/list", adminctl.AdminAuthRequired(adminctl.LoginTypesListHandler)) + mux.HandleFunc("/admin/api/login_types/create", adminctl.AdminAuthRequired(adminctl.LoginTypeCreateHandler)) + mux.HandleFunc("/admin/api/login_types/update", adminctl.AdminAuthRequired(adminctl.LoginTypeUpdateHandler)) + mux.HandleFunc("/admin/api/login_types/delete", adminctl.AdminAuthRequired(adminctl.LoginTypeDeleteHandler)) + mux.HandleFunc("/admin/api/login_types/batch_delete", adminctl.AdminAuthRequired(adminctl.LoginTypesBatchDeleteHandler)) + mux.HandleFunc("/admin/api/login_types/batch_enable", adminctl.AdminAuthRequired(adminctl.LoginTypesBatchEnableHandler)) + mux.HandleFunc("/admin/api/login_types/batch_disable", adminctl.AdminAuthRequired(adminctl.LoginTypesBatchDisableHandler)) + // 卡密类型管理API + mux.HandleFunc("/admin/api/card_types/list", adminctl.AdminAuthRequired(adminctl.CardTypesListHandler)) + mux.HandleFunc("/admin/api/card_types/create", adminctl.AdminAuthRequired(adminctl.CardTypeCreateHandler)) + mux.HandleFunc("/admin/api/card_types/update", adminctl.AdminAuthRequired(adminctl.CardTypeUpdateHandler)) + mux.HandleFunc("/admin/api/card_types/delete", adminctl.AdminAuthRequired(adminctl.CardTypeDeleteHandler)) + mux.HandleFunc("/admin/api/card_types/batch_delete", adminctl.AdminAuthRequired(adminctl.CardTypesBatchDeleteHandler)) + mux.HandleFunc("/admin/api/card_types/batch_enable", adminctl.AdminAuthRequired(adminctl.CardTypesBatchEnableHandler)) + mux.HandleFunc("/admin/api/card_types/batch_disable", adminctl.AdminAuthRequired(adminctl.CardTypesBatchDisableHandler)) + // 卡密管理API + mux.HandleFunc("/admin/api/cards/list", adminctl.AdminAuthRequired(adminctl.CardsListHandler)) + mux.HandleFunc("/admin/api/cards/create", adminctl.AdminAuthRequired(adminctl.CardCreateHandler)) + mux.HandleFunc("/admin/api/cards/update", adminctl.AdminAuthRequired(adminctl.CardUpdateHandler)) + mux.HandleFunc("/admin/api/cards/delete", adminctl.AdminAuthRequired(adminctl.CardDeleteHandler)) + mux.HandleFunc("/admin/api/cards/batch_delete", adminctl.AdminAuthRequired(adminctl.CardsBatchDeleteHandler)) + mux.HandleFunc("/admin/api/cards/batch_update_status", adminctl.AdminAuthRequired(adminctl.CardsBatchUpdateStatusHandler)) + // 新增:卡密导出API(CSV下载) + mux.HandleFunc("/admin/api/cards/export", adminctl.AdminAuthRequired(adminctl.CardsExportHandler)) + // 新增:导出选中卡密API + mux.HandleFunc("/admin/api/cards/export_selected", adminctl.AdminAuthRequired(adminctl.CardsExportSelectedHandler)) + + // 系统信息API(用于仪表盘定时刷新) + mux.HandleFunc("/admin/api/system/info", adminctl.AdminAuthRequired(adminctl.SystemInfoHandler)) + + // 卡密统计API(用于仪表盘统计显示) + mux.HandleFunc("/admin/api/cards/stats_overview", adminctl.AdminAuthRequired(adminctl.CardStatsOverviewHandler)) + mux.HandleFunc("/admin/api/cards/trend_30days", adminctl.AdminAuthRequired(adminctl.CardStatsTrend30DaysHandler)) + mux.HandleFunc("/admin/api/cards/stats_simple", adminctl.AdminAuthRequired(adminctl.CardStatsSimpleHandler)) +} diff --git a/server/home.go b/server/home.go new file mode 100644 index 0000000..858c7f6 --- /dev/null +++ b/server/home.go @@ -0,0 +1,13 @@ +package server + +import ( + "net/http" + "networkDev/controllers/home" +) + +// RegisterHomeRoutes 注册主页路由 +// 只包含根路径,用于主页功能 +func RegisterHomeRoutes(mux *http.ServeMux) { + // 根路径 - 主页 + mux.HandleFunc("/", home.RootHandler) +} diff --git a/server/routes.go b/server/routes.go new file mode 100644 index 0000000..1e3ffed --- /dev/null +++ b/server/routes.go @@ -0,0 +1,37 @@ +package server + +import ( + "io/fs" + "log" + "net/http" + "networkDev/web" +) + +// RegisterRoutes 聚合注册所有路由 +func RegisterRoutes(mux *http.ServeMux) { + registerStaticRoutes(mux) + RegisterHomeRoutes(mux) + RegisterAdminRoutes(mux) + +} + +// registerStaticRoutes 注册静态资源路由 +// 静态资源服务,将 /static/ 和 /assets/ 映射到嵌入的文件系统 +func registerStaticRoutes(mux *http.ServeMux) { + if fsys, err := web.GetStaticFS(); err == nil { + // 为 /static/ 路径创建子文件系统 + if staticSubFS, staticErr := fs.Sub(fsys, "static"); staticErr == nil { + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSubFS)))) + } else { + log.Printf("创建静态资源子文件系统失败: %v", staticErr) + } + // 为 /assets/ 路径创建子文件系统 + if assetsSubFS, assetsErr := fs.Sub(fsys, "assets"); assetsErr == nil { + mux.Handle("/assets/", http.StripPrefix("/assets/", http.FileServer(http.FS(assetsSubFS)))) + } else { + log.Printf("创建资产资源子文件系统失败: %v", assetsErr) + } + } else { + log.Printf("初始化静态资源文件系统失败: %v", err) + } +} diff --git a/services/query.go b/services/query.go new file mode 100644 index 0000000..09549d1 --- /dev/null +++ b/services/query.go @@ -0,0 +1,121 @@ +package services + +import ( + "context" + "fmt" + "networkDev/models" + "networkDev/utils" + "time" + + "gorm.io/gorm" +) + + + +// FindCardByCardNumber 根据卡号查找卡密 +// cardNumber: 卡号 +// db: 数据库连接 +// 返回: 卡密信息和错误 +func FindCardByCardNumber(cardNumber string, db *gorm.DB) (*models.Card, error) { + key := fmt.Sprintf("card:number:%s", cardNumber) + return utils.RedisGetOrSet(context.Background(), key, 60*time.Second, func() (*models.Card, error) { + var card models.Card + err := db.Where("card_number = ?", cardNumber).First(&card).Error + if err != nil { + return nil, err + } + return &card, nil + }) +} + +// FindCardTypeByID 根据ID查找卡密类型 +// id: 卡密类型ID +// db: 数据库连接 +// 返回: 卡密类型信息和错误 +func FindCardTypeByID(id uint, db *gorm.DB) (*models.CardType, error) { + key := fmt.Sprintf("card_type:id:%d", id) + return utils.RedisGetOrSet(context.Background(), key, 30*time.Minute, func() (*models.CardType, error) { + var cardType models.CardType + err := db.Where("id = ?", id).First(&cardType).Error + if err != nil { + return nil, err + } + return &cardType, nil + }) +} + +// FindSettingByName 根据名称查找设置 +// name: 设置名称 +// db: 数据库连接 +// 返回: 设置信息和错误 +func FindSettingByName(name string, db *gorm.DB) (*models.Settings, error) { + key := fmt.Sprintf("setting:%s", name) + return utils.RedisGetOrSet(context.Background(), key, 5*time.Minute, func() (*models.Settings, error) { + var setting models.Settings + err := db.Where("name = ?", name).First(&setting).Error + if err != nil { + return nil, err + } + return &setting, nil + }) +} + + + +// UpdateEntityByID 根据ID更新实体 +// model: 模型类型 +// id: 实体ID +// updates: 更新字段 +// db: 数据库连接 +// 返回: 错误 +func UpdateEntityByID(model interface{}, id uint, updates map[string]interface{}, db *gorm.DB) error { + return db.Model(model).Where("id = ?", id).Updates(updates).Error +} + +// BatchUpdateEntityStatus 批量更新实体状态 +// model: 模型类型 +// ids: 实体ID列表 +// status: 新状态 +// db: 数据库连接 +// 返回: 错误 +func BatchUpdateEntityStatus(model interface{}, ids []uint, status int, db *gorm.DB) error { + if len(ids) == 0 { + return nil + } + return db.Model(model).Where("id IN ?", ids).Update("status", status).Error +} + +// CountEntitiesByCondition 根据条件统计实体数量 +// model: 模型类型 +// condition: 查询条件 +// db: 数据库连接 +// args: 查询参数 +// 返回: 数量和错误 +func CountEntitiesByCondition(model interface{}, condition string, db *gorm.DB, args ...interface{}) (int64, error) { + var count int64 + err := db.Model(model).Where(condition, args...).Count(&count).Error + return count, err +} + +// FindEntitiesByCondition 根据条件查找实体 +// model: 模型类型 +// result: 结果容器 +// condition: 查询条件 +// db: 数据库连接 +// args: 查询参数 +// 返回: 错误 +func FindEntitiesByCondition(model interface{}, result interface{}, condition string, db *gorm.DB, args ...interface{}) error { + return db.Model(model).Where(condition, args...).Find(result).Error +} + +// CheckEntityExists 检查实体是否存在 +// model: 模型类型 +// condition: 查询条件 +// db: 数据库连接 +// args: 查询参数 +// 返回: 是否存在和错误 +func CheckEntityExists(model interface{}, condition string, db *gorm.DB, args ...interface{}) (bool, error) { + var count int64 + err := db.Model(model).Where(condition, args...).Count(&count).Error + return count > 0, err +} diff --git a/services/settings.go b/services/settings.go new file mode 100644 index 0000000..fae5b57 --- /dev/null +++ b/services/settings.go @@ -0,0 +1,105 @@ +package services + +import ( + "networkDev/database" + "networkDev/models" + "strconv" + "sync" + + "github.com/sirupsen/logrus" +) + +// SettingsService 设置服务 +type SettingsService struct { + mu sync.RWMutex + cache map[string]string +} + +var settingsService *SettingsService +var settingsOnce sync.Once + +// GetSettingsService 获取设置服务单例 +func GetSettingsService() *SettingsService { + settingsOnce.Do(func() { + settingsService = &SettingsService{ + cache: make(map[string]string), + } + // 初始化时加载所有设置 + settingsService.loadAllSettings() + }) + return settingsService +} + +// loadAllSettings 从数据库加载所有设置到缓存 +func (s *SettingsService) loadAllSettings() { + db, err := database.GetDB() + if err != nil { + logrus.WithError(err).Error("获取数据库连接失败") + return + } + + var settings []models.Settings + if err := db.Find(&settings).Error; err != nil { + logrus.WithError(err).Error("加载设置失败") + return + } + + s.mu.Lock() + defer s.mu.Unlock() + + for _, setting := range settings { + s.cache[setting.Name] = setting.Value + } + + logrus.WithField("count", len(settings)).Info("设置缓存加载完成") +} + +// GetString 获取字符串类型的设置值 +func (s *SettingsService) GetString(name, defaultValue string) string { + s.mu.RLock() + defer s.mu.RUnlock() + + if value, exists := s.cache[name]; exists { + return value + } + return defaultValue +} + +// GetInt 获取整数类型的设置值 +func (s *SettingsService) GetInt(name string, defaultValue int) int { + strValue := s.GetString(name, "") + if strValue == "" { + return defaultValue + } + + if intValue, err := strconv.Atoi(strValue); err == nil { + return intValue + } + return defaultValue +} + +// GetBool 获取布尔类型的设置值 +func (s *SettingsService) GetBool(name string, defaultValue bool) bool { + strValue := s.GetString(name, "") + if strValue == "" { + return defaultValue + } + + return strValue == "1" || strValue == "true" +} + +// RefreshCache 刷新设置缓存 +func (s *SettingsService) RefreshCache() { + s.loadAllSettings() +} + + +// GetSessionTimeout 获取会话超时时间(秒) +func (s *SettingsService) GetSessionTimeout() int { + return s.GetInt("session_timeout", 3600) // 默认1小时 +} + +// IsMaintenanceMode 检查系统是否关闭 +func (s *SettingsService) IsMaintenanceMode() bool { + return s.GetBool("maintenance_mode", false) +} diff --git a/utils/common.go b/utils/common.go new file mode 100644 index 0000000..02be805 --- /dev/null +++ b/utils/common.go @@ -0,0 +1,80 @@ +package utils + +import ( + "encoding/json" + "net/http" + "networkDev/web" + "strings" +) + +// JsonResponse 通用JSON响应函数 +// 将 success 转换为 code:true -> 0, false -> 1,并输出 data +func JsonResponse(w http.ResponseWriter, status int, success bool, message string, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + // 将success转换为code格式:true -> 0, false -> 1 + code := 1 + if success { + code = 0 + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "code": code, + "msg": message, + "data": data, + }) +} + +// RenderTemplate 通用模板渲染函数 +// templateName: 模板文件名 +// data: 模板数据 +// w: HTTP响应写入器 +func RenderTemplate(w http.ResponseWriter, templateName string, data map[string]interface{}) error { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + + tmpl, err := web.ParseTemplates() + if err != nil { + http.Error(w, "模板解析失败", http.StatusInternalServerError) + return err + } + + if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil { + http.Error(w, "模板渲染失败", http.StatusInternalServerError) + return err + } + return nil +} + +// GetDefaultTemplateData 获取默认模板数据 +// 返回包含系统基础信息的数据映射 +func GetDefaultTemplateData() map[string]interface{} { + return map[string]interface{}{ + "SystemName": "网络验证系统", + "FooterText": "© 2025 凌动技术 保留所有权利", + } +} + +// GetClientIP 获取客户端IP地址 +// 优先从 X-Forwarded-For 和 X-Real-IP 头部获取,否则使用 RemoteAddr +func GetClientIP(r *http.Request) string { + // 检查 X-Forwarded-For 头部 + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // X-Forwarded-For 可能包含多个IP,取第一个 + if idx := strings.Index(xff, ","); idx != -1 { + return strings.TrimSpace(xff[:idx]) + } + return strings.TrimSpace(xff) + } + + // 检查 X-Real-IP 头部 + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return strings.TrimSpace(xri) + } + + // 使用 RemoteAddr + if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { + return r.RemoteAddr[:idx] + } + return r.RemoteAddr +} diff --git a/utils/crypto.go b/utils/crypto.go new file mode 100644 index 0000000..16daafd --- /dev/null +++ b/utils/crypto.go @@ -0,0 +1,323 @@ +package utils + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "io" + "sync" + + "github.com/spf13/viper" + "golang.org/x/crypto/bcrypt" +) + +// CryptoManager 加密管理器,提供高性能的加密解密服务 +type CryptoManager struct { + key []byte + gcm cipher.AEAD + mutex sync.RWMutex + inited bool +} + +// 全局加密管理器实例 +var cryptoManager = &CryptoManager{} + +// initCrypto 初始化加密管理器 +// 缓存密钥和GCM实例,避免重复创建 +func (cm *CryptoManager) initCrypto() error { + cm.mutex.Lock() + defer cm.mutex.Unlock() + + if cm.inited { + return nil + } + + // 从配置中获取密钥 + secret := viper.GetString("encryption_key") + if secret == "" { + secret = "default-secret" + } + + // 生成AES密钥 + sum := sha256.Sum256([]byte(secret)) + cm.key = sum[:] + + // 创建AES cipher + block, err := aes.NewCipher(cm.key) + if err != nil { + return err + } + + // 创建GCM + gcm, err := cipher.NewGCM(block) + if err != nil { + return err + } + + cm.gcm = gcm + cm.inited = true + return nil +} + +// EncryptString 字符串加密(AES-256-GCM) +// 使用缓存的密钥和GCM实例,提高性能 +func EncryptString(plain string) (string, error) { + if err := cryptoManager.initCrypto(); err != nil { + return "", err + } + + cryptoManager.mutex.RLock() + gcm := cryptoManager.gcm + cryptoManager.mutex.RUnlock() + + // 生成随机nonce + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + // 加密 + ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil) + buf := append(nonce, ciphertext...) + return base64.StdEncoding.EncodeToString(buf), nil +} + +// DecryptString 字符串解密(AES-256-GCM) +// 使用缓存的密钥和GCM实例,提高性能 +func DecryptString(enc string) (string, error) { + if err := cryptoManager.initCrypto(); err != nil { + return "", err + } + + cryptoManager.mutex.RLock() + gcm := cryptoManager.gcm + cryptoManager.mutex.RUnlock() + + // 解码base64 + data, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", err + } + + // 检查数据长度 + if len(data) < gcm.NonceSize() { + return "", errors.New("ciphertext too short") + } + + // 分离nonce和密文 + nonce := data[:gcm.NonceSize()] + ciphertext := data[gcm.NonceSize():] + + // 解密 + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + return string(plain), nil +} + +// ResetCrypto 重置加密管理器(用于配置更新后重新初始化) +func ResetCrypto() { + cryptoManager.mutex.Lock() + defer cryptoManager.mutex.Unlock() + cryptoManager.inited = false + cryptoManager.key = nil + cryptoManager.gcm = nil +} + +// EncryptStringBatch 批量加密字符串 +// 减少锁竞争,提高批量处理性能 +func EncryptStringBatch(plains []string) ([]string, error) { + if err := cryptoManager.initCrypto(); err != nil { + return nil, err + } + + cryptoManager.mutex.RLock() + gcm := cryptoManager.gcm + cryptoManager.mutex.RUnlock() + + results := make([]string, len(plains)) + for i, plain := range plains { + // 生成随机nonce + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, err + } + + // 加密 + ciphertext := gcm.Seal(nil, nonce, []byte(plain), nil) + buf := append(nonce, ciphertext...) + results[i] = base64.StdEncoding.EncodeToString(buf) + } + return results, nil +} + +// DecryptStringBatch 批量解密字符串 +// 减少锁竞争,提高批量处理性能 +func DecryptStringBatch(encs []string) ([]string, error) { + if err := cryptoManager.initCrypto(); err != nil { + return nil, err + } + + cryptoManager.mutex.RLock() + gcm := cryptoManager.gcm + cryptoManager.mutex.RUnlock() + + results := make([]string, len(encs)) + for i, enc := range encs { + // 解码base64 + data, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return nil, err + } + + // 检查数据长度 + if len(data) < gcm.NonceSize() { + return nil, errors.New("ciphertext too short") + } + + // 分离nonce和密文 + nonce := data[:gcm.NonceSize()] + ciphertext := data[gcm.NonceSize():] + + // 解密 + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return nil, err + } + + results[i] = string(plain) + } + return results, nil +} + +// GenerateRandomSalt 生成随机密码盐值 +// 生成32字节(64个十六进制字符)的随机盐值,用于加密 +// 返回: 十六进制格式的盐值字符串和错误信息 +func GenerateRandomSalt() (string, error) { + length := 32 // 固定32字节 + + // 生成随机字节 + bytes := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, bytes); err != nil { + return "", err + } + + // 转换为十六进制字符串 + return fmt.Sprintf("%x", bytes), nil +} + +// EncryptStringWithSalt 使用盐值进行字符串加密(AES-256-GCM) +// 将明文和盐值组合后进行加密,增强安全性 +// plain: 待加密的明文字符串 +// salt: 加密盐值 +// 返回: base64编码的密文字符串和错误信息 +func EncryptStringWithSalt(plain, salt string) (string, error) { + if err := cryptoManager.initCrypto(); err != nil { + return "", err + } + + cryptoManager.mutex.RLock() + gcm := cryptoManager.gcm + cryptoManager.mutex.RUnlock() + + // 将明文和盐值组合 + combined := plain + salt + + // 生成随机nonce + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", err + } + + // 加密 + ciphertext := gcm.Seal(nil, nonce, []byte(combined), nil) + buf := append(nonce, ciphertext...) + return base64.StdEncoding.EncodeToString(buf), nil +} + +// DecryptStringWithSalt 使用盐值进行字符串解密(AES-256-GCM) +// 解密密文并移除盐值,返回原始明文 +// enc: base64编码的密文字符串 +// salt: 解密盐值 +// 返回: 解密后的明文字符串和错误信息 +func DecryptStringWithSalt(enc, salt string) (string, error) { + if err := cryptoManager.initCrypto(); err != nil { + return "", err + } + + cryptoManager.mutex.RLock() + gcm := cryptoManager.gcm + cryptoManager.mutex.RUnlock() + + // 解码base64 + data, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return "", err + } + + // 检查数据长度 + if len(data) < gcm.NonceSize() { + return "", errors.New("ciphertext too short") + } + + // 分离nonce和密文 + nonce := data[:gcm.NonceSize()] + ciphertext := data[gcm.NonceSize():] + + // 解密 + plain, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", err + } + + // 移除盐值,返回原始明文 + combined := string(plain) + if len(combined) < len(salt) { + return "", errors.New("decrypted data too short") + } + + // 验证盐值是否匹配 + if combined[len(combined)-len(salt):] != salt { + return "", errors.New("salt mismatch") + } + + return combined[:len(combined)-len(salt)], nil +} + +// HashPasswordWithSalt 使用盐值对密码进行哈希处理 +// 将密码和盐值组合后使用bcrypt进行哈希 +// password: 原始密码 +// salt: 密码盐值 +// 返回: bcrypt哈希值和错误信息 +func HashPasswordWithSalt(password, salt string) (string, error) { + // 将密码和盐值组合 + combined := password + salt + + // 使用bcrypt进行哈希(成本因子12,平衡安全性和性能) + hashed, err := bcrypt.GenerateFromPassword([]byte(combined), 12) + if err != nil { + return "", err + } + + return string(hashed), nil +} + +// VerifyPasswordWithSalt 验证密码和盐值的组合是否匹配哈希值 +// password: 原始密码 +// salt: 密码盐值 +// hashedPassword: 存储的哈希密码 +// 返回: 验证结果(true表示匹配) +func VerifyPasswordWithSalt(password, salt, hashedPassword string) bool { + // 将密码和盐值组合 + combined := password + salt + + // 使用bcrypt验证 + err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(combined)) + return err == nil +} diff --git a/utils/database.go b/utils/database.go new file mode 100644 index 0000000..9ecd813 --- /dev/null +++ b/utils/database.go @@ -0,0 +1,327 @@ +package utils + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/redis/go-redis/v9" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "gorm.io/gorm" +) + +// DatabaseConfig 数据库连接池配置结构体 +// 用于配置数据库连接池的各项参数,包括连接池大小、生命周期管理和健康检查等 +type DatabaseConfig struct { + // 连接池配置 + MaxIdleConns int `mapstructure:"max_idle_conns"` // 最大空闲连接数 + MaxOpenConns int `mapstructure:"max_open_conns"` // 最大打开连接数 + ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"` // 连接最大生存时间 + ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"` // 连接最大空闲时间 + + // 健康检查配置 + PingTimeout time.Duration `mapstructure:"ping_timeout"` // Ping超时时间 + HealthCheckInterval time.Duration `mapstructure:"health_check_interval"` // 健康检查间隔 +} + +// GetDefaultDatabaseConfig 获取默认数据库配置 +// 返回一个包含合理默认值的数据库配置实例 +func GetDefaultDatabaseConfig() *DatabaseConfig { + return &DatabaseConfig{ + MaxIdleConns: 10, // 默认最大空闲连接数 + MaxOpenConns: 100, // 默认最大打开连接数 + ConnMaxLifetime: 30 * time.Minute, // 连接最大生存时间30分钟 + ConnMaxIdleTime: 10 * time.Minute, // 连接最大空闲时间10分钟 + PingTimeout: 5 * time.Second, // Ping超时5秒 + HealthCheckInterval: 30 * time.Second, // 健康检查间隔30秒 + } +} + +// LoadDatabaseConfig 从配置文件加载数据库配置 +// 使用指定的前缀从viper配置中读取数据库配置,如果配置项不存在则使用默认值 +func LoadDatabaseConfig(prefix string) *DatabaseConfig { + config := GetDefaultDatabaseConfig() + + // 从viper读取配置,如果不存在则使用默认值 + if viper.IsSet(prefix + ".max_idle_conns") { + config.MaxIdleConns = viper.GetInt(prefix + ".max_idle_conns") + } + if viper.IsSet(prefix + ".max_open_conns") { + config.MaxOpenConns = viper.GetInt(prefix + ".max_open_conns") + } + if viper.IsSet(prefix + ".conn_max_lifetime") { + config.ConnMaxLifetime = viper.GetDuration(prefix + ".conn_max_lifetime") + } + if viper.IsSet(prefix + ".conn_max_idle_time") { + config.ConnMaxIdleTime = viper.GetDuration(prefix + ".conn_max_idle_time") + } + if viper.IsSet(prefix + ".ping_timeout") { + config.PingTimeout = viper.GetDuration(prefix + ".ping_timeout") + } + if viper.IsSet(prefix + ".health_check_interval") { + config.HealthCheckInterval = viper.GetDuration(prefix + ".health_check_interval") + } + + return config +} + +// ConfigureConnectionPool 配置数据库连接池 +// 根据提供的配置参数设置GORM数据库的连接池属性 +func ConfigureConnectionPool(db *gorm.DB, config *DatabaseConfig) error { + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("获取底层数据库连接失败: %w", err) + } + + // 设置连接池参数 + sqlDB.SetMaxIdleConns(config.MaxIdleConns) + sqlDB.SetMaxOpenConns(config.MaxOpenConns) + sqlDB.SetConnMaxLifetime(config.ConnMaxLifetime) + sqlDB.SetConnMaxIdleTime(config.ConnMaxIdleTime) + + // LogInfo("数据库连接池配置完成", map[string]interface{}{ + // "max_idle_conns": config.MaxIdleConns, + // "max_open_conns": config.MaxOpenConns, + // "conn_max_lifetime": config.ConnMaxLifetime, + // "conn_max_idle_time": config.ConnMaxIdleTime, + // }) + + return nil +} + +// PingDatabase 检查数据库连接健康状态 +// 使用指定的超时时间ping数据库以验证连接是否正常 +func PingDatabase(db *gorm.DB, timeout time.Duration) error { + sqlDB, err := db.DB() + if err != nil { + return fmt.Errorf("获取底层数据库连接失败: %w", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + return sqlDB.PingContext(ctx) +} + +// GetConnectionStats 获取数据库连接池统计信息 +// 返回当前数据库连接池的详细统计数据,包括连接数、等待时间等 +func GetConnectionStats(db *gorm.DB) (*sql.DBStats, error) { + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("获取底层数据库连接失败: %w", err) + } + + stats := sqlDB.Stats() + return &stats, nil +} + +// LogConnectionStats 记录数据库连接池统计信息 +// 获取并记录数据库连接池的统计信息到日志中,用于监控和调试 +func LogConnectionStats(db *gorm.DB) { + stats, err := GetConnectionStats(db) + if err != nil { + LogError("获取数据库连接池统计信息失败", err, nil) + return + } + + LogInfo("数据库连接池统计", map[string]interface{}{ + "open_connections": stats.OpenConnections, + "in_use": stats.InUse, + "idle": stats.Idle, + "wait_count": stats.WaitCount, + "wait_duration": stats.WaitDuration, + "max_idle_closed": stats.MaxIdleClosed, + "max_idle_time_closed": stats.MaxIdleTimeClosed, + "max_lifetime_closed": stats.MaxLifetimeClosed, + }) +} + +// StartHealthCheck 启动数据库健康检查 +// 启动一个后台goroutine定期检查数据库连接健康状态 +// 只在健康检查失败时输出错误日志,正常情况下不输出日志 +func StartHealthCheck(db *gorm.DB, config *DatabaseConfig) { + go func() { + ticker := time.NewTicker(config.HealthCheckInterval) + defer ticker.Stop() + + for range ticker.C { + if err := PingDatabase(db, config.PingTimeout); err != nil { + // 只在健康检查失败时输出错误日志 + LogError("数据库健康检查失败", err, map[string]interface{}{ + "ping_timeout": config.PingTimeout, + }) + } + + // 记录连接池统计信息(仅在调试模式下) + if logrus.GetLevel() == logrus.DebugLevel { + LogConnectionStats(db) + } + } + }() + + // LogInfo("数据库健康检查已启动", map[string]interface{}{ + // "check_interval": config.HealthCheckInterval, + // "ping_timeout": config.PingTimeout, + // }) +} + +// ValidateDatabaseConfig 验证数据库配置参数 +// 检查数据库配置参数的有效性,确保所有参数都在合理范围内 +func ValidateDatabaseConfig(config *DatabaseConfig) error { + if config.MaxIdleConns < 0 { + return fmt.Errorf("最大空闲连接数不能为负数: %d", config.MaxIdleConns) + } + if config.MaxOpenConns < 0 { + return fmt.Errorf("最大打开连接数不能为负数: %d", config.MaxOpenConns) + } + if config.MaxIdleConns > config.MaxOpenConns && config.MaxOpenConns > 0 { + return fmt.Errorf("最大空闲连接数(%d)不能大于最大打开连接数(%d)", config.MaxIdleConns, config.MaxOpenConns) + } + if config.ConnMaxLifetime < 0 { + return fmt.Errorf("连接最大生存时间不能为负数: %v", config.ConnMaxLifetime) + } + if config.ConnMaxIdleTime < 0 { + return fmt.Errorf("连接最大空闲时间不能为负数: %v", config.ConnMaxIdleTime) + } + if config.PingTimeout <= 0 { + return fmt.Errorf("Ping超时时间必须大于0: %v", config.PingTimeout) + } + if config.HealthCheckInterval <= 0 { + return fmt.Errorf("健康检查间隔必须大于0: %v", config.HealthCheckInterval) + } + + return nil +} + +var ( + // redisClient 全局Redis客户端 + redisClient *redis.Client + // redisOnce 确保只初始化一次 + redisOnce sync.Once + // redisAvailable 标记Redis是否可用 + redisAvailable bool +) + +// InitRedis 初始化Redis客户端(仅在配置存在时尝试连接) +// - 从 viper 读取 security.redis.* 配置 +// - 如果连接失败,则标记为不可用,不影响主流程 +func InitRedis() { + redisOnce.Do(func() { + host := viper.GetString("redis.host") + port := viper.GetInt("redis.port") + if host == "" || port == 0 { + logrus.Info("未配置Redis或配置不完整,跳过初始化") + redisAvailable = false + return + } + addr := fmt.Sprintf("%s:%d", host, port) + redisClient = redis.NewClient(&redis.Options{ + Addr: addr, + Password: viper.GetString("redis.password"), + DB: viper.GetInt("redis.db"), + }) + // 健康检查 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := redisClient.Ping(ctx).Err(); err != nil { + logrus.WithError(err).Warn("Redis初始化失败,标记为不可用") + redisAvailable = false + return + } + redisAvailable = true + logrus.WithField("addr", addr).Info("Redis 连接已建立") + }) +} + +// GetRedis 获取全局Redis客户端,可能返回nil(当不可用时) +func GetRedis() *redis.Client { + if redisClient == nil { + InitRedis() + } + if !redisAvailable { + return nil + } + return redisClient +} + +// IsRedisAvailable 判断Redis是否可用 +func IsRedisAvailable() bool { + if redisClient == nil { + InitRedis() + } + return redisAvailable +} + +// RedisGetOrSet 通用Redis缓存获取或设置函数(基于JSON序列化) +// - ctx: 上下文 +// - key: 缓存键 +// - ttl: 过期时间 +// - loader: 当缓存不存在时的加载函数(一般执行数据库查询) +// 返回:目标对象指针和错误 +func RedisGetOrSet[T any](ctx context.Context, key string, ttl time.Duration, loader func() (*T, error)) (*T, error) { + // 如果Redis不可用则直接调用加载函数 + if !IsRedisAvailable() { + return loader() + } + client := GetRedis() + if client == nil { + return loader() + } + + // 先尝试从缓存读取 + data, err := client.Get(ctx, key).Bytes() + if err == nil { + var out T + if uerr := json.Unmarshal(data, &out); uerr == nil { + return &out, nil + } + // 反序列化失败时视为未命中,继续加载 + logrus.WithError(err).WithField("key", key).Warn("Redis缓存反序列化失败,回退到loader") + } else if err != redis.Nil { + // 非空且非不存在的错误,记录告警但不中断 + logrus.WithError(err).WithField("key", key).Warn("读取Redis缓存失败") + } + + // 加载数据 + val, lerr := loader() + if lerr != nil { + return nil, lerr + } + if val == nil { + return nil, nil + } + + // 写回缓存(错误不影响主流程) + if b, merr := json.Marshal(val); merr == nil { + if serr := client.Set(ctx, key, b, ttl).Err(); serr != nil { + logrus.WithError(serr).WithField("key", key).Warn("写入Redis缓存失败") + } + } + return val, nil +} + +// RedisDel 删除一个或多个Redis键(当Redis不可用时静默返回) +// - ctx: 上下文 +// - keys: 需要删除的键名 +func RedisDel(ctx context.Context, keys ...string) error { + // 如果Redis不可用则直接返回 + if !IsRedisAvailable() { + return nil + } + client := GetRedis() + if client == nil { + return nil + } + if len(keys) == 0 { + return nil + } + if _, err := client.Del(ctx, keys...).Result(); err != nil { + logrus.WithError(err).WithField("keys", keys).Warn("删除Redis键失败") + return err + } + return nil +} diff --git a/utils/errors.go b/utils/errors.go new file mode 100644 index 0000000..b4faff1 --- /dev/null +++ b/utils/errors.go @@ -0,0 +1,269 @@ +package utils + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "runtime" + "time" + + "gorm.io/gorm" +) + +// ErrorResponse 统一的错误响应结构 +// 用于标准化API错误响应格式 +type ErrorResponse struct { + Success bool `json:"success"` // 请求是否成功,错误响应时固定为false + Message string `json:"message"` // 错误消息描述 + ErrorCode string `json:"error_code,omitempty"` // 错误代码,用于客户端识别错误类型 + Data interface{} `json:"data"` // 附加数据,可为空 + Timestamp int64 `json:"timestamp"` // 响应时间戳 +} + +// SuccessResponse 统一的成功响应结构 +// 用于标准化API成功响应格式 +type SuccessResponse struct { + Success bool `json:"success"` // 请求是否成功,成功响应时固定为true + Message string `json:"message"` // 成功消息描述 + Data interface{} `json:"data"` // 响应数据 + Timestamp int64 `json:"timestamp"` // 响应时间戳 +} + +// ErrorCode 错误代码常量 +// 定义标准化的错误代码,用于客户端识别和处理不同类型的错误 +const ( + ErrCodeInvalidRequest = "INVALID_REQUEST" // 无效请求,通常是请求参数格式错误 + ErrCodeUnauthorized = "UNAUTHORIZED" // 未授权,需要登录或token无效 + ErrCodeForbidden = "FORBIDDEN" // 禁止访问,权限不足 + ErrCodeNotFound = "NOT_FOUND" // 资源不存在 + ErrCodeConflict = "CONFLICT" // 资源冲突,如重复创建 + ErrCodeInternalError = "INTERNAL_ERROR" // 服务器内部错误 + ErrCodeDatabaseError = "DATABASE_ERROR" // 数据库操作错误 + ErrCodeValidationError = "VALIDATION_ERROR" // 数据验证错误 + ErrCodeTokenExpired = "TOKEN_EXPIRED" // 令牌已过期 + ErrCodeInsufficientData = "INSUFFICIENT_DATA" // 数据不足,缺少必要信息 +) + +// LogLevel 日志级别 +// 定义不同的日志记录级别 +type LogLevel int + +const ( + LogLevelInfo LogLevel = iota // 信息级别,记录一般信息 + LogLevelWarn // 警告级别,记录潜在问题 + LogLevelError // 错误级别,记录错误信息 + LogLevelDebug // 调试级别,记录调试信息 +) + +// LogEntry 日志条目结构 +// 包含完整的日志信息,用于结构化日志记录 +type LogEntry struct { + Level LogLevel `json:"level"` // 日志级别 + Message string `json:"message"` // 日志消息 + Error string `json:"error,omitempty"` // 错误信息,仅在错误日志中存在 + Context interface{} `json:"context,omitempty"` // 上下文信息,额外的结构化数据 + Timestamp time.Time `json:"timestamp"` // 日志时间戳 + File string `json:"file"` // 源文件路径 + Line int `json:"line"` // 源文件行号 +} + +// WriteJSONResponse 写入JSON响应 +// w: HTTP响应写入器 +// statusCode: HTTP状态码 +// response: 响应数据 +func WriteJSONResponse(w http.ResponseWriter, statusCode int, response interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + + if err := json.NewEncoder(w).Encode(response); err != nil { + LogError("Failed to encode JSON response", err, nil) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} + +// WriteErrorResponse 写入错误响应 +// w: HTTP响应写入器 +// statusCode: HTTP状态码 +// message: 错误消息 +// errorCode: 错误代码 +// data: 附加数据 +func WriteErrorResponse(w http.ResponseWriter, statusCode int, message, errorCode string, data interface{}) { + response := ErrorResponse{ + Success: false, + Message: message, + ErrorCode: errorCode, + Data: data, + Timestamp: time.Now().Unix(), + } + + WriteJSONResponse(w, statusCode, response) +} + +// WriteSuccessResponse 写入成功响应 +// w: HTTP响应写入器 +// statusCode: HTTP状态码 +// message: 成功消息 +// data: 响应数据 +func WriteSuccessResponse(w http.ResponseWriter, statusCode int, message string, data interface{}) { + response := SuccessResponse{ + Success: true, + Message: message, + Data: data, + Timestamp: time.Now().Unix(), + } + + WriteJSONResponse(w, statusCode, response) +} + +// HandleDatabaseError 处理数据库错误 +// w: HTTP响应写入器 +// err: 数据库错误 +// operation: 操作描述 +func HandleDatabaseError(w http.ResponseWriter, err error, operation string) { + if err == gorm.ErrRecordNotFound { + LogWarn(fmt.Sprintf("Record not found during %s", operation), map[string]interface{}{ + "operation": operation, + "error": err.Error(), + }) + WriteErrorResponse(w, http.StatusNotFound, "记录不存在", ErrCodeNotFound, nil) + return + } + + LogError(fmt.Sprintf("Database error during %s", operation), err, map[string]interface{}{ + "operation": operation, + }) + WriteErrorResponse(w, http.StatusInternalServerError, "数据库操作失败", ErrCodeDatabaseError, nil) +} + +// HandleValidationError 处理验证错误 +// w: HTTP响应写入器 +// message: 验证错误消息 +// details: 验证错误详情 +func HandleValidationError(w http.ResponseWriter, message string, details interface{}) { + LogWarn("Validation error: "+message, map[string]interface{}{ + "details": details, + }) + WriteErrorResponse(w, http.StatusBadRequest, message, ErrCodeValidationError, details) +} + +// HandleUnauthorizedError 处理未授权错误 +// w: HTTP响应写入器 +// message: 错误消息 +func HandleUnauthorizedError(w http.ResponseWriter, message string) { + LogWarn("Unauthorized access: "+message, nil) + WriteErrorResponse(w, http.StatusUnauthorized, message, ErrCodeUnauthorized, nil) +} + +// HandleInternalError 处理内部错误 +// w: HTTP响应写入器 +// err: 错误 +// operation: 操作描述 +func HandleInternalError(w http.ResponseWriter, err error, operation string) { + LogError(fmt.Sprintf("Internal error during %s", operation), err, map[string]interface{}{ + "operation": operation, + }) + WriteErrorResponse(w, http.StatusInternalServerError, "服务器内部错误", ErrCodeInternalError, nil) +} + +// LogInfo 记录信息日志 +// message: 日志消息 +// context: 上下文信息 +func LogInfo(message string, context interface{}) { + logEntry := createLogEntry(LogLevelInfo, message, nil, context) + printLog(logEntry) +} + +// LogWarn 记录警告日志 +// message: 日志消息 +// context: 上下文信息 +func LogWarn(message string, context interface{}) { + logEntry := createLogEntry(LogLevelWarn, message, nil, context) + printLog(logEntry) +} + +// LogError 记录错误日志 +// message: 日志消息 +// err: 错误对象 +// context: 上下文信息 +func LogError(message string, err error, context interface{}) { + errorStr := "" + if err != nil { + errorStr = err.Error() + } + logEntry := createLogEntry(LogLevelError, message, &errorStr, context) + printLog(logEntry) +} + +// LogDebug 记录调试日志 +// message: 日志消息 +// context: 上下文信息 +func LogDebug(message string, context interface{}) { + logEntry := createLogEntry(LogLevelDebug, message, nil, context) + printLog(logEntry) +} + +// createLogEntry 创建日志条目 +// level: 日志级别 +// message: 日志消息 +// errorStr: 错误字符串 +// context: 上下文信息 +// 返回: 日志条目 +func createLogEntry(level LogLevel, message string, errorStr *string, context interface{}) LogEntry { + _, file, line, _ := runtime.Caller(2) + + entry := LogEntry{ + Level: level, + Message: message, + Context: context, + Timestamp: time.Now(), + File: file, + Line: line, + } + + if errorStr != nil { + entry.Error = *errorStr + } + + return entry +} + +// printLog 打印日志 +// entry: 日志条目 +func printLog(entry LogEntry) { + levelStr := getLevelString(entry.Level) + timestamp := entry.Timestamp.Format("2006-01-02 15:04:05") + + logMessage := fmt.Sprintf("[%s] %s %s", levelStr, timestamp, entry.Message) + + if entry.Error != "" { + logMessage += fmt.Sprintf(" | Error: %s", entry.Error) + } + + if entry.Context != nil { + contextJSON, _ := json.Marshal(entry.Context) + logMessage += fmt.Sprintf(" | Context: %s", string(contextJSON)) + } + + logMessage += fmt.Sprintf(" | %s:%d", entry.File, entry.Line) + + log.Println(logMessage) +} + +// getLevelString 获取日志级别字符串 +// level: 日志级别 +// 返回: 级别字符串 +func getLevelString(level LogLevel) string { + switch level { + case LogLevelInfo: + return "INFO" + case LogLevelWarn: + return "WARN" + case LogLevelError: + return "ERROR" + case LogLevelDebug: + return "DEBUG" + default: + return "UNKNOWN" + } +} \ No newline at end of file diff --git a/utils/logger/http.go b/utils/logger/http.go new file mode 100644 index 0000000..0281171 --- /dev/null +++ b/utils/logger/http.go @@ -0,0 +1,59 @@ +package logger + +import ( + "fmt" + "os" + "time" +) + +// LogRequest 记录HTTP请求日志 - 使用标准Apache Common Log Format +// 格式: IP - - [timestamp] "METHOD path HTTP/1.1" status_code response_size +// method: HTTP请求方法 +// path: 请求路径 +// clientIP: 客户端IP地址 +// statusCode: HTTP状态码 +// duration: 请求处理时长 +func (l *Logger) LogRequest(method, path, clientIP string, statusCode int, duration time.Duration) { + l.LogRequestWithHeaders(method, path, clientIP, statusCode, duration, "-", "-") +} + +// LogRequestWithHeaders 记录HTTP请求日志 - 使用修改的Apache Log Format(移除Referer字段) +// 直接输出标准格式,不通过logrus格式化器 +// method: HTTP请求方法 +// path: 请求路径 +// clientIP: 客户端IP地址 +// statusCode: HTTP状态码 +// duration: 请求处理时长 +// referer: 引用页面(已废弃,保留参数兼容性) +// userAgent: 用户代理字符串 +func (l *Logger) LogRequestWithHeaders(method, path, clientIP string, statusCode int, duration time.Duration, referer, userAgent string) { + // 格式化时间戳为Apache标准格式 + timestamp := time.Now().Format("02/Jan/2006:15:04:05 -0700") + + // 处理空值 + if userAgent == "" { + userAgent = "-" + } + + // 构建修改的HTTP Log格式(完全移除Referer字段) + logLine := fmt.Sprintf(`%s - - [%s] "%s %s HTTP/1.1" %d - "%s" %dms`, + clientIP, + timestamp, + method, + path, + statusCode, + userAgent, + duration.Milliseconds(), + ) + + // 直接输出到标准输出和日志文件,不使用logrus格式化 + l.writeHTTPLog(logLine) +} + +// writeHTTPLog 直接输出HTTP日志到标准输出 +// 避免Logrus的任何格式化和转义,保持Apache日志格式的原始性 +// logLine: 格式化后的日志行 +func (l *Logger) writeHTTPLog(logLine string) { + // 直接输出到标准输出,避免Logrus的转义处理 + fmt.Fprintln(os.Stdout, logLine) +} diff --git a/utils/logger/logger.go b/utils/logger/logger.go new file mode 100644 index 0000000..3297dfa --- /dev/null +++ b/utils/logger/logger.go @@ -0,0 +1,112 @@ +package logger + +import ( + log "github.com/sirupsen/logrus" +) + +// Logger 日志工具结构体 +// 封装logrus.Logger,提供统一的日志接口 +type Logger struct { + *log.Logger // 嵌入logrus.Logger,继承其所有方法 +} + +// NewLogger 创建新的日志实例,使用全局logrus配置 +// 返回: 新的Logger实例 +func NewLogger() *Logger { + // 使用全局logrus实例而不是创建新实例,确保配置一致性 + return &Logger{Logger: log.StandardLogger()} +} + +// InitLogger 初始化HTTP日志处理器 +// 创建专门用于HTTP请求日志的Logger实例,使用全局logrus配置 +// 返回: 初始化后的Logger实例 +func InitLogger() *Logger { + logger := NewLogger() + + // HTTP日志使用全局logrus的配置 + // 通过使用log.StandardLogger()确保与全局配置保持一致 + + // 更新全局日志实例 + SetGlobalLogger(logger) + + return logger +} + +// WithFields 添加字段到日志条目 +// fields: 要添加的字段映射 +// 返回: 包含字段的日志条目 +func (l *Logger) WithFields(fields log.Fields) *log.Entry { + return l.Logger.WithFields(fields) +} + +// WithField 添加单个字段到日志条目 +// key: 字段名 +// value: 字段值 +// 返回: 包含字段的日志条目 +func (l *Logger) WithField(key string, value interface{}) *log.Entry { + return l.Logger.WithField(key, value) +} + +// WithError 添加错误字段到日志条目 +// err: 要记录的错误 +// 返回: 包含错误信息的日志条目 +func (l *Logger) WithError(err error) *log.Entry { + return l.Logger.WithError(err) +} + +// InfoWithFields 记录带字段的信息级别日志 +// msg: 日志消息 +// fields: 附加字段 +func (l *Logger) InfoWithFields(msg string, fields log.Fields) { + l.WithFields(fields).Info(msg) +} + +// ErrorWithFields 记录带字段的错误级别日志 +// msg: 日志消息 +// fields: 附加字段 +func (l *Logger) ErrorWithFields(msg string, fields log.Fields) { + l.WithFields(fields).Error(msg) +} + +// WarnWithFields 记录带字段的警告级别日志 +// msg: 日志消息 +// fields: 附加字段 +func (l *Logger) WarnWithFields(msg string, fields log.Fields) { + l.WithFields(fields).Warn(msg) +} + +// DebugWithFields 记录带字段的调试级别日志 +// msg: 日志消息 +// fields: 附加字段 +func (l *Logger) DebugWithFields(msg string, fields log.Fields) { + l.WithFields(fields).Debug(msg) +} + +// LogError 记录错误日志 +// err: 错误对象 +// msg: 日志消息 +func (l *Logger) LogError(err error, msg string) { + l.WithError(err).Error(msg) +} + +// GlobalLogger 全局日志实例 +// 提供全局访问的日志记录器 +var GlobalLogger *Logger + +// init 包初始化函数 +// 创建全局日志实例,使用全局logrus配置 +func init() { + GlobalLogger = NewLogger() +} + +// GetLogger 获取全局日志实例 +// 返回: 全局Logger实例 +func GetLogger() *Logger { + return GlobalLogger +} + +// SetGlobalLogger 设置全局日志实例 +// logger: 要设置的Logger实例 +func SetGlobalLogger(logger *Logger) { + GlobalLogger = logger +} \ No newline at end of file diff --git a/utils/logger/server.go b/utils/logger/server.go new file mode 100644 index 0000000..383f797 --- /dev/null +++ b/utils/logger/server.go @@ -0,0 +1,26 @@ +package logger + +import ( + log "github.com/sirupsen/logrus" +) + +// LogServerStart 记录服务器启动日志 +// host: 服务器监听地址 +// port: 服务器监听端口 +func (l *Logger) LogServerStart(host string, port int) { + l.WithFields(log.Fields{ + "host": host, + "port": port, + }).Info("HTTP服务器启动") +} + +// LogServerStop 记录服务器停止日志 +func (l *Logger) LogServerStop() { + l.Info("HTTP服务器停止") +} + +// LogConfigLoad 记录配置加载日志 +// configFile: 配置文件路径 +func (l *Logger) LogConfigLoad(configFile string) { + l.WithField("config_file", configFile).Info("配置文件加载") +} \ No newline at end of file diff --git a/utils/timeutil/server.go b/utils/timeutil/server.go new file mode 100644 index 0000000..69cacc8 --- /dev/null +++ b/utils/timeutil/server.go @@ -0,0 +1,44 @@ +package timeutil + +import ( + "fmt" + "time" +) + +// serverStartTime 记录进程启动时间(近似服务器启动时间) +var serverStartTime = time.Now() + +// GetServerStartTime 获取服务器启动时间 +// 返回: 服务器启动的时间戳 +func GetServerStartTime() time.Time { + return serverStartTime +} + +// GetServerUptime 获取服务器运行时长 +// 返回: 从服务器启动到现在的时间间隔 +func GetServerUptime() time.Duration { + return time.Since(serverStartTime) +} + +// GetServerUptimeString 获取服务器运行时长的字符串表示 +// 返回: 格式化的运行时长字符串 +func GetServerUptimeString() string { + duration := time.Since(serverStartTime) + + // 获取总秒数并转换为整数 + totalSeconds := int(duration.Seconds()) + + // 计算小时、分钟、秒 + hours := totalSeconds / 3600 + minutes := (totalSeconds % 3600) / 60 + seconds := totalSeconds % 60 + + // 根据时长长度选择合适的格式 + if hours > 0 { + return fmt.Sprintf("%dh%dm%ds", hours, minutes, seconds) + } else if minutes > 0 { + return fmt.Sprintf("%dm%ds", minutes, seconds) + } else { + return fmt.Sprintf("%ds", seconds) + } +} diff --git a/web/assets/logo.svg b/web/assets/logo.svg new file mode 100644 index 0000000..38e64e5 --- /dev/null +++ b/web/assets/logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/web/assets/themes.json b/web/assets/themes.json new file mode 100644 index 0000000..0f09267 --- /dev/null +++ b/web/assets/themes.json @@ -0,0 +1,355 @@ +{ + "Default": { + "--color-white": "#FFFFFF", + "--color-black": "#000000", + "--lay-color-white": "#FAFAFA", + "--lay-color-black": "#333333", + "--lay-color-red-1": "#FFF1E8", + "--lay-color-red-2": "#FFD7C0", + "--lay-color-red-3": "#FFBB99", + "--lay-color-red-4": "#FF9C71", + "--lay-color-red-5": "#FF7A4A", + "--lay-color-red-6": "#FF5722", + "--lay-color-red-7": "#D23B15", + "--lay-color-red-8": "#A6250B", + "--lay-color-red-9": "#791404", + "--lay-color-red-10": "#4D0800", + "--lay-color-blue-1": "#E8F9FF", + "--lay-color-blue-2": "#C0ECFF", + "--lay-color-blue-3": "#97DCFF", + "--lay-color-blue-4": "#6FCAFF", + "--lay-color-blue-5": "#46B5FF", + "--lay-color-blue-6": "#1E9FFF", + "--lay-color-blue-7": "#1379D2", + "--lay-color-blue-8": "#0A58A6", + "--lay-color-blue-9": "#043A79", + "--lay-color-blue-10": "#00214D", + "--lay-color-lightblue-1": "#E8FDFF", + "--lay-color-lightblue-2": "#C1F4FB", + "--lay-color-lightblue-3": "#9CEAF7", + "--lay-color-lightblue-4": "#77DDF4", + "--lay-color-lightblue-5": "#53CEF0", + "--lay-color-lightblue-6": "#31BDEC", + "--lay-color-lightblue-7": "#1F95C4", + "--lay-color-lightblue-8": "#10709C", + "--lay-color-lightblue-9": "#064E74", + "--lay-color-lightblue-10": "#002F4D", + "--lay-color-layuigreen-1": "#E8FFF9", + "--lay-color-layuigreen-2": "#B5F1E3", + "--lay-color-layuigreen-3": "#87E3D1", + "--lay-color-layuigreen-4": "#5DD6C1", + "--lay-color-layuigreen-5": "#37C8B5", + "--lay-color-layuigreen-6": "#16BAAA", + "--lay-color-layuigreen-7": "#0E9F95", + "--lay-color-layuigreen-8": "#08837F", + "--lay-color-layuigreen-9": "#036868", + "--lay-color-layuigreen-10": "#004A4D", + "--lay-color-green-1": "#E8FFF2", + "--lay-color-green-2": "#B5F1D1", + "--lay-color-green-3": "#86E2B4", + "--lay-color-green-4": "#5CD49C", + "--lay-color-green-5": "#37C588", + "--lay-color-green-6": "#16B777", + "--lay-color-green-7": "#0E9C68", + "--lay-color-green-8": "#088259", + "--lay-color-green-9": "#036749", + "--lay-color-green-10": "#004D38", + "--lay-color-orange-1": "#FFFCE8", + "--lay-color-orange-2": "#FFF5BA", + "--lay-color-orange-3": "#FFEA8B", + "--lay-color-orange-4": "#FFDC5D", + "--lay-color-orange-5": "#FFCB2E", + "--lay-color-orange-6": "#FFB800", + "--lay-color-orange-7": "#D29000", + "--lay-color-orange-8": "#A66C00", + "--lay-color-orange-9": "#794B00", + "--lay-color-orange-10": "#4D2D00", + "--lay-color-cyan-1": "#E8F6FF", + "--lay-color-cyan-2": "#B9CEDD", + "--lay-color-cyan-3": "#8FA7BB", + "--lay-color-cyan-4": "#6A829A", + "--lay-color-cyan-5": "#4A5F78", + "--lay-color-cyan-6": "#2F4056", + "--lay-color-cyan-7": "#223654", + "--lay-color-cyan-8": "#162C51", + "--lay-color-cyan-9": "#0B214F", + "--lay-color-cyan-10": "#00174D", + "--lay-color-purple-1": "#FDE8FF", + "--lay-color-purple-2": "#EDBEF4", + "--lay-color-purple-3": "#DC97E8", + "--lay-color-purple-4": "#C972DD", + "--lay-color-purple-5": "#B651D1", + "--lay-color-purple-6": "#A233C6", + "--lay-color-purple-7": "#8120A8", + "--lay-color-purple-8": "#631289", + "--lay-color-purple-9": "#48076B", + "--lay-color-purple-10": "#2F004D", + "--lay-color-black-1": "#E8F8FF", + "--lay-color-black-2": "#BFD0D8", + "--lay-color-black-3": "#98A8B1", + "--lay-color-black-4": "#73818A", + "--lay-color-black-5": "#505B63", + "--lay-color-black-6": "#2F363C", + "--lay-color-black-7": "#23303C", + "--lay-color-black-8": "#18293C", + "--lay-color-black-9": "#0C213C", + "--lay-color-black-10": "#00183C", + "--lay-color-gray-1": "#FAFAFA", + "--lay-color-gray-2": "#F6F6F6", + "--lay-color-gray-3": "#EEEEEE", + "--lay-color-gray-4": "#E2E2E2", + "--lay-color-gray-5": "#DDDDDD", + "--lay-color-gray-6": "#D2D2D2", + "--lay-color-gray-7": "#CCCCCC", + "--lay-color-gray-8": "#C2C2C2", + "--lay-color-gray-9": "#AAAAAA", + "--lay-color-gray-10": "#939393", + "--lay-color-gray-11": "#858585", + "--lay-color-gray-12": "#7b7b7b", + "--lay-color-gray-13": "#686868", + "--lay-color-primary": "var(--lay-color-layuigreen-6)", + "--lay-color-primary-hover": "var(--lay-color-layuigreen-5)", + "--lay-color-primary-active": "var(--lay-color-layuigreen-7)", + "--lay-color-primary-disabled": "var(--lay-color-layuigreen-3)", + "--lay-color-primary-light": "var(--lay-color-layuigreen-4)", + "--lay-color-secondary": "var(--lay-color-green-6)", + "--lay-color-secondary-hover": "var(--lay-color-green-5)", + "--lay-color-secondary-active": "var(--lay-color-green-7)", + "--lay-color-secondary-disabled": "var(--lay-color-green-3)", + "--lay-color-secondary-light": "var(--lay-color-green-4)", + "--lay-color-info": "var(--lay-color-lightblue-6)", + "--lay-color-info-hover": "var(--lay-color-lightblue-5)", + "--lay-color-info-active": "var(--lay-color-lightblue-7)", + "--lay-color-info-disabled": "var(--lay-color-lightblue-3)", + "--lay-color-info-light": "var(--lay-color-lightblue-4)", + "--lay-color-normal": "var(--lay-color-blue-6)", + "--lay-color-normal-hover": "var(--lay-color-blue-5)", + "--lay-color-normal-active": "var(--lay-color-blue-7)", + "--lay-color-normal-disabled": "var(--lay-color-blue-3)", + "--lay-color-normal-light": "var(--lay-color-blue-4)", + "--lay-color-warning": "var(--lay-color-orange-6)", + "--lay-color-warning-hover": "var(--lay-color-orange-5)", + "--lay-color-warning-active": "var(--lay-color-orange-7)", + "--lay-color-warning-disabled": "var(--lay-color-orange-3)", + "--lay-color-warning-light": "var(--lay-color-orange-4)", + "--lay-color-success": "var(--lay-color-green-6)", + "--lay-color-success-hover": "var(--lay-color-green-5)", + "--lay-color-success-active": "var(--lay-color-green-7)", + "--lay-color-success-disabled": "var(--lay-color-green-3)", + "--lay-color-success-light": "var(--lay-color-green-4)", + "--lay-color-danger": "var(--lay-color-red-6)", + "--lay-color-danger-hover": "var(--lay-color-red-5)", + "--lay-color-danger-active": "var(--lay-color-red-7)", + "--lay-color-danger-disabled": "var(--lay-color-red-3)", + "--lay-color-danger-light": "var(--lay-color-red-4)", + "--lay-color-bg-1": "#17171A", + "--lay-color-bg-2": "#232324", + "--lay-color-bg-3": "#2a2a2b", + "--lay-color-bg-4": "#313132", + "--lay-color-bg-5": "#373739", + "--lay-color-bg-white": "#f6f6f6", + "--lay-color-text-1": "rgba(255,255,255,.9)", + "--lay-color-text-2": "rgba(255,255,255,.7)", + "--lay-color-text-3": "rgba(255,255,255,.5)", + "--lay-color-text-4": "rgba(255,255,255,.3)", + "--lay-color-border-1": "#2e2e30", + "--lay-color-border-2": "#484849", + "--lay-color-border-3": "#5f5f60", + "--lay-color-border-4": "#929293", + "--lay-color-fill-1": "rgba(255,255,255,.04)", + "--lay-color-fill-2": "rgba(255,255,255,.08)", + "--lay-color-fill-3": "rgba(255,255,255,.12)", + "--lay-color-fill-4": "rgba(255,255,255,.16)", + "--lay-color-hover": "var(--lay-color-fill-3)", + "--lay-color-active": "var(--lay-color-fill-3)", + "--lay-shadow-1": "0 4px 6px rgba(0, 0, 0, 6%), 0 1px 10px rgba(0, 0, 0, 8%), 0 2px 4px rgba(0, 0, 0, 12%)", + "--lay-shadow-2": "0 8px 10px rgba(0, 0, 0, 12%), 0 3px 14px rgba(0, 0, 0, 10%), 0 5px 5px rgba(0, 0, 0, 16%)", + "--lay-shadow-3": "0 16px 24px rgba(0, 0, 0, 14%), 0 6px 30px rgba(0, 0, 0, 12%), 0 8px 10px rgba(0, 0, 0, 20%)" + }, + "ColorPaletteDark": { + "--lay-color-red-1": "#4D0800", + "--lay-color-red-2": "#791505", + "--lay-color-red-3": "#A62A11", + "--lay-color-red-4": "#D24622", + "--lay-color-red-5": "#FF6839", + "--lay-color-red-6": "#FF7948", + "--lay-color-red-7": "#FF9C71", + "--lay-color-red-8": "#FFBC9A", + "--lay-color-red-9": "#FFD9C3", + "--lay-color-red-10": "#FFF3EB", + "--lay-color-blue-1": "#00214D", + "--lay-color-blue-2": "#033A79", + "--lay-color-blue-3": "#0F5AA6", + "--lay-color-blue-4": "#1F7FD2", + "--lay-color-blue-5": "#35A9FF", + "--lay-color-blue-6": "#44B4FF", + "--lay-color-blue-7": "#70CAFF", + "--lay-color-blue-8": "#9BDDFF", + "--lay-color-blue-9": "#C6EEFF", + "--lay-color-blue-10": "#F2FCFF", + "--lay-color-lightblue-1": "#002F4D", + "--lay-color-lightblue-2": "#044D74", + "--lay-color-lightblue-3": "#12719C", + "--lay-color-lightblue-4": "#2797C4", + "--lay-color-lightblue-5": "#42C1EC", + "--lay-color-lightblue-6": "#56CFF0", + "--lay-color-lightblue-7": "#79DDF4", + "--lay-color-lightblue-8": "#9DEAF7", + "--lay-color-lightblue-9": "#C3F4FB", + "--lay-color-lightblue-10": "#EAFDFF", + "--lay-color-layuigreen-1": "#004A4D", + "--lay-color-layuigreen-2": "#046868", + "--lay-color-layuigreen-3": "#0E837F", + "--lay-color-layuigreen-4": "#1C9F96", + "--lay-color-layuigreen-5": "#2EBAAC", + "--lay-color-layuigreen-6": "#40C8B6", + "--lay-color-layuigreen-7": "#64D6C2", + "--lay-color-layuigreen-8": "#8CE3D2", + "--lay-color-layuigreen-9": "#B9F1E4", + "--lay-color-layuigreen-10": "#EAFFFA", + "--lay-color-green-1": "#004D38", + "--lay-color-green-2": "#046749", + "--lay-color-green-3": "#0E825B", + "--lay-color-green-4": "#1C9C6D", + "--lay-color-green-5": "#2EB780", + "--lay-color-green-6": "#3FC58B", + "--lay-color-green-7": "#64D4A0", + "--lay-color-green-8": "#8CE2B7", + "--lay-color-green-9": "#BAF1D3", + "--lay-color-green-10": "#EBFFF4", + "--lay-color-orange-1": "#4D2D00", + "--lay-color-orange-2": "#794C04", + "--lay-color-orange-3": "#A66F0A", + "--lay-color-orange-4": "#D29613", + "--lay-color-orange-5": "#FFC11F", + "--lay-color-orange-6": "#FFC926", + "--lay-color-orange-7": "#FFDB57", + "--lay-color-orange-8": "#FFE987", + "--lay-color-orange-9": "#FFF5B8", + "--lay-color-orange-10": "#FFFCE8", + "--lay-color-cyan-1": "#00174D", + "--lay-color-cyan-2": "#0B214F", + "--lay-color-cyan-3": "#162C51", + "--lay-color-cyan-4": "#233754", + "--lay-color-cyan-5": "#304056", + "--lay-color-cyan-6": "#546478", + "--lay-color-cyan-7": "#75879A", + "--lay-color-cyan-8": "#99ABBB", + "--lay-color-cyan-9": "#C2D2DD", + "--lay-color-cyan-10": "#EFF9FF", + "--lay-color-purple-1": "#2F004D", + "--lay-color-purple-2": "#47056B", + "--lay-color-purple-3": "#631389", + "--lay-color-purple-4": "#8326A8", + "--lay-color-purple-5": "#A53FC6", + "--lay-color-purple-6": "#B755D1", + "--lay-color-purple-7": "#CA77DD", + "--lay-color-purple-8": "#DD9BE8", + "--lay-color-purple-9": "#EEC3F4", + "--lay-color-purple-10": "#FDEDFF" + }, + "ColorPaletteLight": { + "--lay-color-red-1": "#FFF1E8", + "--lay-color-red-2": "#FFD7C0", + "--lay-color-red-3": "#FFBB99", + "--lay-color-red-4": "#FF9C71", + "--lay-color-red-5": "#FF7A4A", + "--lay-color-red-6": "#FF5722", + "--lay-color-red-7": "#D23B15", + "--lay-color-red-8": "#A6250B", + "--lay-color-red-9": "#791404", + "--lay-color-red-10": "#4D0800", + "--lay-color-blue-1": "#E8F9FF", + "--lay-color-blue-2": "#C0ECFF", + "--lay-color-blue-3": "#97DCFF", + "--lay-color-blue-4": "#6FCAFF", + "--lay-color-blue-5": "#46B5FF", + "--lay-color-blue-6": "#1E9FFF", + "--lay-color-blue-7": "#1379D2", + "--lay-color-blue-8": "#0A58A6", + "--lay-color-blue-9": "#043A79", + "--lay-color-blue-10": "#00214D", + "--lay-color-lightblue-1": "#E8FDFF", + "--lay-color-lightblue-2": "#C1F4FB", + "--lay-color-lightblue-3": "#9CEAF7", + "--lay-color-lightblue-4": "#77DDF4", + "--lay-color-lightblue-5": "#53CEF0", + "--lay-color-lightblue-6": "#31BDEC", + "--lay-color-lightblue-7": "#1F95C4", + "--lay-color-lightblue-8": "#10709C", + "--lay-color-lightblue-9": "#064E74", + "--lay-color-lightblue-10": "#002F4D", + "--lay-color-layuigreen-1": "#E8FFF9", + "--lay-color-layuigreen-2": "#B5F1E3", + "--lay-color-layuigreen-3": "#87E3D1", + "--lay-color-layuigreen-4": "#5DD6C1", + "--lay-color-layuigreen-5": "#37C8B5", + "--lay-color-layuigreen-6": "#16BAAA", + "--lay-color-layuigreen-7": "#0E9F95", + "--lay-color-layuigreen-8": "#08837F", + "--lay-color-layuigreen-9": "#036868", + "--lay-color-layuigreen-10": "#004A4D", + "--lay-color-green-1": "#E8FFF2", + "--lay-color-green-2": "#B5F1D1", + "--lay-color-green-3": "#86E2B4", + "--lay-color-green-4": "#5CD49C", + "--lay-color-green-5": "#37C588", + "--lay-color-green-6": "#16B777", + "--lay-color-green-7": "#0E9C68", + "--lay-color-green-8": "#088259", + "--lay-color-green-9": "#036749", + "--lay-color-green-10": "#004D38", + "--lay-color-orange-1": "#FFFCE8", + "--lay-color-orange-2": "#FFF5BA", + "--lay-color-orange-3": "#FFEA8B", + "--lay-color-orange-4": "#FFDC5D", + "--lay-color-orange-5": "#FFCB2E", + "--lay-color-orange-6": "#FFB800", + "--lay-color-orange-7": "#D29000", + "--lay-color-orange-8": "#A66C00", + "--lay-color-orange-9": "#794B00", + "--lay-color-orange-10": "#4D2D00", + "--lay-color-cyan-1": "#E8F6FF", + "--lay-color-cyan-2": "#B9CEDD", + "--lay-color-cyan-3": "#8FA7BB", + "--lay-color-cyan-4": "#6A829A", + "--lay-color-cyan-5": "#4A5F78", + "--lay-color-cyan-6": "#2F4056", + "--lay-color-cyan-7": "#223654", + "--lay-color-cyan-8": "#162C51", + "--lay-color-cyan-9": "#0B214F", + "--lay-color-cyan-10": "#00174D", + "--lay-color-purple-1": "#FDE8FF", + "--lay-color-purple-2": "#EDBEF4", + "--lay-color-purple-3": "#DC97E8", + "--lay-color-purple-4": "#C972DD", + "--lay-color-purple-5": "#B651D1", + "--lay-color-purple-6": "#A233C6", + "--lay-color-purple-7": "#8120A8", + "--lay-color-purple-8": "#631289", + "--lay-color-purple-9": "#48076B", + "--lay-color-purple-10": "#2F004D" + }, + "editable": { + "--lay-color-bg-1": "#17171A", + "--lay-color-bg-2": "#232324", + "--lay-color-bg-3": "#2a2a2b", + "--lay-color-bg-4": "#313132", + "--lay-color-bg-5": "#373739", + "--lay-color-bg-white": "#f6f6f6", + "--lay-color-text-1": "rgba(255,255,255,.9)", + "--lay-color-text-2": "rgba(255,255,255,.7)", + "--lay-color-text-3": "rgba(255,255,255,.5)", + "--lay-color-text-4": "rgba(255,255,255,.3)", + "--lay-color-border-1": "#2e2e30", + "--lay-color-border-2": "#484849", + "--lay-color-border-3": "#5f5f60", + "--lay-color-border-4": "#929293", + "--lay-color-fill-1": "rgba(255,255,255,.04)", + "--lay-color-fill-2": "rgba(255,255,255,.08)", + "--lay-color-fill-3": "rgba(255,255,255,.12)", + "--lay-color-fill-4": "rgba(255,255,255,.16)", + "--lay-color-hover": "var(--lay-color-fill-3)", + "--lay-color-active": "var(--lay-color-fill-3)" + } +} diff --git a/web/public.go b/web/public.go new file mode 100644 index 0000000..ab454b6 --- /dev/null +++ b/web/public.go @@ -0,0 +1,67 @@ +package web + +import ( + "embed" + "html/template" + "io/fs" + "log" + "os" + "path/filepath" + + "github.com/spf13/viper" +) + +// TemplatesFS 嵌入模板的文件系统 +// +//go:embed template/*.html template/admin/*.html +var templatesFS embed.FS + +// StaticFS 嵌入静态资源的文件系统(包含 CSS/JS 的 static 与 图片/字体等资源的 assets) +// +//go:embed static/* assets/* +var staticFS embed.FS + +// getDistRootFS 获取基于 server.dist 的本地文件系统 +// 当 server.dist 非空且路径存在时,返回对应的本地只读 FS;否则返回 nil +func getDistRootFS() fs.FS { + // 从配置中读取 server.dist + distPath := viper.GetString("server.dist") + if distPath == "" { + return nil + } + // 归一化路径,兼容相对/绝对 + absPath := distPath + if !filepath.IsAbs(distPath) { + if p, err := filepath.Abs(distPath); err == nil { + absPath = p + } + } + // 检查目录是否存在 + if info, err := os.Stat(absPath); err == nil && info.IsDir() { + return os.DirFS(absPath) + } + log.Printf("server.dist 路径无效或不可访问:%s,将回退使用嵌入资源", distPath) + return nil +} + +// ParseTemplates 解析模板 +// 优先从 server.dist 指定目录加载(当配置非空且有效),否则回退到嵌入模板 +func ParseTemplates() (*template.Template, error) { // Go 顶级函数不支持箭头写法 + if distFS := getDistRootFS(); distFS != nil { + // 期望 dist 目录下存在 template 与 template/admin 结构 + // 如:{dist}/template/*.html 与 {dist}/template/admin/*.html + return template.ParseFS(distFS, "template/*.html", "template/admin/*.html") + } + // 默认:使用嵌入模板 + return template.ParseFS(templatesFS, "template/*.html", "template/admin/*.html") +} + +// GetStaticFS 返回静态资源文件系统(包含 static 与 assets 目录) +// 优先使用 server.dist 指定的本地目录;否则回退到嵌入静态资源 +func GetStaticFS() (fs.FS, error) { // Go 顶级函数不支持箭头写法 + if distFS := getDistRootFS(); distFS != nil { + // 直接返回以 dist 根为起点的 FS,routes 中会再基于此 FS Sub 出 static 与 assets + return distFS, nil + } + return staticFS, nil +} diff --git a/web/static/css/admin.css b/web/static/css/admin.css new file mode 100644 index 0000000..656818a --- /dev/null +++ b/web/static/css/admin.css @@ -0,0 +1,104 @@ +wc-include{padding: 15px;display: block;} +#app {display: none;} +.layui-layout-right .layui-nav-bar {background-color: unset !important;} +.layui-layout-admin .layui-side {top: 0 !important;z-index: 1001;} +.layui-layout-admin .layui-logo {position: relative !important;height: 60px !important;top: -2px !important;} + +/* Logo文字美化样式 */ +.logo-enhanced { + font-family: 'Microsoft YaHei', 'PingFang SC', 'Helvetica Neue', Arial, sans-serif !important; + font-size: 18px !important; + font-weight: 600 !important; + color: #ffffff !important; + text-shadow: 0 1px 2px rgba(0,0,0,0.3) !important; + letter-spacing: 1px !important; +} + +.layui-side, +.layui-header, +.layui-body, +.layui-footer {transition: left 0.3s;} +.collapse .layui-layout-admin .layui-side, +.collapse .layui-layout-admin .layui-header {left: -200px;} +.collapse .layui-layout-admin .layui-footer, +.collapse .layui-layout-admin .layui-body {left: 0px;} + +::view-transition-old(root), +::view-transition-new(root) {animation: none;mix-blend-mode: normal;} +::view-transition-old(root) {z-index: 9999;} +::view-transition-new(root) {z-index: 1;} +.dark::view-transition-old(root) {z-index: 1;} +.dark::view-transition-new(root) {z-index: 9999;} + +/* 以下为自定义样式 */ +.system-info-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 16px; margin-bottom: 20px; } +.system-info-item { padding: 16px; border-radius: 8px; background: var(--card); border: 1px solid var(--border); } +.system-info-label { font-size: 14px; color: var(--muted); margin-bottom: 8px; } +.system-info-value { font-size: 16px; font-weight: 600; color: var(--fg); } + +/* ===================== 滚动条美化与布局约束(右侧滑块条) ===================== */ +/* + 作用: + 1. 统一 Admin 布局下内容区(.layui-body)为局部滚动容器,只在头部与页脚之间滚动 + 2. 美化 .layui-body 的滚动条样式,增强可用性与观感 + 3. 不影响登录页等非 Admin 布局页面(仅在 .layui-layout-admin 作用域内生效) +*/ +:root { + /* 头部与页脚的高度变量,便于后续维护/调整 */ + --admin-header-h: 60px; + --admin-footer-h: 0px; /* 当前页脚未启用,如启用可改为 44px 等 */ +} + +/* Admin 主容器占满视口,高度锁定,避免出现浏览器右侧全局滚动条 */ +.layui-layout-admin { + position: relative; + height: 100vh; + overflow: hidden; +} + +/* 头部/页脚高度同步到变量,确保与内容区上下边界垂直齐平 */ +.layui-layout-admin .layui-header { + height: var(--admin-header-h); + line-height: var(--admin-header-h); +} +.layui-layout-admin .layui-footer { + height: var(--admin-footer-h); + line-height: var(--admin-footer-h); +} + +/* 内容区设为局部滚动容器,顶部/底部与头部/页脚精确对齐 */ +.layui-layout-admin .layui-body { + /* 仅约束垂直方向,左右定位保持与 Layui 默认一致,兼容现有折叠动画 */ + top: var(--admin-header-h) !important; + bottom: var(--admin-footer-h) !important; + overflow: auto; + + /* Firefox 滚动条样式(细滚动条+自定义颜色) */ + scrollbar-width: thin; /* 细滚动条 */ + scrollbar-color: var(--lay-color-secondary) var(--lay-color-bg-3); /* thumb 与 track 颜色 */ +} + +/* WebKit 滚动条样式(Chrome/Edge/Safari) */ +.layui-layout-admin .layui-body::-webkit-scrollbar { + width: 10px; + height: 10px; +} +.layui-layout-admin .layui-body::-webkit-scrollbar-track { + background: var(--lay-color-bg-2); + border-left: 1px solid var(--lay-color-border-2); +} +.layui-layout-admin .layui-body::-webkit-scrollbar-thumb { + /* 渐变+内边透明边框,获得圆润质感 */ + background: linear-gradient(180deg, var(--lay-color-gray-7), var(--lay-color-gray-9)); + border-radius: 8px; + border: 2px solid transparent; + background-clip: padding-box; +} +.layui-layout-admin .layui-body::-webkit-scrollbar-thumb:hover { + background: var(--lay-color-secondary); /* 悬停高亮,强化可交互性 */ +} +.layui-layout-admin .layui-body::-webkit-scrollbar-corner { + background: transparent; +} + +/* ===================== END 滚动条美化与布局约束 ===================== */ diff --git a/web/static/css/home.css b/web/static/css/home.css new file mode 100644 index 0000000..fe9cbf4 --- /dev/null +++ b/web/static/css/home.css @@ -0,0 +1,178 @@ +body { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + font-family: 'Microsoft YaHei', sans-serif; +} +.card-container { + background: rgba(255, 255, 255, 0.95); + border-radius: 15px; + box-shadow: 0 15px 35px rgba(0, 0, 0, 0.2); + padding: 40px; + width: 100%; + max-width: 500px; + backdrop-filter: blur(10px); +} +.header { + text-align: center; + margin-bottom: 30px; +} +.header h1 { + color: #333; + font-size: 28px; + margin-bottom: 10px; + font-weight: 300; +} +.header p { + color: #666; + font-size: 14px; +} +.progress-container { + margin-bottom: 30px; +} +.progress-steps { + display: flex; + justify-content: space-between; + margin-bottom: 20px; + position: relative; +} +.progress-steps::before { + content: ''; + position: absolute; + top: 20px; + left: 20px; + right: 20px; + height: 2px; + background: #e6e6e6; + z-index: 1; +} +.progress-line { + position: absolute; + top: 20px; + left: 20px; + height: 2px; + background: #5FB878; + transition: width 0.5s ease; + z-index: 2; + width: 0%; +} +.step { + position: relative; + z-index: 3; + text-align: center; + flex: 1; +} +.step-circle { + width: 40px; + height: 40px; + border-radius: 50%; + background: #e6e6e6; + color: #999; + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 10px; + font-weight: bold; + transition: all 0.3s ease; +} +.step.active .step-circle { + background: #5FB878; + color: white; +} +.step.completed .step-circle { + background: #5FB878; + color: white; +} +.step-text { + font-size: 12px; + color: #666; +} +.step.active .step-text { + color: #5FB878; + font-weight: bold; +} +.form-container { + margin-top: 20px; +} +.form-item { + margin-bottom: 20px; +} +.form-item label { + display: block; + margin-bottom: 8px; + color: #333; + font-weight: 500; +} +.layui-input { + border-radius: 8px; + border: 1px solid #e6e6e6; + padding: 12px 15px; + font-size: 14px; + transition: border-color 0.3s ease; +} +.layui-input:focus { + border-color: #5FB878; + box-shadow: 0 0 0 2px rgba(95, 184, 120, 0.2); +} +.submit-btn { + width: 100%; + height: 45px; + background: linear-gradient(45deg, #5FB878, #42B983); + color: white; + border: none; + border-radius: 8px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s ease; + margin-top: 20px; +} +.submit-btn:hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(95, 184, 120, 0.4); +} +.submit-btn:disabled { + background: #ccc; + cursor: not-allowed; + transform: none; + box-shadow: none; +} +.hidden { + display: none; +} +.loading { + text-align: center; + padding: 20px; +} +.loading-spinner { + width: 40px; + height: 40px; + border: 4px solid #f3f3f3; + border-top: 4px solid #5FB878; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 15px; +} +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* 修复 select 下拉在某些浏览器中文字垂直被裁剪问题 */ +select.layui-input, +select.layui-select { + /* 统一高度,避免被 padding 挤压导致文字显示不全 */ + height: 40px; + line-height: 40px; /* 对多数浏览器有效,确保文字垂直居中 */ + padding: 0 15px; /* 与 .layui-input 保持一致的水平内边距 */ + box-sizing: border-box; /* 使高度计算更可控,不受 padding 影响 */ + vertical-align: middle; +} + +/* 兼容性优化:在部分内核下 select 需要明确字体大小与行高匹配 */ +select.layui-input option, +select.layui-select option { + line-height: 40px; +} \ No newline at end of file diff --git a/web/static/js/admin.js b/web/static/js/admin.js new file mode 100755 index 0000000..c285791 --- /dev/null +++ b/web/static/js/admin.js @@ -0,0 +1,330 @@ +const VERSION = '2.10.1'; +const layuicss = `https://unpkg.com/layui@${VERSION}/dist/css/layui.css`; +const layuijs = `https://unpkg.com/layui@${VERSION}/dist/layui.js`; +const rootPath = (function (src) { + src = (document.currentScript && document.currentScript.tagName.toUpperCase() === 'SCRIPT') ? document.currentScript.src : document.scripts[document.scripts.length - 1].src; + return src.substring(0, src.lastIndexOf('/') + 1); +})(); + +const app = document.querySelector('#app') + +addLink({ href: layuicss }).then(() => { + app.style.display = 'block'; +}); + +addLink({ id: 'layui_theme_css', href: `./static/src/layui-theme-dark-selector.css` }); + +// TODO 弃用,下个版本只支持选择器模式 +//addLink({ id: 'layui_theme_css', href: `${rootPath}dist/layui-theme-dark.css` }); + +loadScript(layuijs, function () { + layui + .config({ + base: './static/lib/', + }) + .extend({ + drawer: 'drawer/drawer', + }); + layui.use(['drawer', 'colorMode'], function () { + const { $, element, form, layer, util, dropdown, drawer, colorMode } = layui; + + const APPERANCE_KEY = 'layui-theme-demo-prefer-dark'; + + const theme = colorMode.init({ + selector: 'html', + attribute: 'class', + initialValue: 'dark', + modes: { + light: '', + dark: 'dark', + }, + storageKey: APPERANCE_KEY, + onChanged(mode, defaultHandler) { + const isAppearanceTransition = document.startViewTransition && !window.matchMedia(`(prefers-reduced-motion: reduce)`).matches; + const isDark = mode === 'dark'; + + $('#change-theme').attr('class', `layui-icon layui-icon-${isDark ? 'moon' : 'light'}`); + + if (!isAppearanceTransition) { + defaultHandler(); + } else { + rippleViewTransition(isDark, function () { + defaultHandler(); + }); + } + }, + }); + + routerTo({path: location.hash.slice(1) || 'dashboard'}); + + dropdown.render({ + elem: '#change-theme', + align: 'center', + data: [ + { + title: '深色模式', + id: 'dark', + icon: 'layui-icon-moon', + }, + { + title: '浅色模式', + id: 'light', + icon: 'layui-icon-light', + }, + { + title: '跟随系统', + id: 'auto', + icon: 'layui-icon-console', + }, + ], + templet(d) { + return ` + + + ${d.title} + `.trim(); + }, + click(obj) { + const { id: mode } = obj; + theme.setMode(mode); + }, + }); + + util.event('lay-header-event', { + menuLeft() { + $('body').toggleClass('collapse'); + }, + menuRight() { + drawer.open({ + area: '600px', + url: './static/tpl/theme.html', + hideOnClose: true, + id: 'drawer-theme-tpl', + shade: 0.01, + }); + }, + }); + + element.on('nav(nav-side)', function (elem) { + var path = elem.data('path'); + if (path) { + routerTo({path}); + if ($(window).width() <= 768) { + $('body').toggleClass('collapse', false); + } + } + }); + + $('#layuiv').text(layui.v); + + /* + * 后台通用脚本 + * 说明:统一处理全局的退出登录逻辑,遵循后端 jsonResponse 的返回格式: + * code: 0 表示成功,非0表示失败 + * msg: 提示信息 + * data: 业务数据 + */ + + // 绑定退出登录按钮事件(箭头函数写法) + const bindLogout = () => { + const btn = document.getElementById('logout-btn'); + if (!btn) return; + btn.addEventListener('click', (e) => { + e.preventDefault(); + handleLogout(); + }); + }; + + // 执行退出登录(箭头函数写法) + // 功能:弹出确认框 -> 显示加载层 -> 调用 /admin/logout -> 依据 code===0 判断 + const handleLogout = () => { + layer.confirm('确定要退出登录吗?', { + icon: 3, + title: '提示' + }, (index) => { + layer.close(index); + + // 显示加载层 + const loadIndex = layer.load(2, { + content: '正在退出登录...' + }); + + // 调用登出接口 + fetch('/admin/logout', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(response => response.json()) + .then(data => { + layer.close(loadIndex); + const ok = data && data.code === 0; + const msg = (data && (data.msg || data.message)) || (ok ? '退出登录成功' : '退出登录失败'); + if (ok) { + layer.msg(msg, { + icon: 1, + time: 1000 + }, () => { + // 跳转到登录页或后端返回的地址 + const redirect = (data && data.data && data.data.redirect) || '/admin/login'; + window.location.href = redirect; + }); + } else { + layer.msg(msg, { icon: 2 }); + } + }) + .catch(error => { + layer.close(loadIndex); + console.error('登出请求失败:', error); + layer.msg('网络错误,请重试', { icon: 2 }); + }); + }); + }; + + // 页面就绪后绑定事件(箭头函数写法) + (() => { + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', bindLogout); + } else { + bindLogout(); + } + })(); + + // 刷新页面功能处理 + const handleRefresh = () => { + layer.confirm('确定要刷新内容吗?', { + icon: 3, + title: '提示' + }, (index) => { + layer.close(index); + + // 获取当前hash值,确定当前页面路径 + let currentPath = window.location.hash.replace('#', '') || 'dashboard'; + + // 显示加载层 + const loadIndex = layer.load(2, { + content: '正在刷新...' + }); + + // 延迟一下再刷新内容,让用户看到加载效果 + setTimeout(() => { + // 重新加载当前内容页面 + routerTo({ path: currentPath }); + layer.close(loadIndex); + }, 500); + }); + }; + + // 绑定刷新按钮点击事件 + $('#refresh-btn').on('click', handleRefresh); + + function routerTo({ + elem = '#router-view', + path = 'dashboard', + prefix = 'admin/', //路由前缀 + suffix = '', //路由后缀 + } = {}) { + var routerView = $(elem); + var url = prefix + path + suffix; + + var loadTimer = setTimeout(() => { + layer.load(2); + }, 100); + + history.replaceState({}, '', `#${path}`); // 因为并没有处理路由 + routerView.attr('src', url) + routerView.off('load').on('load',function(){ + element.render(); + form.render(); + clearTimeout(loadTimer); + layer.closeLast('loading'); + }) + + // 选中, 展开菜单 + $('#ws-nav-side') + .find("[data-path='" + path + "']") + .parent('dd') + .addClass('layui-this') + .closest('.layui-nav-item') + .addClass('layui-nav-itemed'); + } + + }); +}); + +function rippleViewTransition(isDark, callback) { + // 移植自 https://github.com/vuejs/vitepress/pull/2347 + // 支持 Chrome 111+ + + // 兼容 jQuery 3 下隐式 event 全局对象不可用的问题 + if (!window.event) { + window.event = new MouseEvent('click', { + clientX: document.documentElement.clientWidth, + clientY: 60, + }); + } + + const x = event.clientX; + const y = event.clientY; + const endRadius = Math.hypot(Math.max(x, innerWidth - x), Math.max(y, innerHeight - y)); + const transition = document.startViewTransition(function () { + callback && callback(); + }); + transition.ready.then(function () { + var clipPath = [`circle(0px at ${x}px ${y}px)`, `circle(${endRadius}px at ${x}px ${y}px)`]; + document.documentElement.animate( + { + clipPath: isDark ? clipPath : [...clipPath].reverse(), + }, + { + duration: 300, + easing: 'ease-in', + pseudoElement: isDark ? '::view-transition-new(root)' : '::view-transition-old(root)', + } + ); + }); +} + +function addStyle(id, cssStr) { + const el = document.getElementById(id) || document.createElement('style'); + if (!el.isConnected) { + el.type = 'text/css'; + el.id = id; + document.head.appendChild(el); + } + el.textContent = cssStr; +} + +function addLink(opt) { + return new Promise((resolve) => { + const link = Object.assign(document.createElement('link'), { + rel: 'stylesheet', + onload: () => resolve({ ...opt, status: 'success' }), + onerror: () => resolve({ ...opt, status: 'error' }), // 为了在 Promise.all 的使用场景 + ...opt, + }); + document.head.appendChild(link); + }); +} + +function loadScript(url, callback) { + const script = document.createElement('script'); + script.type = 'text/javascript'; + script.async = 'async'; + script.src = url; + document.body.appendChild(script); + if (script.readyState) { + script.onreadystatechange = function () { + if (script.readyState == 'complete' || script.readyState == 'loaded') { + script.onreadystatechange = null; + callback && callback(); + } + }; + } else { + script.onload = function () { + callback && callback(); + }; + } +} diff --git a/web/static/lib/README.md b/web/static/lib/README.md new file mode 100644 index 0000000..fffcd70 --- /dev/null +++ b/web/static/lib/README.md @@ -0,0 +1,83 @@ +# ColorMode 模块(WIP) + +开箱即用的主题切换(深色/浅色/自定义)模块,具有自动数据持久性。 + +**基本使用** + +```js +layui.use(['colorMode'], function () { + var colorMode = layui.colorMode + + var theme = colorMode.init() + + } +); +``` + +**配置** + +模块仅处理 DOM 属性更改,以便在 CSS 中应用正确的选择器,不会处理实际的样式,主题或 CSS。 +默认情况下,使用 auto 模式(与用户的浏览器首选项匹配),将类 dark 应用于 html 标签时启用深色模式,返回一个对象,用来获取和改变主题。 + +```js +var theme = colorMode.init() + +theme.mode() // 'dark' | 'light' + +theme.setMode('dark') // 设置为深色模式并持久化到 localstorage + +theme.setMode('auto') // 设置为 auto 模式 +``` + +也可以自定义以使其适用于大多数场景 + +```js +var theme = colorMode.init({ + selector: 'body', + attribute: 'theme-mode', + initialValue: 'light', + modes: { + auto: '', + light: 'light', + dark: 'dark', + contrast: 'dark contrast', + }, + storage: localStorage, + storageKey: 'xxx-theme-mode', + disableTransition: true, +}) +``` + +如果上述配置仍不能满足您的需求,可以使用 onChanged 选项完全控制处理更新的方式 + +```js +var theme = colorMode.init({ + onChanged: function(mode, defaultHandler){ + // 自定义更新方式 + } +}) +``` + +**API** + +```ts +/** + * @typedef {object} initOptions + * @prop {string} [selector='html'] - 应用于目标元素的 CSS 选择器 + * @prop {string} [attribute='class'] - 应用于目标元素的 HTML 属性 + * @prop {string} [initialValue='auto'] - 初始颜色模式 + * @prop {Object.} [modes]- 颜色模式。value 为添加到 HTML 属性上的值 + * @prop {(mode: string, defaultHandler: () => void) => void} [onChanged] - 用于处理更新的自定义处理程序,指定时,默认行为将被覆盖。mode 为颜色模式,defaultHandler 为默认处理程序 + * @prop {Storage} [storage=localStorage] - 将数据持久化到 localStorage/sessionStorage 的键。传递 `null` 以禁用持久性 + * @prop {string | null} [storageKey='color-scheme'] - 持久化使用的 key + * @prop {boolean} [disableTransition=true] - 禁用切换时的过渡 {@link https://paco.me/writing/disable-theme-transitions} + * + */ + +/** + * + * @param {initOptions} options + * @returns {{ mode: () => string; setMode: (mode: string) => void;}} + */ +colorMode.init(options) +``` diff --git a/web/static/lib/colorMode.js b/web/static/lib/colorMode.js new file mode 100755 index 0000000..1956241 --- /dev/null +++ b/web/static/lib/colorMode.js @@ -0,0 +1,191 @@ +/** + * WIP + * 移植自 https://github.com/vueuse/vueuse/tree/main/packages/core/useColorMode + */ +// @ts-ignore +layui.define(['jquery'], function (exports) { + 'use strict'; + + /** @type {jQuery}*/ + var $ = layui.jquery; + + var MOD_NAME = 'colorMode'; + var defaultWindow = window; + var document = defaultWindow.document; + + var colorMode = { + /** + * @typedef {object} initOptions + * @prop {string} [selector="html"] - 应用于目标元素的 CSS 选择器 + * @prop {string} [attribute="class"] - 应用于目标元素的 HTML 属性 + * @prop {string} [initialValue='auto'] - 初始颜色模式 + * @prop {Object.} [modes]- 颜色模式。value 为添加到 HTML 属性上的值 + * @prop {(mode: string, defaultHandler: (window?: Window) => void) => void} [onChanged] - 用于处理更新的自定义处理程序,指定时,默认行为将被覆盖。 + * @prop {Storage} [storage=localStorage] - 将数据持久化到 localStorage/sessionStorage 的键。传递 `null` 以禁用持久性 + * @prop {string | null} [storageKey='color-scheme'] - 持久化使用的 key + * @prop {boolean} [disableTransition=true] - 禁用切换时的过渡 {@link https://paco.me/writing/disable-theme-transitions} + * + */ + + /** + * + * @param {initOptions} options + * @returns {{mode: () => string; setMode: (mode: string, window?: Window) => void; }} + */ + init: function (options) { + var defaults = { + selector: 'html', + attribute: 'class', + initialValue: 'auto', + modes: { + auto: '', + light: 'light', + dark: 'dark', + }, + storage: localStorage, + storageKey: 'color-scheme', + disableTransition: true, + }; + + var opts = $.extend(true, {}, defaults, options); + + // 当前颜色模式 + var state; + // 系统颜色模式 + var system; + // 初始化 storage + var store = + opts.storageKey == null + ? opts.initialValue + : (function () { + var v = opts.storage.getItem(opts.storageKey); + if (!v) { + opts.storage.setItem(opts.storageKey, opts.initialValue); + return opts.initialValue; + } + return v; + })(); + + /** + * 更新 HTML 属性值 + * @param {String} selector + * @param {String} attribute + * @param {String} value + * @param {Window} win + */ + var updateHTMLAttrs = function (selector, attribute, value, win) { + win = win || defaultWindow; + var document = win.document; + var el = typeof selector === 'string' ? document.querySelector(selector) : undefined; + if (!el) return; + + /**@type HTMLStyleElement */ + var style; + + if (opts.disableTransition) { + style = document.createElement('style'); + style.appendChild( + document.createTextNode( + '*,*::before,*::after{-webkit-transition:none!important;-moz-transition:none!important;-o-transition:none!important;-ms-transition:none!important;transition:none!important}' + ) + ); + document.head.appendChild(style); + } + + if (attribute === 'class') { + var current = value.split(/\s/g); + $.each(opts.modes, function (_, modeval) { + $.each((modeval || '').split(/\s/g), function (_, v) { + if (!v) return; + if (current.indexOf(v) !== -1) { + el.classList.add(v); + } else { + el.classList.remove(v); + } + }); + }); + } else { + el.setAttribute(attribute, value); + } + + if (opts.disableTransition) { + // 调用 getComputedStyle 强制浏览器重绘 + // @ts-expect-error 未使用的变量 + var _ = window.getComputedStyle(style).opacity; + document.head.removeChild(style); + } + }; + + /** + * 更新状态 + * @param {String} mode - 颜色模式 + */ + var updateState = function (mode) { + store = opts.storageKey == null ? mode : opts.storage.getItem(opts.storageKey); + + state = store === 'auto' ? system : store; + }; + + var prefersColorScheme = function () { + var isSupported = window && 'matchMedia' in window && typeof window.matchMedia === 'function'; + if (!isSupported) { + system = 'light'; + onChanged(system); + return; + } + + var darkThemeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + var update = function () { + var preferredDark = darkThemeMediaQuery.matches; + system = preferredDark ? 'dark' : 'light'; + onChanged(system); + }; + update(); + if ('addEventListener' in darkThemeMediaQuery) { + darkThemeMediaQuery.addEventListener('change', update); + } else { + // @ts-ignore 已弃用 + darkThemeMediaQuery.addListener(update); + } + }; + + prefersColorScheme(); + + function defaultOnChanged(win) { + updateHTMLAttrs(opts.selector, opts.attribute, opts.modes[state], win); + } + + function onChanged(mode, win) { + updateState(mode); + if (opts.onChanged) { + opts.onChanged(state, defaultOnChanged); + } else { + defaultOnChanged(win); + } + } + + return { + setMode: function (mode, win) { + if (opts.storageKey) { + opts.storage.setItem(opts.storageKey, mode); + } + onChanged(mode, win); + }, + mode: function () { + return state; + }, + }; + }, + addStyle: function (id, cssStr) { + var el = /** @type {HTMLStyleElement} */ (document.getElementById(id) || document.createElement('style')); + if (!el.isConnected) { + el.type = 'text/css'; + el.id = id; + document.head.appendChild(el); + } + el.textContent = cssStr; + }, + }; + + exports(MOD_NAME, colorMode); +}); diff --git a/web/static/lib/drawer/drawer.css b/web/static/lib/drawer/drawer.css new file mode 100644 index 0000000..de1ecae --- /dev/null +++ b/web/static/lib/drawer/drawer.css @@ -0,0 +1,317 @@ +.layer-drawer.layui-layer { + border-radius: 0 !important; + overflow: auto; +} + +.layer-drawer.layui-layer.position-absolute { + position: absolute !important; +} + +.layer-drawer-anim, +.layer-drawer-anim.layui-anim { + -webkit-animation-duration: .3s; + animation-duration: .3s; + -webkit-animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1); + animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1); +} + +/* right to left */ +@keyframes layer-rl { + from { + -webkit-transform: translate3d(100%, 0, 0); + -ms-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + opacity: 1; + + } + + to { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + opacity: 1; + } +} + +@-webkit-keyframes layer-rl { + from { + -webkit-transform: translate3d(100%, 0, 0); + -ms-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + opacity: 1; + + } + + to { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + opacity: 1; + } +} + +.layer-anim-rl { + -webkit-animation-name: layer-rl; + animation-name: layer-rl; +} + +/* right to left close */ +@keyframes layer-rl-close { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + + to { + -webkit-transform: translate3d(100%, 0, 0); + -ms-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } +} + +@-webkit-keyframes layer-rl-close { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + + } + + to { + -webkit-transform: translate3d(100%, 0, 0); + -ms-transform: translate3d(100%, 0, 0); + transform: translate3d(100%, 0, 0); + } +} + +.layer-anim-rl-close, +.layer-anim-rl.layer-anim-close { + -webkit-animation-name: layer-rl-close; + animation-name: layer-rl-close; +} + +/* left to right */ +@-webkit-keyframes layer-lr { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + opacity: 1 + } + + to { + -webkit-transform: translate3d(-100%, 0, 0); + -ms-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + opacity: 1 + } +} + +@keyframes layer-lr { + from { + -webkit-transform: translate3d(-100%, 0, 0); + -ms-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + opacity: 1 + } + + to { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + opacity: 1 + } +} + +.layer-anim-lr { + -webkit-animation-name: layer-lr; + animation-name: layer-lr +} + +/* left to right close */ +@-webkit-keyframes layer-lr-close { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + + to { + -webkit-transform: translate3d(-100%, 0, 0); + -ms-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } +} + +@keyframes layer-lr-close { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + + to { + -webkit-transform: translate3d(-100%, 0, 0); + -ms-transform: translate3d(-100%, 0, 0); + transform: translate3d(-100%, 0, 0); + } +} + +.layer-anim-lr-close, +.layer-anim-lr.layer-anim-close { + -webkit-animation-name: layer-lr-close; + animation-name: layer-lr-close +} + +/* top to bottom */ +@-webkit-keyframes layer-tb { + from { + -webkit-transform: translate3d(0, -100%, 0); + -ms-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + opacity: 1; + animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1); + } + + to { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + opacity: 1; + animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1); + } +} + +@keyframes layer-tb { + from { + -webkit-transform: translate3d(0, -100%, 0); + -ms-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + opacity: 1; + animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1); + } + + to { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + opacity: 1; + animation-timing-function: cubic-bezier(0.7, 0.3, 0.1, 1); + } +} + +.layer-anim-tb { + -webkit-animation-name: layer-tb; + animation-name: layer-tb +} + +/* top to bottom close */ +@-webkit-keyframes layer-tb-close { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + + to { + -webkit-transform: translate3d(0, -100%, 0); + -ms-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } +} + +@keyframes layer-tb-close { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + + to { + -webkit-transform: translate3d(0, -100%, 0); + -ms-transform: translate3d(0, -100%, 0); + transform: translate3d(0, -100%, 0); + } +} + +.layer-anim-tb-close, +.layer-anim-tb.layer-anim-close { + -webkit-animation-name: layer-tb-close; + animation-name: layer-tb-close +} + +/* bottom to top */ +@-webkit-keyframes layer-bt { + from { + -webkit-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + opacity: 1 + } + + to { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + opacity: 1 + } +} + +@keyframes layer-bt { + from { + -webkit-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + opacity: 1 + } + + to { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + opacity: 1 + } +} + +.layer-anim-bt { + -webkit-animation-name: layer-bt; + animation-name: layer-bt +} + +/* bottom to top close */ +@-webkit-keyframes layer-bt-close { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + } + + to { + -webkit-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } +} + +@keyframes layer-bt-close { + from { + -webkit-transform: translate3d(0, 0, 0); + -ms-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + + } + + to { + -webkit-transform: translate3d(0, 100%, 0); + -ms-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + + } +} + +.layer-anim-bt-close, +.layer-anim-bt.layer-anim-close { + -webkit-animation-name: layer-bt-close; + animation-name: layer-bt-close +} \ No newline at end of file diff --git a/web/static/lib/drawer/drawer.js b/web/static/lib/drawer/drawer.js new file mode 100755 index 0000000..e37b398 --- /dev/null +++ b/web/static/lib/drawer/drawer.js @@ -0,0 +1,200 @@ +/** + * 抽屉模块 + */ +layui.define(['jquery', 'layer'], function (exports) { + ('use strict'); + + var MOD_NAME = 'drawer'; + var $ = layui.jquery; + var layer = layui.layer; + + layui.link(layui.cache.base + 'drawer/drawer.css'); + var drawer = new (function () { + this.open = function (option) { + return layerDrawer(option); + }; + this.title = layer.title; + this.style = layer.style; + this.close = layer.close; + this.closeAll = layer.closeAll; + })(); + + /** + * + * 封装 layer.open + * + * @param {object} option, `type`, `anim`, `move`, `fixed`, `skin`,`maxWidth`, `maxHeight`, `moveOut`, `moveEnd` 不可用,其它参数和 layer.open 一致, 新增 `iframe`和 `url`参数 + * @returns {number} 原生 layer 的 index + */ + function layerDrawer(option) { + var opt = normalizeOption(option); + if (opt.target) appendToTarget(opt); + if (opt.url) loadFragment(opt); + if (opt.shade) { + $(' +
+
+
+ + + +
+
+ + +
+
+
+
+ +
+
+ +
+
+ + + + + + + diff --git a/web/template/admin/apps.html b/web/template/admin/apps.html new file mode 100644 index 0000000..dc32959 --- /dev/null +++ b/web/template/admin/apps.html @@ -0,0 +1,412 @@ +{{ define "apps.html" }} +
+

应用管理

+
+ + + + +
+ +
+
筛选
+
+
+
+
+ +
+ +
+
+
+ + +
+
+
+
+
+ +
+
应用列表
+
+
+ + +
+
+ + + + + +
+{{ end }} \ No newline at end of file diff --git a/web/template/admin/card_types.html b/web/template/admin/card_types.html new file mode 100644 index 0000000..97445c1 --- /dev/null +++ b/web/template/admin/card_types.html @@ -0,0 +1,415 @@ +{{ define "card_types.html" }} +
+

卡密类型管理

+
+ + + + +
+ +
+
筛选
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+
+
+ +
+
卡密类型列表
+
+
+ +
+
+ + + +
+ + +{{ end }} \ No newline at end of file diff --git a/web/template/admin/cards.html b/web/template/admin/cards.html new file mode 100644 index 0000000..898ff77 --- /dev/null +++ b/web/template/admin/cards.html @@ -0,0 +1,771 @@ +{{ define "cards.html" }} +
+

卡密管理

+
+ + + + + + + + +
+ +
+
筛选
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+
+
+ +
+
卡密列表
+
+
+ +
+
+ + + + + + + + + +
+ + +{{ end }} \ No newline at end of file diff --git a/web/template/admin/dashboard.html b/web/template/admin/dashboard.html new file mode 100644 index 0000000..8ac534e --- /dev/null +++ b/web/template/admin/dashboard.html @@ -0,0 +1,236 @@ +{{ define "dashboard.html" }} +
+

系统信息

+
+
+
+
基本信息
+
+
+
+
版本
+
{{ .Version }}
+
+
+
运行模式
+
{{ .Mode }}
+
+
+
+
+
+
+
+
运行状态
+
+
+
+
数据库
+
{{ .DBType }}
+
+
+
运行时长
+
{{ .Uptime }}
+
+
+
+
+
+
+
+ + +
+
+ +
+
+
当日卡密统计 总数:-
+
+
+
+
+
+ +
+
+
所有卡密统计 总数:-
+
+
+
+
+
+
+ + +
+
+
+
近30天卡密走势
+
+
+
+
+
+
+
+ + +{{ end }} \ No newline at end of file diff --git a/web/template/admin/layout.html b/web/template/admin/layout.html new file mode 100644 index 0000000..9a8d6fd --- /dev/null +++ b/web/template/admin/layout.html @@ -0,0 +1,76 @@ + + + + + + {{ .Title }} - {{ .SystemName }} + + + + + +
+
+ +
    + +
  • + +
  • +
+ +
+
+
+ + + +
+
+
+ + +
+ +
+ + + diff --git a/web/template/admin/login.html b/web/template/admin/login.html new file mode 100644 index 0000000..b5f6a87 --- /dev/null +++ b/web/template/admin/login.html @@ -0,0 +1,259 @@ +{{/* 管理员登录页面模板:使用layui构建的登录界面 */}} + + + + + + {{ .Title }} + + + + + + + + + + \ No newline at end of file diff --git a/web/template/admin/login_types.html b/web/template/admin/login_types.html new file mode 100644 index 0000000..0fae95a --- /dev/null +++ b/web/template/admin/login_types.html @@ -0,0 +1,422 @@ +{{ define "login_types.html" }} +
+

登录方式管理

+
+ + + + +
+ +
+
筛选
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + +
+
+
+
+
+ +
+
登录方式列表
+
+
+ +
+
+ + + +
+ + +{{ end }} \ No newline at end of file diff --git a/web/template/admin/settings.html b/web/template/admin/settings.html new file mode 100644 index 0000000..8f0af37 --- /dev/null +++ b/web/template/admin/settings.html @@ -0,0 +1,277 @@ +{{ define "settings.html" }} +
+

系统设置

+ + +
+
基本信息设置
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ + +
+
系统配置
+
+
+
+ +
+
+ +
+
+
+
+ +
+ +
+
+
+ +
+
+ + 秒(300-86400秒) +
+
+
+ +
+
+
+ + +
+
页脚与备案
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+ + +
+
+ + +
+
+
+ + +{{ end }} \ No newline at end of file diff --git a/web/template/admin/user.html b/web/template/admin/user.html new file mode 100644 index 0000000..2aea793 --- /dev/null +++ b/web/template/admin/user.html @@ -0,0 +1,256 @@ +{{ define "user.html" }} +
+
个人资料
+
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+ + + +
+
+
+
+
+ + +{{ end }} \ No newline at end of file diff --git a/web/template/index.html b/web/template/index.html new file mode 100644 index 0000000..e2bdba4 --- /dev/null +++ b/web/template/index.html @@ -0,0 +1,366 @@ + + + + {{.SystemName}} - 生活就像愤怒的小鸟,失败后总有几只猪在笑。 + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+

系统提醒

+
+
+
+
🚫 未授权,拒绝访问
+
+
+
💬 如有问题,请联系网站管理员
+
+
+ + {{if or .ICPRecord .PSBRecord}}
{{if .ICPRecord}}{{.ICPRecord}}{{end}}{{if and .ICPRecord .PSBRecord}} {{end}}{{if .PSBRecord}}{{.PSBRecord}}{{end}}
{{end}} +
+
+ + + + + + \ No newline at end of file