feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -27,6 +27,9 @@ func (s *Service) ListHomeTools(ctx context.Context) ([]repository.HomeTool, err
|
||||
|
||||
// ReplaceHomeTools replaces grid and audits in one transaction.
|
||||
func (s *Service) ReplaceHomeTools(ctx context.Context, adminID uuid.UUID, items []homesvc.ReplaceInput) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Home == nil {
|
||||
return errors.New("home unavailable")
|
||||
}
|
||||
@@ -44,6 +47,9 @@ func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminI
|
||||
|
||||
// PatchScaleStatus updates published|draft and audits in one transaction.
|
||||
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if status != "published" && status != "draft" {
|
||||
return ErrInvalidScaleStatus
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ type LoginResult struct {
|
||||
type AdminMe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -93,7 +94,7 @@ func (s *Service) Login(ctx context.Context, username, password string) (*LoginR
|
||||
return &LoginResult{
|
||||
Token: token,
|
||||
ExpiresAt: exp,
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username},
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -116,7 +117,7 @@ func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||
if err != nil || acc == nil {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username}, nil
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role}, nil
|
||||
}
|
||||
|
||||
// ListUsers lists terminal users.
|
||||
@@ -197,6 +198,9 @@ type GrantInput struct {
|
||||
|
||||
// GrantMembership extends membership and writes audit.
|
||||
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
days, err := planDays(plan)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -221,6 +225,9 @@ var ErrInvalidAskDelta = errString("invalid ask quota delta")
|
||||
|
||||
// GrantAskQuota adds purchased ask quota and audits.
|
||||
func (s *Service) GrantAskQuota(ctx context.Context, adminID, userID uuid.UUID, delta int) (int, error) {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if delta <= 0 || delta > 1000 {
|
||||
return 0, ErrInvalidAskDelta
|
||||
}
|
||||
@@ -247,6 +254,9 @@ func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, e
|
||||
|
||||
// UpsertPlanPrices updates display prices (not historical order amounts).
|
||||
func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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 sets users.status=banned.
|
||||
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"status": "banned"})
|
||||
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "banned", "user.ban", meta)
|
||||
if errors.Is(err, repository.ErrUserStatusNotFound) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UnbanUser sets users.status=active.
|
||||
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"status": "active"})
|
||||
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "active", "user.unban", meta)
|
||||
if errors.Is(err, repository.ErrUserStatusNotFound) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -93,12 +93,15 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
||||
return s.issue(ctx, uid, phone, nickname)
|
||||
}
|
||||
|
||||
// Logout revokes bearer token.
|
||||
func (s *Service) Logout(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
// Logout revokes the current bearer session and unbinds the device from the account
|
||||
// so a refresh no longer resolves as logged-in via X-Device-Key (Spec R5).
|
||||
func (s *Service) Logout(ctx context.Context, token, deviceKey string) error {
|
||||
if token != "" {
|
||||
if err := s.Repo.RevokeSession(ctx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.Repo.RevokeSession(ctx, token)
|
||||
return s.Repo.RebindDeviceAnonymous(ctx, deviceKey)
|
||||
}
|
||||
|
||||
// Me returns account if registered.
|
||||
|
||||
Reference in New Issue
Block a user