feat:精简
Some checks failed
Create and publish Docker images with specific build args / build-main-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-main-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-cuda126-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-ollama-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/amd64, ubuntu-latest) (push) Has been cancelled
Create and publish Docker images with specific build args / build-slim-image (linux/arm64, ubuntu-24.04-arm) (push) Has been cancelled
Python CI / Format Backend (3.11.x) (push) Has been cancelled
Python CI / Format Backend (3.12.x) (push) Has been cancelled
Frontend Build / Format & Build Frontend (push) Has been cancelled
Frontend Build / Frontend Unit Tests (push) Has been cancelled
Create and publish Docker images with specific build args / merge-main-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-cuda126-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-ollama-images (push) Has been cancelled
Create and publish Docker images with specific build args / merge-slim-images (push) Has been cancelled
Close inactive issues / close-issues (push) Has been cancelled

This commit is contained in:
2026-01-16 18:34:38 +08:00
parent 16263710d9
commit 11fcec9387
137 changed files with 68993 additions and 6435 deletions

View File

@@ -1,9 +1,10 @@
import json
import time
import uuid
from typing import List, Optional, Tuple
from typing import Dict, List, Optional, Tuple
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from sqlalchemy import JSON, BigInteger, Boolean, Column, Integer, Numeric, String, Text
from open_webui.internal.db import Base, get_db
@@ -21,8 +22,19 @@ class SubscriptionPlan(Base):
name = Column(String, nullable=False)
description = Column(Text, nullable=True)
price = Column(Numeric(precision=10, scale=2), default=0)
# per-model monthly limits: {"model_id": limit, ...}
# -1 = unlimited, 0 = not allowed, positive = monthly limit
model_limits = Column(JSON, nullable=True)
# default limit for models not in model_limits
# -1 = unlimited, 0 = not allowed, positive = monthly limit
default_model_limit = Column(Integer, default=0)
# deprecated fields (kept for backward compatibility)
monthly_message_limit = Column(Integer, nullable=True)
allowed_models = Column(JSON, nullable=True)
priority = Column(Integer, default=0)
is_default = Column(Boolean, default=False)
is_active = Column(Boolean, default=True)
@@ -42,6 +54,11 @@ class UserSubscription(Base):
current_period_start = Column(BigInteger)
current_period_end = Column(BigInteger)
# per-model usage tracking: {"model_id": count, ...}
model_usage = Column(JSON, default={})
# deprecated field (kept for backward compatibility)
messages_used = Column(Integer, default=0)
created_at = Column(BigInteger)
@@ -72,7 +89,7 @@ class RedemptionCode(Base):
redemption_type = Column(String, default="duration")
plan_id = Column(String, nullable=True)
duration_days = Column(Integer, nullable=True)
upgrade_expires_at = Column(BigInteger, nullable=True)
upgrade_days = Column(Integer, nullable=True)
user_id = Column(String, index=True, nullable=True)
created_at = Column(BigInteger, index=True)
@@ -85,6 +102,16 @@ class RedemptionCode(Base):
####################
def _parse_json_field(v):
"""Parse JSON field from string if needed."""
if isinstance(v, str):
try:
return json.loads(v)
except json.JSONDecodeError:
return None
return v
class SubscriptionPlanModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -92,22 +119,43 @@ class SubscriptionPlanModel(BaseModel):
name: str
description: Optional[str] = None
price: Decimal = Field(default_factory=lambda: Decimal("0"))
# per-model monthly limits
model_limits: Optional[Dict[str, int]] = None
default_model_limit: int = 0
# deprecated fields
monthly_message_limit: Optional[int] = None
allowed_models: Optional[List[str]] = None
priority: int = 0
is_default: bool = False
is_active: bool = True
created_at: int = Field(default_factory=lambda: int(time.time()))
updated_at: int = Field(default_factory=lambda: int(time.time()))
@field_validator('model_limits', 'allowed_models', mode='before')
@classmethod
def parse_json_fields(cls, v):
return _parse_json_field(v)
def get_model_limit(self, model_id: str) -> int:
"""
Get the usage limit for a specific model.
Returns: -1 = unlimited, 0 = not allowed, positive = monthly limit
"""
if self.model_limits and model_id in self.model_limits:
return self.model_limits[model_id]
return self.default_model_limit
class SubscriptionPlanForm(BaseModel):
id: str
name: str
description: Optional[str] = None
price: float = 0
monthly_message_limit: Optional[int] = None
allowed_models: Optional[List[str]] = None
model_limits: Optional[Dict[str, int]] = None
default_model_limit: int = 0
priority: int = 0
is_default: bool = False
is_active: bool = True
@@ -117,8 +165,8 @@ class UpdateSubscriptionPlanForm(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
price: Optional[float] = None
monthly_message_limit: Optional[int] = None
allowed_models: Optional[List[str]] = None
model_limits: Optional[Dict[str, int]] = None
default_model_limit: Optional[int] = None
priority: Optional[int] = None
is_default: Optional[bool] = None
is_active: Optional[bool] = None
@@ -134,16 +182,48 @@ class UserSubscriptionModel(BaseModel):
expires_at: Optional[int] = None
current_period_start: int
current_period_end: int
# per-model usage tracking
model_usage: Dict[str, int] = Field(default_factory=dict)
# deprecated field
messages_used: int = 0
created_at: int = Field(default_factory=lambda: int(time.time()))
updated_at: int = Field(default_factory=lambda: int(time.time()))
@field_validator('model_usage', mode='before')
@classmethod
def parse_model_usage(cls, v):
parsed = _parse_json_field(v)
return parsed if parsed else {}
def get_model_usage(self, model_id: str) -> int:
"""Get usage count for a specific model."""
return self.model_usage.get(model_id, 0)
class ModelUsageInfo(BaseModel):
"""Usage info for a single model."""
model_id: str
limit: int # -1 = unlimited, 0 = not allowed
used: int
remaining: Optional[int] = None # None if unlimited
class UserSubscriptionWithPlanModel(UserSubscriptionModel):
plan: Optional[SubscriptionPlanModel] = None
messages_remaining: Optional[int] = None
days_remaining: Optional[int] = None
# per-model usage summary
model_usage_info: Optional[List[ModelUsageInfo]] = None
# deprecated
messages_remaining: Optional[int] = None
# admin view fields
username: Optional[str] = None
class SubscriptionUsageLogModel(BaseModel):
model_config = ConfigDict(from_attributes=True)
@@ -166,7 +246,7 @@ class RedemptionCodeModel(BaseModel):
redemption_type: str = "duration"
plan_id: Optional[str] = None
duration_days: Optional[int] = None
upgrade_expires_at: Optional[int] = None
upgrade_days: Optional[int] = None
user_id: Optional[str] = None
created_at: int
expired_at: Optional[int] = None
@@ -179,7 +259,7 @@ class CreateRedemptionCodeForm(BaseModel):
redemption_type: str = "duration"
plan_id: str
duration_days: Optional[int] = Field(default=None, ge=1)
upgrade_expires_at: Optional[int] = Field(default=None, gt=0)
upgrade_days: Optional[int] = Field(default=None, ge=1)
expired_at: Optional[int] = Field(default=None, gt=0)
@@ -187,7 +267,7 @@ class UpdateRedemptionCodeForm(BaseModel):
purpose: Optional[str] = Field(None, min_length=1, max_length=255)
plan_id: Optional[str] = None
duration_days: Optional[int] = Field(None, ge=1)
upgrade_expires_at: Optional[int] = Field(None, gt=0)
upgrade_days: Optional[int] = Field(None, ge=1)
expired_at: Optional[int] = Field(None, gt=0)
@@ -231,8 +311,8 @@ class SubscriptionPlansTable:
name=form_data.name,
description=form_data.description,
price=Decimal(str(form_data.price)),
monthly_message_limit=form_data.monthly_message_limit,
allowed_models=form_data.allowed_models,
model_limits=form_data.model_limits,
default_model_limit=form_data.default_model_limit,
priority=form_data.priority,
is_default=form_data.is_default,
is_active=form_data.is_active,
@@ -302,19 +382,28 @@ class UserSubscriptionsTable:
plan = SubscriptionPlans.get_plan_by_id(sub.plan_id)
now = int(time.time())
messages_remaining = None
if plan and plan.monthly_message_limit:
messages_remaining = max(0, plan.monthly_message_limit - sub.messages_used)
days_remaining = None
if sub.expires_at:
days_remaining = max(0, (sub.expires_at - now) // 86400)
# build model usage info
model_usage_info = []
if plan and plan.model_limits:
for model_id, limit in plan.model_limits.items():
used = sub.get_model_usage(model_id)
remaining = None if limit == -1 else max(0, limit - used)
model_usage_info.append(ModelUsageInfo(
model_id=model_id,
limit=limit,
used=used,
remaining=remaining,
))
return UserSubscriptionWithPlanModel(
**sub.model_dump(),
plan=plan,
messages_remaining=messages_remaining,
days_remaining=days_remaining,
model_usage_info=model_usage_info if model_usage_info else None,
)
def get_all_subscriptions(
@@ -338,10 +427,6 @@ class UserSubscriptionsTable:
plan = SubscriptionPlans.get_plan_by_id(sub_model.plan_id)
now = int(time.time())
messages_remaining = None
if plan and plan.monthly_message_limit:
messages_remaining = max(0, plan.monthly_message_limit - sub_model.messages_used)
days_remaining = None
if sub_model.expires_at:
days_remaining = max(0, (sub_model.expires_at - now) // 86400)
@@ -349,7 +434,6 @@ class UserSubscriptionsTable:
results.append(UserSubscriptionWithPlanModel(
**sub_model.model_dump(),
plan=plan,
messages_remaining=messages_remaining,
days_remaining=days_remaining,
))
@@ -361,11 +445,11 @@ class UserSubscriptionsTable:
# create a default free plan if not exists
default_plan = SubscriptionPlans.create_plan(SubscriptionPlanForm(
id="free",
name="Free",
description="Default free plan",
name="免费版",
description="免费套餐,基础模型无限使用",
price=0,
monthly_message_limit=100,
allowed_models=[],
model_limits={},
default_model_limit=-1, # all models unlimited by default
priority=0,
is_default=True,
is_active=True,
@@ -389,6 +473,7 @@ class UserSubscriptionsTable:
expires_at=None,
current_period_start=now,
current_period_end=period_end,
model_usage={},
messages_used=0,
created_at=now,
updated_at=now,
@@ -507,6 +592,7 @@ class UserSubscriptionsTable:
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
"current_period_start": now,
"current_period_end": period_end,
"model_usage": {},
"messages_used": 0,
"updated_at": now,
})
@@ -515,7 +601,31 @@ class UserSubscriptionsTable:
sub = db.query(UserSubscription).filter(UserSubscription.id == subscription_id).first()
return UserSubscriptionModel.model_validate(sub)
def increment_model_usage(self, subscription_id: str, model_id: str) -> None:
"""Increment usage counter for a specific model."""
with get_db() as db:
sub = db.query(UserSubscription).filter(UserSubscription.id == subscription_id).first()
if not sub:
return
current_usage = sub.model_usage or {}
if isinstance(current_usage, str):
try:
current_usage = json.loads(current_usage)
except json.JSONDecodeError:
current_usage = {}
current_usage[model_id] = current_usage.get(model_id, 0) + 1
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
"model_usage": current_usage,
"messages_used": UserSubscription.messages_used + 1,
"updated_at": int(time.time()),
})
db.commit()
def increment_usage(self, subscription_id: str) -> None:
"""Deprecated: use increment_model_usage instead."""
with get_db() as db:
db.query(UserSubscription).filter(UserSubscription.id == subscription_id).update({
"messages_used": UserSubscription.messages_used + 1,
@@ -659,10 +769,11 @@ class RedemptionCodesTable:
duration_days=redemption_code.duration_days,
)
else:
# upgrade type: calculate expires_at from upgrade_days
return UserSubscriptions.upgrade_subscription(
user_id=user_id,
plan_id=redemption_code.plan_id,
expires_at=redemption_code.upgrade_expires_at,
duration_days=redemption_code.upgrade_days,
)