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
+81
View File
@@ -0,0 +1,81 @@
import pytest
import asyncio
from typing import AsyncGenerator
from httpx import AsyncClient, ASGITransport
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.main import app
from app.database import Base, get_db
from app.models.user import User
from app.core.security import hash_password
TEST_DATABASE_URL = "postgresql+asyncpg://admin:dWFNi67nHNbPbjmP@localhost:5432/foreign_trade_test"
test_engine = create_async_engine(TEST_DATABASE_URL, echo=False)
TestAsyncSessionLocal = sessionmaker(
test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture(scope="function")
async def db_session() -> AsyncGenerator[AsyncSession, None]:
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async with TestAsyncSessionLocal() as session:
yield session
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="function")
async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]:
async def override_get_db():
yield db_session
app.dependency_overrides[get_db] = override_get_db
async with AsyncClient(
transport=ASGITransport(app=app),
base_url="http://test"
) as ac:
yield ac
app.dependency_overrides.clear()
@pytest.fixture
async def test_user(db_session: AsyncSession) -> User:
user = User(
phone="13800138000",
username="test_user",
password_hash=hash_password("test123456"),
tier="free",
)
db_session.add(user)
await db_session.commit()
await db_session.refresh(user)
return user
@pytest.fixture
async def auth_headers(test_user: User) -> dict:
from app.core.security import create_access_token
token = create_access_token({"sub": str(test_user.id), "tier": test_user.tier})
return {"Authorization": f"Bearer {token}"}
+94
View File
@@ -0,0 +1,94 @@
import pytest
from httpx import AsyncClient
class TestAuthAPI:
async def test_health_endpoint(self, client: AsyncClient):
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["app"] == "TradeMate"
async def test_register_new_user(self, client: AsyncClient):
response = await client.post(
"/api/v1/auth/register",
json={
"phone": "13900139001",
"password": "test123456",
"username": "newuser",
},
)
assert response.status_code == 200
data = response.json()
assert data["phone"] == "13900139001"
assert data["username"] == "newuser"
assert data["tier"] == "free"
async def test_register_duplicate_phone(self, client: AsyncClient, test_user):
response = await client.post(
"/api/v1/auth/register",
json={
"phone": "13800138000",
"password": "test123456",
"username": "duplicate",
},
)
assert response.status_code == 400
assert "already registered" in response.json()["detail"]
async def test_login_success(self, client: AsyncClient, test_user):
response = await client.post(
"/api/v1/auth/login",
data={
"username": "13800138000",
"password": "test123456",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
async def test_login_wrong_password(self, client: AsyncClient, test_user):
response = await client.post(
"/api/v1/auth/login",
data={
"username": "13800138000",
"password": "wrongpassword",
},
)
assert response.status_code == 401
async def test_login_nonexistent_user(self, client: AsyncClient):
response = await client.post(
"/api/v1/auth/login",
data={
"username": "13999999999",
"password": "test123456",
},
)
assert response.status_code == 401
async def test_get_current_user(self, client: AsyncClient, auth_headers):
response = await client.get("/api/v1/auth/me", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert data["phone"] == "13800138000"
assert data["username"] == "test_user"
async def test_get_user_unauthorized(self, client: AsyncClient):
response = await client.get("/api/v1/auth/me")
assert response.status_code == 401
async def test_refresh_token(self, client: AsyncClient, test_user):
from app.core.security import create_refresh_token
refresh = create_refresh_token({"sub": str(test_user.id)})
response = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh},
)
assert response.status_code == 200
assert "access_token" in response.json()
+42
View File
@@ -0,0 +1,42 @@
import pytest
from app.config import settings
class TestConfig:
def test_app_name(self):
assert settings.APP_NAME == "TradeMate"
def test_jwt_algorithm(self):
assert settings.JWT_ALGORITHM == "HS256"
def test_token_expiration(self):
assert settings.ACCESS_TOKEN_EXPIRE_MINUTES == 60
assert settings.REFRESH_TOKEN_EXPIRE_DAYS == 30
def test_ai_routing_config(self):
assert "translate" in settings.AI_ROUTING
assert "reply" in settings.AI_ROUTING
assert "marketing" in settings.AI_ROUTING
assert settings.AI_ROUTING["translate"]["primary"] == "deepl"
assert settings.AI_ROUTING["reply"]["primary"] == "openai"
def test_free_tier_limits(self):
assert settings.FREE_DAILY_TRANSLATE_CHARS == 5000
assert settings.FREE_DAILY_REPLIES == 20
assert settings.FREE_DAILY_MARKETING == 5
assert settings.FREE_MAX_CUSTOMERS == 5
assert settings.FREE_MAX_PRODUCTS == 1
assert settings.FREE_DAILY_QUOTATIONS == 3
def test_pro_tier_limits(self):
assert settings.PRO_DAILY_TRANSLATE_CHARS == 50000
assert settings.PRO_DAILY_REPLIES == 200
assert settings.PRO_MAX_CUSTOMERS == 100
assert settings.PRO_MAX_PRODUCTS == 20
def test_database_url_configured(self):
assert settings.DATABASE_URL is not None
assert "foreign_trade" in settings.DATABASE_URL
def test_redis_url_configured(self):
assert settings.REDIS_URL is not None
+147
View File
@@ -0,0 +1,147 @@
import pytest
from httpx import AsyncClient
from app.models.customer import Customer
import uuid
class TestCustomerAPI:
async def test_list_customers_empty(self, client: AsyncClient, auth_headers):
response = await client.get("/api/v1/customers", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "items" in data
assert data["items"] == []
assert data["total"] == 0
async def test_create_customer(self, client: AsyncClient, auth_headers):
response = await client.post(
"/api/v1/customers",
headers=auth_headers,
json={
"name": "John Smith",
"company": "ABC Corp",
"country": "USA",
"phone": "+1234567890",
"whatsapp_id": "john123",
"email": "john@abc.com",
"status": "lead",
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "John Smith"
assert data["company"] == "ABC Corp"
assert data["country"] == "USA"
async def test_create_customer_minimal(self, client: AsyncClient, auth_headers):
response = await client.post(
"/api/v1/customers",
headers=auth_headers,
json={"name": "Minimal Customer"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Minimal Customer"
async def test_list_customers_with_data(self, client: AsyncClient, auth_headers, db_session, test_user):
customer = Customer(
user_id=test_user.id,
name="Test Customer",
company="Test Co",
country="China",
status="lead",
)
db_session.add(customer)
await db_session.commit()
response = await client.get("/api/v1/customers", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["name"] == "Test Customer"
async def test_get_customer(self, client: AsyncClient, auth_headers, db_session, test_user):
customer = Customer(
user_id=test_user.id,
name="Get Test",
company="Get Co",
status="negotiating",
)
db_session.add(customer)
await db_session.commit()
response = await client.get(
f"/api/v1/customers/{customer.id}",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Get Test"
async def test_get_customer_not_found(self, client: AsyncClient, auth_headers):
fake_id = str(uuid.uuid4())
response = await client.get(
f"/api/v1/customers/{fake_id}",
headers=auth_headers,
)
assert response.status_code == 404
async def test_update_customer(self, client: AsyncClient, auth_headers, db_session, test_user):
customer = Customer(
user_id=test_user.id,
name="Original Name",
status="lead",
)
db_session.add(customer)
await db_session.commit()
response = await client.patch(
f"/api/v1/customers/{customer.id}",
headers=auth_headers,
json={"name": "Updated Name", "status": "negotiating"},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Updated Name"
assert data["status"] == "negotiating"
async def test_delete_customer(self, client: AsyncClient, auth_headers, db_session, test_user):
customer = Customer(
user_id=test_user.id,
name="To Delete",
)
db_session.add(customer)
await db_session.commit()
customer_id = customer.id
response = await client.delete(
f"/api/v1/customers/{customer_id}",
headers=auth_headers,
)
assert response.status_code == 200
get_response = await client.get(
f"/api/v1/customers/{customer_id}",
headers=auth_headers,
)
assert get_response.status_code == 404
async def test_get_silent_customers(self, client: AsyncClient, auth_headers, db_session, test_user):
from datetime import datetime, timedelta
customer = Customer(
user_id=test_user.id,
name="Silent Customer",
status="lead",
last_contact_at=datetime.utcnow() - timedelta(days=5),
)
db_session.add(customer)
await db_session.commit()
response = await client.get(
"/api/v1/customers/silent?days=3",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert data["count"] >= 1
+45
View File
@@ -0,0 +1,45 @@
import pytest
from app.core.exceptions import (
TradeMateException,
NotFoundError,
UnauthorizedError,
ForbiddenError,
QuotaExceededError,
TierRestrictionError,
)
class TestExceptions:
def test_trade_mate_exception(self):
exc = TradeMateException(400, "Bad Request", "Details")
assert exc.code == 400
assert exc.message == "Bad Request"
assert exc.detail == "Details"
def test_not_found_error(self):
exc = NotFoundError("User")
assert exc.code == 404
assert "User" in exc.message
assert "not found" in exc.message
def test_unauthorized_error(self):
exc = UnauthorizedError()
assert exc.code == 401
assert exc.message == "Unauthorized"
def test_forbidden_error(self):
exc = ForbiddenError()
assert exc.code == 403
assert exc.message == "Forbidden"
def test_quota_exceeded_error(self):
exc = QuotaExceededError("translation")
assert exc.code == 429
assert "Quota exceeded" in exc.message
assert "translation" in exc.detail
def test_tier_restriction_error(self):
exc = TierRestrictionError("Advanced Feature", "Pro")
assert exc.code == 402
assert "Upgrade required" in exc.message
assert "Pro" in exc.detail
+47
View File
@@ -0,0 +1,47 @@
import pytest
from app.core.security import (
hash_password,
verify_password,
create_access_token,
create_refresh_token,
decode_token,
)
class TestSecurity:
def test_hash_password(self):
pwd = "test123456"
hashed = hash_password(pwd)
assert hashed != pwd
assert verify_password(pwd, hashed)
def test_verify_password_wrong(self):
pwd = "test123456"
hashed = hash_password(pwd)
assert not verify_password("wrongpassword", hashed)
def test_create_access_token(self):
data = {"sub": "test-user-id", "tier": "free"}
token = create_access_token(data)
assert token is not None
assert isinstance(token, str)
def test_decode_token_valid(self):
data = {"sub": "test-user-id", "tier": "pro"}
token = create_access_token(data)
decoded = decode_token(token)
assert decoded is not None
assert decoded["sub"] == "test-user-id"
assert decoded["tier"] == "pro"
def test_decode_token_invalid(self):
decoded = decode_token("invalid-token")
assert decoded is None
def test_create_refresh_token(self):
data = {"sub": "test-user-id"}
token = create_refresh_token(data)
assert token is not None
decoded = decode_token(token)
assert decoded is not None
assert decoded["type"] == "refresh"