0.11.0: 旗舰版功能补齐(License/登录记录/会话管理/角色管理/门户定时任务/模型配额/输出脱敏/供应链扫描/记忆管理/AI助手/真实概览)
- 新增迁移 000031-000034(登录日志/角色/模型配额/记忆) - 新增包: license/memory/modelquota/assistant,扫描引擎 - 全部功能后端+前端+端到端验证通过(25 包单测)
This commit is contained in:
@@ -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": {},
|
||||
|
||||
@@ -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": "登录记录"}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user