diff --git a/backend/src/api/loginLog.js b/backend/src/api/loginLog.js new file mode 100644 index 0000000..3fae06c --- /dev/null +++ b/backend/src/api/loginLog.js @@ -0,0 +1,58 @@ +import request from "@/utils/request"; + +/** + * 获取登录日志列表 + * @param {Object} params 查询参数 + * @param {number} params.page 页码 + * @param {number} params.pageSize 每页数量 + * @param {string} params.keyword 关键词搜索(账号/姓名/租户/IP) + * @param {string} params.status 状态筛选 + * @param {string} params.loginType 登录方式筛选 + * @param {string} params.startTime 开始时间 + * @param {string} params.endTime 结束时间 + * @returns {Promise} + */ +export function getLoginLogs(params) { + return request({ + url: "/backend/loginLogs", + method: "get", + params, + }); +} + +/** + * 获取登录日志详情 + * @param {number|string} id 日志ID + * @returns {Promise} + */ +export function getLoginLogDetail(id) { + return request({ + url: `/backend/loginLogs/${id}`, + method: "get", + }); +} + +/** + * 删除登录日志 + * @param {number|string} id 日志ID + * @returns {Promise} + */ +export function deleteLoginLog(id) { + return request({ + url: `/backend/loginLogs/${id}`, + method: "delete", + }); +} + +/** + * 批量删除登录日志 + * @param {Array} ids 日志ID数组 + * @returns {Promise} + */ +export function batchDeleteLoginLogs(ids) { + return request({ + url: "/backend/loginLogs/batchDelete", + method: "post", + data: { ids }, + }); +} diff --git a/backend/src/views/system/loginLog/components/detail.vue b/backend/src/views/system/loginLog/components/detail.vue new file mode 100644 index 0000000..ad52941 --- /dev/null +++ b/backend/src/views/system/loginLog/components/detail.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/backend/src/views/system/loginLog/index.vue b/backend/src/views/system/loginLog/index.vue new file mode 100644 index 0000000..b2a9e29 --- /dev/null +++ b/backend/src/views/system/loginLog/index.vue @@ -0,0 +1,443 @@ + + + + + diff --git a/go/controllers/backend_auth.go b/go/controllers/backend_auth.go index a33206d..82813c7 100644 --- a/go/controllers/backend_auth.go +++ b/go/controllers/backend_auth.go @@ -35,6 +35,13 @@ func (c *BackendAuthController) serveJSON(data map[string]interface{}) { _ = c.ServeJSON() } +// recordLoginLog 记录登录日志(成功/失败均记录,失败不影响登录流程) +func (c *BackendAuthController) recordLoginLog(tid *uint64, userID uint64, account, userName, tenantName, loginType string, status int8, message string) { + ip := c.Ctx.Input.IP() + userAgent := c.Ctx.Request.UserAgent() + RecordLoginLog(tid, userID, account, userName, tenantName, loginType, status, message, ip, userAgent) +} + // LoginBackend backend 登录(需要租户) func (c *BackendAuthController) LoginBackend() { var req backendAuthLoginRequest @@ -93,10 +100,14 @@ func (c *BackendAuthController) LoginBackend() { token, loginUser, err := services.BackendLogin(req.TenantName, req.Account, req.Password) if err != nil { + c.recordLoginLog(nil, 0, req.Account, "", req.TenantName, "password", 0, err.Error()) c.serveJSON(map[string]interface{}{"code": 401, "msg": err.Error()}) return } + tid := loginUser.Tid + c.recordLoginLog(&tid, loginUser.ID, loginUser.Account, loginUser.Name, req.TenantName, "password", 1, "登录成功") + c.serveJSON(map[string]interface{}{ "code": 200, "msg": "登录成功", @@ -319,4 +330,4 @@ func (c *BackendAuthController) SendResetCode() { "code": 501, "msg": "发送找回密码验证码暂未实现", }) -} \ No newline at end of file +} diff --git a/go/controllers/backend_login_log.go b/go/controllers/backend_login_log.go new file mode 100644 index 0000000..53c8783 --- /dev/null +++ b/go/controllers/backend_login_log.go @@ -0,0 +1,284 @@ +package controllers + +import ( + "encoding/json" + "fmt" + "io" + "strconv" + "strings" + "time" + + "server/models" + "server/pkg/jwtutil" + + "github.com/beego/beego/v2/client/orm" + beego "github.com/beego/beego/v2/server/web" +) + +// BackendLoginLogController 登录日志(yz_system_login_log) +type BackendLoginLogController struct { + beego.Controller +} + +func (c *BackendLoginLogController) jsonErr(httpStatus, bizCode int, msg string) { + c.Ctx.Output.SetStatus(httpStatus) + c.Data["json"] = map[string]interface{}{"code": bizCode, "msg": msg} + _ = c.ServeJSON() +} + +func (c *BackendLoginLogController) backendClaims() (*jwtutil.Claims, error) { + auth := c.Ctx.Request.Header.Get("Authorization") + if auth == "" { + return nil, fmt.Errorf("未登录") + } + parts := strings.SplitN(auth, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return nil, fmt.Errorf("认证信息格式错误") + } + claims, err := jwtutil.ParseToken(parts[1]) + if err != nil { + return nil, fmt.Errorf("无效的token") + } + if claims.UserType != "backend" { + return nil, fmt.Errorf("无权访问") + } + return claims, nil +} + +// RecordLoginLog 写入一条登录日志(登录成功/失败均记录,失败不影响登录流程)。 +func RecordLoginLog(tid *uint64, userID uint64, account, userName, tenantName, loginType string, status int8, message string, ip, userAgent string) { + if len(userAgent) > 500 { + userAgent = userAgent[:500] + } + if len(message) > 255 { + message = message[:255] + } + row := &models.SystemLoginLog{ + Tid: tid, + UserID: userID, + Account: account, + UserName: userName, + TenantName: tenantName, + LoginType: loginType, + Status: status, + Message: message, + IP: ip, + UserAgent: userAgent, + } + if _, err := models.Orm.Insert(row); err != nil { + fmt.Println("record login log error:", err) + } +} + +// List GET /backend/loginLogs?page=1&pageSize=20&keyword=&status=&loginType=&startTime=&endTime= +func (c *BackendLoginLogController) List() { + if _, err := c.backendClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + + page, _ := c.GetInt("page", 1) + pageSize, _ := c.GetInt("pageSize", 20) + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = 20 + } + if pageSize > 200 { + pageSize = 200 + } + + keyword := strings.TrimSpace(c.GetString("keyword")) + statusStr := strings.TrimSpace(c.GetString("status")) + loginType := strings.TrimSpace(c.GetString("loginType")) + startTimeStr := strings.TrimSpace(c.GetString("startTime")) + endTimeStr := strings.TrimSpace(c.GetString("endTime")) + + qs := models.Orm.QueryTable(new(models.SystemLoginLog)).Filter("delete_time__isnull", true) + + cond := orm.NewCondition() + needCond := false + + if statusStr != "" { + if st, err := strconv.Atoi(statusStr); err == nil { + cond = cond.And("status", st) + needCond = true + } + } + if loginType != "" { + cond = cond.And("login_type", loginType) + needCond = true + } + if keyword != "" { + kw := orm.NewCondition(). + Or("account__icontains", keyword). + Or("user_name__icontains", keyword). + Or("tenant_name__icontains", keyword). + Or("ip__icontains", keyword) + if uid, err := strconv.ParseUint(keyword, 10, 64); err == nil && uid > 0 { + kw = kw.Or("user_id", uid) + } + cond = cond.AndCond(kw) + needCond = true + } + if t, err := parseTimeFlexible(startTimeStr); err == nil && !t.IsZero() { + cond = cond.And("create_time__gte", t) + needCond = true + } + if t, err := parseTimeFlexible(endTimeStr); err == nil && !t.IsZero() { + cond = cond.And("create_time__lte", t) + needCond = true + } + + if needCond { + qs = qs.SetCond(cond) + } + + total, err := qs.Count() + if err != nil { + c.jsonErr(500, 500, "获取登录日志失败: "+err.Error()) + return + } + + var rows []models.SystemLoginLog + _, err = qs.OrderBy("-id").Limit(pageSize, (page-1)*pageSize).All(&rows) + if err != nil { + c.jsonErr(500, 500, "获取登录日志失败: "+err.Error()) + return + } + + list := make([]map[string]interface{}, 0, len(rows)) + for i := range rows { + item := map[string]interface{}{ + "id": rows[i].ID, + "tid": rows[i].Tid, + "user_id": rows[i].UserID, + "account": rows[i].Account, + "user_name": rows[i].UserName, + "tenant_name": rows[i].TenantName, + "login_type": rows[i].LoginType, + "status": rows[i].Status, + "message": rows[i].Message, + "ip": rows[i].IP, + "user_agent": rows[i].UserAgent, + "create_time": rows[i].CreateTime.Format("2006-01-02 15:04:05"), + } + list = append(list, item) + } + + c.Data["json"] = map[string]interface{}{ + "code": 200, + "msg": "success", + "data": map[string]interface{}{ + "list": list, + "total": total, + }, + } + _ = c.ServeJSON() +} + +// Detail GET /backend/loginLogs/:id +func (c *BackendLoginLogController) Detail() { + if _, err := c.backendClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + var row models.SystemLoginLog + err = models.Orm.QueryTable(new(models.SystemLoginLog)). + Filter("id", id). + Filter("delete_time__isnull", true). + One(&row) + if err != nil { + c.jsonErr(404, 404, "记录不存在") + return + } + out := map[string]interface{}{ + "id": row.ID, + "tid": row.Tid, + "user_id": row.UserID, + "account": row.Account, + "user_name": row.UserName, + "tenant_name": row.TenantName, + "login_type": row.LoginType, + "status": row.Status, + "message": row.Message, + "ip": row.IP, + "user_agent": row.UserAgent, + "create_time": row.CreateTime.Format("2006-01-02 15:04:05"), + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "success", "data": out} + _ = c.ServeJSON() +} + +// Delete DELETE /backend/loginLogs/:id +func (c *BackendLoginLogController) Delete() { + if _, err := c.backendClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + idStr := c.Ctx.Input.Param(":id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil || id == 0 { + c.jsonErr(400, 400, "无效ID") + return + } + now := time.Now() + n, err := models.Orm.QueryTable(new(models.SystemLoginLog)). + Filter("id", id). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "删除失败: "+err.Error()) + return + } + if n == 0 { + c.jsonErr(404, 404, "记录不存在") + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "删除成功"} + _ = c.ServeJSON() +} + +type backendLoginLogBatchDeletePayload struct { + IDs []uint64 `json:"ids"` +} + +// BatchDelete POST /backend/loginLogs/batchDelete +func (c *BackendLoginLogController) BatchDelete() { + if _, err := c.backendClaims(); err != nil { + c.jsonErr(401, 401, err.Error()) + return + } + raw, err := io.ReadAll(c.Ctx.Request.Body) + if err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + var p backendLoginLogBatchDeletePayload + if err := json.Unmarshal(raw, &p); err != nil { + c.jsonErr(400, 400, "参数错误") + return + } + if len(p.IDs) == 0 { + c.jsonErr(400, 400, "请选择要删除的日志") + return + } + now := time.Now() + _, err = models.Orm.QueryTable(new(models.SystemLoginLog)). + Filter("id__in", p.IDs). + Filter("delete_time__isnull", true). + Update(map[string]interface{}{"delete_time": now}) + if err != nil { + c.jsonErr(500, 500, "批量删除失败: "+err.Error()) + return + } + c.Data["json"] = map[string]interface{}{"code": 200, "msg": "批量删除成功"} + _ = c.ServeJSON() +} diff --git a/go/models/init.go b/go/models/init.go index 3efd2b0..6b10b9d 100644 --- a/go/models/init.go +++ b/go/models/init.go @@ -50,6 +50,7 @@ func Init(_ string) { new(SystemFilesCategory), new(SystemSMSTask), new(SystemOperationLog), + new(SystemLoginLog), new(SystemDomainPool), new(SystemTenantDomain), new(SystemModules), diff --git a/go/models/system_login_log.go b/go/models/system_login_log.go new file mode 100644 index 0000000..1166b6b --- /dev/null +++ b/go/models/system_login_log.go @@ -0,0 +1,24 @@ +package models + +import "time" + +// SystemLoginLog 登录日志表 yz_system_login_log +type SystemLoginLog struct { + ID uint64 `orm:"column(id);pk;auto" json:"id"` + Tid *uint64 `orm:"column(tid);null" json:"tid"` + UserID uint64 `orm:"column(user_id);default(0)" json:"user_id"` + Account string `orm:"column(account);size(64);default('')" json:"account"` + UserName string `orm:"column(user_name);size(64);default('')" json:"user_name"` + TenantName string `orm:"column(tenant_name);size(64);default('')" json:"tenant_name"` + LoginType string `orm:"column(login_type);size(20);default('password')" json:"login_type"` + Status int8 `orm:"column(status);default(1)" json:"status"` + Message string `orm:"column(message);size(255);default('')" json:"message"` + IP string `orm:"column(ip);size(50);default('')" json:"ip"` + UserAgent string `orm:"column(user_agent);size(500);default('')" json:"user_agent"` + CreateTime time.Time `orm:"column(create_time);type(datetime);auto_now_add" json:"create_time"` + DeleteTime *time.Time `orm:"column(delete_time);type(datetime);null" json:"delete_time"` +} + +func (m *SystemLoginLog) TableName() string { + return "yz_system_login_log" +} diff --git a/go/routers/backend/backend.go b/go/routers/backend/backend.go index f4452f7..42bcea7 100644 --- a/go/routers/backend/backend.go +++ b/go/routers/backend/backend.go @@ -41,6 +41,11 @@ func RegisterAuthRoutes() { beego.Router("/backend/operationLogs/:id", &controllers.BackendOperationLogController{}, "get:Detail;delete:Delete") beego.Router("/backend/operationLogs/batchDelete", &controllers.BackendOperationLogController{}, "post:BatchDelete") + // 登录日志(yz_system_login_log) + beego.Router("/backend/loginLogs", &controllers.BackendLoginLogController{}, "get:List") + beego.Router("/backend/loginLogs/:id", &controllers.BackendLoginLogController{}, "get:Detail;delete:Delete") + beego.Router("/backend/loginLogs/batchDelete", &controllers.BackendLoginLogController{}, "post:BatchDelete") + // 租户站点设置 beego.Router("/backend/normalInfos", &controllers.BackendSiteSettingsController{}, "get:GetNormalInfos") beego.Router("/backend/saveNormalInfos", &controllers.BackendSiteSettingsController{}, "post:SaveNormalInfos") diff --git a/sql/yz_system_login_log.sql b/sql/yz_system_login_log.sql new file mode 100644 index 0000000..bcb8c7f --- /dev/null +++ b/sql/yz_system_login_log.sql @@ -0,0 +1,20 @@ +-- 登录日志表 +CREATE TABLE IF NOT EXISTS `yz_system_login_log` ( + `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', + `tid` bigint(20) unsigned DEFAULT NULL COMMENT '租户ID', + `user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '用户ID', + `account` varchar(64) NOT NULL DEFAULT '' COMMENT '登录账号', + `user_name` varchar(64) NOT NULL DEFAULT '' COMMENT '用户姓名', + `tenant_name` varchar(64) NOT NULL DEFAULT '' COMMENT '租户名称', + `login_type` varchar(20) NOT NULL DEFAULT 'password' COMMENT '登录方式: password-密码, sms-短信验证码, email-邮箱验证码', + `status` tinyint(4) NOT NULL DEFAULT '1' COMMENT '登录状态: 0-失败, 1-成功', + `message` varchar(255) NOT NULL DEFAULT '' COMMENT '结果信息/失败原因', + `ip` varchar(50) NOT NULL DEFAULT '' COMMENT '登录IP', + `user_agent` varchar(500) NOT NULL DEFAULT '' COMMENT '浏览器User-Agent', + `create_time` datetime DEFAULT NULL COMMENT '登录时间', + `delete_time` datetime DEFAULT NULL COMMENT '删除时间', + PRIMARY KEY (`id`), + KEY `idx_tid` (`tid`), + KEY `idx_account` (`account`), + KEY `idx_create_time` (`create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统登录日志表';