dev #1

Merged
huangyicao merged 12 commits from dev into main 2026-04-27 09:39:05 +08:00
Showing only changes of commit 0683de528a - Show all commits

View File

@@ -5,11 +5,18 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
try: try:
# For module mode: `uvicorn app.main:app` # For module mode: `uvicorn app.main:app`
from app.db import get_db from app.db import get_db
from app.db_compat import (
current_timestamp_sql,
get_db_dialect_name,
inserted_primary_key,
select_for_update_clause,
)
from app.security import get_current_user_id from app.security import get_current_user_id
from app.schemas.message import ( from app.schemas.message import (
MessageCreateRequest, MessageCreateRequest,
@@ -20,6 +27,12 @@ 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 get_db from db import get_db
from db_compat import (
current_timestamp_sql,
get_db_dialect_name,
inserted_primary_key,
select_for_update_clause,
)
from security import get_current_user_id from security import get_current_user_id
from schemas.message import ( from schemas.message import (
MessageCreateRequest, MessageCreateRequest,
@@ -32,6 +45,9 @@ except ModuleNotFoundError:
router = APIRouter(prefix="/messages", tags=["messages"]) router = APIRouter(prefix="/messages", tags=["messages"])
logger = logging.getLogger("app.messages") logger = logging.getLogger("app.messages")
PARENT_PARTICIPANT_TYPE = 1
PARENT_DIRECT_CONVERSATION_TYPE = 3
def _build_preview(content_type: int, content_text: str | None) -> str: def _build_preview(content_type: int, content_text: str | None) -> str:
if content_type == 1: if content_type == 1:
@@ -61,12 +77,16 @@ def _normalize_content_json(value: Any) -> dict[str, Any] | None:
def _row_to_message_item(row: Mapping[str, Any]) -> MessageItem: def _row_to_message_item(row: Mapping[str, Any]) -> MessageItem:
sender_user_id = row.get("sender_user_id")
if sender_user_id is not None:
sender_user_id = int(sender_user_id)
return MessageItem( return MessageItem(
id=int(row["id"]), id=int(row["id"]),
conversation_id=int(row["conversation_id"]), conversation_id=int(row["conversation_id"]),
seq=int(row["seq"]), seq=int(row["seq"]),
sender_user_id=row["sender_user_id"], sender_user_id=sender_user_id,
role=int(row["role"]), role=int(row.get("role", 1)),
content_type=int(row["content_type"]), content_type=int(row["content_type"]),
content_text=row["content_text"], content_text=row["content_text"],
content_json=_normalize_content_json(row["content_json"]), content_json=_normalize_content_json(row["content_json"]),
@@ -88,8 +108,11 @@ def _get_existing_message(
id, id,
conversation_id, conversation_id,
seq, seq,
sender_user_id, CASE
role, WHEN sender_type = :parent_participant_type THEN sender_id
ELSE NULL
END AS sender_user_id,
1 AS role,
content_type, content_type,
content_text, content_text,
content_json, content_json,
@@ -97,19 +120,170 @@ def _get_existing_message(
media_duration_ms, media_duration_ms,
client_msg_id, client_msg_id,
created_at created_at
FROM chat_message FROM im_messages
WHERE conversation_id = :conversation_id WHERE conversation_id = :conversation_id
AND client_msg_id = :client_msg_id AND client_msg_id = :client_msg_id
LIMIT 1 LIMIT 1
""" """
), ),
{"conversation_id": conversation_id, "client_msg_id": client_msg_id}, {
"conversation_id": conversation_id,
"client_msg_id": client_msg_id,
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
},
) )
.mappings() .mappings()
.first() .first()
) )
def _build_parent_direct_pair(user_a_id: int, user_b_id: int) -> tuple[str, str, str]:
low_id, high_id = sorted((user_a_id, user_b_id))
participant_a_id = str(low_id)
participant_b_id = str(high_id)
return participant_a_id, participant_b_id, f"{participant_a_id}:{participant_b_id}"
def _next_primary_key(db: Session, table_name: str) -> int:
if table_name not in {"im_conversations", "im_messages"}:
raise ValueError(f"unsupported table name: {table_name}")
return int(
db.execute(text(f"SELECT COALESCE(MAX(id), 0) + 1 FROM {table_name}")).scalar_one()
)
def _get_direct_conversation(
db: Session,
user_low_id: int,
user_high_id: int,
) -> Mapping[str, Any] | None:
_, _, pair_key = _build_parent_direct_pair(user_low_id, user_high_id)
return (
db.execute(
text(
"""
SELECT id, last_seq, status
FROM im_conversations
WHERE conversation_type = :conversation_type
AND pair_key = :pair_key
LIMIT 1
"""
),
{
"conversation_type": PARENT_DIRECT_CONVERSATION_TYPE,
"pair_key": pair_key,
},
)
.mappings()
.first()
)
def _get_or_create_direct_conversation(
db: Session,
user_low_id: int,
user_high_id: int,
) -> int:
existing = _get_direct_conversation(
db=db,
user_low_id=user_low_id,
user_high_id=user_high_id,
)
if existing:
return int(existing["id"])
now_sql = current_timestamp_sql(db)
participant_a_id, participant_b_id, pair_key = _build_parent_direct_pair(user_low_id, user_high_id)
conversation_id = None
if get_db_dialect_name(db) == "sqlite":
conversation_id = _next_primary_key(db, "im_conversations")
try:
insert_sql = f"""
INSERT INTO im_conversations (
{'id,' if conversation_id is not None else ''}
conversation_type,
participant_a_type,
participant_a_id,
participant_b_type,
participant_b_id,
pair_key,
status,
last_seq,
message_count,
created_at,
updated_at
)
VALUES (
{':id,' if conversation_id is not None else ''}
:conversation_type,
:participant_a_type,
:participant_a_id,
:participant_b_type,
:participant_b_id,
:pair_key,
1,
0,
0,
{now_sql},
{now_sql}
)
"""
params = {
"conversation_type": PARENT_DIRECT_CONVERSATION_TYPE,
"participant_a_type": PARENT_PARTICIPANT_TYPE,
"participant_a_id": participant_a_id,
"participant_b_type": PARENT_PARTICIPANT_TYPE,
"participant_b_id": participant_b_id,
"pair_key": pair_key,
}
if conversation_id is not None:
params["id"] = conversation_id
result = db.execute(
text(
insert_sql
),
params,
)
if conversation_id is not None:
return conversation_id
return inserted_primary_key(result)
except IntegrityError:
existing = _get_direct_conversation(
db=db,
user_low_id=user_low_id,
user_high_id=user_high_id,
)
if existing:
return int(existing["id"])
raise
def _get_active_parent_profiles(
db: Session,
*,
sender_user_id: int,
peer_user_id: int,
) -> dict[int, Mapping[str, Any]]:
rows = (
db.execute(
text(
"""
SELECT user_id, nickname, avatar_url
FROM parents
WHERE user_id IN (:sender_user_id, :peer_user_id)
AND status = 1
"""
),
{"sender_user_id": sender_user_id, "peer_user_id": peer_user_id},
)
.mappings()
.all()
)
return {int(row["user_id"]): row for row in rows}
def _assert_conversation_access( def _assert_conversation_access(
db: Session, db: Session,
conversation_id: int, conversation_id: int,
@@ -119,8 +293,13 @@ def _assert_conversation_access(
db.execute( db.execute(
text( text(
""" """
SELECT id, user_low_id, user_high_id SELECT
FROM chat_conversation id,
participant_a_type,
participant_a_id,
participant_b_type,
participant_b_id
FROM im_conversations
WHERE id = :conversation_id WHERE id = :conversation_id
LIMIT 1 LIMIT 1
""" """
@@ -133,10 +312,18 @@ def _assert_conversation_access(
if not conversation_row: if not conversation_row:
raise HTTPException(status_code=404, detail="conversation not found") raise HTTPException(status_code=404, detail="conversation not found")
if current_user_id not in ( current_user_id_str = str(current_user_id)
int(conversation_row["user_low_id"]), is_participant = (
int(conversation_row["user_high_id"]), (
): int(conversation_row["participant_a_type"]) == PARENT_PARTICIPANT_TYPE
and conversation_row["participant_a_id"] == current_user_id_str
)
or (
int(conversation_row["participant_b_type"]) == PARENT_PARTICIPANT_TYPE
and conversation_row["participant_b_id"] == current_user_id_str
)
)
if not is_participant:
raise HTTPException(status_code=403, detail="no permission for this conversation") raise HTTPException(status_code=403, detail="no permission for this conversation")
@@ -155,62 +342,30 @@ def create_message(
user_low_id = min(sender_user_id, peer_user_id) user_low_id = min(sender_user_id, peer_user_id)
user_high_id = max(sender_user_id, peer_user_id) user_high_id = max(sender_user_id, peer_user_id)
now_sql = current_timestamp_sql(db)
with db.begin(): with db.begin():
user_count = db.execute( parents = _get_active_parent_profiles(
text( db,
""" sender_user_id=sender_user_id,
SELECT COUNT(*) AS cnt peer_user_id=peer_user_id,
FROM chat_user )
WHERE id IN (:sender_user_id, :peer_user_id) if len(parents) != 2:
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") raise HTTPException(status_code=404, detail="sender or peer user not found")
db.execute( conversation_id = _get_or_create_direct_conversation(
text( db=db,
""" user_low_id=user_low_id,
INSERT INTO chat_conversation ( user_high_id=user_high_id,
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 = ( conversation = (
db.execute( db.execute(
text( text(
""" f"""
SELECT id, last_seq, status SELECT id, last_seq, status
FROM chat_conversation FROM im_conversations
WHERE id = :conversation_id WHERE id = :conversation_id{select_for_update_clause(db)}
FOR UPDATE
""" """
), ),
{"conversation_id": conversation_id}, {"conversation_id": conversation_id},
@@ -247,63 +402,92 @@ def create_message(
next_seq = int(conversation["last_seq"]) + 1 next_seq = int(conversation["last_seq"]) + 1
preview = _build_preview(payload.content_type, payload.content_text) preview = _build_preview(payload.content_type, payload.content_text)
message_id = None
if get_db_dialect_name(db) == "sqlite":
message_id = _next_primary_key(db, "im_messages")
insert_result = db.execute( insert_sql = f"""
text( INSERT INTO im_messages (
""" {'id,' if message_id is not None else ''}
INSERT INTO chat_message (
conversation_id, conversation_id,
seq, seq,
sender_user_id, sender_type,
role, sender_id,
receiver_type,
receiver_id,
content_type, content_type,
content_text, content_text,
content_json, content_json,
media_file_key, media_file_key,
media_duration_ms, media_duration_ms,
client_msg_id, client_msg_id,
sender_name_snapshot,
sender_avatar_snapshot,
receiver_name_snapshot,
receiver_avatar_snapshot,
created_at created_at
) )
VALUES ( VALUES (
{':id,' if message_id is not None else ''}
:conversation_id, :conversation_id,
:seq, :seq,
:sender_user_id, :sender_type,
1, :sender_id,
:receiver_type,
:receiver_id,
:content_type, :content_type,
:content_text, :content_text,
:content_json, :content_json,
:media_file_key, :media_file_key,
:media_duration_ms, :media_duration_ms,
:client_msg_id, :client_msg_id,
CURRENT_TIMESTAMP(3) :sender_name_snapshot,
:sender_avatar_snapshot,
:receiver_name_snapshot,
:receiver_avatar_snapshot,
{now_sql}
) )
""" """
insert_params = {
"conversation_id": conversation_id,
"seq": next_seq,
"sender_type": PARENT_PARTICIPANT_TYPE,
"sender_id": str(sender_user_id),
"receiver_type": PARENT_PARTICIPANT_TYPE,
"receiver_id": str(peer_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,
"sender_name_snapshot": parents[sender_user_id]["nickname"],
"sender_avatar_snapshot": parents[sender_user_id]["avatar_url"],
"receiver_name_snapshot": parents[peer_user_id]["nickname"],
"receiver_avatar_snapshot": parents[peer_user_id]["avatar_url"],
}
if message_id is not None:
insert_params["id"] = message_id
insert_result = db.execute(
text(
insert_sql
), ),
{ insert_params,
"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( db.execute(
text( text(
""" f"""
UPDATE chat_conversation UPDATE im_conversations
SET SET
last_seq = :last_seq, last_seq = :last_seq,
message_count = message_count + 1, message_count = message_count + 1,
last_message_preview = :last_message_preview, last_message_preview = :last_message_preview,
last_message_at = CURRENT_TIMESTAMP(3), last_message_at = {now_sql},
updated_at = CURRENT_TIMESTAMP(3) updated_at = {now_sql}
WHERE id = :conversation_id WHERE id = :conversation_id
""" """
), ),
@@ -322,8 +506,11 @@ def create_message(
id, id,
conversation_id, conversation_id,
seq, seq,
sender_user_id, CASE
role, WHEN sender_type = :parent_participant_type THEN sender_id
ELSE NULL
END AS sender_user_id,
1 AS role,
content_type, content_type,
content_text, content_text,
content_json, content_json,
@@ -331,12 +518,15 @@ def create_message(
media_duration_ms, media_duration_ms,
client_msg_id, client_msg_id,
created_at created_at
FROM chat_message FROM im_messages
WHERE id = :message_id WHERE id = :message_id
LIMIT 1 LIMIT 1
""" """
), ),
{"message_id": insert_result.lastrowid}, {
"message_id": message_id if message_id is not None else inserted_primary_key(insert_result),
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
},
) )
.mappings() .mappings()
.first() .first()
@@ -383,8 +573,11 @@ def list_messages(
id, id,
conversation_id, conversation_id,
seq, seq,
sender_user_id, CASE
role, WHEN sender_type = :parent_participant_type THEN sender_id
ELSE NULL
END AS sender_user_id,
1 AS role,
content_type, content_type,
content_text, content_text,
content_json, content_json,
@@ -392,13 +585,14 @@ def list_messages(
media_duration_ms, media_duration_ms,
client_msg_id, client_msg_id,
created_at created_at
FROM chat_message FROM im_messages
WHERE conversation_id = :conversation_id WHERE conversation_id = :conversation_id
AND deleted_at IS NULL AND deleted_at IS NULL
""" """
params: dict[str, Any] = { params: dict[str, Any] = {
"conversation_id": conversation_id, "conversation_id": conversation_id,
"fetch_limit": limit + 1, "fetch_limit": limit + 1,
"parent_participant_type": PARENT_PARTICIPANT_TYPE,
} }
if cursor_seq is not None: if cursor_seq is not None:
sql += " AND seq < :cursor_seq" sql += " AND seq < :cursor_seq"