v1.0.0提交
This commit is contained in:
4
backend/__init__.py
Normal file
4
backend/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# PerToolBox Server
|
||||
# Copyright (C) 2024 Sea Network Technology Studio
|
||||
# Author: Canglan <admin@sea-studio.top>
|
||||
# License: AGPL v3
|
||||
69
backend/config.py
Normal file
69
backend/config.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 配置模块
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List, Optional
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 应用配置
|
||||
APP_NAME: str = "PerToolBox"
|
||||
DEBUG: bool = False
|
||||
ENVIRONMENT: str = "production"
|
||||
SECRET_KEY: str
|
||||
|
||||
# 数据库
|
||||
DB_HOST: str = "localhost"
|
||||
DB_PORT: int = 3306
|
||||
DB_USER: str
|
||||
DB_PASSWORD: str
|
||||
DB_NAME: str
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_PASSWORD: Optional[str] = None
|
||||
REDIS_DB: int = 0
|
||||
|
||||
# 限流
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
RATE_LIMIT_REQUESTS: int = 100
|
||||
RATE_LIMIT_PERIOD: int = 60
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: List[str] = []
|
||||
|
||||
# 阿里云短信
|
||||
ALIYUN_SMS_ACCESS_KEY_ID: Optional[str] = None
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET: Optional[str] = None
|
||||
ALIYUN_SMS_SIGN_NAME: Optional[str] = None
|
||||
ALIYUN_SMS_TEMPLATE_CODE: Optional[str] = None
|
||||
|
||||
# 腾讯企业邮
|
||||
SMTP_HOST: str = "smtp.exmail.qq.com"
|
||||
SMTP_PORT: int = 465
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_FROM: Optional[str] = None
|
||||
|
||||
# 微信公众号(预留)
|
||||
WECHAT_APPID: Optional[str] = None
|
||||
WECHAT_APPSECRET: Optional[str] = None
|
||||
WECHAT_TOKEN: Optional[str] = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True
|
||||
)
|
||||
|
||||
settings = Settings()
|
||||
33
backend/database.py
Normal file
33
backend/database.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 数据库模块
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
from .config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
echo=settings.DEBUG,
|
||||
connect_args={"connect_timeout": 10}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
24
backend/middleware/logging.py
Normal file
24
backend/middleware/logging.py
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 日志中间件
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import time
|
||||
from fastapi import Request
|
||||
from ..utils.logger import logger
|
||||
|
||||
async def log_requests(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
logger.info(f"→ {request.method} {request.url.path}")
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
process_time = time.time() - start_time
|
||||
logger.info(f"← {request.method} {request.url.path} - {response.status_code} - {process_time:.3f}s")
|
||||
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
29
backend/middleware/rate_limit.py
Normal file
29
backend/middleware/rate_limit.py
Normal file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 限流中间件
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from ..config import settings
|
||||
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
def setup_rate_limit(app):
|
||||
if settings.RATE_LIMIT_ENABLED:
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
def rate_limit(requests: int = None, period: int = None):
|
||||
if not settings.RATE_LIMIT_ENABLED:
|
||||
return lambda func: func
|
||||
|
||||
req = requests or settings.RATE_LIMIT_REQUESTS
|
||||
per = period or settings.RATE_LIMIT_PERIOD
|
||||
return limiter.limit(f"{req}/{per} seconds")
|
||||
59
backend/models.py
Normal file
59
backend/models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 数据模型
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from .database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=True)
|
||||
phone = Column(String(20), unique=True, nullable=True)
|
||||
email = Column(String(100), unique=True, nullable=True)
|
||||
wx_openid = Column(String(100), unique=True, nullable=True)
|
||||
password_hash = Column(String(200), nullable=False)
|
||||
avatar = Column(String(500), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
last_login = Column(DateTime, onupdate=func.now())
|
||||
|
||||
class Todo(Base):
|
||||
__tablename__ = "todos"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
completed = Column(Boolean, default=False)
|
||||
priority = Column(Integer, default=1)
|
||||
category = Column(String(50), default="学习")
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
|
||||
class Note(Base):
|
||||
__tablename__ = "notes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=True)
|
||||
tags = Column(JSON, default=list)
|
||||
is_archived = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
|
||||
class ToolStatsTotal(Base):
|
||||
__tablename__ = "tool_stats_total"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tool_name = Column(String(50), unique=True, nullable=False)
|
||||
total_count = Column(Integer, default=0)
|
||||
24
backend/routers/v1/__init__.py
Normal file
24
backend/routers/v1/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - API v1 路由汇总
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .user import router as user_router
|
||||
from .todos import router as todos_router
|
||||
from .notes import router as notes_router
|
||||
from .tools import router as tools_router
|
||||
from .stats import router as stats_router
|
||||
|
||||
__all__ = [
|
||||
"auth_router",
|
||||
"user_router",
|
||||
"todos_router",
|
||||
"notes_router",
|
||||
"tools_router",
|
||||
"stats_router"
|
||||
]
|
||||
167
backend/routers/v1/auth.py
Normal file
167
backend/routers/v1/auth.py
Normal file
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 认证路由
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import re
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
from ...dependencies import DbDependency
|
||||
from ...models import User
|
||||
from ...schemas import (
|
||||
SendCodeRequest, RegisterRequest, LoginRequest, UserResponse
|
||||
)
|
||||
from ...utils.redis_client import redis_client
|
||||
from ...utils.security import generate_verify_code, hash_password, verify_password, create_access_token
|
||||
from ...utils.sms import send_sms
|
||||
from ...utils.email import send_email
|
||||
from ...utils.logger import logger
|
||||
from ...middleware.rate_limit import rate_limit
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
def is_phone(account: str) -> bool:
|
||||
return re.match(r'^1[3-9]\d{9}$', account) is not None
|
||||
|
||||
def is_email(account: str) -> bool:
|
||||
return re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', account) is not None
|
||||
|
||||
@router.post("/send-code")
|
||||
@rate_limit(requests=1, period=60) # 同一账号每分钟1次
|
||||
async def send_verify_code(request: SendCodeRequest, db: DbDependency):
|
||||
"""发送验证码"""
|
||||
account = request.account
|
||||
account_type = request.type
|
||||
|
||||
# 验证账号格式
|
||||
if account_type == "phone" and not is_phone(account):
|
||||
raise HTTPException(status_code=400, detail="手机号格式错误")
|
||||
if account_type == "email" and not is_email(account):
|
||||
raise HTTPException(status_code=400, detail="邮箱格式错误")
|
||||
|
||||
# 检查是否已存在(注册时使用)
|
||||
# 这里只负责发送,不检查是否存在
|
||||
|
||||
# 生成验证码
|
||||
code = generate_verify_code()
|
||||
|
||||
# 存储到 Redis(5分钟有效)
|
||||
redis_key = f"verify:code:{account}"
|
||||
redis_client.set(redis_key, code, expire=300)
|
||||
|
||||
# 发送验证码
|
||||
success = False
|
||||
if account_type == "phone":
|
||||
success = send_sms(account, code)
|
||||
else:
|
||||
success = await send_email(account, code)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=500, detail="验证码发送失败")
|
||||
|
||||
logger.info(f"验证码已发送: {account} -> {code}")
|
||||
return {"success": True, "message": "验证码已发送"}
|
||||
|
||||
@router.post("/register")
|
||||
@rate_limit(requests=5, period=60)
|
||||
async def register(request: RegisterRequest, db: DbDependency):
|
||||
"""注册(手机号/邮箱 + 验证码)"""
|
||||
account = request.account
|
||||
code = request.code
|
||||
password = request.password
|
||||
|
||||
# 验证验证码
|
||||
redis_key = f"verify:code:{account}"
|
||||
saved_code = redis_client.get(redis_key)
|
||||
if not saved_code or saved_code != code:
|
||||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
|
||||
# 检查用户是否已存在
|
||||
if is_phone(account):
|
||||
existing = db.query(User).filter(User.phone == account).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="手机号已注册")
|
||||
user = User(phone=account, password_hash=hash_password(password))
|
||||
elif is_email(account):
|
||||
existing = db.query(User).filter(User.email == account).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="邮箱已注册")
|
||||
user = User(email=account, password_hash=hash_password(password))
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="账号格式错误")
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
|
||||
# 删除已使用的验证码
|
||||
redis_client.delete(redis_key)
|
||||
|
||||
# 生成 token
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
|
||||
logger.info(f"新用户注册: {account}")
|
||||
return {
|
||||
"success": True,
|
||||
"token": token,
|
||||
"user": UserResponse.model_validate(user)
|
||||
}
|
||||
|
||||
@router.post("/login")
|
||||
@rate_limit(requests=10, period=60)
|
||||
async def login(request: LoginRequest, db: DbDependency):
|
||||
"""登录(支持验证码登录或密码登录)"""
|
||||
account = request.account
|
||||
password = request.password
|
||||
code = request.code
|
||||
|
||||
# 查找用户
|
||||
user = None
|
||||
if is_phone(account):
|
||||
user = db.query(User).filter(User.phone == account).first()
|
||||
elif is_email(account):
|
||||
user = db.query(User).filter(User.email == account).first()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="账号格式错误")
|
||||
|
||||
if not user:
|
||||
raise HTTPException(status_code=400, detail="用户不存在")
|
||||
|
||||
# 验证码登录
|
||||
if code:
|
||||
redis_key = f"verify:code:{account}"
|
||||
saved_code = redis_client.get(redis_key)
|
||||
if not saved_code or saved_code != code:
|
||||
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
||||
redis_client.delete(redis_key)
|
||||
|
||||
# 密码登录
|
||||
elif password:
|
||||
if not verify_password(password, user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="密码错误")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="请提供验证码或密码")
|
||||
|
||||
# 更新最后登录时间
|
||||
from datetime import datetime
|
||||
user.last_login = datetime.now()
|
||||
db.commit()
|
||||
|
||||
token = create_access_token({"sub": str(user.id)})
|
||||
|
||||
logger.info(f"用户登录: {account}")
|
||||
return {
|
||||
"success": True,
|
||||
"token": token,
|
||||
"user": UserResponse.model_validate(user)
|
||||
}
|
||||
|
||||
@router.post("/wechat/login")
|
||||
async def wechat_login():
|
||||
"""微信公众号登录(预留)"""
|
||||
# TODO: 实现公众号 OAuth2 登录
|
||||
raise HTTPException(status_code=501, detail="功能开发中")
|
||||
79
backend/routers/v1/notes.py
Normal file
79
backend/routers/v1/notes.py
Normal file
@@ -0,0 +1,79 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 便签路由
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional, List
|
||||
from ...dependencies import CurrentUserDependency, DbDependency
|
||||
from ...models import Note
|
||||
from ...schemas import NoteCreate, NoteUpdate, NoteResponse
|
||||
from ...middleware.rate_limit import rate_limit
|
||||
|
||||
router = APIRouter(prefix="/api/v1/notes", tags=["notes"])
|
||||
|
||||
@router.get("/", response_model=List[NoteResponse])
|
||||
@rate_limit(requests=100, period=60)
|
||||
async def get_notes(
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
archived: bool = False
|
||||
):
|
||||
return db.query(Note).filter(
|
||||
Note.user_id == current_user.id,
|
||||
Note.is_archived == archived
|
||||
).order_by(Note.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
@router.post("/", response_model=NoteResponse, status_code=201)
|
||||
@rate_limit(requests=50, period=60)
|
||||
async def create_note(
|
||||
data: NoteCreate,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
note = Note(user_id=current_user.id, **data.model_dump())
|
||||
db.add(note)
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
return note
|
||||
|
||||
@router.put("/{note_id}", response_model=NoteResponse)
|
||||
@rate_limit(requests=50, period=60)
|
||||
async def update_note(
|
||||
note_id: int,
|
||||
data: NoteUpdate,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.user_id == current_user.id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="便签不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(note, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
return note
|
||||
|
||||
@router.delete("/{note_id}")
|
||||
@rate_limit(requests=30, period=60)
|
||||
async def delete_note(
|
||||
note_id: int,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.user_id == current_user.id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="便签不存在")
|
||||
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
return {"success": True, "message": "已删除"}
|
||||
81
backend/routers/v1/stats.py
Normal file
81
backend/routers/v1/stats.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 热度统计路由
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter
|
||||
from ...utils.redis_client import redis_client
|
||||
from ...models import ToolStatsTotal
|
||||
from ...dependencies import DbDependency
|
||||
from ...middleware.rate_limit import rate_limit
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["stats"])
|
||||
|
||||
# 预定义工具名称(对应前端页面)
|
||||
TOOL_NAMES = [
|
||||
"todos", "notes", "password", "qrcode",
|
||||
"crypto_hash", "crypto_base64", "crypto_url", "crypto_aes", "json"
|
||||
]
|
||||
|
||||
@router.post("/tool/usage")
|
||||
@rate_limit(requests=20, period=60)
|
||||
async def record_usage(tool_name: str, db: DbDependency):
|
||||
"""记录页面访问次数(热度)"""
|
||||
if tool_name not in TOOL_NAMES:
|
||||
raise HTTPException(status_code=400, detail="无效的工具名")
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
today_key = f"tool:stats:today:{tool_name}:{today}"
|
||||
total_key = f"tool:stats:total:{tool_name}"
|
||||
|
||||
# 增加今日计数(设置48小时过期)
|
||||
today_count = redis_client.incr(today_key)
|
||||
redis_client.expire(today_key, 48 * 3600)
|
||||
|
||||
# 增加总计数
|
||||
total_count = redis_client.incr(total_key)
|
||||
|
||||
# 异步更新 MySQL(可选,这里简单处理)
|
||||
# 实际可改为定时任务同步,此处为简化,直接更新
|
||||
stats = db.query(ToolStatsTotal).filter(ToolStatsTotal.tool_name == tool_name).first()
|
||||
if stats:
|
||||
stats.total_count = total_count
|
||||
else:
|
||||
stats = ToolStatsTotal(tool_name=tool_name, total_count=total_count)
|
||||
db.add(stats)
|
||||
db.commit()
|
||||
|
||||
return {"success": True}
|
||||
|
||||
@router.get("/tool/stats")
|
||||
@rate_limit(requests=100, period=60)
|
||||
async def get_stats(db: DbDependency):
|
||||
"""获取所有工具的今日/总访问次数"""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
result = {}
|
||||
|
||||
for tool_name in TOOL_NAMES:
|
||||
today_key = f"tool:stats:today:{tool_name}:{today}"
|
||||
total_key = f"tool:stats:total:{tool_name}"
|
||||
|
||||
today_count = redis_client.get(today_key)
|
||||
total_count = redis_client.get(total_key)
|
||||
|
||||
if total_count is None:
|
||||
# 从 MySQL 读取
|
||||
stats = db.query(ToolStatsTotal).filter(ToolStatsTotal.tool_name == tool_name).first()
|
||||
total_count = stats.total_count if stats else 0
|
||||
else:
|
||||
total_count = int(total_count)
|
||||
|
||||
result[tool_name] = {
|
||||
"today": int(today_count) if today_count else 0,
|
||||
"total": total_count
|
||||
}
|
||||
|
||||
return result
|
||||
84
backend/routers/v1/todos.py
Normal file
84
backend/routers/v1/todos.py
Normal file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 待办事项路由
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional, List
|
||||
from ...dependencies import CurrentUserDependency, DbDependency
|
||||
from ...models import Todo
|
||||
from ...schemas import TodoCreate, TodoUpdate, TodoResponse
|
||||
from ...middleware.rate_limit import rate_limit
|
||||
|
||||
router = APIRouter(prefix="/api/v1/todos", tags=["todos"])
|
||||
|
||||
@router.get("/", response_model=List[TodoResponse])
|
||||
@rate_limit(requests=100, period=60)
|
||||
async def get_todos(
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency,
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
completed: Optional[bool] = None,
|
||||
category: Optional[str] = None
|
||||
):
|
||||
query = db.query(Todo).filter(Todo.user_id == current_user.id)
|
||||
|
||||
if completed is not None:
|
||||
query = query.filter(Todo.completed == completed)
|
||||
if category:
|
||||
query = query.filter(Todo.category == category)
|
||||
|
||||
return query.order_by(Todo.priority.desc(), Todo.created_at.desc()).offset(skip).limit(limit).all()
|
||||
|
||||
@router.post("/", response_model=TodoResponse, status_code=201)
|
||||
@rate_limit(requests=50, period=60)
|
||||
async def create_todo(
|
||||
data: TodoCreate,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
todo = Todo(user_id=current_user.id, **data.model_dump())
|
||||
db.add(todo)
|
||||
db.commit()
|
||||
db.refresh(todo)
|
||||
return todo
|
||||
|
||||
@router.put("/{todo_id}", response_model=TodoResponse)
|
||||
@rate_limit(requests=50, period=60)
|
||||
async def update_todo(
|
||||
todo_id: int,
|
||||
data: TodoUpdate,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
todo = db.query(Todo).filter(Todo.id == todo_id, Todo.user_id == current_user.id).first()
|
||||
if not todo:
|
||||
raise HTTPException(status_code=404, detail="待办事项不存在")
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(todo, key, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(todo)
|
||||
return todo
|
||||
|
||||
@router.delete("/{todo_id}")
|
||||
@rate_limit(requests=30, period=60)
|
||||
async def delete_todo(
|
||||
todo_id: int,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
todo = db.query(Todo).filter(Todo.id == todo_id, Todo.user_id == current_user.id).first()
|
||||
if not todo:
|
||||
raise HTTPException(status_code=404, detail="待办事项不存在")
|
||||
|
||||
db.delete(todo)
|
||||
db.commit()
|
||||
return {"success": True, "message": "已删除"}
|
||||
183
backend/routers/v1/tools.py
Normal file
183
backend/routers/v1/tools.py
Normal file
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 工具路由(密码、二维码、加密、JSON)
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import urllib.parse
|
||||
import json
|
||||
import qrcode
|
||||
from io import BytesIO
|
||||
import base64 as b64
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ...schemas import (
|
||||
HashRequest, Base64Request, URLRequest, AESRequest,
|
||||
JSONValidateRequest, JSONValidateResponse
|
||||
)
|
||||
from ...middleware.rate_limit import rate_limit
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["tools"])
|
||||
|
||||
# ========== 密码生成 ==========
|
||||
@router.get("/password/generate")
|
||||
@rate_limit(requests=50, period=60)
|
||||
async def generate_password(
|
||||
length: int = 12,
|
||||
upper: bool = True,
|
||||
lower: bool = True,
|
||||
digits: bool = True,
|
||||
symbols: bool = True,
|
||||
count: int = 1
|
||||
):
|
||||
import random
|
||||
import string
|
||||
|
||||
chars = ""
|
||||
if upper:
|
||||
chars += string.ascii_uppercase
|
||||
if lower:
|
||||
chars += string.ascii_lowercase
|
||||
if digits:
|
||||
chars += string.digits
|
||||
if symbols:
|
||||
chars += "!@#$%^&*"
|
||||
|
||||
if not chars:
|
||||
chars = string.ascii_letters + string.digits
|
||||
|
||||
passwords = [''.join(random.choice(chars) for _ in range(length)) for _ in range(count)]
|
||||
return {"passwords": passwords if count > 1 else passwords[0]}
|
||||
|
||||
# ========== 二维码 ==========
|
||||
@router.post("/qrcode/generate")
|
||||
@rate_limit(requests=30, period=60)
|
||||
async def generate_qrcode(content: str, size: int = 10):
|
||||
if not content:
|
||||
raise HTTPException(status_code=400, detail="内容不能为空")
|
||||
|
||||
qr = qrcode.QRCode(box_size=size, border=2)
|
||||
qr.add_data(content)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
|
||||
buffered = BytesIO()
|
||||
img.save(buffered, format="PNG")
|
||||
img_base64 = b64.b64encode(buffered.getvalue()).decode()
|
||||
|
||||
return {"qr_code": f"data:image/png;base64,{img_base64}"}
|
||||
|
||||
# ========== 哈希 ==========
|
||||
@router.post("/crypto/hash")
|
||||
@rate_limit(requests=100, period=60)
|
||||
async def compute_hash(request: HashRequest):
|
||||
text = request.text.encode('utf-8')
|
||||
algo = request.algorithm.lower()
|
||||
|
||||
if algo == "md5":
|
||||
result = hashlib.md5(text).hexdigest()
|
||||
elif algo == "sha1":
|
||||
result = hashlib.sha1(text).hexdigest()
|
||||
elif algo == "sha256":
|
||||
result = hashlib.sha256(text).hexdigest()
|
||||
elif algo == "sha512":
|
||||
result = hashlib.sha512(text).hexdigest()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="不支持的算法")
|
||||
|
||||
return {"algorithm": algo, "result": result}
|
||||
|
||||
# ========== Base64 ==========
|
||||
@router.post("/crypto/base64")
|
||||
@rate_limit(requests=100, period=60)
|
||||
async def base64_process(request: Base64Request):
|
||||
if request.action == "encode":
|
||||
result = base64.b64encode(request.text.encode('utf-8')).decode('utf-8')
|
||||
elif request.action == "decode":
|
||||
try:
|
||||
result = base64.b64decode(request.text).decode('utf-8')
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Base64 解码失败")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="无效的 action")
|
||||
|
||||
return {"action": request.action, "result": result}
|
||||
|
||||
# ========== URL 编解码 ==========
|
||||
@router.post("/crypto/url")
|
||||
@rate_limit(requests=100, period=60)
|
||||
async def url_process(request: URLRequest):
|
||||
if request.action == "encode":
|
||||
result = urllib.parse.quote(request.text, safe='')
|
||||
elif request.action == "decode":
|
||||
result = urllib.parse.unquote(request.text)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="无效的 action")
|
||||
|
||||
return {"action": request.action, "result": result}
|
||||
|
||||
# ========== AES 加解密 ==========
|
||||
@router.post("/crypto/aes")
|
||||
@rate_limit(requests=50, period=60)
|
||||
async def aes_process(request: AESRequest):
|
||||
try:
|
||||
key = request.key.encode('utf-8')
|
||||
mode_map = {"ECB": AES.MODE_ECB, "CBC": AES.MODE_CBC, "GCM": AES.MODE_GCM}
|
||||
mode = mode_map.get(request.mode)
|
||||
|
||||
if not mode:
|
||||
raise HTTPException(status_code=400, detail="不支持的 AES 模式")
|
||||
|
||||
# 密钥长度处理
|
||||
if len(key) not in [16, 24, 32]:
|
||||
raise HTTPException(status_code=400, detail="密钥长度必须为 16/24/32 字节")
|
||||
|
||||
if request.action == "encrypt":
|
||||
cipher = AES.new(key, mode, iv=request.iv.encode('utf-8') if request.iv else None)
|
||||
if request.mode == "GCM":
|
||||
ciphertext = cipher.encrypt(request.text.encode('utf-8'))
|
||||
result = b64.b64encode(ciphertext).decode('utf-8')
|
||||
else:
|
||||
padded = pad(request.text.encode('utf-8'), AES.block_size)
|
||||
ciphertext = cipher.encrypt(padded)
|
||||
result = b64.b64encode(ciphertext).decode('utf-8')
|
||||
elif request.action == "decrypt":
|
||||
ciphertext = b64.b64decode(request.text)
|
||||
cipher = AES.new(key, mode, iv=request.iv.encode('utf-8') if request.iv else None)
|
||||
if request.mode == "GCM":
|
||||
plaintext = cipher.decrypt(ciphertext).decode('utf-8')
|
||||
result = plaintext
|
||||
else:
|
||||
plaintext_padded = cipher.decrypt(ciphertext)
|
||||
result = unpad(plaintext_padded, AES.block_size).decode('utf-8')
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="无效的 action")
|
||||
|
||||
return {"mode": request.mode, "action": request.action, "result": result}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"AES 操作失败: {str(e)}")
|
||||
|
||||
# ========== JSON 校验 ==========
|
||||
@router.post("/json/validate", response_model=JSONValidateResponse)
|
||||
@rate_limit(requests=100, period=60)
|
||||
async def validate_json(request: JSONValidateRequest):
|
||||
try:
|
||||
parsed = json.loads(request.json_string)
|
||||
formatted = json.dumps(parsed, indent=2, ensure_ascii=False)
|
||||
return JSONValidateResponse(valid=True, formatted=formatted)
|
||||
except json.JSONDecodeError as e:
|
||||
return JSONValidateResponse(
|
||||
valid=False,
|
||||
error={
|
||||
"line": e.lineno,
|
||||
"column": e.colno,
|
||||
"message": e.msg
|
||||
}
|
||||
)
|
||||
57
backend/routers/v1/user.py
Normal file
57
backend/routers/v1/user.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 用户路由
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from ...dependencies import CurrentUserDependency, DbDependency
|
||||
from ...models import User
|
||||
from ...schemas import UserResponse, UserUpdateRequest
|
||||
from ...utils.security import hash_password, verify_password
|
||||
from ...utils.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/api/v1/user", tags=["user"])
|
||||
|
||||
@router.get("/profile", response_model=UserResponse)
|
||||
async def get_profile(current_user: CurrentUserDependency):
|
||||
"""获取当前用户信息"""
|
||||
return current_user
|
||||
|
||||
@router.put("/profile", response_model=UserResponse)
|
||||
async def update_profile(
|
||||
request: UserUpdateRequest,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
"""更新用户信息"""
|
||||
if request.username:
|
||||
current_user.username = request.username
|
||||
if request.avatar:
|
||||
current_user.avatar = request.avatar
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
logger.info(f"用户信息更新: {current_user.id}")
|
||||
return current_user
|
||||
|
||||
@router.post("/change-password")
|
||||
async def change_password(
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
"""修改密码"""
|
||||
if not verify_password(old_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
|
||||
current_user.password_hash = hash_password(new_password)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"密码修改: {current_user.id}")
|
||||
return {"success": True, "message": "密码已修改"}
|
||||
131
backend/schemas.py
Normal file
131
backend/schemas.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - Pydantic 模型
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
# ========== 用户相关 ==========
|
||||
class SendCodeRequest(BaseModel):
|
||||
account: str # 手机号或邮箱
|
||||
type: str # phone / email
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
account: str
|
||||
code: str
|
||||
password: str = Field(..., min_length=6, max_length=20)
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
account: str
|
||||
password: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: Optional[str]
|
||||
phone: Optional[str]
|
||||
email: Optional[str]
|
||||
avatar: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
username: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
|
||||
# ========== 待办事项 ==========
|
||||
class TodoBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
completed: bool = False
|
||||
priority: int = Field(1, ge=1, le=3)
|
||||
category: str = Field("学习", max_length=50)
|
||||
due_date: Optional[datetime] = None
|
||||
|
||||
class TodoCreate(TodoBase):
|
||||
pass
|
||||
|
||||
class TodoUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
completed: Optional[bool] = None
|
||||
priority: Optional[int] = None
|
||||
category: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
|
||||
class TodoResponse(TodoBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# ========== 便签 ==========
|
||||
class NoteBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
content: Optional[str] = None
|
||||
tags: List[str] = []
|
||||
is_archived: bool = False
|
||||
|
||||
class NoteCreate(NoteBase):
|
||||
pass
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
is_archived: Optional[bool] = None
|
||||
|
||||
class NoteResponse(NoteBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# ========== 加密工具 ==========
|
||||
class HashRequest(BaseModel):
|
||||
algorithm: str # md5, sha1, sha256, sha512
|
||||
text: str
|
||||
|
||||
class Base64Request(BaseModel):
|
||||
action: str # encode / decode
|
||||
text: str
|
||||
|
||||
class URLRequest(BaseModel):
|
||||
action: str # encode / decode
|
||||
text: str
|
||||
|
||||
class AESRequest(BaseModel):
|
||||
mode: str = "ECB" # ECB, CBC, GCM
|
||||
action: str # encrypt / decrypt
|
||||
key: str
|
||||
iv: Optional[str] = None
|
||||
text: str
|
||||
|
||||
# ========== JSON 校验 ==========
|
||||
class JSONValidateRequest(BaseModel):
|
||||
json_string: str
|
||||
|
||||
class JSONValidateResponse(BaseModel):
|
||||
valid: bool
|
||||
formatted: Optional[str] = None
|
||||
error: Optional[Dict[str, Any]] = None
|
||||
|
||||
# ========== 热度统计 ==========
|
||||
class ToolStatsResponse(BaseModel):
|
||||
today: int
|
||||
total: int
|
||||
|
||||
class ToolUsageRequest(BaseModel):
|
||||
tool_name: str
|
||||
52
backend/utils/email.py
Normal file
52
backend/utils/email.py
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 腾讯企业邮箱服务
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def send_email(recipient: str, code: str) -> bool:
|
||||
"""发送邮件验证码(腾讯企业邮)"""
|
||||
if not all([
|
||||
settings.SMTP_USER,
|
||||
settings.SMTP_PASSWORD,
|
||||
settings.SMTP_FROM
|
||||
]):
|
||||
logger.warning("邮件服务未配置,使用模拟验证码")
|
||||
return True
|
||||
|
||||
try:
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
subject = "PerToolBox 验证码"
|
||||
body = f"您的验证码是:{code},5分钟内有效。"
|
||||
|
||||
msg = MIMEText(body, 'plain', 'utf-8')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = settings.SMTP_FROM
|
||||
msg['To'] = recipient
|
||||
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
username=settings.SMTP_USER,
|
||||
password=settings.SMTP_PASSWORD,
|
||||
use_tls=True
|
||||
)
|
||||
|
||||
logger.info(f"邮件发送成功: {recipient}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"邮件发送异常: {e}")
|
||||
return False
|
||||
46
backend/utils/logger.py
Normal file
46
backend/utils/logger.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 日志工具
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from ..config import settings
|
||||
|
||||
def setup_logger(name: str = "pertoolbox") -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
level = logging.DEBUG if settings.DEBUG else logging.INFO
|
||||
logger.setLevel(level)
|
||||
|
||||
# 控制台输出
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(level)
|
||||
console_format = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
console_handler.setFormatter(console_format)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件输出
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_dir / "app.log")
|
||||
file_handler.setLevel(level)
|
||||
file_format = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(file_format)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
logger = setup_logger()
|
||||
95
backend/utils/redis_client.py
Normal file
95
backend/utils/redis_client.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - Redis 客户端
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import redis
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisClient:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self._connect()
|
||||
|
||||
def _connect(self):
|
||||
try:
|
||||
self.client = redis.Redis(
|
||||
host=settings.REDIS_HOST,
|
||||
port=settings.REDIS_PORT,
|
||||
password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None,
|
||||
db=settings.REDIS_DB,
|
||||
decode_responses=True,
|
||||
socket_timeout=5
|
||||
)
|
||||
self.client.ping()
|
||||
logger.info("Redis 连接成功")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 连接失败: {e}")
|
||||
self.client = None
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
return self.client.get(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis get error: {e}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, expire: int = None) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
self.client.set(key, value, ex=expire)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Redis set error: {e}")
|
||||
return False
|
||||
|
||||
def incr(self, key: str) -> int:
|
||||
if not self.client:
|
||||
return 0
|
||||
try:
|
||||
return self.client.incr(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis incr error: {e}")
|
||||
return 0
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
self.client.delete(key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Redis delete error: {e}")
|
||||
return False
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
return self.client.exists(key) > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Redis exists error: {e}")
|
||||
return False
|
||||
|
||||
def expire(self, key: str, seconds: int) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
return self.client.expire(key, seconds)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis expire error: {e}")
|
||||
return False
|
||||
|
||||
redis_client = RedisClient()
|
||||
44
backend/utils/security.py
Normal file
44
backend/utils/security.py
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 安全工具(密码、JWT)
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from ..config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def generate_verify_code(length: int = 6) -> str:
|
||||
"""生成数字验证码"""
|
||||
return ''.join(random.choices(string.digits, k=length))
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(days=7)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
return payload
|
||||
except jwt.JWTError:
|
||||
return None
|
||||
60
backend/utils/sms.py
Normal file
60
backend/utils/sms.py
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 阿里云短信服务
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def send_sms(phone: str, code: str) -> bool:
|
||||
"""发送短信验证码(阿里云)"""
|
||||
if not all([
|
||||
settings.ALIYUN_SMS_ACCESS_KEY_ID,
|
||||
settings.ALIYUN_SMS_ACCESS_KEY_SECRET,
|
||||
settings.ALIYUN_SMS_SIGN_NAME,
|
||||
settings.ALIYUN_SMS_TEMPLATE_CODE
|
||||
]):
|
||||
logger.warning("阿里云短信未配置,使用模拟验证码")
|
||||
return True
|
||||
|
||||
try:
|
||||
from aliyunsdkcore.client import AcsClient
|
||||
from aliyunsdkcore.request import CommonRequest
|
||||
|
||||
client = AcsClient(
|
||||
settings.ALIYUN_SMS_ACCESS_KEY_ID,
|
||||
settings.ALIYUN_SMS_ACCESS_KEY_SECRET,
|
||||
'cn-hangzhou'
|
||||
)
|
||||
|
||||
request = CommonRequest()
|
||||
request.set_domain('dysmsapi.aliyuncs.com')
|
||||
request.set_version('2017-05-25')
|
||||
request.set_action_name('SendSms')
|
||||
|
||||
request.add_query_param('PhoneNumbers', phone)
|
||||
request.add_query_param('SignName', settings.ALIYUN_SMS_SIGN_NAME)
|
||||
request.add_query_param('TemplateCode', settings.ALIYUN_SMS_TEMPLATE_CODE)
|
||||
request.add_query_param('TemplateParam', json.dumps({'code': code}))
|
||||
|
||||
response = client.do_action_with_exception(request)
|
||||
result = json.loads(response)
|
||||
|
||||
if result.get('Code') == 'OK':
|
||||
logger.info(f"短信发送成功: {phone}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"短信发送失败: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"短信发送异常: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user