Add landing page, referral system, usage quotas, search API management, and yearly pricing
- Separate workspace landing from login for better UX - Referral system rewards both parties with Pro days - Quota enforcement prevents abuse without breaking endpoints - 7-day free trial with auto-downgrade on expiry - Admin-managed search provider config (SearXNG, Bing) - 15% discount on annual subscriptions - MCP search server wrapping opencode search - Fix discovery module field name mismatch causing 422
This commit is contained in:
@@ -218,21 +218,32 @@ URL: {company_url}
|
||||
async def _ai_strategy(self, product: str, market: str) -> Dict[str, Any]:
|
||||
if not self._ai_available:
|
||||
return self._template_strategy(product, market)
|
||||
system = """你是外贸客户发现专家。根据用户的产品和目标市场,分析出潜在买家画像和获取策略。
|
||||
system = """你是外贸客户发现专家。根据用户的产品和目标市场,列出15家有可能采购该产品的潜在公司。
|
||||
|
||||
请以 JSON 格式返回(不要用 markdown 代码块标记):
|
||||
{
|
||||
"buyer_personas": [{"type": "", "description": "", "channels": [], "search_queries": []}],
|
||||
"strategy": "",
|
||||
"tips": []
|
||||
}"""
|
||||
prompt = f"产品:{product}\n目标市场:{market}\n请分析潜在买家画像和获取策略。"
|
||||
"companies": [
|
||||
{"name": "公司名称", "description": "公司业务简介", "country": "所在国家", "match_score": 匹配度0-100, "contact": "联系方式(有就写,没有写'需进一步查找')", "source": "推荐来源说明"}
|
||||
],
|
||||
"strategy": "整体获取策略建议",
|
||||
"tips": ["搜索建议1", "搜索建议2"]
|
||||
}
|
||||
|
||||
要求:
|
||||
- 公司名称要真实感,不要编造知名大公司
|
||||
- 公司业务要与产品相关
|
||||
- 匹配度要有区分度,60-95之间
|
||||
- 至少返回10家
|
||||
- 只返回 JSON,不要其他内容"""
|
||||
|
||||
prompt = f"产品:{product}\n目标市场:{market}\n请列出在该市场可能采购该产品的公司。"
|
||||
try:
|
||||
result = await self.ai.chat(prompt, system_prompt=system)
|
||||
content = result.get("reply", "")
|
||||
parsed = self._extract_json(content)
|
||||
if parsed:
|
||||
if parsed and "companies" in parsed:
|
||||
parsed["provider"] = result.get("provider_used", "unknown")
|
||||
parsed["ai_generated"] = True
|
||||
return parsed
|
||||
return self._template_strategy(product, market)
|
||||
except Exception as e:
|
||||
@@ -241,13 +252,14 @@ URL: {company_url}
|
||||
|
||||
def _template_strategy(self, product: str, market: str) -> Dict[str, Any]:
|
||||
return {
|
||||
"buyer_personas": [
|
||||
{"type": "进口商/批发商", "description": f"从中国进口{product}并在{market}批发的贸易商", "channels": ["LinkedIn", "Google"], "search_queries": [f"{product} importer {market}"]},
|
||||
{"type": "品牌商/OEM买家", "description": f"在{market}销售自有品牌{product}的公司", "channels": ["LinkedIn", "行业展会"], "search_queries": [f"{product} manufacturer {market}"]},
|
||||
"companies": [
|
||||
{"name": f"{product} Importers in {market} (示例)", "description": f"在{market}从事{product}进口和批发的贸易商,建议在LinkedIn上搜索相关关键词", "country": market, "match_score": 75, "contact": "需进一步查找", "source": "AI推荐"},
|
||||
{"name": f"{product} Distributors in {market} (示例)", "description": f"在{market}分销{product}的渠道商,建议通过Google搜索关键词", "country": market, "match_score": 70, "contact": "需进一步查找", "source": "AI推荐"},
|
||||
],
|
||||
"strategy": f"建议在 LinkedIn 和 Google 搜索 {market} 的 {product} 相关公司",
|
||||
"tips": ["使用多个搜索词", "找到公司后在 LinkedIn 找决策人"],
|
||||
"strategy": f"建议在 LinkedIn 和 Google 搜索 {market} 的 {product} 相关公司,使用导入商、批发商、经销商等关键词组合",
|
||||
"tips": ["使用多个搜索词组合", "找到公司后在 LinkedIn 找决策人", "查看公司网站了解其业务范围"],
|
||||
"provider": "template",
|
||||
"ai_generated": True,
|
||||
}
|
||||
|
||||
def _template_analysis(self, url: str) -> Dict[str, Any]:
|
||||
|
||||
@@ -14,12 +14,16 @@ logger = logging.getLogger(__name__)
|
||||
PLANS = {
|
||||
"free": {"price": 0, "duration_days": None},
|
||||
"pro": {"price": 99, "duration_days": 30},
|
||||
"pro_yearly": {"price": 999, "duration_days": 365},
|
||||
"enterprise": {"price": 399, "duration_days": 30},
|
||||
"enterprise_yearly": {"price": 3999, "duration_days": 365},
|
||||
}
|
||||
|
||||
PLAN_DESCRIPTIONS = {
|
||||
"pro": "TradeMate Pro 版会员",
|
||||
"pro_yearly": "TradeMate Pro 版会员(年付)",
|
||||
"enterprise": "TradeMate 企业版会员",
|
||||
"enterprise_yearly": "TradeMate 企业版会员(年付)",
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +45,7 @@ class PaymentService:
|
||||
"id": "free",
|
||||
"name": "免费版",
|
||||
"price": 0,
|
||||
"period": "month",
|
||||
"features": [
|
||||
"1 个产品",
|
||||
"20 次翻译/天",
|
||||
@@ -52,6 +57,7 @@ class PaymentService:
|
||||
"id": "pro",
|
||||
"name": "Pro 版",
|
||||
"price": 99,
|
||||
"period": "month",
|
||||
"features": [
|
||||
"10 个产品",
|
||||
"无限翻译",
|
||||
@@ -60,19 +66,52 @@ class PaymentService:
|
||||
"报价单生成",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pro_yearly",
|
||||
"name": "Pro 版(年付)",
|
||||
"price": 999,
|
||||
"period": "year",
|
||||
"original_price": 1188,
|
||||
"features": [
|
||||
"10 个产品",
|
||||
"无限翻译",
|
||||
"50 个客户",
|
||||
"跟进提醒",
|
||||
"报价单生成",
|
||||
"省 ¥189",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "enterprise",
|
||||
"name": "企业版",
|
||||
"price": 399,
|
||||
"period": "month",
|
||||
"features": [
|
||||
"无限产品",
|
||||
"多人协作",
|
||||
"无限产品/客户",
|
||||
"团队协作",
|
||||
"品牌报价单",
|
||||
"专属语料训练",
|
||||
"API 接入",
|
||||
"优先支持",
|
||||
],
|
||||
},
|
||||
]
|
||||
{
|
||||
"id": "enterprise_yearly",
|
||||
"name": "企业版(年付)",
|
||||
"price": 3999,
|
||||
"period": "year",
|
||||
"original_price": 4788,
|
||||
"features": [
|
||||
"无限产品/客户",
|
||||
"团队协作",
|
||||
"品牌报价单",
|
||||
"专属语料训练",
|
||||
"API 接入",
|
||||
"优先支持",
|
||||
"省 ¥789",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
async def get_current_subscription(self, user_id: str) -> Dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.search_provider import SearchProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
IGNORE_DOMAINS = [
|
||||
"google.com", "facebook.com", "twitter.com", "instagram.com",
|
||||
"youtube.com", "reddit.com", "amazon.com", "ebay.com",
|
||||
"wikipedia.org", "linkedin.com", "pinterest.com", "baidu.com",
|
||||
"bing.com",
|
||||
]
|
||||
|
||||
|
||||
class SearchService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def search(self, query: str, limit: int = 10) -> List[Dict[str, str]]:
|
||||
providers = await self._get_enabled_providers()
|
||||
for provider in providers:
|
||||
try:
|
||||
return await self._search_provider(provider, query, limit)
|
||||
except Exception as e:
|
||||
logger.warning(f"Search provider {provider.provider_type} failed: {e}")
|
||||
return []
|
||||
|
||||
async def _get_enabled_providers(self) -> List[SearchProvider]:
|
||||
result = await self.db.execute(
|
||||
select(SearchProvider)
|
||||
.where(SearchProvider.enabled == True)
|
||||
.order_by(SearchProvider.priority)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def _search_provider(self, provider: SearchProvider, query: str, limit: int) -> List[Dict[str, str]]:
|
||||
pt = provider.provider_type
|
||||
if pt == "searxng":
|
||||
return await searxng_search(provider.api_endpoint, query, limit)
|
||||
elif pt == "bing":
|
||||
return await bing_search(provider.api_key, query, limit)
|
||||
else:
|
||||
raise ValueError(f"Unknown provider type: {pt}")
|
||||
|
||||
|
||||
async def searxng_search(endpoint: Optional[str], query: str, limit: int) -> List[Dict[str, str]]:
|
||||
if not endpoint:
|
||||
raise ValueError("SearXNG endpoint not configured")
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
endpoint.rstrip("/") + "/search",
|
||||
params={"q": query, "format": "json", "language": "zh-CN,en", "categories": "general"},
|
||||
headers={"User-Agent": "TradeMate/1.0"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"SearXNG returned {resp.status_code}")
|
||||
data = resp.json()
|
||||
results = []
|
||||
for item in (data.get("results", []) if isinstance(data, dict) else data):
|
||||
url = item.get("url", "")
|
||||
if any(d in url for d in IGNORE_DOMAINS):
|
||||
continue
|
||||
results.append({
|
||||
"title": (item.get("title") or url)[:100],
|
||||
"url": url.rstrip("/"),
|
||||
"snippet": (item.get("content") or item.get("snippet") or "")[:200],
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
|
||||
async def bing_search(api_key: Optional[str], query: str, limit: int) -> List[Dict[str, str]]:
|
||||
if not api_key:
|
||||
raise ValueError("Bing API key not configured")
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
resp = await client.get(
|
||||
"https://api.bing.microsoft.com/v7.0/search",
|
||||
params={"q": query, "count": min(limit, 50), "mkt": "en-US", "textFormat": "Raw"},
|
||||
headers={"Ocp-Apim-Subscription-Key": api_key},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise ValueError(f"Bing returned {resp.status_code}")
|
||||
data = resp.json()
|
||||
results = []
|
||||
for item in data.get("webPages", {}).get("value", []):
|
||||
url = item.get("url", "")
|
||||
if any(d in url for d in IGNORE_DOMAINS):
|
||||
continue
|
||||
results.append({
|
||||
"title": (item.get("name") or url)[:100],
|
||||
"url": url.rstrip("/"),
|
||||
"snippet": (item.get("snippet") or "")[:200],
|
||||
})
|
||||
if len(results) >= limit:
|
||||
break
|
||||
return results
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from fastapi import HTTPException, Depends
|
||||
from datetime import datetime, date
|
||||
import logging
|
||||
|
||||
from app.models import UsageLog, SystemConfig, User, Customer, Product
|
||||
from app.models.user import User
|
||||
from app.models.subscription import Subscription
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
from app.database import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TIER_LIMITS_DEFAULT = {
|
||||
"free": {"translate_chars": 5000, "replies": 20, "marketing": 5, "customers": 5, "products": 1, "quotations": 3},
|
||||
"pro": {"translate_chars": 50000, "replies": 200, "marketing": 50, "customers": 100, "products": 20, "quotations": 30},
|
||||
"enterprise": {"translate_chars": 999999999, "replies": 9999, "marketing": 9999, "customers": 99999, "products": 9999, "quotations": 9999},
|
||||
}
|
||||
|
||||
ACTION_MAP = {
|
||||
"translate": "translate_chars",
|
||||
"reply": "replies",
|
||||
"marketing_generate": "marketing",
|
||||
"create_customer": "customers",
|
||||
"create_product": "products",
|
||||
"create_quotation": "quotations",
|
||||
}
|
||||
|
||||
|
||||
class UsageService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_limits(self, tier: str) -> dict:
|
||||
config_key = f"{tier}_daily_limits"
|
||||
result = await self.db.execute(select(SystemConfig).where(SystemConfig.key == config_key))
|
||||
row = result.scalar_one_or_none()
|
||||
if row and row.value:
|
||||
return {**TIER_LIMITS_DEFAULT.get(tier, {}), **row.value}
|
||||
return dict(TIER_LIMITS_DEFAULT.get(tier, {}))
|
||||
|
||||
async def get_tier(self, user_id: str) -> str:
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return "free"
|
||||
return user.tier or "free"
|
||||
|
||||
async def get_daily_usage(self, user_id: str, action: str) -> int:
|
||||
today = date.today()
|
||||
stmt = select(func.count()).where(
|
||||
UsageLog.user_id == user_id,
|
||||
UsageLog.action == action,
|
||||
func.cast(UsageLog.created_at, date) == today,
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_daily_chars(self, user_id: str) -> int:
|
||||
today = date.today()
|
||||
stmt = select(func.coalesce(func.sum(
|
||||
(UsageLog.detail["chars"]).as_integer()
|
||||
), 0)).where(
|
||||
UsageLog.user_id == user_id,
|
||||
UsageLog.action == "translate",
|
||||
func.cast(UsageLog.created_at, date) == today,
|
||||
)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def get_total_count(self, user_id: str, model_class) -> int:
|
||||
stmt = select(func.count()).where(model_class.user_id == user_id)
|
||||
result = await self.db.execute(stmt)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def check_quota(self, user_id: str, action: str, chars: int = 0) -> tuple[bool, str]:
|
||||
tier = await self.get_tier(user_id)
|
||||
limits = await self.get_limits(tier)
|
||||
limit_key = ACTION_MAP.get(action)
|
||||
if not limit_key:
|
||||
return True, ""
|
||||
|
||||
limit = limits.get(limit_key, 999999)
|
||||
|
||||
if action == "translate":
|
||||
used = await self.get_daily_chars(user_id)
|
||||
if used + chars > limit:
|
||||
remaining = max(0, limit - used)
|
||||
return False, f"今日翻译字符已达上限({limit}字符),剩余{remaining}字符。升级 Pro 获取更多额度。"
|
||||
elif action in ("create_customer",):
|
||||
used = await self.get_total_count(user_id, Customer)
|
||||
if used >= limit:
|
||||
return False, f"客户数量已达上限({limit}个)。升级 Pro 获取更多客户管理额度。"
|
||||
elif action in ("create_product",):
|
||||
used = await self.get_total_count(user_id, Product)
|
||||
if used >= limit:
|
||||
return False, f"产品数量已达上限({limit}个)。升级 Pro 获取更多产品额度。"
|
||||
else:
|
||||
used = await self.get_daily_usage(user_id, action)
|
||||
if used >= limit:
|
||||
return False, f"今日{action}次数已达上限({limit}次)。升级 Pro 获取更多额度。"
|
||||
|
||||
return True, ""
|
||||
|
||||
async def record_usage(self, user_id: str, action: str, chars: int = 0, detail: dict = None):
|
||||
log = UsageLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
detail=detail or {},
|
||||
)
|
||||
if chars:
|
||||
log.detail["chars"] = chars
|
||||
self.db.add(log)
|
||||
await self.db.commit()
|
||||
|
||||
async def get_usage_stats(self, user_id: str) -> dict:
|
||||
tier = await self.get_tier(user_id)
|
||||
limits = await self.get_limits(tier)
|
||||
|
||||
trial_days_left = 0
|
||||
if tier == "pro":
|
||||
result = await self.db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.plan == "pro_trial",
|
||||
Subscription.status == "active",
|
||||
)
|
||||
)
|
||||
trial_sub = result.scalar_one_or_none()
|
||||
if trial_sub and trial_sub.expires_at:
|
||||
remaining = (trial_sub.expires_at - datetime.utcnow()).days
|
||||
trial_days_left = max(0, remaining)
|
||||
|
||||
customer_count = await self.get_total_count(user_id, Customer)
|
||||
product_count = await self.get_total_count(user_id, Product)
|
||||
translate_chars = await self.get_daily_chars(user_id)
|
||||
reply_count = await self.get_daily_usage(user_id, "reply")
|
||||
marketing_count = await self.get_daily_usage(user_id, "marketing_generate")
|
||||
quotation_count = await self.get_daily_usage(user_id, "create_quotation")
|
||||
|
||||
return {
|
||||
"tier": tier,
|
||||
"limits": limits,
|
||||
"usage": {
|
||||
"translate_chars": translate_chars,
|
||||
"replies": reply_count,
|
||||
"marketing": marketing_count,
|
||||
"customers": customer_count,
|
||||
"products": product_count,
|
||||
"quotations": quotation_count,
|
||||
},
|
||||
"trial_days_left": trial_days_left,
|
||||
}
|
||||
|
||||
|
||||
def require_quota(action: str, chars_field: str = None):
|
||||
async def _check(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = UsageService(db)
|
||||
if action == "translate" and chars_field:
|
||||
raise HTTPException(status_code=400, detail="translate action needs explicit chars check")
|
||||
ok, msg = await svc.check_quota(user_id, action)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=429, detail=msg)
|
||||
return user_id
|
||||
return _check
|
||||
Reference in New Issue
Block a user