初始提交: Qihu-AIFlow v2.0.1 完整项目

- 前端 React+TypeScript (源码+构建产物)
- 后端 Python FastAPI
- 数据库迁移 001-015
- Docker部署配置
- 微信ClawBot API频道绑定
- 项目文档 README.md
This commit is contained in:
qyyfw committed 2026-07-02 14:40:46 +08:00
commit 78ff261b5a
199 files changed
+69126

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
from . import auth
from . import chat
from . import channels
from . import knowledge
from . import market
from . import token_usage
from . import payment
from . import profile
from . import notifications
from . import assets
+1
View File
@@ -0,0 +1 @@
# Admin API routes package
+311
View File
@@ -0,0 +1,311 @@
"""
广告位管理 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File
from fastapi.responses import FileResponse
from sqlalchemy.orm import Session
from ...database import get_db, SessionLocal
from ...models import AdBanner, AIModel
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
import os
import json
import httpx
from datetime import datetime
router = APIRouter()
# 广告图片存储目录
ADS_IMAGE_DIR = "/data/share/project/data/ads"
os.makedirs(ADS_IMAGE_DIR, exist_ok=True)
@router.get("/")
async def get_ads(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取广告轮播图列表"""
ads = db.query(AdBanner).order_by(AdBanner.sort_order.asc()).all()
return [
{
"id": a.id,
"title": a.title,
"image_url": a.image_url,
"link_app_id": a.link_app_id,
"link_app_name": a.link_app_name,
"sort_order": a.sort_order,
"click_count": a.click_count,
"status": a.status,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
for a in ads
]
@router.post("/")
async def create_ad(
title: str,
image_url: str = None,
link_app_id: str = None,
link_app_name: str = None,
sort_order: int = 0,
status: str = "active",
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建广告"""
ad = AdBanner(
id=str(uuid.uuid4()),
title=title,
image_url=image_url,
link_app_id=link_app_id,
link_app_name=link_app_name,
sort_order=sort_order,
status=status,
)
db.add(ad)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_ad",
target_type="ad_banner",
target_id=ad.id,
detail=f"创建广告 {title}",
)
return {"message": "广告已创建", "id": ad.id}
@router.put("/{id}")
async def update_ad(
id: str,
title: str = None,
image_url: str = None,
link_app_id: str = None,
link_app_name: str = None,
sort_order: int = None,
status: str = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新广告"""
ad = db.query(AdBanner).filter(AdBanner.id == id).first()
if not ad:
raise HTTPException(status_code=404, detail="广告不存在")
if title is not None:
ad.title = title
if image_url is not None:
ad.image_url = image_url
if link_app_id is not None:
ad.link_app_id = link_app_id
if link_app_name is not None:
ad.link_app_name = link_app_name
if sort_order is not None:
ad.sort_order = sort_order
if status is not None:
ad.status = status
ad.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_ad",
target_type="ad_banner",
target_id=id,
detail=f"更新广告 {ad.title}",
)
return {"message": "广告已更新"}
@router.delete("/{id}")
async def delete_ad(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除广告"""
ad = db.query(AdBanner).filter(AdBanner.id == id).first()
if not ad:
raise HTTPException(status_code=404, detail="广告不存在")
db.delete(ad)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_ad",
target_type="ad_banner",
target_id=id,
detail=f"删除广告 {ad.title}",
)
return {"message": "广告已删除"}
@router.put("/{id}/toggle")
async def toggle_ad(
id: str,
status: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""切换广告状态"""
ad = db.query(AdBanner).filter(AdBanner.id == id).first()
if not ad:
raise HTTPException(status_code=404, detail="广告不存在")
ad.status = status
ad.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="toggle_ad",
target_type="ad_banner",
target_id=id,
detail=f"切换广告 {ad.title} 状态为 {status}",
)
return {"message": f"广告已{status}"}
@router.post("/upload-image")
async def upload_ad_image(
file: UploadFile = File(...),
admin_user=Depends(get_current_admin),
):
"""上传广告图片,保存到 /data/share/project/data/ads/ 目录"""
# 校验文件类型
allowed_types = {"image/png", "image/jpeg", "image/gif", "image/webp", "image/svg+xml"}
if file.content_type not in allowed_types:
raise HTTPException(status_code=400, detail="仅支持 PNG/JPG/GIF/WebP/SVG 格式的图片")
# 生成唯一文件名
ext = os.path.splitext(file.filename or "image.png")[1] or ".png"
if ext.lower() not in (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"):
ext = ".png"
filename = f"{uuid.uuid4()}{ext}"
filepath = os.path.join(ADS_IMAGE_DIR, filename)
# 保存文件
content = await file.read()
if len(content) > 10 * 1024 * 1024: # 10MB 限制
raise HTTPException(status_code=400, detail="图片大小不能超过10MB")
with open(filepath, "wb") as f:
f.write(content)
# 返回可访问的URL
image_url = f"/app/api/admin/ads/images/{filename}"
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="upload_ad_image",
target_type="ad_banner",
detail=f"上传广告图片 {filename}",
)
return {"message": "图片上传成功", "image_url": image_url, "filename": filename}
@router.get("/images/{filename}")
async def get_ad_image(filename: str):
"""获取广告图片"""
filepath = os.path.join(ADS_IMAGE_DIR, filename)
if not os.path.exists(filepath):
raise HTTPException(status_code=404, detail="图片不存在")
return FileResponse(filepath)
@router.post("/generate-image")
async def generate_ad_image(
prompt: str = Query(default=""),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""AI生成广告图:调用LLM生成prompt,然后生成图片(这里生成渐变色占位图)"""
# 如果没有传入prompt,使用默认prompt
final_prompt = prompt.strip() if prompt.strip() else "A modern tech AI platform banner advertisement, sleek interface, gradient blue purple theme, high quality"
# 尝试使用默认LLM模型生成更优的prompt
enhanced_prompt = final_prompt
try:
default_model = db.query(AIModel).filter(
AIModel.is_default == True, AIModel.is_enabled == True
).first()
if default_model and default_model.endpoint:
async with httpx.AsyncClient(timeout=30) as client:
headers = {}
if default_model.api_key:
headers["Authorization"] = f"Bearer {default_model.api_key}"
resp = await client.post(
default_model.endpoint,
json={
"model": default_model.name,
"messages": [
{"role": "system", "content": "你是一个广告创意专家。请根据用户输入的主题,生成一个简短的中文广告图像prompt描述(不超过100字),用于AI图像生成。只需要输出prompt文本本身,不要其他内容。"},
{"role": "user", "content": f"主题: {final_prompt}"},
],
"max_tokens": 200,
},
headers=headers,
)
if resp.status_code == 200:
content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
if content:
enhanced_prompt = content.strip()[:200]
except Exception as e:
# LLM增强失败,使用原始prompt
pass
# 生成一个唯一的图片文件名(使用prompt的hash作为标识)
import hashlib
prompt_hash = hashlib.md5(enhanced_prompt.encode()).hexdigest()[:12]
filename = f"ai_gen_{prompt_hash}.svg"
filepath = os.path.join(ADS_IMAGE_DIR, filename)
# 生成SVG广告图
svg_content = f'''<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="400" viewBox="0 0 1200 400">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1"/>
<stop offset="50%" style="stop-color:#764ba2;stop-opacity:1"/>
<stop offset="100%" style="stop-color:#5B5FED;stop-opacity:1"/>
</linearGradient>
<linearGradient id="shape" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:rgba(255,255,255,0.15);stop-opacity:1"/>
<stop offset="100%" style="stop-color:rgba(255,255,255,0.05);stop-opacity:1"/>
</linearGradient>
</defs>
<rect width="1200" height="400" fill="url(#bg)"/>
<circle cx="1000" cy="100" r="200" fill="url(#shape)" opacity="0.3"/>
<circle cx="200" cy="300" r="150" fill="url(#shape)" opacity="0.2"/>
<rect x="600" y="150" width="300" height="200" rx="20" fill="url(#shape)" opacity="0.15" transform="rotate(-15 750 250)"/>
<text x="600" y="180" text-anchor="middle" font-family="Arial, sans-serif" font-size="42" font-weight="bold" fill="#ffffff" filter="drop-shadow(0 2px 4px rgba(0,0,0,0.2))">{enhanced_prompt[:40]}</text>
<text x="600" y="240" text-anchor="middle" font-family="Arial, sans-serif" font-size="20" fill="rgba(255,255,255,0.8)">Powered by AIFlow</text>
</svg>'''
with open(filepath, "w", encoding="utf-8") as f:
f.write(svg_content)
image_url = f"/app/api/admin/ads/images/{filename}"
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="generate_ad_image",
target_type="ad_banner",
detail=f"AI生成广告图 {filename}, prompt: {enhanced_prompt[:50]}",
)
return {"message": "AI广告图已生成", "image_url": image_url, "prompt": enhanced_prompt}
+99
View File
@@ -0,0 +1,99 @@
"""
管理员仪表盘 API
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import func
from ...database import get_db, SessionLocal
from ...models import (
User, AIModel, Order, TokenUsageLog, OperationLog, AuditLog
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
from datetime import datetime, date
router = APIRouter()
@router.get("/stats")
async def get_dashboard_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取管理员仪表盘统计数据"""
today = date.today()
active_users = db.query(func.count(User.id)).filter(
User.status == "active"
).scalar() or 0
enabled_models = db.query(func.count(AIModel.id)).filter(
AIModel.is_enabled == True
).scalar() or 0
today_orders = db.query(func.count(Order.id)).filter(
func.date(Order.created_at) == today
).scalar() or 0
today_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).filter(
func.date(TokenUsageLog.created_at) == today
).scalar() or 0
total_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).scalar() or 0
return {
"active_users": active_users,
"enabled_models": enabled_models,
"today_orders": today_orders,
"today_tokens": int(today_tokens),
"total_tokens": int(total_tokens),
}
@router.get("/recent-logs")
async def get_recent_logs(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取最近10条操作日志"""
logs = db.query(OperationLog).order_by(
OperationLog.created_at.desc()
).limit(10).all()
return [
{
"id": log.id,
"user_id": log.user_id,
"username": log.username,
"action": log.action,
"target": log.target,
"target_id": log.target_id,
"detail": log.detail,
"ip": log.ip,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
@router.get("/recent-audit")
async def get_recent_audit(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取最近10条审计日志"""
logs = db.query(AuditLog).order_by(
AuditLog.created_at.desc()
).limit(10).all()
return [
{
"id": log.id,
"admin_id": log.admin_id,
"admin_name": log.admin_name,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
]
+810
View File
@@ -0,0 +1,810 @@
"""
全平台日志与风控 API(管理员)
关键约束: 操作日志、Token日志、审计日志三个实体绝对不能写DELETE操作
增强功能:
- 审计日志: 按时间范围、用户、操作类型多条件过滤
- 操作日志: 关键词搜索和导出
- API调用统计: 端点调用次数、平均响应时间、错误率
- 慢请求记录: 响应时间超过阈值自动标记
- 统计概览API: /app/api/admin/logs/stats
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func, text
from ...database import get_db, SessionLocal
from ...models import (
OperationLog, TokenUsageLog, Order, AuditLog, RiskRule,
ApiCallStat, SlowRequestLog, User
)
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
import csv
import io
from datetime import datetime, timedelta
router = APIRouter()
# ============================================================
# 日志概览统计 API
# ============================================================
@router.get("/stats")
async def get_log_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取日志概览数据(今日调用量、错误数、活跃用户等)"""
today_start = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
# 今日API调用总量
today_calls = db.query(func.sum(ApiCallStat.call_count)).filter(
ApiCallStat.updated_at >= today_start
).scalar() or 0
# 今日错误数
today_errors = db.query(func.sum(ApiCallStat.error_count)).filter(
ApiCallStat.updated_at >= today_start
).scalar() or 0
# 今日操作日志数
today_ops = db.query(OperationLog).filter(
OperationLog.created_at >= today_start
).count()
# 今日审计日志数
today_audits = db.query(AuditLog).filter(
AuditLog.created_at >= today_start
).count()
# 今日活跃用户数(有操作记录的用户)
today_active_users = db.query(func.count(func.distinct(OperationLog.user_id))).filter(
OperationLog.created_at >= today_start
).scalar() or 0
# 今日Token消耗总量
today_tokens = db.query(func.sum(TokenUsageLog.total_tokens)).filter(
TokenUsageLog.created_at >= today_start
).scalar() or 0
# 慢请求数(今日)
today_slow = db.query(SlowRequestLog).filter(
SlowRequestLog.created_at >= today_start
).count()
# 总用户数
total_users = db.query(User).count()
# 错误率
error_rate = round(today_errors / max(today_calls, 1) * 100, 2)
return {
"code": 0,
"data": {
"today": {
"api_calls": int(today_calls),
"errors": int(today_errors),
"error_rate": error_rate,
"operation_logs": today_ops,
"audit_logs": today_audits,
"active_users": today_active_users,
"total_tokens": int(today_tokens),
"slow_requests": today_slow,
},
"total": {
"users": total_users,
},
},
"message": "ok",
}
# ============================================================
# API调用统计
# ============================================================
@router.get("/api-stats")
async def get_api_call_stats(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
sort_by: str = Query("call_count", description="排序字段: call_count / avg_time / error_rate"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取API调用统计:各端点调用次数、平均响应时间、错误率"""
query = db.query(ApiCallStat)
# 排序
if sort_by == "avg_time":
query = query.order_by(ApiCallStat.total_response_time / func.greatest(ApiCallStat.call_count, 1))
elif sort_by == "error_rate":
query = query.order_by(
(ApiCallStat.error_count * 1.0 / func.greatest(ApiCallStat.call_count, 1)).desc()
)
else:
query = query.order_by(ApiCallStat.call_count.desc())
total = query.count()
stats = query.offset((page - 1) * page_size).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": s.id,
"endpoint": s.endpoint,
"method": s.method,
"call_count": s.call_count,
"avg_response_time": round(s.total_response_time / max(s.call_count, 1), 4),
"error_count": s.error_count,
"error_rate": round(s.error_count / max(s.call_count, 1) * 100, 2),
"last_called_at": s.last_called_at.isoformat() if s.last_called_at else None,
}
for s in stats
],
},
"message": "ok",
}
# ============================================================
# 慢请求记录
# ============================================================
@router.get("/slow-requests")
async def get_slow_requests(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
min_response_time: float = Query(None, description="最小响应时间过滤"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取慢请求记录列表"""
query = db.query(SlowRequestLog)
if min_response_time is not None:
query = query.filter(SlowRequestLog.response_time >= min_response_time)
total = query.count()
logs = query.order_by(SlowRequestLog.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": log.id,
"request_id": log.request_id,
"method": log.method,
"endpoint": log.endpoint,
"status_code": log.status_code,
"response_time": log.response_time,
"threshold": log.threshold,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
},
"message": "ok",
}
# ============================================================
# 操作日志(增强: 关键词搜索 + 导出)
# ============================================================
@router.get("/operations")
async def get_operation_logs(
user_id: str = Query(None, description="按用户ID筛选"),
username: str = Query(None, description="按用户名筛选"),
action: str = Query(None, description="按操作类型筛选"),
keyword: str = Query(None, description="关键词搜索(匹配目标、详情)"),
start_date: str = Query(None, description="开始日期 (YYYY-MM-DD)"),
end_date: str = Query(None, description="结束日期 (YYYY-MM-DD)"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取操作日志列表(增强:支持关键词搜索和日期范围过滤)"""
query = db.query(OperationLog)
if user_id:
query = query.filter(OperationLog.user_id == user_id)
if username:
query = query.filter(OperationLog.username.like(f"%{username}%"))
if action:
query = query.filter(OperationLog.action == action)
if keyword:
query = query.filter(
(OperationLog.target.like(f"%{keyword}%")) |
(OperationLog.detail.like(f"%{keyword}%"))
)
if start_date:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(OperationLog.created_at >= sd)
except ValueError:
pass
if end_date:
try:
ed = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(OperationLog.created_at < ed)
except ValueError:
pass
total = query.count()
logs = query.order_by(OperationLog.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": log.id,
"user_id": log.user_id,
"username": log.username,
"action": log.action,
"target": log.target,
"target_id": log.target_id,
"detail": log.detail,
"ip": log.ip,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
},
"message": "ok",
}
# ============================================================
# Token调用日志
# ============================================================
@router.get("/token-logs")
async def get_token_logs(
user_id: str = Query(None, description="按用户ID筛选"),
model_id: str = Query(None, description="按模型ID筛选"),
keyword: str = Query(None, description="关键词搜索(匹配模型名称/应用名称)"),
start_date: str = Query(None, description="开始日期 (YYYY-MM-DD)"),
end_date: str = Query(None, description="结束日期 (YYYY-MM-DD)"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取Token调用日志列表"""
query = db.query(TokenUsageLog)
if user_id:
query = query.filter(TokenUsageLog.user_id == user_id)
if model_id:
query = query.filter(TokenUsageLog.model_id == model_id)
if keyword:
query = query.filter(
(TokenUsageLog.model_name.like(f"%{keyword}%")) |
(TokenUsageLog.app_name.like(f"%{keyword}%"))
)
if start_date:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(TokenUsageLog.created_at >= sd)
except ValueError:
pass
if end_date:
try:
ed = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(TokenUsageLog.created_at < ed)
except ValueError:
pass
total = query.count()
logs = query.order_by(TokenUsageLog.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": log.id,
"user_id": log.user_id,
"model_id": log.model_id,
"model_name": log.model_name,
"app_id": log.app_id,
"app_name": log.app_name,
"conversation_id": log.conversation_id,
"prompt_tokens": log.prompt_tokens,
"completion_tokens": log.completion_tokens,
"total_tokens": log.total_tokens,
"cost": float(log.cost) if log.cost else 0,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
},
"message": "ok",
}
# ============================================================
# 付费订单日志
# ============================================================
@router.get("/payment-logs")
async def get_payment_logs(
user_id: str = Query(None, description="按用户ID筛选"),
status: str = Query(None, description="按状态筛选"),
keyword: str = Query(None, description="关键词搜索(匹配项目名称)"),
start_date: str = Query(None, description="开始日期 (YYYY-MM-DD)"),
end_date: str = Query(None, description="结束日期 (YYYY-MM-DD)"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取付费订单日志列表"""
query = db.query(Order)
if user_id:
query = query.filter(Order.user_id == user_id)
if status:
query = query.filter(Order.status == status)
if keyword:
query = query.filter(Order.item_name.like(f"%{keyword}%"))
if start_date:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(Order.created_at >= sd)
except ValueError:
pass
if end_date:
try:
ed = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(Order.created_at < ed)
except ValueError:
pass
total = query.count()
orders = query.order_by(Order.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": o.id,
"user_id": o.user_id,
"order_type": o.order_type,
"item_id": o.item_id,
"item_name": o.item_name,
"amount": float(o.amount) if o.amount else 0,
"coupon_id": o.coupon_id,
"discount_amount": float(o.discount_amount) if o.discount_amount else 0,
"payment_method": o.payment_method,
"payment_id": o.payment_id,
"status": o.status,
"created_at": o.created_at.isoformat() if o.created_at else None,
"updated_at": o.updated_at.isoformat() if o.updated_at else None,
}
for o in orders
],
},
"message": "ok",
}
# ============================================================
# 审计日志(增强:多条件过滤)
# ============================================================
@router.get("/audit-logs")
async def get_audit_logs(
admin_id: str = Query(None, description="按管理员ID筛选"),
admin_name: str = Query(None, description="按管理员名称筛选"),
action: str = Query(None, description="按操作类型筛选"),
target_type: str = Query(None, description="按目标类型筛选"),
start_date: str = Query(None, description="开始日期 (YYYY-MM-DD)"),
end_date: str = Query(None, description="结束日期 (YYYY-MM-DD)"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取审计日志列表(增强:支持按管理员、操作类型、目标类型、日期范围过滤)"""
query = db.query(AuditLog)
if admin_id:
query = query.filter(AuditLog.admin_id == admin_id)
if admin_name:
query = query.filter(AuditLog.admin_name.like(f"%{admin_name}%"))
if action:
query = query.filter(AuditLog.action == action)
if target_type:
query = query.filter(AuditLog.target_type == target_type)
if start_date:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(AuditLog.created_at >= sd)
except ValueError:
pass
if end_date:
try:
ed = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(AuditLog.created_at < ed)
except ValueError:
pass
total = query.count()
logs = query.order_by(AuditLog.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": log.id,
"admin_id": log.admin_id,
"admin_name": log.admin_name,
"action": log.action,
"target_type": log.target_type,
"target_id": log.target_id,
"detail": log.detail,
"created_at": log.created_at.isoformat() if log.created_at else None,
}
for log in logs
],
},
"message": "ok",
}
# ============================================================
# 日志导出
# ============================================================
@router.get("/export/operations")
async def export_operations(
user_id: str = Query(None),
action: str = Query(None),
keyword: str = Query(None),
start_date: str = Query(None),
end_date: str = Query(None),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""导出操作日志(CSV)- 增强:支持与列表相同的过滤条件"""
query = db.query(OperationLog)
if user_id:
query = query.filter(OperationLog.user_id == user_id)
if action:
query = query.filter(OperationLog.action == action)
if keyword:
query = query.filter(
(OperationLog.target.like(f"%{keyword}%")) |
(OperationLog.detail.like(f"%{keyword}%"))
)
if start_date:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(OperationLog.created_at >= sd)
except ValueError:
pass
if end_date:
try:
ed = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(OperationLog.created_at < ed)
except ValueError:
pass
logs = query.order_by(OperationLog.created_at.desc()).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["ID", "用户ID", "用户名", "操作", "目标", "目标ID", "详情", "IP", "时间"])
for log in logs:
writer.writerow([
log.id, log.user_id, log.username, log.action,
log.target, log.target_id, log.detail, log.ip,
log.created_at.isoformat() if log.created_at else "",
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=operations_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
},
)
@router.get("/export/token-logs")
async def export_token_logs(
user_id: str = Query(None),
keyword: str = Query(None),
start_date: str = Query(None),
end_date: str = Query(None),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""导出Token日志(CSV)"""
query = db.query(TokenUsageLog)
if user_id:
query = query.filter(TokenUsageLog.user_id == user_id)
if keyword:
query = query.filter(
(TokenUsageLog.model_name.like(f"%{keyword}%")) |
(TokenUsageLog.app_name.like(f"%{keyword}%"))
)
if start_date:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(TokenUsageLog.created_at >= sd)
except ValueError:
pass
if end_date:
try:
ed = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(TokenUsageLog.created_at < ed)
except ValueError:
pass
logs = query.order_by(TokenUsageLog.created_at.desc()).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"ID", "用户ID", "模型ID", "模型名称", "应用ID", "应用名称",
"会话ID", "Prompt Tokens", "Completion Tokens", "Total Tokens", "费用", "时间"
])
for log in logs:
writer.writerow([
log.id, log.user_id, log.model_id, log.model_name,
log.app_id, log.app_name, log.conversation_id,
log.prompt_tokens, log.completion_tokens, log.total_tokens,
float(log.cost) if log.cost else 0,
log.created_at.isoformat() if log.created_at else "",
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=token_logs_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
},
)
@router.get("/export/audit-logs")
async def export_audit_logs(
admin_id: str = Query(None),
action: str = Query(None),
target_type: str = Query(None),
start_date: str = Query(None),
end_date: str = Query(None),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""导出审计日志(CSV)"""
query = db.query(AuditLog)
if admin_id:
query = query.filter(AuditLog.admin_id == admin_id)
if action:
query = query.filter(AuditLog.action == action)
if target_type:
query = query.filter(AuditLog.target_type == target_type)
if start_date:
try:
sd = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(AuditLog.created_at >= sd)
except ValueError:
pass
if end_date:
try:
ed = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(AuditLog.created_at < ed)
except ValueError:
pass
logs = query.order_by(AuditLog.created_at.desc()).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["ID", "管理员ID", "管理员名称", "操作", "目标类型", "目标ID", "详情", "时间"])
for log in logs:
writer.writerow([
log.id, log.admin_id, log.admin_name, log.action,
log.target_type, log.target_id, log.detail,
log.created_at.isoformat() if log.created_at else "",
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=audit_logs_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
},
)
# ============================================================
# 操作类型枚举
# ============================================================
@router.get("/action-types")
async def get_action_types(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取所有操作类型枚举值(用于筛选下拉)"""
ops = db.query(OperationLog.action).distinct().all()
audits = db.query(AuditLog.action).distinct().all()
audit_targets = db.query(AuditLog.target_type).distinct().all()
return {
"operation_actions": [r[0] for r in ops if r[0]],
"audit_actions": [r[0] for r in audits if r[0]],
"audit_target_types": [r[0] for r in audit_targets if r[0]],
}
# ============================================================
# 风控规则管理
# ============================================================
@router.get("/risk-rules")
async def get_risk_rules(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取风控规则列表"""
rules = db.query(RiskRule).order_by(RiskRule.created_at.desc()).all()
return [
{
"id": r.id,
"name": r.name,
"type": r.type,
"threshold": r.threshold,
"duration": r.duration,
"is_enabled": r.is_enabled,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rules
]
@router.post("/risk-rules")
async def create_risk_rule(
name: str,
rule_type: str,
threshold: int = 0,
duration: int = 3600,
is_enabled: bool = True,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建风控规则"""
rule = RiskRule(
id=str(uuid.uuid4()),
name=name,
type=rule_type,
threshold=threshold,
duration=duration,
is_enabled=is_enabled,
)
db.add(rule)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_risk_rule",
target_type="risk_rule",
target_id=rule.id,
detail=f"创建风控规则 {name}",
)
return {"message": "风控规则已创建", "id": rule.id}
@router.put("/risk-rules/{id}")
async def update_risk_rule(
id: str,
name: str = None,
threshold: int = None,
duration: int = None,
is_enabled: bool = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新风控规则"""
rule = db.query(RiskRule).filter(RiskRule.id == id).first()
if not rule:
raise HTTPException(status_code=404, detail="风控规则不存在")
if name is not None:
rule.name = name
if threshold is not None:
rule.threshold = threshold
if duration is not None:
rule.duration = duration
if is_enabled is not None:
rule.is_enabled = is_enabled
rule.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_risk_rule",
target_type="risk_rule",
target_id=id,
detail=f"更新风控规则 {rule.name}",
)
return {"message": "风控规则已更新"}
@router.delete("/risk-rules/{id}")
async def delete_risk_rule(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除风控规则"""
rule = db.query(RiskRule).filter(RiskRule.id == id).first()
if not rule:
raise HTTPException(status_code=404, detail="风控规则不存在")
db.delete(rule)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_risk_rule",
target_type="risk_rule",
target_id=id,
detail=f"删除风控规则 {rule.name}",
)
return {"message": "风控规则已删除"}
@@ -0,0 +1,412 @@
"""
应用市场审核 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ...database import get_db, SessionLocal
from ...models import MarketApp, Coupon, UserCoupon, User
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
from datetime import datetime
router = APIRouter()
# ============================================================
# 应用审核管理
# ============================================================
@router.get("/apps/pending")
async def get_pending_apps(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取待审核应用列表"""
apps = db.query(MarketApp).filter(
MarketApp.status == "pending"
).order_by(MarketApp.created_at.desc()).all()
return [
{
"id": a.id,
"name": a.name,
"type": a.type,
"source": a.source,
"publisher_id": a.publisher_id,
"publisher_name": a.publisher_name,
"description": a.description,
"icon": a.icon,
"tutorial": a.tutorial,
"price": float(a.price) if a.price else 0,
"is_member_free": a.is_member_free,
"join_promotion": a.join_promotion,
"status": a.status,
"rating_avg": float(a.rating_avg) if a.rating_avg else 0,
"review_count": a.review_count,
"install_count": a.install_count,
"skill_content": a.skill_content,
"mcp_transport": a.mcp_transport,
"agent_endpoint": a.agent_endpoint,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
for a in apps
]
@router.put("/apps/{id}/approve")
async def approve_app(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""审核通过应用"""
app = db.query(MarketApp).filter(MarketApp.id == id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
app.status = "published"
app.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="approve_app",
target_type="market_app",
target_id=id,
detail=f"审核通过应用 {app.name}",
)
return {"message": "应用审核通过"}
@router.put("/apps/{id}/reject")
async def reject_app(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""审核拒绝应用"""
app = db.query(MarketApp).filter(MarketApp.id == id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
app.status = "removed"
app.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="reject_app",
target_type="market_app",
target_id=id,
detail=f"审核拒绝应用 {app.name}",
)
return {"message": "应用审核已拒绝"}
@router.get("/apps")
async def get_all_apps(
status: str = Query(None, description="状态筛选: pending/published/removed"),
app_type: str = Query(None, description="类型筛选: skill/agent/mcp"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取所有应用列表(管理员)"""
query = db.query(MarketApp)
if status:
query = query.filter(MarketApp.status == status)
if app_type:
query = query.filter(MarketApp.type == app_type)
total = query.count()
apps = query.order_by(MarketApp.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": a.id,
"name": a.name,
"type": a.type,
"source": a.source,
"publisher_id": a.publisher_id,
"publisher_name": a.publisher_name,
"description": a.description,
"icon": a.icon,
"tutorial": a.tutorial,
"price": float(a.price) if a.price else 0,
"is_member_free": a.is_member_free,
"join_promotion": a.join_promotion,
"status": a.status,
"rating_avg": float(a.rating_avg) if a.rating_avg else 0,
"review_count": a.review_count,
"install_count": a.install_count,
"created_at": a.created_at.isoformat() if a.created_at else None,
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
}
for a in apps
],
}
@router.put("/apps/{id}")
async def admin_edit_app(
id: str,
name: str = None,
description: str = None,
icon: str = None,
price: float = None,
is_member_free: bool = None,
join_promotion: bool = None,
tutorial: str = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""管理员编辑应用"""
app = db.query(MarketApp).filter(MarketApp.id == id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
if name is not None:
app.name = name
if description is not None:
app.description = description
if icon is not None:
app.icon = icon
if price is not None:
app.price = price
if is_member_free is not None:
app.is_member_free = is_member_free
if join_promotion is not None:
app.join_promotion = join_promotion
if tutorial is not None:
app.tutorial = tutorial
app.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="edit_market_app",
target_type="market_app",
target_id=id,
detail=f"管理员编辑应用 {app.name}",
)
return {"message": "应用已更新"}
# ============================================================
# 优惠券管理
# ============================================================
@router.get("/coupons")
async def get_coupons(
status: str = Query(None, description="状态筛选: active/expired/disabled"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取优惠券列表"""
query = db.query(Coupon)
if status:
query = query.filter(Coupon.status == status)
coupons = query.order_by(Coupon.created_at.desc()).all()
return [
{
"id": c.id,
"code": c.code,
"type": c.type,
"value": float(c.value) if c.value else 0,
"min_amount": float(c.min_amount) if c.min_amount else 0,
"valid_from": c.valid_from.isoformat() if c.valid_from else None,
"valid_to": c.valid_to.isoformat() if c.valid_to else None,
"total_count": c.total_count,
"used_count": c.used_count,
"status": c.status,
"created_at": c.created_at.isoformat() if c.created_at else None,
}
for c in coupons
]
@router.post("/coupons")
async def create_coupon(
code: str,
coupon_type: str,
value: float,
valid_from: str,
valid_to: str,
min_amount: float = 0,
total_count: int = None,
status: str = "active",
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建优惠券"""
# 检查code是否已存在
existing = db.query(Coupon).filter(Coupon.code == code).first()
if existing:
raise HTTPException(status_code=400, detail="优惠券码已存在")
coupon = Coupon(
id=str(uuid.uuid4()),
code=code,
type=coupon_type,
value=value,
min_amount=min_amount,
valid_from=datetime.fromisoformat(valid_from),
valid_to=datetime.fromisoformat(valid_to),
total_count=total_count,
status=status,
)
db.add(coupon)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_coupon",
target_type="coupon",
target_id=coupon.id,
detail=f"创建优惠券 {code}",
)
return {"message": "优惠券已创建", "id": coupon.id}
@router.put("/coupons/{id}")
async def update_coupon(
id: str,
code: str = None,
value: float = None,
min_amount: float = None,
valid_from: str = None,
valid_to: str = None,
total_count: int = None,
status: str = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新优惠券"""
coupon = db.query(Coupon).filter(Coupon.id == id).first()
if not coupon:
raise HTTPException(status_code=404, detail="优惠券不存在")
if code is not None:
# 检查新code是否重复
if code != coupon.code:
dup = db.query(Coupon).filter(Coupon.code == code).first()
if dup:
raise HTTPException(status_code=400, detail="优惠券码已存在")
coupon.code = code
if value is not None:
coupon.value = value
if min_amount is not None:
coupon.min_amount = min_amount
if valid_from is not None:
coupon.valid_from = datetime.fromisoformat(valid_from)
if valid_to is not None:
coupon.valid_to = datetime.fromisoformat(valid_to)
if total_count is not None:
coupon.total_count = total_count
if status is not None:
coupon.status = status
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_coupon",
target_type="coupon",
target_id=id,
detail=f"更新优惠券 {coupon.code}",
)
return {"message": "优惠券已更新"}
@router.delete("/coupons/{id}")
async def delete_coupon(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除优惠券"""
coupon = db.query(Coupon).filter(Coupon.id == id).first()
if not coupon:
raise HTTPException(status_code=404, detail="优惠券不存在")
db.delete(coupon)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_coupon",
target_type="coupon",
target_id=id,
detail=f"删除优惠券 {coupon.code}",
)
return {"message": "优惠券已删除"}
@router.post("/coupons/{id}/assign")
async def assign_coupon(
id: str,
user_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""发放优惠券给用户"""
coupon = db.query(Coupon).filter(Coupon.id == id).first()
if not coupon:
raise HTTPException(status_code=404, detail="优惠券不存在")
if coupon.status != "active":
raise HTTPException(status_code=400, detail="优惠券非活跃状态")
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 检查是否已发放
existing = db.query(UserCoupon).filter(
UserCoupon.user_id == user_id,
UserCoupon.coupon_id == id,
).first()
if existing:
raise HTTPException(status_code=400, detail="该用户已拥有此优惠券")
user_coupon = UserCoupon(
id=str(uuid.uuid4()),
user_id=user_id,
coupon_id=id,
)
db.add(user_coupon)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="assign_coupon",
target_type="coupon",
target_id=id,
detail=f"发放优惠券 {coupon.code} 给用户 {user.username}",
)
return {"message": "优惠券已发放"}
@@ -0,0 +1,258 @@
"""
会员订阅体系管理 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ...database import get_db, SessionLocal
from ...models import MembershipPlan, MembershipSubscription, User, RiskRule
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
from datetime import datetime
router = APIRouter()
# ============================================================
# 会员计划 CRUD
# ============================================================
@router.get("/plans")
async def get_plans(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取会员计划列表"""
plans = db.query(MembershipPlan).order_by(MembershipPlan.level).all()
return [
{
"id": p.id,
"name": p.name,
"level": p.level,
"duration_type": p.duration_type,
"price": float(p.price) if p.price else 0,
"daily_free_tokens": p.daily_free_tokens,
"features": p.features,
"free_app_ids": p.free_app_ids,
"free_model_ids": p.free_model_ids,
"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,
}
for p in plans
]
@router.post("/plans")
async def create_plan(
name: str,
level: int,
duration_type: str,
price: float,
daily_free_tokens: int = 0,
features: list = None,
free_app_ids: list = None,
free_model_ids: list = None,
is_active: bool = True,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建会员计划"""
plan = MembershipPlan(
id=str(uuid.uuid4()),
name=name,
level=level,
duration_type=duration_type,
price=price,
daily_free_tokens=daily_free_tokens,
features=features,
free_app_ids=free_app_ids,
free_model_ids=free_model_ids,
is_active=is_active,
)
db.add(plan)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_membership_plan",
target_type="membership_plan",
target_id=plan.id,
detail=f"创建会员计划 {name}",
)
return {"message": "会员计划已创建", "id": plan.id}
@router.put("/plans/{id}")
async def update_plan(
id: str,
name: str = None,
level: int = None,
duration_type: str = None,
price: float = None,
daily_free_tokens: int = None,
features: list = None,
free_app_ids: list = None,
free_model_ids: list = None,
is_active: bool = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新会员计划"""
plan = db.query(MembershipPlan).filter(MembershipPlan.id == id).first()
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在")
if name is not None:
plan.name = name
if level is not None:
plan.level = level
if duration_type is not None:
plan.duration_type = duration_type
if price is not None:
plan.price = price
if daily_free_tokens is not None:
plan.daily_free_tokens = daily_free_tokens
if features is not None:
plan.features = features
if free_app_ids is not None:
plan.free_app_ids = free_app_ids
if free_model_ids is not None:
plan.free_model_ids = free_model_ids
if is_active is not None:
plan.is_active = is_active
plan.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_membership_plan",
target_type="membership_plan",
target_id=id,
detail=f"更新会员计划 {plan.name}",
)
return {"message": "会员计划已更新"}
@router.delete("/plans/{id}")
async def delete_plan(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除会员计划"""
plan = db.query(MembershipPlan).filter(MembershipPlan.id == id).first()
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在")
db.delete(plan)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_membership_plan",
target_type="membership_plan",
target_id=id,
detail=f"删除会员计划 {plan.name}",
)
return {"message": "会员计划已删除"}
# ============================================================
# 用户订阅管理
# ============================================================
@router.get("/subscriptions")
async def get_subscriptions(
user_id: str = Query(None),
status: str = Query(None, description="订阅状态: active/expired/downgraded"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取所有用户订阅列表"""
query = db.query(MembershipSubscription)
if user_id:
query = query.filter(MembershipSubscription.user_id == user_id)
if status:
query = query.filter(MembershipSubscription.status == status)
total = query.count()
subscriptions = query.order_by(
MembershipSubscription.created_at.desc()
).offset((page - 1) * page_size).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": s.id,
"user_id": s.user_id,
"plan_id": s.plan_id,
"order_id": s.order_id,
"start_at": s.start_at.isoformat() if s.start_at else None,
"end_at": s.end_at.isoformat() if s.end_at else None,
"auto_renew": s.auto_renew,
"status": s.status,
"created_at": s.created_at.isoformat() if s.created_at else None,
}
for s in subscriptions
],
}
# ============================================================
# 自动化规则(风控)
# ============================================================
@router.get("/automation-rules")
async def get_automation_rules(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取自动化规则配置"""
rules = db.query(RiskRule).order_by(RiskRule.created_at.desc()).all()
if not rules:
# 返回默认规则
return {
"rules": [],
"defaults": {
"max_login_attempts": 5,
"token_usage_threshold_per_hour": 1000000,
"token_usage_threshold_per_day": 10000000,
"concurrent_sessions_limit": 5,
},
}
return {
"rules": [
{
"id": r.id,
"name": r.name,
"type": r.type,
"threshold": r.threshold,
"duration": r.duration,
"is_enabled": r.is_enabled,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
}
for r in rules
],
"defaults": {
"max_login_attempts": 5,
"token_usage_threshold_per_hour": 1000000,
"token_usage_threshold_per_day": 10000000,
"concurrent_sessions_limit": 5,
},
}
+602
View File
@@ -0,0 +1,602 @@
"""
大模型库管理 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query, Body
from sqlalchemy.orm import Session
from sqlalchemy import func
from typing import List, Optional
from pydantic import BaseModel
from ...database import get_db, SessionLocal
from ...models import AIModel, ModelProvider
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
import json
import logging
import httpx
from datetime import datetime
router = APIRouter()
# ============================================================
# 协议预设列表
# ============================================================
PROTOCOLS = [
{"id": "openai_compatible", "name": "OpenAI兼容", "base_path": "/v1", "description": "兼容OpenAI API格式的模型服务"},
{"id": "new_api", "name": "New API", "base_path": "/v1", "description": "新一代API聚合平台"},
{"id": "volcengine", "name": "火山引擎", "base_path": "/v1", "description": "字节跳动火山引擎模型服务"},
{"id": "aliyun_bailian", "name": "阿里云百炼", "base_path": "/v1", "description": "阿里云百炼大模型平台"},
{"id": "ollama", "name": "Ollama", "base_path": "/v1", "description": "本地Ollama模型服务"},
{"id": "xiaoma", "name": "小马算力", "base_path": "/v1", "description": "小马算力模型服务"},
]
# ============================================================
# 模型列表(用于 fetch-models 的回退数据)
# ============================================================
FETCHABLE_MODELS = [
{"id": "gpt-4o", "name": "GPT-4o", "capabilities": ["text", "vision", "tool"], "pricing": "paid"},
{"id": "gpt-4o-mini", "name": "GPT-4o Mini", "capabilities": ["text", "vision"], "pricing": "paid"},
{"id": "deepseek-v3", "name": "DeepSeek-V3", "capabilities": ["text", "thinking"], "pricing": "free"},
{"id": "deepseek-r1", "name": "DeepSeek-R1", "capabilities": ["text", "thinking"], "pricing": "paid"},
{"id": "qwen-max", "name": "Qwen-Max", "capabilities": ["text", "tool"], "pricing": "free"},
{"id": "qwen-vl", "name": "Qwen-VL", "capabilities": ["text", "vision"], "pricing": "paid"},
{"id": "whisper", "name": "Whisper", "capabilities": ["audio"], "pricing": "paid"},
{"id": "dalle", "name": "DALL-E", "capabilities": ["image"], "pricing": "paid"},
]
# 支持通过 API 获取模型列表的协议类型
FETCHABLE_PROTOCOLS = {"openai_compatible", "new_api", "volcengine", "aliyun_bailian", "ollama", "xiaoma"}
# ============================================================
# 辅助函数
# ============================================================
def provider_to_dict(p: ModelProvider) -> dict:
return {
"id": p.id,
"name": p.name,
"protocol": p.protocol,
"api_key": p.api_key,
"api_base_url": p.api_base_url,
"apiKey": p.api_key,
"apiBaseUrl": p.api_base_url,
"created_at": p.created_at.isoformat() if p.created_at else None,
"updated_at": p.updated_at.isoformat() if p.updated_at else None,
"createdAt": p.created_at.isoformat() if p.created_at else None,
"updatedAt": p.updated_at.isoformat() if p.updated_at else None,
}
def model_to_dict(m: AIModel) -> dict:
return {
"id": m.id,
"provider_id": m.provider_id,
"name": m.name,
"api_key": m.api_key,
"endpoint": m.endpoint,
"capabilities": m.capabilities,
"timeout": m.timeout,
"status": m.status,
"price_per_million_tokens": float(m.price_per_million_tokens) if m.price_per_million_tokens else 0,
"is_enabled": m.is_enabled,
"is_default": bool(m.is_default),
"total_calls": m.total_calls or 0,
"total_tokens": m.total_tokens or 0,
"created_at": m.created_at.isoformat() if m.created_at else None,
"updated_at": m.updated_at.isoformat() if m.updated_at else None,
}
# ============================================================
# 协议列表
# ============================================================
@router.get("/protocols")
async def get_protocols(
admin_user=Depends(get_current_admin),
):
"""获取支持的协议列表"""
return PROTOCOLS
# ============================================================
# 模型供应商管理
# ============================================================
@router.get("/providers")
async def get_providers(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取模型供应商列表"""
providers = db.query(ModelProvider).order_by(
ModelProvider.created_at.desc()
).all()
return [provider_to_dict(p) for p in providers]
@router.post("/providers")
async def create_provider(
name: str = Body(...),
protocol: str = Body(...),
api_key: str = Body(default=""),
api_base_url: str = Body(default=None),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""添加模型供应商"""
# 验证协议
valid_protocols = [p["id"] for p in PROTOCOLS]
if protocol not in valid_protocols:
raise HTTPException(status_code=400, detail=f"无效的协议类型,支持: {', '.join(valid_protocols)}")
provider = ModelProvider(
id=str(uuid.uuid4()),
name=name,
protocol=protocol,
api_key=api_key,
api_base_url=api_base_url,
)
db.add(provider)
db.commit()
db.refresh(provider)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_provider",
target_type="model_provider",
target_id=provider.id,
detail=f"添加模型供应商 {name} (协议: {protocol})",
)
return {"message": "供应商已创建", "id": provider.id, "provider": provider_to_dict(provider)}
@router.put("/providers/{id}")
async def update_provider(
id: str,
name: str = Body(default=None),
protocol: str = Body(default=None),
api_key: str = Body(default=None),
api_base_url: str = Body(default=None),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新模型供应商"""
provider = db.query(ModelProvider).filter(ModelProvider.id == id).first()
if not provider:
raise HTTPException(status_code=404, detail="供应商不存在")
if name is not None:
provider.name = name
if protocol is not None:
valid_protocols = [p["id"] for p in PROTOCOLS]
if protocol not in valid_protocols:
raise HTTPException(status_code=400, detail=f"无效的协议类型")
provider.protocol = protocol
if api_key is not None:
provider.api_key = api_key
if api_base_url is not None:
provider.api_base_url = api_base_url
provider.updated_at = datetime.now()
db.commit()
db.refresh(provider)
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_provider",
target_type="model_provider",
target_id=id,
detail=f"更新模型供应商 {provider.name}",
)
return {"message": "供应商已更新", "provider": provider_to_dict(provider)}
@router.delete("/providers/{id}")
async def delete_provider(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除模型供应商"""
provider = db.query(ModelProvider).filter(ModelProvider.id == id).first()
if not provider:
raise HTTPException(status_code=404, detail="供应商不存在")
# 检查是否有模型关联
model_count = db.query(func.count(AIModel.id)).filter(
AIModel.provider_id == id
).scalar()
if model_count > 0:
raise HTTPException(status_code=400, detail=f"该供应商下有 {model_count} 个模型,请先删除模型")
db.delete(provider)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_provider",
target_type="model_provider",
target_id=id,
detail=f"删除模型供应商 {provider.name}",
)
return {"message": "供应商已删除"}
@router.post("/providers/{id}/fetch-models")
async def fetch_models(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取供应商可用模型列表(优先从真实API获取)"""
provider = db.query(ModelProvider).filter(ModelProvider.id == id).first()
if not provider:
raise HTTPException(status_code=404, detail="供应商不存在")
logger = logging.getLogger(__name__)
logger.info(f"Fetching models for provider {provider.name} (protocol: {provider.protocol})")
api_base = (provider.api_base_url or "").rstrip("/")
api_key = provider.api_key or ""
# 尝试从真实API获取模型列表(只要有 api_base 就尝试调用,api_key 可选)
if api_base and provider.protocol in FETCHABLE_PROTOCOLS:
try:
async with httpx.AsyncClient(timeout=15) as client:
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
resp = await client.get(f"{api_base}/v1/models", headers=headers)
if resp.status_code == 200:
data = resp.json()
models_list = data.get("data", [])
if models_list:
result = []
for m in models_list:
mid = m.get("id", "")
if not mid:
continue
result.append({
"id": mid,
"name": mid,
"capabilities": ["text"],
"pricing": "paid",
})
logger.info(f"Successfully fetched {len(result)} models from provider API")
return {"models": result, "source": "api"}
else:
logger.warning(f"Provider API returned empty model list")
return {"models": [], "source": "api", "message": "API返回的模型列表为空"}
elif resp.status_code == 401 or resp.status_code == 403:
logger.warning(f"Provider API returned {resp.status_code}: auth error")
return {"models": [], "source": "error", "message": "API认证失败,请检查API Key是否正确"}
else:
logger.warning(f"Provider API returned status {resp.status_code}: {resp.text[:200]}")
return {"models": [], "source": "error", "message": f"API请求失败(HTTP {resp.status_code}),请检查API地址和密钥"}
except httpx.TimeoutException:
logger.warning(f"Timeout fetching models from provider API: {api_base}")
return {"models": [], "source": "error", "message": "请求API超时,请检查API地址是否正确"}
except Exception as e:
logger.warning(f"Failed to fetch models from provider API: {e}")
return {"models": [], "source": "error", "message": f"请求API失败: {str(e)}"}
# 回退:返回固定模型列表(供无 API 的离线环境使用)
logger.info(f"Using fallback model list for provider {provider.name}")
return {"models": FETCHABLE_MODELS, "source": "fallback"}
class AddModelsRequest(BaseModel):
model_ids: List[str]
@router.post("/providers/{id}/add-models")
async def add_models(
id: str,
req: AddModelsRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""批量添加模型到供应商"""
provider = db.query(ModelProvider).filter(ModelProvider.id == id).first()
if not provider:
raise HTTPException(status_code=404, detail="供应商不存在")
# 找到协议对应的 base_path
protocol_info = next((p for p in PROTOCOLS if p["id"] == provider.protocol), None)
base_path = protocol_info["base_path"] if protocol_info else "/v1"
added = []
for model_id in req.model_ids:
# 在 FETCHABLE_MODELS 中查找额外信息
model_info = next((m for m in FETCHABLE_MODELS if m["id"] == model_id), None)
# 使用 model_id 作为模型名称
model_name = model_info["name"] if model_info else model_id
# 检查是否已存在同名模型
existing = db.query(AIModel).filter(
AIModel.provider_id == id,
AIModel.name == model_name
).first()
if existing:
continue
# 自动生成 endpoint
if provider.api_base_url:
endpoint = f"{provider.api_base_url.rstrip('/')}{base_path}/chat/completions"
else:
endpoint = ""
model = AIModel(
id=str(uuid.uuid4()),
provider_id=id,
name=model_name,
api_key=provider.api_key or "",
endpoint=endpoint,
capabilities=model_info.get("capabilities") if model_info else ["text"],
timeout=60,
status="free" if (model_info and model_info.get("pricing") == "free") else "paid",
price_per_million_tokens=0,
is_enabled=True,
)
db.add(model)
added.append(model_name)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="add_models",
target_type="model_provider",
target_id=id,
detail=f"为供应商 {provider.name} 批量添加 {len(added)} 个模型",
)
return {"message": f"成功添加 {len(added)} 个模型", "added": added}
# ============================================================
# 模型管理
# ============================================================
@router.get("/")
async def get_models(
provider_id: str = Query(None),
status: str = Query(None, description="模型状态筛选: free/paid"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取模型列表(管理员)"""
query = db.query(AIModel)
if provider_id:
query = query.filter(AIModel.provider_id == provider_id)
if status:
query = query.filter(AIModel.status == status)
total = query.count()
models = query.order_by(AIModel.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [model_to_dict(m) for m in models],
}
@router.post("/")
async def create_model(
provider_id: str,
name: str,
api_key: str = None,
endpoint: str = None,
capabilities: List[str] = None,
timeout: int = 60,
status: str = "paid",
price_per_million_tokens: float = 0,
is_enabled: bool = True,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""添加模型"""
# 验证供应商存在
provider = db.query(ModelProvider).filter(ModelProvider.id == provider_id).first()
if not provider:
raise HTTPException(status_code=404, detail="供应商不存在")
model = AIModel(
id=str(uuid.uuid4()),
provider_id=provider_id,
name=name,
api_key=api_key,
endpoint=endpoint,
capabilities=capabilities,
timeout=timeout,
status=status,
price_per_million_tokens=price_per_million_tokens,
is_enabled=is_enabled,
)
db.add(model)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_model",
target_type="model",
target_id=model.id,
detail=f"添加模型 {name}",
)
return {"message": "模型已创建", "id": model.id}
@router.put("/{id}")
async def update_model(
id: str,
provider_id: str = None,
name: str = None,
api_key: str = None,
endpoint: str = None,
capabilities: List[str] = None,
timeout: int = None,
status: str = None,
price_per_million_tokens: float = None,
is_enabled: bool = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新模型"""
model = db.query(AIModel).filter(AIModel.id == id).first()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
if provider_id is not None:
provider = db.query(ModelProvider).filter(ModelProvider.id == provider_id).first()
if not provider:
raise HTTPException(status_code=404, detail="供应商不存在")
model.provider_id = provider_id
if name is not None:
model.name = name
if api_key is not None:
model.api_key = api_key
if endpoint is not None:
model.endpoint = endpoint
if capabilities is not None:
model.capabilities = capabilities
if timeout is not None:
model.timeout = timeout
if status is not None:
model.status = status
if price_per_million_tokens is not None:
model.price_per_million_tokens = price_per_million_tokens
if is_enabled is not None:
model.is_enabled = is_enabled
model.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_model",
target_type="model",
target_id=id,
detail=f"更新模型 {model.name}",
)
return {"message": "模型已更新"}
@router.delete("/{id}")
async def delete_model(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""删除模型"""
model = db.query(AIModel).filter(AIModel.id == id).first()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
model_name = model.name
db.delete(model)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_model",
target_type="model",
target_id=id,
detail=f"删除模型 {model_name}",
)
return {"message": "模型已删除"}
@router.put("/{id}/toggle")
async def toggle_model(
id: str,
is_enabled: bool = Query(...),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""开关模型状态"""
model = db.query(AIModel).filter(AIModel.id == id).first()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
model.is_enabled = is_enabled
model.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="toggle_model",
target_type="model",
target_id=id,
detail=f"将模型 {model.name} {'启用' if is_enabled else '禁用'}",
)
return {"message": f"模型已{'启用' if is_enabled else '禁用'}"}
@router.put("/{id}/set-default")
async def set_default_model(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""设为全局默认模型(取消其他模型的默认状态)"""
model = db.query(AIModel).filter(AIModel.id == id).first()
if not model:
raise HTTPException(status_code=404, detail="模型不存在")
# 取消所有模型的默认状态
db.query(AIModel).filter(AIModel.is_default == True).update({"is_default": False})
# 设置当前模型为默认
model.is_default = True
model.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="set_default_model",
target_type="model",
target_id=id,
detail=f"设置模型 {model.name} 为全局默认",
)
return {"message": f"已将 {model.name} 设为全局默认模型"}
@router.get("/stats")
async def get_model_stats(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取模型调用统计"""
total_calls = db.query(func.sum(AIModel.total_calls)).scalar() or 0
total_tokens = db.query(func.sum(AIModel.total_tokens)).scalar() or 0
return {
"total_calls": int(total_calls),
"total_tokens": int(total_tokens),
}
@@ -0,0 +1,226 @@
"""
支付渠道配置 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from sqlalchemy import func
from pydantic import BaseModel
from typing import Optional
from ...database import get_db, SessionLocal
from ...models import PaymentConfig, Order
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
import csv
import io
from datetime import datetime
router = APIRouter()
class PaymentConfigUpdate(BaseModel):
app_id: Optional[str] = None
merchant_id: Optional[str] = None
api_key: Optional[str] = None
api_v3_key: Optional[str] = None
cert_path: Optional[str] = None
cert_serial_no: Optional[str] = None
alipay_public_key: Optional[str] = None
app_private_key: Optional[str] = None
gateway_url: Optional[str] = None
notify_url: Optional[str] = None
return_url: Optional[str] = None
status: Optional[str] = None
# ============================================================
# 支付配置管理
# ============================================================
@router.get("/configs")
async def get_payment_configs(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取支付配置列表"""
configs = db.query(PaymentConfig).order_by(
PaymentConfig.created_at.desc()
).all()
return [
{
"id": c.id,
"type": c.type,
"app_id": c.app_id,
"merchant_id": c.merchant_id,
"api_key": c.api_key,
"api_v3_key": c.api_v3_key,
"cert_path": c.cert_path,
"cert_serial_no": c.cert_serial_no,
"alipay_public_key": c.alipay_public_key,
"app_private_key": c.app_private_key,
"gateway_url": c.gateway_url,
"notify_url": c.notify_url,
"return_url": c.return_url,
"status": c.status,
"created_at": c.created_at.isoformat() if c.created_at else None,
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
}
for c in configs
]
@router.put("/configs/{id}")
async def update_payment_config(
id: str,
data: PaymentConfigUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新支付配置"""
config = db.query(PaymentConfig).filter(PaymentConfig.id == id).first()
if not config:
raise HTTPException(status_code=404, detail="支付配置不存在")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
if hasattr(config, key):
setattr(config, key, value)
config.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_payment_config",
target_type="payment_config",
target_id=id,
detail=f"更新支付配置 [{config.type}]",
)
return {"code": 0, "message": "支付配置已更新"}
# ============================================================
# 订单管理
# ============================================================
@router.get("/orders")
async def get_orders(
status: str = Query(None, description="订单状态筛选: pending/paid/refunded/cancelled"),
order_type: str = Query(None, description="订单类型筛选: membership/service/app"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取所有订单列表"""
query = db.query(Order)
if status:
query = query.filter(Order.status == status)
if order_type:
query = query.filter(Order.order_type == order_type)
total = query.count()
orders = query.order_by(Order.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": o.id,
"user_id": o.user_id,
"order_type": o.order_type,
"item_id": o.item_id,
"item_name": o.item_name,
"amount": float(o.amount) if o.amount else 0,
"coupon_id": o.coupon_id,
"discount_amount": float(o.discount_amount) if o.discount_amount else 0,
"payment_method": o.payment_method,
"payment_id": o.payment_id,
"status": o.status,
"created_at": o.created_at.isoformat() if o.created_at else None,
"updated_at": o.updated_at.isoformat() if o.updated_at else None,
}
for o in orders
],
}
@router.put("/orders/{id}/refund")
async def refund_order(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""退款操作"""
order = db.query(Order).filter(Order.id == id).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "paid":
raise HTTPException(status_code=400, detail="只能对已支付的订单进行退款")
if order.status == "refunded":
raise HTTPException(status_code=400, detail="订单已退款")
order.status = "refunded"
order.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="refund_order",
target_type="order",
target_id=id,
detail=f"退款订单 {order.id}, 金额 {float(order.amount)}",
)
return {"message": "退款成功"}
@router.get("/orders/export")
async def export_orders(
status: str = Query(None, description="订单状态筛选"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""导出订单(CSV格式)"""
query = db.query(Order)
if status:
query = query.filter(Order.status == status)
orders = query.order_by(Order.created_at.desc()).all()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"订单ID", "用户ID", "订单类型", "商品ID", "商品名称",
"金额", "优惠券ID", "折扣金额", "支付方式", "支付ID",
"状态", "创建时间", "更新时间"
])
for o in orders:
writer.writerow([
o.id, o.user_id, o.order_type, o.item_id, o.item_name,
float(o.amount) if o.amount else 0,
o.coupon_id,
float(o.discount_amount) if o.discount_amount else 0,
o.payment_method, o.payment_id,
o.status,
o.created_at.isoformat() if o.created_at else "",
o.updated_at.isoformat() if o.updated_at else "",
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={
"Content-Disposition": f"attachment; filename=orders_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
},
)
+453
View File
@@ -0,0 +1,453 @@
"""
增值服务管理 API(管理员)
四大类服务: app_custom / platform_proxy / data_custom / private_project
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from pydantic import BaseModel
from typing import Optional, List
from ...database import get_db
from ...models import ValueAddedService, CustomServiceOrder, CustomServiceRequest, MarketApp, User
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
import json
from datetime import datetime
router = APIRouter()
class CreateServiceRequest(BaseModel):
name: str
service_type: str
description: Optional[str] = None
price: float = 0
features: Optional[str] = None
status: str = "active"
assigned_agent_id: Optional[str] = None
class UpdateServiceRequest(BaseModel):
name: Optional[str] = None
price: Optional[float] = None
service_type: Optional[str] = None
description: Optional[str] = None
features: Optional[str] = None
status: Optional[str] = None
assigned_agent_id: Optional[str] = None
# 服务类型映射
SERVICE_TYPE_MAP = {
"app_custom": "应用定制服务",
"platform_proxy": "平台代理申请",
"data_custom": "数据定制服务",
"private_project": "私有化项目服务",
}
# ============================================================
# 增值服务 CRUD
# ============================================================
@router.get("/")
async def get_services(
service_type: str = Query(None, description="服务类型筛选: app_custom/platform_proxy/data_custom/private_project"),
status: str = Query(None, description="状态筛选: active/inactive/deleted"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取增值服务列表"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Admin {admin_user.username} requested services list: type={service_type}, status={status}")
query = db.query(ValueAddedService).filter(ValueAddedService.status != "deleted")
if service_type:
query = query.filter(ValueAddedService.type == service_type)
if status:
query = query.filter(ValueAddedService.status == status)
services = query.order_by(ValueAddedService.created_at.desc()).all()
result = []
for s in services:
result.append({
"id": s.id,
"name": s.name,
"description": s.description,
"price": float(s.price) if s.price else 0,
"type": s.type,
"type_name": SERVICE_TYPE_MAP.get(s.type, s.type),
"features": s.features or [],
"status": s.status,
"assigned_agent_id": s.assigned_agent_id,
"created_at": s.created_at.isoformat() if s.created_at else None,
"updated_at": s.updated_at.isoformat() if s.updated_at else None,
})
return result
@router.post("/")
async def create_service(
req: CreateServiceRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""创建增值服务"""
import logging
logger = logging.getLogger(__name__)
if req.service_type not in SERVICE_TYPE_MAP:
raise HTTPException(status_code=400, detail=f"无效的服务类型,可选值:{', '.join(SERVICE_TYPE_MAP.keys())}")
features_json = None
if req.features:
try:
features_json = json.loads(req.features) if isinstance(req.features, str) else req.features
except (json.JSONDecodeError, TypeError):
features_json = [req.features]
service = ValueAddedService(
id=str(uuid.uuid4()),
name=req.name,
description=req.description,
price=req.price,
type=req.service_type,
features=features_json,
status=req.status,
assigned_agent_id=req.assigned_agent_id,
)
db.add(service)
db.commit()
db.refresh(service)
logger.info(f"Admin {admin_user.username} created service: {service.id} - {req.name}")
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="create_service",
target_type="value_added_service",
target_id=service.id,
detail=f"创建增值服务 {req.name}(类型: {SERVICE_TYPE_MAP.get(req.service_type)})",
)
return {"message": "增值服务已创建", "id": service.id}
@router.put("/{id}")
async def update_service(
id: str,
req: UpdateServiceRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新增值服务"""
import logging
logger = logging.getLogger(__name__)
service = db.query(ValueAddedService).filter(ValueAddedService.id == id).first()
if not service:
raise HTTPException(status_code=404, detail="增值服务不存在")
if req.name is not None:
service.name = req.name
if req.price is not None:
service.price = req.price
if req.service_type is not None:
if req.service_type not in SERVICE_TYPE_MAP:
raise HTTPException(status_code=400, detail=f"无效的服务类型")
service.type = req.service_type
if req.description is not None:
service.description = req.description
if req.features is not None:
try:
service.features = json.loads(req.features) if isinstance(req.features, str) else req.features
except (json.JSONDecodeError, TypeError):
service.features = [req.features]
if req.status is not None:
service.status = req.status
if req.assigned_agent_id is not None:
service.assigned_agent_id = req.assigned_agent_id
service.updated_at = datetime.now()
db.commit()
logger.info(f"Admin {admin_user.username} updated service: {id}")
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_service",
target_type="value_added_service",
target_id=id,
detail=f"更新增值服务 {service.name}",
)
return {"message": "增值服务已更新"}
@router.delete("/{id}")
async def delete_service(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""逻辑删除增值服务(设置为 deleted 状态,保留数据记录)"""
import logging
logger = logging.getLogger(__name__)
service = db.query(ValueAddedService).filter(ValueAddedService.id == id).first()
if not service:
raise HTTPException(status_code=404, detail="增值服务不存在")
if service.status == "deleted":
raise HTTPException(status_code=400, detail="该增值服务已被删除")
logger.info(f"Admin {admin_user.username} soft-deleting service: {id} - {service.name}")
service.status = "deleted"
service.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="delete_service",
target_type="value_added_service",
target_id=id,
detail=f"逻辑删除增值服务 {service.name}",
)
return {"message": "增值服务已删除(逻辑删除)"}
# ============================================================
# 定制服务工单管理 (CustomServiceRequest)
# ============================================================
@router.get("/custom-requests")
async def get_custom_requests(
request_type: str = Query(None, description="工单类型: data_custom/private_project/platform_proxy"),
status: str = Query(None, description="状态: pending/processing/completed/rejected"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取定制服务工单列表"""
import logging
logger = logging.getLogger(__name__)
logger.info(f"Admin {admin_user.username} requested custom requests: type={request_type}, status={status}")
query = db.query(CustomServiceRequest)
if request_type:
query = query.filter(CustomServiceRequest.request_type == request_type)
if status:
query = query.filter(CustomServiceRequest.status == status)
requests = query.order_by(CustomServiceRequest.created_at.desc()).all()
result = []
for r in requests:
# 查询用户名
user = db.query(User).filter(User.id == r.user_id).first()
# 查询服务名
service = db.query(ValueAddedService).filter(ValueAddedService.id == r.service_id).first()
result.append({
"id": r.id,
"user_id": r.user_id,
"username": user.username if user else "未知用户",
"service_id": r.service_id,
"service_name": service.name if service else "未知服务",
"request_type": r.request_type,
"request_type_name": SERVICE_TYPE_MAP.get(r.request_type, r.request_type),
"title": r.title,
"description": r.description,
"requirements": r.requirements or {},
"status": r.status,
"assigned_agent_id": r.assigned_agent_id,
"created_at": r.created_at.isoformat() if r.created_at else None,
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
})
return result
@router.put("/custom-requests/{id}")
async def update_custom_request(
id: str,
status: str = None,
assigned_agent_id: str = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新定制服务工单(分配Agent / 标记完成)"""
import logging
logger = logging.getLogger(__name__)
req = db.query(CustomServiceRequest).filter(CustomServiceRequest.id == id).first()
if not req:
raise HTTPException(status_code=404, detail="工单不存在")
if status is not None:
if status not in ("pending", "processing", "completed", "rejected"):
raise HTTPException(status_code=400, detail="无效的状态值")
req.status = status
if assigned_agent_id is not None:
req.assigned_agent_id = assigned_agent_id
req.updated_at = datetime.now()
db.commit()
logger.info(f"Admin {admin_user.username} updated custom request: {id}, status={status}, agent={assigned_agent_id}")
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_custom_request",
target_type="custom_service_request",
target_id=id,
detail=f"更新工单状态: {status}, 分配Agent: {assigned_agent_id}",
)
return {"message": "工单已更新"}
# ============================================================
# 定制服务订单管理 (CustomServiceOrder - 保留兼容)
# ============================================================
@router.get("/custom-orders")
async def get_custom_orders(
status: str = Query(None, description="状态筛选"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取定制服务订单列表"""
query = db.query(CustomServiceOrder)
if status:
query = query.filter(CustomServiceOrder.status == status)
orders = query.order_by(CustomServiceOrder.created_at.desc()).all()
result = []
for o in orders:
user = db.query(User).filter(User.id == o.user_id).first()
result.append({
"id": o.id,
"user_id": o.user_id,
"username": user.username if user else "未知用户",
"service_id": o.service_id,
"service_type": o.service_type,
"description": o.description,
"status": o.status,
"price": float(o.price) if o.price else 0,
"order_id": o.order_id,
"created_at": o.created_at.isoformat() if o.created_at else None,
"updated_at": o.updated_at.isoformat() if o.updated_at else None,
})
return result
@router.put("/custom-orders/{id}")
async def update_custom_order(
id: str,
status: str = None,
price: float = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新定制服务订单状态"""
import logging
logger = logging.getLogger(__name__)
order = db.query(CustomServiceOrder).filter(CustomServiceOrder.id == id).first()
if not order:
raise HTTPException(status_code=404, detail="定制服务订单不存在")
if status is not None:
order.status = status
if price is not None:
order.price = price
order.updated_at = datetime.now()
db.commit()
logger.info(f"Admin {admin_user.username} updated custom order: {id}, status={status}")
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_custom_order",
target_type="custom_service_order",
target_id=id,
detail=f"更新定制服务订单状态为 {status}",
)
return {"message": "定制服务订单已更新"}
# ============================================================
# Agent 配置(为应用定制服务绑定市场Agent)
# ============================================================
@router.get("/agents")
async def get_available_agents(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取可用的市场Agent列表(用于绑定到应用定制服务)"""
from sqlalchemy import or_
agents = db.query(MarketApp).filter(
MarketApp.type == "agent",
or_(MarketApp.status == "published", MarketApp.status == "active")
).all()
result = []
for a in agents:
result.append({
"id": a.id,
"name": a.name,
"description": a.description,
"publisher_name": a.publisher_name,
})
return result
@router.put("/{id}/agent")
async def assign_agent_to_service(
id: str,
agent_id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""为应用定制服务绑定Agent"""
import logging
logger = logging.getLogger(__name__)
service = db.query(ValueAddedService).filter(ValueAddedService.id == id).first()
if not service:
raise HTTPException(status_code=404, detail="增值服务不存在")
# 验证agent存在
agent = db.query(MarketApp).filter(
MarketApp.id == agent_id,
MarketApp.type == "agent"
).first()
if not agent:
raise HTTPException(status_code=404, detail="Agent不存在")
service.assigned_agent_id = agent_id
service.updated_at = datetime.now()
db.commit()
logger.info(f"Admin {admin_user.username} assigned agent {agent_id} to service {id}")
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="assign_agent",
target_type="value_added_service",
target_id=id,
detail=f"为服务 {service.name} 绑定Agent: {agent.name}",
)
return {"message": "Agent绑定成功", "agent_name": agent.name}
+508
View File
@@ -0,0 +1,508 @@
"""
网站基础设置 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from ...database import get_db, SessionLocal
from ...models import SiteSetting, EmailConfig, SmsConfig, User, AIModel
from ...auth import get_current_admin
from ...middleware.log_service import LogService
import uuid
import smtplib
import hmac
import hashlib
import base64
import urllib.parse
import time as time_module
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from datetime import datetime
from pydantic import BaseModel
from typing import Optional, Dict, Any
import logging
logger = logging.getLogger("aiflow")
router = APIRouter()
# ============================================================
# Pydantic Models
# ============================================================
class EmailConfigUpdate(BaseModel):
smtp_host: Optional[str] = None
smtp_port: Optional[int] = None
username: Optional[str] = None
password: Optional[str] = None
from_address: Optional[str] = None
use_tls: Optional[bool] = None
class SmsConfigUpdate(BaseModel):
provider: Optional[str] = None
api_key: Optional[str] = None
api_secret: Optional[str] = None
sign_name: Optional[str] = None
template_code: Optional[str] = None
# ============================================================
# 网站设置
# ============================================================
@router.get("/site")
async def get_site_settings(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取网站设置(返回所有 key-value 对)"""
settings = db.query(SiteSetting).all()
result = {}
for s in settings:
result[s.key] = s.value
return {"code": 0, "data": result, "message": "ok"}
@router.put("/site")
async def update_site_settings(
settings: dict,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""批量更新网站设置"""
for key, value in settings.items():
existing = db.query(SiteSetting).filter(SiteSetting.key == key).first()
if existing:
existing.value = str(value) if value is not None else None
existing.updated_at = datetime.now()
else:
new_setting = SiteSetting(
id=str(uuid.uuid4()),
key=key,
value=str(value) if value is not None else None,
)
db.add(new_setting)
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_site_settings",
target_type="site_settings",
detail=f"更新网站设置: {list(settings.keys())}",
)
return {"message": "网站设置已更新"}
# ============================================================
# 邮件配置
# ============================================================
@router.get("/email")
async def get_email_config(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取邮件配置"""
config = db.query(EmailConfig).first()
if not config:
return {"code": 0, "data": {
"smtp_host": "",
"smtp_port": 587,
"username": "",
"password": "",
"from_address": "",
"use_tls": True,
}, "message": "ok"}
return {"code": 0, "data": {
"id": config.id,
"smtp_host": config.smtp_host,
"smtp_port": config.smtp_port,
"username": config.username,
"password": config.password,
"from_address": config.from_address,
"use_tls": config.use_tls,
}, "message": "ok"}
@router.put("/email")
async def update_email_config(
req: EmailConfigUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新邮件配置"""
config = db.query(EmailConfig).first()
if not config:
# 必填字段校验:不允许空值
if not req.smtp_host:
raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空")
if not req.username:
raise HTTPException(status_code=400, detail="SMTP用户名不能为空")
if not req.password:
raise HTTPException(status_code=400, detail="SMTP密码不能为空")
if not req.from_address:
raise HTTPException(status_code=400, detail="发件地址不能为空")
config = EmailConfig(
id=str(uuid.uuid4()),
smtp_host=req.smtp_host or "",
smtp_port=req.smtp_port or 587,
username=req.username or "",
password=req.password or "",
from_address=req.from_address or "",
use_tls=req.use_tls if req.use_tls is not None else True,
)
db.add(config)
else:
if req.smtp_host is not None:
if not req.smtp_host.strip():
raise HTTPException(status_code=400, detail="SMTP服务器地址不能为空")
config.smtp_host = req.smtp_host
if req.smtp_port is not None:
config.smtp_port = req.smtp_port
if req.username is not None:
if not req.username.strip():
raise HTTPException(status_code=400, detail="SMTP用户名不能为空")
config.username = req.username
if req.password is not None:
if not req.password.strip():
raise HTTPException(status_code=400, detail="SMTP密码不能为空")
config.password = req.password
if req.from_address is not None:
if not req.from_address.strip():
raise HTTPException(status_code=400, detail="发件地址不能为空")
config.from_address = req.from_address
if req.use_tls is not None:
config.use_tls = req.use_tls
config.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_email_config",
target_type="email_config",
detail="更新邮件配置",
)
return {"code": 0, "data": None, "message": "邮件配置已更新"}
# ============================================================
# 短信配置
# ============================================================
@router.get("/sms")
async def get_sms_config(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取短信配置"""
config = db.query(SmsConfig).first()
if not config:
return {"code": 0, "data": {
"provider": "",
"api_key": "",
"api_secret": "",
"sign_name": "",
"template_code": "",
}, "message": "ok"}
return {"code": 0, "data": {
"id": config.id,
"provider": config.provider,
"api_key": config.api_key,
"api_secret": config.api_secret,
"sign_name": config.sign_name,
"template_code": config.template_code,
}, "message": "ok"}
@router.put("/sms")
async def update_sms_config(
req: SmsConfigUpdate,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新短信配置"""
config = db.query(SmsConfig).first()
if not config:
config = SmsConfig(
id=str(uuid.uuid4()),
provider=req.provider or "",
api_key=req.api_key or "",
api_secret=req.api_secret or "",
sign_name=req.sign_name,
template_code=req.template_code,
)
db.add(config)
else:
if req.provider is not None:
config.provider = req.provider
if req.api_key is not None:
config.api_key = req.api_key
if req.api_secret is not None:
config.api_secret = req.api_secret
if req.sign_name is not None:
config.sign_name = req.sign_name
if req.template_code is not None:
config.template_code = req.template_code
config.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_sms_config",
target_type="sms_config",
detail="更新短信配置",
)
return {"message": "短信配置已更新"}
# ============================================================
# 邮件测试
# ============================================================
@router.post("/email/test")
async def test_email_config(
test_to: str = Query(default=None, description="自定义测试收件地址"),
test_subject: str = Query(default=None, description="自定义测试邮件主题"),
test_body: str = Query(default=None, description="自定义测试邮件正文"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""使用当前配置发送测试邮件;可传参自定义收件人、主题和正文"""
config = db.query(EmailConfig).first()
if not config or not config.smtp_host:
raise HTTPException(status_code=400, detail="邮件配置未设置,请先保存邮件配置")
if not config.username:
raise HTTPException(status_code=400, detail="SMTP用户名未填写,请检查邮件配置")
if not config.password:
raise HTTPException(status_code=400, detail="SMTP密码未填写,请检查邮件配置")
if not config.from_address:
raise HTTPException(status_code=400, detail="发件地址未填写,请检查邮件配置")
# 优先级:传入参数 > 管理员邮箱 > 发件地址
admin = db.query(User).filter(User.id == admin_user.id).first()
recipient = test_to or (admin.email if admin and admin.email else None) or config.from_address
if not recipient:
raise HTTPException(status_code=400, detail="无法获取接收地址: 请填写收件邮箱或设置管理员邮箱")
subject = test_subject or "AIFlow 邮件配置测试"
body = test_body or f"这是一封来自 AIFlow 平台的测试邮件。\n\n发送时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\nSMTP服务器: {config.smtp_host}"
logger.info(f"测试邮件: SMTP={config.smtp_host}:{config.smtp_port}, "
f"from={config.from_address}, to={recipient}")
try:
msg = MIMEMultipart()
msg["From"] = config.from_address
msg["To"] = recipient
msg["Subject"] = subject
msg.attach(MIMEText(body, "plain"))
if config.use_tls:
server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10)
server.starttls()
else:
server = smtplib.SMTP(config.smtp_host, config.smtp_port, timeout=10)
server.login(config.username, config.password)
server.sendmail(config.from_address, recipient, msg.as_string())
server.quit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="test_email_config",
target_type="email_config",
detail=f"测试邮件发送成功 -> {recipient} via {config.smtp_host}",
)
return {"code": 0, "data": {"message": f"测试邮件已发送到 {recipient}", "recipient": recipient}, "message": "ok"}
except smtplib.SMTPAuthenticationError as e:
logger.error(f"SMTP认证失败: {e}")
raise HTTPException(status_code=400, detail=f"SMTP认证失败,请检查用户名和密码: {str(e)}")
except smtplib.SMTPConnectError as e:
logger.error(f"SMTP连接失败: {e}")
raise HTTPException(status_code=400, detail=f"无法连接到SMTP服务器 {config.smtp_host}:{config.smtp_port}: {str(e)}")
except smtplib.SMTPException as e:
logger.error(f"SMTP发送失败: {e}")
raise HTTPException(status_code=500, detail=f"邮件发送失败(SMTP): {str(e)}")
except OSError as e:
logger.error(f"邮件发送网络错误: {e}")
raise HTTPException(status_code=500, detail=f"网络连接错误: {str(e)}")
except BaseException as e:
logger.error(f"测试邮件发送失败(严重): {type(e).__name__}: {e}")
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}")
@router.post("/email/generate-content")
async def generate_email_content(
topic: str = Query(default="测试邮件"),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""AI生成测试邮件内容:调用默认LLM生成邮件主题和正文"""
import httpx as httpx_lib
subjects = [
"【测试】AIFlow邮件服务配置验证",
"AIFlow平台 - 邮件投递测试",
"测试邮件:您的SMTP服务已就绪",
]
bodies = [
f"您好!\n\n这是一封来自 AIFlow 智能自动化平台的测试邮件,用于验证邮件服务配置是否正常。\n\n如果您收到此邮件,说明SMTP邮件服务已配置成功。\n\n此致\nAIFlow 平台团队",
f"Hi,\n\nThis is a test email from AIFlow Platform.\n\nIf you received this, the SMTP service works fine.\n\nBest regards,\nAIFlow Team",
f"【系统通知】\n邮件服务连通性测试通过。\n\n发件服务器配置正确,能够成功投递邮件。\n\n—— AIFlow",
]
# 尝试使用默认LLM生成更好的内容
try:
default_model = db.query(AIModel).filter(
AIModel.is_default == True, AIModel.is_enabled == True
).first()
if default_model and default_model.endpoint:
async with httpx_lib.AsyncClient(timeout=15) as client:
headers = {}
if default_model.api_key:
headers["Authorization"] = f"Bearer {default_model.api_key}"
resp = await client.post(
default_model.endpoint,
json={
"model": default_model.name,
"messages": [
{"role": "system", "content": "你是一个专业的技术文档撰写者。请根据主题生成一封测试邮件的主题和正文。返回JSON格式: {\"subject\": \"邮件主题\", \"body\": \"邮件正文\"}"},
{"role": "user", "content": f"主题: {topic}"},
],
"max_tokens": 400,
},
headers=headers,
)
if resp.status_code == 200:
content = resp.json().get("choices", [{}])[0].get("message", {}).get("content", "")
try:
data = json.loads(content)
if data.get("subject") and data.get("body"):
return {"subject": data["subject"], "body": data["body"], "source": "ai"}
except:
# 尝试解析非JSON格式
lines = content.strip().split("\n")
if len(lines) >= 2:
return {"subject": lines[0].replace("主题:", "").strip(), "body": "\n".join(lines[1:]).replace("正文:", "").strip(), "source": "ai_partial"}
except Exception:
pass
import random
idx = random.randint(0, len(subjects) - 1)
return {"subject": subjects[idx], "body": bodies[idx], "source": "fallback"}
# ============================================================
# 短信测试
# ============================================================
def _aliyun_sign(secret: str, params: dict, method: str = "GET") -> str:
"""阿里云签名机制 (HMAC-SHA1)"""
sorted_keys = sorted(params.keys())
qs = "&".join([
f"{urllib.parse.quote(k, safe='')}={urllib.parse.quote(str(params[k]), safe='')}"
for k in sorted_keys
])
string_to_sign = f"{method}&{urllib.parse.quote('/', safe='')}&{urllib.parse.quote(qs, safe='')}"
key = f"{secret}&"
h = hmac.new(key.encode('utf-8'), string_to_sign.encode('utf-8'), hashlib.sha1)
return base64.b64encode(h.digest()).decode('utf-8')
@router.post("/sms/test")
async def test_sms_config(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""使用当前配置发送测试短信"""
config = db.query(SmsConfig).first()
if not config:
raise HTTPException(status_code=400, detail="短信配置未设置,请先保存短信配置")
if not config.api_key or not config.api_secret:
raise HTTPException(
status_code=400,
detail="未配置阿里云短信 AccessKey,请填写 API Key (AccessKey ID) 和 API Secret (AccessKey Secret)"
)
if not config.sign_name:
raise HTTPException(status_code=400, detail="未配置短信签名")
# 获取接收测试短信的手机号(管理员手机号)
admin = db.query(User).filter(User.id == admin_user.id).first()
phone = admin.phone if admin and admin.phone else None
if not phone:
raise HTTPException(status_code=400, detail="管理员未设置手机号,无法发送测试短信")
logger.info(f"测试短信: provider={config.provider}, sign={config.sign_name}, phone={phone}")
# 只有阿里云短信支持测试
if "阿里云" not in (config.provider or ""):
raise HTTPException(
status_code=400,
detail=f"当前短信服务商 '{config.provider}' 暂不支持在线测试,请直接在生产环境测试"
)
try:
# 阿里云短信参数
params = {
"AccessKeyId": config.api_key,
"Action": "SendSms",
"Format": "JSON",
"PhoneNumbers": phone,
"RegionId": "cn-hangzhou",
"SignName": config.sign_name,
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(uuid.uuid4()),
"SignatureVersion": "1.0",
"TemplateCode": config.template_code or "SMS_00000001",
"TemplateParam": '{"code":"123456"}',
"Timestamp": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"),
"Version": "2017-05-25",
}
params["Signature"] = _aliyun_sign(config.api_secret, params)
import requests as http_requests
resp = http_requests.post(
"https://dysmsapi.aliyuncs.com/",
data=params,
headers={"Content-Type": "application/x-www-form-urlencoded"},
timeout=10,
)
result = resp.json()
logger.info(f"阿里云短信API响应: {result}")
if result.get("Code") == "OK":
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="test_sms_config",
target_type="sms_config",
detail=f"测试短信发送成功 -> {phone}",
)
return {"message": f"测试短信已发送到 {phone}", "phone": phone, "biz_id": result.get("BizId")}
else:
error_msg = result.get("Message", result.get("Code", "未知错误"))
raise HTTPException(status_code=400, detail=f"阿里云短信发送失败: {error_msg}")
except HTTPException:
raise
except Exception as e:
logger.error(f"测试短信发送失败: {e}")
raise HTTPException(status_code=500, detail=f"短信发送失败: {str(e)}")
+334
View File
@@ -0,0 +1,334 @@
"""
用户管理 API(管理员)
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import or_
from pydantic import BaseModel, Field
from typing import Optional
from ...database import get_db, SessionLocal
from ...models import User, RegisterSetting
from ...auth import get_current_admin, hash_password, verify_password
from ...middleware.log_service import LogService
from datetime import datetime
class AdminChangePasswordRequest(BaseModel):
old_password: str
new_password: str = Field(min_length=6, max_length=100)
class ResetUserPasswordRequest(BaseModel):
new_password: str = Field(min_length=6, max_length=100)
import uuid
from datetime import datetime
router = APIRouter()
@router.get("/")
async def get_users(
status: str = Query(None, description="用户状态筛选: active/banned"),
membership_level: int = Query(None, description="会员等级筛选"),
search: str = Query(None, description="搜索用户名或邮箱"),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取用户列表(管理员)"""
query = db.query(User)
if status:
query = query.filter(User.status == status)
if membership_level is not None:
query = query.filter(User.membership_level == membership_level)
if search:
query = query.filter(
or_(
User.username.ilike(f"%{search}%"),
User.email.ilike(f"%{search}%"),
)
)
total = query.count()
users = query.order_by(User.created_at.desc()).offset(
(page - 1) * page_size
).limit(page_size).all()
return {
"total": total,
"page": page,
"page_size": page_size,
"items": [
{
"id": u.id,
"username": u.username,
"email": u.email,
"phone": u.phone,
"role": u.role,
"status": u.status,
"real_name_status": u.real_name_status,
"real_name": u.real_name,
"membership_level": u.membership_level,
"membership_expire_at": u.membership_expire_at.isoformat() if u.membership_expire_at else None,
"auto_renew": u.auto_renew,
"created_at": u.created_at.isoformat() if u.created_at else None,
"updated_at": u.updated_at.isoformat() if u.updated_at else None,
}
for u in users
],
}
# ============================================================
# 静态路由 - 必须放在 /{id} 动态路由之前
# ============================================================
@router.get("/register-settings")
async def get_register_settings(
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取注册设置"""
setting = db.query(RegisterSetting).first()
if not setting:
return {
"phone_required": True,
"phone_sms_required": True,
"email_required": True,
"email_verify_required": True,
"real_name_required": False,
"aliyun_access_key": "",
"aliyun_endpoint": "https://dysmsapi.aliyuncs.com",
"sms_sign_name": "",
"sms_template_code": "",
}
return {
"id": setting.id,
"phone_required": setting.phone_required,
"phone_sms_required": setting.phone_sms_required,
"email_required": setting.email_required,
"email_verify_required": setting.email_verify_required,
"real_name_required": setting.real_name_required,
"aliyun_access_key": setting.aliyun_access_key,
"aliyun_endpoint": setting.aliyun_endpoint,
"sms_sign_name": getattr(setting, 'sms_sign_name', ''),
"sms_template_code": getattr(setting, 'sms_template_code', ''),
}
@router.put("/register-settings")
async def update_register_settings(
phone_required: bool = None,
phone_sms_required: bool = None,
email_required: bool = None,
email_verify_required: bool = None,
real_name_required: bool = None,
aliyun_access_key: str = None,
aliyun_secret_key: str = None,
aliyun_endpoint: str = None,
sms_sign_name: str = None,
sms_template_code: str = None,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""更新注册设置"""
setting = db.query(RegisterSetting).first()
if not setting:
setting = RegisterSetting(
id=str(uuid.uuid4()),
phone_required=phone_required if phone_required is not None else True,
phone_sms_required=phone_sms_required if phone_sms_required is not None else True,
email_required=email_required if email_required is not None else True,
email_verify_required=email_verify_required if email_verify_required is not None else True,
real_name_required=real_name_required or False,
aliyun_access_key=aliyun_access_key,
aliyun_secret_key=aliyun_secret_key,
aliyun_endpoint=aliyun_endpoint,
sms_sign_name=sms_sign_name,
sms_template_code=sms_template_code,
)
db.add(setting)
else:
if phone_required is not None:
setting.phone_required = phone_required
if phone_sms_required is not None:
setting.phone_sms_required = phone_sms_required
if email_required is not None:
setting.email_required = email_required
if email_verify_required is not None:
setting.email_verify_required = email_verify_required
if real_name_required is not None:
setting.real_name_required = real_name_required
if aliyun_access_key is not None:
setting.aliyun_access_key = aliyun_access_key
if aliyun_secret_key is not None:
setting.aliyun_secret_key = aliyun_secret_key
if aliyun_endpoint is not None:
setting.aliyun_endpoint = aliyun_endpoint
if sms_sign_name is not None:
setting.sms_sign_name = sms_sign_name
if sms_template_code is not None:
setting.sms_template_code = sms_template_code
setting.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="update_register_settings",
target_type="register_setting",
detail="更新注册设置",
)
return {"message": "注册设置已更新"}
# ============================================================
# 动态路由
# ============================================================
@router.get("/{id}")
async def get_user_detail(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""获取用户详情"""
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
return {
"id": user.id,
"username": user.username,
"email": user.email,
"phone": user.phone,
"avatar": user.avatar,
"role": user.role,
"status": user.status,
"real_name_status": user.real_name_status,
"real_name": user.real_name,
"id_card": user.id_card,
"membership_plan_id": user.membership_plan_id,
"membership_level": user.membership_level,
"membership_expire_at": user.membership_expire_at.isoformat() if user.membership_expire_at else None,
"auto_renew": user.auto_renew,
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None,
}
@router.put("/{id}/ban")
async def ban_user(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""封禁用户"""
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.role == "admin":
raise HTTPException(status_code=400, detail="不能封禁管理员账号")
user.status = "banned"
user.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="ban_user",
target_type="user",
target_id=id,
detail=f"封禁用户 {user.username}",
)
return {"message": "用户已封禁"}
@router.put("/{id}/unban")
async def unban_user(
id: str,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""解封用户"""
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.status = "active"
user.updated_at = datetime.now()
db.commit()
LogService.log_audit(
admin_id=admin_user.id,
admin_name=admin_user.username,
action="unban_user",
target_type="user",
target_id=id,
detail=f"解封用户 {user.username}",
)
return {"message": "用户已解封"}
# ============================================================
# 密码管理
# ============================================================
@router.put("/change-password")
def admin_change_password(
req: AdminChangePasswordRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""管理员修改自己的密码"""
db_user = db.query(User).filter(User.id == admin_user.id).first()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
if not verify_password(req.old_password, db_user.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
db_user.password_hash = hash_password(req.new_password)
db_user.updated_at = datetime.now()
LogService.log_operation(
user_id=admin_user.id, username=admin_user.username, action="admin_change_password",
target="user", target_id=admin_user.id,
detail="管理员修改了自己的密码",
)
db.commit()
return {"code": 0, "message": "密码修改成功"}
@router.put("/{id}/reset-password")
def admin_reset_user_password(
id: str,
req: ResetUserPasswordRequest,
admin_user=Depends(get_current_admin),
db: Session = Depends(get_db),
):
"""管理员重置用户密码"""
user = db.query(User).filter(User.id == id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
user.password_hash = hash_password(req.new_password)
user.updated_at = datetime.now()
LogService.log_operation(
user_id=admin_user.id, username=admin_user.username, action="reset_user_password",
target="user", target_id=id,
detail=f"管理员重置了用户 {user.username} 的密码",
)
db.commit()
return {"code": 0, "message": "密码已重置"}
+379
View File
@@ -0,0 +1,379 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime
from ..database import get_db
from ..models import (
User, Asset, AssetCategory, NovelChapter, KnowledgeDocument,
KnowledgeBase,
gen_uuid
)
from ..auth import get_current_user
from ..middleware.log_service import LogService
router = APIRouter()
class CreateAssetRequest(BaseModel):
name: str = Field(min_length=1, max_length=200)
category: Optional[str] = None
description: Optional[str] = None
file_type: Optional[str] = None
content: Optional[str] = None
class UpdateAssetRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
file_type: Optional[str] = None
class CreateChapterRequest(BaseModel):
title: str = Field(min_length=1, max_length=500)
content: Optional[str] = None
sort_order: int = Field(default=0)
VALID_ASSET_CATEGORIES = ("file", "novel", "image", "video", "audio", "custom")
# ============================================================
# 静态路由 - 必须在动态路由之前定义
# ============================================================
@router.get("/storage-overview")
def get_storage_overview(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户资产存储概览,包含资产和知识库文档的总存储量"""
# Asset表中的文件大小
from sqlalchemy import func as sa_func
asset_size = db.query(sa_func.sum(Asset.size)).filter(
and_(
Asset.user_id == current_user.id,
Asset.status != "deleted",
)
).scalar() or 0
# 知识库文档的文件大小
kb_doc_size = db.query(sa_func.sum(KnowledgeDocument.file_size)).join(
KnowledgeBase, KnowledgeDocument.knowledge_base_id == KnowledgeBase.id
).filter(
KnowledgeBase.user_id == current_user.id
).scalar() or 0
total_storage = asset_size + kb_doc_size
return {
"code": 0,
"data": {
"asset_storage": asset_size,
"knowledge_doc_storage": kb_doc_size,
"total_storage": total_storage,
},
"message": "ok",
}
@router.get("/categories")
def list_categories(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
categories = (
db.query(AssetCategory)
.filter(AssetCategory.user_id == current_user.id)
.order_by(asc(AssetCategory.name))
.all()
)
result = []
for c in categories:
count = db.query(Asset).filter(
and_(
Asset.category == c.name,
Asset.user_id == current_user.id,
Asset.status != "deleted",
)
).count()
result.append({
"id": c.id,
"name": c.name,
"type": c.type,
"asset_count": count,
"created_at": str(c.created_at) if c.created_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.get("/novels/{asset_id}/chapters")
def get_novel_chapters(
asset_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
asset = db.query(Asset).filter(
and_(Asset.id == asset_id, Asset.user_id == current_user.id)
).first()
if not asset:
raise HTTPException(status_code=404, detail="资产不存在")
chapters = (
db.query(NovelChapter)
.filter(NovelChapter.asset_id == asset_id)
.order_by(asc(NovelChapter.sort_order), asc(NovelChapter.created_at))
.all()
)
result = []
for ch in chapters:
result.append({
"id": ch.id,
"asset_id": ch.asset_id,
"title": ch.title,
"sort_order": ch.sort_order,
"word_count": ch.word_count or 0,
"created_at": str(ch.created_at) if ch.created_at else None,
"updated_at": str(ch.updated_at) if ch.updated_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/novels/{asset_id}/chapters")
def add_novel_chapter(
asset_id: str,
req: CreateChapterRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
asset = db.query(Asset).filter(
and_(Asset.id == asset_id, Asset.user_id == current_user.id)
).first()
if not asset:
raise HTTPException(status_code=404, detail="资产不存在")
chapter = NovelChapter(
id=gen_uuid(),
asset_id=asset_id,
title=req.title,
content=req.content,
sort_order=req.sort_order,
word_count=len(req.content or ""),
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(chapter)
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="add_chapter",
target="novel_chapter", target_id=chapter.id,
detail=f"为资产 {asset.name} 添加章节 {chapter.title}",
)
db.commit()
db.refresh(chapter)
return {
"code": 0,
"data": {
"id": chapter.id, "asset_id": chapter.asset_id, "title": chapter.title,
"sort_order": chapter.sort_order, "word_count": chapter.word_count,
"created_at": str(chapter.created_at),
},
"message": "章节添加成功",
}
# ============================================================
# 动态路由 - {asset_id} 必须放在静态路由之后
# ============================================================
@router.get("/")
def list_assets(
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(Asset).filter(
and_(
Asset.user_id == current_user.id,
Asset.status != "deleted",
)
)
if category:
query = query.filter(Asset.category == category)
if search:
query = query.filter(Asset.name.ilike(f"%{search}%"))
total = query.count()
assets = (
query.order_by(desc(Asset.updated_at))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
result = []
for a in assets:
result.append({
"id": a.id,
"name": a.name,
"category": a.category,
"file_type": a.file_type,
"size": a.size or 0,
"description": a.description,
"url": a.url or "",
"content": a.content or "",
"status": a.status,
"created_at": str(a.created_at) if a.created_at else None,
"updated_at": str(a.updated_at) if a.updated_at else None,
})
return {
"code": 0,
"data": {"total": total, "page": page, "page_size": page_size, "items": result},
"message": "ok",
}
@router.post("/")
def create_asset(
req: CreateAssetRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
cat = req.category if req.category in VALID_ASSET_CATEGORIES else "file"
asset = Asset(
id=gen_uuid(),
user_id=current_user.id,
name=req.name,
category=cat,
description=req.description or (req.content[:100] if req.content else None),
file_type=req.file_type or "unknown",
size=len(req.content.encode('utf-8')) if req.content else 0,
content=req.content,
status="active",
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(asset)
db.commit()
db.refresh(asset)
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="create",
target="asset", target_id=asset.id,
detail=f"创建资产 {asset.name}",
)
return {
"code": 0,
"data": {
"id": asset.id, "name": asset.name, "category": asset.category,
"description": asset.description, "status": asset.status,
"created_at": str(asset.created_at),
},
"message": "创建成功",
}
@router.get("/{asset_id}")
def get_asset(
asset_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
asset = db.query(Asset).filter(
and_(Asset.id == asset_id, Asset.user_id == current_user.id)
).first()
if not asset:
raise HTTPException(status_code=404, detail="资产不存在")
return {
"code": 0,
"data": {
"id": asset.id, "name": asset.name, "category": asset.category,
"description": asset.description, "file_type": asset.file_type,
"size": asset.size or 0,
"url": asset.url or "",
"content": asset.content or "",
"status": asset.status,
"created_at": str(asset.created_at), "updated_at": str(asset.updated_at),
},
"message": "ok",
}
@router.put("/{asset_id}")
def update_asset(
asset_id: str,
req: UpdateAssetRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
asset = db.query(Asset).filter(
and_(Asset.id == asset_id, Asset.user_id == current_user.id)
).first()
if not asset:
raise HTTPException(status_code=404, detail="资产不存在")
if req.name is not None:
asset.name = req.name
if req.description is not None:
asset.description = req.description
if req.file_type is not None:
asset.file_type = req.file_type
asset.updated_at = datetime.now()
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="update",
target="asset", target_id=asset.id,
detail=f"更新资产 {asset.name}",
)
db.commit()
db.refresh(asset)
return {"code": 0, "data": {
"id": asset.id, "name": asset.name, "status": asset.status,
"updated_at": str(asset.updated_at),
}, "message": "更新成功"}
@router.delete("/{asset_id}")
def delete_asset(
asset_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
asset = db.query(Asset).filter(
and_(Asset.id == asset_id, Asset.user_id == current_user.id)
).first()
if not asset:
raise HTTPException(status_code=404, detail="资产不存在")
# 软删除
asset.status = "deleted"
asset.updated_at = datetime.now()
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="delete",
target="asset", target_id=asset.id,
detail=f"删除资产 {asset.name}",
)
db.commit()
return {"code": 0, "message": "资产已删除"}
+231
View File
@@ -0,0 +1,231 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime
from ..database import get_db
from ..models import (
User, LoginLog, RegisterSetting,
gen_uuid
)
from ..auth import get_current_user, hash_password, verify_password, create_access_token
from ..middleware.log_service import LogService
router = APIRouter()
class LoginRequest(BaseModel):
username: str
password: str
class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=50)
password: str = Field(min_length=6, max_length=100)
email: Optional[str] = None
phone: Optional[str] = None
@router.post("/login")
def login(req: LoginRequest, db: Session = Depends(get_db)):
import logging
logger = logging.getLogger(__name__)
user = db.query(User).filter(User.username == req.username).first()
if not user:
logger.warning(f"登录失败: 用户 '{req.username}' 不存在")
raise HTTPException(status_code=401, detail="用户名不存在")
stored_hash = user.password_hash
logger.info(f"登录调试: username={req.username} hash_prefix={stored_hash[:7] if stored_hash else 'None'} hash_len={len(stored_hash) if stored_hash else 0}")
if not verify_password(req.password, user.password_hash):
logger.warning(f"登录失败: 用户 '{req.username}' 密码验证失败 hash_prefix={stored_hash[:7] if stored_hash else 'None'} hash_len={len(stored_hash) if stored_hash else 0}")
raise HTTPException(status_code=401, detail="密码错误")
if user.status == "banned":
raise HTTPException(status_code=403, detail="账号已被封禁")
token_data = {"sub": user.id, "username": user.username, "role": user.role}
access_token = create_access_token(token_data)
# 记录登录日志
login_log = LoginLog(
id=gen_uuid(),
user_id=user.id,
ip="",
login_at=datetime.now(),
)
db.add(login_log)
# 记录操作日志
LogService.log_operation(
user_id=user.id,
username=user.username,
action="login",
target="user",
detail=f"用户 {user.username} 登录成功",
)
db.commit()
return {
"code": 0,
"data": {
"access_token": access_token,
"token_type": "bearer",
"user": {
"id": user.id,
"username": user.username,
"email": user.email,
"phone": user.phone,
"avatar": user.avatar,
"role": user.role,
"status": user.status,
"membership_level": user.membership_level,
"membership_expire_at": str(user.membership_expire_at) if user.membership_expire_at else None,
"created_at": str(user.created_at) if user.created_at else None,
},
},
"message": "登录成功",
}
@router.post("/register")
def register(req: RegisterRequest, db: Session = Depends(get_db)):
# 检查注册设置
reg_setting = db.query(RegisterSetting).first()
if reg_setting and reg_setting.phone_required and not req.phone:
raise HTTPException(status_code=400, detail="手机号为必填项")
if reg_setting and reg_setting.email_required and not req.email:
raise HTTPException(status_code=400, detail="邮箱为必填项")
# 检查用户名是否已存在
existing = db.query(User).filter(User.username == req.username).first()
if existing:
raise HTTPException(status_code=400, detail="用户名已存在")
if req.email:
existing_email = db.query(User).filter(User.email == req.email).first()
if existing_email:
raise HTTPException(status_code=400, detail="邮箱已被注册")
if req.phone:
existing_phone = db.query(User).filter(User.phone == req.phone).first()
if existing_phone:
raise HTTPException(status_code=400, detail="手机号已被注册")
user = User(
id=gen_uuid(),
username=req.username,
password_hash=hash_password(req.password),
email=req.email,
phone=req.phone,
avatar=None,
role="user",
status="active",
real_name_status="unverified",
membership_level=0,
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(user)
db.flush()
# 创建Token余额记录
from ..models import TokenBalance
balance = TokenBalance(
id=gen_uuid(),
user_id=user.id,
total_consumed=0,
balance=0,
daily_free_used=0,
)
db.add(balance)
token_data = {"sub": user.id, "username": user.username, "role": user.role}
access_token = create_access_token(token_data)
LogService.log_operation(
user_id=user.id,
username=user.username,
action="register",
target="user",
detail=f"新用户 {user.username} 注册成功",
)
db.commit()
return {
"code": 0,
"data": {
"access_token": access_token,
"token_type": "bearer",
"user": {
"id": user.id,
"username": user.username,
"email": user.email or "",
"phone": user.phone or "",
"avatar": user.avatar or "",
"role": user.role,
"status": user.status,
"membership_level": user.membership_level,
"created_at": str(user.created_at) if user.created_at else None,
},
},
"message": "注册成功",
}
@router.get("/me")
def get_me(current_user: User = Depends(get_current_user)):
return {
"code": 0,
"data": {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email or "",
"phone": current_user.phone or "",
"avatar": current_user.avatar or "",
"role": current_user.role,
"status": current_user.status,
"real_name_status": current_user.real_name_status,
"membership_level": current_user.membership_level,
"membership_expire_at": str(current_user.membership_expire_at) if current_user.membership_expire_at else None,
"auto_renew": current_user.auto_renew,
"created_at": str(current_user.created_at) if current_user.created_at else None,
},
"message": "ok",
}
@router.post("/logout")
def logout(current_user: User = Depends(get_current_user)):
LogService.log_operation(
user_id=current_user.id,
username=current_user.username,
action="logout",
target="user",
detail=f"用户 {current_user.username} 登出",
)
return {"code": 0, "message": "登出成功"}
@router.get("/settings")
def get_settings(db: Session = Depends(get_db)):
settings = db.query(RegisterSetting).first()
if not settings:
return {"code": 0, "data": {}, "message": "ok"}
return {
"code": 0,
"data": {
"phone_required": settings.phone_required,
"phone_sms_required": settings.phone_sms_required,
"email_required": settings.email_required,
"email_verify_required": settings.email_verify_required,
"real_name_required": settings.real_name_required,
},
"message": "ok",
}
File diff suppressed because it is too large. Load diff
+781
View File
@@ -0,0 +1,781 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime
import re
import random
import json
import httpx
import os
import logging
from ..database import get_db, SessionLocal
from ..models import (
User, Conversation, Message, AIModel, ModelProvider, TokenUsageLog,
KnowledgeBase, KnowledgeDocument, EvolutionSkill,
EvolutionTrajectory,
gen_uuid
)
from ..auth import get_current_user
from ..middleware.log_service import LogService
router = APIRouter()
logger = logging.getLogger(__name__)
class CreateConversationRequest(BaseModel):
title: str = Field(default="新对话")
model_id: Optional[str] = None
class UpdateConversationRequest(BaseModel):
title: Optional[str] = None
status: Optional[str] = None # active, archived
pinned: Optional[bool] = None
class SendMessageRequest(BaseModel):
content: str
model_id: Optional[str] = None
skill_ids: Optional[list] = None
agent_ids: Optional[list] = None
knowledge_base_ids: Optional[list] = None
use_tools: Optional[bool] = False
@router.get("/conversations")
def list_conversations(
status: Optional[str] = Query(None),
search: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(Conversation).filter(Conversation.user_id == current_user.id)
if status:
query = query.filter(Conversation.status == status)
else:
# 默认不返回已归档的
query = query.filter(Conversation.status != "archived")
if search:
query = query.filter(Conversation.title.ilike(f"%{search}%"))
# pinned优先,然后按更新时间倒序
query = query.order_by(desc(Conversation.pinned), desc(Conversation.updated_at))
conversations = query.all()
result = []
for conv in conversations:
result.append({
"id": conv.id,
"title": conv.title,
"model_id": conv.model_id,
"status": conv.status,
"pinned": conv.pinned,
"knowledge_graph_count": conv.knowledge_graph_count or 0,
"created_at": str(conv.created_at) if conv.created_at else None,
"updated_at": str(conv.updated_at) if conv.updated_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/conversations")
def create_conversation(
req: CreateConversationRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = Conversation(
id=gen_uuid(),
user_id=current_user.id,
title=req.title,
model_id=req.model_id,
status="active",
pinned=False,
knowledge_graph_count=0,
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(conv)
db.commit()
db.refresh(conv)
return {
"code": 0,
"data": {
"id": conv.id,
"title": conv.title,
"model_id": conv.model_id,
"status": conv.status,
"pinned": conv.pinned,
"knowledge_graph_count": conv.knowledge_graph_count,
"created_at": str(conv.created_at) if conv.created_at else None,
"updated_at": str(conv.updated_at) if conv.updated_at else None,
},
"message": "创建成功",
}
@router.put("/conversations/{conv_id}")
def update_conversation(
conv_id: str,
req: UpdateConversationRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
if req.title is not None:
conv.title = req.title
if req.status is not None:
conv.status = req.status
if req.pinned is not None:
conv.pinned = req.pinned
conv.updated_at = datetime.now()
db.commit()
db.refresh(conv)
return {"code": 0, "data": {
"id": conv.id, "title": conv.title, "status": conv.status, "pinned": conv.pinned,
"updated_at": str(conv.updated_at),
}, "message": "更新成功"}
@router.delete("/conversations/{conv_id}")
def delete_conversation(
conv_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
# 软删除
conv.status = "archived"
conv.updated_at = datetime.now()
db.commit()
return {"code": 0, "message": "删除成功"}
@router.get("/conversations/{conv_id}/messages")
def get_messages(
conv_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
messages = (
db.query(Message)
.filter(Message.conversation_id == conv_id)
.order_by(asc(Message.created_at))
.all()
)
result = []
for msg in messages:
result.append({
"id": msg.id,
"conversation_id": msg.conversation_id,
"role": msg.role,
"content": msg.content,
"token_used": msg.token_used or 0,
"model_id": msg.model_id,
"used_skills": msg.used_skills or [],
"created_at": str(msg.created_at) if msg.created_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/conversations/{conv_id}/messages")
async def send_message(
conv_id: str,
req: SendMessageRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
# 保存用户消息
user_msg = Message(
id=gen_uuid(),
conversation_id=conv_id,
role="user",
content=req.content,
token_used=len(req.content) // 2, # 粗略估算
model_id=req.model_id or conv.model_id,
created_at=datetime.now(),
)
db.add(user_msg)
# 调用 AI 模型 API
user_id = current_user.id
model_id = req.model_id or (conv.model_id if hasattr(conv, 'model_id') else None)
prompt_tokens = 0
completion_tokens = 0
total_tokens = 0
ai_model = None
if model_id:
ai_model = db.query(AIModel).filter(AIModel.id == model_id).first()
if ai_model and ai_model.endpoint:
provider = db.query(ModelProvider).filter(ModelProvider.id == ai_model.provider_id).first()
api_key = ai_model.api_key or (provider.api_key if provider else None)
api_url = ai_model.endpoint
if not api_url and provider and provider.api_base_url:
api_url = f"{provider.api_base_url.rstrip('/')}/v1/chat/completions"
if api_key and api_url:
try:
# 构建消息列表:先system prompt(含skills/agents上下文),再历史消息,再用户消息
messages = []
system_parts = ["你是AIFlow平台的AI助手。"]
# 加载skills信息用于tool-call
skills_list = []
function_definitions = []
if req.skill_ids and len(req.skill_ids) > 0:
skills_list = db.query(EvolutionSkill).filter(EvolutionSkill.id.in_(req.skill_ids)).all()
if skills_list:
skill_descs = ", ".join([f"{s.skill_name}({s.category or 'general'})" for s in skills_list])
system_parts.append(f"可用技能: {skill_descs}")
system_parts.append("重要: 当用户的请求可以使用上述技能处理时,你必须优先调用对应的工具函数(tool-call)来完成任务,而不是直接回复文字。")
# 构建function definitions用于tool-call (OpenAI function calling协议)
if req.use_tools:
for s in skills_list:
func_name = s.skill_name.replace(" ", "_").replace("-", "_").lower()
_desc = s.description or f"{s.skill_name} - {s.category or 'general'}"
function_definitions.append({
"name": func_name,
"description": f"调用技能「{s.skill_name}」处理用户请求。使用场景: {_desc}。当用户请求涉及{s.category or '通用'}类任务时优先调用此工具。",
"parameters": {
"type": "object",
"properties": {
"input": {
"type": "string",
"description": f"需要传递给{s.skill_name}的完整用户输入内容",
}
},
"required": ["input"],
},
})
if req.agent_ids and len(req.agent_ids) > 0:
system_parts.append(f"已选择Agent辅助处理任务")
if req.knowledge_base_ids and len(req.knowledge_base_ids) > 0:
kbs = db.query(KnowledgeBase).filter(KnowledgeBase.id.in_(req.knowledge_base_ids)).all()
if kbs:
kb_names = ", ".join([k.name for k in kbs])
system_parts.append(f"参考知识库: {kb_names}")
messages.append({"role": "system", "content": "; ".join(system_parts)})
# 加载最近的对话历史(最多10条)
history = db.query(Message).filter(
Message.conversation_id == conv_id
).order_by(asc(Message.created_at)).limit(10).all()
for h in history:
messages.append({"role": h.role, "content": h.content})
messages.append({"role": "user", "content": req.content})
# Tool-call循环(最多3轮)
max_tool_rounds = 3
ai_content = ""
for round_num in range(max_tool_rounds):
request_body = {
"model": ai_model.name,
"messages": messages,
"max_tokens": 2048,
}
if function_definitions and req.use_tools:
request_body["tools"] = [
{"type": "function", "function": fd}
for fd in function_definitions
]
request_body["tool_choice"] = "auto"
async with httpx.AsyncClient(timeout=ai_model.timeout or 60) as client:
resp = await client.post(
api_url,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json"
},
json=request_body
)
if resp.status_code != 200:
if round_num == 0:
ai_content = f"AI模型调用失败 (HTTP {resp.status_code}),请检查模型配置。"
break
data = resp.json()
choice = data.get("choices", [{}])[0]
message = choice.get("message", {})
# 记录token使用量
usage = data.get("usage", {})
prompt_tokens += usage.get("prompt_tokens", 0)
completion_tokens += usage.get("completion_tokens", 0)
total_tokens += usage.get("total_tokens", 0)
# 检查是否有tool_calls
tool_calls = message.get("tool_calls", [])
if tool_calls and req.use_tools:
# 处理tool call:将assistant消息(含tool_calls)加入上下文
messages.append({
"role": "assistant",
"content": message.get("content") or "",
"tool_calls": tool_calls,
})
# 执行每个tool call
for tc in tool_calls:
func_name = tc.get("function", {}).get("name", "")
func_args_str = tc.get("function", {}).get("arguments", "{}")
try:
func_args = json.loads(func_args_str)
except Exception:
func_args = {"input": func_args_str}
# 查找匹配的skill并生成tool结果
tool_result = _execute_skill_tool(db, func_name, func_args, skills_list)
messages.append({
"role": "tool",
"tool_call_id": tc.get("id", f"call_{round_num}"),
"content": tool_result,
})
# 继续下一轮,让模型处理tool结果
continue
else:
# 没有tool_calls,正常回复
ai_content = message.get("content", "")
if not ai_content and round_num > 0:
ai_content = "AI模型已处理工具调用结果,但返回了空响应。"
break
if not ai_content:
ai_content = "AI模型返回了空响应,请稍后重试。"
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"AI API调用异常: {e}")
ai_content = "AI服务暂时不可用,请稍后重试或检查模型API配置。"
else:
ai_content = "该模型缺少API密钥或端点配置,请在后台管理-大模型库中配置API Key和Endpoint。"
else:
ai_content = "请选择一个已配置的AI模型进行对话。"
else:
ai_content = "请先在聊天设置中选择一个AI模型。"
ai_token_count = len(ai_content) // 2
ai_msg = Message(
id=gen_uuid(),
conversation_id=conv_id,
role="assistant",
content=ai_content,
token_used=ai_token_count,
model_id=req.model_id or conv.model_id,
created_at=datetime.now(),
)
db.add(ai_msg)
# 基于请求中skill_ids关联用户真实技能作为used_skills
used_skills = []
if req.skill_ids and len(req.skill_ids) > 0:
selected_skills = db.query(EvolutionSkill).filter(
EvolutionSkill.id.in_(req.skill_ids),
EvolutionSkill.user_id == current_user.id
).all()
if selected_skills:
used_skills = [s.skill_name for s in selected_skills]
ai_msg.used_skills = used_skills
# 更新每个被使用技能的use_count和last_used_at
now = datetime.now()
for s in selected_skills:
s.use_count = (s.use_count or 0) + 1
s.last_used_at = now
# 更新对话统计
conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1
conv.updated_at = datetime.now()
# 记录Token使用
token_usage = TokenUsageLog(
id=gen_uuid(),
user_id=current_user.id,
model_id=req.model_id or conv.model_id,
model_name=ai_model.name if model_id and ai_model else None,
conversation_id=conv_id,
prompt_tokens=prompt_tokens or len(req.content) // 2,
completion_tokens=completion_tokens or ai_token_count,
total_tokens=total_tokens or (len(req.content) // 2 + ai_token_count),
created_at=datetime.now(),
)
db.add(token_usage)
# 记录 AI 进化轨迹 (每次对话产生一条轨迹,供PipelineScheduler处理)
# 去重:检查该对话是否已有trajectory记录,避免重复创建
try:
existing = db.query(EvolutionTrajectory).filter(
EvolutionTrajectory.conversation_id == conv_id
).first()
if not existing:
trajectory = EvolutionTrajectory(
id=gen_uuid(),
user_id=current_user.id,
conversation_id=conv_id,
status="discovered",
token_used=ai_token_count,
created_at=datetime.now(),
)
db.add(trajectory)
db.flush() # 确保trajectory先写入,再启动pipeline
# 自动启动用户进化Pipeline(如果未运行)
try:
from app.services.evolution_service import EvolutionWatcherManager
watcher = EvolutionWatcherManager()
status = watcher.get_user_status(current_user.id, lambda: SessionLocal())
if not status.get("scheduler_running"):
watcher.start_user_evolution(current_user.id, lambda: SessionLocal())
logger.info(f"[Evolution] 自动启动用户 {current_user.id} 的进化Pipeline")
except Exception as evolution_err:
logger.warning(f"[Evolution] 自动启动Pipeline失败: {evolution_err}")
except Exception:
pass # 轨迹记录失败不影响主流程
db.commit()
db.refresh(user_msg)
db.refresh(ai_msg)
return {
"code": 0,
"data": {
"user_message": {
"id": user_msg.id, "role": user_msg.role, "content": user_msg.content,
"token_used": user_msg.token_used, "created_at": str(user_msg.created_at),
},
"assistant_message": {
"id": ai_msg.id, "role": ai_msg.role, "content": ai_msg.content,
"token_used": ai_msg.token_used, "created_at": str(ai_msg.created_at),
"used_skills": used_skills,
},
},
"message": "发送成功",
}
@router.post("/conversations/{conv_id}/clear")
def clear_conversation(
conv_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
# 清空消息(物理删除消息记录)
db.query(Message).filter(
Message.conversation_id == conv_id
).delete()
conv.knowledge_graph_count = 0
conv.updated_at = datetime.now()
db.commit()
return {"code": 0, "message": "对话上下文已清空"}
@router.post("/conversations/{conv_id}/to-knowledge-graph")
def convert_to_knowledge_graph(
conv_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""将对话内容转换为知识图谱:提取消息内容、创建知识库、构建知识图谱"""
conv = db.query(Conversation).filter(
and_(Conversation.id == conv_id, Conversation.user_id == current_user.id)
).first()
if not conv:
raise HTTPException(status_code=404, detail="对话不存在")
# 获取该对话的所有消息
messages = (
db.query(Message)
.filter(Message.conversation_id == conv_id)
.order_by(asc(Message.created_at))
.all()
)
if not messages:
raise HTTPException(status_code=400, detail="对话中没有消息,无法生成知识图谱")
# 1. 自动创建一个知识库(名为"对话图谱-{对话标题}")
kb_name = f"对话图谱-{conv.title}"
kb = KnowledgeBase(
id=gen_uuid(),
user_id=current_user.id,
name=kb_name,
description=f"从对话「{conv.title}」自动生成的知识图谱",
directory_path="/",
document_count=0,
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(kb)
db.flush()
# 2. 将每条消息内容作为文档存入知识库
doc_count = 0
for idx, msg in enumerate(messages):
# 提取消息中的简明摘要作为文档文件名
role_label = "用户" if msg.role == "user" else "AI"
preview = re.sub(r'[\\n\\r\\t]+', ' ', msg.content[:40]).strip()
filename = f"msg_{idx+1:03d}_{role_label}_{preview}.txt"
doc = KnowledgeDocument(
id=gen_uuid(),
knowledge_base_id=kb.id,
filename=filename,
file_type="txt",
file_size=len(msg.content.encode('utf-8')),
status="completed",
chunk_count=max(1, len(msg.content) // 500 + 1),
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(doc)
doc_count += 1
kb.document_count = doc_count
db.flush()
# 3. 调用知识图谱构建逻辑(类似 knowledge.py 中的 build-graph)
documents = db.query(KnowledgeDocument).filter(
and_(
KnowledgeDocument.knowledge_base_id == kb.id,
KnowledgeDocument.status == "completed",
)
).all()
graph_nodes = []
graph_edges = []
doc_entity_types = [
{"type": "concept", "label": "核心概念"},
{"type": "entity", "label": "关键实体"},
{"type": "attribute", "label": "属性特征"},
{"type": "relation", "label": "关联关系"},
]
for idx, doc in enumerate(documents):
doc_node_id = f"doc-{doc.id[:8]}"
graph_nodes.append({
"id": doc_node_id,
"label": doc.filename,
"type": "document",
"size": 18,
"group": "document",
})
filename_base = doc.filename.rsplit(".", 1)[0]
words = filename_base.replace("_", " ").replace("-", " ").split()
for wi, word in enumerate(words):
if len(word) >= 2:
entity_type = doc_entity_types[wi % len(doc_entity_types)]
node_id = f"ent-{doc.id[:8]}-{wi}"
graph_nodes.append({
"id": node_id,
"label": word,
"type": entity_type["type"],
"size": 10 + (wi % 8),
"group": entity_type["type"],
})
graph_edges.append({
"source": doc_node_id,
"target": node_id,
"label": entity_type["label"],
"weight": 0.5 + (0.5 / (wi + 1)),
})
# 在文档之间建立关联
for i in range(len(documents)):
for j in range(i + 1, len(documents)):
fi = documents[i].filename.lower().rsplit(".", 1)[0]
fj = documents[j].filename.lower().rsplit(".", 1)[0]
words_i = set(fi.replace("_", " ").replace("-", " ").split())
words_j = set(fj.replace("_", " ").replace("-", " ").split())
common = words_i & words_j
if common and len(common) > 0:
graph_edges.append({
"source": f"doc-{documents[i].id[:8]}",
"target": f"doc-{documents[j].id[:8]}",
"label": f"共有关键词: {', '.join(list(common)[:3])}",
"weight": min(0.9, 0.3 * len(common)),
})
graph_data = {
"nodes": graph_nodes,
"edges": graph_edges,
"stats": {
"total_nodes": len(graph_nodes),
"total_edges": len(graph_edges),
"document_count": len(documents),
"entity_types": {et["type"]: sum(1 for n in graph_nodes if n.get("group") == et["type"])
for et in doc_entity_types},
},
}
# 更新知识库的知识图谱计数
kb.knowledge_graph_count = len(graph_nodes)
kb.updated_at = datetime.now()
# 更新对话的知识图谱计数
conv.knowledge_graph_count = (conv.knowledge_graph_count or 0) + 1
conv.updated_at = datetime.now()
db.commit()
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="convert_to_graph",
target="conversation", target_id=conv_id,
detail=f"将对话 {conv.title} 转换为知识图谱 {kb.name},共 {len(graph_nodes)} 节点、{len(graph_edges)} 边",
)
return {
"code": 0,
"data": {
"knowledge_base_id": kb.id,
"knowledge_base_name": kb.name,
"graph": graph_data,
},
"message": f"知识图谱构建完成,共 {len(graph_nodes)} 个节点、{len(graph_edges)} 条边",
}
@router.get("/models")
def get_models(db: Session = Depends(get_db)):
models = db.query(AIModel).filter(AIModel.is_enabled == True).all()
result = []
for m in models:
provider = db.query(ModelProvider).filter(ModelProvider.id == m.provider_id).first()
result.append({
"id": m.id,
"name": m.name,
"provider_id": m.provider_id,
"provider_name": provider.name if provider else "",
"status": m.status,
"price_per_million_tokens": float(m.price_per_million_tokens or 0),
"is_enabled": m.is_enabled,
"is_default": bool(m.is_default) if hasattr(m, 'is_default') else False,
"total_calls": m.total_calls or 0,
"total_tokens": m.total_tokens or 0,
"created_at": str(m.created_at) if m.created_at else None,
})
# 默认模型排在最前面
result.sort(key=lambda x: (0 if x["is_default"] else 1, x["name"]))
return {"code": 0, "data": result, "message": "ok"}
def _execute_skill_tool(db: Session, func_name: str, args: dict, skills_list: list) -> str:
"""执行tool-call中的skill工具调用,返回工具执行结果"""
# 根据func_name匹配对应的skill(支持新旧两种命名方式)
matched_skill = None
for s in skills_list:
expected_name = s.skill_name.replace(" ", "_").replace("-", "_").lower()
if func_name == expected_name or func_name == f"skill_{s.id[:8]}":
matched_skill = s
break
skill_name = matched_skill.skill_name if matched_skill else func_name
input_text = args.get("input", "")
# 根据skill类型生成模拟执行结果
skill_type = matched_skill.skill_type if matched_skill else "unknown"
if skill_type == "writer":
result = (
f"技能 '{skill_name}' (写作类) 执行结果:\n"
f"根据输入 '{input_text[:100]}' 生成以下内容:\n"
f"---\n"
f"# 生成文本\n\n这是一篇关于 {input_text[:50]} 的专业文章。"
f"通过深入分析和研究,我们得出以下核心观点:首先,该主题在当前领域具有重要的实践价值;"
f"其次,从技术发展趋势来看,该方向有望在未来3-5年内实现重大突破。"
f"\n\n## 关键建议\n1. 加强基础研究投入\n2. 建立标准化流程\n3. 推动产业协同创新"
f"\n\n*由技能 '{skill_name}' 自动生成*"
)
elif skill_type == "analyzer":
result = (
f"技能 '{skill_name}' (分析类) 执行结果:\n"
f"对输入内容进行了以下分析:\n"
f"---\n"
f"分析主题: {input_text[:80]}\n"
f"情感倾向: 中性偏积极\n"
f"关键词提取: {', '.join([w for w in input_text[:80].replace(',', ',').split(',')[:5]])}\n"
f"关键实体: 识别到 {max(1, len(input_text) // 50)} 个实体\n"
f"语义密度: {'高' if len(input_text) > 50 else '中'}\n"
f"建议行动: 进一步细化分析维度,增加对比数据"
)
elif skill_type == "translator":
result = (
f"技能 '{skill_name}' (翻译类) 执行结果:\n"
f"输入: {input_text[:100]}\n"
f"英文翻译: [This is the English translation of: {input_text[:80]}...]"
f"\n日文翻译: [これは次の内容の日本語訳です: {input_text[:50]}...]"
)
elif skill_type == "summarizer":
result = (
f"技能 '{skill_name}' (摘要类) 执行结果:\n"
f"原文长度: {len(input_text)} 字符\n"
f"摘要:\n"
f"本文围绕 {input_text[:50]} 展开讨论,核心观点包括:技术革新驱动效率提升、"
f"数据标准化是行业发展的关键瓶颈、以及跨平台协作将成为未来趋势。"
f"\n关键数字: 预计增长率为15-20%,实施周期为6-12个月。"
)
else:
result = (
f"技能 '{skill_name}' ({skill_type}) 执行结果:\n"
f"已处理输入: {input_text[:200]}\n"
f"处理状态: 成功\n"
f"输出: 基于技能 '{skill_name}' 的处理逻辑,已完成对输入内容的分析处理。"
f"建议结合具体业务场景进一步优化结果。"
)
# 更新skill使用计数
if matched_skill:
matched_skill.use_count = (matched_skill.use_count or 0) + 1
matched_skill.last_used_at = datetime.now()
return result
+393
View File
@@ -0,0 +1,393 @@
"""
AI自进化 API路由
"""
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from typing import Optional, List
from pydantic import BaseModel
from ..auth import get_current_user
from ..models import User
from ..services.evolution_service import get_evolution_service, EvolutionWatcherManager
from ..database import get_db, SessionLocal
router = APIRouter()
# ============================================================
# Request/Response Models
# ============================================================
class SkillUpdateRequest(BaseModel):
description: Optional[str] = None
tags: Optional[List[str]] = None
category: Optional[str] = None
class TrajectorySubmitRequest(BaseModel):
conversation_id: str
messages: List[dict] = []
class PublishRequest(BaseModel):
price: float = 0.00
market_description: str = ""
class ShareToTeamRequest(BaseModel):
team_id: str
# ============================================================
# 技能管理 API
# ============================================================
@router.get("/skills")
def list_skills(
state: Optional[str] = Query(None, description="技能状态过滤"),
keyword: Optional[str] = Query(None, description="关键词搜索"),
sort_by: str = Query("updated_at", description="排序字段"),
order: str = Query("desc", description="排序方向"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取用户技能列表"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_skills(state=state, keyword=keyword, sort_by=sort_by, order=order)}
@router.get("/skills/{skill_name}")
def get_skill_detail(
skill_name: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取技能详情"""
svc = get_evolution_service(db, current_user.id)
result = svc.get_skill_detail(skill_name)
if not result:
raise HTTPException(status_code=404, detail="技能不存在")
return {"code": 0, "data": result}
@router.put("/skills/{skill_name}")
def update_skill(
skill_name: str,
data: SkillUpdateRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""编辑技能"""
svc = get_evolution_service(db, current_user.id)
result = svc.update_skill(skill_name, data.model_dump(exclude_none=True))
if not result:
raise HTTPException(status_code=404, detail="技能不存在")
return {"code": 0, "data": result}
@router.delete("/skills/{skill_name}")
def delete_skill(
skill_name: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""删除技能"""
svc = get_evolution_service(db, current_user.id)
if not svc.delete_skill(skill_name):
raise HTTPException(status_code=404, detail="技能不存在")
return {"code": 0, "message": "技能已删除"}
@router.post("/skills/{skill_name}/freeze")
def freeze_skill(
skill_name: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""冻结技能"""
svc = get_evolution_service(db, current_user.id)
result = svc.freeze_skill(skill_name)
if not result:
raise HTTPException(status_code=404, detail="技能不存在")
return {"code": 0, "data": result}
@router.post("/skills/{skill_name}/unfreeze")
def unfreeze_skill(
skill_name: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""解冻技能"""
svc = get_evolution_service(db, current_user.id)
result = svc.unfreeze_skill(skill_name)
if not result:
raise HTTPException(status_code=404, detail="技能不存在")
return {"code": 0, "data": result}
@router.get("/skills/{skill_name}/history")
def get_skill_history(
skill_name: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取技能版本历史"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_skill_history(skill_name)}
@router.get("/skills/{skill_name}/trajectories")
def get_skill_trajectories(
skill_name: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取技能来源轨迹列表"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_skill_trajectories(skill_name)}
# ============================================================
# 进化事件 API
# ============================================================
@router.get("/events")
def list_events(
event_type: Optional[str] = Query(None),
skill_name: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=200),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取进化事件列表"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_events(event_type=event_type, skill_name=skill_name, limit=limit)}
# ============================================================
# 驾驶舱数据 API
# ============================================================
@router.get("/dashboard/skill-tree")
def get_skill_tree(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取技能图谱数据(面板A)"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_skill_tree()}
@router.get("/dashboard/timeline")
def get_timeline(
event_type: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取进化时间线数据(面板B)"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_timeline(event_type=event_type)}
@router.get("/dashboard/statistics")
def get_statistics(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取技能使用统计数据(面板C)"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_statistics()}
@router.get("/dashboard/profile")
def get_profile(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取AI能力画像数据(面板D)"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_profile()}
# ============================================================
# 轨迹提交 API
# ============================================================
@router.post("/trajectory/submit")
def submit_trajectory(
data: TrajectorySubmitRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""提交对话轨迹到进化流水线"""
svc = get_evolution_service(db, current_user.id)
result = svc.submit_trajectory(data.conversation_id, data.messages)
return {"code": 0, "data": result, "message": "轨迹已提交"}
# ============================================================
# v2 新增 API
# ============================================================
@router.post("/skills/{skill_name}/rollback/{version}")
def rollback_skill(
skill_name: str,
version: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""技能回滚到指定版本"""
svc = get_evolution_service(db, current_user.id)
result = svc.rollback_skill(skill_name, version)
if not result:
raise HTTPException(status_code=404, detail="技能或版本不存在")
return {"code": 0, "data": result}
@router.post("/skills/{skill_name}/publish")
def publish_skill(
skill_name: str,
data: PublishRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""发布技能到技能市场"""
svc = get_evolution_service(db, current_user.id)
result = svc.publish_to_market(skill_name, data.price, data.market_description)
if not result:
raise HTTPException(status_code=404, detail="技能不存在")
return {"code": 0, "data": result}
@router.post("/skills/{skill_name}/share-to-team")
def share_skill_to_team(
skill_name: str,
data: ShareToTeamRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""将技能共享给团队"""
svc = get_evolution_service(db, current_user.id)
result = svc.share_to_team(skill_name, data.team_id)
if not result:
raise HTTPException(status_code=404, detail="技能或团队不存在")
return {"code": 0, "data": result}
@router.get("/pipeline/status")
def get_pipeline_status(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取流水线状态"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_pipeline_status()}
@router.get("/reports")
def list_reports(
report_type: Optional[str] = Query(None, description="报告类型过滤"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取报告列表"""
svc = get_evolution_service(db, current_user.id)
return {"code": 0, "data": svc.get_reports(report_type=report_type)}
@router.post("/reports/generate")
def generate_report(
report_type: str = Query("weekly", description="报告类型: weekly/monthly"),
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""手动生成进化报告"""
svc = get_evolution_service(db, current_user.id)
result = svc.generate_report(report_type=report_type)
return {"code": 0, "data": result, "message": f"{report_type}报告生成完成"}
@router.get("/reports/{report_id}")
def get_report_detail(
report_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取报告详情"""
svc = get_evolution_service(db, current_user.id)
result = svc.get_report_detail(report_id)
if not result:
raise HTTPException(status_code=404, detail="报告不存在")
return {"code": 0, "data": result}
@router.get("/skills/{skill_name}/versions")
def get_skill_versions(
skill_name: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取技能所有版本记录(版本快照列表)"""
from ..models import SkillVersion
versions = db.query(SkillVersion).filter(
SkillVersion.user_id == current_user.id,
SkillVersion.skill_name == skill_name
).order_by(SkillVersion.version.desc()).all()
return {"code": 0, "data": [{
"id": v.id,
"skill_name": v.skill_name,
"version": v.version,
"git_commit_sha": v.git_commit_sha,
"description": v.description,
"created_at": v.created_at.isoformat() if v.created_at else None,
} for v in versions]}
@router.post("/pipeline/trigger")
def trigger_pipeline(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""手动触发进化流水线(如果未运行则启动,如果已运行则重启)"""
watcher = EvolutionWatcherManager()
# 先尝试启动,如果已存在则重启
if not watcher.start_user_evolution(current_user.id, lambda: SessionLocal()):
watcher.restart_user_evolution(current_user.id, lambda: SessionLocal())
return {"code": 0, "message": "流水线已触发"}
@router.get("/watcher/status")
def get_watcher_status(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""获取当前用户的Watcher状态"""
watcher = EvolutionWatcherManager()
status = watcher.get_user_status(current_user.id, lambda: SessionLocal())
return {"code": 0, "data": status}
@router.post("/watcher/restart")
def restart_watcher(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""重启用户进化引擎"""
watcher = EvolutionWatcherManager()
ok = watcher.restart_user_evolution(current_user.id, lambda: SessionLocal())
return {"code": 0, "message": "进化引擎已重启" if ok else "进化引擎重启失败"}
@router.get("/admin/watchers")
def get_all_watchers(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
):
"""管理员端点 - 获取所有用户的Watcher状态"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="无权限访问")
watcher = EvolutionWatcherManager()
all_status = {}
for uid, scheduler in watcher._schedulers.items():
all_status[str(uid)] = {
"user_id": uid,
"running": scheduler.is_running(),
}
return {"code": 0, "data": all_status}
File diff suppressed because it is too large. Load diff
+598
View File
@@ -0,0 +1,598 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime
from ..database import get_db
from ..models import (
User, MarketApp, AppPurchase, AppReview, AppInstallation, Order,
gen_uuid
)
from ..auth import get_current_user, get_current_user_id, get_current_user_id_optional
from ..middleware.log_service import LogService
router = APIRouter()
class UploadAppRequest(BaseModel):
name: str = Field(min_length=1, max_length=200)
description: Optional[str] = None
icon: Optional[str] = None
type: Optional[str] = None # skill, agent, mcp
source: Optional[str] = None
price: float = Field(default=0, ge=0)
download_url: Optional[str] = None
category: Optional[str] = None
tags: Optional[str] = None
is_member_free: Optional[bool] = False
join_promotion: Optional[bool] = False
agent_endpoint: Optional[str] = None
class PurchaseAppRequest(BaseModel):
amount: Optional[float] = None
class ReviewAppRequest(BaseModel):
rating: int = Field(ge=1, le=5)
content: Optional[str] = None
@router.get("/apps")
def list_apps(
type: Optional[str] = Query(None),
source: Optional[str] = Query(None),
category: Optional[str] = Query(None),
search: Optional[str] = Query(None),
sort: str = Query(default="latest"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
user_id: Optional[str] = Depends(get_current_user_id_optional),
):
query = db.query(MarketApp).filter(MarketApp.status == "published")
if type:
query = query.filter(MarketApp.type == type)
if source:
query = query.filter(MarketApp.source == source)
if category:
query = query.filter(MarketApp.type == category)
if search:
query = query.filter(
and_(
MarketApp.name.ilike(f"%{search}%")
| MarketApp.description.ilike(f"%{search}%")
)
)
if sort == "popular":
query = query.order_by(desc(MarketApp.install_count))
elif sort == "rating":
query = query.order_by(desc(MarketApp.rating_avg))
else:
query = query.order_by(desc(MarketApp.created_at))
total = query.count()
apps = query.offset((page - 1) * page_size).limit(page_size).all()
# 如果用户已登录,获取该用户已安装的应用ID集合(确保按用户隔离)
installed_app_ids = set()
if user_id:
installations = db.query(AppInstallation.app_id).filter(
AppInstallation.user_id == user_id
).all()
installed_app_ids = {inst[0] for inst in installations}
# 查询已安装状态时始终带 user_id 过滤
result = []
for app in apps:
item = {
"id": app.id,
"name": app.name,
"description": app.description,
"icon": app.icon,
"type": app.type,
"source": app.source,
"publisher_id": app.publisher_id,
"publisher_name": app.publisher_name,
"is_member_free": app.is_member_free if app.is_member_free else False,
"join_promotion": app.join_promotion if app.join_promotion else False,
"status": app.status,
"price": float(app.price) if app.price else 0,
"install_count": app.install_count or 0,
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
"review_count": app.review_count or 0,
"created_at": str(app.created_at) if app.created_at else None,
"installed": app.id in installed_app_ids if user_id else False,
}
result.append(item)
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": result,
},
"message": "ok",
}
@router.get("/apps/{app_id}")
def get_app(
app_id: str,
db: Session = Depends(get_db),
current_user: Optional[User] = Depends(get_current_user),
):
app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 检查当前用户是否已购买(按用户隔离)
purchased = False
installed = False
if current_user:
purchase = db.query(AppPurchase).filter(
and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
).first()
if purchase:
purchased = True
installed = purchase.status == "installed"
# 通过 AppInstallation 表检查是否已安装(按用户隔离)
installation = db.query(AppInstallation).filter(
and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id)
).first()
if installation:
installed = True
return {
"code": 0,
"data": {
"id": app.id, "name": app.name, "description": app.description,
"icon": app.icon, "type": app.type, "source": app.source,
"type": app.type,
"price": float(app.price) if app.price else 0,
"install_count": app.install_count or 0,
"rating_avg": float(app.rating_avg) if app.rating_avg else 0,
"review_count": app.review_count or 0,
"publisher_id": app.publisher_id,
"download_url": app.download_url,
"detail": app.detail,
"created_at": str(app.created_at),
"purchased": purchased,
"installed": installed,
},
"message": "ok",
}
@router.post("/apps")
def upload_app(
req: UploadAppRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
app = MarketApp(
id=gen_uuid(),
name=req.name,
description=req.description,
icon=req.icon,
type=req.type if req.type in ("skill","agent","mcp") else "skill",
source=req.source if req.source in ("official","user") else "user",
price=req.price,
install_count=0,
rating_avg=0,
review_count=0,
publisher_id=current_user.id,
publisher_name=current_user.username,
is_member_free=req.is_member_free if req.is_member_free is not None else False,
join_promotion=req.join_promotion if req.join_promotion is not None else False,
agent_endpoint=req.agent_endpoint,
status="published", # 用户创建的应用直接发布
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(app)
db.commit()
db.refresh(app)
# 用户创建/导入的应用自动标记为已安装
existing_install = db.query(AppInstallation).filter(
AppInstallation.user_id == current_user.id,
AppInstallation.app_id == app.id,
).first()
if not existing_install:
installation = AppInstallation(
user_id=current_user.id,
app_id=app.id,
installed_at=datetime.now(),
)
db.add(installation)
db.commit()
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="upload_app",
target="market_app", target_id=app.id,
detail=f"上传应用 {app.name}",
)
return {
"code": 0,
"data": {
"id": app.id, "name": app.name, "status": app.status,
"created_at": str(app.created_at),
},
"message": "提交成功,等待审核",
}
@router.post("/apps/{app_id}/purchase")
def purchase_app(
app_id: str,
req: PurchaseAppRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
app = db.query(MarketApp).filter(
and_(MarketApp.id == app_id, MarketApp.status == "published")
).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在或未上架")
# 检查是否已购买
existing = db.query(AppPurchase).filter(
and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
).first()
if existing and existing.status in ("purchased", "installed"):
raise HTTPException(status_code=400, detail="您已购买该应用")
amount = req.amount or app.price
# 创建订单
order = Order(
id=gen_uuid(),
user_id=current_user.id,
order_type="app",
item_id=app_id,
item_name=app.name,
amount=amount,
status="pending",
created_at=datetime.now(),
)
db.add(order)
db.flush()
# 创建购买记录
purchase = AppPurchase(
id=gen_uuid(),
user_id=current_user.id,
app_id=app_id,
order_id=order.id,
status="purchased",
purchased_at=datetime.now(),
)
db.add(purchase)
# 更新应用下载量
app.install_count = (app.install_count or 0) + 1
app.updated_at = datetime.now()
db.commit()
return {
"code": 0,
"data": {
"order_id": order.id,
"purchase_id": purchase.id,
"amount": amount,
"status": "purchased",
},
"message": "购买成功",
}
@router.post("/apps/{app_id}/install")
def install_app(
app_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 检查是否已安装(通过 AppInstallation 表)
existing_install = db.query(AppInstallation).filter(
and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id)
).first()
if existing_install:
raise HTTPException(status_code=400, detail="应用已安装")
# 检查是否已购买(付费应用需要先购买)
purchase = db.query(AppPurchase).filter(
and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
).first()
# 对于付费应用,必须已购买才能安装
if float(app.price or 0) > 0:
if not purchase:
raise HTTPException(status_code=403, detail="请先购买应用")
if purchase.status == "installed":
raise HTTPException(status_code=400, detail="应用已安装")
# 创建安装记录
installation = AppInstallation(
id=gen_uuid(),
user_id=current_user.id,
app_id=app_id,
installed_at=datetime.now(),
)
db.add(installation)
# 如果有购买记录,更新状态为已安装
if purchase:
purchase.status = "installed"
purchase.installed_at = datetime.now()
# 更新应用安装量
app.install_count = (app.install_count or 0) + 1
app.updated_at = datetime.now()
db.commit()
return {
"code": 0,
"data": {"installation_id": installation.id, "status": "installed"},
"message": "安装成功",
}
@router.delete("/apps/{app_id}/uninstall")
def uninstall_app(
app_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""卸载应用 - 删除当前用户的安装记录(按用户隔离)"""
app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 查找当前用户的安装记录(按用户隔离)
installation = db.query(AppInstallation).filter(
and_(AppInstallation.app_id == app_id, AppInstallation.user_id == current_user.id)
).first()
if not installation:
raise HTTPException(status_code=400, detail="应用未安装")
# 删除安装记录
db.delete(installation)
# 问题4修复: 将AppPurchase状态从installed重置为purchased
purchase = db.query(AppPurchase).filter(
and_(AppPurchase.app_id == app_id, AppPurchase.user_id == current_user.id)
).first()
if purchase and purchase.status == "installed":
purchase.status = "purchased"
# 更新应用安装量(减1,最小为0)
if app.install_count and app.install_count > 0:
app.install_count = app.install_count - 1
app.updated_at = datetime.now()
db.commit()
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="uninstall_app",
target="market_app", target_id=app_id,
detail=f"卸载应用 {app.name}",
)
return {
"code": 0,
"message": "卸载成功",
}
@router.post("/apps/{app_id}/review")
def review_app(
app_id: str,
req: ReviewAppRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
app = db.query(MarketApp).filter(MarketApp.id == app_id).first()
if not app:
raise HTTPException(status_code=404, detail="应用不存在")
# 检查是否已评价
existing = db.query(AppReview).filter(
and_(AppReview.app_id == app_id, AppReview.user_id == current_user.id)
).first()
if existing:
# 更新已有评价
old_rating = existing.rating
existing.rating = req.rating
existing.content = req.content
existing.updated_at = datetime.now()
# 重新计算平均分 (总数不变,减去旧评分加新评分)
if app.review_count and app.review_count > 0:
total_rating = float(app.rating_avg or 0) * app.review_count
total_rating = total_rating - old_rating + req.rating
app.rating_avg = total_rating / app.review_count
else:
app.rating_avg = req.rating
app.review_count = 1
else:
# 创建新评价
review = AppReview(
id=gen_uuid(),
app_id=app_id,
user_id=current_user.id,
rating=req.rating,
content=req.content,
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(review)
# 重新计算平均分
app.review_count = (app.review_count or 0) + 1
if app.review_count == 1:
app.rating_avg = req.rating
else:
total_rating = float(app.rating_avg or 0) * (app.review_count - 1) + req.rating
app.rating_avg = round(total_rating / app.review_count, 1)
app.updated_at = datetime.now()
db.commit()
return {
"code": 0,
"data": {
"rating_avg": float(app.rating_avg or 0),
"review_count": app.review_count or 0,
},
"message": "评价成功",
}
@router.get("/my-apps")
def get_my_apps(
status: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(AppPurchase).filter(AppPurchase.user_id == current_user.id)
purchases = query.order_by(desc(AppPurchase.created_at)).all()
result = []
for p in purchases:
app = db.query(MarketApp).filter(MarketApp.id == p.app_id).first()
if app:
result.append({
"id": p.id,
"app_id": app.id,
"name": app.name,
"icon": app.icon,
"type": app.type,
"price": float(app.price or 0),
"purchased_at": str(p.created_at) if p.created_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.get("/reviews/{app_id}")
def get_reviews(
app_id: str,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
):
query = db.query(AppReview).filter(AppReview.app_id == app_id)
total = query.count()
reviews = (
query.order_by(desc(AppReview.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
result = []
for r in reviews:
user = db.query(User).filter(User.id == r.user_id).first()
result.append({
"id": r.id,
"user_id": r.user_id,
"username": user.username if user else "unknown",
"avatar": user.avatar if user else None,
"rating": r.rating,
"content": r.content,
"created_at": str(r.created_at) if r.created_at else None,
})
return {
"code": 0,
"data": {"total": total, "page": page, "page_size": page_size, "items": result},
"message": "ok",
}
# ============================================================
# Agent部署资源查询接口
# ============================================================
@router.get("/agent-resources")
def get_agent_resources(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Agent部署时需要的资源列表:可用的应用、知识库、文档"""
# 用户已发布的应用
my_apps = db.query(MarketApp).filter(
MarketApp.publisher_id == current_user.id,
MarketApp.status == "published",
).order_by(desc(MarketApp.updated_at)).all()
apps_result = []
for app in my_apps:
apps_result.append({
"id": app.id,
"name": app.name,
"type": app.type,
"description": app.description,
"install_count": app.install_count or 0,
})
# 用户知识库
from ..models import KnowledgeBase, KnowledgeDocument
kb_list = db.query(KnowledgeBase).filter(
KnowledgeBase.user_id == current_user.id
).order_by(desc(KnowledgeBase.updated_at)).all()
kb_result = []
for kb in kb_list:
doc_count = db.query(KnowledgeDocument).filter(
KnowledgeDocument.knowledge_base_id == kb.id
).count()
kb_result.append({
"id": kb.id,
"name": kb.name,
"description": kb.description,
"document_count": doc_count,
})
# 用户已安装的应用(从AppPurchase)
installed = db.query(AppPurchase).filter(
AppPurchase.user_id == current_user.id
).all()
installed_ids = [p.app_id for p in installed]
installed_apps = []
if installed_ids:
iapps = db.query(MarketApp).filter(MarketApp.id.in_(installed_ids)).all()
for ia in iapps:
installed_apps.append({
"id": ia.id,
"name": ia.name,
"type": ia.type,
"description": ia.description,
})
return {
"code": 0,
"data": {
"my_apps": apps_result,
"knowledge_bases": kb_result,
"installed_apps": installed_apps,
},
"message": "ok",
}
+135
View File
@@ -0,0 +1,135 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime
from ..database import get_db
from ..models import (
User, Notification,
gen_uuid
)
from ..auth import get_current_user
from ..middleware.log_service import LogService
router = APIRouter()
@router.get("/")
def list_notifications(
type: Optional[str] = Query(None, description="通知类型"),
is_read: Optional[bool] = Query(None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(Notification).filter(
Notification.user_id == current_user.id
)
if type:
query = query.filter(Notification.type == type)
if is_read is not None:
query = query.filter(Notification.is_read == is_read)
total = query.count()
notifications = (
query.order_by(desc(Notification.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
result = []
for n in notifications:
result.append({
"id": n.id,
"type": n.type,
"title": n.title,
"content": n.content,
"is_read": n.is_read,
"related_id": n.related_id,
"related_type": n.related_type,
"created_at": str(n.created_at) if n.created_at else None,
})
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": result,
},
"message": "ok",
}
@router.put("/{notification_id}/read")
def mark_as_read(
notification_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
notification = db.query(Notification).filter(
and_(
Notification.id == notification_id,
Notification.user_id == current_user.id,
)
).first()
if not notification:
raise HTTPException(status_code=404, detail="通知不存在")
notification.is_read = True
notification.read_at = datetime.now()
db.commit()
return {"code": 0, "message": "已标记为已读"}
@router.put("/read-all")
def mark_all_as_read(
type: Optional[str] = Query(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(Notification).filter(
and_(
Notification.user_id == current_user.id,
Notification.is_read == False,
)
)
if type:
query = query.filter(Notification.type == type)
notifications = query.all()
now = datetime.now()
count = 0
for n in notifications:
n.is_read = True
n.read_at = now
count += 1
db.commit()
return {"code": 0, "data": {"count": count}, "message": f"已标记 {count} 条为已读"}
@router.get("/unread-count")
def get_unread_count(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
count = db.query(func.count(Notification.id)).filter(
and_(
Notification.user_id == current_user.id,
Notification.is_read == False,
)
).scalar() or 0
return {"code": 0, "data": {"count": count}, "message": "ok"}
+608
View File
@@ -0,0 +1,608 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, date
from ..database import get_db
from ..models import (
User, MembershipPlan, Order, Coupon, ValueAddedService,
CustomServiceOrder, CustomServiceRequest, MembershipSubscription,
gen_uuid
)
from ..auth import get_current_user
from ..middleware.log_service import LogService
router = APIRouter()
class CreateOrderRequest(BaseModel):
item_id: str
coupon_id: Optional[str] = None
order_type: str = "membership"
amount: Optional[float] = None
class UseCouponRequest(BaseModel):
code: str
order_id: Optional[str] = None
class CustomServiceOrderRequest(BaseModel):
service_id: str
description: Optional[str] = None
budget: Optional[float] = None
contact_info: Optional[str] = None
class ServiceRequestModel(BaseModel):
"""增值服务工单请求模型(数据定制/私有化/平台代理)"""
service_id: str
title: str
description: Optional[str] = None
requirements: Optional[dict] = None # 需求详情
@router.get("/plans")
def list_plans(db: Session = Depends(get_db)):
plans = db.query(MembershipPlan).filter(MembershipPlan.is_active == True).order_by(asc(MembershipPlan.level)).all()
result = []
for p in plans:
result.append({
"id": p.id,
"name": p.name,
"level": p.level,
"duration_type": p.duration_type,
"price": float(p.price) if p.price else 0,
"daily_free_tokens": p.daily_free_tokens or 0,
"features": p.features or [],
"is_active": p.is_active,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/orders")
def create_order(
req: CreateOrderRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
plan = db.query(MembershipPlan).filter(
and_(MembershipPlan.id == req.item_id, MembershipPlan.is_active == True)
).first()
if not plan:
raise HTTPException(status_code=404, detail="会员计划不存在或已下线")
amount = req.amount or float(plan.price or 0)
order = Order(
id=gen_uuid(),
user_id=current_user.id,
order_type=req.order_type if hasattr(req, 'order_type') and req.order_type else "membership",
item_id=req.item_id,
item_name=plan.name,
amount=amount,
status="pending",
coupon_id=req.coupon_id if hasattr(req, 'coupon_id') and req.coupon_id else None,
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(order)
db.commit()
db.refresh(order)
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="create_order",
target="payment_order", target_id=order.id,
detail=f"创建订单 #{order.id},金额 {amount}",
)
return {
"code": 0,
"data": {
"id": order.id,
"item_id": order.item_id,
"plan_name": plan.name,
"amount": float(order.amount),
"status": order.status,
"created_at": str(order.created_at),
},
"message": "订单创建成功",
}
@router.get("/orders")
def list_orders(
status: Optional[str] = Query(None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(Order).filter(Order.user_id == current_user.id)
if status:
query = query.filter(Order.status == status)
total = query.count()
orders = query.order_by(desc(Order.created_at)).offset((page - 1) * page_size).limit(page_size).all()
result = []
for o in orders:
plan = db.query(MembershipPlan).filter(MembershipPlan.id == o.item_id).first()
result.append({
"id": o.id,
"item_id": o.item_id,
"plan_name": plan.name if plan else "unknown",
"amount": float(o.amount),
"status": o.status,
"coupon_id": o.coupon_id,
"paid_at": str(o.paid_at) if o.paid_at else None,
"created_at": str(o.created_at),
})
return {
"code": 0,
"data": {"total": total, "page": page, "page_size": page_size, "items": result},
"message": "ok",
}
@router.get("/orders/{order_id}")
def get_order(
order_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
order = db.query(Order).filter(
and_(Order.id == order_id, Order.user_id == current_user.id)
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
plan = db.query(MembershipPlan).filter(MembershipPlan.id == order.item_id).first()
return {
"code": 0,
"data": {
"id": order.id,
"item_id": order.item_id,
"plan_name": plan.name if plan else "unknown",
"amount": float(order.amount),
"status": order.status,
"coupon_id": order.coupon_id,
"paid_at": str(order.paid_at) if order.paid_at else None,
"created_at": str(order.created_at),
"updated_at": str(order.updated_at),
},
"message": "ok",
}
@router.post("/orders/{order_id}/pay")
def pay_order(
order_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
order = db.query(Order).filter(
and_(Order.id == order_id, Order.user_id == current_user.id)
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "pending":
raise HTTPException(status_code=400, detail="订单状态不允许支付")
# 模拟支付成功
order.status = "paid"
order.paid_at = datetime.now()
order.updated_at = datetime.now()
# 激活会员
plan = db.query(MembershipPlan).filter(MembershipPlan.id == order.item_id).first()
if plan:
# 查找或创建会员记录
membership = db.query(MembershipSubscription).filter(
MembershipSubscription.user_id == current_user.id
).first()
if membership:
# 如果已有会员,延长有效期
if membership.end_at and membership.end_at > datetime.now():
# 从当前到期日开始延长
membership.end_at = membership.end_at + __import__("datetime").timedelta(days=30)
else:
# 已过期,从现在开始
membership.end_at = datetime.now() + __import__("datetime").timedelta(days=30)
membership.plan_id = plan.id
membership.status = "active"
membership.updated_at = datetime.now()
else:
membership = MembershipSubscription(
id=gen_uuid(),
user_id=current_user.id,
plan_id=plan.id,
status="active",
start_at=datetime.now(),
end_at=datetime.now() + __import__("datetime").timedelta(days=30),
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(membership)
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="pay_order",
target="payment_order", target_id=order.id,
detail=f"支付订单 #{order.id},金额 {order.amount}",
)
db.commit()
return {
"code": 0,
"data": {
"order_id": order.id,
"status": order.status,
"paid_at": str(order.paid_at),
},
"message": "支付成功",
}
@router.get("/coupons")
def list_coupons(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import UserCoupon
now = datetime.now()
# 先获取用户的user_coupons
user_coupons = db.query(UserCoupon).filter(
and_(
UserCoupon.user_id == current_user.id,
UserCoupon.status == "unused",
)
).all()
result = []
coupon_ids = [uc.coupon_id for uc in user_coupons]
if coupon_ids:
coupons = db.query(Coupon).filter(
and_(
Coupon.id.in_(coupon_ids),
Coupon.status == "active",
Coupon.valid_to > now,
)
).order_by(asc(Coupon.valid_to)).all()
for c in coupons:
result.append({
"id": c.id,
"code": c.code,
"type": c.type,
"value": float(c.value) if c.value else 0,
"min_amount": float(c.min_amount) if c.min_amount else 0,
"expire_at": str(c.valid_to) if c.valid_to else None,
"status": c.status,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/coupons/use")
def use_coupon(
req: UseCouponRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
from ..models import UserCoupon
coupon = db.query(Coupon).filter(
and_(
Coupon.code == req.code,
Coupon.status == "active",
)
).first()
if not coupon:
raise HTTPException(status_code=404, detail="优惠券不存在或已失效")
# 检查用户是否持有该优惠券
user_coupon = db.query(UserCoupon).filter(
and_(
UserCoupon.coupon_id == coupon.id,
UserCoupon.user_id == current_user.id,
UserCoupon.status == "unused",
)
).first()
if not user_coupon:
raise HTTPException(status_code=404, detail="优惠券不存在或已使用")
if coupon.valid_to and coupon.valid_to < datetime.now():
user_coupon.status = "expired"
db.commit()
raise HTTPException(status_code=400, detail="优惠券已过期")
# 标记为已使用
user_coupon.status = "used"
user_coupon.used_at = datetime.now()
if req.order_id:
order = db.query(Order).filter(
and_(Order.id == req.order_id, Order.user_id == current_user.id)
).first()
if order and order.status == "pending":
# 应用优惠券折扣
discount = 0
if coupon.type == "fixed":
discount = float(coupon.value or 0)
elif coupon.type == "percent":
discount = float(order.amount) * float(coupon.value or 0) / 100
order.amount = max(0, float(order.amount) - discount)
order.coupon_id = coupon.id
order.discount_amount = discount
db.commit()
return {"code": 0, "data": {"code": coupon.code, "status": user_coupon.status}, "message": "优惠券使用成功"}
@router.get("/services")
def list_services(db: Session = Depends(get_db)):
services = db.query(ValueAddedService).filter(
ValueAddedService.status == "active"
).order_by(asc(ValueAddedService.created_at)).all()
result = []
for s in services:
result.append({
"id": s.id,
"name": s.name,
"description": s.description,
"type": s.type,
"price": float(s.price) if s.price else 0,
"features": s.features or [],
"assigned_agent_id": s.assigned_agent_id,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/custom-services")
def create_custom_service_order(
req: CustomServiceOrderRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
service = db.query(ValueAddedService).filter(
and_(ValueAddedService.id == req.service_id, ValueAddedService.status == "active")
).first()
if not service:
raise HTTPException(status_code=404, detail="服务不存在或已下线")
order = CustomServiceOrder(
id=gen_uuid(),
user_id=current_user.id,
service_id=req.service_id,
service_type=service.type,
description=req.description,
price=req.budget or float(service.price or 0),
status="pending",
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(order)
db.commit()
db.refresh(order)
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="create_custom_service",
target="custom_service_order", target_id=order.id,
detail=f"创建定制服务订单 #{order.id},服务: {service.name}",
)
return {
"code": 0,
"data": {
"id": order.id,
"service_id": order.service_id,
"service_name": service.name,
"price": float(order.price) if order.price else 0,
"status": order.status,
"created_at": str(order.created_at),
},
"message": "定制服务订单已提交",
}
@router.post("/service-requests")
def create_service_request(
req: ServiceRequestModel,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""创建增值服务工单(平台代理/数据定制/私有化项目)"""
service = db.query(ValueAddedService).filter(
and_(ValueAddedService.id == req.service_id, ValueAddedService.status == "active")
).first()
if not service:
raise HTTPException(status_code=404, detail="服务不存在或已下线")
# 验证服务类型是否为工单性质
if service.type not in ("platform_proxy", "data_custom", "private_project"):
raise HTTPException(status_code=400, detail="此服务不支持工单提交")
request_obj = CustomServiceRequest(
id=gen_uuid(),
user_id=current_user.id,
service_id=req.service_id,
request_type=service.type,
title=req.title,
description=req.description,
requirements=req.requirements,
status="pending",
created_at=datetime.now(),
updated_at=datetime.now(),
)
db.add(request_obj)
db.commit()
db.refresh(request_obj)
LogService.log_operation(
user_id=current_user.id, username=current_user.username,
action="create_service_request",
target="custom_service_request", target_id=request_obj.id,
detail=f"创建服务工单 #{request_obj.id},类型: {service.type}, 标题: {req.title}",
)
return {
"code": 0,
"data": {
"id": request_obj.id,
"service_id": request_obj.service_id,
"service_name": service.name,
"request_type": service.type,
"title": request_obj.title,
"status": request_obj.status,
"created_at": str(request_obj.created_at),
},
"message": "服务工单已提交",
}
# ============================================================
# 电子凭证开具 API
# ============================================================
class ReceiptRequest(BaseModel):
receipt_type: str = Field(default="electronic") # electronic / invoice
title: Optional[str] = None # 发票抬头(公司名称或个人姓名)
tax_number: Optional[str] = None # 税号
@router.post("/orders/{order_id}/receipt")
def generate_receipt(
order_id: str,
req: ReceiptRequest = Depends(),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""为已支付订单开具电子凭证"""
order = db.query(Order).filter(
and_(Order.id == order_id, Order.user_id == current_user.id)
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if order.status != "paid":
raise HTTPException(status_code=400, detail="仅已支付订单可开具电子凭证")
# 检查是否已开过凭证
if order.receipt_number:
return {
"code": 0,
"data": {
"receipt_number": order.receipt_number,
"receipt_type": order.receipt_type or "electronic",
"title": order.receipt_title or current_user.username,
"amount": float(order.amount),
"issued_at": order.receipt_issued_at.strftime("%Y-%m-%d %H:%M:%S") if order.receipt_issued_at else "",
"status": "issued",
"message": "该订单已开具电子凭证",
},
"message": "电子凭证已存在",
}
# 生成凭证编号:RCT + 年月日 + 随机6位
now = datetime.now()
receipt_number = f"RCT{now.strftime('%Y%m%d')}{uuid.uuid4().hex[:6].upper()}"
# 保存凭证信息到订单
order.receipt_number = receipt_number
order.receipt_type = req.receipt_type
order.receipt_title = req.title or current_user.username
order.receipt_tax_number = req.tax_number
order.receipt_issued_at = now
order.updated_at = now
# 记录日志
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="generate_receipt",
target="payment_order", target_id=order.id,
detail=f"为订单 #{order.id} 开具电子凭证 {receipt_number}",
)
db.commit()
# 构建凭证详情
receipt_data = {
"receipt_number": receipt_number,
"receipt_type": req.receipt_type,
"title": req.title or current_user.username,
"tax_number": req.tax_number,
"order_id": order.id,
"amount": float(order.amount),
"order_type": order.order_type or "membership",
"item_name": order.item_name or "会员订阅",
"payment_method": order.payment_method or "微信支付",
"payment_id": order.payment_id or f"PAY{now.strftime('%Y%m%d%H%M%S')}",
"paid_at": order.paid_at.strftime("%Y-%m-%d %H:%M:%S") if hasattr(order, 'paid_at') and order.paid_at else now.strftime("%Y-%m-%d %H:%M:%S"),
"issued_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"customer_name": current_user.username,
"customer_email": current_user.email or "",
"customer_phone": current_user.phone or "",
"status": "issued",
"platform_name": "AIFlow智能自动化PaaS平台",
"remarks": "此电子凭证由AIFlow平台自动生成,与纸质凭证具有同等效力。如需帮助,请联系客服。",
}
return {
"code": 0,
"data": receipt_data,
"message": "电子凭证开具成功",
}
@router.get("/orders/{order_id}/receipt")
def get_receipt(
order_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取订单已有的电子凭证"""
order = db.query(Order).filter(
and_(Order.id == order_id, Order.user_id == current_user.id)
).first()
if not order:
raise HTTPException(status_code=404, detail="订单不存在")
if not order.receipt_number:
return {
"code": 0,
"data": None,
"message": "该订单尚未开具电子凭证",
}
return {
"code": 0,
"data": {
"receipt_number": order.receipt_number,
"receipt_type": order.receipt_type or "electronic",
"title": order.receipt_title or current_user.username,
"tax_number": order.receipt_tax_number,
"order_id": order.id,
"amount": float(order.amount),
"order_type": order.order_type or "membership",
"item_name": order.item_name or "会员订阅",
"payment_method": order.payment_method or "微信支付",
"issued_at": order.receipt_issued_at.strftime("%Y-%m-%d %H:%M:%S") if order.receipt_issued_at else "",
"status": "issued" if order.receipt_number else "not_issued",
"platform_name": "AIFlow智能自动化PaaS平台",
},
"message": "ok",
}
+351
View File
@@ -0,0 +1,351 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, date
from ..database import get_db
from ..models import (
User, LoginLog, ApiKey, MembershipSubscription, AppPurchase, MarketApp,
gen_uuid
)
from ..auth import get_current_user, hash_password, verify_password
from ..middleware.log_service import LogService
router = APIRouter()
class UpdateProfileRequest(BaseModel):
username: Optional[str] = None
email: Optional[str] = None
phone: Optional[str] = None
avatar: Optional[str] = None
class UpdatePasswordRequest(BaseModel):
old_password: str
new_password: str = Field(min_length=6, max_length=100)
class CreateApiKeyRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
class UpdateApiKeyRequest(BaseModel):
status: str # enabled, disabled, revoked
@router.get("/")
def get_profile(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# 获取会员信息
membership = db.query(MembershipSubscription).filter(
MembershipSubscription.user_id == current_user.id
).first()
membership_info = None
if membership:
membership_info = {
"id": membership.id,
"plan_id": membership.plan_id,
"status": membership.status,
"start_at": str(membership.start_at) if membership.start_at else None,
"expire_at": str(membership.end_at) if membership.end_at else None,
"is_expired": membership.end_at < datetime.now() if membership.end_at else False,
}
return {
"code": 0,
"data": {
"id": current_user.id,
"username": current_user.username,
"email": current_user.email,
"phone": current_user.phone,
"avatar": current_user.avatar,
"role": current_user.role,
"status": current_user.status,
"membership": membership_info,
"created_at": str(current_user.created_at) if current_user.created_at else None,
"updated_at": str(current_user.updated_at) if current_user.updated_at else None,
},
"message": "ok",
}
@router.put("/")
def update_profile(
req: UpdateProfileRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# 从当前会话中重新获取用户对象
db_user = db.query(User).filter(User.id == current_user.id).first()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
if req.username is not None and req.username != db_user.username:
# 检查用户名是否被占用
existing = db.query(User).filter(User.username == req.username).first()
if existing:
raise HTTPException(status_code=400, detail="用户名已被占用")
db_user.username = req.username
if req.email is not None and req.email != db_user.email:
existing = db.query(User).filter(User.email == req.email).first()
if existing:
raise HTTPException(status_code=400, detail="邮箱已被占用")
db_user.email = req.email
if req.phone is not None:
db_user.phone = req.phone
if req.avatar is not None:
db_user.avatar = req.avatar
db_user.updated_at = datetime.now()
LogService.log_operation(
user_id=db_user.id, username=db_user.username, action="update_profile",
target="user", target_id=db_user.id,
detail=f"用户 {db_user.username} 更新了个人信息",
)
db.commit()
db.refresh(db_user)
return {
"code": 0,
"data": {
"id": db_user.id,
"username": db_user.username,
"email": db_user.email,
"phone": db_user.phone,
"avatar": db_user.avatar,
},
"message": "更新成功",
}
@router.put("/password")
def update_password(
req: UpdatePasswordRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
db_user = db.query(User).filter(User.id == current_user.id).first()
if not db_user:
raise HTTPException(status_code=404, detail="用户不存在")
if not verify_password(req.old_password, db_user.password_hash):
raise HTTPException(status_code=400, detail="原密码错误")
db_user.password_hash = hash_password(req.new_password)
db_user.updated_at = datetime.now()
LogService.log_operation(
user_id=db_user.id, username=db_user.username, action="change_password",
target="user", target_id=db_user.id,
detail=f"用户 {db_user.username} 修改了密码",
)
db.commit()
return {"code": 0, "message": "密码修改成功"}
@router.get("/login-logs")
def get_login_logs(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(LoginLog).filter(LoginLog.user_id == current_user.id)
total = query.count()
logs = (
query.order_by(desc(LoginLog.login_at))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
result = []
for log_item in logs:
result.append({
"id": log_item.id,
"ip": log_item.ip,
"user_agent": log_item.user_agent,
"login_at": str(log_item.login_at) if log_item.login_at else None,
})
return {
"code": 0,
"data": {"total": total, "page": page, "page_size": page_size, "items": result},
"message": "ok",
}
@router.get("/api-keys")
def get_api_keys(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
keys = (
db.query(ApiKey)
.filter(ApiKey.user_id == current_user.id)
.order_by(desc(ApiKey.created_at))
.all()
)
result = []
for k in keys:
preview = None
if k.api_key and len(k.api_key) > 12:
preview = k.api_key[:8] + "****" + k.api_key[-4:]
result.append({
"id": k.id,
"name": k.name,
"key_preview": preview or "****",
"status": k.status,
"last_used_at": str(k.last_used_at) if k.last_used_at else None,
"created_at": str(k.created_at) if k.created_at else None,
"expire_at": str(k.expired_at) if k.expired_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
@router.post("/api-keys")
def create_api_key(
req: CreateApiKeyRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# 生成API密钥
key_value = f"sk-{gen_uuid().replace('-', '')}"
api_key = ApiKey(
id=gen_uuid(),
user_id=current_user.id,
name=req.name,
api_key=key_value,
secret=f"sec-{gen_uuid().replace('-', '')}",
status="active",
created_at=datetime.now(),
)
db.add(api_key)
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="create_api_key",
target="api_key", target_id=api_key.id,
detail=f"用户 {current_user.username} 创建了API密钥 {api_key.name}",
)
db.commit()
db.refresh(api_key)
return {
"code": 0,
"data": {
"id": api_key.id,
"name": api_key.name,
"api_key": key_value, # 仅创建时返回完整key
"status": api_key.status,
"created_at": str(api_key.created_at),
},
"message": "API密钥创建成功,请妥善保管",
}
@router.put("/api-keys/{key_id}")
def update_api_key(
key_id: str,
req: UpdateApiKeyRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
api_key = db.query(ApiKey).filter(
and_(ApiKey.id == key_id, ApiKey.user_id == current_user.id)
).first()
if not api_key:
raise HTTPException(status_code=404, detail="API密钥不存在")
if req.status not in ("enabled", "disabled", "revoked"):
raise HTTPException(status_code=400, detail="无效的状态值")
api_key.status = req.status
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="update_api_key",
target="api_key", target_id=api_key.id,
detail=f"用户 {current_user.username} 更新了API密钥 {api_key.name} 状态为 {req.status}",
)
db.commit()
return {"code": 0, "data": {"id": api_key.id, "status": api_key.status}, "message": "更新成功"}
@router.get("/membership")
def get_membership(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
membership = db.query(MembershipSubscription).filter(
MembershipSubscription.user_id == current_user.id
).first()
if not membership:
return {
"code": 0,
"data": {"has_membership": False, "status": None},
"message": "ok",
}
is_expired = membership.end_at < datetime.now() if membership.end_at else False
return {
"code": 0,
"data": {
"has_membership": True,
"id": membership.id,
"plan_id": membership.plan_id,
"status": "expired" if is_expired else membership.status,
"start_at": str(membership.start_at) if membership.start_at else None,
"expire_at": str(membership.end_at) if membership.end_at else None,
"is_expired": is_expired,
},
"message": "ok",
}
@router.get("/purchased-apps")
def get_purchased_apps(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
purchases = (
db.query(AppPurchase)
.filter(AppPurchase.user_id == current_user.id)
.order_by(desc(AppPurchase.created_at))
.all()
)
result = []
for p in purchases:
app = db.query(MarketApp).filter(MarketApp.id == p.app_id).first()
result.append({
"id": p.id,
"app_id": p.app_id,
"app_name": app.name if app else "unknown",
"app_icon": app.icon if app else None,
"app_type": app.type if app else None,
"price": float(p.price if p.price and app else 0),
"purchased_at": str(p.created_at) if p.created_at else None,
})
return {"code": 0, "data": result, "message": "ok"}
+247
View File
@@ -0,0 +1,247 @@
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from sqlalchemy import and_, desc, asc, func, extract
from pydantic import BaseModel, Field
from typing import Optional
import uuid
from datetime import datetime, date, timedelta
from ..database import get_db
from ..models import (
User, TokenUsageLog, TokenBalance, AIModel,
gen_uuid
)
from ..auth import get_current_user
from ..middleware.log_service import LogService
router = APIRouter()
class RechargeRequest(BaseModel):
amount: int = Field(ge=1)
channel: str = Field(default="simulation")
@router.get("/overview")
def get_overview(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# 总消耗
total_used = db.query(func.coalesce(func.sum(TokenUsageLog.total_tokens), 0)).filter(
TokenUsageLog.user_id == current_user.id
).scalar()
# 今日使用中应用数
today_start = datetime.combine(date.today(), datetime.min.time())
today_apps = db.query(func.count(func.distinct(TokenUsageLog.model_id))).filter(
and_(
TokenUsageLog.user_id == current_user.id,
TokenUsageLog.created_at >= today_start,
)
).scalar() or 0
# 余额
balance = db.query(TokenBalance).filter(
TokenBalance.user_id == current_user.id
).first()
from datetime import timezone, timedelta
shanghai_tz = timezone(timedelta(hours=8))
server_time = datetime.now(shanghai_tz).isoformat()
return {
"code": 0,
"data": {
"total_used": total_used or 0,
"balance": balance.balance if balance else 0,
"today_apps": today_apps,
"server_time": server_time,
},
"message": "ok",
}
@router.get("/rankings")
def get_rankings(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# Token消耗最多的模型
model_rankings = (
db.query(
TokenUsageLog.model_id,
func.sum(TokenUsageLog.total_tokens).label("total"),
func.count(TokenUsageLog.id).label("count"),
)
.filter(TokenUsageLog.user_id == current_user.id)
.group_by(TokenUsageLog.model_id)
.order_by(desc("total"))
.all()
)
models_list = []
for r in model_rankings:
model = db.query(AIModel).filter(AIModel.id == r.model_id).first()
models_list.append({
"model_id": r.model_id,
"model_name": model.name if model else "unknown",
"total_tokens": int(r.total),
"usage_count": int(r.count),
})
# TOP3应用
app_rankings = (
db.query(
TokenUsageLog.conversation_id,
func.sum(TokenUsageLog.total_tokens).label("total"),
)
.filter(TokenUsageLog.user_id == current_user.id)
.group_by(TokenUsageLog.conversation_id)
.order_by(desc("total"))
.limit(3)
.all()
)
apps_list = []
for r in app_rankings:
apps_list.append({
"conversation_id": r.conversation_id,
"total_tokens": int(r.total),
})
return {
"code": 0,
"data": {
"model_rankings": models_list,
"top_apps": apps_list,
},
"message": "ok",
}
@router.get("/logs")
def get_token_logs(
start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"),
end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"),
model_id: Optional[str] = Query(None),
app_type: Optional[str] = Query(None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
query = db.query(TokenUsageLog).filter(TokenUsageLog.user_id == current_user.id)
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
query = query.filter(TokenUsageLog.created_at >= start_dt)
except ValueError:
raise HTTPException(status_code=400, detail="开始日期格式错误,请使用YYYY-MM-DD")
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1)
query = query.filter(TokenUsageLog.created_at < end_dt)
except ValueError:
raise HTTPException(status_code=400, detail="结束日期格式错误,请使用YYYY-MM-DD")
if model_id:
query = query.filter(TokenUsageLog.model_id == model_id)
total = query.count()
logs = (
query.order_by(desc(TokenUsageLog.created_at))
.offset((page - 1) * page_size)
.limit(page_size)
.all()
)
result = []
for log_item in logs:
model = db.query(AIModel).filter(AIModel.id == log_item.model_id).first()
result.append({
"id": log_item.id,
"model_id": log_item.model_id,
"model_name": model.name if model else "unknown",
"prompt_tokens": log_item.prompt_tokens or 0,
"completion_tokens": log_item.completion_tokens or 0,
"total_tokens": log_item.total_tokens or 0,
"conversation_id": log_item.conversation_id,
"created_at": log_item.created_at.isoformat() if log_item.created_at else None,
})
return {
"code": 0,
"data": {
"total": total,
"page": page,
"page_size": page_size,
"items": result,
},
"message": "ok",
}
@router.get("/balance")
def get_balance(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
balance = db.query(TokenBalance).filter(
TokenBalance.user_id == current_user.id
).first()
return {
"code": 0,
"data": {
"balance": balance.balance if balance else 0,
"updated_at": str(balance.updated_at) if balance and balance.updated_at else None,
},
"message": "ok",
}
@router.post("/recharge")
def recharge(
req: RechargeRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
# 获取或创建余额记录
balance = db.query(TokenBalance).filter(
TokenBalance.user_id == current_user.id
).first()
if not balance:
balance = TokenBalance(
id=gen_uuid(),
user_id=current_user.id,
balance=0,
updated_at=datetime.now(),
)
db.add(balance)
db.flush()
old_balance = balance.balance or 0
balance.balance = old_balance + req.amount
balance.updated_at = datetime.now()
# 记录操作日志
LogService.log_operation(
user_id=current_user.id, username=current_user.username, action="recharge",
target="token_balance", target_id=balance.id,
detail=f"充值 {req.amount} tokens,余额从 {old_balance} 变为 {balance.balance}",
)
db.commit()
return {
"code": 0,
"data": {
"balance": balance.balance,
"recharged": req.amount,
},
"message": "充值成功",
}
+102
View File
@@ -0,0 +1,102 @@
"""
AIFlow 认证与授权模块
JWT Token 生成与验证, 密码哈希
使用 bcrypt 库直接处理密码哈希,避免 passlib 与新版 bcrypt 的兼容性问题
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
import bcrypt
from jose import JWTError, jwt
from fastapi import Depends, HTTPException, status, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from .config import SECRET_KEY, ALGORITHM, ACCESS_TOKEN_EXPIRE_MINUTES
logger = logging.getLogger(__name__)
security = HTTPBearer()
security_optional = HTTPBearer(auto_error=False)
def hash_password(password: str) -> str:
"""使用 bcrypt 直接生成密码哈希"""
return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""使用 bcrypt 直接验证密码,捕获所有异常确保稳定性"""
try:
return bcrypt.checkpw(
plain_password.encode("utf-8"),
hashed_password.encode("utf-8"),
)
except Exception as e:
logger.warning(f"密码验证异常: hashed_password 前缀={hashed_password[:7]}... 长度={len(hashed_password)} 错误={e}")
return False
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> Optional[dict]:
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
return payload
except JWTError:
return None
async def get_current_user_id(
credentials: HTTPAuthorizationCredentials = Depends(security)
) -> str:
"""从JWT Token中提取当前用户ID"""
payload = decode_access_token(credentials.credentials)
if payload is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证令牌")
user_id = payload.get("sub")
if user_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的认证令牌")
return user_id
async def get_current_user_id_optional(
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional),
) -> Optional[str]:
"""从JWT Token中提取当前用户ID,不强制要求登录"""
if credentials is None:
return None
payload = decode_access_token(credentials.credentials)
if payload is None:
return None
return payload.get("sub")
async def get_current_user(
user_id: str = Depends(get_current_user_id),
):
"""获取当前用户对象(延迟导入避免循环引用)"""
from .database import SessionLocal
from .models import User
db = SessionLocal()
try:
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
if user.status == "banned":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已被封禁")
return user
finally:
db.close()
async def get_current_admin(
user=Depends(get_current_user),
):
"""验证当前用户为管理员"""
if user.role != "admin":
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限")
return user
+132
View File
@@ -0,0 +1,132 @@
"""
Qihu-AIFlow 智能自动化PaaS平台 - 后端配置
"""
import os
import logging
from pathlib import Path
from logging.handlers import TimedRotatingFileHandler
from dotenv import load_dotenv
# 加载 .env 文件(Docker 环境跳过,直接使用容器环境变量)
if not (Path("/.dockerenv").exists() or Path("/run/.containerenv").exists()):
env_path = Path("/data/share/project/.env")
if env_path.exists():
load_dotenv(env_path, override=False)
# 数据库配置
DATABASE_URL = f"mysql+pymysql://{os.getenv('MYSQL_USER', 'root')}:{os.getenv('MYSQL_PASSWORD', 'pwd2026')}@{os.getenv('MYSQL_HOST', 'localhost')}:{os.getenv('MYSQL_PORT', '3306')}/{os.getenv('MYSQL_DATABASE', 'aiflow')}?charset=utf8mb4"
# Redis配置
REDIS_HOST = os.getenv("REDIS_HOST", "localhost")
REDIS_PORT = int(os.getenv("REDIS_PORT", "6379"))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", "pwd2026")
REDIS_URL = f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/0"
# JWT配置
SECRET_KEY = os.getenv("SECRET_KEY", "aiflow-secret-key-change-in-production-2026")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 # 24小时
# 应用配置
APP_NAME = "Qihu-AIFlow"
APP_VERSION = "1.0.0"
DEBUG = os.getenv("DEBUG", "false").lower() == "true"
# 服务地址
# 三级优先级:
# 1. docker-compose.yml 的环境变量(生产部署)
# 2. .env 文件中的配置(本地开发)
# 3. 硬编码正式域名(最终兜底)
# 注意:如果环境变量是临时开发地址(如team-coding子域名),强制使用.env中的正式域名
APP_ACCESS_URL = os.getenv("APP_ACCESS_URL", "")
# 检测是否为临时/开发环境域名,这些域名在生产不可用
_TEMP_DOMAIN_PATTERNS = ["team-coding", "localhost", "127.0.0.1", "0.0.0.0"]
_is_temp_url = any(p in (APP_ACCESS_URL or "") for p in _TEMP_DOMAIN_PATTERNS)
if not APP_ACCESS_URL or _is_temp_url:
# 尝试从.env文件读取正式域名(Docker环境跳过.env,直接用docker-compose变量)
try:
from dotenv import load_dotenv as _load_dotenv
_env_path = Path("/data/share/project/.env")
if _env_path.exists():
_load_dotenv(_env_path, override=True)
env_url = os.getenv("APP_ACCESS_URL", "")
if env_url:
APP_ACCESS_URL = env_url
_is_temp_url = any(p in (APP_ACCESS_URL or "") for p in _TEMP_DOMAIN_PATTERNS)
except Exception:
pass
# 最终兜底:如果仍然是临时URL或为空,使用硬编码正式域名
if not APP_ACCESS_URL or _is_temp_url:
APP_ACCESS_URL = "https://bot.qihuyun.com"
# 上传文件配置(兼容 Docker /app 和本地 /data/share/project 路径)
_DATA_BASE = Path("/app/data") if Path("/.dockerenv").exists() else Path("/data/share/project/data")
UPLOAD_DIR = _DATA_BASE / "uploads"
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
MAX_UPLOAD_SIZE = 50 * 1024 * 1024 # 50MB
# ============================================================
# 日志配置 - 按天轮转输出到文件
# ============================================================
LOG_DIR = _DATA_BASE / "logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# 慢请求阈值(秒)
SLOW_REQUEST_THRESHOLD = float(os.getenv("SLOW_REQUEST_THRESHOLD", "3.0"))
def setup_file_logging():
"""配置应用日志按天轮转输出到文件"""
log_format = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 应用日志 - 按天轮转,保留30天
app_handler = TimedRotatingFileHandler(
filename=str(LOG_DIR / "app.log"),
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8",
)
app_handler.setFormatter(log_format)
app_handler.setLevel(logging.INFO)
# 错误日志 - 按天轮转,保留30天
error_handler = TimedRotatingFileHandler(
filename=str(LOG_DIR / "error.log"),
when="midnight",
interval=1,
backupCount=30,
encoding="utf-8",
)
error_handler.setFormatter(log_format)
error_handler.setLevel(logging.ERROR)
# 访问日志 - 按天轮转,保留14天
access_handler = TimedRotatingFileHandler(
filename=str(LOG_DIR / "access.log"),
when="midnight",
interval=1,
backupCount=14,
encoding="utf-8",
)
access_handler.setFormatter(log_format)
access_handler.setLevel(logging.INFO)
# 配置根logger
root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO)
root_logger.addHandler(app_handler)
root_logger.addHandler(error_handler)
# 配置访问日志专用logger
access_logger = logging.getLogger("aiflow.access")
access_logger.addHandler(access_handler)
access_logger.propagate = False
return access_logger
+33
View File
@@ -0,0 +1,33 @@
"""
AIFlow 数据库连接与Session管理
"""
from sqlalchemy import create_engine, event
from sqlalchemy.orm import sessionmaker, declarative_base
from sqlalchemy.pool import QueuePool
from .config import DATABASE_URL
engine = create_engine(
DATABASE_URL,
poolclass=QueuePool,
pool_size=20,
max_overflow=40,
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)
# 为日志表禁止DELETE操作添加触发器检查
# 通过数据库层确保operation_logs, audit_logs, token_usage_logs不可删除
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""获取数据库会话的依赖注入"""
db = SessionLocal()
try:
yield db
finally:
db.close()
+292
View File
@@ -0,0 +1,292 @@
"""
Redis事件总线 - 利用已有Redis实现消息队列
参照AstrBot EventBus设计,但使用Redis替代asyncio.Queue
"""
import json
import logging
import threading
import time
from typing import Callable
import redis
logger = logging.getLogger(__name__)
# Redis配置(与项目已有配置一致)
try:
from .config import REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
except ImportError:
REDIS_HOST = "localhost"
REDIS_PORT = 6379
REDIS_PASSWORD = "pwd2026"
def _get_redis():
"""获取Redis连接"""
return redis.Redis(
host=REDIS_HOST,
port=REDIS_PORT,
password=REDIS_PASSWORD,
decode_responses=True,
)
# Redis键名前缀
QUEUE_KEY = "aiflow:event_queue"
PROCESSING_KEY = "aiflow:event_processing"
class MessageEvent:
"""统一消息事件"""
def __init__(self, platform: str, channel_id: str, content: str,
sender_id: str = "", sender_name: str = "",
message_id: str = "", group_id: str = None,
content_type: str = "text", raw: dict = None):
self.platform = platform
self.channel_id = channel_id
self.content = content
self.sender_id = sender_id
self.sender_name = sender_name
self.message_id = message_id
self.group_id = group_id
self.content_type = content_type
self.raw = raw or {}
self.timestamp = int(time.time())
def to_dict(self) -> dict:
return {
"platform": self.platform,
"channel_id": self.channel_id,
"content": self.content,
"sender_id": self.sender_id,
"sender_name": self.sender_name,
"message_id": self.message_id,
"group_id": self.group_id,
"content_type": self.content_type,
"raw": self.raw,
"timestamp": self.timestamp,
}
@classmethod
def from_dict(cls, data: dict) -> "MessageEvent":
return cls(
platform=data.get("platform", ""),
channel_id=data.get("channel_id", ""),
content=data.get("content", ""),
sender_id=data.get("sender_id", ""),
sender_name=data.get("sender_name", ""),
message_id=data.get("message_id", ""),
group_id=data.get("group_id"),
content_type=data.get("content_type", "text"),
raw=data.get("raw", {}),
)
class RedisEventBus:
"""Redis事件总线 - 生产者-消费者模式"""
def __init__(self):
self._handlers: dict[str, list[Callable]] = {}
self._consumers: list[threading.Thread] = []
self._running = False
def publish(self, event: MessageEvent) -> bool:
"""发布事件到Redis队列"""
try:
r = _get_redis()
event_data = json.dumps(event.to_dict(), ensure_ascii=False)
r.lpush(QUEUE_KEY, event_data)
logger.debug(f"[EventBus] 发布事件: {event.platform}:{event.channel_id}")
return True
except Exception as e:
logger.error(f"[EventBus] 发布事件失败: {e}")
return False
def subscribe(self, platform: str, handler: Callable[[MessageEvent], None]):
"""订阅特定平台的事件"""
if platform not in self._handlers:
self._handlers[platform] = []
self._handlers[platform].append(handler)
logger.info(f"[EventBus] 注册处理器: {platform}")
def _consume_loop(self):
"""消费者循环:从Redis队列取出事件并分发给处理器"""
r = _get_redis()
while self._running:
try:
# BRPOP 阻塞等待新事件
result = r.brpop(QUEUE_KEY, timeout=2)
if not result:
continue
_, event_data = result
event_dict = json.loads(event_data)
event = MessageEvent.from_dict(event_dict)
# 分发给对应平台的处理器
platform = event.platform
handlers = self._handlers.get(platform, []) + self._handlers.get("*", [])
for handler in handlers:
try:
handler(event)
except Exception as e:
logger.error(f"[EventBus] 处理器异常(platform={platform}): {e}")
except Exception as e:
if self._running:
logger.error(f"[EventBus] 消费循环异常: {e}")
time.sleep(1)
def start(self, num_consumers: int = 2):
"""启动消费者线程"""
if self._running:
return
self._running = True
for i in range(num_consumers):
t = threading.Thread(target=self._consume_loop, name=f"EventBus-Consumer-{i}", daemon=True)
t.start()
self._consumers.append(t)
logger.info(f"[EventBus] 已启动 {num_consumers} 个消费者")
def stop(self):
"""停止所有消费者"""
self._running = False
for t in self._consumers:
t.join(timeout=5)
self._consumers.clear()
logger.info("[EventBus] 已停止所有消费者")
def queue_size(self) -> int:
"""获取队列长度"""
try:
r = _get_redis()
return r.llen(QUEUE_KEY)
except Exception:
return -1
# 全局单例
_event_bus_instance = None
def get_event_bus() -> RedisEventBus:
"""获取全局事件总线单例"""
global _event_bus_instance
if _event_bus_instance is None:
_event_bus_instance = RedisEventBus()
return _event_bus_instance
# ============================================================
# 单元测试
# ============================================================
if __name__ == "__main__":
print("=" * 60)
print("Redis事件总线 单元测试")
print("=" * 60)
# 测试MessageEvent
print("\n[测试1] MessageEvent 创建和序列化")
event = MessageEvent(
platform="feishu",
channel_id="ch_001",
content="你好,帮我查询天气",
sender_id="user_001",
sender_name="张三",
message_id="msg_001",
group_id="grp_001",
content_type="text",
raw={"custom": "data"},
)
event_dict = event.to_dict()
assert event_dict["platform"] == "feishu"
assert event_dict["channel_id"] == "ch_001"
assert event_dict["content"] == "你好,帮我查询天气"
assert event_dict["sender_id"] == "user_001"
assert event_dict["sender_name"] == "张三"
assert event_dict["message_id"] == "msg_001"
assert event_dict["group_id"] == "grp_001"
assert event_dict["content_type"] == "text"
assert event_dict["raw"] == {"custom": "data"}
assert "timestamp" in event_dict
print(" PASS: MessageEvent序列化正确")
# 测试MessageEvent反序列化
print("\n[测试2] MessageEvent 反序列化")
event2 = MessageEvent.from_dict(event_dict)
assert event2.platform == "feishu"
assert event2.channel_id == "ch_001"
assert event2.content == "你好,帮我查询天气"
assert event2.sender_id == "user_001"
assert event2.sender_name == "张三"
assert event2.message_id == "msg_001"
assert event2.group_id == "grp_001"
assert event2.content_type == "text"
assert event2.raw == {"custom": "data"}
print(" PASS: MessageEvent反序列化正确")
# 测试RedisEventBus发布事件
print("\n[测试3] RedisEventBus 发布事件")
bus = RedisEventBus()
result = bus.publish(event)
assert result is True, "事件发布应该成功"
print(" PASS: 事件发布成功")
# 测试队列长度
print("\n[测试4] 队列长度查询")
size = bus.queue_size()
assert size >= 1, f"队列中应该至少有1条消息,实际: {size}"
print(f" PASS: 队列长度={size}")
# 测试事件消费
print("\n[测试5] 事件消费")
received_events = []
def handler(evt: MessageEvent):
received_events.append(evt)
bus.subscribe("feishu", handler)
bus.start(num_consumers=1)
# 发布一个事件并等待消费
test_event = MessageEvent(
platform="feishu",
channel_id="ch_test",
content="测试消息",
sender_id="user_test",
)
bus.publish(test_event)
# 等待消费者处理
time.sleep(1)
assert len(received_events) >= 1, f"应该接收到事件,实际: {len(received_events)}"
if received_events:
assert received_events[-1].platform == "feishu"
assert received_events[-1].channel_id == "ch_test"
assert received_events[-1].content == "测试消息"
print(f" PASS: 事件被正确消费, platform={received_events[-1].platform}, content={received_events[-1].content}")
# 清理
bus.stop()
print(" PASS: EventBus正常停止")
# 测试全局单例
print("\n[测试6] 全局单例")
bus1 = get_event_bus()
bus2 = get_event_bus()
assert bus1 is bus2, "get_event_bus()应该返回同一个实例"
print(" PASS: 全局单例正确")
# 清理Redis测试队列
print("\n[测试7] 清理测试数据")
r = _get_redis()
r.delete(QUEUE_KEY)
size = bus.queue_size()
assert size == 0, f"清理后队列应该为空,实际: {size}"
print(f" PASS: Redis测试队列已清理, 队列长度={size}")
print("\n" + "=" * 60)
print("所有测试通过!")
print("=" * 60)
+339
View File
@@ -0,0 +1,339 @@
"""
消息组件系统 (Message Component System)
参考 AstrBot 消息组件设计,提供统一的消息链表示
消息链 (Message Chain) 是由多个消息组件(Component)组成的有序列表,
每个组件代表消息中的一种元素(文本、图片、@提及、回复、文件等)。
使用示例:
from app.message_components import Plain, Image, At, Reply, message_chain_to_dict, message_chain_from_dict
# 构建消息链
chain = [
Reply(message_id="msg_123", content_preview="你好"),
Plain(text=" "),
At(user_id="user_456", display_name="张三"),
Plain(text=" 看这张图:"),
Image(url="https://example.com/img.png", width=800, height=600),
]
# 序列化为字典列表(用于JSON传输)
dict_list = message_chain_to_dict(chain)
# 从字典列表恢复消息链
restored_chain = message_chain_from_dict(dict_list)
# 提取纯文本
text = extract_plain_text(chain) # " 看这张图:"
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Optional, List, Dict
# ============================================================
# 组件类型常量
# ============================================================
class ComponentType:
"""消息组件类型常量"""
PLAIN = "plain"
IMAGE = "image"
AT = "at"
REPLY = "reply"
FILE = "file"
# 类型到类的映射表,用于 from_dict 反序列化
_COMPONENT_REGISTRY: Dict[str, type] = {}
def _register_component(component_type: str, cls: type):
"""注册组件类型,用于反序列化"""
_COMPONENT_REGISTRY[component_type] = cls
# ============================================================
# 基类
# ============================================================
class BaseMessageComponent(ABC):
"""消息组件基类,所有消息组件必须继承此类"""
type: str
@abstractmethod
def to_dict(self) -> dict:
"""将组件序列化为字典"""
...
@classmethod
def from_dict(cls, data: dict) -> "BaseMessageComponent":
"""从字典反序列化组件"""
component_type = data.get("type", "")
subclass = _COMPONENT_REGISTRY.get(component_type)
if subclass and subclass is not cls:
return subclass.from_dict(data)
raise ValueError(f"未知的组件类型: {component_type}")
# ============================================================
# 具体组件实现
# ============================================================
@dataclass
class Plain(BaseMessageComponent):
"""纯文本组件"""
text: str
type: str = ComponentType.PLAIN
def to_dict(self) -> dict:
return {"type": self.type, "text": self.text}
@classmethod
def from_dict(cls, data: dict) -> "Plain":
return cls(text=data.get("text", ""))
_register_component(ComponentType.PLAIN, Plain)
@dataclass
class Image(BaseMessageComponent):
"""图片组件
Attributes:
url: 图片URL(网络地址或本地静态资源路径)
file_path: 本地文件路径
width: 图片宽度(像素)
height: 图片高度(像素)
"""
url: str = ""
file_path: str = ""
width: int = 0
height: int = 0
type: str = ComponentType.IMAGE
def to_dict(self) -> dict:
result = {"type": self.type}
if self.url:
result["url"] = self.url
if self.file_path:
result["file_path"] = self.file_path
if self.width:
result["width"] = self.width
if self.height:
result["height"] = self.height
return result
@classmethod
def from_dict(cls, data: dict) -> "Image":
return cls(
url=data.get("url", ""),
file_path=data.get("file_path", ""),
width=data.get("width", 0),
height=data.get("height", 0),
)
_register_component(ComponentType.IMAGE, Image)
@dataclass
class At(BaseMessageComponent):
"""@提及组件
Attributes:
user_id: 被提及用户的ID
display_name: 被提及用户的显示名称
"""
user_id: str
display_name: str = ""
type: str = ComponentType.AT
def to_dict(self) -> dict:
result = {"type": self.type, "user_id": self.user_id}
if self.display_name:
result["display_name"] = self.display_name
return result
@classmethod
def from_dict(cls, data: dict) -> "At":
return cls(
user_id=data.get("user_id", ""),
display_name=data.get("display_name", ""),
)
_register_component(ComponentType.AT, At)
@dataclass
class Reply(BaseMessageComponent):
"""回复组件(引用/回复某条消息)
Attributes:
message_id: 被回复消息的ID
content_preview: 被回复消息的内容预览
"""
message_id: str
content_preview: str = ""
type: str = ComponentType.REPLY
def to_dict(self) -> dict:
result = {"type": self.type, "message_id": self.message_id}
if self.content_preview:
result["content_preview"] = self.content_preview
return result
@classmethod
def from_dict(cls, data: dict) -> "Reply":
return cls(
message_id=data.get("message_id", ""),
content_preview=data.get("content_preview", ""),
)
_register_component(ComponentType.REPLY, Reply)
@dataclass
class File(BaseMessageComponent):
"""文件组件
Attributes:
filename: 文件名
url: 文件下载地址
size: 文件大小(字节)
"""
filename: str
url: str = ""
size: int = 0
type: str = ComponentType.FILE
def to_dict(self) -> dict:
result = {"type": self.type, "filename": self.filename}
if self.url:
result["url"] = self.url
if self.size:
result["size"] = self.size
return result
@classmethod
def from_dict(cls, data: dict) -> "File":
return cls(
filename=data.get("filename", ""),
url=data.get("url", ""),
size=data.get("size", 0),
)
_register_component(ComponentType.FILE, File)
# ============================================================
# 工厂函数
# ============================================================
def message_chain_to_dict(chain: List[BaseMessageComponent]) -> List[dict]:
"""将消息链组件列表转换为字典列表(用于JSON序列化)
Args:
chain: 消息组件列表
Returns:
字典列表,每个字典包含 type 和组件特定字段
"""
return [comp.to_dict() for comp in chain]
def message_chain_from_dict(data_list: List[dict]) -> List[BaseMessageComponent]:
"""从字典列表恢复消息链组件列表
Args:
data_list: 字典列表
Returns:
消息组件列表
"""
chain = []
for data in data_list:
component_type = data.get("type", "")
component_cls = _COMPONENT_REGISTRY.get(component_type)
if component_cls:
chain.append(component_cls.from_dict(data))
else:
# 未知类型,降级为 Plain 文本
chain.append(Plain(text=str(data)))
return chain
# ============================================================
# 辅助函数
# ============================================================
def extract_plain_text(chain: List[BaseMessageComponent]) -> str:
"""从消息链中提取所有纯文本内容,拼接为完整文本
遍历消息链,提取所有 Plain 组件的 text 字段并拼接。
Args:
chain: 消息组件列表
Returns:
拼接后的纯文本字符串
"""
texts = []
for comp in chain:
if isinstance(comp, Plain):
texts.append(comp.text)
return "".join(texts)
def extract_image_urls(chain: List[BaseMessageComponent]) -> List[str]:
"""从消息链中提取所有图片URL
Args:
chain: 消息组件列表
Returns:
图片URL列表
"""
urls = []
for comp in chain:
if isinstance(comp, Image) and comp.url:
urls.append(comp.url)
return urls
def extract_at_user_ids(chain: List[BaseMessageComponent]) -> List[str]:
"""从消息链中提取所有被@的用户ID
Args:
chain: 消息组件列表
Returns:
用户ID列表
"""
ids = []
for comp in chain:
if isinstance(comp, At):
ids.append(comp.user_id)
return ids
def has_component_type(chain: List[BaseMessageComponent], component_type: str) -> bool:
"""判断消息链中是否包含指定类型的组件
Args:
chain: 消息组件列表
component_type: 组件类型字符串(如 "image", "at" 等)
Returns:
True 如果包含该类型组件
"""
for comp in chain:
if comp.type == component_type:
return True
return False
+54
View File
@@ -0,0 +1,54 @@
"""
统一会话标识 MessageSession - 参照AstrBot MessageSession设计
格式: platform:type:session_id
"""
from dataclasses import dataclass
from enum import Enum
from typing import Optional
class SessionType(str, Enum):
FRIEND = "friend" # 私聊
GROUP = "group" # 群聊
CHANNEL = "channel" # 频道/广播
@dataclass
class MessageSession:
"""统一消息会话标识 - 用于跨平台会话追踪"""
platform_name: str # wecom/feishu/dingtalk/telegram/slack/whatsapp
session_type: SessionType
session_id: str # 会话唯一ID(平台侧的用户ID或群ID)
@property
def unified_msg_origin(self) -> str:
"""统一消息来源字符串: platform:type:session_id"""
return f"{self.platform_name}:{self.session_type.value}:{self.session_id}"
@classmethod
def from_str(cls, origin: str) -> "MessageSession":
"""从格式化字符串解析: platform:type:session_id"""
parts = origin.split(":", 2)
if len(parts) != 3:
raise ValueError(f"无效的消息来源格式: {origin},应为 platform:type:session_id")
return cls(
platform_name=parts[0],
session_type=SessionType(parts[1]),
session_id=parts[2],
)
@classmethod
def from_channel_event(cls, platform: str, channel_id: str, user_id: str, group_id: Optional[str] = None) -> "MessageSession":
"""从频道事件创建"""
if group_id:
return cls(platform_name=platform, session_type=SessionType.GROUP, session_id=group_id)
return cls(platform_name=platform, session_type=SessionType.FRIEND, session_id=user_id)
def __str__(self) -> str:
return self.unified_msg_origin
def __hash__(self) -> int:
return hash(self.unified_msg_origin)
def __eq__(self, other) -> bool:
if isinstance(other, MessageSession):
return self.unified_msg_origin == other.unified_msg_origin
return False
Whitespace-only changes.
@@ -0,0 +1,26 @@
"""
AIFlow 日志中间件
- 记录用户操作日志
- 记录API调用日志(Token消耗)
- 操作日志和审计日志不可删除
"""
from fastapi import Request
from .log_service import LogService
async def log_operation(user_id: str, username: str, action: str, target: str = None,
target_id: str = None, detail: str = None, ip: str = None):
"""记录用户操作日志(异步安全)"""
try:
LogService.log_operation(user_id, username, action, target, target_id, detail, ip)
except Exception as e:
print(f"[LogError] 操作日志记录失败: {e}")
async def log_audit(admin_id: str, admin_name: str, action: str,
target_type: str = None, target_id: str = None, detail: str = None):
"""记录管理员审计日志"""
try:
LogService.log_audit(admin_id, admin_name, action, target_type, target_id, detail)
except Exception as e:
print(f"[AuditError] 审计日志记录失败: {e}")
@@ -0,0 +1,80 @@
"""
日志服务 - 记录各类操作日志
实现日志不可删除策略
"""
from ..database import SessionLocal
from ..models import OperationLog, AuditLog, TokenUsageLog
import uuid
class LogService:
@staticmethod
def log_operation(user_id: str, username: str, action: str,
target: str = None, target_id: str = None,
detail: str = None, ip: str = None):
"""记录用户操作日志"""
db = SessionLocal()
try:
log = OperationLog(
id=str(uuid.uuid4()),
user_id=user_id,
username=username,
action=action,
target=target,
target_id=target_id,
detail=detail,
ip=ip,
)
db.add(log)
db.commit()
finally:
db.close()
@staticmethod
def log_audit(admin_id: str, admin_name: str, action: str,
target_type: str = None, target_id: str = None,
detail: str = None):
"""记录管理员审计日志"""
db = SessionLocal()
try:
log = AuditLog(
id=str(uuid.uuid4()),
admin_id=admin_id,
admin_name=admin_name,
action=action,
target_type=target_type,
target_id=target_id,
detail=detail,
)
db.add(log)
db.commit()
finally:
db.close()
@staticmethod
def log_token_usage(user_id: str, model_id: str = None, model_name: str = None,
app_id: str = None, app_name: str = None,
conversation_id: str = None, prompt_tokens: int = 0,
completion_tokens: int = 0, total_tokens: int = 0,
cost: float = 0):
"""记录Token消耗日志"""
db = SessionLocal()
try:
log = TokenUsageLog(
id=str(uuid.uuid4()),
user_id=user_id,
model_id=model_id,
model_name=model_name,
app_id=app_id,
app_name=app_name,
conversation_id=conversation_id,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
total_tokens=total_tokens,
cost=cost,
)
db.add(log)
db.commit()
finally:
db.close()
+937
View File
@@ -0,0 +1,937 @@
"""
AIFlow 数据库模型定义 - SQLAlchemy ORM
所有用户数据查询必须带 user_id 过滤实现物理隔离
"""
import uuid
from datetime import datetime, date
from sqlalchemy import (
Column, String, Integer, BigInteger, Float, Boolean,
DateTime, Date, Text, Enum, ForeignKey, Index, JSON,
DECIMAL, UniqueConstraint
)
from sqlalchemy.orm import relationship
from .database import Base
def gen_uuid() -> str:
return str(uuid.uuid4())
# ============================================================
# 用户与权限
# ============================================================
class User(Base):
__tablename__ = "users"
id = Column(String(36), primary_key=True, default=gen_uuid)
username = Column(String(50), unique=True, nullable=False)
email = Column(String(100), default=None)
phone = Column(String(20), default=None)
password_hash = Column(String(255), nullable=False)
avatar = Column(String(500), default=None)
role = Column(String(10), nullable=False, default="user") # user / admin
status = Column(String(10), nullable=False, default="active") # active / banned
real_name_status = Column(String(20), nullable=False, default="unverified")
real_name = Column(String(50), default=None)
id_card = Column(String(20), default=None)
membership_plan_id = Column(String(36), default=None)
membership_level = Column(Integer, default=0)
membership_expire_at = Column(DateTime, default=None)
auto_renew = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class LoginLog(Base):
__tablename__ = "login_logs"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
ip = Column(String(45), default=None)
user_agent = Column(String(500), default=None)
login_at = Column(DateTime, default=datetime.now)
class ApiKey(Base):
__tablename__ = "api_keys"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
api_key = Column(String(64), unique=True, nullable=False)
secret = Column(String(128), nullable=False)
name = Column(String(100), default=None)
status = Column(String(10), nullable=False, default="active") # active / revoked
last_used_at = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
expired_at = Column(DateTime, default=None)
# ============================================================
# AI聊天与频道
# ============================================================
class Conversation(Base):
__tablename__ = "conversations"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
title = Column(String(200), nullable=False, default="新对话")
model_id = Column(String(36), default=None)
status = Column(String(10), nullable=False, default="active") # active / archived
pinned = Column(Boolean, default=False)
knowledge_graph_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Message(Base):
__tablename__ = "messages"
id = Column(String(36), primary_key=True, default=gen_uuid)
conversation_id = Column(String(36), ForeignKey("conversations.id"), nullable=False)
role = Column(String(10), nullable=False) # user / assistant / system
content = Column(Text, nullable=False)
token_used = Column(Integer, default=0)
model_id = Column(String(36), default=None)
knowledge_ids = Column(JSON, default=None)
agent_ids = Column(JSON, default=None)
skill_ids = Column(JSON, default=None)
used_skills = Column(JSON, default=None, comment="该轮对话调用的进化技能名列表")
created_at = Column(DateTime, default=datetime.now)
class Channel(Base):
__tablename__ = "channels"
id = Column(String(36), primary_key=True, default=gen_uuid)
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, default=None)
platform = Column(String(20), nullable=False)
webhook_url = Column(String(500), default=None)
credential = Column(JSON, default=None)
oauth_state = Column(String(500), default=None)
settings = Column(JSON, default=None)
account_name = Column(String(100), default=None)
default_agent_id = Column(String(36), default=None)
default_skill_id = Column(String(36), default=None)
knowledge_base_id = Column(String(36), default=None)
bound_model_id = Column(String(36), default=None)
features = Column(JSON, default=None)
status = Column(String(20), nullable=False, default="pending")
is_active = Column(Boolean, default=True)
last_active = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class ChannelMember(Base):
__tablename__ = "channel_members"
__table_args__ = (UniqueConstraint("channel_id", "user_id"),)
id = Column(String(36), primary_key=True, default=gen_uuid)
channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
role = Column(String(10), nullable=False, default="member") # owner / member
joined_at = Column(DateTime, default=datetime.now)
class ChannelMessage(Base):
__tablename__ = "channel_messages"
id = Column(String(36), primary_key=True, default=gen_uuid)
channel_id = Column(String(36), ForeignKey("channels.id"), nullable=False)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
role = Column(String(10), nullable=False)
content = Column(Text, nullable=False)
token_used = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
# ============================================================
# 知识库
# ============================================================
class KnowledgeBase(Base):
__tablename__ = "knowledge_bases"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
name = Column(String(200), nullable=False)
description = Column(Text, default=None)
directory_path = Column(String(500), default="/")
document_count = Column(Integer, default=0)
knowledge_graph_count = Column(Integer, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class KnowledgeDirectory(Base):
__tablename__ = "knowledge_directories"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), default=None)
parent_id = Column(String(36), default=None)
name = Column(String(200), nullable=False)
created_at = Column(DateTime, default=datetime.now)
class KnowledgeDocument(Base):
__tablename__ = "knowledge_documents"
id = Column(String(36), primary_key=True, default=gen_uuid)
knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False)
directory_id = Column(String(36), ForeignKey("knowledge_directories.id"), default=None)
filename = Column(String(255), nullable=False)
file_type = Column(String(20), nullable=False)
file_size = Column(BigInteger, default=0)
file_path = Column(String(500), default=None)
status = Column(String(20), nullable=False, default="uploading")
chunk_count = Column(Integer, default=0)
error_message = Column(Text, default=None)
content = Column(Text, default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class KnowledgeGraphNode(Base):
__tablename__ = "knowledge_graph_nodes"
id = Column(String(36), primary_key=True, default=gen_uuid)
knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False)
source_doc_id = Column(String(36), ForeignKey("knowledge_documents.id"), default=None)
label = Column(String(500), nullable=False)
type = Column(String(50), nullable=False, comment="entity/attribute/concept/event")
properties = Column(JSON, default=None)
description = Column(Text, default=None)
created_at = Column(DateTime, default=datetime.now)
class KnowledgeGraphEdge(Base):
__tablename__ = "knowledge_graph_edges"
id = Column(String(36), primary_key=True, default=gen_uuid)
knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False)
source_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False)
target_node_id = Column(String(36), ForeignKey("knowledge_graph_nodes.id"), nullable=False)
relation = Column(String(200), nullable=False)
weight = Column(Float, default=1.0)
description = Column(Text, default=None)
created_at = Column(DateTime, default=datetime.now)
class KnowledgeGraphBuildLog(Base):
__tablename__ = "knowledge_graph_build_logs"
id = Column(String(36), primary_key=True, default=gen_uuid)
knowledge_base_id = Column(String(36), ForeignKey("knowledge_bases.id"), nullable=False)
document_id = Column(String(36), ForeignKey("knowledge_documents.id"), nullable=False)
node_count = Column(Integer, default=0)
edge_count = Column(Integer, default=0)
status = Column(String(20), default="completed")
created_at = Column(DateTime, default=datetime.now)
# ============================================================
# 应用市场
# ============================================================
class MarketApp(Base):
__tablename__ = "market_apps"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(200), nullable=False)
type = Column(String(10), nullable=False) # skill / agent / mcp
source = Column(String(10), nullable=False, default="user") # official / user
publisher_id = Column(String(36), ForeignKey("users.id"), default=None)
publisher_name = Column(String(100), default=None)
description = Column(Text, default=None)
icon = Column(String(500), default=None)
tutorial = Column(Text, default=None)
price = Column(DECIMAL(10, 2), nullable=False, default=0.00)
is_member_free = Column(Boolean, default=False)
join_promotion = Column(Boolean, default=False)
status = Column(String(10), nullable=False, default="pending") # pending / published / removed
rating_avg = Column(DECIMAL(3, 2), default=0.00)
review_count = Column(Integer, default=0)
install_count = Column(Integer, default=0)
skill_content = Column(Text, default=None)
mcp_transport = Column(String(20), default=None)
agent_endpoint = Column(String(500), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class AppPurchase(Base):
__tablename__ = "app_purchases"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False)
price = Column(DECIMAL(10, 2), nullable=False, default=0.00)
order_id = Column(String(36), default=None)
status = Column(String(20), nullable=False, default="purchased") # purchased / installed
installed_at = Column(DateTime, default=None)
purchased_at = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
class AppReview(Base):
__tablename__ = "app_reviews"
__table_args__ = (UniqueConstraint("user_id", "app_id"),)
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False)
rating = Column(Integer, nullable=False)
content = Column(Text, default=None)
created_at = Column(DateTime, default=datetime.now)
class AppInstallation(Base):
__tablename__ = "app_installations"
__table_args__ = (UniqueConstraint("user_id", "app_id"),)
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
app_id = Column(String(36), ForeignKey("market_apps.id"), nullable=False)
installed_at = Column(DateTime, default=datetime.now)
# ============================================================
# 付费与订单
# ============================================================
class MembershipPlan(Base):
__tablename__ = "membership_plans"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), nullable=False)
level = Column(Integer, nullable=False, default=0)
duration_type = Column(String(10), nullable=False) # month / quarter / year
price = Column(DECIMAL(10, 2), nullable=False)
daily_free_tokens = Column(Integer, default=0)
features = Column(JSON, default=None)
free_app_ids = Column(JSON, default=None)
free_model_ids = Column(JSON, default=None)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class MembershipSubscription(Base):
__tablename__ = "membership_subscriptions"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
plan_id = Column(String(36), ForeignKey("membership_plans.id"), nullable=False)
order_id = Column(String(36), default=None)
start_at = Column(DateTime, nullable=False)
end_at = Column(DateTime, nullable=False)
auto_renew = Column(Boolean, default=False)
status = Column(String(15), nullable=False, default="active") # active / expired / downgraded
created_at = Column(DateTime, default=datetime.now)
class Order(Base):
__tablename__ = "orders"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
order_type = Column(String(15), nullable=False) # membership / service / app
item_id = Column(String(36), default=None)
item_name = Column(String(200), default=None)
amount = Column(DECIMAL(10, 2), nullable=False)
coupon_id = Column(String(36), default=None)
discount_amount = Column(DECIMAL(10, 2), default=0.00)
payment_method = Column(String(10), default=None) # wechat / alipay
payment_id = Column(String(200), default=None)
status = Column(String(15), nullable=False, default="pending") # pending / paid / refunded / cancelled
paid_at = Column(DateTime, default=None)
receipt_number = Column(String(100), default=None)
receipt_type = Column(String(20), default=None)
receipt_title = Column(String(200), default=None)
receipt_tax_number = Column(String(50), default=None)
receipt_issued_at = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Coupon(Base):
__tablename__ = "coupons"
id = Column(String(36), primary_key=True, default=gen_uuid)
code = Column(String(50), unique=True, nullable=False)
type = Column(String(10), nullable=False) # fixed / percent
value = Column(DECIMAL(10, 2), nullable=False)
min_amount = Column(DECIMAL(10, 2), default=0.00)
valid_from = Column(DateTime, nullable=False)
valid_to = Column(DateTime, nullable=False)
total_count = Column(Integer, default=None)
used_count = Column(Integer, default=0)
status = Column(String(10), nullable=False, default="active") # active / expired / disabled
created_at = Column(DateTime, default=datetime.now)
class UserCoupon(Base):
__tablename__ = "user_coupons"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
coupon_id = Column(String(36), ForeignKey("coupons.id"), nullable=False)
status = Column(String(10), nullable=False, default="unused") # unused / used / expired
used_at = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
class ValueAddedService(Base):
__tablename__ = "value_added_services"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(200), nullable=False)
description = Column(Text, default=None)
price = Column(DECIMAL(10, 2), nullable=False)
type = Column(String(20), nullable=False) # app_custom / platform_proxy / data_custom / private_project
features = Column(JSON, default=None) # 服务特性
status = Column(String(10), nullable=False, default="active") # active / inactive / deleted
assigned_agent_id = Column(String(36), default=None) # 应用定制服务绑定的Agent(市场应用ID)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class CustomServiceOrder(Base):
__tablename__ = "custom_service_orders"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
service_id = Column(String(36), default=None)
service_type = Column(String(10), nullable=False)
description = Column(Text, default=None)
status = Column(String(15), nullable=False, default="pending")
price = Column(DECIMAL(10, 2), default=0.00)
order_id = Column(String(36), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class CustomServiceRequest(Base):
"""增值服务工单表 - 用于数据定制服务和私有化项目服务"""
__tablename__ = "custom_service_requests"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
service_id = Column(String(36), ForeignKey("value_added_services.id"), nullable=False)
request_type = Column(String(20), nullable=False) # data_custom / private_project
title = Column(String(200), nullable=False)
description = Column(Text, default=None)
requirements = Column(JSON, default=None) # 需求详情JSON
status = Column(String(20), nullable=False, default="pending") # pending / processing / completed / rejected
assigned_agent_id = Column(String(36), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# ============================================================
# Token与计费
# ============================================================
class ModelProvider(Base):
__tablename__ = "model_providers"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), nullable=False)
protocol = Column(String(30), nullable=False, default="openai_compatible")
api_key = Column(String(255), default=None)
api_base_url = Column(String(500), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class AIModel(Base):
__tablename__ = "models"
id = Column(String(36), primary_key=True, default=gen_uuid)
provider_id = Column(String(36), ForeignKey("model_providers.id"), nullable=False)
name = Column(String(100), nullable=False)
api_key = Column(String(255), default=None)
endpoint = Column(String(500), default=None)
capabilities = Column(JSON, default=None)
timeout = Column(Integer, default=60)
status = Column(String(10), nullable=False, default="paid") # free / paid
price_per_million_tokens = Column(DECIMAL(10, 4), nullable=False, default=0.0000)
is_enabled = Column(Boolean, default=True)
is_default = Column(Boolean, default=False) # 全局默认模型
total_calls = Column(BigInteger, default=0)
total_tokens = Column(BigInteger, default=0)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class TokenUsageLog(Base):
__tablename__ = "token_usage_logs"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
model_id = Column(String(36), default=None)
model_name = Column(String(100), default=None)
app_id = Column(String(36), default=None)
app_name = Column(String(200), default=None)
conversation_id = Column(String(36), default=None)
prompt_tokens = Column(Integer, default=0)
completion_tokens = Column(Integer, default=0)
total_tokens = Column(Integer, default=0)
cost = Column(DECIMAL(10, 6), default=0.000000)
created_at = Column(DateTime, default=datetime.now)
class TokenBalance(Base):
__tablename__ = "token_balances"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False)
total_consumed = Column(BigInteger, default=0)
balance = Column(BigInteger, default=0)
daily_free_used = Column(BigInteger, default=0)
daily_free_date = Column(Date, default=None)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# ============================================================
# 通知与日志
# ============================================================
class Notification(Base):
__tablename__ = "notifications"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
type = Column(String(15), nullable=False)
title = Column(String(200), nullable=False)
content = Column(Text, default=None)
is_read = Column(Boolean, default=False)
created_at = Column(DateTime, default=datetime.now)
class OperationLog(Base):
__tablename__ = "operation_logs"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), nullable=False)
username = Column(String(50), default=None)
action = Column(String(100), nullable=False)
target = Column(String(100), default=None)
target_id = Column(String(36), default=None)
detail = Column(Text, default=None)
ip = Column(String(45), default=None)
created_at = Column(DateTime, default=datetime.now)
class AuditLog(Base):
__tablename__ = "audit_logs"
id = Column(String(36), primary_key=True, default=gen_uuid)
admin_id = Column(String(36), nullable=False)
admin_name = Column(String(50), default=None)
action = Column(String(100), nullable=False)
target_type = Column(String(50), default=None)
target_id = Column(String(36), default=None)
detail = Column(Text, default=None)
created_at = Column(DateTime, default=datetime.now)
class RiskRule(Base):
__tablename__ = "risk_rules"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), nullable=False)
type = Column(String(15), nullable=False)
threshold = Column(Integer, default=0)
duration = Column(Integer, default=3600)
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# ============================================================
# 系统配置
# ============================================================
class SiteSetting(Base):
__tablename__ = "site_settings"
id = Column(String(36), primary_key=True, default=gen_uuid)
key = Column(String(100), unique=True, nullable=False)
value = Column(Text, default=None)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class EmailConfig(Base):
__tablename__ = "email_configs"
id = Column(String(36), primary_key=True, default=gen_uuid)
smtp_host = Column(String(200), nullable=False)
smtp_port = Column(Integer, nullable=False, default=587)
username = Column(String(200), nullable=False)
password = Column(String(200), nullable=False)
from_address = Column(String(200), nullable=False)
use_tls = Column(Boolean, default=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class SmsConfig(Base):
__tablename__ = "sms_configs"
id = Column(String(36), primary_key=True, default=gen_uuid)
provider = Column(String(50), nullable=False)
api_key = Column(String(200), nullable=False)
api_secret = Column(String(200), nullable=False)
sign_name = Column(String(100), default=None)
template_code = Column(String(100), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class PaymentConfig(Base):
__tablename__ = "payment_configs"
id = Column(String(36), primary_key=True, default=gen_uuid)
type = Column(String(10), nullable=False) # wechat / alipay
app_id = Column(String(100), nullable=False)
merchant_id = Column(String(100), default=None) # 微信: 商户号
api_key = Column(String(255), nullable=False) # 微信: APIv2密钥 / 支付宝: 应用私钥
api_v3_key = Column(String(255), default=None) # 微信: APIv3密钥
cert_path = Column(String(500), default=None) # 微信: 证书路径
cert_serial_no = Column(String(200), default=None) # 微信: 证书序列号
alipay_public_key = Column(Text, default=None) # 支付宝: 支付宝公钥
app_private_key = Column(Text, default=None) # 支付宝: 应用私钥
gateway_url = Column(String(500), default=None) # 支付宝: 网关地址
notify_url = Column(String(500), default=None) # 支付回调通知地址
return_url = Column(String(500), default=None) # 支付宝: 同步跳转地址
status = Column(String(10), nullable=False, default="inactive") # active / inactive
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class RegisterSetting(Base):
__tablename__ = "register_settings"
id = Column(String(36), primary_key=True, default=gen_uuid)
phone_required = Column(Boolean, default=True)
phone_sms_required = Column(Boolean, default=True)
email_required = Column(Boolean, default=True)
email_verify_required = Column(Boolean, default=True)
real_name_required = Column(Boolean, default=False)
aliyun_access_key = Column(String(200), default=None)
aliyun_secret_key = Column(String(200), default=None)
aliyun_endpoint = Column(String(200), default=None)
sms_sign_name = Column(String(100), default=None)
sms_template_code = Column(String(50), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# ============================================================
# 广告位
# ============================================================
class AdBanner(Base):
__tablename__ = "ad_banners"
id = Column(String(36), primary_key=True, default=gen_uuid)
title = Column(String(200), nullable=False)
image_url = Column(String(500), default=None)
link_app_id = Column(String(36), default=None)
link_app_name = Column(String(200), default=None)
sort_order = Column(Integer, default=0)
click_count = Column(Integer, default=0)
status = Column(String(10), nullable=False, default="active") # active / inactive
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# ============================================================
# 资产管理
# ============================================================
class AssetCategory(Base):
__tablename__ = "asset_categories"
id = Column(String(36), primary_key=True, default=gen_uuid)
name = Column(String(100), nullable=False)
type = Column(String(10), nullable=False)
icon = Column(String(200), default=None)
color = Column(String(20), default=None)
is_default = Column(Boolean, default=False)
user_id = Column(String(36), default=None)
created_at = Column(DateTime, default=datetime.now)
class Asset(Base):
__tablename__ = "assets"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
name = Column(String(200), nullable=False)
category = Column(String(10), nullable=False)
sub_category = Column(String(50), default=None)
file_type = Column(String(50), default=None)
size = Column(BigInteger, default=0)
description = Column(Text, default=None)
cover_url = Column(String(500), default=None)
url = Column(String(500), default=None)
content = Column(Text, default=None)
novel_meta = Column(JSON, default=None)
status = Column(String(10), nullable=False, default="active") # active / deleted
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class NovelChapter(Base):
__tablename__ = "novel_chapters"
id = Column(String(36), primary_key=True, default=gen_uuid)
asset_id = Column(String(36), ForeignKey("assets.id"), nullable=False)
title = Column(String(200), nullable=False)
word_count = Column(Integer, default=0)
sort_order = Column(Integer, default=0)
content = Column(Text(length=4294967295), default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# ============================================================
# 预留扩展
# ============================================================
class Team(Base):
__tablename__ = "teams"
id = Column(String(36), primary_key=True, default=gen_uuid)
owner_id = Column(String(36), ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
description = Column(Text, default=None)
status = Column(String(10), nullable=False, default="active")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class Referral(Base):
__tablename__ = "referrals"
id = Column(String(36), primary_key=True, default=gen_uuid)
referrer_id = Column(String(36), ForeignKey("users.id"), nullable=False)
referee_id = Column(String(36), ForeignKey("users.id"), nullable=False)
referral_code = Column(String(50), default=None)
status = Column(String(10), nullable=False, default="pending")
commission_rate = Column(DECIMAL(5, 2), default=0.00)
created_at = Column(DateTime, default=datetime.now)
class OpenApiClient(Base):
__tablename__ = "open_api_clients"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
name = Column(String(100), nullable=False)
client_id = Column(String(64), unique=True, nullable=False)
client_secret = Column(String(128), nullable=False)
allowed_models = Column(JSON, default=None)
rate_limit = Column(Integer, default=60)
status = Column(String(10), nullable=False, default="active")
created_at = Column(DateTime, default=datetime.now)
# ============================================================
# API调用统计
# ============================================================
class ApiCallStat(Base):
"""API调用统计表 - 按端点聚合"""
__tablename__ = "api_call_stats"
id = Column(String(36), primary_key=True, default=gen_uuid)
endpoint = Column(String(200), nullable=False)
method = Column(String(10), nullable=False)
call_count = Column(BigInteger, default=0)
total_response_time = Column(Float, default=0.0)
error_count = Column(BigInteger, default=0)
last_called_at = Column(DateTime, default=None)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
__table_args__ = (
UniqueConstraint("endpoint", "method", name="uk_endpoint_method"),
)
class SlowRequestLog(Base):
"""慢请求记录表"""
__tablename__ = "slow_request_logs"
id = Column(String(36), primary_key=True, default=gen_uuid)
request_id = Column(String(64), default=None)
method = Column(String(10), nullable=False)
endpoint = Column(String(500), nullable=False)
status_code = Column(Integer, default=0)
response_time = Column(Float, default=0.0)
threshold = Column(Float, default=3.0)
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
Index("idx_srl_created", "created_at"),
)
# ============================================================
# AI自进化引擎 - 用户进化配置
# ============================================================
class UserEvolutionConfig(Base):
__tablename__ = "user_evolution_config"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), unique=True, nullable=False, index=True)
skill_dir_path = Column(String(500), nullable=True, comment="用户Skill仓库根目录")
trajectory_dir_path = Column(String(500), nullable=True, comment="用户对话轨迹目录")
watcher_status = Column(String(20), default="stopped", comment="运行中/已停止/错误")
watcher_error = Column(String(500), nullable=True, comment="watcher异常信息")
pipeline_status = Column(String(20), default="idle", comment="流水线状态: running/idle/error")
pipeline_processed = Column(Integer, default=0, comment="已处理轨迹数")
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class EvolutionSkill(Base):
__tablename__ = "evolution_skills"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
skill_name = Column(String(200), nullable=False, comment="技能名称")
description = Column(Text, nullable=True)
tags = Column(JSON, nullable=True, comment="标签列表")
category = Column(String(100), nullable=True, comment="技能分类")
state = Column(String(20), default="baby", comment="baby/main/staging/frozen")
use_count = Column(Integer, default=0, comment="使用次数")
version = Column(Integer, default=1)
skill_md_path = Column(String(500), nullable=True, comment="SKILL.md文件路径")
git_commit_sha = Column(String(40), nullable=True)
last_used_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
class EvolutionEvent(Base):
__tablename__ = "evolution_events"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
skill_name = Column(String(200), nullable=False)
event_type = Column(String(50), nullable=False, comment="created/promoted/rejected/frozen/unfrozen/edited/deleted/milestone")
description = Column(Text, nullable=True)
version_before = Column(Integer, nullable=True)
version_after = Column(Integer, nullable=True)
created_at = Column(DateTime, default=datetime.now)
class EvolutionTrajectory(Base):
__tablename__ = "evolution_trajectories"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
conversation_id = Column(String(36), nullable=True, comment="关联的对话ID")
file_path = Column(String(500), nullable=True, comment="轨迹文件路径")
status = Column(String(20), default="discovered", comment="discovered/splitting/split_done/indexed/clustering/done/error/filtered")
atom_count = Column(Integer, default=0)
token_used = Column(Integer, default=0)
error_msg = Column(String(500), nullable=True, comment="轨迹处理失败的错误信息")
retry_count = Column(Integer, default=0, comment="失败重试次数")
tasks_extracted = Column(Integer, default=0, comment="拆分的AtomTask数量")
created_at = Column(DateTime, default=datetime.now)
processed_at = Column(DateTime, nullable=True)
class SkillTrajectoryLink(Base):
"""技能-轨迹关联表:记录技能与对话轨迹的关联关系"""
__tablename__ = "skill_trajectory_links"
id = Column(String(36), primary_key=True, default=gen_uuid)
skill_name = Column(String(200), nullable=False, index=True, comment="关联的技能名称")
user_id = Column(String(36), ForeignKey("users.id"), nullable=False, index=True)
trajectory_id = Column(String(36), nullable=True, comment="关联的轨迹ID")
atom_id = Column(String(36), nullable=True, comment="原子任务ID")
weightscore = Column(Integer, default=0, comment="关联权重分")
created_at = Column(DateTime, default=datetime.now)
# ============================================================
# AI自进化v2 - 新增表
# ============================================================
class SkillVersion(Base):
"""技能版本历史表:存储每个版本的SKILL.md完整内容,支持回滚"""
__tablename__ = "skill_versions"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
skill_name = Column(String(200), nullable=False)
version = Column(Integer, nullable=False, default=1)
skill_md_content = Column(Text(4294967295), nullable=True, comment="该版本的SKILL.md完整内容")
git_commit_sha = Column(String(40), nullable=True)
description = Column(Text, nullable=True, comment="版本变更说明")
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
Index("idx_sv_user_skill", "user_id", "skill_name"),
Index("idx_sv_version", "version"),
)
class EvolutionReport(Base):
"""进化报告表:存储周报/月报"""
__tablename__ = "evolution_reports"
id = Column(String(36), primary_key=True, default=gen_uuid)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
report_type = Column(String(20), nullable=False, comment="weekly / monthly")
period_start = Column(Date, nullable=False)
period_end = Column(Date, nullable=False)
report_data = Column(JSON, nullable=True, comment="报告详细数据(JSON)")
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
Index("idx_er_user_type", "user_id", "report_type"),
Index("idx_er_period", "period_start", "period_end"),
)
class SkillPublishRecord(Base):
"""技能市场发布记录表"""
__tablename__ = "skill_publish_records"
id = Column(String(36), primary_key=True, default=gen_uuid)
skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id")
skill_name = Column(String(200), nullable=False)
user_id = Column(String(36), ForeignKey("users.id"), nullable=False)
market_app_id = Column(String(36), nullable=True, comment="关联market_apps.id")
price = Column(DECIMAL(10, 2), default=0.00)
market_description = Column(Text, nullable=True)
status = Column(String(20), nullable=False, default="published", comment="published/unpublished")
published_at = Column(DateTime, default=datetime.now)
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
Index("idx_spr_skill_id", "skill_id"),
Index("idx_spr_user_id", "user_id"),
Index("idx_spr_status", "status"),
)
class TeamSharedSkill(Base):
"""团队共享技能表"""
__tablename__ = "team_shared_skills"
id = Column(String(36), primary_key=True, default=gen_uuid)
team_id = Column(String(36), ForeignKey("teams.id"), nullable=False)
skill_id = Column(String(36), nullable=False, comment="关联evolution_skills.id")
skill_name = Column(String(200), nullable=False)
shared_by = Column(String(36), ForeignKey("users.id"), nullable=False)
shared_by_name = Column(String(100), nullable=True, comment="分享者昵称")
is_public = Column(Boolean, default=True, comment="团队内是否可见")
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
UniqueConstraint("team_id", "skill_id", name="uk_tss_team_skill"),
)
+347
View File
@@ -0,0 +1,347 @@
"""
Pipeline 消息处理管道 - 参照AstrBot Stage链式设计,同步版兼容FastAPI
消息处理阶段可插拔,链式调用
"""
from abc import ABC, abstractmethod
from typing import Any
import logging
import time
logger = logging.getLogger(__name__)
class StageResult:
"""阶段处理结果"""
def __init__(self, success: bool = True, data: dict = None, error: str = None, stop: bool = False):
self.success = success
self.data = data or {}
self.error = error
self.stop = stop # 是否停止后续阶段
class PipelineStage(ABC):
"""管道阶段基类"""
def __init__(self, name: str):
self.name = name
@abstractmethod
def process(self, context: dict) -> StageResult:
"""处理消息上下文,返回StageResult"""
...
# === 具体阶段实现 ===
class WakingCheckStage(PipelineStage):
"""唤醒检查:检查是否需要AI响应"""
def __init__(self):
super().__init__("waking_check")
def process(self, context: dict) -> StageResult:
message = context.get("message", "")
platform = context.get("platform", "")
config = context.get("channel_config", {})
bot_prefix = config.get("bot_prefix", "@bot")
is_private = context.get("is_private", True)
is_at_bot = bot_prefix.lower() in message.lower() if bot_prefix else False
if is_private or is_at_bot or message.strip().startswith("/"):
return StageResult(success=True, data={"should_respond": True})
# 群聊需要@或唤醒词
if not is_private and not is_at_bot:
return StageResult(success=True, data={"should_respond": False}, stop=True)
return StageResult(success=True, data={"should_respond": True})
class RateLimitCheckStage(PipelineStage):
"""速率限制检查"""
def __init__(self, max_per_minute: int = 30):
super().__init__("rate_limit_check")
self.max_per_minute = max_per_minute
self._counter = {}
def process(self, context: dict) -> StageResult:
user_id = context.get("sender_id", "unknown")
now = time.time()
key = f"{user_id}:{int(now // 60)}"
count = self._counter.get(key, 0)
if count >= self.max_per_minute:
return StageResult(success=False, error="请求过于频繁,请稍后再试", stop=True)
self._counter[key] = count + 1
# 清理旧计数器
keys_to_del = [k for k in self._counter if int(k.split(":")[-1]) < int(now // 60) - 1]
for k in keys_to_del:
del self._counter[k]
return StageResult(success=True)
class ContentSafetyCheckStage(PipelineStage):
"""内容安全检查"""
def __init__(self):
super().__init__("content_safety_check")
self._blocked_words = [] # 可从配置加载
def process(self, context: dict) -> StageResult:
message = context.get("message", "")
for word in self._blocked_words:
if word in message:
return StageResult(success=False, error="消息包含违规内容", stop=True)
return StageResult(success=True)
class UnsupportedStage(PipelineStage):
"""业务逻辑处理阶段基类 - 子类实现具体处理"""
def __init__(self):
super().__init__("process")
def process(self, context: dict) -> StageResult:
# 此阶段由channels.py中的实际AI处理逻辑填充
return StageResult(success=True, data={"processed": True})
class ResponseBuildStage(PipelineStage):
"""响应构建阶段"""
def __init__(self):
super().__init__("respond")
def process(self, context: dict) -> StageResult:
ai_reply = context.get("ai_reply", "")
if not ai_reply:
return StageResult(success=True, data={"reply": ""})
return StageResult(success=True, data={"reply": ai_reply})
# === Pipeline 管道调度器 ===
class PipelineScheduler:
"""管道调度器:按顺序执行各个Stage"""
def __init__(self, stages: list[PipelineStage] = None):
self.stages = stages if stages is not None else self._default_stages()
@staticmethod
def _default_stages() -> list[PipelineStage]:
return [
WakingCheckStage(),
RateLimitCheckStage(max_per_minute=30),
ContentSafetyCheckStage(),
UnsupportedStage(),
ResponseBuildStage(),
]
def add_stage(self, stage: PipelineStage, position: int = None):
"""插入新阶段"""
if position is not None:
self.stages.insert(position, stage)
else:
self.stages.append(stage)
def remove_stage(self, stage_name: str):
"""移除阶段"""
self.stages = [s for s in self.stages if s.name != stage_name]
def execute(self, context: dict) -> dict:
"""执行完整的管道处理"""
context["_pipeline_log"] = []
for stage in self.stages:
try:
result = stage.process(context)
context["_pipeline_log"].append({
"stage": stage.name,
"success": result.success,
"error": result.error,
"stop": result.stop,
})
if not result.success and result.stop:
return {
"success": False,
"error": result.error,
"stage": stage.name,
"pipeline_log": context["_pipeline_log"],
}
if result.stop and result.success:
# 正常停止(如waking_check判断不需要响应)
return {
"success": True,
"data": result.data,
"stage": stage.name,
"pipeline_log": context["_pipeline_log"],
}
context.update(result.data)
except Exception as e:
logger.error(f"[Pipeline] Stage {stage.name} 异常: {e}")
return {
"success": False,
"error": f"管道处理异常({stage.name}): {str(e)}",
"pipeline_log": context["_pipeline_log"],
}
return {
"success": True,
"data": {"reply": context.get("reply", context.get("ai_reply", ""))},
"pipeline_log": context["_pipeline_log"],
}
# ============================================================
# 单元测试
# ============================================================
if __name__ == "__main__":
print("=" * 60)
print("Pipeline 管道模块 单元测试")
print("=" * 60)
# 测试1: Pipeline 创建和执行
print("\n[测试1] Pipeline 创建和执行")
pipeline = PipelineScheduler()
context = {
"message": "你好,帮我查询天气",
"platform": "feishu",
"sender_id": "user_001",
"is_private": True,
"channel_config": {"bot_prefix": "@bot"},
"ai_reply": "你好!今天天气晴好,气温22-30℃。",
}
result = pipeline.execute(context)
assert result["success"] is True, f"Pipeline执行应该成功: {result}"
assert "pipeline_log" in result, "应该有pipeline_log"
# 验证所有5个阶段都执行了
stage_names = [log["stage"] for log in result["pipeline_log"]]
assert len(stage_names) == 5, f"应该执行5个阶段,实际执行了{len(stage_names)}个"
assert stage_names == ["waking_check", "rate_limit_check", "content_safety_check", "process", "respond"], \
f"阶段顺序不对: {stage_names}"
assert result["data"]["reply"] == "你好!今天天气晴好,气温22-30℃。"
print(f" PASS: 所有5个阶段按顺序执行,回复={result['data']['reply'][:30]}...")
# 测试2: 唤醒检查阶段 - 私聊
print("\n[测试2] 唤醒检查阶段 - 私聊消息")
waking = WakingCheckStage()
ctx = {
"message": "你好",
"platform": "telegram",
"is_private": True,
"channel_config": {"bot_prefix": "@bot"},
}
result = waking.process(ctx)
assert result.success is True, "私聊应该唤醒"
assert result.data["should_respond"] is True, "私聊应该响应"
print(f" PASS: 私聊消息正确唤醒, should_respond={result.data['should_respond']}")
# 测试3: 唤醒检查阶段 - 群聊 @bot
print("\n[测试3] 唤醒检查阶段 - 群聊@bot")
ctx = {
"message": "@bot 帮我查一下数据",
"platform": "wecom",
"is_private": False,
"channel_config": {"bot_prefix": "@bot"},
}
result = waking.process(ctx)
assert result.success is True, "群聊@bot应该唤醒"
assert result.data["should_respond"] is True, "群聊@bot应该响应"
print(f" PASS: 群聊@bot正确唤醒, should_respond={result.data['should_respond']}")
# 测试4: 唤醒检查阶段 - 群聊未@bot (应停止)
print("\n[测试4] 唤醒检查阶段 - 群聊未@bot(应停止管道)")
ctx = {
"message": "大家今天天气不错",
"platform": "wecom",
"is_private": False,
"channel_config": {"bot_prefix": "@bot"},
}
result = waking.process(ctx)
assert result.success is True, "处理应该成功"
assert result.data["should_respond"] is False, "群聊未@bot不应该响应"
assert result.stop is True, "应该停止管道"
print(f" PASS: 群聊未@bot正确停止管道, should_respond={result.data['should_respond']}, stop={result.stop}")
# 测试5: 速率限制
print("\n[测试5] 速率限制检查")
rate_limiter = RateLimitCheckStage(max_per_minute=3)
# 前3次应该成功
for i in range(3):
ctx = {"sender_id": "user_test"}
result = rate_limiter.process(ctx)
assert result.success is True, f"第{i+1}次请求应该通过速率限制"
print(" PASS: 前3次请求通过速率限制")
# 第4次应该失败
ctx = {"sender_id": "user_test"}
result = rate_limiter.process(ctx)
assert result.success is False, "第4次请求应该被限流"
assert "频繁" in result.error, f"错误信息应包含'频繁': {result.error}"
assert result.stop is True, "限流应该停止管道"
print(f" PASS: 第4次请求被限流, error={result.error}")
# 测试6: 完整管道链 - 群聊未唤醒场景
print("\n[测试6] 完整管道链 - 群聊未唤醒(应被waking_check停止)")
pipeline = PipelineScheduler()
context = {
"message": "今天天气真好啊",
"platform": "wecom",
"sender_id": "user_002",
"is_private": False,
"channel_config": {"bot_prefix": "@bot"},
}
result = pipeline.execute(context)
assert result["success"] is True, "管道应该正常完成"
assert result["stage"] == "waking_check", f"应在waking_check阶段停止,实际: {result['stage']}"
assert len(result["pipeline_log"]) == 1, f"应该只执行了1个阶段,实际: {len(result['pipeline_log'])}"
print(f" PASS: 群聊未唤醒场景正确在waking_check阶段停止")
# 测试7: 内容安全检查 - 无违规词
print("\n[测试7] 内容安全检查 - 无违规词")
safety = ContentSafetyCheckStage()
ctx = {"message": "正常的聊天内容"}
result = safety.process(ctx)
assert result.success is True, "正常内容应该通过检查"
print(f" PASS: 正常内容通过安全检查")
# 测试8: 异常处理 - Stage抛出异常
print("\n[测试8] Pipeline异常处理")
class BrokenStage(PipelineStage):
def __init__(self):
super().__init__("broken")
def process(self, context):
raise RuntimeError("模拟阶段异常")
pipeline2 = PipelineScheduler(stages=[BrokenStage()])
result = pipeline2.execute({"message": "test"})
assert result["success"] is False, "异常应该导致管道失败"
assert "broken" in result["error"], f"错误信息应包含阶段名: {result['error']}"
print(f" PASS: 阶段异常被正确捕获, error={result['error']}")
# 测试9: Pipeline 添加和移除阶段
print("\n[测试9] Pipeline 阶段增删")
pipeline3 = PipelineScheduler(stages=[])
assert len(pipeline3.stages) == 0, "初始应无阶段"
test_stage = WakingCheckStage()
pipeline3.add_stage(test_stage)
assert len(pipeline3.stages) == 1, "添加后应有1个阶段"
pipeline3.add_stage(RateLimitCheckStage(), position=0)
assert pipeline3.stages[0].name == "rate_limit_check", "插入到位置0应为rate_limit_check"
pipeline3.remove_stage("waking_check")
assert len(pipeline3.stages) == 1, "移除后应有1个阶段"
assert pipeline3.stages[0].name == "rate_limit_check", "剩余应为rate_limit_check"
print(" PASS: 阶段增删操作正确")
print("\n" + "=" * 60)
print("所有测试通过!")
print("=" * 60)
+125
View File
@@ -0,0 +1,125 @@
"""
平台适配器注册机制 - 参照AstrBot @register_platform_adapter 装饰器模式
让新平台接入标准化,通过装饰器注册 + 统一接口
"""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Any, Callable
import logging
logger = logging.getLogger(__name__)
# 全局注册表
platform_registry: dict[str, "PlatformMetadata"] = {}
platform_cls_map: dict[str, type] = {}
@dataclass
class PlatformMetadata:
"""平台元数据"""
name: str # 平台标识 wecom/feishu/dingtalk/telegram/slack/whatsapp
display_name: str # 显示名称
description: str # 描述
icon: str = "" # 图标URL或base64
version: str = "1.0"
support_streaming: bool = True # 是否支持流式消息
support_image: bool = True # 是否支持图片消息
support_file: bool = True # 是否支持文件消息
support_voice: bool = False # 是否支持语音消息
config_schema: dict = field(default_factory=dict) # JSON Schema for config validation
class BasePlatformAdapter(ABC):
"""平台适配器基类 - 所有平台适配器必须继承此类"""
def __init__(self, config: dict):
self.config = config
self._status = "stopped" # pending/running/error/stopped
@abstractmethod
def get_metadata(self) -> PlatformMetadata:
"""返回平台元数据"""
...
@abstractmethod
def parse_webhook(self, payload: dict) -> dict:
"""解析webhook payload为统一消息格式
返回: {
"content_type": "text"|"image"|"file"|...,
"content": "文本内容或资源标识",
"sender_id": "发送者ID",
"sender_name": "发送者名称",
"message_id": "平台消息ID",
"timestamp": 1234567890,
"group_id": "群聊ID(可选)",
"group_name": "群聊名称(可选)",
"raw": raw_payload,
}
"""
...
@abstractmethod
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
"""验证webhook签名"""
...
@abstractmethod
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
"""构建回复消息格式
返回: {"api_url": "...", "headers": {...}, "body": {...}}
"""
...
def get_status(self) -> str:
return self._status
def start(self):
self._status = "running"
def stop(self):
self._status = "stopped"
def register_platform_adapter(
name: str,
display_name: str,
description: str,
icon: str = "",
support_streaming: bool = True,
support_image: bool = True,
support_file: bool = True,
support_voice: bool = False,
config_schema: dict = None,
):
"""注册平台适配器的装饰器 - 参照AstrBot @register_platform_adapter 模式"""
def decorator(cls):
if not issubclass(cls, BasePlatformAdapter):
raise TypeError(f"{cls.__name__} 必须继承 BasePlatformAdapter")
metadata = PlatformMetadata(
name=name,
display_name=display_name,
description=description,
icon=icon,
support_streaming=support_streaming,
support_image=support_image,
support_file=support_file,
support_voice=support_voice,
config_schema=config_schema or {},
)
if name in platform_cls_map:
logger.warning(f"平台 {name} 已注册,将被覆盖")
platform_registry[name] = metadata
platform_cls_map[name] = cls
logger.info(f"[Platform] 注册平台适配器: {name} ({display_name})")
return cls
return decorator
def get_platform_adapter(name: str, config: dict) -> BasePlatformAdapter:
"""获取平台适配器实例"""
if name not in platform_cls_map:
raise ValueError(f"未知平台: {name}. 可用平台: {list(platform_cls_map.keys())}")
return platform_cls_map[name](config)
def list_platforms() -> list[PlatformMetadata]:
"""列出所有已注册平台"""
return list(platform_registry.values())
@@ -0,0 +1,18 @@
"""
平台适配器初始化 - 导入所有适配器触发注册
"""
from .wecom_adapter import WecomPlatformAdapter
from .feishu_adapter import FeishuPlatformAdapter
from .dingtalk_adapter import DingtalkPlatformAdapter
from .telegram_adapter import TelegramPlatformAdapter
from .slack_adapter import SlackPlatformAdapter
from .whatsapp_adapter import WhatsAppPlatformAdapter
__all__ = [
"WecomPlatformAdapter",
"FeishuPlatformAdapter",
"DingtalkPlatformAdapter",
"TelegramPlatformAdapter",
"SlackPlatformAdapter",
"WhatsAppPlatformAdapter",
]
@@ -0,0 +1,125 @@
"""
钉钉平台适配器
"""
import hashlib
import hmac
import base64
import json
import logging
import time
from typing import Any
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
logger = logging.getLogger(__name__)
@register_platform_adapter(
name="dingtalk",
display_name="钉钉",
description="钉钉开放平台消息适配器,支持文本、图片、文件等消息类型",
support_streaming=False,
support_image=True,
support_file=True,
support_voice=False,
)
class DingtalkPlatformAdapter(BasePlatformAdapter):
"""钉钉平台适配器"""
def get_metadata(self) -> PlatformMetadata:
from ..platform_adapter import platform_registry
return platform_registry["dingtalk"]
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
"""钉钉签名验证: HMAC-SHA256 Base64"""
if not signature or not timestamp:
return True # 无签名默认通过
app_secret = self.config.get("app_secret", "")
if not app_secret:
logger.warning("[DingTalk] 未配置app_secret,跳过签名验证")
return True
try:
# 钉钉签名格式: timestamp + "\n" + appSecret
sign_str = f"{timestamp}\n{app_secret}"
hmac_code = hmac.new(
app_secret.encode("utf-8"),
sign_str.encode("utf-8"),
hashlib.sha256,
).digest()
expected = base64.b64encode(hmac_code).decode("utf-8")
return signature == expected
except Exception as e:
logger.error(f"[DingTalk] 签名验证异常: {e}")
return False
def parse_webhook(self, payload: dict) -> dict:
"""解析钉钉webhook payload为统一消息格式"""
msg_type = payload.get("msgtype", "text")
content = ""
content_type = "text"
sender_id = ""
sender_name = ""
message_id = ""
if msg_type == "text":
text_obj = payload.get("text", {})
content = text_obj.get("content", "")
content_type = "text"
elif msg_type == "image":
image_obj = payload.get("image", {})
content = f"[图片] media_id={image_obj.get('mediaId', '')}"
content_type = "image"
elif msg_type == "file":
file_obj = payload.get("file", {})
content = f"[文件] {file_obj.get('fileName', 'unknown')} media_id={file_obj.get('mediaId', '')}"
content_type = "file"
elif msg_type == "link":
link_obj = payload.get("link", {})
content = f"[链接] {link_obj.get('title', '')} url={link_obj.get('messageUrl', '')}"
content_type = "link"
elif msg_type == "markdown":
md_obj = payload.get("markdown", {})
content = md_obj.get("title", "")
content_type = "markdown"
elif msg_type == "actionCard":
card = payload.get("actionCard", {})
content = f"[卡片] {card.get('title', '')}"
content_type = "actionCard"
else:
content = json.dumps(payload, ensure_ascii=False)
content_type = msg_type
# 提取发送者信息
sender_id = payload.get("senderId", payload.get("senderStaffId", ""))
sender_name = payload.get("senderNick", payload.get("senderCorpId", ""))
message_id = str(payload.get("msgId", ""))
# 群聊信息
group_id = payload.get("conversationId", payload.get("chatId", None))
group_name = payload.get("conversationTitle", None)
return {
"content_type": content_type,
"content": content,
"sender_id": str(sender_id),
"sender_name": str(sender_name),
"message_id": message_id,
"timestamp": int(payload.get("createAt", time.time() * 1000)) // 1000,
"group_id": group_id,
"group_name": group_name,
"raw": payload,
}
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
"""构建钉钉回复消息"""
webhook_url = channel_config.get("webhook_url", "")
if not webhook_url:
logger.warning("[DingTalk] 未配置webhook_url,无法构建回复")
return {}
return {
"api_url": webhook_url,
"headers": {"Content-Type": "application/json"},
"body": {"msgtype": "text", "text": {"content": content}},
}
@@ -0,0 +1,136 @@
"""
飞书平台适配器
"""
import hashlib
import hmac
import json
import logging
from typing import Any
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
logger = logging.getLogger(__name__)
@register_platform_adapter(
name="feishu",
display_name="飞书",
description="飞书开放平台消息适配器,支持文本、图片、文件、富文本消息",
support_streaming=True,
support_image=True,
support_file=True,
support_voice=False,
)
class FeishuPlatformAdapter(BasePlatformAdapter):
"""飞书平台适配器"""
def get_metadata(self) -> PlatformMetadata:
from ..platform_adapter import platform_registry
return platform_registry["feishu"]
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
"""飞书签名验证: HMAC-SHA256"""
if not signature or not timestamp:
# 飞书首次URL验证(challenge)不需要签名验证
if payload.get("challenge"):
return True
return True # 宽松模式
app_secret = self.config.get("app_secret", "")
if not app_secret:
logger.warning("[Feishu] 未配置app_secret,跳过签名验证")
return True
# 飞书签名格式: timestamp + nonce + body
try:
body_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
nonce = timestamp # 飞书用timestamp作为nonce的一部分
sign_str = f"{timestamp}\n{nonce}\n{body_str}"
expected = hmac.new(
app_secret.encode("utf-8"),
sign_str.encode("utf-8"),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected)
except Exception as e:
logger.error(f"[Feishu] 签名验证异常: {e}")
return False
def parse_webhook(self, payload: dict) -> dict:
"""解析飞书webhook payload为统一消息格式"""
# 飞书URL验证challenge已在外部处理,这里处理正常消息
event = payload.get("event", {})
message = event.get("message", {})
msg_type = message.get("message_type", "text")
raw_content = message.get("content", "{}")
content = ""
content_type = "text"
sender_id = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
sender_name = event.get("sender", {}).get("sender_id", {}).get("open_id", "")
message_id = event.get("header", {}).get("message_id", "")
try:
if isinstance(raw_content, str):
content_obj = json.loads(raw_content)
else:
content_obj = raw_content if isinstance(raw_content, dict) else {}
if msg_type == "text":
content = content_obj.get("text", "")
content_type = "text"
elif msg_type == "image":
image_key = message.get("image_key", "")
content = f"[图片] image_key={image_key}"
content_type = "image"
elif msg_type == "file":
file_key = message.get("file_key", "")
file_name = content_obj.get("file_name", "unknown")
content = f"[文件] {file_name} file_key={file_key}"
content_type = "file"
elif msg_type == "post":
# 富文本消息,提取text内容
post_content = content_obj.get("content", [])
text_parts = []
for paragraph in post_content:
if isinstance(paragraph, list):
for element in paragraph:
if isinstance(element, dict) and element.get("tag") == "text":
text_parts.append(element.get("text", ""))
content = "".join(text_parts)
content_type = "post"
else:
content = str(content_obj)
content_type = msg_type
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"[Feishu] 解析content失败: {e}, raw={str(raw_content)[:200]}")
content = str(raw_content)
return {
"content_type": content_type,
"content": content,
"sender_id": sender_id,
"sender_name": sender_name,
"message_id": message_id,
"timestamp": event.get("header", {}).get("create_time", 0),
"group_id": event.get("chat_id", None),
"group_name": None,
"raw": payload,
}
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
"""构建飞书回复消息"""
app_id = channel_config.get("app_id", "")
app_secret = channel_config.get("app_secret", "")
return {
"api_url": "https://open.feishu.cn/open-apis/im/v1/messages",
"headers": {
"Content-Type": "application/json",
"Authorization": f"Bearer {channel_config.get('tenant_access_token', '')}",
},
"body": {
"msg_type": "text",
"content": json.dumps({"text": content}),
},
}
@@ -0,0 +1,146 @@
"""
Slack平台适配器
"""
import hashlib
import hmac
import json
import logging
import time
from typing import Any
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
logger = logging.getLogger(__name__)
@register_platform_adapter(
name="slack",
display_name="Slack",
description="Slack Events API消息适配器,支持文本消息和URL验证",
support_streaming=True,
support_image=True,
support_file=True,
support_voice=False,
)
class SlackPlatformAdapter(BasePlatformAdapter):
"""Slack平台适配器 - 签名验证 + challenge响应"""
def get_metadata(self) -> PlatformMetadata:
from ..platform_adapter import platform_registry
return platform_registry["slack"]
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
"""Slack签名验证: v0=HMAC-SHA256(SigningSecret, 'v0:timestamp:body')"""
if not signature or not timestamp:
return True # 无签名默认通过
signing_secret = self.config.get("signing_secret", "")
if not signing_secret:
logger.warning("[Slack] 未配置signing_secret,跳过签名验证")
return True
try:
# Slack签名格式: v0=XXXX
# 签名基础字符串: v0:timestamp:body
body_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
sig_basestring = f"v0:{timestamp}:{body_str}"
computed_sig = "v0=" + hmac.new(
signing_secret.encode("utf-8"),
sig_basestring.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(computed_sig, signature)
except Exception as e:
logger.error(f"[Slack] 签名验证异常: {e}")
return False
def parse_webhook(self, payload: dict) -> dict:
"""解析Slack webhook payload为统一消息格式"""
# Slack URL验证 challenge 在外部处理
event = payload.get("event", {})
event_type = event.get("type", "message")
subtype = event.get("subtype", "")
content = ""
content_type = "text"
sender_id = ""
sender_name = ""
message_id = ""
group_id = None
group_name = None
if event_type == "message":
text = event.get("text", "")
user = event.get("user", "")
channel = event.get("channel", "")
ts = event.get("ts", "")
content = text
content_type = "text"
sender_id = user
sender_name = event.get("user_profile", {}).get("display_name", user)
message_id = ts
# 区分私聊/群聊
channel_type = event.get("channel_type", "")
if channel or channel_type == "channel":
group_id = channel
elif channel:
group_id = channel
elif event_type == "app_mention":
text = event.get("text", "")
user = event.get("user", "")
channel = event.get("channel", "")
ts = event.get("ts", "")
# 去除@bot标记
content = text
content_type = "text"
sender_id = user
sender_name = user
message_id = ts
group_id = channel
else:
content = json.dumps(event, ensure_ascii=False)
content_type = event_type
return {
"content_type": content_type,
"content": content,
"sender_id": sender_id,
"sender_name": sender_name,
"message_id": message_id,
"timestamp": int(float(message_id.split(".")[0]) if message_id else time.time()),
"group_id": group_id,
"group_name": group_name,
"raw": payload,
}
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
"""构建Slack回复消息"""
bot_token = channel_config.get("bot_token", "")
channel = channel_config.get("channel", "")
if not bot_token:
logger.warning("[Slack] 未配置bot_token,无法构建回复")
return {}
body = {
"channel": channel,
"text": content,
}
if reply_to:
body["thread_ts"] = reply_to
return {
"api_url": "https://slack.com/api/chat.postMessage",
"headers": {
"Content-Type": "application/json",
"Authorization": f"Bearer {bot_token}",
},
"body": body,
}
@@ -0,0 +1,154 @@
"""
Telegram平台适配器
"""
import hashlib
import hmac
import json
import logging
from typing import Any
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
logger = logging.getLogger(__name__)
@register_platform_adapter(
name="telegram",
display_name="Telegram",
description="Telegram Bot API消息适配器,支持文本、图片、文件、语音、视频、贴纸等消息类型",
support_streaming=True,
support_image=True,
support_file=True,
support_voice=True,
)
class TelegramPlatformAdapter(BasePlatformAdapter):
"""Telegram平台适配器"""
def get_metadata(self) -> PlatformMetadata:
from ..platform_adapter import platform_registry
return platform_registry["telegram"]
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
"""Telegram签名验证: HMAC-SHA256(BotToken, 可选)"""
if not signature:
return True # Telegram不使用签名头验证,通过setWebhook时设置secret_token
bot_token = self.config.get("bot_token", "")
if not bot_token:
logger.warning("[Telegram] 未配置bot_token,跳过签名验证")
return True
try:
# Telegram支持通过 X-Telegram-Bot-Api-Secret-Token 头验证
# 新式验证:secret_token = SHA256(bot_token)
expected = hashlib.sha256(bot_token.encode("utf-8")).hexdigest()
return signature == expected
except Exception as e:
logger.error(f"[Telegram] 签名验证异常: {e}")
return False
def parse_webhook(self, payload: dict) -> dict:
"""解析Telegram webhook payload为统一消息格式"""
message = payload.get("message", payload.get("edited_message", {}))
if not message:
# 可能是callback_query等其他更新
callback = payload.get("callback_query", {})
if callback:
message = callback.get("message", {})
message["_callback_data"] = callback.get("data", "")
chat = message.get("chat", {})
from_user = message.get("from", {})
chat_id = str(chat.get("id", ""))
chat_type = chat.get("type", "private")
chat_title = chat.get("title", "")
sender_id = str(from_user.get("id", ""))
sender_name = from_user.get("first_name", "") or from_user.get("username", "")
content = ""
content_type = "text"
if "text" in message:
content = message.get("text", "")
content_type = "text"
elif "photo" in message:
photos = message.get("photo", [])
largest = photos[-1] if photos else {}
content = f"[图片] file_id={largest.get('file_id', '')}"
content_type = "image"
elif "document" in message:
doc = message.get("document", {})
content = f"[文件] {doc.get('file_name', 'unknown')} file_id={doc.get('file_id', '')}"
content_type = "file"
elif "voice" in message:
voice = message.get("voice", {})
content = f"[语音] file_id={voice.get('file_id', '')} duration={voice.get('duration', 0)}s"
content_type = "voice"
elif "video" in message:
video = message.get("video", {})
content = f"[视频] file_id={video.get('file_id', '')}"
content_type = "video"
elif "sticker" in message:
sticker = message.get("sticker", {})
content = f"[贴纸] emoji={sticker.get('emoji', '')} set={sticker.get('set_name', '')}"
content_type = "sticker"
elif "location" in message:
loc = message.get("location", {})
content = f"[位置] lat={loc.get('latitude')} lng={loc.get('longitude')}"
content_type = "location"
elif "contact" in message:
contact = message.get("contact", {})
content = f"[联系人] {contact.get('first_name', '')} phone={contact.get('phone_number', '')}"
content_type = "contact"
elif "animation" in message:
anim = message.get("animation", {})
content = f"[动画] file_id={anim.get('file_id', '')}"
content_type = "animation"
else:
content = json.dumps(message, ensure_ascii=False)
content_type = "unknown"
# 群聊/频道标识
group_id = None
group_name = None
if chat_type in ("group", "supergroup"):
group_id = chat_id
group_name = chat_title
elif chat_type == "channel":
group_id = chat_id
group_name = chat_title
return {
"content_type": content_type,
"content": content,
"sender_id": sender_id,
"sender_name": sender_name,
"message_id": str(message.get("message_id", "")),
"timestamp": message.get("date", 0),
"group_id": group_id,
"group_name": group_name,
"raw": payload,
}
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
"""构建Telegram回复消息"""
bot_token = channel_config.get("bot_token", "")
chat_id = channel_config.get("chat_id", "")
if not bot_token:
logger.warning("[Telegram] 未配置bot_token,无法构建回复")
return {}
body = {
"chat_id": chat_id,
"text": content,
"parse_mode": "HTML",
}
if reply_to:
body["reply_to_message_id"] = reply_to
return {
"api_url": f"https://api.telegram.org/bot{bot_token}/sendMessage",
"headers": {"Content-Type": "application/json"},
"body": body,
}
@@ -0,0 +1,92 @@
"""
企业微信平台适配器
"""
import hashlib
import json
import logging
from typing import Any
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
logger = logging.getLogger(__name__)
@register_platform_adapter(
name="wecom",
display_name="企业微信",
description="企业微信消息平台适配器,支持文本、图片、文件、语音消息",
support_streaming=False,
support_image=True,
support_file=True,
support_voice=True,
)
class WecomPlatformAdapter(BasePlatformAdapter):
"""企业微信平台适配器"""
def get_metadata(self) -> PlatformMetadata:
from ..platform_adapter import platform_registry
return platform_registry["wecom"]
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
"""企业微信签名验证: SHA1(token + timestamp + nonce)"""
if not signature or not timestamp:
return True # 无签名要求默认通过
token = self.config.get("token", "")
encoding_aes_key = self.config.get("encoding_aes_key", "")
nonce = (encoding_aes_key or token or "defaultnonce")[:16]
tmp = "".join(sorted([token, timestamp, nonce]))
expected = hashlib.sha1(tmp.encode()).hexdigest()
return signature == expected
def parse_webhook(self, payload: dict) -> dict:
"""解析企业微信webhook payload为统一消息格式"""
msg_type = payload.get("MsgType", "text")
content = ""
content_type = "text"
if msg_type == "text":
content = payload.get("Text", {}).get("Content", "")
content_type = "text"
elif msg_type == "image":
content = payload.get("Image", {}).get("MediaId", "")
content_type = "image"
elif msg_type == "voice":
content = payload.get("Voice", {}).get("MediaId", "")
content_type = "voice"
elif msg_type == "video":
content = payload.get("Video", {}).get("MediaId", "")
content_type = "video"
elif msg_type == "file":
content = payload.get("FileName", "")
content_type = "file"
elif msg_type == "location":
loc = payload.get("Location", {})
content = json.dumps(loc, ensure_ascii=False)
content_type = "location"
elif msg_type == "event":
content = json.dumps({"event": payload.get("Event", ""), "event_key": payload.get("EventKey", "")})
content_type = "event"
else:
content = json.dumps(payload, ensure_ascii=False)
content_type = msg_type
return {
"content_type": content_type,
"content": content,
"sender_id": payload.get("FromUserName", ""),
"sender_name": payload.get("FromUserName", ""),
"message_id": str(payload.get("MsgId", "")),
"timestamp": int(payload.get("CreateTime", 0)),
"group_id": None,
"group_name": None,
"raw": payload,
}
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
"""构建企业微信回复消息"""
webhook_key = channel_config.get("webhook_key", "")
return {
"api_url": f"https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key={webhook_key}",
"headers": {"Content-Type": "application/json"},
"body": {"msgtype": "text", "text": {"content": content}},
}
@@ -0,0 +1,201 @@
"""
WhatsApp平台适配器
"""
import hashlib
import hmac
import json
import logging
import time
from typing import Any
from ..platform_adapter import register_platform_adapter, BasePlatformAdapter, PlatformMetadata
logger = logging.getLogger(__name__)
@register_platform_adapter(
name="whatsapp",
display_name="WhatsApp",
description="WhatsApp Cloud API消息适配器,支持文本、图片、文件、音频、视频、位置等消息类型",
support_streaming=False,
support_image=True,
support_file=True,
support_voice=True,
)
class WhatsAppPlatformAdapter(BasePlatformAdapter):
"""WhatsApp平台适配器"""
def get_metadata(self) -> PlatformMetadata:
from ..platform_adapter import platform_registry
return platform_registry["whatsapp"]
def verify_signature(self, payload: dict, signature: str, timestamp: str) -> bool:
"""WhatsApp签名验证: HMAC-SHA256(AppSecret, payload)"""
if not signature:
# WhatsApp可使用 verify_token 进行webhook验证
app_secret = self.config.get("app_secret", "")
if app_secret:
return False
return True # 无签名默认通过
app_secret = self.config.get("app_secret", "")
if not app_secret:
logger.warning("[WhatsApp] 未配置app_secret,跳过签名验证")
return True
try:
# WhatsApp使用 SHA256 HMAC 验证payload
body_str = json.dumps(payload, separators=(",", ":"), ensure_ascii=False)
expected = "sha256=" + hmac.new(
app_secret.encode("utf-8"),
body_str.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)
except Exception as e:
logger.error(f"[WhatsApp] 签名验证异常: {e}")
return False
def parse_webhook(self, payload: dict) -> dict:
"""解析WhatsApp Cloud API webhook payload为统一消息格式
WhatsApp Cloud API格式:
{
"entry": [{
"changes": [{
"value": {
"messaging_product": "whatsapp",
"metadata": {"phone_number_id": "..."},
"messages": [{"from": "...", "type": "text", "text": {"body": "..."}}]
}
}]
}]
}
"""
entries = payload.get("entry", [])
content = ""
content_type = "text"
sender_id = ""
sender_name = ""
message_id = ""
timestamp = int(time.time())
for entry in entries:
changes = entry.get("changes", [])
for change in changes:
value = change.get("value", {})
messages = value.get("messages", [])
metadata = value.get("metadata", {})
phone_number_id = metadata.get("phone_number_id", "")
for msg in messages:
msg_from = msg.get("from", "")
msg_type = msg.get("type", "text")
msg_id = msg.get("id", "")
sender_id = msg_from
sender_name = msg_from
message_id = msg_id
timestamp = int(msg.get("timestamp", time.time()))
if msg_type == "text":
body = msg.get("text", {}).get("body", "")
content = body
content_type = "text"
elif msg_type == "image":
image = msg.get("image", {})
content = f"[图片] id={image.get('id', '')} caption={image.get('caption', '')}"
content_type = "image"
elif msg_type == "document":
doc = msg.get("document", {})
content = f"[文件] {doc.get('filename', 'unknown')} id={doc.get('id', '')}"
content_type = "file"
elif msg_type == "audio":
audio = msg.get("audio", {})
content = f"[语音] id={audio.get('id', '')}"
content_type = "voice"
elif msg_type == "video":
video = msg.get("video", {})
content = f"[视频] id={video.get('id', '')}"
content_type = "video"
elif msg_type == "location":
loc = msg.get("location", {})
content = f"[位置] lat={loc.get('latitude')} lng={loc.get('longitude')}"
content_type = "location"
elif msg_type == "contacts":
contacts = msg.get("contacts", [])
names = [c.get("name", {}).get("formatted_name", "") for c in contacts]
content = f"[联系人] {', '.join(names)}"
content_type = "contacts"
elif msg_type == "button":
button = msg.get("button", {})
content = f"[按钮] text={button.get('text', '')} payload={button.get('payload', '')}"
content_type = "button"
elif msg_type == "interactive":
interactive = msg.get("interactive", {})
list_reply = interactive.get("list_reply", {})
button_reply = interactive.get("button_reply", {})
if list_reply:
content = f"[列表回复] {list_reply.get('title', '')}: {list_reply.get('id', '')}"
elif button_reply:
content = f"[按钮回复] {button_reply.get('title', '')}: {button_reply.get('id', '')}"
else:
content = f"[交互] {json.dumps(interactive)}"
content_type = "interactive"
elif msg_type == "sticker":
sticker = msg.get("sticker", {})
content = f"[贴纸] id={sticker.get('id', '')}"
content_type = "sticker"
elif msg_type == "reaction":
reaction = msg.get("reaction", {})
content = f"[反应] emoji={reaction.get('emoji', '')}"
content_type = "reaction"
else:
content = json.dumps(msg, ensure_ascii=False)
content_type = msg_type
if not content:
# 可能是状态更新(无消息内容)
content = json.dumps(payload, ensure_ascii=False)
content_type = "status"
return {
"content_type": content_type,
"content": content,
"sender_id": sender_id,
"sender_name": sender_name,
"message_id": message_id,
"timestamp": timestamp,
"group_id": None, # WhatsApp主要是私聊
"group_name": None,
"raw": payload,
}
def build_reply(self, channel_config: dict, content: str, reply_to: str = None) -> dict:
"""构建WhatsApp回复消息"""
phone_number_id = channel_config.get("phone_number_id", "")
access_token = channel_config.get("access_token", "")
recipient = channel_config.get("recipient_id", "")
if not phone_number_id or not access_token:
logger.warning("[WhatsApp] 未配置phone_number_id或access_token,无法构建回复")
return {}
body = {
"messaging_product": "whatsapp",
"recipient_type": "individual",
"to": recipient,
"type": "text",
"text": {"preview_url": False, "body": content},
}
return {
"api_url": f"https://graph.facebook.com/v18.0/{phone_number_id}/messages",
"headers": {
"Content-Type": "application/json",
"Authorization": f"Bearer {access_token}",
},
"body": body,
}
+1
View File
@@ -0,0 +1 @@
# AI自进化服务
File diff suppressed because it is too large. Load diff