Initial commit: TradeMate 外贸小助手 MVP

项目结构:
- backend/     Python FastAPI 后端
- uni-app/     uni-app跨端前端
- docs/        设计文档
- docker-compose.yml  Docker编排
- nginx/scripts/systemd 运维配置

已完成功能:
- 用户认证 (JWT)
- 智能翻译 + 回复建议
- 营销素材生成
- 客户管理 + 沉默检测
- 报价单管理
- 产品库管理
- 汇率换算
- 推送通知 (uni-push)
- WhatsApp Webhook框架
- Celery定时任务
This commit is contained in:
TradeMate Dev
2026-05-08 18:17:12 +08:00
commit c6206787da
121 changed files with 11743 additions and 0 deletions
View File
+58
View File
@@ -0,0 +1,58 @@
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
class TradeMateException(Exception):
def __init__(self, code: int, message: str, detail: str = None):
self.code = code
self.message = message
self.detail = detail
class NotFoundError(TradeMateException):
def __init__(self, resource: str = "Resource"):
super().__init__(404, f"{resource} not found")
class UnauthorizedError(TradeMateException):
def __init__(self, detail: str = "Authentication required"):
super().__init__(401, "Unauthorized", detail)
class ForbiddenError(TradeMateException):
def __init__(self, detail: str = "Insufficient permissions"):
super().__init__(403, "Forbidden", detail)
class QuotaExceededError(TradeMateException):
def __init__(self, feature: str):
super().__init__(429, "Quota exceeded", f"Daily limit reached for {feature}. Upgrade to Pro for more.")
class TierRestrictionError(TradeMateException):
def __init__(self, feature: str, required_tier: str):
super().__init__(
402,
"Upgrade required",
f"{feature} requires {required_tier} plan",
)
def register_exception_handlers(app: FastAPI):
@app.exception_handler(TradeMateException)
async def handle_tradmate_exception(request: Request, exc: TradeMateException):
return JSONResponse(
status_code=exc.code,
content={
"error": exc.message,
"detail": exc.detail,
"code": exc.code,
},
)
@app.exception_handler(Exception)
async def handle_generic_exception(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"error": "Internal server error", "detail": str(exc) if app.debug else "An unexpected error occurred"},
)
+118
View File
@@ -0,0 +1,118 @@
from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings
from app.core.security import decode_token
import redis.asyncio as aioredis
import logging
from datetime import datetime
logger = logging.getLogger(__name__)
def get_user_tier_from_token(request: Request) -> str:
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return "anonymous"
payload = decode_token(auth[7:])
if not payload:
return "anonymous"
request.state.user_id = payload.get("sub")
request.state.user_tier = payload.get("tier", "free")
return request.state.user_tier
class TierMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if request.url.path.startswith("/api/v1"):
tier = get_user_tier_from_token(request)
tier_config = {
"free": {
"max_products": settings.FREE_MAX_PRODUCTS,
"max_customers": settings.FREE_MAX_CUSTOMERS,
},
"pro": {
"max_products": settings.PRO_MAX_PRODUCTS,
"max_customers": settings.PRO_MAX_CUSTOMERS,
},
"enterprise": {
"max_products": 9999,
"max_customers": 99999,
},
}
request.state.tier_config = tier_config.get(tier, tier_config["free"])
else:
request.state.user_id = None
request.state.user_tier = "anonymous"
request.state.tier_config = {}
response = await call_next(request)
return response
class QuotaMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not request.url.path.startswith("/api/v1"):
return await call_next(request)
if request.state.user_tier in ("anonymous",):
return await call_next(request)
user_id = request.state.user_id
tier = request.state.user_tier
if tier == "enterprise":
return await call_next(request)
path = request.url.path
method = request.method
if method == "GET":
return await call_next(request)
quota_map = {
"/api/v1/translate": {
"free": settings.FREE_DAILY_TRANSLATE_CHARS,
"pro": settings.PRO_DAILY_TRANSLATE_CHARS,
},
"/api/v1/translate/reply": {
"free": settings.FREE_DAILY_REPLIES,
"pro": settings.PRO_DAILY_REPLIES,
},
"/api/v1/marketing": {
"free": settings.FREE_DAILY_MARKETING,
"pro": settings.PRO_DAILY_MARKETING,
},
"/api/v1/quotations": {
"free": settings.FREE_DAILY_QUOTATIONS,
"pro": settings.PRO_DAILY_QUOTATIONS,
},
}
matched_key = None
for prefix, limits in quota_map.items():
if path.startswith(prefix):
matched_key = prefix
break
if not matched_key:
return await call_next(request)
limit = quota_map[matched_key].get(tier)
if limit is None:
return await call_next(request)
try:
r = aioredis.from_url(settings.REDIS_URL)
key = f"quota:{user_id}:{matched_key}:{datetime.utcnow().strftime('%Y%m%d')}"
current = await r.incr(key)
await r.expire(key, 86400)
if current > limit:
from app.core.exceptions import QuotaExceededError
raise QuotaExceededError(matched_key)
request.state.quota_remaining = limit - current
except QuotaExceededError:
raise
except Exception as e:
logger.warning(f"Quota check failed: {e}")
return await call_next(request)
+38
View File
@@ -0,0 +1,38 @@
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
)
to_encode.update({"exp": expire, "type": "access"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def create_refresh_token(data: dict) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
to_encode.update({"exp": expire, "type": "refresh"})
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
def decode_token(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
except JWTError:
return None