feat(ops): add tenant admin user management module

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) <noreply@anthropic.com>
This commit is contained in:
2026-04-29 00:02:46 +08:00
parent 62b824520c
commit 7be0de0614
15 changed files with 2574 additions and 27 deletions
+610
View File
@@ -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
}
+9
View File
@@ -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
}
@@ -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
}
@@ -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})
}
}
+23 -7
View File
@@ -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))
}
}