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:
@@ -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"},
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user