初始提交: Qihu-AIFlow v2.0.1 完整项目
- 前端 React+TypeScript (源码+构建产物) - 后端 Python FastAPI - 数据库迁移 001-015 - Docker部署配置 - 微信ClawBot API频道绑定 - 项目文档 README.md
This commit is contained in:
commit
78ff261b5a
199 files changed
+69126
No files matched your search
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
# Admin API routes package
|
||||
@@ -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}
|
||||
@@ -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
|
||||
]
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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)}")
|
||||
@@ -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": "密码已重置"}
|
||||
@@ -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": "资产已删除"}
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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": "充值成功",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# AI自进化服务
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user