diff --git a/config.yaml b/config.yaml index ecff253..832dcf5 100644 --- a/config.yaml +++ b/config.yaml @@ -1,6 +1,6 @@ # ========== 服务配置 ========== server: - port: 8080 + port: 6655 read_timeout: 15 # 读超时(秒) write_timeout: 30 # 写超时(秒) idle_timeout: 120 # 空闲超时(秒) @@ -107,6 +107,12 @@ ai: api_type: "deepseek" # deepseek | openai model: "deepseek-chat" +# ========== 企业微信 Webhook ========== +wechat_webhook: + enabled: true + url: "" # 企业微信群机器人 Webhook URL + # 示例: https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + # ========== CORS 跨域 ========== cors: allowed_origins: diff --git a/internal/config/config.go b/internal/config/config.go index ad7ea2d..9d84d52 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,26 +9,27 @@ import ( // Config 全局配置根结构 type Config struct { - Server ServerConfig `yaml:"server"` - Database DatabaseConfig `yaml:"database"` - JWT JWTConfig `yaml:"jwt"` - Upload UploadConfig `yaml:"upload"` - Storage StorageConfig `yaml:"storage"` - SMS SMSConfig `yaml:"sms"` - Wechat WechatConfig `yaml:"wechat"` - AI AIConfig `yaml:"ai"` - CORS CORSConfig `yaml:"cors"` - RateLimit RateLimitConfig `yaml:"rate_limit"` - Env EnvConfig `yaml:"env"` + Server ServerConfig `yaml:"server"` + Database DatabaseConfig `yaml:"database"` + JWT JWTConfig `yaml:"jwt"` + Upload UploadConfig `yaml:"upload"` + Storage StorageConfig `yaml:"storage"` + SMS SMSConfig `yaml:"sms"` + Wechat WechatConfig `yaml:"wechat"` + WechatWebhook WechatWebhookConfig `yaml:"wechat_webhook"` + AI AIConfig `yaml:"ai"` + CORS CORSConfig `yaml:"cors"` + RateLimit RateLimitConfig `yaml:"rate_limit"` + Env EnvConfig `yaml:"env"` } // ServerConfig 服务配置 type ServerConfig struct { - Port int `yaml:"port"` - ReadTimeout int `yaml:"read_timeout"` // 读超时(秒) - WriteTimeout int `yaml:"write_timeout"` // 写超时(秒) - IdleTimeout int `yaml:"idle_timeout"` // 空闲超时(秒) - ShutdownTimeout int `yaml:"shutdown_timeout"` // 优雅关机等待(秒) + Port int `yaml:"port"` + ReadTimeout int `yaml:"read_timeout"` // 读超时(秒) + WriteTimeout int `yaml:"write_timeout"` // 写超时(秒) + IdleTimeout int `yaml:"idle_timeout"` // 空闲超时(秒) + ShutdownTimeout int `yaml:"shutdown_timeout"` // 优雅关机等待(秒) } // DatabaseConfig 数据库配置 @@ -152,6 +153,12 @@ type WechatConfig struct { NotifyURL string `yaml:"notify_url"` } +// WechatWebhookConfig 企业微信群机器人Webhook配置 +type WechatWebhookConfig struct { + Enabled bool `yaml:"enabled"` + URL string `yaml:"url"` +} + // AIConfig AI 配置 type AIConfig struct { APIKey string `yaml:"api_key"` diff --git a/internal/database/database.go b/internal/database/database.go index 5904979..49623ad 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -9,7 +9,6 @@ import ( "gotest/internal/model" "github.com/glebarez/sqlite" - "golang.org/x/crypto/bcrypt" "gorm.io/driver/mysql" "gorm.io/gorm" ) @@ -61,7 +60,7 @@ func Init(cfg *config.DatabaseConfig) (*DB, error) { // auto_migrate 自动建表/更新表结构 if cfg.AutoMigrate { if err := db.AutoMigrate( - &model.User{}, &model.Config{}, + &model.User{}, &model.Config{}, &model.AccessLog{}, &model.Company{}, &model.EnterpriseNews{}, &model.EnterpriseService{}, &model.TeamMember{}, &model.ProductCategory{}, &model.Product{}, &model.RestaurantCategory{}, &model.Restaurant{}, &model.MenuCategory{}, &model.MenuItem{}, @@ -102,9 +101,9 @@ func Init(cfg *config.DatabaseConfig) (*DB, error) { return &DB{db}, nil } -// seedData 初始化基础数据,仅在用户表为空时执行 +// seedData 初始化基础数据 func seedData(db *gorm.DB) error { - // 检查是否已有用户,避免重复初始化 + // 检查是否已有用户 var count int64 if err := db.Model(&model.User{}).Count(&count).Error; err != nil { return err @@ -114,24 +113,7 @@ func seedData(db *gorm.DB) error { return nil } - // 创建默认管理员账号 admin / admin123 - hashedPassword, err := bcrypt.GenerateFromPassword([]byte("admin123"), bcrypt.DefaultCost) - if err != nil { - return fmt.Errorf("生成管理员密码哈希失败: %w", err) - } - - admin := model.User{ - Username: "admin", - Password: string(hashedPassword), - Role: "admin", - Status: 1, - } - - if err := db.Create(&admin).Error; err != nil { - return fmt.Errorf("创建默认管理员失败: %w", err) - } - log.Println("默认管理员账号已创建: admin / admin123(请及时修改密码)") - + log.Println("未检测到管理员账号,请通过后台初始化页面创建管理员") return nil } diff --git a/internal/database/seed_enterprise.go b/internal/database/seed_enterprise.go index 394d836..31bae90 100644 --- a/internal/database/seed_enterprise.go +++ b/internal/database/seed_enterprise.go @@ -25,7 +25,7 @@ func seedEnterpriseData(db *gorm.DB) error { Slogan: "创新驱动 · 智领未来", Description: "Nexus 科技集团成立于 2010 年,是一家专注于人工智能、云计算和物联网技术的高科技企业。我们致力于为全球客户提供数字化转型解决方案,业务覆盖智能制造、智慧城市、金融科技等多个领域。经过十余年发展,集团已拥有超过 2000 名员工,服务客户超过 5000 家,足迹遍及亚洲、欧洲和北美。", Vision: "成为全球领先的智能科技企业,用技术赋能每一个行业。", - Mission: "以创新技术为核心驱动力,为客户创造价值,为社会承担责任,为员工提供平台。", + Mission: "以创新技术为核心驱动力,为客户创造价值,为社会承担责任,为员工提供平台。", Phone: "400-888-9999", Email: "contact@nexus-tech.com", Address: "上海市浦东新区张江高科技园区博云路 2 号", diff --git a/internal/database/seed_food.go b/internal/database/seed_food.go index 0929ad6..0b84f78 100644 --- a/internal/database/seed_food.go +++ b/internal/database/seed_food.go @@ -59,7 +59,10 @@ func seedFoodData(db *gorm.DB) error { } // 为每个餐厅创建菜品分类和菜品 - type catSeed struct{ name string; items []model.MenuItem } + type catSeed struct { + name string + items []model.MenuItem + } restaurantMenus := map[uint][]catSeed{ // 蜀香苑 川菜 1: { diff --git a/internal/geoip/geoip.go b/internal/geoip/geoip.go new file mode 100644 index 0000000..a16fbed --- /dev/null +++ b/internal/geoip/geoip.go @@ -0,0 +1,107 @@ +package geoip + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + "sync" + "time" +) + +// ipLocation 缓存的 IP 归属地信息 +type ipLocation struct { + Location string + FetchedAt time.Time +} + +// Service IP 归属地查询服务(基于 ip-api.com 免费接口 + 内存缓存) +type Service struct { + cache sync.Map +} + +// NewService 创建 IP 归属地查询服务 +func NewService() *Service { + return &Service{} +} + +// ipApiResponse ip-api.com 返回结构 +type ipApiResponse struct { + Status string `json:"status"` + Country string `json:"country"` + RegionName string `json:"regionName"` + City string `json:"city"` + ISP string `json:"isp"` + Query string `json:"query"` +} + +// Lookup 查询 IP 归属地,带内存缓存(24小时有效) +func (s *Service) Lookup(ip string) string { + // 本地/内网 IP 直接返回 + if ip == "127.0.0.1" || ip == "::1" || ip == "" || + strings.HasPrefix(ip, "192.168.") || + strings.HasPrefix(ip, "10.") || + strings.HasPrefix(ip, "172.") { + return "本地网络" + } + + // 检查缓存(24小时有效) + if v, ok := s.cache.Load(ip); ok { + loc := v.(*ipLocation) + if time.Since(loc.FetchedAt) < 24*time.Hour { + return loc.Location + } + s.cache.Delete(ip) + } + + // 查询 ip-api.com(免费接口,限制 45 次/分钟) + location := s.queryIPAPI(ip) + if location == "" { + return "未知" + } + + // 写入缓存 + s.cache.Store(ip, &ipLocation{ + Location: location, + FetchedAt: time.Now(), + }) + + return location +} + +// queryIPAPI 调用 ip-api.com 查询 +func (s *Service) queryIPAPI(ip string) string { + client := &http.Client{Timeout: 3 * time.Second} + url := fmt.Sprintf("http://ip-api.com/json/%s?lang=zh-CN", ip) + + resp, err := client.Get(url) + if err != nil { + return "" + } + defer resp.Body.Close() + + var result ipApiResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "" + } + + if result.Status != "success" { + return "" + } + + parts := []string{} + if result.Country != "" { + parts = append(parts, result.Country) + } + if result.RegionName != "" { + parts = append(parts, result.RegionName) + } + if result.City != "" { + parts = append(parts, result.City) + } + if result.ISP != "" { + parts = append(parts, result.ISP) + } + + return strings.Join(parts, " ") +} diff --git a/internal/handler/access_log.go b/internal/handler/access_log.go new file mode 100644 index 0000000..899eae4 --- /dev/null +++ b/internal/handler/access_log.go @@ -0,0 +1,107 @@ +package handler + +import ( + "strconv" + + "gotest/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// AccessLogHandler 访问日志处理器 +type AccessLogHandler struct { + db *gorm.DB +} + +// NewAccessLogHandler 创建访问日志处理器 +func NewAccessLogHandler(db *gorm.DB) *AccessLogHandler { + return &AccessLogHandler{db: db} +} + +// ListLogs 获取访问日志列表 GET /api/v1/admin/access-logs +func (h *AccessLogHandler) ListLogs(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + ip := c.Query("ip") + path := c.Query("path") + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + offset := (page - 1) * pageSize + + query := h.db.Model(&model.AccessLog{}) + + if ip != "" { + query = query.Where("ip LIKE ?", "%"+ip+"%") + } + if path != "" { + query = query.Where("path LIKE ?", "%"+path+"%") + } + + var total int64 + query.Count(&total) + + var logs []model.AccessLog + if err := query.Order("id DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil { + FailInternal(c, "查询失败: "+err.Error()) + return + } + + Success(c, gin.H{ + "list": logs, + "total": total, + "page": page, + "page_size": pageSize, + }) +} + +// GetStats 获取访问统计 GET /api/v1/admin/access-logs/stats +func (h *AccessLogHandler) GetStats(c *gin.Context) { + var totalVisits int64 + h.db.Model(&model.AccessLog{}).Count(&totalVisits) + + var todayVisits int64 + h.db.Model(&model.AccessLog{}).Where("created_at >= date('now')").Count(&todayVisits) + + var uniqueIPs int64 + h.db.Model(&model.AccessLog{}).Distinct("ip").Count(&uniqueIPs) + + // 最近 24 小时每小时访问量 + type hourlyCount struct { + Hour string `json:"hour"` + Count int64 `json:"count"` + } + var hourly []hourlyCount + h.db.Model(&model.AccessLog{}). + Select("strftime('%Y-%m-%d %H:00', created_at) as hour, count(*) as count"). + Where("created_at >= datetime('now', '-24 hours')"). + Group("hour"). + Order("hour ASC"). + Scan(&hourly) + + // 最近 10 条日志 + var recent []model.AccessLog + h.db.Order("id DESC").Limit(10).Find(&recent) + + Success(c, gin.H{ + "total_visits": totalVisits, + "today_visits": todayVisits, + "unique_ips": uniqueIPs, + "hourly": hourly, + "recent": recent, + }) +} + +// DeleteLogs 清空访问日志 DELETE /api/v1/admin/access-logs +func (h *AccessLogHandler) DeleteLogs(c *gin.Context) { + if err := h.db.Where("1 = 1").Delete(&model.AccessLog{}).Error; err != nil { + FailInternal(c, "清空失败: "+err.Error()) + return + } + SuccessMsg(c, "清空成功") +} diff --git a/internal/handler/config.go b/internal/handler/config.go new file mode 100644 index 0000000..65d3b7f --- /dev/null +++ b/internal/handler/config.go @@ -0,0 +1,82 @@ +package handler + +import ( + "gotest/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// ConfigHandler 系统配置处理器 +type ConfigHandler struct { + db *gorm.DB +} + +// NewConfigHandler 创建系统配置处理器 +func NewConfigHandler(db *gorm.DB) *ConfigHandler { + return &ConfigHandler{db: db} +} + +// GetConfig 获取配置 GET /api/v1/admin/config/:key +func (h *ConfigHandler) GetConfig(c *gin.Context) { + key := c.Param("key") + var config model.Config + if err := h.db.Where("`key` = ?", key).First(&config).Error; err != nil { + Success(c, gin.H{"key": key, "value": "", "remark": ""}) + return + } + Success(c, config) +} + +// SetConfig 设置配置 PUT /api/v1/admin/config/:key +func (h *ConfigHandler) SetConfig(c *gin.Context) { + key := c.Param("key") + var req struct { + Value string `json:"value"` + Remark string `json:"remark"` + } + if err := c.ShouldBindJSON(&req); err != nil { + FailBadRequest(c, "参数错误") + return + } + + var config model.Config + result := h.db.Where("`key` = ?", key).First(&config) + if result.Error == gorm.ErrRecordNotFound { + // 创建新配置 + config = model.Config{ + Key: key, + Value: req.Value, + Remark: req.Remark, + } + if err := h.db.Create(&config).Error; err != nil { + FailInternal(c, "创建失败: "+err.Error()) + return + } + } else if result.Error == nil { + // 更新已有配置 + config.Value = req.Value + if req.Remark != "" { + config.Remark = req.Remark + } + if err := h.db.Save(&config).Error; err != nil { + FailInternal(c, "更新失败: "+err.Error()) + return + } + } else { + FailInternal(c, "数据库错误: "+result.Error.Error()) + return + } + + Success(c, config) +} + +// ListConfigs 获取所有配置 GET /api/v1/admin/config +func (h *ConfigHandler) ListConfigs(c *gin.Context) { + var configs []model.Config + if err := h.db.Order("id ASC").Find(&configs).Error; err != nil { + FailInternal(c, "查询失败: "+err.Error()) + return + } + Success(c, configs) +} diff --git a/internal/handler/customer.go b/internal/handler/customer.go new file mode 100644 index 0000000..47d91ab --- /dev/null +++ b/internal/handler/customer.go @@ -0,0 +1,116 @@ +package handler + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" + + "gotest/internal/config" + + "github.com/gin-gonic/gin" +) + +// CustomerHandler 客户信息收集处理器 +type CustomerHandler struct { + cfg *config.Config +} + +// NewCustomerHandler 创建客户信息收集处理器 +func NewCustomerHandler(cfg *config.Config) *CustomerHandler { + return &CustomerHandler{cfg: cfg} +} + +// CustomerRequest 客户信息请求 +type CustomerRequest struct { + Name string `json:"name" binding:"required"` + Phone string `json:"phone" binding:"required"` + Company string `json:"company"` + Email string `json:"email"` + Needs string `json:"needs" binding:"required"` +} + +// Submit 提交客户信息 POST /api/v1/customer/submit +func (h *CustomerHandler) Submit(c *gin.Context) { + var req CustomerRequest + if err := c.ShouldBindJSON(&req); err != nil { + FailBadRequest(c, "请填写完整的联系信息") + return + } + + // 构建企业微信消息内容 + content := fmt.Sprintf("【新客户咨询】\n"+ + "━━━━━━━━━━━━━━━━━━━━\n"+ + "👤 姓名:%s\n"+ + "📱 电话:%s\n"+ + "📧 邮箱:%s\n"+ + "🏢 公司:%s\n"+ + "💼 需求:%s\n"+ + "⏰ 时间:%s\n"+ + "━━━━━━━━━━━━━━━━━━━━", + req.Name, req.Phone, req.Email, req.Company, req.Needs, time.Now().Format("2006-01-02 15:04:05")) + + // 发送到企业微信webhook + if err := h.sendToWechatWebhook(content); err != nil { + // 即使webhook失败,也记录下来并返回成功(避免暴露内部错误) + fmt.Printf("发送企业微信webhook失败: %v\n", err) + } + + Success(c, gin.H{ + "message": "提交成功,我们会尽快联系您!", + }) +} + +// sendToWechatWebhook 发送消息到企业微信群机器人 +func (h *CustomerHandler) sendToWechatWebhook(content string) error { + if h.cfg.WechatWebhook.URL == "" { + return fmt.Errorf("企业微信webhook URL未配置") + } + + // 构建消息体 + message := map[string]interface{}{ + "msgtype": "text", + "text": map[string]string{ + "content": content, + }, + } + + jsonData, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("序列化消息失败: %w", err) + } + + // 发送POST请求 + resp, err := http.Post(h.cfg.WechatWebhook.URL, "application/json", bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("发送请求失败: %w", err) + } + defer resp.Body.Close() + + // 读取响应 + body, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("读取响应失败: %w", err) + } + + // 检查响应状态 + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("请求失败,状态码: %d,响应: %s", resp.StatusCode, string(body)) + } + + // 解析响应JSON + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return fmt.Errorf("解析响应失败: %w", err) + } + + // 检查企业微信返回的错误码 + if errcode, ok := result["errcode"].(float64); ok && errcode != 0 { + errmsg, _ := result["errmsg"].(string) + return fmt.Errorf("企业微信返回错误: %v - %s", errcode, errmsg) + } + + return nil +} diff --git a/internal/handler/dashboard.go b/internal/handler/dashboard.go index 7abb594..862ca7e 100644 --- a/internal/handler/dashboard.go +++ b/internal/handler/dashboard.go @@ -28,7 +28,7 @@ var tableRegistry = map[string]interface{}{ "game_games": &model.Game{}, "game_categories": &model.GameCategory{}, // 应用模块 - "app_apps": &model.App{}, + "app_apps": &model.App{}, "app_categories": &model.AppCategory{}, // 音乐模块 "music_songs": &model.Song{}, @@ -37,22 +37,22 @@ var tableRegistry = map[string]interface{}{ // tableLabels 模块中文标签(用于前端展示) var tableLabels = map[string]string{ - "enterprise_company": "企业信息", - "enterprise_news": "企业新闻", - "enterprise_services": "企业服务", - "enterprise_team": "团队成员", - "shop_products": "商品管理", - "shop_categories": "商品分类", - "food_restaurants": "餐厅管理", - "food_categories": "餐厅分类", - "food_menu_items": "菜品管理", + "enterprise_company": "企业信息", + "enterprise_news": "企业新闻", + "enterprise_services": "企业服务", + "enterprise_team": "团队成员", + "shop_products": "商品管理", + "shop_categories": "商品分类", + "food_restaurants": "餐厅管理", + "food_categories": "餐厅分类", + "food_menu_items": "菜品管理", "food_menu_categories": "菜品分类", - "game_games": "游戏管理", - "game_categories": "游戏分类", - "app_apps": "应用管理", - "app_categories": "应用分类", - "music_songs": "歌曲管理", - "music_categories": "音乐分类", + "game_games": "游戏管理", + "game_categories": "游戏分类", + "app_apps": "应用管理", + "app_categories": "应用分类", + "music_songs": "歌曲管理", + "music_categories": "音乐分类", } // tablesWithSort 拥有 sort 列的表(用于排序) @@ -75,40 +75,40 @@ var tablesWithSort = map[string]bool{ // tablesWithName 拥有 name 列的表(用于关键词搜索) var tablesWithName = map[string]bool{ - "enterprise_company": true, - "enterprise_team": true, - "enterprise_services": false, // 有 title 无 name - "enterprise_news": false, - "shop_products": true, - "shop_categories": true, - "food_restaurants": true, - "food_categories": true, - "food_menu_items": true, - "food_menu_categories": true, - "game_games": true, - "game_categories": true, - "app_apps": true, - "app_categories": true, - "music_songs": false, // 有 title 无 name - "music_categories": true, + "enterprise_company": true, + "enterprise_team": true, + "enterprise_services": false, // 有 title 无 name + "enterprise_news": false, + "shop_products": true, + "shop_categories": true, + "food_restaurants": true, + "food_categories": true, + "food_menu_items": true, + "food_menu_categories": true, + "game_games": true, + "game_categories": true, + "app_apps": true, + "app_categories": true, + "music_songs": false, // 有 title 无 name + "music_categories": true, } // tablesWithTitle 拥有 title 列的表(用于关键词搜索) var tablesWithTitle = map[string]bool{ - "enterprise_news": true, - "enterprise_services": true, - "music_songs": true, + "enterprise_news": true, + "enterprise_services": true, + "music_songs": true, } // tablesWithStatus 拥有 status 列的表(用于状态过滤) var tablesWithStatus = map[string]bool{ - "enterprise_news": true, - "shop_products": true, - "food_restaurants": true, - "food_menu_items": true, - "game_games": true, - "app_apps": true, - "music_songs": true, + "enterprise_news": true, + "shop_products": true, + "food_restaurants": true, + "food_menu_items": true, + "game_games": true, + "app_apps": true, + "music_songs": true, } // DashboardHandler 管理后台仪表盘处理器(通用 CRUD + 统计) @@ -140,7 +140,7 @@ func (h *DashboardHandler) GetStats(c *gin.Context) { // 按业务线分组 grouped := gin.H{ "enterprise": gin.H{ - "label": "企业官网", + "label": "企业官网", "items": gin.H{ "company": stats["enterprise_company"], "news": stats["enterprise_news"], @@ -158,10 +158,10 @@ func (h *DashboardHandler) GetStats(c *gin.Context) { "food": gin.H{ "label": "外卖平台", "items": gin.H{ - "restaurants": stats["food_restaurants"], - "categories": stats["food_categories"], - "menu_items": stats["food_menu_items"], - "menu_categories": stats["food_menu_categories"], + "restaurants": stats["food_restaurants"], + "categories": stats["food_categories"], + "menu_items": stats["food_menu_items"], + "menu_categories": stats["food_menu_categories"], }, }, "game": gin.H{ diff --git a/internal/middleware/access_log.go b/internal/middleware/access_log.go new file mode 100644 index 0000000..cfef814 --- /dev/null +++ b/internal/middleware/access_log.go @@ -0,0 +1,78 @@ +package middleware + +import ( + "net/http" + "strings" + "time" + + "gotest/internal/geoip" + "gotest/internal/model" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// responseWriter 包装 gin.ResponseWriter 以捕获状态码 +type responseWriter struct { + gin.ResponseWriter + statusCode int +} + +func (w *responseWriter) WriteHeader(code int) { + w.statusCode = code + w.ResponseWriter.WriteHeader(code) +} + +// AccessLog 访问日志中间件,记录每次 HTTP 请求到数据库 +func AccessLog(db *gorm.DB, ipService *geoip.Service) gin.HandlerFunc { + return func(c *gin.Context) { + // 跳过健康检查和静态资源 + path := c.Request.URL.Path + if path == "/api/health" || strings.HasPrefix(path, "/shared/") || strings.HasPrefix(path, "/assets/") { + c.Next() + return + } + + start := time.Now() + clientIP := c.ClientIP() + + // 包装响应写入器以捕获状态码 + rw := &responseWriter{ResponseWriter: c.Writer, statusCode: http.StatusOK} + c.Writer = rw + + c.Next() + + // 异步写入日志(不阻塞请求响应) + go func() { + cost := int(time.Since(start).Milliseconds()) + ua := c.Request.UserAgent() + if len(ua) > 500 { + ua = ua[:500] + } + referer := c.Request.Referer() + if len(referer) > 500 { + referer = referer[:500] + } + p := c.Request.URL.Path + if len(p) > 500 { + p = p[:500] + } + + // 查询 IP 归属地 + location := ipService.Lookup(clientIP) + + log := model.AccessLog{ + IP: clientIP, + Path: p, + Method: c.Request.Method, + UserAgent: ua, + Referer: referer, + Location: location, + Status: rw.statusCode, + Cost: cost, + } + + db.Create(&log) + }() + } +} diff --git a/internal/model/access_log.go b/internal/model/access_log.go new file mode 100644 index 0000000..bb92325 --- /dev/null +++ b/internal/model/access_log.go @@ -0,0 +1,17 @@ +package model + +import "time" + +// AccessLog 网站访问日志 +type AccessLog struct { + ID uint `gorm:"primaryKey" json:"id"` + IP string `gorm:"size:45;index" json:"ip"` + Path string `gorm:"size:500;index" json:"path"` + Method string `gorm:"size:10" json:"method"` + UserAgent string `gorm:"size:500" json:"user_agent"` + Referer string `gorm:"size:500" json:"referer"` + Location string `gorm:"size:255" json:"location"` // IP 归属地 + Status int `json:"status"` + Cost int `json:"cost"` // 耗时(毫秒) + CreatedAt time.Time `gorm:"index" json:"created_at"` +} diff --git a/internal/model/app.go b/internal/model/app.go index 3e623ce..a6707be 100644 --- a/internal/model/app.go +++ b/internal/model/app.go @@ -19,19 +19,19 @@ type App struct { Description string `gorm:"type:text" json:"description"` CategoryID uint `gorm:"index" json:"category_id"` CategoryName string `gorm:"size:50" json:"category_name"` - Icon string `gorm:"size:255" json:"icon"` // 应用图标URL - Developer string `gorm:"size:100" json:"developer"` // 开发者 + Icon string `gorm:"size:255" json:"icon"` // 应用图标URL + Developer string `gorm:"size:100" json:"developer"` // 开发者 Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"` - Reviews int `gorm:"default:0" json:"reviews"` // 评价数 - Downloads int `gorm:"default:0" json:"downloads"` // 下载量 - Size string `gorm:"size:50" json:"size"` // 应用大小 - Version string `gorm:"size:50" json:"version"` // 版本号 - UpdateDate string `gorm:"size:20" json:"update_date"` // 更新日期 - Platform string `gorm:"size:100" json:"platform"` // 平台 - Price string `gorm:"size:50" json:"price"` // 价格(免费/付费) - Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 - Screenshots string `gorm:"type:text" json:"screenshots"` // JSON: 截图描述数组 - Features string `gorm:"type:text" json:"features"` // JSON: 特性列表 + Reviews int `gorm:"default:0" json:"reviews"` // 评价数 + Downloads int `gorm:"default:0" json:"downloads"` // 下载量 + Size string `gorm:"size:50" json:"size"` // 应用大小 + Version string `gorm:"size:50" json:"version"` // 版本号 + UpdateDate string `gorm:"size:20" json:"update_date"` // 更新日期 + Platform string `gorm:"size:100" json:"platform"` // 平台 + Price string `gorm:"size:50" json:"price"` // 价格(免费/付费) + Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 + Screenshots string `gorm:"type:text" json:"screenshots"` // JSON: 截图描述数组 + Features string `gorm:"type:text" json:"features"` // JSON: 特性列表 IsFeatured bool `gorm:"default:false" json:"is_featured"` // 精选 IsNew bool `gorm:"default:false" json:"is_new"` // 新上架 Sort int `gorm:"default:0" json:"sort"` diff --git a/internal/model/enterprise.go b/internal/model/enterprise.go index e5e1e16..8eb1ca4 100644 --- a/internal/model/enterprise.go +++ b/internal/model/enterprise.go @@ -8,9 +8,9 @@ type Company struct { Name string `gorm:"size:100;not null" json:"name"` Logo string `gorm:"size:255" json:"logo"` Slogan string `gorm:"size:255" json:"slogan"` - Description string `gorm:"type:text" json:"description"` // 关于我们 - Vision string `gorm:"type:text" json:"vision"` // 愿景 - Mission string `gorm:"type:text" json:"mission"` // 使命 + Description string `gorm:"type:text" json:"description"` // 关于我们 + Vision string `gorm:"type:text" json:"vision"` // 愿景 + Mission string `gorm:"type:text" json:"mission"` // 使命 Phone string `gorm:"size:30" json:"phone"` Email string `gorm:"size:100" json:"email"` Address string `gorm:"size:255" json:"address"` @@ -41,8 +41,8 @@ type EnterpriseService struct { ID uint `gorm:"primaryKey" json:"id"` Title string `gorm:"size:100;not null" json:"title"` Description string `gorm:"type:text" json:"description"` - Icon string `gorm:"size:50" json:"icon"` // 图标标识 - Image string `gorm:"size:255" json:"image"` // 图片URL + Icon string `gorm:"size:50" json:"icon"` // 图标标识 + Image string `gorm:"size:255" json:"image"` // 图片URL Features string `gorm:"type:text" json:"features"` // JSON: 特性列表 Sort int `gorm:"default:0" json:"sort"` CreatedAt time.Time `json:"created_at"` diff --git a/internal/model/food.go b/internal/model/food.go index b20d024..4a1b9aa 100644 --- a/internal/model/food.go +++ b/internal/model/food.go @@ -14,25 +14,25 @@ type RestaurantCategory struct { // Restaurant 餐厅模型(3 套外卖网站共用同一套数据) type Restaurant struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:100;not null" json:"name"` - Description string `gorm:"type:text" json:"description"` - CategoryID uint `gorm:"index" json:"category_id"` - CategoryName string `gorm:"size:50" json:"category_name"` - Cover string `gorm:"size:255" json:"cover"` // 封面图URL - Logo string `gorm:"size:255" json:"logo"` // Logo URL - Rating float64 `gorm:"type:decimal(2,1);default:4.5" json:"rating"` - Sales int `gorm:"default:0" json:"sales"` // 月销量 - DeliveryFee float64 `gorm:"type:decimal(6,2);default:0" json:"delivery_fee"` // 配送费 - MinOrder float64 `gorm:"type:decimal(6,2);default:0" json:"min_order"` // 起送价 - DeliveryTime int `json:"delivery_time"` // 配送时间(分钟) - Distance string `gorm:"size:50" json:"distance"` // 距离 - Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 - Notice string `gorm:"type:text" json:"notice"` // 公告 - Status int `gorm:"default:1;not null" json:"status"` // 1=营业 0=休息 - Sort int `gorm:"default:0" json:"sort"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:100;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + CategoryID uint `gorm:"index" json:"category_id"` + CategoryName string `gorm:"size:50" json:"category_name"` + Cover string `gorm:"size:255" json:"cover"` // 封面图URL + Logo string `gorm:"size:255" json:"logo"` // Logo URL + Rating float64 `gorm:"type:decimal(2,1);default:4.5" json:"rating"` + Sales int `gorm:"default:0" json:"sales"` // 月销量 + DeliveryFee float64 `gorm:"type:decimal(6,2);default:0" json:"delivery_fee"` // 配送费 + MinOrder float64 `gorm:"type:decimal(6,2);default:0" json:"min_order"` // 起送价 + DeliveryTime int `json:"delivery_time"` // 配送时间(分钟) + Distance string `gorm:"size:50" json:"distance"` // 距离 + Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 + Notice string `gorm:"type:text" json:"notice"` // 公告 + Status int `gorm:"default:1;not null" json:"status"` // 1=营业 0=休息 + Sort int `gorm:"default:0" json:"sort"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // MenuCategory 菜品分类(属于某个餐厅) @@ -47,19 +47,19 @@ type MenuCategory struct { // MenuItem 菜品模型 type MenuItem struct { - ID uint `gorm:"primaryKey" json:"id"` - RestaurantID uint `gorm:"index;not null" json:"restaurant_id"` - CategoryID uint `gorm:"index" json:"category_id"` // 菜品分类ID - Name string `gorm:"size:100;not null" json:"name"` - Description string `gorm:"type:text" json:"description"` - Price float64 `gorm:"type:decimal(8,2);not null" json:"price"` - OriginalPrice float64 `gorm:"type:decimal(8,2)" json:"original_price"` - Image string `gorm:"size:255" json:"image"` - Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"` - Sales int `gorm:"default:0" json:"sales"` - Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 - Sort int `gorm:"default:0" json:"sort"` - Status int `gorm:"default:1;not null" json:"status"` // 1=上架 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + RestaurantID uint `gorm:"index;not null" json:"restaurant_id"` + CategoryID uint `gorm:"index" json:"category_id"` // 菜品分类ID + Name string `gorm:"size:100;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + Price float64 `gorm:"type:decimal(8,2);not null" json:"price"` + OriginalPrice float64 `gorm:"type:decimal(8,2)" json:"original_price"` + Image string `gorm:"size:255" json:"image"` + Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"` + Sales int `gorm:"default:0" json:"sales"` + Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 + Sort int `gorm:"default:0" json:"sort"` + Status int `gorm:"default:1;not null" json:"status"` // 1=上架 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/model/game.go b/internal/model/game.go index 0638c73..6742294 100644 --- a/internal/model/game.go +++ b/internal/model/game.go @@ -14,28 +14,28 @@ type GameCategory struct { // Game 游戏模型(3 套游戏网站共用同一套数据) type Game struct { - ID uint `gorm:"primaryKey" json:"id"` - Name string `gorm:"size:200;not null" json:"name"` - Description string `gorm:"type:text" json:"description"` - CategoryID uint `gorm:"index" json:"category_id"` - CategoryName string `gorm:"size:50" json:"category_name"` - Cover string `gorm:"size:255" json:"cover"` // 封面图URL - Developer string `gorm:"size:100" json:"developer"` // 开发商 - Publisher string `gorm:"size:100" json:"publisher"` // 发行商 - Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"` - Plays int `gorm:"default:0" json:"plays"` // 游玩次数 - Downloads int `gorm:"default:0" json:"downloads"` // 下载量 - Size string `gorm:"size:50" json:"size"` // 游戏大小 - Version string `gorm:"size:50" json:"version"` // 版本号 - ReleaseDate string `gorm:"size:20" json:"release_date"` // 发行日期 - Platform string `gorm:"size:100" json:"platform"` // 平台 - Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 - Screenshots string `gorm:"type:text" json:"screenshots"` // JSON: 截图描述数组 - Features string `gorm:"type:text" json:"features"` // JSON: 特性列表 - IsHot bool `gorm:"default:false" json:"is_hot"` // 热门 - IsNew bool `gorm:"default:false" json:"is_new"` // 新游 - Sort int `gorm:"default:0" json:"sort"` - Status int `gorm:"default:1;not null" json:"status"` // 1=上架 - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uint `gorm:"primaryKey" json:"id"` + Name string `gorm:"size:200;not null" json:"name"` + Description string `gorm:"type:text" json:"description"` + CategoryID uint `gorm:"index" json:"category_id"` + CategoryName string `gorm:"size:50" json:"category_name"` + Cover string `gorm:"size:255" json:"cover"` // 封面图URL + Developer string `gorm:"size:100" json:"developer"` // 开发商 + Publisher string `gorm:"size:100" json:"publisher"` // 发行商 + Rating float64 `gorm:"type:decimal(2,1);default:5.0" json:"rating"` + Plays int `gorm:"default:0" json:"plays"` // 游玩次数 + Downloads int `gorm:"default:0" json:"downloads"` // 下载量 + Size string `gorm:"size:50" json:"size"` // 游戏大小 + Version string `gorm:"size:50" json:"version"` // 版本号 + ReleaseDate string `gorm:"size:20" json:"release_date"` // 发行日期 + Platform string `gorm:"size:100" json:"platform"` // 平台 + Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 + Screenshots string `gorm:"type:text" json:"screenshots"` // JSON: 截图描述数组 + Features string `gorm:"type:text" json:"features"` // JSON: 特性列表 + IsHot bool `gorm:"default:false" json:"is_hot"` // 热门 + IsNew bool `gorm:"default:false" json:"is_new"` // 新游 + Sort int `gorm:"default:0" json:"sort"` + Status int `gorm:"default:1;not null" json:"status"` // 1=上架 + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/model/music.go b/internal/model/music.go index 5b3731e..9b13da8 100644 --- a/internal/model/music.go +++ b/internal/model/music.go @@ -15,23 +15,23 @@ type MusicCategory struct { // Song 歌曲模型(3 套音乐网站共用同一套数据) type Song struct { ID uint `gorm:"primaryKey" json:"id"` - Title string `gorm:"size:200;not null" json:"title"` // 歌曲名 - Artist string `gorm:"size:100;not null" json:"artist"` // 歌手 - Album string `gorm:"size:200" json:"album"` // 专辑 + Title string `gorm:"size:200;not null" json:"title"` // 歌曲名 + Artist string `gorm:"size:100;not null" json:"artist"` // 歌手 + Album string `gorm:"size:200" json:"album"` // 专辑 CategoryID uint `gorm:"index" json:"category_id"` - CategoryName string `gorm:"size:50" json:"category_name"` // 曲风 - Cover string `gorm:"size:255" json:"cover"` // 封面URL - Duration int `gorm:"default:0" json:"duration"` // 时长(秒) - PlayCount int `gorm:"default:0" json:"play_count"` // 播放量 - LikeCount int `gorm:"default:0" json:"like_count"` // 收藏量 - Lyrics string `gorm:"type:text" json:"lyrics"` // 歌词 - ReleaseDate string `gorm:"size:20" json:"release_date"` // 发行日期 - Language string `gorm:"size:50" json:"language"` // 语种 - Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 - IsHot bool `gorm:"default:false" json:"is_hot"` // 热门 - IsNew bool `gorm:"default:false" json:"is_new"` // 新歌 + CategoryName string `gorm:"size:50" json:"category_name"` // 曲风 + Cover string `gorm:"size:255" json:"cover"` // 封面URL + Duration int `gorm:"default:0" json:"duration"` // 时长(秒) + PlayCount int `gorm:"default:0" json:"play_count"` // 播放量 + LikeCount int `gorm:"default:0" json:"like_count"` // 收藏量 + Lyrics string `gorm:"type:text" json:"lyrics"` // 歌词 + ReleaseDate string `gorm:"size:20" json:"release_date"` // 发行日期 + Language string `gorm:"size:50" json:"language"` // 语种 + Tags string `gorm:"type:text" json:"tags"` // JSON: 标签数组 + IsHot bool `gorm:"default:false" json:"is_hot"` // 热门 + IsNew bool `gorm:"default:false" json:"is_new"` // 新歌 Sort int `gorm:"default:0" json:"sort"` - Status int `gorm:"default:1;not null" json:"status"` // 1=上架 + Status int `gorm:"default:1;not null" json:"status"` // 1=上架 CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/internal/model/user.go b/internal/model/user.go index dac8c13..dfc847b 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -10,8 +10,8 @@ import ( type User struct { ID uint `gorm:"primaryKey" json:"id"` Username string `gorm:"size:50;uniqueIndex;not null" json:"username"` - Password string `gorm:"size:255;not null" json:"-"` // bcrypt 哈希,不返回前端 - Phone string `gorm:"size:20;index" json:"phone"` // 手机号 + Password string `gorm:"size:255;not null" json:"-"` // bcrypt 哈希,不返回前端 + Phone string `gorm:"size:20;index" json:"phone"` // 手机号 Role string `gorm:"size:20;default:user;not null" json:"role"` // admin | user Avatar string `gorm:"size:255" json:"avatar"` // 头像 URL Status int `gorm:"default:1;not null" json:"status"` // 1=正常 0=禁用 diff --git a/internal/router/router.go b/internal/router/router.go index c763050..3292f58 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -6,6 +6,7 @@ import ( "gotest/internal/config" "gotest/internal/database" + "gotest/internal/geoip" "gotest/internal/handler" "gotest/internal/middleware" @@ -27,6 +28,10 @@ func Setup( appH *handler.AppHandler, musicH *handler.MusicHandler, dashboardH *handler.DashboardHandler, + customerH *handler.CustomerHandler, + accessLogH *handler.AccessLogHandler, + configH *handler.ConfigHandler, + ipLocationService *geoip.Service, ) *gin.Engine { if cfg.Env.Name == "prod" { gin.SetMode(gin.ReleaseMode) @@ -34,12 +39,13 @@ func Setup( r := gin.New() - // 全局中间件链(顺序:RequestID → Recover → Logger → CORS → RateLimit) + // 全局中间件链(顺序:RequestID → Recover → Logger → CORS → RateLimit → AccessLog) r.Use(middleware.RequestID()) r.Use(middleware.Recover()) r.Use(middleware.Logger()) r.Use(middleware.CORS(&cfg.CORS)) r.Use(middleware.RateLimit(&cfg.RateLimit)) + r.Use(middleware.AccessLog(db.DB, ipLocationService)) // 健康检查(含数据库就绪探针) r.GET("/api/health", func(c *gin.Context) { @@ -147,6 +153,15 @@ func Setup( music.GET("/new", musicH.ListNew) } + // 公开接口(无需认证) + // ... + + // 客户信息收集(公开,无需认证) + customer := api.Group("/customer") + { + customer.POST("/submit", customerH.Submit) + } + // 需要登录的路由 authed := api.Group("") authed.Use(middleware.JWTAuth(cfg.JWT.Secret)) @@ -180,6 +195,16 @@ func Setup( admin.POST("/dashboard/:table", dashboardH.CreateItem) admin.PUT("/dashboard/:table/:id", dashboardH.UpdateItem) admin.DELETE("/dashboard/:table/:id", dashboardH.DeleteItem) + + // 访问日志 + admin.GET("/access-logs", accessLogH.ListLogs) + admin.GET("/access-logs/stats", accessLogH.GetStats) + admin.DELETE("/access-logs", accessLogH.DeleteLogs) + + // 系统配置(webhook 等) + admin.GET("/config", configH.ListConfigs) + admin.GET("/config/:key", configH.GetConfig) + admin.PUT("/config/:key", configH.SetConfig) } } diff --git a/internal/service/food.go b/internal/service/food.go index ba30997..fd93b20 100644 --- a/internal/service/food.go +++ b/internal/service/food.go @@ -58,9 +58,9 @@ func (s *FoodService) GetRestaurantMenu(restaurantID uint) (map[string]interface return nil, err } return map[string]interface{}{ - "restaurant": restaurant, - "categories": categories, - "menu_items": items, + "restaurant": restaurant, + "categories": categories, + "menu_items": items, }, nil } diff --git a/internal/service/upload.go b/internal/service/upload.go index 54106b0..faa2f30 100644 --- a/internal/service/upload.go +++ b/internal/service/upload.go @@ -76,11 +76,11 @@ func (s *UploadService) Upload(module string, filename string, contentType strin // FileInfo 文件信息 type FileInfo struct { - Name string `json:"name"` - URL string `json:"url"` - Size int64 `json:"size"` - ModTime string `json:"mod_time"` - IsDir bool `json:"is_dir"` + Name string `json:"name"` + URL string `json:"url"` + Size int64 `json:"size"` + ModTime string `json:"mod_time"` + IsDir bool `json:"is_dir"` } // ListFiles 列出指定模块下的所有文件(递归遍历子目录) diff --git a/main.go b/main.go index 13ce45f..85d91a7 100644 --- a/main.go +++ b/main.go @@ -15,6 +15,7 @@ import ( "gotest/internal/config" "gotest/internal/database" + "gotest/internal/geoip" "gotest/internal/handler" "gotest/internal/repository" "gotest/internal/router" @@ -88,9 +89,15 @@ func main() { appHandler := handler.NewAppHandler(appService) musicHandler := handler.NewMusicHandler(musicService) dashboardHandler := handler.NewDashboardHandler(db.DB) + customerHandler := handler.NewCustomerHandler(cfg) + accessLogHandler := handler.NewAccessLogHandler(db.DB) + configHandler := handler.NewConfigHandler(db.DB) + + // IP 归属地查询服务 + ipLocationService := geoip.NewService() // 8. 设置路由 - r := router.Setup(cfg, db, authHandler, userHandler, adminHandler, uploadHandler, enterpriseHandler, shopHandler, foodHandler, gameHandler, appHandler, musicHandler, dashboardHandler) + r := router.Setup(cfg, db, authHandler, userHandler, adminHandler, uploadHandler, enterpriseHandler, shopHandler, foodHandler, gameHandler, appHandler, musicHandler, dashboardHandler, customerHandler, accessLogHandler, configHandler, ipLocationService) // 9. 配置嵌入式前端:18 套网站模板 setupFrontend(r) diff --git a/web/dist/admin/index.html b/web/dist/admin/index.html index 5de3081..060f41c 100644 --- a/web/dist/admin/index.html +++ b/web/dist/admin/index.html @@ -216,19 +216,38 @@ tbody tr:last-child td{border-bottom:none}