public.py 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. """公共接口(登录等)"""
  2. import random
  3. import string
  4. from datetime import timedelta
  5. import httpx
  6. from typing import Optional, Tuple
  7. from fastapi import APIRouter, Depends, HTTPException, status, Request
  8. from fastapi.security import OAuth2PasswordRequestForm
  9. from sqlalchemy.orm import Session
  10. from app.core.database import get_db
  11. from app.core.security import verify_password, get_password_hash
  12. from app.core.auth import (
  13. create_access_token,
  14. create_refresh_token,
  15. decode_token,
  16. get_client_ip,
  17. get_current_user,
  18. get_current_user_by_token,
  19. extend_session,
  20. is_refresh_token_valid,
  21. is_session_valid,
  22. revoke_refresh_token,
  23. revoke_session,
  24. store_refresh_token,
  25. store_session,
  26. )
  27. from app.core.config import settings
  28. from app.core.response import success_response, error_response
  29. from app.models.models import SysUser, SysUserMenuScope, SysUserRegionScope
  30. from app.crud.user import user_crud
  31. from app.crud.region import region_crud
  32. from app.crud.password_history import password_history_crud
  33. from app.core.utils import generate_uuid
  34. from app.core import login_crypto
  35. router = APIRouter(prefix="", tags=["公共接口"])
  36. def _avatar_url_field(user: SysUser) -> Optional[str]:
  37. raw = getattr(user, "avatar_url", None)
  38. if not raw:
  39. return None
  40. s = str(raw).strip()
  41. return s if s else None
  42. async def _wx_mini_code_to_openid(js_code: str) -> Tuple[Optional[str], Optional[str]]:
  43. """调用微信 jscode2session,成功返回 (openid, None),失败返回 (None, 错误信息)。"""
  44. appid = settings.WXPAY_MINI_APPID
  45. secret = settings.WXPAY_SECRET
  46. if not appid or not secret:
  47. return None, "后端未配置小程序 AppID/Secret"
  48. async with httpx.AsyncClient() as client:
  49. r = await client.get(
  50. "https://api.weixin.qq.com/sns/jscode2session",
  51. params={
  52. "appid": appid,
  53. "secret": secret,
  54. "js_code": js_code,
  55. "grant_type": "authorization_code",
  56. },
  57. timeout=10.0,
  58. )
  59. data = r.json()
  60. if data.get("errcode"):
  61. return None, data.get("errmsg") or "微信登录失败"
  62. openid = data.get("openid")
  63. if not openid:
  64. return None, "未获取到 openid"
  65. return openid, None
  66. @router.get("/web/auth/login-rsa-public-key", response_model=dict)
  67. async def web_login_rsa_public_key():
  68. """Web 端登录用 RSA 公钥(PKCS#1 v1.5,与 JSEncrypt 一致)"""
  69. login_crypto.ensure_initialized()
  70. return success_response({"publicKeyPem": login_crypto.get_public_pem()})
  71. @router.get("/mini/auth/login-rsa-public-key", response_model=dict)
  72. async def mini_login_rsa_public_key():
  73. """小程序端登录用 RSA 公钥"""
  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. async def web_login(
  78. request: Request,
  79. body: dict,
  80. db: Session = Depends(get_db)
  81. ):
  82. """Web端登录(默认仅接受 encryptedLogin;可配置 LOGIN_ALLOW_PLAINTEXT_LOGIN 明文排障)"""
  83. login_crypto.ensure_initialized()
  84. username, 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_username(db, username)
  88. if not user:
  89. return error_response(401, "用户名或密码错误")
  90. if not verify_password(password, user.password_hash):
  91. return error_response(401, "用户名或密码错误")
  92. if not user.is_active:
  93. return error_response(403, "账号已被禁用,请联系管理员")
  94. region_name = None
  95. if user.region_id:
  96. region = region_crud.get(db, user.region_id)
  97. region_name = region.name if region else None
  98. # 创建 Redis 会话(用于强制登出/吊销)
  99. session_id = generate_uuid()
  100. access_jti = generate_uuid()
  101. refresh_jti = generate_uuid()
  102. access_token = create_access_token(
  103. data={
  104. "sub": user.id,
  105. "username": user.username,
  106. "role": user.role,
  107. "region_id": user.region_id,
  108. "real_name": user.real_name
  109. },
  110. session_id=session_id,
  111. token_jti=access_jti,
  112. )
  113. refresh_token = create_refresh_token(
  114. data={
  115. "sub": user.id,
  116. "username": user.username,
  117. "role": user.role
  118. },
  119. session_id=session_id,
  120. refresh_jti=refresh_jti,
  121. )
  122. await store_session(session_id=session_id, user_id=user.id)
  123. await store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
  124. return success_response({
  125. "token": access_token,
  126. "refreshToken": refresh_token,
  127. "expiresIn": 7200,
  128. "userInfo": {
  129. "id": user.id,
  130. "username": user.username,
  131. "realName": user.real_name,
  132. "phone": user.phone,
  133. "role": user.role,
  134. "regionId": user.region_id,
  135. "regionName": region_name,
  136. "avatarUrl": _avatar_url_field(user),
  137. }
  138. })
  139. @router.post("/web/auth/refresh-token", response_model=dict)
  140. async def refresh_token(
  141. body: dict,
  142. db: Session = Depends(get_db)
  143. ):
  144. """刷新Token"""
  145. refresh_token = body.get("refreshToken")
  146. if not refresh_token:
  147. return error_response(400, "refreshToken不能为空")
  148. from app.core.auth import decode_token
  149. try:
  150. payload = decode_token(refresh_token)
  151. if payload.get("type") != "refresh":
  152. return error_response(401, "无效的refreshToken")
  153. session_id = payload.get("sid")
  154. refresh_jti = payload.get("jti")
  155. if not await is_session_valid(session_id=session_id):
  156. return error_response(401, "refreshToken已失效,请重新登录")
  157. if not await is_refresh_token_valid(refresh_jti=refresh_jti):
  158. return error_response(401, "无效的refreshToken")
  159. user_id = payload.get("sub")
  160. user = user_crud.get(db, user_id)
  161. if not user or not user.is_active:
  162. return error_response(401, "用户不存在或已禁用")
  163. # refreshToken 只允许使用一次:先吊销旧的 jti
  164. await revoke_refresh_token(refresh_jti=refresh_jti)
  165. # 同步延长会话 TTL(避免刷新后会话过期)
  166. await extend_session(session_id=session_id)
  167. new_access_jti = generate_uuid()
  168. new_refresh_jti = generate_uuid()
  169. new_access_token = create_access_token(
  170. data={
  171. "sub": user.id,
  172. "username": user.username,
  173. "role": user.role,
  174. "region_id": user.region_id,
  175. "real_name": user.real_name
  176. },
  177. session_id=session_id,
  178. token_jti=new_access_jti,
  179. )
  180. new_refresh_token = create_refresh_token(
  181. data={
  182. "sub": user.id,
  183. "username": user.username,
  184. "role": user.role
  185. },
  186. session_id=session_id,
  187. refresh_jti=new_refresh_jti,
  188. )
  189. await store_refresh_token(refresh_jti=new_refresh_jti, user_id=user.id)
  190. return success_response({
  191. "token": new_access_token,
  192. "refreshToken": new_refresh_token,
  193. "expiresIn": 7200
  194. })
  195. except HTTPException:
  196. return error_response(401, "refreshToken已过期")
  197. @router.post("/web/auth/logout", response_model=dict)
  198. async def logout(request: Request):
  199. """登出"""
  200. authorization = request.headers.get("Authorization", "")
  201. if not authorization.startswith("Bearer "):
  202. return error_response(401, "未授权")
  203. token = authorization.replace("Bearer ", "")
  204. try:
  205. payload = decode_token(token)
  206. session_id = payload.get("sid")
  207. if session_id:
  208. await revoke_session(session_id=session_id)
  209. except HTTPException:
  210. return error_response(401, "Token无效或已过期")
  211. return success_response({"message": "登出成功"})
  212. @router.get("/web/auth/current-user", response_model=dict)
  213. async def get_current_user(request: Request, db: Session = Depends(get_db)):
  214. """获取当前用户信息"""
  215. authorization = request.headers.get("Authorization", "")
  216. if not authorization.startswith("Bearer "):
  217. return error_response(401, "未授权")
  218. token = authorization.replace("Bearer ", "")
  219. try:
  220. current_user = await get_current_user_by_token(token=token, db=db)
  221. region_name = None
  222. if current_user.get("region_id"):
  223. region = region_crud.get(db, current_user.get("region_id"))
  224. region_name = region.name if region else None
  225. region_scope_rows = (
  226. db.query(SysUserRegionScope.region_id)
  227. .filter(
  228. SysUserRegionScope.user_id == current_user.get("user_id"),
  229. SysUserRegionScope.is_del == 0,
  230. )
  231. .all()
  232. )
  233. region_ids = sorted({str(row[0]).strip() for row in region_scope_rows if row and row[0]})
  234. au = current_user.get("avatar_url")
  235. avatar_url = str(au).strip() if au else None
  236. return success_response({
  237. "id": current_user.get("user_id"),
  238. "username": current_user.get("username"),
  239. "realName": current_user.get("real_name"),
  240. "phone": current_user.get("phone"),
  241. "role": current_user.get("role"),
  242. "isSuperAdmin": current_user.get("role") == "super_admin",
  243. "regionId": current_user.get("region_id"),
  244. "regionName": region_name,
  245. "regionIds": region_ids,
  246. "isActive": current_user.get("is_active"),
  247. "avatarUrl": avatar_url if avatar_url else None,
  248. })
  249. except HTTPException:
  250. return error_response(401, "Token无效或已过期")
  251. @router.get("/web/auth/menu-codes", response_model=dict)
  252. async def get_web_auth_menu_codes(request: Request, db: Session = Depends(get_db)):
  253. """获取当前用户可见菜单 code 列表。super_admin 永远全菜单。"""
  254. authorization = request.headers.get("Authorization", "")
  255. if not authorization.startswith("Bearer "):
  256. return error_response(401, "未授权")
  257. token = authorization.replace("Bearer ", "")
  258. try:
  259. current_user = await get_current_user_by_token(token=token, db=db)
  260. role = current_user.get("role")
  261. if role == "super_admin":
  262. return success_response({
  263. "isSuperAdmin": True,
  264. "all": True,
  265. "menuCodes": []
  266. })
  267. rows = (
  268. db.query(SysUserMenuScope.menu_code)
  269. .filter(
  270. SysUserMenuScope.user_id == current_user.get("user_id"),
  271. SysUserMenuScope.is_del == 0,
  272. )
  273. .all()
  274. )
  275. menu_codes = sorted({str(row[0]).strip() for row in rows if row and row[0]})
  276. return success_response({
  277. "isSuperAdmin": False,
  278. "all": False,
  279. "menuCodes": menu_codes
  280. })
  281. except HTTPException:
  282. return error_response(401, "Token无效或已过期")
  283. # ============ 小程序端接口 ============
  284. @router.post("/mini/auth/login", response_model=dict)
  285. async def mini_login(
  286. request: Request,
  287. body: dict,
  288. db: Session = Depends(get_db)
  289. ):
  290. """小程序端登录(默认仅接受 encryptedLogin)"""
  291. login_crypto.ensure_initialized()
  292. username, password, cred_err = login_crypto.extract_login_credentials(body)
  293. if cred_err:
  294. return error_response(400, cred_err)
  295. user = user_crud.get_by_username(db, username)
  296. if not user:
  297. return error_response(401, "用户名或密码错误")
  298. if not verify_password(password, user.password_hash):
  299. return error_response(401, "用户名或密码错误")
  300. if not user.is_active:
  301. return error_response(403, "账号已被禁用,请联系管理员")
  302. # 小程序端只允许 supplier/client/deliver 登录
  303. if user.role == "admin":
  304. return error_response(403, "运营账号不允许在小程序登录,请使用Web端")
  305. region_name = None
  306. if user.region_id:
  307. region = region_crud.get(db, user.region_id)
  308. region_name = region.name if region else None
  309. session_id = generate_uuid()
  310. access_jti = generate_uuid()
  311. refresh_jti = generate_uuid()
  312. access_token = create_access_token(
  313. data={
  314. "sub": user.id,
  315. "username": user.username,
  316. "role": user.role,
  317. "region_id": user.region_id,
  318. "real_name": user.real_name
  319. },
  320. session_id=session_id,
  321. token_jti=access_jti,
  322. )
  323. refresh_token = create_refresh_token(
  324. data={
  325. "sub": user.id,
  326. "username": user.username,
  327. "role": user.role
  328. },
  329. session_id=session_id,
  330. refresh_jti=refresh_jti,
  331. )
  332. await store_session(session_id=session_id, user_id=user.id)
  333. await store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
  334. return success_response({
  335. "token": access_token,
  336. "refreshToken": refresh_token,
  337. "expiresIn": 7200,
  338. "userInfo": {
  339. "id": user.id,
  340. "username": user.username,
  341. "realName": user.real_name,
  342. "phone": user.phone,
  343. "role": user.role,
  344. "regionId": user.region_id,
  345. "regionName": region_name,
  346. "avatarUrl": _avatar_url_field(user),
  347. }
  348. })
  349. @router.post("/mini/auth/sms-login", response_model=dict)
  350. async def mini_sms_login(
  351. request: Request,
  352. body: dict,
  353. db: Session = Depends(get_db)
  354. ):
  355. """微信小程序 wx.login 返回的 code 登录(需已在系统中绑定 wx_mini_openid)"""
  356. code = body.get("code")
  357. if not code:
  358. return error_response(400, "code不能为空")
  359. openid, wx_err = await _wx_mini_code_to_openid(code)
  360. if wx_err:
  361. return error_response(400, wx_err)
  362. user = user_crud.get_by_wx_mini_openid(db, openid)
  363. if not user:
  364. return error_response(401, "未绑定微信,请先使用账号密码登录并绑定")
  365. if not user.is_active:
  366. return error_response(403, "账号已被禁用,请联系管理员")
  367. if user.role == "admin":
  368. return error_response(403, "运营账号不允许在小程序登录,请使用Web端")
  369. region_name = None
  370. if user.region_id:
  371. region = region_crud.get(db, user.region_id)
  372. region_name = region.name if region else None
  373. session_id = generate_uuid()
  374. access_jti = generate_uuid()
  375. refresh_jti = generate_uuid()
  376. access_token = create_access_token(
  377. data={
  378. "sub": user.id,
  379. "username": user.username,
  380. "role": user.role,
  381. "region_id": user.region_id,
  382. "real_name": user.real_name
  383. },
  384. session_id=session_id,
  385. token_jti=access_jti,
  386. )
  387. refresh_token = create_refresh_token(
  388. data={
  389. "sub": user.id,
  390. "username": user.username,
  391. "role": user.role
  392. },
  393. session_id=session_id,
  394. refresh_jti=refresh_jti,
  395. )
  396. await store_session(session_id=session_id, user_id=user.id)
  397. await store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
  398. return success_response({
  399. "token": access_token,
  400. "refreshToken": refresh_token,
  401. "expiresIn": 7200,
  402. "userInfo": {
  403. "id": user.id,
  404. "username": user.username,
  405. "realName": user.real_name,
  406. "phone": user.phone,
  407. "role": user.role,
  408. "regionId": user.region_id,
  409. "regionName": region_name,
  410. "avatarUrl": _avatar_url_field(user),
  411. }
  412. })
  413. @router.post("/mini/auth/bind-wechat", response_model=dict)
  414. async def mini_bind_wechat(
  415. body: dict,
  416. db: Session = Depends(get_db),
  417. current_user: dict = Depends(get_current_user),
  418. ):
  419. """将当前登录账号与微信 openid 绑定(绑定后可使用 sms-login)"""
  420. code = body.get("code")
  421. if not code:
  422. return error_response(400, "code不能为空")
  423. openid, wx_err = await _wx_mini_code_to_openid(code)
  424. if wx_err:
  425. return error_response(400, wx_err)
  426. if current_user.get("role") == "admin":
  427. return error_response(403, "运营账号不允许绑定小程序")
  428. existing = user_crud.get_by_wx_mini_openid(db, openid)
  429. if existing and existing.id != current_user.get("user_id"):
  430. return error_response(400, "该微信已绑定其他账号")
  431. user_crud.update_with_history(
  432. db,
  433. current_user.get("user_id"),
  434. {"wx_mini_openid": openid},
  435. {"user_id": current_user.get("user_id"), "real_name": current_user.get("real_name")},
  436. )
  437. return success_response({"ok": True})
  438. @router.post("/mini/auth/code-openid", response_model=dict)
  439. async def mini_code_openid(body: dict):
  440. """wx.login 换取 openid(用于调起微信支付等,code 仅单次有效)"""
  441. code = body.get("code")
  442. if not code:
  443. return error_response(400, "code不能为空")
  444. openid, wx_err = await _wx_mini_code_to_openid(code)
  445. if wx_err:
  446. return error_response(400, wx_err)
  447. return success_response({"openid": openid})
  448. @router.get("/mini/auth/current-user", response_model=dict)
  449. async def get_mini_current_user(request: Request, db: Session = Depends(get_db)):
  450. """小程序端获取当前用户信息"""
  451. authorization = request.headers.get("Authorization", "")
  452. if not authorization.startswith("Bearer "):
  453. return error_response(401, "未授权")
  454. token = authorization.replace("Bearer ", "")
  455. try:
  456. current_user = await get_current_user_by_token(token=token, db=db)
  457. region_name = None
  458. if current_user.get("region_id"):
  459. region = region_crud.get(db, current_user.get("region_id"))
  460. region_name = region.name if region else None
  461. au = current_user.get("avatar_url")
  462. avatar_url = str(au).strip() if au else None
  463. return success_response({
  464. "id": current_user.get("user_id"),
  465. "username": current_user.get("username"),
  466. "realName": current_user.get("real_name"),
  467. "phone": current_user.get("phone"),
  468. "role": current_user.get("role"),
  469. "regionId": current_user.get("region_id"),
  470. "regionName": region_name,
  471. "isActive": current_user.get("is_active"),
  472. "avatarUrl": avatar_url if avatar_url else None,
  473. })
  474. except HTTPException:
  475. return error_response(401, "Token无效或已过期")
  476. @router.post("/mini/auth/logout", response_model=dict)
  477. async def mini_logout(request: Request):
  478. """小程序端登出"""
  479. authorization = request.headers.get("Authorization", "")
  480. if not authorization.startswith("Bearer "):
  481. return error_response(401, "未授权")
  482. token = authorization.replace("Bearer ", "")
  483. try:
  484. payload = decode_token(token)
  485. session_id = payload.get("sid")
  486. if session_id:
  487. await revoke_session(session_id=session_id)
  488. except HTTPException:
  489. return error_response(401, "Token无效或已过期")
  490. return success_response({"message": "登出成功"})
  491. @router.post("/mini/user/change-password", response_model=dict)
  492. async def mini_change_password(
  493. body: dict,
  494. request: Request,
  495. db: Session = Depends(get_db)
  496. ):
  497. """小程序端修改密码"""
  498. old_password = body.get("oldPassword")
  499. new_password = body.get("newPassword")
  500. if not old_password or not new_password:
  501. return error_response(400, "旧密码和新密码都不能为空")
  502. if len(new_password) < 6:
  503. return error_response(400, "新密码长度不能少于6位")
  504. authorization = request.headers.get("Authorization", "")
  505. if not authorization.startswith("Bearer "):
  506. return error_response(401, "未授权")
  507. token = authorization.replace("Bearer ", "")
  508. try:
  509. current_user = await get_current_user_by_token(token=token, db=db)
  510. user_id = current_user.get("user_id")
  511. user = user_crud.get(db, user_id)
  512. if not user:
  513. return error_response(404, "用户不存在")
  514. if not verify_password(old_password, user.password_hash):
  515. return error_response(400, "旧密码错误")
  516. user.password_hash = get_password_hash(new_password)
  517. db.commit()
  518. ip_address = get_client_ip(request)
  519. password_history_crud.create_password_history(
  520. db,
  521. user_id=user.id,
  522. operation_type="modify",
  523. operator_id=user.id,
  524. operator_name=user.real_name,
  525. ip_address=ip_address
  526. )
  527. return success_response({"message": "密码修改成功"})
  528. except HTTPException:
  529. return error_response(401, "Token无效或已过期")