v1.0.0提交

This commit is contained in:
2026-03-31 16:03:55 +08:00
parent 5f3945ae03
commit dce843fd9d
25 changed files with 1702 additions and 0 deletions

4
backend/__init__.py Normal file
View 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
View 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
View 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()

View 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

View 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
View 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)

View 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
View 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()
# 存储到 Redis5分钟有效
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="功能开发中")

View 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": "已删除"}

View 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

View 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
View 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
}
)

View 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
View 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
View 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
View 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()

View 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
View 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
View 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