package app import ( "context" "errors" "fmt" "net/mail" "regexp" "strings" "sync" "time" "github.com/jackc/pgx/v5/pgconn" "golang.org/x/crypto/bcrypt" "github.com/geo-platform/tenant-api/internal/ops/domain" "github.com/geo-platform/tenant-api/internal/ops/repository" sharedauth "github.com/geo-platform/tenant-api/internal/shared/auth" sharedcache "github.com/geo-platform/tenant-api/internal/shared/cache" "github.com/geo-platform/tenant-api/internal/shared/response" ) const ( ActionAdminUserCreate = "admin_user.create" ActionAdminUserUpdateProfile = "admin_user.update_profile" ActionAdminUserChangePlan = "admin_user.change_plan" ActionAdminUserChangeRole = "admin_user.change_role" ActionAdminUserChangeExpiry = "admin_user.change_expiry" ActionAdminUserResetUsage = "admin_user.reset_usage" ActionAdminUserSetKOL = "admin_user.set_kol" ActionAdminUserDisable = "admin_user.disable" ActionAdminUserEnable = "admin_user.enable" ActionAdminUserResetPassword = "admin_user.reset_password" ActionAdminUserResetLoginLock = "admin_user.reset_login_lock" ) var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`) type AdminUserService struct { users *repository.AdminUserRepository audits *AuditService loginGuard *sharedauth.LoginGuard cache sharedcache.Cache configMu sync.RWMutex defaultPlanCode string } func NewAdminUserService(users *repository.AdminUserRepository, audits *AuditService, defaultPlanCode string) *AdminUserService { defaultPlanCode = strings.TrimSpace(defaultPlanCode) if defaultPlanCode == "" { defaultPlanCode = "free" } return &AdminUserService{ users: users, audits: audits, defaultPlanCode: defaultPlanCode, } } func (s *AdminUserService) WithLoginGuard(g *sharedauth.LoginGuard) *AdminUserService { s.loginGuard = g return s } func (s *AdminUserService) WithCache(c sharedcache.Cache) *AdminUserService { s.cache = c return s } func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) { if s == nil { return } defaultPlanCode = strings.TrimSpace(defaultPlanCode) if defaultPlanCode == "" { defaultPlanCode = "free" } s.configMu.Lock() s.defaultPlanCode = defaultPlanCode s.configMu.Unlock() } func (s *AdminUserService) currentDefaultPlanCode() string { if s == nil { return "free" } s.configMu.RLock() defer s.configMu.RUnlock() if strings.TrimSpace(s.defaultPlanCode) == "" { return "free" } return s.defaultPlanCode } type AdminUserView struct { ID int64 `json:"id"` Email *string `json:"email"` Phone string `json:"phone"` Name *string `json:"name"` Status string `json:"status"` TenantID *int64 `json:"tenant_id"` TenantName *string `json:"tenant_name"` TenantStatus *string `json:"tenant_status"` TenantRole *string `json:"tenant_role"` PrimaryWorkspaceID *int64 `json:"primary_workspace_id"` PlanCode *string `json:"plan_code"` PlanName *string `json:"plan_name"` SubscriptionStatus *string `json:"subscription_status"` SubscriptionEndAt *time.Time `json:"subscription_end_at"` KolProfileID *int64 `json:"kol_profile_id"` KolDisplayName *string `json:"kol_display_name"` KolStatus *string `json:"kol_status"` KolMarketEnabled *bool `json:"kol_market_enabled"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } type PlanView struct { ID int64 `json:"id"` Code string `json:"code"` Name string `json:"name"` Status string `json:"status"` DurationSecs int64 `json:"duration_secs"` ArticleLimit int `json:"article_limit"` AIPointsMonthly int `json:"ai_points_monthly"` BrandLimit int `json:"brand_limit"` } type RoleView struct { Code string `json:"code"` Name string `json:"name"` Description string `json:"description"` Permissions []string `json:"permissions"` } type AdminUserListInput struct { Keyword string Status string Page int Size int } type AdminUserListResult struct { Items []AdminUserView `json:"items"` Total int64 `json:"total"` Page int `json:"page"` Size int `json:"size"` } type CreateAdminUserInput struct { Phone string Email string Password string Name string PlanCode string Role string } type UpdateAdminUserInput struct { Phone string Email string Name string } type SetAdminUserKOLInput struct { Enabled bool DisplayName string MarketEnabled bool } type ResetPlanUsageView struct { TenantID int64 `json:"tenant_id"` SubscriptionID int64 `json:"subscription_id"` ArticleReservations int64 `json:"article_reservations"` ArticleAmount int64 `json:"article_amount"` AIPointReservations int64 `json:"ai_point_reservations"` AIPointsAmount int64 `json:"ai_points_amount"` ArticleWindowStart time.Time `json:"article_window_start"` ArticleWindowEnd time.Time `json:"article_window_end"` AIPointsWindowStart time.Time `json:"ai_points_window_start"` AIPointsWindowEnd time.Time `json:"ai_points_window_end"` } type ResetAdminUserPlanUsageResult struct { User AdminUserView `json:"user"` Reset ResetPlanUsageView `json:"reset"` } type ResetLoginLockResult struct { ID int64 `json:"id"` RedisDeletedKeys int64 `json:"redis_deleted_keys"` FallbackDeletedKeys int `json:"fallback_deleted_keys"` RedisScanPatterns []string `json:"redis_scan_patterns"` RedisAddr string `json:"redis_addr,omitempty"` RedisDB int `json:"redis_db"` } func toAdminUserView(u *repository.AdminUserRecord) AdminUserView { return AdminUserView{ ID: u.ID, Email: u.Email, Phone: u.Phone, Name: u.Name, Status: u.Status, TenantID: u.TenantID, TenantName: u.TenantName, TenantStatus: u.TenantStatus, TenantRole: u.TenantRole, PrimaryWorkspaceID: u.PrimaryWorkspaceID, PlanCode: u.PlanCode, PlanName: u.PlanName, SubscriptionStatus: u.SubscriptionStatus, SubscriptionEndAt: u.SubscriptionEndAt, KolProfileID: u.KolProfileID, KolDisplayName: u.KolDisplayName, KolStatus: u.KolStatus, KolMarketEnabled: u.KolMarketEnabled, CreatedAt: u.CreatedAt, UpdatedAt: u.UpdatedAt, } } func toPlanView(p repository.PlanRecord) PlanView { return PlanView{ ID: p.ID, Code: p.Code, Name: p.Name, Status: p.Status, DurationSecs: p.DurationSecs, ArticleLimit: p.ArticleLimit, AIPointsMonthly: p.AIPointsMonthly, BrandLimit: p.BrandLimit, } } func roleViews() []RoleView { return []RoleView{ { Code: "tenant_admin", Name: "管理员", Description: "拥有当前个人空间的全部管理权限,包括成员、品牌、内容和精调模版管理。", Permissions: sharedauth.PermissionsForRole("tenant_admin"), }, { Code: "editor", Name: "编辑", Description: "可管理品牌和内容生成,但不能执行成员与高权限管理操作。", Permissions: sharedauth.PermissionsForRole("editor"), }, { Code: "viewer", Name: "只读", Description: "仅可查看工作台、品牌、文章和模版信息。", Permissions: sharedauth.PermissionsForRole("viewer"), }, } } func isValidTenantRole(role string) bool { for _, item := range roleViews() { if item.Code == role { return true } } return false } func workspaceRoleForTenantRole(role string) string { switch role { case "tenant_admin": return "owner" case "editor": return "editor" default: return "viewer" } } func (s *AdminUserService) List(ctx context.Context, in AdminUserListInput) (*AdminUserListResult, error) { page := in.Page if page <= 0 { page = 1 } size := in.Size if size <= 0 || size > 200 { size = 20 } items, total, err := s.users.List(ctx, repository.AdminUserListFilter{ Keyword: strings.TrimSpace(in.Keyword), Status: strings.TrimSpace(in.Status), Limit: size, Offset: (page - 1) * size, }) if err != nil { return nil, response.ErrInternal(50030, "query_failed", "failed to list users") } views := make([]AdminUserView, 0, len(items)) for i := range items { views = append(views, toAdminUserView(&items[i])) } return &AdminUserListResult{Items: views, Total: total, Page: page, Size: size}, nil } func (s *AdminUserService) ListPlans(ctx context.Context) ([]PlanView, error) { plans, err := s.users.ListActivePlans(ctx) if err != nil { return nil, response.ErrInternal(50036, "query_failed", "failed to list plans") } views := make([]PlanView, 0, len(plans)) for _, plan := range plans { views = append(views, toPlanView(plan)) } return views, nil } func (s *AdminUserService) ListRoles() []RoleView { return roleViews() } func (s *AdminUserService) Get(ctx context.Context, id int64) (*AdminUserView, error) { user, err := s.users.GetByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在") } return nil, response.ErrInternal(50031, "query_failed", "failed to get user") } view := toAdminUserView(user) return &view, nil } func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAdminUserInput) (*AdminUserView, error) { phone, err := normalizePhone(in.Phone) if err != nil { return nil, err } email, err := normalizeOptionalEmail(in.Email) if err != nil { return nil, err } if len(in.Password) < 8 { return nil, response.ErrBadRequest(40031, "weak_password", "密码至少 8 位") } planCode := strings.TrimSpace(in.PlanCode) if planCode == "" { planCode = s.currentDefaultPlanCode() } role := strings.TrimSpace(in.Role) if role == "" { role = "tenant_admin" } if !isValidTenantRole(role) { return nil, response.ErrBadRequest(40036, "invalid_role", "角色值无效") } name := normalizeOptionalName(in.Name) hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost) if err != nil { return nil, err } user, err := s.users.CreatePersonalTenantUser(ctx, repository.CreateAdminUserInput{ Email: email, Phone: phone, PasswordHash: string(hash), Name: name, PlanCode: planCode, TenantRole: role, WorkspaceRole: workspaceRoleForTenantRole(role), }) if err != nil { return nil, s.mapWriteError(err) } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserCreate, "admin_user", user.ID, map[string]any{ "phone": phone, "email": email, "name": name, "plan_code": planCode, "tenant_role": role, "tenant_id": user.TenantID, })) } view := toAdminUserView(user) return &view, nil } func (s *AdminUserService) Update(ctx context.Context, actor *Actor, id int64, in UpdateAdminUserInput) error { phone, err := normalizePhone(in.Phone) if err != nil { return err } email, err := normalizeOptionalEmail(in.Email) if err != nil { return err } name := normalizeOptionalName(in.Name) if err := s.users.UpdateProfile(ctx, id, repository.UpdateAdminUserProfileInput{ Email: email, Phone: phone, Name: name, }); err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return response.ErrNotFound(40430, "user_not_found", "用户不存在") } return s.mapWriteError(err) } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserUpdateProfile, "admin_user", id, map[string]any{ "phone": phone, "email": email, "name": name, })) } return nil } func (s *AdminUserService) ChangePlan(ctx context.Context, actor *Actor, id int64, planCode string) (*AdminUserView, error) { normalizedPlanCode := strings.TrimSpace(planCode) if normalizedPlanCode == "" { return nil, response.ErrBadRequest(40034, "invalid_plan", "请选择套餐") } user, err := s.users.ChangePlan(ctx, id, normalizedPlanCode) if err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在") } if errors.Is(err, repository.ErrDefaultPlanNotFound) { return nil, response.ErrBadRequest(40035, "plan_not_found", "套餐不存在或已停用") } return nil, response.ErrInternal(50037, "update_failed", "failed to update user plan") } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangePlan, "admin_user", id, map[string]any{ "plan_code": normalizedPlanCode, "tenant_id": user.TenantID, })) } view := toAdminUserView(user) return &view, nil } func (s *AdminUserService) UpdateSubscriptionExpiry(ctx context.Context, actor *Actor, id int64, endAt time.Time) (*AdminUserView, error) { if endAt.IsZero() { return nil, response.ErrBadRequest(40038, "invalid_subscription_expiry", "请选择会员到期时间") } user, err := s.users.UpdateSubscriptionEndAt(ctx, id, endAt.UTC()) if err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在") } if errors.Is(err, repository.ErrActivePlanNotFound) { return nil, response.ErrBadRequest(40039, "subscription_not_found", "用户当前没有可编辑的套餐") } return nil, response.ErrInternal(50040, "update_failed", "failed to update subscription expiry") } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangeExpiry, "admin_user", id, map[string]any{ "tenant_id": user.TenantID, "end_at": endAt.UTC(), })) } view := toAdminUserView(user) return &view, nil } func (s *AdminUserService) ResetCurrentPlanUsage(ctx context.Context, actor *Actor, id int64) (*ResetAdminUserPlanUsageResult, error) { user, reset, err := s.users.ResetCurrentPlanUsage(ctx, id) if err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在") } if errors.Is(err, repository.ErrActivePlanNotFound) { return nil, response.ErrBadRequest(40040, "subscription_not_found", "用户当前没有可重置的套餐") } return nil, response.ErrInternal(50041, "reset_failed", "failed to reset current plan usage") } resetView := ResetPlanUsageView{ TenantID: reset.TenantID, SubscriptionID: reset.SubscriptionID, ArticleReservations: reset.ArticleReservations, ArticleAmount: reset.ArticleAmount, AIPointReservations: reset.AIPointReservations, AIPointsAmount: reset.AIPointsAmount, ArticleWindowStart: reset.ArticleWindowStart, ArticleWindowEnd: reset.ArticleWindowEnd, AIPointsWindowStart: reset.AIPointsWindowStart, AIPointsWindowEnd: reset.AIPointsWindowEnd, } s.invalidateQuotaSummaryCache(ctx, reset.TenantID) if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetUsage, "admin_user", id, map[string]any{ "tenant_id": reset.TenantID, "subscription_id": reset.SubscriptionID, "article_reservations": reset.ArticleReservations, "article_amount": reset.ArticleAmount, "ai_point_reservations": reset.AIPointReservations, "ai_points_amount": reset.AIPointsAmount, "article_window_start": reset.ArticleWindowStart, "article_window_end": reset.ArticleWindowEnd, "ai_points_window_start": reset.AIPointsWindowStart, "ai_points_window_end": reset.AIPointsWindowEnd, })) } return &ResetAdminUserPlanUsageResult{ User: toAdminUserView(user), Reset: resetView, }, nil } func (s *AdminUserService) invalidateQuotaSummaryCache(ctx context.Context, tenantID int64) { if s == nil || s.cache == nil || tenantID <= 0 { return } _ = s.cache.Delete(ctx, fmt.Sprintf("workspace:quota_summary:%d", tenantID)) } func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) { normalizedRole := strings.TrimSpace(role) if !isValidTenantRole(normalizedRole) { return nil, response.ErrBadRequest(40036, "invalid_role", "角色值无效") } user, err := s.users.ChangeRole(ctx, id, normalizedRole, workspaceRoleForTenantRole(normalizedRole)) if err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在") } return nil, response.ErrInternal(50038, "update_failed", "failed to update user role") } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserChangeRole, "admin_user", id, map[string]any{ "tenant_role": normalizedRole, "tenant_id": user.TenantID, })) } view := toAdminUserView(user) return &view, nil } func (s *AdminUserService) SetKOL(ctx context.Context, actor *Actor, id int64, in SetAdminUserKOLInput) (*AdminUserView, error) { displayName := strings.TrimSpace(in.DisplayName) if in.Enabled && len([]rune(displayName)) > 100 { return nil, response.ErrBadRequest(40037, "invalid_kol_display_name", "KOL 昵称不能超过 100 个字") } user, err := s.users.SetKOL(ctx, id, in.Enabled, displayName, in.MarketEnabled) if err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在") } return nil, response.ErrInternal(50039, "update_failed", "failed to update KOL identity") } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserSetKOL, "admin_user", id, map[string]any{ "enabled": in.Enabled, "display_name": displayName, "market_enabled": in.MarketEnabled, "tenant_id": user.TenantID, "kol_profile_id": user.KolProfileID, })) } view := toAdminUserView(user) return &view, nil } func (s *AdminUserService) SetStatus(ctx context.Context, actor *Actor, id int64, status string) error { if status != domain.StatusActive && status != domain.StatusDisabled { return response.ErrBadRequest(40032, "invalid_status", "状态值无效") } if err := s.users.UpdateStatus(ctx, id, status); err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return response.ErrNotFound(40430, "user_not_found", "用户不存在") } return response.ErrInternal(50032, "update_failed", "failed to update user status") } action := ActionAdminUserEnable if status == domain.StatusDisabled { action = ActionAdminUserDisable } if actor != nil { _ = s.audits.Append(ctx, actor.audit(action, "admin_user", id, nil)) } return nil } func (s *AdminUserService) ResetPassword(ctx context.Context, actor *Actor, id int64, password string) error { if len(password) < 8 { return response.ErrBadRequest(40031, "weak_password", "密码至少 8 位") } hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return err } if err := s.users.UpdatePassword(ctx, id, string(hash)); err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return response.ErrNotFound(40430, "user_not_found", "用户不存在") } return response.ErrInternal(50033, "update_failed", "failed to update user password") } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetPassword, "admin_user", id, nil)) } return nil } func (s *AdminUserService) ResetLoginLock(ctx context.Context, actor *Actor, id int64) (*ResetLoginLockResult, error) { user, err := s.users.GetByID(ctx, id) if err != nil { if errors.Is(err, repository.ErrAdminUserNotFound) { return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在") } return nil, response.ErrInternal(50034, "query_failed", "failed to get user") } result := &ResetLoginLockResult{ID: id} if s.loginGuard != nil { unlock, err := s.loginGuard.UnlockWithResult(ctx, user.Phone) if err != nil { return nil, response.ErrServiceUnavailable(50305, "login_guard_unavailable", "登录保护暂时不可用,请稍后重试") } result.RedisDeletedKeys = unlock.RedisDeletedKeys result.FallbackDeletedKeys = unlock.FallbackDeletedKeys result.RedisScanPatterns = unlock.RedisScanPatterns result.RedisAddr = unlock.RedisAddr result.RedisDB = unlock.RedisDB } if actor != nil { _ = s.audits.Append(ctx, actor.audit(ActionAdminUserResetLoginLock, "admin_user", id, nil)) } return result, nil } func (s *AdminUserService) mapWriteError(err error) error { if errors.Is(err, repository.ErrDefaultPlanNotFound) { return response.ErrInternal(50034, "default_plan_missing", "默认套餐未配置") } var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "23505" { return response.ErrConflict(40930, "duplicate_user", "手机号或邮箱已存在") } return response.ErrInternal(50035, "write_failed", "failed to save user") } func normalizePhone(input string) (string, error) { phone := strings.TrimSpace(input) phone = strings.NewReplacer(" ", "", "-", "", "\t", "").Replace(phone) if !phonePattern.MatchString(phone) { return "", response.ErrBadRequest(40030, "invalid_phone", "请输入有效手机号") } return phone, nil } func normalizeOptionalEmail(input string) (*string, error) { email := strings.TrimSpace(input) if email == "" { return nil, nil } parsed, err := mail.ParseAddress(email) if err != nil || parsed.Address != email { return nil, response.ErrBadRequest(40033, "invalid_email", "邮箱格式无效") } return &email, nil } func normalizeOptionalName(input string) *string { name := strings.TrimSpace(input) if name == "" { return nil } return &name }