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))
}
}