From 3e3f4390a640bd16f5ed40c8dd6034b162cdc466 Mon Sep 17 00:00:00 2001 From: ChengCan <783785929@qq.com> Date: Thu, 26 Mar 2026 11:06:10 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E7=94=A8=E6=88=B7=E9=89=B4?= =?UTF-8?q?=E6=9D=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mini-program/.env.example | 3 + mini-program/app/main.py | 6 ++ mini-program/app/middleware/__init__.py | 1 + mini-program/app/middleware/auth.py | 75 +++++++++++++++++++ mini-program/app/routers/auth.py | 95 +++++++++++++++++++++++++ mini-program/app/routers/messages.py | 51 ++++++++++--- mini-program/app/schemas/auth.py | 13 ++++ mini-program/app/schemas/message.py | 4 -- mini-program/app/security.py | 64 +++++++++++++++++ mini-program/app/settings.py | 9 +++ mini-program/docs/api.md | 86 +++++++++++++++++----- mini-program/requirements.txt | 1 + 12 files changed, 379 insertions(+), 29 deletions(-) create mode 100644 mini-program/app/middleware/__init__.py create mode 100644 mini-program/app/middleware/auth.py create mode 100644 mini-program/app/routers/auth.py create mode 100644 mini-program/app/schemas/auth.py create mode 100644 mini-program/app/security.py diff --git a/mini-program/.env.example b/mini-program/.env.example index 6202b6b..ae08b16 100644 --- a/mini-program/.env.example +++ b/mini-program/.env.example @@ -9,3 +9,6 @@ DB_PORT=3306 DB_USER=root DB_PASSWORD=change_me DB_NAME=mini_program +JWT_SECRET=change_me_to_a_long_random_string +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60 diff --git a/mini-program/app/main.py b/mini-program/app/main.py index eeb064d..0e9546c 100644 --- a/mini-program/app/main.py +++ b/mini-program/app/main.py @@ -5,12 +5,16 @@ from fastapi import FastAPI try: # For module mode: `uvicorn app.main:app` from app.db import check_db_connection, close_db_engine + from app.middleware.auth import install_auth_middleware + from app.routers.auth import router as auth_router from app.routers.health import router as health_router from app.routers.messages import router as messages_router from app.settings import settings except ModuleNotFoundError: # For script mode: `python app/main.py` or VS Code "Run Python File" from db import check_db_connection, close_db_engine + from middleware.auth import install_auth_middleware + from routers.auth import router as auth_router from routers.health import router as health_router from routers.messages import router as messages_router from settings import settings @@ -31,6 +35,8 @@ def create_app() -> FastAPI: def on_shutdown() -> None: close_db_engine() + install_auth_middleware(app) + app.include_router(auth_router) app.include_router(health_router) app.include_router(messages_router) return app diff --git a/mini-program/app/middleware/__init__.py b/mini-program/app/middleware/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/mini-program/app/middleware/__init__.py @@ -0,0 +1 @@ + diff --git a/mini-program/app/middleware/auth.py b/mini-program/app/middleware/auth.py new file mode 100644 index 0000000..0427158 --- /dev/null +++ b/mini-program/app/middleware/auth.py @@ -0,0 +1,75 @@ +from collections.abc import Awaitable, Callable + +from fastapi import FastAPI, HTTPException, Request +from sqlalchemy import text + +try: + # For module mode: `uvicorn app.main:app` + from app.db import SessionLocal + from app.security import auth_error_response, decode_access_token +except ModuleNotFoundError: + # For script mode: `python app/main.py` or VS Code "Run Python File" + from db import SessionLocal + from security import auth_error_response, decode_access_token + + +EXCLUDED_PATH_PREFIXES = ( + "/health", + "/docs", + "/redoc", + "/openapi.json", + "/auth/login", +) + + +def _is_excluded_path(path: str) -> bool: + for prefix in EXCLUDED_PATH_PREFIXES: + if path == prefix or path.startswith(f"{prefix}/"): + return True + return False + + +def install_auth_middleware(app: FastAPI) -> None: + @app.middleware("http") + async def auth_middleware( + request: Request, + call_next: Callable[[Request], Awaitable], + ): + path = request.url.path + if request.method == "OPTIONS" or _is_excluded_path(path): + return await call_next(request) + + auth_header = request.headers.get("Authorization") + if not auth_header: + return auth_error_response("missing authorization header") + + parts = auth_header.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + return auth_error_response("invalid authorization format") + + try: + user_id = decode_access_token(parts[1].strip()) + except HTTPException: + return auth_error_response("invalid or expired access token") + + with SessionLocal() as db: + user_row = ( + db.execute( + text( + """ + SELECT id, status + FROM chat_user + WHERE id = :user_id + LIMIT 1 + """ + ), + {"user_id": user_id}, + ) + .mappings() + .first() + ) + if not user_row or int(user_row["status"]) != 1: + return auth_error_response("user not found or disabled") + + request.state.user_id = user_id + return await call_next(request) diff --git a/mini-program/app/routers/auth.py b/mini-program/app/routers/auth.py new file mode 100644 index 0000000..5066c6d --- /dev/null +++ b/mini-program/app/routers/auth.py @@ -0,0 +1,95 @@ +import hashlib + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import text +from sqlalchemy.orm import Session + +try: + # For module mode: `uvicorn app.main:app` + from app.db import get_db + from app.schemas.auth import LoginRequest, LoginResponse + from app.security import create_access_token +except ModuleNotFoundError: + # For script mode: `python app/main.py` or VS Code "Run Python File" + from db import get_db + from schemas.auth import LoginRequest, LoginResponse + from security import create_access_token + + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/login", response_model=LoginResponse) +def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse: + with db.begin(): + row = ( + db.execute( + text( + """ + SELECT + ua.id AS user_auth_id, + ua.user_id, + ua.password_hash, + ua.status AS auth_status, + u.status AS user_status + FROM chat_user_auth ua + JOIN chat_user u ON u.id = ua.user_id + WHERE ua.auth_type = 6 + AND ua.auth_identifier = :username + LIMIT 1 + """ + ), + {"username": payload.username}, + ) + .mappings() + .first() + ) + if not row: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) + + if int(row["auth_status"]) != 1 or int(row["user_status"]) != 1: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="account is disabled", + ) + + # Seed data currently stores sha256; migrate to bcrypt/argon2 in production. + password_hash = hashlib.sha256(payload.password.encode("utf-8")).hexdigest() + if not row["password_hash"] or password_hash != row["password_hash"]: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="invalid username or password", + ) + + db.execute( + text( + """ + UPDATE chat_user_auth + SET last_login_at = CURRENT_TIMESTAMP(3), + updated_at = CURRENT_TIMESTAMP(3) + WHERE id = :user_auth_id + """ + ), + {"user_auth_id": int(row["user_auth_id"])}, + ) + db.execute( + text( + """ + UPDATE chat_user + SET last_login_at = CURRENT_TIMESTAMP(3), + updated_at = CURRENT_TIMESTAMP(3) + WHERE id = :user_id + """ + ), + {"user_id": int(row["user_id"])}, + ) + + access_token, expires_in = create_access_token(user_id=int(row["user_id"])) + return LoginResponse( + access_token=access_token, + expires_in=expires_in, + user_id=int(row["user_id"]), + ) diff --git a/mini-program/app/routers/messages.py b/mini-program/app/routers/messages.py index 89a1ae8..36c917b 100644 --- a/mini-program/app/routers/messages.py +++ b/mini-program/app/routers/messages.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import Session try: # For module mode: `uvicorn app.main:app` from app.db import get_db + from app.security import get_current_user_id from app.schemas.message import ( MessageCreateRequest, MessageCreateResponse, @@ -18,6 +19,7 @@ try: except ModuleNotFoundError: # For script mode: `python app/main.py` or VS Code "Run Python File" from db import get_db + from security import get_current_user_id from schemas.message import ( MessageCreateRequest, MessageCreateResponse, @@ -106,14 +108,48 @@ def _get_existing_message( ) +def _assert_conversation_access( + db: Session, + conversation_id: int, + current_user_id: int, +) -> None: + conversation_row = ( + db.execute( + text( + """ + SELECT id, user_low_id, user_high_id + FROM chat_conversation + WHERE id = :conversation_id + LIMIT 1 + """ + ), + {"conversation_id": conversation_id}, + ) + .mappings() + .first() + ) + if not conversation_row: + raise HTTPException(status_code=404, detail="conversation not found") + + if current_user_id not in ( + int(conversation_row["user_low_id"]), + int(conversation_row["user_high_id"]), + ): + raise HTTPException(status_code=403, detail="no permission for this conversation") + + @router.post("", response_model=MessageCreateResponse, status_code=status.HTTP_201_CREATED) def create_message( payload: MessageCreateRequest, response: Response, + current_user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db), ) -> MessageCreateResponse: - sender_user_id = payload.sender_user_id + sender_user_id = current_user_id peer_user_id = payload.peer_user_id + if sender_user_id == peer_user_id: + raise HTTPException(status_code=422, detail="peer_user_id cannot be same as current user") + user_low_id = min(sender_user_id, peer_user_id) user_high_id = max(sender_user_id, peer_user_id) @@ -303,17 +339,14 @@ def list_messages( conversation_id: int = Query(gt=0), cursor_seq: int | None = Query(default=None, ge=1), limit: int = Query(default=20, ge=1, le=100), + current_user_id: int = Depends(get_current_user_id), db: Session = Depends(get_db), ) -> MessageListResponse: - conversation_exists = ( - db.execute( - text("SELECT 1 FROM chat_conversation WHERE id = :conversation_id LIMIT 1"), - {"conversation_id": conversation_id}, - ).first() - is not None + _assert_conversation_access( + db=db, + conversation_id=conversation_id, + current_user_id=current_user_id, ) - if not conversation_exists: - raise HTTPException(status_code=404, detail="conversation not found") sql = """ SELECT diff --git a/mini-program/app/schemas/auth.py b/mini-program/app/schemas/auth.py new file mode 100644 index 0000000..43197c8 --- /dev/null +++ b/mini-program/app/schemas/auth.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=191) + password: str = Field(min_length=1, max_length=128) + + +class LoginResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int + user_id: int diff --git a/mini-program/app/schemas/message.py b/mini-program/app/schemas/message.py index 8d7799c..c76e7d3 100644 --- a/mini-program/app/schemas/message.py +++ b/mini-program/app/schemas/message.py @@ -5,7 +5,6 @@ from pydantic import BaseModel, Field, model_validator class MessageCreateRequest(BaseModel): - sender_user_id: int = Field(gt=0) peer_user_id: int = Field(gt=0) client_msg_id: str = Field(min_length=1, max_length=64) content_type: int = Field(description="1-text 2-audio 3-image 4-json") @@ -16,9 +15,6 @@ class MessageCreateRequest(BaseModel): @model_validator(mode="after") def validate_message_fields(self) -> "MessageCreateRequest": - if self.sender_user_id == self.peer_user_id: - raise ValueError("sender_user_id and peer_user_id cannot be the same") - if self.content_type not in {1, 2, 3, 4}: raise ValueError("content_type must be one of 1, 2, 3, 4") diff --git a/mini-program/app/security.py b/mini-program/app/security.py new file mode 100644 index 0000000..a11ec88 --- /dev/null +++ b/mini-program/app/security.py @@ -0,0 +1,64 @@ +from datetime import UTC, datetime, timedelta + +import jwt +from fastapi import HTTPException, Request, status +from starlette.responses import JSONResponse + +try: + # For module mode: `uvicorn app.main:app` + from app.settings import settings +except ModuleNotFoundError: + # For script mode: `python app/main.py` or VS Code "Run Python File" + from settings import settings + + +def create_access_token(user_id: int) -> tuple[str, int]: + now = datetime.now(UTC) + expires_delta = timedelta(minutes=settings.jwt_access_token_expire_minutes) + expires_at = now + expires_delta + payload = { + "sub": str(user_id), + "token_type": "access", + "iat": int(now.timestamp()), + "exp": int(expires_at.timestamp()), + } + token = jwt.encode( + payload=payload, + key=settings.jwt_secret, + algorithm=settings.jwt_algorithm, + ) + return token, int(expires_delta.total_seconds()) + + +def decode_access_token(token: str) -> int: + try: + payload = jwt.decode( + jwt=token, + key=settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + ) + except jwt.PyJWTError as exc: + raise HTTPException(status_code=401, detail="invalid or expired access token") from exc + + if payload.get("token_type") != "access": + raise HTTPException(status_code=401, detail="invalid token type") + + sub = payload.get("sub") + if not isinstance(sub, str) or not sub.isdigit(): + raise HTTPException(status_code=401, detail="invalid token subject") + return int(sub) + + +def auth_error_response(detail: str = "unauthorized") -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content={"detail": detail}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def get_current_user_id(request: Request) -> int: + user_id = getattr(request.state, "user_id", None) + if not isinstance(user_id, int): + raise HTTPException(status_code=401, detail="unauthorized") + return user_id diff --git a/mini-program/app/settings.py b/mini-program/app/settings.py index dbb078b..3f0fe71 100644 --- a/mini-program/app/settings.py +++ b/mini-program/app/settings.py @@ -26,6 +26,15 @@ class Settings(BaseSettings): db_user: str = Field(default="root", validation_alias="DB_USER") db_password: str = Field(default="", validation_alias="DB_PASSWORD") db_name: str = Field(default="mini_program", validation_alias="DB_NAME") + jwt_secret: str = Field( + default="dev_only_change_jwt_secret", + validation_alias="JWT_SECRET", + ) + jwt_algorithm: str = Field(default="HS256", validation_alias="JWT_ALGORITHM") + jwt_access_token_expire_minutes: int = Field( + default=60, + validation_alias="JWT_ACCESS_TOKEN_EXPIRE_MINUTES", + ) @property def mysql_dsn(self) -> str: diff --git a/mini-program/docs/api.md b/mini-program/docs/api.md index 2e271d3..9ae5f15 100644 --- a/mini-program/docs/api.md +++ b/mini-program/docs/api.md @@ -11,6 +11,11 @@ http://127.0.0.1:8001 - Swagger UI:`/docs` - ReDoc:`/redoc` +鉴权说明: + +- 除 `GET /health`、`POST /auth/login`、`/docs`、`/redoc`、`/openapi.json` 外,其余接口都需要 Bearer Token。 +- 请求头格式:`Authorization: Bearer ` + ## 1. 健康检查 ### `GET /health` @@ -27,23 +32,63 @@ http://127.0.0.1:8001 } ``` -## 2. 发送消息 +## 2. 登录 + +### `POST /auth/login` + +用途: + +- 用户登录并获取访问令牌(access token)。 +- 当前示例账号:`test1/test2/test3`,密码均为 `123`。 + +请求体: + +```json +{ + "username": "test1", + "password": "123" +} +``` + +成功响应(`200`): + +```json +{ + "access_token": "", + "token_type": "bearer", + "expires_in": 3600, + "user_id": 1 +} +``` + +常见错误码: + +- `401`:用户名或密码错误,或账号不可用 +- `422`:请求参数校验失败 + +## 3. 发送消息 ### `POST /messages` 用途: - 保存单聊消息。 -- 服务端会根据 `sender_user_id` 和 `peer_user_id` 自动查找或创建会话。 +- 服务端从 token 中识别发送者,不需要传 `sender_user_id`。 +- 服务端根据“当前用户 + peer_user_id”自动查找或创建会话。 - `client_msg_id` 用于幂等控制。 +请求头: + +``` +Authorization: Bearer +``` + 请求体: ```json { - "sender_user_id": 1, "peer_user_id": 2, - "client_msg_id": "msg-20260326-0001", + "client_msg_id": "msg-20260326-0002", "content_type": 1, "content_text": "hello" } @@ -51,8 +96,7 @@ http://127.0.0.1:8001 字段规则: -- `sender_user_id`:`int > 0` -- `peer_user_id`:`int > 0`,且不能与 `sender_user_id` 相同 +- `peer_user_id`:`int > 0`,且不能与当前登录用户相同 - `client_msg_id`:`string`,长度 `1-64` - `content_type`:`1=text 2=audio 3=image 4=json` - `content_text`:`content_type=1` 时必填 @@ -66,9 +110,9 @@ http://127.0.0.1:8001 { "idempotent": false, "message": { - "id": 1006, + "id": 1008, "conversation_id": 1, - "seq": 6, + "seq": 7, "sender_user_id": 1, "role": 1, "content_type": 1, @@ -76,8 +120,8 @@ http://127.0.0.1:8001 "content_json": null, "media_file_key": null, "media_duration_ms": null, - "client_msg_id": "msg-20260326-0001", - "created_at": "2026-03-26T10:40:00.123000" + "client_msg_id": "msg-20260326-0002", + "created_at": "2026-03-26T11:20:00.123000" } } ``` @@ -88,9 +132,9 @@ http://127.0.0.1:8001 { "idempotent": true, "message": { - "id": 1006, + "id": 1008, "conversation_id": 1, - "seq": 6, + "seq": 7, "sender_user_id": 1, "role": 1, "content_type": 1, @@ -98,19 +142,20 @@ http://127.0.0.1:8001 "content_json": null, "media_file_key": null, "media_duration_ms": null, - "client_msg_id": "msg-20260326-0001", - "created_at": "2026-03-26T10:40:00.123000" + "client_msg_id": "msg-20260326-0002", + "created_at": "2026-03-26T11:20:00.123000" } } ``` 常见错误码: -- `404`:发送人或接收人不存在/不可用 +- `401`:未登录或 token 无效 +- `404`:接收人不存在/不可用 - `409`:会话不是激活状态 - `422`:请求参数校验失败 -## 3. 查询消息 +## 4. 查询消息 ### `GET /messages` @@ -118,6 +163,13 @@ http://127.0.0.1:8001 - 按会话分页读取消息。 - 返回结果按 `seq` 正序排列。 +- 仅会话参与者可读取。 + +请求头: + +``` +Authorization: Bearer +``` Query 参数: @@ -162,5 +214,7 @@ GET /messages?conversation_id=1&cursor_seq=50&limit=20 常见错误码: +- `401`:未登录或 token 无效 +- `403`:无权限读取该会话 - `404`:会话不存在 - `422`:请求参数校验失败 diff --git a/mini-program/requirements.txt b/mini-program/requirements.txt index 4df36d6..a1643a5 100644 --- a/mini-program/requirements.txt +++ b/mini-program/requirements.txt @@ -4,3 +4,4 @@ pydantic-settings==2.8.1 python-dotenv==1.0.1 SQLAlchemy==2.0.38 PyMySQL==1.1.1 +PyJWT==2.10.1