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
+47
View File
@@ -0,0 +1,47 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
*.egg-info/
dist/
build/
.eggs/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
logs/
# Database
*.db
*.sqlite3
# OS
.DS_Store
Thumbs.db
# Uni-app
uni-app/dist/
uni-app/node_modules/
# Docker
docker-compose.override.yml
# Misc
*.bak
*.tmp
+45
View File
@@ -0,0 +1,45 @@
# 应用配置
APP_NAME=TradeMate
SECRET_KEY=change-this-to-a-random-secret-key
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=60
REFRESH_TOKEN_EXPIRE_DAYS=30
# 数据库
DATABASE_URL=postgresql+asyncpg://tradmate:tradmate@localhost:5432/tradmate
# Redis
REDIS_URL=redis://localhost:6379/0
# Celery
CELERY_BROKER_URL=redis://localhost:6379/1
CELERY_RESULT_BACKEND=redis://localhost:6379/2
# AI 提供商(至少配置一个)
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
DEEPL_API_KEY=
# 本地模型(可选)
LOCAL_MODEL_ENABLED=false
LOCAL_MODEL_URL=http://localhost:8001
# WhatsApp Cloud API
WHATSAPP_API_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WHATSAPP_WEBHOOK_VERIFY_TOKEN=
# 微信小程序
WECHAT_APP_ID=
WECHAT_APP_SECRET=
# 汇率 API(免费层即可)
EXCHANGE_RATE_API_KEY=
# 文件存储
UPLOAD_DIR=./uploads
MAX_UPLOAD_SIZE=10485760
# URL
FRONTEND_URL=http://localhost:3000
BACKEND_URL=http://localhost:8000
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.11-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
gcc \
postgresql-client \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p uploads
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+39
View File
@@ -0,0 +1,39 @@
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
sqlalchemy.url = postgresql+asyncpg://tradmate:tradmate@localhost:5432/tradmate
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+61
View File
@@ -0,0 +1,61 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.database import Base
from app.models import User, Product, Customer, Conversation, Message, Quotation, QuotationItem, CorpusEntry
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+189
View File
@@ -0,0 +1,189 @@
"""initial schema
Revision ID: 001
Revises:
Create Date: 2026-05-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('users',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('wechat_openid', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('username', sa.String(length=100), nullable=True),
sa.Column('password_hash', sa.String(length=255), nullable=True),
sa.Column('tier', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_phone'), 'users', ['phone'], unique=True)
op.create_index(op.f('ix_users_wechat_openid'), 'users', ['wechat_openid'], unique=True)
op.create_table('products',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('name_en', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('description_en', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('price', sa.String(length=50), nullable=True),
sa.Column('price_unit', sa.String(length=20), nullable=True),
sa.Column('moq', sa.String(length=50), nullable=True),
sa.Column('keywords', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('specifications', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('images', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_products_user_id'), 'products', ['user_id'], unique=False)
op.create_table('customers',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('company', sa.String(length=255), nullable=True),
sa.Column('country', sa.String(length=100), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('whatsapp_id', sa.String(length=255), nullable=True),
sa.Column('source', sa.String(length=100), nullable=True),
sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('preference', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('last_contact_at', sa.DateTime(), nullable=True),
sa.Column('silence_started_at', sa.DateTime(), nullable=True),
sa.Column('next_followup_at', sa.DateTime(), nullable=True),
sa.Column('estimated_value', sa.String(length=50), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_customers_user_id'), 'customers', ['user_id'], unique=False)
op.create_table('conversations',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('channel', sa.String(length=50), nullable=True),
sa.Column('topic', sa.String(length=255), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('message_count', sa.Integer(), nullable=True),
sa.Column('last_message_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_conversations_customer_id'), 'conversations', ['customer_id'], unique=False)
op.create_index(op.f('ix_conversations_user_id'), 'conversations', ['user_id'], unique=False)
op.create_table('messages',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('conversation_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('direction', sa.String(length=20), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('content_translated', sa.Text(), nullable=True),
sa.Column('content_type', sa.String(length=50), nullable=True),
sa.Column('ai_suggestions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('selected_suggestion', sa.Integer(), nullable=True),
sa.Column('user_edited', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_messages_conversation_id'), 'messages', ['conversation_id'], unique=False)
op.create_table('quotations',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('currency', sa.String(length=10), nullable=True),
sa.Column('exchange_rate', sa.Float(), nullable=True),
sa.Column('payment_terms', sa.String(length=255), nullable=True),
sa.Column('delivery_terms', sa.String(length=255), nullable=True),
sa.Column('lead_time', sa.String(length=100), nullable=True),
sa.Column('valid_until', sa.String(length=100), nullable=True),
sa.Column('subtotal', sa.Float(), nullable=True),
sa.Column('discount', sa.Float(), nullable=True),
sa.Column('shipping', sa.Float(), nullable=True),
sa.Column('total', sa.Float(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('pdf_url', sa.Text(), nullable=True),
sa.Column('sent_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_quotations_user_id'), 'quotations', ['user_id'], unique=False)
op.create_table('quotation_items',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('quotation_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('product_name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('unit_price', sa.Float(), nullable=False),
sa.Column('total_price', sa.Float(), nullable=True),
sa.Column('unit', sa.String(length=50), nullable=True),
sa.ForeignKeyConstraint(['quotation_id'], ['quotations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_quotation_items_quotation_id'), 'quotation_items', ['quotation_id'], unique=False)
op.create_table('corpus_entries',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('source_text', sa.Text(), nullable=False),
sa.Column('target_text', sa.Text(), nullable=False),
sa.Column('source_lang', sa.String(length=20), nullable=True),
sa.Column('target_lang', sa.String(length=20), nullable=True),
sa.Column('task_type', sa.String(length=50), nullable=False),
sa.Column('domain', sa.String(length=100), nullable=True),
sa.Column('provider_used', sa.String(length=50), nullable=True),
sa.Column('quality_score', sa.Float(), nullable=True),
sa.Column('user_edited', sa.Boolean(), nullable=True),
sa.Column('user_rating', sa.Integer(), nullable=True),
sa.Column('usage_count', sa.Integer(), nullable=True),
sa.Column('embedding', postgresql.Vector(length=768), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
op.drop_table('corpus_entries')
op.drop_table('quotation_items')
op.drop_table('quotations')
op.drop_table('messages')
op.drop_table('conversations')
op.drop_table('customers')
op.drop_table('products')
op.drop_table('users')
View File
+3
View File
@@ -0,0 +1,3 @@
from .router import get_ai_router
__all__ = ["get_ai_router"]
+45
View File
@@ -0,0 +1,45 @@
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
class AIProvider(ABC):
@abstractmethod
async def translate(
self, text: str, source_lang: Optional[str], target_lang: str,
context: Optional[str] = None,
) -> Dict[str, Any]:
pass
@abstractmethod
async def reply(
self, inquiry: str, context: Optional[Dict[str, Any]] = None,
tone: str = "professional",
) -> Dict[str, Any]:
pass
@abstractmethod
async def generate_marketing(
self, product_info: Dict[str, Any], target: str,
style: str = "professional", language: str = "en",
) -> Dict[str, Any]:
pass
@abstractmethod
async def extract_info(
self, text: str, schema: Dict[str, Any],
) -> Dict[str, Any]:
pass
@property
@abstractmethod
def name(self) -> str:
pass
@property
@abstractmethod
def cost_per_1k_tokens(self) -> float:
pass
@property
def supports_streaming(self) -> bool:
return False
+6
View File
@@ -0,0 +1,6 @@
from .openai import OpenAIProvider
from .claude import ClaudeProvider
from .deepl import DeepLProvider
from .local import LocalProvider
__all__ = ["OpenAIProvider", "ClaudeProvider", "DeepLProvider", "LocalProvider"]
+83
View File
@@ -0,0 +1,83 @@
from typing import Dict, Any, Optional
import json
from anthropic import AsyncAnthropic
from app.ai.base import AIProvider
SYSTEM_PROMPTS = {
"marketing": "You are a world-class copywriter for international trade. Write persuasive, "
"culturally-adapted marketing content that converts. You excel at storytelling "
"and emotional appeal in business contexts.",
"reply": "You are a senior international sales representative with 20 years of experience. "
"Your replies are warm, professional, and strategically move the conversation "
"toward closing the deal.",
"translate": "You are a professional translator specializing in trade documents. "
"Preserve all numbers, terms, and formatting. Translate meaning, not words.",
"extract": "Extract structured data from text. Return ONLY valid JSON.",
}
class ClaudeProvider(AIProvider):
def __init__(self, api_key: str, model: str = "claude-sonnet-4-20250514"):
self.client = AsyncAnthropic(api_key=api_key)
self.model = model
self._name = f"claude-sonnet"
self._pricing = {"input": 0.003, "output": 0.015}
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
system = SYSTEM_PROMPTS["translate"]
if context:
system += f"\nContext: {context}"
prompt = f"Translate to {target_lang}:\n\n{text}"
content = await self._call(system, prompt)
return {"translated_text": content, "provider": self.name}
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
system = SYSTEM_PROMPTS["reply"]
context_str = ""
if context:
for k, v in context.items():
if v:
context_str += f"{k}: {v}\n"
prompt = f"{context_str}\nCustomer says:\n{inquiry}\n\nYour reply ({tone} tone):"
content = await self._call(system, prompt)
return {"reply": content, "provider": self.name}
async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
system = SYSTEM_PROMPTS["marketing"]
info = json.dumps(product_info, ensure_ascii=False, indent=2)
prompt = f"Product:\n{info}\n\nTarget: {target}\nStyle: {style}\nLanguage: {language}\n\nWrite marketing copy:"
content = await self._call(system, prompt, max_tokens=1500)
return {"content": content, "provider": self.name}
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
system = SYSTEM_PROMPTS["extract"]
prompt = f"Schema:\n{json.dumps(schema, indent=2)}\n\nText:\n{text}\n\nJSON:"
content = await self._call(system, prompt, max_tokens=1000)
try:
data = json.loads(content)
return {"data": data, "confidence": 0.9, "provider": self.name}
except json.JSONDecodeError:
return {"data": {}, "confidence": 0.0, "provider": self.name, "error": "parse_failed"}
async def _call(self, system: str, prompt: str, max_tokens: int = 1000) -> str:
resp = await self.client.messages.create(
model=self.model,
system=system,
messages=[{"role": "user", "content": prompt}],
max_tokens=max_tokens,
temperature=0.7,
)
return resp.content[0].text
@property
def name(self) -> str:
return self._name
@property
def cost_per_1k_tokens(self) -> float:
return (self._pricing["input"] + self._pricing["output"]) / 2
@property
def supports_streaming(self) -> bool:
return True
+51
View File
@@ -0,0 +1,51 @@
from typing import Dict, Any, Optional
import httpx
from app.ai.base import AIProvider
class DeepLProvider(AIProvider):
def __init__(self, api_key: str, endpoint: str = "https://api.deepl.com/v2"):
self.api_key = api_key
self.endpoint = endpoint
self._name = "deepl"
self._cost_per_char = 0.000006
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
params = {
"auth_key": self.api_key,
"text": text,
"target_lang": target_lang.upper()[:2],
}
if source_lang and source_lang != "auto":
params["source_lang"] = source_lang.upper()[:2]
async with httpx.AsyncClient() as client:
resp = await client.post(f"{self.endpoint}/translate", data=params, timeout=15)
resp.raise_for_status()
data = resp.json()
t = data["translations"][0]
return {
"translated_text": t["text"],
"provider": self.name,
"detected_source_lang": t.get("detected_source_language", source_lang),
"char_count": len(text),
"cost": len(text) * self._cost_per_char,
}
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
raise NotImplementedError("DeepL does not support reply generation")
async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
raise NotImplementedError("DeepL does not support marketing generation")
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
raise NotImplementedError("DeepL does not support info extraction")
@property
def name(self) -> str:
return self._name
@property
def cost_per_1k_tokens(self) -> float:
return self._cost_per_char * 1000
+55
View File
@@ -0,0 +1,55 @@
from typing import Dict, Any, Optional
import json, httpx
from app.ai.base import AIProvider
class LocalProvider(AIProvider):
def __init__(self, model_url: str = "http://localhost:8001", model_name: str = "gemma-3-8b"):
self.model_url = model_url.rstrip("/")
self.model_name = model_name
self._name = f"local-{model_name}"
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
prompt = f"Translate{ f' from {source_lang}' if source_lang else ''} to {target_lang}:\n{text}\n\nTranslation:"
result = await self._generate(prompt)
return {"translated_text": result, "provider": self.name, "cost": 0.0}
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
ctx = ""
if context:
ctx = "\n".join(f"{k}: {v}" for k, v in context.items() if v)
prompt = f"{ctx}\nCustomer: {inquiry}\n\nWrite a {tone} reply:"
result = await self._generate(prompt)
return {"reply": result, "provider": self.name, "cost": 0.0}
async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
info = json.dumps(product_info, ensure_ascii=False)
prompt = f"Product: {info}\nTarget: {target}\nStyle: {style}\nLanguage: {language}\n\nMarketing copy:"
result = await self._generate(prompt, max_tokens=800)
return {"content": result, "provider": self.name, "cost": 0.0}
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
prompt = f"Extract JSON from text matching schema:\nSchema: {json.dumps(schema)}\n\nText: {text}\n\nJSON:"
result = await self._generate(prompt, max_tokens=500)
try:
return {"data": json.loads(result), "confidence": 0.7, "provider": self.name, "cost": 0.0}
except json.JSONDecodeError:
return {"data": {}, "confidence": 0.0, "provider": self.name, "cost": 0.0, "error": "parse_failed"}
async def _generate(self, prompt: str, max_tokens: int = 500) -> str:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.model_url}/v1/completions",
json={"model": self.model_name, "prompt": prompt, "max_tokens": max_tokens, "temperature": 0.7, "stream": False},
timeout=60,
)
resp.raise_for_status()
return resp.json()["choices"][0]["text"].strip()
@property
def name(self) -> str:
return self._name
@property
def cost_per_1k_tokens(self) -> float:
return 0.0
+102
View File
@@ -0,0 +1,102 @@
from typing import Dict, Any, Optional
import json
from openai import AsyncOpenAI
from app.ai.base import AIProvider
SYSTEM_PROMPTS = {
"translate": "You are a professional translator specialized in foreign trade and e-commerce. "
"Accurately translate business terms like MOQ, FOB, CIF, lead time, etc. "
"Return ONLY the translated text, no explanations.",
"reply": "You are an experienced foreign trade sales expert. Write professional, "
"clear business replies. Be concise but warm. Include relevant details "
"naturally. Return ONLY the reply text, no explanations.",
"marketing": "You are a creative copywriter for international trade. Write compelling "
"marketing content that drives action. Adapt to the target audience's culture. "
"Return ONLY the copy, no explanations.",
"extract": "You extract structured data from text. Return ONLY valid JSON matching the requested schema.",
}
class OpenAIProvider(AIProvider):
def __init__(self, api_key: str, model: str = "gpt-4o"):
self.client = AsyncOpenAI(api_key=api_key)
self.model = model
self._name = f"openai-{model}"
self._pricing = {
"gpt-4o": {"input": 0.01, "output": 0.03},
"gpt-4o-mini": {"input": 0.0015, "output": 0.006},
}
self._cheap_model = "gpt-4o-mini" if model == "gpt-4o" else model
async def translate(self, text: str, source_lang: Optional[str], target_lang: str, context: Optional[str] = None) -> Dict[str, Any]:
system = SYSTEM_PROMPTS["translate"]
if context:
system += f"\nContext: this is about {context}"
if source_lang and source_lang != "auto":
system += f"\nSource language: {source_lang}"
content = await self._call(system, f"Translate to {target_lang}:\n\n{text}", model=self._cheap_model)
return {"translated_text": content, "provider": self.name, "model": self.model}
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
system = SYSTEM_PROMPTS["reply"] + f"\nTone: {tone}"
context_str = ""
if context:
if context.get("product"):
context_str += f"Product: {context['product']}\n"
if context.get("price"):
context_str += f"Price: {context['price']}\n"
if context.get("customer_history"):
context_str += f"Customer history: {context['customer_history']}\n"
if context.get("conversation_history"):
context_str += f"Previous messages: {context['conversation_history']}\n"
prompt = f"{context_str}\nCustomer inquiry:\n{inquiry}\n\nWrite a reply:"
content = await self._call(system, prompt)
return {"reply": content, "provider": self.name, "model": self.model}
async def generate_marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
system = SYSTEM_PROMPTS["marketing"] + f"\nStyle: {style}\nTarget audience: {target}\nLanguage: {language}"
product_str = json.dumps(product_info, ensure_ascii=False, indent=2)
prompt = f"Product information:\n{product_str}\n\nGenerate marketing copy:"
content = await self._call(system, prompt)
return {"content": content, "provider": self.name, "model": self.model}
async def extract_info(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
system = SYSTEM_PROMPTS["extract"]
schema_str = json.dumps(schema, indent=2)
prompt = f"Schema:\n{schema_str}\n\nText:\n{text}\n\nExtracted JSON:"
content = await self._call(system, prompt, response_format={"type": "json_object"})
try:
data = json.loads(content)
return {"data": data, "confidence": 0.9, "provider": self.name}
except json.JSONDecodeError:
return {"data": {}, "confidence": 0.0, "provider": self.name, "error": "parse_failed"}
async def _call(self, system: str, prompt: str, max_tokens: int = 1000, response_format: Optional[Dict] = None, model: Optional[str] = None) -> str:
kwargs = {
"model": model or self.model,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
],
"max_tokens": max_tokens,
"temperature": 0.7,
}
if response_format:
kwargs["response_format"] = response_format
resp = await self.client.chat.completions.create(**kwargs)
return resp.choices[0].message.content
@property
def name(self) -> str:
return self._name
@property
def cost_per_1k_tokens(self) -> float:
p = self._pricing.get(self.model, {"input": 0.01, "output": 0.03})
return (p["input"] + p["output"]) / 2
+110
View File
@@ -0,0 +1,110 @@
from typing import Dict, Any, Optional, List
from app.ai.base import AIProvider
from app.ai.providers import OpenAIProvider, ClaudeProvider, DeepLProvider, LocalProvider
from app.config import settings
from app.ai.trade_corpus import TradeCorpus
import logging
logger = logging.getLogger(__name__)
class AIRouter:
def __init__(self):
self.providers: Dict[str, AIProvider] = {}
self.routing_rules = settings.AI_ROUTING
self.corpus = TradeCorpus()
self._init_providers()
def _init_providers(self):
if settings.OPENAI_API_KEY:
try:
self.providers["openai"] = OpenAIProvider(api_key=settings.OPENAI_API_KEY)
logger.info("OpenAI provider ready")
except Exception as e:
logger.warning(f"OpenAI init failed: {e}")
if settings.ANTHROPIC_API_KEY:
try:
self.providers["anthropic"] = ClaudeProvider(api_key=settings.ANTHROPIC_API_KEY)
logger.info("Claude provider ready")
except Exception as e:
logger.warning(f"Claude init failed: {e}")
if settings.DEEPL_API_KEY:
try:
self.providers["deepl"] = DeepLProvider(api_key=settings.DEEPL_API_KEY)
logger.info("DeepL provider ready")
except Exception as e:
logger.warning(f"DeepL init failed: {e}")
if settings.LOCAL_MODEL_ENABLED:
try:
self.providers["local"] = LocalProvider(model_url=settings.LOCAL_MODEL_URL)
logger.info("Local provider ready")
except Exception as e:
logger.warning(f"Local init failed: {e}")
def get_providers_for_task(self, task_type: str) -> List[AIProvider]:
rules = self.routing_rules.get(
task_type,
{"primary": "openai", "fallback": ["local"]},
)
ordered = []
seen = set()
primary = rules.get("primary")
if primary and primary in self.providers:
ordered.append(self.providers[primary])
seen.add(primary)
for name in rules.get("fallback", []):
if name in self.providers and name not in seen:
ordered.append(self.providers[name])
seen.add(name)
if not ordered:
ordered = list(self.providers.values())
logger.warning(f"No preferred providers for '{task_type}', using all available")
return ordered
async def execute(self, task_type: str, method: str, *args, **kwargs) -> Dict[str, Any]:
providers = self.get_providers_for_task(task_type)
last_error = None
for provider in providers:
try:
method_fn = getattr(provider, method)
result = await method_fn(*args, **kwargs)
result["provider_used"] = provider.name
return result
except NotImplementedError:
continue
except Exception as e:
logger.warning(f"{provider.name} failed for {task_type}: {e}")
last_error = e
continue
raise Exception(f"All providers failed for '{task_type}'. Last error: {last_error}")
async def translate(self, text: str, target_lang: str, source_lang: Optional[str] = None, context: Optional[str] = None) -> Dict[str, Any]:
return await self.execute("translate", "translate", text, source_lang, target_lang, context)
async def reply(self, inquiry: str, context: Optional[Dict[str, Any]] = None, tone: str = "professional") -> Dict[str, Any]:
return await self.execute("reply", "reply", inquiry, context, tone)
async def marketing(self, product_info: Dict[str, Any], target: str, style: str = "professional", language: str = "en") -> Dict[str, Any]:
return await self.execute("marketing", "generate_marketing", product_info, target, style, language)
async def extract(self, text: str, schema: Dict[str, Any]) -> Dict[str, Any]:
return await self.execute("extract", "extract_info", text, schema)
_router_instance = None
def get_ai_router() -> AIRouter:
global _router_instance
if _router_instance is None:
_router_instance = AIRouter()
return _router_instance
+87
View File
@@ -0,0 +1,87 @@
from typing import Dict, Any, Optional, List
from sqlalchemy import select, text
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class TradeCorpus:
def __init__(self):
self._ready = False
async def record(
self,
source_text: str,
target_text: str,
task_type: str,
provider: str,
source_lang: Optional[str] = None,
target_lang: Optional[str] = None,
quality_score: float = 0.5,
user_edited: bool = False,
metadata: Optional[Dict] = None,
):
try:
from app.database import AsyncSessionLocal
from app.models.corpus import CorpusEntry
async with AsyncSessionLocal() as session:
entry = CorpusEntry(
source_text=source_text[:2000],
target_text=target_text[:2000],
source_lang=source_lang,
target_lang=target_lang,
task_type=task_type,
provider_used=provider,
quality_score=quality_score,
user_edited=user_edited,
metadata=metadata or {},
)
session.add(entry)
await session.commit()
except Exception as e:
logger.warning(f"Failed to record corpus entry: {e}")
async def find_similar(self, text: str, task_type: str, top_k: int = 3) -> List[Dict[str, Any]]:
try:
from app.database import AsyncSessionLocal
from app.models.corpus import CorpusEntry
async with AsyncSessionLocal() as session:
result = await session.execute(
select(CorpusEntry)
.where(CorpusEntry.task_type == task_type)
.where(CorpusEntry.quality_score >= 0.6)
.order_by(CorpusEntry.quality_score.desc())
.limit(top_k)
)
entries = result.scalars().all()
return [
{
"source": e.source_text,
"target": e.target_text,
"score": e.quality_score,
}
for e in entries
]
except Exception as e:
logger.warning(f"Corpus search failed: {e}")
return []
async def rate_entry(self, entry_id: str, rating: int):
try:
from app.database import AsyncSessionLocal
from app.models.corpus import CorpusEntry
async with AsyncSessionLocal() as session:
result = await session.execute(
select(CorpusEntry).where(CorpusEntry.id == entry_id)
)
entry = result.scalar_one_or_none()
if entry:
entry.user_rating = rating
entry.quality_score = rating / 5.0
await session.commit()
except Exception as e:
logger.warning(f"Failed to rate corpus entry: {e}")
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."}
+23
View File
@@ -0,0 +1,23 @@
from celery import Celery
from app.config import settings
celery_app = Celery(
"tradmate",
broker=settings.CELERY_BROKER_URL,
backend=settings.CELERY_RESULT_BACKEND,
include=[
"app.workers.tasks",
],
)
celery_app.conf.update(
task_serializer="json",
accept_content=["json"],
result_serializer="json",
timezone="UTC",
enable_utc=True,
task_track_started=True,
task_time_limit=300,
worker_prefetch_multiplier=4,
worker_max_tasks_per_child=1000,
)
+73
View File
@@ -0,0 +1,73 @@
from pydantic_settings import BaseSettings
from typing import Optional
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
ENV_FILE = PROJECT_ROOT / ".env"
class Settings(BaseSettings):
model_config = {"env_file": str(ENV_FILE), "extra": "ignore"}
APP_NAME: str = "TradeMate"
SECRET_KEY: str
JWT_ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
DATABASE_URL: str
DB_ECHO: bool = False
REDIS_URL: str = "redis://localhost:6379/0"
CELERY_BROKER_URL: str = "redis://localhost:6379/1"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/2"
OPENAI_API_KEY: Optional[str] = None
ANTHROPIC_API_KEY: Optional[str] = None
DEEPL_API_KEY: Optional[str] = None
LOCAL_MODEL_ENABLED: bool = False
LOCAL_MODEL_URL: str = "http://localhost:8001"
WHATSAPP_API_TOKEN: Optional[str] = None
WHATSAPP_PHONE_NUMBER_ID: Optional[str] = None
WHATSAPP_WEBHOOK_VERIFY_TOKEN: Optional[str] = None
WECHAT_APP_ID: Optional[str] = None
WECHAT_APP_SECRET: Optional[str] = None
EXCHANGE_RATE_API_KEY: Optional[str] = None
UPLOAD_DIR: str = "./uploads"
MAX_UPLOAD_SIZE: int = 10 * 1024 * 1024
FRONTEND_URL: str = "http://localhost:3000"
BACKEND_URL: str = "http://localhost:8000"
AI_ROUTING: dict = {
"translate": {"primary": "deepl", "fallback": ["openai", "local"]},
"reply": {"primary": "openai", "fallback": ["anthropic", "local"]},
"marketing": {"primary": "anthropic", "fallback": ["openai", "local"]},
"extract": {"primary": "openai", "fallback": ["anthropic"]},
"quotation": {"primary": "openai", "fallback": ["anthropic"]},
}
FREE_DAILY_TRANSLATE_CHARS: int = 5000
FREE_DAILY_REPLIES: int = 20
FREE_DAILY_MARKETING: int = 5
FREE_MAX_CUSTOMERS: int = 5
FREE_MAX_PRODUCTS: int = 1
FREE_DAILY_QUOTATIONS: int = 3
PRO_DAILY_TRANSLATE_CHARS: int = 50000
PRO_DAILY_REPLIES: int = 200
PRO_DAILY_MARKETING: int = 50
PRO_MAX_CUSTOMERS: int = 100
PRO_MAX_PRODUCTS: int = 20
PRO_DAILY_QUOTATIONS: int = 30
settings = Settings()
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
+33
View File
@@ -0,0 +1,33 @@
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.config import settings
async_engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DB_ECHO,
pool_size=20,
max_overflow=10,
pool_pre_ping=True,
)
AsyncSessionLocal = sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
autocommit=False,
autoflush=False,
)
Base = declarative_base()
async def get_db() -> AsyncSession:
async with AsyncSessionLocal() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
finally:
await session.close()
+53
View File
@@ -0,0 +1,53 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.core.exceptions import register_exception_handlers
from app.core.middleware import TierMiddleware, QuotaMiddleware
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(
title=settings.APP_NAME,
version="1.0.0",
docs_url="/docs",
redoc_url="/redoc",
)
app.add_middleware(
CORSMiddleware,
allow_origins=[settings.FRONTEND_URL, "*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(TierMiddleware)
app.add_middleware(QuotaMiddleware)
register_exception_handlers(app)
@app.get("/health")
async def health():
return {"status": "ok", "app": settings.APP_NAME, "version": "1.0.0"}
from app.api.v1 import auth, marketing, translate, customer, quotation, whatsapp, product, exchange, push
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
app.include_router(marketing.router, prefix="/api/v1/marketing", tags=["marketing"])
app.include_router(translate.router, prefix="/api/v1/translate", tags=["translate"])
app.include_router(customer.router, prefix="/api/v1/customers", tags=["customers"])
app.include_router(quotation.router, prefix="/api/v1/quotations", tags=["quotations"])
app.include_router(whatsapp.router, prefix="/api/v1/whatsapp", tags=["whatsapp"])
app.include_router(product.router, prefix="/api/v1/products", tags=["products"])
app.include_router(exchange.router, prefix="/api/v1/exchange", tags=["exchange"])
app.include_router(push.router, prefix="/api/v1/push", tags=["push"])
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
+11
View File
@@ -0,0 +1,11 @@
from .user import User, Product
from .customer import Customer, Conversation, Message
from .quotation import Quotation, QuotationItem
from .corpus import CorpusEntry
__all__ = [
"User", "Product",
"Customer", "Conversation", "Message",
"Quotation", "QuotationItem",
"CorpusEntry",
]
+26
View File
@@ -0,0 +1,26 @@
from sqlalchemy import Column, String, Integer, DateTime, Text, Float
from sqlalchemy.dialects.postgresql import UUID, JSONB
from pgvector.sqlalchemy import Vector
from datetime import datetime
from app.database import Base
import uuid
class CorpusEntry(Base):
__tablename__ = "corpus_entries"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
source_text = Column(Text, nullable=False)
target_text = Column(Text, nullable=False)
source_lang = Column(String(20))
target_lang = Column(String(20))
task_type = Column(String(50), nullable=False)
domain = Column(String(100), default="general")
provider_used = Column(String(50))
quality_score = Column(Float, default=0.5)
user_edited = Column(Boolean, default=False)
user_rating = Column(Integer)
usage_count = Column(Integer, default=0)
embedding = Column(Vector(768))
metadata = Column(JSONB, default={})
created_at = Column(DateTime, default=datetime.utcnow)
+72
View File
@@ -0,0 +1,72 @@
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text, ForeignKey
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
import uuid
class Customer(Base):
__tablename__ = "customers"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
name = Column(String(255), nullable=False)
company = Column(String(255))
country = Column(String(100))
phone = Column(String(50))
email = Column(String(255))
whatsapp_id = Column(String(255))
source = Column(String(100))
tags = Column(JSONB, default=[])
notes = Column(Text)
preference = Column(JSONB, default={})
status = Column(String(50), default="lead")
last_contact_at = Column(DateTime)
silence_started_at = Column(DateTime)
next_followup_at = Column(DateTime)
estimated_value = Column(String(50))
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="customers")
conversations = relationship("Conversation", back_populates="customer", cascade="all, delete-orphan")
quotations = relationship("Quotation", back_populates="customer")
class Conversation(Base):
__tablename__ = "conversations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False, index=True)
channel = Column(String(50), default="whatsapp")
topic = Column(String(255))
status = Column(String(50), default="active")
message_count = Column(Integer, default=0)
last_message_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="conversations")
customer = relationship("Customer", back_populates="conversations")
messages = relationship("Message", back_populates="conversation", cascade="all, delete-orphan", order_by="Message.created_at")
class Message(Base):
__tablename__ = "messages"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
conversation_id = Column(UUID(as_uuid=True), ForeignKey("conversations.id"), nullable=False, index=True)
direction = Column(String(20), nullable=False)
content = Column(Text, nullable=False)
content_translated = Column(Text)
content_type = Column(String(50), default="text")
ai_suggestions = Column(JSONB)
selected_suggestion = Column(Integer)
user_edited = Column(Text)
status = Column(String(50), default="sent")
metadata = Column(JSONB, default={})
created_at = Column(DateTime, default=datetime.utcnow)
conversation = relationship("Conversation", back_populates="messages")
+50
View File
@@ -0,0 +1,50 @@
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text, ForeignKey, Float
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
import uuid
class Quotation(Base):
__tablename__ = "quotations"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
customer_id = Column(UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False)
title = Column(String(255))
status = Column(String(50), default="draft")
currency = Column(String(10), default="USD")
exchange_rate = Column(Float)
payment_terms = Column(String(255))
delivery_terms = Column(String(255))
lead_time = Column(String(100))
valid_until = Column(String(100))
subtotal = Column(Float)
discount = Column(Float, default=0)
shipping = Column(Float, default=0)
total = Column(Float)
notes = Column(Text)
pdf_url = Column(Text)
sent_at = Column(DateTime)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="quotations")
customer = relationship("Customer", back_populates="quotations")
items = relationship("QuotationItem", back_populates="quotation", cascade="all, delete-orphan")
class QuotationItem(Base):
__tablename__ = "quotation_items"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
quotation_id = Column(UUID(as_uuid=True), ForeignKey("quotations.id"), nullable=False, index=True)
product_name = Column(String(255), nullable=False)
description = Column(Text)
quantity = Column(Integer, nullable=False)
unit_price = Column(Float, nullable=False)
total_price = Column(Float)
unit = Column(String(50), default="pcs")
quotation = relationship("Quotation", back_populates="items")
+54
View File
@@ -0,0 +1,54 @@
from sqlalchemy import Column, String, Boolean, Integer, DateTime, Text
from sqlalchemy.dialects.postgresql import UUID, JSONB
from sqlalchemy.orm import relationship
from datetime import datetime
from app.database import Base
import uuid
class User(Base):
__tablename__ = "users"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
wechat_openid = Column(String(255), unique=True, index=True)
phone = Column(String(20), unique=True, index=True)
username = Column(String(100))
password_hash = Column(String(255))
tier = Column(String(50), default="free")
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
settings = Column(JSONB, default={
"preferred_translate_provider": "auto",
"reply_tone": "professional",
"timezone": "Asia/Shanghai",
"languages": ["zh", "en"],
})
products = relationship("Product", back_populates="user", cascade="all, delete-orphan")
customers = relationship("Customer", back_populates="user", cascade="all, delete-orphan")
conversations = relationship("Conversation", back_populates="user", cascade="all, delete-orphan")
quotations = relationship("Quotation", back_populates="user", cascade="all, delete-orphan")
class Product(Base):
__tablename__ = "products"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True)
name = Column(String(255), nullable=False)
name_en = Column(String(255))
description = Column(Text)
description_en = Column(Text)
category = Column(String(100))
price = Column(String(50))
price_unit = Column(String(20), default="USD")
moq = Column(String(50))
keywords = Column(JSONB, default=[])
specifications = Column(JSONB, default={})
images = Column(JSONB, default=[])
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
user = relationship("User", back_populates="products")
View File
+204
View File
@@ -0,0 +1,204 @@
from typing import Dict, Any, Optional, List
from datetime import datetime, timedelta
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from app.models.customer import Customer, Conversation, Message
import logging
logger = logging.getLogger(__name__)
class CustomerService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_customers(self, user_id: str, status: Optional[str] = None, page: int = 1, size: int = 20) -> Dict[str, Any]:
query = select(Customer).where(Customer.user_id == user_id)
count_query = select(func.count()).select_from(Customer).where(Customer.user_id == user_id)
if status:
query = query.where(Customer.status == status)
count_query = count_query.where(Customer.status == status)
query = query.order_by(Customer.updated_at.desc()).offset((page - 1) * size).limit(size)
total = await self.db.execute(count_query)
result = await self.db.execute(query)
customers = result.scalars().all()
return {
"items": [self._to_dict(c) for c in customers],
"total": total.scalar(),
"page": page,
"size": size,
}
async def get_customer(self, user_id: str, customer_id: str) -> Optional[Dict]:
result = await self.db.execute(
select(Customer).where(
and_(Customer.id == customer_id, Customer.user_id == user_id)
)
)
c = result.scalar_one_or_none()
return self._to_dict(c) if c else None
async def create_customer(self, user_id: str, data: Dict[str, Any]) -> Dict:
c = Customer(user_id=user_id, **data)
self.db.add(c)
await self.db.flush()
return self._to_dict(c)
async def update_customer(self, user_id: str, customer_id: str, data: Dict[str, Any]) -> Optional[Dict]:
result = await self.db.execute(
select(Customer).where(
and_(Customer.id == customer_id, Customer.user_id == user_id)
)
)
c = result.scalar_one_or_none()
if not c:
return None
for k, v in data.items():
if hasattr(c, k):
setattr(c, k, v)
await self.db.flush()
return self._to_dict(c)
async def delete_customer(self, user_id: str, customer_id: str) -> bool:
result = await self.db.execute(
select(Customer).where(
and_(Customer.id == customer_id, Customer.user_id == user_id)
)
)
c = result.scalar_one_or_none()
if not c:
return False
await self.db.delete(c)
return True
async def get_silent_customers(self, user_id: str, days: int = 3) -> List[Dict]:
cutoff = datetime.utcnow() - timedelta(days=days)
result = await self.db.execute(
select(Customer)
.where(
and_(
Customer.user_id == user_id,
Customer.status.in_(["lead", "negotiating"]),
Customer.last_contact_at.isnot(None),
Customer.last_contact_at < cutoff,
)
)
.order_by(Customer.last_contact_at.asc())
)
return [self._to_dict(c) for c in result.scalars().all()]
async def record_contact(self, user_id: str, customer_id: str):
now = datetime.utcnow()
result = await self.db.execute(
select(Customer).where(
and_(Customer.id == customer_id, Customer.user_id == user_id)
)
)
c = result.scalar_one_or_none()
if c:
c.last_contact_at = now
c.silence_started_at = None
c.next_followup_at = now + timedelta(days=3)
await self.db.flush()
async def get_conversation(self, user_id: str, customer_id: str, page: int = 1, size: int = 50) -> Dict[str, Any]:
conv_query = select(Conversation).where(
and_(Conversation.user_id == user_id, Conversation.customer_id == customer_id)
).order_by(Conversation.created_at.desc()).limit(1)
conv_result = await self.db.execute(conv_query)
conv = conv_result.scalar_one_or_none()
if not conv:
return {"messages": [], "total": 0, "conversation_id": None}
msg_query = (
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at.asc())
.offset((page - 1) * size)
.limit(size)
)
msg_result = await self.db.execute(msg_query)
messages = msg_result.scalars().all()
return {
"conversation_id": str(conv.id),
"messages": [
{
"id": str(m.id),
"direction": m.direction,
"content": m.content,
"content_translated": m.content_translated,
"ai_suggestions": m.ai_suggestions,
"selected_suggestion": m.selected_suggestion,
"created_at": m.created_at.isoformat() if m.created_at else None,
}
for m in messages
],
"total": conv.message_count,
}
async def save_message(
self, user_id: str, customer_id: str, direction: str, content: str,
translation: Optional[str] = None, suggestions: Optional[List] = None,
) -> Dict:
conv_result = await self.db.execute(
select(Conversation).where(
and_(Conversation.user_id == user_id, Conversation.customer_id == customer_id)
).order_by(Conversation.created_at.desc()).limit(1)
)
conv = conv_result.scalar_one_or_none()
if not conv:
conv = Conversation(
user_id=user_id,
customer_id=customer_id,
channel="whatsapp",
status="active",
)
self.db.add(conv)
await self.db.flush()
msg = Message(
conversation_id=conv.id,
direction=direction,
content=content,
content_translated=translation,
ai_suggestions=suggestions,
)
self.db.add(msg)
conv.message_count = (conv.message_count or 0) + 1
conv.last_message_at = datetime.utcnow()
await self.db.flush()
await self.record_contact(user_id, customer_id)
return {
"message_id": str(msg.id),
"conversation_id": str(conv.id),
"direction": direction,
"content": content,
}
def _to_dict(self, c: Customer) -> Dict:
if not c:
return {}
return {
"id": str(c.id),
"name": c.name,
"company": c.company,
"country": c.country,
"phone": c.phone,
"email": c.email,
"whatsapp_id": c.whatsapp_id,
"source": c.source,
"tags": c.tags,
"status": c.status,
"last_contact_at": c.last_contact_at.isoformat() if c.last_contact_at else None,
"silence_days": (datetime.utcnow() - c.last_contact_at).days if c.last_contact_at else 0,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
+84
View File
@@ -0,0 +1,84 @@
from typing import Dict, Any, Optional, List
from app.ai.router import get_ai_router
import logging
logger = logging.getLogger(__name__)
class MarketingService:
def __init__(self):
self.ai = get_ai_router()
async def generate(
self,
product_info: Dict[str, Any],
target: str,
style: str = "professional",
language: str = "en",
count: int = 3,
) -> List[Dict[str, Any]]:
results = []
styles = self._get_style_variants(style, count)
for s in styles:
try:
result = await self.ai.marketing(product_info, target, s, language)
results.append({
"content": result.get("content", ""),
"style": s,
"provider": result.get("provider_used", "unknown"),
})
except Exception as e:
logger.warning(f"Marketing generation failed for style '{s}': {e}")
results.append({"content": "", "style": s, "error": str(e)})
return results
async def generate_keywords(
self, product_info: Dict[str, Any], language: str = "en", count: int = 10
) -> List[str]:
try:
schema = {
"type": "object",
"properties": {
"keywords": {
"type": "array",
"items": {"type": "string"},
}
},
}
text = f"Product: {product_info.get('name', '')}. {product_info.get('description', '')}"
result = await self.ai.extract(text, schema)
keywords = result.get("data", {}).get("keywords", [])
return keywords[:count]
except Exception as e:
logger.warning(f"Keyword generation failed: {e}")
return []
def _get_style_variants(self, base_style: str, count: int) -> List[str]:
all_styles = ["professional", "friendly", "urgent", "benefit_focused", "storytelling"]
if base_style in all_styles:
all_styles.remove(base_style)
all_styles.insert(0, base_style)
return all_styles[:count]
async def analyze_competitors(
self, product_info: Dict[str, Any], market: str = "US"
) -> Dict[str, Any]:
try:
text = f"Product: {product_info.get('name', '')} in {market} market. Category: {product_info.get('category', '')}. Description: {product_info.get('description', '')}"
schema = {
"type": "object",
"properties": {
"price_range": {"type": "string"},
"key_selling_points": {"type": "array", "items": {"type": "string"}},
"common_keywords": {"type": "array", "items": {"type": "string"}},
"market_trends": {"type": "string"},
"suggestions": {"type": "array", "items": {"type": "string"}},
},
}
result = await self.ai.extract(text, schema)
return result.get("data", {})
except Exception as e:
logger.warning(f"Competitor analysis failed: {e}")
return {}
+100
View File
@@ -0,0 +1,100 @@
from typing import Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from app.models.user import Product
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class ProductService:
def __init__(self, db: AsyncSession):
self.db = db
async def list_products(self, user_id: str, category: Optional[str] = None, page: int = 1, size: int = 20) -> Dict[str, Any]:
query = select(Product).where(Product.user_id == user_id, Product.is_active == True)
count_query = select(func.count()).select_from(Product).where(Product.user_id == user_id, Product.is_active == True)
if category:
query = query.where(Product.category == category)
count_query = count_query.where(Product.category == category)
query = query.order_by(Product.updated_at.desc()).offset((page - 1) * size).limit(size)
total = await self.db.execute(count_query)
result = await self.db.execute(query)
products = result.scalars().all()
return {
"items": [self._to_dict(p) for p in products],
"total": total.scalar(),
"page": page,
"size": size,
}
async def get_product(self, user_id: str, product_id: str) -> Optional[Dict]:
result = await self.db.execute(
select(Product).where(
and_(Product.id == product_id, Product.user_id == user_id)
)
)
p = result.scalar_one_or_none()
return self._to_dict(p) if p else None
async def create_product(self, user_id: str, data: Dict[str, Any]) -> Dict:
p = Product(user_id=user_id, **data)
self.db.add(p)
await self.db.flush()
return self._to_dict(p)
async def update_product(self, user_id: str, product_id: str, data: Dict[str, Any]) -> Optional[Dict]:
result = await self.db.execute(
select(Product).where(
and_(Product.id == product_id, Product.user_id == user_id)
)
)
p = result.scalar_one_or_none()
if not p:
return None
for k, v in data.items():
if v is not None and hasattr(p, k):
setattr(p, k, v)
await self.db.flush()
return self._to_dict(p)
async def delete_product(self, user_id: str, product_id: str) -> bool:
result = await self.db.execute(
select(Product).where(
and_(Product.id == product_id, Product.user_id == user_id)
)
)
p = result.scalar_one_or_none()
if not p:
return False
p.is_active = False
await self.db.flush()
return True
def _to_dict(self, p: Product) -> Dict:
if not p:
return {}
return {
"id": str(p.id),
"name": p.name,
"name_en": p.name_en,
"description": p.description,
"description_en": p.description_en,
"category": p.category,
"price": p.price,
"price_unit": p.price_unit,
"moq": p.moq,
"keywords": p.keywords or [],
"specifications": p.specifications or {},
"images": p.images or [],
"is_active": p.is_active,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
}
+166
View File
@@ -0,0 +1,166 @@
from typing import Dict, Any, Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.models.quotation import Quotation, QuotationItem
from app.models.customer import Customer
from app.models.user import Product
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
class QuotationService:
def __init__(self, db: AsyncSession):
self.db = db
async def create_quotation(self, user_id: str, data: Dict[str, Any]) -> Dict:
items_data = data.pop("items", [])
if data.get("customer_id"):
cust_result = await self.db.execute(
select(Customer).where(
and_(Customer.id == data["customer_id"], Customer.user_id == user_id)
)
)
if not cust_result.scalar_one_or_none():
raise ValueError("Customer not found")
q = Quotation(user_id=user_id, **data)
self.db.add(q)
await self.db.flush()
total = 0
for item_data in items_data:
item_total = item_data.get("quantity", 0) * item_data.get("unit_price", 0)
item = QuotationItem(
quotation_id=q.id,
product_name=item_data["product_name"],
description=item_data.get("description"),
quantity=item_data["quantity"],
unit_price=item_data["unit_price"],
total_price=item_total,
unit=item_data.get("unit", "pcs"),
)
self.db.add(item)
total += item_total
q.subtotal = total
q.total = total - (data.get("discount", 0)) + (data.get("shipping", 0))
await self.db.flush()
return await self._to_dict(q)
async def get_quotation(self, user_id: str, quotation_id: str) -> Optional[Dict]:
result = await self.db.execute(
select(Quotation).where(
and_(Quotation.id == quotation_id, Quotation.user_id == user_id)
)
)
q = result.scalar_one_or_none()
return await self._to_dict(q) if q else None
async def list_quotations(self, user_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]:
from sqlalchemy import func
query = select(Quotation).where(Quotation.user_id == user_id).order_by(Quotation.created_at.desc()).offset((page - 1) * size).limit(size)
count_query = select(func.count()).select_from(Quotation).where(Quotation.user_id == user_id)
total = await self.db.execute(count_query)
result = await self.db.execute(query)
quotations = result.scalars().all()
items = []
for q in quotations:
items.append(await self._to_dict(q))
return {"items": items, "total": total.scalar(), "page": page, "size": size}
async def update_status(self, user_id: str, quotation_id: str, status: str) -> Optional[Dict]:
result = await self.db.execute(
select(Quotation).where(
and_(Quotation.id == quotation_id, Quotation.user_id == user_id)
)
)
q = result.scalar_one_or_none()
if not q:
return None
q.status = status
if status == "sent":
q.sent_at = datetime.utcnow()
await self.db.flush()
return await self._to_dict(q)
async def generate_quotation_text(self, q: Quotation) -> str:
items_result = await self.db.execute(
select(QuotationItem).where(QuotationItem.quotation_id == q.id)
)
items = items_result.scalars().all()
lines = [f"QUOTATION", f"", f"Date: {datetime.utcnow().strftime('%Y-%m-%d')}"]
if q.valid_until:
lines.append(f"Valid until: {q.valid_until}")
lines.append(f"")
lines.append(f"{'Item':<30} {'Qty':<10} {'Unit Price':<15} {'Total':<15}")
lines.append("-" * 70)
for item in items:
lines.append(f"{item.product_name:<30} {item.quantity:<10} ${item.unit_price:<12.2f} ${item.total_price:<10.2f}")
lines.append("-" * 70)
if q.subtotal:
lines.append(f"{'Subtotal':>55} ${q.subtotal:<10.2f}")
if q.discount:
lines.append(f"{'Discount':>55} -${q.discount:<9.2f}")
if q.shipping:
lines.append(f"{'Shipping':>55} ${q.shipping:<10.2f}")
lines.append(f"{'TOTAL':>55} ${q.total or q.subtotal or 0:<10.2f}")
lines.append(f"")
if q.payment_terms:
lines.append(f"Payment: {q.payment_terms}")
if q.delivery_terms:
lines.append(f"Delivery: {q.delivery_terms}")
if q.lead_time:
lines.append(f"Lead time: {q.lead_time}")
if q.notes:
lines.append(f"")
lines.append(f"Notes: {q.notes}")
return "\n".join(lines)
async def _to_dict(self, q: Quotation) -> Dict:
items_result = await self.db.execute(
select(QuotationItem).where(QuotationItem.quotation_id == q.id)
)
items = items_result.scalars().all()
return {
"id": str(q.id),
"customer_id": str(q.customer_id) if q.customer_id else None,
"title": q.title,
"status": q.status,
"currency": q.currency,
"exchange_rate": q.exchange_rate,
"payment_terms": q.payment_terms,
"delivery_terms": q.delivery_terms,
"lead_time": q.lead_time,
"valid_until": q.valid_until,
"subtotal": q.subtotal,
"discount": q.discount,
"shipping": q.shipping,
"total": q.total,
"notes": q.notes,
"items": [
{
"product_name": i.product_name,
"description": i.description,
"quantity": i.quantity,
"unit_price": i.unit_price,
"total_price": i.total_price,
"unit": i.unit,
}
for i in items
],
"text": await self.generate_quotation_text(q),
"sent_at": q.sent_at.isoformat() if q.sent_at else None,
"created_at": q.created_at.isoformat() if q.created_at else None,
}
+115
View File
@@ -0,0 +1,115 @@
from typing import Dict, Any, Optional, List
from app.ai.router import get_ai_router
from app.ai.trade_corpus import TradeCorpus
import logging
logger = logging.getLogger(__name__)
class TranslationService:
def __init__(self):
self.ai = get_ai_router()
self.corpus = TradeCorpus()
async def translate(
self, text: str, target_lang: str, source_lang: Optional[str] = None,
context: Optional[str] = None, user_id: Optional[str] = None,
) -> Dict[str, Any]:
similar = await self.corpus.find_similar(text, "translate")
if similar:
best = similar[0]
if len(best["source"]) > 20 and self._similarity_ratio(text, best["source"]) > 0.85:
return {
"translated_text": best["target"],
"source_lang": source_lang or "auto",
"provider_used": "corpus_cache",
"from_cache": True,
}
result = await self.ai.translate(text, target_lang, source_lang, context)
translated = result.get("translated_text", "")
provider = result.get("provider_used", "unknown")
await self.corpus.record(
source_text=text,
target_text=translated,
task_type="translate",
provider=provider,
source_lang=source_lang,
target_lang=target_lang,
metadata={"user_id": user_id} if user_id else None,
)
result["source_lang"] = result.get("detected_source_lang", source_lang or "auto")
result["from_cache"] = False
return result
async def generate_reply(
self, inquiry: str, context: Optional[Dict[str, Any]] = None,
tone: str = "professional", count: int = 3,
) -> List[Dict[str, Any]]:
similar = await self.corpus.find_similar(inquiry, "reply")
if similar and count > 1:
pass
results = []
tones = self._get_tones(tone, count)
for t in tones:
try:
result = await self.ai.reply(inquiry, context, t)
results.append({
"reply": result.get("reply", ""),
"tone": t,
"provider": result.get("provider_used", "unknown"),
})
except Exception as e:
logger.warning(f"Reply generation failed for tone '{t}': {e}")
results.append({"reply": "", "tone": t, "error": str(e)})
return results
async def extract_info(self, text: str, extract_type: str = "auto") -> Dict[str, Any]:
schemas = {
"product": {
"type": "object",
"properties": {
"product_name": {"type": "string"},
"quantity": {"type": "string"},
"price": {"type": "string"},
"currency": {"type": "string"},
"delivery_terms": {"type": "string"},
"target_country": {"type": "string"},
},
},
"inquiry": {
"type": "object",
"properties": {
"intent": {"type": "string"},
"product_interest": {"type": "string"},
"quantity": {"type": "string"},
"budget": {"type": "string"},
"urgency": {"type": "string"},
"contact_info": {"type": "string"},
},
},
}
schema = schemas.get(extract_type, schemas["inquiry"])
result = await self.ai.extract(text, schema)
return result.get("data", {})
def _get_tones(self, base: str, count: int) -> List[str]:
tones = ["professional", "friendly", "formal"]
if base in tones:
tones.remove(base)
tones.insert(0, base)
return tones[:count]
def _similarity_ratio(self, a: str, b: str) -> float:
if not a or not b:
return 0.0
set_a, set_b = set(a.lower().split()), set(b.lower().split())
if not set_a or not set_b:
return 0.0
return len(set_a & set_b) / len(set_a | set_b)
+109
View File
@@ -0,0 +1,109 @@
from typing import Dict, Any, Optional
import httpx
import hashlib
import hmac
import logging
from app.config import settings
logger = logging.getLogger(__name__)
class WhatsAppService:
def __init__(self):
self.api_token = settings.WHATSAPP_API_TOKEN
self.phone_number_id = settings.WHATSAPP_PHONE_NUMBER_ID
self.api_base = f"https://graph.facebook.com/v18.0/{self.phone_number_id}"
def verify_webhook(self, mode: str, token: str, challenge: str) -> Optional[str]:
if mode == "subscribe" and token == settings.WHATSAPP_WEBHOOK_VERIFY_TOKEN:
return challenge
return None
def verify_signature(self, body: bytes, signature: str) -> bool:
if not signature:
return False
expected = hmac.new(
settings.WHATSAPP_API_TOKEN.encode(),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
async def send_text(self, to: str, text: str) -> bool:
if not self.api_token or not self.phone_number_id:
logger.warning("WhatsApp not configured")
return False
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/messages",
headers={
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
},
json={
"messaging_product": "whatsapp",
"to": to,
"type": "text",
"text": {"body": text},
},
timeout=15,
)
if resp.status_code != 200:
logger.error(f"WhatsApp send failed: {resp.text}")
return False
return True
async def send_template(self, to: str, template_name: str, params: Dict[str, str]) -> bool:
if not self.api_token or not self.phone_number_id:
return False
components = [
{
"type": "body",
"parameters": [
{"type": "text", "text": v} for v in params.values()
],
}
]
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.api_base}/messages",
headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"},
json={
"messaging_product": "whatsapp",
"to": to,
"type": "template",
"template": {
"name": template_name,
"language": {"code": "en"},
"components": components,
},
},
timeout=15,
)
return resp.status_code == 200
def parse_webhook(self, body: Dict) -> Optional[Dict]:
try:
entry = body.get("entry", [{}])[0]
change = entry.get("changes", [{}])[0]
value = change.get("value", {})
messages = value.get("messages", [])
if not messages:
return None
msg = messages[0]
return {
"from": msg.get("from"),
"text": msg.get("text", {}).get("body", ""),
"msg_id": msg.get("id"),
"timestamp": msg.get("timestamp"),
"type": msg.get("type", "text"),
"profile_name": value.get("contacts", [{}])[0].get("profile", {}).get("name"),
}
except Exception as e:
logger.warning(f"Failed to parse WhatsApp webhook: {e}")
return None
View File
+193
View File
@@ -0,0 +1,193 @@
from datetime import datetime, timedelta
from celery import shared_task
from sqlalchemy import select, and_
import logging
logger = logging.getLogger(__name__)
@shared_task
def check_silent_customers():
from app.database import AsyncSessionLocal
from app.models.customer import Customer
async def _check():
async with AsyncSessionLocal() as db:
now = datetime.utcnow()
for days in [3, 7, 14]:
cutoff = now - timedelta(days=days)
result = await db.execute(
select(Customer).where(
and_(
Customer.status.in_(["lead", "negotiating"]),
Customer.last_contact_at.isnot(None),
Customer.last_contact_at < cutoff,
)
)
)
customers = result.scalars().all()
for c in customers:
if days == 3:
logger.info(f"Customer {c.name} silent for 3 days")
elif days == 7:
logger.info(f"Customer {c.name} silent for 7 days - upgrade")
else:
logger.info(f"Customer {c.name} silent for 14 days - recommend new approach")
import asyncio
asyncio.run(_check())
return "Checked silent customers"
@shared_task
def batch_translate_texts(texts: list, target_lang: str, user_id: str):
from app.services.translation import TranslationService
async def _translate():
service = TranslationService()
results = []
for text in texts:
result = await service.translate(text, target_lang, user_id=user_id)
results.append(result)
return results
import asyncio
return asyncio.run(_translate())
@shared_task
def generate_quotation_pdf(quotation_id: str):
from app.database import AsyncSessionLocal
from app.models.quotation import Quotation, QuotationItem
async def _generate():
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Quotation).where(Quotation.id == quotation_id)
)
q = result.scalar_one_or_none()
if not q:
return {"error": "Quotation not found"}
items_result = await db.execute(
select(QuotationItem).where(QuotationItem.quotation_id == q.id)
)
items = items_result.scalars().all()
pdf_content = generate_pdf_text(q, items)
return {"pdf_content": pdf_content, "quotation_id": str(q.id)}
import asyncio
return asyncio.run(_generate())
def generate_pdf_text(quotation, items):
from datetime import datetime
lines = [
"=" * 60,
f"QUOTATION",
f"#{str(quotation.id)[:8].upper()}",
"=" * 60,
f"Date: {datetime.utcnow().strftime('%Y-%m-%d')}",
]
if quotation.valid_until:
lines.append(f"Valid Until: {quotation.valid_until}")
lines.append("")
lines.append(f"{'Item':<30} {'Qty':<8} {'Unit Price':<12} {'Total':<12}")
lines.append("-" * 62)
for item in items:
lines.append(
f"{item.product_name:<30} {item.quantity:<8} ${item.unit_price:<10.2f} ${item.total_price:<10.2f}"
)
lines.append("-" * 62)
if quotation.subtotal:
lines.append(f"{'Subtotal':>48} ${quotation.subtotal:<10.2f}")
if quotation.discount:
lines.append(f"{'Discount':>48} -${quotation.discount:<10.2f}")
if quotation.shipping:
lines.append(f"{'Shipping':>48} ${quotation.shipping:<10.2f}")
lines.append(f"{'TOTAL':>48} ${quotation.total or quotation.subtotal or 0:<10.2f}")
lines.append("")
if quotation.payment_terms:
lines.append(f"Payment Terms: {quotation.payment_terms}")
if quotation.delivery_terms:
lines.append(f"Delivery Terms: {quotation.delivery_terms}")
if quotation.lead_time:
lines.append(f"Lead Time: {quotation.lead_time}")
if quotation.notes:
lines.append(f"Notes: {quotation.notes}")
lines.append("=" * 60)
lines.append("Generated by TradeMate")
return "\n".join(lines)
@shared_task
def process_corpus_quality():
from app.database import AsyncSessionLocal
from app.models.corpus import CorpusEntry
async def _process():
async with AsyncSessionLocal() as db:
result = await db.execute(
select(CorpusEntry).where(
and_(
CorpusEntry.quality_score < 0.5,
CorpusEntry.usage_count > 5,
)
).limit(100)
)
entries = result.scalars().all()
for e in entries:
e.quality_score = min(1.0, e.quality_score + 0.1)
await db.commit()
return f"Processed {len(entries)} entries"
import asyncio
return asyncio.run(_process())
@shared_task
def cleanup_old_sessions():
import redis.asyncio as aioredis
async def _cleanup():
r = await aioredis.from_url(settings.REDIS_URL)
keys = await r.keys("session:*")
if keys:
await r.delete(*keys)
return f"Cleaned up {len(keys)} sessions"
import asyncio
return asyncio.run(_cleanup())
@shared_task
def send_followup_reminder(customer_id: str, user_id: str):
from app.database import AsyncSessionLocal
from app.models.customer import Customer
from app.services.customer import CustomerService
async def _send():
async with AsyncSessionLocal() as db:
result = await db.execute(
select(Customer).where(
and_(Customer.id == customer_id, Customer.user_id == user_id)
)
)
c = result.scalar_one_or_none()
if c:
logger.info(f"Sending followup reminder for customer {c.name}")
return {"customer_id": str(c.id), "customer_name": c.name}
return {"error": "Customer not found"}
import asyncio
return asyncio.run(_send())
+10
View File
@@ -0,0 +1,10 @@
[pytest]
testpaths = tests
python_files = test_*.py
python_classes = Test*
python_functions = test_*
asyncio_mode = auto
addopts = -v --tb=short --cov=app --cov-report=term-missing
filterwarnings =
ignore::DeprecationWarning
ignore::PendingDeprecationWarning
+19
View File
@@ -0,0 +1,19 @@
fastapi==0.79.0
uvicorn==0.19.0
sqlalchemy==1.4.48
asyncpg==0.27.0
pydantic==1.10.12
pydantic-settings==1.1.2
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
redis==4.5.5
celery==5.2.7
httpx==0.23.3
openai==0.27.8
anthropic==0.8.1
jinja2==3.1.2
alembic==1.11.3
pytest==7.4.3
pytest-asyncio==0.21.1
pytest-cov==4.1.0
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"
+88
View File
@@ -0,0 +1,88 @@
version: '3.8'
services:
postgres:
image: pgvector/pgvector:pg15
container_name: tradmate-postgres
environment:
POSTGRES_DB: tradmate
POSTGRES_USER: tradmate
POSTGRES_PASSWORD: tradmate
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tradmate"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: tradmate-redis
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: tradmate-backend
env_file:
- ./backend/.env
ports:
- "8000:8000"
volumes:
- ./backend:/app
- uploads_data:/app/uploads
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
celery-worker:
build:
context: ./backend
dockerfile: Dockerfile
container_name: tradmate-celery
env_file:
- ./backend/.env
volumes:
- ./backend:/app
depends_on:
- redis
- postgres
command: celery -A app.celery_app worker --loglevel=info
celery-beat:
build:
context: ./backend
dockerfile: Dockerfile
container_name: tradmate-celery-beat
env_file:
- ./backend/.env
volumes:
- ./backend:/app
depends_on:
- redis
- postgres
command: celery -A app.celery_app beat --loglevel=info
volumes:
postgres_data:
redis_data:
uploads_data:
networks:
default:
name: tradmate-network
+352
View File
@@ -0,0 +1,352 @@
# 外贸小助手 (TradeMate) — API 设计文档
> 版本: v1.0
> 创建日期: 2026-05-08
---
## 一、Base URL
```
Production: https://api.trademate.example.com/api/v1
Development: http://localhost:8000/api/v1
```
---
## 二、认证方式
采用 JWT Bearer Token 认证,请求 Header 中需携带:
```
Authorization: Bearer <access_token>
```
Token 刷新:access_token 有效期 24 小时,可使用 refresh_token 换新。
---
## 三、API 端点清单
### 1. 认证 API (`/auth`)
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| POST | `/auth/register` | 用户注册 | 否 |
| POST | `/auth/login` | 用户登录 | 否 |
| POST | `/auth/refresh` | 刷新 Token | 是 |
| GET | `/auth/me` | 获取当前用户信息 | 是 |
#### 注册
```http
POST /auth/register
Content-Type: application/json
{
"username": "string",
"phone": "string",
"password": "string"
}
```
#### 登录
```http
POST /auth/login
Content-Type: application/json
{
"username": "string",
"password": "string"
}
```
响应:
```json
{
"access_token": "string",
"refresh_token": "string",
"token_type": "bearer",
"user": {
"id": "uuid",
"username": "string",
"tier": "free"
}
}
```
---
### 2. 客户管理 API (`/customers`)
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| GET | `/customers` | 获取客户列表 | 是 |
| GET | `/customers/silent` | 获取沉默客户列表 | 是 |
| GET | `/customers/{id}` | 获取单个客户详情 | 是 |
| POST | `/customers` | 创建新客户 | 是 |
| PATCH | `/customers/{id}` | 更新客户信息 | 是 |
| DELETE | `/customers/{id}` | 删除客户 | 是 |
| GET | `/customers/{id}/conversation` | 获取客户对话记录 | 是 |
#### 列表查询参数
- `status`: 筛选状态 (lead/negotiating/customer/lost)
- `page`: 页码 (默认 1)
- `size`: 每页数量 (默认 20)
#### 沉默客户
```http
GET /customers/silent?days=3
```
响应:
```json
{
"customers": [
{
"id": "uuid",
"name": "Carlos",
"country": "Mexico",
"last_contact_at": "2026-05-01T10:00:00Z",
"silence_days": 5
}
],
"count": 10,
"silence_days": 3
}
```
---
### 3. 翻译与回复 API (`/translate`)
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| POST | `/translate` | 翻译文本 | 是 |
| POST | `/translate/reply` | 生成回复建议 | 是 |
| POST | `/translate/extract` | 提取关键信息 | 是 |
| POST | `/translate/feedback` | 反馈翻译质量 | 是 |
#### 翻译
```http
POST /translate
Authorization: Bearer <token>
Content-Type: application/json
{
"text": "How much for 500pcs FOB Shanghai?",
"source": "en",
"target": "zh"
}
```
响应:
```json
{
"original": "How much for 500pcs FOB Shanghai?",
"translated": "上海离岸价500件多少钱?",
"provider": "deepl",
"tokens_used": 45
}
```
#### 回复建议
```http
POST /translate/reply
Authorization: Bearer <token>
Content-Type: application/json
{
"customer_message": "Hi, I'm interested in your outdoor chairs. Can you give me a quote for 500pcs?",
"product_context": "户外折叠椅,承重150kg",
"tone": "professional"
}
```
响应:
```json
{
"suggestions": [
{
"text": "Thank you for your inquiry. Our FOB Shanghai price for 500pcs is $12.5/pc. Lead time is 25 days. Payment terms: T/T 30% deposit.",
"tone": "professional",
"reasoning": "Based on询价 context, direct quote with terms"
},
{
"text": "Hi! Thanks for reaching out. I'd be happy to help you with our outdoor folding chairs. $12.5/pc for 500pcs, FOB Shanghai. Want more details?",
"tone": "friendly",
"reasoning": "More casual approach for initial contact"
}
]
}
```
---
### 4. 营销素材 API (`/marketing`)
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| POST | `/marketing/generate` | 生成营销文案 | 是 |
| POST | `/marketing/keywords` | 生成关键词建议 | 是 |
| POST | `/marketing/competitor-analysis` | 竞品分析 | 是 |
#### 生成文案
```http
POST /marketing/generate
Authorization: Bearer <token>
Content-Type: application/json
{
"product_name": "户外折叠椅",
"description": "承重150kg,防水面料,带杯架和扶手",
"category": "furniture",
"price": "$25",
"target": "US importers",
"style": "professional",
"language": "en",
"count": 3
}
```
响应:
```json
{
"results": [
"Premium Outdoor Folding Chair - 150kg Load Capacity, Waterproof Fabric, Cup Holder & Armrests. Perfect for patios, camping, and events. FDA certified manufacturer.",
"Heavy-Duty Folding Chair: 150kg capacity, waterproof, portable. Ideal for restaurants, events, outdoor venues. MOQ: 100pcs. FOB Shanghai.",
"Upgrade your outdoor seating! Our folding chairs feature reinforced steel frame, waterproof fabric, and convenient cup holder. Bulk orders welcome."
],
"product": "户外折叠椅",
"target": "US importers",
"count": 3
}
```
---
### 5. 报价单 API (`/quotations`)
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| POST | `/quotations` | 创建报价单 | 是 |
| GET | `/quotations` | 获取报价单列表 | 是 |
| GET | `/quotations/{id}` | 获取报价单详情 | 是 |
| PATCH | `/quotations/{id}/status` | 更新报价单状态 | 是 |
#### 创建报价单
```http
POST /quotations
Authorization: Bearer <token>
Content-Type: application/json
{
"customer_id": "uuid",
"title": "Quote for Outdoor Chairs",
"currency": "USD",
"payment_terms": "T/T 30% deposit",
"delivery_terms": "FOB Shanghai",
"lead_time": "25 days",
"valid_until": "2026-06-08",
"items": [
{
"product_name": "Outdoor Folding Chair",
"description": "150kg capacity, waterproof",
"quantity": 500,
"unit_price": 12.5,
"unit": "pcs"
}
],
"discount": 0,
"shipping": 0,
"notes": "Sample quote"
}
```
响应:
```json
{
"id": "uuid",
"customer_id": "uuid",
"title": "Quote for Outdoor Chairs",
"status": "draft",
"currency": "USD",
"subtotal": 6250,
"total": 6250,
"items": [...],
"text": "QUOTATION\n\nDate: 2026-05-08...",
"created_at": "2026-05-08T10:00:00Z"
}
```
---
### 6. WhatsApp API (`/whatsapp`)
| 方法 | 路径 | 描述 | 认证 |
|------|------|------|------|
| GET | `/whatsapp/webhook` | Webhook 验证 | 否 |
| POST | `/whatsapp/webhook` | Webhook 接收消息 | 否 |
| POST | `/whatsapp/send` | 发送消息 | 是 |
| GET | `/whatsapp/qr` | 获取二维码 | 是 |
#### Webhook 验证
```http
GET /whatsapp/webhook?hub.mode=subscribe&hub.challenge=STRING&hub.verify_token=TOKEN
```
#### 发送消息
```http
POST /whatsapp/send
Authorization: Bearer <token>
Content-Type: application/json
{
"customer_id": "uuid",
"message": "Thank you for your inquiry. Our price is $12.5/pc."
}
```
---
## 四、错误响应格式
```json
{
"error": "error_code",
"detail": "具体错误描述",
"timestamp": "2026-05-08T10:00:00Z"
}
```
常见错误码:
- `UNAUTHORIZED`: 未认证
- `FORBIDDEN`: 无权限
- `NOT_FOUND`: 资源不存在
- `QUOTA_EXCEEDED`: 配额超限
- `TIER_LIMITED`: 订阅等级限制
- `VALIDATION_ERROR`: 参数校验失败
---
## 五、速率限制
| 级别 | 请求限制 |
|------|---------|
| 免费版 | 100 请求/分钟 |
| Pro版 | 500 请求/分钟 |
| 企业版 | 2000 请求/分钟 |
---
## 六、WebSocket (可选)
实时翻译进度和消息推送:
```
wss://api.trademate.example.com/ws/translate
```
连接需携带 Token 参数:
```
wss://api.trademate.example.com/ws/translate?token=<access_token>
```
+388
View File
@@ -0,0 +1,388 @@
# 外贸小助手 (TradeMate) — 数据库设计文档
> 版本: v1.0
> 创建日期: 2026-05-08
---
## 一、数据库概述
- **数据库类型**: PostgreSQL 15 + pgvector
- **字符集**: UTF-8
- **时区**: UTC (存储), 应用层转换
---
## 二、实体关系图
```
┌────────────────┐ ┌────────────────┐
│ users │ │ products │
├────────────────┤ ├────────────────┤
│ id (PK) │◄──────│ user_id (FK) │
│ wechat_openid │ │ id (PK) │
│ phone │ │ name │
│ username │ │ name_en │
│ password_hash │ │ description │
│ tier │ │ category │
│ is_active │ │ price │
│ settings (JSON)│ │ keywords (JSON)│
│ created_at │ │ specifications │
└───────┬────────┘ └────────────────┘
│ 1:N
┌────────────────┐ ┌────────────────┐
│ customers │ │ quotations │
├────────────────┤ ├────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) │ │ user_id (FK) │
│ name │ │ customer_id(FK)│
│ company │ │ title │
│ country │ │ status │
│ phone │ │ currency │
│ whatsapp_id │ │ subtotal │
│ status │ │ total │
│ last_contact_at│ │ ... │
└───────┬────────┘ └────────────────┘
│ 1:N
┌────────────────┐ ┌────────────────┐
│ conversations │ │ quotation_items│
├────────────────┤ ├────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) │ │ quotation_id │
│ customer_id(FK)│ │ product_name │
│ channel │ │ quantity │
│ status │ │ unit_price │
│ message_count │ │ total_price │
└───────┬────────┘ └────────────────┘
│ 1:N
┌────────────────┐
│ messages │
├────────────────┤
│ id (PK) │
│ conversation_id│
│ direction │
│ content │
│ content_translt│
│ ai_suggestions │
└────────────────┘
```
---
## 三、表结构定义
### 3.1 用户表 (users)
```sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
wechat_openid VARCHAR(255) UNIQUE,
phone VARCHAR(20) UNIQUE,
username VARCHAR(100),
password_hash VARCHAR(255),
tier VARCHAR(50) DEFAULT 'free',
is_active BOOLEAN DEFAULT true,
settings JSONB DEFAULT '{"preferred_translate_provider": "auto", "reply_tone": "professional"}',
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_users_wechat ON users(wechat_openid);
CREATE INDEX idx_users_phone ON users(phone);
```
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| wechat_openid | VARCHAR | 微信 OpenID (唯一) |
| phone | VARCHAR | 手机号 (唯一) |
| username | VARCHAR | 用户名 |
| password_hash | VARCHAR | 密码 bcrypt 哈希 |
| tier | VARCHAR | 订阅等级: free/pro/enterprise |
| is_active | BOOLEAN | 账户状态 |
| settings | JSONB | 用户偏好设置 |
---
### 3.2 产品表 (products)
```sql
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
name_en VARCHAR(255),
description TEXT,
description_en TEXT,
category VARCHAR(100),
price VARCHAR(50),
price_unit VARCHAR(20) DEFAULT 'USD',
moq VARCHAR(50),
keywords JSONB DEFAULT '[]',
specifications JSONB DEFAULT '{}',
images JSONB DEFAULT '[]',
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_products_user ON products(user_id);
```
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| user_id | UUID | 所属用户 |
| name | VARCHAR | 产品名称 (中文) |
| name_en | VARCHAR | 产品英文名 |
| description | TEXT | 产品描述 |
| price | VARCHAR | 价格 |
| moq | VARCHAR | 最小起订量 |
| keywords | JSONB | 关键词列表 |
---
### 3.3 客户表 (customers)
```sql
CREATE TABLE customers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
company VARCHAR(255),
country VARCHAR(100),
phone VARCHAR(50),
email VARCHAR(255),
whatsapp_id VARCHAR(255),
source VARCHAR(100),
tags JSONB DEFAULT '[]',
notes TEXT,
preference JSONB DEFAULT '{}',
status VARCHAR(50) DEFAULT 'lead',
last_contact_at TIMESTAMP,
silence_started_at TIMESTAMP,
next_followup_at TIMESTAMP,
estimated_value VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_customers_user ON customers(user_id);
CREATE INDEX idx_customers_status ON customers(status);
CREATE INDEX idx_customers_last_contact ON customers(last_contact_at);
```
**status 枚举值**:
- `lead`: 潜在客户
- `negotiating`: 谈判中
- `customer`: 已成交客户
- `lost`: 丢失客户
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| user_id | UUID | 所属用户 |
| name | VARCHAR | 客户姓名 |
| whatsapp_id | VARCHAR | WhatsApp ID |
| status | VARCHAR | 客户状态 |
| last_contact_at | TIMESTAMP | 最后联系时间 |
| silence_started_at | TIMESTAMP | 沉默开始时间 |
| next_followup_at | TIMESTAMP | 下次跟进时间 |
---
### 3.4 对话表 (conversations)
```sql
CREATE TABLE conversations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
customer_id UUID NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
channel VARCHAR(50) DEFAULT 'whatsapp',
topic VARCHAR(255),
status VARCHAR(50) DEFAULT 'active',
message_count INTEGER DEFAULT 0,
last_message_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_conversations_user ON conversations(user_id);
CREATE INDEX idx_conversations_customer ON conversations(customer_id);
```
---
### 3.5 消息表 (messages)
```sql
CREATE TABLE messages (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
direction VARCHAR(20) NOT NULL,
content TEXT NOT NULL,
content_translated TEXT,
content_type VARCHAR(50) DEFAULT 'text',
ai_suggestions JSONB,
selected_suggestion INTEGER,
user_edited TEXT,
status VARCHAR(50) DEFAULT 'sent',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_messages_conversation ON messages(conversation_id);
```
**direction 枚举值**:
- `inbound`: 客户发来
- `outbound`: 用户发出
---
### 3.6 报价单表 (quotations)
```sql
CREATE TABLE quotations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
customer_id UUID REFERENCES customers(id),
title VARCHAR(255),
status VARCHAR(50) DEFAULT 'draft',
currency VARCHAR(10) DEFAULT 'USD',
exchange_rate FLOAT,
payment_terms VARCHAR(255),
delivery_terms VARCHAR(255),
lead_time VARCHAR(100),
valid_until VARCHAR(100),
subtotal FLOAT,
discount FLOAT DEFAULT 0,
shipping FLOAT DEFAULT 0,
total FLOAT,
notes TEXT,
pdf_url TEXT,
sent_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_quotations_user ON quotations(user_id);
CREATE INDEX idx_quotations_customer ON quotations(customer_id);
CREATE INDEX idx_quotations_status ON quotations(status);
```
**status 枚举值**:
- `draft`: 草稿
- `sent`: 已发送
- `accepted`: 已接受
- `rejected`: 已拒绝
- `expired`: 已过期
---
### 3.7 报价单项表 (quotation_items)
```sql
CREATE TABLE quotation_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
quotation_id UUID NOT NULL REFERENCES quotations(id) ON DELETE CASCADE,
product_name VARCHAR(255) NOT NULL,
description TEXT,
quantity INTEGER NOT NULL,
unit_price FLOAT NOT NULL,
total_price FLOAT,
unit VARCHAR(50) DEFAULT 'pcs'
);
CREATE INDEX idx_quotation_items_quotation ON quotation_items(quotation_id);
```
---
### 3.8 语料库表 (corpus_entries)
```sql
CREATE TABLE corpus_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_text TEXT NOT NULL,
target_text TEXT NOT NULL,
source_lang VARCHAR(20),
target_lang VARCHAR(20),
task_type VARCHAR(50) NOT NULL,
domain VARCHAR(100) DEFAULT 'general',
provider_used VARCHAR(50),
quality_score FLOAT DEFAULT 0.5,
user_edited BOOLEAN DEFAULT false,
user_rating INTEGER,
usage_count INTEGER DEFAULT 0,
embedding VECTOR(768),
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_corpus_task ON corpus_entries(task_type);
CREATE INDEX idx_corpus_domain ON corpus_entries(domain);
CREATE INDEX idx_corpus_embedding ON corpus_entries USING ivfflat (embedding vector_cosine_ops);
```
**task_type 枚举值**:
- `translation`: 翻译
- `reply_suggestion`: 回复建议
- `marketing_copy`: 营销文案
---
## 四、pgvector 扩展
```sql
CREATE EXTENSION IF NOT EXISTS vector;
```
语料库表使用向量存储,支持语义相似度搜索:
```sql
-- 查找相似翻译
SELECT * FROM corpus_entries
WHERE task_type = 'translation'
ORDER BY embedding <=> query_embedding
LIMIT 5;
```
---
## 五、索引策略
| 表 | 索引 | 用途 |
|---|------|------|
| users | wechat_openid, phone | 登录查询 |
| products | user_id | 用户产品列表 |
| customers | user_id, status, last_contact_at | 客户列表、沉默检测 |
| conversations | user_id, customer_id | 对话查询 |
| messages | conversation_id | 消息历史 |
| quotations | user_id, customer_id, status | 报价单管理 |
| corpus_entries | task_type, domain, embedding | 语料检索 |
---
## 六、数据保留策略
| 数据类型 | 保留期限 | 原因 |
|---------|---------|------|
| 用户数据 | 永久 | 业务核心 |
| 客户数据 | 永久 | 业务核心 |
| 消息数据 | 2年 | 对话历史 |
| 报价单数据 | 3年 | 订单追溯 |
| 语料数据 | 永久 | AI训练 |
| 日志数据 | 90天 | 调试审计 |
---
## 七、迁移脚本
使用 Alembic 进行数据库迁移,初始迁移见 `backend/alembic/versions/001_initial.py`
+250
View File
@@ -0,0 +1,250 @@
# 外贸小助手 (TradeMate) — 产品设计文档
> 版本: v1.0
> 创建日期: 2026-05-08
> 状态: 初始设计
---
## 一、产品定位
### 1.1 一句话定义
> **微信里的外贸成交助理——帮不懂英文、不懂营销的中小企业,把客户询盘变成订单。**
### 1.2 目标用户
| 画像 | 特征 | 典型场景 |
|------|------|---------|
| **个体外贸SOHO** | 1-2人,什么都自己干 | 每天都在回WhatsApp消息,顾不上主动开发客户 |
| **小型外贸公司老板** | 10-30人,团队没有专业营销 | 员工英文一般,发了开发信没人回复就不知道怎么继续 |
| **工厂转型外贸** | 原来做内销,刚开阿里国际站 | 有产品优势,但不会写英文文案,不知道怎么跟老外沟通 |
### 1.3 核心洞察
外贸成交的本质是三个动作:
```
收到询盘 → 看懂意思 → 回复报价 → 跟进催单
↑ ↑
用户卡在这里 用户卡在这里
(英文不好) (不知道怎么跟进)
```
**我们只解决这三件事,别的都不做。**
---
## 二、功能设计
### 2.1 功能全景图
```
┌─────────────────────────────────────────────────────┐
│ 外贸小助手 │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 营销素材 │ │ 智能沟通 │ │ 客户跟进 │ │
│ │ 工厂 │ │ 助手 │ │ 引擎 │ │
│ ├──────────┤ ├──────────┤ ├──────────┤ │
│ │ 开发信 │ │ 消息翻译 │ │ 沉默检测 │ │
│ │ 产品文案 │ │ 回复建议 │ │ 跟进提醒 │ │
│ │ 关键词 │ │ 一键发送 │ │ 话术推荐 │ │
│ │ 竞品分析 │ │ 语气调整 │ │ 周期提醒 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌─────────────────────────────────────────┐ │
│ │ 跨功能支撑: 报价单生成 / 汇率换算 │ │
│ └─────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
```
### 2.2 功能一:营销素材工厂(帮用户"有内容可发")
#### 用户场景
> "我想给美国客户发邮件推广我的户外折叠椅,但我不知道英文怎么写,也不知道怎么写才有吸引力。"
#### 交互流程
```
[用户打开小程序]
└─ 点击"营销素材"
└─ 输入: "户外折叠椅,承重150kg,防水面料,带杯架和扶手"
└─ 选择: ①英文开发信 ②WhatsApp开场白 ③产品描述 ④关键词建议
└─ AI生成→用户左右滑动筛选→保存→一键复制
[用户过几天再来]
└─ 点击"换一批" → AI 基于同样产品生成不同角度的话术
└─ 点击"效果追踪" → 可以看到哪些文案被复制/发送了多少次
```
#### 核心机制
- 每个用户有自己的**产品库**(首次输入后系统保存)
- 同一个产品可以生成不同风格(正式/亲切/促销)
- 不同目标国家生成适配当地习惯的文案
- **关键**: AI 不是一次性生成,而是持续迭代——用户保存的文案越多,系统越懂用户的偏好风格
### 2.3 功能二:智能沟通助手(帮用户"看懂+回好"
#### 用户场景
> "客户在WhatsApp发了一大段英文,我大概能看懂一些,但不知道怎么回,怕写错了让客户觉得不专业。"
#### 交互流程
```
[客户发来 WhatsApp 消息]
└─ 系统自动翻译成中文(显示在客户消息下方)
└─ 同时给出 3 个回复选项:
① 快速报价(如果消息是询价)
② 专业回复(通用商务场景)
③ 亲切回复(如果客户是老客户)
└─ 用户选择一个 → 自动填入对话输入框 → 用户可编辑 → 发送
[用户主动发起]
└─ 打开小程序 → 输入中文 → 选择语气 → AI 翻译成英文
└─ 支持: 文字转语音(方便用户确认发音)
```
#### 核心机制
- **翻译质量领先**:结合 DeepL + OpenAI + 外贸术语库,翻译"MOQ、FOB、lead time"等术语准确
- **回复建议有上下文**:基于聊天历史 + 产品库 + 客户画像,不是通用 ChatGPT 式回复
- **用户微调记录**:用户每次编辑 AI 建议后再发送,系统记录差异,未来建议自动适配用户风格
#### 翻译 vs 回复建议 的边界
| 场景 | 做什么 | 用哪个引擎 |
|------|--------|-----------|
| 用户想看懂客户消息 | 翻译(直译,保留原意) | DeepL(准确) |
| 用户想回复客户 | 生成回复建议(意译,优化表达) | OpenAI/Claude(生成质量高) |
| 用户想写营销文案 | 生成创意内容 | Claude(写作最优) |
| 用户需要正式报价 | 生成结构化报价单 | OpenAI(结构化能力强) |
### 2.4 功能三:客户跟进引擎(帮用户"不丢单")
#### 用户场景
> "上周报完价客户就没消息了,我也不知道该不该再发消息,发了怕烦到客户,不发怕被别人抢走。"
#### 交互流程
```
[系统自动检测]
└─ 客户沉默 3 天 → 推送提醒:
"客户 Carlos (墨西哥) 已沉默3天,建议发送跟进消息"
└─ 提供跟进话术(基于上次聊天内容定制)
└─ 客户沉默 7 天 → 升级提醒:
"客户已沉默1周,建议发送限时优惠或新产品信息"
└─ 提供促销话术 + 可选折扣模板
[用户主动查看]
└─ 打开"客户"页面 → 按沉默天数排序
└─ 每个客户显示: 最后联系时间、沉默天数、建议动作
└─ 一键发送跟进消息
```
#### 核心机制
- **沉默检测规则**
- 3天无回复 = 轻度沉默 → 跟进提醒
- 7天无回复 = 中度沉默 → 升级提醒 + 提供优惠话术
- 14天无回复 = 重度沉默 → 建议换话题(发新品/行业资讯/节日问候)
- **行业基准对比**:跨用户匿名统计不同国家客户的回复率基线,帮助判断"这个客户是不是真的没意向"
- **最佳发送时间**:基于历史数据推荐(如拉美客户下午3-5点回复率高)
### 2.5 跨功能支撑:报价单生成
#### 场景
> "客户问价格了,我得赶紧报个价。但报价单要用英文写,还要算运费、汇率……"
#### 功能
```
客户说 "How much for 500pcs FOB Shanghai?"
系统:
- 识别出:FOB上海、500件、询价
- 自动调取用户产品库里的价格($12.5/pc)
- 显示当前汇率(USD/CNY
- 生成报价草稿:
"FOB Shanghai: $12.5/pc
Total: $6,250
Lead time: 25 days
Payment: T/T 30% deposit"
- 用户确认 → 生成正式报价单图片 → 一键发送
```
---
## 三、用户旅程
### 3.1 首次使用(30秒上手)
```
1. 扫码打开小程序 → 微信授权登录
2. 输入产品信息(中文): "你主要卖什么?"
└─ 示例: "户外折叠椅,主要出口欧美"
3. 系统自动:
├─ 生成 5 条开发信模板
├─ 生成 5 条 WhatsApp 开场白
└─ 保存产品到我的产品库
4. 引导到"客户"页面 → 提示"可以导入或手动添加客户"
5. 完成。全程 < 30 秒。
```
### 3.2 日常使用
```
早上:
└─ 打开小程序 → 看"待跟进"列表 → 选择1-2个客户发跟进消息
白天:
└─ WhatsApp 收到消息 → 切到小程序翻译 → 复制回复
晚上:
└─ 打开小程序 → 看今日概览(回复了几个客户、几个沉默)→ 准备明天的跟进
```
---
## 四、数据模型概要
| 实体 | 核心字段 | 用途 |
|------|---------|------|
| 用户 | tier, 产品库 | 账户+订阅 |
| 产品 | 名称, 描述, 价格, 规格, 关键词 | 用户自己的产品信息 |
| 客户 | 姓名, 国家, WhatsApp, 来源, 标签 | 客户管理 |
| 对话 | 消息, 翻译, 回复, 状态 | 沟通记录 |
| 报价单 | 产品, 数量, 价格, 条款, 状态 | 报价管理 |
| 语料条目 | 源文, 译文, 领域, 质量评分 | AI 训练数据 |
---
## 五、护城河策略
详见 `TECH_ARCHITECTURE.md` 第 5 章,核心三层:
1. **外贸垂直语料库**:用户每次使用产生的翻译/回复数据,积累成行业专属语料
2. **用户产品知识库**:产品信息+客户偏好+历史报价,迁移成本极高
3. **沉默客户模式算法**:跨用户行为数据产生的预测能力,网络效应
---
## 六、盈利模式
| 层级 | 价格 | 能力 |
|------|------|------|
| 免费版 | ¥0 | 1个产品、20次翻译/天、5个客户、基础回复建议 |
| Pro版 | ¥99/月 | 10个产品、无限翻译、50个客户、跟进提醒、报价单 |
| 企业版 | ¥399/月 | 无限产品、多人协作、品牌报价单、专属语料训练、API |
---
## 七、路线图
| 阶段 | 时间 | 功能 |
|------|------|------|
| MVP | 第1-4周 | 智能翻译+回复建议+基础营销素材+产品库 |
| V2 | 第5-8周 | 沉默客户跟进+WhatsApp集成+报价单生成 |
| V3 | 第9-12周 | 语料库训练+回复质量优化+多人协作 |
| V4 | 第13-16周 | 跨用户A/B测试+预测算法+API开放 |
+235
View File
@@ -0,0 +1,235 @@
# 外贸小助手 (TradeMate) — 项目进度文档
> 版本: v1.0
> 更新日期: 2026-05-08
> 状态: MVP开发中
---
## 一、项目概述
**项目名称**: 外贸小助手 (TradeMate)
**项目类型**: 微信小程序 + 后端API
**目标用户**: 外贸SOHO、小型外贸公司、工厂转型外贸
---
## 二、功能实现总览
### 2.1 已完成功能 ✅
| 功能模块 | 后端API | 前端页面 | 状态 |
|---------|---------|---------|------|
| **用户认证** | /auth/register, login, refresh, me, settings | 登录页 | ✅ |
| **智能翻译** | /translate, /reply, /extract, /feedback | 翻译页 | ✅ |
| **回复建议** | /translate/reply (3种语气) | 翻译页 | ✅ |
| **营销素材** | /marketing/generate, /keywords, /competitor-analysis | 营销页 | ✅ |
| **客户管理** | /customers CRUD, /silent, /conversation | 客户页 | ✅ |
| **沉默检测** | /customers/silent (3/7/14天) | 客户页 | ✅ |
| **报价单** | /quotations CRUD, /status | 报价页 | ✅ |
| **产品库** | /products CRUD | 产品页 | ✅ |
| **汇率换算** | /exchange/convert, /rates | (待集成) | ✅ |
| **推送通知** | /push/register, /send, /devices | (uni-push) | ✅ |
| **WhatsApp** | /whatsapp/webhook, /send, /qr | (框架) | ✅ |
| **定时任务** | Celery tasks | - | ✅ |
**前端页面**:
- 登录页 (pages/login)
- 首页仪表盘 (pages/index)
- 翻译+回复 (pages/translate)
- 客户管理 (pages/customers)
- 营销素材 (pages/marketing)
- 报价单 (pages/quotation)
- 产品库 (pages/product)
- 自定义TabBar
### 2.2 未完成功能 ❌
| 功能 | 优先级 | 说明 |
|------|--------|------|
| **微信登录** | 高 | 需配置微信开放平台OAuth |
| **WhatsApp真实集成** | 高 | 需注册Meta Business,配置真实API |
| **报价单PDF生成** | 中 | 需集成 weasyprint 库 |
| **文字转语音(TTS)** | 中 | uni-app 有对应API |
| **批量导入客户** | 中 | 需集成文件上传+xlsx解析 |
| **Web管理后台** | 低 | 设计中有,未实现 |
| **数据分析报表** | 低 | 首页数据目前为模拟 |
| **多人协作/团队** | 低 | 企业版功能 |
| **语料库训练** | 低 | V3功能,仅框架 |
### 2.3 缺失文档
- [x] 产品设计文档 (PRODUCT_DESIGN.md)
- [x] 技术架构文档 (TECH_ARCHITECTURE.md)
- [x] API设计文档 (API_DESIGN.md)
- [x] 数据库设计文档 (DATABASE_SCHEMA.md)
- [ ] 项目进度文档 (本文档) ✅
---
## 三、技术栈
### 3.1 后端
| 技术 | 版本 | 用途 |
|------|------|------|
| Python | 3.11+ | 运行环境 |
| FastAPI | latest | Web框架 |
| SQLAlchemy | 2.0+ | ORM |
| PostgreSQL | 15 | 主数据库 |
| pgvector | latest | 向量数据库 |
| Redis | 7 | 缓存/队列 |
| Celery | 5.0+ | 定时任务 |
| AI Providers | - | DeepL/OpenAI/Claude |
### 3.2 前端
| 技术 | 版本 | 用途 |
|------|------|------|
| uni-app | 3.0+ | 跨端框架 |
| Vue | 3.4+ | UI框架 |
| Sass/SCSS | - | 样式预处理 |
| uni-push | 2.0 | 推送服务 |
### 3.3 部署
| 技术 | 用途 |
|------|------|
| Docker | 容器化 |
| Docker Compose | 编排 |
| Nginx | 反向代理 |
| Systemd | 进程管理 |
---
## 四、目录结构
```
trade-assistant/
├── docs/ # 设计文档
│ ├── PRODUCT_DESIGN.md # 产品设计
│ ├── TECH_ARCHITECTURE.md # 技术架构
│ ├── API_DESIGN.md # API接口
│ ├── DATABASE_SCHEMA.md # 数据库设计
│ └── PROJECT_STATUS.md # 项目进度
├── backend/ # Python后端
│ ├── app/
│ │ ├── main.py # FastAPI入口
│ │ ├── config.py # 配置
│ │ ├── database.py # 数据库连接
│ │ ├── celery_app.py # Celery配置
│ │ ├── models/ # 数据模型
│ │ │ ├── user.py # 用户+产品
│ │ │ ├── customer.py # 客户+对话+消息
│ │ │ ├── quotation.py # 报价单+明细
│ │ │ └── corpus.py # 语料库
│ │ ├── api/v1/ # REST API
│ │ │ ├── auth.py # 认证
│ │ │ ├── translate.py # 翻译
│ │ │ ├── marketing.py # 营销
│ │ │ ├── customer.py # 客户
│ │ │ ├── quotation.py # 报价单
│ │ │ ├── product.py # 产品
│ │ │ ├── exchange.py # 汇率
│ │ │ ├── push.py # 推送
│ │ │ └── whatsapp.py # WhatsApp
│ │ ├── services/ # 业务逻辑
│ │ ├── ai/ # AI抽象层
│ │ │ ├── router.py # 智能路由
│ │ │ ├── trade_corpus.py # 语料库
│ │ │ └── providers/ # 各引擎实现
│ │ ├── core/ # 核心组件
│ │ │ ├── security.py # JWT认证
│ │ │ ├── exceptions.py # 异常处理
│ │ │ └── middleware.py # 中间件
│ │ └── workers/ # Celery任务
│ │ └── tasks.py
│ ├── alembic/ # 数据库迁移
│ ├── requirements.txt
│ ├── Dockerfile
│ └── .env.example
├── uni-app/ # uni-app前端
│ ├── src/
│ │ ├── pages/ # 页面
│ │ │ ├── login/ # 登录
│ │ │ ├── index/ # 首页
│ │ │ ├── translate/ # 翻译
│ │ │ ├── customers/ # 客户
│ │ │ ├── marketing/ # 营销
│ │ │ ├── quotation/ # 报价单
│ │ │ └── product/ # 产品库
│ │ ├── components/ # 组件
│ │ │ └── tabbar/ # 自定义TabBar
│ │ ├── utils/ # 工具
│ │ │ ├── api.js # API封装
│ │ │ └── push.js # 推送服务
│ │ ├── static/ # 静态资源
│ │ ├── App.vue # 应用入口
│ │ ├── main.js # Vue初始化
│ │ └── pages.json # 页面配置
│ ├── package.json
│ └── vite.config.js
├── miniprogram/ # 微信小程序(原生-已弃用)
├── docker-compose.yml # Docker编排
├── nginx/ # Nginx配置
├── scripts/ # 运维脚本
├── systemd/ # Systemd服务
└── data/ # 数据目录
```
---
## 五、待办事项
### 5.1 高优先级 (MVP)
- [ ] 配置微信登录OAuth
- [ ] 配置WhatsApp Cloud API真实环境
- [ ] 集成PDF生成库 (weasyprint)
- [ ] 添加批量客户导入功能
### 5.2 中优先级 (V2)
- [ ] 添加文字转语音(TTS)功能
- [ ] 实现Web管理后台基础功能
- [ ] 数据分析图表集成
### 5.3 低优先级 (V3+)
- [ ] 团队/多人协作功能
- [ ] 语料库训练模型
- [ ] API开放平台
---
## 六、部署说明
### 开发环境
```bash
# 启动后端
cd backend
docker-compose up -d
# 启动前端
cd uni-app
npm install
npm run dev:mp-weixin
```
### 生产环境
详见 `scripts/deploy.sh` 和 systemd 配置
---
## 七、相关文档链接
- [产品设计](./PRODUCT_DESIGN.md)
- [技术架构](./TECH_ARCHITECTURE.md)
- [API设计](./API_DESIGN.md)
- [数据库设计](./DATABASE_SCHEMA.md)
+361
View File
@@ -0,0 +1,361 @@
# 外贸小助手 (TradeMate) — 技术架构文档
> 版本: v1.0
> 创建日期: 2026-05-08
---
## 一、系统架构总览
```
┌──────────────────────────────────────────────────────┐
│ 客户端层 │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ 微信小程序 │ │ Web后台 │ │ WhatsApp │ │
│ │ (主入口) │ │ (管理用) │ │ 原生客户端 │ │
│ └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ │
└─────────┼───────────────┼────────────────┼───────────┘
│ HTTP/JSON │ HTTP/JSON │ WhatsApp
│ │ │ Cloud API
┌─────────┼───────────────┼────────────────┼───────────┐
│ ┌┴──────────────┴────────────────┴┐ │
│ │ API Gateway (FastAPI) │ │
│ │ - 认证 (JWT + WeChat OAuth) │ │
│ │ - 用户 tier 中间件 │ │
│ │ - 配额控制中间件 │ │
│ └────────────────┬─────────────────┘ │
│ │ │
│ ┌────────────────┼─────────────────┐ │
│ │ │ │ │
│ ┌─────┴─────┐ ┌──────┴──────┐ ┌───────┴──────┐ │
│ │ 业务服务 │ │ AI 服务 │ │ WhatsApp │ │
│ │ │ │ 抽象层 │ │ 集成服务 │ │
│ │ - 营销素材 │ │ - Provider │ │ - 消息收发 │ │
│ │ - 翻译回复 │ │ - Router │ │ - Webhook │ │
│ │ - 客户跟进 │ │ - 语料库 │ │ - 会话管理 │ │
│ │ - 报价单 │ │ - 成本控制 │ │ │ │
│ └─────┬─────┘ └──────┬──────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌─────┴─────────────────┴─────────────────┴──────┐ │
│ │ 数据层 │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │
│ │ │PostgreSQL│ │ Redis │ │ 文件存储 │ │ │
│ │ │+pgvector │ │ (缓存) │ │ (报价单PDF) │ │ │
│ │ └──────────┘ └──────────┘ └──────────────┘ │ │
│ └──────────────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────┘
```
---
## 二、技术栈选型
| 层级 | 技术 | 选型理由 |
|------|------|---------|
| 客户端 | 微信小程序 | 触达成本最低,中国外贸商家几乎100%使用微信 |
| 后端 | FastAPI (Python 3.11+) | 异步性能好,类型安全,生态成熟 |
| 主数据库 | PostgreSQL 15 + pgvector | 结构化数据 + 向量检索(语料库语义搜索) |
| 缓存 | Redis 7 | 会话缓存、配额计数、任务队列 |
| 任务队列 | Celery + Redis broker | 异步翻译、批量语料处理 |
| AI Provider | DeepL + OpenAI + Claude + 本地模型 | 按场景路由,成本优化 |
| WhatsApp | WhatsApp Cloud API | 官方API,合规稳定 |
| 部署 | Docker + Docker Compose | 一键部署,环境一致 |
---
## 三、目录结构
```
trade-assistant/
├── docs/ # 设计文档
│ ├── PRODUCT_DESIGN.md
│ ├── TECH_ARCHITECTURE.md
│ ├── API_DESIGN.md # API 接口设计
│ └── DATABASE_SCHEMA.md # 数据库设计
├── backend/ # Python 后端
│ ├── app/
│ │ ├── main.py # FastAPI 入口
│ │ ├── config.py # 配置管理
│ │ ├── database.py # 数据库连接
│ │ │
│ │ ├── models/ # SQLAlchemy 数据模型
│ │ │ ├── user.py
│ │ │ ├── product.py
│ │ │ ├── customer.py
│ │ │ ├── conversation.py
│ │ │ ├── quotation.py
│ │ │ └── corpus.py
│ │ │
│ │ ├── api/v1/ # REST API 路由
│ │ │ ├── auth.py # 认证
│ │ │ ├── marketing.py # 营销素材
│ │ │ ├── translate.py # 翻译与回复
│ │ │ ├── customer.py # 客户管理
│ │ │ ├── quotation.py # 报价单
│ │ │ └── whatsapp.py # WhatsApp 集成
│ │ │
│ │ ├── services/ # 业务逻辑层
│ │ │ ├── marketing.py # 营销素材生成
│ │ │ ├── translation.py # 翻译+回复引擎
│ │ │ ├── customer.py # 客户跟进引擎
│ │ │ ├── quotation.py # 报价单服务
│ │ │ └── whatsapp.py # WhatsApp 服务
│ │ │
│ │ ├── ai/ # AI 抽象层
│ │ │ ├── base.py # Provider 接口
│ │ │ ├── router.py # 智能路由+fallback
│ │ │ ├── trade_corpus.py # 外贸语料库
│ │ │ └── providers/ # 各引擎实现
│ │ │ ├── openai.py
│ │ │ ├── claude.py
│ │ │ ├── deepl.py
│ │ │ └── local.py
│ │ │
│ │ ├── core/ # 核心基础设施
│ │ │ ├── security.py # JWT + 加密
│ │ │ ├── exceptions.py # 异常处理
│ │ │ └── middleware.py # 中间件
│ │ │
│ │ └── workers/ # Celery 任务
│ │ └── tasks.py
│ │
│ ├── alembic/ # 数据库迁移
│ ├── requirements.txt
│ ├── Dockerfile
│ └── .env.example
├── miniprogram/ # 微信小程序
│ ├── app.js / app.json / app.wxss
│ ├── pages/
│ │ ├── index/ # 首页仪表盘
│ │ ├── translate/ # 翻译+回复
│ │ ├── customers/ # 客户管理
│ │ ├── marketing/ # 营销素材
│ │ └── quotation/ # 报价单
│ ├── components/ # 通用组件
│ └── utils/ # 工具函数
├── data/ # 数据存储
│ ├── corpus/ # 外贸语料库文件
│ └── models/ # 微调模型占位
├── scripts/ # 运维脚本
├── docker-compose.yml
└── README.md
```
---
## 四、AI 服务架构(核心)
### 4.1 Provider 抽象层
```
┌─────────────────────────────────────────────────────┐
│ AIRouter │
│ │
│ execute(task_type, method, *args, **kwargs) │
│ │
│ 1. 根据 task_type 获取优先级列表 │
│ 2. 按优先级依次尝试 provider │
│ 3. 失败自动 fallback 到下一个 │
│ 4. 记录每次调用的 provider、token、耗时、成本 │
│ 5. 将结果存入语料库(如果质量达标) │
└──────────┬──────────┬──────────┬──────────┬──────────┘
│ │ │ │
┌──────┴──┐ ┌───┴────┐ ┌───┴────┐ ┌───┴──────┐
│ OpenAI │ │ Claude │ │ DeepL │ │ Local │
│ Provider│ │Provider│ │Provider│ │Provider │
└─────────┘ └────────┘ └────────┘ └──────────┘
```
### 4.2 路由规则
| 任务类型 | 主引擎 | Fallback | 成本策略 |
|---------|--------|---------|---------|
| 翻译(看懂) | DeepL | OpenAI → 本地 | 优先低成本 |
| 回复建议 | OpenAI GPT-4o | Claude → 本地 | 质量优先 |
| 营销文案 | Claude | OpenAI → 本地 | 质量优先 |
| 结构化提取 | OpenAI GPT-4o | Claude | 结构化能力优先 |
| 语料训练 | 本地(离线) | - | 零成本 |
### 4.3 语料库架构
```
┌─────────────────────────────────────────────────────────┐
│ TradeCorpus │
│ │
│ 收集: │
│ ├─ 用户翻译记录(原文→译文,用户是否编辑过) │
│ ├─ 用户选择的回复(用户从N个AI建议中选了哪个) │
│ ├─ 用户点赞/点踩的反馈 │
│ └─ 用户最终发出的版本(可能是AI建议+用户修改) │
│ │
│ 存储: │
│ ├─ PostgreSQL (结构化: 源文, 译文, 领域, 评分) │
│ └─ pgvector (语义向量, 用于相似翻译检索) │
│ │
│ 使用: │
│ ├─ 运行时: 相似翻译召回 → 做 few-shot prompt │
│ ├─ 离线: 训练专用翻译模型(用户量 >1万后启动) │
│ └─ 质量评估: 持续评估各 provider 的质量分数 │
└─────────────────────────────────────────────────────────┘
```
---
## 五、护城河实现策略
### 5.1 外贸垂直语料库(数据壁垒)
**数据采集点**(在用户流程中无感埋点):
```
用户操作 → 系统记录
────────────────────────────────────────
用户输入中文要翻译 → 原文
用户选择了某个回复建议 → 被选中的建议 + 未被选中的建议
用户修改了AI建议再发送 → AI原文 + 用户修改版本
用户点了赞/点踩 → 质量标签
用户复制了某条营销文案 → 文案被使用的统计
```
**语料库达到 10 万条后的效果**:
- 翻译时从语料库检索相似度 >90% 的历史翻译,直接复用或做 few-shot
- 回复建议不再是通用 prompt,而是"过去100个类似询盘的回复模式"
- 新用户冷启动时直接受益于存量语料
### 5.2 用户产品知识库(迁移成本壁垒)
用户存入的数据是不可替代的资产:
```
用户产品库:
├─ 产品名称/描述(双语)
├─ 价格表(不同客户不同价)
├─ 规格参数
├─ 产品图片
└─ 关键词(多语言)
客户画像:
├─ 基本信息(国家/公司/职位)
├─ 沟通偏好(正式/亲切)
├─ 历史价格
├─ 付款习惯
└─ 砍价风格
→ 换平台 = 所有数据重录
→ 用户用得越久,走得越难
```
### 5.3 沉默客户模式算法(网络效应)
```
输入:
- 本用户的客户沉默时长
- 跨用户匿名统计的"不同国家客户回复率时间分布"
- 历史成功跟进的话术模式
输出:
- 最佳跟进时间
- 最高回复率的话术类型
- 客户"可能还有意向"的概率评分
网络效应:
- 用户A的墨西哥客户跟进数据 → 改善用户B的墨西哥客户跟进建议
- 用户越多,预测越准
- 新用户直接获得"过去100个相似案例的最佳实践"
```
---
## 六、配额与计费系统
### 6.1 计费计量点
```
┌──────────────┬────────────┬───────────────┐
│ 功能 │ 计量单位 │ 免费版限制 │
├──────────────┼────────────┼───────────────┤
│ 翻译 │ 字符数 │ 5000/天 │
│ 回复建议 │ 请求次数 │ 20次/天 │
│ 营销文案生成 │ 请求次数 │ 5次/天 │
│ 报价单生成 │ 份数 │ 3份/天 │
│ 客户管理 │ 客户数 │ 5个 │
│ 跟进提醒 │ - │ 仅沉默检测 │
└──────────────┴────────────┴───────────────┘
```
### 6.2 实现架构
```
用户请求 → tier middleware → quota middleware → 业务逻辑
│ │
↓ ↓
User.tier Redis INCR user:daily_count
控制功能可用性 检查是否超限
```
---
## 七、WhatsApp 集成架构
```
WhatsApp User WhatsApp Cloud API TradeMate Backend
│ │ │
│ 发送消息 │ │
│ ────────────────────────────────► │ │
│ │ Webhook POST /webhook/whatsapp │
│ │ ────────────────────────────────► │
│ │ │
│ │ 1. 验证 Webhook signature │
│ │ 2. 解析消息内容 + 发送者信息 │
│ │ 3. 调用翻译服务(用户可配置) │
│ │ 4. 存储到 conversations 表 │
│ │ 5. 检查是否触发沉默检测更新 │
│ │ 6. 返回 200 OK │
│ │ │
│ 回复消息 │ POST /messages (通过 Cloud API) │
│ ◄──────────────────────────────── │ ──── 用户在小程序确认回复后 ────► │
│ │ │
```
**注意**WhatsApp Cloud API 要求企业通过 Meta Business 认证,初始阶段可以使用用户手动复制消息到 WhatsApp 的方式替代,降低启动门槛。
---
## 八、安全设计
| 维度 | 措施 |
|------|------|
| 认证 | JWT + 微信 OAuth + refresh token 轮换 |
| API 安全 | 全站 HTTPS、请求签名校验、rate limiting |
| 数据安全 | 密码 bcrypt 哈希、敏感配置环境变量注入 |
| AI API Key | 仅服务端持有,不暴露给前端 |
| 用户隔离 | 所有查询强制 user_id 过滤 |
| WhatsApp | Webhook signature 验证、消息内容不落日志 |
---
## 九、部署架构
```
开发环境: docker-compose up(单机)
├─ backend:8000
├─ postgres:5432
├─ redis:6379
└─ celery-worker
生产环境: 阿里云/腾讯云轻量服务器
├─ Nginx 反代 + SSL
├─ systemd 管理 backend + celery
├─ PostgreSQL 走内网
└─ Redis 走内网
关键指标:
├─ API 响应: < 200ms (p95)
├─ 翻译延迟: < 2s (p95)
├─ 并发用户: 500+(单实例)
└─ 可用性: 99.5%
```
+29
View File
@@ -0,0 +1,29 @@
App({
globalData: {
userInfo: null,
token: null,
baseUrl: 'http://localhost:8000/api/v1',
},
onLaunch() {
const token = wx.getStorageSync('token');
if (token) {
this.globalData.token = token;
}
},
setToken(token) {
this.globalData.token = token;
wx.setStorageSync('token', token);
},
clearToken() {
this.globalData.token = null;
wx.removeStorageSync('token');
},
getAuthHeader() {
const token = this.globalData.token;
return token ? { Authorization: `Bearer ${token}` } : {};
},
});
+55
View File
@@ -0,0 +1,55 @@
{
"pages": [
"pages/login/login",
"pages/index/index",
"pages/translate/translate",
"pages/customers/customers",
"pages/marketing/marketing",
"pages/quotation/quotation"
],
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#1890ff",
"navigationBarTitleText": "外贸小助手",
"navigationBarTextStyle": "white"
},
"tabBar": {
"color": "#666666",
"selectedColor": "#1890ff",
"backgroundColor": "#ffffff",
"borderStyle": "black",
"list": [
{
"pagePath": "pages/index/index",
"text": "首页",
"iconPath": "assets/home.png",
"selectedIconPath": "assets/home-active.png"
},
{
"pagePath": "pages/translate/translate",
"text": "翻译",
"iconPath": "assets/translate.png",
"selectedIconPath": "assets/translate-active.png"
},
{
"pagePath": "pages/customers/customers",
"text": "客户",
"iconPath": "assets/customers.png",
"selectedIconPath": "assets/customers-active.png"
},
{
"pagePath": "pages/marketing/marketing",
"text": "营销",
"iconPath": "assets/marketing.png",
"selectedIconPath": "assets/marketing-active.png"
},
{
"pagePath": "pages/quotation/quotation",
"text": "报价",
"iconPath": "assets/quotation.png",
"selectedIconPath": "assets/quotation-active.png"
}
]
},
"sitemapLocation": "sitemap.json"
}
+102
View File
@@ -0,0 +1,102 @@
page {
background-color: #f5f5f5;
font-size: 28rpx;
color: #333;
}
.container {
padding: 20rpx;
}
.btn-primary {
background-color: #1890ff;
color: #fff;
border-radius: 8rpx;
padding: 20rpx 40rpx;
font-size: 28rpx;
}
.btn-primary:active {
background-color: #40a9ff;
}
.btn-secondary {
background-color: #fff;
color: #1890ff;
border: 1rpx solid #1890ff;
border-radius: 8rpx;
padding: 20rpx 40rpx;
font-size: 28rpx;
}
.card {
background-color: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.input-field {
border: 1rpx solid #d9d9d9;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
width: 100%;
box-sizing: border-box;
}
.input-field:focus {
border-color: #40a9ff;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
color: #333;
}
.text-gray {
color: #999;
}
.text-primary {
color: #1890ff;
}
.text-success {
color: #52c41a;
}
.text-warning {
color: #faad14;
}
.text-danger {
color: #ff4d4f;
}
.flex {
display: flex;
}
.flex-center {
display: flex;
align-items: center;
justify-content: center;
}
.flex-between {
display: flex;
align-items: center;
justify-content: space-between;
}
.mt-10 { margin-top: 10rpx; }
.mt-20 { margin-top: 20rpx; }
.mt-30 { margin-top: 30rpx; }
.mb-10 { margin-bottom: 10rpx; }
.mb-20 { margin-bottom: 20rpx; }
.mb-30 { margin-bottom: 30rpx; }
.p-20 { padding: 20rpx; }
+121
View File
@@ -0,0 +1,121 @@
const { customerApi } = require('../../utils/api');
Page({
data: {
customers: [],
page: 1,
hasMore: true,
loading: false,
showAddModal: false,
newCustomer: {
name: '',
company: '',
country: '',
phone: '',
whatsapp_id: '',
email: '',
},
},
onLoad() {
this.loadCustomers();
},
onReachBottom() {
if (this.data.hasMore && !this.data.loading) {
this.setData({ page: this.data.page + 1 });
this.loadCustomers(true);
}
},
async loadCustomers(isAppend = false) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
const result = await customerApi.list(this.data.page);
this.setData({
customers: isAppend ? [...this.data.customers, ...result.items] : result.items,
hasMore: result.items.length >= result.size,
loading: false,
});
} catch (err) {
wx.showToast({ title: err.message || '加载失败', icon: 'none' });
this.setData({ loading: false });
}
},
showAdd() {
this.setData({ showAddModal: true });
},
hideAdd() {
this.setData({ showAddModal: false });
},
onInput(e) {
const field = e.currentTarget.dataset.field;
const value = e.detail.value;
this.setData({
[`newCustomer.${field}`]: value,
});
},
async addCustomer() {
const { newCustomer } = this.data;
if (!newCustomer.name) {
wx.showToast({ title: '请输入客户名称', icon: 'none' });
return;
}
try {
await customerApi.create(newCustomer);
wx.showToast({ title: '添加成功', icon: 'success' });
this.hideAdd();
this.setData({ page: 1, customers: [] });
this.loadCustomers();
} catch (err) {
wx.showToast({ title: err.message || '添加失败', icon: 'none' });
}
},
async deleteCustomer(e) {
const id = e.currentTarget.dataset.id;
wx.showModal({
title: '确认删除',
content: '确定要删除该客户吗?',
success: async (res) => {
if (res.confirm) {
try {
await customerApi.delete(id);
wx.showToast({ title: '已删除', icon: 'success' });
this.setData({ page: 1, customers: [] });
this.loadCustomers();
} catch (err) {
wx.showToast({ title: '删除失败', icon: 'none' });
}
}
},
});
},
viewDetail(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({ url: `/pages/customers/detail?id=${id}` });
},
getStatusClass(status) {
const map = {
lead: 'text-primary',
negotiating: 'text-warning',
closed: 'text-success',
};
return map[status] || '';
},
onPullDownRefresh() {
this.setData({ page: 1, customers: [] });
this.loadCustomers();
wx.stopPullDownRefresh();
},
});
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "客户管理",
"enablePullDownRefresh": true,
"usingComponents": {}
}
@@ -0,0 +1,68 @@
<view class="container">
<view class="header">
<text class="section-title">客户列表</text>
<button class="add-btn" bindtap="showAdd">+ 添加客户</button>
</view>
<view class="customer-list">
<view class="customer-item" wx:for="{{customers}}" wx:key="id" bindtap="viewDetail" data-id="{{item.id}}">
<view class="customer-info">
<view class="customer-name">{{item.name}}</view>
<view class="customer-meta">
{{item.company}} {{item.country ? '· ' + item.country : ''}}
</view>
<view class="customer-meta" wx:if="{{item.last_contact_at}}">
最后联系: {{item.last_contact_at}}
</view>
</view>
<view class="customer-status status-{{item.status}}">
{{item.status === 'lead' ? '潜在' : item.status === 'negotiating' ? '谈判中' : '已成交'}}
</view>
</view>
<view class="empty" wx:if="{{customers.length === 0 && !loading}}">
<text>暂无客户,点击上方添加</text>
</view>
</view>
<view class="modal" wx:if="{{showAddModal}}" bindtap="hideAdd">
<view class="modal-content" catchtap>
<view class="modal-title">添加客户</view>
<view class="form-group">
<text class="form-label">姓名 *</text>
<input class="form-input" placeholder="客户姓名" data-field="name" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">公司</text>
<input class="form-input" placeholder="公司名称" data-field="company" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">国家</text>
<input class="form-input" placeholder="如:美国、德国" data-field="country" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">电话</text>
<input class="form-input" placeholder="手机号" data-field="phone" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">WhatsApp</text>
<input class="form-input" placeholder="WhatsApp ID" data-field="whatsapp_id" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">邮箱</text>
<input class="form-input" placeholder="邮箱地址" data-field="email" bindinput="onInput" />
</view>
<view class="modal-btns">
<view class="modal-btn btn-cancel" bindtap="hideAdd">取消</view>
<view class="modal-btn btn-confirm" bindtap="addCustomer">确定</view>
</view>
</view>
</view>
</view>
+160
View File
@@ -0,0 +1,160 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
background: #fff;
}
.add-btn {
background: #1890ff;
color: #fff;
font-size: 28rpx;
padding: 15rpx 30rpx;
border-radius: 8rpx;
}
.customer-list {
padding: 0 30rpx;
}
.customer-item {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.customer-info {
flex: 1;
}
.customer-name {
font-size: 30rpx;
font-weight: bold;
color: #333;
margin-bottom: 8rpx;
}
.customer-meta {
font-size: 24rpx;
color: #999;
}
.customer-status {
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
}
.status-lead {
background: #e6f7ff;
color: #1890ff;
}
.status-negotiating {
background: #fffbe6;
color: #faad14;
}
.status-closed {
background: #f6ffed;
color: #52c41a;
}
.customer-actions {
display: flex;
gap: 20rpx;
}
.action-btn {
font-size: 24rpx;
color: #999;
padding: 10rpx;
}
.delete-btn {
color: #ff4d4f;
}
.empty {
text-align: center;
padding: 100rpx;
color: #999;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-content {
background: #fff;
border-radius: 12rpx;
padding: 40rpx;
width: 80%;
max-height: 80vh;
overflow-y: auto;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 30rpx;
text-align: center;
}
.form-group {
margin-bottom: 20rpx;
}
.form-label {
font-size: 26rpx;
color: #666;
margin-bottom: 10rpx;
display: block;
}
.form-input {
border: 1rpx solid #d9d9d9;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
width: 100%;
box-sizing: border-box;
}
.modal-btns {
display: flex;
gap: 20rpx;
margin-top: 30rpx;
}
.modal-btn {
flex: 1;
padding: 20rpx;
border-radius: 8rpx;
text-align: center;
font-size: 28rpx;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-confirm {
background: #1890ff;
color: #fff;
}
+80
View File
@@ -0,0 +1,80 @@
const app = getApp();
const { authApi, customerApi, translateApi } = require('../../utils/api');
Page({
data: {
userInfo: null,
stats: {
customers: 0,
silentCustomers: 0,
todayTranslations: 0,
quotations: 0,
},
silentCustomers: [],
loading: true,
},
onLoad() {
this.loadData();
},
onShow() {
const token = app.globalData.token;
if (!token) {
wx.redirectTo({ url: '/pages/login/login' });
} else {
this.loadData();
}
},
async loadData() {
try {
const userInfo = await authApi.getUserInfo();
const silentData = await customerApi.getSilent(3);
this.setData({
userInfo,
stats: {
customers: silentData.count + Math.floor(Math.random() * 10),
silentCustomers: silentData.count,
todayTranslations: Math.floor(Math.random() * 20),
quotations: Math.floor(Math.random() * 5),
},
silentCustomers: silentData.customers.slice(0, 5),
loading: false,
});
} catch (err) {
console.error('Failed to load data:', err);
this.setData({ loading: false });
}
},
goToTranslate() {
wx.switchTab({ url: '/pages/translate/translate' });
},
goToCustomers() {
wx.switchTab({ url: '/pages/customers/customers' });
},
goToMarketing() {
wx.switchTab({ url: '/pages/marketing/marketing' });
},
goToQuotation() {
wx.switchTab({ url: '/pages/quotation/quotation' });
},
onLogout() {
wx.showModal({
title: '确认退出',
content: '确定要退出登录吗?',
success: (res) => {
if (res.confirm) {
app.clearToken();
wx.redirectTo({ url: '/pages/login/login' });
}
},
});
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "外贸小助手",
"usingComponents": {}
}
+61
View File
@@ -0,0 +1,61 @@
<view class="container">
<view class="header">
<view class="user-info">
<view class="avatar">👤</view>
<view class="user-detail">
<view class="username">{{userInfo.username || '用户'}}</view>
<text class="tier-badge">{{userInfo.tier === 'pro' ? 'Pro' : userInfo.tier === 'enterprise' ? '企业版' : '免费版'}}</text>
</view>
</view>
</view>
<view class="stats-grid">
<view class="stat-item" bindtap="goToCustomers">
<text class="stat-value">{{stats.customers}}</text>
<text class="stat-label">客户数</text>
</view>
<view class="stat-item">
<text class="stat-value text-danger">{{stats.silentCustomers}}</text>
<text class="stat-label">待跟进</text>
</view>
<view class="stat-item" bindtap="goToTranslate">
<text class="stat-value">{{stats.todayTranslations}}</text>
<text class="stat-label">今日翻译</text>
</view>
<view class="stat-item" bindtap="goToQuotation">
<text class="stat-value">{{stats.quotations}}</text>
<text class="stat-label">报价单</text>
</view>
</view>
<view class="menu-grid">
<view class="menu-item" bindtap="goToTranslate">
<view class="menu-icon">📝</view>
<text class="menu-text">翻译</text>
</view>
<view class="menu-item" bindtap="goToCustomers">
<view class="menu-icon">👥</view>
<text class="menu-text">客户</text>
</view>
<view class="menu-item" bindtap="goToMarketing">
<view class="menu-icon">📢</view>
<text class="menu-text">营销</text>
</view>
<view class="menu-item" bindtap="goToQuotation">
<view class="menu-icon">📄</view>
<text class="menu-text">报价</text>
</view>
</view>
<view class="section" wx:if="{{silentCustomers.length > 0}}">
<view class="section-title">待跟进客户</view>
<view class="silent-list">
<view class="silent-item" wx:for="{{silentCustomers}}" wx:key="id">
<text class="customer-name">{{item.name}}</text>
<text class="silence-days">沉默 {{item.silence_days}} 天</text>
</view>
</view>
</view>
<button class="logout-btn" bindtap="onLogout">退出登录</button>
</view>
+142
View File
@@ -0,0 +1,142 @@
.header {
background: linear-gradient(135deg, #1890ff, #40a9ff);
padding: 40rpx 30rpx;
color: #fff;
}
.user-info {
display: flex;
align-items: center;
}
.avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
background-color: rgba(255, 255, 255, 0.3);
display: flex;
align-items: center;
justify-content: center;
font-size: 48rpx;
margin-right: 20rpx;
}
.user-detail {
flex: 1;
}
.username {
font-size: 36rpx;
font-weight: bold;
margin-bottom: 8rpx;
}
.tier-badge {
font-size: 24rpx;
background: rgba(255, 255, 255, 0.2);
padding: 4rpx 16rpx;
border-radius: 20rpx;
}
.stats-grid {
display: flex;
flex-wrap: wrap;
padding: 30rpx;
background: #fff;
margin: -30rpx 30rpx 30rpx;
border-radius: 12rpx;
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.08);
}
.stat-item {
width: 25%;
text-align: center;
padding: 20rpx 0;
}
.stat-value {
font-size: 40rpx;
font-weight: bold;
color: #1890ff;
display: block;
}
.stat-label {
font-size: 24rpx;
color: #999;
margin-top: 8rpx;
}
.menu-grid {
display: flex;
flex-wrap: wrap;
padding: 0 30rpx;
}
.menu-item {
width: 25%;
padding: 30rpx;
box-sizing: border-box;
text-align: center;
}
.menu-icon {
width: 80rpx;
height: 80rpx;
background: #e6f7ff;
border-radius: 20rpx;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 10rpx;
font-size: 40rpx;
}
.menu-text {
font-size: 24rpx;
color: #666;
}
.section {
padding: 30rpx;
}
.section-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 20rpx;
}
.silent-list {
background: #fff;
border-radius: 12rpx;
}
.silent-item {
padding: 30rpx;
border-bottom: 1rpx solid #f0f0f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.silent-item:last-child {
border-bottom: none;
}
.customer-name {
font-size: 28rpx;
color: #333;
}
.silence-days {
font-size: 24rpx;
color: #ff4d4f;
}
.logout-btn {
margin: 40rpx 30rpx;
background: #fff;
color: #ff4d4f;
border: 1rpx solid #ff4d4f;
}
+81
View File
@@ -0,0 +1,81 @@
const app = getApp();
const { authApi } = require('../../utils/api');
Page({
data: {
phone: '',
password: '',
username: '',
isRegister: false,
loading: false,
error: '',
},
onPhoneInput(e) {
this.setData({ phone: e.detail.value });
},
onPasswordInput(e) {
this.setData({ password: e.detail.value });
},
onUsernameInput(e) {
this.setData({ username: e.detail.value });
},
toggleMode() {
this.setData({
isRegister: !this.data.isRegister,
error: '',
});
},
async handleSubmit() {
const { phone, password, username, isRegister } = this.data;
if (!phone || !password) {
this.setData({ error: '请输入手机号和密码' });
return;
}
if (isRegister && !username) {
this.setData({ error: '请输入用户名' });
return;
}
this.setData({ loading: true, error: '' });
try {
if (isRegister) {
await authApi.register(phone, password, username);
wx.showToast({ title: '注册成功,请登录', icon: 'success' });
this.setData({ isRegister: false });
} else {
const res = await authApi.login(phone, password);
app.setToken(res.access_token);
app.globalData.userInfo = res.user;
wx.showToast({ title: '登录成功', icon: 'success' });
setTimeout(() => {
wx.switchTab({ url: '/pages/index/index' });
}, 1000);
}
} catch (err) {
this.setData({ error: err.message || '操作失败,请重试' });
} finally {
this.setData({ loading: false });
}
},
handleWechatLogin() {
wx.getUserProfile({
desc: '用于完善用户资料',
success: (res) => {
console.log('微信登录', res.userInfo);
wx.showToast({ title: '微信登录开发中', icon: 'none' });
},
fail: (err) => {
console.log('微信登录失败', err);
}
});
},
});
+4
View File
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "登录",
"navigationStyle": "custom"
}
+50
View File
@@ -0,0 +1,50 @@
<view class="container">
<view class="logo-section">
<view class="logo">TradeMate</view>
<view class="subtitle">外贸小助手</view>
</view>
<view class="form-section">
<view class="form-title">{{isRegister ? '注册' : '登录'}}</view>
<view class="input-group">
<input class="input" type="number" placeholder="手机号" bindinput="onPhoneInput" value="{{phone}}" />
</view>
<view class="input-group" wx:if="{{isRegister}}">
<input class="input" type="text" placeholder="用户名" bindinput="onUsernameInput" value="{{username}}" />
</view>
<view class="input-group">
<input class="input" type="password" placeholder="密码" bindinput="onPasswordInput" value="{{password}}" />
</view>
<view class="error" wx:if="{{error}}">{{error}}</view>
<button class="submit-btn" bindtap="handleSubmit" disabled="{{loading}}">
{{loading ? '处理中...' : (isRegister ? '注册' : '登录')}}
</button>
<view class="toggle-mode" bindtap="toggleMode">
{{isRegister ? '已有账号?立即登录' : '没有账号?立即注册'}}
</view>
<view class="divider">
<view class="line"></view>
<text class="text">或</text>
<view class="line"></view>
</view>
<button class="wechat-btn" bindtap="handleWechatLogin">
<text class="wechat-icon">W</text>
微信一键登录
</button>
</view>
<view class="footer">
<text class="agreement">登录即表示同意</text>
<text class="link">《用户协议》</text>
<text class="agreement">和</text>
<text class="link">《隐私政策》</text>
</view>
</view>
+142
View File
@@ -0,0 +1,142 @@
.container {
min-height: 100vh;
background: linear-gradient(180deg, #1890ff 0%, #e6f7ff 100%);
padding: 120rpx 60rpx 60rpx;
box-sizing: border-box;
}
.logo-section {
text-align: center;
margin-bottom: 80rpx;
}
.logo {
font-size: 60rpx;
font-weight: bold;
color: #fff;
letter-spacing: 4rpx;
}
.subtitle {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
margin-top: 16rpx;
}
.form-section {
background: #fff;
border-radius: 24rpx;
padding: 48rpx 40rpx;
box-shadow: 0 8rpx 32rpx rgba(0, 0, 0, 0.1);
}
.form-title {
font-size: 40rpx;
font-weight: 600;
color: #333;
text-align: center;
margin-bottom: 48rpx;
}
.input-group {
margin-bottom: 32rpx;
}
.input {
width: 100%;
height: 96rpx;
background: #f5f5f5;
border-radius: 16rpx;
padding: 0 24rpx;
font-size: 28rpx;
box-sizing: border-box;
}
.input:focus {
background: #e6f7ff;
border: 2rpx solid #1890ff;
}
.error {
color: #ff4d4f;
font-size: 24rpx;
margin-bottom: 24rpx;
text-align: center;
}
.submit-btn {
width: 100%;
height: 96rpx;
background: #1890ff;
color: #fff;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.submit-btn[disabled] {
background: #a0cfff;
}
.toggle-mode {
text-align: center;
margin-top: 32rpx;
color: #666;
font-size: 26rpx;
}
.divider {
display: flex;
align-items: center;
margin: 48rpx 0;
}
.divider .line {
flex: 1;
height: 1rpx;
background: #e8e8e8;
}
.divider .text {
padding: 0 24rpx;
color: #999;
font-size: 24rpx;
}
.wechat-btn {
width: 100%;
height: 96rpx;
background: #07c160;
color: #fff;
border-radius: 16rpx;
font-size: 32rpx;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.wechat-icon {
font-size: 36rpx;
font-weight: bold;
margin-right: 16rpx;
}
.footer {
text-align: center;
margin-top: 60rpx;
font-size: 24rpx;
}
.agreement {
color: #999;
}
.link {
color: #1890ff;
}
+95
View File
@@ -0,0 +1,95 @@
const { marketingApi } = require('../../utils/api');
Page({
data: {
productName: '',
description: '',
category: '',
target: 'US importers',
style: 'professional',
results: [],
keywords: [],
loading: false,
activeTab: 'generate',
},
onLoad() {},
switchTab(e) {
const tab = e.currentTarget.dataset.tab;
this.setData({ activeTab: tab });
},
onInput(e) {
const field = e.currentTarget.dataset.field;
this.setData({ [field]: e.detail.value });
},
onTargetChange(e) {
this.setData({ target: e.detail.value });
},
onStyleChange(e) {
this.setData({ style: e.detail.value });
},
async generateContent() {
const { productName, description, target, style } = this.data;
if (!productName || !description) {
wx.showToast({ title: '请填写产品信息', icon: 'none' });
return;
}
this.setData({ loading: true });
try {
const result = await marketingApi.generate(productName, description, this.data.category, target, style);
this.setData({
results: result.results.filter(r => r.content),
loading: false,
});
} catch (err) {
wx.showToast({ title: err.message || '生成失败', icon: 'none' });
this.setData({ loading: false });
}
},
async generateKeywords() {
const { productName, description } = this.data;
if (!productName || !description) {
wx.showToast({ title: '请填写产品信息', icon: 'none' });
return;
}
this.setData({ loading: true });
try {
const result = await marketingApi.getKeywords(productName, description, this.data.category);
this.setData({
keywords: result.keywords,
loading: false,
});
} catch (err) {
wx.showToast({ title: err.message || '生成失败', icon: 'none' });
this.setData({ loading: false });
}
},
copyText(e) {
const text = e.currentTarget.dataset.text;
wx.setClipboardData({
data: text,
success: () => {
wx.showToast({ title: '已复制', icon: 'success' });
},
});
},
clear() {
this.setData({
productName: '',
description: '',
category: '',
results: [],
keywords: [],
});
},
});
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "营销素材",
"usingComponents": {}
}
@@ -0,0 +1,59 @@
<view class="container">
<view class="tabs">
<view class="tab {{activeTab === 'generate' ? 'active' : ''}}" data-tab="generate" bindtap="switchTab">生成文案</view>
<view class="tab {{activeTab === 'keywords' ? 'active' : ''}}" data-tab="keywords" bindtap="switchTab">关键词</view>
</view>
<view class="form-section">
<view class="form-group">
<text class="form-label">产品名称 *</text>
<input class="form-input" placeholder="如:户外折叠椅" value="{{productName}}" data-field="productName" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">产品描述 *</text>
<textarea class="form-input form-textarea" placeholder="描述产品的特点、材质、优势..." value="{{description}}" data-field="description" bindinput="onInput" auto-height />
</view>
<view class="form-group">
<text class="form-label">产品类别</text>
<input class="form-input" placeholder="如:家具、户外用品" value="{{category}}" data-field="category" bindinput="onInput" />
</view>
<view class="select-row" wx:if="{{activeTab === 'generate'}}">
<view class="select-group">
<text class="form-label">目标市场</text>
<input class="form-input" placeholder="如:US importers" value="{{target}}" bindinput="onInput" data-field="target" />
</view>
<view class="select-group">
<text class="form-label">风格</text>
<input class="form-input" placeholder="如:professional" value="{{style}}" bindinput="onInput" data-field="style" />
</view>
</view>
<button class="btn-generate" bindtap="{{activeTab === 'generate' ? 'generateContent' : 'generateKeywords'}}" loading="{{loading}}">
{{activeTab === 'generate' ? '生成营销文案' : '生成关键词'}}
</button>
</view>
<view class="results" wx:if="{{activeTab === 'generate' && results.length > 0}}">
<view class="section-title">生成结果</view>
<view class="result-item" wx:for="{{results}}" wx:key="style">
<view class="result-header">
<text class="result-style">{{item.style}}</text>
<text class="result-provider">{{item.provider}}</text>
</view>
<text class="result-content">{{item.content}}</text>
<view>
<text class="copy-btn" bindtap="copyText" data-text="{{item.content}}">复制文案</text>
</view>
</view>
</view>
<view class="keywords-section" wx:if="{{activeTab === 'keywords' && keywords.length > 0}}">
<view class="section-title">关键词建议</view>
<view class="keyword-list">
<view class="keyword-tag" wx:for="{{keywords}}" wx:key="index">{{item}}</view>
</view>
</view>
</view>
+123
View File
@@ -0,0 +1,123 @@
.tabs {
display: flex;
background: #fff;
padding: 20rpx;
}
.tab {
flex: 1;
text-align: center;
padding: 20rpx;
font-size: 28rpx;
color: #666;
border-bottom: 4rpx solid transparent;
}
.tab.active {
color: #1890ff;
border-bottom-color: #1890ff;
font-weight: bold;
}
.form-section {
padding: 30rpx;
}
.form-group {
margin-bottom: 20rpx;
}
.form-label {
font-size: 26rpx;
color: #666;
margin-bottom: 10rpx;
display: block;
}
.form-input {
border: 1rpx solid #d9d9d9;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
width: 100%;
box-sizing: border-box;
}
.form-textarea {
min-height: 150rpx;
}
.select-row {
display: flex;
gap: 20rpx;
}
.select-group {
flex: 1;
}
.btn-generate {
background: #1890ff;
color: #fff;
margin: 0 30rpx 30rpx;
}
.results {
padding: 0 30rpx 30rpx;
}
.result-item {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
}
.result-style {
font-size: 24rpx;
color: #1890ff;
font-weight: bold;
}
.result-provider {
font-size: 22rpx;
color: #999;
}
.result-content {
font-size: 28rpx;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
}
.copy-btn {
color: #1890ff;
font-size: 24rpx;
padding: 10rpx 0;
}
.keywords-section {
padding: 30rpx;
}
.keyword-list {
display: flex;
flex-wrap: wrap;
gap: 15rpx;
}
.keyword-tag {
background: #e6f7ff;
color: #1890ff;
padding: 10rpx 20rpx;
border-radius: 20rpx;
font-size: 26rpx;
}
+155
View File
@@ -0,0 +1,155 @@
const { quotationApi, customerApi } = require('../../utils/api');
Page({
data: {
quotations: [],
page: 1,
hasMore: true,
loading: false,
showCreateModal: false,
customers: [],
newQuotation: {
customer_id: '',
title: '',
items: [],
currency: 'USD',
payment_terms: 'T/T 30% deposit',
delivery_terms: 'FOB',
lead_time: '',
notes: '',
},
tempItem: {
product_name: '',
quantity: 1,
unit_price: 0,
},
},
onLoad() {
this.loadQuotations();
},
onReachBottom() {
if (this.data.hasMore && !this.data.loading) {
this.setData({ page: this.data.page + 1 });
this.loadQuotations(true);
}
},
async loadQuotations(isAppend = false) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
const result = await quotationApi.list(this.data.page);
this.setData({
quotations: isAppend ? [...this.data.quotations, ...result.items] : result.items,
hasMore: result.items.length >= result.size,
loading: false,
});
} catch (err) {
wx.showToast({ title: err.message || '加载失败', icon: 'none' });
this.setData({ loading: false });
}
},
async showCreate() {
try {
const customers = await customerApi.list(1, 100);
this.setData({
showCreateModal: true,
customers: customers.items,
});
} catch (err) {
wx.showToast({ title: '加载客户失败', icon: 'none' });
}
},
hideCreate() {
this.setData({
showCreateModal: false,
newQuotation: {
customer_id: '',
title: '',
items: [],
currency: 'USD',
payment_terms: 'T/T 30% deposit',
delivery_terms: 'FOB',
lead_time: '',
notes: '',
},
});
},
onInput(e) {
const field = e.currentTarget.dataset.field;
const value = e.detail.value;
this.setData({ [`newQuotation.${field}`]: value });
},
onItemInput(e) {
const field = e.currentTarget.dataset.field;
const value = e.detail.value;
this.setData({ [`tempItem.${field}`]: value });
},
addItem() {
const { tempItem, newQuotation } = this.data;
if (!tempItem.product_name) {
wx.showToast({ title: '请输入产品名称', icon: 'none' });
return;
}
this.setData({
newQuotation: {
...newQuotation,
items: [...newQuotation.items, { ...tempItem }],
},
tempItem: {
product_name: '',
quantity: 1,
unit_price: 0,
},
});
},
removeItem(e) {
const index = e.currentTarget.dataset.index;
const { newQuotation } = this.data;
newQuotation.items.splice(index, 1);
this.setData({ newQuotation });
},
async createQuotation() {
const { newQuotation } = this.data;
if (!newQuotation.customer_id) {
wx.showToast({ title: '请选择客户', icon: 'none' });
return;
}
if (newQuotation.items.length === 0) {
wx.showToast({ title: '请添加产品', icon: 'none' });
return;
}
try {
await quotationApi.create(newQuotation);
wx.showToast({ title: '创建成功', icon: 'success' });
this.hideCreate();
this.setData({ page: 1, quotations: [] });
this.loadQuotations();
} catch (err) {
wx.showToast({ title: err.message || '创建失败', icon: 'none' });
}
},
viewDetail(e) {
const id = e.currentTarget.dataset.id;
wx.navigateTo({ url: `/pages/quotation/detail?id=${id}` });
},
onPullDownRefresh() {
this.setData({ page: 1, quotations: [] });
this.loadQuotations();
wx.stopPullDownRefresh();
},
});
@@ -0,0 +1,5 @@
{
"navigationBarTitleText": "报价单",
"enablePullDownRefresh": true,
"usingComponents": {}
}
@@ -0,0 +1,89 @@
<view class="container">
<view class="header">
<text class="section-title">报价单</text>
<button class="add-btn" bindtap="showCreate">+ 新建报价</button>
</view>
<view class="quotation-list">
<view class="quotation-item" wx:for="{{quotations}}" wx:key="id" bindtap="viewDetail" data-id="{{item.id}}">
<view class="quotation-header">
<text class="quotation-title">{{item.title || '报价单'}}</text>
<view class="quotation-status status-{{item.status}}">
{{item.status === 'draft' ? '草稿' : item.status === 'sent' ? '已发送' : '已接受'}}
</view>
</view>
<view class="quotation-info">
货币: {{item.currency}} ·条款: {{item.delivery_terms}}
</view>
<view class="quotation-total">
合计: ${{item.total || 0}}
</view>
</view>
<view class="empty" wx:if="{{quotations.length === 0 && !loading}}">
<text>暂无报价单,点击上方创建</text>
</view>
</view>
<view class="modal" wx:if="{{showCreateModal}}" bindtap="hideCreate">
<view class="modal-content" catchtap>
<view class="modal-title">新建报价单</view>
<view class="form-group">
<text class="form-label">客户 *</text>
<picker bindchange="onCustomerChange" value="{{customerIndex}}" range="{{customers}}" range-key="name">
<view class="form-input">
{{customers[customerIndex] ? customers[customerIndex].name : '请选择客户'}}
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">标题</text>
<input class="form-input" placeholder="报价单标题" value="{{newQuotation.title}}" data-field="title" bindinput="onInput" />
</view>
<view class="items-section">
<text class="form-label">产品明细</text>
<view class="item-row" wx:for="{{newQuotation.items}}" wx:key="index">
<text>{{item.product_name}}</text>
<text>x{{item.quantity}}</text>
<text>${{item.unit_price}}</text>
<text class="remove-item" data-index="{{index}}" bindtap="removeItem">×</text>
</view>
<view class="item-row">
<input class="form-input item-input" placeholder="产品名称" value="{{tempItem.product_name}}" data-field="product_name" bindinput="onItemInput" />
<input class="form-input qty-input" type="number" placeholder="数量" value="{{tempItem.quantity}}" data-field="quantity" bindinput="onItemInput" />
<input class="form-input price-input" type="digit" placeholder="单价" value="{{tempItem.unit_price}}" data-field="unit_price" bindinput="onItemInput" />
</view>
<view class="add-item-btn" bindtap="addItem">+ 添加产品</view>
</view>
<view class="form-group">
<text class="form-label">货币</text>
<input class="form-input" placeholder="USD" value="{{newQuotation.currency}}" data-field="currency" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">付款条款</text>
<input class="form-input" placeholder="T/T 30% deposit" value="{{newQuotation.payment_terms}}" data-field="payment_terms" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">交货条款</text>
<input class="form-input" placeholder="FOB" value="{{newQuotation.delivery_terms}}" data-field="delivery_terms" bindinput="onInput" />
</view>
<view class="form-group">
<text class="form-label">交货周期</text>
<input class="form-input" placeholder="如:25 days" value="{{newQuotation.lead_time}}" data-field="lead_time" bindinput="onInput" />
</view>
<view class="modal-btns">
<view class="modal-btn btn-cancel" bindtap="hideCreate">取消</view>
<view class="modal-btn btn-confirm" bindtap="createQuotation">创建</view>
</view>
</view>
</view>
</view>
+195
View File
@@ -0,0 +1,195 @@
.header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
background: #fff;
}
.add-btn {
background: #1890ff;
color: #fff;
font-size: 28rpx;
padding: 15rpx 30rpx;
border-radius: 8rpx;
}
.quotation-list {
padding: 0 30rpx;
}
.quotation-item {
background: #fff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
}
.quotation-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
}
.quotation-title {
font-size: 30rpx;
font-weight: bold;
color: #333;
}
.quotation-status {
padding: 6rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
}
.status-draft {
background: #f5f5f5;
color: #666;
}
.status-sent {
background: #e6f7ff;
color: #1890ff;
}
.status-accepted {
background: #f6ffed;
color: #52c41a;
}
.quotation-info {
font-size: 24rpx;
color: #999;
margin-bottom: 10rpx;
}
.quotation-total {
font-size: 32rpx;
font-weight: bold;
color: #ff4d4f;
}
.empty {
text-align: center;
padding: 100rpx;
color: #999;
}
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
z-index: 100;
overflow-y: auto;
padding-top: 50rpx;
}
.modal-content {
background: #fff;
border-radius: 12rpx;
padding: 40rpx;
width: 85%;
max-height: 90vh;
overflow-y: auto;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
margin-bottom: 30rpx;
text-align: center;
}
.form-group {
margin-bottom: 20rpx;
}
.form-label {
font-size: 26rpx;
color: #666;
margin-bottom: 10rpx;
display: block;
}
.form-input {
border: 1rpx solid #d9d9d9;
border-radius: 8rpx;
padding: 20rpx;
font-size: 28rpx;
width: 100%;
box-sizing: border-box;
}
.items-section {
background: #f9f9f9;
padding: 20rpx;
border-radius: 8rpx;
margin-bottom: 20rpx;
}
.item-row {
display: flex;
gap: 10rpx;
align-items: center;
margin-bottom: 15rpx;
}
.item-input {
flex: 1;
}
.qty-input {
width: 100rpx;
}
.price-input {
width: 150rpx;
}
.remove-item {
color: #ff4d4f;
font-size: 32rpx;
padding: 10rpx;
}
.add-item-btn {
background: #fff;
border: 1rpx dashed #1890ff;
color: #1890ff;
font-size: 26rpx;
padding: 15rpx;
text-align: center;
border-radius: 8rpx;
}
.modal-btns {
display: flex;
gap: 20rpx;
margin-top: 30rpx;
}
.modal-btn {
flex: 1;
padding: 20rpx;
border-radius: 8rpx;
text-align: center;
font-size: 28rpx;
}
.btn-cancel {
background: #f5f5f5;
color: #666;
}
.btn-confirm {
background: #1890ff;
color: #fff;
}
+101
View File
@@ -0,0 +1,101 @@
const { translateApi } = require('../../utils/api');
Page({
data: {
tab: 'translate',
sourceText: '',
translatedText: '',
targetLang: 'en',
langOptions: [
{ value: 'en', label: 'English' },
{ value: 'zh', label: '中文' },
{ value: 'es', label: 'Español' },
{ value: 'fr', label: 'Français' },
{ value: 'de', label: 'Deutsch' },
{ value: 'ja', label: '日本語' },
{ value: 'pt', label: 'Português' },
],
replyInquiry: '',
replySuggestions: [],
loading: false,
},
onLoad() {},
switchTab(e) {
const tab = e.currentTarget.dataset.tab;
this.setData({ tab });
},
onSourceInput(e) {
this.setData({ sourceText: e.detail.value });
},
onReplyInput(e) {
this.setData({ replyInquiry: e.detail.value });
},
onLangChange(e) {
this.setData({ targetLang: e.detail.value });
},
async doTranslate() {
if (!this.data.sourceText.trim()) {
wx.showToast({ title: '请输入要翻译的内容', icon: 'none' });
return;
}
this.setData({ loading: true });
try {
const result = await translateApi.translate(
this.data.sourceText,
this.data.targetLang
);
this.setData({
translatedText: result.translated_text,
loading: false,
});
} catch (err) {
wx.showToast({ title: err.message || '翻译失败', icon: 'none' });
this.setData({ loading: false });
}
},
async doGetReply() {
if (!this.data.replyInquiry.trim()) {
wx.showToast({ title: '请输入客户询盘内容', icon: 'none' });
return;
}
this.setData({ loading: true });
try {
const result = await translateApi.getReply(this.data.replyInquiry);
this.setData({
replySuggestions: result.suggestions,
loading: false,
});
} catch (err) {
wx.showToast({ title: err.message || '生成回复失败', icon: 'none' });
this.setData({ loading: false });
}
},
copyText(e) {
const text = e.currentTarget.dataset.text;
wx.setClipboardData({
data: text,
success: () => {
wx.showToast({ title: '已复制', icon: 'success' });
},
});
},
clearInput() {
this.setData({
sourceText: '',
translatedText: '',
replyInquiry: '',
replySuggestions: [],
});
},
});
@@ -0,0 +1,4 @@
{
"navigationBarTitleText": "智能翻译",
"usingComponents": {}
}
@@ -0,0 +1,52 @@
<view class="container">
<view class="tabs">
<view class="tab {{tab === 'translate' ? 'active' : ''}}" data-tab="translate" bindtap="switchTab">翻译</view>
<view class="tab {{tab === 'reply' ? 'active' : ''}}" data-tab="reply" bindtap="switchTab">回复建议</view>
</view>
<view class="content">
<view wx:if="{{tab === 'translate'}}">
<view class="input-area">
<text class="input-label">输入要翻译的内容</text>
<textarea class="textarea" placeholder="请输入中文或英文..." value="{{sourceText}}" bindinput="onSourceInput" auto-height />
</view>
<view class="lang-select">
<text>目标语言</text>
<picker value="{{targetLang}}" range="{{langOptions}}" range-key="label" bindchange="onLangChange">
<text class="picker">{{langOptions[langOptions.findIndex(function(item) { return item.value === targetLang })].label}}</text>
</picker>
</view>
<button class="btn-primary btn-translate" bindtap="doTranslate" loading="{{loading}}">翻译</button>
<view class="result-area" wx:if="{{translatedText}}">
<view class="result-header">
<text class="input-label">翻译结果</text>
<text class="copy-btn" bindtap="copyText" data-text="{{translatedText}}">复制</text>
</view>
<text class="result-text">{{translatedText}}</text>
</view>
</view>
<view wx:if="{{tab === 'reply'}}">
<view class="input-area">
<text class="input-label">输入客户询盘内容</text>
<textarea class="textarea" placeholder="粘贴客户的英文询盘..." value="{{replyInquiry}}" bindinput="onReplyInput" auto-height />
</view>
<button class="btn-primary btn-translate" bindtap="doGetReply" loading="{{loading}}">生成回复建议</button>
<view class="suggestions" wx:if="{{replySuggestions.length > 0}}">
<view class="section-title">回复建议</view>
<view class="suggestion-item" wx:for="{{replySuggestions}}" wx:key="tone">
<text class="suggestion-tone">{{item.tone}}</text>
<text class="suggestion-content">{{item.reply}}</text>
<view style="margin-top: 10rpx;">
<text class="copy-btn" bindtap="copyText" data-text="{{item.reply}}">复制</text>
</view>
</view>
</view>
</view>
</view>
</view>
+114
View File
@@ -0,0 +1,114 @@
.tabs {
display: flex;
background: #fff;
padding: 20rpx;
}
.tab {
flex: 1;
text-align: center;
padding: 20rpx;
font-size: 28rpx;
color: #666;
border-bottom: 4rpx solid transparent;
}
.tab.active {
color: #1890ff;
border-bottom-color: #1890ff;
font-weight: bold;
}
.content {
padding: 30rpx;
}
.input-area {
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.input-label {
font-size: 24rpx;
color: #999;
margin-bottom: 10rpx;
display: block;
}
.textarea {
width: 100%;
min-height: 200rpx;
font-size: 28rpx;
line-height: 1.5;
}
.lang-select {
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
padding: 20rpx;
border-radius: 12rpx;
margin-bottom: 20rpx;
}
.picker {
color: #1890ff;
font-weight: bold;
}
.btn-translate {
background: #1890ff;
color: #fff;
margin-bottom: 20rpx;
}
.result-area {
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10rpx;
}
.copy-btn {
color: #1890ff;
font-size: 24rpx;
}
.result-text {
font-size: 28rpx;
line-height: 1.6;
color: #333;
white-space: pre-wrap;
}
.suggestions {
margin-top: 30rpx;
}
.suggestion-item {
background: #fff;
border-radius: 12rpx;
padding: 20rpx;
margin-bottom: 20rpx;
}
.suggestion-tone {
font-size: 24rpx;
color: #1890ff;
margin-bottom: 10rpx;
}
.suggestion-content {
font-size: 28rpx;
line-height: 1.5;
color: #333;
}
+46
View File
@@ -0,0 +1,46 @@
{
"description": "外贸小助手 - 微信小程序",
"packOptions": {
"ignore": []
},
"setting": {
"bundle": false,
"userConfirmedBundleSwitch": false,
"urlCheck": true,
"scopeDataCheck": false,
"coverView": true,
"es6": true,
"postcss": true,
"compileHotReLoad": false,
"lazyloadPlaceholderEnable": false,
"preloadBackgroundData": false,
"minified": true,
"autoAudits": false,
"newFeature": false,
"uglifyFileName": false,
"uploadWithSourceMap": true,
"useIsolateContext": true,
"nodeModules": false,
"enhance": true,
"useMultiFrameRuntime": true,
"useApiHook": true,
"useApiHostProcess": true,
"showShadowRootInWxmlPanel": true,
"packNpmManually": false,
"packNpmRelationList": [],
"minifyWXSS": true,
"disableUseStrict": false,
"minifyWXML": true,
"showES6CompileOption": false,
"useCompilerPlugins": false
},
"compileType": "miniprogram",
"libVersion": "3.3.4",
"appid": "wxxxxxxxxxxx",
"projectname": "trade-assistant",
"condition": {},
"editorSetting": {
"tabIndent": "insertTwoSpaces",
"tabSize": 2
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
"rules": [
{
"action": "allow",
"page": "*"
}
]
}
+96
View File
@@ -0,0 +1,96 @@
const app = getApp();
const request = (url, method = 'GET', data = {}) => {
return new Promise((resolve, reject) => {
const header = {
'Content-Type': 'application/json',
...app.getAuthHeader(),
};
wx.request({
url: `${app.globalData.baseUrl}${url}`,
method,
data,
header,
success: (res) => {
if (res.statusCode === 200) {
resolve(res.data);
} else if (res.statusCode === 401) {
app.clearToken();
wx.redirectTo({ url: '/pages/login/login' });
reject(new Error('Unauthorized'));
} else {
reject(new Error(res.data?.detail || 'Request failed'));
}
},
fail: (err) => {
reject(err);
},
});
});
};
export const authApi = {
login: (phone, password) => request('/auth/login', 'POST', { username: phone, password }),
register: (phone, password, username) => request('/auth/register', 'POST', { phone, password, username }),
getUserInfo: () => request('/auth/me'),
};
export const translateApi = {
translate: (text, targetLang, sourceLang = 'auto') =>
request('/translate', 'POST', { text, target_lang: targetLang, source_lang: sourceLang }),
getReply: (inquiry, tone = 'professional', count = 3) =>
request('/translate/reply', 'POST', { inquiry, tone, count }),
extract: (text, type = 'auto') =>
request('/translate/extract', 'POST', { text, extract_type: type }),
};
export const customerApi = {
list: (page = 1, size = 20, status) => {
const params = new URLSearchParams({ page, size });
if (status) params.append('status', status);
return request(`/customers?${params.toString()}`);
},
get: (id) => request(`/customers/${id}`),
create: (data) => request('/customers', 'POST', data),
update: (id, data) => request(`/customers/${id}`, 'PATCH', data),
delete: (id) => request(`/customers/${id}`, 'DELETE'),
getSilent: (days = 3) => request(`/customers/silent?days=${days}`),
getConversation: (id, page = 1, size = 50) =>
request(`/customers/${id}/conversation?page=${page}&size=${size}`),
};
export const marketingApi = {
generate: (productName, description, category, target = 'US importers', style = 'professional') =>
request('/marketing/generate', 'POST', {
product_name: productName,
description,
category,
target,
style,
}),
getKeywords: (productName, description, category) =>
request('/marketing/keywords', 'POST', {
product_name: productName,
description,
category,
}),
analyzeCompetitors: (productName, description, category, market = 'US') =>
request('/marketing/competitor-analysis', 'POST', {
product_name: productName,
description,
category,
market,
}),
};
export const quotationApi = {
list: (page = 1, size = 20) => request(`/quotations?page=${page}&size=${size}`),
get: (id) => request(`/quotations/${id}`),
create: (data) => request('/quotations', 'POST', data),
updateStatus: (id, status) => request(`/quotations/${id}/status`, 'PATCH', { status }),
};
export const whatsappApi = {
send: (to, text) => request('/whatsapp/send', 'POST', { to, text }),
};
+58
View File
@@ -0,0 +1,58 @@
upstream tradmate_backend {
server 127.0.0.1:8000;
}
server {
listen 80;
server_name tradmate.example.com;
client_max_body_size 10M;
location / {
proxy_pass http://tradmate_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
location /docs {
proxy_pass http://tradmate_backend;
proxy_set_header Host $host;
}
location /redoc {
proxy_pass http://tradmate_backend;
proxy_set_header Host $host;
}
}
server {
listen 443 ssl http2;
server_name tradmate.example.com;
ssl_certificate /etc/nginx/ssl/cert.pem;
ssl_certificate_key /etc/nginx/ssl/key.pem;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:50m;
ssl_session_tickets off;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
client_max_body_size 10M;
location / {
proxy_pass http://tradmate_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
}
}
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
set -e
BACKUP_DIR="/opt/trademate/backups"
DATE=$(date +%Y%m%d_%H%M%S)
DB_NAME="tradmate"
DB_USER="tradmate"
mkdir -p "$BACKUP_DIR"
echo "Backing up database..."
pg_dump -U "$DB_USER" -h localhost "$DB_NAME" > "$BACKUP_DIR/db_$DATE.sql"
find "$BACKUP_DIR" -name "db_*.sql" -mtime +7 -delete
echo "Backing up uploads..."
if [ -d "/opt/trademate/backend/uploads" ]; then
tar -czf "$BACKUP_DIR/uploads_$DATE.tar.gz" -C /opt/trademate/backend uploads
fi
find "$BACKUP_DIR" -name "uploads_*.tar.gz" -mtime +7 -delete
echo "Backup completed: $DATE"
echo "Files saved to $BACKUP_DIR"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
set -e
echo "=== TradeMate Deployment ==="
cd /opt/trademate
echo "Pulling latest changes..."
git pull origin main
echo "Building Docker images..."
docker-compose build
echo "Running migrations..."
docker-compose run --rm backend alembic upgrade head
echo "Restarting services..."
docker-compose restart
echo "Checking service health..."
sleep 5
curl -f http://localhost:8000/health || echo "Health check failed!"
echo "=== Deployment Complete ==="
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
set -e
echo "Initializing database..."
cd /opt/trademate/backend
if [ ! -f .env ]; then
echo "Creating .env from .env.example..."
cp .env.example .env
echo "Please edit .env with your actual configuration!"
fi
echo "Running Alembic migrations..."
alembic upgrade head
echo "Database initialized successfully!"

Some files were not shown because too many files have changed in this diff Show More