public.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. """公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/zkxw.sql 基线)"""
  2. import os
  3. from typing import Optional
  4. from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File
  5. from sqlalchemy.exc import OperationalError
  6. from sqlalchemy.orm import Session
  7. from app.core.database import get_db
  8. from app.core.security import verify_password
  9. from app.core.auth import (
  10. create_access_token,
  11. create_refresh_token,
  12. decode_token,
  13. get_current_user_by_token,
  14. extend_session,
  15. is_refresh_token_valid,
  16. is_session_valid,
  17. revoke_refresh_token,
  18. revoke_session,
  19. store_refresh_token,
  20. store_session,
  21. )
  22. from app.core.response import success_response, error_response
  23. from app.core.config import settings
  24. from app.core.storage import storage
  25. from app.core.avatar_url import extract_avatar_storage_key
  26. from app.models.models import SysUser
  27. from app.crud.user import user_crud
  28. from app.core.utils import generate_uuid
  29. from app.core import login_crypto
  30. from app.services.web_auth_roles import (
  31. get_user_role_names,
  32. iter_user_menu_perm_codes,
  33. resolve_web_role_for_user,
  34. user_has_active_roles,
  35. )
  36. from app.schemas.auth import ProfileUpdateRequest
  37. from app.crud.user import update_user, change_password
  38. router = APIRouter(prefix="", tags=["公共接口"])
  39. def _avatar_url_field(user: SysUser) -> Optional[str]:
  40. """返回库中原始头像路径,由前端按 VITE_MINIO_BASE_URL 拼展示 URL。"""
  41. raw = getattr(user, "avatar", None)
  42. if not raw:
  43. return None
  44. s = str(raw).strip()
  45. return s if s else None
  46. def _build_user_info_payload(user: SysUser, web_role: str, role_names: list = None) -> dict:
  47. """与 web-admin `userInfo` 字段约定一致。"""
  48. phone = (getattr(user, "phone_no", None) or "").strip() or None
  49. return {
  50. "id": user.id,
  51. "username": user.user_no,
  52. "realName": user.nick_name,
  53. "phone": phone,
  54. "email": (getattr(user, "email", None) or "").strip() or None,
  55. "sex": getattr(user, "sex", None) or "0",
  56. "remark": (getattr(user, "remark", None) or "").strip() or None,
  57. "role": web_role,
  58. "roleNames": role_names or [],
  59. "regionId": user.dept_id,
  60. "regionName": None,
  61. "avatarUrl": _avatar_url_field(user),
  62. }
  63. def _build_current_user_payload(current_user: dict) -> dict:
  64. """组装 current-user 接口响应。"""
  65. au = current_user.get("avatar_url")
  66. avatar_url = str(au).strip() if au else None
  67. return {
  68. "id": current_user.get("user_id"),
  69. "username": current_user.get("username"),
  70. "realName": current_user.get("real_name"),
  71. "phone": current_user.get("phone"),
  72. "email": current_user.get("email"),
  73. "sex": current_user.get("sex") or "0",
  74. "remark": current_user.get("remark"),
  75. "role": current_user.get("role"),
  76. "roleNames": current_user.get("role_names") or current_user.get("roleNames") or [],
  77. "isSuperAdmin": current_user.get("role") == "super_admin",
  78. "regionId": current_user.get("region_id"),
  79. "regionName": None,
  80. "regionIds": [],
  81. "isActive": current_user.get("is_active"),
  82. "avatarUrl": avatar_url if avatar_url else None,
  83. }
  84. async def _resolve_current_user_from_request(request: Request, db: Session) -> dict:
  85. authorization = request.headers.get("Authorization", "")
  86. if not authorization.startswith("Bearer "):
  87. raise HTTPException(status_code=401, detail="未授权")
  88. token = authorization.replace("Bearer ", "")
  89. return await get_current_user_by_token(token=token, db=db)
  90. def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
  91. return {
  92. "sub": user.id,
  93. "username": user.user_no,
  94. "role": web_role,
  95. "region_id": user.dept_id,
  96. "real_name": user.nick_name,
  97. }
  98. @router.get("/web/auth/login-rsa-public-key", response_model=dict)
  99. async def web_login_rsa_public_key():
  100. """Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)"""
  101. login_crypto.ensure_initialized()
  102. return success_response({"publicKeyPem": login_crypto.get_public_pem()})
  103. @router.post("/web/auth/login", response_model=dict)
  104. async def web_login(
  105. request: Request,
  106. body: dict,
  107. db: Session = Depends(get_db),
  108. ):
  109. """Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。"""
  110. login_crypto.ensure_initialized()
  111. user_no, password, cred_err = login_crypto.extract_login_credentials(body)
  112. if cred_err:
  113. return error_response(400, cred_err)
  114. user = user_crud.get_by_user_no(db, user_no)
  115. if not user:
  116. return error_response(401, "用户名或密码错误")
  117. pwd_hash = getattr(user, "password", None) or ""
  118. if not verify_password(password, pwd_hash):
  119. return error_response(401, "用户名或密码错误")
  120. if user.status != "1":
  121. return error_response(403, "账号已被停用,请联系管理员")
  122. if not user_has_active_roles(db, user.id):
  123. return error_response(403, "账号未分配角色,请联系管理员")
  124. web_role = resolve_web_role_for_user(db, user.id)
  125. role_names = get_user_role_names(db, user.id)
  126. session_id = generate_uuid()
  127. access_jti = generate_uuid()
  128. refresh_jti = generate_uuid()
  129. access_token = create_access_token(
  130. data=_jwt_user_payload(user, web_role),
  131. session_id=session_id,
  132. token_jti=access_jti,
  133. )
  134. refresh_token = create_refresh_token(
  135. data={
  136. "sub": user.id,
  137. "username": user.user_no,
  138. "role": web_role,
  139. },
  140. session_id=session_id,
  141. refresh_jti=refresh_jti,
  142. )
  143. await store_session(session_id=session_id, user_id=user.id)
  144. await store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
  145. return success_response(
  146. {
  147. "token": access_token,
  148. "refreshToken": refresh_token,
  149. "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
  150. "userInfo": _build_user_info_payload(user, web_role, role_names),
  151. }
  152. )
  153. @router.post("/web/auth/refresh-token", response_model=dict)
  154. async def refresh_token(body: dict, db: Session = Depends(get_db)):
  155. """刷新 Token"""
  156. refresh_token_val = body.get("refreshToken")
  157. if not refresh_token_val:
  158. return error_response(400, "refreshToken不能为空")
  159. try:
  160. payload = decode_token(refresh_token_val)
  161. if payload.get("type") != "refresh":
  162. return error_response(401, "无效的refreshToken")
  163. session_id = payload.get("sid")
  164. refresh_jti = payload.get("jti")
  165. if not await is_session_valid(session_id=session_id):
  166. return error_response(401, "refreshToken已失效,请重新登录")
  167. if not await is_refresh_token_valid(refresh_jti=refresh_jti):
  168. return error_response(401, "无效的refreshToken")
  169. user_id = payload.get("sub")
  170. user = user_crud.get(db, user_id)
  171. if not user or user.del_flag != 0 or user.status != "1":
  172. return error_response(401, "用户不存在或已禁用")
  173. web_role = resolve_web_role_for_user(db, user.id)
  174. await revoke_refresh_token(refresh_jti=refresh_jti)
  175. await extend_session(session_id=session_id)
  176. new_access_jti = generate_uuid()
  177. new_refresh_jti = generate_uuid()
  178. new_access_token = create_access_token(
  179. data=_jwt_user_payload(user, web_role),
  180. session_id=session_id,
  181. token_jti=new_access_jti,
  182. )
  183. new_refresh_token = create_refresh_token(
  184. data={
  185. "sub": user.id,
  186. "username": user.user_no,
  187. "role": web_role,
  188. },
  189. session_id=session_id,
  190. refresh_jti=new_refresh_jti,
  191. )
  192. await store_refresh_token(refresh_jti=new_refresh_jti, user_id=user.id)
  193. return success_response(
  194. {
  195. "token": new_access_token,
  196. "refreshToken": new_refresh_token,
  197. "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
  198. }
  199. )
  200. except HTTPException:
  201. return error_response(401, "refreshToken已过期")
  202. @router.post("/web/auth/logout", response_model=dict)
  203. async def logout(request: Request):
  204. """登出:吊销 Redis 会话(若已配置 REDIS_HOST)。"""
  205. authorization = request.headers.get("Authorization", "")
  206. if not authorization.startswith("Bearer "):
  207. return error_response(401, "未授权")
  208. token = authorization.replace("Bearer ", "")
  209. try:
  210. payload = decode_token(token)
  211. session_id = payload.get("sid")
  212. if session_id:
  213. await revoke_session(session_id=session_id)
  214. except HTTPException:
  215. return error_response(401, "Token无效或已过期")
  216. return success_response({"message": "登出成功"})
  217. @router.get("/web/auth/current-user", response_model=dict)
  218. async def get_current_user_profile(request: Request, db: Session = Depends(get_db)):
  219. """获取当前用户信息(与 sys_user 字段对应)。"""
  220. try:
  221. current_user = await _resolve_current_user_from_request(request, db)
  222. return success_response(_build_current_user_payload(current_user))
  223. except HTTPException:
  224. return error_response(401, "Token无效或已过期")
  225. @router.put("/web/auth/profile", response_model=dict)
  226. async def update_current_user_profile(
  227. request: Request,
  228. body: ProfileUpdateRequest,
  229. db: Session = Depends(get_db),
  230. ):
  231. """当前用户更新个人信息(手机号、邮箱、性别、备注)。"""
  232. try:
  233. current_user = await _resolve_current_user_from_request(request, db)
  234. user_id = current_user.get("user_id")
  235. if not user_id:
  236. return error_response(401, "未授权")
  237. payload = body.model_dump(exclude_unset=True, by_alias=False)
  238. update_user(
  239. db,
  240. user_id,
  241. phone_no=payload.get("phone_no"),
  242. email=payload.get("email"),
  243. sex=payload.get("sex"),
  244. remark=payload.get("remark"),
  245. avatar=payload.get("avatar"),
  246. user_id_updater=user_id,
  247. )
  248. user = user_crud.get(db, user_id)
  249. if not user:
  250. return error_response(404, "用户不存在")
  251. web_role = resolve_web_role_for_user(db, user.id)
  252. role_names = get_user_role_names(db, user.id)
  253. return success_response(_build_user_info_payload(user, web_role, role_names))
  254. except HTTPException:
  255. return error_response(401, "Token无效或已过期")
  256. except ValueError as exc:
  257. return error_response(400, str(exc))
  258. @router.post("/web/auth/change-password", response_model=dict)
  259. async def change_current_user_password(
  260. request: Request,
  261. body: dict,
  262. db: Session = Depends(get_db),
  263. ):
  264. """当前用户修改密码。"""
  265. try:
  266. current_user = await _resolve_current_user_from_request(request, db)
  267. user_id = current_user.get("user_id")
  268. if not user_id:
  269. return error_response(401, "未授权")
  270. old_password = (body.get("oldPassword") or body.get("old_password") or "").strip()
  271. new_password = (body.get("newPassword") or body.get("new_password") or "").strip()
  272. if not old_password:
  273. return error_response(400, "请输入原密码")
  274. if len(new_password) < 6:
  275. return error_response(400, "新密码长度不能少于6位")
  276. change_password(db, user_id, old_password, new_password)
  277. return success_response({"message": "密码修改成功"})
  278. except HTTPException:
  279. return error_response(401, "Token无效或已过期")
  280. except ValueError as exc:
  281. return error_response(400, str(exc))
  282. _AVATAR_MAX_BYTES = 2 * 1024 * 1024
  283. _AVATAR_ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
  284. @router.post("/web/auth/upload-avatar", response_model=dict)
  285. async def upload_current_user_avatar(
  286. request: Request,
  287. file: UploadFile = File(...),
  288. db: Session = Depends(get_db),
  289. ):
  290. """当前用户上传头像(仅图片,最大 2MB)。"""
  291. try:
  292. await _resolve_current_user_from_request(request, db)
  293. except HTTPException:
  294. return error_response(401, "Token无效或已过期")
  295. if not file or not file.filename:
  296. return error_response(400, "请选择图片")
  297. ext = os.path.splitext(file.filename)[1].lower()
  298. if ext not in _AVATAR_ALLOWED_EXT:
  299. return error_response(400, "仅支持 JPG、PNG、GIF、WEBP、BMP 格式")
  300. content_type = (file.content_type or "").lower()
  301. if content_type and not content_type.startswith("image/"):
  302. return error_response(400, "仅支持上传图片文件")
  303. try:
  304. file_content = await file.read()
  305. if not file_content:
  306. return error_response(400, "文件内容为空")
  307. if len(file_content) > _AVATAR_MAX_BYTES:
  308. return error_response(400, "头像大小不能超过 2MB")
  309. url = storage.upload_file(file_content, file.filename, "avatar")
  310. stored_url = extract_avatar_storage_key(url)
  311. return success_response({"url": stored_url})
  312. except Exception as exc:
  313. return error_response(500, f"上传失败: {str(exc)}")
  314. @router.get("/web/auth/menu-codes", response_model=dict)
  315. async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db)):
  316. """获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。"""
  317. authorization = request.headers.get("Authorization", "")
  318. if not authorization.startswith("Bearer "):
  319. return error_response(401, "未授权")
  320. token = authorization.replace("Bearer ", "")
  321. try:
  322. current_user = await get_current_user_by_token(token=token, db=db)
  323. role = current_user.get("role")
  324. if role == "super_admin":
  325. return success_response(
  326. {
  327. "isSuperAdmin": True,
  328. "all": True,
  329. "menuCodes": [],
  330. }
  331. )
  332. try:
  333. codes = iter_user_menu_perm_codes(db, current_user.get("user_id") or "")
  334. menu_codes = sorted(codes)
  335. except OperationalError:
  336. menu_codes = []
  337. return success_response(
  338. {
  339. "isSuperAdmin": False,
  340. "all": False,
  341. "menuCodes": menu_codes,
  342. }
  343. )
  344. except HTTPException:
  345. return error_response(401, "Token无效或已过期")