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) {
@@ -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)
})
}
}
+41 -3
View File
@@ -36,6 +36,8 @@ type AdminUserRecord struct {
TenantID *int64
TenantName *string
TenantStatus *string
BrandLimit *int
QuestionLimit *int
TenantRole *string
PrimaryWorkspaceID *int64
PlanCode *string
@@ -76,6 +78,8 @@ type CreateAdminUserInput struct {
PlanCode string
TenantRole string
WorkspaceRole string
BrandLimit *int
QuestionLimit *int
}
type UpdateAdminUserProfileInput struct {
@@ -106,6 +110,8 @@ u.status,
tm.tenant_id,
t.name AS tenant_name,
t.status AS tenant_status,
t.brand_limit,
t.question_limit,
tm.tenant_role,
wm.workspace_id AS primary_workspace_id,
sub.plan_code,
@@ -186,6 +192,8 @@ func scanAdminUser(row pgx.Row) (*AdminUserRecord, error) {
&u.TenantID,
&u.TenantName,
&u.TenantStatus,
&u.BrandLimit,
&u.QuestionLimit,
&u.TenantRole,
&u.PrimaryWorkspaceID,
&u.PlanCode,
@@ -333,9 +341,9 @@ RETURNING id`, in.Email, in.Phone, in.PasswordHash, in.Name).Scan(&userID); 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 {
INSERT INTO tenants (name, status, brand_limit, question_limit)
VALUES ($1, 'active', $2, $3)
RETURNING id`, tenantName, in.BrandLimit, in.QuestionLimit).Scan(&tenantID); err != nil {
return nil, err
}
@@ -782,6 +790,36 @@ WHERE id = $4
return nil
}
func (r *AdminUserRepository) UpdateLimits(ctx context.Context, userID int64, brandLimit, questionLimit *int) (*AdminUserRecord, error) {
var tenantID int64
err := r.pool.QueryRow(ctx, `
WITH target_tenant AS (
SELECT tm.tenant_id
FROM tenant_memberships tm
JOIN users u ON u.id = tm.user_id AND u.deleted_at IS NULL
WHERE tm.user_id = $1
AND tm.deleted_at IS NULL
ORDER BY tm.created_at ASC, tm.id ASC
LIMIT 1
)
UPDATE tenants t
SET brand_limit = $2,
question_limit = $3,
updated_at = NOW()
FROM target_tenant target
WHERE t.id = target.tenant_id
AND t.deleted_at IS NULL
RETURNING t.id`, userID, brandLimit, questionLimit).Scan(&tenantID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrAdminUserNotFound
}
return nil, err
}
return r.GetByID(ctx, userID)
}
func (r *AdminUserRepository) UpdateStatus(ctx context.Context, id int64, status string) error {
cmd, err := r.pool.Exec(ctx, `
UPDATE users
@@ -11,12 +11,14 @@ import (
)
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"`
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"`
BrandLimit *int `json:"brand_limit"`
QuestionLimit *int `json:"question_limit"`
}
type updateAdminUserRequest struct {
@@ -25,6 +27,11 @@ type updateAdminUserRequest struct {
Name string `json:"name"`
}
type updateAdminUserLimitsRequest struct {
BrandLimit *int `json:"brand_limit"`
QuestionLimit *int `json:"question_limit"`
}
type changeAdminUserPlanRequest struct {
PlanCode string `json:"plan_code" binding:"required"`
}
@@ -86,12 +93,14 @@ func createAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc {
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,
Phone: body.Phone,
Email: body.Email,
Name: body.Name,
Password: body.Password,
PlanCode: body.PlanCode,
Role: body.Role,
BrandLimit: body.BrandLimit,
QuestionLimit: body.QuestionLimit,
})
if err != nil {
response.Error(c, err)
@@ -141,6 +150,30 @@ func updateAdminUserHandler(svc *app.AdminUserService) gin.HandlerFunc {
}
}
func updateAdminUserLimitsHandler(svc *app.AdminUserService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
if err != nil {
response.Error(c, err)
return
}
var body updateAdminUserLimitsRequest
if err := c.ShouldBindJSON(&body); err != nil {
response.Error(c, response.ErrBadRequest(40000, "invalid_payload", err.Error()))
return
}
detail, err := svc.UpdateLimits(c.Request.Context(), actorFromGin(c), id, app.UpdateAdminUserLimitsInput{
BrandLimit: body.BrandLimit,
QuestionLimit: body.QuestionLimit,
})
if err != nil {
response.Error(c, err)
return
}
response.Success(c, detail)
}
}
func changeAdminUserPlanHandler(svc *app.AdminUserService) gin.HandlerFunc {
return func(c *gin.Context) {
id, err := parseIDParam(c)
+1
View File
@@ -112,6 +112,7 @@ func RegisterRoutes(d Deps) {
authed.POST("/admin-users", createAdminUserHandler(d.AdminUsers))
authed.GET("/admin-users/:id", getAdminUserHandler(d.AdminUsers))
authed.PATCH("/admin-users/:id", updateAdminUserHandler(d.AdminUsers))
authed.PUT("/admin-users/:id/limits", updateAdminUserLimitsHandler(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))