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

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": "密码已修改"}