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.
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
+52
View File
@@ -0,0 +1,52 @@
from fastapi import APIRouter
from app.api.admin.about import router as admin_about_router
from app.api.admin.discipline import router as admin_discipline_router
from app.api.admin.edu import router as admin_edu_router
from app.api.admin.content import router as content_router
from app.api.admin.expert import router as admin_expert_router
from app.api.admin.uploaded_cases import router as admin_uploaded_cases_router
from app.api.admin.home import router as admin_home_router
from app.api.admin.reports import router as admin_reports_router
from app.api.admin.legal import router as admin_legal_router
from app.api.admin.rbac import public_router as rbac_public_router
from app.api.admin.rbac import router as rbac_router
from app.api.admin.routes import router as admin_router
from app.api.admin.security import public_router as security_public_router
from app.api.admin.security import router as security_router
from app.api.site.about import router as site_about_router
from app.api.site.discipline import router as site_discipline_router
from app.api.site.edu import router as site_edu_router
from app.api.site.auth import router as site_auth_router
from app.api.site.legal import router as site_legal_router
from app.api.site.member import router as member_router
from app.api.site.home import router as site_home_router
from app.api.site.public import router as site_public_router
from app.api.site.reports import router as site_reports_router
from app.api.site.settings import router as site_settings_router
api_router = APIRouter()
api_router.include_router(site_public_router, prefix="/api/site")
api_router.include_router(site_settings_router, prefix="/api/site")
api_router.include_router(site_home_router, prefix="/api/site")
api_router.include_router(site_reports_router, prefix="/api/site")
api_router.include_router(site_about_router, prefix="/api/site")
api_router.include_router(site_discipline_router, prefix="/api/site")
api_router.include_router(site_edu_router, prefix="/api/site")
api_router.include_router(site_legal_router, prefix="/api/site")
api_router.include_router(site_auth_router, prefix="/api/site")
api_router.include_router(member_router, prefix="/api/site/member")
api_router.include_router(rbac_public_router, prefix="/api/admin")
api_router.include_router(security_public_router, prefix="/api/admin")
api_router.include_router(rbac_router, prefix="/api/admin")
api_router.include_router(security_router, prefix="/api/admin")
api_router.include_router(content_router, prefix="/api/admin")
api_router.include_router(admin_home_router, prefix="/api/admin")
api_router.include_router(admin_reports_router, prefix="/api/admin")
api_router.include_router(admin_about_router, prefix="/api/admin")
api_router.include_router(admin_discipline_router, prefix="/api/admin")
api_router.include_router(admin_edu_router, prefix="/api/admin")
api_router.include_router(admin_legal_router, prefix="/api/admin")
api_router.include_router(admin_expert_router, prefix="/api/admin")
api_router.include_router(admin_uploaded_cases_router, prefix="/api/admin")
api_router.include_router(admin_router, prefix="/api/admin")
View File
Whitespace-only changes.
+44
View File
@@ -0,0 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.about import public_about_page, public_expert, public_experts
from app.models.home import DEFAULT_COLUMN_PAGE_SIZE, public_column_articles
router = APIRouter()
@router.get("/about/pages/{page_key}")
def read_public_about_page(page_key: str, db: Session = Depends(get_db)) -> dict:
body = public_about_page(db, page_key)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="单篇页键不正确")
return body
@router.get("/about/articles/{category}")
def read_public_column_articles(
category: str,
current: int = Query(1, ge=1),
size: int = Query(DEFAULT_COLUMN_PAGE_SIZE, ge=1, le=100),
kind: str | None = Query(None),
q: str | None = Query(None),
db: Session = Depends(get_db),
) -> dict:
body = public_column_articles(db, category, current=current, size=size, kind=kind, q=q)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章类别不正确")
return body
@router.get("/about/experts")
def read_public_experts(db: Session = Depends(get_db)) -> dict:
return public_experts(db)
@router.get("/about/experts/{expert_id}")
def read_public_expert(expert_id: int, db: Session = Depends(get_db)) -> dict:
body = public_expert(db, expert_id)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="专家委员会不存在")
return body
+124
View File
@@ -0,0 +1,124 @@
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.core.security import create_access_token
from app.db import get_db
from app.models.member import create_member, get_member_by_email, set_member_password
from app.services.email_code import (
consume_email_code,
is_valid_email,
issue_email_code,
normalize_email,
)
from app.services.password import hash_password, validate_password, verify_password
router = APIRouter()
class EmailCodeRequest(BaseModel):
email: str
purpose: str
class RegisterRequest(BaseModel):
email: str
code: str
password: str
privacyAccepted: bool = False
class LoginRequest(BaseModel):
email: str
password: str
code: str | None = None
class ResetPasswordRequest(BaseModel):
email: str
code: str
password: str
def _bad(detail: str) -> None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
@router.post("/auth/email-code")
def send_email_code(body: EmailCodeRequest, db: Session = Depends(get_db)) -> dict:
purpose = (body.purpose or "").strip()
email = normalize_email(body.email)
if purpose not in {"register", "login", "reset"}:
_bad("验证码用途不正确")
if not is_valid_email(email):
_bad("邮箱格式不正确")
existing = get_member_by_email(db, email)
if purpose == "register" and existing is not None:
_bad("该邮箱已注册")
if purpose in {"login", "reset"} and existing is None:
_bad("该邮箱尚未注册")
if purpose in {"login", "reset"} and existing is not None and existing.status != "active":
_bad("账号不可用")
try:
issue_email_code(email=email, purpose=purpose)
except ValueError as exc:
_bad(str(exc))
return {"ok": True}
@router.post("/auth/register")
def register(body: RegisterRequest, db: Session = Depends(get_db)) -> dict:
email = normalize_email(body.email)
if not is_valid_email(email):
_bad("邮箱格式不正确")
if not body.privacyAccepted:
_bad("请先阅读并同意隐私政策")
if get_member_by_email(db, email) is not None:
_bad("该邮箱已注册")
# 先校验密码,避免弱密码拒收时已消费验证码,用户改密后无法再用同一码。
validate_password(body.password)
if not consume_email_code(email=email, purpose="register", code=body.code):
_bad("验证码不正确或已过期")
member = create_member(
db,
email=email,
password_hash=hash_password(body.password),
privacy_accepted_at=datetime.now(timezone.utc).replace(tzinfo=None),
)
token = create_access_token(str(member.id), "member")
return {"accessToken": token, "tokenType": "member", "email": member.email}
@router.post("/auth/login")
def login(body: LoginRequest, db: Session = Depends(get_db)) -> dict:
email = normalize_email(body.email)
if not is_valid_email(email):
_bad("邮箱格式不正确")
member = get_member_by_email(db, email)
if member is None or member.status != "active":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码不正确")
if body.code is not None and str(body.code).strip():
if not consume_email_code(email=email, purpose="login", code=body.code):
_bad("验证码不正确或已过期")
if not verify_password(body.password, member.password_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码不正确")
token = create_access_token(str(member.id), "member")
return {"accessToken": token, "tokenType": "member", "email": member.email}
@router.post("/auth/reset-password")
def reset_password(body: ResetPasswordRequest, db: Session = Depends(get_db)) -> dict:
email = normalize_email(body.email)
if not is_valid_email(email):
_bad("邮箱格式不正确")
member = get_member_by_email(db, email)
if member is None or member.status != "active":
_bad("该邮箱尚未注册")
validate_password(body.password)
if not consume_email_code(email=email, purpose="reset", code=body.code):
_bad("验证码不正确或已过期")
set_member_password(db, member, hash_password(body.password))
token = create_access_token(str(member.id), "member")
return {"accessToken": token, "tokenType": "member", "email": member.email}
+72
View File
@@ -0,0 +1,72 @@
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.content import read_upload_limit
from app.models.discipline import create_complaint, public_complaint_status, public_notice
router = APIRouter()
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:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过大小上限")
chunks.extend(piece)
return bytes(chunks)
@router.get("/discipline/notice")
def read_notice(db: Session = Depends(get_db)) -> dict:
return public_notice(db)
@router.post("/discipline/complaints")
def submit_complaint(
title: str = Form(""),
subject: str = Form(""),
body: str = Form(""),
contactName: str = Form(""),
mobile: str = Form(""),
email: str = Form(""),
evidence: list[UploadFile] | None = File(default=None),
db: Session = Depends(get_db),
):
limit = read_upload_limit(db)
if limit is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="未配置上传大小上限")
files: list[tuple[str, str, bytes]] = []
for upload in evidence or []:
if upload is None or not getattr(upload, "filename", None):
continue
files.append((upload.filename, _content_type(upload.content_type), _read_limited(upload, limit)))
try:
return create_complaint(
db,
title=title,
subject=subject,
body=body,
contact_name=contactName,
mobile=mobile,
email=email,
files=files,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
@router.get("/discipline/complaints/{code}")
def read_complaint_status(code: str, db: Session = Depends(get_db)) -> dict:
body = public_complaint_status(db, code)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="未找到该查询码")
return body
+12
View File
@@ -0,0 +1,12 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.home import public_edu_platforms
router = APIRouter()
@router.get("/edu/platforms")
def read_edu_platforms(db: Session = Depends(get_db)) -> dict:
return public_edu_platforms(db)
+28
View File
@@ -0,0 +1,28 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.home import public_article, public_home, public_slide
router = APIRouter()
@router.get("/home")
def read_public_home(db: Session = Depends(get_db)) -> dict:
return public_home(db)
@router.get("/home/articles/{article_id}")
def read_public_article(article_id: int, db: Session = Depends(get_db)) -> dict:
body = public_article(db, article_id)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
return body
@router.get("/home/slides/{slide_id}")
def read_public_slide(slide_id: int, db: Session = Depends(get_db)) -> dict:
body = public_slide(db, slide_id)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="轮播不存在")
return body
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.legal import public_legal_page
router = APIRouter()
@router.get("/legal/{page_key}")
def read_public_legal_page(page_key: str, db: Session = Depends(get_db)) -> dict:
body = public_legal_page(db, page_key)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="合规页键不正确")
return body
+171
View File
@@ -0,0 +1,171 @@
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.core.deps import require_member
from app.db import get_db
from app.models.content import AdminFile, read_upload_limit
from app.models.expert import create_question, member_question, member_questions
from app.models.member import get_member_by_id
from app.models.uploaded_case import create_uploaded_case
from app.services.storage import (
make_object_key,
private_object_url,
remove_stored_object,
safe_filename,
upload_object,
)
router = APIRouter(dependencies=[Depends(require_member)])
class QuestionWrite(BaseModel):
title: str = ""
body: str = ""
attachmentFileIds: list[int] = Field(default_factory=list)
def _member_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
def _active_member(db: Session, principal: dict):
member = get_member_by_id(db, _member_id(principal))
if member is None or member.status != "active":
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效")
return member
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:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件超过大小上限")
chunks.extend(piece)
return bytes(chunks)
@router.get("/me")
def member_me(
principal: dict = Depends(require_member),
db: Session = Depends(get_db),
) -> dict:
member = _active_member(db, principal)
return {
"id": member.id,
"email": member.email,
"tokenType": principal["typ"],
}
@router.post("/files")
def upload_private_file(
file: UploadFile = File(...),
principal: dict = Depends(require_member),
db: Session = Depends(get_db),
):
member = _active_member(db, principal)
limit = read_upload_limit(db)
if limit is None:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="未配置上传大小上限")
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("private", object_key, data, content_type)
row = AdminFile(
bucket="private",
object_key=object_key,
filename=filename,
content_type=content_type,
size=len(data),
created_by=member.id,
)
db.add(row)
try:
db.commit()
db.refresh(row)
except Exception:
db.rollback()
try:
remove_stored_object("private", object_key)
except HTTPException:
pass
raise
url, expires_at = private_object_url(row.object_key)
return {
"id": row.id,
"bucket": row.bucket,
"filename": row.filename,
"contentType": row.content_type,
"size": row.size,
"url": url,
"expiresAt": expires_at.isoformat(),
}
@router.get("/expert-questions")
def list_my_questions(
principal: dict = Depends(require_member),
db: Session = Depends(get_db),
) -> dict:
member = _active_member(db, principal)
return member_questions(db, member.id)
@router.get("/expert-questions/{question_id}")
def read_my_question(
question_id: int,
principal: dict = Depends(require_member),
db: Session = Depends(get_db),
) -> dict:
member = _active_member(db, principal)
body = member_question(db, member.id, question_id)
if body is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="提问不存在")
return body
@router.post("/expert-questions")
def ask_question(
body: QuestionWrite,
principal: dict = Depends(require_member),
db: Session = Depends(get_db),
) -> dict:
member = _active_member(db, principal)
try:
question_id = create_question(
db,
member_id=member.id,
title=body.title,
body=body.body,
attachment_file_ids=body.attachmentFileIds,
)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"id": question_id}
@router.post("/uploaded-cases")
def submit_uploaded_case(
body: dict,
principal: dict = Depends(require_member),
db: Session = Depends(get_db),
) -> dict:
member = _active_member(db, principal)
try:
row = create_uploaded_case(db, member, body)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
return {"id": row.id, "message": "提交成功"}
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter
from app.models.uploaded_case import (
KIND_LABELS,
OPEN_KINDS,
commercial_options,
office_options,
regions,
residential_options,
industrial_options,
)
router = APIRouter()
@router.get("/health")
def site_health() -> dict:
return {"status": "ok", "scope": "site"}
@router.get("/uploaded-case-options")
def uploaded_case_options() -> dict:
return {
"kinds": [{"key": key, "label": label} for key, label in KIND_LABELS.items()],
"openKinds": [key for key in KIND_LABELS if key in OPEN_KINDS],
"regions": regions(),
"residential": residential_options(),
"commercial": commercial_options(),
"office": office_options(),
"industrial": industrial_options(),
}
+15
View File
@@ -0,0 +1,15 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.report_filing import query_filing
router = APIRouter()
@router.get("/reports/query")
def read_report_query(number: str = "", db: Session = Depends(get_db)) -> dict:
try:
return query_filing(db, number)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
+23
View File
@@ -0,0 +1,23 @@
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.content import settings_view
router = APIRouter()
@router.get("/settings")
def read_public_settings(db: Session = Depends(get_db)) -> dict:
# 只返回门户展示字段。上传上限和登录锁定参数留在管理端。
data = settings_view(db)
return {
"siteName": data["site_name"],
"logoUrl": data["logoUrl"],
"bannerUrls": data["bannerUrls"],
"icpText": data["icp_text"],
"policeText": data["police_text"],
"friendLinks": data["friend_links"],
"linkCirea": data["link_cirea"],
"linkHousing": data["link_housing"],
}