diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e71ea78 --- /dev/null +++ b/.env.example @@ -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= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7d1920e --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..25fbfc6 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1,4 @@ +# PerToolBox Server +# Copyright (C) 2024 Sea Network Technology Studio +# Author: Canglan +# License: AGPL v3 \ No newline at end of file diff --git a/backend/config.py b/backend/config.py new file mode 100644 index 0000000..8b0a884 --- /dev/null +++ b/backend/config.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 配置模块 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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() \ No newline at end of file diff --git a/backend/database.py b/backend/database.py new file mode 100644 index 0000000..c72d31a --- /dev/null +++ b/backend/database.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 数据库模块 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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() \ No newline at end of file diff --git a/backend/middleware/logging.py b/backend/middleware/logging.py new file mode 100644 index 0000000..6931d0c --- /dev/null +++ b/backend/middleware/logging.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 日志中间件 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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 \ No newline at end of file diff --git a/backend/middleware/rate_limit.py b/backend/middleware/rate_limit.py new file mode 100644 index 0000000..6e3a18b --- /dev/null +++ b/backend/middleware/rate_limit.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 限流中间件 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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") \ No newline at end of file diff --git a/backend/models.py b/backend/models.py new file mode 100644 index 0000000..73f32e8 --- /dev/null +++ b/backend/models.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 数据模型 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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) \ No newline at end of file diff --git a/backend/routers/v1/__init__.py b/backend/routers/v1/__init__.py new file mode 100644 index 0000000..70576aa --- /dev/null +++ b/backend/routers/v1/__init__.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - API v1 路由汇总 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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" +] \ No newline at end of file diff --git a/backend/routers/v1/auth.py b/backend/routers/v1/auth.py new file mode 100644 index 0000000..498dcad --- /dev/null +++ b/backend/routers/v1/auth.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 认证路由 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +License: AGPL v3 +""" + +import re +from fastapi import APIRouter, HTTPException, status +from sqlalchemy.orm import Session +from ...dependencies import DbDependency +from ...models import User +from ...schemas import ( + SendCodeRequest, RegisterRequest, LoginRequest, UserResponse +) +from ...utils.redis_client import redis_client +from ...utils.security import generate_verify_code, hash_password, verify_password, create_access_token +from ...utils.sms import send_sms +from ...utils.email import send_email +from ...utils.logger import logger +from ...middleware.rate_limit import rate_limit + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +def is_phone(account: str) -> bool: + return re.match(r'^1[3-9]\d{9}$', account) is not None + +def is_email(account: str) -> bool: + return re.match(r'^[\w\.-]+@[\w\.-]+\.\w+$', account) is not None + +@router.post("/send-code") +@rate_limit(requests=1, period=60) # 同一账号每分钟1次 +async def send_verify_code(request: SendCodeRequest, db: DbDependency): + """发送验证码""" + account = request.account + account_type = request.type + + # 验证账号格式 + if account_type == "phone" and not is_phone(account): + raise HTTPException(status_code=400, detail="手机号格式错误") + if account_type == "email" and not is_email(account): + raise HTTPException(status_code=400, detail="邮箱格式错误") + + # 检查是否已存在(注册时使用) + # 这里只负责发送,不检查是否存在 + + # 生成验证码 + code = generate_verify_code() + + # 存储到 Redis(5分钟有效) + redis_key = f"verify:code:{account}" + redis_client.set(redis_key, code, expire=300) + + # 发送验证码 + success = False + if account_type == "phone": + success = send_sms(account, code) + else: + success = await send_email(account, code) + + if not success: + raise HTTPException(status_code=500, detail="验证码发送失败") + + logger.info(f"验证码已发送: {account} -> {code}") + return {"success": True, "message": "验证码已发送"} + +@router.post("/register") +@rate_limit(requests=5, period=60) +async def register(request: RegisterRequest, db: DbDependency): + """注册(手机号/邮箱 + 验证码)""" + account = request.account + code = request.code + password = request.password + + # 验证验证码 + redis_key = f"verify:code:{account}" + saved_code = redis_client.get(redis_key) + if not saved_code or saved_code != code: + raise HTTPException(status_code=400, detail="验证码错误或已过期") + + # 检查用户是否已存在 + if is_phone(account): + existing = db.query(User).filter(User.phone == account).first() + if existing: + raise HTTPException(status_code=400, detail="手机号已注册") + user = User(phone=account, password_hash=hash_password(password)) + elif is_email(account): + existing = db.query(User).filter(User.email == account).first() + if existing: + raise HTTPException(status_code=400, detail="邮箱已注册") + user = User(email=account, password_hash=hash_password(password)) + else: + raise HTTPException(status_code=400, detail="账号格式错误") + + db.add(user) + db.commit() + db.refresh(user) + + # 删除已使用的验证码 + redis_client.delete(redis_key) + + # 生成 token + token = create_access_token({"sub": str(user.id)}) + + logger.info(f"新用户注册: {account}") + return { + "success": True, + "token": token, + "user": UserResponse.model_validate(user) + } + +@router.post("/login") +@rate_limit(requests=10, period=60) +async def login(request: LoginRequest, db: DbDependency): + """登录(支持验证码登录或密码登录)""" + account = request.account + password = request.password + code = request.code + + # 查找用户 + user = None + if is_phone(account): + user = db.query(User).filter(User.phone == account).first() + elif is_email(account): + user = db.query(User).filter(User.email == account).first() + else: + raise HTTPException(status_code=400, detail="账号格式错误") + + if not user: + raise HTTPException(status_code=400, detail="用户不存在") + + # 验证码登录 + if code: + redis_key = f"verify:code:{account}" + saved_code = redis_client.get(redis_key) + if not saved_code or saved_code != code: + raise HTTPException(status_code=400, detail="验证码错误或已过期") + redis_client.delete(redis_key) + + # 密码登录 + elif password: + if not verify_password(password, user.password_hash): + raise HTTPException(status_code=400, detail="密码错误") + else: + raise HTTPException(status_code=400, detail="请提供验证码或密码") + + # 更新最后登录时间 + from datetime import datetime + user.last_login = datetime.now() + db.commit() + + token = create_access_token({"sub": str(user.id)}) + + logger.info(f"用户登录: {account}") + return { + "success": True, + "token": token, + "user": UserResponse.model_validate(user) + } + +@router.post("/wechat/login") +async def wechat_login(): + """微信公众号登录(预留)""" + # TODO: 实现公众号 OAuth2 登录 + raise HTTPException(status_code=501, detail="功能开发中") \ No newline at end of file diff --git a/backend/routers/v1/notes.py b/backend/routers/v1/notes.py new file mode 100644 index 0000000..06330c0 --- /dev/null +++ b/backend/routers/v1/notes.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 便签路由 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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": "已删除"} \ No newline at end of file diff --git a/backend/routers/v1/stats.py b/backend/routers/v1/stats.py new file mode 100644 index 0000000..4c88b02 --- /dev/null +++ b/backend/routers/v1/stats.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 热度统计路由 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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 \ No newline at end of file diff --git a/backend/routers/v1/todos.py b/backend/routers/v1/todos.py new file mode 100644 index 0000000..d72ef87 --- /dev/null +++ b/backend/routers/v1/todos.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 待办事项路由 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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": "已删除"} \ No newline at end of file diff --git a/backend/routers/v1/tools.py b/backend/routers/v1/tools.py new file mode 100644 index 0000000..63797bc --- /dev/null +++ b/backend/routers/v1/tools.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 工具路由(密码、二维码、加密、JSON) +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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 + } + ) \ No newline at end of file diff --git a/backend/routers/v1/user.py b/backend/routers/v1/user.py new file mode 100644 index 0000000..8ed5583 --- /dev/null +++ b/backend/routers/v1/user.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 用户路由 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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": "密码已修改"} \ No newline at end of file diff --git a/backend/schemas.py b/backend/schemas.py new file mode 100644 index 0000000..b5a6814 --- /dev/null +++ b/backend/schemas.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - Pydantic 模型 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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 \ No newline at end of file diff --git a/backend/utils/email.py b/backend/utils/email.py new file mode 100644 index 0000000..c5fc110 --- /dev/null +++ b/backend/utils/email.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 腾讯企业邮箱服务 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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 \ No newline at end of file diff --git a/backend/utils/logger.py b/backend/utils/logger.py new file mode 100644 index 0000000..36d7035 --- /dev/null +++ b/backend/utils/logger.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 日志工具 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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() \ No newline at end of file diff --git a/backend/utils/redis_client.py b/backend/utils/redis_client.py new file mode 100644 index 0000000..fb45a8d --- /dev/null +++ b/backend/utils/redis_client.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - Redis 客户端 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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() \ No newline at end of file diff --git a/backend/utils/security.py b/backend/utils/security.py new file mode 100644 index 0000000..a20dd7b --- /dev/null +++ b/backend/utils/security.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 安全工具(密码、JWT) +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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 \ No newline at end of file diff --git a/backend/utils/sms.py b/backend/utils/sms.py new file mode 100644 index 0000000..021c020 --- /dev/null +++ b/backend/utils/sms.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 阿里云短信服务 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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 \ No newline at end of file diff --git a/dependencies.py b/dependencies.py new file mode 100644 index 0000000..e534e8e --- /dev/null +++ b/dependencies.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - 依赖注入 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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)] \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..2e24c00 --- /dev/null +++ b/main.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +PerToolBox Server - FastAPI 主程序 +Copyright (C) 2024 Sea Network Technology Studio +Author: Canglan +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 + - 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 " + 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 + ) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3c8be57 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/scripts/init_db.sql b/scripts/init_db.sql new file mode 100644 index 0000000..afeabea --- /dev/null +++ b/scripts/init_db.sql @@ -0,0 +1,83 @@ +-- PerToolBox 数据库初始化脚本 +-- Copyright (C) 2024 Sea Network Technology Studio +-- Author: Canglan +-- 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; \ No newline at end of file