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) {
|
||||
|
||||
@@ -12,7 +12,7 @@ func TestAdminUserInvalidateQuotaSummaryCache(t *testing.T) {
|
||||
|
||||
svc.invalidateQuotaSummaryCache(context.Background(), 42)
|
||||
|
||||
if want := []string{"workspace:quota_summary:42"}; !reflect.DeepEqual(cache.deletedKeys, want) {
|
||||
if want := []string{"workspace:quota_summary:42", "brand:library_summary:42"}; !reflect.DeepEqual(cache.deletedKeys, want) {
|
||||
t.Fatalf("deleted keys = %v, want %v", cache.deletedKeys, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateOptionalAdminUserLimit(t *testing.T) {
|
||||
positive := 3
|
||||
zero := 0
|
||||
negative := -1
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value *int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "null inherits config", value: nil},
|
||||
{name: "positive override", value: &positive},
|
||||
{name: "zero rejected", value: &zero, wantErr: true},
|
||||
{name: "negative rejected", value: &negative, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateOptionalAdminUserLimit("brand_limit", tt.value)
|
||||
if !tt.wantErr {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
var appErr *response.AppError
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
assert.Equal(t, "invalid_limit", appErr.Message)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user