Merge remote-tracking branch 'origin/feat/swarm-subscription-gate' into deploy/test-pr69-73
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
+18
@@ -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(),
|
||||
|
||||
@@ -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 小时前",
|
||||
|
||||
Reference in New Issue
Block a user