From 7be0de0614d5a448bd73157bf32b2f604621282e Mon Sep 17 00:00:00 2001 From: liangxu Date: Wed, 29 Apr 2026 00:02:46 +0800 Subject: [PATCH] feat(ops): add tenant admin user management module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds /admin-users CRUD on the ops backend, covering listing, plan/role/KOL toggles, subscription expiry, status flips and password resets, with a configurable default plan code. The ops console exposes a new top-level "用户管理" view and reorganises the sidebar so 操作员管理 and 审计日志 move under a "系统设置" group; AccountsView is renamed accordingly. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/ops-web/src/layouts/AppShell.vue | 51 +- apps/ops-web/src/main.ts | 2 + apps/ops-web/src/router/index.ts | 12 +- apps/ops-web/src/styles.css | 2 - apps/ops-web/src/views/AccountsView.vue | 2 +- apps/ops-web/src/views/AdminUsersView.vue | 737 ++++++++++++++++ apps/ops-web/src/views/LoginView.vue | 2 +- deploy/ops-config.yaml | 3 + server/cmd/ops-api/main.go | 19 +- server/configs/ops-config.yaml | 3 + server/internal/ops/app/admin_user.go | 610 +++++++++++++ server/internal/ops/config/config.go | 9 + server/internal/ops/repository/admin_user.go | 833 ++++++++++++++++++ .../ops/transport/admin_user_handler.go | 286 ++++++ server/internal/ops/transport/router.go | 30 +- 15 files changed, 2574 insertions(+), 27 deletions(-) create mode 100644 apps/ops-web/src/views/AdminUsersView.vue create mode 100644 server/internal/ops/app/admin_user.go create mode 100644 server/internal/ops/repository/admin_user.go create mode 100644 server/internal/ops/transport/admin_user_handler.go diff --git a/apps/ops-web/src/layouts/AppShell.vue b/apps/ops-web/src/layouts/AppShell.vue index 091e8c0..8f1d5f6 100644 --- a/apps/ops-web/src/layouts/AppShell.vue +++ b/apps/ops-web/src/layouts/AppShell.vue @@ -9,7 +9,9 @@ theme="light" mode="inline" :selected-keys="selectedKeys" + :open-keys="openKeys" :items="menuItems" + @update:open-keys="onOpenKeysChange" @click="onMenuClick" /> @@ -44,12 +46,19 @@ + + diff --git a/apps/ops-web/src/views/LoginView.vue b/apps/ops-web/src/views/LoginView.vue index e8140c3..42a9c29 100644 --- a/apps/ops-web/src/views/LoginView.vue +++ b/apps/ops-web/src/views/LoginView.vue @@ -100,7 +100,7 @@ async function onSubmit() { try { await auth.login(form.username.trim(), form.password); const redirect = - typeof route.query.redirect === "string" ? route.query.redirect : "/accounts"; + typeof route.query.redirect === "string" ? route.query.redirect : "/admin-users"; void router.replace(redirect); } catch (error) { if (error instanceof OpsApiError) { diff --git a/deploy/ops-config.yaml b/deploy/ops-config.yaml index f1845a7..22e3d9b 100644 --- a/deploy/ops-config.yaml +++ b/deploy/ops-config.yaml @@ -25,3 +25,6 @@ default_admin: display_name: 系统管理员 email: "" password: "" # 由 OPS_DEFAULT_ADMIN_PASSWORD 环境变量注入(仅在 operator_accounts 为空时生效) + +admin_users: + default_plan_code: free diff --git a/server/cmd/ops-api/main.go b/server/cmd/ops-api/main.go index c7c2d2a..29d6385 100644 --- a/server/cmd/ops-api/main.go +++ b/server/cmd/ops-api/main.go @@ -12,8 +12,8 @@ import ( "github.com/gin-gonic/gin" - opsconfig "github.com/geo-platform/tenant-api/internal/ops/config" "github.com/geo-platform/tenant-api/internal/ops/app" + opsconfig "github.com/geo-platform/tenant-api/internal/ops/config" "github.com/geo-platform/tenant-api/internal/ops/repository" "github.com/geo-platform/tenant-api/internal/ops/transport" "github.com/geo-platform/tenant-api/internal/shared/observability" @@ -45,12 +45,14 @@ func main() { defer pool.Close() accountsRepo := repository.NewAccountRepository(pool) + adminUsersRepo := repository.NewAdminUserRepository(pool) auditsRepo := repository.NewAuditRepository(pool) auditSvc := app.NewAuditService(auditsRepo, logger) issuer := app.NewTokenIssuer(cfg.JWT.Secret, cfg.JWT.AccessTTL) authSvc := app.NewAuthService(accountsRepo, auditSvc, issuer) accountSvc := app.NewAccountService(accountsRepo, auditSvc) + adminUserSvc := app.NewAdminUserService(adminUsersRepo, auditSvc, cfg.AdminUsers.DefaultPlanCode) if err := app.SeedDefaultAdmin(ctx, accountsRepo, app.DefaultAdminSeed{ Username: cfg.DefaultAdmin.Username, @@ -67,13 +69,14 @@ func main() { engine := gin.New() transport.RegisterRoutes(transport.Deps{ - Engine: engine, - Logger: logger, - DB: pool, - Issuer: issuer, - Auth: authSvc, - Accounts: accountSvc, - Audits: auditSvc, + Engine: engine, + Logger: logger, + DB: pool, + Issuer: issuer, + Auth: authSvc, + Accounts: accountSvc, + AdminUsers: adminUserSvc, + Audits: auditSvc, }) addr := fmt.Sprintf(":%d", cfg.Server.Port) diff --git a/server/configs/ops-config.yaml b/server/configs/ops-config.yaml index 4880855..b18c1ac 100644 --- a/server/configs/ops-config.yaml +++ b/server/configs/ops-config.yaml @@ -25,3 +25,6 @@ default_admin: display_name: 系统管理员 email: "" password: "liang0920" # 必须通过 OPS_DEFAULT_ADMIN_PASSWORD 提供(首次启动且表为空时使用) + +admin_users: + default_plan_code: free diff --git a/server/internal/ops/app/admin_user.go b/server/internal/ops/app/admin_user.go new file mode 100644 index 0000000..7b473d7 --- /dev/null +++ b/server/internal/ops/app/admin_user.go @@ -0,0 +1,610 @@ +package app + +import ( + "context" + "errors" + "net/mail" + "regexp" + "strings" + "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" + "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" +) + +var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`) + +type AdminUserService struct { + users *repository.AdminUserRepository + audits *AuditService + 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, + } +} + +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"` + AIPointUsageLogs int64 `json:"ai_point_usage_logs"` + 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"` +} + +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.defaultPlanCode + } + 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, + AIPointUsageLogs: reset.AIPointUsageLogs, + ArticleWindowStart: reset.ArticleWindowStart, + ArticleWindowEnd: reset.ArticleWindowEnd, + AIPointsWindowStart: reset.AIPointsWindowStart, + AIPointsWindowEnd: reset.AIPointsWindowEnd, + } + + 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, + "ai_point_usage_logs": reset.AIPointUsageLogs, + "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) 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) 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 +} diff --git a/server/internal/ops/config/config.go b/server/internal/ops/config/config.go index f11e309..bb0255f 100644 --- a/server/internal/ops/config/config.go +++ b/server/internal/ops/config/config.go @@ -17,6 +17,7 @@ type Config struct { Log sharedconfig.LogConfig `mapstructure:"log"` JWT JWTConfig `mapstructure:"jwt"` DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` + AdminUsers AdminUsersConfig `mapstructure:"admin_users"` } type JWTConfig struct { @@ -31,6 +32,10 @@ type DefaultAdminConfig struct { Email string `mapstructure:"email"` } +type AdminUsersConfig struct { + DefaultPlanCode string `mapstructure:"default_plan_code"` +} + func Load(path string) (*Config, error) { v := viper.New() v.SetConfigFile(path) @@ -46,6 +51,7 @@ func Load(path string) (*Config, error) { v.SetDefault("jwt.access_ttl", 8*time.Hour) v.SetDefault("default_admin.username", "admin") v.SetDefault("default_admin.display_name", "Administrator") + v.SetDefault("admin_users.default_plan_code", "free") if err := v.ReadInConfig(); err != nil { return nil, fmt.Errorf("read config: %w", err) @@ -83,5 +89,8 @@ func applyEnvOverrides(cfg *Config) error { if v := os.Getenv("OPS_DEFAULT_ADMIN_EMAIL"); v != "" { cfg.DefaultAdmin.Email = v } + if v := os.Getenv("OPS_ADMIN_USERS_DEFAULT_PLAN_CODE"); v != "" { + cfg.AdminUsers.DefaultPlanCode = v + } return nil } diff --git a/server/internal/ops/repository/admin_user.go b/server/internal/ops/repository/admin_user.go new file mode 100644 index 0000000..2a87e77 --- /dev/null +++ b/server/internal/ops/repository/admin_user.go @@ -0,0 +1,833 @@ +package repository + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository" +) + +var ( + ErrAdminUserNotFound = errors.New("admin user not found") + ErrDefaultPlanNotFound = errors.New("default plan not found") + ErrActivePlanNotFound = errors.New("active plan subscription not found") + ErrPrimaryWorkspaceEmpty = errors.New("primary workspace not created") +) + +type AdminUserRepository struct { + pool *pgxpool.Pool +} + +func NewAdminUserRepository(pool *pgxpool.Pool) *AdminUserRepository { + return &AdminUserRepository{pool: pool} +} + +type AdminUserRecord struct { + ID int64 + Email *string + Phone string + Name *string + Status string + TenantID *int64 + TenantName *string + TenantStatus *string + TenantRole *string + PrimaryWorkspaceID *int64 + PlanCode *string + PlanName *string + SubscriptionStatus *string + SubscriptionEndAt *time.Time + KolProfileID *int64 + KolDisplayName *string + KolStatus *string + KolMarketEnabled *bool + CreatedAt time.Time + UpdatedAt time.Time +} + +type PlanRecord struct { + ID int64 + Code string + Name string + Status string + DurationSecs int64 + ArticleLimit int + AIPointsMonthly int + BrandLimit int +} + +type AdminUserListFilter struct { + Keyword string + Status string + Limit int + Offset int +} + +type CreateAdminUserInput struct { + Email *string + Phone string + PasswordHash string + Name *string + PlanCode string + TenantRole string + WorkspaceRole string +} + +type UpdateAdminUserProfileInput struct { + Email *string + Phone string + Name *string +} + +type ResetPlanUsageResult struct { + TenantID int64 + SubscriptionID int64 + ArticleReservations int64 + ArticleAmount int64 + AIPointReservations int64 + AIPointsAmount int64 + AIPointUsageLogs int64 + ArticleWindowStart time.Time + ArticleWindowEnd time.Time + AIPointsWindowStart time.Time + AIPointsWindowEnd time.Time +} + +const adminUserColumns = ` +u.id, +u.email, +u.phone, +u.name, +u.status, +tm.tenant_id, +t.name AS tenant_name, +t.status AS tenant_status, +tm.tenant_role, +wm.workspace_id AS primary_workspace_id, +sub.plan_code, +sub.plan_name, +sub.subscription_status, +sub.subscription_end_at, +kp.id AS kol_profile_id, +kp.display_name AS kol_display_name, +kp.status AS kol_status, +kp.market_enabled AS kol_market_enabled, +u.created_at, +u.updated_at` + +const adminUserFromClause = ` +FROM users u +LEFT JOIN LATERAL ( + SELECT tm.tenant_id, tm.tenant_role + FROM tenant_memberships tm + WHERE tm.user_id = u.id + AND tm.deleted_at IS NULL + ORDER BY tm.created_at ASC, tm.id ASC + LIMIT 1 +) tm ON TRUE +LEFT JOIN tenants t + ON t.id = tm.tenant_id + AND t.deleted_at IS NULL +LEFT JOIN LATERAL ( + SELECT wm.workspace_id + FROM workspace_memberships wm + WHERE wm.user_id = u.id + AND wm.tenant_id = tm.tenant_id + AND wm.is_primary = TRUE + ORDER BY wm.created_at ASC, wm.id ASC + LIMIT 1 +) wm ON TRUE +LEFT JOIN LATERAL ( + SELECT + p.plan_code, + p.name AS plan_name, + s.status AS subscription_status, + s.end_at AS subscription_end_at + FROM tenant_plan_subscriptions s + JOIN plans p + ON p.id = s.plan_id + AND p.deleted_at IS NULL + WHERE s.tenant_id = tm.tenant_id + AND s.deleted_at IS NULL + ORDER BY + CASE + WHEN s.status = 'active' + AND p.status = 'active' + AND s.start_at <= NOW() + AND s.end_at > NOW() + THEN 0 + ELSE 1 + END, + s.start_at DESC, + s.id DESC + LIMIT 1 +) sub ON TRUE +LEFT JOIN LATERAL ( + SELECT kp.id, kp.display_name, kp.status, kp.market_enabled + FROM kol_profiles kp + WHERE kp.user_id = u.id + AND kp.deleted_at IS NULL + ORDER BY kp.created_at ASC, kp.id ASC + LIMIT 1 +) kp ON TRUE` + +func scanAdminUser(row pgx.Row) (*AdminUserRecord, error) { + var u AdminUserRecord + err := row.Scan( + &u.ID, + &u.Email, + &u.Phone, + &u.Name, + &u.Status, + &u.TenantID, + &u.TenantName, + &u.TenantStatus, + &u.TenantRole, + &u.PrimaryWorkspaceID, + &u.PlanCode, + &u.PlanName, + &u.SubscriptionStatus, + &u.SubscriptionEndAt, + &u.KolProfileID, + &u.KolDisplayName, + &u.KolStatus, + &u.KolMarketEnabled, + &u.CreatedAt, + &u.UpdatedAt, + ) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAdminUserNotFound + } + return nil, err + } + return &u, nil +} + +func (r *AdminUserRepository) GetByID(ctx context.Context, id int64) (*AdminUserRecord, error) { + row := r.pool.QueryRow(ctx, ` +SELECT `+adminUserColumns+` +`+adminUserFromClause+` +WHERE u.id = $1 + AND u.deleted_at IS NULL`, id) + return scanAdminUser(row) +} + +func (r *AdminUserRepository) List(ctx context.Context, f AdminUserListFilter) ([]AdminUserRecord, int64, error) { + args := []any{} + where := "u.deleted_at IS NULL" + + if f.Keyword != "" { + args = append(args, "%"+f.Keyword+"%") + where += fmt.Sprintf(" AND (u.name ILIKE $%d OR u.email ILIKE $%d OR u.phone ILIKE $%d OR t.name ILIKE $%d)", + len(args), len(args), len(args), len(args)) + } + if f.Status != "" { + args = append(args, f.Status) + where += fmt.Sprintf(" AND u.status = $%d", len(args)) + } + + var total int64 + if err := r.pool.QueryRow(ctx, ` +SELECT COUNT(*) +`+adminUserFromClause+` +WHERE `+where, args...).Scan(&total); err != nil { + return nil, 0, err + } + + limit := f.Limit + if limit <= 0 || limit > 200 { + limit = 20 + } + offset := f.Offset + if offset < 0 { + offset = 0 + } + args = append(args, limit, offset) + limitArg := len(args) - 1 + offsetArg := len(args) + + rows, err := r.pool.Query(ctx, fmt.Sprintf(` +SELECT %s +%s +WHERE %s +ORDER BY u.created_at DESC, u.id DESC +LIMIT $%d OFFSET $%d`, adminUserColumns, adminUserFromClause, where, limitArg, offsetArg), args...) + if err != nil { + return nil, 0, err + } + defer rows.Close() + + out := make([]AdminUserRecord, 0, limit) + for rows.Next() { + record, err := scanAdminUser(rows) + if err != nil { + return nil, 0, err + } + out = append(out, *record) + } + return out, total, rows.Err() +} + +func (r *AdminUserRepository) ListActivePlans(ctx context.Context) ([]PlanRecord, error) { + rows, err := r.pool.Query(ctx, ` +SELECT + id, + plan_code, + name, + status, + COALESCE(NULLIF(quota_policy_json->>'subscription_duration_seconds', '')::BIGINT, 259200), + COALESCE(NULLIF(quota_policy_json->>'article_generation', '')::INT, 0), + COALESCE(NULLIF(quota_policy_json->>'ai_points_monthly', '')::INT, 0), + COALESCE(NULLIF(quota_policy_json->>'brand_limit', '')::INT, 0) +FROM plans +WHERE status = 'active' + AND deleted_at IS NULL +ORDER BY id ASC`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []PlanRecord{} + for rows.Next() { + var p PlanRecord + if err := rows.Scan( + &p.ID, + &p.Code, + &p.Name, + &p.Status, + &p.DurationSecs, + &p.ArticleLimit, + &p.AIPointsMonthly, + &p.BrandLimit, + ); err != nil { + return nil, err + } + out = append(out, p) + } + return out, rows.Err() +} + +func (r *AdminUserRepository) CreatePersonalTenantUser(ctx context.Context, in CreateAdminUserInput) (*AdminUserRecord, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var userID int64 + if err := tx.QueryRow(ctx, ` +INSERT INTO users (email, phone, password_hash, name, status) +VALUES ($1, $2, $3, $4, 'active') +RETURNING id`, in.Email, in.Phone, in.PasswordHash, in.Name).Scan(&userID); err != nil { + return nil, err + } + + var tenantID int64 + tenantName := fmt.Sprintf("user-%d", userID) + if err := tx.QueryRow(ctx, ` +INSERT INTO tenants (name, status) +VALUES ($1, 'active') +RETURNING id`, tenantName).Scan(&tenantID); err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, ` +INSERT INTO tenant_memberships (tenant_id, user_id, tenant_role) +VALUES ($1, $2, $3)`, tenantID, userID, in.TenantRole); err != nil { + return nil, err + } + + var workspaceID int64 + if err := tx.QueryRow(ctx, ` +INSERT INTO workspaces (tenant_id, name, slug, is_default) +VALUES ($1, 'Default', 'default', TRUE) +RETURNING id`, tenantID).Scan(&workspaceID); err != nil { + return nil, err + } + if workspaceID == 0 { + return nil, ErrPrimaryWorkspaceEmpty + } + + if _, err := tx.Exec(ctx, ` +INSERT INTO workspace_memberships (workspace_id, user_id, tenant_id, role, is_primary) +VALUES ($1, $2, $3, $4, TRUE)`, workspaceID, userID, tenantID, in.WorkspaceRole); err != nil { + return nil, err + } + + var ( + planID int64 + durationSeconds int64 + ) + if err := tx.QueryRow(ctx, ` +SELECT id, + COALESCE(NULLIF(quota_policy_json->>'subscription_duration_seconds', '')::BIGINT, 259200) +FROM plans +WHERE plan_code = $1 + AND status = 'active' + AND deleted_at IS NULL +LIMIT 1`, in.PlanCode).Scan(&planID, &durationSeconds); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDefaultPlanNotFound + } + return nil, err + } + if durationSeconds <= 0 { + durationSeconds = 259200 + } + + if _, err := tx.Exec(ctx, ` +INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status) +VALUES ($1, $2, NOW(), NOW() + ($3::BIGINT * INTERVAL '1 second'), 'active')`, + tenantID, planID, durationSeconds); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return r.GetByID(ctx, userID) +} + +func (r *AdminUserRepository) ChangePlan(ctx context.Context, userID int64, planCode string) (*AdminUserRecord, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var tenantID int64 + if err := tx.QueryRow(ctx, ` +SELECT tm.tenant_id +FROM tenant_memberships tm +WHERE tm.user_id = $1 + AND tm.deleted_at IS NULL +ORDER BY tm.created_at ASC, tm.id ASC +LIMIT 1`, userID).Scan(&tenantID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAdminUserNotFound + } + return nil, err + } + + var ( + planID int64 + durationSeconds int64 + ) + if err := tx.QueryRow(ctx, ` +SELECT id, + COALESCE(NULLIF(quota_policy_json->>'subscription_duration_seconds', '')::BIGINT, 259200) +FROM plans +WHERE plan_code = $1 + AND status = 'active' + AND deleted_at IS NULL +LIMIT 1`, planCode).Scan(&planID, &durationSeconds); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrDefaultPlanNotFound + } + return nil, err + } + if durationSeconds <= 0 { + durationSeconds = 259200 + } + + if _, err := tx.Exec(ctx, ` +UPDATE tenant_plan_subscriptions +SET status = 'replaced', + updated_at = NOW() +WHERE tenant_id = $1 + AND status = 'active' + AND deleted_at IS NULL`, tenantID); err != nil { + return nil, err + } + + if _, err := tx.Exec(ctx, ` +INSERT INTO tenant_plan_subscriptions (tenant_id, plan_id, start_at, end_at, status) +VALUES ($1, $2, NOW(), NOW() + ($3::BIGINT * INTERVAL '1 second'), 'active')`, + tenantID, planID, durationSeconds); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return r.GetByID(ctx, userID) +} + +func (r *AdminUserRepository) UpdateSubscriptionEndAt(ctx context.Context, userID int64, endAt time.Time) (*AdminUserRecord, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var tenantID int64 + if err := tx.QueryRow(ctx, ` +SELECT tm.tenant_id +FROM tenant_memberships tm +WHERE tm.user_id = $1 + AND tm.deleted_at IS NULL +ORDER BY tm.created_at ASC, tm.id ASC +LIMIT 1`, userID).Scan(&tenantID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAdminUserNotFound + } + return nil, err + } + + tag, err := tx.Exec(ctx, ` +UPDATE tenant_plan_subscriptions +SET end_at = $1, + updated_at = NOW() +WHERE tenant_id = $2 + AND status = 'active' + AND deleted_at IS NULL`, endAt.UTC(), tenantID) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, ErrActivePlanNotFound + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return r.GetByID(ctx, userID) +} + +func (r *AdminUserRepository) ResetCurrentPlanUsage(ctx context.Context, userID int64) (*AdminUserRecord, ResetPlanUsageResult, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, ResetPlanUsageResult{}, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var tenantID int64 + if err := tx.QueryRow(ctx, ` +SELECT tm.tenant_id +FROM tenant_memberships tm +WHERE tm.user_id = $1 + AND tm.deleted_at IS NULL +ORDER BY tm.created_at ASC, tm.id ASC +LIMIT 1`, userID).Scan(&tenantID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ResetPlanUsageResult{}, ErrAdminUserNotFound + } + return nil, ResetPlanUsageResult{}, err + } + + if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(20260428, hashint8($1)::int)`, tenantID); err != nil { + return nil, ResetPlanUsageResult{}, err + } + + now := time.Now().UTC() + access, err := tenantrepo.NewTenantPlanRepository(tx).GetTenantPlanAccess(ctx, tenantID, now) + if err != nil { + return nil, ResetPlanUsageResult{}, err + } + if access == nil || access.SubscriptionStatus != "active" { + return nil, ResetPlanUsageResult{}, ErrActivePlanNotFound + } + + referenceTime := now + if !access.EndAt.IsZero() && !access.EndAt.After(now) && access.EndAt.After(access.StartAt) { + referenceTime = access.EndAt.Add(-time.Nanosecond) + } + if referenceTime.Before(access.StartAt) { + referenceTime = access.StartAt + } + + articleStart, articleEnd, _ := access.ArticleQuotaWindow(referenceTime) + aiStart, aiEnd, _ := access.AIPointsWindow(referenceTime) + + result := ResetPlanUsageResult{ + TenantID: tenantID, + SubscriptionID: access.SubscriptionID, + ArticleWindowStart: articleStart, + ArticleWindowEnd: articleEnd, + AIPointsWindowStart: aiStart, + AIPointsWindowEnd: aiEnd, + } + + if articleEnd.After(articleStart) { + count, amount, err := resetQuotaReservations(ctx, tx, tenantID, "article_generation", articleStart, articleEnd) + if err != nil { + return nil, ResetPlanUsageResult{}, err + } + result.ArticleReservations = count + result.ArticleAmount = amount + } + + if aiEnd.After(aiStart) { + count, amount, logCount, err := resetAIPointReservations(ctx, tx, tenantID, aiStart, aiEnd) + if err != nil { + return nil, ResetPlanUsageResult{}, err + } + result.AIPointReservations = count + result.AIPointsAmount = amount + result.AIPointUsageLogs = logCount + } + + if err := tx.Commit(ctx); err != nil { + return nil, ResetPlanUsageResult{}, err + } + + user, err := r.GetByID(ctx, userID) + if err != nil { + return nil, ResetPlanUsageResult{}, err + } + return user, result, nil +} + +func resetQuotaReservations(ctx context.Context, tx pgx.Tx, tenantID int64, quotaType string, startAt, endAt time.Time) (int64, int64, error) { + row := tx.QueryRow(ctx, ` +WITH reset AS ( + UPDATE quota_reservations + SET status = 'refunded', + refunded_amount = reserved_amount, + updated_at = NOW() + WHERE tenant_id = $1 + AND quota_type = $2 + AND status IN ('pending', 'confirmed') + AND created_at >= $3 + AND created_at < $4 + RETURNING reserved_amount +) +SELECT COUNT(*)::BIGINT, + COALESCE(SUM(reserved_amount), 0)::BIGINT +FROM reset`, tenantID, quotaType, startAt.UTC(), endAt.UTC()) + + var count int64 + var amount int64 + if err := row.Scan(&count, &amount); err != nil { + return 0, 0, err + } + return count, amount, nil +} + +func resetAIPointReservations(ctx context.Context, tx pgx.Tx, tenantID int64, startAt, endAt time.Time) (int64, int64, int64, error) { + row := tx.QueryRow(ctx, ` +WITH reset AS ( + UPDATE quota_reservations + SET status = 'refunded', + refunded_amount = reserved_amount, + updated_at = NOW() + WHERE tenant_id = $1 + AND quota_type = 'ai_points' + AND status IN ('pending', 'confirmed') + AND created_at >= $2 + AND created_at < $3 + RETURNING id, reserved_amount +), +logs AS ( + UPDATE ai_point_usage_logs l + SET status = 'refunded', + error_message = 'ops reset current plan usage', + completed_at = COALESCE(l.completed_at, NOW()), + updated_at = NOW() + FROM reset r + WHERE l.tenant_id = $1 + AND l.quota_reservation_id = r.id + AND l.status IN ('pending', 'completed') + RETURNING l.id +) +SELECT (SELECT COUNT(*) FROM reset)::BIGINT, + COALESCE((SELECT SUM(reserved_amount) FROM reset), 0)::BIGINT, + (SELECT COUNT(*) FROM logs)::BIGINT`, tenantID, startAt.UTC(), endAt.UTC()) + + var reservationCount int64 + var amount int64 + var logCount int64 + if err := row.Scan(&reservationCount, &amount, &logCount); err != nil { + return 0, 0, 0, err + } + return reservationCount, amount, logCount, nil +} + +func (r *AdminUserRepository) ChangeRole(ctx context.Context, userID int64, tenantRole, workspaceRole string) (*AdminUserRecord, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var tenantID int64 + if err := tx.QueryRow(ctx, ` +SELECT tm.tenant_id +FROM tenant_memberships tm +WHERE tm.user_id = $1 + AND tm.deleted_at IS NULL +ORDER BY tm.created_at ASC, tm.id ASC +LIMIT 1`, userID).Scan(&tenantID); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAdminUserNotFound + } + return nil, err + } + + tag, err := tx.Exec(ctx, ` +UPDATE tenant_memberships +SET tenant_role = $1, + updated_at = NOW() +WHERE tenant_id = $2 + AND user_id = $3 + AND deleted_at IS NULL`, tenantRole, tenantID, userID) + if err != nil { + return nil, err + } + if tag.RowsAffected() == 0 { + return nil, ErrAdminUserNotFound + } + + if _, err := tx.Exec(ctx, ` +UPDATE workspace_memberships +SET role = $1 +WHERE tenant_id = $2 + AND user_id = $3`, workspaceRole, tenantID, userID); err != nil { + return nil, err + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return r.GetByID(ctx, userID) +} + +func (r *AdminUserRepository) SetKOL(ctx context.Context, userID int64, enabled bool, displayName string, marketEnabled bool) (*AdminUserRecord, error) { + tx, err := r.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer func() { + _ = tx.Rollback(ctx) + }() + + var ( + tenantID int64 + userName *string + phone string + ) + if err := tx.QueryRow(ctx, ` +SELECT tm.tenant_id, u.name, u.phone +FROM users u +JOIN tenant_memberships tm + ON tm.user_id = u.id + AND tm.deleted_at IS NULL +WHERE u.id = $1 + AND u.deleted_at IS NULL +ORDER BY tm.created_at ASC, tm.id ASC +LIMIT 1`, userID).Scan(&tenantID, &userName, &phone); err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrAdminUserNotFound + } + return nil, err + } + + if displayName == "" { + if userName != nil && *userName != "" { + displayName = *userName + } else { + displayName = phone + } + } + + if enabled { + if _, err := tx.Exec(ctx, ` +INSERT INTO kol_profiles (tenant_id, user_id, display_name, market_enabled, status) +VALUES ($1, $2, $3, $4, 'active') +ON CONFLICT (user_id) WHERE deleted_at IS NULL +DO UPDATE SET + display_name = EXCLUDED.display_name, + market_enabled = EXCLUDED.market_enabled, + status = 'active', + updated_at = NOW()`, tenantID, userID, displayName, marketEnabled); err != nil { + return nil, err + } + } else { + if _, err := tx.Exec(ctx, ` +UPDATE kol_profiles +SET status = 'suspended', + market_enabled = FALSE, + updated_at = NOW() +WHERE user_id = $1 + AND deleted_at IS NULL`, userID); err != nil { + return nil, err + } + } + + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + return r.GetByID(ctx, userID) +} + +func (r *AdminUserRepository) UpdateProfile(ctx context.Context, id int64, in UpdateAdminUserProfileInput) error { + cmd, err := r.pool.Exec(ctx, ` +UPDATE users +SET email = $1, + phone = $2, + name = $3, + updated_at = NOW() +WHERE id = $4 + AND deleted_at IS NULL`, in.Email, in.Phone, in.Name, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return ErrAdminUserNotFound + } + return nil +} + +func (r *AdminUserRepository) UpdateStatus(ctx context.Context, id int64, status string) error { + cmd, err := r.pool.Exec(ctx, ` +UPDATE users +SET status = $1, + updated_at = NOW() +WHERE id = $2 + AND deleted_at IS NULL`, status, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return ErrAdminUserNotFound + } + return nil +} + +func (r *AdminUserRepository) UpdatePassword(ctx context.Context, id int64, passwordHash string) error { + cmd, err := r.pool.Exec(ctx, ` +UPDATE users +SET password_hash = $1, + updated_at = NOW() +WHERE id = $2 + AND deleted_at IS NULL`, passwordHash, id) + if err != nil { + return err + } + if cmd.RowsAffected() == 0 { + return ErrAdminUserNotFound + } + return nil +} diff --git a/server/internal/ops/transport/admin_user_handler.go b/server/internal/ops/transport/admin_user_handler.go new file mode 100644 index 0000000..1ad92d9 --- /dev/null +++ b/server/internal/ops/transport/admin_user_handler.go @@ -0,0 +1,286 @@ +package transport + +import ( + "strconv" + "time" + + "github.com/gin-gonic/gin" + + "github.com/geo-platform/tenant-api/internal/ops/app" + "github.com/geo-platform/tenant-api/internal/shared/response" +) + +type createAdminUserRequest struct { + Phone string `json:"phone" binding:"required"` + Email string `json:"email"` + Name string `json:"name"` + Password string `json:"password" binding:"required"` + PlanCode string `json:"plan_code"` + Role string `json:"role"` +} + +type updateAdminUserRequest struct { + Phone string `json:"phone" binding:"required"` + Email string `json:"email"` + Name string `json:"name"` +} + +type changeAdminUserPlanRequest struct { + PlanCode string `json:"plan_code" binding:"required"` +} + +type updateAdminUserSubscriptionExpiryRequest struct { + EndAt time.Time `json:"end_at" binding:"required"` +} + +type changeAdminUserRoleRequest struct { + Role string `json:"role" binding:"required"` +} + +type setAdminUserKOLRequest struct { + Enabled bool `json:"enabled"` + DisplayName string `json:"display_name"` + MarketEnabled bool `json:"market_enabled"` +} + +func listPlansHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + plans, err := svc.ListPlans(c.Request.Context()) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, plans) + } +} + +func listRolesHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + response.Success(c, svc.ListRoles()) + } +} + +func listAdminUsersHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + result, err := svc.List(c.Request.Context(), app.AdminUserListInput{ + Keyword: c.Query("keyword"), + Status: c.Query("status"), + Page: page, + Size: size, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func createAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + var body createAdminUserRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + detail, err := svc.Create(c.Request.Context(), actorFromGin(c), app.CreateAdminUserInput{ + Phone: body.Phone, + Email: body.Email, + Name: body.Name, + Password: body.Password, + PlanCode: body.PlanCode, + Role: body.Role, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} + +func getAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + detail, err := svc.Get(c.Request.Context(), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} + +func updateAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body updateAdminUserRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + if err := svc.Update(c.Request.Context(), actorFromGin(c), id, app.UpdateAdminUserInput{ + Phone: body.Phone, + Email: body.Email, + Name: body.Name, + }); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"id": id}) + } +} + +func changeAdminUserPlanHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body changeAdminUserPlanRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + detail, err := svc.ChangePlan(c.Request.Context(), actorFromGin(c), id, body.PlanCode) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} + +func updateAdminUserSubscriptionExpiryHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body updateAdminUserSubscriptionExpiryRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + detail, err := svc.UpdateSubscriptionExpiry(c.Request.Context(), actorFromGin(c), id, body.EndAt) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} + +func resetAdminUserPlanUsageHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + result, err := svc.ResetCurrentPlanUsage(c.Request.Context(), actorFromGin(c), id) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, result) + } +} + +func changeAdminUserRoleHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body changeAdminUserRoleRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + detail, err := svc.ChangeRole(c.Request.Context(), actorFromGin(c), id, body.Role) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} + +func setAdminUserKOLHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body setAdminUserKOLRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + detail, err := svc.SetKOL(c.Request.Context(), actorFromGin(c), id, app.SetAdminUserKOLInput{ + Enabled: body.Enabled, + DisplayName: body.DisplayName, + MarketEnabled: body.MarketEnabled, + }) + if err != nil { + response.Error(c, err) + return + } + response.Success(c, detail) + } +} + +func setAdminUserStatusHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body setStatusRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + if err := svc.SetStatus(c.Request.Context(), actorFromGin(c), id, body.Status); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"id": id, "status": body.Status}) + } +} + +func resetAdminUserPasswordHandler(svc *app.AdminUserService) gin.HandlerFunc { + return func(c *gin.Context) { + id, err := parseIDParam(c) + if err != nil { + response.Error(c, err) + return + } + var body resetPasswordRequest + if err := c.ShouldBindJSON(&body); err != nil { + response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error())) + return + } + if err := svc.ResetPassword(c.Request.Context(), actorFromGin(c), id, body.Password); err != nil { + response.Error(c, err) + return + } + response.Success(c, gin.H{"id": id}) + } +} diff --git a/server/internal/ops/transport/router.go b/server/internal/ops/transport/router.go index 5ffdd28..b3ab105 100644 --- a/server/internal/ops/transport/router.go +++ b/server/internal/ops/transport/router.go @@ -11,13 +11,14 @@ import ( ) type Deps struct { - Engine *gin.Engine - Logger *zap.Logger - DB *pgxpool.Pool - Issuer *app.TokenIssuer - Auth *app.AuthService - Accounts *app.AccountService - Audits *app.AuditService + Engine *gin.Engine + Logger *zap.Logger + DB *pgxpool.Pool + Issuer *app.TokenIssuer + Auth *app.AuthService + Accounts *app.AccountService + AdminUsers *app.AdminUserService + Audits *app.AuditService } func RegisterRoutes(d Deps) { @@ -57,6 +58,21 @@ func RegisterRoutes(d Deps) { authed.POST("/accounts/:id/status", setAccountStatusHandler(d.Accounts)) authed.POST("/accounts/:id/password", resetAccountPasswordHandler(d.Accounts)) + authed.GET("/plans", listPlansHandler(d.AdminUsers)) + authed.GET("/roles", listRolesHandler(d.AdminUsers)) + + authed.GET("/admin-users", listAdminUsersHandler(d.AdminUsers)) + authed.POST("/admin-users", createAdminUserHandler(d.AdminUsers)) + authed.GET("/admin-users/:id", getAdminUserHandler(d.AdminUsers)) + authed.PATCH("/admin-users/:id", updateAdminUserHandler(d.AdminUsers)) + authed.POST("/admin-users/:id/plan", changeAdminUserPlanHandler(d.AdminUsers)) + authed.POST("/admin-users/:id/subscription-expiry", updateAdminUserSubscriptionExpiryHandler(d.AdminUsers)) + authed.POST("/admin-users/:id/reset-plan-usage", resetAdminUserPlanUsageHandler(d.AdminUsers)) + authed.POST("/admin-users/:id/role", changeAdminUserRoleHandler(d.AdminUsers)) + authed.POST("/admin-users/:id/kol", setAdminUserKOLHandler(d.AdminUsers)) + authed.POST("/admin-users/:id/status", setAdminUserStatusHandler(d.AdminUsers)) + authed.POST("/admin-users/:id/password", resetAdminUserPasswordHandler(d.AdminUsers)) + authed.GET("/audits", listAuditsHandler(d.Audits)) } }