From 5a1af9f82f20e22292cec3ec68f42762e61e64c4 Mon Sep 17 00:00:00 2001 From: TradeMate Dev Date: Thu, 14 May 2026 09:19:30 +0800 Subject: [PATCH] feat: production branch with deploy config for baota panel - Add deploy/ directory with production env, supervisor, nginx, migration configs - Include all latest features: admin management, feedback, footer with ICP/beian - Database: foreign_trade (PostgreSQL), user: foreign_trade - Frontend: trade.yuzhiran.com, backend proxy via Nginx --- backend/alembic/env.py | 2 +- backend/app/api/v1/admin.py | 106 +++++- backend/app/models/__init__.py | 2 + backend/app/models/system_config.py | 15 + backend/app/models/user.py | 3 + backend/app/services/admin.py | 269 ++++++++++++++- deploy/README.md | 105 ++++++ deploy/backend/.env.production | 59 ++++ deploy/backend/supervisord.conf | 13 + deploy/database/migrate.sh | 29 ++ deploy/frontend/nginx.conf | 70 ++++ docs/GO_TO_MARKET.md | 135 ++++++++ uni-app/src/pages/admin/admin.vue | 495 +++++++++++++++++++++++++--- uni-app/src/pages/index/index.vue | 124 +++++++ uni-app/src/utils/api.js | 21 +- 15 files changed, 1377 insertions(+), 71 deletions(-) create mode 100644 backend/app/models/system_config.py create mode 100644 deploy/README.md create mode 100644 deploy/backend/.env.production create mode 100644 deploy/backend/supervisord.conf create mode 100644 deploy/database/migrate.sh create mode 100644 deploy/frontend/nginx.conf create mode 100644 docs/GO_TO_MARKET.md diff --git a/backend/alembic/env.py b/backend/alembic/env.py index ca6b71b..f42644b 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -13,7 +13,7 @@ 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, Team, TeamMember, UsageLog, Notification, Feedback, Subscription, PreferenceAnalysis, MarketingEffect, Device, FollowupStrategy, FollowupLog +from app.models import User, Product, Customer, Conversation, Message, Quotation, QuotationItem, CorpusEntry, Team, TeamMember, UsageLog, Notification, Feedback, Subscription, PreferenceAnalysis, MarketingEffect, Device, FollowupStrategy, FollowupLog, SystemConfig target_metadata = Base.metadata diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 09492ca..16018b1 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -1,3 +1,6 @@ +import uuid +from typing import Optional +from datetime import date, datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db @@ -26,11 +29,19 @@ async def get_dashboard( async def list_users( page: int = Query(1, ge=1), size: int = Query(20, ge=1, le=100), + role: Optional[str] = Query(None), _: dict = Depends(require_admin), db: AsyncSession = Depends(get_db), ): service = AdminService(db) - return await service.list_users(page, size) + return await service.list_users(page, size, role) + + +def _validate_uuid(user_id: str): + try: + uuid.UUID(user_id) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid user ID format") @router.patch("/users/{target_user_id}/tier") @@ -40,6 +51,7 @@ async def update_user_tier( _: dict = Depends(require_admin), db: AsyncSession = Depends(get_db), ): + _validate_uuid(target_user_id) service = AdminService(db) tier = data.get("tier") if tier not in ("free", "pro", "enterprise"): @@ -56,6 +68,7 @@ async def toggle_user_active( _: dict = Depends(require_admin), db: AsyncSession = Depends(get_db), ): + _validate_uuid(target_user_id) service = AdminService(db) success = await service.toggle_user_active(target_user_id) if not success: @@ -63,6 +76,97 @@ async def toggle_user_active( return {"message": "User active status toggled"} +@router.patch("/users/{target_user_id}/role") +async def update_user_role( + target_user_id: str, + data: dict, + _: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + _validate_uuid(target_user_id) + service = AdminService(db) + role = data.get("role") + if role not in ("user", "admin"): + raise HTTPException(status_code=400, detail="Invalid role. Must be 'user' or 'admin'") + result = await service.update_user_role(target_user_id, role) + if not result: + raise HTTPException(status_code=404, detail="User not found") + return result + + +@router.get("/users/search") +async def search_users( + q: str = Query(..., min_length=1), + _: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + service = AdminService(db) + return await service.search_users(q) + + +@router.get("/users/{target_user_id}") +async def get_user_detail( + target_user_id: str, + _: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + _validate_uuid(target_user_id) + service = AdminService(db) + result = await service.get_user_detail(target_user_id) + if not result: + raise HTTPException(status_code=404, detail="User not found") + return result + + +@router.get("/usage-stats") +async def get_usage_stats( + _: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + service = AdminService(db) + return await service.get_usage_stats() + + +@router.get("/logs") +async def get_logs( + page: int = Query(1, ge=1), + size: int = Query(50, ge=1, le=200), + action: Optional[str] = Query(None), + user_id: Optional[str] = Query(None), + date_from: Optional[date] = Query(None), + date_to: Optional[date] = Query(None), + _: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + service = AdminService(db) + dt_from = datetime.combine(date_from, datetime.min.time()) if date_from else None + dt_to = datetime.combine(date_to, datetime.max.time()) if date_to else None + return await service.get_logs(page, size, action, user_id, dt_from, dt_to) + + +@router.get("/config") +async def list_config( + _: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + service = AdminService(db) + return await service.list_config() + + +@router.put("/config/{key}") +async def update_config( + key: str, + data: dict, + _: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + service = AdminService(db) + item = await service.update_config(key, data.get("value")) + if not item: + raise HTTPException(status_code=404, detail="Config not found") + return item + + @router.get("/health") async def system_health( db: AsyncSession = Depends(get_db), diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index bafda56..c96fddc 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -10,6 +10,7 @@ from .subscription import Subscription from .preference import PreferenceAnalysis, MarketingEffect from .device import Device from .followup import FollowupStrategy, FollowupLog +from .system_config import SystemConfig __all__ = [ "User", "Product", @@ -24,4 +25,5 @@ __all__ = [ "PreferenceAnalysis", "MarketingEffect", "Device", "FollowupStrategy", "FollowupLog", + "SystemConfig", ] diff --git a/backend/app/models/system_config.py b/backend/app/models/system_config.py new file mode 100644 index 0000000..bbe6914 --- /dev/null +++ b/backend/app/models/system_config.py @@ -0,0 +1,15 @@ +from sqlalchemy import Column, String, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID, JSONB +from datetime import datetime +from app.database import Base +import uuid + + +class SystemConfig(Base): + __tablename__ = "system_configs" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + key = Column(String(100), unique=True, nullable=False, index=True) + value = Column(JSONB, nullable=False, default={}) + description = Column(Text, default="") + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/app/models/user.py b/backend/app/models/user.py index e3579a4..1798111 100644 --- a/backend/app/models/user.py +++ b/backend/app/models/user.py @@ -17,6 +17,9 @@ class User(Base): tier = Column(String(50), default="free") role = Column(String(20), default="user") is_active = Column(Boolean, default=True) + email = Column(String(255)) + last_login_at = Column(DateTime, nullable=True) + login_count = Column(Integer, default=0) created_at = Column(DateTime, default=datetime.utcnow) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) settings = Column(JSONB, default={ diff --git a/backend/app/services/admin.py b/backend/app/services/admin.py index ae84d8d..c271d3d 100644 --- a/backend/app/services/admin.py +++ b/backend/app/services/admin.py @@ -1,11 +1,12 @@ -from typing import Dict, Any, List +from typing import Dict, Any, List, Optional from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, func, and_ -from app.models.user import User +from sqlalchemy import select, func, text, and_ +from app.models.user import User, Product from app.models.team import Team, TeamMember from app.models.analytics import UsageLog from app.models.customer import Customer from app.models.quotation import Quotation +from app.models.system_config import SystemConfig from datetime import datetime, timedelta import logging @@ -64,26 +65,36 @@ class AdminService: ], } - async def list_users(self, page: int = 1, size: int = 20) -> Dict[str, Any]: - query = select(User).order_by(User.created_at.desc()).offset((page - 1) * size).limit(size) + def _user_to_dict(self, u: User) -> Dict[str, Any]: + return { + "id": str(u.id), + "username": u.username, + "phone": u.phone, + "email": u.email, + "tier": u.tier, + "role": u.role, + "is_active": u.is_active, + "last_login_at": u.last_login_at.isoformat() if u.last_login_at else None, + "login_count": u.login_count or 0, + "created_at": u.created_at.isoformat() if u.created_at else None, + "settings": u.settings, + } + + async def list_users(self, page: int = 1, size: int = 20, role: Optional[str] = None) -> Dict[str, Any]: + base_query = select(User) count_query = select(func.count(User.id)) + if role: + base_query = base_query.where(User.role == role) + count_query = count_query.where(User.role == role) + + query = base_query.order_by(User.created_at.desc()).offset((page - 1) * size).limit(size) total = await self.db.execute(count_query) result = await self.db.execute(query) users = result.scalars().all() return { - "items": [ - { - "id": str(u.id), - "username": u.username, - "phone": u.phone, - "tier": u.tier, - "is_active": u.is_active, - "created_at": u.created_at.isoformat() if u.created_at else None, - } - for u in users - ], + "items": [self._user_to_dict(u) for u in users], "total": total.scalar(), "page": page, "size": size, @@ -107,6 +118,58 @@ class AdminService: await self.db.flush() return True + async def update_user_role(self, user_id: str, role: str) -> Optional[Dict[str, Any]]: + if role not in ("user", "admin"): + raise ValueError("Invalid role") + result = await self.db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + return None + user.role = role + await self.db.flush() + return self._user_to_dict(user) + + async def get_user_detail(self, user_id: str) -> Optional[Dict[str, Any]]: + result = await self.db.execute(select(User).where(User.id == user_id)) + user = result.scalar_one_or_none() + if not user: + return None + + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + today_start_date = today_start.date() + + product_count = await self.db.execute(select(func.count()).select_from(User.__table__).where(User.id == user_id)) + + prod_count = await self.db.execute( + select(func.count(Product.id)).where(Product.user_id == user_id) + ) + cust_count = await self.db.execute( + select(func.count(Customer.id)).where(Customer.user_id == user_id) + ) + quot_count = await self.db.execute( + select(func.count(Quotation.id)).where(Quotation.user_id == user_id) + ) + usage_today = await self.db.execute( + select(func.count(UsageLog.id)).where( + UsageLog.user_id == user_id, UsageLog.created_at >= today_start + ) + ) + usage_total = await self.db.execute( + select(func.count(UsageLog.id)).where(UsageLog.user_id == user_id) + ) + + return { + **self._user_to_dict(user), + "stats": { + "products": prod_count.scalar() or 0, + "customers": cust_count.scalar() or 0, + "quotations": quot_count.scalar() or 0, + "usage_today": usage_today.scalar() or 0, + "usage_total": usage_total.scalar() or 0, + }, + } + async def get_system_health(self) -> Dict[str, Any]: return { "status": "healthy", @@ -127,3 +190,177 @@ class AdminService: await self.db.flush() except Exception as e: logger.warning(f"Failed to log usage: {e}") + + async def get_usage_stats(self) -> Dict[str, Any]: + now = datetime.utcnow() + today_start = now.replace(hour=0, minute=0, second=0, microsecond=0) + week_ago = now - timedelta(days=7) + + by_action = await self.db.execute( + select(UsageLog.action, func.count(UsageLog.id)) + .where(UsageLog.created_at >= today_start) + .group_by(UsageLog.action) + .order_by(func.count(UsageLog.id).desc()) + ) + + daily_result = await self.db.execute( + text( + "SELECT date_trunc('day', created_at) AS day, count(id) " + "FROM usage_logs WHERE created_at >= :week_ago " + "GROUP BY date_trunc('day', created_at) " + "ORDER BY date_trunc('day', created_at)" + ), + {"week_ago": week_ago}, + ) + + today_logs = await self.db.execute( + select(func.count(UsageLog.id)).where(UsageLog.created_at >= today_start) + ) + + dau = await self.db.execute( + select(func.count(func.distinct(UsageLog.user_id))) + .where(UsageLog.created_at >= today_start) + ) + + total_users = await self.db.execute(select(func.count(User.id))) + + return { + "today_total": today_logs.scalar() or 0, + "dau": dau.scalar() or 0, + "total_users": total_users.scalar() or 0, + "by_action": {row[0]: row[1] for row in by_action.all()}, + "daily_trend": [ + {"date": str(row[0].date()), "count": row[1]} + for row in daily_result.all() + ], + } + + async def get_logs( + self, + page: int = 1, + size: int = 50, + action: Optional[str] = None, + user_id: Optional[str] = None, + date_from: Optional[datetime] = None, + date_to: Optional[datetime] = None, + ) -> Dict[str, Any]: + filters = [] + if action: + filters.append(UsageLog.action == action) + if user_id: + filters.append(UsageLog.user_id == user_id) + if date_from: + filters.append(UsageLog.created_at >= date_from) + if date_to: + filters.append(UsageLog.created_at <= date_to) + + count_query = select(func.count(UsageLog.id)) + data_query = select(UsageLog).order_by(UsageLog.created_at.desc()) + + if filters: + where_clause = and_(*filters) + count_query = count_query.where(where_clause) + data_query = data_query.where(where_clause) + + total = await self.db.execute(count_query) + result = await self.db.execute( + data_query.offset((page - 1) * size).limit(size) + ) + logs = result.scalars().all() + + return { + "items": [ + { + "id": str(log.id), + "user_id": str(log.user_id), + "action": log.action, + "detail": log.detail, + "ip_address": log.ip_address, + "user_agent": log.user_agent, + "created_at": log.created_at.isoformat() if log.created_at else None, + } + for log in logs + ], + "total": total.scalar(), + "page": page, + "size": size, + } + + async def _seed_default_configs(self): + defaults = [ + SystemConfig(key="ai_provider_translate", value={"primary": "sensenova", "fallback": ["openai", "local"]}, description="翻译任务 AI 模型选择"), + SystemConfig(key="ai_provider_reply", value={"primary": "sensenova", "fallback": ["anthropic", "local"]}, description="回复建议 AI 模型选择"), + SystemConfig(key="ai_provider_marketing", value={"primary": "sensenova", "fallback": ["openai", "local"]}, description="营销文案 AI 模型选择"), + SystemConfig(key="ai_provider_extract", value={"primary": "sensenova", "fallback": ["openai"]}, description="信息提取 AI 模型选择"), + SystemConfig(key="ai_provider_quotation", value={"primary": "sensenova", "fallback": ["openai"]}, description="报价单 AI 模型选择"), + SystemConfig(key="feature_guest_mode", value={"enabled": True}, description="游客模式开关"), + SystemConfig(key="feature_wechat_login", value={"enabled": False}, description="微信登录开关"), + SystemConfig(key="feature_registration", value={"enabled": True}, description="新用户注册开关"), + SystemConfig(key="free_daily_limits", value={"translate_chars": 5000, "replies": 20, "marketing": 5, "customers": 5, "products": 1, "quotations": 3}, description="免费版每日配额"), + SystemConfig(key="pro_daily_limits", value={"translate_chars": 50000, "replies": 200, "marketing": 50, "customers": 100, "products": 20, "quotations": 30}, description="Pro 版每日配额"), + ] + for cfg in defaults: + self.db.add(cfg) + await self.db.flush() + + async def list_config(self) -> List[Dict[str, Any]]: + result = await self.db.execute( + select(func.count(SystemConfig.id)) + ) + if result.scalar() == 0: + await self._seed_default_configs() + + result = await self.db.execute( + select(SystemConfig).order_by(SystemConfig.key) + ) + configs = result.scalars().all() + return [ + { + "key": c.key, + "value": c.value, + "description": c.description, + "updated_at": c.updated_at.isoformat() if c.updated_at else None, + } + for c in configs + ] + + async def update_config(self, key: str, value: Any) -> Optional[Dict[str, Any]]: + result = await self.db.execute( + select(SystemConfig).where(SystemConfig.key == key) + ) + config = result.scalar_one_or_none() + if not config: + return None + config.value = value + config.updated_at = datetime.utcnow() + await self.db.flush() + return { + "key": config.key, + "value": config.value, + "description": config.description, + "updated_at": config.updated_at.isoformat() if config.updated_at else None, + } + + async def search_users(self, query: str) -> List[Dict[str, Any]]: + result = await self.db.execute( + select(User) + .where( + (User.username.ilike(f"%{query}%")) | (User.phone.ilike(f"%{query}%")) + ) + .order_by(User.created_at.desc()) + .limit(20) + ) + users = result.scalars().all() + return [ + { + "id": str(u.id), + "username": u.username, + "phone": u.phone, + "tier": u.tier, + "role": u.role, + "is_active": u.is_active, + "created_at": u.created_at.isoformat() if u.created_at else None, + "settings": u.settings, + } + for u in users + ] diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..a4119e5 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,105 @@ +# 生产部署文档(宝塔面板) + +## 前提条件 + +宝塔面板已安装: +- Nginx +- PostgreSQL(已有数据库 `foreign_trade`) +- Redis +- Python 3.11+ + +## 目录结构(宝塔站点根目录) + +``` +/www/wwwroot/trade.yuzhiran.com/ +├── backend/ # FastAPI 后端代码 +│ ├── .env # 生产环境变量 +│ ├── app/ +│ ├── alembic/ +│ └── ... +├── frontend/ # 前端静态文件 +│ └── dist/ # uni-app 构建产物 +└── deploy/ # 部署配置(此目录) +``` + +## 部署步骤 + +### 1. 上传代码到服务器 + +将项目代码(production 分支)上传到 `/www/wwwroot/trade.yuzhiran.com/` + +### 2. 配置后端环境变量 + +```bash +cp deploy/backend/.env.production backend/.env +# 编辑 backend/.env,填入: +# - SECRET_KEY(随机字符串,用于 JWT 签名) +# - AI API Key(OPENAI_API_KEY 或 SENSENOVA_API_KEY 等) +vim backend/.env +``` + +### 3. 安装后端依赖 & 运行迁移 + +```bash +cd backend +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +alembic upgrade head +``` + +### 4. 构建前端 + +```bash +cd uni-app +npm install +npm run build:h5 +# 产物在 dist/build/h5/ 目录 +# 将 dist/build/h5/ 下的内容复制到 /www/wwwroot/trade.yuzhiran.com/frontend/dist/ +``` + +### 5. 配置 Nginx(宝塔面板操作) + +在宝塔面板中: +1. 添加站点 `trade.yuzhiran.com` +2. 站点根目录设为 `/www/wwwroot/trade.yuzhiran.com/frontend/dist` +3. 修改 Nginx 配置,参考 `deploy/frontend/nginx.conf` + +关键配置点: +- 根目录指向前端 dist +- `/api/` 反向代理到 `http://127.0.0.1:8000` +- SPA fallback: `try_files $uri $uri/ /index.html` + +### 6. 启动后端(宝塔 Python 项目管理器) + +在宝塔面板中使用 **Python项目管理器**: +- 项目路径:`/www/wwwroot/trade.yuzhiran.com/backend` +- Python 版本:3.11 +- 启动命令:`uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2` +- 项目名称:`trademate-backend` + +或者使用 Supervisor 命令行部署: +```bash +pip install supervisor +supervisord -c deploy/backend/supervisord.conf +``` + +### 7. 验证 + +- 访问 https://trade.yuzhiran.com → 前端正常加载 +- 访问 https://trade.yuzhiran.com/api/health → 返回 `{"status": "ok"}` + +## 常见问题 + +**Q: 数据库迁移失败?** +A: 确认 PostgreSQL 中 `foreign_trade` 数据库已创建,用户有权限。 + ```sql + CREATE DATABASE foreign_trade; + GRANT ALL PRIVILEGES ON DATABASE foreign_trade TO foreign_trade; + ``` + +**Q: 前端 API 请求 502?** +A: 检查后端是否启动,以及 Nginx 中 `/api/` 的 proxy_pass 地址是否正确。 + +**Q: CORS 报错?** +A: 确认 `backend/.env` 中的 `FRONTEND_URL` 与实际前端域名一致。 diff --git a/deploy/backend/.env.production b/deploy/backend/.env.production new file mode 100644 index 0000000..059229f --- /dev/null +++ b/deploy/backend/.env.production @@ -0,0 +1,59 @@ +# 生产环境配置(基于宝塔面板 PostgreSQL foreign_trade 数据库) +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 + +# 数据库(foreign_trade) +DATABASE_URL=postgresql+asyncpg://foreign_trade:dWFNi67nHNbPbjmP@localhost:5432/foreign_trade + +# 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= + +SENSENOVA_API_KEY= +SENSENOVA_BASE_URL=https://token.sensenova.cn/v1 +SENSENOVA_MODEL=sensenova-6.7-flash-lite + +IFLYTEK_API_KEY= +IFLYTEK_API_BASE=https://maas-api.cn-huabei-1.xf-yun.com/v2 +IFLYTEK_MODEL=astron-code-latest + +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 + +# 错误监控 (Sentry) +SENTRY_DSN= +DEBUG=false + +# URL(以宝塔实际域名/端口为准) +FRONTEND_URL=https://trade.yuzhiran.com +BACKEND_URL=https://api.trade.yuzhiran.com + +# 数据库调试关闭 +DB_ECHO=false diff --git a/deploy/backend/supervisord.conf b/deploy/backend/supervisord.conf new file mode 100644 index 0000000..7589ff5 --- /dev/null +++ b/deploy/backend/supervisord.conf @@ -0,0 +1,13 @@ +[program:trademate-backend] +command=/www/server/panel/pyenv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 --proxy-headers --forwarded-allow-ips='*' +directory=/www/wwwroot/trade.yuzhiran.com/backend +user=www +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +stdout_logfile=/www/wwwlogs/trademate-backend.log +stderr_logfile=/www/wwwlogs/trademate-backend.error.log +stdout_logfile_maxbytes=50MB +stderr_logfile_maxbytes=50MB +environment=PATH="/www/server/panel/pyenv/bin:/usr/local/bin:/usr/bin:/bin",HOME="/home/www" diff --git a/deploy/database/migrate.sh b/deploy/database/migrate.sh new file mode 100644 index 0000000..88d50b3 --- /dev/null +++ b/deploy/database/migrate.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# 生产数据库迁移脚本 +# 使用方式: bash deploy/database/migrate.sh +# 注意:需先在 backend/ 目录下配置好 .env 或设置好环境变量 + +set -e + +cd "$(dirname "$0")/../../backend" + +# 检查 .env 是否存在 +if [ ! -f ".env" ]; then + echo "❌ 未找到 .env 文件,请先复制 deploy/backend/.env.production 到 backend/.env" + echo " cp deploy/backend/.env.production backend/.env" + echo " 然后编辑 .env 填入 SECRET_KEY 和 AI API Key" + exit 1 +fi + +echo "🔧 激活虚拟环境..." +if [ -d "venv" ]; then + source venv/bin/activate +fi + +echo "📦 安装/更新依赖..." +pip install -r requirements.txt -q + +echo "🗄️ 运行数据库迁移..." +alembic upgrade head + +echo "✅ 迁移完成" diff --git a/deploy/frontend/nginx.conf b/deploy/frontend/nginx.conf new file mode 100644 index 0000000..c0b2bd5 --- /dev/null +++ b/deploy/frontend/nginx.conf @@ -0,0 +1,70 @@ +# 宝塔面板 Nginx 配置 +# 注意:请勿直接覆盖宝塔生成的配置文件! +# 将此配置中的 server 块复制到宝塔对应站点的配置中 +# 宝塔路径: /www/server/panel/vhost/nginx/trade.yuzhiran.com.conf + +server { + listen 80; + server_name trade.yuzhiran.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name trade.yuzhiran.com; + + # SSL 证书(宝塔面板中配置,或取消注释以下行) + # ssl_certificate /www/server/panel/vhost/cert/trade.yuzhiran.com/fullchain.pem; + # ssl_certificate_key /www/server/panel/vhost/cert/trade.yuzhiran.com/privkey.pem; + # ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; + # ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5; + # ssl_prefer_server_ciphers on; + + # 前端静态文件(uni-app build:h5 产物) + root /www/wwwroot/trade.yuzhiran.com/frontend/dist; + index index.html; + + # gzip + gzip on; + gzip_min_length 1k; + gzip_comp_level 6; + gzip_types text/plain text/css text/javascript application/json application/javascript image/svg+xml; + + # API 反向代理到后端 + location /api/ { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + + # WebSocket 支持(如有需要) + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + # 上传文件(如有需要可配置单独的路径) + # location /uploads/ { + # alias /www/wwwroot/trade.yuzhiran.com/backend/uploads/; + # expires 7d; + # } + + # SPA 路由 fallback + location / { + try_files $uri $uri/ /index.html; + } + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # 禁止访问隐藏文件 + location ~ /\. { + deny all; + } +} diff --git a/docs/GO_TO_MARKET.md b/docs/GO_TO_MARKET.md new file mode 100644 index 0000000..3aa003b --- /dev/null +++ b/docs/GO_TO_MARKET.md @@ -0,0 +1,135 @@ +# TradeMate 外贸小助手 — 营销推广方案 + +> 版本: v1.0 +> 更新日期: 2026-05-14 +> 状态: 待上线后执行 + +--- + +## 一、核心策略 + +**定位**: 外贸 SOHO / 小微外贸公司的 AI 工作台 +**差异化**: 不是翻译工具,是"从询盘到成交"的全流程 AI 辅助 +**初期目标**: 获取 200 个活跃用户,验证付费转化模型 + +--- + +## 二、阶段一:冷启动(第 1-3 周) + +### 2.1 福步外贸论坛 — 3 篇深度帖 + +| 序号 | 标题方向 | 目的 | 排期 | +|------|----------|------|------| +| 1 | 做外贸 5 年,我整理了一套客户跟进 SOP(附模板) | 引流+建立专业形象 | 第 1 天 | +| 2 | 报完价客户就不回了,这 3 种跟进策略我用了最有效 | 展示工具价值 | 第 5 天 | +| 3 | 我用 AI 写开发信,回复率从 8% 提高到 23%(经验分享) | 转化付费 | 第 10 天 | + +**操作要点**: +- 每个帖子都要有真实案例/数据支撑 +- 帖子里自然植入工具功能(不要硬广) +- 回复区用"有人问类似问题"的方式引导到工具 +- 发帖后每天回复 3-5 个评论 + +### 2.2 知乎 — 回答引流 + +**回答方向**(搜索这些关键词去答): +- 外贸新人如何开发客户? +- 外贸报价格式怎么写? +- 有没有好用的外贸工具推荐? +- 做外贸用哪个翻译软件好? + +**策略**: 每个回答开头给干货,末尾"我们团队做了一个小工具,可以免费体验……" + +### 2.3 公众号/知识星球 + +- 开设公众号,发 5-10 篇行业干货建立内容库 +- 免费提供"询盘分析报告"(用户发截图,帮分析客户意图) +- 通过私域沉淀种子用户 + +--- + +## 三、阶段二:裂变增长(第 4-6 周) + +### 3.1 推荐码 — 邀请得 Pro 体验 + +**规则**: +``` +现有用户 → 分享邀请链接 → 新用户注册 + 完成首次翻译 +→ 邀请人获赠 7 天 Pro +→ 被邀请人获赠 7 天 Pro +``` + +**实现**: 在 User 表加 `referrer_id` 字段,User 表加 `pro_expires_at` 字段(到期时间),由推荐逻辑自动设置。 + +### 3.2 案例征集 + +- 征集"用 TradeMate 成交的第一个客户"故事 +- 入选者赠送 3 个月 Pro +- 故事加工成帖子二次传播 + +--- + +## 四、阶段三:付费转化(第 7 周起) + +### 4.1 免费 → 付费的转化点设计 + +``` +免费用户 → 用完免费配额 → 弹窗: + 方案 A: 邀请朋友 → 重置配额 + 方案 B: 升级 Pro → ¥xx/月 + 方案 C: 填问卷反馈 → 赠送 50 次翻译 +``` + +### 4.2 定价策略建议 + +| Tier | 价格 | 定位 | +|------|------|------| +| Free | ¥0 | 体验门槛,5 个客户/日上限 | +| Pro | ¥39/月 | 单人使用,无限客户 + 优先响应 | +| Enterprise | ¥99/月 | 团队协作 + 专属模型 | + +--- + +## 五、内容排期表 + +| 周期 | 渠道 | 内容类型 | 频率 | +|------|------|----------|------| +| 第 1 周 | 福步论坛 | 干货长帖 | 2 篇 | +| 第 1 周 | 知乎 | 回答 | 5 个/天 | +| 第 2 周 | 公众号 | 行业文章 | 3 篇 | +| 第 2 周 | 外贸微信群 | 答疑+引导 | 每日 | +| 第 3 周 | Product Hunt | 英文发布 | 1 次 | +| 第 4 周 | 行业导航站 | 提交收录 | 5-8 个站 | + +--- + +## 六、效果指标 + +| 指标 | 第 1 月目标 | 第 3 月目标 | +|------|:---------:|:---------:| +| 注册用户 | 500 | 3000 | +| DAU | 50 | 300 | +| 付费用户 | 10 | 100 | +| 月收入 | ¥390 | ¥3900+ | + +--- + +## 七、内容素材准备清单 + +- [ ] 产品截图(首页、翻译、客户管理、报价单) +- [ ] 15s 演示视频(录屏 + 配音) +- [ ] 3 个用户案例故事 +- [ ] 免费 vs Pro 功能对比图 +- [ ] 询盘分析报告模板(用于免费引流) +- [ ] 英文版 Landing Page(Product Hunt 用) +- [ ] FAQ 文档(10 条常见问题) + +--- + +## 八、注意事项 + +1. **内容 > 广告** — 外贸圈是信任驱动型社群,一篇干货的价值远超竞价广告 +2. **先服务后转化** — 先免费帮用户解决具体问题(分析询盘、写跟进等),再引导使用工具 +3. **不要群发** — 微信群发链接会被踢,一对一私聊更有效 +4. **收集反馈** — 每 10 个用户做一次电话/语音回访,了解真实使用场景 +5. **关注留存而非注册** — 100 个注册但次日留存 10% 不如 30 个注册留存 60% diff --git a/uni-app/src/pages/admin/admin.vue b/uni-app/src/pages/admin/admin.vue index f91d36b..f7c1480 100644 --- a/uni-app/src/pages/admin/admin.vue +++ b/uni-app/src/pages/admin/admin.vue @@ -2,100 +2,439 @@ 管理后台 - 系统概览 + 系统管理与监控 - - - {{ dashboard.users?.total || 0 }} - 用户总数 - - - {{ dashboard.teams?.total || 0 }} - 团队数 - - - {{ dashboard.customers?.total || 0 }} - 客户总数 - - - {{ dashboard.usage?.today || 0 }} - 今日请求 - + + 概览 + 用户 + 统计 + 日志 + 配置 - - - 最近注册用户 + + + + + {{ dashboard.users?.total || 0 }} + 用户总数 + + + {{ dashboard.teams?.total || 0 }} + 团队数 + + + {{ dashboard.customers?.total || 0 }} + 客户总数 + + + {{ dashboard.usage?.today || 0 }} + 今日请求 + - - - + + + + + + 搜索用户 + + + + 搜索 + + + + + + + + 无匹配用户 + + + + + 所有用户 + 共 {{ userTotal }} 人 + + + + + + + + + 上一页 + {{ userPage }} / {{ userTotalPages }} + 下一页 - - - 用户管理 + + + + + {{ usageStats.today_total || 0 }} + 今日请求 + + + {{ usageStats.dau || 0 }} + 日活跃用户 + + + {{ usageStats.total_users || 0 }} + 总用户 + - - -