Merge remote-tracking branch 'origin/feat/swarm-subscription-gate' into deploy/test-pr69-73

This commit is contained in:
2026-06-11 19:19:32 +08:00
9 changed files with 113 additions and 1 deletions
@@ -1166,6 +1166,12 @@ func createAgentDeployment(c *gin.Context, enforceUserScope bool) {
}
func AgentCreateUserSwarm(c *gin.Context) {
// 订阅套餐 gate:蜂群按套餐开通(管理员在套餐编辑里逐个设 SwarmEnabled,不硬编码 tier)。
// 普通用户须有任一活跃套餐开通蜂群;管理员(role>=admin)绕过,便于测试/运维。
if c.GetInt("role") < common.RoleAdminUser && !model.GetUserSwarmEnabled(c.GetInt("id")) {
agentError(c, "POLICY_REJECTED", "当前订阅套餐未开通蜂群(swarm);请升级套餐或联系管理员")
return
}
record, ok := createAgentDeploymentRecord(c, true, agentRuntimeModeSwarm)
if !ok {
return
@@ -1008,6 +1008,9 @@ func TestAgentUserSwarmsAdapterCreatesScopedDeployment(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Set("id", 7)
// 蜂群订阅 gate(PR#70):普通用户须有开通蜂群的活跃套餐;admin 绕过(便于测试/运维)。
// 本用例聚焦 adapter 的用户作用域,非 gate 本身(gate 见 TestGetUserSwarmEnabled),走 admin 旁路。
ctx.Set("role", common.RoleAdminUser)
ctx.Set("group", "development")
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/swarms", strings.NewReader(string(body)))
ctx.Request.Header.Set("Content-Type", "application/json")
+2 -1
View File
@@ -4,10 +4,10 @@ import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
"github.com/heicode/manager/model"
"github.com/heicode/manager/setting/ratio_setting"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
@@ -244,6 +244,7 @@ func AdminUpdateSubscriptionPlan(c *gin.Context) {
"creem_product_id": req.Plan.CreemProductId,
"max_purchase_per_user": req.Plan.MaxPurchasePerUser,
"max_agents": req.Plan.MaxAgents,
"swarm_enabled": req.Plan.SwarmEnabled,
"total_amount": req.Plan.TotalAmount,
"upgrade_group": req.Plan.UpgradeGroup,
"quota_reset_period": req.Plan.QuotaResetPeriod,
+32
View File
@@ -171,6 +171,12 @@ type SubscriptionPlan struct {
// expressed: admins set each plan's cap; the code does not hard-code tiers.
MaxAgents int `json:"max_agents" gorm:"type:int;default:0"`
// Whether users on this plan may use the multi-agent swarm (蜂群).
// Default false: swarm access is opt-in per plan; admins toggle it in the
// plan editor. Enforced in AgentCreateUserSwarm via GetUserSwarmEnabled.
// No hard-coded tiers — admins decide which plans get swarm.
SwarmEnabled bool `json:"swarm_enabled" gorm:"default:false"`
// Upgrade user group after purchase (empty = no change)
UpgradeGroup string `json:"upgrade_group" gorm:"type:varchar(64);default:''"`
@@ -732,6 +738,32 @@ func GetUserMaxAgents(userId int, defaultMax int) int {
return defaultMax
}
// GetUserSwarmEnabled reports whether the user has any active subscription plan
// with SwarmEnabled=true. Mirrors GetUserMaxAgents (#8, tier-aware): no
// hard-coded tiers — swarm access is per-plan, set by admins. No active plan /
// DB unavailable => false (swarm is opt-in per plan).
func GetUserSwarmEnabled(userId int) bool {
if userId <= 0 || DB == nil {
return false
}
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 false
}
for _, s := range subs {
plan, err := GetSubscriptionPlanById(s.PlanId)
if err != nil || plan == nil {
continue
}
if plan.SwarmEnabled {
return true
}
}
return false
}
// GetAllUserSubscriptions returns all subscriptions (active and expired) for a user.
func GetAllUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
if userId <= 0 {
@@ -0,0 +1,47 @@
package model
import (
"testing"
"github.com/heicode/manager/common"
"github.com/stretchr/testify/require"
)
// GetUserSwarmEnabled gates swarm access per subscription plan (admin-set,
// opt-in): true iff the user has any ACTIVE plan with SwarmEnabled=true.
func TestGetUserSwarmEnabled(t *testing.T) {
now := common.GetTimestamp()
future := now + 100000
onPlan := SubscriptionPlan{Title: "swarm-on", SwarmEnabled: true}
require.NoError(t, DB.Create(&onPlan).Error)
offPlan := SubscriptionPlan{Title: "swarm-off", SwarmEnabled: false}
require.NoError(t, DB.Create(&offPlan).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 -> false (opt-in default)
require.False(t, GetUserSwarmEnabled(991001))
// 2. active plan with swarm on -> true
mkSub(991002, onPlan.Id, future)
require.True(t, GetUserSwarmEnabled(991002))
// 3. active plan with swarm off -> false
mkSub(991003, offPlan.Id, future)
require.False(t, GetUserSwarmEnabled(991003))
// 4. expired swarm-on plan -> false (not active)
mkSub(991004, onPlan.Id, now-100)
require.False(t, GetUserSwarmEnabled(991004))
// 5. multiple active plans, one on -> true
mkSub(991005, offPlan.Id, future)
mkSub(991005, onPlan.Id, future)
require.True(t, GetUserSwarmEnabled(991005))
// guard: invalid user -> false
require.False(t, GetUserSwarmEnabled(0))
}
@@ -350,6 +350,24 @@ export function SubscriptionsMutateDrawer({
</FormItem>
)}
/>
<FormField
control={form.control}
name='swarm_enabled'
render={({ field }) => (
<FormItem className='flex flex-row items-center gap-2 pt-8'>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
<FormLabel className='!mt-0'>
{t('Swarm Access')}
</FormLabel>
</FormItem>
)}
/>
</div>
</div>
@@ -22,6 +22,7 @@ export function getPlanFormSchema(t: TFunction) {
sort_order: z.coerce.number(),
max_purchase_per_user: z.coerce.number().min(0),
max_agents: z.coerce.number().min(0),
swarm_enabled: z.boolean(),
total_amount: z.coerce.number().min(0),
upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
@@ -44,6 +45,7 @@ export const PLAN_FORM_DEFAULTS: PlanFormValues = {
sort_order: 0,
max_purchase_per_user: 0,
max_agents: 0,
swarm_enabled: false,
total_amount: 0,
upgrade_group: '',
stripe_price_id: '',
@@ -64,6 +66,7 @@ export function planToFormValues(plan: SubscriptionPlan): PlanFormValues {
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),
swarm_enabled: plan.swarm_enabled === true,
total_amount: Number(plan.total_amount || 0),
upgrade_group: plan.upgrade_group || '',
stripe_price_id: plan.stripe_price_id || '',
@@ -19,6 +19,7 @@ export const subscriptionPlanSchema = z.object({
sort_order: z.number(),
max_purchase_per_user: z.number(),
max_agents: z.number(),
swarm_enabled: z.boolean().optional().default(false),
total_amount: z.number(),
upgrade_group: z.string().optional(),
stripe_price_id: z.string().optional(),
+1
View File
@@ -16,6 +16,7 @@
"0 uses the global default cap": "0 表示使用全局默认上限",
"1 Day": "1 天",
"Agent Deploy Limit": "Agent 部署上限",
"Swarm Access": "蜂群使用权",
"1 day ago": "1 天前",
"1 Hour": "1 小时",
"1 hour ago": "1 小时前",