package identity import ( "context" "encoding/json" "errors" "fmt" "net/http" "regexp" "sort" "strings" "aigateway.local/core/internal/platform/apiresponse" platformid "aigateway.local/core/internal/platform/id" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" ) var ( ErrIdentityConflict = errors.New("identity already exists") permissionPattern = regexp.MustCompile(`^[a-z][a-z0-9_.:-]{2,127}$`) ) type ManagementHTTPHandler struct { service *Service mux *http.ServeMux } type identityInput struct { Login string `json:"login"` DisplayName string `json:"display_name"` Role string `json:"role"` Password *string `json:"password"` Permissions []string `json:"permissions"` Active *bool `json:"active"` DepartmentID *string `json:"department_id"` } func NewManagementHTTPHandler(service *Service) *ManagementHTTPHandler { h := &ManagementHTTPHandler{service: service, mux: http.NewServeMux()} h.mux.HandleFunc("GET /api/v1/admin/identities/admins", h.list(KindAdmin)) 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("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) h.mux.HandleFunc("POST /api/v1/admin/departments", h.createDepartment) h.mux.HandleFunc("PUT /api/v1/admin/departments/{department_id}", h.updateDepartment) h.registerOIDC() return h } func (h *ManagementHTTPHandler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { h.mux.ServeHTTP(writer, request) } func (h *ManagementHTTPHandler) list(kind Kind) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { if _, ok := h.requirePermission(writer, request); !ok { return } accounts, err := h.service.repository.ListIdentities(request.Context(), kind) if err != nil { h.writeError(writer, err) return } items := make([]map[string]any, 0, len(accounts)) for _, account := range accounts { items = append(items, managementView(account)) } apiresponse.OK(writer, items) } } func (h *ManagementHTTPHandler) create(kind Kind) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { actor, ok := h.requirePermission(writer, request) if !ok { return } input, account, password, ok := h.decode(writer, request, kind, true) if !ok { return } _ = input passwordHash, err := h.service.hasher.Hash(password) if err != nil { h.writeError(writer, err) return } account.PasswordHash = passwordHash created, err := h.service.repository.CreateIdentity(request.Context(), account, actor.ID) if err != nil { h.writeError(writer, err) return } apiresponse.OK(writer, managementView(created)) } } func (h *ManagementHTTPHandler) update(kind Kind) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { actor, ok := h.requirePermission(writer, request) if !ok { return } _, account, password, ok := h.decode(writer, request, kind, false) if !ok { return } account.ID = request.PathValue("identity_id") current, err := h.service.findByID(request.Context(), kind, account.ID) if err != nil { h.writeError(writer, err) return } if kind == KindAdmin && actor.ID == current.ID && (account.Role != current.Role || !account.Active) { apiresponse.Error(writer, http.StatusConflict, "不能停用自身账号或修改自身角色") return } var passwordHash *string if password != "" { hash, err := h.service.hasher.Hash(password) if err != nil { h.writeError(writer, err) return } passwordHash = &hash } updated, err := h.service.repository.UpdateIdentity(request.Context(), account, passwordHash, actor.ID) if err != nil { h.writeError(writer, err) return } apiresponse.OK(writer, managementView(updated)) } } 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)) decoder.DisallowUnknownFields() if err := decoder.Decode(&input); err != nil { apiresponse.Error(writer, http.StatusBadRequest, "请求格式无效") return input, Account{}, "", false } input.Login = strings.ToLower(strings.TrimSpace(input.Login)) input.DisplayName = strings.TrimSpace(input.DisplayName) input.Role = strings.ToLower(strings.TrimSpace(input.Role)) if input.Role == "" { if kind == KindPortal { input.Role = "member" } else { input.Role = "operator" } } if len(input.Login) < 2 || len(input.Login) > 128 || len(input.DisplayName) > 64 { apiresponse.Error(writer, http.StatusBadRequest, "账号或显示名称格式无效") return input, Account{}, "", false } if kind == KindAdmin && input.Role != "superadmin" && input.Role != "operator" && input.Role != "auditor" { apiresponse.Error(writer, http.StatusBadRequest, "管理员角色无效") return input, Account{}, "", false } if kind == KindPortal && input.Role != "member" { apiresponse.Error(writer, http.StatusBadRequest, "门户角色无效") return input, Account{}, "", false } permissions, err := normalizePermissions(input.Permissions) if err != nil { apiresponse.Error(writer, http.StatusBadRequest, err.Error()) return input, Account{}, "", false } password := "" if input.Password != nil { password = *input.Password } if creating && len(password) < 12 || password != "" && len(password) < 12 || len(password) > 1024 { apiresponse.Error(writer, http.StatusBadRequest, "口令长度必须为 12 至 1024 个字符") return input, Account{}, "", false } active := true if input.Active != nil { active = *input.Active } var departmentID *string if input.DepartmentID != nil && strings.TrimSpace(*input.DepartmentID) != "" { value := strings.TrimSpace(*input.DepartmentID) department, err := h.service.repository.GetDepartment(request.Context(), value) if err != nil || !department.Active { apiresponse.Error(writer, http.StatusBadRequest, "所选部门不存在或已停用") return input, Account{}, "", false } departmentID = &value } return input, Account{ Kind: kind, Login: input.Login, DisplayName: input.DisplayName, Role: input.Role, Permissions: permissions, Active: active, DepartmentID: departmentID, }, password, true } func (h *ManagementHTTPHandler) requirePermission(writer http.ResponseWriter, request *http.Request) (Account, bool) { account, err := h.service.Authenticate(request.Context(), KindAdmin, request.Header.Get("Authorization")) if err != nil { apiresponse.Error(writer, http.StatusUnauthorized, "登录状态无效或已过期") return Account{}, false } if !HasPermission(account, PermissionIdentityManage) { apiresponse.Error(writer, http.StatusForbidden, "缺少身份管理权限") return Account{}, false } return account, true } func (h *ManagementHTTPHandler) writeError(writer http.ResponseWriter, err error) { switch { case errors.Is(err, ErrNotFound): apiresponse.Error(writer, http.StatusNotFound, "账号不存在") case errors.Is(err, ErrIdentityConflict): apiresponse.Error(writer, http.StatusConflict, "账号已存在") case errors.Is(err, ErrUnavailable): apiresponse.Error(writer, http.StatusServiceUnavailable, "身份管理服务暂不可用") default: apiresponse.Error(writer, http.StatusInternalServerError, "身份管理操作失败") } } func normalizePermissions(values []string) ([]string, error) { seen := make(map[string]struct{}) result := make([]string, 0, len(values)) for _, value := range values { value = strings.ToLower(strings.TrimSpace(value)) if value != "*" && !permissionPattern.MatchString(value) { return nil, fmt.Errorf("权限字符串 %q 格式无效", value) } if _, exists := seen[value]; exists { continue } seen[value] = struct{}{} result = append(result, value) } sort.Strings(result) return result, nil } func managementView(account Account) map[string]any { return map[string]any{ "id": account.ID, "kind": account.Kind, "login": account.Login, "display_name": account.DisplayName, "role": account.Role, "permissions": account.Permissions, "effective_permissions": EffectivePermissions(account), "active": account.Active, "auth_source": account.AuthSource, "totp_enabled": account.TOTPEnabled, "locked_until": account.LockedUntil, "created_at": account.CreatedAt, "updated_at": account.UpdatedAt, "department_id": account.DepartmentID, "department_name": account.DepartmentName, } } func (r *Repository) ListIdentities(ctx context.Context, kind Kind) ([]Account, error) { if r.pool == nil { return nil, ErrUnavailable } query := ` SELECT id::text, username, display_name, role, permissions, active, totp_enabled, locked_until, 'local', created_at, updated_at FROM gateway.admin_accounts ORDER BY lower(username)` if kind == KindPortal { query = ` SELECT u.id::text, u.account, u.name, u.role, u.permissions, u.active, u.totp_enabled, u.locked_until, u.auth_source, u.created_at, u.updated_at, u.department_id::text, COALESCE(d.name, '') FROM gateway.portal_users u LEFT JOIN gateway.departments d ON d.id = u.department_id ORDER BY lower(u.account)` } rows, err := r.pool.Query(ctx, query) if err != nil { return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) } defer rows.Close() accounts := make([]Account, 0) for rows.Next() { account := Account{Kind: kind} arguments := []any{ &account.ID, &account.Login, &account.DisplayName, &account.Role, &account.Permissions, &account.Active, &account.TOTPEnabled, &account.LockedUntil, &account.AuthSource, &account.CreatedAt, &account.UpdatedAt, } if kind == KindPortal { arguments = append(arguments, &account.DepartmentID, &account.DepartmentName) } if err := rows.Scan(arguments...); err != nil { return nil, fmt.Errorf("%w: %v", ErrUnavailable, err) } accounts = append(accounts, account) } return accounts, mapRepositoryError(rows.Err()) } func (r *Repository) CreateIdentity(ctx context.Context, account Account, actorID string) (Account, error) { if r.pool == nil { return Account{}, ErrUnavailable } id, err := platformid.NewUUID() if err != nil { return Account{}, err } eventID, err := platformid.NewUUID() if err != nil { return Account{}, err } account.ID = id tx, err := r.pool.Begin(ctx) if err != nil { return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) } defer func() { _ = tx.Rollback(ctx) }() if account.Kind == KindAdmin { err = tx.QueryRow(ctx, ` INSERT INTO gateway.admin_accounts (id, username, display_name, role, permissions, password_hash, active) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING created_at, updated_at`, account.ID, account.Login, account.DisplayName, account.Role, account.Permissions, account.PasswordHash, account.Active, ).Scan(&account.CreatedAt, &account.UpdatedAt) } else { account.AuthSource = "local" err = tx.QueryRow(ctx, ` INSERT INTO gateway.portal_users (id, account, name, role, permissions, password_hash, auth_source, active, department_id) VALUES ($1, $2, $3, $4, $5, $6, 'local', $7, $8) RETURNING created_at, updated_at`, account.ID, account.Login, account.DisplayName, account.Role, account.Permissions, account.PasswordHash, account.Active, account.DepartmentID, ).Scan(&account.CreatedAt, &account.UpdatedAt) } if err != nil { return Account{}, mapManagementError(err) } payload, _ := json.Marshal(map[string]any{"identity_id": account.ID, "kind": account.Kind, "actor_id": actorID}) if _, err := tx.Exec(ctx, ` INSERT INTO gateway.outbox_events (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) VALUES ($1, 'identity.created', 1, 'identity', $2, $3)`, eventID, account.ID, payload); err != nil { return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) } if err := tx.Commit(ctx); err != nil { return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) } return account, nil } func (r *Repository) UpdateIdentity(ctx context.Context, account Account, passwordHash *string, actorID string) (Account, error) { if r.pool == nil { return Account{}, ErrUnavailable } eventID, err := platformid.NewUUID() if err != nil { return Account{}, err } tx, err := r.pool.Begin(ctx) if err != nil { return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) } defer func() { _ = tx.Rollback(ctx) }() if account.Kind == KindAdmin { err = tx.QueryRow(ctx, ` UPDATE gateway.admin_accounts SET username = $2, display_name = $3, role = $4, permissions = $5, active = $6, password_hash = COALESCE($7, password_hash), updated_at = clock_timestamp() WHERE id = $1 RETURNING totp_enabled, locked_until, created_at, updated_at`, account.ID, account.Login, account.DisplayName, account.Role, account.Permissions, account.Active, passwordHash, ).Scan(&account.TOTPEnabled, &account.LockedUntil, &account.CreatedAt, &account.UpdatedAt) } else { account.AuthSource = "local" err = tx.QueryRow(ctx, ` UPDATE gateway.portal_users SET account = $2, name = $3, role = $4, permissions = $5, active = $6, password_hash = COALESCE($7, password_hash), department_id = $8, updated_at = clock_timestamp() WHERE id = $1 RETURNING auth_source, totp_enabled, locked_until, created_at, updated_at`, account.ID, account.Login, account.DisplayName, account.Role, account.Permissions, account.Active, passwordHash, account.DepartmentID, ).Scan(&account.AuthSource, &account.TOTPEnabled, &account.LockedUntil, &account.CreatedAt, &account.UpdatedAt) } if err != nil { return Account{}, mapManagementError(err) } payload, _ := json.Marshal(map[string]any{"identity_id": account.ID, "kind": account.Kind, "actor_id": actorID}) if _, err := tx.Exec(ctx, ` INSERT INTO gateway.outbox_events (event_id, event_type, event_version, aggregate_type, aggregate_id, payload) VALUES ($1, 'identity.updated', 1, 'identity', $2, $3)`, eventID, account.ID, payload); err != nil { return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) } if err := tx.Commit(ctx); err != nil { return Account{}, fmt.Errorf("%w: %v", ErrUnavailable, err) } return account, nil } func mapManagementError(err error) error { if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } var pgError *pgconn.PgError if errors.As(err, &pgError) && pgError.Code == "23505" { return ErrIdentityConflict } if err != nil { return fmt.Errorf("%w: %v", ErrUnavailable, err) } return nil }