feat(tenant): per-tenant brand and question limit overrides
Add nullable brand_limit / question_limit columns on tenants so ops admins can override the plan-derived brand-library quotas per account. When set, these take precedence over config.yml plan defaults across the brand-library summary, question materialization, and the daily monitoring worker's primary-client plan resolution. Ops side exposes them on admin-user create plus a new PUT /admin-users/:id/limits endpoint, invalidating the brand-library summary cache on change. The ops-web AdminUsers view gains matching inputs on the create and edit modals. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -23,6 +23,7 @@ import (
|
||||
const (
|
||||
ActionAdminUserCreate = "admin_user.create"
|
||||
ActionAdminUserUpdateProfile = "admin_user.update_profile"
|
||||
ActionAdminUserUpdateLimits = "admin_user.update_limits"
|
||||
ActionAdminUserChangePlan = "admin_user.change_plan"
|
||||
ActionAdminUserChangeRole = "admin_user.change_role"
|
||||
ActionAdminUserChangeExpiry = "admin_user.change_expiry"
|
||||
@@ -101,6 +102,8 @@ type AdminUserView struct {
|
||||
TenantID *int64 `json:"tenant_id"`
|
||||
TenantName *string `json:"tenant_name"`
|
||||
TenantStatus *string `json:"tenant_status"`
|
||||
BrandLimit *int `json:"brand_limit"`
|
||||
QuestionLimit *int `json:"question_limit"`
|
||||
TenantRole *string `json:"tenant_role"`
|
||||
PrimaryWorkspaceID *int64 `json:"primary_workspace_id"`
|
||||
PlanCode *string `json:"plan_code"`
|
||||
@@ -148,12 +151,14 @@ type AdminUserListResult struct {
|
||||
}
|
||||
|
||||
type CreateAdminUserInput struct {
|
||||
Phone string
|
||||
Email string
|
||||
Password string
|
||||
Name string
|
||||
PlanCode string
|
||||
Role string
|
||||
Phone string
|
||||
Email string
|
||||
Password string
|
||||
Name string
|
||||
PlanCode string
|
||||
Role string
|
||||
BrandLimit *int
|
||||
QuestionLimit *int
|
||||
}
|
||||
|
||||
type UpdateAdminUserInput struct {
|
||||
@@ -162,6 +167,11 @@ type UpdateAdminUserInput struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
type UpdateAdminUserLimitsInput struct {
|
||||
BrandLimit *int
|
||||
QuestionLimit *int
|
||||
}
|
||||
|
||||
type SetAdminUserKOLInput struct {
|
||||
Enabled bool
|
||||
DisplayName string
|
||||
@@ -205,6 +215,8 @@ func toAdminUserView(u *repository.AdminUserRecord) AdminUserView {
|
||||
TenantID: u.TenantID,
|
||||
TenantName: u.TenantName,
|
||||
TenantStatus: u.TenantStatus,
|
||||
BrandLimit: u.BrandLimit,
|
||||
QuestionLimit: u.QuestionLimit,
|
||||
TenantRole: u.TenantRole,
|
||||
PrimaryWorkspaceID: u.PrimaryWorkspaceID,
|
||||
PlanCode: u.PlanCode,
|
||||
@@ -332,6 +344,12 @@ func (s *AdminUserService) Get(ctx context.Context, id int64) (*AdminUserView, e
|
||||
}
|
||||
|
||||
func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAdminUserInput) (*AdminUserView, error) {
|
||||
if err := validateOptionalAdminUserLimit("brand_limit", in.BrandLimit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateOptionalAdminUserLimit("question_limit", in.QuestionLimit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
phone, err := normalizePhone(in.Phone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -370,6 +388,8 @@ func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAd
|
||||
PlanCode: planCode,
|
||||
TenantRole: role,
|
||||
WorkspaceRole: workspaceRoleForTenantRole(role),
|
||||
BrandLimit: in.BrandLimit,
|
||||
QuestionLimit: in.QuestionLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, s.mapWriteError(err)
|
||||
@@ -377,12 +397,14 @@ func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAd
|
||||
|
||||
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,
|
||||
"phone": phone,
|
||||
"email": email,
|
||||
"name": name,
|
||||
"plan_code": planCode,
|
||||
"tenant_role": role,
|
||||
"tenant_id": user.TenantID,
|
||||
"brand_limit": in.BrandLimit,
|
||||
"question_limit": in.QuestionLimit,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -422,6 +444,44 @@ func (s *AdminUserService) Update(ctx context.Context, actor *Actor, id int64, i
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) UpdateLimits(ctx context.Context, actor *Actor, id int64, in UpdateAdminUserLimitsInput) (*AdminUserView, error) {
|
||||
if err := validateOptionalAdminUserLimit("brand_limit", in.BrandLimit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateOptionalAdminUserLimit("question_limit", in.QuestionLimit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user, err := s.users.UpdateLimits(ctx, id, in.BrandLimit, in.QuestionLimit)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrAdminUserNotFound) {
|
||||
return nil, response.ErrNotFound(40430, "user_not_found", "用户不存在")
|
||||
}
|
||||
return nil, response.ErrInternal(50042, "update_failed", "failed to update user limits")
|
||||
}
|
||||
if user.TenantID != nil {
|
||||
s.invalidateQuotaSummaryCache(ctx, *user.TenantID)
|
||||
}
|
||||
|
||||
if actor != nil {
|
||||
_ = s.audits.Append(ctx, actor.audit(ActionAdminUserUpdateLimits, "admin_user", id, map[string]any{
|
||||
"tenant_id": user.TenantID,
|
||||
"brand_limit": in.BrandLimit,
|
||||
"question_limit": in.QuestionLimit,
|
||||
}))
|
||||
}
|
||||
|
||||
view := toAdminUserView(user)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func validateOptionalAdminUserLimit(field string, value *int) error {
|
||||
if value != nil && *value <= 0 {
|
||||
return response.ErrBadRequest(40041, "invalid_limit", field+" must be a positive integer or null")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AdminUserService) ChangePlan(ctx context.Context, actor *Actor, id int64, planCode string) (*AdminUserView, error) {
|
||||
normalizedPlanCode := strings.TrimSpace(planCode)
|
||||
if normalizedPlanCode == "" {
|
||||
@@ -529,6 +589,7 @@ func (s *AdminUserService) invalidateQuotaSummaryCache(ctx context.Context, tena
|
||||
return
|
||||
}
|
||||
_ = s.cache.Delete(ctx, fmt.Sprintf("workspace:quota_summary:%d", tenantID))
|
||||
_ = s.cache.Delete(ctx, fmt.Sprintf("brand:library_summary:%d", tenantID))
|
||||
}
|
||||
|
||||
func (s *AdminUserService) ChangeRole(ctx context.Context, actor *Actor, id int64, role string) (*AdminUserView, error) {
|
||||
|
||||
Reference in New Issue
Block a user