diff --git a/README.md b/README.md index 3465e4d..947fc58 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,28 @@ This is the backend service for JieNote, built with FastAPI. uvicorn app.main:app --reload ``` +## Redis +- Redis is used for caching and session management. +- Make sure to have Redis installed and running. + +```bash +cd path/to/redis +# Start Redis server +redis-server.exe redis.windows.conf +``` +Attention!!! +- Make sure the port is not occupied by other services. +- If you want to use the default port, please modify the `redis.windows.conf` file. +- Must connect Redis before running the application. ‼️‼️‼️ + + ## Folder Structure - `app/`: Contains the main application code. - `tests/`: Contains test cases. - `env/`: Virtual environment (not included in version control). + + ## ER Diagram ![ER Diagram](img/er_diagram.jpg) diff --git "a/alembic/versions/2acf0b902f73_\346\267\273\345\212\240\345\244\264\345\203\217\351\273\230\350\256\244\345\200\274.py" "b/alembic/versions/2acf0b902f73_\346\267\273\345\212\240\345\244\264\345\203\217\351\273\230\350\256\244\345\200\274.py" new file mode 100644 index 0000000..f9dca8a --- /dev/null +++ "b/alembic/versions/2acf0b902f73_\346\267\273\345\212\240\345\244\264\345\203\217\351\273\230\350\256\244\345\200\274.py" @@ -0,0 +1,32 @@ +"""添加头像默认值 + +Revision ID: 2acf0b902f73 +Revises: b7940480e6e6 +Create Date: 2025-04-11 22:54:09.734172 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2acf0b902f73' +down_revision: Union[str, None] = 'b7940480e6e6' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git "a/alembic/versions/9af9d4a35bef_fix_\346\233\264\346\224\271password\345\220\215.py" "b/alembic/versions/9af9d4a35bef_fix_\346\233\264\346\224\271password\345\220\215.py" new file mode 100644 index 0000000..2f52761 --- /dev/null +++ "b/alembic/versions/9af9d4a35bef_fix_\346\233\264\346\224\271password\345\220\215.py" @@ -0,0 +1,34 @@ +"""fix:更改password名 + +Revision ID: 9af9d4a35bef +Revises: c49010e96150 +Create Date: 2025-04-12 10:27:52.832186 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '9af9d4a35bef' +down_revision: Union[str, None] = 'c49010e96150' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('password', sa.String(length=60), nullable=False)) + op.drop_column('users', 'hash_password') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('hash_password', mysql.VARCHAR(length=60), nullable=False)) + op.drop_column('users', 'password') + # ### end Alembic commands ### diff --git "a/alembic/versions/c49010e96150_fix_\346\225\260\346\215\256\345\272\223\345\256\232\344\271\211.py" "b/alembic/versions/c49010e96150_fix_\346\225\260\346\215\256\345\272\223\345\256\232\344\271\211.py" new file mode 100644 index 0000000..5c4fa4e --- /dev/null +++ "b/alembic/versions/c49010e96150_fix_\346\225\260\346\215\256\345\272\223\345\256\232\344\271\211.py" @@ -0,0 +1,34 @@ +"""fix 数据库定义 + +Revision ID: c49010e96150 +Revises: 2acf0b902f73 +Create Date: 2025-04-12 10:19:29.708681 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = 'c49010e96150' +down_revision: Union[str, None] = '2acf0b902f73' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('notes', sa.Column('content', sa.String(length=255), nullable=True)) + op.drop_column('notes', 'name') + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('notes', sa.Column('name', mysql.VARCHAR(length=30), nullable=True)) + op.drop_column('notes', 'content') + # ### end Alembic commands ### diff --git a/app/api/v1/endpoints/auth.py b/app/api/v1/endpoints/auth.py new file mode 100644 index 0000000..81bb525 --- /dev/null +++ b/app/api/v1/endpoints/auth.py @@ -0,0 +1,123 @@ +from fastapi import APIRouter, HTTPException, Depends +from sqlalchemy.orm import Session +from passlib.context import CryptContext +from datetime import datetime, timedelta +import jwt +import smtplib +from email.mime.text import MIMEText +from email.header import Header +import random +import time +import redis +from email.utils import formataddr + +from app.db.session import SessionLocal +from app.models.model import User +from app.schemas.auth import UserCreate, UserLogin, UserSendCode +from app.core.config import settings +from app.curd.user import get_user_by_email, create_user + +router = APIRouter() + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +SECRET_KEY = settings.SECRET_KEY +ALGORITHM = settings.ALGORITHM +ACCESS_TOKEN_EXPIRE_MINUTES = settings.ACCESS_TOKEN_EXPIRE_MINUTES + +# 配置 Redis 连接 +while True: + try: + print("Connecting to Redis...") + redis_client = redis.StrictRedis(host='localhost', port=6379, db=0) + redis_client.ping() + break + except redis.ConnectionError: + print("Redis connection failed, retrying...") + time.sleep(1) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +def create_access_token(data: dict, expires_delta: timedelta = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(minutes=15) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + +@router.post("/register", response_model=dict) +def register(user: UserCreate, db: Session = Depends(get_db)): + existing_user = get_user_by_email(db, user.email) + if (redis_client.exists(f"email:{user.email}:code")): + code = redis_client.get(f"email:{user.email}:code").decode("utf-8") + if (user.code != code): + raise HTTPException(status_code=400, detail="Invalid verification code") + else: + raise HTTPException(status_code=400, detail="Verification code expired or not sent") + + if (existing_user): + raise HTTPException(status_code=400, detail="Email already registered") + hashed_password = pwd_context.hash(user.password) + create_user(db, user.email, user.username, hashed_password) + return {"msg": "User registered successfully"} + +@router.post("/login", response_model=dict) +def login(user: UserLogin, db: Session = Depends(get_db)): + db_user = get_user_by_email(db, user.email) + if not db_user or not pwd_context.verify(user.password, db_user.password): + raise HTTPException(status_code=401, detail="Invalid email or password") + access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + access_token = create_access_token( + data={"sub": db_user.email}, expires_delta=access_token_expires + ) + return {"access_token": access_token, "token_type": "bearer", } + +# 发送验证码 +@router.post("/send_code", response_model=dict) +def send_code(user_send_code : UserSendCode, db: Session = Depends(get_db)): + # 检查 Redis 中是否存在该邮箱的发送记录 + if redis_client.exists(f"email:{user_send_code.email}:time"): + raise HTTPException(status_code=429, detail="You can only request a verification code once every 5 minutes.") + + # 生成随机验证码 + code = str(random.randint(100000, 999999)) + + # SMTP 配置 + smtp_server = "smtp.163.com" + smtp_port = 465 + sender_email = "19855278313@163.com" # 替换为你的网易邮箱 + sender_password = "DHSihwnVc4wS89eV" # 替换为你的授权码 + + # 邮件内容 + subject = "验证码" + body = f"欢迎使用JieNote,很开心遇见您,您的验证码是:{code},请在5分钟内使用。" + + # 创建MIMEText对象时需要显式指定子类型和编码 + message = MIMEText(_text=body, _subtype='plain', _charset='utf-8') + message["From"] = formataddr(("JieNote团队", "noreply@jienote.com")) + message["To"] = user_send_code.email + message["Subject"] = Header(subject, 'utf-8').encode() + # 添加必要的内容传输编码头 + message.add_header('Content-Transfer-Encoding', 'base64') + + try: + # 连接 SMTP 服务器并发送邮件 + with smtplib.SMTP_SSL(smtp_server, smtp_port) as server: + server.login(sender_email, sender_password) + server.sendmail(sender_email, [user_send_code.email], message.as_string()) + + # 将验证码和发送时间存储到 Redis,设置 5 分钟过期时间 + redis_client.setex(f"email:{user_send_code.email}:code", 300, code) + redis_client.setex(f"email:{user_send_code.email}:time", 300, int(time.time())) + + return {"msg": "Verification code sent"} + + except smtplib.SMTPException as e: + raise HTTPException(status_code=500, detail=f"Failed to send email: {str(e)}") \ No newline at end of file diff --git a/app/core/config.py b/app/core/config.py index 68818c4..fedf166 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -1,6 +1,12 @@ +import os +from datetime import timedelta + class Settings: - PROJECT_NAME: str = "JieNote Backend" - VERSION: str = "1.0.0" + PROJECT_NAME: str = "JieNote Backend" # 项目名称 + VERSION: str = "1.0.0" # 项目版本 SQLALCHEMY_DATABASE_URL = "mysql+pymysql://root:coders007@47.93.172.156:3306/JieNote" # 替换为实际的用户名、密码和数据库名称 + SECRET_KEY: str = os.getenv("SECRET_KEY", "your_secret_key") # 替换为更安全的密钥 + ALGORITHM: str = "HS256" # JWT算法 + ACCESS_TOKEN_EXPIRE_MINUTES: int = 30 # token过期时间 settings = Settings() \ No newline at end of file diff --git a/app/curd/user.py b/app/curd/user.py new file mode 100644 index 0000000..e0393ff --- /dev/null +++ b/app/curd/user.py @@ -0,0 +1,12 @@ +from sqlalchemy.orm import Session +from app.models.model import User + +def get_user_by_email(db: Session, email: str): + return db.query(User).filter(User.email == email).first() + +def create_user(db: Session, email: str, username: str,hashed_password: str): + new_user = User(email=email, username=username, password=hashed_password, avatar="app/static/avatar/default.jpg") + db.add(new_user) + db.commit() + db.refresh(new_user) + return new_user \ No newline at end of file diff --git a/app/db/session.py b/app/db/session.py index 5801d8e..7c9648b 100644 --- a/app/db/session.py +++ b/app/db/session.py @@ -3,5 +3,5 @@ from app.core.config import settings -engine = create_engine(settings.SQLALCHEMY_DATABASE_URI, pool_pre_ping=True) #连接mysql +engine = create_engine(settings.SQLALCHEMY_DATABASE_URL, pool_pre_ping=True) #连接mysql SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/app/main.py b/app/main.py index e8f2e2f..078afc5 100644 --- a/app/main.py +++ b/app/main.py @@ -1,4 +1,5 @@ from fastapi import FastAPI +from app.api.v1.endpoints.auth import router as auth_router app = FastAPI() @@ -8,4 +9,6 @@ def read_root(): @app.get("/items/{item_id}") def read_item(item_id: int, q: str = None): - return {"item_id": item_id, "q": q} \ No newline at end of file + return {"item_id": item_id, "q": q} + +app.include_router(auth_router, prefix="/public", tags=["auth"]) \ No newline at end of file diff --git a/app/models/model.py b/app/models/model.py index 0f2959d..83db46a 100644 --- a/app/models/model.py +++ b/app/models/model.py @@ -1,6 +1,5 @@ -from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey, UniqueConstraint +from sqlalchemy import Column, Integer, String, Boolean, Table, ForeignKey, UniqueConstraint, CheckConstraint from sqlalchemy.orm import relationship - from app.db.base_class import Base # 多对多关系表 @@ -8,54 +7,64 @@ 'user_group', Base.metadata, Column('user_id', Integer, ForeignKey('users.id'), primary_key=True), Column('group_id', Integer, ForeignKey('groups.id'), primary_key=True), - Column('is_admin', Boolean, default=False) # 是否是管理员 + Column('is_admin', Boolean, default=False) ) class User(Base): __tablename__ = 'users' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - email = Column(String(30), index=True, nullable=False, unique=True) + email = Column(String(30), unique=True, index=True, nullable=False) username = Column(String(30), index=True, nullable=False) - hash_password = Column(String(60), nullable=False) - avatar = Column(String(100), nullable=True) # 头像的url + password = Column(String(60), nullable=False) + avatar = Column(String(100)) groups = relationship('Group', secondary=user_group, back_populates='users') + folders = relationship('Folder', back_populates='user') class Group(Base): __tablename__ = 'groups' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - leader = Column(Integer) # the id of the leader + leader = Column(Integer) users = relationship('User', secondary=user_group, back_populates='groups') + folders = relationship('Folder', back_populates='group') -class Folder(Base): # 文件夹 +class Folder(Base): __tablename__ = 'folders' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - name = Column(String(30)) + name = Column(String(30), nullable=False) + user_id = Column(Integer, ForeignKey('users.id')) group_id = Column(Integer, ForeignKey('groups.id')) + + # 关系定义 user = relationship('User', back_populates='folders') group = relationship('Group', back_populates='folders') + articles = relationship('Article', back_populates='folder') __table_args__ = ( UniqueConstraint('user_id', 'group_id', name='uq_user_group_folder'), - ) + ) class Article(Base): __tablename__ = 'articles' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - name = Column(String(30)) + name = Column(String(30), nullable=False) folder_id = Column(Integer, ForeignKey('folders.id')) + folder = relationship('Folder', back_populates='articles') + notes = relationship('Note', back_populates='article') + tags = relationship('Tag', back_populates='article') class Note(Base): __tablename__ = 'notes' id = Column(Integer, primary_key=True, index=True, autoincrement=True) - name = Column(String(30)) + content = Column(String(255)) # 为 content 字段指定长度 article_id = Column(Integer, ForeignKey('articles.id')) + article = relationship('Article', back_populates='notes') class Tag(Base): @@ -64,13 +73,5 @@ class Tag(Base): id = Column(Integer, primary_key=True, index=True, autoincrement=True) content = Column(String(30)) article_id = Column(Integer, ForeignKey('articles.id')) - article = relationship('Article', back_populates='tags') - -# 添加反向关系 -User.folders = relationship('Folder', back_populates='users') -Group.folders = relationship('Folder', back_populates='groups') -Folder.articles = relationship('Article', back_populates='folders') -Article.notes = relationship('Note', back_populates='articles') -Article.tags = relationship('Tag', back_populates='articles') - - + + article = relationship('Article', back_populates='tags') \ No newline at end of file diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..9fa4bfc --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel, EmailStr + +class UserCreate(BaseModel): + email: EmailStr + username: str + password: str + code: str + avatar: str | None = None + +class UserLogin(BaseModel): + email: EmailStr + password: str + +class UserSendCode(BaseModel): + email: EmailStr \ No newline at end of file diff --git a/app/static/avatar/default.jpg b/app/static/avatar/default.jpg new file mode 100644 index 0000000..232972a Binary files /dev/null and b/app/static/avatar/default.jpg differ diff --git a/requirements.txt b/requirements.txt index 84b9803..6ac98c4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,23 +1,38 @@ alembic==1.15.2 annotated-types==0.7.0 anyio==4.9.0 +bcrypt==4.3.0 +cffi==1.17.1 click==8.1.8 colorama==0.4.6 +cryptography==44.0.2 +dnspython==2.7.0 dotenv==0.9.9 +email_validator==2.2.0 fastapi==0.115.12 greenlet==3.1.1 h11==0.14.0 idna==3.10 +jwt==1.3.1 Mako==1.3.9 MarkupSafe==3.0.2 +numpy==2.2.4 +pandas==2.2.3 passlib==1.7.4 +pycparser==2.22 pydantic==2.11.2 pydantic_core==2.33.1 +PyJWT==2.10.1 PyMySQL==1.1.1 +python-dateutil==2.9.0.post0 python-dotenv==1.1.0 +pytz==2025.2 +redis==5.2.1 +six==1.17.0 sniffio==1.3.1 SQLAlchemy==2.0.40 starlette==0.46.1 typing-inspection==0.4.0 typing_extensions==4.13.1 +tzdata==2025.2 uvicorn==0.34.0