public.py 13 KB

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