Initial commit - DesignerCEP Project with Caddy deployment

This commit is contained in:
zuowei1216
2025-12-19 21:27:17 +08:00
commit 8ea58fe480
170 changed files with 47469 additions and 0 deletions

1
Server/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Init file

107
Server/app/api/v1/admin.py Normal file
View File

@@ -0,0 +1,107 @@
import os
import shutil
from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status, Form
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.group import PluginGroup as DBPluginGroup
from app.models.user import User
from app.schemas.group import PluginGroupCreate, PluginGroupUpdate, PluginGroup
from app.schemas.admin import UserInfo
from app.core.config import settings
from typing import List
router = APIRouter()
# Hardcoded admin token for simplicity as per requirements
ADMIN_TOKEN = "admin-secret-token"
def verify_admin(token: str = Form(...)):
if token != ADMIN_TOKEN:
raise HTTPException(status_code=403, detail="Admin permission required")
def get_admin_dep(x_admin_token: str = None):
# Alternative using header
if x_admin_token != ADMIN_TOKEN:
raise HTTPException(status_code=403, detail="Admin permission required")
# Ensure archives directory exists
ARCHIVES_DIR = "archives"
os.makedirs(ARCHIVES_DIR, exist_ok=True)
@router.post("/upload_version")
async def upload_version(
file: UploadFile = File(...),
# token: str = Form(...), # Simple auth
db: Session = Depends(get_db)
):
# if token != ADMIN_TOKEN:
# raise HTTPException(status_code=403, detail="Invalid admin token")
file_location = os.path.join(ARCHIVES_DIR, file.filename)
with open(file_location, "wb+") as file_object:
shutil.copyfileobj(file.file, file_object)
return {"code": 200, "message": f"File '{file.filename}' uploaded successfully", "filename": file.filename}
@router.get("/archives")
async def list_archives():
if not os.path.exists(ARCHIVES_DIR):
return []
files = os.listdir(ARCHIVES_DIR)
# Sort by name (which usually includes timestamp) desc
files.sort(reverse=True)
return files
@router.post("/groups", response_model=PluginGroup)
async def create_group(group: PluginGroupCreate, db: Session = Depends(get_db)):
db_group = DBPluginGroup(**group.model_dump())
db.add(db_group)
db.commit()
db.refresh(db_group)
return db_group
@router.get("/groups", response_model=List[PluginGroup])
async def list_groups(db: Session = Depends(get_db)):
return db.query(DBPluginGroup).all()
@router.put("/groups/{group_id}", response_model=PluginGroup)
async def update_group(group_id: int, group_update: PluginGroupUpdate, db: Session = Depends(get_db)):
db_group = db.query(DBPluginGroup).filter(DBPluginGroup.id == group_id).first()
if not db_group:
raise HTTPException(status_code=404, detail="Group not found")
update_data = group_update.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(db_group, key, value)
db.commit()
db.refresh(db_group)
return db_group
@router.get("/users", response_model=List[UserInfo])
async def list_users(db: Session = Depends(get_db)):
return db.query(User).all()
@router.put("/users/{user_id}/group")
async def update_user_group(user_id: int, group_id: int, db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
group = db.query(DBPluginGroup).filter(DBPluginGroup.id == group_id).first()
if not group:
raise HTTPException(status_code=404, detail="Group not found")
user.group_id = group_id
db.commit()
return {"code": 200, "message": "User group updated"}
@router.put("/users/{user_id}/permissions")
async def update_user_permissions(user_id: int, permissions: str = Form(...), db: Session = Depends(get_db)):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
user.permissions = permissions
db.commit()
return {"code": 200, "message": "User permissions updated"}

View File

@@ -0,0 +1,83 @@
"""
用户行为分析 API
记录和分析用户操作
"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional, Any
from datetime import datetime
router = APIRouter()
# 简单的内存存储(生产环境应该用数据库)
action_logs = []
class ActionLog(BaseModel):
username: str
device_id: str
action: str
details: Optional[Any] = None
timestamp: int
session_id: str
class ActionLogResponse(BaseModel):
success: bool
message: str
@router.post("/log", response_model=ActionLogResponse)
async def log_action(log: ActionLog):
"""
记录用户行为
"""
try:
# 添加服务器时间
log_entry = {
**log.dict(),
"server_time": datetime.now().isoformat(),
"ip": "unknown" # 可以从请求中获取
}
action_logs.append(log_entry)
# 只保留最近 10000 条
if len(action_logs) > 10000:
action_logs.pop(0)
# 可以在这里添加异常检测逻辑
# 例如:检测同一用户短时间内的大量操作
return ActionLogResponse(success=True, message="已记录")
except Exception as e:
return ActionLogResponse(success=False, message=str(e))
@router.get("/stats/{username}")
async def get_user_stats(username: str):
"""
获取用户统计信息
"""
user_logs = [log for log in action_logs if log.get("username") == username]
# 统计各操作类型的次数
action_counts = {}
for log in user_logs:
action = log.get("action", "unknown")
action_counts[action] = action_counts.get(action, 0) + 1
return {
"username": username,
"total_actions": len(user_logs),
"action_counts": action_counts,
"recent_actions": user_logs[-10:] # 最近 10 条
}
@router.get("/recent")
async def get_recent_logs(limit: int = 100):
"""
获取最近的操作日志(管理员用)
"""
return {
"total": len(action_logs),
"logs": action_logs[-limit:]
}

99
Server/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,99 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.schemas.auth import (
UserLogin, UserRegister, Token, UserLogout, UserHeartbeat,
VerifyRequest, VerifyResponse, VerifyEmailRequest,
ForgotPasswordRequest, ResetPasswordRequest, SendVerificationCodeRequest
)
from app.services.auth_service import auth_service
from app.db import get_db
from app.models.session import UserSession
from app.models.user import User
from app.core.security import get_current_user
from datetime import datetime, timezone
router = APIRouter()
@router.post("/send-verification-code")
async def send_verification_code(body: SendVerificationCodeRequest, db: Session = Depends(get_db)):
# 发送注册验证码
return auth_service.send_verification_code(db, body.email)
@router.post("/login", response_model=Token)
async def login(login_data: UserLogin, db: Session = Depends(get_db)):
# 登录接口:校验用户密码,返回访问令牌
return auth_service.login(db, login_data)
@router.post("/register", response_model=Token)
async def register(register_data: UserRegister, db: Session = Depends(get_db)):
# 注册接口:创建新用户,返回访问令牌
return auth_service.register(db, register_data)
@router.post("/verify-email")
async def verify_email(body: VerifyEmailRequest, db: Session = Depends(get_db)):
return auth_service.verify_email(db, body.username, body.code)
@router.post("/forgot-password")
async def forgot_password(body: ForgotPasswordRequest, db: Session = Depends(get_db)):
return auth_service.forgot_password(db, body.email)
@router.post("/reset-password")
async def reset_password(body: ResetPasswordRequest, db: Session = Depends(get_db)):
if body.new_password != body.confirm_password:
raise HTTPException(status_code=400, detail="两次输入的密码不一致")
# 传入 email 参数
return auth_service.reset_password(db, body.token, body.new_password, body.email)
@router.post("/logout")
async def logout(body: UserLogout, db: Session = Depends(get_db)):
# 登出接口:将指定设备会话置为非活跃
return auth_service.logout(db, body.username, body.device_id)
@router.get("/online-time/{username}")
async def get_online_time(username: str, db: Session = Depends(get_db)):
# 在线时长统计:累计历史会话的时长(秒),以及当前活跃会话的实时时长(秒)
user = db.query(User).filter(User.username == username).first()
if not user:
return {"username": username, "total_seconds": 0, "active_seconds": 0}
# 历史累计(已登出的会话)
total = db.query(UserSession).filter(
UserSession.user_id == user.id,
UserSession.active == False,
UserSession.duration_seconds != None
).with_entities(UserSession.duration_seconds).all()
total_seconds = sum([d[0] for d in total]) if total else 0
# 当前活跃会话实时时长
now = datetime.now(timezone.utc)
active = db.query(UserSession).filter(
UserSession.user_id == user.id,
UserSession.active == True,
UserSession.login_at != None
).first()
if active:
login_at = active.login_at
if login_at and login_at.tzinfo is None:
login_at = login_at.replace(tzinfo=timezone.utc)
last_seen = active.last_seen_at or login_at
if last_seen and last_seen.tzinfo is None:
last_seen = last_seen.replace(tzinfo=timezone.utc)
# 判定是否在线:如果 last_seen 在最近 2 分钟内,则认为在线,用 now 计算实时时长
# 否则认为已断开(异常退出),用 last_seen 计算截止时长
# 阈值设为 120 秒(假设前端心跳间隔为 60 秒)
is_online = (now - last_seen).total_seconds() < 120
if is_online:
end_time = now
else:
end_time = last_seen
active_seconds = int(max(0, (end_time - login_at).total_seconds())) if login_at else 0
else:
active_seconds = 0
return {"username": username, "total_seconds": total_seconds, "active_seconds": active_seconds}
@router.post("/heartbeat")
async def heartbeat(body: UserHeartbeat, db: Session = Depends(get_db)):
# 心跳接口:更新会话的最近在线时间
return auth_service.heartbeat(db, body.username, body.device_id)

View File

@@ -0,0 +1,36 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.schemas.client import CheckUpdateRequest, CheckUpdateResponse, LoginResponse
from app.schemas.auth import UserLogin
from app.services.group_service import group_service
from app.services.auth_service import auth_service
from app.db import get_db
router = APIRouter()
@router.post("/check_update", response_model=CheckUpdateResponse)
async def check_update(request: CheckUpdateRequest, db: Session = Depends(get_db)):
try:
data = group_service.check_update(db, request.username)
return CheckUpdateResponse(code=200, data=data, message="success")
except HTTPException as e:
# Wrap HTTPException to match response format if needed, or let global handler handle it.
# Requirements imply specific format.
# But usually 4xx/5xx are handled by exception handlers.
# If I want to return 200 with code=404 in body (anti-pattern but possible), I should do it here.
# The example shows code=200.
# Let's assume standard HTTP status codes for errors, but if successful, return wrapped data.
raise e
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/login", response_model=LoginResponse)
async def login(login_data: UserLogin, db: Session = Depends(get_db)):
# Re-use UserLogin schema as it matches {username, password} + device_id (optional in spec but present in schema)
# Spec says {username, password}, UserLogin has device_id.
# If device_id is missing in request, validation fails.
# Spec example doesn't show device_id in request, but "Backend Development Guidelines" 4.5 says "Must provide stable device_id".
# So I will assume the client sends device_id.
data = auth_service.client_login(db, login_data)
return LoginResponse(code=200, data=data, message="success")

View File

@@ -0,0 +1,120 @@
"""
服务器端计算 Demo - 简单数学计算
演示:前端获取图层名称 → 后端计算数学表达式 → 返回结果
"""
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
import re
import logging
from datetime import datetime
from typing import Optional
from app.core.api_keys import validate_api_key, get_key_info
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
datefmt='%Y-%m-%d %H:%M:%S'
)
logger = logging.getLogger(__name__)
router = APIRouter()
class CalculateRequest(BaseModel):
"""计算请求"""
expression: str # 数学表达式,如 "87-98"
class CalculateResult(BaseModel):
"""计算结果"""
success: bool
expression: str
result: float = None
message: str
@router.post("/calculate", response_model=CalculateResult)
async def calculate_expression(
request: CalculateRequest,
x_api_key: Optional[str] = Header(None) # 可选的 API Key 验证
):
"""
🔒 服务器端数学计算(核心算法)
客户端只能拿到计算结果,看不到算法
示例:前端发送 "87-98" → 后端计算 → 返回 -11
"""
# ==================== 📝 日志:打印请求 ====================
logger.info("="*60)
logger.info("📥 收到计算请求")
logger.info(f" 时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
logger.info(f" 表达式: {request.expression}")
logger.info(f" API Key: {x_api_key if x_api_key else '未提供'}")
logger.info("="*60)
# ==================== 🔐 API Key 验证 ====================
if not validate_api_key(x_api_key):
logger.warning(f"❌ API Key 验证失败: {x_api_key}")
raise HTTPException(status_code=403, detail="无效的 API Key")
# 获取 Key 信息
key_info = get_key_info(x_api_key)
logger.info(f"✅ API Key 验证通过 | 名称: {key_info['name']} | 权限: {key_info['permissions']}")
try:
# 🔒 核心算法在这里(客户端看不到)
# 可以是复杂的数学模型、AI 推理等
expression = request.expression.strip()
# ==================== 🛡️ 安全检查 ====================
logger.info(f"🛡️ 安全检查: 验证表达式格式...")
# 只允许数字和基本运算符
if not re.match(r'^[\d\s\+\-\*\/\(\)\.]+$', expression):
logger.warning(f"❌ 表达式包含非法字符: {expression}")
return CalculateResult(
success=False,
expression=expression,
message="只支持基本数学运算(+、-、*、/"
)
logger.info("✅ 表达式格式验证通过")
# ==================== 🔒 核心算法执行 ====================
logger.info("🔒 开始执行核心算法...")
# 这里可以放你的核心算法
# 示例:简单计算
result = eval(expression)
logger.info(f"✅ 计算完成: {expression} = {result}")
# ==================== 📤 日志:打印输出 ====================
response = CalculateResult(
success=True,
expression=expression,
result=float(result),
message=f"计算成功: {expression} = {result}"
)
logger.info("="*60)
logger.info("📤 返回计算结果")
logger.info(f" 成功: {response.success}")
logger.info(f" 表达式: {response.expression}")
logger.info(f" 结果: {response.result}")
logger.info(f" 消息: {response.message}")
logger.info("="*60)
return response
except Exception as e:
logger.error(f"❌ 计算失败: {str(e)}")
logger.error("="*60)
return CalculateResult(
success=False,
expression=request.expression,
message=f"计算失败: {str(e)}"
)

View File

@@ -0,0 +1,140 @@
"""
服务器端 JSX 执行器
关键业务逻辑在服务器执行,前端只能通过 API 调用
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional, Dict, Any
from app.core.security import get_current_user
from app.db import get_db_session
import json
router = APIRouter()
# JSX 脚本模板库(服务器端存储)
JSX_TEMPLATES = {
"create_layer": """
(function(layerName) {
try {
if (app.documents.length === 0) {
return JSON.stringify({ error: '没有打开的文档' });
}
var doc = app.activeDocument;
var layer = doc.artLayers.add();
layer.name = layerName;
return JSON.stringify({
success: true,
layerName: layerName
});
} catch (e) {
return JSON.stringify({ error: e.toString() });
}
})('{layerName}')
""",
"create_layer_with_style": """
(function(layerName, opacity, color) {
try {
if (app.documents.length === 0) {
return JSON.stringify({ error: '没有打开的文档' });
}
var doc = app.activeDocument;
var layer = doc.artLayers.add();
layer.name = layerName;
layer.opacity = opacity;
// 这里是你的核心算法
// 客户端无法看到具体实现
return JSON.stringify({
success: true,
layerName: layerName,
applied: true
});
} catch (e) {
return JSON.stringify({ error: e.toString() });
}
})('{layerName}', {opacity}, '{color}')
""",
# 更多核心功能...
}
class JSXExecuteRequest(BaseModel):
template_name: str # 模板名称(不是完整脚本)
params: Dict[str, Any] # 参数
device_id: str
class JSXExecuteResponse(BaseModel):
success: bool
jsx_code: str # 返回要执行的 JSX 代码
message: Optional[str] = None
@router.post("/execute", response_model=JSXExecuteResponse)
async def execute_jsx(
request: JSXExecuteRequest,
current_user: dict = Depends(get_current_user)
):
"""
服务器端生成 JSX 代码
客户端只能通过 API 获取,无法直接看到核心逻辑
"""
try:
username = current_user.get("username")
# 1. 验证用户和设备
# ... (从数据库检查用户是否有权限、设备是否绑定)
# 2. 检查模板是否存在
if request.template_name not in JSX_TEMPLATES:
raise HTTPException(status_code=404, detail="模板不存在")
# 3. 验证用户权限(某些高级功能需要付费)
# ... (从数据库检查用户等级)
# 4. 获取模板并填充参数
template = JSX_TEMPLATES[request.template_name]
# 参数安全过滤(防止注入)
safe_params = {
key: str(value).replace("'", "\\'").replace('"', '\\"')
for key, value in request.params.items()
}
# 填充参数
jsx_code = template.format(**safe_params)
# 5. 记录操作日志
# ... (记录谁在什么时候执行了什么操作)
return JSXExecuteResponse(
success=True,
jsx_code=jsx_code,
message="代码已生成"
)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/templates")
async def list_templates(current_user: dict = Depends(get_current_user)):
"""
列出可用的 JSX 模板(不返回具体代码)
"""
return {
"templates": [
{
"name": "create_layer",
"description": "创建图层",
"params": ["layerName"]
},
{
"name": "create_layer_with_style",
"description": "创建带样式的图层(高级)",
"params": ["layerName", "opacity", "color"],
"premium": True # 需要付费
}
]
}

View File

@@ -0,0 +1,80 @@
"""
API Key 管理
用于验证客户端请求
"""
from datetime import datetime
from typing import Optional, Dict
class APIKeyManager:
"""API Key 管理器"""
# 🔐 有效的 API Keys
# 生产环境建议从环境变量或数据库读取
VALID_KEYS: Dict[str, dict] = {
"demo_key_123": {
"name": "测试密钥",
"created": "2024-12-16",
"permissions": ["calculate"],
"rate_limit": 100 # 每小时最多 100 次请求
},
"prod_key_xyz789abc": {
"name": "生产密钥",
"created": "2024-12-16",
"permissions": ["calculate", "admin"],
"rate_limit": 1000
}
}
@classmethod
def validate_key(cls, api_key: Optional[str]) -> bool:
"""验证 API Key 是否有效"""
if not api_key:
return False
return api_key in cls.VALID_KEYS
@classmethod
def get_key_info(cls, api_key: str) -> Optional[dict]:
"""获取 API Key 信息"""
return cls.VALID_KEYS.get(api_key)
@classmethod
def check_permission(cls, api_key: str, permission: str) -> bool:
"""检查 API Key 是否有指定权限"""
key_info = cls.get_key_info(api_key)
if not key_info:
return False
return permission in key_info.get("permissions", [])
@classmethod
def add_key(cls, api_key: str, name: str, permissions: list = None):
"""添加新的 API Key"""
cls.VALID_KEYS[api_key] = {
"name": name,
"created": datetime.now().strftime("%Y-%m-%d"),
"permissions": permissions or ["calculate"],
"rate_limit": 100
}
@classmethod
def remove_key(cls, api_key: str):
"""删除 API Key"""
if api_key in cls.VALID_KEYS:
del cls.VALID_KEYS[api_key]
@classmethod
def list_keys(cls) -> Dict[str, dict]:
"""列出所有 API Keys"""
return cls.VALID_KEYS.copy()
# 快捷函数
def validate_api_key(api_key: Optional[str]) -> bool:
"""验证 API Key"""
return APIKeyManager.validate_key(api_key)
def get_key_info(api_key: str) -> Optional[dict]:
"""获取 API Key 信息"""
return APIKeyManager.get_key_info(api_key)

24
Server/app/core/config.py Normal file
View File

@@ -0,0 +1,24 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
ENV: str = "development"
PROJECT_NAME: str = "DesignerCEP Backend"
API_V1_STR: str = "/api/v1"
DATABASE_URL: str = "sqlite:///./designercep.db"
SECRET_KEY: str = "change-me"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080
ALLOWED_ORIGINS: str = "*"
ADMIN_TOKEN: str = "admin-token"
# Email Configuration
SMTP_HOST: str = "smtp.gmail.com"
SMTP_PORT: int = 587
SMTP_USER: str = "ly1104803132@gmail.com"
SMTP_PASSWORD: str = "wsfrpnmkojpsqdkk"
EMAILS_FROM_EMAIL: str = "ly1104803132@gmail.com"
EMAILS_FROM_NAME: str = "Designer"
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
settings = Settings()

115
Server/app/core/security.py Normal file
View File

@@ -0,0 +1,115 @@
from datetime import datetime, timedelta
from typing import Optional
import jwt
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from passlib.context import CryptContext
from sqlalchemy.orm import Session
from app.core.config import settings
from app.db import get_db
from app.models.session import UserSession
# 密码哈希配置(使用 bcrypt
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl=f"{settings.API_V1_STR}/auth/login")
def verify_password(plain_password: str, hashed_password: str) -> bool:
# 验证明文密码与哈希是否匹配
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
# 对密码进行哈希
return pwd_context.hash(password)
def create_access_token(subject: str, device_id: str, expires_delta: Optional[timedelta] = None) -> str:
# 创建 JWT 访问令牌,默认过期时间从配置读取
expire = datetime.utcnow() + (expires_delta or timedelta(minutes=getattr(settings, "ACCESS_TOKEN_EXPIRE_MINUTES", 60)))
# 将 device_id 写入 Token Payload
to_encode = {"sub": subject, "device_id": device_id, "exp": expire}
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)) -> str:
"""
验证 JWT Token 并返回当前用户名 (sub)
增加 Session 强校验:检查数据库中 Session 是否活跃
"""
print("="*60)
print("[get_current_user] 开始验证 Token")
print(f" - Token (前30字符): {token[:30]}...")
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的认证凭据",
headers={"WWW-Authenticate": "Bearer"},
)
# 1. 解析 Token
try:
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
username: str = payload.get("sub")
device_id: str = payload.get("device_id")
print(f"[get_current_user] ✓ Token 解析成功")
print(f" - username: {username}")
print(f" - device_id: {device_id}")
print(f" - exp: {payload.get('exp')}")
if username is None:
print("[get_current_user] ✗ username 为空")
raise credentials_exception
except jwt.ExpiredSignatureError:
print("[get_current_user] ✗ Token 已过期")
raise credentials_exception
except jwt.InvalidTokenError as e:
print(f"[get_current_user] ✗ Token 无效: {e}")
raise credentials_exception
except jwt.PyJWTError as e:
print(f"[get_current_user] ✗ Token 解析失败: {e}")
raise credentials_exception
# 2. Session 强校验 (如果有 device_id)
# 如果 Token 是旧版本没有 device_id可以选择放行或拒绝。为了安全建议逐步拒绝。
# 这里我们假设所有新 Token 都有 device_id
if device_id:
print(f"[get_current_user] 开始 Session 强校验")
# 查询 User ID
from app.models.user import User
user = db.query(User).filter(User.username == username).first()
if not user:
print(f"[get_current_user] ✗ 用户不存在: {username}")
raise credentials_exception
print(f"[get_current_user] ✓ 用户存在: user_id={user.id}")
# 查询活跃 Session
print(f"[get_current_user] 查询活跃 Session: user_id={user.id}, device_id={device_id}")
session = db.query(UserSession).filter(
UserSession.user_id == user.id,
UserSession.device_id == device_id,
UserSession.active == True
).first()
if not session:
# Session 不存在或已失效(被踢下线/登出)
print(f"[get_current_user] ✗ Session 不存在或已失效")
# 调试:列出该用户的所有 Session
all_sessions = db.query(UserSession).filter(UserSession.user_id == user.id).all()
print(f"[get_current_user] 该用户共有 {len(all_sessions)} 个 Session:")
for s in all_sessions:
print(f" - session_id={s.id}, device_id={s.device_id}, active={s.active}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="会话已失效或在其他设备登录",
headers={"WWW-Authenticate": "Bearer"},
)
print(f"[get_current_user] ✓ Session 验证通过: session_id={session.id}")
else:
print(f"[get_current_user] ⚠️ Token 中没有 device_id跳过 Session 校验")
print("="*60)
return username

99
Server/app/db.py Normal file
View File

@@ -0,0 +1,99 @@
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, declarative_base
from app.core.config import settings
# 数据库连接字符串,默认使用 SQLite 本地文件
SQLALCHEMY_DATABASE_URL = getattr(settings, "DATABASE_URL", "sqlite:///./designercep.db")
# 创建数据库引擎
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False} if SQLALCHEMY_DATABASE_URL.startswith("sqlite") else {}
)
# 会话工厂与 ORM 基类
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
# FastAPI 依赖注入使用的数据库会话
db = SessionLocal()
try:
yield db
finally:
db.close()
def init_db():
# 初始化数据库(创建所有 ORM 映射的表)
from app.models.user import User
from app.models.group import PluginGroup
from app.models.session import UserSession
Base.metadata.create_all(bind=engine)
ensure_migrations()
seed_data()
def seed_data():
"""Ensure default data exists"""
from app.models.group import PluginGroup
db = SessionLocal()
try:
default_group = db.query(PluginGroup).filter(PluginGroup.name == "default").first()
if not default_group:
print("Creating 'default' group...")
new_group = PluginGroup(name="default", comment="Default User Group")
db.add(new_group)
db.commit()
print("Default group created.")
except Exception as e:
print(f"Error seeding data: {e}")
finally:
db.close()
def ensure_migrations():
# 轻量级迁移:为 SQLite 动态添加缺失列
if not SQLALCHEMY_DATABASE_URL.startswith("sqlite"):
return
with engine.connect() as conn:
def has_column(table: str, col: str) -> bool:
try:
rows = conn.exec_driver_sql(f"PRAGMA table_info('{table}')").fetchall()
names = {r[1] for r in rows} if rows else set()
return col in names
except Exception:
return False
def add_col(table: str, col: str, type_sql: str):
try:
conn.exec_driver_sql(f"ALTER TABLE {table} ADD COLUMN {col} {type_sql}")
except Exception as e:
print(f"Migration error for {table}.{col}: {e}")
# user_sessions 需要的列
if not has_column("user_sessions", "login_at"):
add_col("user_sessions", "login_at", "TIMESTAMP NULL")
if not has_column("user_sessions", "logout_at"):
add_col("user_sessions", "logout_at", "TIMESTAMP NULL")
if not has_column("user_sessions", "duration_seconds"):
add_col("user_sessions", "duration_seconds", "INTEGER NULL")
if not has_column("user_sessions", "last_seen_at"):
add_col("user_sessions", "last_seen_at", "TIMESTAMP NULL")
# users 需要的列
if not has_column("users", "group_id"):
add_col("users", "group_id", "INTEGER NULL REFERENCES plugin_groups(id)")
if not has_column("users", "permissions"):
add_col("users", "permissions", "TEXT NULL")
if not has_column("users", "expire_date"):
add_col("users", "expire_date", "TIMESTAMP NULL")
# users email & verification columns
if not has_column("users", "email"):
add_col("users", "email", "VARCHAR(255) NULL")
if not has_column("users", "is_verified"):
add_col("users", "is_verified", "BOOLEAN DEFAULT 0")
if not has_column("users", "verification_code"):
add_col("users", "verification_code", "VARCHAR(6) NULL")
if not has_column("users", "reset_token"):
add_col("users", "reset_token", "VARCHAR(128) NULL")
if not has_column("users", "reset_token_expire"):
add_col("users", "reset_token_expire", "TIMESTAMP NULL")

110
Server/app/main.py Normal file
View File

@@ -0,0 +1,110 @@
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
import os
from pathlib import Path
from app.core.config import settings
from app.api.v1 import auth, client, admin, analytics, jsx_demo
from app.db import init_db
from datetime import datetime
app = FastAPI(title=settings.PROJECT_NAME)
# Ensure archives directory exists
os.makedirs("archives", exist_ok=True)
# ========== CORS 配置 ==========
IS_DEV = os.getenv("ENV", "development") == "development"
if IS_DEV:
# 开发环境:保持宽松
print("⚠️ Running in Development Mode: CORS allows *")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
else:
# 生产环境:严格配置
allowed_origins = os.getenv("ALLOWED_ORIGINS", "").split(",")
print(f"🔒 Running in Production Mode: CORS allowed origins: {allowed_origins}")
app.add_middleware(
CORSMiddleware,
allow_origins=allowed_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
allow_headers=["*"],
)
# ✅ CEP 环境特殊处理 (Origin: null or cep://)
@app.middleware("http")
async def cep_cors_middleware(request: Request, call_next):
origin = request.headers.get("origin")
# CEP 的 Origin 是 null 或 cep://
if origin in ["null", None] or (origin and origin.startswith("cep://")):
response = await call_next(request)
response.headers["Access-Control-Allow-Origin"] = "*"
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS"
response.headers["Access-Control-Allow-Headers"] = "*"
return response
return await call_next(request)
app.include_router(auth.router, prefix=f"{settings.API_V1_STR}/auth", tags=["authentication"])
app.include_router(client.router, prefix=f"{settings.API_V1_STR}/client", tags=["client"])
app.include_router(admin.router, prefix=f"{settings.API_V1_STR}/admin", tags=["admin"])
app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["analytics"])
app.include_router(jsx_demo.router, prefix=f"{settings.API_V1_STR}/jsx_demo", tags=["jsx_demo"])
# Health Check
@app.get("/health")
def health_check():
return {"status": "healthy", "timestamp": datetime.now().isoformat(), "env": "development" if IS_DEV else "production"}
# ========== Static Files (Development Only) ==========
if IS_DEV:
# Mount archives directory for download
app.mount("/download", StaticFiles(directory="archives"), name="download")
# Mount Shell directory (登录页面)
shell_dir = Path(__file__).parent.parent / "Designer"
if shell_dir.exists():
app.mount("/shell", StaticFiles(directory=str(shell_dir), html=True), name="shell")
print(f"✓ Shell 已挂载 (Dev): {shell_dir}")
else:
print(f"⚠️ Shell 目录不存在: {shell_dir}")
print(" 请先运行: cd Designer && npm run build:shell")
# Mount DesignerCache directory to serve Core application files
designer_cache = Path.home() / "AppData" / "Roaming" / "DesignerCache"
if designer_cache.exists():
app.mount("/core", StaticFiles(directory=str(designer_cache), html=True), name="core")
print(f"✓ Core 已挂载 (Dev): {designer_cache}")
else:
# Create directory if it doesn't exist
designer_cache.mkdir(parents=True, exist_ok=True)
app.mount("/core", StaticFiles(directory=str(designer_cache), html=True), name="core")
else:
print(" Production Mode: Static files are NOT mounted by FastAPI (handled by Caddy/Nginx).")
@app.get("/")
def read_root():
from fastapi.responses import RedirectResponse
if IS_DEV:
# 重定向到 Shell 登录页
return RedirectResponse(url="/shell/index.html")
return {"message": "DesignerCEP API is running"}
@app.on_event("startup")
def on_startup():
# 应用启动时初始化数据库(创建表)
init_db()
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)

View File

@@ -0,0 +1,13 @@
from sqlalchemy import Column, Integer, String, Text
from sqlalchemy.orm import relationship
from app.db import Base
class PluginGroup(Base):
__tablename__ = "plugin_groups"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(64), unique=True, index=True, nullable=False)
current_version_file = Column(String(255), nullable=True) # Name of the zip file
comment = Column(Text, nullable=True)
users = relationship("User", back_populates="group")

View File

@@ -0,0 +1,19 @@
from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, func
from sqlalchemy.orm import relationship
from app.db import Base
class UserSession(Base):
# 用户会话表,用于限制单设备同时在线
__tablename__ = "user_sessions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
device_id = Column(String(128), nullable=False, index=True) # 设备标识(前端提供)
active = Column(Boolean, default=True, nullable=False) # 是否处于活跃登录状态
expires_at = Column(DateTime(timezone=True), nullable=True) # 过期时间(与令牌一致)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
login_at = Column(DateTime(timezone=True), nullable=True) # 登录时间
logout_at = Column(DateTime(timezone=True), nullable=True) # 登出时间
duration_seconds = Column(Integer, nullable=True) # 在线时长(秒)
last_seen_at = Column(DateTime(timezone=True), nullable=True) # 最近心跳时间(用于统计活跃时长)
user = relationship("User")

26
Server/app/models/user.py Normal file
View File

@@ -0,0 +1,26 @@
from sqlalchemy import Column, Integer, String, DateTime, func, ForeignKey, Text, Boolean
from sqlalchemy.orm import relationship
from app.db import Base
class User(Base):
# 用户表定义
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(64), unique=True, index=True, nullable=False) # 用户名唯一
hashed_password = Column(String(128), nullable=False) # 加密后的密码
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) # 创建时间
group_id = Column(Integer, ForeignKey("plugin_groups.id"), nullable=True)
permissions = Column(Text, nullable=True) # Comma separated permissions
expire_date = Column(DateTime(timezone=True), nullable=True)
# Email & Verification
email = Column(String(255), unique=True, index=True, nullable=True)
is_verified = Column(Boolean, default=False)
verification_code = Column(String(6), nullable=True)
# Password Reset
reset_token = Column(String(128), nullable=True)
reset_token_expire = Column(DateTime(timezone=True), nullable=True)
group = relationship("PluginGroup", back_populates="users")

View File

@@ -0,0 +1,13 @@
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
class UserInfo(BaseModel):
id: int
username: str
group_id: Optional[int] = None
permissions: Optional[str] = None
expire_date: Optional[datetime] = None
class Config:
from_attributes = True

View File

@@ -0,0 +1,60 @@
from pydantic import BaseModel
class UserLogin(BaseModel):
# 登录请求模型
username: str
password: str
device_id: str # 设备标识,用于限制单设备登录
class UserRegister(BaseModel):
# 注册请求模型
username: str
password: str
confirm_password: str
email: str | None = None # Optional for backward compatibility, but required for verification
code: str | None = None # 新增:验证码
device_id: str = "unknown_device" # 兼容旧前端,设为默认值,建议前端传入
class SendVerificationCodeRequest(BaseModel):
email: str
class VerifyEmailRequest(BaseModel):
username: str
code: str
class ForgotPasswordRequest(BaseModel):
email: str
class ResetPasswordRequest(BaseModel):
email: str # 新增:必须传 email 配合验证码
token: str # 这里是 6 位数字验证码
new_password: str
confirm_password: str
class Token(BaseModel):
# 登录/注册成功返回的令牌模型
access_token: str
token_type: str
username: str
class UserLogout(BaseModel):
# 登出请求模型
username: str
device_id: str
class UserHeartbeat(BaseModel):
# 心跳请求模型(更新最近在线时间)
username: str
device_id: str
class VerifyRequest(BaseModel):
"""验证请求模型"""
username: str
device_id: str
timestamp: int
class VerifyResponse(BaseModel):
"""验证响应模型"""
valid: bool
username: str | None = None
expire_date: str | None = None

View File

@@ -0,0 +1,28 @@
from typing import List, Optional
from pydantic import BaseModel
from datetime import date, datetime
class CheckUpdateData(BaseModel):
version: str
download_url: str
force_update: bool
is_expired: bool
class CheckUpdateResponse(BaseModel):
code: int
data: CheckUpdateData
message: str
class LoginData(BaseModel):
token: str
username: str
expire_date: Optional[str] # YYYY-MM-DD
permissions: List[str]
class LoginResponse(BaseModel):
code: int
data: LoginData
message: str
class CheckUpdateRequest(BaseModel):
username: str

View File

@@ -0,0 +1,19 @@
from typing import Optional
from pydantic import BaseModel
class PluginGroupBase(BaseModel):
name: str
current_version_file: Optional[str] = None
comment: Optional[str] = None
class PluginGroupCreate(PluginGroupBase):
pass
class PluginGroupUpdate(PluginGroupBase):
name: Optional[str] = None
class PluginGroup(PluginGroupBase):
id: int
class Config:
from_attributes = True

View File

@@ -0,0 +1,421 @@
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.schemas.auth import UserLogin, UserRegister, Token, VerifyRequest, VerifyResponse
from app.schemas.client import LoginData
from app.models.user import User
from app.core.security import verify_password, get_password_hash, create_access_token
from app.models.session import UserSession
from datetime import datetime, timedelta, timezone
import random
import secrets
import string
from app.services.email_service import email_service
from app.models.group import PluginGroup
class AuthService:
def login(self, db: Session, login_data: UserLogin) -> Token:
# 根据用户名查找用户并验证密码
user = db.query(User).filter(User.username == login_data.username).first()
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户名或密码错误",
headers={"WWW-Authenticate": "Bearer"},
)
# 单设备同时在线限制:自动踢掉其他设备的旧会话
now = datetime.now(timezone.utc)
other_active_sessions = (
db.query(UserSession)
.filter(
UserSession.user_id == user.id,
UserSession.device_id != login_data.device_id,
UserSession.active == True,
(UserSession.expires_at == None) | (UserSession.expires_at > now),
)
.all()
)
# 自动踢掉其他设备(设置为非活跃)
if other_active_sessions:
for session in other_active_sessions:
session.active = False
session.logout_at = now
# 计算该会话的时长
if session.login_at:
login_at = session.login_at
if login_at.tzinfo is None:
login_at = login_at.replace(tzinfo=timezone.utc)
session.duration_seconds = int((now - login_at).total_seconds())
db.commit()
token = create_access_token(subject=login_data.username, device_id=login_data.device_id)
# 记录/更新当前设备会话
session = (
db.query(UserSession)
.filter(UserSession.user_id == user.id, UserSession.device_id == login_data.device_id)
.first()
)
expires = now + timedelta(days=7) # 7 天有效期
if session:
session.active = True
session.expires_at = expires
session.login_at = now
session.logout_at = None
session.duration_seconds = None
session.last_seen_at = now
else:
session = UserSession(
user_id=user.id,
device_id=login_data.device_id,
active=True,
expires_at=expires,
login_at=now,
last_seen_at=now,
)
db.add(session)
db.commit()
return Token(access_token=token, token_type="bearer", username=login_data.username)
def client_login(self, db: Session, login_data: UserLogin) -> LoginData:
# Re-use logic or call login internally?
# Ideally refactor, but for now let's copy the essential verification logic to ensure correct return type
# Or better, call login to get token and session handling, then enrich data.
token_obj = self.login(db, login_data)
user = db.query(User).filter(User.username == login_data.username).first()
permissions_list = []
if user.permissions:
permissions_list = [p.strip() for p in user.permissions.split(",")]
expire_date_str = None
if user.expire_date:
expire_date_str = user.expire_date.strftime("%Y-%m-%d")
return LoginData(
token=token_obj.access_token,
username=user.username,
expire_date=expire_date_str,
permissions=permissions_list
)
def register(self, db: Session, register_data: UserRegister) -> Token:
# 校验确认密码一致性
if register_data.password != register_data.confirm_password:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="两次输入的密码不一致"
)
# 检查用户名是否已存在
existing = db.query(User).filter(User.username == register_data.username).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="用户名已被注册"
)
# 检查邮箱是否已存在
if register_data.email:
existing_email = db.query(User).filter(User.email == register_data.email).first()
if existing_email:
# 如果已验证,或者是旧流程(无验证码),则报错
# 新流程(有验证码)允许存在未验证的用户记录(即临时用户)
is_new_flow = hasattr(register_data, "code") and register_data.code
if existing_email.is_verified or not is_new_flow:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="该邮箱已被注册"
)
# 验证码校验逻辑 (如果提供了验证码)
if hasattr(register_data, "code") and register_data.code:
# 这里需要一种机制来验证验证码,通常需要先调用 send-verification-code 接口
# 并在数据库或缓存中暂存验证码。
# 由于当前 User 表是注册成功才创建,我们需要一个临时存储或者允许预先创建未验证用户。
# 方案:先查询是否有未验证的同名/同邮箱用户,或者使用 Redis。
# 简单方案:使用一个专门的 VerificationCode 表,或者复用 User 表但标记状态。
# 这里为了配合新的需求,我们需要修改 User 表的使用方式:
# 1. 发送验证码时,如果用户不存在,创建一个 is_verified=False 的用户,存 code
# 2. 注册时,查找该用户,验证 code更新密码等信息设 is_verified=True
# 但 send-verification-code 接口目前还未实现,我们先假设用户通过该接口发送了验证码
# 并且我们通过 email 查找到了这个预创建的用户记录
pre_user = db.query(User).filter(User.email == register_data.email).first()
if not pre_user:
raise HTTPException(status_code=400, detail="请先发送验证码")
if pre_user.verification_code != register_data.code:
raise HTTPException(status_code=400, detail="验证码错误")
# 验证通过,更新用户信息 (从预创建转为正式)
user = pre_user
user.username = register_data.username
user.hashed_password = get_password_hash(register_data.password)
user.is_verified = True
user.verification_code = None
# 处理组逻辑
target_group = db.query(PluginGroup).filter(PluginGroup.name == "default").first()
if not target_group:
target_group = db.query(PluginGroup).first()
user.group_id = target_group.id if target_group else None
else:
# 旧逻辑:直接创建,后续验证
# 创建新用户,保存哈希后的密码
# 自动分配组策略:
# 1. 尝试查找名为 "default" 的组
# 2. 如果不存在,尝试使用数据库中第一个组
# 3. 如果没有任何组,则 group_id 为 None
target_group = db.query(PluginGroup).filter(PluginGroup.name == "default").first()
if not target_group:
target_group = db.query(PluginGroup).first()
group_id = target_group.id if target_group else None
user = User(
username=register_data.username,
hashed_password=get_password_hash(register_data.password),
group_id=group_id,
email=register_data.email,
is_verified=False
)
# 如果提供了邮箱,生成验证码并发送
if register_data.email:
code = ''.join(random.choices(string.digits, k=6))
user.verification_code = code
try:
email_service.send_verification_email(register_data.email, code)
except Exception as e:
print(f"Failed to send verification email: {e}")
db.add(user)
db.commit()
db.refresh(user)
# 注册成功后,自动创建会话并登录
# 注意:这里需要 device_id如果前端未传默认值为 "unknown_device"
device_id = getattr(register_data, "device_id", "unknown_device")
# 创建 Session
now = datetime.now(timezone.utc)
expires = now + timedelta(days=7) # 保持与 Login 一致
session = UserSession(
user_id=user.id,
device_id=device_id,
active=True,
expires_at=expires,
login_at=now,
last_seen_at=now,
)
db.add(session)
db.commit()
token = create_access_token(subject=register_data.username, device_id=device_id)
return Token(access_token=token, token_type="bearer", username=register_data.username)
def send_verification_code(self, db: Session, email: str) -> dict:
# 1. 检查邮箱是否已被正式注册
existing = db.query(User).filter(User.email == email, User.is_verified == True).first()
if existing:
raise HTTPException(status_code=400, detail="该邮箱已被注册")
# 2. 查找或创建临时用户记录
user = db.query(User).filter(User.email == email).first()
code = ''.join(random.choices(string.digits, k=6))
if user:
# 更新现有临时用户的验证码
user.verification_code = code
else:
# 创建临时用户 (username 暂时用 email 占位,注册时会更新)
# 注意username 是 unique 且 nullable=False所以必须给一个值
# 我们可以用 "temp_{email}" 或者随机字符串,只要不冲突
temp_username = f"temp_{secrets.token_hex(8)}"
user = User(
username=temp_username,
email=email,
hashed_password="temp_password_placeholder", # 必填字段占位
is_verified=False,
verification_code=code
)
db.add(user)
db.commit()
# 3. 发送邮件
try:
email_service.send_verification_email(email, code)
except Exception as e:
raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}")
return {"detail": "验证码已发送"}
def verify_email(self, db: Session, username: str, code: str) -> dict:
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.is_verified:
return {"detail": "邮箱已验证"}
if not user.verification_code or user.verification_code != code:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误")
user.is_verified = True
user.verification_code = None
db.commit()
return {"detail": "验证成功"}
def forgot_password(self, db: Session, email: str) -> dict:
user = db.query(User).filter(User.email == email).first()
if not user:
# 为安全起见,不提示用户不存在,或者提示已发送
return {"detail": "如果邮箱存在,重置邮件已发送"}
# 改用6位数字验证码作为Token为了用户体验
token = ''.join(random.choices(string.digits, k=6))
# 存储 Token (复用 reset_token 字段,虽然叫 token 但存的是验证码)
user.reset_token = token
user.reset_token_expire = datetime.now(timezone.utc) + timedelta(minutes=30)
db.commit()
try:
email_service.send_reset_password_email(email, token)
except Exception as e:
raise HTTPException(status_code=500, detail=f"邮件发送失败: {str(e)}")
return {"detail": "如果邮箱存在,重置邮件已发送"}
def reset_password(self, db: Session, token: str, new_password: str, email: str) -> dict:
# 修改reset_password 需要 email + code 来定位用户
# 因为6位验证码可能重复所以必须配合邮箱查找
user = db.query(User).filter(User.email == email).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
if user.reset_token != token:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误")
if not user.reset_token_expire:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码无效")
expire_time = user.reset_token_expire
if expire_time.tzinfo is None:
expire_time = expire_time.replace(tzinfo=timezone.utc)
if expire_time < datetime.now(timezone.utc):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码已过期")
user.hashed_password = get_password_hash(new_password)
user.reset_token = None
user.reset_token_expire = None
db.commit()
return {"detail": "密码重置成功"}
def logout(self, db: Session, username: str, device_id: str) -> dict:
# 将指定用户的指定设备会话标记为非活跃
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
session = (
db.query(UserSession)
.filter(UserSession.user_id == user.id, UserSession.device_id == device_id, UserSession.active == True)
.first()
)
if not session:
# 没有活跃会话也视为成功,前端可以重入
return {"detail": "已退出登录"}
session.active = False
# 记录退出时间与在线时长(秒)
now = datetime.now(timezone.utc)
session.logout_at = now
if session.login_at:
login_at = session.login_at
if login_at.tzinfo is None:
login_at = login_at.replace(tzinfo=timezone.utc)
session.duration_seconds = int((now - login_at).total_seconds())
db.commit()
return {"detail": "已退出登录"}
def heartbeat(self, db: Session, username: str, device_id: str) -> dict:
# 更新指定设备会话的最近心跳时间,用于统计活跃在线时长
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
session = (
db.query(UserSession)
.filter(UserSession.user_id == user.id, UserSession.device_id == device_id, UserSession.active == True)
.first()
)
if not session:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在或已登出")
session.last_seen_at = datetime.now(timezone.utc)
db.commit()
return {"detail": "心跳已更新"}
def verify_license(self, db: Session, verify_data: VerifyRequest, current_username: str) -> VerifyResponse:
# 1. 验证用户是否存在
user = db.query(User).filter(User.username == verify_data.username).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="用户不存在"
)
# 2. 检查 Token 用户一致性
if current_username != verify_data.username:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Token 用户与请求用户不匹配"
)
# 3. 检查账户是否过期
expire_date_str = None
if user.expire_date:
expire_dt = user.expire_date
if expire_dt.tzinfo is None:
expire_dt = expire_dt.replace(tzinfo=timezone.utc)
if datetime.now(timezone.utc) > expire_dt:
return VerifyResponse(
valid=False,
username=user.username,
expire_date=user.expire_date.isoformat()
)
expire_date_str = user.expire_date.isoformat()
# 4. 检查会话是否活跃
session = db.query(UserSession).filter(
UserSession.user_id == user.id,
UserSession.device_id == verify_data.device_id,
UserSession.active == True
).first()
if not session:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="会话不存在或已登出"
)
# 5. 更新最后活跃时间
session.last_seen_at = datetime.now(timezone.utc)
db.commit()
return VerifyResponse(
valid=True,
username=user.username,
expire_date=expire_date_str
)
auth_service = AuthService()

View File

@@ -0,0 +1,63 @@
import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class EmailService:
def send_email(self, to_email: str, subject: str, body: str):
if not settings.SMTP_USER or settings.SMTP_USER == "your-email@gmail.com":
logger.warning("SMTP not configured, skipping email sending.")
print(f"--- Mock Email ---\nTo: {to_email}\nSubject: {subject}\nBody: {body}\n------------------")
return
try:
msg = MIMEMultipart()
msg['From'] = f"{settings.EMAILS_FROM_NAME} <{settings.EMAILS_FROM_EMAIL}>"
msg['To'] = to_email
msg['Subject'] = subject
msg.attach(MIMEText(body, 'html'))
server = smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT)
server.starttls()
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
text = msg.as_string()
server.sendmail(settings.EMAILS_FROM_EMAIL, to_email, text)
server.quit()
logger.info(f"Email sent to {to_email}")
except Exception as e:
logger.error(f"Failed to send email: {e}")
raise e
def send_verification_email(self, to_email: str, code: str):
subject = "验证您的邮箱 - Designer"
body = f"""
<html>
<body>
<h2>欢迎注册 Designer</h2>
<p>您的验证码是:<strong style="font-size: 24px; color: #165DFF;">{code}</strong></p>
<p>请输入此验证码完成注册。如果您没有请求此代码,请忽略此邮件。</p>
</body>
</html>
"""
self.send_email(to_email, subject, body)
def send_reset_password_email(self, to_email: str, token: str):
subject = "重置密码 - Designer"
body = f"""
<html>
<body>
<h2>重置密码</h2>
<p>您收到了这封邮件是因为您(或者其他人)请求重置您的账户密码。</p>
<p>您的重置验证码是:<strong style="font-size: 24px; color: #165DFF;">{token}</strong></p>
<p>请在重置密码页面输入此验证码。</p>
<p>如果您没有请求重置密码,请忽略此邮件。</p>
</body>
</html>
"""
self.send_email(to_email, subject, body)
email_service = EmailService()

View File

@@ -0,0 +1,60 @@
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from app.models.user import User
from app.models.group import PluginGroup
from app.schemas.client import CheckUpdateData
from datetime import datetime, timezone
class GroupService:
def check_update(self, db: Session, username: str) -> CheckUpdateData:
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
# Check expiration
is_expired = False
if user.expire_date:
now = datetime.now(timezone.utc)
expire_date = user.expire_date
if expire_date.tzinfo is None:
expire_date = expire_date.replace(tzinfo=timezone.utc)
if now > expire_date:
is_expired = True
# Get group and version
if not user.group_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户未分配组")
group = db.query(PluginGroup).filter(PluginGroup.id == user.group_id).first()
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户所属组不存在")
if not group.current_version_file:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前组无可用版本")
# Construct download URL (assuming base URL or relative path)
# In a real scenario, this might be from config
download_url = f"/download/{group.current_version_file}"
# Extract version from filename roughly or use file metadata if available
# Assuming filename format: plugin_v1.0.2.zip
version = "unknown"
filename = group.current_version_file
if "v" in filename:
try:
# Simple extraction logic, can be improved
parts = filename.split("v")
if len(parts) > 1:
version_part = parts[1]
version = "v" + version_part.split(".zip")[0].split("_")[0]
except:
pass
return CheckUpdateData(
version=version,
download_url=download_url,
force_update=False,
is_expired=is_expired
)
group_service = GroupService()