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
+2
View File
@@ -82,6 +82,7 @@ const (
PermissionTraceRead = "trace:read"
PermissionAgentNodeRead = "agent_node:read"
PermissionAgentNodeManage = "agent_node:manage"
PermissionSystemManage = "system:manage"
)
var rolePermissions = map[string][]string{
@@ -107,6 +108,7 @@ var rolePermissions = map[string][]string{
PermissionScheduledTaskRead, PermissionScheduledTaskManage,
PermissionTraceRead,
PermissionAgentNodeRead, PermissionAgentNodeManage,
PermissionSystemManage,
},
"auditor": {PermissionProviderRead, PermissionAPIKeyRead, PermissionAuditRead, PermissionUsageRead, PermissionOutboxRead, PermissionContentPolicyRead, PermissionPricingRead, PermissionPromptRead, PermissionKnowledgeRead, PermissionToolRead, PermissionApplicationRead, PermissionNotificationRead, PermissionMCPServerRead, PermissionSkillRead, PermissionDigitalEmployeeRead, PermissionMarketplaceRead, PermissionFileRead, PermissionInboxRead, PermissionScheduledTaskRead, PermissionTraceRead, PermissionAgentNodeRead},
"member": {},
+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": "登录记录"}},
}},
}
}
+145 -3
View File
@@ -24,6 +24,37 @@ var (
type ManagementHTTPHandler struct {
service *Service
mux *http.ServeMux
// license 提供账号数上限管控(nil 时不限制)。
license interface {
AccountLimit() int
}
}
// SetLicenseManager 注入 License 管理器用于账号数管控。
func (h *ManagementHTTPHandler) SetLicenseManager(manager interface{ AccountLimit() int }) {
h.license = manager
}
// checkAccountLimit 在创建账号前校验 License 账号数上限。
func (h *ManagementHTTPHandler) checkAccountLimit(writer http.ResponseWriter, request *http.Request) bool {
if h.license == nil {
return true
}
limit := h.license.AccountLimit()
if limit <= 0 {
return true // 不限
}
var total int
err := h.service.repository.CountIdentities(request.Context(), &total)
if err != nil {
apiresponse.Error(writer, http.StatusServiceUnavailable, "身份服务暂不可用")
return false
}
if total >= limit {
apiresponse.Error(writer, http.StatusForbidden, fmt.Sprintf("账号数已达 License 上限(%d 个),请联系管理员升级", limit))
return false
}
return true
}
type identityInput struct {
@@ -42,6 +73,10 @@ func NewManagementHTTPHandler(service *Service) *ManagementHTTPHandler {
h.mux.HandleFunc("POST /api/v1/admin/identities/admins", h.create(KindAdmin))
h.mux.HandleFunc("PUT /api/v1/admin/identities/admins/{identity_id}", h.update(KindAdmin))
h.mux.HandleFunc("GET /api/v1/admin/identities/portal-users", h.list(KindPortal))
h.mux.HandleFunc("GET /api/v1/admin/roles", h.listRoles)
h.mux.HandleFunc("POST /api/v1/admin/roles", h.createRole)
h.mux.HandleFunc("PUT /api/v1/admin/roles/{role_id}", h.updateRole)
h.mux.HandleFunc("DELETE /api/v1/admin/roles/{role_id}", h.deleteRole)
h.mux.HandleFunc("POST /api/v1/admin/identities/portal-users", h.create(KindPortal))
h.mux.HandleFunc("PUT /api/v1/admin/identities/portal-users/{identity_id}", h.update(KindPortal))
h.mux.HandleFunc("GET /api/v1/admin/departments", h.listDepartments)
@@ -79,6 +114,9 @@ func (h *ManagementHTTPHandler) create(kind Kind) http.HandlerFunc {
if !ok {
return
}
if !h.checkAccountLimit(writer, request) {
return
}
input, account, password, ok := h.decode(writer, request, kind, true)
if !ok {
return
@@ -150,6 +188,98 @@ func (h *ManagementHTTPHandler) update(kind Kind) http.HandlerFunc {
}
}
func (h *ManagementHTTPHandler) listRoles(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
_ = actor
roles, err := h.service.repository.ListRoles(request.Context())
if err != nil {
h.writeError(writer, err)
return
}
apiresponse.OK(writer, roles)
}
type roleInput struct {
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
}
func (h *ManagementHTTPHandler) decodeRole(writer http.ResponseWriter, request *http.Request) (roleInput, bool) {
var input roleInput
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&input); err != nil {
apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效")
return input, false
}
input.Code = strings.ToLower(strings.TrimSpace(input.Code))
input.Name = strings.TrimSpace(input.Name)
input.Description = strings.TrimSpace(input.Description)
if !roleCodePattern.MatchString(input.Code) || input.Name == "" || len(input.Name) > 64 || len(input.Description) > 512 {
apiresponse.Error(writer, http.StatusBadRequest, "角色代码或名称格式无效")
return input, false
}
permissions, err := normalizePermissions(input.Permissions)
if err != nil {
apiresponse.Error(writer, http.StatusBadRequest, err.Error())
return input, false
}
input.Permissions = permissions
return input, true
}
func (h *ManagementHTTPHandler) createRole(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
input, ok := h.decodeRole(writer, request)
if !ok {
return
}
role, err := h.service.repository.SaveRole(request.Context(), "", input.Code, input.Name, input.Description, input.Permissions, actor.ID, true)
if err != nil {
h.writeError(writer, err)
return
}
apiresponse.OK(writer, role)
}
func (h *ManagementHTTPHandler) updateRole(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
input, ok := h.decodeRole(writer, request)
if !ok {
return
}
role, err := h.service.repository.SaveRole(request.Context(), request.PathValue("role_id"), input.Code, input.Name, input.Description, input.Permissions, actor.ID, false)
if err != nil {
h.writeError(writer, err)
return
}
apiresponse.OK(writer, role)
}
func (h *ManagementHTTPHandler) deleteRole(writer http.ResponseWriter, request *http.Request) {
actor, ok := h.requirePermission(writer, request)
if !ok {
return
}
_ = actor
if err := h.service.repository.DeleteRole(request.Context(), request.PathValue("role_id")); err != nil {
h.writeError(writer, err)
return
}
apiresponse.OK(writer, map[string]bool{"deleted": true})
}
func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http.Request, kind Kind, creating bool) (identityInput, Account, string, bool) {
var input identityInput
decoder := json.NewDecoder(http.MaxBytesReader(writer, request.Body, 1<<20))
@@ -176,9 +306,19 @@ func (h *ManagementHTTPHandler) decode(writer http.ResponseWriter, request *http
apiresponse.Error(writer, http.StatusBadRequest, "账号或显示名称格式无效")
return input, Account{}, "", false
}
if kind == KindAdmin && input.Role != "" && input.Role != "superadmin" && input.Role != "operator" && input.Role != "auditor" {
apiresponse.Error(writer, http.StatusBadRequest, "管理员角色无效")
return input, Account{}, "", false
if kind == KindAdmin && input.Role != "" {
switch input.Role {
case "superadmin", "operator", "auditor":
default:
// 自定义角色:必须存在于角色表,并把其权限展开到账号
// permissions(角色权限变更后由管理员重新分配或手动同步)。
role, roleErr := h.service.repository.FindRole(request.Context(), input.Role)
if roleErr != nil {
apiresponse.Error(writer, http.StatusBadRequest, "角色不存在")
return input, Account{}, "", false
}
input.Permissions = append(input.Permissions, role.Permissions...)
}
}
if kind == KindPortal && input.Role != "" && input.Role != "member" {
apiresponse.Error(writer, http.StatusBadRequest, "门户角色无效")
@@ -230,6 +370,8 @@ func (h *ManagementHTTPHandler) requirePermission(writer http.ResponseWriter, re
return account, true
}
var roleCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
func (h *ManagementHTTPHandler) writeError(writer http.ResponseWriter, err error) {
switch {
case errors.Is(err, ErrNotFound):
+87
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
@@ -281,6 +282,92 @@ func expectOne(pool *pgxpool.Pool, ctx context.Context, query string, arguments
return nil
}
// LoginLog 是一条登录尝试记录。
type LoginLog struct {
ID string `json:"id"`
Kind string `json:"kind"`
Login string `json:"login"`
Success bool `json:"success"`
IP *string `json:"ip,omitempty"`
UserAgent string `json:"user_agent"`
Reason string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
// RecordLoginLog 记录一次登录尝试(成功或失败)。
func (r *Repository) RecordLoginLog(ctx context.Context, kind Kind, login string, success bool, ip, userAgent, reason string) error {
if r.pool == nil {
return ErrUnavailable
}
id, err := platformid.NewUUID()
if err != nil {
return err
}
ipValue := strings.TrimSpace(ip)
_, err = r.pool.Exec(ctx, `INSERT INTO gateway.login_logs(id,kind,login,success,ip,user_agent,reason) VALUES($1,$2,$3,$4,nullif($5,'')::inet,$6,$7)`,
id, string(kind), login, success, ipValue, truncateText(userAgent, 256), truncateText(reason, 64))
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}
// ListLoginLogs 查询登录记录(按时间倒序)。
func (r *Repository) ListLoginLogs(ctx context.Context, kind Kind, login string, limit int) ([]LoginLog, error) {
if r.pool == nil {
return nil, ErrUnavailable
}
if limit < 1 || limit > 500 {
limit = 50
}
query := `SELECT id::text,kind,login,success,ip::text,user_agent,reason,created_at FROM gateway.login_logs WHERE kind=$1`
args := []any{string(kind)}
if login != "" {
args = append(args, login)
query += ` AND login=$` + strconv.Itoa(len(args))
}
query += ` ORDER BY created_at DESC LIMIT $` + strconv.Itoa(len(args)+1)
args = append(args, limit)
rows, err := r.pool.Query(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
items := []LoginLog{}
for rows.Next() {
var item LoginLog
var ip *string
if err := rows.Scan(&item.ID, &item.Kind, &item.Login, &item.Success, &ip, &item.UserAgent, &item.Reason, &item.CreatedAt); err != nil {
return nil, err
}
if ip != nil && *ip != "" {
item.IP = ip
}
items = append(items, item)
}
return items, rows.Err()
}
func truncateText(value string, limit int) string {
value = strings.TrimSpace(value)
if len(value) <= limit {
return value
}
return value[:limit]
}
// CountIdentities 统计管理员与门户账号总数(License 账号数管控用)。
func (r *Repository) CountIdentities(ctx context.Context, total *int) error {
if r.pool == nil {
return ErrUnavailable
}
err := r.pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM gateway.admin_accounts) + (SELECT count(*) FROM gateway.portal_users)`).Scan(total)
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return nil
}
func (r *Repository) CreateAdmin(ctx context.Context, login, displayName, role, passwordHash string) (string, error) {
if r.pool == nil {
return "", ErrUnavailable
+132
View File
@@ -0,0 +1,132 @@
package identity
import (
"context"
"errors"
"fmt"
"strings"
platformid "aigateway.local/core/internal/platform/id"
"github.com/jackc/pgx/v5"
)
// Role 是一个自定义角色定义(内置角色在代码中,不落库)。
type Role struct {
ID string `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Builtin bool `json:"builtin"`
CreatedBy *string `json:"created_by,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// ListRoles 返回全部角色(内置 + 自定义)。
func (r *Repository) ListRoles(ctx context.Context) ([]Role, error) {
if r.pool == nil {
return nil, ErrUnavailable
}
rows, err := r.pool.Query(ctx, `SELECT id::text,code,name,description,permissions,builtin,created_by::text,created_at,updated_at FROM gateway.roles ORDER BY builtin DESC,created_at`)
if err != nil {
return nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
defer rows.Close()
items := []Role{}
for rows.Next() {
var item Role
var createdBy *string
if err := rows.Scan(&item.ID, &item.Code, &item.Name, &item.Description, &item.Permissions, &item.Builtin, &createdBy, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
item.CreatedBy = createdBy
items = append(items, item)
}
if err := rows.Err(); err != nil {
return nil, err
}
// 合并代码内置角色(带可读名称)。
builtinNames := map[string]string{"superadmin": "超级管理员", "operator": "运维操作员", "auditor": "审计员", "member": "普通成员"}
for code, permissions := range rolePermissions {
items = append(items, Role{ID: "builtin:" + code, Code: code, Name: builtinNames[code], Permissions: permissions, Builtin: true})
}
return items, nil
}
// FindRole 按 code 查询角色;builtin 角色由代码返回。
func (r *Repository) FindRole(ctx context.Context, code string) (Role, error) {
code = strings.ToLower(strings.TrimSpace(code))
if permissions, ok := rolePermissions[code]; ok {
return Role{Code: code, Name: code, Permissions: permissions, Builtin: true}, nil
}
var item Role
var createdBy *string
err := r.pool.QueryRow(ctx, `SELECT id::text,code,name,description,permissions,builtin,created_by::text,created_at,updated_at FROM gateway.roles WHERE code=$1`, code).Scan(&item.ID, &item.Code, &item.Name, &item.Description, &item.Permissions, &item.Builtin, &createdBy, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return Role{}, ErrNotFound
}
if err != nil {
return Role{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
item.CreatedBy = createdBy
return item, nil
}
// SaveRole 创建或更新自定义角色;内置角色禁止修改。
func (r *Repository) SaveRole(ctx context.Context, id, code, name, description string, permissions []string, actorID string, create bool) (Role, error) {
if r.pool == nil {
return Role{}, ErrUnavailable
}
code = strings.ToLower(strings.TrimSpace(code))
if _, builtin := rolePermissions[code]; builtin {
return Role{}, errors.New("内置角色不可修改")
}
if create {
id, err := platformid.NewUUID()
if err != nil {
return Role{}, err
}
_, err = r.pool.Exec(ctx, `INSERT INTO gateway.roles(id,code,name,description,permissions,created_by) VALUES($1,$2,$3,$4,$5,$6)`, id, code, strings.TrimSpace(name), strings.TrimSpace(description), permissions, actorID)
if err != nil {
return Role{}, mapRoleError(err)
}
return r.FindRole(ctx, code)
}
tag, err := r.pool.Exec(ctx, `UPDATE gateway.roles SET name=$2,description=$3,permissions=$4,updated_at=clock_timestamp() WHERE id=$1 AND NOT builtin`, id, strings.TrimSpace(name), strings.TrimSpace(description), permissions)
if err != nil {
return Role{}, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if tag.RowsAffected() == 0 {
return Role{}, ErrNotFound
}
var item Role
item, err = r.FindRole(ctx, code)
if err != nil {
return Role{}, err
}
return item, nil
}
// DeleteRole 删除自定义角色(内置角色禁止)。
func (r *Repository) DeleteRole(ctx context.Context, id string) error {
if r.pool == nil {
return ErrUnavailable
}
tag, err := r.pool.Exec(ctx, `DELETE FROM gateway.roles WHERE id=$1 AND NOT builtin`, id)
if err != nil {
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
if tag.RowsAffected() == 0 {
return ErrNotFound
}
return nil
}
func mapRoleError(err error) error {
var pgError interface{ Code() string }
if errors.As(err, &pgError) && pgError.Code() == "23505" {
return ErrIdentityConflict
}
return fmt.Errorf("%w: %v", ErrUnavailable, err)
}
+10
View File
@@ -77,6 +77,16 @@ func (s *Service) AllowLogin(ctx context.Context, ip string) bool {
return s.limiter == nil || s.limiter.Allow(ctx, ip)
}
// RecordLoginLog 记录登录审计(成功/失败与原因)。
func (s *Service) RecordLoginLog(ctx context.Context, kind Kind, login string, success bool, ip, userAgent, reason string) error {
return s.repository.RecordLoginLog(ctx, kind, login, success, ip, userAgent, reason)
}
// ListLoginLogs 查询登录记录。
func (s *Service) ListLoginLogs(ctx context.Context, kind Kind, login string, limit int) ([]LoginLog, error) {
return s.repository.ListLoginLogs(ctx, kind, login, limit)
}
// ClientIP 提取登录限流使用的客户端 IP:仅在直连对端是可信代理时采信
// X-Forwarded-For,否则直接用对端地址,防止伪造头绕过限流。
func (s *Service) ClientIP(r *http.Request) string {