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:
+47
@@ -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
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"}
|
||||
@@ -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')
|
||||
@@ -0,0 +1,3 @@
|
||||
from .router import get_ai_router
|
||||
|
||||
__all__ = ["get_ai_router"]
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
@@ -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."}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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()
|
||||
@@ -0,0 +1,58 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
|
||||
class TradeMateException(Exception):
|
||||
def __init__(self, code: int, message: str, detail: str = None):
|
||||
self.code = code
|
||||
self.message = message
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class NotFoundError(TradeMateException):
|
||||
def __init__(self, resource: str = "Resource"):
|
||||
super().__init__(404, f"{resource} not found")
|
||||
|
||||
|
||||
class UnauthorizedError(TradeMateException):
|
||||
def __init__(self, detail: str = "Authentication required"):
|
||||
super().__init__(401, "Unauthorized", detail)
|
||||
|
||||
|
||||
class ForbiddenError(TradeMateException):
|
||||
def __init__(self, detail: str = "Insufficient permissions"):
|
||||
super().__init__(403, "Forbidden", detail)
|
||||
|
||||
|
||||
class QuotaExceededError(TradeMateException):
|
||||
def __init__(self, feature: str):
|
||||
super().__init__(429, "Quota exceeded", f"Daily limit reached for {feature}. Upgrade to Pro for more.")
|
||||
|
||||
|
||||
class TierRestrictionError(TradeMateException):
|
||||
def __init__(self, feature: str, required_tier: str):
|
||||
super().__init__(
|
||||
402,
|
||||
"Upgrade required",
|
||||
f"{feature} requires {required_tier} plan",
|
||||
)
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI):
|
||||
@app.exception_handler(TradeMateException)
|
||||
async def handle_tradmate_exception(request: Request, exc: TradeMateException):
|
||||
return JSONResponse(
|
||||
status_code=exc.code,
|
||||
content={
|
||||
"error": exc.message,
|
||||
"detail": exc.detail,
|
||||
"code": exc.code,
|
||||
},
|
||||
)
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def handle_generic_exception(request: Request, exc: Exception):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"error": "Internal server error", "detail": str(exc) if app.debug else "An unexpected error occurred"},
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
from fastapi import Request
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.config import settings
|
||||
from app.core.security import decode_token
|
||||
import redis.asyncio as aioredis
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_user_tier_from_token(request: Request) -> str:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if not auth.startswith("Bearer "):
|
||||
return "anonymous"
|
||||
payload = decode_token(auth[7:])
|
||||
if not payload:
|
||||
return "anonymous"
|
||||
request.state.user_id = payload.get("sub")
|
||||
request.state.user_tier = payload.get("tier", "free")
|
||||
return request.state.user_tier
|
||||
|
||||
|
||||
class TierMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if request.url.path.startswith("/api/v1"):
|
||||
tier = get_user_tier_from_token(request)
|
||||
tier_config = {
|
||||
"free": {
|
||||
"max_products": settings.FREE_MAX_PRODUCTS,
|
||||
"max_customers": settings.FREE_MAX_CUSTOMERS,
|
||||
},
|
||||
"pro": {
|
||||
"max_products": settings.PRO_MAX_PRODUCTS,
|
||||
"max_customers": settings.PRO_MAX_CUSTOMERS,
|
||||
},
|
||||
"enterprise": {
|
||||
"max_products": 9999,
|
||||
"max_customers": 99999,
|
||||
},
|
||||
}
|
||||
request.state.tier_config = tier_config.get(tier, tier_config["free"])
|
||||
else:
|
||||
request.state.user_id = None
|
||||
request.state.user_tier = "anonymous"
|
||||
request.state.tier_config = {}
|
||||
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
|
||||
class QuotaMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
if not request.url.path.startswith("/api/v1"):
|
||||
return await call_next(request)
|
||||
|
||||
if request.state.user_tier in ("anonymous",):
|
||||
return await call_next(request)
|
||||
|
||||
user_id = request.state.user_id
|
||||
tier = request.state.user_tier
|
||||
|
||||
if tier == "enterprise":
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
method = request.method
|
||||
|
||||
if method == "GET":
|
||||
return await call_next(request)
|
||||
|
||||
quota_map = {
|
||||
"/api/v1/translate": {
|
||||
"free": settings.FREE_DAILY_TRANSLATE_CHARS,
|
||||
"pro": settings.PRO_DAILY_TRANSLATE_CHARS,
|
||||
},
|
||||
"/api/v1/translate/reply": {
|
||||
"free": settings.FREE_DAILY_REPLIES,
|
||||
"pro": settings.PRO_DAILY_REPLIES,
|
||||
},
|
||||
"/api/v1/marketing": {
|
||||
"free": settings.FREE_DAILY_MARKETING,
|
||||
"pro": settings.PRO_DAILY_MARKETING,
|
||||
},
|
||||
"/api/v1/quotations": {
|
||||
"free": settings.FREE_DAILY_QUOTATIONS,
|
||||
"pro": settings.PRO_DAILY_QUOTATIONS,
|
||||
},
|
||||
}
|
||||
|
||||
matched_key = None
|
||||
for prefix, limits in quota_map.items():
|
||||
if path.startswith(prefix):
|
||||
matched_key = prefix
|
||||
break
|
||||
|
||||
if not matched_key:
|
||||
return await call_next(request)
|
||||
|
||||
limit = quota_map[matched_key].get(tier)
|
||||
if limit is None:
|
||||
return await call_next(request)
|
||||
|
||||
try:
|
||||
r = aioredis.from_url(settings.REDIS_URL)
|
||||
key = f"quota:{user_id}:{matched_key}:{datetime.utcnow().strftime('%Y%m%d')}"
|
||||
current = await r.incr(key)
|
||||
await r.expire(key, 86400)
|
||||
if current > limit:
|
||||
from app.core.exceptions import QuotaExceededError
|
||||
raise QuotaExceededError(matched_key)
|
||||
request.state.quota_remaining = limit - current
|
||||
except QuotaExceededError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f"Quota check failed: {e}")
|
||||
|
||||
return await call_next(request)
|
||||
@@ -0,0 +1,38 @@
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
from app.config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + (
|
||||
expires_delta or timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({"exp": expire, "type": "access"})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(data: dict) -> str:
|
||||
to_encode = data.copy()
|
||||
expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
to_encode.update({"exp": expire, "type": "refresh"})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
return jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
except JWTError:
|
||||
return None
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}"}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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`。
|
||||
@@ -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开放 |
|
||||
@@ -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)
|
||||
@@ -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%
|
||||
```
|
||||
@@ -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}` } : {};
|
||||
},
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "外贸小助手",
|
||||
"usingComponents": {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"navigationBarTitleText": "登录",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"desc": "关于本文件的更多信息,请参考文档 https://developers.weixin.qq.com/miniprogram/dev/framework/sitemap.html",
|
||||
"rules": [
|
||||
{
|
||||
"action": "allow",
|
||||
"page": "*"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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 ==="
|
||||
@@ -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
Reference in New Issue
Block a user