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:
+105
-1
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user