沿用订阅体系现成先例 MaxPurchasePerUser,给 SubscriptionPlan 加 MaxAgents: - model: SubscriptionPlan.MaxAgents(0=回退全局默认)+ GetUserMaxAgents(取用户 active 订阅档最高 MaxAgents,无则回退默认)。 - HeicodeDeployAgent: 部署上限改 model.GetUserMaxAgents(userID, 环境默认5)。 团队8=管理员把团队档配成8;个人5=默认;代码不硬编码 tier。 - subscription 控制器: Create/Update 校验 MaxAgents>=0;Update updateMap 补 max_agents。 - 前端管理端套餐表单(plan-form/types/drawer)加「Agent 部署上限」字段 + zh i18n。 测试 model/subscription_max_agents_test.go 全过(无订阅/团队档/0回退/过期/多档取最高/非法用户)。 go build、go vet、前端 tsc 干净。 Fixes #8 Co-authored-by: chenchen <chenchen@xinghanlab.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -138,15 +138,17 @@ func HeicodeDeployAgent(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Enforce the per-user deployed-agent cap (产品文档「个人5/团队8」). The cap
|
||||
// source is env HEICODE_MAX_AGENTS_PER_USER (default 5 = personal tier); the
|
||||
// team tier (8) is a follow-up that depends on subscription-tier modelling
|
||||
// (see issue #7). Only the user's own non-stopped template agents count — a
|
||||
// stopped agent consumes no runtime, so it is excluded. LOWER(status) keeps
|
||||
// the comparison portable across SQLite/MySQL/PostgreSQL. maxAgents<=0 means
|
||||
// "unlimited" (escape hatch). This closes the over-deployment hole where a
|
||||
// user could deploy unbounded agents across repeated calls.
|
||||
if maxAgents := common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_USER", 5); maxAgents > 0 {
|
||||
// Enforce the per-user deployed-agent cap (产品文档「个人5/团队8」). The cap is
|
||||
// tier-aware (#8): it is the highest MaxAgents among the user's active
|
||||
// subscription plans, falling back to env HEICODE_MAX_AGENTS_PER_USER
|
||||
// (default 5) when no active plan sets one. So 团队 8 = an admin-configured
|
||||
// team plan's MaxAgents; 个人 5 = the default — no hard-coded tier guess.
|
||||
// Only the user's own non-stopped template agents count — a stopped agent
|
||||
// consumes no runtime, so it is excluded. LOWER(status) keeps the comparison
|
||||
// portable across SQLite/MySQL/PostgreSQL. maxAgents<=0 means "unlimited"
|
||||
// (escape hatch). Closes the over-deployment hole across repeated calls.
|
||||
envMaxAgents := common.GetEnvOrDefault("HEICODE_MAX_AGENTS_PER_USER", 5)
|
||||
if maxAgents := model.GetUserMaxAgents(userID, envMaxAgents); maxAgents > 0 {
|
||||
var active int64
|
||||
if err := model.DB.Model(&model.AgentDeployment{}).
|
||||
Where("user_id = ? AND template_id <> '' AND LOWER(status) <> ?", strconv.Itoa(userID), "stopped").
|
||||
|
||||
@@ -140,6 +140,10 @@ func AdminCreateSubscriptionPlan(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.MaxAgents < 0 {
|
||||
common.ApiErrorMsg(c, "Agent 部署上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
return
|
||||
@@ -203,6 +207,10 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
common.ApiErrorMsg(c, "购买上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.MaxAgents < 0 {
|
||||
common.ApiErrorMsg(c, "Agent 部署上限不能为负数")
|
||||
return
|
||||
}
|
||||
if req.Plan.TotalAmount < 0 {
|
||||
common.ApiErrorMsg(c, "总额度不能为负数")
|
||||
return
|
||||
@@ -235,6 +243,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
|
||||
"stripe_price_id": req.Plan.StripePriceId,
|
||||
"creem_product_id": req.Plan.CreemProductId,
|
||||
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
|
||||
"max_agents": req.Plan.MaxAgents,
|
||||
"total_amount": req.Plan.TotalAmount,
|
||||
"upgrade_group": req.Plan.UpgradeGroup,
|
||||
"quota_reset_period": req.Plan.QuotaResetPeriod,
|
||||
|
||||
@@ -165,6 +165,12 @@ type SubscriptionPlan struct {
|
||||
// Max purchases per user (0 = unlimited)
|
||||
MaxPurchasePerUser int `json:"max_purchase_per_user" gorm:"type:int;default:0"`
|
||||
|
||||
// Max concurrently-deployed template agents for users on this plan
|
||||
// (0 = fall back to the global HEICODE_MAX_AGENTS_PER_USER default).
|
||||
// Enforced in HeicodeDeployAgent. This is how 个人 5 / 团队 8 (#8) is
|
||||
// expressed: admins set each plan's cap; the code does not hard-code tiers.
|
||||
MaxAgents int `json:"max_agents" gorm:"type:int;default:0"`
|
||||
|
||||
// Upgrade user group after purchase (empty = no change)
|
||||
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
|
||||
|
||||
@@ -696,6 +702,36 @@ func HasActiveUserSubscription(userId int) (bool, error) {
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// GetUserMaxAgents returns the user's effective concurrent-agent cap: the highest
|
||||
// MaxAgents among their active subscription plans, or defaultMax when no active
|
||||
// plan sets one. This is the tier-aware enforcement for #8 (个人默认 / 团队档),
|
||||
// keyed off admin-configured per-plan caps rather than a hard-coded tier guess.
|
||||
func GetUserMaxAgents(userId int, defaultMax int) int {
|
||||
if userId <= 0 || DB == nil {
|
||||
return defaultMax
|
||||
}
|
||||
now := common.GetTimestamp()
|
||||
var subs []UserSubscription
|
||||
if err := DB.Where("user_id = ? AND status = ? AND end_time > ?", userId, "active", now).
|
||||
Find(&subs).Error; err != nil || len(subs) == 0 {
|
||||
return defaultMax
|
||||
}
|
||||
best := 0
|
||||
for _, s := range subs {
|
||||
plan, err := GetSubscriptionPlanById(s.PlanId)
|
||||
if err != nil || plan == nil {
|
||||
continue
|
||||
}
|
||||
if plan.MaxAgents > best {
|
||||
best = plan.MaxAgents
|
||||
}
|
||||
}
|
||||
if best > 0 {
|
||||
return best
|
||||
}
|
||||
return defaultMax
|
||||
}
|
||||
|
||||
// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user.
|
||||
func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
|
||||
if userId <= 0 {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/heicode/manager/common"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// GetUserMaxAgents drives the tier-aware deploy cap (#8): highest MaxAgents among
|
||||
// the user's ACTIVE subscription plans, else the caller's default.
|
||||
func TestGetUserMaxAgents(t *testing.T) {
|
||||
// SubscriptionPlan / UserSubscription are migrated by the package TestMain
|
||||
// (task_cas_test.go); the table already carries the new max_agents column.
|
||||
now := common.GetTimestamp()
|
||||
future := now + 100000
|
||||
const def = 5
|
||||
|
||||
teamPlan := SubscriptionPlan{Title: "team-cap", MaxAgents: 8}
|
||||
require.NoError(t, DB.Create(&teamPlan).Error)
|
||||
soloPlan := SubscriptionPlan{Title: "solo-cap", MaxAgents: 0} // 0 => fall back to default
|
||||
require.NoError(t, DB.Create(&soloPlan).Error)
|
||||
midPlan := SubscriptionPlan{Title: "mid-cap", MaxAgents: 5}
|
||||
require.NoError(t, DB.Create(&midPlan).Error)
|
||||
|
||||
mkSub := func(uid, planId int, end int64) {
|
||||
require.NoError(t, DB.Create(&UserSubscription{UserId: uid, PlanId: planId, Status: "active", EndTime: end}).Error)
|
||||
}
|
||||
|
||||
// 1. no subscription -> default
|
||||
require.Equal(t, def, GetUserMaxAgents(990001, def))
|
||||
|
||||
// 2. active team plan -> its cap (8)
|
||||
mkSub(990002, teamPlan.Id, future)
|
||||
require.Equal(t, 8, GetUserMaxAgents(990002, def))
|
||||
|
||||
// 3. active plan with MaxAgents=0 -> default
|
||||
mkSub(990003, soloPlan.Id, future)
|
||||
require.Equal(t, def, GetUserMaxAgents(990003, def))
|
||||
|
||||
// 4. expired (end_time in the past) -> default, even if row still says active
|
||||
mkSub(990004, teamPlan.Id, now-100)
|
||||
require.Equal(t, def, GetUserMaxAgents(990004, def))
|
||||
|
||||
// 5. multiple active plans -> highest cap wins (5 and 8 -> 8)
|
||||
mkSub(990005, midPlan.Id, future)
|
||||
mkSub(990005, teamPlan.Id, future)
|
||||
require.Equal(t, 8, GetUserMaxAgents(990005, def))
|
||||
|
||||
// guard: invalid user -> default
|
||||
require.Equal(t, def, GetUserMaxAgents(0, def))
|
||||
}
|
||||
+24
@@ -286,6 +286,30 @@ export function SubscriptionsMutateDrawer({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name='max_agents'
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('Agent Deploy Limit')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
type='number'
|
||||
min={0}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseInt(e.target.value, 10) || 0)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('0 uses the global default cap')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='grid grid-cols-2 gap-3'>
|
||||
|
||||
@@ -21,6 +21,7 @@ export function getPlanFormSchema(t: TFunction) {
|
||||
enabled: z.boolean(),
|
||||
sort_order: z.coerce.number(),
|
||||
max_purchase_per_user: z.coerce.number().min(0),
|
||||
max_agents: z.coerce.number().min(0),
|
||||
total_amount: z.coerce.number().min(0),
|
||||
upgrade_group: z.string().optional(),
|
||||
stripe_price_id: z.string().optional(),
|
||||
@@ -42,6 +43,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
max_purchase_per_user: 0,
|
||||
max_agents: 0,
|
||||
total_amount: 0,
|
||||
upgrade_group: '',
|
||||
stripe_price_id: '',
|
||||
@@ -61,6 +63,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
|
||||
enabled: plan.enabled !== false,
|
||||
sort_order: Number(plan.sort_order || 0),
|
||||
max_purchase_per_user: Number(plan.max_purchase_per_user || 0),
|
||||
max_agents: Number(plan.max_agents || 0),
|
||||
total_amount: Number(plan.total_amount || 0),
|
||||
upgrade_group: plan.upgrade_group || '',
|
||||
stripe_price_id: plan.stripe_price_id || '',
|
||||
@@ -83,6 +86,7 @@ export function formValuesToPlanPayload(values: PlanFormValues): PlanPayload {
|
||||
: 0,
|
||||
sort_order: Number(values.sort_order || 0),
|
||||
max_purchase_per_user: Number(values.max_purchase_per_user || 0),
|
||||
max_agents: Number(values.max_agents || 0),
|
||||
total_amount: Number(values.total_amount || 0),
|
||||
upgrade_group: values.upgrade_group || '',
|
||||
},
|
||||
|
||||
@@ -18,6 +18,7 @@ export const subscriptionPlanSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
sort_order: z.number(),
|
||||
max_purchase_per_user: z.number(),
|
||||
max_agents: z.number(),
|
||||
total_amount: z.number(),
|
||||
upgrade_group: z.string().optional(),
|
||||
stripe_price_id: z.string().optional(),
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"/status/": "/status/",
|
||||
"/your/endpoint": "/your/endpoint",
|
||||
"0 means unlimited": "0 表示不限",
|
||||
"0 uses the global default cap": "0 表示使用全局默认上限",
|
||||
"1 Day": "1 天",
|
||||
"Agent Deploy Limit": "Agent 部署上限",
|
||||
"1 day ago": "1 天前",
|
||||
"1 Hour": "1 小时",
|
||||
"1 hour ago": "1 小时前",
|
||||
|
||||
Reference in New Issue
Block a user