添加日志
This commit is contained in:
@@ -12,3 +12,6 @@ DB_NAME=mini_program
|
|||||||
JWT_SECRET=change_me_to_a_long_random_string
|
JWT_SECRET=change_me_to_a_long_random_string
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
LOG_JSON=false
|
||||||
|
SLOW_SQL_MS=200
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
from sqlalchemy import create_engine, text
|
from sqlalchemy import create_engine, event, text
|
||||||
from sqlalchemy.orm import Session, sessionmaker
|
from sqlalchemy.orm import Session, sessionmaker
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -19,6 +21,47 @@ engine = create_engine(
|
|||||||
pool_recycle=1800,
|
pool_recycle=1800,
|
||||||
future=True,
|
future=True,
|
||||||
)
|
)
|
||||||
|
logger = logging.getLogger("app.db")
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(engine, "before_cursor_execute")
|
||||||
|
def before_cursor_execute(
|
||||||
|
conn,
|
||||||
|
cursor,
|
||||||
|
statement,
|
||||||
|
parameters,
|
||||||
|
context,
|
||||||
|
executemany,
|
||||||
|
):
|
||||||
|
conn.info.setdefault("query_start_time", []).append(time.perf_counter())
|
||||||
|
|
||||||
|
|
||||||
|
@event.listens_for(engine, "after_cursor_execute")
|
||||||
|
def after_cursor_execute(
|
||||||
|
conn,
|
||||||
|
cursor,
|
||||||
|
statement,
|
||||||
|
parameters,
|
||||||
|
context,
|
||||||
|
executemany,
|
||||||
|
):
|
||||||
|
start_time = None
|
||||||
|
query_start_time = conn.info.get("query_start_time")
|
||||||
|
if query_start_time:
|
||||||
|
start_time = query_start_time.pop(-1)
|
||||||
|
if start_time is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
||||||
|
if elapsed_ms >= settings.slow_sql_ms:
|
||||||
|
logger.warning(
|
||||||
|
"slow sql detected",
|
||||||
|
extra={
|
||||||
|
"event": "slow_sql",
|
||||||
|
"sql_ms": round(elapsed_ms, 2),
|
||||||
|
"statement": " ".join(statement.split())[:280],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
SessionLocal = sessionmaker(
|
SessionLocal = sessionmaker(
|
||||||
bind=engine,
|
bind=engine,
|
||||||
@@ -39,7 +82,9 @@ def get_db() -> Generator[Session, None, None]:
|
|||||||
def check_db_connection() -> None:
|
def check_db_connection() -> None:
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
conn.execute(text("SELECT 1"))
|
conn.execute(text("SELECT 1"))
|
||||||
|
logger.info("database connection check succeeded", extra={"event": "db_check"})
|
||||||
|
|
||||||
|
|
||||||
def close_db_engine() -> None:
|
def close_db_engine() -> None:
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
logger.info("database engine disposed", extra={"event": "db_close"})
|
||||||
|
|||||||
86
mini-program/app/logging_setup.py
Normal file
86
mini-program/app/logging_setup.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
_CONFIGURED = False
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"timestamp": datetime.now(UTC).isoformat(),
|
||||||
|
"level": record.levelname,
|
||||||
|
"logger": record.name,
|
||||||
|
"message": record.getMessage(),
|
||||||
|
}
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"event",
|
||||||
|
"request_id",
|
||||||
|
"method",
|
||||||
|
"path",
|
||||||
|
"status_code",
|
||||||
|
"duration_ms",
|
||||||
|
"client_ip",
|
||||||
|
"user_id",
|
||||||
|
"conversation_id",
|
||||||
|
"message_id",
|
||||||
|
"seq",
|
||||||
|
"client_msg_id",
|
||||||
|
"idempotent",
|
||||||
|
"count",
|
||||||
|
"has_more",
|
||||||
|
"cursor_seq",
|
||||||
|
"limit",
|
||||||
|
"reason",
|
||||||
|
"sql_ms",
|
||||||
|
"statement",
|
||||||
|
"username",
|
||||||
|
):
|
||||||
|
if hasattr(record, key):
|
||||||
|
payload[key] = getattr(record, key)
|
||||||
|
|
||||||
|
if record.exc_info:
|
||||||
|
payload["exception"] = self.formatException(record.exc_info)
|
||||||
|
return json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
global _CONFIGURED
|
||||||
|
if _CONFIGURED:
|
||||||
|
return
|
||||||
|
|
||||||
|
log_level_name = settings.log_level.upper().strip()
|
||||||
|
log_level = getattr(logging, log_level_name, logging.INFO)
|
||||||
|
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
if settings.log_json:
|
||||||
|
handler.setFormatter(JsonFormatter())
|
||||||
|
else:
|
||||||
|
handler.setFormatter(
|
||||||
|
logging.Formatter(
|
||||||
|
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
root_logger = logging.getLogger()
|
||||||
|
root_logger.handlers.clear()
|
||||||
|
root_logger.setLevel(log_level)
|
||||||
|
root_logger.addHandler(handler)
|
||||||
|
|
||||||
|
# Keep uvicorn logs consistent with app logs.
|
||||||
|
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||||
|
uvicorn_logger = logging.getLogger(logger_name)
|
||||||
|
uvicorn_logger.handlers.clear()
|
||||||
|
uvicorn_logger.propagate = True
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
|
||||||
|
_CONFIGURED = True
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
import os
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import FastAPI
|
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.db import check_db_connection, close_db_engine
|
||||||
|
from app.logging_setup import configure_logging
|
||||||
from app.middleware.auth import install_auth_middleware
|
from app.middleware.auth import install_auth_middleware
|
||||||
|
from app.middleware.request_log import install_request_logging_middleware
|
||||||
from app.routers.auth import router as auth_router
|
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.routers.messages import router as messages_router
|
||||||
@@ -13,13 +16,19 @@ try:
|
|||||||
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 db import check_db_connection, close_db_engine
|
||||||
|
from logging_setup import configure_logging
|
||||||
from middleware.auth import install_auth_middleware
|
from middleware.auth import install_auth_middleware
|
||||||
|
from middleware.request_log import install_request_logging_middleware
|
||||||
from routers.auth import router as auth_router
|
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 routers.messages import router as messages_router
|
||||||
from settings import settings
|
from settings import settings
|
||||||
|
|
||||||
|
|
||||||
|
configure_logging()
|
||||||
|
logger = logging.getLogger("app.main")
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title=settings.app_name,
|
title=settings.app_name,
|
||||||
@@ -30,12 +39,15 @@ def create_app() -> FastAPI:
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
def on_startup() -> None:
|
def on_startup() -> None:
|
||||||
check_db_connection()
|
check_db_connection()
|
||||||
|
logger.info("application started", extra={"event": "app_start"})
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def on_shutdown() -> None:
|
def on_shutdown() -> None:
|
||||||
|
logger.info("application shutting down", extra={"event": "app_shutdown"})
|
||||||
close_db_engine()
|
close_db_engine()
|
||||||
|
|
||||||
install_auth_middleware(app)
|
install_auth_middleware(app)
|
||||||
|
install_request_logging_middleware(app)
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(health_router)
|
app.include_router(health_router)
|
||||||
app.include_router(messages_router)
|
app.include_router(messages_router)
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException, Request
|
from fastapi import FastAPI, HTTPException, Request
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -21,6 +22,8 @@ EXCLUDED_PATH_PREFIXES = (
|
|||||||
"/auth/login",
|
"/auth/login",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.auth")
|
||||||
|
|
||||||
|
|
||||||
def _is_excluded_path(path: str) -> bool:
|
def _is_excluded_path(path: str) -> bool:
|
||||||
for prefix in EXCLUDED_PATH_PREFIXES:
|
for prefix in EXCLUDED_PATH_PREFIXES:
|
||||||
@@ -41,15 +44,42 @@ def install_auth_middleware(app: FastAPI) -> None:
|
|||||||
|
|
||||||
auth_header = request.headers.get("Authorization")
|
auth_header = request.headers.get("Authorization")
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
|
logger.warning(
|
||||||
|
"auth failed: missing header",
|
||||||
|
extra={
|
||||||
|
"event": "auth_check",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"path": path,
|
||||||
|
"reason": "missing_authorization_header",
|
||||||
|
},
|
||||||
|
)
|
||||||
return auth_error_response("missing authorization header")
|
return auth_error_response("missing authorization header")
|
||||||
|
|
||||||
parts = auth_header.split(" ", 1)
|
parts = auth_header.split(" ", 1)
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
if len(parts) != 2 or parts[0].lower() != "bearer":
|
||||||
|
logger.warning(
|
||||||
|
"auth failed: invalid header format",
|
||||||
|
extra={
|
||||||
|
"event": "auth_check",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"path": path,
|
||||||
|
"reason": "invalid_authorization_format",
|
||||||
|
},
|
||||||
|
)
|
||||||
return auth_error_response("invalid authorization format")
|
return auth_error_response("invalid authorization format")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_id = decode_access_token(parts[1].strip())
|
user_id = decode_access_token(parts[1].strip())
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
|
logger.warning(
|
||||||
|
"auth failed: invalid token",
|
||||||
|
extra={
|
||||||
|
"event": "auth_check",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"path": path,
|
||||||
|
"reason": "invalid_or_expired_token",
|
||||||
|
},
|
||||||
|
)
|
||||||
return auth_error_response("invalid or expired access token")
|
return auth_error_response("invalid or expired access token")
|
||||||
|
|
||||||
with SessionLocal() as db:
|
with SessionLocal() as db:
|
||||||
@@ -69,6 +99,16 @@ def install_auth_middleware(app: FastAPI) -> None:
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not user_row or int(user_row["status"]) != 1:
|
if not user_row or int(user_row["status"]) != 1:
|
||||||
|
logger.warning(
|
||||||
|
"auth failed: user unavailable",
|
||||||
|
extra={
|
||||||
|
"event": "auth_check",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"path": path,
|
||||||
|
"user_id": user_id,
|
||||||
|
"reason": "user_not_found_or_disabled",
|
||||||
|
},
|
||||||
|
)
|
||||||
return auth_error_response("user not found or disabled")
|
return auth_error_response("user not found or disabled")
|
||||||
|
|
||||||
request.state.user_id = user_id
|
request.state.user_id = user_id
|
||||||
|
|||||||
65
mini-program/app/middleware/request_log.py
Normal file
65
mini-program/app/middleware/request_log.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.request")
|
||||||
|
|
||||||
|
|
||||||
|
def install_request_logging_middleware(app: FastAPI) -> None:
|
||||||
|
@app.middleware("http")
|
||||||
|
async def request_logging_middleware(
|
||||||
|
request: Request,
|
||||||
|
call_next: Callable[[Request], Awaitable[Response]],
|
||||||
|
) -> Response:
|
||||||
|
request_id = request.headers.get("X-Request-ID") or uuid.uuid4().hex
|
||||||
|
request.state.request_id = request_id
|
||||||
|
started_at = time.perf_counter()
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
logger.exception(
|
||||||
|
"request failed",
|
||||||
|
extra={
|
||||||
|
"event": "request",
|
||||||
|
"request_id": request_id,
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"status_code": 500,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"client_ip": request.client.host if request.client else None,
|
||||||
|
"user_id": getattr(request.state, "user_id", None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
duration_ms = round((time.perf_counter() - started_at) * 1000, 2)
|
||||||
|
status_code = response.status_code
|
||||||
|
level = logging.INFO
|
||||||
|
if status_code >= 500:
|
||||||
|
level = logging.ERROR
|
||||||
|
elif status_code >= 400:
|
||||||
|
level = logging.WARNING
|
||||||
|
|
||||||
|
logger.log(
|
||||||
|
level,
|
||||||
|
"request completed",
|
||||||
|
extra={
|
||||||
|
"event": "request",
|
||||||
|
"request_id": request_id,
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"status_code": status_code,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"client_ip": request.client.host if request.client else None,
|
||||||
|
"user_id": getattr(request.state, "user_id", None),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -17,10 +18,22 @@ except ModuleNotFoundError:
|
|||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
logger = logging.getLogger("app.auth")
|
||||||
|
|
||||||
|
|
||||||
|
def _mask_username(username: str) -> str:
|
||||||
|
if len(username) <= 2:
|
||||||
|
return "*" * len(username)
|
||||||
|
return f"{username[:2]}***"
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=LoginResponse)
|
@router.post("/login", response_model=LoginResponse)
|
||||||
def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse:
|
def login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> LoginResponse:
|
||||||
|
username_masked = _mask_username(payload.username)
|
||||||
with db.begin():
|
with db.begin():
|
||||||
row = (
|
row = (
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -45,12 +58,31 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse
|
|||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
|
logger.warning(
|
||||||
|
"login failed",
|
||||||
|
extra={
|
||||||
|
"event": "login",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"username": username_masked,
|
||||||
|
"reason": "invalid_username_or_password",
|
||||||
|
},
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="invalid username or password",
|
detail="invalid username or password",
|
||||||
)
|
)
|
||||||
|
|
||||||
if int(row["auth_status"]) != 1 or int(row["user_status"]) != 1:
|
if int(row["auth_status"]) != 1 or int(row["user_status"]) != 1:
|
||||||
|
logger.warning(
|
||||||
|
"login failed",
|
||||||
|
extra={
|
||||||
|
"event": "login",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"username": username_masked,
|
||||||
|
"user_id": int(row["user_id"]),
|
||||||
|
"reason": "account_disabled",
|
||||||
|
},
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="account is disabled",
|
detail="account is disabled",
|
||||||
@@ -59,6 +91,15 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse
|
|||||||
# Seed data currently stores sha256; migrate to bcrypt/argon2 in production.
|
# Seed data currently stores sha256; migrate to bcrypt/argon2 in production.
|
||||||
password_hash = hashlib.sha256(payload.password.encode("utf-8")).hexdigest()
|
password_hash = hashlib.sha256(payload.password.encode("utf-8")).hexdigest()
|
||||||
if not row["password_hash"] or password_hash != row["password_hash"]:
|
if not row["password_hash"] or password_hash != row["password_hash"]:
|
||||||
|
logger.warning(
|
||||||
|
"login failed",
|
||||||
|
extra={
|
||||||
|
"event": "login",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"username": username_masked,
|
||||||
|
"reason": "invalid_username_or_password",
|
||||||
|
},
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="invalid username or password",
|
detail="invalid username or password",
|
||||||
@@ -88,6 +129,15 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> LoginResponse
|
|||||||
)
|
)
|
||||||
|
|
||||||
access_token, expires_in = create_access_token(user_id=int(row["user_id"]))
|
access_token, expires_in = create_access_token(user_id=int(row["user_id"]))
|
||||||
|
logger.info(
|
||||||
|
"login succeeded",
|
||||||
|
extra={
|
||||||
|
"event": "login",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"user_id": int(row["user_id"]),
|
||||||
|
"username": username_masked,
|
||||||
|
},
|
||||||
|
)
|
||||||
return LoginResponse(
|
return LoginResponse(
|
||||||
access_token=access_token,
|
access_token=access_token,
|
||||||
expires_in=expires_in,
|
expires_in=expires_in,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ except ModuleNotFoundError:
|
|||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||||
|
logger = logging.getLogger("app.messages")
|
||||||
|
|
||||||
|
|
||||||
def _build_preview(content_type: int, content_text: str | None) -> str:
|
def _build_preview(content_type: int, content_text: str | None) -> str:
|
||||||
@@ -141,6 +143,7 @@ def _assert_conversation_access(
|
|||||||
@router.post("", response_model=MessageCreateResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("", response_model=MessageCreateResponse, status_code=status.HTTP_201_CREATED)
|
||||||
def create_message(
|
def create_message(
|
||||||
payload: MessageCreateRequest,
|
payload: MessageCreateRequest,
|
||||||
|
request: Request,
|
||||||
response: Response,
|
response: Response,
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
current_user_id: int = Depends(get_current_user_id),
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -223,6 +226,19 @@ def create_message(
|
|||||||
|
|
||||||
existing = _get_existing_message(db, conversation_id, payload.client_msg_id)
|
existing = _get_existing_message(db, conversation_id, payload.client_msg_id)
|
||||||
if existing:
|
if existing:
|
||||||
|
logger.info(
|
||||||
|
"message idempotent hit",
|
||||||
|
extra={
|
||||||
|
"event": "message_create",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"user_id": sender_user_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"message_id": int(existing["id"]),
|
||||||
|
"seq": int(existing["seq"]),
|
||||||
|
"client_msg_id": payload.client_msg_id,
|
||||||
|
"idempotent": True,
|
||||||
|
},
|
||||||
|
)
|
||||||
response.status_code = status.HTTP_200_OK
|
response.status_code = status.HTTP_200_OK
|
||||||
return MessageCreateResponse(
|
return MessageCreateResponse(
|
||||||
idempotent=True,
|
idempotent=True,
|
||||||
@@ -328,6 +344,19 @@ def create_message(
|
|||||||
if not created:
|
if not created:
|
||||||
raise HTTPException(status_code=500, detail="failed to load created message")
|
raise HTTPException(status_code=500, detail="failed to load created message")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"message created",
|
||||||
|
extra={
|
||||||
|
"event": "message_create",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"user_id": sender_user_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"message_id": int(created["id"]),
|
||||||
|
"seq": int(created["seq"]),
|
||||||
|
"client_msg_id": payload.client_msg_id,
|
||||||
|
"idempotent": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
return MessageCreateResponse(
|
return MessageCreateResponse(
|
||||||
idempotent=False,
|
idempotent=False,
|
||||||
message=_row_to_message_item(created),
|
message=_row_to_message_item(created),
|
||||||
@@ -336,6 +365,7 @@ def create_message(
|
|||||||
|
|
||||||
@router.get("", response_model=MessageListResponse)
|
@router.get("", response_model=MessageListResponse)
|
||||||
def list_messages(
|
def list_messages(
|
||||||
|
request: Request,
|
||||||
conversation_id: int = Query(gt=0),
|
conversation_id: int = Query(gt=0),
|
||||||
cursor_seq: int | None = Query(default=None, ge=1),
|
cursor_seq: int | None = Query(default=None, ge=1),
|
||||||
limit: int = Query(default=20, ge=1, le=100),
|
limit: int = Query(default=20, ge=1, le=100),
|
||||||
@@ -382,6 +412,19 @@ def list_messages(
|
|||||||
items = [_row_to_message_item(row) for row in rows]
|
items = [_row_to_message_item(row) for row in rows]
|
||||||
|
|
||||||
next_cursor_seq = items[0].seq if has_more and items else None
|
next_cursor_seq = items[0].seq if has_more and items else None
|
||||||
|
logger.info(
|
||||||
|
"messages listed",
|
||||||
|
extra={
|
||||||
|
"event": "message_list",
|
||||||
|
"request_id": getattr(request.state, "request_id", None),
|
||||||
|
"user_id": current_user_id,
|
||||||
|
"conversation_id": conversation_id,
|
||||||
|
"cursor_seq": cursor_seq,
|
||||||
|
"limit": limit,
|
||||||
|
"count": len(items),
|
||||||
|
"has_more": has_more,
|
||||||
|
},
|
||||||
|
)
|
||||||
return MessageListResponse(
|
return MessageListResponse(
|
||||||
conversation_id=conversation_id,
|
conversation_id=conversation_id,
|
||||||
has_more=has_more,
|
has_more=has_more,
|
||||||
|
|||||||
@@ -35,6 +35,9 @@ class Settings(BaseSettings):
|
|||||||
default=60,
|
default=60,
|
||||||
validation_alias="JWT_ACCESS_TOKEN_EXPIRE_MINUTES",
|
validation_alias="JWT_ACCESS_TOKEN_EXPIRE_MINUTES",
|
||||||
)
|
)
|
||||||
|
log_level: str = Field(default="INFO", validation_alias="LOG_LEVEL")
|
||||||
|
log_json: bool = Field(default=False, validation_alias="LOG_JSON")
|
||||||
|
slow_sql_ms: int = Field(default=200, validation_alias="SLOW_SQL_MS")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def mysql_dsn(self) -> str:
|
def mysql_dsn(self) -> str:
|
||||||
|
|||||||
Reference in New Issue
Block a user