public.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440
  1. """公共接口(Web 登录、刷新 Token、登出;用户模型对齐 docs/sql/shanxia2.0.sql 基线)"""
  2. from typing import Optional
  3. from fastapi import APIRouter, Depends, HTTPException, Request
  4. from sqlalchemy.exc import OperationalError
  5. from sqlalchemy.orm import Session
  6. from app.core.database import get_db
  7. from app.core.security import verify_password
  8. from app.core.auth import (
  9. create_access_token,
  10. create_refresh_token,
  11. decode_token,
  12. get_current_user_by_token,
  13. extend_session,
  14. is_refresh_token_valid,
  15. is_session_valid,
  16. revoke_refresh_token,
  17. revoke_session,
  18. store_refresh_token,
  19. store_session,
  20. )
  21. from app.core.response import success_response, error_response
  22. from app.core.config import settings
  23. from app.models.models import SysUser
  24. from app.crud.user import user_crud, change_password
  25. from app.core.utils import generate_uuid
  26. from app.core import login_crypto
  27. from app.services.web_auth_roles import (
  28. iter_user_menu_perm_codes,
  29. resolve_web_role_for_user,
  30. user_has_bound_roles,
  31. )
  32. from app.services.mp_webview_ticket import TICKET_TTL_SEC, consume_ticket, issue_ticket
  33. from app.crud.crm.quote import view_by_share_code
  34. router = APIRouter(prefix="", tags=["公共接口"])
  35. def _avatar_url_field(user: SysUser) -> Optional[str]:
  36. raw = getattr(user, "avatar", None)
  37. if not raw:
  38. return None
  39. s = str(raw).strip()
  40. return s if s else None
  41. def _web_role_list(web_role: str) -> list[str]:
  42. """与 web-admin 静态路由 meta.roles(admin/super_admin)对齐的单元素数组。"""
  43. role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
  44. return [role]
  45. def _build_user_info_payload(user: SysUser, web_role: str) -> dict:
  46. """与 web-admin `userInfo` 字段约定一致。"""
  47. phone = (getattr(user, "phone_no", None) or "").strip() or None
  48. role = (web_role or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
  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. "role": role,
  56. "roles": _web_role_list(role),
  57. "regionId": user.dept_id,
  58. "regionName": None,
  59. "avatarUrl": _avatar_url_field(user),
  60. }
  61. def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
  62. tenant_no = (getattr(user, "tenant_no", None) or "").strip() or settings.DEFAULT_TENANT_NO
  63. return {
  64. "sub": user.id,
  65. "username": user.user_no,
  66. "role": web_role,
  67. "region_id": user.dept_id,
  68. "real_name": user.nick_name,
  69. "tenant_no": tenant_no,
  70. }
  71. @router.get("/web/auth/login-rsa-public-key", response_model=dict)
  72. def web_login_rsa_public_key():
  73. """Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)"""
  74. login_crypto.ensure_initialized()
  75. return success_response({"publicKeyPem": login_crypto.get_public_pem()})
  76. @router.post("/web/auth/login", response_model=dict)
  77. def web_login(
  78. request: Request,
  79. body: dict,
  80. db: Session = Depends(get_db),
  81. ):
  82. """Web 端登录:账号为 sys_user.user_no,密码为 sys_user.password(bcrypt)。"""
  83. login_crypto.ensure_initialized()
  84. user_no, password, cred_err = login_crypto.extract_login_credentials(body)
  85. if cred_err:
  86. return error_response(400, cred_err)
  87. user = user_crud.get_by_user_no_for_web_login(db, user_no)
  88. if not user:
  89. return error_response(400, "用户名或密码错误")
  90. pwd_hash = getattr(user, "password", None) or ""
  91. if not verify_password(password, pwd_hash):
  92. return error_response(400, "用户名或密码错误")
  93. if user.status != "1":
  94. return error_response(403, "账号已被停用,请联系管理员")
  95. web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
  96. session_id = generate_uuid()
  97. access_jti = generate_uuid()
  98. refresh_jti = generate_uuid()
  99. access_token = create_access_token(
  100. data=_jwt_user_payload(user, web_role),
  101. session_id=session_id,
  102. token_jti=access_jti,
  103. )
  104. refresh_token = create_refresh_token(
  105. data={
  106. "sub": user.id,
  107. "username": user.user_no,
  108. "role": web_role,
  109. },
  110. session_id=session_id,
  111. refresh_jti=refresh_jti,
  112. )
  113. store_session(session_id=session_id, user_id=user.id)
  114. store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
  115. return success_response(
  116. {
  117. "token": access_token,
  118. "refreshToken": refresh_token,
  119. "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
  120. "userInfo": _build_user_info_payload(user, web_role),
  121. }
  122. )
  123. @router.post("/web/auth/refresh-token", response_model=dict)
  124. def refresh_token(body: dict, db: Session = Depends(get_db)):
  125. """刷新 Token"""
  126. refresh_token_val = body.get("refreshToken")
  127. if not refresh_token_val:
  128. return error_response(400, "refreshToken不能为空")
  129. try:
  130. payload = decode_token(refresh_token_val)
  131. if payload.get("type") != "refresh":
  132. return error_response(401, "无效的refreshToken")
  133. session_id = payload.get("sid")
  134. refresh_jti = payload.get("jti")
  135. if not is_session_valid(session_id=session_id):
  136. return error_response(401, "refreshToken已失效,请重新登录")
  137. if not is_refresh_token_valid(refresh_jti=refresh_jti):
  138. return error_response(401, "无效的refreshToken")
  139. user_id = payload.get("sub")
  140. user = user_crud.get(db, user_id)
  141. if not user or user.del_flag != 0 or user.status != "1":
  142. return error_response(401, "用户不存在或已禁用")
  143. web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
  144. revoke_refresh_token(refresh_jti=refresh_jti)
  145. extend_session(session_id=session_id)
  146. new_access_jti = generate_uuid()
  147. new_refresh_jti = generate_uuid()
  148. new_access_token = create_access_token(
  149. data=_jwt_user_payload(user, web_role),
  150. session_id=session_id,
  151. token_jti=new_access_jti,
  152. )
  153. new_refresh_token = create_refresh_token(
  154. data={
  155. "sub": user.id,
  156. "username": user.user_no,
  157. "role": web_role,
  158. },
  159. session_id=session_id,
  160. refresh_jti=new_refresh_jti,
  161. )
  162. store_refresh_token(refresh_jti=new_refresh_jti, user_id=user.id)
  163. return success_response(
  164. {
  165. "token": new_access_token,
  166. "refreshToken": new_refresh_token,
  167. "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
  168. }
  169. )
  170. except HTTPException:
  171. return error_response(401, "refreshToken已过期")
  172. @router.post("/web/auth/mp-webview-ticket", response_model=dict)
  173. def mp_webview_ticket(request: Request, db: Session = Depends(get_db)):
  174. """小程序内嵌 WebView:签发一次性短时 ticket(需 Bearer)。"""
  175. authorization = request.headers.get("Authorization", "")
  176. if not authorization.startswith("Bearer "):
  177. return error_response(401, "未授权")
  178. token = authorization.replace("Bearer ", "").strip()
  179. try:
  180. current_user = get_current_user_by_token(token=token, db=db)
  181. payload = decode_token(token)
  182. except HTTPException:
  183. return error_response(401, "Token无效或已过期")
  184. session_id = str(payload.get("sid") or "").strip()
  185. if not session_id:
  186. return error_response(401, "Token无效或已过期")
  187. try:
  188. ticket = issue_ticket(
  189. user_id=str(current_user.get("user_id") or ""),
  190. session_id=session_id,
  191. refresh_jti=str(payload.get("jti") or ""),
  192. )
  193. except RuntimeError:
  194. return error_response(503, "暂时无法打开内嵌页,请稍后重试")
  195. return success_response({"ticket": ticket, "expiresIn": TICKET_TTL_SEC})
  196. @router.post("/web/auth/mp-webview-exchange", response_model=dict)
  197. def mp_webview_exchange(body: dict, db: Session = Depends(get_db)):
  198. """小程序内嵌 WebView:用 ticket 兑换 Web JWT(不吊销原 refresh)。"""
  199. ticket = str((body or {}).get("ticket") or "").strip()
  200. if not ticket:
  201. return error_response(400, "ticket不能为空")
  202. try:
  203. data = consume_ticket(ticket)
  204. except RuntimeError:
  205. return error_response(503, "暂时无法打开内嵌页,请稍后重试")
  206. if not data:
  207. return error_response(401, "ticket无效或已过期")
  208. user_id = str(data.get("userId") or "").strip()
  209. session_id = str(data.get("sessionId") or "").strip()
  210. if not user_id or not session_id:
  211. return error_response(401, "ticket无效或已过期")
  212. if not is_session_valid(session_id):
  213. return error_response(401, "登录已失效,请重新登录")
  214. user = user_crud.get(db, user_id)
  215. if not user or getattr(user, "del_flag", 0) != 0:
  216. return error_response(401, "用户不存在或已删除")
  217. if user.status != "1":
  218. return error_response(403, "账号已被停用,请联系管理员")
  219. web_role = resolve_web_role_for_user(db, user.id, user.tenant_no)
  220. access_jti = generate_uuid()
  221. refresh_jti = generate_uuid()
  222. access_token = create_access_token(
  223. data=_jwt_user_payload(user, web_role),
  224. session_id=session_id,
  225. token_jti=access_jti,
  226. )
  227. refresh_token_val = create_refresh_token(
  228. data={"sub": user.id, "username": user.user_no, "role": web_role},
  229. session_id=session_id,
  230. refresh_jti=refresh_jti,
  231. )
  232. store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
  233. return success_response(
  234. {
  235. "token": access_token,
  236. "refreshToken": refresh_token_val,
  237. "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
  238. "userInfo": _build_user_info_payload(user, web_role),
  239. }
  240. )
  241. @router.post("/web/auth/logout", response_model=dict)
  242. def logout(request: Request):
  243. """登出:吊销 Redis 会话(若已配置 REDIS_HOST)。无 Token 或已失效时仍返回成功(幂等)。"""
  244. authorization = request.headers.get("Authorization", "")
  245. if authorization.startswith("Bearer "):
  246. token = authorization.replace("Bearer ", "")
  247. try:
  248. payload = decode_token(token)
  249. session_id = payload.get("sid")
  250. if session_id:
  251. revoke_session(session_id=session_id)
  252. except HTTPException:
  253. pass
  254. return success_response({"message": "登出成功"})
  255. @router.get("/web/auth/current-user", response_model=dict)
  256. def get_current_user_profile(request: Request, db: Session = Depends(get_db)):
  257. """获取当前用户信息(与 sys_user 字段对应)。"""
  258. authorization = request.headers.get("Authorization", "")
  259. if not authorization.startswith("Bearer "):
  260. return error_response(401, "未授权")
  261. token = authorization.replace("Bearer ", "")
  262. try:
  263. current_user = get_current_user_by_token(token=token, db=db)
  264. au = current_user.get("avatar_url")
  265. avatar_url = str(au).strip() if au else None
  266. web_role = str(current_user.get("role") or "").strip() or settings.WEB_AUTH_DEFAULT_ROLE
  267. return success_response(
  268. {
  269. "id": current_user.get("user_id"),
  270. "username": current_user.get("username"),
  271. "realName": current_user.get("real_name"),
  272. "phone": current_user.get("phone"),
  273. "email": current_user.get("email"),
  274. "role": web_role,
  275. "roles": _web_role_list(web_role),
  276. "isSuperAdmin": web_role == "super_admin",
  277. "regionId": current_user.get("region_id"),
  278. "regionName": None,
  279. "regionIds": [],
  280. "isActive": current_user.get("is_active"),
  281. "avatarUrl": avatar_url if avatar_url else None,
  282. }
  283. )
  284. except HTTPException:
  285. return error_response(401, "Token无效或已过期")
  286. @router.get("/web/auth/menu-codes", response_model=dict)
  287. def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db)):
  288. """获取当前用户菜单权限标识(sys_menu.perms,经角色继承)。"""
  289. authorization = request.headers.get("Authorization", "")
  290. if not authorization.startswith("Bearer "):
  291. return error_response(401, "未授权")
  292. token = authorization.replace("Bearer ", "")
  293. try:
  294. current_user = get_current_user_by_token(token=token, db=db)
  295. user_id = current_user.get("user_id") or ""
  296. tenant_no = current_user.get("tenant_no")
  297. if not user_has_bound_roles(db, user_id, tenant_no):
  298. return success_response(
  299. {
  300. "isSuperAdmin": False,
  301. "all": False,
  302. "menuCodes": [],
  303. }
  304. )
  305. role = current_user.get("role")
  306. if role == "super_admin":
  307. return success_response(
  308. {
  309. "isSuperAdmin": True,
  310. "all": True,
  311. "menuCodes": [],
  312. }
  313. )
  314. try:
  315. codes = iter_user_menu_perm_codes(db, user_id, tenant_no)
  316. menu_codes = sorted(codes)
  317. except OperationalError:
  318. menu_codes = []
  319. return success_response(
  320. {
  321. "isSuperAdmin": False,
  322. "all": False,
  323. "menuCodes": menu_codes,
  324. }
  325. )
  326. except HTTPException:
  327. return error_response(401, "Token无效或已过期")
  328. @router.post("/web/auth/change-password", response_model=dict)
  329. def change_current_user_password(
  330. request: Request,
  331. body: dict,
  332. db: Session = Depends(get_db),
  333. ):
  334. """当前用户修改密码。"""
  335. authorization = request.headers.get("Authorization", "")
  336. if not authorization.startswith("Bearer "):
  337. return error_response(401, "未授权")
  338. token = authorization.replace("Bearer ", "")
  339. try:
  340. current_user = get_current_user_by_token(token=token, db=db)
  341. user_id = current_user.get("user_id")
  342. if not user_id:
  343. return error_response(401, "未授权")
  344. old_password = (body.get("oldPassword") or body.get("old_password") or "").strip()
  345. new_password = (body.get("newPassword") or body.get("new_password") or "").strip()
  346. if not old_password:
  347. return error_response(400, "请输入原密码")
  348. if len(new_password) < 6:
  349. return error_response(400, "新密码长度不能少于6位")
  350. change_password(db, user_id, old_password, new_password)
  351. return success_response({"message": "密码修改成功"})
  352. except HTTPException:
  353. return error_response(401, "Token无效或已过期")
  354. except ValueError as exc:
  355. return error_response(400, str(exc))
  356. @router.get("/public/crm/quote/share/{share_code}")
  357. def public_quote_share(
  358. share_code: str,
  359. request: Request,
  360. password: str = "",
  361. db: Session = Depends(get_db),
  362. ):
  363. """公开分享查看报价单 — Java: GET /share/{shareCode}(无 JWT)"""
  364. try:
  365. ip_address = request.client.host if request.client else ""
  366. user_agent = request.headers.get("user-agent", "")
  367. detail = view_by_share_code(
  368. db,
  369. share_code,
  370. password=password or "",
  371. ip_address=ip_address,
  372. user_agent=user_agent,
  373. )
  374. return success_response(detail)
  375. except ValueError as e:
  376. return error_response(400, str(e))
  377. except Exception as e:
  378. return error_response(500, str(e))