admin.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448
  1. """管理端 API:工作台、台账办理、配置、用户/角色、审计、附件上传。"""
  2. from __future__ import annotations
  3. import logging
  4. from typing import Annotated, Any, Dict, List, Optional
  5. from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
  6. from fastapi.responses import StreamingResponse
  7. from sqlalchemy.orm import Session
  8. from app.core import storage
  9. from app.core.database import get_db
  10. from app.core.deps import get_current_user, require_menu
  11. from app.core.response import AppHTTPError, success_response
  12. from app.core.security import hash_password
  13. from app.core.storage import StorageError, _normalize_folder
  14. from app.core.upload_validate import validate_image_upload, validate_upload
  15. from app.crud import attachment as attachment_crud
  16. from app.crud import menu as menu_crud
  17. from app.crud import oper_log as oper_log_crud
  18. from app.crud import role as role_crud
  19. from app.crud import user as user_crud
  20. from app.models.system import SysUser
  21. from app.schemas.business import HotlineUpdateBody, QrcodeUpdateBody, StatusUpdateBody
  22. from app.schemas.system import (
  23. ResetPasswordBody,
  24. RoleMenusBody,
  25. UserCreateBody,
  26. UserUpdateBody,
  27. )
  28. from app.services import admin_ledger_service, auth_service, config_service, dashboard_service
  29. logger = logging.getLogger(__name__)
  30. router = APIRouter(prefix="/web/admin", tags=["admin"])
  31. def _iso(dt) -> Optional[str]:
  32. if not dt:
  33. return None
  34. return dt.isoformat(sep=" ", timespec="seconds")
  35. def _user_to_dict(db: Session, user: SysUser) -> Dict[str, Any]:
  36. role = user_crud.get_role_by_id(db, user.role_id)
  37. return {
  38. "id": user.id,
  39. "username": user.username,
  40. "displayName": user.display_name,
  41. "roleId": user.role_id,
  42. "roleCode": role.role_code if role else "",
  43. "roleName": role.role_name if role else "",
  44. "status": user.status,
  45. "createTime": _iso(user.create_time),
  46. "updateTime": _iso(user.update_time),
  47. }
  48. def _role_to_dict(role) -> Dict[str, Any]:
  49. return {
  50. "id": role.id,
  51. "roleCode": role.role_code,
  52. "roleName": role.role_name,
  53. "remark": role.remark or "",
  54. }
  55. def _oper_log_to_dict(row) -> Dict[str, Any]:
  56. return {
  57. "id": row.id,
  58. "userId": row.user_id,
  59. "username": row.username,
  60. "module": row.module,
  61. "action": row.action,
  62. "detail": row.detail,
  63. "ip": row.ip,
  64. "createTime": _iso(row.create_time),
  65. }
  66. @router.get("/dashboard")
  67. def dashboard(
  68. db: Annotated[Session, Depends(get_db)],
  69. _user: Annotated[SysUser, Depends(get_current_user)],
  70. ):
  71. return success_response(dashboard_service.get_dashboard(db))
  72. @router.get("/feedbacks")
  73. def list_feedbacks(
  74. db: Annotated[Session, Depends(get_db)],
  75. _user: Annotated[SysUser, Depends(get_current_user)],
  76. status: Optional[str] = Query(None),
  77. fbType: Optional[str] = Query(None),
  78. keyword: Optional[str] = Query(None),
  79. page: int = Query(1, ge=1),
  80. pageSize: int = Query(10, ge=1, le=100),
  81. ):
  82. return success_response(
  83. admin_ledger_service.list_feedbacks_page(
  84. db,
  85. status=status,
  86. fb_type=fbType,
  87. keyword=keyword,
  88. page=page,
  89. page_size=pageSize,
  90. )
  91. )
  92. @router.get("/feedbacks/{feedback_id}")
  93. def get_feedback(
  94. feedback_id: str,
  95. db: Annotated[Session, Depends(get_db)],
  96. _user: Annotated[SysUser, Depends(get_current_user)],
  97. ):
  98. return success_response(
  99. admin_ledger_service.get_feedback_detail(db, feedback_id)
  100. )
  101. @router.put("/feedbacks/{feedback_id}/status")
  102. def update_feedback_status(
  103. feedback_id: str,
  104. body: StatusUpdateBody,
  105. db: Annotated[Session, Depends(get_db)],
  106. user: Annotated[SysUser, Depends(require_menu("feedback:handle"))],
  107. ):
  108. return success_response(
  109. admin_ledger_service.update_feedback_status(
  110. db,
  111. user,
  112. feedback_id,
  113. status=body.status,
  114. handle_remark=body.handle_remark,
  115. )
  116. )
  117. @router.get("/suggestions")
  118. def list_suggestions(
  119. db: Annotated[Session, Depends(get_db)],
  120. _user: Annotated[SysUser, Depends(get_current_user)],
  121. status: Optional[str] = Query(None),
  122. keyword: Optional[str] = Query(None),
  123. page: int = Query(1, ge=1),
  124. pageSize: int = Query(10, ge=1, le=100),
  125. ):
  126. return success_response(
  127. admin_ledger_service.list_suggestions_page(
  128. db,
  129. status=status,
  130. keyword=keyword,
  131. page=page,
  132. page_size=pageSize,
  133. )
  134. )
  135. @router.get("/suggestions/{suggestion_id}")
  136. def get_suggestion(
  137. suggestion_id: str,
  138. db: Annotated[Session, Depends(get_db)],
  139. _user: Annotated[SysUser, Depends(get_current_user)],
  140. ):
  141. return success_response(
  142. admin_ledger_service.get_suggestion_detail(db, suggestion_id)
  143. )
  144. @router.put("/suggestions/{suggestion_id}/status")
  145. def update_suggestion_status(
  146. suggestion_id: str,
  147. body: StatusUpdateBody,
  148. db: Annotated[Session, Depends(get_db)],
  149. user: Annotated[SysUser, Depends(require_menu("suggestion:handle"))],
  150. ):
  151. return success_response(
  152. admin_ledger_service.update_suggestion_status(
  153. db,
  154. user,
  155. suggestion_id,
  156. status=body.status,
  157. handle_remark=body.handle_remark,
  158. )
  159. )
  160. @router.get("/hotline")
  161. def get_hotline(
  162. db: Annotated[Session, Depends(get_db)],
  163. _user: Annotated[SysUser, Depends(get_current_user)],
  164. ):
  165. return success_response(config_service.get_hotline_payload(db))
  166. @router.put("/hotline")
  167. def put_hotline(
  168. body: HotlineUpdateBody,
  169. db: Annotated[Session, Depends(get_db)],
  170. user: Annotated[SysUser, Depends(require_menu("hotline:write"))],
  171. ):
  172. return success_response(
  173. config_service.update_hotline(
  174. db,
  175. user,
  176. phone=body.phone,
  177. phones=body.phones,
  178. email=body.email,
  179. address=body.address,
  180. work_time=body.work_time,
  181. )
  182. )
  183. @router.get("/qrcode")
  184. def get_qrcode(
  185. db: Annotated[Session, Depends(get_db)],
  186. _user: Annotated[SysUser, Depends(get_current_user)],
  187. ):
  188. return success_response(config_service.get_qrcode_payload(db))
  189. @router.put("/qrcode")
  190. def put_qrcode(
  191. body: QrcodeUpdateBody,
  192. db: Annotated[Session, Depends(get_db)],
  193. user: Annotated[SysUser, Depends(require_menu("qrcode:write"))],
  194. ):
  195. return success_response(
  196. config_service.update_qrcode(
  197. db,
  198. user,
  199. land_url=body.land_url,
  200. title=body.title,
  201. image_url=body.image_url,
  202. )
  203. )
  204. @router.post("/attachment/upload")
  205. async def admin_upload(
  206. db: Annotated[Session, Depends(get_db)],
  207. _user: Annotated[SysUser, Depends(get_current_user)],
  208. file: UploadFile = File(...),
  209. folder: Optional[str] = Form(""),
  210. ):
  211. if not file or not file.filename:
  212. raise AppHTTPError(400, "请选择文件")
  213. try:
  214. folder_norm = _normalize_folder(folder or "")
  215. except StorageError as exc:
  216. raise AppHTTPError(400, exc.message) from exc
  217. content = await file.read()
  218. if folder_norm == "qrcode":
  219. validate_image_upload(file.filename, content)
  220. else:
  221. validate_upload(file.filename, content)
  222. try:
  223. stored = storage.upload_file(content, file.filename, folder_norm)
  224. except StorageError as exc:
  225. raise AppHTTPError(400, exc.message) from exc
  226. row = attachment_crud.create_attachment(
  227. db,
  228. file_name=file.filename,
  229. object_key=stored["objectKey"],
  230. url=stored["url"],
  231. size=len(content),
  232. )
  233. return success_response(
  234. {
  235. "id": row.id,
  236. "url": row.url,
  237. "fileName": row.file_name,
  238. "size": row.size,
  239. }
  240. )
  241. @router.get("/attachment/file")
  242. def download_attachment_file(
  243. user: Annotated[SysUser, Depends(get_current_user)],
  244. url: str = Query(...),
  245. ):
  246. try:
  247. stream, content_type, filename, inline = storage.open_file(url)
  248. except StorageError as exc:
  249. raise AppHTTPError(400, exc.message) from exc
  250. logger.info("attachment_proxy user_id=%s filename=%s", user.id, filename)
  251. disposition = "inline" if inline else "attachment"
  252. # ASCII-safe filename fallback for Content-Disposition
  253. safe_name = filename.encode("ascii", "ignore").decode() or "download"
  254. headers = {
  255. "X-Content-Type-Options": "nosniff",
  256. "Content-Disposition": f'{disposition}; filename="{safe_name}"',
  257. }
  258. return StreamingResponse(stream, media_type=content_type, headers=headers)
  259. @router.get("/users")
  260. def list_users(
  261. db: Annotated[Session, Depends(get_db)],
  262. _user: Annotated[SysUser, Depends(require_menu("user:manage"))],
  263. ):
  264. rows = user_crud.list_users(db)
  265. return success_response([_user_to_dict(db, u) for u in rows])
  266. @router.post("/users")
  267. def create_user(
  268. body: UserCreateBody,
  269. db: Annotated[Session, Depends(get_db)],
  270. _user: Annotated[SysUser, Depends(require_menu("user:manage"))],
  271. ):
  272. username = (body.username or "").strip()
  273. password = body.password or ""
  274. display_name = (body.display_name or "").strip()
  275. if not username or not password or not display_name:
  276. raise AppHTTPError(400, "用户名、密码、显示名不能为空")
  277. if user_crud.get_user_by_username(db, username):
  278. raise AppHTTPError(400, "用户名已存在")
  279. role = user_crud.get_role_by_id(db, body.role_id)
  280. if not role:
  281. raise AppHTTPError(400, "角色不存在")
  282. row = user_crud.create_user(
  283. db,
  284. username=username,
  285. password_hash=hash_password(password),
  286. display_name=display_name,
  287. role_id=role.id,
  288. )
  289. return success_response(_user_to_dict(db, row))
  290. @router.put("/users/{user_id}")
  291. def update_user(
  292. user_id: str,
  293. body: UserUpdateBody,
  294. db: Annotated[Session, Depends(get_db)],
  295. _user: Annotated[SysUser, Depends(require_menu("user:manage"))],
  296. ):
  297. row = user_crud.get_user_by_id(db, user_id)
  298. if not row:
  299. raise AppHTTPError(400, "用户不存在")
  300. role_id = body.role_id
  301. if role_id is not None:
  302. role = user_crud.get_role_by_id(db, role_id)
  303. if not role:
  304. raise AppHTTPError(400, "角色不存在")
  305. status = body.status
  306. if status is not None and status not in ("0", "1"):
  307. raise AppHTTPError(400, "状态无效")
  308. display_name = body.display_name
  309. if display_name is not None:
  310. display_name = display_name.strip()
  311. if not display_name:
  312. raise AppHTTPError(400, "显示名不能为空")
  313. updated = user_crud.update_user(
  314. db,
  315. row,
  316. display_name=display_name,
  317. role_id=role_id,
  318. status=status,
  319. )
  320. return success_response(_user_to_dict(db, updated))
  321. @router.put("/users/{user_id}/reset-password")
  322. def reset_user_password(
  323. user_id: str,
  324. body: ResetPasswordBody,
  325. db: Annotated[Session, Depends(get_db)],
  326. _user: Annotated[SysUser, Depends(require_menu("user:manage"))],
  327. ):
  328. row = user_crud.get_user_by_id(db, user_id)
  329. if not row:
  330. raise AppHTTPError(400, "用户不存在")
  331. password = body.password or ""
  332. if not password:
  333. raise AppHTTPError(400, "密码不能为空")
  334. user_crud.update_user(db, row, password_hash=hash_password(password))
  335. return success_response({"id": user_id})
  336. @router.get("/roles")
  337. def list_roles(
  338. db: Annotated[Session, Depends(get_db)],
  339. _user: Annotated[SysUser, Depends(get_current_user)],
  340. ):
  341. return success_response([_role_to_dict(r) for r in role_crud.list_roles(db)])
  342. @router.get("/menus/tree")
  343. def menus_tree(
  344. db: Annotated[Session, Depends(get_db)],
  345. _user: Annotated[SysUser, Depends(get_current_user)],
  346. ):
  347. menus = menu_crud.list_all_menus(db)
  348. return success_response(auth_service.build_menu_tree(menus))
  349. @router.get("/roles/{role_id}/menus")
  350. def get_role_menus(
  351. role_id: str,
  352. db: Annotated[Session, Depends(get_db)],
  353. _user: Annotated[SysUser, Depends(get_current_user)],
  354. ):
  355. role = role_crud.get_role_by_id(db, role_id)
  356. if not role:
  357. raise AppHTTPError(400, "角色不存在")
  358. return success_response({"menuIds": role_crud.list_menu_ids_for_role(db, role_id)})
  359. @router.put("/roles/{role_id}/menus")
  360. def put_role_menus(
  361. role_id: str,
  362. body: RoleMenusBody,
  363. db: Annotated[Session, Depends(get_db)],
  364. _user: Annotated[SysUser, Depends(require_menu("role:grant"))],
  365. ):
  366. role = role_crud.get_role_by_id(db, role_id)
  367. if not role:
  368. raise AppHTTPError(400, "角色不存在")
  369. menu_ids: List[str] = list(body.menu_ids or [])
  370. if menu_ids:
  371. valid_ids = {m.id for m in menu_crud.list_all_menus(db)}
  372. invalid = [mid for mid in menu_ids if mid not in valid_ids]
  373. if invalid:
  374. raise AppHTTPError(400, "存在无效菜单")
  375. role_crud.replace_role_menus(db, role_id, menu_ids)
  376. return success_response({"menuIds": menu_ids})
  377. @router.get("/oper-logs")
  378. def list_oper_logs(
  379. db: Annotated[Session, Depends(get_db)],
  380. _user: Annotated[SysUser, Depends(require_menu("audit"))],
  381. page: int = Query(1, ge=1),
  382. pageSize: int = Query(10, ge=1, le=100),
  383. ):
  384. items, total = oper_log_crud.list_oper_logs(db, page=page, page_size=pageSize)
  385. return success_response(
  386. {
  387. "list": [_oper_log_to_dict(r) for r in items],
  388. "total": total,
  389. "page": page,
  390. "pageSize": pageSize,
  391. }
  392. )