bed5c7abef
- 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
260 lines
9.2 KiB
Python
260 lines
9.2 KiB
Python
import logging
|
|
import hashlib
|
|
from typing import Optional, Dict, Any
|
|
from datetime import datetime, timedelta
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from app.models.subscription import Subscription
|
|
from app.models.user import User
|
|
from app.config import settings
|
|
from app.services.wechat_pay import WeChatPayService
|
|
|
|
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 企业版会员(年付)",
|
|
}
|
|
|
|
|
|
class PaymentService:
|
|
def __init__(self, db: AsyncSession):
|
|
self.db = db
|
|
self._wxpay = None
|
|
|
|
@property
|
|
def wxpay(self) -> Optional[WeChatPayService]:
|
|
if self._wxpay is None and settings.WECHAT_PAY_MCH_ID:
|
|
self._wxpay = WeChatPayService()
|
|
return self._wxpay
|
|
|
|
async def get_plans(self) -> Dict[str, Any]:
|
|
return {
|
|
"plans": [
|
|
{
|
|
"id": "free",
|
|
"name": "免费版",
|
|
"price": 0,
|
|
"period": "month",
|
|
"features": [
|
|
"1 个产品",
|
|
"20 次翻译/天",
|
|
"5 个客户",
|
|
"基础回复建议",
|
|
],
|
|
},
|
|
{
|
|
"id": "pro",
|
|
"name": "Pro 版",
|
|
"price": 99,
|
|
"period": "month",
|
|
"features": [
|
|
"10 个产品",
|
|
"无限翻译",
|
|
"50 个客户",
|
|
"跟进提醒",
|
|
"报价单生成",
|
|
],
|
|
},
|
|
{
|
|
"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]:
|
|
result = await self.db.execute(
|
|
select(Subscription).where(
|
|
Subscription.user_id == user_id,
|
|
Subscription.status == "active",
|
|
).order_by(Subscription.created_at.desc()).limit(1)
|
|
)
|
|
sub = result.scalar_one_or_none()
|
|
|
|
result = await self.db.execute(
|
|
select(User).where(User.id == user_id)
|
|
)
|
|
user = result.scalar_one_or_none()
|
|
|
|
return {
|
|
"plan": user.tier if user else "free",
|
|
"status": sub.status if sub else "active",
|
|
"expires_at": sub.expires_at.isoformat() if sub and sub.expires_at else None,
|
|
"auto_renew": sub.auto_renew if sub else False,
|
|
}
|
|
|
|
async def create_order(self, user_id: str, plan: str,
|
|
pay_type: str = "jsapi") -> Dict[str, Any]:
|
|
if plan not in PLANS:
|
|
raise ValueError(f"Invalid plan: {plan}")
|
|
|
|
plan_info = PLANS[plan]
|
|
if plan_info["price"] == 0:
|
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if user:
|
|
user.tier = plan
|
|
await self.db.flush()
|
|
return {"status": "ok", "plan": plan, "amount": 0}
|
|
|
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
|
user = result.scalar_one_or_none()
|
|
if not user:
|
|
raise ValueError("User not found")
|
|
|
|
order_id = f"ORD{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{user_id[-6:]}"
|
|
description = PLAN_DESCRIPTIONS.get(plan, f"TradeMate {plan}")
|
|
|
|
sub = Subscription(
|
|
user_id=user_id,
|
|
plan=plan,
|
|
status="pending",
|
|
amount=plan_info["price"],
|
|
payment_id=order_id,
|
|
)
|
|
self.db.add(sub)
|
|
await self.db.flush()
|
|
|
|
wxpay_available = self.wxpay is not None and settings.WECHAT_PAY_NOTIFY_URL not in (
|
|
"", "https://example.com/api/v1/payment/notify"
|
|
)
|
|
|
|
if wxpay_available:
|
|
try:
|
|
if pay_type == "jsapi":
|
|
openid = user.wechat_openid
|
|
if not openid:
|
|
raise ValueError("用户未绑定微信,请在微信小程序中登录后支付")
|
|
|
|
wx_result = await self.wxpay.create_jsapi_order(
|
|
order_id, openid, int(plan_info["price"] * 100), description
|
|
)
|
|
prepay_id = wx_result.get("prepay_id", "")
|
|
pay_params = self.wxpay.build_jsapi_pay_params(prepay_id)
|
|
return {
|
|
"status": "pending",
|
|
"order_id": order_id,
|
|
"plan": plan,
|
|
"amount": plan_info["price"],
|
|
"currency": "CNY",
|
|
"pay_type": "jsapi",
|
|
"pay_params": pay_params,
|
|
}
|
|
|
|
elif pay_type == "native":
|
|
wx_result = await self.wxpay.create_native_order(
|
|
order_id, int(plan_info["price"] * 100), description
|
|
)
|
|
code_url = wx_result.get("code_url", "")
|
|
return {
|
|
"status": "pending",
|
|
"order_id": order_id,
|
|
"plan": plan,
|
|
"amount": plan_info["price"],
|
|
"currency": "CNY",
|
|
"pay_type": "native",
|
|
"code_url": code_url,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"WeChat Pay order failed: {e}")
|
|
raise ValueError(f"支付创建失败: {str(e)}")
|
|
|
|
# 开发环境回退:生成模拟支付参数
|
|
pay_params = {
|
|
"appId": settings.WECHAT_APP_ID or "",
|
|
"timeStamp": str(int(datetime.utcnow().timestamp())),
|
|
"nonceStr": hashlib.md5(order_id.encode()).hexdigest()[:16],
|
|
"package": f"prepay_id={order_id}",
|
|
"signType": "MD5",
|
|
}
|
|
sign_str = "&".join(f"{k}={v}" for k, v in sorted(pay_params.items()))
|
|
sign_str += f"&key={settings.SECRET_KEY}"
|
|
pay_params["paySign"] = hashlib.md5(sign_str.encode()).hexdigest().upper()
|
|
return {
|
|
"status": "pending",
|
|
"order_id": order_id,
|
|
"plan": plan,
|
|
"amount": plan_info["price"],
|
|
"currency": "CNY",
|
|
"pay_type": pay_type,
|
|
"pay_params": pay_params,
|
|
}
|
|
|
|
async def handle_payment_callback(self, payment_id: str, success: bool) -> bool:
|
|
result = await self.db.execute(
|
|
select(Subscription).where(Subscription.payment_id == payment_id)
|
|
)
|
|
sub = result.scalar_one_or_none()
|
|
if not sub:
|
|
return False
|
|
|
|
if success:
|
|
sub.status = "active"
|
|
sub.started_at = datetime.utcnow()
|
|
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
|
|
|
|
user_result = await self.db.execute(select(User).where(User.id == sub.user_id))
|
|
user = user_result.scalar_one_or_none()
|
|
if user:
|
|
user.tier = sub.plan
|
|
else:
|
|
sub.status = "failed"
|
|
|
|
await self.db.flush()
|
|
return True
|
|
|
|
|
|
payment_service = PaymentService
|