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

44
.env.example Normal file
View File

@@ -0,0 +1,44 @@
# 应用配置
APP_NAME=PerToolBox
DEBUG=False
ENVIRONMENT=production
SECRET_KEY=change-this-to-32-chars-random-key
# 数据库
DB_HOST=localhost
DB_PORT=3306
DB_USER=toolbox_user
DB_PASSWORD=your_password
DB_NAME=toolbox_db
# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# 限流
RATE_LIMIT_ENABLED=True
RATE_LIMIT_REQUESTS=100
RATE_LIMIT_PERIOD=60
# CORS前端域名多个域名用逗号分隔
ALLOWED_ORIGINS=https://your-domain.com
# 短信配置(阿里云接入)
ALIYUN_SMS_ACCESS_KEY_ID=
ALIYUN_SMS_ACCESS_KEY_SECRET=
ALIYUN_SMS_SIGN_NAME=
ALIYUN_SMS_TEMPLATE_CODE=
# SMTP配置
SMTP_HOST=smtp.example.com
SMTP_PORT=465
SMTP_USER=noreply@example.com
SMTP_PASSWORD=
SMTP_FROM=noreply@example.com
# 微信公众号(可选)
WECHAT_APPID=
WECHAT_APPSECRET=
WECHAT_TOKEN=

35
.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
.venv/
# Environment
.env
.env.local
.env.production
# IDE
.vscode/
.idea/
*.swp
# Logs
logs/
*.log
# Database
*.db
*.sqlite
# OS
.DS_Store
Thumbs.db
# Backup
*.bak
backups/

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

57
dependencies.py Normal file
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 typing import Annotated, Optional
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from .database import get_db
from .models import User
from .utils.security import decode_access_token
from .utils.logger import logger
security = HTTPBearer()
DbDependency = Annotated[Session, Depends(get_db)]
async def get_current_user(
db: DbDependency,
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]
) -> Optional[User]:
token = credentials.credentials
payload = decode_access_token(token)
if not payload:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 token",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = payload.get("sub")
if not user_id:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="无效的 token",
headers={"WWW-Authenticate": "Bearer"},
)
user = db.query(User).filter(User.id == int(user_id)).first()
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="用户不存在",
headers={"WWW-Authenticate": "Bearer"},
)
return user
CurrentUserDependency = Annotated[User, Depends(get_current_user)]
OptionalCurrentUserDependency = Annotated[Optional[User], Depends(get_current_user)]

146
main.py Normal file
View File

@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PerToolBox Server - FastAPI 主程序
Copyright (C) 2024 Sea Network Technology Studio
Author: Canglan <admin@sea-studio.top>
License: AGPL v3
API 文档: /api/v1/docs
健康检查: /health
"""
from contextlib import asynccontextmanager
from fastapi import FastAPI, Depends
from fastapi.middleware.cors import CORSMiddleware
from fastapi.openapi.utils import get_openapi
from sqlalchemy.orm import Session
from .config import settings
from .database import engine, Base, get_db
from .middleware.logging import log_requests
from .middleware.rate_limit import setup_rate_limit
from .utils.logger import logger
from .routers.v1 import (
auth_router, user_router, todos_router,
notes_router, tools_router, stats_router
)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""应用生命周期管理"""
logger.info(f"启动 {settings.APP_NAME} v1.0.0")
logger.info(f"环境: {settings.ENVIRONMENT}")
logger.info(f"调试模式: {settings.DEBUG}")
# 创建数据库表
Base.metadata.create_all(bind=engine)
logger.info("数据库表初始化完成")
yield
logger.info("应用关闭")
app = FastAPI(
title=settings.APP_NAME,
description="""
## PerToolBox 个人工具箱 API v1
提供以下功能:
- ✅ 用户认证(手机/邮箱验证码登录)
- ✅ 待办事项管理
- ✅ 便签本
- ✅ 密码生成器
- ✅ 二维码生成
- ✅ 加密工具箱哈希、Base64、URL、AES
- ✅ JSON 校验与格式化
- ✅ 热度统计(页面访问次数)
### 版权信息
- © 2024 Sea Network Technology Studio
- Author: Canglan <admin@sea-studio.top>
- License: AGPL v3
""",
version="1.0.0",
docs_url="/api/v1/docs",
redoc_url="/api/v1/redoc",
openapi_url="/api/v1/openapi.json",
lifespan=lifespan
)
# CORS 配置
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 限流配置
setup_rate_limit(app)
# 日志中间件
app.middleware("http")(log_requests)
# 健康检查
@app.get("/health")
async def health_check(db: Session = Depends(get_db)):
try:
db.execute("SELECT 1")
return {
"status": "healthy",
"database": "connected",
"version": "1.0.0"
}
except Exception as e:
logger.error(f"健康检查失败: {e}")
return {
"status": "unhealthy",
"database": str(e)
}
# 注册路由
app.include_router(auth_router)
app.include_router(user_router)
app.include_router(todos_router)
app.include_router(notes_router)
app.include_router(tools_router)
app.include_router(stats_router)
# 自定义 OpenAPI
def custom_openapi():
if app.openapi_schema:
return app.openapi_schema
openapi_schema = get_openapi(
title=settings.APP_NAME,
version="1.0.0",
description=app.description,
routes=app.routes,
)
openapi_schema["info"]["x-copyright"] = "Sea Network Technology Studio"
openapi_schema["info"]["x-author"] = "Canglan <admin@sea-studio.top>"
openapi_schema["info"]["x-license"] = "AGPL v3"
app.openapi_schema = openapi_schema
return app.openapi_schema
app.openapi = custom_openapi
if __name__ == "__main__":
import uvicorn
uvicorn.run(
"backend.main:app",
host="0.0.0.0",
port=8000,
reload=settings.DEBUG
)

16
requirements.txt Normal file
View File

@@ -0,0 +1,16 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
gunicorn==21.2.0
sqlalchemy==2.0.23
pymysql==1.1.0
pydantic==2.5.0
pydantic-settings==2.1.0
python-dotenv==1.0.0
qrcode[pil]==7.4.2
python-multipart==0.0.6
redis==5.0.1
slowapi==0.1.9
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
aiosmtplib==2.0.2
tencentcloud-sdk-python==3.0.1000

83
scripts/init_db.sql Normal file
View File

@@ -0,0 +1,83 @@
-- PerToolBox 数据库初始化脚本
-- Copyright (C) 2024 Sea Network Technology Studio
-- Author: Canglan <admin@sea-studio.top>
-- License: AGPL v3
CREATE DATABASE IF NOT EXISTS toolbox_db
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
USE toolbox_db;
-- 用户表
CREATE TABLE IF NOT EXISTS `users` (
`id` INT NOT NULL AUTO_INCREMENT,
`username` VARCHAR(50) NULL,
`phone` VARCHAR(20) NULL,
`email` VARCHAR(100) NULL,
`wx_openid` VARCHAR(100) NULL,
`password_hash` VARCHAR(200) NOT NULL,
`avatar` VARCHAR(500) NULL,
`is_active` TINYINT(1) DEFAULT 1,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`last_login` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE INDEX `idx_phone` (`phone`),
UNIQUE INDEX `idx_email` (`email`),
UNIQUE INDEX `idx_wx_openid` (`wx_openid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 待办事项表
CREATE TABLE IF NOT EXISTS `todos` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`title` VARCHAR(200) NOT NULL,
`description` TEXT,
`completed` TINYINT(1) DEFAULT 0,
`priority` TINYINT DEFAULT 1,
`category` VARCHAR(50) DEFAULT '学习',
`due_date` DATETIME,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`),
INDEX `idx_completed` (`completed`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 便签表
CREATE TABLE IF NOT EXISTS `notes` (
`id` INT NOT NULL AUTO_INCREMENT,
`user_id` INT NOT NULL,
`title` VARCHAR(200) NOT NULL,
`content` TEXT,
`tags` JSON,
`is_archived` TINYINT(1) DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
INDEX `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 工具总访问次数表
CREATE TABLE IF NOT EXISTS `tool_stats_total` (
`id` INT NOT NULL AUTO_INCREMENT,
`tool_name` VARCHAR(50) NOT NULL,
`total_count` INT DEFAULT 0,
PRIMARY KEY (`id`),
UNIQUE INDEX `idx_tool_name` (`tool_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 插入默认工具记录
INSERT INTO `tool_stats_total` (`tool_name`, `total_count`) VALUES
('todos', 0),
('notes', 0),
('password', 0),
('qrcode', 0),
('crypto_hash', 0),
('crypto_base64', 0),
('crypto_url', 0),
('crypto_aes', 0),
('json', 0)
ON DUPLICATE KEY UPDATE total_count=total_count;
SELECT '数据库初始化完成!' AS message;