0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)

- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆)
- 新增包: license/memory/modelquota/assistant,扫描引擎
- 全部功能后端+前端+端到端验证通过(25 包单测)
This commit is contained in:
2026-08-13 11:37:18 +08:00
parent 9501751792
commit c22669c31d
43 changed files with 3672 additions and 254 deletions
+67
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strconv"
"strings"
"time"
@@ -42,6 +43,7 @@ type factorRequest struct {
func NewHTTPHandler(service *Service) *HTTPHandler {
handler := &HTTPHandler{service: service, mux: http.NewServeMux()}
handler.mux.HandleFunc("POST /api/v1/admin/login", handler.login(KindAdmin))
handler.mux.HandleFunc("GET /api/v1/admin/login-logs", handler.loginLogs(KindAdmin))
handler.registerTOTP(KindAdmin, "/api/v1/admin")
handler.mux.HandleFunc("GET /api/v1/admin/whoami", handler.whoami(KindAdmin))
handler.mux.HandleFunc("POST /api/v1/admin/password", handler.changePassword(KindAdmin))
@@ -82,6 +84,33 @@ func (h *HTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Reques
h.mux.ServeHTTP(writer, request)
}
func (h *HTTPHandler) loginLogs(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
account, err := h.service.Authenticate(request.Context(), KindAdmin, request.Header.Get("Authorization"))
if err != nil {
apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期")
return
}
if !HasPermission(account, PermissionAuditRead) {
apiresponse.Error(writer, http.StatusForbidden, "缺少审计日志查看权限")
return
}
login := strings.TrimSpace(request.URL.Query().Get("login"))
limit := 50
if value := request.URL.Query().Get("limit"); value != "" {
if parsed, parseErr := strconv.Atoi(value); parseErr == nil {
limit = parsed
}
}
logs, err := h.service.ListLoginLogs(request.Context(), kind, login, limit)
if err != nil {
apiresponse.Error(writer, http.StatusServiceUnavailable, "登录记录查询失败")
return
}
apiresponse.OK(writer, logs)
}
}
func (h *HTTPHandler) login(kind Kind) http.HandlerFunc {
return func(writer http.ResponseWriter, request *http.Request) {
// 防爆破:按 IP 的滑动窗口限流,超限返回 429(与账号锁定叠加)。
@@ -109,9 +138,13 @@ func (h *HTTPHandler) login(kind Kind) http.HandlerFunc {
}
result, err := h.service.Login(request.Context(), kind, login, input.Password)
if err != nil {
// 登录失败审计(429 限流在 AllowLogin 阶段已拦截,此处都是真实失败)。
_ = h.service.RecordLoginLog(request.Context(), kind, login, false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err))
h.writeIdentityError(writer, err)
return
}
// 登录成功(含进入 TOTP 挑战阶段)。
_ = h.service.RecordLoginLog(request.Context(), kind, login, true, h.service.ClientIP(request), request.UserAgent(), "success")
apiresponse.OK(writer, map[string]any{
"token": result.Token, "refreshToken": "", "require_totp": result.RequireTOTP,
"temp_token": result.TempToken,
@@ -119,6 +152,28 @@ func (h *HTTPHandler) login(kind Kind) http.HandlerFunc {
}
}
// loginFailureReason 把登录错误归一化为审计用原因码。
func loginFailureReason(err error) string {
switch {
case errors.Is(err, ErrInvalidCredentials):
return "invalid_credentials"
case errors.Is(err, ErrAccountDisabled):
return "account_disabled"
case errors.Is(err, ErrInvalidTOTP):
return "invalid_totp"
case errors.Is(err, ErrTOTPNotEnabled), errors.Is(err, ErrTOTPSetupRequired):
return "totp_not_configured"
case errors.Is(err, ErrUnavailable):
return "service_unavailable"
default:
var locked LockedError
if errors.As(err, &locked) {
return "account_locked"
}
return "unknown"
}
}
func (h *HTTPHandler) registerTOTP(kind Kind, prefix string) {
h.mux.HandleFunc("POST "+prefix+"/login/totp", h.completeTOTPLogin(kind))
h.mux.HandleFunc("GET "+prefix+"/totp/status", h.totpStatus(kind))
@@ -142,9 +197,11 @@ func (h *HTTPHandler) completeTOTPLogin(kind Kind) http.HandlerFunc {
}
result, err := h.service.CompleteTOTPLogin(request.Context(), kind, input.TempToken, input.Code, input.BackupCode)
if err != nil {
_ = h.service.RecordLoginLog(request.Context(), kind, "", false, h.service.ClientIP(request), request.UserAgent(), loginFailureReason(err))
h.writeIdentityError(writer, err)
return
}
_ = h.service.RecordLoginLog(request.Context(), kind, result.Account.Login, true, h.service.ClientIP(request), request.UserAgent(), "success")
apiresponse.OK(writer, map[string]any{"token": result.Token, "refreshToken": "", "require_totp": false})
}
}
@@ -426,6 +483,13 @@ func adminMenus(account Account) []map[string]any {
if HasPermission(account, PermissionScheduledTaskRead) || HasPermission(account, PermissionScheduledTaskManage) {
systemChildren = append(systemChildren, map[string]any{"name": "ScheduledTasks", "path": "scheduled-tasks", "component": "/gateway/scheduled-tasks", "meta": map[string]any{"title": "定时任务"}})
}
if HasPermission(account, PermissionAuditRead) {
systemChildren = append(systemChildren, map[string]any{"name": "LoginLogs", "path": "login-logs", "component": "/system/login-logs", "meta": map[string]any{"title": "登录记录"}})
}
if HasPermission(account, PermissionSystemManage) {
systemChildren = append(systemChildren, map[string]any{"name": "Assistant", "path": "assistant", "component": "/system/assistant", "meta": map[string]any{"title": "AI 助手"}})
systemChildren = append(systemChildren, map[string]any{"name": "License", "path": "license", "component": "/system/license", "meta": map[string]any{"title": "License 授权"}})
}
if len(systemChildren) > 0 {
menus = append(menus, map[string]any{"name": "System", "path": "/system", "component": "/index/index", "meta": map[string]any{"title": "系统管理", "icon": "ri:user-3-line"}, "children": systemChildren})
}
@@ -442,6 +506,9 @@ func portalMenus() []map[string]any {
{"name": "PortalAccess", "path": "access", "component": "/portal/access", "meta": map[string]any{"title": "模型权限"}},
{"name": "PortalFiles", "path": "files", "component": "/portal/files", "meta": map[string]any{"title": "文件仓库"}},
{"name": "PortalInbox", "path": "inbox", "component": "/portal/inbox", "meta": map[string]any{"title": "站内消息"}},
{"name": "PortalScheduledTasks", "path": "scheduled-tasks", "component": "/portal/scheduled-tasks", "meta": map[string]any{"title": "定时任务"}},
{"name": "PortalMemories", "path": "memories", "component": "/portal/memories", "meta": map[string]any{"title": "记忆管理"}},
{"name": "PortalLoginLogs", "path": "login-logs", "component": "/portal/login-logs", "meta": map[string]any{"title": "登录记录"}},
}},
}
}