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:
@@ -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")
|
||||||
@@ -12,7 +12,7 @@ import sqlalchemy as sa
|
|||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = 'add_perf_indexes'
|
revision = 'add_perf_indexes'
|
||||||
down_revision = 'add_payment_transactions_table'
|
down_revision = 'add_payment_transactions'
|
||||||
branch_labels = None
|
branch_labels = None
|
||||||
depends_on = None
|
depends_on = None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select
|
||||||
|
from app.database import get_db
|
||||||
|
from app.api.v1.admin import require_admin
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
from app.models.credit_package import CreditPackage, SubscriptionPlan
|
||||||
|
from app.models.user_credit import UserCredit
|
||||||
|
from app.models.user import User
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PackageForm(BaseModel):
|
||||||
|
name: str
|
||||||
|
name_en: str
|
||||||
|
credits: int
|
||||||
|
price: float
|
||||||
|
price_usd: Optional[float] = None
|
||||||
|
original_price: Optional[float] = None
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class PlanForm(BaseModel):
|
||||||
|
name: str
|
||||||
|
name_en: str
|
||||||
|
credits_per_month: int
|
||||||
|
price: float
|
||||||
|
price_usd: Optional[float] = None
|
||||||
|
duration_days: int = 30
|
||||||
|
is_active: bool = True
|
||||||
|
sort_order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AdjustCreditsForm(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
credits: float
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credit-packages")
|
||||||
|
async def list_packages(
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(CreditPackage).order_by(CreditPackage.sort_order)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"name_en": p.name_en,
|
||||||
|
"credits": p.credits,
|
||||||
|
"price": p.price,
|
||||||
|
"price_usd": p.price_usd,
|
||||||
|
"original_price": p.original_price,
|
||||||
|
"is_active": p.is_active,
|
||||||
|
"sort_order": p.sort_order,
|
||||||
|
} for p in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/credit-packages")
|
||||||
|
async def create_package(
|
||||||
|
data: PackageForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
pkg = CreditPackage(**data.model_dump())
|
||||||
|
db.add(pkg)
|
||||||
|
await db.flush()
|
||||||
|
return {"id": str(pkg.id), "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/credit-packages/{pkg_id}")
|
||||||
|
async def update_package(
|
||||||
|
pkg_id: str,
|
||||||
|
data: PackageForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(pkg_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效ID")
|
||||||
|
result = await db.execute(select(CreditPackage).where(CreditPackage.id == uid))
|
||||||
|
pkg = result.scalar_one_or_none()
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="次数包不存在")
|
||||||
|
for k, v in data.model_dump().items():
|
||||||
|
setattr(pkg, k, v)
|
||||||
|
await db.flush()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/credit-packages/{pkg_id}")
|
||||||
|
async def delete_package(
|
||||||
|
pkg_id: str,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(pkg_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效ID")
|
||||||
|
result = await db.execute(select(CreditPackage).where(CreditPackage.id == uid))
|
||||||
|
pkg = result.scalar_one_or_none()
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="次数包不存在")
|
||||||
|
await db.delete(pkg)
|
||||||
|
await db.flush()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscription-plans")
|
||||||
|
async def list_plans(
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await db.execute(
|
||||||
|
select(SubscriptionPlan).order_by(SubscriptionPlan.sort_order)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"name_en": p.name_en,
|
||||||
|
"credits_per_month": p.credits_per_month,
|
||||||
|
"price": p.price,
|
||||||
|
"price_usd": p.price_usd,
|
||||||
|
"duration_days": p.duration_days,
|
||||||
|
"is_active": p.is_active,
|
||||||
|
"sort_order": p.sort_order,
|
||||||
|
} for p in result.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscription-plans")
|
||||||
|
async def create_plan(
|
||||||
|
data: PlanForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
plan = SubscriptionPlan(**data.model_dump())
|
||||||
|
db.add(plan)
|
||||||
|
await db.flush()
|
||||||
|
return {"id": str(plan.id), "status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/subscription-plans/{plan_id}")
|
||||||
|
async def update_plan(
|
||||||
|
plan_id: str,
|
||||||
|
data: PlanForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(plan_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效ID")
|
||||||
|
result = await db.execute(select(SubscriptionPlan).where(SubscriptionPlan.id == uid))
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="订阅套餐不存在")
|
||||||
|
for k, v in data.model_dump().items():
|
||||||
|
setattr(plan, k, v)
|
||||||
|
await db.flush()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/subscription-plans/{plan_id}")
|
||||||
|
async def delete_plan(
|
||||||
|
plan_id: str,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(plan_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效ID")
|
||||||
|
result = await db.execute(select(SubscriptionPlan).where(SubscriptionPlan.id == uid))
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="订阅套餐不存在")
|
||||||
|
await db.delete(plan)
|
||||||
|
await db.flush()
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/user-credits")
|
||||||
|
async def list_user_credits(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
offset = (page - 1) * size
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserCredit).order_by(UserCredit.updated_at.desc()).offset(offset).limit(size)
|
||||||
|
)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
count_result = await db.execute(select(func.count(UserCredit.id)))
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
enriched = []
|
||||||
|
for uc in items:
|
||||||
|
user_result = await db.execute(select(User).where(User.id == uc.user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
enriched.append({
|
||||||
|
"id": str(uc.id),
|
||||||
|
"user_id": str(uc.user_id),
|
||||||
|
"username": user.username if user else "N/A",
|
||||||
|
"balance": uc.balance,
|
||||||
|
"total_purchased": uc.total_purchased,
|
||||||
|
"total_used": uc.total_used,
|
||||||
|
"subscription_plan_id": str(uc.subscription_plan_id) if uc.subscription_plan_id else None,
|
||||||
|
"subscription_expires_at": uc.subscription_expires_at.isoformat() if uc.subscription_expires_at else None,
|
||||||
|
"free_trial_used": uc.free_trial_used,
|
||||||
|
"updated_at": uc.updated_at.isoformat() if uc.updated_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"items": enriched, "total": total, "page": page, "size": size}
|
||||||
|
|
||||||
|
|
||||||
|
class AdjustForm(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
credits: float
|
||||||
|
reason: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/user-credits/adjust")
|
||||||
|
async def adjust_credits(
|
||||||
|
data: AdjustForm,
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
try:
|
||||||
|
uid = uuid.UUID(data.user_id)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="无效用户ID")
|
||||||
|
balance = await svc.add_credits(
|
||||||
|
user_id=uid,
|
||||||
|
credits=data.credits,
|
||||||
|
source="admin_grant",
|
||||||
|
description=data.reason or f"管理员调整: {data.credits:+.1f} 次",
|
||||||
|
)
|
||||||
|
return {"status": "ok", "balance": balance}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credit-consumptions")
|
||||||
|
async def list_consumptions(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=200),
|
||||||
|
user_id: str = Query(None),
|
||||||
|
result_type: str = Query(None),
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.models.credit_consumption import CreditConsumption
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
|
||||||
|
conditions = []
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
conditions.append(CreditConsumption.user_id == uuid.UUID(user_id))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if result_type:
|
||||||
|
conditions.append(CreditConsumption.result_type == result_type)
|
||||||
|
|
||||||
|
stmt = select(CreditConsumption).where(*conditions).order_by(
|
||||||
|
desc(CreditConsumption.created_at)
|
||||||
|
).offset((page - 1) * size).limit(size)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
count_stmt = select(func.count(CreditConsumption.id)).where(*conditions)
|
||||||
|
count_result = await db.execute(count_stmt)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [{
|
||||||
|
"id": str(c.id),
|
||||||
|
"user_id": str(c.user_id),
|
||||||
|
"result_type": c.result_type,
|
||||||
|
"credits_change": c.credits_change,
|
||||||
|
"balance_after": c.balance_after,
|
||||||
|
"source": c.source,
|
||||||
|
"description": c.description,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
} for c in items],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/credit-stats")
|
||||||
|
async def credit_stats(
|
||||||
|
_: dict = Depends(require_admin),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_stats()
|
||||||
@@ -9,6 +9,7 @@ from app.ai.local_faq import match_faq
|
|||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
from app.models.system_config import SystemConfig
|
from app.models.system_config import SystemConfig
|
||||||
from app.services.admin import AdminService
|
from app.services.admin import AdminService
|
||||||
|
from app.services.credit import CreditService
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import re
|
import re
|
||||||
@@ -108,6 +109,14 @@ async def chat(
|
|||||||
f"db={t1-t0:.2f}s orm={t2-t1:.2f}s total={t4-t_start:.2f}s"
|
f"db={t1-t0:.2f}s orm={t2-t1:.2f}s total={t4-t_start:.2f}s"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "ai_chat")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f})"
|
||||||
|
)
|
||||||
|
|
||||||
t3 = time.time()
|
t3 = time.time()
|
||||||
ai = get_ai_router()
|
ai = get_ai_router()
|
||||||
result = await ai.chat(data.message, data.history or [], system_prompt)
|
result = await ai.chat(data.message, data.history or [], system_prompt)
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ async def register(
|
|||||||
)
|
)
|
||||||
db.add(sub)
|
db.add(sub)
|
||||||
|
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
await credit_svc.grant_free_trial(user.id)
|
||||||
|
|
||||||
if data.ref_code:
|
if data.ref_code:
|
||||||
try:
|
try:
|
||||||
from app.api.v1.referral import do_claim_referral
|
from app.api.v1.referral import do_claim_referral
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from app.database import get_db
|
||||||
|
from app.api.v1.deps import get_current_user_id
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
from app.services.payment import PaymentService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequest(BaseModel):
|
||||||
|
package_id: str
|
||||||
|
pay_type: str = "alipay"
|
||||||
|
|
||||||
|
|
||||||
|
class SubscribeRequest(BaseModel):
|
||||||
|
plan_id: str
|
||||||
|
pay_type: str = "alipay"
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/balance")
|
||||||
|
async def get_balance(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_balance(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history")
|
||||||
|
async def get_history(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
size: int = Query(20, ge=1, le=100),
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_history(user_id, page, size)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/packages")
|
||||||
|
async def list_packages(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_packages()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/subscription-plans")
|
||||||
|
async def list_subscription_plans(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
return await svc.get_subscription_plans()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/purchase")
|
||||||
|
async def purchase_package(
|
||||||
|
req: PurchaseRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
packages = await svc.get_packages()
|
||||||
|
pkg = next((p for p in packages if p["id"] == req.package_id), None)
|
||||||
|
if not pkg:
|
||||||
|
raise HTTPException(status_code=404, detail="次数包不存在")
|
||||||
|
|
||||||
|
pay_svc = PaymentService(db)
|
||||||
|
order = await pay_svc.create_credit_order(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=pkg["price"],
|
||||||
|
description=f"购买 {pkg['name']} ({pkg['credits']}次)",
|
||||||
|
pay_type=req.pay_type,
|
||||||
|
metadata={"credit_package_id": req.package_id, "credits": pkg["credits"]},
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/subscribe")
|
||||||
|
async def subscribe_plan(
|
||||||
|
req: SubscribeRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CreditService(db)
|
||||||
|
plans = await svc.get_subscription_plans()
|
||||||
|
plan = next((p for p in plans if p["id"] == req.plan_id), None)
|
||||||
|
if not plan:
|
||||||
|
raise HTTPException(status_code=404, detail="订阅套餐不存在")
|
||||||
|
|
||||||
|
pay_svc = PaymentService(db)
|
||||||
|
order = await pay_svc.create_credit_order(
|
||||||
|
user_id=user_id,
|
||||||
|
amount=plan["price"],
|
||||||
|
description=f"开通 {plan['name']} (每月{plan['credits_per_month']}次)",
|
||||||
|
pay_type=req.pay_type,
|
||||||
|
metadata={"subscription_plan_id": req.plan_id, "credits_per_month": plan["credits_per_month"]},
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cancel-subscription")
|
||||||
|
async def cancel_subscription(
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
from app.models.user_credit import UserCredit
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
result = await db.execute(select(UserCredit).where(UserCredit.user_id == user_id))
|
||||||
|
uc = result.scalar_one_or_none()
|
||||||
|
if not uc or not uc.subscription_plan_id:
|
||||||
|
raise HTTPException(status_code=400, detail="没有有效的订阅")
|
||||||
|
|
||||||
|
uc.subscription_auto_renew = False
|
||||||
|
await db.flush()
|
||||||
|
return {"success": True, "message": "已取消自动续费,当前订阅到期后不再续费"}
|
||||||
@@ -4,6 +4,11 @@ from pydantic import BaseModel
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.discovery import DiscoveryService
|
from app.services.discovery import DiscoveryService
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
from app.api.v1.deps import get_current_user_id
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -23,45 +28,104 @@ class OutreachRequest(BaseModel):
|
|||||||
product: Dict[str, Any]
|
product: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
CREDIT_COST = {
|
||||||
|
"search": 10,
|
||||||
|
"analyze": 5,
|
||||||
|
"outreach": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _deduct_credits(user_id: str, result_type: str, db: AsyncSession):
|
||||||
|
svc = CreditService(db)
|
||||||
|
ok, balance = await svc.deduct(user_id, result_type)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 {CREDIT_COST.get(result_type, 1)})"
|
||||||
|
)
|
||||||
|
return balance
|
||||||
|
|
||||||
|
|
||||||
@router.post("/search")
|
@router.post("/search")
|
||||||
async def search_leads(req: SearchRequest, db: AsyncSession = Depends(get_db)):
|
async def search_leads(
|
||||||
|
req: SearchRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
if not req.product_description.strip():
|
if not req.product_description.strip():
|
||||||
raise HTTPException(status_code=400, detail="请填写产品描述")
|
raise HTTPException(status_code=400, detail="请填写产品描述")
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "lead_search")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 10)"
|
||||||
|
)
|
||||||
|
|
||||||
svc = DiscoveryService(db=db)
|
svc = DiscoveryService(db=db)
|
||||||
try:
|
try:
|
||||||
result = await svc.search(req.product_description, req.target_market)
|
result = await svc.search(req.product_description, req.target_market)
|
||||||
return {"success": True, "data": result}
|
return {"success": True, "data": result, "credits_remaining": balance - 10}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
await credit_svc.add_credits(user_id, 10, "refund", "搜索失败退回次数")
|
||||||
logger.error(f"Search failed: {e}")
|
logger.error(f"Search failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail="搜索失败,请稍后重试")
|
raise HTTPException(status_code=500, detail="搜索失败,请稍后重试")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/analyze")
|
@router.post("/analyze")
|
||||||
async def analyze_company(req: AnalyzeRequest):
|
async def analyze_company(
|
||||||
|
req: AnalyzeRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
if not req.company_url.strip():
|
if not req.company_url.strip():
|
||||||
raise HTTPException(status_code=400, detail="请填写公司网址")
|
raise HTTPException(status_code=400, detail="请填写公司网址")
|
||||||
if not req.product_description.strip():
|
if not req.product_description.strip():
|
||||||
raise HTTPException(status_code=400, detail="请填写产品描述")
|
raise HTTPException(status_code=400, detail="请填写产品描述")
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "company_analysis")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 5)"
|
||||||
|
)
|
||||||
|
|
||||||
svc = DiscoveryService()
|
svc = DiscoveryService()
|
||||||
try:
|
try:
|
||||||
result = await svc.analyze(req.company_url, req.product_description)
|
result = await svc.analyze(req.company_url, req.product_description)
|
||||||
return {"success": True, "data": result}
|
return {"success": True, "data": result, "credits_remaining": balance - 5}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
await credit_svc.add_credits(user_id, 5, "refund", "分析失败退回次数")
|
||||||
logger.error(f"Analysis failed: {e}")
|
logger.error(f"Analysis failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail="分析失败,请稍后重试")
|
raise HTTPException(status_code=500, detail="分析失败,请稍后重试")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/outreach")
|
@router.post("/outreach")
|
||||||
async def generate_outreach(req: OutreachRequest):
|
async def generate_outreach(
|
||||||
|
req: OutreachRequest,
|
||||||
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
if not req.company.get("name"):
|
if not req.company.get("name"):
|
||||||
raise HTTPException(status_code=400, detail="请填写公司名称")
|
raise HTTPException(status_code=400, detail="请填写公司名称")
|
||||||
if not req.product.get("name"):
|
if not req.product.get("name"):
|
||||||
raise HTTPException(status_code=400, detail="请填写产品名称")
|
raise HTTPException(status_code=400, detail="请填写产品名称")
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "outreach")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 3)"
|
||||||
|
)
|
||||||
|
|
||||||
svc = DiscoveryService()
|
svc = DiscoveryService()
|
||||||
try:
|
try:
|
||||||
result = await svc.generate_outreach(req.company, req.product)
|
result = await svc.generate_outreach(req.company, req.product)
|
||||||
return {"success": True, "data": result}
|
return {"success": True, "data": result, "credits_remaining": balance - 3}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
await credit_svc.add_credits(user_id, 3, "refund", "生成失败退回次数")
|
||||||
logger.error(f"Outreach generation failed: {e}")
|
logger.error(f"Outreach generation failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail="生成失败,请稍后重试")
|
raise HTTPException(status_code=500, detail="生成失败,请稍后重试")
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.followup_engine import FollowupEngine
|
from app.services.followup_engine import FollowupEngine
|
||||||
|
from app.services.credit import CreditService
|
||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -84,6 +85,14 @@ async def trigger_followup_scan(
|
|||||||
user_id: str = Depends(get_current_user_id),
|
user_id: str = Depends(get_current_user_id),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "followup_scan")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 2)"
|
||||||
|
)
|
||||||
|
|
||||||
engine = FollowupEngine(db)
|
engine = FollowupEngine(db)
|
||||||
result = await engine.scan_and_followup()
|
result = await engine.scan_and_followup()
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.services.marketing import MarketingService
|
from app.services.marketing import MarketingService
|
||||||
from app.services.preference import UserPreferenceService
|
from app.services.preference import UserPreferenceService
|
||||||
|
from app.services.credit import CreditService
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
@@ -45,6 +46,14 @@ async def generate_marketing(
|
|||||||
user_id: str = Depends(get_current_user_id),
|
user_id: str = Depends(get_current_user_id),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "marketing_content")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 5)"
|
||||||
|
)
|
||||||
|
|
||||||
service = MarketingService()
|
service = MarketingService()
|
||||||
pref_service = UserPreferenceService(db)
|
pref_service = UserPreferenceService(db)
|
||||||
pref_context = await pref_service.get_preference_context(user_id, "marketing")
|
pref_context = await pref_service.get_preference_context(user_id, "marketing")
|
||||||
@@ -63,6 +72,7 @@ async def generate_marketing(
|
|||||||
"product": data.product_name,
|
"product": data.product_name,
|
||||||
"target": data.target,
|
"target": data.target,
|
||||||
"count": len(results),
|
"count": len(results),
|
||||||
|
"credits_remaining": balance - 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -70,7 +80,16 @@ async def generate_marketing(
|
|||||||
async def generate_keywords(
|
async def generate_keywords(
|
||||||
data: KeywordsRequest,
|
data: KeywordsRequest,
|
||||||
user_id: str = Depends(get_current_user_id),
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "marketing_content")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 5)"
|
||||||
|
)
|
||||||
|
|
||||||
service = MarketingService()
|
service = MarketingService()
|
||||||
product_info = {
|
product_info = {
|
||||||
"name": data.product_name,
|
"name": data.product_name,
|
||||||
@@ -79,14 +98,23 @@ async def generate_keywords(
|
|||||||
}
|
}
|
||||||
keywords = await service.generate_keywords(product_info, data.language, data.count)
|
keywords = await service.generate_keywords(product_info, data.language, data.count)
|
||||||
|
|
||||||
return {"keywords": keywords, "product": data.product_name}
|
return {"keywords": keywords, "product": data.product_name, "credits_remaining": balance - 5}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/competitor-analysis")
|
@router.post("/competitor-analysis")
|
||||||
async def competitor_analysis(
|
async def competitor_analysis(
|
||||||
data: CompetitorRequest,
|
data: CompetitorRequest,
|
||||||
user_id: str = Depends(get_current_user_id),
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "competitor_analysis")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 10)"
|
||||||
|
)
|
||||||
|
|
||||||
service = MarketingService()
|
service = MarketingService()
|
||||||
product_info = {
|
product_info = {
|
||||||
"name": data.product_name,
|
"name": data.product_name,
|
||||||
@@ -95,4 +123,4 @@ async def competitor_analysis(
|
|||||||
}
|
}
|
||||||
analysis = await service.analyze_competitors(product_info, data.market)
|
analysis = await service.analyze_competitors(product_info, data.market)
|
||||||
|
|
||||||
return {"analysis": analysis, "product": data.product_name, "market": data.market}
|
return {"analysis": analysis, "product": data.product_name, "market": data.market, "credits_remaining": balance - 10}
|
||||||
|
|||||||
@@ -104,8 +104,7 @@ async def import_products(
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
|
MAX_UPLOAD_SIZE = settings.MAX_UPLOAD_SIZE
|
||||||
MAX_UPLOAD_SIZE = settings.MAX_UPLOAD_SIZE
|
|
||||||
|
|
||||||
filename = file.filename or "unknown"
|
filename = file.filename or "unknown"
|
||||||
file_size = 0
|
file_size = 0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from app.database import get_db
|
|||||||
from app.services.translation import TranslationService
|
from app.services.translation import TranslationService
|
||||||
from app.services.tts import tts_service
|
from app.services.tts import tts_service
|
||||||
from app.services.preference import UserPreferenceService
|
from app.services.preference import UserPreferenceService
|
||||||
|
from app.services.credit import CreditService
|
||||||
from app.core.security import decode_token
|
from app.core.security import decode_token
|
||||||
from app.api.v1.deps import get_current_user_id
|
from app.api.v1.deps import get_current_user_id
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ class ExtractRequest(BaseModel):
|
|||||||
async def translate_text(
|
async def translate_text(
|
||||||
data: TranslateRequest,
|
data: TranslateRequest,
|
||||||
user_id: str = Depends(get_current_user_id),
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
service = TranslationService()
|
service = TranslationService()
|
||||||
result = await service.translate(
|
result = await service.translate(
|
||||||
@@ -44,6 +46,13 @@ async def translate_text(
|
|||||||
context=data.context,
|
context=data.context,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
char_count = len(data.text)
|
||||||
|
await credit_svc.deduct(
|
||||||
|
user_id, "translate",
|
||||||
|
metadata={"chars": char_count, "target_lang": data.target_lang},
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@@ -54,6 +63,15 @@ async def generate_reply(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
pref_service = UserPreferenceService(db)
|
pref_service = UserPreferenceService(db)
|
||||||
|
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "reply_suggest")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 2)"
|
||||||
|
)
|
||||||
|
|
||||||
pref_context = await pref_service.get_preference_context(user_id, "reply")
|
pref_context = await pref_service.get_preference_context(user_id, "reply")
|
||||||
|
|
||||||
service = TranslationService()
|
service = TranslationService()
|
||||||
@@ -71,7 +89,16 @@ async def generate_reply(
|
|||||||
async def extract_info(
|
async def extract_info(
|
||||||
data: ExtractRequest,
|
data: ExtractRequest,
|
||||||
user_id: str = Depends(get_current_user_id),
|
user_id: str = Depends(get_current_user_id),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
|
credit_svc = CreditService(db)
|
||||||
|
ok, balance = await credit_svc.deduct(user_id, "info_extract")
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=402,
|
||||||
|
detail=f"次数不足 (剩余 {balance:.1f}, 需要 1)"
|
||||||
|
)
|
||||||
|
|
||||||
service = TranslationService()
|
service = TranslationService()
|
||||||
result = await service.extract_info(data.text, data.extract_type)
|
result = await service.extract_info(data.text, data.extract_type)
|
||||||
return {"extracted": result, "type": data.extract_type}
|
return {"extracted": result, "type": data.extract_type}
|
||||||
|
|||||||
+3
-1
@@ -129,7 +129,7 @@ async def health():
|
|||||||
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
|
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
|
||||||
|
|
||||||
|
|
||||||
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, discovery_record, certification, invoice, usage, referral, admin_search, search, admin_ai
|
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push, admin, analytics, teams, onboarding, notification, feedback, payment, interaction, silent_pattern, training, followup, ai_assistant, discovery, discovery_record, certification, invoice, usage, referral, admin_search, search, admin_ai, credits, admin_credits
|
||||||
|
|
||||||
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
|
||||||
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
|
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
|
||||||
@@ -161,6 +161,8 @@ app.include_router(usage.router, prefix="/api/v1/usage", tags=["usage"])
|
|||||||
app.include_router(referral.router, prefix="/api/v1/referral", tags=["referral"])
|
app.include_router(referral.router, prefix="/api/v1/referral", tags=["referral"])
|
||||||
app.include_router(admin_search.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin_search.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
app.include_router(admin_ai.router, prefix="/api/v1/admin", tags=["admin"])
|
app.include_router(admin_ai.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
app.include_router(admin_credits.router, prefix="/api/v1/admin", tags=["admin"])
|
||||||
|
app.include_router(credits.router, prefix="/api/v1/credits", tags=["credits"])
|
||||||
app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
|
app.include_router(search.router, prefix="/api/v1/search", tags=["search"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ from .search_provider import SearchProvider
|
|||||||
from .discovery_record import DiscoveryRecord
|
from .discovery_record import DiscoveryRecord
|
||||||
from .ai_provider import AIProvider
|
from .ai_provider import AIProvider
|
||||||
from .payment_transaction import PaymentTransaction
|
from .payment_transaction import PaymentTransaction
|
||||||
|
from .credit_package import CreditPackage, SubscriptionPlan
|
||||||
|
from .user_credit import UserCredit
|
||||||
|
from .credit_consumption import CreditConsumption
|
||||||
|
from .credit_purchase import CreditPurchase
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"User", "Product",
|
"User", "Product",
|
||||||
@@ -37,4 +41,8 @@ __all__ = [
|
|||||||
"DiscoveryRecord",
|
"DiscoveryRecord",
|
||||||
"AIProvider",
|
"AIProvider",
|
||||||
"PaymentTransaction",
|
"PaymentTransaction",
|
||||||
|
"CreditPackage", "SubscriptionPlan",
|
||||||
|
"UserCredit",
|
||||||
|
"CreditConsumption",
|
||||||
|
"CreditPurchase",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from sqlalchemy import Column, String, Float, DateTime, ForeignKey, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID, JSONB
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CreditConsumption(Base):
|
||||||
|
__tablename__ = "credit_consumptions"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
result_type = Column(String(50), nullable=False)
|
||||||
|
reference_id = Column(UUID(as_uuid=True), nullable=True)
|
||||||
|
credits_change = Column(Float, nullable=False)
|
||||||
|
balance_after = Column(Float, nullable=False)
|
||||||
|
source = Column(String(30), nullable=False)
|
||||||
|
description = Column(String(500))
|
||||||
|
metadata_ = Column("metadata", JSONB)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
from sqlalchemy import Column, String, Integer, Float, Boolean, DateTime
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CreditPackage(Base):
|
||||||
|
__tablename__ = "credit_packages"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
name_en = Column(String(100), nullable=False)
|
||||||
|
credits = Column(Integer, nullable=False)
|
||||||
|
price = Column(Float, nullable=False)
|
||||||
|
price_usd = Column(Float)
|
||||||
|
original_price = Column(Float)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionPlan(Base):
|
||||||
|
__tablename__ = "subscription_plans"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
name = Column(String(100), nullable=False)
|
||||||
|
name_en = Column(String(100), nullable=False)
|
||||||
|
credits_per_month = Column(Integer, nullable=False)
|
||||||
|
price = Column(Float, nullable=False)
|
||||||
|
price_usd = Column(Float)
|
||||||
|
duration_days = Column(Integer, default=30)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
sort_order = Column(Integer, default=0)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from sqlalchemy import Column, String, Integer, Float, DateTime, ForeignKey
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class CreditPurchase(Base):
|
||||||
|
__tablename__ = "credit_purchases"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True)
|
||||||
|
package_id = Column(UUID(as_uuid=True), ForeignKey("credit_packages.id"), nullable=True)
|
||||||
|
subscription_plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True)
|
||||||
|
credits = Column(Integer, nullable=False)
|
||||||
|
amount = Column(Float, nullable=False)
|
||||||
|
currency = Column(String(3), default="CNY")
|
||||||
|
payment_method = Column(String(20))
|
||||||
|
status = Column(String(20), default="pending")
|
||||||
|
payment_transaction_id = Column(UUID(as_uuid=True), ForeignKey("payment_transactions.id"), nullable=True)
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
paid_at = Column(DateTime, nullable=True)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
from sqlalchemy import Column, String, Float, Boolean, DateTime, ForeignKey, Integer, Date
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from datetime import datetime
|
||||||
|
from app.database import Base
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class UserCredit(Base):
|
||||||
|
__tablename__ = "user_credits"
|
||||||
|
|
||||||
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, unique=True, index=True)
|
||||||
|
balance = Column(Float, default=0)
|
||||||
|
total_purchased = Column(Float, default=0)
|
||||||
|
total_used = Column(Float, default=0)
|
||||||
|
|
||||||
|
subscription_plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=True)
|
||||||
|
subscription_expires_at = Column(DateTime, nullable=True)
|
||||||
|
subscription_auto_renew = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
free_trial_used = Column(Boolean, default=False)
|
||||||
|
daily_translate_chars = Column(Integer, default=0)
|
||||||
|
daily_translate_date = Column(Date, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy import select, func, desc
|
||||||
|
from datetime import datetime, date
|
||||||
|
from decimal import Decimal
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.models import UserCredit, CreditConsumption, CreditPackage, SubscriptionPlan, CreditPurchase
|
||||||
|
from app.models.system_config import SystemConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DEFAULT_CONSUMPTION_RATES = {
|
||||||
|
"lead_search": 10,
|
||||||
|
"company_analysis": 5,
|
||||||
|
"market_intel": 20,
|
||||||
|
"translate_per_1000chars": 1,
|
||||||
|
"reply_suggest": 2,
|
||||||
|
"outreach": 3,
|
||||||
|
"marketing_content": 5,
|
||||||
|
"competitor_analysis": 10,
|
||||||
|
"ai_chat_per_10msg": 1,
|
||||||
|
"info_extract": 1,
|
||||||
|
"quotation": 2,
|
||||||
|
"followup_scan": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
FREE_TRIAL_CREDITS = 30
|
||||||
|
DAILY_FREE_TRANSLATE_CHARS = 1000
|
||||||
|
|
||||||
|
|
||||||
|
class CreditService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def _ensure_credit(self, user_id: str) -> UserCredit:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(UserCredit).where(UserCredit.user_id == user_id)
|
||||||
|
)
|
||||||
|
uc = result.scalar_one_or_none()
|
||||||
|
if not uc:
|
||||||
|
uc = UserCredit(user_id=user_id, balance=0)
|
||||||
|
self.db.add(uc)
|
||||||
|
await self.db.flush()
|
||||||
|
return uc
|
||||||
|
|
||||||
|
async def get_balance(self, user_id: str) -> dict:
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
rates = await self._get_rates()
|
||||||
|
return {
|
||||||
|
"balance": uc.balance,
|
||||||
|
"total_purchased": uc.total_purchased,
|
||||||
|
"total_used": uc.total_used,
|
||||||
|
"subscription": {
|
||||||
|
"plan_id": str(uc.subscription_plan_id) if uc.subscription_plan_id else None,
|
||||||
|
"expires_at": uc.subscription_expires_at.isoformat() if uc.subscription_expires_at else None,
|
||||||
|
"auto_renew": uc.subscription_auto_renew,
|
||||||
|
} if uc.subscription_plan_id else None,
|
||||||
|
"free_trial_used": uc.free_trial_used,
|
||||||
|
"daily_free_translate_chars_left": max(0, DAILY_FREE_TRANSLATE_CHARS - await self._daily_translate_chars(uc)),
|
||||||
|
"rates": rates,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def deduct(self, user_id: str, result_type: str, reference_id: str = None, amount: float = None, metadata: dict = None) -> tuple[bool, float]:
|
||||||
|
rates = await self._get_rates()
|
||||||
|
cost = amount or rates.get(result_type, 1)
|
||||||
|
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
|
||||||
|
if result_type == "translate":
|
||||||
|
char_count = (metadata or {}).get("chars", 0)
|
||||||
|
if char_count > 0:
|
||||||
|
daily_free = await self._daily_translate_chars(uc)
|
||||||
|
free_remaining = max(0, DAILY_FREE_TRANSLATE_CHARS - daily_free)
|
||||||
|
free_used = min(free_remaining, char_count)
|
||||||
|
paid_chars = char_count - free_used
|
||||||
|
cost = (paid_chars / 1000) * rates.get("translate_per_1000chars", 1)
|
||||||
|
if free_used > 0:
|
||||||
|
today = date.today()
|
||||||
|
if uc.daily_translate_date != today:
|
||||||
|
uc.daily_translate_date = today
|
||||||
|
uc.daily_translate_chars = 0
|
||||||
|
uc.daily_translate_chars += free_used
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
if cost <= 0:
|
||||||
|
await self._log(user_id, result_type, reference_id, 0, uc.balance, "daily_free", metadata)
|
||||||
|
return True, uc.balance
|
||||||
|
|
||||||
|
if uc.balance < cost:
|
||||||
|
return False, uc.balance
|
||||||
|
|
||||||
|
uc.balance -= cost
|
||||||
|
uc.total_used += cost
|
||||||
|
balance_after = uc.balance
|
||||||
|
|
||||||
|
await self._log(user_id, result_type, reference_id, -cost, balance_after, "credit", metadata)
|
||||||
|
await self.db.flush()
|
||||||
|
return True, balance_after
|
||||||
|
|
||||||
|
async def add_credits(self, user_id: str, credits: float, source: str, description: str = None) -> float:
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
uc.balance += credits
|
||||||
|
if credits > 0:
|
||||||
|
uc.total_purchased += credits
|
||||||
|
balance_after = uc.balance
|
||||||
|
await self._log(user_id, "topup", None, credits, balance_after, source, {"description": description})
|
||||||
|
await self.db.flush()
|
||||||
|
return balance_after
|
||||||
|
|
||||||
|
async def grant_free_trial(self, user_id: str) -> float:
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
if uc.free_trial_used:
|
||||||
|
return uc.balance
|
||||||
|
return await self.add_credits(
|
||||||
|
user_id, FREE_TRIAL_CREDITS, "free_trial",
|
||||||
|
f"新用户注册赠送 {FREE_TRIAL_CREDITS} 次"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def consume_for_subscription(self, user_id: str, plan_id: str) -> tuple[bool, str]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SubscriptionPlan).where(SubscriptionPlan.id == plan_id, SubscriptionPlan.is_active == True)
|
||||||
|
)
|
||||||
|
plan = result.scalar_one_or_none()
|
||||||
|
if not plan:
|
||||||
|
return False, "套餐不存在"
|
||||||
|
|
||||||
|
uc = await self._ensure_credit(user_id)
|
||||||
|
amount = plan.price
|
||||||
|
|
||||||
|
return True, "ok"
|
||||||
|
|
||||||
|
async def _log(self, user_id: str, result_type: str, reference_id: str,
|
||||||
|
credits_change: float, balance_after: float, source: str, metadata: dict = None):
|
||||||
|
log = CreditConsumption(
|
||||||
|
user_id=user_id,
|
||||||
|
result_type=result_type,
|
||||||
|
reference_id=reference_id,
|
||||||
|
credits_change=credits_change,
|
||||||
|
balance_after=balance_after,
|
||||||
|
source=source,
|
||||||
|
metadata_=metadata or {},
|
||||||
|
)
|
||||||
|
self.db.add(log)
|
||||||
|
|
||||||
|
async def get_history(self, user_id: str, page: int = 1, size: int = 20) -> dict:
|
||||||
|
offset = (page - 1) * size
|
||||||
|
stmt = select(CreditConsumption).where(
|
||||||
|
CreditConsumption.user_id == user_id
|
||||||
|
).order_by(desc(CreditConsumption.created_at)).offset(offset).limit(size)
|
||||||
|
result = await self.db.execute(stmt)
|
||||||
|
items = result.scalars().all()
|
||||||
|
|
||||||
|
count_stmt = select(func.count()).where(CreditConsumption.user_id == user_id)
|
||||||
|
count_result = await self.db.execute(count_stmt)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": [{
|
||||||
|
"id": str(item.id),
|
||||||
|
"result_type": item.result_type,
|
||||||
|
"credits_change": item.credits_change,
|
||||||
|
"balance_after": item.balance_after,
|
||||||
|
"source": item.source,
|
||||||
|
"description": item.description,
|
||||||
|
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||||||
|
} for item in items],
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"size": size,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_rates(self) -> dict:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SystemConfig).where(SystemConfig.key == "credit_consumption_rates")
|
||||||
|
)
|
||||||
|
row = result.scalar_one_or_none()
|
||||||
|
if row and row.value:
|
||||||
|
return {**DEFAULT_CONSUMPTION_RATES, **row.value}
|
||||||
|
return dict(DEFAULT_CONSUMPTION_RATES)
|
||||||
|
|
||||||
|
async def _daily_translate_chars(self, uc: UserCredit) -> int:
|
||||||
|
today = date.today()
|
||||||
|
if uc.daily_translate_date != today:
|
||||||
|
return 0
|
||||||
|
return uc.daily_translate_chars or 0
|
||||||
|
|
||||||
|
async def get_packages(self) -> list:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(CreditPackage).where(CreditPackage.is_active == True).order_by(CreditPackage.sort_order)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"name_en": p.name_en,
|
||||||
|
"credits": p.credits,
|
||||||
|
"price": p.price,
|
||||||
|
"price_usd": p.price_usd,
|
||||||
|
"original_price": p.original_price,
|
||||||
|
} for p in result.scalars().all()]
|
||||||
|
|
||||||
|
async def get_subscription_plans(self) -> list:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(SubscriptionPlan).where(SubscriptionPlan.is_active == True).order_by(SubscriptionPlan.sort_order)
|
||||||
|
)
|
||||||
|
return [{
|
||||||
|
"id": str(p.id),
|
||||||
|
"name": p.name,
|
||||||
|
"name_en": p.name_en,
|
||||||
|
"credits_per_month": p.credits_per_month,
|
||||||
|
"price": p.price,
|
||||||
|
"price_usd": p.price_usd,
|
||||||
|
"duration_days": p.duration_days,
|
||||||
|
} for p in result.scalars().all()]
|
||||||
|
|
||||||
|
async def get_stats(self) -> dict:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(UserCredit.total_purchased), 0))
|
||||||
|
)
|
||||||
|
total_purchased = result.scalar()
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(UserCredit.balance), 0))
|
||||||
|
)
|
||||||
|
total_balance = result.scalar()
|
||||||
|
|
||||||
|
result = await self.db.execute(select(func.count(UserCredit.id)))
|
||||||
|
total_users = result.scalar()
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.coalesce(func.sum(CreditConsumption.credits_change), 0)).where(
|
||||||
|
CreditConsumption.credits_change < 0
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_consumed = abs(result.scalar() or 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_purchased": total_purchased,
|
||||||
|
"total_balance": total_balance,
|
||||||
|
"total_consumed": total_consumed,
|
||||||
|
"total_users_with_credits": total_users,
|
||||||
|
}
|
||||||
|
|
||||||
|
CREDIT_CONSUMPTION = {
|
||||||
|
"lead_search": 10,
|
||||||
|
"company_analysis": 5,
|
||||||
|
"market_intel": 20,
|
||||||
|
"translate_per_1000chars": 1,
|
||||||
|
"reply_suggest": 2,
|
||||||
|
"outreach": 3,
|
||||||
|
"marketing_content": 5,
|
||||||
|
"competitor_analysis": 10,
|
||||||
|
"ai_chat": 1,
|
||||||
|
"info_extract": 1,
|
||||||
|
"quotation": 2,
|
||||||
|
"followup_scan": 2,
|
||||||
|
}
|
||||||
@@ -259,7 +259,7 @@ URL: {company_url}
|
|||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
import re
|
import re
|
||||||
brace = text.find("{")
|
brace = text.find("{")
|
||||||
end = text.rfind("}")
|
end = text.rfind("}")
|
||||||
if brace >= 0 and end > brace:
|
if brace >= 0 and end > brace:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -143,6 +143,39 @@ class PaymentService:
|
|||||||
**gw_result,
|
**gw_result,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def create_credit_order(self, user_id: str, amount: float,
|
||||||
|
description: str, pay_type: str = "alipay",
|
||||||
|
metadata: dict = None) -> Dict[str, Any]:
|
||||||
|
order_no = gen_order_no(user_id)
|
||||||
|
gw = get_gateway(pay_type)
|
||||||
|
|
||||||
|
meta_remark = {"uid": user_id, "oid": order_no, "type": "credit_purchase"}
|
||||||
|
if metadata:
|
||||||
|
meta_remark.update(metadata)
|
||||||
|
|
||||||
|
gw_result = await gw.create_order(order_no, int(amount * 100),
|
||||||
|
description, pay_type=pay_type,
|
||||||
|
remark=json.dumps(meta_remark, separators=(",", ":")))
|
||||||
|
|
||||||
|
txn = PaymentTransaction(
|
||||||
|
user_id=user_id, order_no=order_no, plan="credit_purchase",
|
||||||
|
amount=amount, gateway="unified", pay_type=pay_type,
|
||||||
|
status="pending", description=json.dumps(metadata or {}, ensure_ascii=False),
|
||||||
|
gateway_order_no=gw_result.get("gateway_order_id", ""),
|
||||||
|
)
|
||||||
|
self.db.add(txn)
|
||||||
|
await self.db.flush()
|
||||||
|
return {
|
||||||
|
"status": "pending",
|
||||||
|
"order_id": order_no,
|
||||||
|
"amount": amount,
|
||||||
|
"currency": "CNY",
|
||||||
|
"gateway": "unified",
|
||||||
|
"pay_type": pay_type,
|
||||||
|
"metadata": metadata or {},
|
||||||
|
**gw_result,
|
||||||
|
}
|
||||||
|
|
||||||
async def handle_callback(self, order_no: str, gateway_order_id: str,
|
async def handle_callback(self, order_no: str, gateway_order_id: str,
|
||||||
gateway_order_no: str, success: bool,
|
gateway_order_no: str, success: bool,
|
||||||
amount: float = 0, notify_raw: str = "") -> bool:
|
amount: float = 0, notify_raw: str = "") -> bool:
|
||||||
@@ -162,30 +195,52 @@ class PaymentService:
|
|||||||
txn.paid_at = datetime.utcnow()
|
txn.paid_at = datetime.utcnow()
|
||||||
txn.notify_raw = notify_raw
|
txn.notify_raw = notify_raw
|
||||||
|
|
||||||
sub_result = await self.db.execute(
|
if txn.plan == "credit_purchase":
|
||||||
select(Subscription).where(Subscription.payment_id == order_no)
|
from app.services.credit import CreditService
|
||||||
)
|
credit_svc = CreditService(self.db)
|
||||||
sub = sub_result.scalar_one_or_none()
|
|
||||||
if sub:
|
|
||||||
sub.status = "active"
|
|
||||||
sub.started_at = datetime.utcnow()
|
|
||||||
if PLANS[sub.plan]["duration_days"]:
|
|
||||||
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
|
|
||||||
|
|
||||||
user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
|
if txn.description:
|
||||||
user = user_result.scalar_one_or_none()
|
try:
|
||||||
if user:
|
meta = json.loads(txn.description)
|
||||||
user.tier = txn.plan
|
credits = meta.get("credits", 0)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
credits = 0
|
||||||
|
else:
|
||||||
|
credits = 0
|
||||||
|
|
||||||
|
if not credits:
|
||||||
|
credits = max(1, int(txn.amount / 0.79))
|
||||||
|
|
||||||
|
await credit_svc.add_credits(
|
||||||
|
txn.user_id, credits, "package",
|
||||||
|
f"支付完成 - 获得 {credits} 次信用额度"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sub_result = await self.db.execute(
|
||||||
|
select(Subscription).where(Subscription.payment_id == order_no)
|
||||||
|
)
|
||||||
|
sub = sub_result.scalar_one_or_none()
|
||||||
|
if sub:
|
||||||
|
sub.status = "active"
|
||||||
|
sub.started_at = datetime.utcnow()
|
||||||
|
if PLANS[sub.plan]["duration_days"]:
|
||||||
|
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
|
||||||
|
|
||||||
|
user_result = await self.db.execute(select(User).where(User.id == txn.user_id))
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
if user:
|
||||||
|
user.tier = txn.plan
|
||||||
else:
|
else:
|
||||||
txn.status = "failed"
|
txn.status = "failed"
|
||||||
txn.notify_raw = notify_raw
|
txn.notify_raw = notify_raw
|
||||||
|
|
||||||
sub_result = await self.db.execute(
|
if txn.plan != "credit_purchase":
|
||||||
select(Subscription).where(Subscription.payment_id == order_no)
|
sub_result = await self.db.execute(
|
||||||
)
|
select(Subscription).where(Subscription.payment_id == order_no)
|
||||||
sub = sub_result.scalar_one_or_none()
|
)
|
||||||
if sub:
|
sub = sub_result.scalar_one_or_none()
|
||||||
sub.status = "failed"
|
if sub:
|
||||||
|
sub.status = "failed"
|
||||||
|
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -0,0 +1,399 @@
|
|||||||
|
# TradeMate Credit System — 信用计费系统设计方案
|
||||||
|
|
||||||
|
## 1. 业务模式变更
|
||||||
|
|
||||||
|
### 1.1 从工具订阅 → 结果付费
|
||||||
|
|
||||||
|
- **旧模式**:按月订阅解锁功能(Free/Pro/Enterprise)
|
||||||
|
- **新模式**:按次付费购买"结果",所有 AI 功能统一消耗信用额度(Credits)
|
||||||
|
- **核心**:用户不为功能付费,为**产生的价值**(客户线索、分析报告、翻译字符)付费
|
||||||
|
|
||||||
|
### 1.2 目标用户群
|
||||||
|
|
||||||
|
- **国内**:中国外贸 SOHO/小团队 → 中文界面,¥ 计价
|
||||||
|
- **海外**:全球跨境小B → 英文界面,$ 计价
|
||||||
|
- 两端统一产品逻辑,分语言/货币展示
|
||||||
|
|
||||||
|
### 1.3 产品定位
|
||||||
|
|
||||||
|
| 维度 | 旧 | 新 |
|
||||||
|
|------|-----|-----|
|
||||||
|
| 定位 | 外贸工具套装 | 外贸客户发现引擎 |
|
||||||
|
| Slogan | 外贸小助手 | Customer Discovery Engine / 外贸获客引擎 |
|
||||||
|
| 核心价值 | 多功能的工具箱 | 输入产品,找到买家 |
|
||||||
|
| 免费策略 | 限功能免费版 | 注册送 N 次,用完即购 |
|
||||||
|
| 收费锚点 | 按月付费解锁 | 按产出结果扣次 |
|
||||||
|
|
||||||
|
## 2. 信用额度系统(Credits)
|
||||||
|
|
||||||
|
### 2.1 定义
|
||||||
|
|
||||||
|
**1 Credit = 1 次消费单位。** 所有 AI 调用均以 Credits 计价。
|
||||||
|
|
||||||
|
用户持有的 Credits 通过两种方式获得:
|
||||||
|
- **一次性购买(Credit Pack)**:永不过期,用不完的余额留存
|
||||||
|
- **月订阅(Subscription)**:每月自动到账,月底未用完清零
|
||||||
|
|
||||||
|
两种方式的 Credits 统一存放在 `user_credits.balance` 中,消费时按 `source` 记录来源。
|
||||||
|
|
||||||
|
### 2.2 消费价格表
|
||||||
|
|
||||||
|
所有 AI 相关功能均消耗 Credits,覆盖 API 调用成本:
|
||||||
|
|
||||||
|
| 功能 | Credits | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| **客户搜索** (lead_search) | 10 | 多源搜索 + AI 匹配 + 联系方式提取 |
|
||||||
|
| **公司深度分析** (company_analysis) | 5 | 单家公司 AI 分析报告 |
|
||||||
|
| **市场情报报告** (market_intel) | 20 | 多维度市场研究 |
|
||||||
|
| **翻译** (translate, 5000 chars) | 5 | 按字符计费,不足按比例折算 |
|
||||||
|
| **回复建议** (reply_suggest) | 2 | AI 生成 3 条回复建议 |
|
||||||
|
| **开发信生成** (outreach) | 3 | 单封定制开发信 |
|
||||||
|
| **营销素材** (marketing_content) | 5 | 多风格营销文案 |
|
||||||
|
| **竞品分析** (competitor_analysis) | 10 | 多个竞品对比分析 |
|
||||||
|
| **AI 助手对话** (ai_chat, 10条消息) | 1 | 普通 AI 对话 |
|
||||||
|
| **信息提取** (info_extract) | 1 | 询盘/邮件信息提取 |
|
||||||
|
| **报价单生成** (quotation) | 2 | AI 辅助报价 |
|
||||||
|
| **跟进策略** (followup_scan) | 2 | AI 扫描跟进建议 |
|
||||||
|
|
||||||
|
### 2.3 计费规则
|
||||||
|
|
||||||
|
- **按次扣减**:功能调用成功后扣减,失败不扣
|
||||||
|
- **余额不足**:返回 `402 Payment Required` + 当前余额 + 所需额度
|
||||||
|
- **不足扣减**:如余额不足单次消费,允许扣至负数(欠费模式),欠费部分下次充值自动抵扣
|
||||||
|
- **日免费额度**:翻译每日免费 1000 字符(防流失),超出部分扣 Credits
|
||||||
|
- **免费试用**:新用户注册自动赠送 30 Credits(≈ 3 次搜索)
|
||||||
|
|
||||||
|
### 2.4 消费记录
|
||||||
|
|
||||||
|
每次扣减记录在 `credit_consumptions` 表,包含:
|
||||||
|
- user_id, result_type(功能类型)
|
||||||
|
- reference_id(关联业务记录 ID)
|
||||||
|
- credits_change(正数 = 充值,负数 = 消费)
|
||||||
|
- balance_after(扣减后余额)
|
||||||
|
- source(package/subscription/admin_grant)
|
||||||
|
- metadata(Jsonb,保存上下文,如搜索关键词、翻译字符数)
|
||||||
|
|
||||||
|
## 3. 定价体系
|
||||||
|
|
||||||
|
### 3.1 次数包(一次性,永不过期)
|
||||||
|
|
||||||
|
| 次数 | 价格(¥) | 次均 | 价格($) |
|
||||||
|
|------|---------|------|---------|
|
||||||
|
| 20 | ¥19 | ¥0.95 | $3 |
|
||||||
|
| 100 | ¥79 | ¥0.79 | $12 |
|
||||||
|
| 500 | ¥299 | ¥0.60 | $45 |
|
||||||
|
| 2000 | ¥899 | ¥0.45 | $138 |
|
||||||
|
|
||||||
|
### 3.2 订阅套餐(每月重置)
|
||||||
|
|
||||||
|
| 套餐 | 月次数 | 月费(¥) | 月费($) | 次均 |
|
||||||
|
|------|-------|---------|---------|------|
|
||||||
|
| Starter | 100 | ¥69 | $10 | ¥0.69 |
|
||||||
|
| Pro | 500 | ¥269 | $40 | ¥0.54 |
|
||||||
|
| Enterprise | 2000 | ¥699 | $105 | ¥0.35 |
|
||||||
|
|
||||||
|
### 3.3 新旧过渡方案
|
||||||
|
|
||||||
|
- 现有有效订阅用户 → 按剩余天数折算为 Credits 存入余额
|
||||||
|
- Pro 剩余 15 天 ≈ 100 × 0.5 ≈ 50 Credits 一次性赠送
|
||||||
|
- 同时保留原订阅到期日
|
||||||
|
- 新用户 → 走新信用系统
|
||||||
|
- 过渡期两种支付方式并存
|
||||||
|
|
||||||
|
## 4. 数据库设计
|
||||||
|
|
||||||
|
### 4.1 新增表
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 次数包定义(一次性购买)
|
||||||
|
CREATE TABLE credit_packages (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL, -- 中文名
|
||||||
|
name_en VARCHAR(100) NOT NULL, -- 英文名
|
||||||
|
credits INTEGER NOT NULL, -- 额定量
|
||||||
|
price NUMERIC(10,2) NOT NULL, -- 人民币价格
|
||||||
|
price_usd NUMERIC(10,2), -- 美元价格
|
||||||
|
original_price NUMERIC(10,2), -- 原价(划线价)
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 订阅套餐定义(周期性)
|
||||||
|
CREATE TABLE subscription_plans (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
name_en VARCHAR(100) NOT NULL,
|
||||||
|
credits_per_month INTEGER NOT NULL,
|
||||||
|
price NUMERIC(10,2) NOT NULL,
|
||||||
|
price_usd NUMERIC(10,2),
|
||||||
|
duration_days INTEGER NOT NULL DEFAULT 30,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
sort_order INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 用户信用余额
|
||||||
|
CREATE TABLE user_credits (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL UNIQUE REFERENCES users(id),
|
||||||
|
balance NUMERIC(12,1) NOT NULL DEFAULT 0, -- 当前余额(支持小数)
|
||||||
|
total_purchased NUMERIC(12,1) NOT NULL DEFAULT 0,
|
||||||
|
total_used NUMERIC(12,1) NOT NULL DEFAULT 0,
|
||||||
|
-- 订阅关联
|
||||||
|
subscription_plan_id UUID REFERENCES subscription_plans(id),
|
||||||
|
subscription_expires_at TIMESTAMP,
|
||||||
|
subscription_auto_renew BOOLEAN DEFAULT FALSE,
|
||||||
|
-- 免费赠送标记
|
||||||
|
free_trial_used BOOLEAN DEFAULT FALSE,
|
||||||
|
daily_translate_chars INTEGER DEFAULT 0,
|
||||||
|
daily_translate_date DATE,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 信用消费日志
|
||||||
|
CREATE TABLE credit_consumptions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
result_type VARCHAR(50) NOT NULL, -- lead_search / translate / outreach / ...
|
||||||
|
reference_id UUID, -- 关联业务记录
|
||||||
|
credits_change NUMERIC(10,1) NOT NULL, -- 负=消费,正=充值
|
||||||
|
balance_after NUMERIC(12,1) NOT NULL,
|
||||||
|
source VARCHAR(30) NOT NULL, -- package / subscription / admin_grant / free_trial / daily_free
|
||||||
|
description VARCHAR(500),
|
||||||
|
metadata JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 次数包购买记录
|
||||||
|
CREATE TABLE credit_purchases (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
package_id UUID REFERENCES credit_packages(id),
|
||||||
|
subscription_plan_id UUID REFERENCES subscription_plans(id),
|
||||||
|
credits INTEGER NOT NULL,
|
||||||
|
amount NUMERIC(10,2) NOT NULL,
|
||||||
|
currency VARCHAR(3) DEFAULT 'CNY',
|
||||||
|
payment_method VARCHAR(20), -- alipay / wechat / stripe
|
||||||
|
status VARCHAR(20) DEFAULT 'pending', -- pending / paid / refunded
|
||||||
|
payment_transaction_id UUID REFERENCES payment_transactions(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
paid_at TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 索引
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_credit_consumptions_user ON credit_consumptions(user_id, created_at DESC);
|
||||||
|
CREATE INDEX idx_credit_consumptions_type ON credit_consumptions(result_type);
|
||||||
|
CREATE INDEX idx_credit_purchases_user ON credit_purchases(user_id);
|
||||||
|
CREATE INDEX idx_credit_purchases_status ON credit_purchases(status);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 配置项
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"free_trial_credits": {"value": 30, "desc": "新用户免费赠送次数"},
|
||||||
|
"daily_free_translate_chars": {"value": 1000, "desc": "每日免费翻译字符数"},
|
||||||
|
"credit_consumption_rates": {
|
||||||
|
"value": {
|
||||||
|
"lead_search": 10,
|
||||||
|
"company_analysis": 5,
|
||||||
|
"market_intel": 20,
|
||||||
|
"translate_per_1000chars": 1,
|
||||||
|
"reply_suggest": 2,
|
||||||
|
"outreach": 3,
|
||||||
|
"marketing_content": 5,
|
||||||
|
"competitor_analysis": 10,
|
||||||
|
"ai_chat_per_10msg": 1,
|
||||||
|
"info_extract": 1,
|
||||||
|
"quotation": 2,
|
||||||
|
"followup_scan": 2
|
||||||
|
},
|
||||||
|
"desc": "各功能信用消耗速率"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. API 设计
|
||||||
|
|
||||||
|
### 5.1 信用系统
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/credits/balance → 余额+订阅信息
|
||||||
|
GET /api/v1/credits/history?page=&size= → 消费历史
|
||||||
|
POST /api/v1/credits/packages → 购买次数包(走支付网关)
|
||||||
|
POST /api/v1/credits/subscribe → 开通订阅
|
||||||
|
POST /api/v1/credits/cancel-subscription → 取消订阅
|
||||||
|
GET /api/v1/credits/packages → 次数包列表
|
||||||
|
GET /api/v1/credits/subscription-plans → 订阅套餐列表
|
||||||
|
GET /api/v1/credits/rates → 各功能消耗速率
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 现有功能增加额度扣减
|
||||||
|
|
||||||
|
所有 AI 功能在返回结果后调用 `CreditService.deduct()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 在每个 AI 功能服务层中(discovery/discovery.py, translate/translation.py, etc.)
|
||||||
|
from app.services.credit import CreditService
|
||||||
|
|
||||||
|
svc = CreditService(db)
|
||||||
|
success, balance = await svc.deduct(user_id, "lead_search", reference_id=record_id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=402, detail="次数不足")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 管理端
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/credit-packages → CRUD
|
||||||
|
POST /api/v1/admin/credit-packages
|
||||||
|
PUT /api/v1/admin/credit-packages/{id}
|
||||||
|
DELETE /api/v1/admin/credit-packages/{id}
|
||||||
|
GET /api/v1/admin/subscription-plans → CRUD
|
||||||
|
POST /api/v1/admin/subscription-plans
|
||||||
|
PUT /api/v1/admin/subscription-plans/{id}
|
||||||
|
GET /api/v1/admin/user-credits → 用户余额列表
|
||||||
|
POST /api/v1/admin/user-credits/adjust → 手动调整余额
|
||||||
|
GET /api/v1/admin/credit-consumptions → 消费流水
|
||||||
|
GET /api/v1/admin/credit-stats → 统计数据
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 前端改造
|
||||||
|
|
||||||
|
### 6.1 导航栏重构
|
||||||
|
|
||||||
|
```
|
||||||
|
新用户侧边栏(按优先级):
|
||||||
|
|
||||||
|
【核心】
|
||||||
|
★ 发现客户 /discovery ← 首页
|
||||||
|
市场情报 /market-intel ← 新增
|
||||||
|
我的线索 /my-leads ← 新增(收藏/保存的线索)
|
||||||
|
|
||||||
|
【工具(增值)】
|
||||||
|
客户管理 /customers
|
||||||
|
产品库 /products
|
||||||
|
报价单 /quotations
|
||||||
|
智能翻译 /translate
|
||||||
|
开发信 /outreach
|
||||||
|
营销素材 /marketing
|
||||||
|
|
||||||
|
【查看】
|
||||||
|
工作台 /workspace ← 仪表盘(用量统计/最近操作)
|
||||||
|
团队协作 /team
|
||||||
|
个人中心 /profile
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 Topbar 改造
|
||||||
|
|
||||||
|
顶部始终显示 `余额: X.X 次` 按钮,点击跳转购买页。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<el-button class="credit-balance" @click="$router.push('/credits')">
|
||||||
|
<el-icon><Coin /></el-icon>
|
||||||
|
{{ balance }} 次
|
||||||
|
</el-button>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 购买页面
|
||||||
|
|
||||||
|
替代现有 `/upgrade` 路由为 `/credits`:
|
||||||
|
|
||||||
|
- Tab1: **次数包** — 多卡片展示(20/100/500/2000次),支付弹窗
|
||||||
|
- Tab2: **订阅套餐** — Starter/Pro/Enterprise,月付/年付
|
||||||
|
- Tab3: **消费明细** — 近期消费流水
|
||||||
|
|
||||||
|
### 6.4 余额不足引导
|
||||||
|
|
||||||
|
所有 AI 功能检测到余额不足时,前端弹窗:
|
||||||
|
```
|
||||||
|
"次数不足,当前剩余 X 次,本次操作需要 Y 次
|
||||||
|
[去购买] [取消]"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.5 免费额度提示
|
||||||
|
|
||||||
|
新用户注册后首页显示:
|
||||||
|
```
|
||||||
|
"欢迎使用 TradeMate!您有 30 次免费搜索额度,用完即止。"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. 国际化
|
||||||
|
|
||||||
|
### 7.1 第一阶段(核心页面)
|
||||||
|
|
||||||
|
| 页面 | 优先级 |
|
||||||
|
|------|--------|
|
||||||
|
| 登录/注册 | P0 |
|
||||||
|
| 导航栏 | P0 |
|
||||||
|
| 发现客户 | P0 |
|
||||||
|
| 购买页面 | P0 |
|
||||||
|
| 工作台 | P1 |
|
||||||
|
| 翻译 | P1 |
|
||||||
|
| 个人中心 | P1 |
|
||||||
|
|
||||||
|
### 7.2 技术方案
|
||||||
|
|
||||||
|
- 使用 vue-i18n + JSON locale files
|
||||||
|
- `zh-CN.json`(默认)+ `en.json`
|
||||||
|
- 通过 URL query `?lang=en` 或 Cookie 切换
|
||||||
|
- 管理后台跟随用户语言偏好
|
||||||
|
|
||||||
|
```json
|
||||||
|
// en.json
|
||||||
|
{
|
||||||
|
"nav": {
|
||||||
|
"discovery": "Find Buyers",
|
||||||
|
"translate": "Translate",
|
||||||
|
"customers": "Customers",
|
||||||
|
"credits": "Buy Credits"
|
||||||
|
},
|
||||||
|
"discovery": {
|
||||||
|
"search_placeholder": "e.g. LED lighting, solar panels...",
|
||||||
|
"search_button": "Search Buyers",
|
||||||
|
"credits_cost": "This search costs 10 credits"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. 实施计划
|
||||||
|
|
||||||
|
### Phase 1 — 信用系统后端(当前)
|
||||||
|
1. 数据库模型 + migration
|
||||||
|
2. CreditService
|
||||||
|
3. 信用系统 API
|
||||||
|
4. 管理端 CRUD
|
||||||
|
|
||||||
|
### Phase 2 — 功能接入扣次
|
||||||
|
1. Discovery 接入(搜索/分析/市场报告)
|
||||||
|
2. Translate 接入
|
||||||
|
3. Marketing 接入
|
||||||
|
4. AI Chat / Outreach 接入
|
||||||
|
|
||||||
|
### Phase 3 — 前端改造
|
||||||
|
1. 新导航 + 余额显示
|
||||||
|
2. 购买/订阅页面
|
||||||
|
3. 余额不足引导
|
||||||
|
4. 工作台改仪表盘
|
||||||
|
|
||||||
|
### Phase 4 — 国际化 + 定价上线
|
||||||
|
1. 中英文切换
|
||||||
|
2. 海外支付(Stripe)
|
||||||
|
3. 定价正式发布
|
||||||
|
4. 老用户数据迁移
|
||||||
|
|
||||||
|
## 9. 关键风险
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|------|------|
|
||||||
|
| 用户觉得按次付费贵 | 免费 30 次让用户先体验价值;套餐价需低于用户感知价值 |
|
||||||
|
| 搜索结果质量不稳 | 多搜索源回退(DB → Bing → Google CSE → AI),持续优化 |
|
||||||
|
| 老用户不满过渡方案 | 老用户按剩余天数高折抵换算,并保留原有套餐期限内权益 |
|
||||||
|
| 翻译用量大成本高 | 每日免费额兜底 + Credits 定价覆盖 API 成本 |
|
||||||
Reference in New Issue
Block a user