feat(ECR-013B): AccountLifecycle Closed;启用 Loop 连续执行

UserStatus 迁移、DeviceAuth 拒绝非 active、admin-h5 CTA;
Reviewer Closed。Human 授权 LOOP_AUTHORIZATION(免逐闸确认)。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 17:47:18 +08:00
co-authored by Cursor
parent f75b42397f
commit e25cd94b0c
33 changed files with 799 additions and 56 deletions
@@ -0,0 +1,76 @@
package admin
import (
"context"
"encoding/json"
"strings"
"github.com/google/uuid"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
)
var (
ErrInvalidStatusEdge = errString("invalid status transition")
ErrReasonRequired = errString("reason required")
)
var allowedStatusEdges = map[string]map[string]struct{}{
"active": {"disabled": {}, "banned": {}, "suspended": {}},
"disabled": {"active": {}, "banned": {}},
"suspended": {"active": {}, "banned": {}, "disabled": {}},
"banned": {"active": {}, "disabled": {}},
}
// TransitionUserStatus migrates UserStatus with audit.
func (s *Service) TransitionUserStatus(ctx context.Context, adminID, userID uuid.UUID, toStatus, reason string) error {
toStatus = strings.TrimSpace(toStatus)
reason = strings.TrimSpace(reason)
if reason == "" {
return ErrReasonRequired
}
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return err
}
if !ok {
return ErrUserNotFound
}
from, err := s.Repo.GetUserStatus(ctx, userID)
if err != nil || from == "" {
return ErrUserNotFound
}
if from == toStatus {
return ErrInvalidStatusEdge
}
next, okEdge := allowedStatusEdges[from]
if !okEdge {
return ErrInvalidStatusEdge
}
if _, okEdge = next[toStatus]; !okEdge {
return ErrInvalidStatusEdge
}
meta, _ := json.Marshal(map[string]string{
"from": from, "to": toStatus, "reason": reason,
})
return s.Repo.TransitionUserStatusWithAudit(ctx, adminID, userID, from, toStatus, reason, meta)
}
// ListStatusTransitions returns recent transitions.
func (s *Service) ListStatusTransitions(ctx context.Context, userID uuid.UUID, limit int) ([]repository.AccountTransition, error) {
ok, err := s.Repo.UserExists(ctx, userID)
if err != nil {
return nil, err
}
if !ok {
return nil, ErrUserNotFound
}
items, err := s.Repo.ListStatusTransitions(ctx, userID, limit)
if err != nil {
return nil, err
}
if items == nil {
items = []repository.AccountTransition{}
}
return items, nil
}
+2
View File
@@ -19,12 +19,14 @@ const (
PermContentWrite = "admin.content.write"
PermRolesRead = "admin.roles.read"
PermRolesWrite = "admin.roles.write"
PermUsersStatusWrite = "admin.users.status.write"
)
var knownPermissions = map[string]struct{}{
PermUsersRead: {}, PermMembershipGrant: {}, PermAskQuotaGrant: {},
PermOrdersRead: {}, PermAuditRead: {}, PermAnalyticsRead: {},
PermContentWrite: {}, PermRolesRead: {}, PermRolesWrite: {},
PermUsersStatusWrite: {},
}
var (
@@ -35,6 +35,9 @@ type SessionResult struct {
User Me `json:"user"`
}
// ErrAccountRestricted is returned when UserStatus is not active.
var ErrAccountRestricted = errors.New("账户已受限")
// Register upgrades or opens an account (same open rules as Login).
func (s *Service) Register(ctx context.Context, userID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
return s.OpenLogin(ctx, userID, deviceKey, phone, password, nickname)
@@ -60,6 +63,9 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
acc, err := s.Repo.GetByPhone(ctx, phone)
if err == nil {
if acc.Status != "active" {
return nil, ErrAccountRestricted
}
_ = s.Repo.TouchPassword(ctx, acc.ID, hashStr)
if deviceKey != "" {
_ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
@@ -78,6 +84,9 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
uid := deviceUserID
if curErr == nil && cur.Phone == "" {
if cur.Status != "active" {
return nil, ErrAccountRestricted
}
if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nickname); err != nil {
return nil, errors.New("登录失败,请重试")
}