Compare commits
8 Commits
5f3945ae03
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
e97c7548a7
|
|||
|
c0cfc4ba9f
|
|||
|
c4ad386b9f
|
|||
|
a38306307c
|
|||
|
23319d8e10
|
|||
|
4e99ca2b21
|
|||
| fdc3134835 | |||
|
dce843fd9d
|
44
.env.example
Normal file
44
.env.example
Normal 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", "http://localhost:3000"]
|
||||
|
||||
# 短信配置(阿里云接入)
|
||||
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
35
.gitignore
vendored
Normal 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/
|
||||
155
README.md
155
README.md
@@ -1,2 +1,155 @@
|
||||
# PerToolBoxServer
|
||||
# PerToolBox Server - 后端服务
|
||||
|
||||
> 基于 FastAPI 的个人工具箱后端 API
|
||||
|
||||
---
|
||||
|
||||
## 📋 项目信息
|
||||
|
||||
- 项目名称:PerToolBox Server
|
||||
- 项目类型:RESTful API 服务
|
||||
- 版权所有:Sea Network Technology Studio
|
||||
- 权利人:Canglan
|
||||
- 开源协议:AGPL v3
|
||||
- 联系方式:admin@sea-studio.top
|
||||
|
||||
---
|
||||
|
||||
## ✨ 功能模块
|
||||
|
||||
### 🔐 用户系统
|
||||
- 手机验证码登录
|
||||
- 邮箱验证码登录
|
||||
|
||||
### 📝 数据功能
|
||||
- 待办事项管理
|
||||
- 便签本
|
||||
|
||||
### 🧰 工具模块
|
||||
- 密码生成器
|
||||
- 二维码生成
|
||||
- 加密工具箱(Hash / Base64 / URL / AES)
|
||||
- JSON 校验与格式化
|
||||
|
||||
### 📊 统计模块
|
||||
- 接口访问热度统计
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 系统架构
|
||||
|
||||
```text
|
||||
Client (Web / Mobile)
|
||||
│
|
||||
▼
|
||||
Nginx
|
||||
│
|
||||
▼
|
||||
FastAPI
|
||||
┌───────┴────────┐
|
||||
▼ ▼
|
||||
MySQL Redis
|
||||
(数据存储) (缓存/验证码)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 技术 | 版本 |
|
||||
|------|------|
|
||||
| Python | 3.12+ |
|
||||
| FastAPI | 0.104+ |
|
||||
| MySQL | 5.7+ |
|
||||
| Redis | 7.x+ |
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 环境变量
|
||||
|
||||
复制并修改:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
- DATABASE_URL=mysql://user:password@host:port/db
|
||||
- REDIS_URL=redis://host:port/0
|
||||
- SECRET_KEY=your-secret-key
|
||||
- CORS_ORIGINS=*
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 获取代码
|
||||
|
||||
```bash
|
||||
git clone https://hz-gitea.sea-studio.top/Sea-Studio/PerToolBoxServer.git
|
||||
cd PerToolBoxServer
|
||||
```
|
||||
|
||||
### 2. 创建环境
|
||||
|
||||
```bash
|
||||
python3.12 -m venv venv
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
### 3. 安装依赖
|
||||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 4. 初始化数据库
|
||||
|
||||
```bash
|
||||
mysql -u root -p < scripts/init_db.sql
|
||||
```
|
||||
|
||||
### 5. 启动服务
|
||||
|
||||
```bash
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 9999 --reload
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🌐 反向代理(Nginx)
|
||||
|
||||
```nginx
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:9999/api/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 API 文档
|
||||
|
||||
启动后访问:
|
||||
|
||||
- http://your-server:9999/docs
|
||||
- http://your-server:9999/redoc
|
||||
|
||||
---
|
||||
|
||||
## 📁 项目结构
|
||||
|
||||
PerToolBoxServer/
|
||||
├── backend/ # 核心代码
|
||||
├── scripts/ # 数据库脚本
|
||||
├── requirements.txt
|
||||
└── .env.example
|
||||
|
||||
---
|
||||
|
||||
## 📞 联系方式
|
||||
|
||||
- Git:https://hz-gitea.sea-studio.top/Sea-Studio/PerToolBoxServer
|
||||
- 邮箱:admin@sea-studio.top
|
||||
|
||||
4
backend/__init__.py
Normal file
4
backend/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# PerToolBox Server
|
||||
# Copyright (C) 2024 Sea Network Technology Studio
|
||||
# Author: Canglan <admin@sea-studio.top>
|
||||
# License: AGPL v3
|
||||
69
backend/config.py
Normal file
69
backend/config.py
Normal file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 配置模块
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from typing import List, Optional
|
||||
|
||||
class Settings(BaseSettings):
|
||||
# 应用配置
|
||||
APP_NAME: str = "PerToolBox"
|
||||
DEBUG: bool = False
|
||||
ENVIRONMENT: str = "production"
|
||||
SECRET_KEY: str
|
||||
|
||||
# 数据库
|
||||
DB_HOST: str = "localhost"
|
||||
DB_PORT: int = 3306
|
||||
DB_USER: str
|
||||
DB_PASSWORD: str
|
||||
DB_NAME: str
|
||||
|
||||
@property
|
||||
def DATABASE_URL(self) -> str:
|
||||
return f"mysql+pymysql://{self.DB_USER}:{self.DB_PASSWORD}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}?charset=utf8mb4"
|
||||
|
||||
# Redis
|
||||
REDIS_HOST: str = "localhost"
|
||||
REDIS_PORT: int = 6379
|
||||
REDIS_PASSWORD: Optional[str] = None
|
||||
REDIS_DB: int = 0
|
||||
|
||||
# 限流
|
||||
RATE_LIMIT_ENABLED: bool = True
|
||||
RATE_LIMIT_REQUESTS: int = 100
|
||||
RATE_LIMIT_PERIOD: int = 60
|
||||
|
||||
# CORS
|
||||
ALLOWED_ORIGINS: List[str] = []
|
||||
|
||||
# 阿里云短信
|
||||
ALIYUN_SMS_ACCESS_KEY_ID: Optional[str] = None
|
||||
ALIYUN_SMS_ACCESS_KEY_SECRET: Optional[str] = None
|
||||
ALIYUN_SMS_SIGN_NAME: Optional[str] = None
|
||||
ALIYUN_SMS_TEMPLATE_CODE: Optional[str] = None
|
||||
|
||||
# 腾讯企业邮
|
||||
SMTP_HOST: str = "smtp.exmail.qq.com"
|
||||
SMTP_PORT: int = 465
|
||||
SMTP_USER: Optional[str] = None
|
||||
SMTP_PASSWORD: Optional[str] = None
|
||||
SMTP_FROM: Optional[str] = None
|
||||
|
||||
# 微信公众号(预留)
|
||||
WECHAT_APPID: Optional[str] = None
|
||||
WECHAT_APPSECRET: Optional[str] = None
|
||||
WECHAT_TOKEN: Optional[str] = None
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=True
|
||||
)
|
||||
|
||||
settings = Settings()
|
||||
33
backend/database.py
Normal file
33
backend/database.py
Normal file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 数据库模块
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from typing import Generator
|
||||
from .config import settings
|
||||
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=20,
|
||||
max_overflow=10,
|
||||
pool_pre_ping=True,
|
||||
echo=settings.DEBUG,
|
||||
connect_args={"connect_timeout": 10}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
Base = declarative_base()
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
148
backend/main.py
Normal file
148
backend/main.py
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/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
|
||||
)
|
||||
from sqlalchemy import text
|
||||
|
||||
|
||||
@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:
|
||||
# 使用 text() 包裹 SQL 语句
|
||||
db.execute(text("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
|
||||
)
|
||||
24
backend/middleware/logging.py
Normal file
24
backend/middleware/logging.py
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 日志中间件
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import time
|
||||
from fastapi import Request
|
||||
from ..utils.logger import logger
|
||||
|
||||
async def log_requests(request: Request, call_next):
|
||||
start_time = time.time()
|
||||
logger.info(f"→ {request.method} {request.url.path}")
|
||||
|
||||
response = await call_next(request)
|
||||
|
||||
process_time = time.time() - start_time
|
||||
logger.info(f"← {request.method} {request.url.path} - {response.status_code} - {process_time:.3f}s")
|
||||
|
||||
response.headers["X-Process-Time"] = str(process_time)
|
||||
return response
|
||||
34
backend/middleware/rate_limit.py
Normal file
34
backend/middleware/rate_limit.py
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/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
|
||||
|
||||
# 直接返回 slowapi 的限流装饰器
|
||||
return limiter.limit(f"{req}/{per} seconds")
|
||||
59
backend/models.py
Normal file
59
backend/models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 数据模型
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from .database import Base
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
username = Column(String(50), unique=True, nullable=True)
|
||||
phone = Column(String(20), unique=True, nullable=True)
|
||||
email = Column(String(100), unique=True, nullable=True)
|
||||
wx_openid = Column(String(100), unique=True, nullable=True)
|
||||
password_hash = Column(String(200), nullable=False)
|
||||
avatar = Column(String(500), nullable=True)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
last_login = Column(DateTime, onupdate=func.now())
|
||||
|
||||
class Todo(Base):
|
||||
__tablename__ = "todos"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
description = Column(Text, nullable=True)
|
||||
completed = Column(Boolean, default=False)
|
||||
priority = Column(Integer, default=1)
|
||||
category = Column(String(50), default="学习")
|
||||
due_date = Column(DateTime, nullable=True)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
|
||||
class Note(Base):
|
||||
__tablename__ = "notes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
user_id = Column(Integer, nullable=False, index=True)
|
||||
title = Column(String(200), nullable=False)
|
||||
content = Column(Text, nullable=True)
|
||||
tags = Column(JSON, default=list)
|
||||
is_archived = Column(Boolean, default=False)
|
||||
created_at = Column(DateTime, server_default=func.now())
|
||||
updated_at = Column(DateTime, onupdate=func.now())
|
||||
|
||||
class ToolStatsTotal(Base):
|
||||
__tablename__ = "tool_stats_total"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
tool_name = Column(String(50), unique=True, nullable=False)
|
||||
total_count = Column(Integer, default=0)
|
||||
24
backend/routers/v1/__init__.py
Normal file
24
backend/routers/v1/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - API v1 路由汇总
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from .auth import router as auth_router
|
||||
from .user import router as user_router
|
||||
from .todos import router as todos_router
|
||||
from .notes import router as notes_router
|
||||
from .tools import router as tools_router
|
||||
from .stats import router as stats_router
|
||||
|
||||
__all__ = [
|
||||
"auth_router",
|
||||
"user_router",
|
||||
"todos_router",
|
||||
"notes_router",
|
||||
"tools_router",
|
||||
"stats_router"
|
||||
]
|
||||
160
backend/routers/v1/auth.py
Normal file
160
backend/routers/v1/auth.py
Normal file
@@ -0,0 +1,160 @@
|
||||
#!/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, Request
|
||||
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)
|
||||
async def send_verify_code(
|
||||
request: Request, # 添加 request 参数
|
||||
req: SendCodeRequest,
|
||||
db: DbDependency
|
||||
):
|
||||
"""发送验证码"""
|
||||
account = req.account
|
||||
account_type = req.type
|
||||
|
||||
if account_type == "phone" and not is_phone(account):
|
||||
raise HTTPException(status_code=400, detail="手机号格式错误")
|
||||
if account_type == "email" and not is_email(account):
|
||||
raise HTTPException(status_code=400, detail="邮箱格式错误")
|
||||
|
||||
code = generate_verify_code()
|
||||
redis_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: Request, # 添加 request 参数
|
||||
req: RegisterRequest,
|
||||
db: DbDependency
|
||||
):
|
||||
"""注册"""
|
||||
account = req.account
|
||||
code = req.code
|
||||
password = req.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 = 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: Request, # 添加 request 参数
|
||||
req: LoginRequest,
|
||||
db: DbDependency
|
||||
):
|
||||
"""登录"""
|
||||
account = req.account
|
||||
password = req.password
|
||||
code = req.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(request: Request): # 添加 request 参数
|
||||
"""微信公众号登录(预留)"""
|
||||
raise HTTPException(status_code=501, detail="功能开发中")
|
||||
83
backend/routers/v1/notes.py
Normal file
83
backend/routers/v1/notes.py
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/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, Request
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
note_id: int,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
note = db.query(Note).filter(Note.id == note_id, Note.user_id == current_user.id).first()
|
||||
if not note:
|
||||
raise HTTPException(status_code=404, detail="便签不存在")
|
||||
|
||||
db.delete(note)
|
||||
db.commit()
|
||||
return {"success": True, "message": "已删除"}
|
||||
81
backend/routers/v1/stats.py
Normal file
81
backend/routers/v1/stats.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 热度统计路由
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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}"
|
||||
|
||||
today_count = redis_client.incr(today_key)
|
||||
redis_client.expire(today_key, 48 * 3600)
|
||||
total_count = redis_client.incr(total_key)
|
||||
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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:
|
||||
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
|
||||
88
backend/routers/v1/todos.py
Normal file
88
backend/routers/v1/todos.py
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/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, Request
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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": "已删除"}
|
||||
202
backend/routers/v1/tools.py
Normal file
202
backend/routers/v1/tools.py
Normal file
@@ -0,0 +1,202 @@
|
||||
#!/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, Request, Query
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
length: int = Query(12, ge=4, le=64),
|
||||
upper: bool = Query(True),
|
||||
lower: bool = Query(True),
|
||||
digits: bool = Query(True),
|
||||
symbols: bool = Query(True),
|
||||
count: int = Query(1, ge=1, le=10)
|
||||
):
|
||||
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(
|
||||
request: Request, # 添加 request 参数
|
||||
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: Request, # 添加 request 参数
|
||||
req: HashRequest
|
||||
):
|
||||
text = req.text.encode('utf-8')
|
||||
algo = req.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: Request, # 添加 request 参数
|
||||
req: Base64Request
|
||||
):
|
||||
if req.action == "encode":
|
||||
result = base64.b64encode(req.text.encode('utf-8')).decode('utf-8')
|
||||
elif req.action == "decode":
|
||||
try:
|
||||
result = base64.b64decode(req.text).decode('utf-8')
|
||||
except Exception:
|
||||
raise HTTPException(status_code=400, detail="Base64 解码失败")
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="无效的 action")
|
||||
|
||||
return {"action": req.action, "result": result}
|
||||
|
||||
# ========== URL 编解码 ==========
|
||||
@router.post("/crypto/url")
|
||||
@rate_limit(requests=100, period=60)
|
||||
async def url_process(
|
||||
request: Request, # 添加 request 参数
|
||||
req: URLRequest
|
||||
):
|
||||
if req.action == "encode":
|
||||
result = urllib.parse.quote(req.text, safe='')
|
||||
elif req.action == "decode":
|
||||
result = urllib.parse.unquote(req.text)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="无效的 action")
|
||||
|
||||
return {"action": req.action, "result": result}
|
||||
|
||||
# ========== AES 加解密 ==========
|
||||
@router.post("/crypto/aes")
|
||||
@rate_limit(requests=50, period=60)
|
||||
async def aes_process(
|
||||
request: Request, # 添加 request 参数
|
||||
req: AESRequest
|
||||
):
|
||||
try:
|
||||
key = req.key.encode('utf-8')
|
||||
mode_map = {"ECB": AES.MODE_ECB, "CBC": AES.MODE_CBC, "GCM": AES.MODE_GCM}
|
||||
mode = mode_map.get(req.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 req.action == "encrypt":
|
||||
cipher = AES.new(key, mode, iv=req.iv.encode('utf-8') if req.iv else None)
|
||||
if req.mode == "GCM":
|
||||
ciphertext = cipher.encrypt(req.text.encode('utf-8'))
|
||||
result = b64.b64encode(ciphertext).decode('utf-8')
|
||||
else:
|
||||
padded = pad(req.text.encode('utf-8'), AES.block_size)
|
||||
ciphertext = cipher.encrypt(padded)
|
||||
result = b64.b64encode(ciphertext).decode('utf-8')
|
||||
elif req.action == "decrypt":
|
||||
ciphertext = b64.b64decode(req.text)
|
||||
cipher = AES.new(key, mode, iv=req.iv.encode('utf-8') if req.iv else None)
|
||||
if req.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": req.mode, "action": req.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: Request, # 添加 request 参数
|
||||
req: JSONValidateRequest
|
||||
):
|
||||
try:
|
||||
parsed = json.loads(req.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
|
||||
}
|
||||
)
|
||||
66
backend/routers/v1/user.py
Normal file
66
backend/routers/v1/user.py
Normal file
@@ -0,0 +1,66 @@
|
||||
#!/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, Request
|
||||
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
|
||||
from ...middleware.rate_limit import rate_limit
|
||||
|
||||
router = APIRouter(prefix="/api/v1/user", tags=["user"])
|
||||
|
||||
@router.get("/profile", response_model=UserResponse)
|
||||
@rate_limit(requests=50, period=60)
|
||||
async def get_profile(
|
||||
request: Request, # 添加 request 参数
|
||||
current_user: CurrentUserDependency
|
||||
):
|
||||
"""获取当前用户信息"""
|
||||
return current_user
|
||||
|
||||
@router.put("/profile", response_model=UserResponse)
|
||||
@rate_limit(requests=20, period=60)
|
||||
async def update_profile(
|
||||
request: Request, # 添加 request 参数
|
||||
req: UserUpdateRequest,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
"""更新用户信息"""
|
||||
if req.username:
|
||||
current_user.username = req.username
|
||||
if req.avatar:
|
||||
current_user.avatar = req.avatar
|
||||
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
|
||||
logger.info(f"用户信息更新: {current_user.id}")
|
||||
return current_user
|
||||
|
||||
@router.post("/change-password")
|
||||
@rate_limit(requests=10, period=60)
|
||||
async def change_password(
|
||||
request: Request, # 添加 request 参数
|
||||
old_password: str,
|
||||
new_password: str,
|
||||
current_user: CurrentUserDependency,
|
||||
db: DbDependency
|
||||
):
|
||||
"""修改密码"""
|
||||
if not verify_password(old_password, current_user.password_hash):
|
||||
raise HTTPException(status_code=400, detail="原密码错误")
|
||||
|
||||
current_user.password_hash = hash_password(new_password)
|
||||
db.commit()
|
||||
|
||||
logger.info(f"密码修改: {current_user.id}")
|
||||
return {"success": True, "message": "密码已修改"}
|
||||
131
backend/schemas.py
Normal file
131
backend/schemas.py
Normal file
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - Pydantic 模型
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from datetime import datetime
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
# ========== 用户相关 ==========
|
||||
class SendCodeRequest(BaseModel):
|
||||
account: str # 手机号或邮箱
|
||||
type: str # phone / email
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
account: str
|
||||
code: str
|
||||
password: str = Field(..., min_length=6, max_length=20)
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
account: str
|
||||
password: Optional[str] = None
|
||||
code: Optional[str] = None
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: int
|
||||
username: Optional[str]
|
||||
phone: Optional[str]
|
||||
email: Optional[str]
|
||||
avatar: Optional[str]
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class UserUpdateRequest(BaseModel):
|
||||
username: Optional[str] = None
|
||||
avatar: Optional[str] = None
|
||||
|
||||
# ========== 待办事项 ==========
|
||||
class TodoBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
description: Optional[str] = None
|
||||
completed: bool = False
|
||||
priority: int = Field(1, ge=1, le=3)
|
||||
category: str = Field("学习", max_length=50)
|
||||
due_date: Optional[datetime] = None
|
||||
|
||||
class TodoCreate(TodoBase):
|
||||
pass
|
||||
|
||||
class TodoUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
completed: Optional[bool] = None
|
||||
priority: Optional[int] = None
|
||||
category: Optional[str] = None
|
||||
due_date: Optional[datetime] = None
|
||||
|
||||
class TodoResponse(TodoBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# ========== 便签 ==========
|
||||
class NoteBase(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=200)
|
||||
content: Optional[str] = None
|
||||
tags: List[str] = []
|
||||
is_archived: bool = False
|
||||
|
||||
class NoteCreate(NoteBase):
|
||||
pass
|
||||
|
||||
class NoteUpdate(BaseModel):
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
tags: Optional[List[str]] = None
|
||||
is_archived: Optional[bool] = None
|
||||
|
||||
class NoteResponse(NoteBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# ========== 加密工具 ==========
|
||||
class HashRequest(BaseModel):
|
||||
algorithm: str # md5, sha1, sha256, sha512
|
||||
text: str
|
||||
|
||||
class Base64Request(BaseModel):
|
||||
action: str # encode / decode
|
||||
text: str
|
||||
|
||||
class URLRequest(BaseModel):
|
||||
action: str # encode / decode
|
||||
text: str
|
||||
|
||||
class AESRequest(BaseModel):
|
||||
mode: str = "ECB" # ECB, CBC, GCM
|
||||
action: str # encrypt / decrypt
|
||||
key: str
|
||||
iv: Optional[str] = None
|
||||
text: str
|
||||
|
||||
# ========== JSON 校验 ==========
|
||||
class JSONValidateRequest(BaseModel):
|
||||
json_string: str
|
||||
|
||||
class JSONValidateResponse(BaseModel):
|
||||
valid: bool
|
||||
formatted: Optional[str] = None
|
||||
error: Optional[Dict[str, Any]] = None
|
||||
|
||||
# ========== 热度统计 ==========
|
||||
class ToolStatsResponse(BaseModel):
|
||||
today: int
|
||||
total: int
|
||||
|
||||
class ToolUsageRequest(BaseModel):
|
||||
tool_name: str
|
||||
52
backend/utils/email.py
Normal file
52
backend/utils/email.py
Normal file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 腾讯企业邮箱服务
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
async def send_email(recipient: str, code: str) -> bool:
|
||||
"""发送邮件验证码(腾讯企业邮)"""
|
||||
if not all([
|
||||
settings.SMTP_USER,
|
||||
settings.SMTP_PASSWORD,
|
||||
settings.SMTP_FROM
|
||||
]):
|
||||
logger.warning("邮件服务未配置,使用模拟验证码")
|
||||
return True
|
||||
|
||||
try:
|
||||
import aiosmtplib
|
||||
from email.mime.text import MIMEText
|
||||
|
||||
subject = "PerToolBox 验证码"
|
||||
body = f"您的验证码是:{code},5分钟内有效。"
|
||||
|
||||
msg = MIMEText(body, 'plain', 'utf-8')
|
||||
msg['Subject'] = subject
|
||||
msg['From'] = settings.SMTP_FROM
|
||||
msg['To'] = recipient
|
||||
|
||||
await aiosmtplib.send(
|
||||
msg,
|
||||
hostname=settings.SMTP_HOST,
|
||||
port=settings.SMTP_PORT,
|
||||
username=settings.SMTP_USER,
|
||||
password=settings.SMTP_PASSWORD,
|
||||
use_tls=True
|
||||
)
|
||||
|
||||
logger.info(f"邮件发送成功: {recipient}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"邮件发送异常: {e}")
|
||||
return False
|
||||
46
backend/utils/logger.py
Normal file
46
backend/utils/logger.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 日志工具
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from ..config import settings
|
||||
|
||||
def setup_logger(name: str = "pertoolbox") -> logging.Logger:
|
||||
logger = logging.getLogger(name)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
level = logging.DEBUG if settings.DEBUG else logging.INFO
|
||||
logger.setLevel(level)
|
||||
|
||||
# 控制台输出
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(level)
|
||||
console_format = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
console_handler.setFormatter(console_format)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件输出
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
file_handler = logging.FileHandler(log_dir / "app.log")
|
||||
file_handler.setLevel(level)
|
||||
file_format = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(file_format)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
logger = setup_logger()
|
||||
95
backend/utils/redis_client.py
Normal file
95
backend/utils/redis_client.py
Normal file
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - Redis 客户端
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import redis
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Any
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class RedisClient:
|
||||
def __init__(self):
|
||||
self.client = None
|
||||
self._connect()
|
||||
|
||||
def _connect(self):
|
||||
try:
|
||||
self.client = redis.Redis(
|
||||
host=settings.REDIS_HOST,
|
||||
port=settings.REDIS_PORT,
|
||||
password=settings.REDIS_PASSWORD if settings.REDIS_PASSWORD else None,
|
||||
db=settings.REDIS_DB,
|
||||
decode_responses=True,
|
||||
socket_timeout=5
|
||||
)
|
||||
self.client.ping()
|
||||
logger.info("Redis 连接成功")
|
||||
except Exception as e:
|
||||
logger.error(f"Redis 连接失败: {e}")
|
||||
self.client = None
|
||||
|
||||
def get(self, key: str) -> Optional[str]:
|
||||
if not self.client:
|
||||
return None
|
||||
try:
|
||||
return self.client.get(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis get error: {e}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Any, expire: int = None) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
self.client.set(key, value, ex=expire)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Redis set error: {e}")
|
||||
return False
|
||||
|
||||
def incr(self, key: str) -> int:
|
||||
if not self.client:
|
||||
return 0
|
||||
try:
|
||||
return self.client.incr(key)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis incr error: {e}")
|
||||
return 0
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
self.client.delete(key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Redis delete error: {e}")
|
||||
return False
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
return self.client.exists(key) > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Redis exists error: {e}")
|
||||
return False
|
||||
|
||||
def expire(self, key: str, seconds: int) -> bool:
|
||||
if not self.client:
|
||||
return False
|
||||
try:
|
||||
return self.client.expire(key, seconds)
|
||||
except Exception as e:
|
||||
logger.error(f"Redis expire error: {e}")
|
||||
return False
|
||||
|
||||
redis_client = RedisClient()
|
||||
44
backend/utils/security.py
Normal file
44
backend/utils/security.py
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 安全工具(密码、JWT)
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import random
|
||||
import string
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import jwt
|
||||
from passlib.context import CryptContext
|
||||
from ..config import settings
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
def generate_verify_code(length: int = 6) -> str:
|
||||
"""生成数字验证码"""
|
||||
return ''.join(random.choices(string.digits, k=length))
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(days=7)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
return payload
|
||||
except jwt.JWTError:
|
||||
return None
|
||||
60
backend/utils/sms.py
Normal file
60
backend/utils/sms.py
Normal file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 阿里云短信服务
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from ..config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def send_sms(phone: str, code: str) -> bool:
|
||||
"""发送短信验证码(阿里云)"""
|
||||
if not all([
|
||||
settings.ALIYUN_SMS_ACCESS_KEY_ID,
|
||||
settings.ALIYUN_SMS_ACCESS_KEY_SECRET,
|
||||
settings.ALIYUN_SMS_SIGN_NAME,
|
||||
settings.ALIYUN_SMS_TEMPLATE_CODE
|
||||
]):
|
||||
logger.warning("阿里云短信未配置,使用模拟验证码")
|
||||
return True
|
||||
|
||||
try:
|
||||
from aliyunsdkcore.client import AcsClient
|
||||
from aliyunsdkcore.request import CommonRequest
|
||||
|
||||
client = AcsClient(
|
||||
settings.ALIYUN_SMS_ACCESS_KEY_ID,
|
||||
settings.ALIYUN_SMS_ACCESS_KEY_SECRET,
|
||||
'cn-hangzhou'
|
||||
)
|
||||
|
||||
request = CommonRequest()
|
||||
request.set_domain('dysmsapi.aliyuncs.com')
|
||||
request.set_version('2017-05-25')
|
||||
request.set_action_name('SendSms')
|
||||
|
||||
request.add_query_param('PhoneNumbers', phone)
|
||||
request.add_query_param('SignName', settings.ALIYUN_SMS_SIGN_NAME)
|
||||
request.add_query_param('TemplateCode', settings.ALIYUN_SMS_TEMPLATE_CODE)
|
||||
request.add_query_param('TemplateParam', json.dumps({'code': code}))
|
||||
|
||||
response = client.do_action_with_exception(request)
|
||||
result = json.loads(response)
|
||||
|
||||
if result.get('Code') == 'OK':
|
||||
logger.info(f"短信发送成功: {phone}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"短信发送失败: {result}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"短信发送异常: {e}")
|
||||
return False
|
||||
57
dependencies.py
Normal file
57
dependencies.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
PerToolBox Server - 依赖注入
|
||||
Copyright (C) 2024 Sea Network Technology Studio
|
||||
Author: Canglan <admin@sea-studio.top>
|
||||
License: AGPL v3
|
||||
"""
|
||||
|
||||
from 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
146
main.py
Normal 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
|
||||
)
|
||||
18
requirements.txt
Normal file
18
requirements.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
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
|
||||
pycryptodome==3.20.0
|
||||
httpx==0.25.1
|
||||
83
scripts/init_db.sql
Normal file
83
scripts/init_db.sql
Normal 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;
|
||||
Reference in New Issue
Block a user