避免 roles.write 绕过全部 can();定价读写挂 membership.plans 权限;去掉快捷封禁双路径并让 ban/unban 走 lifecycle。 Co-authored-by: Cursor <cursoragent@cursor.com>
131 lines
3.7 KiB
Go
131 lines
3.7 KiB
Go
package admin
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
|
)
|
|
|
|
const (
|
|
RoleSuper = "super"
|
|
RoleOps = "ops"
|
|
|
|
pushTitleMaxRunes = 128
|
|
)
|
|
|
|
var (
|
|
ErrForbidden = errString("forbidden")
|
|
ErrInvalidRole = errString("invalid role")
|
|
ErrPushNotFound = errString("push job not found")
|
|
ErrInvalidPush = errString("invalid push job")
|
|
ErrAdminNotFound = errString("admin not found")
|
|
ErrLastSuper = errString("cannot demote last super")
|
|
)
|
|
|
|
// RequireSuper returns ErrForbidden unless admin role is super.
|
|
func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error {
|
|
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
|
if err != nil || acc == nil {
|
|
return ErrAdminNotFound
|
|
}
|
|
if acc.Role != RoleSuper {
|
|
return ErrForbidden
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// BanUser transitions user status to banned via the lifecycle state machine.
|
|
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
|
return s.TransitionUserStatus(ctx, adminID, userID, "banned", "admin ban")
|
|
}
|
|
|
|
// UnbanUser transitions user status to active via the lifecycle state machine.
|
|
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
|
return s.TransitionUserStatus(ctx, adminID, userID, "active", "admin unban")
|
|
}
|
|
|
|
// ListAdmins returns admin accounts (super only caller).
|
|
func (s *Service) ListAdmins(ctx context.Context, actorID uuid.UUID) ([]repository.AdminListItem, error) {
|
|
if err := s.RequireSuper(ctx, actorID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.Repo.ListAdmins(ctx)
|
|
}
|
|
|
|
// UpdateAdminRole changes an admin role (super only).
|
|
func (s *Service) UpdateAdminRole(ctx context.Context, actorID, targetID uuid.UUID, role string) error {
|
|
if err := s.RequireSuper(ctx, actorID); err != nil {
|
|
return err
|
|
}
|
|
if role != RoleSuper && role != RoleOps {
|
|
return ErrInvalidRole
|
|
}
|
|
target, err := s.Repo.FindAccountByID(ctx, targetID)
|
|
if err != nil || target == nil {
|
|
return ErrAdminNotFound
|
|
}
|
|
if target.Role == RoleSuper && role != RoleSuper {
|
|
n, err := s.Repo.CountAdminsByRole(ctx, RoleSuper)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n <= 1 {
|
|
return ErrLastSuper
|
|
}
|
|
}
|
|
meta, _ := json.Marshal(map[string]any{"role": role})
|
|
err = s.Repo.UpdateAdminRoleWithAudit(ctx, actorID, targetID, role, meta)
|
|
if errors.Is(err, repository.ErrAdminNotFound) {
|
|
return ErrAdminNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ListPushJobs lists push stubs.
|
|
func (s *Service) ListPushJobs(ctx context.Context, limit, offset int) ([]repository.PushJob, error) {
|
|
return s.Repo.ListPushJobs(ctx, limit, offset)
|
|
}
|
|
|
|
// CreatePushJob creates a draft push stub.
|
|
func (s *Service) CreatePushJob(ctx context.Context, adminID uuid.UUID, title, body, audience string) (*repository.PushJob, error) {
|
|
if err := validatePushTitle(title); err != nil {
|
|
return nil, err
|
|
}
|
|
meta, _ := json.Marshal(map[string]any{"title": title})
|
|
return s.Repo.CreatePushJobWithAudit(ctx, adminID, title, body, audience, meta)
|
|
}
|
|
|
|
// UpdatePushJob updates a push stub.
|
|
func (s *Service) UpdatePushJob(
|
|
ctx context.Context,
|
|
adminID, jobID uuid.UUID,
|
|
title, body, status *string,
|
|
) (*repository.PushJob, error) {
|
|
if status != nil && *status != "draft" && *status != "cancelled" {
|
|
return nil, ErrInvalidPush
|
|
}
|
|
if title != nil {
|
|
if err := validatePushTitle(*title); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
meta, _ := json.Marshal(map[string]any{})
|
|
job, err := s.Repo.UpdatePushJobWithAudit(ctx, adminID, jobID, title, body, status, meta)
|
|
if errors.Is(err, repository.ErrPushJobNotFound) {
|
|
return nil, ErrPushNotFound
|
|
}
|
|
return job, err
|
|
}
|
|
|
|
func validatePushTitle(title string) error {
|
|
if title == "" || utf8.RuneCountInString(title) > pushTitleMaxRunes {
|
|
return ErrInvalidPush
|
|
}
|
|
return nil
|
|
}
|