Files
digital-psychology/apps/api/internal/service/admin/system.go
T
jackyu66gitandCursor 0d50c0ee73 fix(admin): 收紧 isSuper、plan-prices RBAC 与封禁状态机
避免 roles.write 绕过全部 can();定价读写挂 membership.plans 权限;去掉快捷封禁双路径并让 ban/unban 走 lifecycle。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:56:00 +08:00

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
}