public.py 15 KB

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