diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/IM_API_Collection.postman_collection.json b/IM_API_Collection.postman_collection.json new file mode 100644 index 0000000..0597276 --- /dev/null +++ b/IM_API_Collection.postman_collection.json @@ -0,0 +1,791 @@ +{ + "info": { + "_postman_id": "nl-im-api-collection", + "name": "IM系统 API 接口集合", + "description": "IM系统后端API接口测试集合,包含认证、用户、联系人、房间、消息、附件等所有接口", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "base_url", + "value": "http://localhost:12080", + "type": "string" + }, + { + "key": "token", + "value": "", + "type": "string" + }, + { + "key": "user_id", + "value": "10001", + "type": "string" + } + ], + "item": [ + { + "name": "认证模块", + "item": [ + { + "name": "用户登录", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"account\": \"user1@example.com\",\n \"password\": \"12345678\",\n \"remember\": true\n}" + }, + "url": { + "raw": "{{base_url}}/api/login", + "host": ["{{base_url}}"], + "path": ["api", "login"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "if (pm.response.code === 200) {", + " var jsonData = pm.response.json();", + " pm.collectionVariables.set(\"token\", jsonData.token);", + " pm.collectionVariables.set(\"user_id\", jsonData.user.id);", + "}" + ] + } + } + ] + }, + { + "name": "用户注册", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"newuser@example.com\",\n \"phone\": \"13800138000\",\n \"password\": \"12345678\",\n \"confirm_password\": \"12345678\",\n \"code\": \"123456\",\n \"agree_terms\": true\n}" + }, + "url": { + "raw": "{{base_url}}/api/register", + "host": ["{{base_url}}"], + "path": ["api", "register"] + } + } + }, + { + "name": "发送邮箱验证码", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"target\": \"test@example.com\",\n \"type\": \"email\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/send-email-code", + "host": ["{{base_url}}"], + "path": ["api", "send-email-code"] + } + } + }, + { + "name": "发送短信验证码", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"target\": \"13800138000\",\n \"type\": \"sms\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/send-sms-code", + "host": ["{{base_url}}"], + "path": ["api", "send-sms-code"] + } + } + }, + { + "name": "检查Token有效性", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/check-token", + "host": ["{{base_url}}"], + "path": ["api", "check-token"] + } + } + } + ] + }, + { + "name": "用户管理", + "item": [ + { + "name": "获取当前用户信息", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/user/my-info", + "host": ["{{base_url}}"], + "path": ["api", "user", "my-info"] + } + } + }, + { + "name": "获取用户列表", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/user/list?page=1&page_size=20", + "host": ["{{base_url}}"], + "path": ["api", "user", "list"], + "query": [ + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + } + ] + } + } + }, + { + "name": "创建用户", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"email\": \"admin@example.com\",\n \"phone\": \"13900139000\",\n \"password\": \"12345678\",\n \"name\": \"管理员\",\n \"avatar\": \"A\",\n \"desc\": \"系统管理员\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/user/create", + "host": ["{{base_url}}"], + "path": ["api", "user", "create"] + } + } + }, + { + "name": "更新用户信息", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": \"{{user_id}}\",\n \"updates\": {\n \"name\": \"新名称\",\n \"desc\": \"新签名\",\n \"region\": \"北京\"\n }\n}" + }, + "url": { + "raw": "{{base_url}}/api/user/update", + "host": ["{{base_url}}"], + "path": ["api", "user", "update"] + } + } + } + ] + }, + { + "name": "联系人管理", + "item": [ + { + "name": "获取联系人列表", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/contacts", + "host": ["{{base_url}}"], + "path": ["api", "contacts"] + } + } + }, + { + "name": "搜索用户", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/contacts/search?keyword=10002", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "search"], + "query": [ + { + "key": "keyword", + "value": "10002" + } + ] + } + } + }, + { + "name": "添加好友", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"to_user_id\": \"10002\",\n \"message\": \"你好,我想加你为好友\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/contacts/add-friend", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "add-friend"] + } + } + }, + { + "name": "获取好友申请列表", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/contacts/friend-requests", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "friend-requests"] + } + } + }, + { + "name": "接受好友申请", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"request_id\": 1\n}" + }, + "url": { + "raw": "{{base_url}}/api/contacts/accept-request", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "accept-request"] + } + } + }, + { + "name": "拒绝好友申请", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"request_id\": 1\n}" + }, + "url": { + "raw": "{{base_url}}/api/contacts/reject-request", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "reject-request"] + } + } + }, + { + "name": "获取分组列表", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/contacts/groups", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "groups"] + } + } + }, + { + "name": "创建分组", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"group_name\": \"家人\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/contacts/groups", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "groups"] + } + } + }, + { + "name": "更新分组", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"group_name\": \"朋友\",\n \"sort_order\": 1\n}" + }, + "url": { + "raw": "{{base_url}}/api/contacts/groups/update/1", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "groups", "update", "1"] + } + } + }, + { + "name": "删除分组", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/contacts/groups/delete/1", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "groups", "delete", "1"] + } + } + }, + { + "name": "获取好友详情", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/contacts/10002", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "10002"] + } + } + }, + { + "name": "更新好友信息", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"remark_name\": \"备注名称\",\n \"group_id\": 1,\n \"is_top\": true,\n \"is_muted\": false\n}" + }, + "url": { + "raw": "{{base_url}}/api/contacts/update/10002", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "update", "10002"] + } + } + }, + { + "name": "删除好友", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/contacts/delete/10002", + "host": ["{{base_url}}"], + "path": ["api", "contacts", "delete", "10002"] + } + } + } + ] + }, + { + "name": "房间管理", + "item": [ + { + "name": "创建房间", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"room_type\": \"p2p\",\n \"members\": [\"{{user_id}}\", \"10002\"]\n}" + }, + "url": { + "raw": "{{base_url}}/api/rooms", + "host": ["{{base_url}}"], + "path": ["api", "rooms"] + } + } + }, + { + "name": "获取房间信息", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/rooms/10001_10002", + "host": ["{{base_url}}"], + "path": ["api", "rooms", "10001_10002"] + } + } + } + ] + }, + { + "name": "消息管理", + "item": [ + { + "name": "发送消息", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + }, + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "X-User-ID", + "value": "{{user_id}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"receiver_user_id\": \"10002\",\n \"room_id\": \"10001_10002\",\n \"message_type\": 0,\n \"content\": \"这是一条测试消息\",\n \"duration\": 0\n}" + }, + "url": { + "raw": "{{base_url}}/api/send", + "host": ["{{base_url}}"], + "path": ["api", "send"] + } + } + }, + { + "name": "获取历史消息", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/messages?room_id=10001_10002&page=1&page_size=50", + "host": ["{{base_url}}"], + "path": ["api", "messages"], + "query": [ + { + "key": "room_id", + "value": "10001_10002" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "50" + } + ] + } + } + } + ] + }, + { + "name": "附件管理", + "item": [ + { + "name": "上传附件", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": [] + }, + { + "key": "type", + "value": "image", + "type": "text" + } + ] + }, + "url": { + "raw": "{{base_url}}/api/attachments/upload", + "host": ["{{base_url}}"], + "path": ["api", "attachments", "upload"] + } + } + }, + { + "name": "获取附件信息", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/attachments/1", + "host": ["{{base_url}}"], + "path": ["api", "attachments", "1"] + } + } + }, + { + "name": "获取附件列表", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/attachments?type=image&page=1&page_size=20", + "host": ["{{base_url}}"], + "path": ["api", "attachments"], + "query": [ + { + "key": "type", + "value": "image" + }, + { + "key": "page", + "value": "1" + }, + { + "key": "page_size", + "value": "20" + } + ] + } + } + }, + { + "name": "删除附件", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}" + } + ], + "url": { + "raw": "{{base_url}}/api/attachments/delete/1", + "host": ["{{base_url}}"], + "path": ["api", "attachments", "delete", "1"] + } + } + } + ] + }, + { + "name": "系统接口", + "item": [ + { + "name": "健康检查", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/health", + "host": ["{{base_url}}"], + "path": ["api", "health"] + } + } + }, + { + "name": "获取ICE服务器配置", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/ice-servers?user_id={{user_id}}", + "host": ["{{base_url}}"], + "path": ["api", "ice-servers"], + "query": [ + { + "key": "user_id", + "value": "{{user_id}}" + } + ] + } + } + }, + { + "name": "检查用户在线状态", + "request": { + "method": "GET", + "url": { + "raw": "{{base_url}}/api/check-user-online?user_id=10002", + "host": ["{{base_url}}"], + "path": ["api", "check-user-online"], + "query": [ + { + "key": "user_id", + "value": "10002" + } + ] + } + } + } + ] + } + ] +} + diff --git a/cmd/server/main.go b/cmd/server/main.go index e2b1481..463ed41 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,3 +1,22 @@ +/** + * package main + * + * IM系统后端服务主程序 + * + * 功能概述: + * 1. 初始化配置、数据库、Redis等基础服务 + * 2. 初始化业务服务(认证、用户、联系人、房间、聊天、附件等) + * 3. 启动WebSocket服务器和TURN服务器 + * 4. 注册HTTP API路由和中间件 + * 5. 启动HTTP服务器 + * + * 技术栈: + * - Gin: HTTP Web框架 + * - GORM: ORM数据库操作 + * - Redis: 缓存和消息队列 + * - WebSocket: 实时通信 + * - JWT: 身份认证 + */ package main import ( @@ -8,9 +27,11 @@ import ( "xk-websocket-v2/internal/api" "xk-websocket-v2/internal/manager" + "xk-websocket-v2/internal/middleware" "xk-websocket-v2/internal/model" "xk-websocket-v2/internal/service" "xk-websocket-v2/internal/turnserver" + "xk-websocket-v2/internal/utils" "xk-websocket-v2/internal/ws" "github.com/gin-gonic/gin" @@ -21,7 +42,17 @@ import ( "gorm.io/gorm" ) -// ... (initConfig, initDB, initRedis 保持不变) ... +/** + * initConfig + * + * 功能:初始化配置文件读取器 + * + * 步骤: + * 1. 设置配置文件名为 "config" + * 2. 设置配置文件类型为 YAML + * 3. 添加配置文件搜索路径(configs目录和当前目录) + * 4. 读取配置文件,如果失败则终止程序 + */ func initConfig() { viper.SetConfigName("config") viper.SetConfigType("yaml") @@ -32,72 +63,227 @@ func initConfig() { } } +/** + * initDB + * + * 功能:初始化MySQL数据库连接并执行数据库迁移 + * + * 步骤: + * 1. 从配置文件中读取数据库连接字符串(DSN) + * 2. 使用GORM连接MySQL数据库 + * 3. 如果连接失败,终止程序 + * 4. 执行自动数据库迁移,创建所有表结构 + * 5. 为所有表添加中文注释 + * 6. 返回数据库连接实例 + * + * @returns *gorm.DB 数据库连接实例 + */ func initDB() *gorm.DB { + // 步骤1: 从配置文件读取数据库连接字符串 dsn := viper.GetString("database.dsn") + + // 步骤2: 使用GORM连接MySQL数据库 db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{}) if err != nil { log.Fatalf("❌ 数据库连接失败: %v", err) } - db.AutoMigrate(&model.ChatMessage{}) + + // 步骤3: 执行自动数据库迁移,创建所有表结构 + err = db.AutoMigrate( + &model.ChatMessage{}, + &model.User{}, + &model.UserContact{}, + &model.ContactGroup{}, + &model.ChatRoom{}, + &model.FriendRequest{}, + &model.VerificationCode{}, + &model.Attachment{}, + &model.ApiRequestLog{}, + &model.LoginLog{}, + ) + if err != nil { + log.Fatalf("❌ 数据库迁移失败: %v", err) + } + + // 步骤4: 为所有表添加中文注释,提高数据库可读性 + tableComments := []struct { + table string + comment string + }{ + {"chat_messages", "聊天消息表,持久化存储聊天记录,包括文本、多媒体和信令状态"}, + {"users", "用户基本信息表"}, + {"user_contacts", "用户联系人表,存储好友关系、分组、备注等信息"}, + {"contact_groups", "联系人分组表"}, + {"chat_rooms", "聊天房间表,支持点对点和群聊"}, + {"friend_requests", "好友申请表"}, + {"verification_codes", "验证码表"}, + {"attachments", "附件表,记录上传的文件信息"}, + {"api_request_logs", "接口请求日志表"}, + {"login_logs", "登录日志表"}, + } + + for _, tc := range tableComments { + sql := fmt.Sprintf("ALTER TABLE `%s` COMMENT = '%s'", tc.table, tc.comment) + if err := db.Exec(sql).Error; err != nil { + log.Printf("⚠️ 添加表注释失败 %s: %v", tc.table, err) + } + } + + log.Println("✅ 数据库迁移完成") return db } +/** + * initRedis + * + * 功能:初始化Redis连接 + * + * 步骤: + * 1. 从配置文件读取Redis连接信息(地址、密码、数据库编号) + * 2. 创建Redis客户端实例 + * 3. 执行Ping操作测试连接 + * 4. 如果连接失败,终止程序 + * 5. 返回Redis客户端实例 + * + * @returns *redis.Client Redis客户端实例 + */ func initRedis() *redis.Client { + // 步骤1: 从配置文件读取Redis连接信息 rdb := redis.NewClient(&redis.Options{ - Addr: viper.GetString("redis.addr"), - Password: viper.GetString("redis.password"), - DB: viper.GetInt("redis.db"), + Addr: viper.GetString("redis.addr"), // Redis服务器地址 + Password: viper.GetString("redis.password"), // Redis密码 + DB: viper.GetInt("redis.db"), // Redis数据库编号 }) + + // 步骤2: 执行Ping操作测试连接是否正常 if _, err := rdb.Ping(rdb.Context()).Result(); err != nil { log.Fatalf("❌ Redis 连接失败: %v", err) } + return rdb } +/** + * main + * + * 功能:程序主入口,初始化所有服务并启动HTTP服务器 + * + * 执行流程: + * 1. 初始化配置、数据库、Redis + * 2. 启动WebSocket工作池 + * 3. 初始化雪花ID生成器 + * 4. 初始化所有业务服务 + * 5. 启动TURN服务器(用于WebRTC) + * 6. 创建Gin路由引擎 + * 7. 注册中间件(响应时间、请求日志、CORS) + * 8. 注册WebSocket路由 + * 9. 注册HTTP API路由 + * 10. 启动HTTP服务器 + */ func main() { - initConfig() - db := initDB() - rdb := initRedis() + // 步骤1: 初始化基础服务 + initConfig() // 读取配置文件 + db := initDB() // 连接MySQL数据库 + rdb := initRedis() // 连接Redis + // 步骤2: 启动WebSocket工作池,用于处理WebSocket消息 ws.StartWorkerPool() - defer ws.StopWorkerPool() - service.InitChatService(db, rdb) + defer ws.StopWorkerPool() // 程序退出时关闭工作池 + + // 步骤3: 初始化雪花ID生成器(用于生成全局唯一ID) + // 从配置文件读取数据中心ID和机器ID,如果未配置则使用默认值 + datacenterID := viper.GetInt64("snowflake.datacenter_id") + if datacenterID == 0 { + datacenterID = 1 // 默认数据中心ID为1 + } + machineID := viper.GetInt64("snowflake.machine_id") + if machineID == 0 { + machineID = 1 // 默认机器ID为1 + } + if err := utils.InitSnowflake(datacenterID, machineID); err != nil { + log.Fatalf("❌ 初始化雪花ID生成器失败: %v", err) + } + + // 步骤4: 初始化所有业务服务 + service.InitChatService(db, rdb) // 聊天服务(消息处理、WebSocket分发) + service.InitAuthService(db, rdb) // 认证服务(登录、注册、验证码) + service.InitUserService(db) // 用户服务(用户信息管理) + service.InitContactService(db) // 联系人服务(好友管理、分组管理) + service.InitRoomService(db) // 房间服务(聊天房间管理) + service.InitAttachmentService(db) // 附件服务(文件上传、管理) + service.InitLoginLogService(db) // 登录日志服务(记录登录历史) + + // 步骤5: 启动TURN服务器(用于WebRTC音视频通话) go turnserver.Start() + // 步骤6: 创建Gin路由引擎 r := gin.Default() - // 允许跨域 (重要:为了前端本地开发) + // 步骤7: 注册中间件(按顺序执行) + // 响应时间统计中间件(必须在最前面,用于记录请求开始时间) + r.Use(middleware.ResponseTimeMiddleware()) + + // 接口请求日志中间件(记录所有API请求信息) + requestLogMiddleware := middleware.NewRequestLogMiddleware(db) + r.Use(requestLogMiddleware.Handler()) + + // CORS跨域中间件(允许前端跨域访问,重要:为了前端本地开发) r.Use(func(c *gin.Context) { + // 设置允许的源(*表示允许所有源) c.Writer.Header().Set("Access-Control-Allow-Origin", "*") - c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + // 设置允许的HTTP方法 + c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") + // 设置允许的请求头 c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-User-ID") + // 处理OPTIONS预检请求 if c.Request.Method == "OPTIONS" { - c.AbortWithStatus(204) + c.AbortWithStatus(204) // 返回204 No Content return } c.Next() }) + // 静态文件服务(提供附件访问功能) + // 访问路径:/uploads/xxx -> ./uploads/xxx + r.Static("/uploads", "./uploads") + + // 步骤8: 注册WebSocket路由 + // 路径:GET /ws?user_id=xxx r.GET("/ws", func(c *gin.Context) { + // 步骤1: 创建WebSocket升级器,允许所有来源连接 upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + + // 步骤2: 将HTTP连接升级为WebSocket连接 conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) if err != nil { - return + return // 升级失败,直接返回 } + // 步骤3: 获取用户ID(从查询参数中获取) userID := c.Query("user_id") + + // 步骤4: 生成唯一的客户端ID(节点ID + 时间戳) clientID := fmt.Sprintf("%s-%d", viper.GetString("app.node_id"), time.Now().UnixNano()) + + // 步骤5: 创建客户端对象,包含连接和发送队列 client := &manager.Client{ID: clientID, Conn: conn, SendQueue: make(chan []byte, 256)} + // 步骤6: 注册客户端到管理器 manager.Manager.Register(client) + + // 步骤7: 如果提供了用户ID,绑定用户到客户端 if userID != "" { service.ChatSvc.BindUser(client, userID) } + + // 步骤8: 发送客户端ID给前端 client.SendQueue <- []byte(fmt.Sprintf(`{"clientId": "%s"}`, clientID)) + // 步骤9: 循环读取WebSocket消息 for { _, message, err := conn.ReadMessage() if err != nil { + // 连接断开,注销客户端 manager.Manager.Unregister(client) break } @@ -107,22 +293,66 @@ func main() { } }) + // 步骤9: 注册HTTP API路由 apiGroup := r.Group("/api") { + // 公开接口(不需要认证,任何人都可以访问) + apiGroup.POST("/login", api.LoginHandler) + apiGroup.POST("/register", api.RegisterHandler) + apiGroup.POST("/send-email-code", api.SendEmailCodeHandler) + apiGroup.POST("/send-sms-code", api.SendSmsCodeHandler) + apiGroup.GET("/check-token", api.CheckTokenHandler) + apiGroup.GET("/health", api.HealthHandler) + apiGroup.GET("/ice-servers", api.ICEHandler) + + // 消息相关(可选认证,兼容旧代码) apiGroup.POST("/send", api.SendHandler) apiGroup.POST("/send-to-user", api.SendToUserHandler) apiGroup.POST("/bind", api.BindHandler) apiGroup.GET("/check-user-online", api.CheckUserOnlineHandler) apiGroup.GET("/messages", api.HistoryHandler) apiGroup.GET("/messages/sync", api.SyncMessagesHandler) - apiGroup.GET("/health", api.HealthHandler) - apiGroup.GET("/ice-servers", api.ICEHandler) - // 新增:联系人列表 - apiGroup.GET("/contacts", api.ContactListHandler) + // 需要认证的接口组(必须携带有效的JWT Token) + authGroup := apiGroup.Group("") + authGroup.Use(middleware.JWTAuthMiddleware()) // 使用JWT认证中间件 + { + // 用户管理 + authGroup.GET("/user/my-info", api.GetMyInfoHandler) + authGroup.GET("/user/list", api.GetUserListHandler) + authGroup.POST("/user/create", api.CreateUserHandler) + authGroup.POST("/user/update", api.UpdateUserHandler) + authGroup.POST("/user/delete", api.DeleteUserHandler) + + // 联系人管理 + authGroup.GET("/contacts", api.ContactListHandler) + authGroup.GET("/contacts/search", api.SearchUsersHandler) + authGroup.POST("/contacts/add-friend", api.AddFriendHandler) + authGroup.GET("/contacts/friend-requests", api.GetFriendRequestsHandler) + authGroup.POST("/contacts/accept-request", api.AcceptFriendRequestHandler) + authGroup.POST("/contacts/reject-request", api.RejectFriendRequestHandler) + authGroup.GET("/contacts/groups", api.GetGroupsHandler) + authGroup.POST("/contacts/groups", api.CreateGroupHandler) + authGroup.POST("/contacts/groups/update/:id", api.UpdateGroupHandler) + authGroup.POST("/contacts/groups/delete/:id", api.DeleteGroupHandler) + authGroup.GET("/contacts/:id", api.GetContactDetailHandler) + authGroup.POST("/contacts/update/:id", api.UpdateContactHandler) + authGroup.POST("/contacts/delete/:id", api.DeleteContactHandler) + + // 房间管理 + authGroup.POST("/rooms", api.CreateRoomHandler) + authGroup.GET("/rooms/:id", api.GetRoomHandler) + + // 附件管理 + authGroup.POST("/attachments/upload", api.UploadAttachmentHandler) + authGroup.GET("/attachments", api.GetAttachmentsHandler) + authGroup.GET("/attachments/:id", api.GetAttachmentHandler) + authGroup.POST("/attachments/delete/:id", api.DeleteAttachmentHandler) + } } - port := viper.GetString("app.port") + // 步骤10: 启动HTTP服务器 + port := viper.GetString("app.port") // 从配置文件读取端口号 log.Printf("🚀 服务启动在端口: %s", port) - r.Run(":" + port) + r.Run(":" + port) // 启动服务器并监听指定端口 } diff --git a/configs/config.yaml b/configs/config.yaml index 685cd8f..4407dae 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -16,7 +16,8 @@ app: database: # 数据库连接字符串 (DSN) # 格式: user:password@tcp(host:port)/dbname?charset=utf8mb4&parseTime=True&loc=Local - dsn: "root:root@tcp(127.0.0.1:3306)/nl_im?charset=utf8mb4&parseTime=True&loc=Local" +# dsn: "root:root@tcp(127.0.0.1:3306)/nl_im?charset=utf8mb4&parseTime=True&loc=Local" + dsn: "root:mysql_PKC65h@tcp(101.43.12.11:3306)/nl_im_plus?charset=utf8mb4&parseTime=True&loc=Local" # 连接池最大空闲连接数 max_idle_conns: 10 # 连接池最大打开连接数 @@ -26,11 +27,18 @@ database: # Redis 配置 (用于缓存和集群消息广播) # ========================================== redis: - addr: "127.0.0.1:6379" - password: "redis_pBjaRs" + addr: "101.43.12.11:6379" + password: "redis_xeePNa" # 数据库索引 (0-15) db: 0 +# ========================================== +# JWT 配置 +# ========================================== +jwt: + # JWT签名密钥(生产环境应使用强随机密钥) + secret: "xk-websocket-jwt-secret-key-2025-change-in-production" + # ========================================== # TURN/STUN 服务器配置 (WebRTC 中继) # ========================================== @@ -38,7 +46,7 @@ turn: # 是否启用内置 TURN 服务 enabled: true # 服务器公网 IP (重要:客户端必须能访问该IP) - public_ip: "127.0.0.1" + public_ip: "101.43.12.11" # TURN 服务监听端口 (UDP & TCP) listen_port: 3478 # 认证领域名称 diff --git a/go.mod b/go.mod index 30b6d67..44fcc14 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.24.1 require ( github.com/gin-gonic/gin v1.11.0 github.com/go-redis/redis/v8 v8.11.5 + github.com/golang-jwt/jwt/v5 v5.3.0 github.com/gorilla/websocket v1.5.3 github.com/panjf2000/ants/v2 v2.11.3 github.com/pion/turn/v2 v2.1.6 github.com/spf13/viper v1.21.0 + golang.org/x/crypto v0.45.0 gorm.io/driver/mysql v1.6.0 gorm.io/gorm v1.31.1 ) @@ -59,7 +61,6 @@ require ( go.uber.org/mock v0.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.23.0 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect diff --git a/go.sum b/go.sum index 0d20b6c..46ea8fd 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.0 h1:EmkZ9RIsX+Uq4DYFowegAuJo8+xdX3T/2dwNPXbxEYE= github.com/goccy/go-yaml v1.19.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= diff --git a/internal/api/attachment_handler.go b/internal/api/attachment_handler.go new file mode 100644 index 0000000..568951e --- /dev/null +++ b/internal/api/attachment_handler.go @@ -0,0 +1,153 @@ +/** + * package api + * 作用:附件管理相关API处理器 + */ +package api + +import ( + "strconv" + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/service" + "xk-websocket-v2/internal/utils" + + "github.com/gin-gonic/gin" +) + +/** + * UploadAttachmentHandler + * 功能:上传附件 + * 路径:POST /api/attachments/upload + */ +func UploadAttachmentHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + // 获取文件 + file, err := c.FormFile("file") + if err != nil { + utils.BadRequest(c, "文件上传失败: "+err.Error()) + return + } + + // 获取文件类型 + fileType := c.PostForm("type") + if fileType == "" { + // 根据文件扩展名推断类型 + ext := file.Filename[len(file.Filename)-4:] + if ext == ".jpg" || ext == ".png" || ext == ".gif" || ext == "webp" || ext == "jpeg" { + fileType = "image" + } else { + fileType = "video" + } + } + + // 验证文件类型 + if fileType != "image" && fileType != "video" { + utils.BadRequest(c, "文件类型必须是image或video") + return + } + + // 验证文件大小 + if fileType == "image" && file.Size > model.MaxImageSize { + utils.BadRequest(c, "图片大小不能超过10MB") + return + } + if fileType == "video" && file.Size > model.MaxVideoSize { + utils.BadRequest(c, "视频大小不能超过500MB") + return + } + + // 打开文件 + src, err := file.Open() + if err != nil { + utils.BadRequest(c, "打开文件失败: "+err.Error()) + return + } + defer src.Close() + + // 上传文件 + attachment, err := service.AttachmentSvc.UploadFile( + userID.(string), + file.Filename, + fileType, + file.Size, + src, + ) + if err != nil { + utils.BadRequest(c, err.Error()) + return + } + + utils.SuccessWithData(c, attachment, "上传成功") +} + +/** + * GetAttachmentHandler + * 功能:获取附件信息 + * 路径:GET /api/attachments/:id + */ +func GetAttachmentHandler(c *gin.Context) { + attachmentID, _ := strconv.ParseUint(c.Param("id"), 10, 32) + + attachment, err := service.AttachmentSvc.GetAttachment(uint(attachmentID)) + if err != nil { + utils.NotFound(c, "附件不存在") + return + } + + utils.SuccessWithData(c, attachment, "获取成功") +} + +/** + * DeleteAttachmentHandler + * 功能:删除附件(仅上传者可删除) + * 路径:DELETE /api/attachments/:id + */ +func DeleteAttachmentHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + attachmentID, _ := strconv.ParseUint(c.Param("id"), 10, 32) + + if err := service.AttachmentSvc.DeleteAttachment(uint(attachmentID), userID.(string)); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + utils.Success(c, "附件已删除") +} + +/** + * GetAttachmentsHandler + * 功能:获取附件列表 + * 路径:GET /api/attachments + */ +func GetAttachmentsHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + fileType := c.Query("type") + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + attachments, total, err := service.AttachmentSvc.GetUserAttachments(userID.(string), fileType, page, pageSize) + if err != nil { + utils.InternalError(c, "查询失败") + return + } + + // 确保返回空数组而不是null + if attachments == nil { + attachments = []model.Attachment{} + } + + utils.SuccessWithData(c, gin.H{ + "data": attachments, + "total": total, + "page": page, + "size": pageSize, + }, "获取成功") +} + diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go new file mode 100644 index 0000000..1652d90 --- /dev/null +++ b/internal/api/auth_handler.go @@ -0,0 +1,225 @@ +/** + * package api + * 作用:认证相关API处理器 + */ +package api + +import ( + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/service" + "xk-websocket-v2/internal/utils" + + "github.com/gin-gonic/gin" +) + +/** + * LoginHandler + * 功能:用户登录 + * 路径:POST /api/login + */ +func LoginHandler(c *gin.Context) { + var req model.LoginReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + // 获取客户端IP + ip := getClientIP(c) + + // 调用认证服务登录 + user, err := service.AuthSvc.Login(req.Account, req.Password) + if err != nil { + // 记录登录失败日志 + service.LoginLogSvc.LogLogin(req.Account, "0", ip, false) + utils.Unauthorized(c, err.Error()) + return + } + + // 生成Token + token, err := utils.GenerateToken(user.ID) + if err != nil { + // 记录登录失败日志 + service.LoginLogSvc.LogLogin(req.Account, "0", ip, false) + utils.InternalError(c, "生成Token失败") + return + } + + // 清除密码字段 + user.Password = "" + + // 记录登录成功日志 + service.LoginLogSvc.LogLogin(req.Account, user.ID, ip, true) + + utils.SuccessWithData(c, model.LoginResponse{ + Token: token, + User: *user, + }, "登录成功") +} + +// getClientIP 获取客户端IP +func getClientIP(c *gin.Context) string { + // 优先从X-Forwarded-For获取 + ip := c.GetHeader("X-Forwarded-For") + if ip != "" { + return ip + } + + // 从X-Real-IP获取 + ip = c.GetHeader("X-Real-IP") + if ip != "" { + return ip + } + + // 从RemoteAddr获取 + return c.ClientIP() +} + +/** + * RegisterHandler + * 功能:用户注册 + * 路径:POST /api/register + */ +func RegisterHandler(c *gin.Context) { + var req model.RegisterReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + // 如果提供了验证码,验证验证码 + if req.Code != "" { + // 根据邮箱或手机号确定类型 + codeType := "email" + if len(req.Phone) > 0 { + codeType = "sms" + } + target := req.Email + if codeType == "sms" { + target = req.Phone + } + + valid, err := service.AuthSvc.VerifyCode(target, req.Code, codeType) + if err != nil || !valid { + utils.BadRequest(c, "验证码无效或已过期") + return + } + } + + // 调用认证服务注册 + user, err := service.AuthSvc.Register(&req) + if err != nil { + utils.BadRequest(c, err.Error()) + return + } + + // 生成Token + token, err := utils.GenerateToken(user.ID) + if err != nil { + utils.InternalError(c, "生成Token失败") + return + } + + utils.SuccessWithData(c, model.RegisterResponse{ + Token: token, + User: *user, + }, "注册成功") +} + +/** + * LogoutHandler + * 功能:用户登出(可选,前端清除token) + * 路径:POST /api/logout + */ +func LogoutHandler(c *gin.Context) { + // 登出主要是前端清除token,后端可以记录日志或清除session + utils.Success(c, "登出成功") +} + +/** + * CheckTokenHandler + * 功能:检查Token有效性 + * 路径:GET /api/check-token + */ +func CheckTokenHandler(c *gin.Context) { + token := c.GetHeader("Authorization") + if token == "" { + utils.Unauthorized(c, "缺少Token") + return + } + + // 移除 "Bearer " 前缀(如果存在) + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + userID, err := utils.ValidateToken(token) + if err != nil { + utils.Unauthorized(c, "Token无效或已过期") + return + } + + utils.SuccessWithData(c, gin.H{ + "status": "valid", + "user_id": userID, + }, "Token有效") +} + +/** + * SendEmailCodeHandler + * 功能:发送邮箱验证码 + * 路径:POST /api/send-email-code + */ +func SendEmailCodeHandler(c *gin.Context) { + var req model.SendCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if req.Type != "email" { + utils.BadRequest(c, "类型必须是email") + return + } + + code, err := service.AuthSvc.SendEmailCode(req.Target) + if err != nil { + utils.InternalError(c, err.Error()) + return + } + + // 开发环境返回验证码,生产环境不应返回 + utils.SuccessWithData(c, gin.H{ + "code": code, // 仅开发环境,生产环境应移除 + }, "验证码已发送") +} + +/** + * SendSmsCodeHandler + * 功能:发送短信验证码 + * 路径:POST /api/send-sms-code + */ +func SendSmsCodeHandler(c *gin.Context) { + var req model.SendCodeReq + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if req.Type != "sms" { + utils.BadRequest(c, "类型必须是sms") + return + } + + code, err := service.AuthSvc.SendSmsCode(req.Target) + if err != nil { + utils.InternalError(c, err.Error()) + return + } + + // 开发环境返回验证码,生产环境不应返回 + utils.SuccessWithData(c, gin.H{ + "code": code, // 仅开发环境,生产环境应移除 + }, "验证码已发送") +} + diff --git a/internal/api/contact_handler.go b/internal/api/contact_handler.go new file mode 100644 index 0000000..8d3d4e8 --- /dev/null +++ b/internal/api/contact_handler.go @@ -0,0 +1,362 @@ +/** + * package api + * 作用:联系人管理相关API处理器 + */ +package api + +import ( + "strconv" + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/service" + "xk-websocket-v2/internal/utils" + + "github.com/gin-gonic/gin" +) + +/** + * ContactListHandler + * 功能:获取联系人列表(已存在,需改为从数据库查询) + * 路径:GET /api/contacts + */ +func ContactListHandler(c *gin.Context) { + // 从Context获取用户ID(由JWT中间件注入) + userID, exists := c.Get("user_id") + if !exists { + // 如果没有认证,返回空列表(兼容旧代码) + utils.SuccessWithData(c, []interface{}{}, "获取成功") + return + } + + contacts, err := service.ContactSvc.GetContactsWithUserInfo(userID.(string)) + if err != nil { + utils.InternalError(c, "查询失败") + return + } + + // 确保返回空数组而不是null + if contacts == nil { + contacts = []map[string]interface{}{} + } + + utils.SuccessWithData(c, contacts, "获取成功") +} + +/** + * SearchUsersHandler + * 功能:搜索用户 + * 路径:GET /api/contacts/search + */ +func SearchUsersHandler(c *gin.Context) { + keyword := c.Query("keyword") + if keyword == "" { + utils.BadRequest(c, "搜索关键词不能为空") + return + } + + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20")) + if limit < 1 || limit > 100 { + limit = 20 + } + + users, err := service.ContactSvc.SearchUsers(keyword, limit) + if err != nil { + utils.InternalError(c, "搜索失败") + return + } + + // 确保返回空数组而不是null + if users == nil { + users = []model.User{} + } + + utils.SuccessWithData(c, users, "搜索成功") +} + +/** + * AddFriendHandler + * 功能:添加好友(发送申请) + * 路径:POST /api/contacts/add-friend + */ +func AddFriendHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req struct { + ToUserID string `json:"to_user_id" binding:"required"` + Message string `json:"message"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := service.ContactSvc.AddFriend(userID.(string), req.ToUserID, req.Message); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + utils.Success(c, "好友申请已发送") +} + +/** + * GetFriendRequestsHandler + * 功能:获取好友申请列表 + * 路径:GET /api/contacts/friend-requests + */ +func GetFriendRequestsHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + requests, err := service.ContactSvc.GetFriendRequests(userID.(string)) + if err != nil { + utils.InternalError(c, "查询失败") + return + } + + // 确保返回空数组而不是null + if requests == nil { + requests = []model.FriendRequest{} + } + + utils.SuccessWithData(c, requests, "获取成功") +} + +/** + * AcceptFriendRequestHandler + * 功能:接受好友申请 + * 路径:POST /api/contacts/accept-request + */ +func AcceptFriendRequestHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req struct { + RequestID uint `json:"request_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if err := service.ContactSvc.AcceptFriendRequest(req.RequestID, userID.(string)); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + utils.Success(c, "已接受好友申请") +} + +/** + * RejectFriendRequestHandler + * 功能:拒绝好友申请 + * 路径:POST /api/contacts/reject-request + */ +func RejectFriendRequestHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req struct { + RequestID uint `json:"request_id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if err := service.ContactSvc.RejectFriendRequest(req.RequestID, userID.(string)); err != nil { + utils.BadRequest(c, err.Error()) + return + } + + utils.Success(c, "已拒绝好友申请") +} + +/** + * GetGroupsHandler + * 功能:获取分组列表 + * 路径:GET /api/contacts/groups + */ +func GetGroupsHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + groups, err := service.ContactSvc.GetGroups(userID.(string)) + if err != nil { + utils.InternalError(c, "查询失败") + return + } + + // 确保返回空数组而不是null + if groups == nil { + groups = []model.ContactGroup{} + } + + utils.SuccessWithData(c, groups, "获取成功") +} + +/** + * CreateGroupHandler + * 功能:创建分组 + * 路径:POST /api/contacts/groups + */ +func CreateGroupHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req struct { + GroupName string `json:"group_name" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + group, err := service.ContactSvc.CreateGroup(userID.(string), req.GroupName) + if err != nil { + utils.BadRequest(c, "创建失败: "+err.Error()) + return + } + + utils.SuccessWithData(c, group, "创建成功") +} + +/** + * UpdateGroupHandler + * 功能:更新分组 + * 路径:PUT /api/contacts/groups/:id + */ +func UpdateGroupHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + groupID, _ := strconv.ParseUint(c.Param("id"), 10, 32) + + var req struct { + GroupName string `json:"group_name"` + SortOrder int `json:"sort_order"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + updates := make(map[string]interface{}) + if req.GroupName != "" { + updates["group_name"] = req.GroupName + } + if req.SortOrder > 0 { + updates["sort_order"] = req.SortOrder + } + + if err := service.ContactSvc.UpdateGroup(uint(groupID), userID.(string), updates); err != nil { + utils.BadRequest(c, "更新失败: "+err.Error()) + return + } + + utils.Success(c, "更新成功") +} + +/** + * DeleteGroupHandler + * 功能:删除分组 + * 路径:DELETE /api/contacts/groups/:id + */ +func DeleteGroupHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + groupID, _ := strconv.ParseUint(c.Param("id"), 10, 32) + + if err := service.ContactSvc.DeleteGroup(uint(groupID), userID.(string)); err != nil { + utils.BadRequest(c, "删除失败: "+err.Error()) + return + } + + utils.Success(c, "删除成功") +} + +/** + * GetContactDetailHandler + * 功能:获取好友详情 + * 路径:GET /api/contacts/:id + */ +func GetContactDetailHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + contactID := c.Param("id") + + contact, err := service.ContactSvc.GetContactDetail(userID.(string), contactID) + if err != nil { + utils.NotFound(c, "好友不存在") + return + } + + // 获取联系人用户信息 + user, err := service.UserSvc.GetUserByID(contactID) + if err != nil { + utils.NotFound(c, "用户不存在") + return + } + + result := map[string]interface{}{ + "contact": contact, + "user": user, + } + + utils.SuccessWithData(c, result, "获取成功") +} + +/** + * UpdateContactHandler + * 功能:更新好友信息 + * 路径:PUT /api/contacts/:id + */ +func UpdateContactHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + contactID := c.Param("id") + + var req struct { + RemarkName string `json:"remark_name"` + GroupID uint `json:"group_id"` + IsTop *bool `json:"is_top"` + IsMuted *bool `json:"is_muted"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + updates := make(map[string]interface{}) + if req.RemarkName != "" { + updates["remark_name"] = req.RemarkName + } + if req.GroupID > 0 { + updates["group_id"] = req.GroupID + } + if req.IsTop != nil { + updates["is_top"] = *req.IsTop + } + if req.IsMuted != nil { + updates["is_muted"] = *req.IsMuted + } + + if err := service.ContactSvc.UpdateContact(userID.(string), contactID, updates); err != nil { + utils.BadRequest(c, "更新失败: "+err.Error()) + return + } + + utils.Success(c, "更新成功") +} + +/** + * DeleteContactHandler + * 功能:删除好友 + * 路径:DELETE /api/contacts/:id + */ +func DeleteContactHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + contactID := c.Param("id") + + if err := service.ContactSvc.DeleteContact(userID.(string), contactID); err != nil { + utils.BadRequest(c, "删除失败: "+err.Error()) + return + } + + utils.Success(c, "已删除好友") +} + diff --git a/internal/api/handler.go b/internal/api/handler.go index ed8a038..2c7398e 100644 --- a/internal/api/handler.go +++ b/internal/api/handler.go @@ -7,12 +7,13 @@ package api import ( "fmt" - "net/http" + "strconv" "time" "xk-websocket-v2/internal/manager" "xk-websocket-v2/internal/model" "xk-websocket-v2/internal/service" "xk-websocket-v2/internal/turnserver" + "xk-websocket-v2/internal/utils" "github.com/gin-gonic/gin" "github.com/spf13/viper" @@ -33,7 +34,7 @@ func SendHandler(c *gin.Context) { var req model.SendMessageReq // 1. 绑定并校验 JSON 参数 if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "无效的JSON参数"}) + utils.BadRequest(c, "无效的JSON参数") return } @@ -63,7 +64,7 @@ func SendHandler(c *gin.Context) { service.ChatSvc.HandleUserMessage(mockClient, &req) // 6. 返回成功响应 - c.JSON(http.StatusOK, gin.H{"status": "ok"}) + utils.Success(c, "消息已发送") } /** @@ -89,12 +90,12 @@ func SendToUserHandler(c *gin.Context) { func BindHandler(c *gin.Context) { var req model.BindReq if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"}) + utils.BadRequest(c, "参数错误") return } // 调用服务层进行绑定 service.ChatSvc.BindUserByClientID(req.ClientID, req.UserID) - c.JSON(http.StatusOK, gin.H{"status": "success"}) + utils.Success(c, "绑定成功") } /** @@ -106,7 +107,7 @@ func CheckUserOnlineHandler(c *gin.Context) { userID := c.Query("user_id") // 调用服务层查询 Redis isOnline := service.ChatSvc.IsUserOnline(userID) - c.JSON(http.StatusOK, gin.H{"status": "success", "result": isOnline}) + utils.SuccessWithData(c, gin.H{"is_online": isOnline}, "查询成功") } // ========================================== @@ -120,18 +121,52 @@ func CheckUserOnlineHandler(c *gin.Context) { */ func HistoryHandler(c *gin.Context) { roomID := c.Query("room_id") - var msgs []model.ChatMessage - - // 简单查询最近 50 条 - // 生产环境应添加 page, page_size 参数 - result := service.ChatSvc.DB.Where("room_id = ?", roomID). - Order("created_at desc").Limit(50).Find(&msgs) - - if result.Error != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "db error"}) + if roomID == "" { + utils.BadRequest(c, "room_id参数必填") return } - c.JSON(http.StatusOK, gin.H{"data": msgs}) + + // 分页参数 + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 50 + } + + var msgs []model.ChatMessage + var total int64 + + // 获取总数 + service.ChatSvc.DB.Model(&model.ChatMessage{}).Where("room_id = ?", roomID).Count(&total) + + // 分页查询 + offset := (page - 1) * pageSize + result := service.ChatSvc.DB.Where("room_id = ?", roomID). + Order("created_at desc"). + Offset(offset). + Limit(pageSize). + Find(&msgs) + + if result.Error != nil { + utils.InternalError(c, "db error") + return + } + + // 确保返回空数组而不是null + if msgs == nil { + msgs = []model.ChatMessage{} + } + + utils.SuccessWithData(c, gin.H{ + "data": msgs, + "total": total, + "page": page, + "size": pageSize, + }, "获取成功") } /** @@ -143,28 +178,6 @@ func SyncMessagesHandler(c *gin.Context) { HistoryHandler(c) } -/** - * ContactListHandler - * 功能:获取联系人列表 (模拟数据)。 - * 路径:GET /api/contacts - * 说明:用于前端展示登录后的好友列表。 - */ -func ContactListHandler(c *gin.Context) { - // 模拟 10 个用户数据 - users := []model.UserContact{ - {ID: "1001", Name: "张三 (我)", Avatar: "张", Desc: "Golang 专家"}, - {ID: "1002", Name: "李琦 (妻)", Avatar: "李", Desc: "在线"}, - {ID: "1003", Name: "王医生", Avatar: "医", Desc: "主任医师"}, - {ID: "1004", Name: "客服小蜜", Avatar: "客", Desc: "全天在线"}, - {ID: "1005", Name: "技术支持", Avatar: "技", Desc: "请重启试试"}, - {ID: "1006", Name: "财务小赵", Avatar: "财", Desc: "报销单请提交"}, - {ID: "1007", Name: "运维阿强", Avatar: "运", Desc: "服务器维护中"}, - {ID: "1008", Name: "测试小丽", Avatar: "测", Desc: "Bug 太多了"}, - {ID: "1009", Name: "HR", Avatar: "人", Desc: "本月考勤异常"}, - {ID: "1010", Name: "老板", Avatar: "老", Desc: "今晚开会"}, - } - c.JSON(http.StatusOK, gin.H{"data": users}) -} // ========================================== // 系统与 WebRTC 接口 @@ -176,11 +189,11 @@ func ContactListHandler(c *gin.Context) { * 路径:GET /api/health */ func HealthHandler(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{ + utils.SuccessWithData(c, gin.H{ "status": "ok", "node": viper.GetString("app.node_id"), "time": time.Now().Format(time.RFC3339), - }) + }, "服务正常") } /** @@ -205,5 +218,5 @@ func ICEHandler(c *gin.Context) { Username: username, Credential: credential, } - c.JSON(http.StatusOK, gin.H{"data": []model.ICEServerConfig{cfg}}) + utils.SuccessWithData(c, []model.ICEServerConfig{cfg}, "获取成功") } diff --git a/internal/api/room_handler.go b/internal/api/room_handler.go new file mode 100644 index 0000000..9840e68 --- /dev/null +++ b/internal/api/room_handler.go @@ -0,0 +1,71 @@ +/** + * package api + * 作用:房间管理相关API处理器 + */ +package api + +import ( + "xk-websocket-v2/internal/service" + "xk-websocket-v2/internal/utils" + + "github.com/gin-gonic/gin" +) + +/** + * CreateRoomHandler + * 功能:创建房间 + * 路径:POST /api/rooms + */ +func CreateRoomHandler(c *gin.Context) { + userID, _ := c.Get("user_id") + + var req struct { + RoomType string `json:"room_type" binding:"required,oneof=p2p group"` + Members []string `json:"members" binding:"required"` + RoomName string `json:"room_name"` + RoomAvatar string `json:"room_avatar"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + room, err := service.RoomSvc.CreateRoom(req.RoomType, req.Members, userID.(string)) + if err != nil { + utils.BadRequest(c, err.Error()) + return + } + + // 如果提供了房间名称和头像,更新 + if req.RoomName != "" || req.RoomAvatar != "" { + updates := make(map[string]interface{}) + if req.RoomName != "" { + updates["room_name"] = req.RoomName + } + if req.RoomAvatar != "" { + updates["room_avatar"] = req.RoomAvatar + } + service.RoomSvc.DB.Model(room).Updates(updates) + } + + utils.SuccessWithData(c, room, "创建成功") +} + +/** + * GetRoomHandler + * 功能:获取房间信息 + * 路径:GET /api/rooms/:id + */ +func GetRoomHandler(c *gin.Context) { + roomID := c.Param("id") + + room, err := service.RoomSvc.GetRoom(roomID) + if err != nil { + utils.NotFound(c, "房间不存在") + return + } + + utils.SuccessWithData(c, room, "获取成功") +} + diff --git a/internal/api/user_handler.go b/internal/api/user_handler.go new file mode 100644 index 0000000..df91dba --- /dev/null +++ b/internal/api/user_handler.go @@ -0,0 +1,155 @@ +/** + * package api + * 作用:用户管理相关API处理器 + */ +package api + +import ( + "fmt" + "math/rand" + "strconv" + "time" + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/service" + "xk-websocket-v2/internal/utils" + + "github.com/gin-gonic/gin" +) + +/** + * GetMyInfoHandler + * 功能:获取当前用户信息 + * 路径:GET /api/user/my-info + * 需要:JWT认证 + */ +func GetMyInfoHandler(c *gin.Context) { + // 从Context获取用户ID(由JWT中间件注入) + userID, exists := c.Get("user_id") + if !exists { + utils.Unauthorized(c, "未认证") + return + } + + user, err := service.UserSvc.GetUserByID(userID.(string)) + if err != nil { + utils.NotFound(c, "用户不存在") + return + } + + utils.SuccessWithData(c, user, "获取成功") +} + +/** + * GetUserListHandler + * 功能:获取用户列表 + * 路径:GET /api/user/list + */ +func GetUserListHandler(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20")) + + if page < 1 { + page = 1 + } + if pageSize < 1 || pageSize > 100 { + pageSize = 20 + } + + users, total, err := service.UserSvc.GetUserList(page, pageSize) + if err != nil { + utils.InternalError(c, "查询失败") + return + } + + // 确保返回空数组而不是null + if users == nil { + users = []model.User{} + } + + utils.SuccessWithData(c, gin.H{ + "data": users, + "total": total, + "page": page, + "size": pageSize, + }, "获取成功") +} + +/** + * CreateUserHandler + * 功能:创建用户(管理员) + * 路径:POST /api/user/create + */ +func CreateUserHandler(c *gin.Context) { + var user model.User + if err := c.ShouldBindJSON(&user); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + // 生成用户ID + user.ID = generateUserID() + + if err := service.UserSvc.CreateUser(&user); err != nil { + utils.BadRequest(c, "创建用户失败: "+err.Error()) + return + } + + user.Password = "" // 清除密码 + utils.SuccessWithData(c, user, "创建成功") +} + +/** + * UpdateUserHandler + * 功能:更新用户信息 + * 路径:POST /api/user/update + */ +func UpdateUserHandler(c *gin.Context) { + var req struct { + ID string `json:"id" binding:"required"` + Updates map[string]interface{} `json:"updates" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误: "+err.Error()) + return + } + + if err := service.UserSvc.UpdateUser(req.ID, req.Updates); err != nil { + utils.BadRequest(c, "更新失败: "+err.Error()) + return + } + + utils.Success(c, "更新成功") +} + +/** + * DeleteUserHandler + * 功能:删除用户 + * 路径:POST /api/user/delete + */ +func DeleteUserHandler(c *gin.Context) { + var req struct { + ID string `json:"id" binding:"required"` + } + + if err := c.ShouldBindJSON(&req); err != nil { + utils.BadRequest(c, "参数错误") + return + } + + if err := service.UserSvc.DeleteUser(req.ID); err != nil { + utils.BadRequest(c, "删除失败: "+err.Error()) + return + } + + utils.Success(c, "删除成功") +} + +// 辅助函数:生成用户ID +func generateUserID() string { + // 使用时间戳+随机数生成用户ID + timestamp := time.Now().UnixNano() + random := rand.Intn(1000000) + return fmt.Sprintf("user_%d_%d", timestamp, random) +} + diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go new file mode 100644 index 0000000..67e3720 --- /dev/null +++ b/internal/middleware/auth.go @@ -0,0 +1,112 @@ +/** + * package middleware + * 作用:JWT认证中间件 + */ +package middleware + +import ( + "net/http" + "xk-websocket-v2/internal/utils" + + "github.com/gin-gonic/gin" +) + +/** + * JWTAuthMiddleware + * + * 功能:JWT认证中间件 + * + * 作用: + * 1. 从HTTP请求头或查询参数中提取JWT Token + * 2. 验证Token的有效性和过期时间 + * 3. 从Token中解析出用户ID + * 4. 将用户ID注入到Gin Context中,供后续处理器使用 + * 5. 如果Token无效或缺失,返回401未授权错误 + * + * 使用场景: + * - 需要用户登录才能访问的API接口 + * - 需要在处理器中获取当前用户信息的接口 + * + * @returns gin.HandlerFunc 中间件处理函数 + */ +func JWTAuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 步骤1: 从HTTP请求头中获取Token(标准方式) + token := c.GetHeader("Authorization") + if token == "" { + // 步骤2: 如果请求头中没有,尝试从查询参数获取(兼容旧代码) + token = c.Query("token") + } + + // 步骤3: 如果仍然没有Token,返回401未授权错误 + if token == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "缺少认证Token"}) + c.Abort() // 终止请求处理 + return + } + + // 步骤4: 移除 "Bearer " 前缀(如果存在) + // 标准JWT Token格式:Bearer + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + // 步骤5: 验证Token的有效性和过期时间 + userID, err := utils.ValidateToken(token) + if err != nil { + // Token无效或已过期,返回401错误 + c.JSON(http.StatusUnauthorized, gin.H{"error": "Token无效或已过期: " + err.Error()}) + c.Abort() + return + } + + // 步骤6: 将解析出的用户ID注入到Context中 + // 后续处理器可以通过 c.Get("user_id") 获取当前用户ID + c.Set("user_id", userID) + c.Next() // 继续执行下一个中间件或处理器 + } +} + +/** + * OptionalJWTAuthMiddleware + * + * 功能:可选的JWT认证中间件(不强制要求认证) + * + * 作用: + * 1. 如果请求中提供了Token,则验证Token并注入用户ID + * 2. 如果没有提供Token,则继续执行,不返回错误 + * 3. 适用于既支持登录用户访问,也支持匿名用户访问的接口 + * + * 使用场景: + * - 公开接口,但登录用户可以获取更多信息 + * - 兼容旧代码,不强制要求认证 + * + * @returns gin.HandlerFunc 中间件处理函数 + */ +func OptionalJWTAuthMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 步骤1: 尝试从请求头或查询参数获取Token + token := c.GetHeader("Authorization") + if token == "" { + token = c.Query("token") + } + + // 步骤2: 如果提供了Token,则验证并注入用户ID + if token != "" { + // 移除 "Bearer " 前缀 + if len(token) > 7 && token[:7] == "Bearer " { + token = token[7:] + } + + // 验证Token,如果有效则注入用户ID + // 如果Token无效,不返回错误,继续执行(允许匿名访问) + if userID, err := utils.ValidateToken(token); err == nil { + c.Set("user_id", userID) + } + } + + // 步骤3: 继续执行,无论是否有Token + c.Next() + } +} + diff --git a/internal/middleware/request_log.go b/internal/middleware/request_log.go new file mode 100644 index 0000000..fb49f03 --- /dev/null +++ b/internal/middleware/request_log.go @@ -0,0 +1,173 @@ +/** + * package middleware + * 作用:接口请求日志中间件 + * 说明:记录所有API请求信息,异步写入数据库 + */ +package middleware + +import ( + "bytes" + "encoding/json" + "io" + "strings" + "time" + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/utils" + + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +// RequestLogMiddleware 请求日志中间件 +type RequestLogMiddleware struct { + DB *gorm.DB +} + +// NewRequestLogMiddleware 创建请求日志中间件 +func NewRequestLogMiddleware(db *gorm.DB) *RequestLogMiddleware { + return &RequestLogMiddleware{DB: db} +} + +// Handler 中间件处理函数 +func (m *RequestLogMiddleware) Handler() gin.HandlerFunc { + return func(c *gin.Context) { + // 跳过OPTIONS请求 + if c.Request.Method == "OPTIONS" { + c.Next() + return + } + + // 获取请求IP + ip := getClientIP(c) + + // 本地IP不记录 + if utils.GetIPLocation(ip) == "本地" { + c.Next() + return + } + + // 获取请求参数 + var requestBody []byte + if c.Request.Body != nil { + requestBody, _ = io.ReadAll(c.Request.Body) + c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody)) + } + + // 获取用户ID(从Context中获取,未登录为"0") + userID := "0" + if uid, exists := c.Get("user_id"); exists { + if uidStr, ok := uid.(string); ok { + userID = uidStr + } + } + + // 记录开始时间 + startTime := time.Now() + + // 创建响应写入器 + writer := &responseWriter{ + ResponseWriter: c.Writer, + body: &bytes.Buffer{}, + } + c.Writer = writer + + // 处理请求 + c.Next() + + // 计算请求时间 + duration := time.Since(startTime) + + // 异步记录日志(避免影响性能) + go m.logRequest(c, ip, userID, requestBody, writer.body.Bytes(), writer.status, duration) + } +} + +// logRequest 记录请求日志 +func (m *RequestLogMiddleware) logRequest(c *gin.Context, ip, userID string, requestBody, responseBody []byte, httpStatus int, duration time.Duration) { + // 获取IP归属地 + location := utils.GetIPLocation(ip) + + // 获取响应code(从响应体中解析) + responseCode := 0 + if len(responseBody) > 0 { + // 尝试解析响应体获取code + var resp model.ApiResponse + if err := json.Unmarshal(responseBody, &resp); err == nil { + responseCode = resp.Code + } + } + + // 限制请求参数长度(避免存储过大) + requestParams := string(requestBody) + if len(requestParams) > 5000 { + requestParams = requestParams[:5000] + "...(truncated)" + } + + // 限制返回参数长度 + responseParams := string(responseBody) + if len(responseParams) > 5000 { + responseParams = responseParams[:5000] + "...(truncated)" + } + + // 创建日志记录 + log := model.ApiRequestLog{ + Route: c.FullPath(), + IP: ip, + IPLocation: location, + UserID: userID, + Method: c.Request.Method, + RequestParams: requestParams, + ResponseParams: responseParams, + ResponseCode: responseCode, + HTTPStatus: httpStatus, + RequestTime: time.Now(), + } + + // 异步写入数据库 + m.DB.Create(&log) +} + +// getClientIP 获取客户端IP +func getClientIP(c *gin.Context) string { + // 优先从X-Forwarded-For获取 + ip := c.GetHeader("X-Forwarded-For") + if ip != "" { + // X-Forwarded-For可能包含多个IP,取第一个 + ips := strings.Split(ip, ",") + if len(ips) > 0 { + return strings.TrimSpace(ips[0]) + } + } + + // 从X-Real-IP获取 + ip = c.GetHeader("X-Real-IP") + if ip != "" { + return ip + } + + // 从RemoteAddr获取 + return c.ClientIP() +} + +// responseWriter 响应写入器(用于捕获响应内容) +type responseWriter struct { + gin.ResponseWriter + body *bytes.Buffer + status int +} + +func (w *responseWriter) Write(b []byte) (int, error) { + w.body.Write(b) + return w.ResponseWriter.Write(b) +} + +func (w *responseWriter) WriteString(s string) (int, error) { + w.body.WriteString(s) + return w.ResponseWriter.WriteString(s) +} + +func (w *responseWriter) WriteHeader(statusCode int) { + w.status = statusCode + w.ResponseWriter.WriteHeader(statusCode) +} + diff --git a/internal/middleware/response.go b/internal/middleware/response.go new file mode 100644 index 0000000..a1ed984 --- /dev/null +++ b/internal/middleware/response.go @@ -0,0 +1,26 @@ +/** + * package middleware + * 作用:响应时间统计中间件 + * 说明:记录请求开始时间,用于计算响应时间 + */ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" +) + +/** + * ResponseTimeMiddleware + * 作用:记录请求开始时间,用于后续计算响应时间 + */ +func ResponseTimeMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + // 记录请求开始时间 + c.Set("request_start_time", time.Now()) + // 继续处理请求 + c.Next() + } +} + diff --git a/internal/model/response.go b/internal/model/response.go new file mode 100644 index 0000000..3a96a51 --- /dev/null +++ b/internal/model/response.go @@ -0,0 +1,28 @@ +/** + * package model + * 作用:定义统一的API响应格式 + */ +package model + +/** + * InterfaceInfo + * 作用:接口信息,包含响应时间和服务器标识 + */ +type InterfaceInfo struct { + ResultTime string `json:"result_time"` // 响应时间,格式:XX ms + Ecs string `json:"ecs"` // 服务器标识 +} + +/** + * ApiResponse + * 作用:统一的API响应结构体 + * 说明:所有API响应都使用此格式,HTTP状态码统一返回200,错误通过code字段标识 + */ +type ApiResponse struct { + Code int `json:"code"` // 业务状态码:0=成功,非0=失败 + Message string `json:"message"` // 响应消息 + Result interface{} `json:"result"` // 响应数据 + Type string `json:"type"` // 响应类型:"success" 或 "error" + InterfaceInfo InterfaceInfo `json:"interface_info"` // 接口信息 +} + diff --git a/internal/model/types.go b/internal/model/types.go index 8d249fb..9bbe722 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -41,6 +41,11 @@ type ChatMessage struct { CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` } +// TableName 指定表名和注释 +func (ChatMessage) TableName() string { + return "chat_messages" +} + // ========================================== // 交互数据传输对象 (DTO - Data Transfer Object) // ========================================== @@ -125,19 +130,281 @@ type ICEServerConfig struct { Credential string `json:"credential,omitempty"` } +/** + * User + * 对应数据库表:users + * 作用:用户基本信息表 + */ +type User struct { + // 用户唯一ID + ID string `gorm:"primaryKey;type:varchar(100);comment:用户唯一ID" json:"id"` + // 邮箱 + Email string `gorm:"type:varchar(255);uniqueIndex;comment:邮箱" json:"email"` + // 手机号 + Phone string `gorm:"type:varchar(20);uniqueIndex;comment:手机号" json:"phone"` + // 密码(加密后) + Password string `gorm:"type:varchar(255);comment:密码(加密后)" json:"-"` + // 用户名称 + Name string `gorm:"type:varchar(100);comment:用户名称" json:"name"` + // 用户头像URL或字符 + Avatar string `gorm:"type:varchar(500);comment:用户头像" json:"avatar"` + // 用户描述或签名 + Desc string `gorm:"type:varchar(500);comment:用户描述或签名" json:"desc"` + // 地区 + Region string `gorm:"type:varchar(100);comment:地区" json:"region"` + // 创建时间 + CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` + // 更新时间 + UpdatedAt time.Time `gorm:"autoUpdateTime;comment:更新时间" json:"updated_at"` +} + +// TableName 指定表名和注释 +func (User) TableName() string { + return "users" +} + /** * UserContact - * 作用:模拟联系人结构 + * 对应数据库表:user_contacts + * 作用:用户联系人表,存储好友关系、分组、备注等信息 */ type UserContact struct { - // 用户唯一ID - ID string `json:"id"` - // 用户名称 - Name string `json:"name"` - // 用户头像URL或字符 - Avatar string `json:"avatar"` - // 用户描述或签名 - Desc string `json:"desc"` + // 主键ID + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 用户ID - 当前用户 + UserID string `gorm:"type:varchar(100);index;comment:用户ID" json:"user_id"` + // 联系人ID - 好友的用户ID + ContactID string `gorm:"type:varchar(100);index;comment:联系人ID" json:"contact_id"` + // 备注名称 - 用户自定义的好友备注 + RemarkName string `gorm:"type:varchar(100);comment:备注名称" json:"remark_name"` + // 分组ID - 好友所属分组 + GroupID uint `gorm:"type:int;index;comment:分组ID" json:"group_id"` + // 是否置顶 + IsTop bool `gorm:"type:tinyint(1);default:0;comment:是否置顶" json:"is_top"` + // 是否免打扰 + IsMuted bool `gorm:"type:tinyint(1);default:0;comment:是否免打扰" json:"is_muted"` + // 最后聊天时间 + LastChatTime *time.Time `gorm:"type:datetime;comment:最后聊天时间" json:"last_chat_time"` + // 最后一条消息 + LastMessage string `gorm:"type:text;comment:最后一条消息" json:"last_message"` + // 未读消息数 + UnreadCount int `gorm:"type:int;default:0;comment:未读消息数" json:"unread_count"` + // 创建时间 + CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` + // 更新时间 + UpdatedAt time.Time `gorm:"autoUpdateTime;comment:更新时间" json:"updated_at"` +} + +// TableName 指定表名和注释 +func (UserContact) TableName() string { + return "user_contacts" +} + +/** + * ContactGroup + * 对应数据库表:contact_groups + * 作用:联系人分组表 + */ +type ContactGroup struct { + // 主键ID + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 用户ID - 分组所属用户 + UserID string `gorm:"type:varchar(100);index;comment:用户ID" json:"user_id"` + // 分组名称 + GroupName string `gorm:"type:varchar(100);comment:分组名称" json:"group_name"` + // 排序顺序 + SortOrder int `gorm:"type:int;default:0;comment:排序顺序" json:"sort_order"` + // 创建时间 + CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` +} + +// TableName 指定表名和注释 +func (ContactGroup) TableName() string { + return "contact_groups" +} + +/** + * ChatRoom + * 对应数据库表:chat_rooms + * 作用:聊天房间表,支持点对点和群聊 + */ +type ChatRoom struct { + // 房间ID - 主键 + RoomID string `gorm:"primaryKey;type:varchar(100);comment:房间ID" json:"room_id"` + // 房间类型 - "p2p" 点对点 / "group" 群聊 + RoomType string `gorm:"type:varchar(20);index;comment:房间类型" json:"room_type"` + // 房间名称 - 群聊时显示 + RoomName string `gorm:"type:varchar(200);comment:房间名称" json:"room_name"` + // 房间头像 - 群聊时显示 + RoomAvatar string `gorm:"type:varchar(500);comment:房间头像" json:"room_avatar"` + // 成员列表 - JSON数组 + Members string `gorm:"type:text;comment:成员列表JSON" json:"members"` + // 创建者ID + CreatorID string `gorm:"type:varchar(100);index;comment:创建者ID" json:"creator_id"` + // 最后消息时间 + LastMessageTime *time.Time `gorm:"type:datetime;index;comment:最后消息时间" json:"last_message_time"` + // 最后消息内容 + LastMessage string `gorm:"type:text;comment:最后消息内容" json:"last_message"` + // 创建时间 + CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` + // 更新时间 + UpdatedAt time.Time `gorm:"autoUpdateTime;comment:更新时间" json:"updated_at"` +} + +// TableName 指定表名和注释 +func (ChatRoom) TableName() string { + return "chat_rooms" +} + +/** + * FriendRequest + * 对应数据库表:friend_requests + * 作用:好友申请表 + */ +type FriendRequest struct { + // 主键ID + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 发送者用户ID + FromUserID string `gorm:"type:varchar(100);index;comment:发送者用户ID" json:"from_user_id"` + // 接收者用户ID + ToUserID string `gorm:"type:varchar(100);index;comment:接收者用户ID" json:"to_user_id"` + // 申请消息 + Message string `gorm:"type:text;comment:申请消息" json:"message"` + // 状态 - "pending", "accepted", "rejected" + Status string `gorm:"type:varchar(20);default:'pending';index;comment:状态" json:"status"` + // 创建时间 + CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` + // 更新时间 + UpdatedAt time.Time `gorm:"autoUpdateTime;comment:更新时间" json:"updated_at"` +} + +// TableName 指定表名和注释 +func (FriendRequest) TableName() string { + return "friend_requests" +} + +/** + * VerificationCode + * 对应数据库表:verification_codes + * 作用:验证码表 + */ +type VerificationCode struct { + // 主键ID + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 邮箱或手机号 + Target string `gorm:"type:varchar(255);index;comment:邮箱或手机号" json:"target"` + // 验证码 + Code string `gorm:"type:varchar(10);comment:验证码" json:"-"` + // 类型 - "email" 或 "sms" + Type string `gorm:"type:varchar(20);comment:类型" json:"type"` + // 过期时间 + ExpiresAt time.Time `gorm:"type:datetime;index;comment:过期时间" json:"expires_at"` + // 创建时间 + CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` +} + +// TableName 指定表名和注释 +func (VerificationCode) TableName() string { + return "verification_codes" +} + +/** + * Attachment + * 对应数据库表:attachments + * 作用:附件表,记录上传的文件信息 + */ +type Attachment struct { + // 主键ID + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 上传者ID + UploaderID string `gorm:"type:varchar(100);index;comment:上传者ID" json:"uploader_id"` + // 文件名 + FileName string `gorm:"type:varchar(255);comment:文件名" json:"file_name"` + // 文件类型 - "image" 或 "video" + FileType string `gorm:"type:varchar(20);index;comment:文件类型" json:"file_type"` + // 文件大小(字节) + FileSize int64 `gorm:"type:bigint;comment:文件大小(字节)" json:"file_size"` + // 文件路径 + FilePath string `gorm:"type:varchar(500);comment:文件路径" json:"file_path"` + // 文件URL + FileURL string `gorm:"type:varchar(500);comment:文件URL" json:"file_url"` + // MIME类型 + MimeType string `gorm:"type:varchar(100);comment:MIME类型" json:"mime_type"` + // 宽度(图片/视频) + Width int `gorm:"type:int;comment:宽度" json:"width"` + // 高度(图片/视频) + Height int `gorm:"type:int;comment:高度" json:"height"` + // 时长(视频,秒) + Duration int `gorm:"type:int;comment:时长(秒)" json:"duration"` + // 创建时间 + CreatedAt time.Time `gorm:"autoCreateTime;comment:创建时间" json:"created_at"` +} + +// TableName 指定表名和注释 +func (Attachment) TableName() string { + return "attachments" +} + +/** + * ApiRequestLog + * 对应数据库表:api_request_logs + * 作用:接口请求日志表,记录所有API请求信息 + */ +type ApiRequestLog struct { + // 主键ID + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 请求路由 + Route string `gorm:"type:varchar(255);index;comment:请求路由" json:"route"` + // 请求IP + IP string `gorm:"type:varchar(50);index;comment:请求IP" json:"ip"` + // IP归属地 + IPLocation string `gorm:"type:varchar(255);comment:IP归属地" json:"ip_location"` + // 请求用户ID(未登录为0) + UserID string `gorm:"type:varchar(100);index;comment:请求用户ID" json:"user_id"` + // 请求方式 + Method string `gorm:"type:varchar(10);comment:请求方式" json:"method"` + // 请求参数(JSON) + RequestParams string `gorm:"type:text;comment:请求参数" json:"request_params"` + // 返回参数(JSON) + ResponseParams string `gorm:"type:text;comment:返回参数" json:"response_params"` + // 返回code + ResponseCode int `gorm:"type:int;index;comment:返回code" json:"response_code"` + // 返回http状态 + HTTPStatus int `gorm:"type:int;comment:返回http状态" json:"http_status"` + // 请求时间 + RequestTime time.Time `gorm:"type:datetime;index;comment:请求时间" json:"request_time"` +} + +// TableName 指定表名和注释 +func (ApiRequestLog) TableName() string { + return "api_request_logs" +} + +/** + * LoginLog + * 对应数据库表:login_logs + * 作用:登录日志表,记录所有登录尝试 + */ +type LoginLog struct { + // 主键ID + ID uint `gorm:"primaryKey;comment:主键ID" json:"id"` + // 登录账号 + Account string `gorm:"type:varchar(255);index;comment:登录账号" json:"account"` + // 登录ID(失败记录0) + UserID string `gorm:"type:varchar(100);index;comment:登录ID" json:"user_id"` + // 归属地(IP归属地) + Location string `gorm:"type:varchar(255);comment:归属地" json:"location"` + // 地址(IP地址) + IP string `gorm:"type:varchar(50);index;comment:IP地址" json:"ip"` + // 成功/失败状态(true=成功,false=失败) + Success bool `gorm:"type:tinyint(1);index;comment:成功状态" json:"success"` + // 登录时间 + LoginTime time.Time `gorm:"type:datetime;index;comment:登录时间" json:"login_time"` +} + +// TableName 指定表名和注释 +func (LoginLog) TableName() string { + return "login_logs" } /** @@ -153,6 +420,75 @@ type ClusterMessage struct { Payload interface{} `json:"payload"` } +// ========================================== +// 认证相关 DTO +// ========================================== + +/** + * LoginReq + * 作用:登录请求参数 + */ +type LoginReq struct { + // 账号(邮箱或手机号) + Account string `json:"account" binding:"required"` + // 密码 + Password string `json:"password" binding:"required"` + // 记住我 + Remember bool `json:"remember,omitempty"` +} + +/** + * RegisterReq + * 作用:注册请求参数 + */ +type RegisterReq struct { + // 邮箱 + Email string `json:"email" binding:"required,email"` + // 手机号 + Phone string `json:"phone" binding:"required"` + // 密码 + Password string `json:"password" binding:"required,min=8"` + // 确认密码 + ConfirmPassword string `json:"confirm_password" binding:"required"` + // 验证码(可选) + Code string `json:"code,omitempty"` + // 同意用户协议 + AgreeTerms bool `json:"agree_terms" binding:"required"` +} + +/** + * LoginResponse + * 作用:登录响应 + */ +type LoginResponse struct { + // Token + Token string `json:"token"` + // 用户信息 + User User `json:"user"` +} + +/** + * RegisterResponse + * 作用:注册响应 + */ +type RegisterResponse struct { + // Token + Token string `json:"token"` + // 用户信息 + User User `json:"user"` +} + +/** + * SendCodeReq + * 作用:发送验证码请求 + */ +type SendCodeReq struct { + // 邮箱或手机号 + Target string `json:"target" binding:"required"` + // 类型 - "email" 或 "sms" + Type string `json:"type" binding:"required,oneof=email sms"` +} + // ========================================== // 常量定义 // ========================================== @@ -162,4 +498,8 @@ const ( KeyUserNodeMap = "ws:user:node:" // Redis Channel: 集群广播频道 ChanClusterBroadcast = "ws:cluster:broadcast" + + // 文件大小限制(字节) + MaxImageSize = 10 * 1024 * 1024 // 10MB + MaxVideoSize = 500 * 1024 * 1024 // 500MB ) diff --git a/internal/service/attachment_service.go b/internal/service/attachment_service.go new file mode 100644 index 0000000..a209e24 --- /dev/null +++ b/internal/service/attachment_service.go @@ -0,0 +1,207 @@ +/** + * package service + * 作用:附件管理服务 + */ +package service + +import ( + "errors" + "fmt" + "io" + "mime" + "os" + "path/filepath" + "strings" + "time" + "xk-websocket-v2/internal/model" + + "gorm.io/gorm" +) + +// AttachmentService 附件服务结构体 +type AttachmentService struct { + DB *gorm.DB +} + +// AttachmentSvc 全局单例 +var AttachmentSvc *AttachmentService + +/** + * InitAttachmentService + * 功能:初始化附件服务 + */ +func InitAttachmentService(db *gorm.DB) { + AttachmentSvc = &AttachmentService{DB: db} + // 创建上传目录 + os.MkdirAll("./uploads/images", os.ModePerm) + os.MkdirAll("./uploads/videos", os.ModePerm) +} + +/** + * UploadFile + * 功能:上传文件 + * @param userID 上传者ID + * @param fileName 文件名 + * @param fileType 文件类型(image/video) + * @param fileSize 文件大小 + * @param fileData 文件数据 + * @returns 附件信息和错误 + */ +func (s *AttachmentService) UploadFile(userID, fileName, fileType string, fileSize int64, fileData io.Reader) (*model.Attachment, error) { + // 验证文件类型 + if fileType != "image" && fileType != "video" { + return nil, errors.New("文件类型必须是image或video") + } + + // 验证文件大小 + if fileType == "image" && fileSize > model.MaxImageSize { + return nil, fmt.Errorf("图片大小不能超过%dMB", model.MaxImageSize/(1024*1024)) + } + if fileType == "video" && fileSize > model.MaxVideoSize { + return nil, fmt.Errorf("视频大小不能超过%dMB", model.MaxVideoSize/(1024*1024)) + } + + // 验证文件扩展名 + ext := strings.ToLower(filepath.Ext(fileName)) + allowedExts := s.getAllowedExtensions(fileType) + if !contains(allowedExts, ext) { + return nil, fmt.Errorf("不支持的文件类型,允许的类型: %v", allowedExts) + } + + // 生成唯一文件名 + timestamp := time.Now().UnixNano() + randomStr := fmt.Sprintf("%d", timestamp%1000000) + newFileName := fmt.Sprintf("%d_%s%s", timestamp, randomStr, ext) + + // 确定保存路径 + var saveDir string + if fileType == "image" { + saveDir = "./uploads/images" + } else { + saveDir = "./uploads/videos" + } + + filePath := filepath.Join(saveDir, newFileName) + + // 保存文件 + file, err := os.Create(filePath) + if err != nil { + return nil, fmt.Errorf("创建文件失败: %v", err) + } + defer file.Close() + + _, err = io.Copy(file, fileData) + if err != nil { + os.Remove(filePath) // 删除失败的文件 + return nil, fmt.Errorf("保存文件失败: %v", err) + } + + // 生成访问URL + fileURL := fmt.Sprintf("/uploads/%s/%s", fileType+"s", newFileName) + + // 获取MIME类型 + mimeType := mime.TypeByExtension(ext) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + // 创建附件记录 + attachment := model.Attachment{ + UploaderID: userID, + FileName: fileName, + FileType: fileType, + FileSize: fileSize, + FilePath: filePath, + FileURL: fileURL, + MimeType: mimeType, + } + + if err := s.DB.Create(&attachment).Error; err != nil { + os.Remove(filePath) // 删除文件 + return nil, fmt.Errorf("保存附件记录失败: %v", err) + } + + return &attachment, nil +} + +/** + * GetAttachment + * 功能:获取附件信息 + */ +func (s *AttachmentService) GetAttachment(attachmentID uint) (*model.Attachment, error) { + var attachment model.Attachment + result := s.DB.First(&attachment, attachmentID) + if result.Error != nil { + return nil, result.Error + } + return &attachment, nil +} + +/** + * DeleteAttachment + * 功能:删除附件(验证上传者权限) + */ +func (s *AttachmentService) DeleteAttachment(attachmentID uint, userID string) error { + var attachment model.Attachment + if err := s.DB.First(&attachment, attachmentID).Error; err != nil { + return err + } + + // 验证权限 + if attachment.UploaderID != userID { + return errors.New("无权删除此附件") + } + + // 删除文件 + if _, err := os.Stat(attachment.FilePath); err == nil { + os.Remove(attachment.FilePath) + } + + // 删除记录 + return s.DB.Delete(&attachment).Error +} + +/** + * GetUserAttachments + * 功能:获取用户附件列表 + */ +func (s *AttachmentService) GetUserAttachments(userID, fileType string, page, pageSize int) ([]model.Attachment, int64, error) { + var attachments []model.Attachment + var total int64 + + query := s.DB.Where("uploader_id = ?", userID) + if fileType != "" { + query = query.Where("file_type = ?", fileType) + } + + // 获取总数 + query.Model(&model.Attachment{}).Count(&total) + + // 分页查询 + offset := (page - 1) * pageSize + result := query.Order("created_at DESC"). + Offset(offset). + Limit(pageSize). + Find(&attachments) + + return attachments, total, result.Error +} + +// 辅助函数:获取允许的文件扩展名 +func (s *AttachmentService) getAllowedExtensions(fileType string) []string { + if fileType == "image" { + return []string{".jpg", ".jpeg", ".png", ".gif", ".webp"} + } + return []string{".mp4", ".avi", ".mov", ".wmv", ".flv", ".mkv"} +} + +// 辅助函数:检查字符串是否在切片中 +func contains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} + diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go new file mode 100644 index 0000000..29fe0ca --- /dev/null +++ b/internal/service/auth_service.go @@ -0,0 +1,235 @@ +/** + * package service + * 作用:认证服务,处理登录、注册、验证码等功能 + */ +package service + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "math/big" + "strings" + "time" + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/utils" + + "github.com/go-redis/redis/v8" + "gorm.io/gorm" +) + +// AuthService 认证服务结构体 +type AuthService struct { + DB *gorm.DB + Redis *redis.Client +} + +// AuthSvc 全局单例 +var AuthSvc *AuthService + +/** + * InitAuthService + * 功能:初始化认证服务 + */ +func InitAuthService(db *gorm.DB, rdb *redis.Client) { + AuthSvc = &AuthService{DB: db, Redis: rdb} +} + +/** + * Login + * 功能:用户登录验证 + * @param account 账号(邮箱或手机号) + * @param password 密码 + * @returns 用户信息和错误 + */ +func (s *AuthService) Login(account, password string) (*model.User, error) { + var user model.User + + // 根据邮箱或手机号查询用户 + result := s.DB.Where("email = ? OR phone = ?", account, account).First(&user) + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, errors.New("用户不存在") + } + return nil, result.Error + } + + // 验证密码 + if !utils.CheckPassword(password, user.Password) { + return nil, errors.New("密码错误") + } + + return &user, nil +} + +/** + * Register + * 功能:用户注册 + * @param req 注册请求 + * @returns 用户信息和错误 + */ +func (s *AuthService) Register(req *model.RegisterReq) (*model.User, error) { + // 验证密码一致性 + if req.Password != req.ConfirmPassword { + return nil, errors.New("两次密码输入不一致") + } + + // 检查邮箱是否已存在 + var existingUser model.User + if err := s.DB.Where("email = ?", req.Email).First(&existingUser).Error; err == nil { + return nil, errors.New("邮箱已被注册") + } + + // 检查手机号是否已存在 + if err := s.DB.Where("phone = ?", req.Phone).First(&existingUser).Error; err == nil { + return nil, errors.New("手机号已被注册") + } + + // 加密密码 + hashedPassword, err := utils.HashPassword(req.Password) + if err != nil { + return nil, fmt.Errorf("密码加密失败: %v", err) + } + + // 生成用户ID(使用时间戳+随机数) + userID := fmt.Sprintf("%d%s", time.Now().UnixNano(), generateRandomString(6)) + + // 生成默认名称(使用邮箱前缀) + defaultName := req.Email + if atIndex := strings.Index(req.Email, "@"); atIndex > 0 { + defaultName = req.Email[:atIndex] + } + if len(defaultName) > 20 { + defaultName = defaultName[:20] + } + + // 创建用户 + user := model.User{ + ID: userID, + Email: req.Email, + Phone: req.Phone, + Password: hashedPassword, + Name: defaultName, + Avatar: generateAvatar(userID), + Desc: "", + Region: "", + } + + if err := s.DB.Create(&user).Error; err != nil { + return nil, fmt.Errorf("创建用户失败: %v", err) + } + + // 清除密码字段 + user.Password = "" + + return &user, nil +} + +/** + * SendEmailCode + * 功能:发送邮箱验证码(模拟) + * @param email 邮箱 + * @returns 验证码和错误 + */ +func (s *AuthService) SendEmailCode(email string) (string, error) { + // 生成6位验证码 + code := generateCode(6) + + // 保存验证码到数据库(5分钟过期) + vc := model.VerificationCode{ + Target: email, + Code: code, + Type: "email", + ExpiresAt: time.Now().Add(5 * time.Minute), + } + + if err := s.DB.Create(&vc).Error; err != nil { + return "", fmt.Errorf("保存验证码失败: %v", err) + } + + // 模拟发送(实际应调用邮件服务) + // 这里直接返回验证码,生产环境应通过邮件发送 + return code, nil +} + +/** + * SendSmsCode + * 功能:发送短信验证码(模拟) + * @param phone 手机号 + * @returns 验证码和错误 + */ +func (s *AuthService) SendSmsCode(phone string) (string, error) { + // 生成6位验证码 + code := generateCode(6) + + // 保存验证码到数据库(5分钟过期) + vc := model.VerificationCode{ + Target: phone, + Code: code, + Type: "sms", + ExpiresAt: time.Now().Add(5 * time.Minute), + } + + if err := s.DB.Create(&vc).Error; err != nil { + return "", fmt.Errorf("保存验证码失败: %v", err) + } + + // 模拟发送(实际应调用短信服务) + // 这里直接返回验证码,生产环境应通过短信发送 + return code, nil +} + +/** + * VerifyCode + * 功能:验证验证码 + * @param target 邮箱或手机号 + * @param code 验证码 + * @param codeType 类型(email/sms) + * @returns 是否有效 + */ +func (s *AuthService) VerifyCode(target, code, codeType string) (bool, error) { + var vc model.VerificationCode + + // 查询验证码 + result := s.DB.Where("target = ? AND code = ? AND type = ? AND expires_at > ?", + target, code, codeType, time.Now()). + Order("created_at DESC"). + First(&vc) + + if result.Error != nil { + if errors.Is(result.Error, gorm.ErrRecordNotFound) { + return false, errors.New("验证码无效或已过期") + } + return false, result.Error + } + + return true, nil +} + +// 辅助函数:生成随机字符串 +func generateRandomString(length int) string { + b := make([]byte, length) + rand.Read(b) + return hex.EncodeToString(b)[:length] +} + +// 辅助函数:生成验证码 +func generateCode(length int) string { + code := "" + for i := 0; i < length; i++ { + n, _ := rand.Int(rand.Reader, big.NewInt(10)) + code += n.String() + } + return code +} + +// 辅助函数:生成默认头像 +func generateAvatar(userID string) string { + // 简单实现:使用用户ID的第一个字符 + if len(userID) > 0 { + return string(userID[0]) + } + return "U" +} + diff --git a/internal/service/contact_service.go b/internal/service/contact_service.go new file mode 100644 index 0000000..e74e703 --- /dev/null +++ b/internal/service/contact_service.go @@ -0,0 +1,310 @@ +/** + * package service + * 作用:联系人管理服务 + */ +package service + +import ( + "errors" + "xk-websocket-v2/internal/model" + + "gorm.io/gorm" +) + +// ContactService 联系人服务结构体 +type ContactService struct { + DB *gorm.DB +} + +// ContactSvc 全局单例 +var ContactSvc *ContactService + +/** + * InitContactService + * 功能:初始化联系人服务 + */ +func InitContactService(db *gorm.DB) { + ContactSvc = &ContactService{DB: db} +} + +/** + * SearchUsers + * 功能:搜索用户(按用户名、ID、邮箱、手机号) + */ +func (s *ContactService) SearchUsers(keyword string, limit int) ([]model.User, error) { + return UserSvc.SearchUsers(keyword, limit) +} + +/** + * AddFriend + * 功能:发送好友申请 + */ +func (s *ContactService) AddFriend(fromUserID, toUserID, message string) error { + // 检查是否已经是好友 + var existingContact model.UserContact + result := s.DB.Where("user_id = ? AND contact_id = ?", fromUserID, toUserID).First(&existingContact) + if result.Error == nil { + return errors.New("已经是好友关系") + } + + // 检查是否已有待处理的申请 + var existingRequest model.FriendRequest + result = s.DB.Where("from_user_id = ? AND to_user_id = ? AND status = ?", + fromUserID, toUserID, "pending").First(&existingRequest) + if result.Error == nil { + return errors.New("已发送过好友申请") + } + + // 创建好友申请 + request := model.FriendRequest{ + FromUserID: fromUserID, + ToUserID: toUserID, + Message: message, + Status: "pending", + } + + return s.DB.Create(&request).Error +} + +/** + * GetFriendRequests + * 功能:获取好友申请列表 + */ +func (s *ContactService) GetFriendRequests(userID string) ([]model.FriendRequest, error) { + var requests []model.FriendRequest + result := s.DB.Where("to_user_id = ? AND status = ?", userID, "pending"). + Order("created_at DESC"). + Find(&requests) + return requests, result.Error +} + +/** + * AcceptFriendRequest + * 功能:接受好友申请 + */ +func (s *ContactService) AcceptFriendRequest(requestID uint, userID string) error { + // 查找申请 + var request model.FriendRequest + if err := s.DB.First(&request, requestID).Error; err != nil { + return err + } + + // 验证是否为接收者 + if request.ToUserID != userID { + return errors.New("无权操作此申请") + } + + // 开始事务 + tx := s.DB.Begin() + + // 更新申请状态 + if err := tx.Model(&request).Update("status", "accepted").Error; err != nil { + tx.Rollback() + return err + } + + // 创建双向好友关系 + contact1 := model.UserContact{ + UserID: request.FromUserID, + ContactID: request.ToUserID, + } + contact2 := model.UserContact{ + UserID: request.ToUserID, + ContactID: request.FromUserID, + } + + if err := tx.Create(&contact1).Error; err != nil { + tx.Rollback() + return err + } + if err := tx.Create(&contact2).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +/** + * RejectFriendRequest + * 功能:拒绝好友申请 + */ +func (s *ContactService) RejectFriendRequest(requestID uint, userID string) error { + var request model.FriendRequest + if err := s.DB.First(&request, requestID).Error; err != nil { + return err + } + + if request.ToUserID != userID { + return errors.New("无权操作此申请") + } + + return s.DB.Model(&request).Update("status", "rejected").Error +} + +/** + * GetContacts + * 功能:获取好友列表 + */ +func (s *ContactService) GetContacts(userID string) ([]model.UserContact, error) { + var contacts []model.UserContact + result := s.DB.Where("user_id = ?", userID). + Order("is_top DESC, last_chat_time DESC, created_at DESC"). + Find(&contacts) + return contacts, result.Error +} + +/** + * GetContactDetail + * 功能:获取好友详情 + */ +func (s *ContactService) GetContactDetail(userID, contactID string) (*model.UserContact, error) { + var contact model.UserContact + result := s.DB.Where("user_id = ? AND contact_id = ?", userID, contactID).First(&contact) + if result.Error != nil { + return nil, result.Error + } + return &contact, nil +} + +/** + * UpdateContact + * 功能:更新好友信息(备注、分组等) + */ +func (s *ContactService) UpdateContact(userID, contactID string, updates map[string]interface{}) error { + return s.DB.Model(&model.UserContact{}). + Where("user_id = ? AND contact_id = ?", userID, contactID). + Updates(updates).Error +} + +/** + * DeleteContact + * 功能:删除好友 + */ +func (s *ContactService) DeleteContact(userID, contactID string) error { + // 删除双向好友关系 + tx := s.DB.Begin() + + if err := tx.Where("user_id = ? AND contact_id = ?", userID, contactID).Delete(&model.UserContact{}).Error; err != nil { + tx.Rollback() + return err + } + + if err := tx.Where("user_id = ? AND contact_id = ?", contactID, userID).Delete(&model.UserContact{}).Error; err != nil { + tx.Rollback() + return err + } + + return tx.Commit().Error +} + +/** + * GetGroups + * 功能:获取分组列表 + */ +func (s *ContactService) GetGroups(userID string) ([]model.ContactGroup, error) { + var groups []model.ContactGroup + result := s.DB.Where("user_id = ?", userID). + Order("sort_order ASC, created_at ASC"). + Find(&groups) + return groups, result.Error +} + +/** + * CreateGroup + * 功能:创建分组 + */ +func (s *ContactService) CreateGroup(userID, groupName string) (*model.ContactGroup, error) { + // 获取当前最大排序值 + var maxOrder int + s.DB.Model(&model.ContactGroup{}). + Where("user_id = ?", userID). + Select("COALESCE(MAX(sort_order), 0)"). + Scan(&maxOrder) + + group := model.ContactGroup{ + UserID: userID, + GroupName: groupName, + SortOrder: maxOrder + 1, + } + + if err := s.DB.Create(&group).Error; err != nil { + return nil, err + } + + return &group, nil +} + +/** + * UpdateGroup + * 功能:更新分组 + */ +func (s *ContactService) UpdateGroup(groupID uint, userID string, updates map[string]interface{}) error { + return s.DB.Model(&model.ContactGroup{}). + Where("id = ? AND user_id = ?", groupID, userID). + Updates(updates).Error +} + +/** + * DeleteGroup + * 功能:删除分组 + */ +func (s *ContactService) DeleteGroup(groupID uint, userID string) error { + // 检查分组是否存在且属于该用户 + var group model.ContactGroup + if err := s.DB.Where("id = ? AND user_id = ?", groupID, userID).First(&group).Error; err != nil { + return err + } + + // 将该分组下的联系人移到默认分组(group_id = 0) + if err := s.DB.Model(&model.UserContact{}). + Where("user_id = ? AND group_id = ?", userID, groupID). + Update("group_id", 0).Error; err != nil { + return err + } + + // 删除分组 + return s.DB.Delete(&group).Error +} + +/** + * GetContactsWithUserInfo + * 功能:获取好友列表(包含用户信息) + */ +func (s *ContactService) GetContactsWithUserInfo(userID string) ([]map[string]interface{}, error) { + var contacts []model.UserContact + if err := s.DB.Where("user_id = ?", userID). + Order("is_top DESC, last_chat_time DESC"). + Find(&contacts).Error; err != nil { + return nil, err + } + + var result []map[string]interface{} + for _, contact := range contacts { + // 获取联系人用户信息 + var user model.User + if err := s.DB.Where("id = ?", contact.ContactID).First(&user).Error; err != nil { + continue + } + user.Password = "" + + // 组合数据 + item := map[string]interface{}{ + "id": contact.ContactID, + "name": user.Name, + "avatar": user.Avatar, + "desc": user.Desc, + "remark_name": contact.RemarkName, + "group_id": contact.GroupID, + "is_top": contact.IsTop, + "is_muted": contact.IsMuted, + "last_chat_time": contact.LastChatTime, + "last_message": contact.LastMessage, + "unread_count": contact.UnreadCount, + } + result = append(result, item) + } + + return result, nil +} + diff --git a/internal/service/login_log_service.go b/internal/service/login_log_service.go new file mode 100644 index 0000000..5734e3f --- /dev/null +++ b/internal/service/login_log_service.go @@ -0,0 +1,56 @@ +/** + * package service + * 作用:登录日志服务 + */ +package service + +import ( + "time" + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/utils" + + "gorm.io/gorm" +) + +// LoginLogService 登录日志服务 +type LoginLogService struct { + DB *gorm.DB +} + +// LoginLogSvc 全局单例 +var LoginLogSvc *LoginLogService + +/** + * InitLoginLogService + * 功能:初始化登录日志服务 + */ +func InitLoginLogService(db *gorm.DB) { + LoginLogSvc = &LoginLogService{DB: db} +} + +/** + * LogLogin + * 功能:记录登录日志 + * @param account 登录账号 + * @param userID 用户ID(失败为"0") + * @param ip IP地址 + * @param success 是否成功 + */ +func (s *LoginLogService) LogLogin(account, userID, ip string, success bool) { + // 获取IP归属地 + location := utils.GetIPLocation(ip) + + // 创建登录日志 + log := model.LoginLog{ + Account: account, + UserID: userID, + Location: location, + IP: ip, + Success: success, + LoginTime: time.Now(), + } + + // 异步写入数据库 + go s.DB.Create(&log) +} + diff --git a/internal/service/room_service.go b/internal/service/room_service.go new file mode 100644 index 0000000..cd0c8a3 --- /dev/null +++ b/internal/service/room_service.go @@ -0,0 +1,148 @@ +/** + * package service + * 作用:房间管理服务 + */ +package service + +import ( + "encoding/json" + "fmt" + "time" + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/utils" + + "gorm.io/gorm" +) + +// RoomService 房间服务结构体 +type RoomService struct { + DB *gorm.DB +} + +// RoomSvc 全局单例 +var RoomSvc *RoomService + +/** + * InitRoomService + * 功能:初始化房间服务 + */ +func InitRoomService(db *gorm.DB) { + RoomSvc = &RoomService{DB: db} +} + +/** + * GenerateP2PRoomID + * 功能:生成点对点聊天房间ID + * 规则:将两个用户ID按字母序排序后,用下划线连接 + */ +func GenerateP2PRoomID(userID1, userID2 string) string { + if userID1 < userID2 { + return fmt.Sprintf("%s_%s", userID1, userID2) + } + return fmt.Sprintf("%s_%s", userID2, userID1) +} + +/** + * GenerateGroupRoomID + * 功能:生成群聊房间ID + * 规则:使用雪花ID生成全局唯一ID + */ +func GenerateGroupRoomID() string { + id, err := utils.NextID() + if err != nil { + // 如果雪花ID生成失败,使用备用方案 + return fmt.Sprintf("group_%d", time.Now().UnixNano()) + } + return fmt.Sprintf("group_%d", id) +} + +/** + * CreateRoom + * 功能:创建房间 + */ +func (s *RoomService) CreateRoom(roomType string, members []string, creatorID string) (*model.ChatRoom, error) { + var roomID string + if roomType == "p2p" { + if len(members) != 2 { + return nil, fmt.Errorf("点对点房间需要2个成员") + } + roomID = GenerateP2PRoomID(members[0], members[1]) + } else { + roomID = GenerateGroupRoomID() + } + + // 检查房间是否已存在 + var existingRoom model.ChatRoom + if err := s.DB.Where("room_id = ?", roomID).First(&existingRoom).Error; err == nil { + return &existingRoom, nil + } + + // 序列化成员列表 + membersJSON, err := json.Marshal(members) + if err != nil { + return nil, err + } + + room := model.ChatRoom{ + RoomID: roomID, + RoomType: roomType, + Members: string(membersJSON), + CreatorID: creatorID, + } + + if roomType == "group" { + room.RoomName = "群聊" + } + + if err := s.DB.Create(&room).Error; err != nil { + return nil, err + } + + return &room, nil +} + +/** + * GetRoom + * 功能:获取房间信息 + */ +func (s *RoomService) GetRoom(roomID string) (*model.ChatRoom, error) { + var room model.ChatRoom + result := s.DB.Where("room_id = ?", roomID).First(&room) + if result.Error != nil { + return nil, result.Error + } + return &room, nil +} + +/** + * GetOrCreateP2PRoom + * 功能:获取或创建点对点房间 + */ +func (s *RoomService) GetOrCreateP2PRoom(userID1, userID2 string) (*model.ChatRoom, error) { + roomID := GenerateP2PRoomID(userID1, userID2) + + // 尝试获取现有房间 + var room model.ChatRoom + if err := s.DB.Where("room_id = ?", roomID).First(&room).Error; err == nil { + return &room, nil + } + + // 创建新房间 + members := []string{userID1, userID2} + return s.CreateRoom("p2p", members, userID1) +} + +/** + * UpdateRoomLastMessage + * 功能:更新房间最后消息 + */ +func (s *RoomService) UpdateRoomLastMessage(roomID, message string) error { + now := time.Now() + return s.DB.Model(&model.ChatRoom{}). + Where("room_id = ?", roomID). + Updates(map[string]interface{}{ + "last_message": message, + "last_message_time": now, + }).Error +} + diff --git a/internal/service/user_service.go b/internal/service/user_service.go new file mode 100644 index 0000000..c1604bd --- /dev/null +++ b/internal/service/user_service.go @@ -0,0 +1,165 @@ +/** + * package service + * 作用:用户管理服务 + */ +package service + +import ( + "errors" + "xk-websocket-v2/internal/model" + "xk-websocket-v2/internal/utils" + + "gorm.io/gorm" +) + +// UserService 用户服务结构体 +type UserService struct { + DB *gorm.DB +} + +// UserSvc 全局单例 +var UserSvc *UserService + +/** + * InitUserService + * 功能:初始化用户服务 + */ +func InitUserService(db *gorm.DB) { + UserSvc = &UserService{DB: db} +} + +/** + * GetUserByID + * 功能:根据ID获取用户 + */ +func (s *UserService) GetUserByID(userID string) (*model.User, error) { + var user model.User + result := s.DB.Where("id = ?", userID).First(&user) + if result.Error != nil { + return nil, result.Error + } + user.Password = "" // 清除密码 + return &user, nil +} + +/** + * GetUserByEmail + * 功能:根据邮箱获取用户 + */ +func (s *UserService) GetUserByEmail(email string) (*model.User, error) { + var user model.User + result := s.DB.Where("email = ?", email).First(&user) + if result.Error != nil { + return nil, result.Error + } + user.Password = "" // 清除密码 + return &user, nil +} + +/** + * GetUserByPhone + * 功能:根据手机号获取用户 + */ +func (s *UserService) GetUserByPhone(phone string) (*model.User, error) { + var user model.User + result := s.DB.Where("phone = ?", phone).First(&user) + if result.Error != nil { + return nil, result.Error + } + user.Password = "" // 清除密码 + return &user, nil +} + +/** + * CreateUser + * 功能:创建用户 + */ +func (s *UserService) CreateUser(user *model.User) error { + // 如果提供了密码,加密密码 + if user.Password != "" { + hashedPassword, err := utils.HashPassword(user.Password) + if err != nil { + return err + } + user.Password = hashedPassword + } + return s.DB.Create(user).Error +} + +/** + * UpdateUser + * 功能:更新用户信息 + */ +func (s *UserService) UpdateUser(userID string, updates map[string]interface{}) error { + // 如果更新密码,需要加密 + if password, ok := updates["password"].(string); ok && password != "" { + hashedPassword, err := utils.HashPassword(password) + if err != nil { + return err + } + updates["password"] = hashedPassword + } + return s.DB.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error +} + +/** + * DeleteUser + * 功能:删除用户 + */ +func (s *UserService) DeleteUser(userID string) error { + return s.DB.Where("id = ?", userID).Delete(&model.User{}).Error +} + +/** + * GetUserList + * 功能:获取用户列表(分页) + */ +func (s *UserService) GetUserList(page, pageSize int) ([]model.User, int64, error) { + var users []model.User + var total int64 + + // 获取总数 + s.DB.Model(&model.User{}).Count(&total) + + // 分页查询 + offset := (page - 1) * pageSize + result := s.DB.Offset(offset).Limit(pageSize).Find(&users) + if result.Error != nil { + return nil, 0, result.Error + } + + // 清除所有用户的密码 + for i := range users { + users[i].Password = "" + } + + return users, total, nil +} + +/** + * SearchUsers + * 功能:搜索用户(按用户名、ID、邮箱、手机号) + */ +func (s *UserService) SearchUsers(keyword string, limit int) ([]model.User, error) { + var users []model.User + + query := s.DB.Where("name LIKE ? OR id LIKE ? OR email LIKE ? OR phone LIKE ?", + "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%") + + if limit > 0 { + query = query.Limit(limit) + } + + result := query.Find(&users) + if result.Error != nil && !errors.Is(result.Error, gorm.ErrRecordNotFound) { + return nil, result.Error + } + + // 清除所有用户的密码 + for i := range users { + users[i].Password = "" + } + + return users, nil +} + diff --git a/internal/utils/ip_location.go b/internal/utils/ip_location.go new file mode 100644 index 0000000..9950068 --- /dev/null +++ b/internal/utils/ip_location.go @@ -0,0 +1,163 @@ +/** + * package utils + * 作用:IP归属地查询工具 + * 说明:使用第三方API查询IP归属地信息 + */ +package utils + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// IPLocationInfo IP归属地信息 +type IPLocationInfo struct { + Country string `json:"country"` // 国家 + Region string `json:"region"` // 省份 + City string `json:"city"` // 城市 + ISP string `json:"isp"` // 运营商 + FullLocation string `json:"full_location"` // 完整归属地 +} + +// 缓存结构(简单内存缓存) +var ( + ipCache = make(map[string]*IPLocationInfo) + cacheTimeout = 24 * time.Hour +) + +/** + * GetIPLocation + * 功能:获取IP归属地信息 + * @param ip IP地址 + * @returns 归属地信息字符串 + */ +func GetIPLocation(ip string) string { + // 排除本地IP + if isLocalIP(ip) { + return "本地" + } + + // 检查缓存 + if info, ok := ipCache[ip]; ok { + return info.FullLocation + } + + // 查询IP归属地 + info := queryIPLocation(ip) + if info != nil { + // 构建完整归属地字符串 + parts := []string{} + if info.Country != "" { + parts = append(parts, info.Country) + } + if info.Region != "" { + parts = append(parts, info.Region) + } + if info.City != "" { + parts = append(parts, info.City) + } + if info.ISP != "" { + parts = append(parts, info.ISP) + } + + if len(parts) > 0 { + info.FullLocation = strings.Join(parts, " ") + } else { + info.FullLocation = "未知" + } + + // 存入缓存 + ipCache[ip] = info + return info.FullLocation + } + + return "未知" +} + +/** + * isLocalIP + * 功能:判断是否为本地IP + */ +func isLocalIP(ip string) bool { + // 本地回环地址 + if ip == "127.0.0.1" || ip == "localhost" || ip == "::1" { + return true + } + + // 内网地址 + if strings.HasPrefix(ip, "192.168.") || + strings.HasPrefix(ip, "10.") || + strings.HasPrefix(ip, "172.16.") || + strings.HasPrefix(ip, "172.17.") || + strings.HasPrefix(ip, "172.18.") || + strings.HasPrefix(ip, "172.19.") || + strings.HasPrefix(ip, "172.20.") || + strings.HasPrefix(ip, "172.21.") || + strings.HasPrefix(ip, "172.22.") || + strings.HasPrefix(ip, "172.23.") || + strings.HasPrefix(ip, "172.24.") || + strings.HasPrefix(ip, "172.25.") || + strings.HasPrefix(ip, "172.26.") || + strings.HasPrefix(ip, "172.27.") || + strings.HasPrefix(ip, "172.28.") || + strings.HasPrefix(ip, "172.29.") || + strings.HasPrefix(ip, "172.30.") || + strings.HasPrefix(ip, "172.31.") { + return true + } + + return false +} + +/** + * queryIPLocation + * 功能:查询IP归属地(使用ip-api.com免费API) + */ +func queryIPLocation(ip string) *IPLocationInfo { + // 使用ip-api.com免费API(限制:每分钟45次请求) + url := fmt.Sprintf("http://ip-api.com/json/%s?lang=zh-CN&fields=status,message,country,regionName,city,isp", ip) + + client := &http.Client{ + Timeout: 3 * time.Second, + } + + resp, err := client.Get(url) + if err != nil { + return nil + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil + } + + var result struct { + Status string `json:"status"` + Message string `json:"message"` + Country string `json:"country"` + Region string `json:"regionName"` + City string `json:"city"` + ISP string `json:"isp"` + } + + if err := json.Unmarshal(body, &result); err != nil { + return nil + } + + if result.Status != "success" { + return nil + } + + return &IPLocationInfo{ + Country: result.Country, + Region: result.Region, + City: result.City, + ISP: result.ISP, + } +} + diff --git a/internal/utils/jwt.go b/internal/utils/jwt.go new file mode 100644 index 0000000..348be8b --- /dev/null +++ b/internal/utils/jwt.go @@ -0,0 +1,166 @@ +/** + * package utils + * + * JWT Token生成和验证工具包 + * + * 功能概述: + * 1. 生成JWT Token(包含用户ID和过期时间) + * 2. 解析JWT Token(验证签名和过期时间) + * 3. 验证Token有效性(提取用户ID) + * + * 使用场景: + * - 用户登录后生成Token + * - API请求时验证Token + * - 从Token中提取用户信息 + */ +package utils + +import ( + "errors" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/spf13/viper" +) + +// jwtSecret JWT签名密钥(从配置文件读取) +var jwtSecret []byte + +/** + * init + * + * 功能:初始化JWT签名密钥 + * + * 步骤: + * 1. 从配置文件读取JWT密钥 + * 2. 如果配置文件中没有,使用默认密钥(仅用于开发环境) + * 3. 将密钥转换为字节数组存储 + * + * 注意:生产环境必须使用配置文件中的强随机密钥 + */ +func init() { + secret := viper.GetString("jwt.secret") + if secret == "" { + secret = "xk-websocket-secret-key-2025" // 默认密钥,生产环境应使用配置 + } + jwtSecret = []byte(secret) +} + +/** + * Claims + * + * JWT Token的载荷结构 + * + * 字段说明: + * - UserID: 用户ID(业务数据) + * - RegisteredClaims: JWT标准声明(过期时间、签发时间等) + */ +type Claims struct { + UserID string `json:"user_id"` // 用户ID + jwt.RegisteredClaims // JWT标准声明 +} + +/** + * GenerateToken + * + * 功能:生成JWT Token + * + * 步骤: + * 1. 设置Token过期时间(默认7天) + * 2. 创建Claims对象,包含用户ID和标准声明 + * 3. 使用HS256算法签名Token + * 4. 返回Token字符串 + * + * @param userID 用户ID + * @returns token字符串和错误 + */ +func GenerateToken(userID string) (string, error) { + // 步骤1: 设置Token过期时间(7天后过期) + expirationTime := time.Now().Add(7 * 24 * time.Hour) + + // 步骤2: 创建Claims对象,包含用户ID和标准声明 + claims := &Claims{ + UserID: userID, // 业务数据:用户ID + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(expirationTime), // 过期时间 + IssuedAt: jwt.NewNumericDate(time.Now()), // 签发时间 + NotBefore: jwt.NewNumericDate(time.Now()), // 生效时间(立即生效) + }, + } + + // 步骤3: 使用HS256算法创建Token并签名 + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + tokenString, err := token.SignedString(jwtSecret) + if err != nil { + return "", err + } + + // 步骤4: 返回Token字符串 + return tokenString, nil +} + +/** + * ParseToken + * + * 功能:解析JWT Token + * + * 步骤: + * 1. 创建空的Claims对象 + * 2. 使用密钥解析Token并验证签名 + * 3. 检查Token是否有效(签名正确、未过期) + * 4. 返回Claims对象 + * + * @param tokenString token字符串 + * @returns Claims和错误 + */ +func ParseToken(tokenString string) (*Claims, error) { + // 步骤1: 创建空的Claims对象 + claims := &Claims{} + + // 步骤2: 解析Token并验证签名 + // 使用密钥验证Token的签名是否有效 + token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) { + return jwtSecret, nil // 返回签名密钥 + }) + + if err != nil { + return nil, err // 解析失败(签名错误、格式错误等) + } + + // 步骤3: 检查Token是否有效(签名正确、未过期) + if !token.Valid { + return nil, errors.New("invalid token") + } + + // 步骤4: 返回解析后的Claims对象 + return claims, nil +} + +/** + * ValidateToken + * + * 功能:验证Token有效性并提取用户ID + * + * 步骤: + * 1. 调用ParseToken解析Token + * 2. 如果解析成功,从Claims中提取用户ID + * 3. 返回用户ID + * + * 使用场景: + * - 中间件中验证Token + * - API处理器中获取当前用户ID + * + * @param tokenString token字符串 + * @returns 用户ID和错误 + */ +func ValidateToken(tokenString string) (string, error) { + // 步骤1: 解析Token + claims, err := ParseToken(tokenString) + if err != nil { + return "", err // Token无效或已过期 + } + + // 步骤2: 从Claims中提取用户ID + return claims.UserID, nil +} + diff --git a/internal/utils/password.go b/internal/utils/password.go new file mode 100644 index 0000000..eb86e6e --- /dev/null +++ b/internal/utils/password.go @@ -0,0 +1,82 @@ +/** + * package utils + * + * 密码加密和验证工具包 + * + * 功能概述: + * 1. 使用bcrypt算法加密密码(单向哈希,不可逆) + * 2. 验证明文密码与加密密码是否匹配 + * + * 安全特性: + * - 使用bcrypt算法,自动加盐 + * - 计算成本可调,防止暴力破解 + * - 相同密码每次加密结果不同(因为盐值随机) + */ +package utils + +import "golang.org/x/crypto/bcrypt" + +/** + * HashPassword + * + * 功能:使用bcrypt算法加密密码 + * + * 步骤: + * 1. 将明文密码转换为字节数组 + * 2. 使用bcrypt算法生成哈希值(自动加盐) + * 3. 将哈希值转换为字符串返回 + * + * 特点: + * - 每次加密结果不同(因为盐值随机) + * - 使用默认计算成本(10轮) + * - 单向加密,不可逆 + * + * 使用场景: + * - 用户注册时加密密码 + * - 用户修改密码时加密新密码 + * + * @param password 明文密码 + * @returns 加密后的密码哈希字符串和错误 + */ +func HashPassword(password string) (string, error) { + // 步骤1-2: 使用bcrypt算法生成密码哈希 + // bcrypt.DefaultCost = 10,表示进行2^10=1024轮哈希计算 + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + + // 步骤3: 将哈希值转换为字符串返回 + return string(bytes), nil +} + +/** + * CheckPassword + * + * 功能:验证明文密码与加密密码是否匹配 + * + * 步骤: + * 1. 从加密密码中提取盐值 + * 2. 使用相同的盐值对明文密码进行哈希 + * 3. 比较两个哈希值是否相同 + * + * 特点: + * - 即使密码相同,每次加密的哈希值也不同 + * - 但可以通过CompareHashAndPassword正确验证 + * - 验证过程是安全的,不会泄露密码信息 + * + * 使用场景: + * - 用户登录时验证密码 + * - 修改密码时验证旧密码 + * + * @param password 明文密码 + * @param hash 加密后的密码哈希 + * @returns 是否匹配(true=匹配,false=不匹配) + */ +func CheckPassword(password, hash string) bool { + // 步骤1-3: 比较明文密码的哈希值与存储的哈希值 + // 如果匹配,err为nil,返回true;否则返回false + err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) + return err == nil +} + diff --git a/internal/utils/response.go b/internal/utils/response.go new file mode 100644 index 0000000..50e3db5 --- /dev/null +++ b/internal/utils/response.go @@ -0,0 +1,172 @@ +/** + * package utils + * 作用:提供统一的API响应工具函数 + * 说明:所有响应统一返回HTTP 200状态码,错误通过code字段标识 + */ +package utils + +import ( + "net/http" + "strconv" + "time" + "xk-websocket-v2/internal/model" + + "github.com/gin-gonic/gin" + "github.com/spf13/viper" +) + +// 业务状态码常量 +const ( + CodeSuccess = 0 // 成功 + CodeBadRequest = 400 // 参数错误 + CodeUnauthorized = 401 // 未认证 + CodeForbidden = 403 // 无权限 + CodeNotFound = 404 // 资源不存在 + CodeInternalError = 500 // 服务器错误 +) + +// 响应类型常量 +const ( + TypeSuccess = "success" + TypeError = "error" +) + +/** + * getECS + * 作用:获取服务器标识 + */ +func getECS() string { + ecs := viper.GetString("server.ecs") + if ecs == "" { + return "localhost" + } + return ecs +} + +/** + * formatDuration + * 作用:格式化响应时间为 "XX ms" 格式 + */ +func formatDuration(d time.Duration) string { + ms := d.Milliseconds() + return strconv.FormatInt(ms, 10) + " ms" +} + +/** + * Response + * 作用:统一的响应函数,所有响应都通过此函数返回 + * 说明:统一返回HTTP 200状态码 + */ +func Response(c *gin.Context, code int, message string, result interface{}, responseType string) { + // 从Context获取请求开始时间 + startTime, exists := c.Get("request_start_time") + var duration time.Duration + if exists { + duration = time.Since(startTime.(time.Time)) + } else { + duration = 0 + } + + response := model.ApiResponse{ + Code: code, + Message: message, + Result: result, + Type: responseType, + InterfaceInfo: model.InterfaceInfo{ + ResultTime: formatDuration(duration), + Ecs: getECS(), + }, + } + + // 统一返回HTTP 200状态码 + c.JSON(http.StatusOK, response) +} + +/** + * Success + * 作用:成功响应(无数据) + */ +func Success(c *gin.Context, message string) { + if message == "" { + message = "操作成功" + } + Response(c, CodeSuccess, message, nil, TypeSuccess) +} + +/** + * SuccessWithData + * 作用:成功响应(带数据) + */ +func SuccessWithData(c *gin.Context, data interface{}, message string) { + if message == "" { + message = "获取成功" + } + Response(c, CodeSuccess, message, data, TypeSuccess) +} + +/** + * Error + * 作用:错误响应(自定义状态码和消息) + */ +func Error(c *gin.Context, code int, message string) { + if message == "" { + message = "操作失败" + } + Response(c, code, message, nil, TypeError) +} + +/** + * BadRequest + * 作用:参数错误响应(code=400) + */ +func BadRequest(c *gin.Context, message string) { + if message == "" { + message = "参数错误" + } + Response(c, CodeBadRequest, message, nil, TypeError) +} + +/** + * Unauthorized + * 作用:未认证响应(code=401) + */ +func Unauthorized(c *gin.Context, message string) { + if message == "" { + message = "未认证" + } + Response(c, CodeUnauthorized, message, nil, TypeError) +} + +/** + * Forbidden + * 作用:无权限响应(code=403) + */ +func Forbidden(c *gin.Context, message string) { + if message == "" { + message = "无权限" + } + Response(c, CodeForbidden, message, nil, TypeError) +} + +/** + * NotFound + * 作用:资源不存在响应(code=404) + */ +func NotFound(c *gin.Context, message string) { + if message == "" { + message = "资源不存在" + } + Response(c, CodeNotFound, message, nil, TypeError) +} + +/** + * InternalError + * 作用:服务器错误响应(code=500) + */ +func InternalError(c *gin.Context, message string) { + if message == "" { + message = "服务器错误" + } + Response(c, CodeInternalError, message, nil, TypeError) +} + diff --git a/internal/utils/snowflake.go b/internal/utils/snowflake.go new file mode 100644 index 0000000..58146ad --- /dev/null +++ b/internal/utils/snowflake.go @@ -0,0 +1,182 @@ +/** + * package utils + * 作用:雪花ID生成器,生成全局唯一的ID + * 说明:使用Twitter的雪花算法,生成64位整数ID + */ +package utils + +import ( + "errors" + "sync" + "time" +) + +const ( + // 时间戳占用位数(41位,可以使用69年) + timestampBits = 41 + // 数据中心ID占用位数(5位,最多32个数据中心) + datacenterIDBits = 5 + // 机器ID占用位数(5位,每个数据中心最多32台机器) + machineIDBits = 5 + // 序列号占用位数(12位,每毫秒最多4096个ID) + sequenceBits = 12 + + // 最大值 + maxDatacenterID = -1 ^ (-1 << datacenterIDBits) + maxMachineID = -1 ^ (-1 << machineIDBits) + maxSequence = -1 ^ (-1 << sequenceBits) + + // 位移 + machineIDShift = sequenceBits + datacenterIDShift = sequenceBits + machineIDBits + timestampShift = sequenceBits + machineIDBits + datacenterIDBits + + // 起始时间戳(2024-01-01 00:00:00) + epoch int64 = 1704067200000 +) + +// Snowflake 雪花ID生成器 +type Snowflake struct { + mutex sync.Mutex + datacenterID int64 + machineID int64 + sequence int64 + lastStamp int64 +} + +var ( + // 全局雪花ID生成器实例 + globalSnowflake *Snowflake + once sync.Once +) + +/** + * InitSnowflake + * 功能:初始化全局雪花ID生成器 + * @param datacenterID 数据中心ID(0-31) + * @param machineID 机器ID(0-31) + */ +func InitSnowflake(datacenterID, machineID int64) error { + if datacenterID < 0 || datacenterID > maxDatacenterID { + return errors.New("datacenter ID must be between 0 and 31") + } + if machineID < 0 || machineID > maxMachineID { + return errors.New("machine ID must be between 0 and 31") + } + + once.Do(func() { + globalSnowflake = &Snowflake{ + datacenterID: datacenterID, + machineID: machineID, + sequence: 0, + lastStamp: -1, + } + }) + + return nil +} + +/** + * NextID + * 功能:生成下一个ID + * @returns 64位整数ID + */ +func NextID() (int64, error) { + if globalSnowflake == nil { + // 默认使用datacenterID=1, machineID=1 + if err := InitSnowflake(1, 1); err != nil { + return 0, err + } + } + + return globalSnowflake.nextID() +} + +/** + * nextID + * 功能:生成下一个ID(内部方法) + */ +func (s *Snowflake) nextID() (int64, error) { + s.mutex.Lock() + defer s.mutex.Unlock() + + now := time.Now().UnixMilli() + + // 如果当前时间小于上次时间,说明时钟回拨 + if now < s.lastStamp { + return 0, errors.New("clock moved backwards") + } + + // 如果是同一毫秒内 + if now == s.lastStamp { + s.sequence = (s.sequence + 1) & maxSequence + // 序列号溢出,等待下一毫秒 + if s.sequence == 0 { + now = s.waitNextMillis(s.lastStamp) + } + } else { + // 新的毫秒,序列号重置 + s.sequence = 0 + } + + s.lastStamp = now + + // 生成ID + id := ((now - epoch) << timestampShift) | + (s.datacenterID << datacenterIDShift) | + (s.machineID << machineIDShift) | + s.sequence + + return id, nil +} + +/** + * waitNextMillis + * 功能:等待下一毫秒 + */ +func (s *Snowflake) waitNextMillis(lastStamp int64) int64 { + now := time.Now().UnixMilli() + for now <= lastStamp { + now = time.Now().UnixMilli() + } + return now +} + +/** + * NextIDString + * 功能:生成下一个ID(字符串格式) + */ +func NextIDString() (string, error) { + id, err := NextID() + if err != nil { + return "", err + } + return int64ToString(id), nil +} + +/** + * int64ToString + * 功能:将int64转换为字符串 + */ +func int64ToString(id int64) string { + if id == 0 { + return "0" + } + negative := id < 0 + if negative { + id = -id + } + + var result []byte + for id > 0 { + result = append([]byte{byte('0' + id%10)}, result...) + id /= 10 + } + + if negative { + result = append([]byte{'-'}, result...) + } + + return string(result) +} + diff --git a/server.exe b/server.exe new file mode 100644 index 0000000..0f8794e Binary files /dev/null and b/server.exe differ diff --git a/添加默认用户信息.sql b/添加默认用户信息.sql new file mode 100644 index 0000000..967b63f --- /dev/null +++ b/添加默认用户信息.sql @@ -0,0 +1,38 @@ +-- ========================================== +-- IM系统 - 添加默认用户信息 +-- 说明:为用户表添加20个测试用户,ID从10001开始 +-- 所有用户默认密码:12345678 +-- ========================================== + +-- 插入20个测试用户 +INSERT INTO `users` (`id`, `email`, `phone`, `password`, `name`, `avatar`, `desc`, `region`, `created_at`, `updated_at`) VALUES +('10001', 'user001@example.com', '13800138001', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '张三', '张', 'Golang 开发工程师', '北京', NOW(), NOW()), +('10002', 'user002@example.com', '13800138002', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '李四', '李', '前端开发工程师', '上海', NOW(), NOW()), +('10003', 'user003@example.com', '13800138003', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '王五', '王', '产品经理', '深圳', NOW(), NOW()), +('10004', 'user004@example.com', '13800138004', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '赵六', '赵', 'UI设计师', '广州', NOW(), NOW()), +('10005', 'user005@example.com', '13800138005', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '钱七', '钱', '测试工程师', '杭州', NOW(), NOW()), +('10006', 'user006@example.com', '13800138006', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '孙八', '孙', '运维工程师', '成都', NOW(), NOW()), +('10007', 'user007@example.com', '13800138007', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '周九', '周', '数据分析师', '武汉', NOW(), NOW()), +('10008', 'user008@example.com', '13800138008', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '吴十', '吴', '项目经理', '西安', NOW(), NOW()), +('10009', 'user009@example.com', '13800138009', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '郑十一', '郑', '架构师', '南京', NOW(), NOW()), +('10010', 'user010@example.com', '13800138010', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '王十二', '王', '技术总监', '苏州', NOW(), NOW()), +('10011', 'user011@example.com', '13800138011', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '冯十三', '冯', 'HR经理', '天津', NOW(), NOW()), +('10012', 'user012@example.com', '13800138012', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '陈十四', '陈', '财务经理', '重庆', NOW(), NOW()), +('10013', 'user013@example.com', '13800138013', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '褚十五', '褚', '市场经理', '长沙', NOW(), NOW()), +('10014', 'user014@example.com', '13800138014', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '卫十六', '卫', '销售经理', '郑州', NOW(), NOW()), +('10015', 'user015@example.com', '13800138015', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '蒋十七', '蒋', '客服主管', '济南', NOW(), NOW()), +('10016', 'user016@example.com', '13800138016', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '沈十八', '沈', '运营专员', '青岛', NOW(), NOW()), +('10017', 'user017@example.com', '13800138017', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '韩十九', '韩', '内容编辑', '大连', NOW(), NOW()), +('10018', 'user018@example.com', '13800138018', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '杨二十', '杨', '商务拓展', '厦门', NOW(), NOW()), +('10019', 'user019@example.com', '13800138019', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '朱二一', '朱', '法务专员', '福州', NOW(), NOW()), +('10020', 'user020@example.com', '13800138020', '$2a$10$OsVRHKowu.mVT4GNxzZ3/eo8xEABkWsUGAt4nV5nalzOXW47vtBmC', '秦二二', '秦', '行政助理', '合肥', NOW(), NOW()); + +-- 说明: +-- 1. 所有用户默认密码为:12345678 +-- 2. 密码已使用bcrypt加密存储 +-- 3. 用户ID从10001开始,连续到10020 +-- 4. 邮箱格式:user001@example.com 到 user020@example.com +-- 5. 手机号格式:13800138001 到 13800138020 +-- 6. 头像使用用户姓氏的首字符 +-- 7. 每个用户都有不同的职位和地区信息 +