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:
2026-07-24 09:04:06 +08:00
parent d69510274c
commit 348f2e3451
16 changed files with 413 additions and 63 deletions
+73 -12
View File
@@ -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) {