Compare commits
4 Commits
d5c397b68c
...
3e3f4390a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e3f4390a6 | ||
|
|
f7b264e6c8 | ||
|
|
7371848a6b | ||
|
|
f9ca4a32a3 |
@@ -9,3 +9,6 @@ DB_PORT=3306
|
|||||||
DB_USER=root
|
DB_USER=root
|
||||||
DB_PASSWORD=change_me
|
DB_PASSWORD=change_me
|
||||||
DB_NAME=mini_program
|
DB_NAME=mini_program
|
||||||
|
JWT_SECRET=change_me_to_a_long_random_string
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
|||||||
45
mini-program/app/db.py
Normal file
45
mini-program/app/db.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
settings.mysql_dsn,
|
||||||
|
pool_size=5,
|
||||||
|
max_overflow=10,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
pool_recycle=1800,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(
|
||||||
|
bind=engine,
|
||||||
|
autocommit=False,
|
||||||
|
autoflush=False,
|
||||||
|
expire_on_commit=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> Generator[Session, None, None]:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def check_db_connection() -> None:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
|
||||||
|
|
||||||
|
def close_db_engine() -> None:
|
||||||
|
engine.dispose()
|
||||||
@@ -4,11 +4,19 @@ from fastapi import FastAPI
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
# For module mode: `uvicorn app.main:app`
|
# 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.health import router as health_router
|
||||||
|
from app.routers.messages import router as messages_router
|
||||||
from app.settings import settings
|
from app.settings import settings
|
||||||
except ModuleNotFoundError:
|
except ModuleNotFoundError:
|
||||||
# For script mode: `python app/main.py` or VS Code "Run Python File"
|
# 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.health import router as health_router
|
||||||
|
from routers.messages import router as messages_router
|
||||||
from settings import settings
|
from settings import settings
|
||||||
|
|
||||||
|
|
||||||
@@ -18,7 +26,19 @@ def create_app() -> FastAPI:
|
|||||||
version=settings.app_version,
|
version=settings.app_version,
|
||||||
description=settings.app_description,
|
description=settings.app_description,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
def on_startup() -> None:
|
||||||
|
check_db_connection()
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
def on_shutdown() -> None:
|
||||||
|
close_db_engine()
|
||||||
|
|
||||||
|
install_auth_middleware(app)
|
||||||
|
app.include_router(auth_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
|
app.include_router(messages_router)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1
mini-program/app/middleware/__init__.py
Normal file
1
mini-program/app/middleware/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
75
mini-program/app/middleware/auth.py
Normal file
75
mini-program/app/middleware/auth.py
Normal file
@@ -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)
|
||||||
95
mini-program/app/routers/auth.py
Normal file
95
mini-program/app/routers/auth.py
Normal file
@@ -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"]),
|
||||||
|
)
|
||||||
390
mini-program/app/routers/messages.py
Normal file
390
mini-program/app/routers/messages.py
Normal file
@@ -0,0 +1,390 @@
|
|||||||
|
import json
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, Response, 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.security import get_current_user_id
|
||||||
|
from app.schemas.message import (
|
||||||
|
MessageCreateRequest,
|
||||||
|
MessageCreateResponse,
|
||||||
|
MessageItem,
|
||||||
|
MessageListResponse,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
MessageItem,
|
||||||
|
MessageListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||||
|
|
||||||
|
|
||||||
|
def _build_preview(content_type: int, content_text: str | None) -> str:
|
||||||
|
if content_type == 1:
|
||||||
|
preview = f"user: {(content_text or '').strip()}"
|
||||||
|
elif content_type == 2:
|
||||||
|
preview = "user: [audio]"
|
||||||
|
elif content_type == 3:
|
||||||
|
preview = "user: [image]"
|
||||||
|
else:
|
||||||
|
preview = "user: [json]"
|
||||||
|
return preview[:255]
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_content_json(value: Any) -> dict[str, Any] | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(value)
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
return parsed
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_message_item(row: Mapping[str, Any]) -> MessageItem:
|
||||||
|
return MessageItem(
|
||||||
|
id=int(row["id"]),
|
||||||
|
conversation_id=int(row["conversation_id"]),
|
||||||
|
seq=int(row["seq"]),
|
||||||
|
sender_user_id=row["sender_user_id"],
|
||||||
|
role=int(row["role"]),
|
||||||
|
content_type=int(row["content_type"]),
|
||||||
|
content_text=row["content_text"],
|
||||||
|
content_json=_normalize_content_json(row["content_json"]),
|
||||||
|
media_file_key=row["media_file_key"],
|
||||||
|
media_duration_ms=row["media_duration_ms"],
|
||||||
|
client_msg_id=row["client_msg_id"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_existing_message(
|
||||||
|
db: Session, conversation_id: int, client_msg_id: str
|
||||||
|
) -> Mapping[str, Any] | None:
|
||||||
|
return (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
conversation_id,
|
||||||
|
seq,
|
||||||
|
sender_user_id,
|
||||||
|
role,
|
||||||
|
content_type,
|
||||||
|
content_text,
|
||||||
|
content_json,
|
||||||
|
media_file_key,
|
||||||
|
media_duration_ms,
|
||||||
|
client_msg_id,
|
||||||
|
created_at
|
||||||
|
FROM chat_message
|
||||||
|
WHERE conversation_id = :conversation_id
|
||||||
|
AND client_msg_id = :client_msg_id
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"conversation_id": conversation_id, "client_msg_id": client_msg_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 = 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)
|
||||||
|
|
||||||
|
with db.begin():
|
||||||
|
user_count = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS cnt
|
||||||
|
FROM chat_user
|
||||||
|
WHERE id IN (:sender_user_id, :peer_user_id)
|
||||||
|
AND status = 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"sender_user_id": sender_user_id, "peer_user_id": peer_user_id},
|
||||||
|
).scalar_one()
|
||||||
|
|
||||||
|
if int(user_count) != 2:
|
||||||
|
raise HTTPException(status_code=404, detail="sender or peer user not found")
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO chat_conversation (
|
||||||
|
user_low_id,
|
||||||
|
user_high_id,
|
||||||
|
status,
|
||||||
|
last_seq,
|
||||||
|
message_count,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:user_low_id,
|
||||||
|
:user_high_id,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
CURRENT_TIMESTAMP(3),
|
||||||
|
CURRENT_TIMESTAMP(3)
|
||||||
|
)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
id = LAST_INSERT_ID(id),
|
||||||
|
updated_at = CURRENT_TIMESTAMP(3)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"user_low_id": user_low_id, "user_high_id": user_high_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
conversation_id = int(db.execute(text("SELECT LAST_INSERT_ID()")).scalar_one())
|
||||||
|
|
||||||
|
conversation = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT id, last_seq, status
|
||||||
|
FROM chat_conversation
|
||||||
|
WHERE id = :conversation_id
|
||||||
|
FOR UPDATE
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"conversation_id": conversation_id},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not conversation:
|
||||||
|
raise HTTPException(status_code=500, detail="failed to load conversation")
|
||||||
|
|
||||||
|
if int(conversation["status"]) != 1:
|
||||||
|
raise HTTPException(status_code=409, detail="conversation is not active")
|
||||||
|
|
||||||
|
existing = _get_existing_message(db, conversation_id, payload.client_msg_id)
|
||||||
|
if existing:
|
||||||
|
response.status_code = status.HTTP_200_OK
|
||||||
|
return MessageCreateResponse(
|
||||||
|
idempotent=True,
|
||||||
|
message=_row_to_message_item(existing),
|
||||||
|
)
|
||||||
|
|
||||||
|
next_seq = int(conversation["last_seq"]) + 1
|
||||||
|
preview = _build_preview(payload.content_type, payload.content_text)
|
||||||
|
|
||||||
|
insert_result = db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO chat_message (
|
||||||
|
conversation_id,
|
||||||
|
seq,
|
||||||
|
sender_user_id,
|
||||||
|
role,
|
||||||
|
content_type,
|
||||||
|
content_text,
|
||||||
|
content_json,
|
||||||
|
media_file_key,
|
||||||
|
media_duration_ms,
|
||||||
|
client_msg_id,
|
||||||
|
created_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
:conversation_id,
|
||||||
|
:seq,
|
||||||
|
:sender_user_id,
|
||||||
|
1,
|
||||||
|
:content_type,
|
||||||
|
:content_text,
|
||||||
|
:content_json,
|
||||||
|
:media_file_key,
|
||||||
|
:media_duration_ms,
|
||||||
|
:client_msg_id,
|
||||||
|
CURRENT_TIMESTAMP(3)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"seq": next_seq,
|
||||||
|
"sender_user_id": sender_user_id,
|
||||||
|
"content_type": payload.content_type,
|
||||||
|
"content_text": payload.content_text,
|
||||||
|
"content_json": json.dumps(payload.content_json, ensure_ascii=False)
|
||||||
|
if payload.content_json is not None
|
||||||
|
else None,
|
||||||
|
"media_file_key": payload.media_file_key,
|
||||||
|
"media_duration_ms": payload.media_duration_ms,
|
||||||
|
"client_msg_id": payload.client_msg_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE chat_conversation
|
||||||
|
SET
|
||||||
|
last_seq = :last_seq,
|
||||||
|
message_count = message_count + 1,
|
||||||
|
last_message_preview = :last_message_preview,
|
||||||
|
last_message_at = CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at = CURRENT_TIMESTAMP(3)
|
||||||
|
WHERE id = :conversation_id
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"last_seq": next_seq,
|
||||||
|
"last_message_preview": preview,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
created = (
|
||||||
|
db.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
conversation_id,
|
||||||
|
seq,
|
||||||
|
sender_user_id,
|
||||||
|
role,
|
||||||
|
content_type,
|
||||||
|
content_text,
|
||||||
|
content_json,
|
||||||
|
media_file_key,
|
||||||
|
media_duration_ms,
|
||||||
|
client_msg_id,
|
||||||
|
created_at
|
||||||
|
FROM chat_message
|
||||||
|
WHERE id = :message_id
|
||||||
|
LIMIT 1
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"message_id": insert_result.lastrowid},
|
||||||
|
)
|
||||||
|
.mappings()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not created:
|
||||||
|
raise HTTPException(status_code=500, detail="failed to load created message")
|
||||||
|
|
||||||
|
return MessageCreateResponse(
|
||||||
|
idempotent=False,
|
||||||
|
message=_row_to_message_item(created),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=MessageListResponse)
|
||||||
|
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:
|
||||||
|
_assert_conversation_access(
|
||||||
|
db=db,
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
current_user_id=current_user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
conversation_id,
|
||||||
|
seq,
|
||||||
|
sender_user_id,
|
||||||
|
role,
|
||||||
|
content_type,
|
||||||
|
content_text,
|
||||||
|
content_json,
|
||||||
|
media_file_key,
|
||||||
|
media_duration_ms,
|
||||||
|
client_msg_id,
|
||||||
|
created_at
|
||||||
|
FROM chat_message
|
||||||
|
WHERE conversation_id = :conversation_id
|
||||||
|
AND deleted_at IS NULL
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"fetch_limit": limit + 1,
|
||||||
|
}
|
||||||
|
if cursor_seq is not None:
|
||||||
|
sql += " AND seq < :cursor_seq"
|
||||||
|
params["cursor_seq"] = cursor_seq
|
||||||
|
sql += " ORDER BY seq DESC LIMIT :fetch_limit"
|
||||||
|
|
||||||
|
rows = db.execute(text(sql), params).mappings().all()
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
rows.reverse()
|
||||||
|
items = [_row_to_message_item(row) for row in rows]
|
||||||
|
|
||||||
|
next_cursor_seq = items[0].seq if has_more and items else None
|
||||||
|
return MessageListResponse(
|
||||||
|
conversation_id=conversation_id,
|
||||||
|
has_more=has_more,
|
||||||
|
next_cursor_seq=next_cursor_seq,
|
||||||
|
items=items,
|
||||||
|
)
|
||||||
1
mini-program/app/schemas/__init__.py
Normal file
1
mini-program/app/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
13
mini-program/app/schemas/auth.py
Normal file
13
mini-program/app/schemas/auth.py
Normal file
@@ -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
|
||||||
57
mini-program/app/schemas/message.py
Normal file
57
mini-program/app/schemas/message.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, model_validator
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreateRequest(BaseModel):
|
||||||
|
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")
|
||||||
|
content_text: str | None = Field(default=None)
|
||||||
|
content_json: dict[str, Any] | None = None
|
||||||
|
media_file_key: str | None = Field(default=None, max_length=255)
|
||||||
|
media_duration_ms: int | None = Field(default=None, ge=0)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_message_fields(self) -> "MessageCreateRequest":
|
||||||
|
if self.content_type not in {1, 2, 3, 4}:
|
||||||
|
raise ValueError("content_type must be one of 1, 2, 3, 4")
|
||||||
|
|
||||||
|
if self.content_type == 1 and not (self.content_text and self.content_text.strip()):
|
||||||
|
raise ValueError("content_text is required when content_type is 1")
|
||||||
|
|
||||||
|
if self.content_type in {2, 3} and not self.media_file_key:
|
||||||
|
raise ValueError("media_file_key is required when content_type is 2 or 3")
|
||||||
|
|
||||||
|
if self.content_type == 4 and self.content_json is None:
|
||||||
|
raise ValueError("content_json is required when content_type is 4")
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class MessageItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
conversation_id: int
|
||||||
|
seq: int
|
||||||
|
sender_user_id: int | None
|
||||||
|
role: int
|
||||||
|
content_type: int
|
||||||
|
content_text: str | None
|
||||||
|
content_json: dict[str, Any] | None
|
||||||
|
media_file_key: str | None
|
||||||
|
media_duration_ms: int | None
|
||||||
|
client_msg_id: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MessageCreateResponse(BaseModel):
|
||||||
|
idempotent: bool
|
||||||
|
message: MessageItem
|
||||||
|
|
||||||
|
|
||||||
|
class MessageListResponse(BaseModel):
|
||||||
|
conversation_id: int
|
||||||
|
has_more: bool
|
||||||
|
next_cursor_seq: int | None
|
||||||
|
items: list[MessageItem]
|
||||||
64
mini-program/app/security.py
Normal file
64
mini-program/app/security.py
Normal file
@@ -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
|
||||||
@@ -26,6 +26,15 @@ class Settings(BaseSettings):
|
|||||||
db_user: str = Field(default="root", validation_alias="DB_USER")
|
db_user: str = Field(default="root", validation_alias="DB_USER")
|
||||||
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
|
db_password: str = Field(default="", validation_alias="DB_PASSWORD")
|
||||||
db_name: str = Field(default="mini_program", validation_alias="DB_NAME")
|
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
|
@property
|
||||||
def mysql_dsn(self) -> str:
|
def mysql_dsn(self) -> str:
|
||||||
|
|||||||
220
mini-program/docs/api.md
Normal file
220
mini-program/docs/api.md
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
# 接口文档
|
||||||
|
|
||||||
|
本地 Base URL:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://127.0.0.1:8001
|
||||||
|
```
|
||||||
|
|
||||||
|
自动生成文档:
|
||||||
|
|
||||||
|
- Swagger UI:`/docs`
|
||||||
|
- ReDoc:`/redoc`
|
||||||
|
|
||||||
|
鉴权说明:
|
||||||
|
|
||||||
|
- 除 `GET /health`、`POST /auth/login`、`/docs`、`/redoc`、`/openapi.json` 外,其余接口都需要 Bearer Token。
|
||||||
|
- 请求头格式:`Authorization: Bearer <access_token>`
|
||||||
|
|
||||||
|
## 1. 健康检查
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 检查服务是否存活。
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 登录
|
||||||
|
|
||||||
|
### `POST /auth/login`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 用户登录并获取访问令牌(access token)。
|
||||||
|
- 当前示例账号:`test1/test2/test3`,密码均为 `123`。
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "test1",
|
||||||
|
"password": "123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应(`200`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "<jwt-token>",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"user_id": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
常见错误码:
|
||||||
|
|
||||||
|
- `401`:用户名或密码错误,或账号不可用
|
||||||
|
- `422`:请求参数校验失败
|
||||||
|
|
||||||
|
## 3. 发送消息
|
||||||
|
|
||||||
|
### `POST /messages`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 保存单聊消息。
|
||||||
|
- 服务端从 token 中识别发送者,不需要传 `sender_user_id`。
|
||||||
|
- 服务端根据“当前用户 + peer_user_id”自动查找或创建会话。
|
||||||
|
- `client_msg_id` 用于幂等控制。
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
请求体:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"peer_user_id": 2,
|
||||||
|
"client_msg_id": "msg-20260326-0002",
|
||||||
|
"content_type": 1,
|
||||||
|
"content_text": "hello"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
字段规则:
|
||||||
|
|
||||||
|
- `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` 时必填
|
||||||
|
- `content_json`:`content_type=4` 时必填
|
||||||
|
- `media_file_key`:`content_type=2` 或 `3` 时必填
|
||||||
|
- `media_duration_ms`:媒体时长(可选,毫秒)
|
||||||
|
|
||||||
|
成功响应(首次入库,`201`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"idempotent": false,
|
||||||
|
"message": {
|
||||||
|
"id": 1008,
|
||||||
|
"conversation_id": 1,
|
||||||
|
"seq": 7,
|
||||||
|
"sender_user_id": 1,
|
||||||
|
"role": 1,
|
||||||
|
"content_type": 1,
|
||||||
|
"content_text": "hello",
|
||||||
|
"content_json": null,
|
||||||
|
"media_file_key": null,
|
||||||
|
"media_duration_ms": null,
|
||||||
|
"client_msg_id": "msg-20260326-0002",
|
||||||
|
"created_at": "2026-03-26T11:20:00.123000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
成功响应(幂等命中,`200`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"idempotent": true,
|
||||||
|
"message": {
|
||||||
|
"id": 1008,
|
||||||
|
"conversation_id": 1,
|
||||||
|
"seq": 7,
|
||||||
|
"sender_user_id": 1,
|
||||||
|
"role": 1,
|
||||||
|
"content_type": 1,
|
||||||
|
"content_text": "hello",
|
||||||
|
"content_json": null,
|
||||||
|
"media_file_key": null,
|
||||||
|
"media_duration_ms": null,
|
||||||
|
"client_msg_id": "msg-20260326-0002",
|
||||||
|
"created_at": "2026-03-26T11:20:00.123000"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
常见错误码:
|
||||||
|
|
||||||
|
- `401`:未登录或 token 无效
|
||||||
|
- `404`:接收人不存在/不可用
|
||||||
|
- `409`:会话不是激活状态
|
||||||
|
- `422`:请求参数校验失败
|
||||||
|
|
||||||
|
## 4. 查询消息
|
||||||
|
|
||||||
|
### `GET /messages`
|
||||||
|
|
||||||
|
用途:
|
||||||
|
|
||||||
|
- 按会话分页读取消息。
|
||||||
|
- 返回结果按 `seq` 正序排列。
|
||||||
|
- 仅会话参与者可读取。
|
||||||
|
|
||||||
|
请求头:
|
||||||
|
|
||||||
|
```
|
||||||
|
Authorization: Bearer <access_token>
|
||||||
|
```
|
||||||
|
|
||||||
|
Query 参数:
|
||||||
|
|
||||||
|
- `conversation_id`(必填):`int > 0`
|
||||||
|
- `cursor_seq`(可选):`int >= 1`,用于向前翻页
|
||||||
|
- `limit`(可选):默认 `20`,最大 `100`
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /messages?conversation_id=1&limit=20
|
||||||
|
GET /messages?conversation_id=1&cursor_seq=50&limit=20
|
||||||
|
```
|
||||||
|
|
||||||
|
响应示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"conversation_id": 1,
|
||||||
|
"has_more": false,
|
||||||
|
"next_cursor_seq": null,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": 1001,
|
||||||
|
"conversation_id": 1,
|
||||||
|
"seq": 1,
|
||||||
|
"sender_user_id": 1,
|
||||||
|
"role": 1,
|
||||||
|
"content_type": 1,
|
||||||
|
"content_text": "Hi, did you finish homework?",
|
||||||
|
"content_json": {
|
||||||
|
"lang": "en"
|
||||||
|
},
|
||||||
|
"media_file_key": null,
|
||||||
|
"media_duration_ms": null,
|
||||||
|
"client_msg_id": "c1-m1",
|
||||||
|
"created_at": "2026-03-25T10:00:05"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
常见错误码:
|
||||||
|
|
||||||
|
- `401`:未登录或 token 无效
|
||||||
|
- `403`:无权限读取该会话
|
||||||
|
- `404`:会话不存在
|
||||||
|
- `422`:请求参数校验失败
|
||||||
@@ -2,3 +2,6 @@ fastapi==0.115.11
|
|||||||
uvicorn[standard]==0.34.0
|
uvicorn[standard]==0.34.0
|
||||||
pydantic-settings==2.8.1
|
pydantic-settings==2.8.1
|
||||||
python-dotenv==1.0.1
|
python-dotenv==1.0.1
|
||||||
|
SQLAlchemy==2.0.38
|
||||||
|
PyMySQL==1.1.1
|
||||||
|
PyJWT==2.10.1
|
||||||
|
|||||||
Reference in New Issue
Block a user