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
This commit is contained in:
TradeMate Dev
2026-05-14 09:19:30 +08:00
parent 23a31f7c00
commit 5a1af9f82f
15 changed files with 1377 additions and 71 deletions
+1 -1
View File
@@ -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
+105 -1
View File
@@ -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),
+2
View File
@@ -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",
]
+15
View File
@@ -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)
+3
View File
@@ -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={
+253 -16
View File
@@ -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
]