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