feat: membership benefits refinement - permissions and quotas linked to plans

This commit is contained in:
qyyfw committed 2026-07-07 15:39:01 +08:00
1 parent 0886c26157
commit 839be8f97f
4 files changed
+1188 -426

No files matched your search

+572 -39
View File
@@ -1,18 +1,55 @@
"""
会员订阅体系管理 API(管理员)
会员订阅体系管理 API(管理员)— 增强版:权益细化与权限挂钩
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import text
from ...database import get_db, SessionLocal
from ...models import MembershipPlan, MembershipSubscription, User, RiskRule
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
from datetime import datetime
import json
from datetime import datetime, date
router = APIRouter()
def _format_plan(p):
"""格式化会员计划数据"""
return {
"id": p.id,
"name": p.name,
"level": p.level,
"duration_type": p.duration_type,
"price": float(p.price) if p.price else 0,
"daily_free_tokens": p.daily_free_tokens or 0,
"features": p.features or [],
"free_app_ids": p.free_app_ids or [],
"free_model_ids": p.free_model_ids or [],
"is_active": p.is_active,
# 配额字段
"max_agents": getattr(p, 'max_agents', 5) or 5,
"max_workflows": getattr(p, 'max_workflows', 3) or 3,
"max_knowledge_bases": getattr(p, 'max_knowledge_bases', 2) or 2,
"max_storage_mb": getattr(p, 'max_storage_mb', 1024) or 1024,
"api_calls_per_day": getattr(p, 'api_calls_per_day', 100) or 100,
"api_calls_per_min": getattr(p, 'api_calls_per_min', 10) or 10,
# 权限字段
"can_publish_apps": bool(getattr(p, 'can_publish_apps', 0)),
"can_create_api_keys": bool(getattr(p, 'can_create_api_keys', 0)),
"can_use_workflow": bool(getattr(p, 'can_use_workflow', 0)),
"can_use_mcp": bool(getattr(p, 'can_use_mcp', 0)),
"can_use_code_exec": bool(getattr(p, 'can_use_code_exec', 0)),
"can_use_privacy_mode": bool(getattr(p, 'can_use_privacy_mode', 0)),
"can_use_dag": bool(getattr(p, 'can_use_dag', 0)),
"allowed_model_tiers": getattr(p, 'allowed_model_tiers', None) or ["free"],
"permissions": getattr(p, 'permissions', None) or {},
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
# ============================================================
# 会员计划 CRUD
# ============================================================
@@ -22,25 +59,9 @@ async def get_plans(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取会员计划列表"""
"""获取会员计划列表(含权益配置)"""
plans = db.query(MembershipPlan).order_by(MembershipPlan.level).all()
return [
{
"id": p.id,
"name": p.name,
"level": p.level,
"duration_type": p.duration_type,
"price": float(p.price) if p.price else 0,
"daily_free_tokens": p.daily_free_tokens,
"features": p.features,
"free_app_ids": p.free_app_ids,
"free_model_ids": p.free_model_ids,
"is_active": p.is_active,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
for p in plans
]
return [_format_plan(p) for p in plans]
@router.post("/plans")
@@ -53,11 +74,28 @@ async def create_plan(
features: list = None,
free_app_ids: list = None,
free_model_ids: list = None,
# 配额参数
max_agents: int = 5,
max_workflows: int = 3,
max_knowledge_bases: int = 2,
max_storage_mb: int = 1024,
api_calls_per_day: int = 100,
api_calls_per_min: int = 10,
# 权限参数
can_publish_apps: bool = False,
can_create_api_keys: bool = False,
can_use_workflow: bool = False,
can_use_mcp: bool = False,
can_use_code_exec: bool = False,
can_use_privacy_mode: bool = False,
can_use_dag: bool = False,
allowed_model_tiers: list = None,
permissions: dict = None,
is_active: bool = True,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建会员计划"""
"""创建会员计划(含权益配置)"""
plan = MembershipPlan(
id=str(uuid.uuid4()),
name=name,
@@ -70,6 +108,24 @@ async def create_plan(
free_model_ids=free_model_ids,
is_active=is_active,
)
# 设置配额字段
plan.max_agents = max_agents
plan.max_workflows = max_workflows
plan.max_knowledge_bases = max_knowledge_bases
plan.max_storage_mb = max_storage_mb
plan.api_calls_per_day = api_calls_per_day
plan.api_calls_per_min = api_calls_per_min
# 设置权限字段
plan.can_publish_apps = can_publish_apps
plan.can_create_api_keys = can_create_api_keys
plan.can_use_workflow = can_use_workflow
plan.can_use_mcp = can_use_mcp
plan.can_use_code_exec = can_use_code_exec
plan.can_use_privacy_mode = can_use_privacy_mode
plan.can_use_dag = can_use_dag
plan.allowed_model_tiers = allowed_model_tiers or ["free"]
plan.permissions = permissions or {}
db.add(plan)
db.commit()
@@ -79,7 +135,7 @@ async def create_plan(
action="create_membership_plan",
target_type="membership_plan",
target_id=plan.id,
detail=f"创建会员计划 {name}",
detail=f"创建会员计划 {name} (level={level})",
)
return {"message": "会员计划已创建", "id": plan.id}
@@ -96,15 +152,33 @@ async def update_plan(
features: list = None,
free_app_ids: list = None,
free_model_ids: list = None,
# 配额参数
max_agents: int = None,
max_workflows: int = None,
max_knowledge_bases: int = None,
max_storage_mb: int = None,
api_calls_per_day: int = None,
api_calls_per_min: int = None,
# 权限参数
can_publish_apps: bool = None,
can_create_api_keys: bool = None,
can_use_workflow: bool = None,
can_use_mcp: bool = None,
can_use_code_exec: bool = None,
can_use_privacy_mode: bool = None,
can_use_dag: bool = None,
allowed_model_tiers: list = None,
permissions: dict = None,
is_active: bool = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新会员计划"""
"""更新会员计划(含权益配置)"""
plan = db.query(MembershipPlan).filter(MembershipPlan.id == id).first()
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在")
# 基础字段
if name is not None:
plan.name = name
if level is not None:
@@ -123,6 +197,41 @@ async def update_plan(
plan.free_model_ids = free_model_ids
if is_active is not None:
plan.is_active = is_active
# 配额字段
if max_agents is not None:
plan.max_agents = max_agents
if max_workflows is not None:
plan.max_workflows = max_workflows
if max_knowledge_bases is not None:
plan.max_knowledge_bases = max_knowledge_bases
if max_storage_mb is not None:
plan.max_storage_mb = max_storage_mb
if api_calls_per_day is not None:
plan.api_calls_per_day = api_calls_per_day
if api_calls_per_min is not None:
plan.api_calls_per_min = api_calls_per_min
# 权限字段
if can_publish_apps is not None:
plan.can_publish_apps = can_publish_apps
if can_create_api_keys is not None:
plan.can_create_api_keys = can_create_api_keys
if can_use_workflow is not None:
plan.can_use_workflow = can_use_workflow
if can_use_mcp is not None:
plan.can_use_mcp = can_use_mcp
if can_use_code_exec is not None:
plan.can_use_code_exec = can_use_code_exec
if can_use_privacy_mode is not None:
plan.can_use_privacy_mode = can_use_privacy_mode
if can_use_dag is not None:
plan.can_use_dag = can_use_dag
if allowed_model_tiers is not None:
plan.allowed_model_tiers = allowed_model_tiers
if permissions is not None:
plan.permissions = permissions
plan.updated_at = datetime.now()
db.commit()
@@ -132,7 +241,7 @@ async def update_plan(
action="update_membership_plan",
target_type="membership_plan",
target_id=id,
detail=f"更新会员计划 {plan.name}",
detail=f"更新会员计划 {plan.name} (含权益配置)",
)
return {"message": "会员计划已更新"}
@@ -149,6 +258,11 @@ async def delete_plan(
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在")
# 检查是否有用户正在使用此计划
user_count = db.query(User).filter(User.membership_plan_id == id).count()
if user_count > 0:
raise HTTPException(status_code=400, detail=f"有 {user_count} 个用户正在使用此计划,无法删除")
db.delete(plan)
db.commit()
@@ -164,6 +278,352 @@ async def delete_plan(
return {"message": "会员计划已删除"}
# ============================================================
# 会员权益对比(前端对比表用)
# ============================================================
@router.get("/benefits-comparison")
async def get_benefits_comparison(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取会员权益对比表数据"""
plans = db.query(MembershipPlan).filter(
MembershipPlan.is_active == True
).order_by(MembershipPlan.level).all()
# 定义对比维度
dimensions = [
{"key": "daily_free_tokens", "label": "每日免费Token", "type": "number"},
{"key": "max_agents", "label": "最大Agent数量", "type": "number"},
{"key": "max_workflows", "label": "最大工作流数量", "type": "number"},
{"key": "max_knowledge_bases", "label": "最大知识库数量", "type": "number"},
{"key": "max_storage_mb", "label": "存储配额(MB)", "type": "number"},
{"key": "api_calls_per_day", "label": "每日API调用限额", "type": "number"},
{"key": "api_calls_per_min", "label": "每分钟API调用限额", "type": "number"},
{"key": "can_publish_apps", "label": "发布应用到市场", "type": "boolean"},
{"key": "can_create_api_keys", "label": "创建开发者API密钥", "type": "boolean"},
{"key": "can_use_workflow", "label": "使用工作流编排", "type": "boolean"},
{"key": "can_use_mcp", "label": "使用MCP工具", "type": "boolean"},
{"key": "can_use_code_exec", "label": "使用代码执行", "type": "boolean"},
{"key": "can_use_privacy_mode", "label": "使用隐私模式", "type": "boolean"},
{"key": "can_use_dag", "label": "使用DAG多Agent", "type": "boolean"},
{"key": "allowed_model_tiers", "label": "可用模型层级", "type": "list"},
]
return {
"dimensions": dimensions,
"plans": [_format_plan(p) for p in plans],
}
# ============================================================
# 全局会员配置管理
# ============================================================
@router.get("/global-configs")
async def get_global_configs(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取全局会员配置"""
result = db.execute(text("SELECT id, config_key, config_value, config_type, description FROM membership_global_configs ORDER BY config_key"))
configs = []
for row in result:
val = row[2]
if row[3] == 'boolean':
val = val == 'true'
elif row[3] == 'number':
try:
val = int(val)
except:
val = 0
elif row[3] == 'json':
try:
val = json.loads(val)
except:
pass
configs.append({
"id": row[0],
"key": row[1],
"value": val,
"type": row[3],
"description": row[4] or "",
})
return configs
@router.put("/global-configs/{key}")
async def update_global_config(
key: str,
value: str = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新全局会员配置"""
result = db.execute(text("SELECT id, config_type FROM membership_global_configs WHERE config_key = :key"), {"key": key})
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="配置项不存在")
config_id, config_type = row[0], row[1]
store_value = value
if config_type == 'boolean':
store_value = 'true' if value in ('true', '1', 'True') else 'false'
db.execute(text("UPDATE membership_global_configs SET config_value = :val WHERE id = :id"), {"val": store_value, "id": config_id})
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_membership_config",
target_type="membership_config",
target_id=config_id,
detail=f"更新会员配置 {key}={store_value}",
)
return {"message": "配置已更新"}
# ============================================================
# 用户会员管理
# ============================================================
@router.get("/user-memberships")
async def get_user_memberships(
user_id: str = Query(None),
plan_id: str = Query(None),
status: str = Query(None, description="会员状态: active/expired"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取用户会员信息列表"""
query = db.query(User).filter(User.role == 'user')
if user_id:
query = query.filter(User.id == user_id)
users_q = query
if plan_id:
users_q = users_q.filter(User.membership_plan_id == plan_id)
total = users_q.count()
users = users_q.order_by(User.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
items = []
for u in users:
plan = db.query(MembershipPlan).filter(MembershipPlan.id == u.membership_plan_id).first() if u.membership_plan_id else None
# 判断状态
now = datetime.now()
if u.membership_expire_at and u.membership_expire_at < now:
member_status = "expired"
elif u.membership_level > 0:
member_status = "active"
else:
member_status = "free"
if status and member_status != status:
continue
items.append({
"user_id": u.id,
"username": u.username,
"email": u.email,
"membership_plan_id": u.membership_plan_id,
"membership_plan_name": plan.name if plan else "免费版",
"membership_level": u.membership_level or 0,
"membership_expire_at": u.membership_expire_at.isoformat() if u.membership_expire_at else None,
"auto_renew": bool(u.auto_renew),
"member_status": member_status,
"created_at": u.created_at.isoformat() if u.created_at else None,
})
return {"total": total, "page": page, "page_size": page_size, "items": items}
@router.put("/users/{user_id}/assign-plan")
async def assign_plan_to_user(
user_id: str,
plan_id: str,
duration_days: int = Query(30, description="订阅时长(天)"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""为用户分配会员计划(管理员手动分配)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
plan = db.query(MembershipPlan).filter(MembershipPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在")
from datetime import timedelta
now = datetime.now()
# 如果用户当前会员未到期,从到期时间续期
start_at = now
if user.membership_expire_at and user.membership_expire_at > now:
start_at = user.membership_expire_at
end_at = start_at + timedelta(days=duration_days)
user.membership_plan_id = plan_id
user.membership_level = plan.level
user.membership_expire_at = end_at
# 创建订阅记录
sub = MembershipSubscription(
id=str(uuid.uuid4()),
user_id=user_id,
plan_id=plan_id,
start_at=start_at,
end_at=end_at,
status="active",
)
db.add(sub)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="assign_membership_plan",
target_type="user",
target_id=user_id,
detail=f"为用户 {user.username} 分配计划 {plan.name} ({duration_days}天)",
)
return {"message": f"已为用户 {user.username} 分配 {plan.name} 计划", "expire_at": end_at.isoformat()}
@router.put("/users/{user_id}/revoke")
async def revoke_membership(
user_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""撤销用户会员(降级为免费版)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
old_plan = user.membership_plan_id
user.membership_plan_id = None
user.membership_level = 0
user.membership_expire_at = None
user.auto_renew = False
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="revoke_membership",
target_type="user",
target_id=user_id,
detail=f"撤销用户 {user.username} 的会员 (原计划ID: {old_plan})",
)
return {"message": f"已撤销用户 {user.username} 的会员"}
# ============================================================
# 用户配额使用情况
# ============================================================
@router.get("/users/{user_id}/usage")
async def get_user_usage(
user_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取用户资源使用情况"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 获取用户计划
plan = None
if user.membership_plan_id:
plan = db.query(MembershipPlan).filter(MembershipPlan.id == user.membership_plan_id).first()
# 统计用户实际资源使用
from ...models import Agent, KnowledgeBase
agents_count = db.query(Agent).filter(Agent.owner_id == user_id).count() if hasattr(Agent, 'owner_id') else 0
# 使用原生SQL统计
try:
kb_result = db.execute(text("SELECT COUNT(*) FROM knowledge_bases WHERE owner_id = :uid"), {"uid": user_id})
kb_count = kb_result.fetchone()[0]
except:
kb_count = 0
try:
agent_result = db.execute(text("SELECT COUNT(*) FROM agents WHERE owner_id = :uid"), {"uid": user_id})
agent_count = agent_result.fetchone()[0]
except:
agent_count = 0
try:
wf_result = db.execute(text("SELECT COUNT(*) FROM workflows WHERE owner_id = :uid"), {"uid": user_id})
wf_count = wf_result.fetchone()[0]
except:
wf_count = 0
# 获取今日API使用量
today = date.today()
try:
usage_result = db.execute(text("SELECT api_calls_total, tokens_used FROM user_api_usage WHERE user_id = :uid AND usage_date = :d"), {"uid": user_id, "d": today})
usage_row = usage_result.fetchone()
api_calls_today = usage_row[0] if usage_row else 0
tokens_today = usage_row[1] if usage_row else 0
except:
api_calls_today = 0
tokens_today = 0
# 获取存储使用量
try:
storage_result = db.execute(text("SELECT storage_used_mb FROM user_resource_usage WHERE user_id = :uid"), {"uid": user_id})
storage_row = storage_result.fetchone()
storage_used = float(storage_row[0]) if storage_row else 0.0
except:
storage_used = 0.0
plan_limits = {}
if plan:
plan_limits = {
"max_agents": getattr(plan, 'max_agents', 5) or 5,
"max_workflows": getattr(plan, 'max_workflows', 3) or 3,
"max_knowledge_bases": getattr(plan, 'max_knowledge_bases', 2) or 2,
"max_storage_mb": getattr(plan, 'max_storage_mb', 1024) or 1024,
"api_calls_per_day": getattr(plan, 'api_calls_per_day', 100) or 100,
"daily_free_tokens": plan.daily_free_tokens or 0,
}
return {
"user_id": user_id,
"username": user.username,
"plan_name": plan.name if plan else "免费版",
"plan_level": user.membership_level or 0,
"usage": {
"agents_count": agent_count,
"workflows_count": wf_count,
"knowledge_bases_count": kb_count,
"storage_used_mb": storage_used,
"api_calls_today": api_calls_today,
"tokens_today": tokens_today,
},
"limits": plan_limits,
"over_limit": {
"agents": agent_count > plan_limits.get("max_agents", 999) if plan_limits else False,
"workflows": wf_count > plan_limits.get("max_workflows", 999) if plan_limits else False,
"knowledge_bases": kb_count > plan_limits.get("max_knowledge_bases", 999) if plan_limits else False,
"storage": storage_used > plan_limits.get("max_storage_mb", 999999) if plan_limits else False,
"api_calls": api_calls_today > plan_limits.get("api_calls_per_day", 999999) if plan_limits else False,
},
}
# ============================================================
# 用户订阅管理
# ============================================================
@@ -190,24 +650,98 @@ async def get_subscriptions(
MembershipSubscription.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all()
# 关联用户名和计划名
items = []
for s in subscriptions:
u = db.query(User).filter(User.id == s.user_id).first()
p = db.query(MembershipPlan).filter(MembershipPlan.id == s.plan_id).first()
items.append({
"id": s.id,
"user_id": s.user_id,
"username": u.username if u else "",
"plan_id": s.plan_id,
"plan_name": p.name if p else "",
"order_id": s.order_id,
"start_at": s.start_at.isoformat() if s.start_at else None,
"end_at": s.end_at.isoformat() if s.end_at else None,
"auto_renew": s.auto_renew,
"status": s.status,
"created_at": s.created_at.isoformat() if s.created_at else None,
})
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": s.id,
"user_id": s.user_id,
"plan_id": s.plan_id,
"order_id": s.order_id,
"start_at": s.start_at.isoformat() if s.start_at else None,
"end_at": s.end_at.isoformat() if s.end_at else None,
"auto_renew": s.auto_renew,
"status": s.status,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
for s in subscriptions
],
"items": items,
}
# ============================================================
# 会员统计概览
# ============================================================
@router.get("/stats")
async def get_membership_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取会员统计概览"""
total_users = db.query(User).filter(User.role == 'user').count()
# 各等级用户数
level_stats = {}
for level in range(4):
count = db.query(User).filter(User.membership_level == level).count()
level_stats[f"level_{level}"] = count
# 活跃会员(未到期)
now = datetime.now()
active_members = db.query(User).filter(
User.membership_level > 0,
User.membership_expire_at > now
).count()
# 已过期会员
expired_members = db.query(User).filter(
User.membership_level > 0,
User.membership_expire_at <= now
).count()
# 自动续费用户数
auto_renew_count = db.query(User).filter(
User.auto_renew == True,
User.membership_level > 0
).count()
# 各计划订阅数
plan_stats = []
plans = db.query(MembershipPlan).order_by(MembershipPlan.level).all()
for plan in plans:
count = db.query(User).filter(User.membership_plan_id == plan.id).count()
plan_stats.append({
"plan_id": plan.id,
"plan_name": plan.name,
"level": plan.level,
"user_count": count,
"is_active": plan.is_active,
})
# 今日新增订阅
today_start = datetime(now.year, now.month, now.day)
today_new_subs = db.query(MembershipSubscription).filter(
MembershipSubscription.created_at >= today_start
).count()
return {
"total_users": total_users,
"active_members": active_members,
"expired_members": expired_members,
"free_users": level_stats.get("level_0", 0),
"auto_renew_count": auto_renew_count,
"level_stats": level_stats,
"plan_stats": plan_stats,
"today_new_subs": today_new_subs,
}
@@ -224,7 +758,6 @@ async def get_automation_rules(
rules = db.query(RiskRule).order_by(RiskRule.created_at.desc()).all()
if not rules:
# 返回默认规则
return {
"rules": [],
"defaults": {