Compare commits

...

10 Commits

18 changed files with 858 additions and 299 deletions

View File

@@ -1,7 +1,9 @@
from fastapi import FastAPI from fastapi import FastAPI
from .blog import router as blog_router from .blog import router as blog_router
from .blog_stats import router as blog_stats_router
from .session import router as session_router from .session import router as session_router
def register_routers(app: FastAPI): def register_routers(app: FastAPI):
app.include_router(blog_router, prefix="") app.include_router(blog_router, prefix="")
app.include_router(blog_stats_router, prefix="")
app.include_router(session_router, prefix="") app.include_router(session_router, prefix="")

View File

@@ -1,6 +1,6 @@
from typing import List from typing import List
from fastapi import APIRouter, Depends, Query, Request from fastapi import APIRouter, Depends, Query, Request, UploadFile, File, Path, Body
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from config.auth import verify_token from config.auth import verify_token
@@ -10,7 +10,7 @@ from schemas.blog import BlogResponse, BlogQuery, BlogCategoryResponse, BlogStat
from schemas.blog_elastic import BlogSearch from schemas.blog_elastic import BlogSearch
from schemas.pagination import PageResult from schemas.pagination import PageResult
from config.database import get_db from config.database import get_db
from service import blog_service, blog_elastic_service from service import blog_service, blog_elastic_service, file_service
router = APIRouter( router = APIRouter(
prefix="/blog", prefix="/blog",
@@ -19,61 +19,111 @@ router = APIRouter(
) )
@router.post("/file/upload", summary="上传博客图片", response_model=str)
async def sync_all_blog(md5: str = Query(..., description="文件MD5值"),
file: UploadFile = File(..., description="要上传的图片文件")):
return await file_service.upload_file(md5, file)
@router.get("/sync", summary="同步博客到elastic", response_model=bool) @router.get("/sync", summary="同步博客到elastic", response_model=bool)
def sync_all_blog(db: Session = Depends(get_db)): def sync_all_blog(db: Session = Depends(get_db)):
return blog_elastic_service.sync_all_blog(db) return blog_elastic_service.sync_all_blog(db)
@router.get("/search", summary="搜索elastic", response_model=List[BlogSearch]) @router.get("/search", summary="搜索elastic", response_model=List[BlogSearch])
def search_blog(keyword: str): def search_blog(keyword: str = Query(..., description="搜索关键词")):
return blog_elastic_service.search_blog(keyword) return blog_elastic_service.search_blog(keyword)
@router.get("/page", summary="分页查询博客", response_model=PageResult[BlogResponse]) @router.get("/page", summary="分页查询博客", response_model=PageResult[BlogResponse])
def query_blog_by_page(current_page: int = Query(1, ge=1, alias="currentPage", description="当前页码"), def query_blog_by_page(
page_size: int = Query(10, ge=1, le=100, alias="pageSize", description="每页数量"), current_page: int = Query(1, ge=1, alias="currentPage", description="当前页码从1开始"),
db: Session = Depends(get_db)): page_size: int = Query(10, ge=1, le=100, alias="pageSize", description="每页显示数量1-100之间"),
db: Session = Depends(get_db)
):
return blog_service.query_blog_by_page(db, current_page, page_size) return blog_service.query_blog_by_page(db, current_page, page_size)
@router.get("/condition", summary="条件查询博客", response_model=List[BlogResponse]) @router.get("/condition", summary="条件查询博客", response_model=List[BlogResponse])
def query_blog_by_condition(query: BlogQuery = Depends(), def query_blog_by_condition(query: BlogQuery = Depends(), db: Session = Depends(get_db)):
db: Session = Depends(get_db)):
return blog_service.query_blog_by_condition(db, query) return blog_service.query_blog_by_condition(db, query)
@router.get("/condition/page", summary="条件分页查询博客", response_model=PageResult[BlogResponse])
def query_blog_by_condition_page(
query: BlogQuery = Depends(),
current_page: int = Query(1, ge=1, alias="currentPage", description="当前页码从1开始"),
page_size: int = Query(10, ge=1, le=100, alias="pageSize", description="每页显示数量1-100之间"),
db: Session = Depends(get_db)
):
return blog_service.query_blog_by_condition_page(db, query, current_page, page_size)
@router.get("/unapproved", summary="查询待发布博客", response_model=List[BlogResponse])
def query_unapproved_blog(db: Session = Depends(get_db), _=Depends(verify_token)):
return blog_service.query_unapproved_blog(db)
@router.get("/{blog_id}/content", summary="查询博客内容", response_model=BlogResponse) @router.get("/{blog_id}/content", summary="查询博客内容", response_model=BlogResponse)
def query_blog_by_id(blog_id: int, db: Session = Depends(get_db)): def query_blog_by_id(blog_id: int = Path(..., ge=1, description="博客ID"), db: Session = Depends(get_db)):
return blog_service.query_blog_by_id(db, blog_id) return blog_service.query_blog_by_id(db, blog_id)
@router.get("/all/page", summary="分页查询博客", response_model=PageResult[BlogResponse])
def query_blog_by_page(
current_page: int = Query(1, ge=1, alias="currentPage", description="当前页码从1开始"),
page_size: int = Query(10, ge=1, le=100, alias="pageSize", description="每页显示数量1-100之间"),
db: Session = Depends(get_db),
_=Depends(verify_token)
):
return blog_service.query_blog_by_page(db, current_page, page_size, True)
@router.post("", summary="新增博客内容", response_model=bool) @router.post("", summary="新增博客内容", response_model=bool)
def add_blog(blog: BlogCreate, db: Session = Depends(get_db), _=Depends(verify_token)): def add_blog(
blog: BlogCreate = Body(..., description="博客创建数据"),
db: Session = Depends(get_db),
_=Depends(verify_token)
):
return blog_service.add_blog(db, blog) return blog_service.add_blog(db, blog)
@router.put("/{blog_id}", summary="更新博客内容", response_model=bool) @router.put("/{blog_id}", summary="更新博客内容", response_model=bool)
def update_blog(blog_id: int, blog: BlogUpdate, db: Session = Depends(get_db), _=Depends(verify_token)): def update_blog(
blog_id: int = Path(..., ge=1, description="博客ID"),
blog: BlogUpdate = Body(..., description="博客更新数据"),
db: Session = Depends(get_db),
_=Depends(verify_token)
):
return blog_service.update_blog(db, blog_id, blog) return blog_service.update_blog(db, blog_id, blog)
@router.delete("/{blog_id}", summary="删除博客内容", response_model=bool) @router.delete("/{blog_id}", summary="删除博客内容", response_model=bool)
def delete_blog(blog_id: int, db: Session = Depends(get_db), _=Depends(verify_token)): def delete_blog(
blog_id: int = Path(..., ge=1, description="博客ID"),
db: Session = Depends(get_db),
_=Depends(verify_token)
):
return blog_service.delete_blog(db, blog_id) return blog_service.delete_blog(db, blog_id)
@router.get("/{blog_id}/adjacent", summary="查询相邻博客", response_model=List[BlogAdjacentResponse]) @router.get("/{blog_id}/adjacent", summary="查询相邻博客", response_model=List[BlogAdjacentResponse])
def query_blog_latest(blog_id: int, db: Session = Depends(get_db)): def query_blog_latest(blog_id: int = Path(..., ge=1, description="博客ID"), db: Session = Depends(get_db)):
return blog_service.query_blog_adjacent(db, blog_id) return blog_service.query_blog_adjacent(db, blog_id)
@router.put("/{blog_id}/comment", summary="新增博客评论", response_model=bool) @router.put("/{blog_id}/comment", summary="新增博客评论", response_model=bool)
def add_blog_comment(blog_id: int, blog_comment: BlogCommentCreate, request: Request, db: Session = Depends(get_db)): def add_blog_comment(
request: Request,
blog_id: int = Path(..., ge=1, description="博客ID"),
blog_comment: BlogCommentCreate = Body(..., description="博客评论数据"),
db: Session = Depends(get_db)
):
return blog_service.add_blog_comment(db, request, blog_id, blog_comment) return blog_service.add_blog_comment(db, request, blog_id, blog_comment)
@router.get("/{blog_id}/comment", summary="查询博客评论", response_model=list[BlogCommentResponse]) @router.get("/{blog_id}/comment", summary="查询博客评论", response_model=list[BlogCommentResponse])
def query_blog_comment(blog_id: int, db: Session = Depends(get_db)): def query_blog_comment(blog_id: int = Path(..., ge=1, description="博客ID"), db: Session = Depends(get_db)):
return blog_service.query_blog_comment(db, blog_id) return blog_service.query_blog_comment(db, blog_id)
@@ -88,9 +138,11 @@ def query_blog_stats(db: Session = Depends(get_db)):
@router.get("/visit", summary="查询博客访问信息", response_model=PageResult[BlogVisitResponse]) @router.get("/visit", summary="查询博客访问信息", response_model=PageResult[BlogVisitResponse])
def query_blog_visit(current_page: int = Query(1, ge=1, alias="currentPage", description="当前页码"), def query_blog_visit(
page_size: int = Query(10, ge=1, le=100, alias="pageSize", description="每页数量"), current_page: int = Query(1, ge=1, alias="currentPage", description="当前页码从1开始"),
db: Session = Depends(get_db)): page_size: int = Query(10, ge=1, le=100, alias="pageSize", description="每页显示数量1-100之间"),
db: Session = Depends(get_db)
):
return blog_service.query_blog_visit(db, current_page, page_size) return blog_service.query_blog_visit(db, current_page, page_size)

51
api/blog_stats.py Normal file
View File

@@ -0,0 +1,51 @@
from typing import List
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from schemas.blog_stats import BlogOverview, BlogChartStats
from config.database import get_db
from service import blog_stats_service
router = APIRouter(
prefix="/stats",
tags=["博客统计"],
responses={404: {"description": "Not found"}}
)
@router.get("/overview", summary="统计数据", response_model=BlogOverview)
def query_blog_overview(db: Session = Depends(get_db)):
return blog_stats_service.query_blog_overview(db)
@router.get("/category", summary="博客分类统计", response_model=List[BlogChartStats])
def query_blog_category(db: Session = Depends(get_db)):
return blog_stats_service.query_blog_category(db)
@router.get("/approved/monthly", summary="每月博客发布统计", response_model=List[BlogChartStats])
def query_blog_monthly(year: int, db: Session = Depends(get_db)):
return blog_stats_service.query_blog_approved_monthly(db, year)
@router.get("/visit/monthly", summary="每月博客访问统计", response_model=List[BlogChartStats])
def query_blog_visit_monthly(year: int, db: Session = Depends(get_db)):
return blog_stats_service.query_blog_visit_monthly(db, year)
@router.get("/visit/rank", summary="博客访问数量排行", response_model=List[BlogChartStats])
def query_blog_visit_rank(db: Session = Depends(get_db)):
return blog_stats_service.query_blog_visit_rank(db)
@router.get("/read/rank", summary="博客阅读时长排行", response_model=List[BlogChartStats])
def query_blog_read_rank(db: Session = Depends(get_db)):
return blog_stats_service.query_blog_read_rank(db)
@router.get("/comment/rank", summary="博客评论数量排行", response_model=List[BlogChartStats])
def query_blog_comment_rank(db: Session = Depends(get_db)):
return blog_stats_service.query_blog_comment_rank(db)
# 分类统计饼图

157
blog.sql Normal file
View File

@@ -0,0 +1,157 @@
-- MySQL dump 10.13 Distrib 8.0.26, for Win64 (x86_64)
--
-- Host: localhost Database: blog
-- ------------------------------------------------------
-- Server version 8.0.27
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!50503 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `blog`
--
DROP TABLE IF EXISTS `blog`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `blog` (
`id` bigint NOT NULL,
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '博客标题',
`top_value` int NOT NULL COMMENT '置顶值 越大越靠前',
`is_great` tinyint NOT NULL COMMENT '是否是精品 0否1是',
`category_id` bigint NOT NULL COMMENT '博客类别',
`summary` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '博客内容概要',
`content_id` bigint NOT NULL COMMENT '博客内容',
`word_count` int NOT NULL COMMENT '字数统计',
`read_duration` decimal(10,2) NOT NULL COMMENT '阅读时长',
`is_approved` tinyint NOT NULL COMMENT '是否发布',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`create_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建人',
`update_time` datetime DEFAULT NULL COMMENT '发布时间',
`update_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '发布人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='博客记录';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `blog_category`
--
DROP TABLE IF EXISTS `blog_category`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `blog_category` (
`id` bigint NOT NULL,
`name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '类别名称',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`create_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`update_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='博客类别';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `blog_comment`
--
DROP TABLE IF EXISTS `blog_comment`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `blog_comment` (
`id` bigint NOT NULL COMMENT 'id',
`blog_id` bigint NOT NULL COMMENT '博客id',
`parent_id` bigint NOT NULL COMMENT '父评论id',
`name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '评论人昵称',
`website` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '评论人网站',
`ip_address` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '评论人ip',
`user_agent` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '评论人浏览器信息',
`content` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '评论内容',
`is_approved` tinyint NOT NULL COMMENT '是否通过',
`create_time` datetime DEFAULT NULL COMMENT '评论时间',
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='博客评论';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `blog_content`
--
DROP TABLE IF EXISTS `blog_content`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `blog_content` (
`id` bigint NOT NULL,
`content` mediumblob NOT NULL COMMENT '博客内容',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci ROW_FORMAT=DYNAMIC COMMENT='博客内容';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `blog_like`
--
DROP TABLE IF EXISTS `blog_like`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `blog_like` (
`id` bigint NOT NULL COMMENT 'id',
`user_id` bigint DEFAULT NULL COMMENT '点赞人',
`type` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '点赞类型(博客、评论)',
`target_id` bigint DEFAULT NULL COMMENT '点赞目标的id',
`status` tinyint DEFAULT NULL COMMENT '点赞状态 0取消点赞/1点赞',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`create_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`update_by` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='博客点赞';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `blog_visit`
--
DROP TABLE IF EXISTS `blog_visit`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `blog_visit` (
`id` bigint NOT NULL,
`ip` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT 'ip地址',
`os` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '操作系统',
`browser` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '浏览器',
`uri` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '路径',
`blog_id` bigint DEFAULT NULL COMMENT '博客id',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`create_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '创建人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
`update_by` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '更新人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci ROW_FORMAT=DYNAMIC COMMENT='博客访问记录';
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Dumping routines for database 'blog'
--
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-10-23 19:56:43

View File

@@ -10,8 +10,11 @@ ES_PASSWORD = "19940822Cxx"
BLOG_INDEX = "blog" BLOG_INDEX = "blog"
# 注意Python的elasticsearch的版本要和服务器的一致 # 注意Python的elasticsearch的版本要和服务器的一致
es = Elasticsearch(ES_HOST, http_auth=(ES_USER, ES_PASSWORD)) try:
es = Elasticsearch(ES_HOST, http_auth=(ES_USER, ES_PASSWORD))
if not es.indices.exists(index=BLOG_INDEX): if not es.indices.exists(index=BLOG_INDEX):
es.indices.create(index=BLOG_INDEX, body=BLOG_MAPPING) es.indices.create(index=BLOG_INDEX, body=BLOG_MAPPING)
logger.info(f"Elastic索引 {BLOG_INDEX} 创建成功") logger.info(f"Elastic索引 {BLOG_INDEX} 创建成功")
except Exception as e:
logger.error(f"elastic异常: {str(e)}")

View File

@@ -3,18 +3,13 @@ import sys
from fluent import sender from fluent import sender
from loguru import logger from loguru import logger
from pathlib import Path
from config.setting import settings from config.setting import settings
FLUENTD_HOST = 'host.docker.internal' FLUENTD_HOST = settings.FLUENTD_HOST
FLUENTD_PORT = 24224 FLUENTD_PORT = 24224
TOPIC_TAG = 'blog-service' TOPIC_TAG = 'blog-service'
# 日志目录
LOG_DIR = Path(__file__).parent.parent / "logs"
LOG_DIR.mkdir(exist_ok=True)
# 日志级别 # 日志级别
LOG_LEVEL = settings.LOG_LEVEL.upper() LOG_LEVEL = settings.LOG_LEVEL.upper()
@@ -80,39 +75,16 @@ logger.add(
diagnose=True, # 显示详细异常信息 diagnose=True, # 显示详细异常信息
) )
logger.add( if settings.ENVIRONMENT == 'docker':
logger.add(
log_to_fluent, log_to_fluent,
level=LOG_LEVEL, # 处理 INFO 及以上级别 level=LOG_LEVEL, # 处理 INFO 及以上级别
format="{message}", # 原始消息(实际使用结构化数据) format="{message}", # 原始消息(实际使用结构化数据)
backtrace=True, # 启用堆栈回溯 backtrace=True, # 启用堆栈回溯
diagnose=True # 显示诊断信息 diagnose=True # 显示诊断信息
) )
# # 添加文件处理器 - 常规日志 atexit.register(fluent_sender.close)
# logger.add(
# sink=LOG_DIR / "app.log",
# level="INFO",
# format=FILE_FORMAT,
# rotation="10 MB", # 日志文件大小达到10MB时自动分割
# retention="7 days", # 保留7天的日志
# compression="zip", # 归档时压缩为zip
# enqueue=True, # 异步写入
# serialize=False, # 不使用JSON格式
# )
#
# # 添加文件处理器 - 错误日志
# logger.add(
# sink=LOG_DIR / "error.log",
# level="ERROR",
# format=FILE_FORMAT,
# rotation="10 MB",
# retention="30 days",
# compression="zip",
# enqueue=True,
# serialize=False,
# )
atexit.register(fluent_sender.close)
# 导出配置好的logger # 导出配置好的logger
__all__ = ["logger"] __all__ = ["logger"]

15
config/rustfs.py Normal file
View File

@@ -0,0 +1,15 @@
import boto3
from botocore.client import Config
from config.setting import settings
access_key = 'jRxroVX8PUuSOia71qE4'
secret_access = 'RZE82VATN1Gqj9xy3d5OzFvHoDBgwJ4PSf7IKuei'
s3 = boto3.client('s3',
endpoint_url=f'http://{settings.RUSTFS_HOST}:{settings.RUSTFS_PORT}',
aws_access_key_id=access_key,
aws_secret_access_key=secret_access,
config=Config(signature_version='s3v4'),
region_name='cn-east-1'
)

View File

@@ -7,6 +7,9 @@ class Settings(BaseSettings):
DB_HOST: str DB_HOST: str
DB_PASSWORD: str DB_PASSWORD: str
ES_HOST: str ES_HOST: str
FLUENTD_HOST: str
RUSTFS_HOST:str
RUSTFS_PORT: str
class Config: class Config:
env_file = ".env" # 指定.env文件路径 env_file = ".env" # 指定.env文件路径

View File

@@ -35,13 +35,13 @@ async def global_exception_handler(request: Request, call_next):
except SQLAlchemyError as e: except SQLAlchemyError as e:
# 记录数据库异常 # 记录数据库异常
logger.critical(f"数据库异常: {str(e)}", exc_info=True) logger.critical(f"数据库异常: {str(e)}")
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"message": "数据库操作失败", "details": str(e)}) content={"message": "数据库操作失败", "details": str(e)})
except Exception as e: except Exception as e:
# 记录未知异常(带堆栈信息) # 记录未知异常(带堆栈信息)
logger.critical(f"未知异常: {str(e)}", exc_info=True) logger.critical(f"未知异常: {str(e)}")
return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, return JSONResponse(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={"code": 500, "message": "服务器内部错误", "details": str(e)}) content={"code": 500, "message": "服务器内部错误", "details": str(e)})

View File

@@ -1,99 +1,129 @@
from sqlalchemy import ( from sqlalchemy import (BigInteger, Boolean, Column, Integer, DECIMAL, String, LargeBinary)
BigInteger,
Boolean,
Column,
Integer,
DECIMAL,
SMALLINT,
String,
LargeBinary
)
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from models.base import AuditBase, IdBase from models.base import AuditBase, IdBase
class Blog(AuditBase): class Blog(AuditBase):
title = Column(String(255), nullable=True, comment="博客标题") title = Column(String(255), nullable=False, comment="博客标题")
top_value = Column(Integer, nullable=True, comment="置顶值 越大越靠前") top_value = Column(Integer, nullable=False, default=0, comment="置顶值 越大越靠前")
is_great = Column(Boolean, nullable=True, comment="是否是精品 0否1是") is_great = Column(Boolean, nullable=False, default=False, comment="是否是精品")
category_id = Column(BigInteger, nullable=True, comment="博客类别") category_id = Column(BigInteger, nullable=False, comment="博客类别")
summary = Column(String(255), nullable=True, comment="博客内容概要") summary = Column(String(255), nullable=False, comment="博客内容概要")
content_id = Column(BigInteger, nullable=True, comment="博客内容") content_id = Column(BigInteger, nullable=False, comment="博客内容")
word_count = Column(Integer, nullable=True, comment="字数统计") word_count = Column(Integer, nullable=False, default=0, comment="字数统计")
read_duration = Column(DECIMAL(10, 2), nullable=True, comment="阅读时长") read_duration = Column(DECIMAL(10, 2), nullable=False, default=0.00, comment="阅读时长")
is_approved = Column(Boolean, nullable=False, default=False, comment="是否发布")
# 分类关系(多对一)
category = relationship( category = relationship(
"BlogCategory", "BlogCategory",
# 与BlogCategory的blogs属性建立双向关系
back_populates="blogs", back_populates="blogs",
# 只级联保存和合并操作,不级联删除(删除博客不应删除分类
cascade="save-update, merge",
# 明确指定连接条件
# 如果数据库设置了外键可以省略
primaryjoin="foreign(Blog.category_id) == BlogCategory.id" primaryjoin="foreign(Blog.category_id) == BlogCategory.id"
) )
# 内容关系(一对一)
content = relationship( content = relationship(
"BlogContent", "BlogContent",
# 与BlogContent的blog属性建立双向关系
back_populates="blog", back_populates="blog",
# 完全级联操作:保存、合并、刷新、删除等所有操作都会级联
cascade="all, delete-orphan",
# 设置为False表示一对一关系返回单个对象而不是列表
uselist=False,
# 确保内容只有一个父博客与delete-orphan配合使用
single_parent=True,
# 明确指定连接条件
primaryjoin="foreign(Blog.content_id) == BlogContent.id" primaryjoin="foreign(Blog.content_id) == BlogContent.id"
) )
# 访问记录(一对多)
visits = relationship( visits = relationship(
"BlogVisit", "BlogVisit",
# 与BlogVisit的blog属性建立双向关系
back_populates="blog", back_populates="blog",
# 完全级联操作:博客删除时自动删除所有访问记录
cascade="all, delete-orphan",
# 明确指定连接条件
primaryjoin="Blog.id == foreign(BlogVisit.blog_id)" primaryjoin="Blog.id == foreign(BlogVisit.blog_id)"
) )
# 4. 评论(一对多)
comments = relationship( comments = relationship(
"BlogComment", "BlogComment",
# 与BlogComment的blog属性建立双向关系
back_populates="blog", back_populates="blog",
# 完全级联操作:博客删除时自动删除所有评论
cascade="all, delete-orphan",
# 明确指定连接条件
primaryjoin="Blog.id == foreign(BlogComment.blog_id)" primaryjoin="Blog.id == foreign(BlogComment.blog_id)"
) )
class BlogCategory(AuditBase): class BlogCategory(AuditBase):
name = Column(String(45), nullable=True, comment="类别名称") name = Column(String(45), nullable=False, comment="类别名称")
blogs = relationship( blogs = relationship(
"Blog", "Blog",
# 与Blog的category属性建立双向关系
back_populates="category", back_populates="category",
# 完全级联操作:分类删除时自动删除所有关联的博客
# 警告:这会级联删除分类下的所有博客,包括博客的内容、访问记录和评论
cascade="all, delete-orphan",
# 明确指定连接条件
primaryjoin="BlogCategory.id == foreign(Blog.category_id)" primaryjoin="BlogCategory.id == foreign(Blog.category_id)"
) )
class BlogContent(IdBase): class BlogContent(IdBase):
content = Column(LargeBinary, comment="博客内容") content = Column(LargeBinary, nullable=False, comment="博客内容")
blog = relationship( blog = relationship(
"Blog", "Blog",
# 与Blog的content属性建立双向关系
back_populates="content", back_populates="content",
# 设置为False表示一对一关系
uselist=False,
# 明确指定连接条件
primaryjoin="BlogContent.id == foreign(Blog.content_id)" primaryjoin="BlogContent.id == foreign(Blog.content_id)"
) )
class BlogVisit(AuditBase): class BlogVisit(AuditBase):
ip = Column(String(255), nullable=True, comment="ip地址") ip = Column(String(255), nullable=False, comment="IP地址")
os = Column(String(255), nullable=True, comment="操作系统") os = Column(String(255), nullable=False, comment="操作系统")
browser = Column(String(255), nullable=True, comment="浏览器") browser = Column(String(255), nullable=False, comment="浏览器")
uri = Column(String(255), nullable=True, comment="路径") uri = Column(String(255), nullable=False, comment="路径")
blog_id = Column(BigInteger, nullable=True, comment="博客id") blog_id = Column(BigInteger, nullable=True, comment="博客ID")
blog = relationship( blog = relationship(
"Blog", "Blog",
# 与Blog的visits属性建立双向关系
back_populates="visits", back_populates="visits",
# 明确指定连接条件
primaryjoin="foreign(BlogVisit.blog_id) == Blog.id" primaryjoin="foreign(BlogVisit.blog_id) == Blog.id"
) )
class BlogComment(AuditBase): class BlogComment(AuditBase):
blog_id = Column(BigInteger, comment="博客id") blog_id = Column(BigInteger, nullable=False, comment="博客ID")
parent_id = Column(BigInteger, comment="父评论id") parent_id = Column(BigInteger, nullable=False, comment="父评论ID")
name = Column(String(255), comment="评论人昵称") name = Column(String(255), nullable=False, comment="评论人昵称")
website = Column(String(255), nullable=True, comment="评论人网站") website = Column(String(255), nullable=True, comment="评论人网站")
ip_address = Column(String(45), nullable=True, comment="评论人ip") ip_address = Column(String(45), nullable=False, comment="评论人IP")
user_agent = Column(String(255), nullable=True, comment="评论人浏览器信息") user_agent = Column(String(255), nullable=False, comment="评论人浏览器信息")
content = Column(String(512), comment="评论内容") content = Column(String(255), nullable=False, comment="评论内容")
is_approved = Column(SMALLINT, comment="是否通过") is_approved = Column(Boolean, nullable=False, default=False, comment="是否通过")
blog = relationship( blog = relationship(
"Blog", "Blog",
# 与Blog的comments属性建立双向关系
back_populates="comments", back_populates="comments",
# 明确指定连接条件
primaryjoin="foreign(BlogComment.blog_id) == Blog.id" primaryjoin="foreign(BlogComment.blog_id) == Blog.id"
) )

View File

@@ -1 +1,15 @@
fastapi~=0.115.12 fastapi~=0.115.12
sqlalchemy~=2.0.41
pydantic~=2.11.4
PyJWT~=2.10.1
passlib~=1.7.4
loguru~=0.7.3
pydantic-settings~=2.9.1
pymysql~=1.1.1
python-multipart~=0.0.20
uvicorn~=0.23.0
elasticsearch~=8.12.0
fluent-logger~=0.10.0
boto3~=1.40.59
botocore~=1.40.59
# pip download -r requirements.txt -d ./packages --only-binary=:all: --platform manylinux2014_x86_64 -i https://pypi.tuna.tsinghua.edu.cn/simple

View File

@@ -1,24 +1,42 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, field_validator, ConfigDict
from datetime import datetime from datetime import datetime
class BlogQuery(BaseModel): class BlogQuery(BaseModel):
# 类别 """博客查询参数"""
category: Optional[str] = None category: Optional[str] = Field(None, description="分类名称")
# 标题 title: Optional[str] = Field(None, description="标题关键词")
title: Optional[str] = None year: Optional[int] = Field(None, description="发布年份", ge=2000, le=datetime.now().year)
# 年份
year: Optional[int] = None @field_validator('year')
def validate_year(cls, v):
if v is not None and v > datetime.now().year:
raise ValueError('年份不能超过当前年份')
return v
class BlogBase(BaseModel): class BlogBase(BaseModel):
title: str """博客基础模型"""
topValue: int title: str = Field(..., min_length=1, max_length=255, description="博客标题")
isGreat: bool top_value: int = Field(default=0, ge=0, description="置顶值,越大越靠前", alias="topValue")
category: str is_great: bool = Field(default=False, description="是否是精品", alias="isGreat")
content: Optional[str] = None category: str = Field(..., min_length=1, max_length=45, description="分类名称")
content: Optional[str] = Field(None, description="博客内容")
is_approved: bool = Field(default=False, description="是否已发布", alias="isApproved")
@field_validator('title')
def title_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('标题不能为空')
return v.strip()
@field_validator('category')
def category_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('分类不能为空')
return v.strip()
class BlogCreate(BlogBase): class BlogCreate(BlogBase):
@@ -30,83 +48,105 @@ class BlogUpdate(BlogBase):
class BlogResponse(BlogBase): class BlogResponse(BlogBase):
id: Optional[int] = None id: int = Field(..., description="博客ID")
summary: Optional[str] = None summary: Optional[str] = Field(None, description="内容摘要")
wordCount: Optional[int] = None word_count: Optional[int] = Field(None, description="字数统计", alias="wordCount")
readDuration: Optional[float] = None read_duration: Optional[float] = Field(None, description="阅读时长", alias="readDuration")
visitCount: Optional[int] = None visit_count: Optional[int] = Field(0, description="访问次数", alias="visitCount")
createTime: Optional[datetime] = None create_time: datetime = Field(..., description="创建时间", alias="createTime")
updateTime: Optional[datetime] = None update_time: datetime = Field(..., description="更新时间", alias="updateTime")
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True,
json_encoders = { populate_by_name=True,
# 自定义 datetime 类型的序列化格式 json_encoders={
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S') datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
} }
)
class BlogCategoryResponse(BaseModel): class BlogCategoryResponse(BaseModel):
name: str name: str = Field(..., description="分类名称")
count: int count: int = Field(..., description="博客数量")
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True,
populate_by_name=True
)
class BlogStatsResponse(BaseModel): class BlogStatsResponse(BaseModel):
blogCount: int blog_count: int = Field(..., description="博客总数", alias="blogCount")
categoryCount: int category_count: int = Field(..., description="分类总数", alias="categoryCount")
wordCount: int word_count: int = Field(..., description="总字数", alias="wordCount")
class BlogVisitResponse(BaseModel): class BlogVisitResponse(BaseModel):
ip: str ip: str = Field(..., description="IP地址")
os: str os: str = Field(..., description="操作系统")
browser: str browser: str = Field(..., description="浏览器")
uri: str uri: str = Field(..., description="访问路径")
title: Optional[str] = None title: str = Field(None, description="博客标题")
visitTime: datetime visit_time: datetime = Field(..., description="访问时间", alias="visitTime")
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True,
json_encoders = { populate_by_name=True,
json_encoders={
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S') datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
} }
)
class BlogLatestResponse(BaseModel): class BlogLatestResponse(BaseModel):
id: int id: int = Field(..., description="博客ID")
title: str title: str = Field(..., description="博客标题")
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True,
populate_by_name=True
)
class BlogAdjacentResponse(BaseModel): class BlogAdjacentResponse(BaseModel):
id: int id: int = Field(..., description="博客ID")
title: str title: str = Field(..., description="博客标题")
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True,
populate_by_name=True
)
class BlogCommentCreate(BaseModel): class BlogCommentCreate(BaseModel):
parentId: int parent_id: int = Field(default=0, ge=0, description="父评论ID0表示顶级评论", alias="parentId")
name: str name: str = Field(..., min_length=1, max_length=50, description="评论人昵称")
website: Optional[str] = None website: Optional[str] = Field(None, description="评论人网站")
content: str content: str = Field(..., min_length=1, max_length=1000, description="评论内容")
@field_validator('name')
def name_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('昵称不能为空')
return v.strip()
@field_validator('content')
def content_not_empty(cls, v):
if not v or not v.strip():
raise ValueError('评论内容不能为空')
return v.strip()
class BlogCommentResponse(BlogCommentCreate): class BlogCommentResponse(BlogCommentCreate):
id: int id: int = Field(..., description="评论ID")
ipAddress: str ip_address: str = Field(..., description="IP地址", alias="ipAddress")
userAgent: str user_agent: str = Field(..., description="浏览器信息", alias="userAgent")
createTime: datetime create_time: datetime = Field(..., description="创建时间", alias="createTime")
class Config: model_config = ConfigDict(
from_attributes = True from_attributes=True,
json_encoders = { populate_by_name=True,
# 自定义 datetime 类型的序列化格式 json_encoders={
datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S') datetime: lambda dt: dt.strftime('%Y-%m-%d %H:%M:%S')
} }
)

View File

@@ -9,6 +9,7 @@ class BlogElastic(BaseModel):
title: str title: str
content: str content: str
category: str category: str
isApproved: int
createTime: Optional[Union[datetime, str]] = None createTime: Optional[Union[datetime, str]] = None
updateTime: Optional[Union[datetime, str]] = None updateTime: Optional[Union[datetime, str]] = None

16
schemas/blog_stats.py Normal file
View File

@@ -0,0 +1,16 @@
from pydantic import BaseModel, ConfigDict
class BlogOverview(BaseModel):
blogCount: int
categoryCount: int
wordCount: int
greatCount: int
visitCount: int
class BlogChartStats(BaseModel):
name: str
value: int
model_config = ConfigDict(from_attributes=True)

View File

@@ -17,11 +17,12 @@ def sync_all_blog(db: Session) -> bool:
if settings.ENVIRONMENT == 'dev': if settings.ENVIRONMENT == 'dev':
return True return True
query = select( stmt = select(
Blog.id, Blog.id,
Blog.title, Blog.title,
BlogCategory.name.label("category"), BlogCategory.name.label("category"),
BlogContent.content.label("content"), BlogContent.content.label("content"),
Blog.is_approved.label("isApproved"),
Blog.create_time.label("createTime"), Blog.create_time.label("createTime"),
Blog.update_time.label("updateTime"), Blog.update_time.label("updateTime"),
).select_from(Blog).outerjoin( ).select_from(Blog).outerjoin(
@@ -29,7 +30,7 @@ def sync_all_blog(db: Session) -> bool:
).outerjoin( ).outerjoin(
BlogContent, Blog.content_id == BlogContent.id BlogContent, Blog.content_id == BlogContent.id
) )
results = db.execute(query).fetchall() results = db.execute(stmt).fetchall()
actions = [ actions = [
{ {
@@ -40,6 +41,7 @@ def sync_all_blog(db: Session) -> bool:
"title": blog.title, "title": blog.title,
"category": blog.category, "category": blog.category,
"content": blog.content.decode("utf-8"), "content": blog.content.decode("utf-8"),
"isApproved": blog.isApproved,
"createTime": blog.createTime.strftime("%Y-%m-%d %H:%M:%S"), "createTime": blog.createTime.strftime("%Y-%m-%d %H:%M:%S"),
"updateTime": blog.updateTime.strftime("%Y-%m-%d %H:%M:%S") "updateTime": blog.updateTime.strftime("%Y-%m-%d %H:%M:%S")
} }
@@ -106,11 +108,24 @@ def search_blog(keyword: str) -> List[BlogSearch]:
# 构造查询请求 # 构造查询请求
body = { body = {
"query": { "query": {
"bool": {
"must": [
{
"multi_match": { "multi_match": {
"query": keyword, "query": keyword,
"fields": ["title^3", "content"], "fields": ["title^3", "content"],
"type": "phrase" "type": "phrase"
} }
}
],
"filter": [
{
"term": {
"isApproved": 1
}
}
]
}
}, },
"highlight": { "highlight": {
"pre_tags": ["<mark>"], "pre_tags": ["<mark>"],

View File

@@ -1,7 +1,7 @@
from typing import List from typing import List
from fastapi import Request from fastapi import Request
from sqlalchemy import select, func, desc, and_, asc, delete from sqlalchemy import select, func, desc, and_, asc
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from models.blog import Blog, BlogCategory, BlogVisit, BlogContent, BlogComment from models.blog import Blog, BlogCategory, BlogVisit, BlogContent, BlogComment
@@ -13,53 +13,62 @@ from schemas.pagination import PageResult
from schemas.paginate_query import paginate_query from schemas.paginate_query import paginate_query
from middleware.exceptions import AppException from middleware.exceptions import AppException
from service.blog_elastic_service import add_blog_elastic from service.blog_elastic_service import add_blog_elastic
from service.blog_stats_service import query_blog_overview
from utils.blog_utils import get_blog_summary, get_word_count, get_read_duration from utils.blog_utils import get_blog_summary, get_word_count, get_read_duration
def query_blog_by_page(db: Session, current_page: int = 1, page_size: int = 10) -> PageResult[BlogResponse]: def query_blog_by_page(db: Session, current_page: int = 1, page_size: int = 10,
query = select( is_all=False) -> PageResult[BlogResponse]:
stmt = (select(
Blog.id, Blog.id,
Blog.title, Blog.title,
Blog.top_value.label("topValue"), Blog.top_value,
Blog.is_great.label("isGreat"), Blog.is_great,
BlogCategory.name.label("category"), BlogCategory.name.label("category"),
Blog.summary, Blog.summary,
Blog.word_count.label("wordCount"), Blog.word_count,
Blog.read_duration.label("readDuration"), Blog.read_duration,
func.count(BlogVisit.id).label("visitCount"), func.count(BlogVisit.id).label("visitCount"),
Blog.create_time.label("createTime"), Blog.is_approved,
Blog.update_time.label("updateTime") Blog.create_time,
).select_from(Blog).outerjoin( Blog.update_time
BlogCategory, Blog.category_id == BlogCategory.id ).outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
).outerjoin( .outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id)
BlogVisit, Blog.id == BlogVisit.blog_id .group_by(Blog.id, BlogCategory.name)
).group_by( .order_by(desc(Blog.is_great), desc(Blog.top_value), desc(Blog.update_time)))
Blog.id, BlogCategory.name
).order_by(
desc(Blog.top_value), desc(Blog.update_time)
)
return paginate_query(db, query, current_page, page_size) if not is_all:
stmt = stmt.where(Blog.is_approved == 1)
return paginate_query(db, stmt, current_page, page_size)
def query_blog_by_condition(db: Session, blog_query: BlogQuery) -> List[BlogResponse]: def query_blog_by_condition(db: Session, blog_query: BlogQuery) -> List[BlogResponse]:
query = select( stmt = get_query_blog_by_condition_stmt(blog_query)
results = db.execute(stmt).fetchall()
return [BlogResponse.model_validate(result) for result in results]
def get_query_blog_by_condition_stmt(blog_query: BlogQuery):
stmt = (select(
Blog.id, Blog.id,
Blog.title, Blog.title,
Blog.top_value.label("topValue"), Blog.top_value,
Blog.is_great.label("isGreat"), Blog.is_great,
BlogCategory.name.label("category"), BlogCategory.name.label("category"),
Blog.summary, Blog.summary,
BlogContent.content.label("content"), BlogContent.content,
Blog.word_count.label("wordCount"), Blog.word_count,
Blog.read_duration.label("readDuration"), Blog.read_duration,
Blog.create_time.label("createTime"), func.count(BlogVisit.id).label("visitCount"),
Blog.update_time.label("updateTime") Blog.is_approved,
).select_from(Blog).outerjoin( Blog.create_time,
BlogCategory, Blog.category_id == BlogCategory.id Blog.update_time
).outerjoin( ).where(Blog.is_approved == 1)
BlogContent, Blog.content_id == BlogContent.id .outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
) .outerjoin(BlogContent, Blog.content_id == BlogContent.id)
.outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id)
.group_by(Blog.id))
conditions = [] conditions = []
@@ -73,42 +82,71 @@ def query_blog_by_condition(db: Session, blog_query: BlogQuery) -> List[BlogResp
conditions.append(func.extract('year', Blog.create_time) == blog_query.year) conditions.append(func.extract('year', Blog.create_time) == blog_query.year)
if conditions: if conditions:
query = query.where(and_(*conditions)) stmt = stmt.where(and_(*conditions))
query = query.order_by(desc(Blog.update_time)) stmt = stmt.order_by(desc(Blog.is_great), desc(Blog.update_time))
results = db.execute(query).fetchall() return stmt
return [BlogResponse.from_orm(result) for result in results]
def query_blog_by_condition_page(db: Session, blog_query: BlogQuery,
current_page: int = 1, page_size: int = 10) -> PageResult[BlogResponse]:
stmt = get_query_blog_by_condition_stmt(blog_query)
return paginate_query(db, stmt, current_page, page_size)
def query_unapproved_blog(db: Session) -> List[BlogResponse]:
stmt = ((select(
Blog.id,
Blog.title,
Blog.top_value,
Blog.is_great,
BlogCategory.name.label("category"),
Blog.summary,
BlogContent.content,
Blog.word_count,
Blog.read_duration,
func.count(BlogVisit.id),
Blog.is_approved,
Blog.create_time,
Blog.update_time
).where(Blog.is_approved == 0)
.outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
.outerjoin(BlogContent, Blog.content_id == BlogContent.id))
.outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id)
.group_by(Blog.id)
.order_by(desc(Blog.update_time)))
results = db.execute(stmt).fetchall()
return [BlogResponse.model_validate(result) for result in results]
def query_blog_by_id(db: Session, blog_id: int) -> BlogResponse: def query_blog_by_id(db: Session, blog_id: int) -> BlogResponse:
check_blog_exist(db, blog_id) check_blog_exist(db, blog_id)
query = db.query( stmt = (select(
Blog.id.label("id"), Blog.id,
Blog.title.label("title"), Blog.title,
Blog.top_value.label("topValue"), Blog.top_value,
Blog.is_great.label("isGreat"), Blog.is_great,
BlogCategory.name.label("category"), BlogCategory.name.label("category"),
Blog.summary.label("summary"), Blog.summary,
BlogContent.content.label("content"), BlogContent.content,
Blog.word_count.label("wordCount"), Blog.word_count,
Blog.read_duration.label("readDuration"), Blog.read_duration,
func.count(BlogVisit.id).label("visitCount"), func.count(BlogVisit.id).label("visitCount"),
Blog.create_time.label("createTime"), Blog.is_approved,
Blog.update_time.label("updateTime") Blog.create_time,
).select_from(Blog).outerjoin( Blog.update_time
BlogCategory, Blog.category_id == BlogCategory.id ).where(Blog.id == blog_id)
).outerjoin( .outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
BlogContent, Blog.content_id == BlogContent.id .outerjoin(BlogContent, Blog.content_id == BlogContent.id)
).outerjoin( .outerjoin(BlogVisit, Blog.id == BlogVisit.blog_id))
BlogVisit, Blog.id == BlogVisit.blog_id
).filter(Blog.id == blog_id)
blog = query.first() blog = db.execute(stmt).first()
return BlogResponse.from_orm(blog) return BlogResponse.model_validate(blog)
def add_blog(db: Session, blog: BlogCreate) -> bool: def add_blog(db: Session, blog: BlogCreate) -> bool:
@@ -116,15 +154,18 @@ def add_blog(db: Session, blog: BlogCreate) -> bool:
db_blog = Blog( db_blog = Blog(
title=blog.title, title=blog.title,
top_value=blog.topValue, top_value=blog.top_value,
is_great=blog.isGreat, is_great=blog.is_great,
category_id=add_blog_category(db, blog.category), category_id=add_blog_category(db, blog.category),
content_id=add_blog_content(db, blog.content),
summary=get_blog_summary(blog.content), summary=get_blog_summary(blog.content),
word_count=word_count, word_count=word_count,
read_duration=get_read_duration(word_count) read_duration=get_read_duration(word_count),
is_approved=blog.is_approved
) )
# 级联新增
db_blog.content = BlogContent(content=blog.content.encode('utf-8'))
db.add(db_blog) db.add(db_blog)
db.commit() db.commit()
db.refresh(db_blog) db.refresh(db_blog)
@@ -133,7 +174,8 @@ def add_blog(db: Session, blog: BlogCreate) -> bool:
id=db_blog.id, id=db_blog.id,
title=db_blog.title, title=db_blog.title,
content=blog.content, content=blog.content,
category=blog.category category=blog.category,
isApproved=blog.isApproved
) )
add_blog_elastic(blog_elastic) add_blog_elastic(blog_elastic)
@@ -154,30 +196,22 @@ def add_blog_category(db: Session, category: str) -> int:
return category_id return category_id
def add_blog_content(db: Session, content: str) -> int:
db_blog_content = BlogContent(content=content.encode('utf-8'))
db.add(db_blog_content)
db.commit()
return db_blog_content.id
def update_blog(db: Session, blog_id: int, blog: BlogUpdate) -> bool: def update_blog(db: Session, blog_id: int, blog: BlogUpdate) -> bool:
db_blog = check_blog_exist(db, blog_id) db_blog = check_blog_exist(db, blog_id)
update_data = blog.model_dump(exclude_unset=True) db_blog.title = blog.title
db_blog.top_value = blog.topValue
update_blog_content(db, db_blog.content_id, blog.content) db_blog.is_great = blog.isGreat
db_blog.title = update_data["title"]
db_blog.top_value = update_data["topValue"]
db_blog.is_great = update_data["isGreat"]
db_blog.category_id = update_blog_category(db, blog.category) db_blog.category_id = update_blog_category(db, blog.category)
# 级联更新
db_blog.content = BlogContent(content=blog.content.encode('utf-8'))
word_count = get_word_count(blog.content) word_count = get_word_count(blog.content)
db_blog.summary = get_blog_summary(blog.content), db_blog.summary = get_blog_summary(blog.content),
db_blog.word_count = word_count db_blog.word_count = word_count
db_blog.read_duration = get_read_duration(word_count) db_blog.read_duration = get_read_duration(word_count)
db_blog.is_approved = blog.isApproved
db.commit() db.commit()
db.refresh(db_blog) db.refresh(db_blog)
@@ -194,85 +228,56 @@ def update_blog_category(db: Session, category: str) -> int:
return db_blog_category.id return db_blog_category.id
def update_blog_content(db: Session, blog_content_id: int, blog_content: str) -> bool:
db_blog_content = check_blog_content_exist(db, blog_content_id)
db_blog_content.content = blog_content.encode('utf-8')
db.commit()
db.refresh(db_blog_content)
return True
def delete_blog(db: Session, blog_id: int) -> bool: def delete_blog(db: Session, blog_id: int) -> bool:
db_blog = check_blog_exist(db, blog_id) db_blog = check_blog_exist(db, blog_id)
delete_blog_content(db, db_blog.content_id) # 级联删除
db.execute(delete(Blog).where(Blog.id == blog_id)) db.delete(db_blog)
db.commit()
return True
def delete_blog_content(db: Session, blog_content_id: int) -> bool:
check_blog_content_exist(db, blog_content_id)
db.execute(delete(BlogContent).where(BlogContent.id == blog_content_id))
db.commit() db.commit()
return True return True
def query_blog_category(db: Session) -> List[BlogCategoryResponse]: def query_blog_category(db: Session) -> List[BlogCategoryResponse]:
query = select( query = (select(BlogCategory.name, func.count(Blog.id).label("count"))
BlogCategory.name, .where(Blog.is_approved == 1)
func.count(Blog.id).label("count") .outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
).select_from(Blog).outerjoin( .group_by(BlogCategory.name))
BlogCategory, Blog.category_id == BlogCategory.id
).group_by(
BlogCategory.name
)
results = db.execute(query).fetchall() results = db.execute(query).fetchall()
return [BlogCategoryResponse.from_orm(result) for result in results] return [BlogCategoryResponse.model_validate(result) for result in results]
def query_blog_stats(db: Session) -> BlogStatsResponse: def query_blog_stats(db: Session) -> BlogStatsResponse:
blog_stats = query_blog_overview(db)
return BlogStatsResponse( return BlogStatsResponse(
blogCount=db.query(Blog).count(), blogCount=blog_stats.blogCount,
categoryCount=db.query(BlogCategory).count(), categoryCount=blog_stats.categoryCount,
wordCount=db.query(func.sum(Blog.word_count)).scalar() wordCount=blog_stats.wordCount
) )
def query_blog_latest(db: Session) -> List[BlogLatestResponse]: def query_blog_latest(db: Session) -> List[BlogLatestResponse]:
query = select( stmt = select(Blog.id, Blog.title).where(Blog.is_approved == 1).order_by(desc(Blog.update_time)).limit(5)
Blog.id, results = db.execute(stmt).fetchall()
Blog.title
).select_from(Blog).order_by(desc(Blog.update_time)).limit(5)
results = db.execute(query).fetchall() return [BlogLatestResponse.model_validate(result) for result in results]
return [BlogLatestResponse.from_orm(result) for result in results]
def query_blog_adjacent(db: Session, blog_id: int) -> List[BlogAdjacentResponse]: def query_blog_adjacent(db: Session, blog_id: int) -> List[BlogAdjacentResponse]:
check_blog_exist(db, blog_id) check_blog_exist(db, blog_id)
prev_result = db.execute( prev_stmt = (select(Blog.id, Blog.title)
select(Blog.id, Blog.title) .where(Blog.id < blog_id, Blog.is_approved == 1)
.where(Blog.id < blog_id) .order_by(desc(Blog.id)))
.order_by(desc(Blog.id)) prev_result = db.execute(prev_stmt).first()
.limit(1)
).first()
next_result = db.execute( next_stmt = (select(Blog.id, Blog.title)
select(Blog.id, Blog.title) .where(Blog.id > blog_id, Blog.is_approved == 1)
.where(Blog.id > blog_id) .order_by(asc(Blog.id)))
.order_by(asc(Blog.id)) next_result = db.execute(next_stmt).first()
.limit(1)
).first()
return [ return [
BlogAdjacentResponse( BlogAdjacentResponse(
@@ -308,21 +313,21 @@ def query_blog_visit(db: Session, current_page: int = 1, page_size: int = 10) ->
def query_blog_comment(db: Session, blog_id: int) -> List[BlogCommentResponse]: def query_blog_comment(db: Session, blog_id: int) -> List[BlogCommentResponse]:
query = select( stmt = select(
BlogComment.id, BlogComment.id,
BlogComment.blog_id.label("blogId"), BlogComment.blog_id,
BlogComment.parent_id.label("parentId"), BlogComment.parent_id,
BlogComment.name, BlogComment.name,
BlogComment.website, BlogComment.website,
BlogComment.ip_address.label("ipAddress"), BlogComment.ip_address,
BlogComment.user_agent.label("userAgent"), BlogComment.user_agent,
BlogComment.content, BlogComment.content,
BlogComment.is_approved.label("isApproved"), BlogComment.is_approved,
BlogComment.create_time.label("createTime") BlogComment.create_time
).select_from(BlogComment).where(BlogComment.blog_id == blog_id) ).where(BlogComment.blog_id == blog_id, BlogComment.is_approved == 1).order_by(desc(BlogComment.create_time))
results = db.execute(query).fetchall() results = db.execute(stmt).fetchall()
return [BlogCommentResponse.from_orm(result) for result in results] return [BlogCommentResponse.model_validate(result) for result in results]
def add_blog_comment(db: Session, request: Request, blog_id: int, blog_comment: BlogCommentCreate) -> bool: def add_blog_comment(db: Session, request: Request, blog_id: int, blog_comment: BlogCommentCreate) -> bool:
@@ -346,7 +351,7 @@ def add_blog_comment(db: Session, request: Request, blog_id: int, blog_comment:
content=blog_comment.content, content=blog_comment.content,
ip_address=request.client.host, ip_address=request.client.host,
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
is_approved=1 is_approved=True
) )
db.add(db_comment) db.add(db_comment)

View File

@@ -0,0 +1,143 @@
from datetime import datetime
from typing import List
from sqlalchemy import select, func, distinct, extract
from sqlalchemy.orm import Session
from models.blog import Blog, BlogVisit, BlogComment, BlogCategory
from schemas.blog_stats import BlogOverview, BlogChartStats
def query_blog_overview(db: Session) -> BlogOverview:
blog_count_stmt = select(func.count(Blog.id)).where(Blog.is_approved == 1)
blog_count = db.execute(blog_count_stmt).scalar() or 0
word_count_stmt = select(func.sum(Blog.word_count)).where(Blog.is_approved == 1)
word_count = db.execute(word_count_stmt).scalar() or 0
category_count_stmt = select(func.count(distinct(Blog.category_id))).where(Blog.is_approved == 1)
category_count = db.execute(category_count_stmt).scalar() or 0
great_count_stmt = select(func.count(Blog.id)).where(Blog.is_approved == 1, Blog.is_great == 1)
great_count = db.execute(great_count_stmt).scalar() or 0
visit_count_stmt = select(func.count(BlogVisit.id))
visit_count = db.execute(visit_count_stmt).scalar() or 0
return BlogOverview(
blogCount=blog_count,
categoryCount=category_count,
wordCount=word_count,
greatCount=great_count,
visitCount=visit_count
)
def query_blog_category(db: Session) -> List[BlogChartStats]:
query = (
select(
BlogCategory.name.label("name"),
func.count(Blog.id).label("value")
)
.where(Blog.is_approved == 1)
.outerjoin(BlogCategory, Blog.category_id == BlogCategory.id)
.group_by(BlogCategory.name))
results = db.execute(query).fetchall()
return [BlogChartStats.model_validate(result) for result in results]
def query_blog_approved_monthly(db: Session, year: int = None) -> List[BlogChartStats]:
current_year = datetime.now().year
target_year = year if year else current_year
# extract():返回日期/时间的单独部分
stmt = (
select(
func.concat(extract('month', Blog.create_time), '').label('name'),
func.count(Blog.id).label('value')
)
.where(
Blog.is_approved == 1,
extract('year', Blog.create_time) == target_year
)
.group_by('name')
)
results = db.execute(stmt).fetchall()
return [BlogChartStats.model_validate(result) for result in results]
def query_blog_visit_monthly(db: Session, year: int = None) -> List[BlogChartStats]:
current_year = datetime.now().year
target_year = year if year else current_year
stmt = (
select(
func.concat(extract('month', BlogVisit.create_time), '').label('name'),
func.count(BlogVisit.id).label('value')
)
.where(
extract('year', BlogVisit.create_time) == target_year
)
.group_by('name')
)
results = db.execute(stmt).fetchall()
return [BlogChartStats.model_validate(result) for result in results]
def query_blog_visit_rank(db: Session, limit: int = 5) -> List[BlogChartStats]:
stmt = (
select(
Blog.title.label('name'),
func.count(BlogVisit.id).label('value')
)
.join(BlogVisit, Blog.id == BlogVisit.blog_id)
.where(Blog.is_approved == 1)
.group_by(Blog.title)
.order_by(func.count(BlogVisit.id).desc())
.limit(limit)
)
results = db.execute(stmt).fetchall()
return [BlogChartStats.model_validate(result) for result in results]
def query_blog_read_rank(db: Session, limit: int = 5) -> List[BlogChartStats]:
stmt = (
select(
Blog.title.label('name'),
func.round(func.sum(Blog.read_duration)).label('value')
)
.where(Blog.is_approved == 1)
.group_by(Blog.title)
.order_by(func.sum(Blog.read_duration).desc())
.limit(limit)
)
results = db.execute(stmt).fetchall()
return [BlogChartStats.model_validate(result) for result in results]
def query_blog_comment_rank(db: Session, limit: int = 5) -> List[BlogChartStats]:
stmt = (
select(
Blog.title.label('name'),
func.count(BlogComment.id).label('value')
)
.join(BlogComment, Blog.id == BlogComment.blog_id)
.where(Blog.is_approved == 1)
.group_by(Blog.title)
.order_by(func.count(BlogComment.id).desc())
.limit(limit)
)
results = db.execute(stmt).fetchall()
return [BlogChartStats.model_validate(result) for result in results]

40
service/file_service.py Normal file
View File

@@ -0,0 +1,40 @@
from fastapi import UploadFile, File, HTTPException
from config.rustfs import s3
from config.setting import settings
ALLOWED_IMAGE_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
"image/svg+xml",
]
BUCKET = 'blog'
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)"
)
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}"