From c6206787da938e5a6394d4b2eeed5a934beb768e Mon Sep 17 00:00:00 2001 From: TradeMate Dev Date: Fri, 8 May 2026 18:17:12 +0800 Subject: [PATCH] =?UTF-8?q?Initial=20commit:=20TradeMate=20=E5=A4=96?= =?UTF-8?q?=E8=B4=B8=E5=B0=8F=E5=8A=A9=E6=89=8B=20MVP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目结构: - backend/ Python FastAPI 后端 - uni-app/ uni-app跨端前端 - docs/ 设计文档 - docker-compose.yml Docker编排 - nginx/scripts/systemd 运维配置 已完成功能: - 用户认证 (JWT) - 智能翻译 + 回复建议 - 营销素材生成 - 客户管理 + 沉默检测 - 报价单管理 - 产品库管理 - 汇率换算 - 推送通知 (uni-push) - WhatsApp Webhook框架 - Celery定时任务 --- .gitignore | 47 ++ backend/.env.example | 45 ++ backend/Dockerfile | 20 + backend/alembic.ini | 39 ++ backend/alembic/env.py | 61 ++ backend/alembic/script.py.mako | 25 + backend/alembic/versions/001_initial.py | 189 ++++++ backend/app/__init__.py | 0 backend/app/ai/__init__.py | 3 + backend/app/ai/base.py | 45 ++ backend/app/ai/providers/__init__.py | 6 + backend/app/ai/providers/claude.py | 83 +++ backend/app/ai/providers/deepl.py | 51 ++ backend/app/ai/providers/local.py | 55 ++ backend/app/ai/providers/openai.py | 102 +++ backend/app/ai/router.py | 110 +++ backend/app/ai/trade_corpus.py | 87 +++ backend/app/api/__init__.py | 0 backend/app/api/v1/__init__.py | 0 backend/app/api/v1/auth.py | 153 +++++ backend/app/api/v1/customer.py | 99 +++ backend/app/api/v1/deps.py | 13 + backend/app/api/v1/exchange.py | 54 ++ backend/app/api/v1/marketing.py | 90 +++ backend/app/api/v1/product.py | 101 +++ backend/app/api/v1/push.py | 147 ++++ backend/app/api/v1/quotation.py | 60 ++ backend/app/api/v1/translate.py | 86 +++ backend/app/api/v1/whatsapp.py | 62 ++ backend/app/celery_app.py | 23 + backend/app/config.py | 73 ++ backend/app/core/__init__.py | 0 backend/app/core/exceptions.py | 58 ++ backend/app/core/middleware.py | 118 ++++ backend/app/core/security.py | 38 ++ backend/app/database.py | 33 + backend/app/main.py | 53 ++ backend/app/models/__init__.py | 11 + backend/app/models/corpus.py | 26 + backend/app/models/customer.py | 72 ++ backend/app/models/quotation.py | 50 ++ backend/app/models/user.py | 54 ++ backend/app/services/__init__.py | 0 backend/app/services/customer.py | 204 ++++++ backend/app/services/marketing.py | 84 +++ backend/app/services/product.py | 100 +++ backend/app/services/quotation.py | 166 +++++ backend/app/services/translation.py | 115 ++++ backend/app/services/whatsapp.py | 109 +++ backend/app/workers/__init__.py | 0 backend/app/workers/tasks.py | 193 ++++++ backend/pytest.ini | 10 + backend/requirements.txt | 19 + backend/tests/__init__.py | 0 backend/tests/conftest.py | 81 +++ backend/tests/test_auth_api.py | 94 +++ backend/tests/test_config.py | 42 ++ backend/tests/test_customer_api.py | 147 ++++ backend/tests/test_exceptions.py | 45 ++ backend/tests/test_security.py | 47 ++ docker-compose.yml | 88 +++ docs/API_DESIGN.md | 352 ++++++++++ docs/DATABASE_SCHEMA.md | 388 +++++++++++ docs/PRODUCT_DESIGN.md | 250 +++++++ docs/PROJECT_STATUS.md | 235 +++++++ docs/TECH_ARCHITECTURE.md | 361 ++++++++++ miniprogram/app.js | 29 + miniprogram/app.json | 55 ++ miniprogram/app.wxss | 102 +++ miniprogram/pages/customers/customers.js | 121 ++++ miniprogram/pages/customers/customers.json | 5 + miniprogram/pages/customers/customers.wxml | 68 ++ miniprogram/pages/customers/customers.wxss | 160 +++++ miniprogram/pages/index/index.js | 80 +++ miniprogram/pages/index/index.json | 4 + miniprogram/pages/index/index.wxml | 61 ++ miniprogram/pages/index/index.wxss | 142 ++++ miniprogram/pages/login/login.js | 81 +++ miniprogram/pages/login/login.json | 4 + miniprogram/pages/login/login.wxml | 50 ++ miniprogram/pages/login/login.wxss | 142 ++++ miniprogram/pages/marketing/marketing.js | 95 +++ miniprogram/pages/marketing/marketing.json | 4 + miniprogram/pages/marketing/marketing.wxml | 59 ++ miniprogram/pages/marketing/marketing.wxss | 123 ++++ miniprogram/pages/quotation/quotation.js | 155 +++++ miniprogram/pages/quotation/quotation.json | 5 + miniprogram/pages/quotation/quotation.wxml | 89 +++ miniprogram/pages/quotation/quotation.wxss | 195 ++++++ miniprogram/pages/translate/translate.js | 101 +++ miniprogram/pages/translate/translate.json | 4 + miniprogram/pages/translate/translate.wxml | 52 ++ miniprogram/pages/translate/translate.wxss | 114 ++++ miniprogram/project.config.json | 46 ++ miniprogram/sitemap.json | 9 + miniprogram/utils/api.js | 96 +++ nginx/trademate.conf | 58 ++ scripts/backup.sh | 25 + scripts/deploy.sh | 25 + scripts/init-db.sh | 18 + scripts/logs.sh | 9 + systemd/tradmate-backend.service | 16 + systemd/tradmate-celery-beat.service | 16 + systemd/tradmate-celery.service | 16 + uni-app/index.html | 12 + uni-app/package.json | 25 + uni-app/src/App.vue | 110 +++ .../src/components/tabbar/custom-tabbar.vue | 95 +++ uni-app/src/main.js | 9 + uni-app/src/pages.json | 53 ++ uni-app/src/pages/customers/customers.vue | 591 ++++++++++++++++ uni-app/src/pages/index/index.vue | 299 +++++++++ uni-app/src/pages/login/login.vue | 287 ++++++++ uni-app/src/pages/marketing/marketing.vue | 391 +++++++++++ uni-app/src/pages/product/product.vue | 602 +++++++++++++++++ uni-app/src/pages/quotation/quotation.vue | 635 ++++++++++++++++++ uni-app/src/pages/translate/translate.vue | 319 +++++++++ uni-app/src/static/common.css | 23 + uni-app/src/utils/api.js | 88 +++ uni-app/src/utils/push.js | 183 +++++ uni-app/vite.config.js | 15 + 121 files changed, 11743 insertions(+) create mode 100644 .gitignore create mode 100644 backend/.env.example create mode 100644 backend/Dockerfile create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/001_initial.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/ai/__init__.py create mode 100644 backend/app/ai/base.py create mode 100644 backend/app/ai/providers/__init__.py create mode 100644 backend/app/ai/providers/claude.py create mode 100644 backend/app/ai/providers/deepl.py create mode 100644 backend/app/ai/providers/local.py create mode 100644 backend/app/ai/providers/openai.py create mode 100644 backend/app/ai/router.py create mode 100644 backend/app/ai/trade_corpus.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/api/v1/customer.py create mode 100644 backend/app/api/v1/deps.py create mode 100644 backend/app/api/v1/exchange.py create mode 100644 backend/app/api/v1/marketing.py create mode 100644 backend/app/api/v1/product.py create mode 100644 backend/app/api/v1/push.py create mode 100644 backend/app/api/v1/quotation.py create mode 100644 backend/app/api/v1/translate.py create mode 100644 backend/app/api/v1/whatsapp.py create mode 100644 backend/app/celery_app.py create mode 100644 backend/app/config.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/core/middleware.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/corpus.py create mode 100644 backend/app/models/customer.py create mode 100644 backend/app/models/quotation.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/customer.py create mode 100644 backend/app/services/marketing.py create mode 100644 backend/app/services/product.py create mode 100644 backend/app/services/quotation.py create mode 100644 backend/app/services/translation.py create mode 100644 backend/app/services/whatsapp.py create mode 100644 backend/app/workers/__init__.py create mode 100644 backend/app/workers/tasks.py create mode 100644 backend/pytest.ini create mode 100644 backend/requirements.txt create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_auth_api.py create mode 100644 backend/tests/test_config.py create mode 100644 backend/tests/test_customer_api.py create mode 100644 backend/tests/test_exceptions.py create mode 100644 backend/tests/test_security.py create mode 100644 docker-compose.yml create mode 100644 docs/API_DESIGN.md create mode 100644 docs/DATABASE_SCHEMA.md create mode 100644 docs/PRODUCT_DESIGN.md create mode 100644 docs/PROJECT_STATUS.md create mode 100644 docs/TECH_ARCHITECTURE.md create mode 100644 miniprogram/app.js create mode 100644 miniprogram/app.json create mode 100644 miniprogram/app.wxss create mode 100644 miniprogram/pages/customers/customers.js create mode 100644 miniprogram/pages/customers/customers.json create mode 100644 miniprogram/pages/customers/customers.wxml create mode 100644 miniprogram/pages/customers/customers.wxss create mode 100644 miniprogram/pages/index/index.js create mode 100644 miniprogram/pages/index/index.json create mode 100644 miniprogram/pages/index/index.wxml create mode 100644 miniprogram/pages/index/index.wxss create mode 100644 miniprogram/pages/login/login.js create mode 100644 miniprogram/pages/login/login.json create mode 100644 miniprogram/pages/login/login.wxml create mode 100644 miniprogram/pages/login/login.wxss create mode 100644 miniprogram/pages/marketing/marketing.js create mode 100644 miniprogram/pages/marketing/marketing.json create mode 100644 miniprogram/pages/marketing/marketing.wxml create mode 100644 miniprogram/pages/marketing/marketing.wxss create mode 100644 miniprogram/pages/quotation/quotation.js create mode 100644 miniprogram/pages/quotation/quotation.json create mode 100644 miniprogram/pages/quotation/quotation.wxml create mode 100644 miniprogram/pages/quotation/quotation.wxss create mode 100644 miniprogram/pages/translate/translate.js create mode 100644 miniprogram/pages/translate/translate.json create mode 100644 miniprogram/pages/translate/translate.wxml create mode 100644 miniprogram/pages/translate/translate.wxss create mode 100644 miniprogram/project.config.json create mode 100644 miniprogram/sitemap.json create mode 100644 miniprogram/utils/api.js create mode 100644 nginx/trademate.conf create mode 100644 scripts/backup.sh create mode 100644 scripts/deploy.sh create mode 100644 scripts/init-db.sh create mode 100644 scripts/logs.sh create mode 100644 systemd/tradmate-backend.service create mode 100644 systemd/tradmate-celery-beat.service create mode 100644 systemd/tradmate-celery.service create mode 100644 uni-app/index.html create mode 100644 uni-app/package.json create mode 100644 uni-app/src/App.vue create mode 100644 uni-app/src/components/tabbar/custom-tabbar.vue create mode 100644 uni-app/src/main.js create mode 100644 uni-app/src/pages.json create mode 100644 uni-app/src/pages/customers/customers.vue create mode 100644 uni-app/src/pages/index/index.vue create mode 100644 uni-app/src/pages/login/login.vue create mode 100644 uni-app/src/pages/marketing/marketing.vue create mode 100644 uni-app/src/pages/product/product.vue create mode 100644 uni-app/src/pages/quotation/quotation.vue create mode 100644 uni-app/src/pages/translate/translate.vue create mode 100644 uni-app/src/static/common.css create mode 100644 uni-app/src/utils/api.js create mode 100644 uni-app/src/utils/push.js create mode 100644 uni-app/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db040a0 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..fd3c61e --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..c2beffd --- /dev/null +++ b/backend/Dockerfile @@ -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"] \ No newline at end of file diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..d910f09 --- /dev/null +++ b/backend/alembic.ini @@ -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 \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..ed34ff8 --- /dev/null +++ b/backend/alembic/env.py @@ -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() \ No newline at end of file diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..2b1f3ac --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} \ No newline at end of file diff --git a/backend/alembic/versions/001_initial.py b/backend/alembic/versions/001_initial.py new file mode 100644 index 0000000..475a65c --- /dev/null +++ b/backend/alembic/versions/001_initial.py @@ -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') \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/ai/__init__.py b/backend/app/ai/__init__.py new file mode 100644 index 0000000..39c5099 --- /dev/null +++ b/backend/app/ai/__init__.py @@ -0,0 +1,3 @@ +from .router import get_ai_router + +__all__ = ["get_ai_router"] diff --git a/backend/app/ai/base.py b/backend/app/ai/base.py new file mode 100644 index 0000000..9ddc889 --- /dev/null +++ b/backend/app/ai/base.py @@ -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 diff --git a/backend/app/ai/providers/__init__.py b/backend/app/ai/providers/__init__.py new file mode 100644 index 0000000..75c6a6b --- /dev/null +++ b/backend/app/ai/providers/__init__.py @@ -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"] diff --git a/backend/app/ai/providers/claude.py b/backend/app/ai/providers/claude.py new file mode 100644 index 0000000..a9ba41f --- /dev/null +++ b/backend/app/ai/providers/claude.py @@ -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 diff --git a/backend/app/ai/providers/deepl.py b/backend/app/ai/providers/deepl.py new file mode 100644 index 0000000..aba65c9 --- /dev/null +++ b/backend/app/ai/providers/deepl.py @@ -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 diff --git a/backend/app/ai/providers/local.py b/backend/app/ai/providers/local.py new file mode 100644 index 0000000..e67d4a6 --- /dev/null +++ b/backend/app/ai/providers/local.py @@ -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 diff --git a/backend/app/ai/providers/openai.py b/backend/app/ai/providers/openai.py new file mode 100644 index 0000000..4506d5d --- /dev/null +++ b/backend/app/ai/providers/openai.py @@ -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 diff --git a/backend/app/ai/router.py b/backend/app/ai/router.py new file mode 100644 index 0000000..71c7287 --- /dev/null +++ b/backend/app/ai/router.py @@ -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 diff --git a/backend/app/ai/trade_corpus.py b/backend/app/ai/trade_corpus.py new file mode 100644 index 0000000..39bfbec --- /dev/null +++ b/backend/app/ai/trade_corpus.py @@ -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}") diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..bf25d94 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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} diff --git a/backend/app/api/v1/customer.py b/backend/app/api/v1/customer.py new file mode 100644 index 0000000..e4c0ab8 --- /dev/null +++ b/backend/app/api/v1/customer.py @@ -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) diff --git a/backend/app/api/v1/deps.py b/backend/app/api/v1/deps.py new file mode 100644 index 0000000..826ee28 --- /dev/null +++ b/backend/app/api/v1/deps.py @@ -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") diff --git a/backend/app/api/v1/exchange.py b/backend/app/api/v1/exchange.py new file mode 100644 index 0000000..913ad63 --- /dev/null +++ b/backend/app/api/v1/exchange.py @@ -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", + } \ No newline at end of file diff --git a/backend/app/api/v1/marketing.py b/backend/app/api/v1/marketing.py new file mode 100644 index 0000000..ef98793 --- /dev/null +++ b/backend/app/api/v1/marketing.py @@ -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} diff --git a/backend/app/api/v1/product.py b/backend/app/api/v1/product.py new file mode 100644 index 0000000..f12e8d2 --- /dev/null +++ b/backend/app/api/v1/product.py @@ -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"} \ No newline at end of file diff --git a/backend/app/api/v1/push.py b/backend/app/api/v1/push.py new file mode 100644 index 0000000..54d928f --- /dev/null +++ b/backend/app/api/v1/push.py @@ -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) + } \ No newline at end of file diff --git a/backend/app/api/v1/quotation.py b/backend/app/api/v1/quotation.py new file mode 100644 index 0000000..c266a8d --- /dev/null +++ b/backend/app/api/v1/quotation.py @@ -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 diff --git a/backend/app/api/v1/translate.py b/backend/app/api/v1/translate.py new file mode 100644 index 0000000..781ea7b --- /dev/null +++ b/backend/app/api/v1/translate.py @@ -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"} diff --git a/backend/app/api/v1/whatsapp.py b/backend/app/api/v1/whatsapp.py new file mode 100644 index 0000000..49c7686 --- /dev/null +++ b/backend/app/api/v1/whatsapp.py @@ -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."} diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py new file mode 100644 index 0000000..b21ac2e --- /dev/null +++ b/backend/app/celery_app.py @@ -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, +) \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..dc2d215 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..cfeae14 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -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"}, + ) diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000..76e8f54 --- /dev/null +++ b/backend/app/core/middleware.py @@ -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) diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..e182368 --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..8e93c67 --- /dev/null +++ b/backend/app/database.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..5d27274 --- /dev/null +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..9ac6419 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/corpus.py b/backend/app/models/corpus.py new file mode 100644 index 0000000..6313bd5 --- /dev/null +++ b/backend/app/models/corpus.py @@ -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) diff --git a/backend/app/models/customer.py b/backend/app/models/customer.py new file mode 100644 index 0000000..846177c --- /dev/null +++ b/backend/app/models/customer.py @@ -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") diff --git a/backend/app/models/quotation.py b/backend/app/models/quotation.py new file mode 100644 index 0000000..60ab3d1 --- /dev/null +++ b/backend/app/models/quotation.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..faf4646 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/customer.py b/backend/app/services/customer.py new file mode 100644 index 0000000..02757ce --- /dev/null +++ b/backend/app/services/customer.py @@ -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, + } diff --git a/backend/app/services/marketing.py b/backend/app/services/marketing.py new file mode 100644 index 0000000..0a710a9 --- /dev/null +++ b/backend/app/services/marketing.py @@ -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 {} diff --git a/backend/app/services/product.py b/backend/app/services/product.py new file mode 100644 index 0000000..0111fd2 --- /dev/null +++ b/backend/app/services/product.py @@ -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, + } \ No newline at end of file diff --git a/backend/app/services/quotation.py b/backend/app/services/quotation.py new file mode 100644 index 0000000..f8c60e9 --- /dev/null +++ b/backend/app/services/quotation.py @@ -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, + } diff --git a/backend/app/services/translation.py b/backend/app/services/translation.py new file mode 100644 index 0000000..621b0e4 --- /dev/null +++ b/backend/app/services/translation.py @@ -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) diff --git a/backend/app/services/whatsapp.py b/backend/app/services/whatsapp.py new file mode 100644 index 0000000..d84302f --- /dev/null +++ b/backend/app/services/whatsapp.py @@ -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 diff --git a/backend/app/workers/__init__.py b/backend/app/workers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/workers/tasks.py b/backend/app/workers/tasks.py new file mode 100644 index 0000000..fd6d4a2 --- /dev/null +++ b/backend/app/workers/tasks.py @@ -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()) \ No newline at end of file diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..3df7fdd --- /dev/null +++ b/backend/pytest.ini @@ -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 \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7e6d855 --- /dev/null +++ b/backend/requirements.txt @@ -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 \ No newline at end of file diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..e966d16 --- /dev/null +++ b/backend/tests/conftest.py @@ -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}"} \ No newline at end of file diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py new file mode 100644 index 0000000..82a3921 --- /dev/null +++ b/backend/tests/test_auth_api.py @@ -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() \ No newline at end of file diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py new file mode 100644 index 0000000..ce48041 --- /dev/null +++ b/backend/tests/test_config.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_customer_api.py b/backend/tests/test_customer_api.py new file mode 100644 index 0000000..abf3d4a --- /dev/null +++ b/backend/tests/test_customer_api.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_exceptions.py b/backend/tests/test_exceptions.py new file mode 100644 index 0000000..dddab21 --- /dev/null +++ b/backend/tests/test_exceptions.py @@ -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 \ No newline at end of file diff --git a/backend/tests/test_security.py b/backend/tests/test_security.py new file mode 100644 index 0000000..a548e4d --- /dev/null +++ b/backend/tests/test_security.py @@ -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" \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a71c9cd --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/docs/API_DESIGN.md b/docs/API_DESIGN.md new file mode 100644 index 0000000..369eda3 --- /dev/null +++ b/docs/API_DESIGN.md @@ -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 +``` + +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 +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 +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 +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 +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 +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= +``` \ No newline at end of file diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md new file mode 100644 index 0000000..4a7116b --- /dev/null +++ b/docs/DATABASE_SCHEMA.md @@ -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`。 \ No newline at end of file diff --git a/docs/PRODUCT_DESIGN.md b/docs/PRODUCT_DESIGN.md new file mode 100644 index 0000000..fbb243b --- /dev/null +++ b/docs/PRODUCT_DESIGN.md @@ -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开放 | diff --git a/docs/PROJECT_STATUS.md b/docs/PROJECT_STATUS.md new file mode 100644 index 0000000..57973c9 --- /dev/null +++ b/docs/PROJECT_STATUS.md @@ -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) \ No newline at end of file diff --git a/docs/TECH_ARCHITECTURE.md b/docs/TECH_ARCHITECTURE.md new file mode 100644 index 0000000..09c3d12 --- /dev/null +++ b/docs/TECH_ARCHITECTURE.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% +``` diff --git a/miniprogram/app.js b/miniprogram/app.js new file mode 100644 index 0000000..8025975 --- /dev/null +++ b/miniprogram/app.js @@ -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}` } : {}; + }, +}); \ No newline at end of file diff --git a/miniprogram/app.json b/miniprogram/app.json new file mode 100644 index 0000000..9bfe6a0 --- /dev/null +++ b/miniprogram/app.json @@ -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" +} \ No newline at end of file diff --git a/miniprogram/app.wxss b/miniprogram/app.wxss new file mode 100644 index 0000000..3bd6565 --- /dev/null +++ b/miniprogram/app.wxss @@ -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; } \ No newline at end of file diff --git a/miniprogram/pages/customers/customers.js b/miniprogram/pages/customers/customers.js new file mode 100644 index 0000000..fc03089 --- /dev/null +++ b/miniprogram/pages/customers/customers.js @@ -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(); + }, +}); \ No newline at end of file diff --git a/miniprogram/pages/customers/customers.json b/miniprogram/pages/customers/customers.json new file mode 100644 index 0000000..314ec7a --- /dev/null +++ b/miniprogram/pages/customers/customers.json @@ -0,0 +1,5 @@ +{ + "navigationBarTitleText": "客户管理", + "enablePullDownRefresh": true, + "usingComponents": {} +} \ No newline at end of file diff --git a/miniprogram/pages/customers/customers.wxml b/miniprogram/pages/customers/customers.wxml new file mode 100644 index 0000000..2d7acbc --- /dev/null +++ b/miniprogram/pages/customers/customers.wxml @@ -0,0 +1,68 @@ + + + 客户列表 + + + + + + + {{item.name}} + + {{item.company}} {{item.country ? '· ' + item.country : ''}} + + + 最后联系: {{item.last_contact_at}} + + + + {{item.status === 'lead' ? '潜在' : item.status === 'negotiating' ? '谈判中' : '已成交'}} + + + + + 暂无客户,点击上方添加 + + + + + + 添加客户 + + + 姓名 * + + + + + 公司 + + + + + 国家 + + + + + 电话 + + + + + WhatsApp + + + + + 邮箱 + + + + + 取消 + 确定 + + + + \ No newline at end of file diff --git a/miniprogram/pages/customers/customers.wxss b/miniprogram/pages/customers/customers.wxss new file mode 100644 index 0000000..1f2c078 --- /dev/null +++ b/miniprogram/pages/customers/customers.wxss @@ -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; +} \ No newline at end of file diff --git a/miniprogram/pages/index/index.js b/miniprogram/pages/index/index.js new file mode 100644 index 0000000..7824ba5 --- /dev/null +++ b/miniprogram/pages/index/index.js @@ -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' }); + } + }, + }); + }, +}); \ No newline at end of file diff --git a/miniprogram/pages/index/index.json b/miniprogram/pages/index/index.json new file mode 100644 index 0000000..ab888fc --- /dev/null +++ b/miniprogram/pages/index/index.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "外贸小助手", + "usingComponents": {} +} \ No newline at end of file diff --git a/miniprogram/pages/index/index.wxml b/miniprogram/pages/index/index.wxml new file mode 100644 index 0000000..d996197 --- /dev/null +++ b/miniprogram/pages/index/index.wxml @@ -0,0 +1,61 @@ + + + + + + + + {{stats.customers}} + 客户数 + + + {{stats.silentCustomers}} + 待跟进 + + + {{stats.todayTranslations}} + 今日翻译 + + + {{stats.quotations}} + 报价单 + + + + + + 📝 + 翻译 + + + 👥 + 客户 + + + 📢 + 营销 + + + 📄 + 报价 + + + + + 待跟进客户 + + + {{item.name}} + 沉默 {{item.silence_days}} 天 + + + + + + \ No newline at end of file diff --git a/miniprogram/pages/index/index.wxss b/miniprogram/pages/index/index.wxss new file mode 100644 index 0000000..c9591f6 --- /dev/null +++ b/miniprogram/pages/index/index.wxss @@ -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; +} \ No newline at end of file diff --git a/miniprogram/pages/login/login.js b/miniprogram/pages/login/login.js new file mode 100644 index 0000000..2fabcc9 --- /dev/null +++ b/miniprogram/pages/login/login.js @@ -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); + } + }); + }, +}); \ No newline at end of file diff --git a/miniprogram/pages/login/login.json b/miniprogram/pages/login/login.json new file mode 100644 index 0000000..3e94c7f --- /dev/null +++ b/miniprogram/pages/login/login.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "登录", + "navigationStyle": "custom" +} \ No newline at end of file diff --git a/miniprogram/pages/login/login.wxml b/miniprogram/pages/login/login.wxml new file mode 100644 index 0000000..8d399d5 --- /dev/null +++ b/miniprogram/pages/login/login.wxml @@ -0,0 +1,50 @@ + + + + 外贸小助手 + + + + {{isRegister ? '注册' : '登录'}} + + + + + + + + + + + + + + {{error}} + + + + + {{isRegister ? '已有账号?立即登录' : '没有账号?立即注册'}} + + + + + + + + + + + + + 登录即表示同意 + 《用户协议》 + + 《隐私政策》 + + \ No newline at end of file diff --git a/miniprogram/pages/login/login.wxss b/miniprogram/pages/login/login.wxss new file mode 100644 index 0000000..2f5e845 --- /dev/null +++ b/miniprogram/pages/login/login.wxss @@ -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; +} \ No newline at end of file diff --git a/miniprogram/pages/marketing/marketing.js b/miniprogram/pages/marketing/marketing.js new file mode 100644 index 0000000..c7f415b --- /dev/null +++ b/miniprogram/pages/marketing/marketing.js @@ -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: [], + }); + }, +}); \ No newline at end of file diff --git a/miniprogram/pages/marketing/marketing.json b/miniprogram/pages/marketing/marketing.json new file mode 100644 index 0000000..a987e51 --- /dev/null +++ b/miniprogram/pages/marketing/marketing.json @@ -0,0 +1,4 @@ +{ + "navigationBarTitleText": "营销素材", + "usingComponents": {} +} \ No newline at end of file diff --git a/miniprogram/pages/marketing/marketing.wxml b/miniprogram/pages/marketing/marketing.wxml new file mode 100644 index 0000000..48fa968 --- /dev/null +++ b/miniprogram/pages/marketing/marketing.wxml @@ -0,0 +1,59 @@ + + + 生成文案 + 关键词 + + + + + 产品名称 * + + + + + 产品描述 * +