chore: initialize GSREA website project
This commit is contained in:
commit
d3c5461de5
1842 files changed
+246140
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -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}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
@@ -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)}
|
||||
@@ -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)
|
||||
@@ -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}
|
||||
@@ -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"]}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Whitespace-only changes.
@@ -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
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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": "提交成功"}
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
}
|
||||
Reference in new issue
Block a user