admin.py 16 KB

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