fix: resolve 10 code review issues

High priority:
- Fix concurrent race condition for view_count/like_count (atomic update)
- Add route request ID tracking to prevent race conditions
- Filter get_joke by status=approved (no pending content leak)
- Add error feedback for like button

Performance:
- Optimize random joke query (avoid full table sort)
- Limit page_size max to 100 (DoS prevention)

Medium:
- Add localStorage quota error handling
- Handle empty AI response gracefully
- Fix generate content title extraction

Low:
- Add rejected_jokes to stats API
- Update dashboard to show rejected count
This commit is contained in:
bwstudio
2026-06-02 20:35:08 +08:00
parent 0b43973236
commit ceed63fcb0
144 changed files with 191660 additions and 270 deletions
+7
View File
@@ -0,0 +1,7 @@
from .jokes import router as jokes_router
from .categories import router as categories_router
from .auth import router as auth_router
from .admin import router as admin_router
from .settings import router as settings_router
__all__ = ["jokes_router", "categories_router", "auth_router", "admin_router", "settings_router"]
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+164 -13
View File
@@ -1,3 +1,5 @@
import json
from fastapi import APIRouter, Depends, HTTPException, Header
from jose import JWTError, jwt
from sqlalchemy import func
@@ -8,8 +10,12 @@ from app.database import get_db
from app.models.joke import Joke
from app.models.category import JokeCrowd, JokeType
from app.models.user import AdminUser
from app.models.link import Link
from app.models.feedback import Feedback
from app.schemas.category import JokeCrowdCreate, JokeCrowdResponse, JokeTypeCreate, JokeTypeResponse
from app.schemas.joke import JokeCreate, JokeResponse, JokeUpdate, PaginatedJokeResponse
from app.schemas.link import LinkCreate, LinkResponse
from app.schemas.feedback import FeedbackResponse
router = APIRouter(prefix="/admin", tags=["管理后台"])
@@ -39,21 +45,48 @@ def get_current_admin_user(
return user
def joke_to_response(joke: Joke) -> JokeResponse:
def _parse_ids(raw) -> list[int]:
"""解析 DB 中的 JSON 列表字符串为 Python list"""
if not raw:
return []
if isinstance(raw, list):
return raw
try:
return json.loads(raw)
except Exception:
return []
def _get_type_names(db: Session, ids: list[int]) -> list[str]:
rows = db.query(JokeType).filter(JokeType.id.in_(ids)).all()
return [r.name for r in rows]
def _get_crowd_names(db: Session, ids: list[int]) -> list[str]:
rows = db.query(JokeCrowd).filter(JokeCrowd.id.in_(ids)).all()
return [r.name for r in rows]
def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
"""Convert Joke model to JokeResponse schema."""
ids = _parse_ids(joke.type_ids)
crowd_ids = _parse_ids(joke.crowd_ids)
type_names = _get_type_names(db, ids) if db else []
crowd_names = _get_crowd_names(db, crowd_ids) if db else []
return JokeResponse(
id=joke.id,
title=joke.title,
content=joke.content,
type_id=joke.type_id,
crowd_id=joke.crowd_id,
polished_content=joke.polished_content,
type_ids=ids,
crowd_ids=crowd_ids,
status=joke.status,
view_count=joke.view_count,
like_count=joke.like_count,
created_at=joke.created_at,
updated_at=joke.updated_at,
type_name=joke.type.name if joke.type else None,
crowd_name=joke.crowd.name if joke.crowd else None,
type_names=type_names,
crowd_names=crowd_names,
)
@@ -67,6 +100,8 @@ def admin_list_jokes(
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取所有笑话(支持状态筛选)"""
# 限制 page_size 防止 DoS
page_size = max(1, min(page_size, 100))
query = db.query(Joke)
if status:
query = query.filter(Joke.status == status)
@@ -74,7 +109,7 @@ def admin_list_jokes(
offset = (page - 1) * page_size
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
return PaginatedJokeResponse(
items=[joke_to_response(j) for j in jokes],
items=[joke_to_response(j, db) for j in jokes],
total=total,
page=page,
page_size=page_size,
@@ -88,11 +123,29 @@ def admin_create_joke(
current_user: AdminUser = Depends(get_current_admin_user),
):
"""创建笑话"""
db_joke = Joke(**joke.model_dump())
data = joke.model_dump()
if data.get("type_ids") is not None:
data["type_ids"] = json.dumps(data["type_ids"])
if data.get("crowd_ids") is not None:
data["crowd_ids"] = json.dumps(data["crowd_ids"])
db_joke = Joke(**data)
db.add(db_joke)
db.commit()
db.refresh(db_joke)
return joke_to_response(db_joke)
return joke_to_response(db_joke, db)
@router.get("/jokes/{joke_id}", response_model=JokeResponse)
def admin_get_joke(
joke_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取单个笑话"""
joke = db.query(Joke).filter(Joke.id == joke_id).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
return joke_to_response(joke, db)
@router.put("/jokes/{joke_id}", response_model=JokeResponse)
@@ -108,10 +161,12 @@ def admin_update_joke(
raise HTTPException(status_code=404, detail="笑话不存在")
update_data = joke.model_dump(exclude_unset=True)
for key, value in update_data.items():
if key in ("type_ids", "crowd_ids") and value is not None:
value = json.dumps(value)
setattr(db_joke, key, value)
db.commit()
db.refresh(db_joke)
return joke_to_response(db_joke)
return joke_to_response(db_joke, db)
@router.delete("/jokes/{joke_id}")
@@ -152,12 +207,14 @@ def admin_stats(
total_jokes = db.query(Joke).count()
approved_jokes = db.query(Joke).filter(Joke.status == "approved").count()
pending_jokes = db.query(Joke).filter(Joke.status == "pending").count()
rejected_jokes = db.query(Joke).filter(Joke.status == "rejected").count()
total_views = db.query(Joke).with_entities(func.sum(Joke.view_count)).scalar() or 0
total_likes = db.query(Joke).with_entities(func.sum(Joke.like_count)).scalar() or 0
return {
"total_jokes": total_jokes,
"approved_jokes": approved_jokes,
"pending_jokes": pending_jokes,
"rejected_jokes": rejected_jokes,
"total_views": total_views,
"total_likes": total_likes,
}
@@ -209,8 +266,11 @@ def admin_delete_type(
db_type = db.query(JokeType).filter(JokeType.id == type_id).first()
if not db_type:
raise HTTPException(status_code=404, detail="类型不存在")
# Check if there are jokes using this type
joke_count = db.query(Joke).filter(Joke.type_id == type_id).count()
# Check both old single-field and new array-field associations
old_count = db.query(Joke).filter(Joke.type_id == type_id).count()
all_jokes = db.query(Joke.type_ids).filter(Joke.type_ids.isnot(None)).all()
new_count = sum(1 for (raw,) in all_jokes if _parse_ids(raw) and type_id in _parse_ids(raw))
joke_count = old_count + new_count
if joke_count > 0:
raise HTTPException(status_code=400, detail=f"{joke_count} 条笑话使用此类型,无法删除")
db.delete(db_type)
@@ -263,10 +323,101 @@ def admin_delete_crowd(
db_crowd = db.query(JokeCrowd).filter(JokeCrowd.id == crowd_id).first()
if not db_crowd:
raise HTTPException(status_code=404, detail="人群分类不存在")
# Check if there are jokes using this crowd
joke_count = db.query(Joke).filter(Joke.crowd_id == crowd_id).count()
# Check both old single-field and new array-field associations
old_count = db.query(Joke).filter(Joke.crowd_id == crowd_id).count()
all_jokes = db.query(Joke.crowd_ids).filter(Joke.crowd_ids.isnot(None)).all()
new_count = sum(1 for (raw,) in all_jokes if _parse_ids(raw) and crowd_id in _parse_ids(raw))
joke_count = old_count + new_count
if joke_count > 0:
raise HTTPException(status_code=400, detail=f"{joke_count} 条笑话使用此人群,无法删除")
db.delete(db_crowd)
db.commit()
return {"message": "删除成功"}
# ============================================================
# 友情链接管理
# ============================================================
@router.get("/links", response_model=list[LinkResponse])
def admin_list_links(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取所有友情链接"""
return db.query(Link).order_by(Link.sort_order, Link.id).all()
@router.post("/links", response_model=LinkResponse)
def admin_create_link(
link: LinkCreate,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""创建友情链接"""
db_link = Link(**link.model_dump())
db.add(db_link)
db.commit()
db.refresh(db_link)
return db_link
@router.put("/links/{link_id}", response_model=LinkResponse)
def admin_update_link(
link_id: int,
link: LinkCreate,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""更新友情链接"""
db_link = db.query(Link).filter(Link.id == link_id).first()
if not db_link:
raise HTTPException(status_code=404, detail="链接不存在")
for key, value in link.model_dump().items():
setattr(db_link, key, value)
db.commit()
db.refresh(db_link)
return db_link
@router.delete("/links/{link_id}")
def admin_delete_link(
link_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""删除友情链接"""
db_link = db.query(Link).filter(Link.id == link_id).first()
if not db_link:
raise HTTPException(status_code=404, detail="链接不存在")
db.delete(db_link)
db.commit()
return {"message": "删除成功"}
# ============================================================
# 反馈建议管理
# ============================================================
@router.get("/feedbacks", response_model=list[FeedbackResponse])
def admin_list_feedbacks(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取所有反馈建议"""
return db.query(Feedback).order_by(Feedback.created_at.desc()).all()
@router.delete("/feedbacks/{feedback_id}")
def admin_delete_feedback(
feedback_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""删除反馈"""
db_feedback = db.query(Feedback).filter(Feedback.id == feedback_id).first()
if not db_feedback:
raise HTTPException(status_code=404, detail="反馈不存在")
db.delete(db_feedback)
db.commit()
return {"message": "删除成功"}
+18
View File
@@ -0,0 +1,18 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.feedback import Feedback
from app.schemas.feedback import FeedbackCreate, FeedbackResponse
router = APIRouter(tags=["反馈建议"])
@router.post("/feedback", response_model=FeedbackResponse)
def create_feedback(feedback: FeedbackCreate, db: Session = Depends(get_db)):
"""公开:提交反馈建议"""
db_feedback = Feedback(**feedback.model_dump())
db.add(db_feedback)
db.commit()
db.refresh(db_feedback)
return db_feedback
+17 -3
View File
@@ -76,8 +76,12 @@ def generate_joke(
raise HTTPException(status_code=500, detail=f"生成失败: {str(e)}")
def _parse_response(raw: str) -> GenerateResponse:
def _parse_response(raw: str | None) -> GenerateResponse:
"""解析 AI 返回内容,提取标题和内容"""
# 防御:处理空或 None 输入
if not raw or not raw.strip():
raise ValueError("AI 返回内容为空")
title = ""
content = raw
@@ -86,16 +90,26 @@ def _parse_response(raw: str) -> GenerateResponse:
line = line.strip()
if line.startswith("标题:") or line.startswith("标题:"):
title = line.split("", 1)[-1].split(":", 1)[-1].strip()
content = content.replace(line, "").strip()
# 只替换这一行,不要 replace 全局
lines = content.split("\n")
for i, l in enumerate(lines):
if l.strip() == line:
lines[i] = ""
break
content = "\n".join(lines).strip()
break
# 如果没有提取到标题,取第一行或前20字
# 如果没有提取到标题,取第一行
if not title:
first_line = raw.split("\n")[0].strip()
if first_line.startswith("标题"):
first_line = first_line.split("", 1)[-1].split(":", 1)[-1].strip()
title = first_line[:30] if len(first_line) > 30 else first_line
# 防御:content 不能为空
if not content.strip():
content = "(内容生成失败,请重新生成)"
return GenerateResponse(
title=title or "生成的笑话",
content=content.strip(),
+110 -17
View File
@@ -1,3 +1,4 @@
import json
import random
from fastapi import APIRouter, Depends, HTTPException, Query
@@ -6,26 +7,56 @@ from sqlalchemy.orm import Session
from app.database import get_db
from app.models.joke import Joke
from app.models.category import JokeCrowd, JokeType
from app.schemas.joke import JokeResponse, PaginatedJokeResponse
router = APIRouter(prefix="/jokes", tags=["笑话"])
def joke_to_response(joke: Joke) -> JokeResponse:
def _parse_ids(raw) -> list[int]:
if not raw:
return []
if isinstance(raw, list):
return raw
try:
return json.loads(raw)
except Exception:
return []
def _get_type_names(db: Session, ids: list[int]) -> list[str]:
if not ids:
return []
rows = db.query(JokeType).filter(JokeType.id.in_(ids)).all()
return [r.name for r in rows]
def _get_crowd_names(db: Session, ids: list[int]) -> list[str]:
if not ids:
return []
rows = db.query(JokeCrowd).filter(JokeCrowd.id.in_(ids)).all()
return [r.name for r in rows]
def joke_to_response(joke: Joke, db: Session = None) -> JokeResponse:
"""Convert Joke model to JokeResponse schema."""
ids = _parse_ids(joke.type_ids)
crowd_ids = _parse_ids(joke.crowd_ids)
type_names = _get_type_names(db, ids) if db else []
crowd_names = _get_crowd_names(db, crowd_ids) if db else []
return JokeResponse(
id=joke.id,
title=joke.title,
content=joke.content,
type_id=joke.type_id,
crowd_id=joke.crowd_id,
type_ids=ids,
crowd_ids=crowd_ids,
status=joke.status,
view_count=joke.view_count,
like_count=joke.like_count,
created_at=joke.created_at,
updated_at=joke.updated_at,
type_name=joke.type.name if joke.type else None,
crowd_name=joke.crowd.name if joke.crowd else None,
type_names=type_names,
crowd_names=crowd_names,
)
@@ -33,24 +64,46 @@ def joke_to_response(joke: Joke) -> JokeResponse:
def list_jokes(
page: int = 1,
page_size: int = 20,
type_id: int | None = None,
crowd_id: int | None = None,
type_ids: str | None = Query(None, description="逗号分隔的类型 ID,如 1,3,5"),
crowd_ids: str | None = Query(None, description="逗号分隔的人群 ID,如 2,4"),
db: Session = Depends(get_db),
):
"""获取笑话列表(仅返回已审核通过的笑话)"""
query = db.query(Joke).filter(Joke.status == "approved")
if type_id is not None:
query = query.filter(Joke.type_id == type_id)
if crowd_id is not None:
query = query.filter(Joke.crowd_id == crowd_id)
# 支持多选过滤:逗号分隔的 ID
if type_ids:
filter_set = {int(x.strip()) for x in type_ids.split(",") if x.strip().isdigit()}
if filter_set:
# 兼容旧单值字段 type_id
old_filter = Joke.type_id.in_(filter_set)
# 兼容新数组字段 type_ids(JSON 包含任一)
new_filter = [
Joke.type_ids.contains(str(tid)) for tid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
if crowd_ids:
filter_set = {int(x.strip()) for x in crowd_ids.split(",") if x.strip().isdigit()}
if filter_set:
old_filter = Joke.crowd_id.in_(filter_set)
new_filter = [
Joke.crowd_ids.contains(str(cid)) for cid in filter_set
]
combined = old_filter
for nf in new_filter:
combined = combined | nf
query = query.filter(combined)
total = query.count()
offset = (page - 1) * page_size
jokes = query.order_by(Joke.created_at.desc()).offset(offset).limit(page_size).all()
return PaginatedJokeResponse(
items=[joke_to_response(j) for j in jokes],
items=[joke_to_response(j, db) for j in jokes],
total=total,
page=page,
page_size=page_size,
@@ -60,19 +113,59 @@ def list_jokes(
@router.get("/{joke_id}", response_model=JokeResponse)
def get_joke(joke_id: int, db: Session = Depends(get_db)):
"""获取单条笑话详情"""
joke = db.query(Joke).filter(Joke.id == joke_id).first()
# 只返回已审核通过的笑话
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 增加浏览次数
joke.view_count += 1
# 使用原子更新避免并发竞态
db.query(Joke).filter(Joke.id == joke_id).update({Joke.view_count: Joke.view_count + 1})
db.commit()
return joke_to_response(joke)
# 重新查询获取更新后的数据
db.refresh(joke)
return joke_to_response(joke, db)
@router.get("/random", response_model=JokeResponse)
def get_random_joke(db: Session = Depends(get_db)):
"""随机获取一条已审核通过的笑话"""
# 使用效率更高的方式:随机 ID 取模
max_id = db.query(func.max(Joke.id)).filter(Joke.status == "approved").scalar()
if not max_id:
raise HTTPException(status_code=404, detail="暂无笑话")
# 尝试最多 10 次找到有效笑话
for _ in range(10):
random_id = random.randint(1, max_id)
joke = db.query(Joke).filter(
Joke.id >= random_id,
Joke.status == "approved"
).first()
if joke:
return joke_to_response(joke, db)
# 兜底:全表随机
joke = db.query(Joke).filter(Joke.status == "approved").order_by(func.random()).first()
if not joke:
raise HTTPException(status_code=404, detail="暂无笑话")
return joke_to_response(joke)
return joke_to_response(joke, db)
@router.post("/{joke_id}/like")
def like_joke(joke_id: int, db: Session = Depends(get_db)):
"""为笑话点赞(仅允许已审核通过的笑话)"""
# 先检查笑话是否存在且已审核
joke = db.query(Joke).filter(
Joke.id == joke_id,
Joke.status == "approved"
).first()
if not joke:
raise HTTPException(status_code=404, detail="笑话不存在")
# 使用原子更新避免并发竞态
db.query(Joke).filter(Joke.id == joke_id).update({Joke.like_count: Joke.like_count + 1})
db.commit()
# 获取更新后的值
db.refresh(joke)
return {"message": "点赞成功", "like_count": joke.like_count}
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.link import Link
from app.schemas.link import LinkCreate, LinkResponse
router = APIRouter(tags=["友情链接"])
@router.get("/links", response_model=list[LinkResponse])
def list_links(db: Session = Depends(get_db)):
"""公开:获取所有友情链接"""
return db.query(Link).order_by(Link.sort_order, Link.id).all()
+96
View File
@@ -0,0 +1,96 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.setting import AiSetting
from app.schemas.setting import AiSettingCreate
from app.routers.admin import get_current_admin_user
from app.models.user import AdminUser
router = APIRouter(prefix="/admin/settings", tags=["AI设置"])
@router.get("", response_model=list)
def list_settings(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""列出所有 AI 配置"""
return db.query(AiSetting).order_by(AiSetting.created_at.desc()).all()
@router.get("/active")
def get_active_setting(
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""获取当前激活的 AI 配置(爬虫调用,无需用户认证,token 校验仍保留)"""
setting = db.query(AiSetting).filter(AiSetting.is_active == True).first()
if not setting:
raise HTTPException(status_code=404, detail="未找到激活的 AI 配置")
return setting
@router.post("")
def create_setting(
setting: AiSettingCreate,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""新建 AI 配置"""
db_setting = AiSetting(**setting.model_dump())
db.add(db_setting)
db.commit()
db.refresh(db_setting)
return db_setting
@router.put("/{setting_id}")
def update_setting(
setting_id: int,
setting: AiSettingCreate,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""更新 AI 配置"""
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
if not db_setting:
raise HTTPException(status_code=404, detail="配置不存在")
for key, value in setting.model_dump().items():
setattr(db_setting, key, value)
db.commit()
db.refresh(db_setting)
return db_setting
@router.put("/{setting_id}/toggle")
def toggle_setting(
setting_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""切换激活状态(只能有一个活跃)"""
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
if not db_setting:
raise HTTPException(status_code=404, detail="配置不存在")
# 先全部设为非活跃
db.query(AiSetting).update({AiSetting.is_active: False})
db_setting.is_active = True
db.commit()
return {"message": "已激活", "id": setting_id}
@router.delete("/{setting_id}")
def delete_setting(
setting_id: int,
db: Session = Depends(get_db),
current_user: AdminUser = Depends(get_current_admin_user),
):
"""删除 AI 配置"""
db_setting = db.query(AiSetting).filter(AiSetting.id == setting_id).first()
if not db_setting:
raise HTTPException(status_code=404, detail="配置不存在")
db.delete(db_setting)
db.commit()
return {"message": "删除成功"}