chore: initialize GSREA website project

This commit is contained in:
zlwu committed 2026-10-30 11:05:55 +08:00
commit d3c5461de5
1842 files changed
+246140

No files matched your search

View File
Whitespace-only changes.
+90
View File
@@ -0,0 +1,90 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.about import (
admin_about_page,
admin_experts,
create_expert,
delete_expert,
save_about_page,
update_expert,
)
from app.models.home import admin_column_articles
router = APIRouter(dependencies=[Depends(require_staff)])
class AboutPageWrite(BaseModel):
published: bool = False
bodyHtml: str = ""
attachmentFileIds: list[int] = Field(default_factory=list)
class ExpertWrite(BaseModel):
name: str
bodyHtml: str = ""
sortOrder: int = 0
published: bool = False
attachmentFileIds: list[int] = Field(default_factory=list)
@router.get("/about/pages/{page_key}")
def read_about_page(page_key: str, db: Session = Depends(get_db)) -> dict:
body = admin_about_page(db, page_key)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="单篇页键不正确")
return body
@router.put("/about/pages/{page_key}")
def write_about_page(page_key: str, body: AboutPageWrite, db: Session = Depends(get_db)) -> dict:
try:
save_about_page(db, page_key, body.model_dump())
except ValueError as exc:
code = status.HTTP_404_NOT_FOUND if "不正确" in str(exc) else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=code, detail=str(exc)) from exc
return {"ok": True}
@router.get("/about/articles/{category}")
def list_column_articles(category: str, db: Session = Depends(get_db)) -> dict:
body = admin_column_articles(db, category)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章类别不正确")
return body
@router.get("/about/experts")
def list_experts(db: Session = Depends(get_db)) -> dict:
return admin_experts(db)
@router.post("/about/experts")
def write_expert(body: ExpertWrite, db: Session = Depends(get_db)) -> dict:
try:
expert_id = create_expert(db, body.model_dump())
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"id": expert_id}
@router.put("/about/experts/{expert_id}")
def revise_expert(expert_id: int, body: ExpertWrite, db: Session = Depends(get_db)) -> dict:
try:
update_expert(db, expert_id, body.model_dump())
except ValueError as exc:
code = status.HTTP_404_NOT_FOUND if str(exc) == "专家委员会不存在" else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=code, detail=str(exc)) from exc
return {"ok": True}
@router.delete("/about/experts/{expert_id}")
def remove_expert(expert_id: int, db: Session = Depends(get_db)) -> dict:
try:
delete_expert(db, expert_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {"ok": True}
+353
View File
@@ -0,0 +1,353 @@
import json
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile, status
from pydantic import BaseModel
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.content import (
SETTING_KEYS,
AdminFile,
SensitiveWord,
read_upload_limit,
save_settings,
settings_view,
)
from app.services.html_filter import is_javascript_url, preview_filter
from app.services.storage import (
make_object_key,
private_object_url,
public_object_url,
remove_stored_object,
safe_filename,
upload_object,
)
router = APIRouter(dependencies=[Depends(require_staff)])
_TEXT_KEYS = {"site_name", "icp_text", "police_text", "link_cirea", "link_housing"}
_LINK_KEYS = {"link_cirea", "link_housing"}
_POSITIVE = {
"upload_max_bytes": "上传大小上限必须是正整数",
"login_max_failures": "登录锁定参数必须是正整数",
"login_lock_minutes": "登录锁定参数必须是正整数",
}
_WORKDAYS = {"complaint_workdays", "expert_reply_workdays"}
_INT_MAX = 2147483647
class WordIn(BaseModel):
word: str = ""
class PreviewIn(BaseModel):
html: str = ""
def _bad(detail: str) -> None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
def _actor_id(principal: dict) -> int | None:
try:
return int(principal["sub"])
except (KeyError, TypeError, ValueError):
return None
def _content_type(value: str | None) -> str:
raw = (value or "").split(";", 1)[0].strip().lower()
return raw.replace("\r", "").replace("\n", "")[:128]
def _read_limited(upload: UploadFile, limit: int) -> bytes:
chunks = bytearray()
while True:
piece = upload.file.read(65536)
if not piece:
break
if len(chunks) + len(piece) > limit:
_bad("文件超过大小上限")
chunks.extend(piece)
return bytes(chunks)
def _text_setting(key: str, value) -> str:
if not isinstance(value, str):
_bad("配置项格式不正确")
text = value.strip()
if key in _LINK_KEYS and is_javascript_url(text):
_bad("链接地址不正确")
return text
def _logo_id(value) -> str:
if value is None:
return ""
if isinstance(value, str) and not value.strip():
return ""
if isinstance(value, bool) or not isinstance(value, (int, str)):
_bad("logo_file_id 必须是附件编号")
text = str(value).strip()
if not text.isdigit() or int(text) <= 0:
_bad("logo_file_id 必须是附件编号")
return str(int(text))
def _banner_ids(value) -> str:
if not isinstance(value, list):
_bad("banner_file_ids 必须是数组")
numbers: list[int] = []
for item in value:
if isinstance(item, bool) or not isinstance(item, (int, str)):
_bad("banner_file_ids 必须是附件编号")
text = str(item).strip()
if not text.isdigit() or int(text) <= 0:
_bad("banner_file_ids 必须是附件编号")
numbers.append(int(text))
return json.dumps(numbers, separators=(",", ":"))
def _friend_links(value) -> str:
if not isinstance(value, list):
_bad("friend_links 必须是数组")
cleaned: list[dict[str, str]] = []
for item in value:
if not isinstance(item, dict):
_bad("相关链接必须包含名称和地址")
name = item.get("name")
url = item.get("url")
if not isinstance(name, str) or not isinstance(url, str):
_bad("相关链接必须包含名称和地址")
name = name.strip()
url = url.strip()
if not name or not url:
_bad("相关链接必须包含名称和地址")
if is_javascript_url(url):
_bad("链接地址不正确")
cleaned.append({"name": name, "url": url})
return json.dumps(cleaned, ensure_ascii=False, separators=(",", ":"))
def _positive(value, message: str) -> str:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0 or value > _INT_MAX:
_bad(message)
return str(value)
def _workdays(value) -> str:
if value is None or value == "":
return ""
if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > _INT_MAX:
_bad("时限必须是非负整数")
return str(value)
def _normalize_settings(payload: dict) -> dict[str, str]:
if any(key not in SETTING_KEYS for key in payload):
_bad("未知的配置项")
stored: dict[str, str] = {}
for key, value in payload.items():
if key in _TEXT_KEYS:
stored[key] = _text_setting(key, value)
elif key == "logo_file_id":
stored[key] = _logo_id(value)
elif key == "banner_file_ids":
stored[key] = _banner_ids(value)
elif key == "friend_links":
stored[key] = _friend_links(value)
elif key in _POSITIVE:
stored[key] = _positive(value, _POSITIVE[key])
elif key in _WORKDAYS:
stored[key] = _workdays(value)
return stored
def _file_link(row: AdminFile) -> tuple[str, str | None]:
if row.bucket == "public":
return public_object_url(row.object_key), None
if row.bucket == "private":
url, expires_at = private_object_url(row.object_key)
return url, expires_at.isoformat()
_bad("附件存储位置不正确")
return "", None
def _file_body(row: AdminFile, *, with_meta: bool) -> dict:
url, expires_at = _file_link(row)
body = {
"id": row.id,
"bucket": row.bucket,
"filename": row.filename,
"contentType": row.content_type,
"size": row.size,
"url": url,
"expiresAt": expires_at,
}
if with_meta:
body["createdBy"] = row.created_by
body["createdAt"] = row.created_at.isoformat() if row.created_at else None
return body
def _word_body(row: SensitiveWord) -> dict:
return {
"id": row.id,
"word": row.word,
"createdAt": row.created_at.isoformat() if row.created_at else None,
}
@router.post("/files")
def upload_file(
file: UploadFile = File(...),
bucket: str | None = Form(default=None),
db: Session = Depends(get_db),
principal: dict = Depends(require_staff),
):
if bucket not in {"public", "private"}:
_bad("bucket 只能是 public 或 private")
limit = read_upload_limit(db)
if limit is None:
_bad("未配置上传大小上限")
data = _read_limited(file, limit)
filename = safe_filename(file.filename)
content_type = _content_type(file.content_type)
object_key = make_object_key(filename)
upload_object(bucket, object_key, data, content_type)
row = AdminFile(
bucket=bucket,
object_key=object_key,
filename=filename,
content_type=content_type,
size=len(data),
created_by=_actor_id(principal),
)
db.add(row)
try:
db.commit()
db.refresh(row)
except Exception:
db.rollback()
try:
remove_stored_object(bucket, object_key)
except HTTPException:
pass
raise
return _file_body(row, with_meta=False)
def _like_contains(value: str) -> str:
escaped = value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
return f"%{escaped}%"
@router.get("/files")
def list_files(
current: int = Query(default=1, ge=1),
size: int = Query(default=10, ge=1, le=100),
bucket: str | None = Query(default=None),
q: str | None = Query(default=None),
db: Session = Depends(get_db),
):
if bucket is not None and bucket not in {"public", "private"}:
_bad("bucket 只能是 public 或 private")
count_stmt = select(func.count()).select_from(AdminFile)
stmt = select(AdminFile).order_by(AdminFile.id.desc())
if bucket is not None:
count_stmt = count_stmt.where(AdminFile.bucket == bucket)
stmt = stmt.where(AdminFile.bucket == bucket)
keyword = (q or "").strip()[:100]
if keyword:
needle = _like_contains(keyword)
count_stmt = count_stmt.where(AdminFile.filename.like(needle, escape="\\"))
stmt = stmt.where(AdminFile.filename.like(needle, escape="\\"))
total = int(db.scalar(count_stmt) or 0)
rows = db.scalars(stmt.offset((current - 1) * size).limit(size)).all()
return {
"records": [_file_body(row, with_meta=True) for row in rows],
"current": current,
"size": size,
"total": total,
}
@router.get("/files/{file_id}")
def read_file(file_id: int, db: Session = Depends(get_db)):
row = db.get(AdminFile, file_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
return _file_body(row, with_meta=True)
@router.delete("/files/{file_id}")
def delete_file(file_id: int, db: Session = Depends(get_db)):
row = db.get(AdminFile, file_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="附件不存在")
remove_stored_object(row.bucket, row.object_key)
db.delete(row)
db.commit()
return {"id": file_id}
@router.get("/site-settings")
def read_site_settings(db: Session = Depends(get_db)):
return settings_view(db)
@router.put("/site-settings")
async def update_site_settings(request: Request, db: Session = Depends(get_db)):
try:
payload = await request.json()
except Exception:
_bad("配置项格式不正确")
if not isinstance(payload, dict):
_bad("配置项格式不正确")
save_settings(db, _normalize_settings(payload))
return settings_view(db)
@router.get("/sensitive-words")
def list_words(db: Session = Depends(get_db)):
rows = db.scalars(select(SensitiveWord).order_by(SensitiveWord.id.asc())).all()
return [_word_body(row) for row in rows]
@router.post("/sensitive-words")
def create_word(body: WordIn, db: Session = Depends(get_db)):
word = body.word.strip()
if not word:
_bad("敏感词不能为空")
if len(word) > 64:
_bad("敏感词不能超过 64 个字符")
exists = db.scalar(select(SensitiveWord.id).where(SensitiveWord.word == word))
if exists is not None:
_bad("敏感词已存在")
row = SensitiveWord(word=word)
db.add(row)
try:
db.commit()
db.refresh(row)
except IntegrityError:
db.rollback()
_bad("敏感词已存在")
return _word_body(row)
@router.delete("/sensitive-words/{word_id}")
def delete_word(word_id: int, db: Session = Depends(get_db)):
row = db.get(SensitiveWord, word_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="敏感词不存在")
db.delete(row)
db.commit()
return {"id": word_id}
@router.post("/content/preview-filter")
def preview_content(body: PreviewIn, db: Session = Depends(get_db)):
return preview_filter(body.html, db)
+76
View File
@@ -0,0 +1,76 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.discipline import (
admin_complaint,
admin_complaints,
admin_notice,
mark_processed,
save_notice,
)
router = APIRouter(dependencies=[Depends(require_staff)])
class NoticeWrite(BaseModel):
phone: str = ""
email: str = ""
scopeHtml: str = ""
processHtml: str = ""
def _staff_id(principal: dict) -> int:
try:
return int(principal["sub"])
except (TypeError, ValueError, KeyError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录") from exc
@router.get("/discipline/notice")
def read_notice(db: Session = Depends(get_db)) -> dict:
return admin_notice(db)
@router.put("/discipline/notice")
def write_notice(body: NoticeWrite, db: Session = Depends(get_db)) -> dict:
try:
save_notice(db, body.model_dump())
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return admin_notice(db)
@router.get("/discipline/complaints")
def list_complaints(
status_filter: str | None = Query(None, alias="status"),
db: Session = Depends(get_db),
) -> dict:
try:
return admin_complaints(db, status=status_filter)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.get("/discipline/complaints/{complaint_id}")
def read_complaint(complaint_id: int, db: Session = Depends(get_db)) -> dict:
body = admin_complaint(db, complaint_id)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="投诉不存在")
return body
@router.post("/discipline/complaints/{complaint_id}/process")
def process_complaint(
complaint_id: int,
principal: dict = Depends(require_staff),
db: Session = Depends(get_db),
) -> dict:
try:
mark_processed(db, complaint_id, staff_id=_staff_id(principal))
except ValueError as exc:
code = status.HTTP_404_NOT_FOUND if str(exc) == "投诉不存在" else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=code, detail=str(exc)) from exc
return {"ok": True}
+34
View File
@@ -0,0 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.home import admin_edu_platforms, save_edu_platforms
router = APIRouter(dependencies=[Depends(require_staff)])
class EduPlatformWrite(BaseModel):
name: str = ""
url: str = ""
imageFileId: int | None = None
sortOrder: int = 0
class EduPlatformsWrite(BaseModel):
items: list[EduPlatformWrite] = Field(default_factory=list)
@router.get("/edu/platforms")
def list_edu_platforms(db: Session = Depends(get_db)) -> dict:
return admin_edu_platforms(db)
@router.put("/edu/platforms")
def write_edu_platforms(body: EduPlatformsWrite, db: Session = Depends(get_db)) -> dict:
try:
save_edu_platforms(db, [item.model_dump() for item in body.items])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
+63
View File
@@ -0,0 +1,63 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.expert import admin_question, admin_questions, pending_count, reply_question
router = APIRouter(dependencies=[Depends(require_staff)])
class ReplyWrite(BaseModel):
bodyHtml: str = ""
attachmentFileIds: list[int] = Field(default_factory=list)
def _staff_id(principal: dict) -> int:
try:
return int(principal["sub"])
except (TypeError, ValueError, KeyError) as exc:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="请先登录") from exc
@router.get("/expert-questions/pending-count")
def read_pending_count(db: Session = Depends(get_db)) -> dict:
return {"count": pending_count(db)}
@router.get("/expert-questions")
def list_questions(
status_filter: str | None = Query(None, alias="status"),
db: Session = Depends(get_db),
) -> dict:
return admin_questions(db, status=status_filter)
@router.get("/expert-questions/{question_id}")
def read_question(question_id: int, db: Session = Depends(get_db)) -> dict:
body = admin_question(db, question_id)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提问不存在")
return body
@router.post("/expert-questions/{question_id}/reply")
def write_reply(
question_id: int,
body: ReplyWrite,
principal: dict = Depends(require_staff),
db: Session = Depends(get_db),
) -> dict:
try:
reply_question(
db,
question_id=question_id,
staff_id=_staff_id(principal),
body_html=body.bodyHtml,
attachment_file_ids=body.attachmentFileIds,
)
except ValueError as exc:
code = status.HTTP_404_NOT_FOUND if str(exc) == "提问不存在" else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=code, detail=str(exc)) from exc
return {"ok": True}
+323
View File
@@ -0,0 +1,323 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.home import (
admin_home_view,
create_article,
create_slide,
delete_article,
delete_slide,
update_slide,
save_audiences,
save_compliance,
save_contact,
save_home,
save_honors,
save_hot_links,
save_members,
save_menu,
save_related_links,
set_article_display,
update_article,
)
router = APIRouter(dependencies=[Depends(require_staff)])
class TemplateWrite(BaseModel):
name: str
slogan: str = ""
valueProposition: str = ""
class HomeWrite(BaseModel):
activeTemplate: str
templates: list[TemplateWrite] = Field(min_length=2, max_length=2)
class ArticleDisplayWrite(BaseModel):
news: int | None = None
notices: int | None = None
policies: int | None = None
dashboard: int | None = None
class ArticleWrite(BaseModel):
category: str
title: str = ""
publishedAt: str | None = None
published: bool = False
bodyHtml: str = ""
imageFileId: int | None = None
attachmentFileIds: list[int] = Field(default_factory=list)
kind: str | None = None
class AudienceLinkWrite(BaseModel):
label: str
url: str = ""
visible: bool = True
sortOrder: int = 0
class AudienceCategoryWrite(BaseModel):
name: str
links: list[AudienceLinkWrite] = Field(default_factory=list)
class AudienceWrite(BaseModel):
categories: list[AudienceCategoryWrite] = Field(min_length=3, max_length=3)
class HotLinkWrite(BaseModel):
key: str
label: str
url: str = ""
visible: bool = True
sortOrder: int = 0
class HotLinksWrite(BaseModel):
links: list[HotLinkWrite] = Field(default_factory=list)
class ContactWrite(BaseModel):
name: str
address: str = ""
phone: str = ""
email: str = ""
latitude: float | None = None
longitude: float | None = None
class ContactBatchWrite(BaseModel):
templates: list[ContactWrite] = Field(min_length=2, max_length=2)
class RelatedLinkWrite(BaseModel):
label: str
url: str = ""
visible: bool = True
sortOrder: int = 0
class RelatedCategoryWrite(BaseModel):
name: str
links: list[RelatedLinkWrite] = Field(default_factory=list)
class RelatedLinksWrite(BaseModel):
templateName: str
categories: list[RelatedCategoryWrite] = Field(min_length=3, max_length=3)
class ComplianceWrite(BaseModel):
templateName: str
links: list[RelatedLinkWrite] = Field(default_factory=list)
class HonorItemWrite(BaseModel):
imageFileId: int
sortOrder: int = 0
class HonorWrite(BaseModel):
intervalSeconds: int = Field(gt=0)
items: list[HonorItemWrite] = Field(default_factory=list)
class MemberItemWrite(BaseModel):
name: str
url: str = ""
imageFileId: int
sortOrder: int = 0
class MemberWrite(BaseModel):
intervalSeconds: int = Field(gt=0)
items: list[MemberItemWrite] = Field(default_factory=list)
class MenuItemWrite(BaseModel):
key: str
label: str
url: str = ""
visible: bool = False
sortOrder: int = 0
class MenuWrite(BaseModel):
items: list[MenuItemWrite] = Field(default_factory=list)
class SlideWrite(BaseModel):
title: str = ""
imageFileId: int
sortOrder: int = 0
enabled: bool = True
intervalSeconds: int = Field(gt=0)
published: bool = False
bodyHtml: str = ""
attachmentFileIds: list[int] = Field(default_factory=list)
@router.get("/home")
def read_home(db: Session = Depends(get_db)) -> dict:
return admin_home_view(db)
@router.put("/home")
def write_home(body: HomeWrite, db: Session = Depends(get_db)) -> dict:
try:
save_home(
db,
body.activeTemplate,
[item.model_dump() for item in body.templates],
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/audiences")
def write_audiences(body: AudienceWrite, db: Session = Depends(get_db)) -> dict:
try:
save_audiences(db, [item.model_dump() for item in body.categories])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/hot-links")
def write_hot_links(body: HotLinksWrite, db: Session = Depends(get_db)) -> dict:
try:
save_hot_links(db, [item.model_dump() for item in body.links])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/contact")
def write_contact(body: ContactBatchWrite, db: Session = Depends(get_db)) -> dict:
try:
save_contact(db, [item.model_dump() for item in body.templates])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/related-links")
def write_related_links(body: RelatedLinksWrite, db: Session = Depends(get_db)) -> dict:
try:
save_related_links(
db,
body.templateName,
[item.model_dump() for item in body.categories],
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/compliance")
def write_compliance(body: ComplianceWrite, db: Session = Depends(get_db)) -> dict:
try:
save_compliance(db, body.templateName, [item.model_dump() for item in body.links])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/honors")
def write_honors(body: HonorWrite, db: Session = Depends(get_db)) -> dict:
try:
save_honors(db, body.intervalSeconds, [item.model_dump() for item in body.items])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/members")
def write_members(body: MemberWrite, db: Session = Depends(get_db)) -> dict:
try:
save_members(db, body.intervalSeconds, [item.model_dump() for item in body.items])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/menu")
def write_menu(body: MenuWrite, db: Session = Depends(get_db)) -> dict:
try:
save_menu(db, [item.model_dump() for item in body.items])
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.put("/home/articles/display")
def write_article_display(body: ArticleDisplayWrite, db: Session = Depends(get_db)) -> dict:
try:
set_article_display(db, body.model_dump(exclude_unset=True))
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"ok": True}
@router.post("/home/articles")
def write_article(body: ArticleWrite, db: Session = Depends(get_db)) -> dict:
try:
article_id = create_article(db, body.model_dump())
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"id": article_id}
@router.put("/home/articles/{article_id}")
def revise_article(article_id: int, body: ArticleWrite, db: Session = Depends(get_db)) -> dict:
try:
update_article(db, article_id, body.model_dump())
except ValueError as exc:
code = status.HTTP_404_NOT_FOUND if str(exc) == "文章不存在" else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=code, detail=str(exc)) from exc
return {"ok": True}
@router.delete("/home/articles/{article_id}")
def remove_article(article_id: int, db: Session = Depends(get_db)) -> dict:
try:
delete_article(db, article_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {"ok": True}
@router.post("/home/slides")
def write_slide(body: SlideWrite, db: Session = Depends(get_db)) -> dict:
try:
slide_id = create_slide(db, body.model_dump())
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"id": slide_id}
@router.put("/home/slides/{slide_id}")
def revise_slide(slide_id: int, body: SlideWrite, db: Session = Depends(get_db)) -> dict:
try:
update_slide(db, slide_id, body.model_dump())
except ValueError as exc:
code = status.HTTP_404_NOT_FOUND if str(exc) == "轮播不存在" else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=code, detail=str(exc)) from exc
return {"ok": True}
@router.delete("/home/slides/{slide_id}")
def remove_slide(slide_id: int, db: Session = Depends(get_db)) -> dict:
try:
delete_slide(db, slide_id)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
return {"ok": True}
+42
View File
@@ -0,0 +1,42 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.legal import LEGAL_PAGE_KEYS, LEGAL_PAGE_TITLES, admin_legal_page, save_legal_page
router = APIRouter(dependencies=[Depends(require_staff)])
class LegalPageWrite(BaseModel):
published: bool = False
bodyHtml: str = ""
@router.get("/legal")
def list_legal_pages(db: Session = Depends(get_db)) -> dict:
items = []
for key in LEGAL_PAGE_KEYS:
body = admin_legal_page(db, key)
if body is not None:
items.append(body)
return {"items": items}
@router.get("/legal/{page_key}")
def read_legal_page(page_key: str, db: Session = Depends(get_db)) -> dict:
body = admin_legal_page(db, page_key)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合规页键不正确")
return body
@router.put("/legal/{page_key}")
def write_legal_page(page_key: str, body: LegalPageWrite, db: Session = Depends(get_db)) -> dict:
try:
save_legal_page(db, page_key, body.model_dump())
except ValueError as exc:
code = status.HTTP_404_NOT_FOUND if "不正确" in str(exc) else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=code, detail=str(exc)) from exc
return {"ok": True, "title": LEGAL_PAGE_TITLES.get(page_key, page_key)}
+232
View File
@@ -0,0 +1,232 @@
from fastapi import APIRouter, Depends, Query, Request
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.services import rbac as rbac_service
public_router = APIRouter()
router = APIRouter(dependencies=[Depends(require_staff)])
class LoginIn(BaseModel):
username: str
password: str
captchaId: str = ""
captchaCode: str = ""
class OwnPasswordIn(BaseModel):
oldPassword: str
newPassword: str
class UserCreateIn(BaseModel):
username: str
password: str
displayName: str
mobile: str | None = None
roleIds: list[int] = []
class UserUpdateIn(BaseModel):
displayName: str
mobile: str | None = None
status: str
roleIds: list[int] = []
class ResetPasswordIn(BaseModel):
password: str | None = None
newPassword: str | None = None
class RoleCreateIn(BaseModel):
code: str
name: str
description: str | None = None
class RoleUpdateIn(BaseModel):
name: str
description: str | None = None
status: str
class RoleResourcesIn(BaseModel):
resourceIds: list[int] = []
class ResourceIn(BaseModel):
parentId: int | None = None
type: str
name: str
title: str
path: str | None = ""
component: str | None = ""
icon: str | None = ""
permission: str | None = ""
sort: int = 0
visible: bool = True
status: str = "active"
def _actor_id(principal: dict = Depends(require_staff)) -> int:
try:
return int(principal["sub"])
except (TypeError, ValueError):
from fastapi import HTTPException
raise HTTPException(status_code=401, detail="登录已失效")
def _resource_payload(body: ResourceIn) -> dict:
return {
"parent_id": body.parentId,
"type": body.type,
"name": body.name,
"title": body.title,
"path": body.path,
"component": body.component,
"icon": body.icon,
"permission": body.permission,
"sort": body.sort,
"visible": body.visible,
"status": body.status,
}
@public_router.post("/auth/login")
def login(body: LoginIn, request: Request, db: Session = Depends(get_db)) -> dict:
request.state.audit_username = (body.username or "").strip()[:64]
return rbac_service.authenticate(db, body.username, body.password, body.captchaId, body.captchaCode)
@router.post("/auth/password")
def change_password(
body: OwnPasswordIn,
actor_id: int = Depends(_actor_id),
db: Session = Depends(get_db),
) -> dict:
return rbac_service.change_own_password(db, actor_id, body.oldPassword, body.newPassword)
@router.get("/users")
def list_users(
current: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=200),
username: str | None = None,
displayName: str | None = None,
status: str | None = None,
db: Session = Depends(get_db),
) -> dict:
return rbac_service.query_users(
db,
current=current,
size=size,
username=username,
display_name=displayName,
status_value=status,
)
@router.post("/users")
def create_user(body: UserCreateIn, db: Session = Depends(get_db)) -> dict:
return rbac_service.create_user(
db,
username=body.username,
password=body.password,
display_name=body.displayName,
mobile=body.mobile,
role_ids=body.roleIds,
)
@router.put("/users/{user_id}")
def update_user(
user_id: int,
body: UserUpdateIn,
actor_id: int = Depends(_actor_id),
db: Session = Depends(get_db),
) -> dict:
return rbac_service.update_user(
db,
user_id,
actor_id,
display_name=body.displayName,
status_value=body.status,
role_ids=body.roleIds,
mobile=body.mobile,
mobile_provided="mobile" in body.model_fields_set,
)
@router.post("/users/{user_id}/password")
def reset_password(user_id: int, body: ResetPasswordIn, db: Session = Depends(get_db)) -> dict:
plain = body.password if body.password is not None else body.newPassword
if plain is None:
from fastapi import HTTPException
raise HTTPException(status_code=400, detail="请填写密码")
return rbac_service.reset_user_password(db, user_id, plain)
@router.get("/roles")
def list_roles(
current: int = Query(1, ge=1),
size: int = Query(10, ge=1, le=200),
name: str | None = None,
code: str | None = None,
status: str | None = None,
db: Session = Depends(get_db),
) -> dict:
return rbac_service.query_roles(
db,
current=current,
size=size,
name=name,
code=code,
status_value=status,
)
@router.post("/roles")
def create_role(body: RoleCreateIn, db: Session = Depends(get_db)) -> dict:
return rbac_service.create_role(db, code=body.code, name=body.name, description=body.description)
@router.put("/roles/{role_id}")
def update_role(role_id: int, body: RoleUpdateIn, db: Session = Depends(get_db)) -> dict:
return rbac_service.update_role(
db,
role_id,
name=body.name,
description=body.description,
status_value=body.status,
)
@router.put("/roles/{role_id}/resources")
def replace_role_resources(role_id: int, body: RoleResourcesIn, db: Session = Depends(get_db)) -> dict:
return rbac_service.replace_role_resources(db, role_id, body.resourceIds)
@router.get("/resources")
def list_resources(db: Session = Depends(get_db)) -> list[dict]:
return rbac_service.list_resources(db)
@router.post("/resources")
def create_resource(body: ResourceIn, db: Session = Depends(get_db)) -> dict:
return rbac_service.create_resource(db, _resource_payload(body))
@router.put("/resources/{resource_id}")
def update_resource(resource_id: int, body: ResourceIn, db: Session = Depends(get_db)) -> dict:
return rbac_service.update_resource(db, resource_id, _resource_payload(body))
@router.delete("/resources/{resource_id}")
def delete_resource(resource_id: int, db: Session = Depends(get_db)) -> dict:
return rbac_service.delete_resource(db, resource_id)
+66
View File
@@ -0,0 +1,66 @@
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.report_filing import create_filing, delete_filing, list_filings, update_filing
router = APIRouter(dependencies=[Depends(require_staff)])
class FilingWrite(BaseModel):
institutionName: str
reportNumber: str
issuedOn: str
published: bool = False
def _raise(exc: ValueError) -> None:
code = status.HTTP_404_NOT_FOUND if str(exc) == "备案不存在" else status.HTTP_400_BAD_REQUEST
raise HTTPException(status_code=code, detail=str(exc)) from exc
@router.get("/reports")
def read_reports(db: Session = Depends(get_db)) -> dict:
return list_filings(db)
@router.post("/reports")
def write_report(body: FilingWrite, db: Session = Depends(get_db)) -> dict:
try:
filing_id = create_filing(
db,
institution_name=body.institutionName,
report_number=body.reportNumber,
issued_on=body.issuedOn,
published=body.published,
)
except ValueError as exc:
_raise(exc)
return {"id": filing_id}
@router.put("/reports/{filing_id}")
def replace_report(filing_id: int, body: FilingWrite, db: Session = Depends(get_db)) -> dict:
try:
update_filing(
db,
filing_id,
institution_name=body.institutionName,
report_number=body.reportNumber,
issued_on=body.issuedOn,
published=body.published,
)
except ValueError as exc:
_raise(exc)
return {"ok": True}
@router.delete("/reports/{filing_id}")
def remove_report(filing_id: int, db: Session = Depends(get_db)) -> dict:
try:
delete_filing(db, filing_id)
except ValueError as exc:
_raise(exc)
return {"ok": True}
+10
View File
@@ -0,0 +1,10 @@
from fastapi import APIRouter, Depends
from app.core.deps import require_staff
router = APIRouter(dependencies=[Depends(require_staff)])
@router.get("/health")
def admin_health(principal: dict = Depends(require_staff)) -> dict:
return {"status": "ok", "scope": "admin", "subject": principal["sub"]}
+177
View File
@@ -0,0 +1,177 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel, ConfigDict
from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.content import SiteSetting
from app.models.security import AdminAuditLog, AdminIpBlacklist
from app.services.captcha import issue_captcha
from app.services.login_guard import (
LOGIN_LOCK_MINUTES_KEY,
LOGIN_MAX_FAILURES_KEY,
read_login_policy,
)
public_router = APIRouter()
router = APIRouter(dependencies=[Depends(require_staff)])
class LoginSecurityBody(BaseModel):
model_config = ConfigDict(strict=True)
loginMaxFailures: int
loginLockMinutes: int
class IpBlacklistBody(BaseModel):
model_config = ConfigDict(strict=True)
ip: str
remark: str = ""
def _naive(value: datetime) -> datetime:
if value.tzinfo is None:
return value
return value.astimezone(timezone.utc).replace(tzinfo=None)
def _iso(value: datetime | None) -> str | None:
if value is None:
return None
if value.tzinfo is None:
value = value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def _save_setting(db: Session, key: str, value: int) -> None:
row = db.scalar(select(SiteSetting).where(SiteSetting.key == key))
text = str(value)
if row is None:
db.add(SiteSetting(key=key, value=text))
else:
row.value = text
def _ip_item(row: AdminIpBlacklist) -> dict:
return {"id": row.id, "ip": row.ip, "remark": row.remark, "createdAt": _iso(row.created_at)}
def _audit_item(row: AdminAuditLog) -> dict:
return {
"id": row.id,
"userId": row.user_id,
"username": row.username,
"method": row.method,
"path": row.path,
"statusCode": row.status_code,
"ip": row.ip,
"createdAt": _iso(row.created_at),
}
@public_router.get("/auth/captcha")
def get_captcha() -> dict:
return issue_captcha()
@router.get("/login-security")
def get_login_security(db: Session = Depends(get_db)) -> dict:
failures, minutes = read_login_policy(db)
return {"loginMaxFailures": failures, "loginLockMinutes": minutes}
@router.put("/login-security")
def update_login_security(body: LoginSecurityBody, db: Session = Depends(get_db)) -> dict:
if body.loginMaxFailures <= 0 or body.loginLockMinutes <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="必须是正整数")
_save_setting(db, LOGIN_MAX_FAILURES_KEY, body.loginMaxFailures)
_save_setting(db, LOGIN_LOCK_MINUTES_KEY, body.loginLockMinutes)
try:
db.commit()
except IntegrityError:
db.rollback()
_save_setting(db, LOGIN_MAX_FAILURES_KEY, body.loginMaxFailures)
_save_setting(db, LOGIN_LOCK_MINUTES_KEY, body.loginLockMinutes)
db.commit()
failures, minutes = read_login_policy(db)
return {"loginMaxFailures": failures, "loginLockMinutes": minutes}
@router.get("/ip-blacklist")
def list_ip_blacklist(db: Session = Depends(get_db)) -> list[dict]:
rows = db.scalars(select(AdminIpBlacklist).order_by(AdminIpBlacklist.id.asc())).all()
return [_ip_item(row) for row in rows]
@router.post("/ip-blacklist")
def create_ip_blacklist(body: IpBlacklistBody, db: Session = Depends(get_db)) -> dict:
ip = body.ip.strip()
remark = body.remark.strip()
if not ip or len(ip) > 64 or any(char.isspace() for char in ip):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 无效")
if len(remark) > 255:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="备注过长")
if db.scalar(select(AdminIpBlacklist.id).where(AdminIpBlacklist.ip == ip)) is not None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 已存在")
row = AdminIpBlacklist(ip=ip, remark=remark)
db.add(row)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 已存在")
db.refresh(row)
return _ip_item(row)
@router.delete("/ip-blacklist/{id}")
def delete_ip_blacklist(id: int, db: Session = Depends(get_db)) -> dict:
row = db.get(AdminIpBlacklist, id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="记录不存在")
db.delete(row)
db.commit()
return {"id": id}
@router.get("/audit-logs")
def list_audit_logs(
current: int = Query(1, ge=1),
size: int = Query(20, ge=1, le=200),
username: str | None = None,
startTime: datetime | None = None,
endTime: datetime | None = None,
db: Session = Depends(get_db),
) -> dict:
filters = []
if username and username.strip():
escaped = username.strip().replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
filters.append(AdminAuditLog.username.like(f"%{escaped}%", escape="\\"))
if startTime is not None:
filters.append(AdminAuditLog.created_at >= _naive(startTime))
if endTime is not None:
filters.append(AdminAuditLog.created_at <= _naive(endTime))
count_stmt = select(func.count()).select_from(AdminAuditLog)
list_stmt = select(AdminAuditLog)
if filters:
count_stmt = count_stmt.where(*filters)
list_stmt = list_stmt.where(*filters)
total = int(db.scalar(count_stmt) or 0)
rows = db.scalars(
list_stmt.order_by(AdminAuditLog.created_at.desc(), AdminAuditLog.id.desc())
.offset((current - 1) * size)
.limit(size)
).all()
return {
"records": [_audit_item(row) for row in rows],
"current": current,
"size": size,
"total": total,
}
+30
View File
@@ -0,0 +1,30 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.core.deps import require_staff
from app.db import get_db
from app.models.uploaded_case import list_uploaded_cases, uploaded_case_detail
router = APIRouter(dependencies=[Depends(require_staff)])
@router.get("/uploaded-cases")
def read_uploaded_cases(
kind: str = Query("residential"),
current: int = Query(1),
size: int = Query(10),
q: str = Query(""),
db: Session = Depends(get_db),
) -> dict:
body = list_uploaded_cases(db, kind=kind, current=current, size=size, q=q)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="案例种类不正确")
return body
@router.get("/uploaded-cases/{case_id}")
def read_uploaded_case(case_id: int, db: Session = Depends(get_db)) -> dict:
body = uploaded_case_detail(db, case_id)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="上传案例不存在")
return body