Initial commit: TradeMate 外贸小助手 MVP

项目结构:
- backend/     Python FastAPI 后端
- uni-app/     uni-app跨端前端
- docs/        设计文档
- docker-compose.yml  Docker编排
- nginx/scripts/systemd 运维配置

已完成功能:
- 用户认证 (JWT)
- 智能翻译 + 回复建议
- 营销素材生成
- 客户管理 + 沉默检测
- 报价单管理
- 产品库管理
- 汇率换算
- 推送通知 (uni-push)
- WhatsApp Webhook框架
- Celery定时任务
This commit is contained in:
TradeMate Dev
2026-05-08 18:17:12 +08:00
commit c6206787da
121 changed files with 11743 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
from app.database import Base
from app.models import User, Product, Customer, Conversation, Message, Quotation, QuotationItem, CorpusEntry
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+189
View File
@@ -0,0 +1,189 @@
"""initial schema
Revision ID: 001
Revises:
Create Date: 2026-05-08
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = '001'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table('users',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('wechat_openid', sa.String(length=255), nullable=True),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('username', sa.String(length=100), nullable=True),
sa.Column('password_hash', sa.String(length=255), nullable=True),
sa.Column('tier', sa.String(length=50), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.Column('settings', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_phone'), 'users', ['phone'], unique=True)
op.create_index(op.f('ix_users_wechat_openid'), 'users', ['wechat_openid'], unique=True)
op.create_table('products',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('name_en', sa.String(length=255), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('description_en', sa.Text(), nullable=True),
sa.Column('category', sa.String(length=100), nullable=True),
sa.Column('price', sa.String(length=50), nullable=True),
sa.Column('price_unit', sa.String(length=20), nullable=True),
sa.Column('moq', sa.String(length=50), nullable=True),
sa.Column('keywords', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('specifications', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('images', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_products_user_id'), 'products', ['user_id'], unique=False)
op.create_table('customers',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('company', sa.String(length=255), nullable=True),
sa.Column('country', sa.String(length=100), nullable=True),
sa.Column('phone', sa.String(length=50), nullable=True),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('whatsapp_id', sa.String(length=255), nullable=True),
sa.Column('source', sa.String(length=100), nullable=True),
sa.Column('tags', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('preference', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('last_contact_at', sa.DateTime(), nullable=True),
sa.Column('silence_started_at', sa.DateTime(), nullable=True),
sa.Column('next_followup_at', sa.DateTime(), nullable=True),
sa.Column('estimated_value', sa.String(length=50), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_customers_user_id'), 'customers', ['user_id'], unique=False)
op.create_table('conversations',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('channel', sa.String(length=50), nullable=True),
sa.Column('topic', sa.String(length=255), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('message_count', sa.Integer(), nullable=True),
sa.Column('last_message_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_conversations_customer_id'), 'conversations', ['customer_id'], unique=False)
op.create_index(op.f('ix_conversations_user_id'), 'conversations', ['user_id'], unique=False)
op.create_table('messages',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('conversation_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('direction', sa.String(length=20), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('content_translated', sa.Text(), nullable=True),
sa.Column('content_type', sa.String(length=50), nullable=True),
sa.Column('ai_suggestions', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('selected_suggestion', sa.Integer(), nullable=True),
sa.Column('user_edited', sa.Text(), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['conversation_id'], ['conversations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_messages_conversation_id'), 'messages', ['conversation_id'], unique=False)
op.create_table('quotations',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('user_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('customer_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('title', sa.String(length=255), nullable=True),
sa.Column('status', sa.String(length=50), nullable=True),
sa.Column('currency', sa.String(length=10), nullable=True),
sa.Column('exchange_rate', sa.Float(), nullable=True),
sa.Column('payment_terms', sa.String(length=255), nullable=True),
sa.Column('delivery_terms', sa.String(length=255), nullable=True),
sa.Column('lead_time', sa.String(length=100), nullable=True),
sa.Column('valid_until', sa.String(length=100), nullable=True),
sa.Column('subtotal', sa.Float(), nullable=True),
sa.Column('discount', sa.Float(), nullable=True),
sa.Column('shipping', sa.Float(), nullable=True),
sa.Column('total', sa.Float(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('pdf_url', sa.Text(), nullable=True),
sa.Column('sent_at', sa.DateTime(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.Column('updated_at', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['customer_id'], ['customers.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_quotations_user_id'), 'quotations', ['user_id'], unique=False)
op.create_table('quotation_items',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('quotation_id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('product_name', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('quantity', sa.Integer(), nullable=False),
sa.Column('unit_price', sa.Float(), nullable=False),
sa.Column('total_price', sa.Float(), nullable=True),
sa.Column('unit', sa.String(length=50), nullable=True),
sa.ForeignKeyConstraint(['quotation_id'], ['quotations.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_quotation_items_quotation_id'), 'quotation_items', ['quotation_id'], unique=False)
op.create_table('corpus_entries',
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
sa.Column('source_text', sa.Text(), nullable=False),
sa.Column('target_text', sa.Text(), nullable=False),
sa.Column('source_lang', sa.String(length=20), nullable=True),
sa.Column('target_lang', sa.String(length=20), nullable=True),
sa.Column('task_type', sa.String(length=50), nullable=False),
sa.Column('domain', sa.String(length=100), nullable=True),
sa.Column('provider_used', sa.String(length=50), nullable=True),
sa.Column('quality_score', sa.Float(), nullable=True),
sa.Column('user_edited', sa.Boolean(), nullable=True),
sa.Column('user_rating', sa.Integer(), nullable=True),
sa.Column('usage_count', sa.Integer(), nullable=True),
sa.Column('embedding', postgresql.Vector(length=768), nullable=True),
sa.Column('metadata', postgresql.JSONB(astext_type=sa.Text()), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
def downgrade() -> None:
op.drop_table('corpus_entries')
op.drop_table('quotation_items')
op.drop_table('quotations')
op.drop_table('messages')
op.drop_table('conversations')
op.drop_table('customers')
op.drop_table('products')
op.drop_table('users')