Spaces:
Running
Running
账号、私信与消息提醒配套更新
Browse files- chat_store.py +18 -2
- models.py +4 -0
- router_messages.py +8 -1
- 前端页面/components/用户体验_国际化.js +8 -0
- 前端页面/core/网络请求_业务API.js +6 -1
- 前端页面/social/私信聊天组件.js +21 -5
chat_store.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
import copy
|
| 3 |
import logging
|
| 4 |
import math
|
|
|
|
| 5 |
import time
|
| 6 |
import uuid
|
| 7 |
|
|
@@ -76,7 +77,10 @@ def _project(message):
|
|
| 76 |
return result
|
| 77 |
|
| 78 |
|
| 79 |
-
def send_message(sender, receiver, content):
|
|
|
|
|
|
|
|
|
|
| 80 |
if not isinstance(receiver, str) or not receiver or not isinstance(content, str) or not content.strip() or len(content) > MAX_MESSAGE_LENGTH:
|
| 81 |
raise HTTPException(status_code=400, detail="私信内容或接收者不正确")
|
| 82 |
users = _read("users.json")
|
|
@@ -89,7 +93,19 @@ def send_message(sender, receiver, content):
|
|
| 89 |
message = {"id": f"chat_{int(time.time())}_{uuid.uuid4().hex}", "sender": sender,
|
| 90 |
"receiver": receiver, "content": content, "created_at": int(time.time()), "is_read": False}
|
| 91 |
key = f"{min(sender, receiver)}_{max(sender, receiver)}"
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
|
| 95 |
def chat_list(account):
|
|
|
|
| 2 |
import copy
|
| 3 |
import logging
|
| 4 |
import math
|
| 5 |
+
import re
|
| 6 |
import time
|
| 7 |
import uuid
|
| 8 |
|
|
|
|
| 77 |
return result
|
| 78 |
|
| 79 |
|
| 80 |
+
def send_message(sender, receiver, content, client_request_id=None):
|
| 81 |
+
if client_request_id is not None and (not isinstance(client_request_id, str)
|
| 82 |
+
or re.fullmatch(r"[0-9a-f]{32}", client_request_id) is None):
|
| 83 |
+
raise HTTPException(status_code=400, detail="发送请求标识不正确")
|
| 84 |
if not isinstance(receiver, str) or not receiver or not isinstance(content, str) or not content.strip() or len(content) > MAX_MESSAGE_LENGTH:
|
| 85 |
raise HTTPException(status_code=400, detail="私信内容或接收者不正确")
|
| 86 |
users = _read("users.json")
|
|
|
|
| 93 |
message = {"id": f"chat_{int(time.time())}_{uuid.uuid4().hex}", "sender": sender,
|
| 94 |
"receiver": receiver, "content": content, "created_at": int(time.time()), "is_read": False}
|
| 95 |
key = f"{min(sender, receiver)}_{max(sender, receiver)}"
|
| 96 |
+
def append(data):
|
| 97 |
+
if client_request_id is not None:
|
| 98 |
+
for messages in data.values():
|
| 99 |
+
for existing in messages:
|
| 100 |
+
if existing["sender"] == sender and existing.get("client_request_id") == client_request_id:
|
| 101 |
+
if existing["receiver"] != receiver or existing["content"] != content:
|
| 102 |
+
raise HTTPException(status_code=409, detail="发送请求标识已用于其它消息")
|
| 103 |
+
return {"id": existing["id"], "client_request_id": client_request_id}
|
| 104 |
+
message["client_request_id"] = client_request_id
|
| 105 |
+
data.setdefault(key, []).append(message)
|
| 106 |
+
if client_request_id is not None:
|
| 107 |
+
return {"id": message["id"], "client_request_id": client_request_id}
|
| 108 |
+
return _mutate(append)
|
| 109 |
|
| 110 |
|
| 111 |
def chat_list(account):
|
models.py
CHANGED
|
@@ -131,6 +131,10 @@ class PrivateMessage(BaseModel):
|
|
| 131 |
receiver: str
|
| 132 |
content: str
|
| 133 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
class PrivacySettings(BaseModel):
|
| 135 |
follows: bool
|
| 136 |
likes: bool
|
|
|
|
| 131 |
receiver: str
|
| 132 |
content: str
|
| 133 |
|
| 134 |
+
class IdempotentPrivateMessage(PrivateMessage):
|
| 135 |
+
client_request_id: str = Field(..., strict=True, min_length=32, max_length=32, pattern=r"^[0-9a-f]{32}$")
|
| 136 |
+
|
| 137 |
+
|
| 138 |
class PrivacySettings(BaseModel):
|
| 139 |
follows: bool
|
| 140 |
likes: bool
|
router_messages.py
CHANGED
|
@@ -12,7 +12,7 @@ import os
|
|
| 12 |
import 数据库连接 as db
|
| 13 |
import chat_store
|
| 14 |
import notification_store
|
| 15 |
-
from models import PrivateMessage
|
| 16 |
from 安全认证 import require_auth, is_admin
|
| 17 |
|
| 18 |
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
|
@@ -142,6 +142,13 @@ async def send_private_message(request: Request, msg: PrivateMessage, current_us
|
|
| 142 |
await asyncio.to_thread(chat_store.send_message, current_user, msg.receiver, msg.content)
|
| 143 |
return {"status": "success"}
|
| 144 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
@router.get("/api/chats/{account}")
|
| 146 |
async def get_chat_list(account: str, current_user: str = Depends(require_auth)):
|
| 147 |
if account != current_user:
|
|
|
|
| 12 |
import 数据库连接 as db
|
| 13 |
import chat_store
|
| 14 |
import notification_store
|
| 15 |
+
from models import PrivateMessage, IdempotentPrivateMessage
|
| 16 |
from 安全认证 import require_auth, is_admin
|
| 17 |
|
| 18 |
# 🔒 频次限制(与 router_users_auth.py / router_wallet.py / router_prompts.py 同一写法)
|
|
|
|
| 142 |
await asyncio.to_thread(chat_store.send_message, current_user, msg.receiver, msg.content)
|
| 143 |
return {"status": "success"}
|
| 144 |
|
| 145 |
+
@router.post("/api/messages/private/idempotent")
|
| 146 |
+
@shared_limit(30)
|
| 147 |
+
async def send_idempotent_private_message(request: Request, msg: IdempotentPrivateMessage, current_user: str = Depends(require_auth)):
|
| 148 |
+
receipt = await asyncio.to_thread(chat_store.send_message, current_user, msg.receiver, msg.content, msg.client_request_id)
|
| 149 |
+
return {"status": "success", "data": receipt}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
@router.get("/api/chats/{account}")
|
| 153 |
async def get_chat_list(account: str, current_user: str = Depends(require_auth)):
|
| 154 |
if account != current_user:
|
前端页面/components/用户体验_国际化.js
CHANGED
|
@@ -629,6 +629,10 @@ const translations = {
|
|
| 629 |
'chat.date_day': '日',
|
| 630 |
'chat.load_more': '加载更早的消息',
|
| 631 |
'chat.msg_too_long': '消息不能超1000字',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
'chat.send_failed': '发送失败: {msg}',
|
| 633 |
'chat.load_list_failed': '加载对话列表失败',
|
| 634 |
'chat.load_history_failed': '加载消息记录失败',
|
|
@@ -2340,6 +2344,10 @@ const translations = {
|
|
| 2340 |
'chat.date_day': '',
|
| 2341 |
'chat.load_more': 'Load earlier messages',
|
| 2342 |
'chat.msg_too_long': 'Message cannot exceed 1000 characters',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2343 |
'chat.send_failed': 'Send failed: {msg}',
|
| 2344 |
'chat.load_list_failed': 'Failed to load conversations',
|
| 2345 |
'chat.load_history_failed': 'Failed to load messages',
|
|
|
|
| 629 |
'chat.date_day': '日',
|
| 630 |
'chat.load_more': '加载更早的消息',
|
| 631 |
'chat.msg_too_long': '消息不能超1000字',
|
| 632 |
+
'chat.send_unconfirmed': '发送结果暂时无法确认,请先查看会话。保持原文重试可复用本次发送标识。',
|
| 633 |
+
'chat.send_service_unavailable': '私信发送服务暂不可用,请确认接收账号及云端版本。',
|
| 634 |
+
'chat.send_request_conflict': '发送请求与原消息不一致,请先核对会话中的消息。',
|
| 635 |
+
'chat.send_rejected': '发送请求未获接受,请检查输入或稍后重试。',
|
| 636 |
'chat.send_failed': '发送失败: {msg}',
|
| 637 |
'chat.load_list_failed': '加载对话列表失败',
|
| 638 |
'chat.load_history_failed': '加载消息记录失败',
|
|
|
|
| 2344 |
'chat.date_day': '',
|
| 2345 |
'chat.load_more': 'Load earlier messages',
|
| 2346 |
'chat.msg_too_long': 'Message cannot exceed 1000 characters',
|
| 2347 |
+
'chat.send_unconfirmed': 'Sending could not be confirmed. Check the conversation first. Retrying unchanged text reuses this send identifier.',
|
| 2348 |
+
'chat.send_service_unavailable': 'Messaging is unavailable. Check the recipient and cloud version.',
|
| 2349 |
+
'chat.send_request_conflict': 'This request differs from the original message. Check the conversation first.',
|
| 2350 |
+
'chat.send_rejected': 'The send request was not accepted. Check your input or try again later.',
|
| 2351 |
'chat.send_failed': 'Send failed: {msg}',
|
| 2352 |
'chat.load_list_failed': 'Failed to load conversations',
|
| 2353 |
'chat.load_history_failed': 'Failed to load messages',
|
前端页面/core/网络请求_业务API.js
CHANGED
|
@@ -105,7 +105,12 @@ const api = {
|
|
| 105 |
// 🔥 新增:轻量级获取未读消息数量(不标记已读,用于轮询红点)
|
| 106 |
async getUnreadCount(account) { return request(`/api/messages/${account}?count_only=true`); },
|
| 107 |
async markMessagesRead(account) { return request(`/api/messages/${account}/read`, { method: "POST" }); },
|
| 108 |
-
async sendPrivateMessage(sender, receiver, content
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
async getChatList(account, options = {}) { return request(`/api/chats/${account}`, options); },
|
| 110 |
async getChatHistory(account, targetAccount, options = {}) { return request(`/api/chats/${account}/${targetAccount}`, options); },
|
| 111 |
async getWallet(account, options = {}) { return request(`/api/wallet/${account}`, options); },
|
|
|
|
| 105 |
// 🔥 新增:轻量级获取未读消息数量(不标记已读,用于轮询红点)
|
| 106 |
async getUnreadCount(account) { return request(`/api/messages/${account}?count_only=true`); },
|
| 107 |
async markMessagesRead(account) { return request(`/api/messages/${account}/read`, { method: "POST" }); },
|
| 108 |
+
async sendPrivateMessage(sender, receiver, content, clientRequestId) {
|
| 109 |
+
const path = clientRequestId === undefined ? "/api/messages/private" : "/api/messages/private/idempotent";
|
| 110 |
+
const body = { sender, receiver, content };
|
| 111 |
+
if (clientRequestId !== undefined) body.client_request_id = clientRequestId;
|
| 112 |
+
return request(path, { method: "POST", body, retries: 0 });
|
| 113 |
+
},
|
| 114 |
async getChatList(account, options = {}) { return request(`/api/chats/${account}`, options); },
|
| 115 |
async getChatHistory(account, targetAccount, options = {}) { return request(`/api/chats/${account}/${targetAccount}`, options); },
|
| 116 |
async getWallet(account, options = {}) { return request(`/api/wallet/${account}`, options); },
|
前端页面/social/私信聊天组件.js
CHANGED
|
@@ -140,7 +140,7 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 140 |
|
| 141 |
let constructing = true, observer, listGeneration = 0, historyGeneration = 0, conversationGeneration = 0;
|
| 142 |
let sending = false;
|
| 143 |
-
const drafts = new Map(), clearTimes = new Map();
|
| 144 |
const componentId = `chat-${++chatSequence}`;
|
| 145 |
const sessionMatches = captureRequestSession();
|
| 146 |
const scope = createTaskViewScope({
|
|
@@ -153,7 +153,7 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 153 |
});
|
| 154 |
scope.addCleanup(() => {
|
| 155 |
clearTimeout(pollTimer); pollTimer = null; observer?.disconnect();
|
| 156 |
-
drafts.clear(); clearTimes.clear(); allMessages = []; currentTargetInfo = null; currentChatTarget = null;
|
| 157 |
inputField.value = ''; messagesArea.replaceChildren(); chatListContainer.replaceChildren(); chatHeader.replaceChildren();
|
| 158 |
window.dispatchEvent(new CustomEvent('comfy-chat-unread-changed'));
|
| 159 |
});
|
|
@@ -416,12 +416,28 @@ export function openChatModal(currentUser, targetAccount = null) {
|
|
| 416 |
const current = () => scope.isCurrent() && currentChatTarget === target && generation === conversationGeneration;
|
| 417 |
sending = true; sendBtn.disabled = true; sendBtn.setAttribute('aria-busy', 'true');
|
| 418 |
try {
|
| 419 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 420 |
if (!current()) return;
|
| 421 |
-
if (result?.status !== 'success'
|
|
|
|
|
|
|
| 422 |
if (inputField.value === originalInput) { inputField.value = ''; drafts.delete(target); adjustHeight(); }
|
| 423 |
await Promise.allSettled([loadChatHistory(target, currentTargetInfo?.name, currentTargetInfo?.avatar), loadChatList()]);
|
| 424 |
-
} catch (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 425 |
finally {
|
| 426 |
sending = false;
|
| 427 |
if (scope.isCurrent()) { sendBtn.disabled = false; sendBtn.setAttribute('aria-busy', 'false'); }
|
|
|
|
| 140 |
|
| 141 |
let constructing = true, observer, listGeneration = 0, historyGeneration = 0, conversationGeneration = 0;
|
| 142 |
let sending = false;
|
| 143 |
+
const drafts = new Map(), clearTimes = new Map(), pendingMessages = new Map();
|
| 144 |
const componentId = `chat-${++chatSequence}`;
|
| 145 |
const sessionMatches = captureRequestSession();
|
| 146 |
const scope = createTaskViewScope({
|
|
|
|
| 153 |
});
|
| 154 |
scope.addCleanup(() => {
|
| 155 |
clearTimeout(pollTimer); pollTimer = null; observer?.disconnect();
|
| 156 |
+
drafts.clear(); clearTimes.clear(); pendingMessages.clear(); allMessages = []; currentTargetInfo = null; currentChatTarget = null;
|
| 157 |
inputField.value = ''; messagesArea.replaceChildren(); chatListContainer.replaceChildren(); chatHeader.replaceChildren();
|
| 158 |
window.dispatchEvent(new CustomEvent('comfy-chat-unread-changed'));
|
| 159 |
});
|
|
|
|
| 416 |
const current = () => scope.isCurrent() && currentChatTarget === target && generation === conversationGeneration;
|
| 417 |
sending = true; sendBtn.disabled = true; sendBtn.setAttribute('aria-busy', 'true');
|
| 418 |
try {
|
| 419 |
+
let pending = pendingMessages.get(target);
|
| 420 |
+
if (!pending || pending.content !== txt) {
|
| 421 |
+
const bytes = new Uint8Array(16);
|
| 422 |
+
crypto.getRandomValues(bytes);
|
| 423 |
+
pending = { id: Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''), content: txt };
|
| 424 |
+
pendingMessages.set(target, pending);
|
| 425 |
+
}
|
| 426 |
+
const result = await api.sendPrivateMessage(currentUser.account, target, txt, pending.id);
|
| 427 |
if (!current()) return;
|
| 428 |
+
if (result?.status !== 'success' || typeof result.data?.id !== 'string' || !result.data.id
|
| 429 |
+
|| result.data.client_request_id !== pending.id) throw new Error('Unconfirmed message');
|
| 430 |
+
if (pendingMessages.get(target) === pending) pendingMessages.delete(target);
|
| 431 |
if (inputField.value === originalInput) { inputField.value = ''; drafts.delete(target); adjustHeight(); }
|
| 432 |
await Promise.allSettled([loadChatHistory(target, currentTargetInfo?.name, currentTargetInfo?.avatar), loadChatList()]);
|
| 433 |
+
} catch (error) {
|
| 434 |
+
if (current()) {
|
| 435 |
+
const message = error?.status === 404 ? t('chat.send_service_unavailable')
|
| 436 |
+
: error?.status === 409 ? t('chat.send_request_conflict')
|
| 437 |
+
: error?.status >= 400 && error?.status < 500 ? t('chat.send_rejected') : t('chat.send_unconfirmed');
|
| 438 |
+
showToast(message, 'error');
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
finally {
|
| 442 |
sending = false;
|
| 443 |
if (scope.isCurrent()) { sendBtn.disabled = false; sendBtn.setAttribute('aria-busy', 'false'); }
|