commit 30b5bf2aa1e8f1e40947afa89a76ca878b40944d Author: Cxx0822 <1556464090@qq.com> Date: Sun Sep 6 16:07:00 2026 +0800 feat: 初始化工程 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ddac70 --- /dev/null +++ b/.gitignore @@ -0,0 +1,68 @@ +# Python 字节码文件 +__pycache__/ +*.py[cod] +*$py.class + +# C 扩展 +*.so + +# 分发/打包 +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# 虚拟环境 +venv/ +env/ +ENV/ +.env +.venv + +# 测试 +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Django 相关 +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal +media/ + +# PyCharm IDE +.idea/ +*.iml +*.iws +*.ipr + +# VS Code +.vscode/ +*.code-workspace +.history/ + +# 其他 +.DS_Store + +logs/ +packages/ \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..ab1f416 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +# Editor-based HTTP Client requests +/httpRequests/ diff --git a/.idea/family-service.iml b/.idea/family-service.iml new file mode 100644 index 0000000..18f2b92 --- /dev/null +++ b/.idea/family-service.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..3d12e4d --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0295eb2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim +WORKDIR /app +RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime +RUN echo 'Asia/Shanghai' > /etc/timezone +COPY ./packages /app/packages +COPY requirements.txt /app/ +RUN pip install --no-cache-dir --no-index --find-links=/app/packages -r requirements.txt +COPY . /app/ +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +# pip download -r requirements.txt -d ./packages --only-binary=:all: --platform manylinux2014_x86_64 -i https://pypi.tuna.tsinghua.edu.cn/simple diff --git a/config/database.py b/config/database.py new file mode 100644 index 0000000..3c386eb --- /dev/null +++ b/config/database.py @@ -0,0 +1,26 @@ +import os + +from dotenv import load_dotenv +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +load_dotenv() + +DATABASE_URL = f"mysql+pymysql://root:{os.getenv('DB_PASSWORD')}@{os.getenv('DB_HOST')}:3306/family" + +engine = create_engine(url=DATABASE_URL, pool_pre_ping=True, pool_recycle=3600) + +# 会话工厂 +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +# ORM基类 +Base = declarative_base() + + +def get_db(): + # 创建数据库会话实例 + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/config/rustfs.py b/config/rustfs.py new file mode 100644 index 0000000..d0eed23 --- /dev/null +++ b/config/rustfs.py @@ -0,0 +1,18 @@ +import os + +import boto3 +from botocore.client import Config +from dotenv import load_dotenv + +load_dotenv() + +access_key = 'TRatSfBovO0W36NUhP2c' +secret_access = '4ZFzdOUjSVeNu0W8RGg5MasyBmpL79rlAHQwb32Y' + +s3 = boto3.client('s3', + endpoint_url=f'http://{os.getenv("RUSTFS_HOST")}:{os.getenv("RUSTFS_PORT")}', + aws_access_key_id=access_key, + aws_secret_access_key=secret_access, + config=Config(signature_version='s3v4'), + region_name='cn-east-1' + ) diff --git a/id_generator/__init__.py b/id_generator/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/id_generator/generator.py b/id_generator/generator.py new file mode 100644 index 0000000..fc995d7 --- /dev/null +++ b/id_generator/generator.py @@ -0,0 +1,38 @@ +""" +雪花算法生成器IdGenerator +""" + +# !/usr/bin/python +# coding=UTF-8 + + +from . import options +from . import snowflake_m1 + + +class DefaultIdGenerator: + """ + ID生成器 + """ + + def __init__(self): + self.snowflake = None + + def set_id_generator(self, option: options.IdGeneratorOptions): + """ + 设置id生成规则信息 + """ + + if option.base_time < 100000: + raise ValueError("base time error.") + + self.snowflake = snowflake_m1.SnowFlakeM1(option) + + def next_id(self) -> int: + """ + 获取新的UUID + """ + + if self.snowflake is None: + raise ValueError("please set id generator at first.") + return self.snowflake.next_id() diff --git a/id_generator/idregister.py b/id_generator/idregister.py new file mode 100644 index 0000000..787faac --- /dev/null +++ b/id_generator/idregister.py @@ -0,0 +1,134 @@ +""" +worker id generator +""" + +# !/usr/bin/python +# coding=UTF-8 + + +from threading import Thread +import time +import logging +import redis + + +class Register: + """ + redis封装 + - host 代表redis ip + - port 代表redis端口 + - max_worker_id worker_id的最大值, 默认为100 + - password redis的密码, 默认为空 + """ + + def __init__(self, host, port, max_worker_id=100, password=None): + self.redis_impl = redis.StrictRedis(host=host, port=port, db=0, password=password) + self.loop_count = 0 + self.max_loop_count = 10 + self.worker_id_expire_time = 15 + self.max_worker_id = max_worker_id + self.worker_id = -1 + self.is_stop = False + + def get_lock(self, key): + """ + 获取分布式全局锁,并设置过期时间为30秒 + """ + + if self.redis_impl.setnx(key, 1): + self.redis_impl.expire(key, 30) + return True + if self.redis_impl.ttl(key) < 0: + self.redis_impl.expire(key, 30) + return False + + def stop(self): + """ + 退出注册器的线程 + """ + + self.is_stop = True + + def get_worker_id(self): + """ + 获取全局唯一worker_id, 会创建一个线程给worker id续期 + 失败返回-1 + """ + + self.loop_count = 0 + + def extern_life(my_id): + while 1: + time.sleep(self.worker_id_expire_time / 3) + # 是否关闭了 + if self.is_stop: + return + # 更新生命周期 + if self.worker_id != my_id: + break + try: + self.redis_impl.expire( + f"IdGen:WorkerId:Value:{my_id}", + self.worker_id_expire_time) + except Exception as exe: + logging.error(exe) + continue + + self.worker_id = self.__get_next_worker_id() + if self.worker_id > -1: + Thread(target=extern_life, args=[self.worker_id]).start() + return self.worker_id + + def __get_next_worker_id(self): + """ + 获取全局唯一worker id内部实现 + """ + + cur = self.redis_impl.incrby("IdGen:WorkerId:Index", 1) + + def can_reset(): + try: + reset_value = self.redis_impl.incr("IdGen:WorkerId:Value:Edit") + return reset_value != 1 + except Exception as ept: + logging.error(ept) + return False + + def end_reset(): + try: + self.redis_impl.set("IdGen:WorkerId:Value:Edit", 0) + except Exception as ept: + logging.error(ept) + + def is_available(worker_id: int): + try: + rst = self.redis_impl.get(f"IdGen:WorkerId:Value:{worker_id}") + return rst != "Y" + except Exception as ept: + logging.error(ept) + return False + + if cur > self.max_worker_id: + if can_reset(): + self.redis_impl.set("IdGen:WorkerId:Index", -1) + end_reset() + self.loop_count += 1 + + if self.loop_count > self.max_loop_count: + self.loop_count = 0 + return -1 + + time.sleep(0.2 * self.loop_count) + return self.__get_next_worker_id() + time.sleep(0.2) + return self.__get_next_worker_id() + if is_available(cur): + self.redis_impl.setex( + f"IdGen:WorkerId:Value:{cur}", + self.worker_id_expire_time, + "Y" + ) + self.loop_count = 0 + return cur + + return self.__get_next_worker_id() diff --git a/id_generator/options.py b/id_generator/options.py new file mode 100644 index 0000000..8558381 --- /dev/null +++ b/id_generator/options.py @@ -0,0 +1,43 @@ +""" +生成器IdGenerator配置选项 +""" + +# !/usr/bin/python +# coding=UTF-8 + + +class IdGeneratorOptions: + """ + ID生成器配置 + - worker_id 全局唯一id, 区分不同uuid生成器实例 + - worker_id_bit_length 生成的uuid中worker_id占用的位数 + - seq_bit_length 生成的uuid中序列号占用的位数 + """ + + def __init__(self, worker_id=0, worker_id_bit_length=6, seq_bit_length=6): + + # 雪花计算方法,(1-漂移算法|2-传统算法), 默认1。目前只实现了1。 + self.method = 1 + + # 基础时间(ms单位), 不能超过当前系统时间 + self.base_time = 1582136402000 + + # 机器码, 必须由外部设定, 最大值 2^worker_id_bit_length-1 + self.worker_id = worker_id + + # 机器码位长, 默认值6, 取值范围 [1, 15](要求:序列数位长+机器码位长不超过22) + self.worker_id_bit_length = worker_id_bit_length + + # 序列数位长, 默认值6, 取值范围 [3, 21](要求:序列数位长+机器码位长不超过22) + self.seq_bit_length = seq_bit_length + + # 最大序列数(含), 设置范围 [max_seq_number, 2^seq_bit_length-1] + # 默认值0, 表示最大序列数取最大值(2^seq_bit_length-1]) + self.max_seq_number = 0 + + # 最小序列数(含), 默认值5, 取值范围 [5, max_seq_number], 每毫秒的前5个序列数对应编号0-4是保留位 + # 其中1-4是时间回拨相应预留位, 0是手工新值预留位 + self.min_seq_number = 5 + + # 最大漂移次数(含), 默认2000, 推荐范围500-10000(与计算能力有关) + self.top_over_cost_count = 2000 diff --git a/id_generator/snowflake.py b/id_generator/snowflake.py new file mode 100644 index 0000000..ea36cfa --- /dev/null +++ b/id_generator/snowflake.py @@ -0,0 +1,20 @@ + +""" +雪花算法生成器接口声明 +""" + +# !/usr/bin/python +# coding=UTF-8 + + +class SnowFlake(): + + def __init__(self, options): + self.options = options + + def next_id(self) -> int: + """ + 获取新的UUID + """ + + return 0 diff --git a/id_generator/snowflake_m1.py b/id_generator/snowflake_m1.py new file mode 100644 index 0000000..596b76d --- /dev/null +++ b/id_generator/snowflake_m1.py @@ -0,0 +1,147 @@ +""" +M1生成器 +""" + +# !/usr/bin/python +# coding=UTF-8 + +import threading +import time +from .snowflake import SnowFlake +from .options import IdGeneratorOptions + + +class SnowFlakeM1(SnowFlake): + """ + M1规则ID生成器配置 + """ + + def __init__(self, options: IdGeneratorOptions): + # 1.base_time + self.base_time = 1582136402000 + if options.base_time != 0: + self.base_time = int(options.base_time) + + # 2.worker_id_bit_length + self.worker_id_bit_length = 6 + if options.worker_id_bit_length != 0: + self.worker_id_bit_length = int(options.worker_id_bit_length) + + # 3.worker_id + self.worker_id = options.worker_id + + # 4.seq_bit_length + self.seq_bit_length = 6 + if options.seq_bit_length != 0: + self.seq_bit_length = int(options.seq_bit_length) + + # 5.max_seq_number + self.max_seq_number = int(options.max_seq_number) + if options.max_seq_number <= 0: + self.max_seq_number = (1 << self.seq_bit_length) - 1 + + # 6.min_seq_number + self.min_seq_number = int(options.min_seq_number) + + # 7.top_over_cost_count + self.top_over_cost_count = int(options.top_over_cost_count) + + # 8.Others + self.__timestamp_shift = self.worker_id_bit_length + self.seq_bit_length + self.__current_seq_number = self.min_seq_number + self.__last_time_tick: int = 0 + self.__turn_back_time_tick: int = 0 + self.__turn_back_index: int = 0 + self.__is_over_cost = False + self.___over_cost_count_in_one_term: int = 0 + self.__id_lock = threading.Lock() + + def __next_over_cost_id(self) -> int: + current_time_tick = self.__get_current_time_tick() + if current_time_tick > self.__last_time_tick: + self.__last_time_tick = current_time_tick + self.__current_seq_number = self.min_seq_number + self.__is_over_cost = False + self.___over_cost_count_in_one_term = 0 + return self.__calc_id(self.__last_time_tick) + + if self.___over_cost_count_in_one_term >= self.top_over_cost_count: + self.__last_time_tick = self.__get_next_time_tick() + self.__current_seq_number = self.min_seq_number + self.__is_over_cost = False + self.___over_cost_count_in_one_term = 0 + return self.__calc_id(self.__last_time_tick) + + if self.__current_seq_number > self.max_seq_number: + self.__last_time_tick += 1 + self.__current_seq_number = self.min_seq_number + self.__is_over_cost = True + self.___over_cost_count_in_one_term += 1 + return self.__calc_id(self.__last_time_tick) + + return self.__calc_id(self.__last_time_tick) + + def __next_normal_id(self) -> int: + current_time_tick = self.__get_current_time_tick() + if current_time_tick < self.__last_time_tick: + if self.__turn_back_time_tick < 1: + self.__turn_back_time_tick = self.__last_time_tick - 1 + self.__turn_back_index += 1 + # 每毫秒序列数的前5位是预留位, 0用于手工新值, 1-4是时间回拨次序 + # 支持4次回拨次序(避免回拨重叠导致ID重复), 可无限次回拨(次序循环使用)。 + if self.__turn_back_index > 4: + self.__turn_back_index = 1 + + return self.__calc_turn_back_id(self.__turn_back_time_tick) + + # 时间追平时, _TurnBackTimeTick清零 + self.__turn_back_time_tick = min(self.__turn_back_time_tick, 0) + + if current_time_tick > self.__last_time_tick: + self.__last_time_tick = current_time_tick + self.__current_seq_number = self.min_seq_number + return self.__calc_id(self.__last_time_tick) + + if self.__current_seq_number > self.max_seq_number: + self.__last_time_tick += 1 + self.__current_seq_number = self.min_seq_number + self.__is_over_cost = True + self.___over_cost_count_in_one_term = 1 + return self.__calc_id(self.__last_time_tick) + + return self.__calc_id(self.__last_time_tick) + + def __calc_id(self, use_time_tick) -> int: + self.__current_seq_number += 1 + return ( + (use_time_tick << self.__timestamp_shift) + + (self.worker_id << self.seq_bit_length) + + self.__current_seq_number + ) % int(1e64) + + def __calc_turn_back_id(self, use_time_tick) -> int: + self.__turn_back_time_tick -= 1 + return ( + (use_time_tick << self.__timestamp_shift) + + (self.worker_id << self.seq_bit_length) + + self.__turn_back_index + ) % int(1e64) + + def __get_current_time_tick(self) -> int: + return int((time.time_ns() / 1e6) - self.base_time) + + def __get_next_time_tick(self) -> int: + temp_time_ticker = self.__get_current_time_tick() + while temp_time_ticker <= self.__last_time_tick: + # 0.001 = 1 mili sec + time.sleep(0.001) + temp_time_ticker = self.__get_current_time_tick() + return temp_time_ticker + + def next_id(self) -> int: + with self.__id_lock: + if self.__is_over_cost: + nextid = self.__next_over_cost_id() + else: + nextid = self.__next_normal_id() + return nextid diff --git a/main.py b/main.py new file mode 100644 index 0000000..4087702 --- /dev/null +++ b/main.py @@ -0,0 +1,16 @@ +from fastapi import FastAPI +from starlette.middleware.cors import CORSMiddleware + +from routers import routers + +app = FastAPI(title="Family Service") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +for router in routers: + app.include_router(router) diff --git a/models/base.py b/models/base.py new file mode 100644 index 0000000..add1ab4 --- /dev/null +++ b/models/base.py @@ -0,0 +1,41 @@ +from sqlalchemy import Column, BigInteger, DateTime, event +from sqlalchemy.ext.declarative import declared_attr + +from config.database import Base +from datetime import datetime + +from utils.common import camel_to_snake +from id_generator import options, generator + +# https://github.com/yitter/IdGenerator/tree/master/Python +options = options.IdGeneratorOptions(worker_id=23) +idgen = generator.DefaultIdGenerator() +idgen.set_id_generator(options) + + +# 第二层基类:包含ID +class IdBase(Base): + __abstract__ = True + + id = Column(BigInteger, primary_key=True, index=True) + + @declared_attr + def __tablename__(cls): + # 自动把数据库实体类名驼峰转为数据库表名下划线 + return camel_to_snake(cls.__name__) + + +# 自动填充id +@event.listens_for(IdBase, 'before_insert', propagate=True) +def before_insert_listener(mapper, connection, target): + if target.id is None: + target.id = idgen.next_id() + + +# 第二层基类:包含ID和审计字段 +class AuditBase(IdBase): + __abstract__ = True + + create_time = Column(DateTime, nullable=True, default=datetime.now) + update_time = Column(DateTime, nullable=True, default=datetime.now, onupdate=datetime.now) + diff --git a/models/family.py b/models/family.py new file mode 100644 index 0000000..3289d64 --- /dev/null +++ b/models/family.py @@ -0,0 +1,41 @@ +from sqlalchemy import Column, JSON, String, Text, Integer, BigInteger, ForeignKey +from sqlalchemy.orm import relationship, Mapped + +from models.base import AuditBase + + +class Users(AuditBase): + name = Column(String(45), nullable=False, comment="昵称") + avatar = Column(String(255), nullable=True, comment="头像") + + records: Mapped[list["Records"]] = relationship("Records", back_populates="user") + + +class Records(AuditBase): + user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, comment="用户id") + content = Column(Text, nullable=False, comment="内容") + image_list = Column(JSON, nullable=True, default=list, comment="图片") + video_list = Column(JSON, nullable=True, default=list, comment="视频") + like_count = Column(Integer, nullable=True, default=0, comment="点赞数") + comment_count = Column(Integer, nullable=True, default=0, comment="评论数") + + user: Mapped["Users"] = relationship("Users", back_populates="records") + comments: Mapped[list["Comments"]] = relationship( + "Comments", + back_populates="record", + lazy="selectin", + ) + + +class Likes(AuditBase): + record_id = Column(BigInteger, ForeignKey("records.id"), nullable=False, comment="记录id") + user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, comment="用户id") + + +class Comments(AuditBase): + record_id = Column(BigInteger, ForeignKey("records.id"), nullable=False, comment="记录id") + user_id = Column(BigInteger, ForeignKey("users.id"), nullable=False, comment="用户id") + content = Column(String(255), nullable=False, comment="内容") + + record: Mapped["Records"] = relationship("Records", back_populates="comments") + user: Mapped["Users"] = relationship("Users") # ← 就加了这一行 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0738711 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +fastapi~=0.140.0 +python-dotenv~=1.2.2 +requests~=2.34.2 +starlette~=1.3.1 +pydantic~=2.13.4 +SQLAlchemy~=2.0.51 +asyncpg~=0.30.0 +uvicorn~=0.23.0 +pymysql~=1.2.0 +boto3~=1.40.59 +botocore~=1.40.59 +python-multipart~=0.0.20 \ No newline at end of file diff --git a/routers/__init__.py b/routers/__init__.py new file mode 100644 index 0000000..a9b8a93 --- /dev/null +++ b/routers/__init__.py @@ -0,0 +1,13 @@ +from .user import router as user_router +from .record import router as record_router +from .like import router as like_router +from .comment import router as comment_router +from .file import router as file_router + +routers = [ + user_router, + record_router, + like_router, + comment_router, + file_router +] diff --git a/routers/comment.py b/routers/comment.py new file mode 100644 index 0000000..6a04339 --- /dev/null +++ b/routers/comment.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from config.database import get_db +from schemas.comment import CommentCreate, CommentUpdate +from service import comment_service + +router = APIRouter(prefix="/comments", tags=["评论"]) + + +@router.post("", response_model=bool, summary="发表评论") +def create(comment_in: CommentCreate, db: Session = Depends(get_db)): + return comment_service.create_comment(db, comment_in) + + +@router.put("/{comment_id}", response_model=bool, summary="编辑评论") +def update(comment_id: int, comment_in: CommentUpdate, db: Session = Depends(get_db)): + return comment_service.update_comment(db, comment_id, comment_in) + + +@router.delete("/{comment_id}", summary="删除评论") +def delete(comment_id: int, current_user_id: int, db: Session = Depends(get_db)): + return comment_service.delete_comment(db, comment_id, current_user_id) diff --git a/routers/file.py b/routers/file.py new file mode 100644 index 0000000..fa5d1f8 --- /dev/null +++ b/routers/file.py @@ -0,0 +1,10 @@ +from fastapi import APIRouter, Query, UploadFile, File +from service import file_service + +router = APIRouter(prefix="/file", tags=["文件"]) + + +@router.post("/upload", summary="上传文件", response_model=str) +async def upload(md5: str = Query(..., description="文件MD5值"), + file: UploadFile = File(..., description="要上传的文件")): + return await file_service.upload_file(md5, file) diff --git a/routers/like.py b/routers/like.py new file mode 100644 index 0000000..a3a25ee --- /dev/null +++ b/routers/like.py @@ -0,0 +1,18 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from config.database import get_db +from schemas.like import LikeCreate +from service import like_service + +router = APIRouter(prefix="/likes", tags=["点赞"]) + + +@router.post("", summary="点赞") +def like(like_in: LikeCreate, db: Session = Depends(get_db)): + return like_service.create_like(db, like_in) + + +@router.delete("", summary="取消点赞") +def unlike(record_id: int, user_id: int, db: Session = Depends(get_db)): + return like_service.delete_like(db, record_id, user_id) diff --git a/routers/record.py b/routers/record.py new file mode 100644 index 0000000..418718e --- /dev/null +++ b/routers/record.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List + +from config.database import get_db +from schemas.record import RecordCreate, RecordUpdate, RecordResponse +from service import record_service + +router = APIRouter(prefix="/records", tags=["记录"]) + + +@router.post("", response_model=bool, summary="发布记录") +def create(record_in: RecordCreate, db: Session = Depends(get_db)): + return record_service.create_record(db, record_in) + + +@router.get("", response_model=List[RecordResponse], summary="获取所有记录") +def list_all(skip: int = 0, limit: int = 20, db: Session = Depends(get_db)): + return record_service.get_all_records(db, skip, limit) + + +@router.get("/{record_id}", response_model=RecordResponse, summary="获取记录详情") +def get(record_id: int, db: Session = Depends(get_db)): + record = record_service.get_record_by_id(db, record_id) + if not record: + raise HTTPException(status_code=404, detail="记录不存在") + return record + + +@router.put("/{record_id}", response_model=bool, summary="更新记录") +def update(record_id: int, record_in: RecordUpdate, db: Session = Depends(get_db)): + return record_service.update_record(db, record_id, record_in) + + +@router.delete("/{record_id}", summary="删除记录") +def delete(record_id: int, db: Session = Depends(get_db)): + return record_service.delete_record(db, record_id) diff --git a/routers/user.py b/routers/user.py new file mode 100644 index 0000000..9258b9f --- /dev/null +++ b/routers/user.py @@ -0,0 +1,31 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from config.database import get_db +from schemas.user import UserCreate, UserUpdate, UserResponse +from service import user_service + +router = APIRouter(prefix="/users", tags=["用户"]) + + +@router.post("", response_model=UserResponse, summary="创建用户") +def create(user_in: UserCreate, db: Session = Depends(get_db)): + return user_service.create_user(db, user_in) + + +@router.get("/{user_id}", response_model=UserResponse, summary="获取用户详情") +def get(user_id: int, db: Session = Depends(get_db)): + user = user_service.get_user_by_id(db, user_id) + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + return user + + +@router.put("/{user_id}", response_model=UserResponse, summary="更新用户") +def update(user_id: int, user_in: UserUpdate, db: Session = Depends(get_db)): + return user_service.update_user(db, user_id, user_in) + + +@router.delete("/{user_id}", summary="删除用户") +def delete(user_id: int, db: Session = Depends(get_db)): + return user_service.delete_user(db, user_id) diff --git a/schemas/comment.py b/schemas/comment.py new file mode 100644 index 0000000..5fc6b80 --- /dev/null +++ b/schemas/comment.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from pydantic import BaseModel + +from schemas.user import UserResponse + + +class CommentCreate(BaseModel): + record_id: int + user_id: int + content: str + + +class CommentUpdate(BaseModel): + content: str + + +class CommentResponse(BaseModel): + id: int + record_id: int + user: UserResponse + content: str + create_time: datetime + update_time: datetime + + model_config = { + "from_attributes": True, + "json_encoders": { + datetime: lambda dt: dt.strftime("%Y-%m-%d %H:%M:%S") + } + } diff --git a/schemas/like.py b/schemas/like.py new file mode 100644 index 0000000..9989c69 --- /dev/null +++ b/schemas/like.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel + + +class LikeCreate(BaseModel): + record_id: int + user_id: int diff --git a/schemas/record.py b/schemas/record.py new file mode 100644 index 0000000..fb24c24 --- /dev/null +++ b/schemas/record.py @@ -0,0 +1,40 @@ +from datetime import datetime + +from pydantic import BaseModel +from typing import Optional, List + +from schemas.comment import CommentResponse +from schemas.user import UserResponse + + +class RecordCreate(BaseModel): + user_id: int + content: str + image_list: List[str] = [] + video_list: List[str] = [] + + +class RecordUpdate(BaseModel): + content: Optional[str] = None + image_list: Optional[List[str]] = None + video_list: Optional[List[str]] = None + + +class RecordResponse(BaseModel): + id: int + user: UserResponse + content: str + image_list: List[str] + video_list: List[str] + like_count: int + comment_count: int + comments: List[CommentResponse] = [] + create_time: datetime + update_time: datetime + + model_config = { + "from_attributes": True, + "json_encoders": { + datetime: lambda dt: dt.strftime("%Y-%m-%d %H:%M:%S") + } + } diff --git a/schemas/user.py b/schemas/user.py new file mode 100644 index 0000000..007c64a --- /dev/null +++ b/schemas/user.py @@ -0,0 +1,29 @@ +from datetime import datetime + +from pydantic import BaseModel +from typing import Optional + + +class UserCreate(BaseModel): + name: str + avatar: Optional[str] = None + + +class UserUpdate(BaseModel): + name: Optional[str] = None + avatar: Optional[str] = None + + +class UserResponse(BaseModel): + id: int + name: str + avatar: Optional[str] = None + create_time: datetime + update_time: datetime + + model_config = { + "from_attributes": True, + "json_encoders": { + datetime: lambda dt: dt.strftime("%Y-%m-%d %H:%M:%S") + } + } diff --git a/service/comment_service.py b/service/comment_service.py new file mode 100644 index 0000000..37ea9fa --- /dev/null +++ b/service/comment_service.py @@ -0,0 +1,53 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.family import Comments +from schemas.comment import CommentCreate, CommentUpdate +from service.record_service import get_record_by_id + + +def get_comment_by_id(db: Session, comment_id: int) -> Comments | None: + result = db.execute(select(Comments).where(Comments.id == comment_id)) + return result.scalar_one_or_none() + + +def create_comment(db: Session, comment_dto: CommentCreate) -> bool: + comment = Comments(**comment_dto.model_dump()) + db.add(comment) + + record = get_record_by_id(db, comment_dto.record_id) + if record: + record.comment_count = (record.comment_count or 0) + 1 + + db.commit() + db.refresh(comment) + + return True + + +def update_comment(db: Session, comment_id: int, comment_dto: CommentUpdate) -> bool: + comment = get_comment_by_id(db, comment_id) + if not comment: + return False + + comment.content = comment_dto.content + db.commit() + db.refresh(comment) + return True + + +def delete_comment(db: Session, comment_id: int, current_user_id: int) -> bool: + comment = get_comment_by_id(db, comment_id) + if not comment: + return False + if comment.user_id != current_user_id: + return False + + record = get_record_by_id(db, comment.record_id) + if record and record.comment_count and record.comment_count > 0: + record.comment_count -= 1 + + db.delete(comment) + db.commit() + + return True diff --git a/service/file_service.py b/service/file_service.py new file mode 100644 index 0000000..5daa1b9 --- /dev/null +++ b/service/file_service.py @@ -0,0 +1,48 @@ +from fastapi import UploadFile, File, HTTPException + +from config.rustfs import s3 + +ALLOWED_IMAGE_TYPES = [ + # 图片 + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + # 视频 + "video/mp4", + "video/mpeg", + "video/quicktime", + "video/x-msvideo", + "video/webm", + "video/x-matroska", + "video/ogg", +] + +BUCKET = 'family' +NGINX_PROXY = 'rustfs' + + +async def upload_file(md5: str, file: UploadFile = File(...)) -> str: + # 校验文件类型 + if file.content_type not in ALLOWED_IMAGE_TYPES: + raise HTTPException( + status_code=400, + detail="只允许上传图片或视频文件 (JPEG, PNG, GIF, WEBP, SVG, MP4, MOV, AVI, WEBM, MKV, OGV)" + ) + + file_ext = file.filename.split('.')[-1] + unique_filename = f"{md5}.{file_ext}" + + file_content = await file.read() + + # 上传到S3 + s3.put_object( + Bucket=BUCKET, + Key=unique_filename, + Body=file_content, + ContentType=file.content_type + ) + + # 返回文件url + return f"{NGINX_PROXY}/{BUCKET}/{unique_filename}" \ No newline at end of file diff --git a/service/like_service.py b/service/like_service.py new file mode 100644 index 0000000..29d249b --- /dev/null +++ b/service/like_service.py @@ -0,0 +1,46 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.family import Likes +from schemas.like import LikeCreate +from service.record_service import get_record_by_id + + +def get_like(db: Session, record_id: int, user_id: int) -> Likes | None: + result = db.execute( + select(Likes).where(Likes.record_id == record_id, Likes.user_id == user_id) + ) + return result.scalar_one_or_none() + + +def create_like(db: Session, like_dto: LikeCreate) -> bool: + exist = get_like(db, like_dto.record_id, like_dto.user_id) + if exist: + return False + + like = Likes(**like_dto.model_dump()) + db.add(like) + + record = get_record_by_id(db, like_dto.record_id) + if record: + record.like_count = (record.like_count or 0) + 1 + + db.commit() + + return True + + +def delete_like(db: Session, record_id: int, user_id: int) -> bool: + like = get_like(db, record_id, user_id) + if not like: + return False + + db.delete(like) + + record = get_record_by_id(db, record_id) + if record and record.like_count and record.like_count > 0: + record.like_count -= 1 + + db.commit() + + return True diff --git a/service/record_service.py b/service/record_service.py new file mode 100644 index 0000000..7f3ee41 --- /dev/null +++ b/service/record_service.py @@ -0,0 +1,59 @@ +from sqlalchemy import select, desc +from sqlalchemy.orm import Session, selectinload + +from models.family import Records, Comments +from schemas.record import RecordCreate, RecordUpdate, RecordResponse + + +def get_all_records(db: Session, skip: int = 0, limit: int = 20) -> list[RecordResponse]: + result = db.execute( + select(Records) + .options( + selectinload(Records.user), + selectinload(Records.comments).selectinload(Comments.user), + ) + .order_by(desc(Records.create_time)) + .offset(skip) + .limit(limit) + ) + records = result.scalars().all() + return [RecordResponse.model_validate(r) for r in records] + + +def create_record(db: Session, record_dto: RecordCreate) -> bool: + data = record_dto.model_dump() + record = Records(**data) + db.add(record) + db.commit() + db.refresh(record) + + return True + + +def get_record_by_id(db: Session, record_id: int) -> Records | None: + result = db.execute(select(Records).where(Records.id == record_id)) + return result.scalar_one_or_none() + + +def update_record(db: Session, record_id: int, record_dto: RecordUpdate) -> bool: + record = get_record_by_id(db, record_id) + if not record: + return False + + for field, value in record_dto.model_dump(exclude_unset=True).items(): + setattr(record, field, value) + db.commit() + db.refresh(record) + + return True + + +def delete_record(db: Session, record_id: int) -> bool: + record = get_record_by_id(db, record_id) + if not record: + return False + + db.delete(record) + db.commit() + + return True diff --git a/service/user_service.py b/service/user_service.py new file mode 100644 index 0000000..191813f --- /dev/null +++ b/service/user_service.py @@ -0,0 +1,43 @@ +from sqlalchemy import select +from sqlalchemy.orm import Session + +from models.family import Users +from schemas.user import UserCreate, UserUpdate + + +def get_user_by_id(db: Session, user_id: int) -> Users | None: + result = db.execute(select(Users).where(Users.id == user_id)) + return result.scalar_one_or_none() + + +def create_user(db: Session, user: UserCreate) -> Users: + user = Users(**user.model_dump()) + db.add(user) + db.commit() + db.refresh(user) + + return user + + +def update_user(db: Session, user_id: int, obj_in: UserUpdate) -> Users: + user = get_user_by_id(db, user_id) + if not user: + return False + + for field, value in obj_in.model_dump(exclude_unset=True).items(): + setattr(user, field, value) + db.commit() + db.refresh(user) + + return user + + +def delete_user(db: Session, user_id: int) -> bool: + user = get_user_by_id(db, user_id) + if not user: + return False + + db.delete(user) + db.commit() + + return True diff --git a/test_main.http b/test_main.http new file mode 100644 index 0000000..a2d81a9 --- /dev/null +++ b/test_main.http @@ -0,0 +1,11 @@ +# Test your FastAPI endpoints + +GET http://127.0.0.1:8000/ +Accept: application/json + +### + +GET http://127.0.0.1:8000/hello/User +Accept: application/json + +### diff --git a/utils/common.py b/utils/common.py new file mode 100644 index 0000000..bb599fe --- /dev/null +++ b/utils/common.py @@ -0,0 +1,13 @@ +import re + + +def camel_to_snake(name: str) -> str: + """将驼峰命名转换为蛇形命名(CamelCase → snake_case)""" + name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', name) + return re.sub('([a-z0-9])([A-Z])', r'\1_\2', name).lower() + + +def snake_to_camel(name: str) -> str: + """将蛇形命名转换为驼峰命名(snake_case → CamelCase)""" + components = name.split('_') + return ''.join(x.title() for x in components)