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:
TradeMate Dev
2026-05-26 11:40:13 +08:00
parent 52dba37f22
commit bed5c7abef
39 changed files with 1988 additions and 152 deletions
+24 -12
View File
@@ -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]:
+42 -3
View File
@@ -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]:
+102
View File
@@ -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
+169
View File
@@ -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