feat: credit-based billing system

- New DB models: credit_packages, subscription_plans, user_credits, credit_consumptions, credit_purchases
- CreditService: balance, deduct, add_credits, grant_free_trial, history
- User API: /api/v1/credits/* (balance/history/packages/purchase/subscribe)
- Admin API: /api/v1/admin/credit-* (CRUD packages/plans, user credits, consumptions)
- PaymentService.create_credit_order + handle_callback for credit purchases
- Credit deduction on: discovery, translate, marketing, ai_chat, followup
- Free trial 30 credits on registration
- Documentation: docs/CREDIT_SYSTEM.md
This commit is contained in:
TradeMate Dev
2026-06-12 10:39:45 +08:00
parent 5d895ae12c
commit 2a107a42f3
21 changed files with 1528 additions and 33 deletions
@@ -0,0 +1,101 @@
"""add credit system tables (packages, plans, user credits, consumptions, purchases)
Revision ID: add_credit_system
Revises: add_perf_indexes
Create Date: 2026-06-12
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import UUID, JSONB
revision = "add_credit_system"
down_revision = "add_perf_indexes"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"credit_packages",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("name_en", sa.String(100), nullable=False),
sa.Column("credits", sa.Integer, nullable=False),
sa.Column("price", sa.Float, nullable=False),
sa.Column("price_usd", sa.Float, nullable=True),
sa.Column("original_price", sa.Float, nullable=True),
sa.Column("is_active", sa.Boolean, default=True),
sa.Column("sort_order", sa.Integer, default=0),
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, default=sa.func.now()),
)
op.create_table(
"subscription_plans",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("name_en", sa.String(100), nullable=False),
sa.Column("credits_per_month", sa.Integer, nullable=False),
sa.Column("price", sa.Float, nullable=False),
sa.Column("price_usd", sa.Float, nullable=True),
sa.Column("duration_days", sa.Integer, default=30),
sa.Column("is_active", sa.Boolean, default=True),
sa.Column("sort_order", sa.Integer, default=0),
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, default=sa.func.now()),
)
op.create_table(
"user_credits",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, unique=True, index=True),
sa.Column("balance", sa.Float, default=0),
sa.Column("total_purchased", sa.Float, default=0),
sa.Column("total_used", sa.Float, default=0),
sa.Column("subscription_plan_id", UUID(as_uuid=True), sa.ForeignKey("subscription_plans.id"), nullable=True),
sa.Column("subscription_expires_at", sa.DateTime, nullable=True),
sa.Column("subscription_auto_renew", sa.Boolean, default=False),
sa.Column("free_trial_used", sa.Boolean, default=False),
sa.Column("daily_translate_chars", sa.Integer, default=0),
sa.Column("daily_translate_date", sa.Date, nullable=True),
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
sa.Column("updated_at", sa.DateTime, default=sa.func.now()),
)
op.create_table(
"credit_consumptions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("result_type", sa.String(50), nullable=False),
sa.Column("reference_id", UUID(as_uuid=True), nullable=True),
sa.Column("credits_change", sa.Float, nullable=False),
sa.Column("balance_after", sa.Float, nullable=False),
sa.Column("source", sa.String(30), nullable=False),
sa.Column("description", sa.String(500), nullable=True),
sa.Column("metadata", JSONB, nullable=True),
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
)
op.create_index("idx_credit_consumptions_user", "credit_consumptions", ["user_id", sa.text("created_at DESC")])
op.create_index("idx_credit_consumptions_type", "credit_consumptions", ["result_type"])
op.create_table(
"credit_purchases",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id"), nullable=False, index=True),
sa.Column("package_id", UUID(as_uuid=True), sa.ForeignKey("credit_packages.id"), nullable=True),
sa.Column("subscription_plan_id", UUID(as_uuid=True), sa.ForeignKey("subscription_plans.id"), nullable=True),
sa.Column("credits", sa.Integer, nullable=False),
sa.Column("amount", sa.Float, nullable=False),
sa.Column("currency", sa.String(3), default="CNY"),
sa.Column("payment_method", sa.String(20), nullable=True),
sa.Column("status", sa.String(20), default="pending"),
sa.Column("payment_transaction_id", UUID(as_uuid=True), sa.ForeignKey("payment_transactions.id"), nullable=True),
sa.Column("created_at", sa.DateTime, default=sa.func.now()),
sa.Column("paid_at", sa.DateTime, nullable=True),
)
op.create_index("idx_credit_purchases_user", "credit_purchases", ["user_id"])
op.create_index("idx_credit_purchases_status", "credit_purchases", ["status"])
def downgrade():
op.drop_table("credit_purchases")
op.drop_table("credit_consumptions")
op.drop_table("user_credits")
op.drop_table("subscription_plans")
op.drop_table("credit_packages")