diff --git a/backend/app/api/public.py b/backend/app/api/public.py index 8c9650a06..255eb47e9 100644 --- a/backend/app/api/public.py +++ b/backend/app/api/public.py @@ -31,6 +31,7 @@ from app.services.web_auth_roles import ( resolve_web_role_for_user, user_has_bound_roles, ) +from app.services.mp_webview_ticket import TICKET_TTL_SEC, consume_ticket, issue_ticket from app.crud.crm.quote import view_by_share_code router = APIRouter(prefix="", tags=["公共接口"]) @@ -207,6 +208,79 @@ def refresh_token(body: dict, db: Session = Depends(get_db)): return error_response(401, "refreshToken已过期") +@router.post("/web/auth/mp-webview-ticket", response_model=dict) +def mp_webview_ticket(request: Request, db: Session = Depends(get_db)): + """小程序内嵌 WebView:签发一次性短时 ticket(需 Bearer)。""" + authorization = request.headers.get("Authorization", "") + if not authorization.startswith("Bearer "): + return error_response(401, "未授权") + token = authorization.replace("Bearer ", "").strip() + try: + current_user = get_current_user_by_token(token=token, db=db) + payload = decode_token(token) + except HTTPException: + return error_response(401, "Token无效或已过期") + session_id = str(payload.get("sid") or "").strip() + if not session_id: + return error_response(401, "Token无效或已过期") + try: + ticket = issue_ticket( + user_id=str(current_user.get("user_id") or ""), + session_id=session_id, + refresh_jti=str(payload.get("jti") or ""), + ) + except RuntimeError: + return error_response(503, "暂时无法打开内嵌页,请稍后重试") + return success_response({"ticket": ticket, "expiresIn": TICKET_TTL_SEC}) + + +@router.post("/web/auth/mp-webview-exchange", response_model=dict) +def mp_webview_exchange(body: dict, db: Session = Depends(get_db)): + """小程序内嵌 WebView:用 ticket 兑换 Web JWT(不吊销原 refresh)。""" + ticket = str((body or {}).get("ticket") or "").strip() + if not ticket: + return error_response(400, "ticket不能为空") + try: + data = consume_ticket(ticket) + except RuntimeError: + return error_response(503, "暂时无法打开内嵌页,请稍后重试") + if not data: + return error_response(401, "ticket无效或已过期") + user_id = str(data.get("userId") or "").strip() + session_id = str(data.get("sessionId") or "").strip() + if not user_id or not session_id: + return error_response(401, "ticket无效或已过期") + if not is_session_valid(session_id): + return error_response(401, "登录已失效,请重新登录") + user = user_crud.get(db, user_id) + if not user or getattr(user, "del_flag", 0) != 0: + return error_response(401, "用户不存在或已删除") + if user.status != "1": + return error_response(403, "账号已被停用,请联系管理员") + web_role = resolve_web_role_for_user(db, user.id, user.tenant_no) + access_jti = generate_uuid() + refresh_jti = generate_uuid() + access_token = create_access_token( + data=_jwt_user_payload(user, web_role), + session_id=session_id, + token_jti=access_jti, + ) + refresh_token_val = create_refresh_token( + data={"sub": user.id, "username": user.user_no, "role": web_role}, + session_id=session_id, + refresh_jti=refresh_jti, + ) + store_refresh_token(refresh_jti=refresh_jti, user_id=user.id) + return success_response( + { + "token": access_token, + "refreshToken": refresh_token_val, + "expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60, + "userInfo": _build_user_info_payload(user, web_role), + } + ) + + @router.post("/web/auth/logout", response_model=dict) def logout(request: Request): """登出:吊销 Redis 会话(若已配置 REDIS_HOST)。无 Token 或已失效时仍返回成功(幂等)。""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 00b92bd5f..6112a25e3 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -160,6 +160,7 @@ class Settings(BaseSettings): "/api/web/auth/login-rsa-public-key," "/api/web/auth/login," "/api/web/auth/refresh-token," + "/api/web/auth/mp-webview-exchange," "/api/web/admin/attachment/upload," "/api/web/admin/products/upload-image," "/api/web/admin/categories/upload-image," diff --git a/backend/tests/test_mp_webview_auth_api.py b/backend/tests/test_mp_webview_auth_api.py new file mode 100644 index 000000000..d40e45bdd --- /dev/null +++ b/backend/tests/test_mp_webview_auth_api.py @@ -0,0 +1,22 @@ +"""小程序 WebView ticket 签发 / 兑换 HTTP 接口。""" +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def test_mp_webview_ticket_requires_bearer(): + resp = client.post("/api/web/auth/mp-webview-ticket") + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 401 + assert body["message"] == "未授权" + + +def test_mp_webview_exchange_requires_ticket(): + resp = client.post("/api/web/auth/mp-webview-exchange", json={}) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 400 + assert body["message"] == "ticket不能为空"