feat(agent): 部署上限按订阅档动态化,闭合 #8 个人5/团队8 (#21)

沿用订阅体系现成先例 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:
zsbgnw12
2026-06-09 14:28:19 +08:00
committed by GitHub
co-authored by chenchen Claude Opus 4.8
parent af094dde27
commit 7d56b54324
8 changed files with 139 additions and 9 deletions
+11 -9
View File
@@ -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").
+9
View File
@@ -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,
+36
View File
@@ -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))
}
@@ -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(),
+2
View File
@@ -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 小时前",