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
View File
+153
View File
@@ -0,0 +1,153 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Annotated
from app.database import get_db
from app.models.user import User
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
from pydantic import BaseModel, EmailStr
from datetime import datetime
router = APIRouter()
class RegisterRequest(BaseModel):
phone: str
password: str
username: str = ""
class LoginResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
user: dict
class RefreshRequest(BaseModel):
refresh_token: str
@router.post("/register")
async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)]):
existing = await db.execute(select(User).where(User.phone == data.phone))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Phone already registered")
user = User(
phone=data.phone,
username=data.username or data.phone,
password_hash=hash_password(data.password),
tier="free",
)
db.add(user)
await db.flush()
return {
"id": str(user.id),
"phone": user.phone,
"username": user.username,
"tier": user.tier,
}
@router.post("/login", response_model=LoginResponse)
async def login(
form: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[AsyncSession, Depends(get_db)],
):
result = await db.execute(select(User).where(User.phone == form.username))
user = result.scalar_one_or_none()
if not user or not verify_password(form.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
)
return LoginResponse(
access_token=create_access_token({"sub": str(user.id), "tier": user.tier}),
refresh_token=create_refresh_token({"sub": str(user.id)}),
user={
"id": str(user.id),
"phone": user.phone,
"username": user.username,
"tier": user.tier,
},
)
@router.post("/refresh")
async def refresh(data: RefreshRequest):
payload = decode_token(data.refresh_token)
if not payload or payload.get("type") != "refresh":
raise HTTPException(status_code=401, detail="Invalid refresh token")
return {
"access_token": create_access_token({"sub": payload["sub"]}),
"token_type": "bearer",
}
@router.get("/me")
async def get_me(
authorization: str = None,
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
payload = decode_token(authorization[7:])
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
result = await db.execute(select(User).where(User.id == payload["sub"]))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": str(user.id),
"phone": user.phone,
"username": user.username,
"tier": user.tier,
"settings": user.settings,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
class SettingsUpdate(BaseModel):
preferred_translate_provider: str = None
reply_tone: str = None
timezone: str = None
languages: list = None
@router.patch("/settings")
async def update_settings(
data: SettingsUpdate,
authorization: str = None,
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
payload = decode_token(authorization[7:])
if not payload:
raise HTTPException(status_code=401, detail="Invalid token")
result = await db.execute(select(User).where(User.id == payload["sub"]))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=404, detail="User not found")
settings = user.settings or {}
for key, value in data.dict(exclude_unset=True).items():
if value is not None:
settings[key] = value
user.settings = settings
await db.flush()
return {"settings": user.settings}
+99
View File
@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from app.database import get_db
from app.services.customer import CustomerService
from app.core.security import decode_token
from app.api.v1.deps import get_current_user_id
router = APIRouter()
@router.get("")
async def list_customers(
status: Optional[str] = None,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = CustomerService(db)
return await service.list_customers(user_id, status, page, size)
@router.get("/silent")
async def get_silent(
days: int = Query(3, ge=1),
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = CustomerService(db)
customers = await service.get_silent_customers(user_id, days)
return {
"customers": customers,
"count": len(customers),
"silence_days": days,
}
@router.get("/{customer_id}")
async def get_customer(
customer_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = CustomerService(db)
customer = await service.get_customer(user_id, customer_id)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
return customer
@router.post("")
async def create_customer(
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = CustomerService(db)
customer = await service.create_customer(user_id, data)
return customer
@router.patch("/{customer_id}")
async def update_customer(
customer_id: str,
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = CustomerService(db)
customer = await service.update_customer(user_id, customer_id, data)
if not customer:
raise HTTPException(status_code=404, detail="Customer not found")
return customer
@router.delete("/{customer_id}")
async def delete_customer(
customer_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = CustomerService(db)
deleted = await service.delete_customer(user_id, customer_id)
if not deleted:
raise HTTPException(status_code=404, detail="Customer not found")
return {"message": "Customer deleted"}
@router.get("/{customer_id}/conversation")
async def get_conversation(
customer_id: str,
page: int = Query(1, ge=1),
size: int = Query(50, ge=1, le=200),
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = CustomerService(db)
return await service.get_conversation(user_id, customer_id, page, size)
+13
View File
@@ -0,0 +1,13 @@
from fastapi import HTTPException, Depends
from app.core.security import decode_token
async def get_current_user_id(authorization: str = None) -> str:
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing or invalid token")
payload = decode_token(authorization[7:])
if not payload:
raise HTTPException(status_code=401, detail="Invalid or expired token")
return payload.get("sub")
+54
View File
@@ -0,0 +1,54 @@
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class ExchangeRateResponse(BaseModel):
from_currency: str
to_currency: str
rate: float
updated_at: str
EXCHANGE_RATES = {
("USD", "CNY"): 7.24,
("EUR", "CNY"): 7.85,
("GBP", "CNY"): 9.15,
("CNY", "USD"): 0.138,
("USD", "EUR"): 0.92,
("EUR", "USD"): 1.09,
("GBP", "USD"): 1.27,
("USD", "GBP"): 0.79,
}
@router.get("/convert")
async def convert_currency(
from_currency: str = "USD",
to_currency: str = "CNY",
amount: float = 1.0,
):
rate = EXCHANGE_RATES.get((from_currency, to_currency), 1.0)
return {
"from_currency": from_currency,
"to_currency": to_currency,
"amount": amount,
"converted": round(amount * rate, 2),
"rate": rate,
"updated_at": "2026-05-08T00:00:00Z",
}
@router.get("/rates")
async def get_rates(base: str = "USD"):
rates = {}
for (from_curr, to_curr), rate in EXCHANGE_RATES.items():
if from_curr == base:
rates[to_curr] = rate
return {
"base": base,
"rates": rates,
"updated_at": "2026-05-08T00:00:00Z",
}
+90
View File
@@ -0,0 +1,90 @@
from fastapi import APIRouter, HTTPException
from typing import Optional
from pydantic import BaseModel
from app.services.marketing import MarketingService
from app.core.security import decode_token
from app.config import settings
router = APIRouter()
class MarketingRequest(BaseModel):
product_name: str
description: str
category: Optional[str] = None
price: Optional[str] = None
keywords: Optional[list] = None
target: str = "US importers"
style: str = "professional"
language: str = "en"
count: int = 3
class KeywordsRequest(BaseModel):
product_name: str
description: str
category: Optional[str] = None
language: str = "en"
count: int = 10
class CompetitorRequest(BaseModel):
product_name: str
description: str
category: Optional[str] = None
market: str = "US"
@router.post("/generate")
async def generate_marketing(data: MarketingRequest, authorization: str = None):
if not authorization:
raise HTTPException(status_code=401, detail="Missing token")
service = MarketingService()
product_info = {
"name": data.product_name,
"description": data.description,
"category": data.category,
"price": data.price,
"keywords": data.keywords,
}
results = await service.generate(product_info, data.target, data.style, data.language, data.count)
return {
"results": results,
"product": data.product_name,
"target": data.target,
"count": len(results),
}
@router.post("/keywords")
async def generate_keywords(data: KeywordsRequest, authorization: str = None):
if not authorization:
raise HTTPException(status_code=401, detail="Missing token")
service = MarketingService()
product_info = {
"name": data.product_name,
"description": data.description,
"category": data.category,
}
keywords = await service.generate_keywords(product_info, data.language, data.count)
return {"keywords": keywords, "product": data.product_name}
@router.post("/competitor-analysis")
async def competitor_analysis(data: CompetitorRequest, authorization: str = None):
if not authorization:
raise HTTPException(status_code=401, detail="Missing token")
service = MarketingService()
product_info = {
"name": data.product_name,
"description": data.description,
"category": data.category,
}
analysis = await service.analyze_competitors(product_info, data.market)
return {"analysis": analysis, "product": data.product_name, "market": data.market}
+101
View File
@@ -0,0 +1,101 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from app.database import get_db
from app.services.product import ProductService
from app.api.v1.deps import get_current_user_id
from pydantic import BaseModel
router = APIRouter()
class ProductCreate(BaseModel):
name: str
name_en: Optional[str] = None
description: Optional[str] = None
description_en: Optional[str] = None
category: Optional[str] = None
price: Optional[str] = None
price_unit: Optional[str] = "USD"
moq: Optional[str] = None
keywords: Optional[list] = []
specifications: Optional[dict] = {}
images: Optional[list] = []
class ProductUpdate(BaseModel):
name: Optional[str] = None
name_en: Optional[str] = None
description: Optional[str] = None
description_en: Optional[str] = None
category: Optional[str] = None
price: Optional[str] = None
price_unit: Optional[str] = None
moq: Optional[str] = None
keywords: Optional[list] = None
specifications: Optional[dict] = None
images: Optional[list] = None
is_active: Optional[bool] = None
@router.get("")
async def list_products(
category: Optional[str] = None,
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = ProductService(db)
return await service.list_products(user_id, category, page, size)
@router.get("/{product_id}")
async def get_product(
product_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = ProductService(db)
product = await service.get_product(user_id, product_id)
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
@router.post("")
async def create_product(
data: ProductCreate,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = ProductService(db)
product = await service.create_product(user_id, data.dict())
return product
@router.patch("/{product_id}")
async def update_product(
product_id: str,
data: ProductUpdate,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = ProductService(db)
product = await service.update_product(user_id, product_id, data.dict(exclude_unset=True))
if not product:
raise HTTPException(status_code=404, detail="Product not found")
return product
@router.delete("/{product_id}")
async def delete_product(
product_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = ProductService(db)
deleted = await service.delete_product(user_id, product_id)
if not deleted:
raise HTTPException(status_code=404, detail="Product not found")
return {"message": "Product deleted"}
+147
View File
@@ -0,0 +1,147 @@
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional, List
from pydantic import BaseModel
from app.database import get_db
from app.models.user import User
from app.core.security import decode_token
router = APIRouter()
class DeviceRegister(BaseModel):
client_id: str
platform: Optional[str] = None
device_info: Optional[dict] = None
class PushMessage(BaseModel):
title: str
content: str
payload: Optional[dict] = None
target_type: str = "all"
target_value: Optional[str] = None
class PushResponse(BaseModel):
success: bool
message_id: Optional[str] = None
error: Optional[str] = None
# 模拟存储的设备信息(实际应存数据库)
devices_db = {}
@router.post("/register")
async def register_device(
data: DeviceRegister,
authorization: str = None,
db: AsyncSession = Depends(get_db),
):
if not authorization or not authorization.startswith("Bearer "):
return {"error": "Unauthorized"}, 401
payload = decode_token(authorization[7:])
if not payload:
return {"error": "Invalid token"}, 401
user_id = payload.get("sub")
if user_id not in devices_db:
devices_db[user_id] = []
existing = [d for d in devices_db[user_id] if d.get("client_id") == data.client_id]
if not existing:
devices_db[user_id].append({
"client_id": data.client_id,
"platform": data.platform,
"device_info": data.device_info,
})
return {"success": True, "message": "Device registered"}
@router.post("/send")
async def send_push(
message: PushMessage,
authorization: str = None,
db: AsyncSession = Depends(get_db),
):
if not authorization or not authorization.startswith("Bearer "):
return {"error": "Unauthorized"}, 401
payload = decode_token(authorization[7:])
if not payload:
return {"error": "Invalid token"}, 401
user_id = payload.get("sub")
user_devices = devices_db.get(user_id, [])
if not user_devices:
return PushResponse(success=False, error="No devices registered")
# 实际项目中这里调用 uni-push/极光等API
# 模拟返回成功
message_id = f"msg_{user_id}_{int(payload.get('iat', 0))}"
print(f"Push message to user {user_id}: {message.title} - {message.content}")
return PushResponse(success=True, message_id=message_id)
@router.post("/send-to-customer")
async def send_to_customer(
customer_id: str,
title: str,
content: str,
payload: Optional[dict] = None,
authorization: str = None,
):
"""
针对特定客户的推送通知
例如:客户沉默提醒、报价提醒等
"""
if not authorization or not authorization.startswith("Bearer "):
return {"error": "Unauthorized"}, 401
payload_data = decode_token(authorization[7:])
if not payload_data:
return {"error": "Invalid token"}, 401
user_id = payload_data.get("sub")
# 这里可以添加针对客户的特定逻辑
notification = {
"type": "customer_alert",
"customer_id": customer_id,
"title": title,
"content": content,
"payload": payload or {}
}
print(f"Customer notification for user {user_id}, customer {customer_id}: {title}")
return PushResponse(success=True, message_id=f"alert_{customer_id}")
@router.get("/devices")
async def list_devices(
authorization: str = None,
):
"""列出用户已注册的设备"""
if not authorization or not authorization.startswith("Bearer "):
return {"error": "Unauthorized"}, 401
payload = decode_token(authorization[7:])
if not payload:
return {"error": "Invalid token"}, 401
user_id = payload.get("sub")
user_devices = devices_db.get(user_id, [])
return {
"devices": user_devices,
"count": len(user_devices)
}
+60
View File
@@ -0,0 +1,60 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated, Optional
from app.database import get_db
from app.services.quotation import QuotationService
from app.api.v1.deps import get_current_user_id
router = APIRouter()
@router.post("")
async def create_quotation(
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = QuotationService(db)
try:
quotation = await service.create_quotation(user_id, data)
return quotation
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.get("")
async def list_quotations(
page: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=100),
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = QuotationService(db)
return await service.list_quotations(user_id, page, size)
@router.get("/{quotation_id}")
async def get_quotation(
quotation_id: str,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = QuotationService(db)
quotation = await service.get_quotation(user_id, quotation_id)
if not quotation:
raise HTTPException(status_code=404, detail="Quotation not found")
return quotation
@router.patch("/{quotation_id}/status")
async def update_quotation_status(
quotation_id: str,
data: dict,
user_id: str = Depends(get_current_user_id),
db: Annotated[AsyncSession, Depends(get_db)] = None,
):
service = QuotationService(db)
quotation = await service.update_status(user_id, quotation_id, data.get("status", "draft"))
if not quotation:
raise HTTPException(status_code=404, detail="Quotation not found")
return quotation
+86
View File
@@ -0,0 +1,86 @@
from fastapi import APIRouter, HTTPException
from typing import Optional, Dict, Any
from pydantic import BaseModel
from app.services.translation import TranslationService
from app.core.security import decode_token
router = APIRouter()
class TranslateRequest(BaseModel):
text: str
target_lang: str
source_lang: Optional[str] = "auto"
context: Optional[str] = None
class ReplyRequest(BaseModel):
inquiry: str
tone: str = "professional"
count: int = 3
context: Optional[Dict[str, Any]] = None
class ExtractRequest(BaseModel):
text: str
extract_type: str = "auto"
@router.post("")
async def translate_text(data: TranslateRequest, authorization: str = None):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
payload = decode_token(authorization[7:])
user_id = payload.get("sub") if payload else None
service = TranslationService()
result = await service.translate(
text=data.text,
target_lang=data.target_lang,
source_lang=data.source_lang,
context=data.context,
user_id=user_id,
)
return result
@router.post("/reply")
async def generate_reply(data: ReplyRequest, authorization: str = None):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
service = TranslationService()
results = await service.generate_reply(
inquiry=data.inquiry,
context=data.context,
tone=data.tone,
count=data.count,
)
return {"suggestions": results, "inquiry": data.inquiry, "count": len(results)}
@router.post("/extract")
async def extract_info(data: ExtractRequest, authorization: str = None):
if not authorization or not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="Missing token")
service = TranslationService()
result = await service.extract_info(data.text, data.extract_type)
return {"extracted": result, "type": data.extract_type}
@router.post("/feedback")
async def feedback(data: dict, authorization: str = None):
if not authorization:
raise HTTPException(status_code=401, detail="Missing token")
from app.ai.trade_corpus import TradeCorpus
corpus = TradeCorpus()
entry_id = data.get("entry_id")
rating = data.get("rating")
if entry_id and rating:
await corpus.rate_entry(entry_id, rating)
return {"status": "ok"}
+62
View File
@@ -0,0 +1,62 @@
from fastapi import APIRouter, Request, HTTPException, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated
from app.database import get_db
from app.services.whatsapp import WhatsAppService
from app.services.customer import CustomerService
from app.services.translation import TranslationService
from app.core.security import decode_token
from app.api.v1.deps import get_current_user_id
from app.config import settings
router = APIRouter()
@router.get("/webhook")
async def verify_webhook(
hub_mode: str = None,
hub_verify_token: str = None,
hub_challenge: str = None,
):
svc = WhatsAppService()
result = svc.verify_webhook(hub_mode, hub_verify_token, hub_challenge)
if result:
return int(result)
raise HTTPException(status_code=403, detail="Verification failed")
@router.post("/webhook")
async def handle_webhook(request: Request, db: Annotated[AsyncSession, Depends(get_db)] = None):
svc = WhatsAppService()
body = await request.json()
msg_data = svc.parse_webhook(body)
if not msg_data:
return {"status": "ok"}
# TODO: Route to correct user based on WhatsApp number
# For MVP, handle as generic incoming message
return {"status": "ok", "message": "received"}
@router.post("/send")
async def send_message(
data: dict,
user_id: str = Depends(get_current_user_id),
):
text = data.get("text")
to = data.get("to")
if not text or not to:
raise HTTPException(status_code=400, detail="text and to are required")
svc = WhatsAppService()
sent = await svc.send_text(to, text)
if not sent:
raise HTTPException(status_code=500, detail="Failed to send WhatsApp message")
return {"status": "sent", "to": to}
@router.get("/qr")
async def get_qr():
return {"message": "WhatsApp QR login not available via API. Use WhatsApp Cloud API instead."}