Merge branch 'fork' of 1030-6/d8d-admin-mobile-starter-public into main

This commit is contained in:
2025-05-15 12:08:28 +00:00
committed by Gogs
12 changed files with 798 additions and 81 deletions

View File

@@ -3,6 +3,10 @@
迁移管理页面在正式环境中需要验证env中配置的密码参数才能打开
2025.05.15 0.1.6
站内消息支持三种类型admin发送mobile订阅
增加admin, mobile 消息io连接
增加socketio 路由 支持
增加socketio server 支持
修正文件分类后端api路由查询表名为file_categories
将react版本降为18.3.1

View File

@@ -1,15 +1,20 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Table, Space, Modal, Form, Input, Select, message } from 'antd';
import { io, Socket } from 'socket.io-client';
import type { TableProps } from 'antd';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { MessageAPI , UserAPI } from './api/index.ts';
import type { UserMessage } from '../share/types.ts';
import { MessageStatusNameMap , MessageStatus} from '../share/types.ts';
import { MessageStatusNameMap , MessageStatus, MessageType } from '../share/types.ts';
import { useAuth } from "./hooks_sys.tsx";
export const MessagesPage = () => {
export const MessagesPage = () => {
const { token } = useAuth();
const [socket, setSocket] = useState<Socket | null>(null);
const [isSocketConnected, setIsSocketConnected] = useState(false);
const queryClient = useQueryClient();
const [form] = Form.useForm();
const [isModalVisible, setIsModalVisible] = useState(false);
@@ -59,8 +64,58 @@ export const MessagesPage = () => {
});
// 发送消息
// 初始化Socket.IO连接
useEffect(() => {
if (!token) return;
const newSocket = io('/', {
path: '/socket.io',
transports: ['websocket'],
autoConnect: false,
query: {
socket_token: token
}
});
newSocket.on('connect', () => {
setIsSocketConnected(true);
message.success('实时消息连接已建立');
});
newSocket.on('disconnect', () => {
setIsSocketConnected(false);
message.warning('实时消息连接已断开');
});
newSocket.on('error', (err) => {
message.error(`实时消息错误: ${err}`);
});
newSocket.connect();
setSocket(newSocket);
return () => {
newSocket.disconnect();
};
}, [token]);
const sendMessageMutation = useMutation({
mutationFn: (data: any) => MessageAPI.sendMessage(data),
mutationFn: async (data: any) => {
// 优先使用Socket.IO发送
if (isSocketConnected && socket) {
return new Promise((resolve, reject) => {
socket.emit('message:send', data, (response: any) => {
if (response.error) {
reject(new Error(response.error));
} else {
resolve(response.data);
}
});
});
}
// 回退到HTTP API
return MessageAPI.sendMessage(data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['messages'] });
queryClient.invalidateQueries({ queryKey: ['unreadCount'] });
@@ -167,9 +222,9 @@ export const MessagesPage = () => {
style={{ width: 120 }}
allowClear
options={[
{ value: 'SYSTEM', label: '系统消息' },
{ value: 'NOTICE', label: '公告' },
{ value: 'PERSONAL', label: '个人消息' },
{ value: MessageType.SYSTEM, label: '系统消息' },
{ value: MessageType.ANNOUNCE, label: '公告' },
{ value: MessageType.PRIVATE, label: '个人消息' },
]}
/>
</Form.Item>
@@ -178,8 +233,8 @@ export const MessagesPage = () => {
style={{ width: 120 }}
allowClear
options={[
{ value: 'UNREAD', label: '未读' },
{ value: 'READ', label: '已读' },
{ value: MessageStatus.UNREAD, label: '未读' },
{ value: MessageStatus.READ, label: '已读' },
]}
/>
</Form.Item>
@@ -235,9 +290,9 @@ export const MessagesPage = () => {
>
<Select
options={[
{ value: 'SYSTEM', label: '系统消息' },
{ value: 'NOTICE', label: '公告' },
{ value: 'PERSONAL', label: '个人消息' },
{ value: MessageType.SYSTEM, label: '系统消息' },
{ value: MessageType.ANNOUNCE, label: '公告' },
{ value: MessageType.PRIVATE, label: '个人消息' },
]}
/>
</Form.Item>
@@ -266,10 +321,11 @@ export const MessagesPage = () => {
</Form.Item>
<Form.Item>
<Button
type="primary"
<Button
type="primary"
htmlType="submit"
loading={sendMessageMutation.status === 'pending'}
icon={isSocketConnected ? <span style={{color:'green'}}></span> : <span style={{color:'red'}}></span>}
>
</Button>

View File

@@ -1,16 +1,20 @@
import React from 'react';
import React, { useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import { BellIcon } from '@heroicons/react/24/outline';
import { MessageStatus } from '../share/types.ts';
import { io, Socket } from 'socket.io-client';
// 添加通知页面组件
import { MessageAPI } from './api/index.ts';
import { useAuth } from "./hooks.tsx";
export const NotificationsPage = () => {
const { token , user} = useAuth();
const queryClient = useQueryClient();
const [socket, setSocket] = React.useState<Socket | null>(null);
const [isSubscribed, setIsSubscribed] = React.useState(false);
// 获取消息列表
const { data: messages, isLoading } = useQuery({
@@ -18,6 +22,72 @@ export const NotificationsPage = () => {
queryFn: () => MessageAPI.getMessages(),
});
// 初始化Socket.IO连接
useEffect(() => {
if (!token || !user) return;
const newSocket = io('/', {
path: '/socket.io',
transports: ['websocket'],
withCredentials: true,
query: {
socket_token: token
}
});
setSocket(newSocket);
// 订阅消息频道
newSocket.on('connect', () => {
// 订阅个人频道
newSocket.emit('message:subscribe', `user_${user.id}`);
// 订阅系统频道
newSocket.emit('message:subscribe', 'system');
// 订阅公告频道
newSocket.emit('message:subscribe', 'announce');
setIsSubscribed(true);
});
// 处理实时消息
const handleNewMessage = (newMessage: any) => {
queryClient.setQueryData(['messages'], (oldData: any) => {
if (!oldData) return oldData;
return {
...oldData,
data: [newMessage, ...oldData.data]
};
});
// 更新未读计数
queryClient.setQueryData(['unreadCount'], (oldData: any) => {
if (!oldData) return oldData;
return {
...oldData,
count: oldData.count + 1
};
});
};
// 处理广播消息
newSocket.on('message:broadcasted', handleNewMessage);
// 处理频道推送消息
newSocket.on('message:received', handleNewMessage);
// 错误处理
newSocket.on('error', (error) => {
console.error('Socket error:', error);
});
return () => {
if (newSocket) {
newSocket.emit('message:unsubscribe', `user_${user.id}`);
newSocket.emit('message:unsubscribe', 'system');
newSocket.emit('message:unsubscribe', 'announce');
newSocket.disconnect();
}
};
}, [queryClient, token]);
// 获取未读消息数量
const { data: unreadCount } = useQuery({
queryKey: ['unreadCount'],

View File

@@ -28,7 +28,9 @@
"@ant-design/plots": "https://esm.d8d.fun/@ant-design/plots@2.1.13?dev&deps=react@18.3.1,react-dom@18.3.1",
"react-hook-form": "https://esm.d8d.fun/react-hook-form@7.55.0?dev&deps=react@18.3.1,react-dom@18.3.1",
"@heroicons/react/24/outline": "https://esm.d8d.fun/@heroicons/react@2.1.1/24/outline?dev&deps=react@18.3.1,react-dom@18.3.1",
"@heroicons/react/24/solid": "https://esm.d8d.fun/@heroicons/react@2.1.1/24/solid?dev&deps=react@18.3.1,react-dom@18.3.1"
"@heroicons/react/24/solid": "https://esm.d8d.fun/@heroicons/react@2.1.1/24/solid?dev&deps=react@18.3.1,react-dom@18.3.1",
"socket.io": "https://deno.land/x/socket_io@0.2.1/mod.ts",
"socket.io-client": "https://esm.d8d.fun/socket.io-client@4.8.1"
},
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext", "deno.ns"]

68
deno.lock generated
View File

@@ -2575,9 +2575,32 @@
"https://esm.d8d.fun/xmlhttprequest-ssl@~2.1.1?target=denonext": "https://esm.d8d.fun/xmlhttprequest-ssl@2.1.2?target=denonext"
},
"remote": {
"https://deno.land/std@0.150.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.150.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
"https://deno.land/std@0.150.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
"https://deno.land/std@0.150.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
"https://deno.land/std@0.150.0/fmt/colors.ts": "6f9340b7fb8cc25a993a99e5efc56fe81bb5af284ff412129dd06df06f53c0b4",
"https://deno.land/std@0.150.0/fs/exists.ts": "cb734d872f8554ea40b8bff77ad33d4143c1187eac621a55bf37781a43c56f6d",
"https://deno.land/std@0.150.0/io/buffer.ts": "bd0c4bf53db4b4be916ca5963e454bddfd3fcd45039041ea161dbf826817822b",
"https://deno.land/std@0.150.0/log/handlers.ts": "b88c24df61eaeee8581dbef3622f21aebfd061cd2fda49affc1711c0e54d57da",
"https://deno.land/std@0.150.0/log/levels.ts": "82c965b90f763b5313e7595d4ba78d5095a13646d18430ebaf547526131604d1",
"https://deno.land/std@0.150.0/log/logger.ts": "4d25581bc02dfbe3ad7e8bb480e1f221793a85be5e056185a0cea134f7a7fdf4",
"https://deno.land/std@0.150.0/log/mod.ts": "65d2702785714b8d41061426b5c279f11b3dcbc716f3eb5384372a430af63961",
"https://deno.land/std@0.150.0/media_types/_util.ts": "ce9b4fc4ba1c447dafab619055e20fd88236ca6bdd7834a21f98bd193c3fbfa1",
"https://deno.land/std@0.150.0/media_types/mod.ts": "2d4b6f32a087029272dc59e0a55ae3cc4d1b27b794ccf528e94b1925795b3118",
"https://deno.land/std@0.150.0/media_types/vendor/mime-db.v1.52.0.ts": "724cee25fa40f1a52d3937d6b4fbbfdd7791ff55e1b7ac08d9319d5632c7f5af",
"https://deno.land/std@0.150.0/testing/_diff.ts": "029a00560b0d534bc0046f1bce4bd36b3b41ada3f2a3178c85686eb2ff5f1413",
"https://deno.land/std@0.150.0/testing/_format.ts": "0d8dc79eab15b67cdc532826213bbe05bccfd276ca473a50a3fc7bbfb7260642",
"https://deno.land/std@0.150.0/testing/_test_suite.ts": "ad453767aeb8c300878a6b7920e20370f4ce92a7b6c8e8a5d1ac2b7c14a09acb",
"https://deno.land/std@0.150.0/testing/asserts.ts": "0ee58a557ac764e762c62bb21f00e7d897e3919e71be38b2d574fb441d721005",
"https://deno.land/std@0.150.0/testing/bdd.ts": "182bb823e09bd75b76063ecf50722870101b7cfadf97a09fa29127279dc21128",
"https://deno.land/std@0.158.0/_util/assert.ts": "e94f2eb37cebd7f199952e242c77654e43333c1ac4c5c700e929ea3aa5489f74",
"https://deno.land/std@0.158.0/async/deferred.ts": "c01de44b9192359cebd3fe93273fcebf9e95110bf3360023917da9a2d1489fae",
"https://deno.land/std@0.158.0/async/delay.ts": "0419dfc993752849692d1f9647edf13407c7facc3509b099381be99ffbc9d699",
"https://deno.land/std@0.158.0/bytes/bytes_list.ts": "aba5e2369e77d426b10af1de0dcc4531acecec27f9b9056f4f7bfbf8ac147ab4",
"https://deno.land/std@0.158.0/bytes/equals.ts": "3c3558c3ae85526f84510aa2b48ab2ad7bdd899e2e0f5b7a8ffc85acb3a6043a",
"https://deno.land/std@0.158.0/bytes/mod.ts": "763f97d33051cc3f28af1a688dfe2830841192a9fea0cbaa55f927b49d49d0bf",
"https://deno.land/std@0.158.0/io/buffer.ts": "fae02290f52301c4e0188670e730cd902f9307fb732d79c4aa14ebdc82497289",
"https://deno.land/std@0.217.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975",
"https://deno.land/std@0.217.0/assert/_diff.ts": "dcc63d94ca289aec80644030cf88ccbf7acaa6fbd7b0f22add93616b36593840",
"https://deno.land/std@0.217.0/assert/_format.ts": "0ba808961bf678437fb486b56405b6fefad2cf87b5809667c781ddee8c32aff4",
@@ -2654,6 +2677,51 @@
"https://deno.land/x/deno_dom@v0.1.48/src/dom/utils-types.ts": "96db30e3e4a75b194201bb9fa30988215da7f91b380fca6a5143e51ece2a8436",
"https://deno.land/x/deno_dom@v0.1.48/src/dom/utils.ts": "4c6206516fb8f61f37a209c829e812c4f5a183e46d082934dd14c91bde939263",
"https://deno.land/x/deno_dom@v0.1.48/src/parser.ts": "e06b2300d693e6ae7564e53dfa5c9a9e97fdb8c044c39c52c8b93b5d60860be3",
"https://deno.land/x/socket_io@0.2.1/deps.ts": "2c9c7fd0f00c9f8774a7cbf6bea2e73b274989aacb3ebfbd289a3c1bbe632bcb",
"https://deno.land/x/socket_io@0.2.1/mod.ts": "29050911ca6f9605623c672238bb209ca37ed23606c596d199e6e33de04168f9",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io-parser/base64-arraybuffer.ts": "57ccea6679609df5416159fcc8a47936ad28ad6fe32235ef78d9223a3a823407",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io-parser/mod.ts": "27d35094e2159ba49f6e74f11ed83b6208a6adb5a2d5ab3cbbdcdc9dc0e36ae7",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io/lib/cors.ts": "e39b530dc3526ef85f288766ce592fa5cce2ec38b3fa19922041a7885b79b67c",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io/lib/server.ts": "2faf79492858e532ad199c32b565bb04528fa9ea55d167e223dc9d019ef9315f",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io/lib/socket.ts": "feb50d196decd7b1fc79af2706465cc7b4b8b18ebb7ca3dc8cedadb0a393103e",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io/lib/transport.ts": "b09c589a099d539cd0b61f7b3be0b9d2d9ba7b8cbafdf4824425176ecf8d89c9",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io/lib/transports/polling.ts": "5650189f6cd742ec0fd45f8c262105f07463b24e8183da6ffb2bf775baf21395",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io/lib/transports/websocket.ts": "4a868e73d3b8b207d822d358f14723bd8d1cd80c6926be9c4bf6c4234e8a0d00",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io/lib/util.ts": "9f396a141422c8a2e2ef4cbb31c8b7ec96665d8f1ca397888eaaa9ad28ca8c65",
"https://deno.land/x/socket_io@0.2.1/packages/engine.io/mod.ts": "3f7d85ebd3bee6e17838f4867927d808f35090a71e088fd4dd802e3255d44c4a",
"https://deno.land/x/socket_io@0.2.1/packages/event-emitter/mod.ts": "dcb2cb9c0b409060cf15a6306a8dbebea844aa3c58f782ed1d4bc3ccef7c2835",
"https://deno.land/x/socket_io@0.2.1/packages/msgpack/lib/decode.ts": "5906fa37474130b09fe308adb53c95e40d2484a015891be3249fb2f626c462bb",
"https://deno.land/x/socket_io@0.2.1/packages/msgpack/lib/encode.ts": "15dab78be56d539c03748c9d57086f7fd580eb8fbe2f8209c28750948c7d962e",
"https://deno.land/x/socket_io@0.2.1/packages/msgpack/mod.ts": "c7f4a9859af3e0b23794b400546d93475b19ba5110a02245112a0a994a31d309",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io-parser/mod.ts": "44479cf563b0ad80efedd1059cd40114bc5db199b45e623c2764e80f4a264f8c",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io-redis-adapter/mod.ts": "45d6b7f077f94fec385152bda7fda5ac3153c2ca3548cf4859891af673fa97cc",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io/lib/adapter.ts": "594fbe748497ce346e8b783065cb19b284f27d702ff661d872971d14ccf6cf29",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io/lib/broadcast-operator.ts": "d842eb933acc996a05ac701f6d83ffee49ee9c905c9adbdee70832776045bf63",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io/lib/client.ts": "5e5d8e39d58cc5eb14f219e85ae6eef8fe6dd5f685f4c73496531aa48529c2c6",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io/lib/namespace.ts": "d9ec734c8b45f4204c40382e1d3a8fb4d1b8bffab4bca4d2d69baece0703d4f8",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io/lib/parent-namespace.ts": "9628cbf54e35bea01956825f557b4a82a37c8f1343423c0e7d952d475bf68ea0",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io/lib/server.ts": "19b10d05e09e436fda0d8d205ba1ce06bb5877f516176b8148de8a3c26b37688",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io/lib/socket.ts": "c448032d0f819d40d6dc30eb312c391c5d902eeeeb67bfd4f4f4c3499545eb7e",
"https://deno.land/x/socket_io@0.2.1/packages/socket.io/mod.ts": "dfd465bdcf23161af0c4d79fb8fc8912418c46a20d15e8b314cec6d9fb508196",
"https://deno.land/x/socket_io@0.2.1/test_deps.ts": "42e6bff240c54a2d7ade82154f8655650664a65ad9228623a0fdb3d0cde11ef0",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/backoff.ts": "33e4a6e245f8743fbae0ce583993a671a3ac2ecee433a3e7f0bd77b5dd541d84",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/command.ts": "802df3a1f49f6c49fe3e8fcf13fd0cc360b8a02369de0310a72d7f0c8e4ceaab",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/connection.ts": "b325d5b720af8132cd81d6d8b6a2e61936631b36d0dd08907d5421107bf50723",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/errors.ts": "bc8f7091cb9f36cdd31229660e0139350b02c26851e3ac69d592c066745feb27",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/executor.ts": "03e5f43df4e0c9c62b0e1be778811d45b6a1966ddf406e21ed5a227af70b7183",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/mod.ts": "20908f005f5c102525ce6aa9261648c95c5f61c6cf782b2cbb2fce88b1220f69",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/pipeline.ts": "80cc26a881149264d51dd019f1044c4ec9012399eca9f516057dc81c9b439370",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/protocol/_util.ts": "0525f7f444a96b92cd36423abdfe221f8d8de4a018dc5cb6750a428a5fc897c2",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/protocol/command.ts": "b1efd3b62fe5d1230e6d96b5c65ba7de1592a1eda2cc927161e5997a15f404ac",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/protocol/mod.ts": "f2601df31d8adc71785b5d19f6a7e43dfce94adbb6735c4dafc1fb129169d11a",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/protocol/reply.ts": "beac2061b03190bada179aef1a5d92b47a5104d9835e8c7468a55c24812ae9e4",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/protocol/types.ts": "40b0a568cb7fd4dc9107997062584d24e5c6ffa1f21acb6410aa19c92f89e9e1",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/pubsub.ts": "324b87dae0700e4cb350780ce3ae5bc02780f79f3de35e01366b894668b016c6",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/redis.ts": "a5c2cf8c72e7c92c9c8c6911f98227062649f6cba966938428c5414200f3aa54",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/stream.ts": "f116d73cfe04590ff9fa8a3c08be8ff85219d902ef2f6929b8c1e88d92a07810",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/async/deferred.ts": "7391210927917113e04247ef013d800d54831f550e9a0b439244675c56058c55",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/async/delay.ts": "c7e2604f7cb5ef00d595de8dd600604902d5be03a183b515b3d6d4bbb48e1700",
"https://deno.land/x/socket_io@0.2.1/vendor/deno.land/x/redis@v0.27.1/vendor/https/deno.land/std/io/buffer.ts": "8c5f84b7ecf71bc3e12aa299a9fae9e72e495db05281fcdd62006ecd3c5ed3f3",
"https://deno.land/x/xhr@0.3.0/mod.ts": "094aacd627fd9635cd942053bf8032b5223b909858fa9dc8ffa583752ff63b20",
"https://esm.d8d.fun/@ant-design/charts-util@0.0.1-alpha.5/X-ZHJlYWN0LWRvbUAxOS4wLjAscmVhY3RAMTkuMC4w/denonext/charts-util.development.mjs": "92a5ac00883b6b3b33b5498616d35fdd262d6ecbca85914aaaa1612d501c33a4",
"https://esm.d8d.fun/@ant-design/charts-util@0.0.1-alpha.5/X-ZHJlYWN0LWRvbUAxOS4wLjAscmVhY3RAMTkuMC4w/denonext/charts-util.mjs": "2b5590c1c3b095fd2cc95448c174aa747dd187929dd0f0ecf79a19a95e2f48ba",

View File

@@ -0,0 +1,99 @@
# 消息系统架构设计方案
## 1. 架构图
```mermaid
flowchart LR
subgraph Admin端
A[发送消息] -->|类型转换| B(server/routes_io_messages.ts)
end
subgraph Server
B --> C{消息类型}
C -->|ANNOUNCE| D[存入DB+推announce]
C -->|PRIVATE| E[存入DB+推user_[id]]
C -->|SYSTEM| F[存入DB+推system]
D & E & F --> G[Socket推送]
end
subgraph Mobile端
G --> H[多频道订阅]
H --> I[按类型处理UI]
end
```
## 2. 关键数据结构
### 消息类型枚举 (client/share/types.ts)
```typescript
export enum MessageType {
SYSTEM = 'system', // 系统消息
ANNOUNCE = 'announce', // 公告
PRIVATE = 'private' // 私信
}
export enum MessageStatus {
UNREAD = 0, // 未读
READ = 1, // 已读
DELETED = 2 // 已删除
}
```
### 消息表结构
```sql
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
type ENUM('SYSTEM','ANNOUNCE','PRIVATE') NOT NULL,
sender_id INTEGER REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_messages (
user_id INTEGER REFERENCES users(id),
message_id INTEGER REFERENCES messages(id),
status ENUM('UNREAD','READ') DEFAULT 'UNREAD',
PRIMARY KEY (user_id, message_id)
);
```
## 3. 事件流说明
### Socket.IO 事件规范
| 事件名称 | 方向 | 描述 |
|---------|------|------|
| message:subscribe | 客户端→服务端 | 订阅消息频道 |
| message:unsubscribe | 客户端→服务端 | 取消订阅 |
| message:send | 客户端→服务端 | 发送消息 |
| message:received | 服务端→客户端 | 消息接收确认 |
| message:broadcasted | 服务端→客户端 | 广播新消息 |
### 频道订阅规范
| 消息类型 | 目标频道 | 订阅方式 |
|----------|----------|----------|
| SYSTEM | system | socket.join('system') |
| ANNOUNCE | announce | socket.join('announce') |
| PRIVATE | user_[id]| socket.join(`user_${userId}`) |
### 实时推送流程
1. Admin发送消息 → 服务端接收(message:send)
2. 服务端处理:
- 存储消息到数据库
- 根据类型推送:
* SYSTEM: io.to('system').emit('message:broadcasted')
* ANNOUNCE: io.to('announce').emit('message:broadcasted')
* PRIVATE: io.to(`user_${targetId}`).emit('message:broadcasted')
3. Mobile端
- 初始化时订阅相关频道
- 按频道接收处理消息
## 4. 接口定义
### HTTP API
- GET /api/messages - 获取消息列表
- POST /api/messages - 发送消息
- GET /api/messages/unread - 获取未读消息数
- PUT /api/messages/:id/read - 标记消息为已读
### 权限控制
- 系统消息: 仅管理员可发送
- 公告: 管理员和特定角色可发送
- 私信: 所有用户可发送

View File

@@ -4,6 +4,7 @@ import React from 'hono/jsx'
import type { Context as HonoContext } from 'hono'
import { serveStatic } from 'hono/deno'
import { APIClient } from '@d8d-appcontainer/api'
import { Auth } from '@d8d-appcontainer/auth';
import debug from "debug"
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
@@ -54,16 +55,17 @@ interface EsmScriptConfig {
// 定义模块参数接口
interface ModuleParams {
apiClient: APIClient
apiClient: APIClient,
auth: Auth,
app: Hono
moduleDir: string
}
export default function({ apiClient, app, moduleDir }: ModuleParams) {
export default function({ apiClient, app, moduleDir , auth}: ModuleParams) {
const honoApp = app
// 创建路由
const router = createRouter(apiClient, moduleDir)
const router = createRouter(apiClient, moduleDir, auth)
honoApp.route('/', router)
// 首页路由 - SSR

View File

@@ -45,11 +45,11 @@ export const withAuth = async (c: HonoContext<{ Variables: Variables }>, next: (
export type WithAuth = typeof withAuth;
// 环境变量设置中间件
export const setEnvVariables = (apiClient: APIClient, moduleDir: string) => {
export const setEnvVariables = (apiClient: APIClient, moduleDir: string, auth: Auth) => {
return async (c: HonoContext<{ Variables: Variables }>, next: () => Promise<void>) => {
c.set('apiClient', apiClient)
c.set('moduleDir', moduleDir)
c.set('auth', await initAuth(apiClient))
c.set('auth', auth)
c.set('systemSettings', await initSystemSettings(apiClient))
await next()
}
@@ -58,37 +58,7 @@ export const setEnvVariables = (apiClient: APIClient, moduleDir: string) => {
// CORS中间件
export const corsMiddleware = cors()
// 初始化Auth实例
const initAuth = async (apiClient: APIClient) => {
try {
log.auth('正在初始化Auth实例')
const auth = new Auth(apiClient as any, {
jwtSecret: Deno.env.get("JWT_SECRET") || 'your-jwt-secret-key',
initialUsers: [],
storagePrefix: '',
userTable: 'users',
fieldNames: {
id: 'id',
username: 'username',
password: 'password',
phone: 'phone',
email: 'email',
is_disabled: 'is_disabled',
is_deleted: 'is_deleted'
},
tokenExpiry: 24 * 60 * 60,
refreshTokenExpiry: 7 * 24 * 60 * 60
})
log.auth('Auth实例初始化完成')
return auth
} catch (error) {
log.auth('Auth初始化失败:', error)
throw error
}
}
// 初始化系统设置
const initSystemSettings = async (apiClient: APIClient) => {

View File

@@ -2,6 +2,7 @@
import { Hono } from 'hono'
import { corsMiddleware, withAuth, setEnvVariables } from './middlewares.ts'
import type { APIClient } from '@d8d-appcontainer/api'
import { Auth } from '@d8d-appcontainer/auth';
// 导入路由模块
import { createAuthRoutes } from "./routes_auth.ts"
@@ -17,7 +18,7 @@ import { createMessagesRoutes } from "./routes_messages.ts"
import { createMigrationsRoutes } from "./routes_migrations.ts"
import { createHomeRoutes } from "./routes_home.ts"
export function createRouter(apiClient: APIClient, moduleDir: string) {
export function createRouter(apiClient: APIClient, moduleDir: string , auth: Auth) {
const router = new Hono()
// 添加CORS中间件
@@ -27,7 +28,7 @@ export function createRouter(apiClient: APIClient, moduleDir: string) {
const api = new Hono()
// 设置环境变量
api.use('*', setEnvVariables(apiClient, moduleDir))
api.use('*', setEnvVariables(apiClient, moduleDir, auth))
// 注册所有路由
api.route('/auth', createAuthRoutes(withAuth))

73
server/router_io.ts Normal file
View File

@@ -0,0 +1,73 @@
import { Socket, Server } from "socket.io";
import { Auth } from '@d8d-appcontainer/auth';
import type { User as AuthUser } from '@d8d-appcontainer/auth';
import { APIClient } from '@d8d-appcontainer/api';
import { setupMessageEvents } from './routes_io_messages.ts';
import debug from "debug";
const log = debug('socketio:auth');
interface SetupSocketIOProps {
io: Server, auth: Auth, apiClient: APIClient
}
export interface SocketWithUser extends Socket {
user?: AuthUser;
}
// 定义自定义上下文类型
export interface Variables {
socket: SocketWithUser
auth: Auth
user: AuthUser
apiClient: APIClient
// moduleDir: string
// systemSettings?: SystemSettingRecord
}
export function setupSocketIO({ io, auth, apiClient }:SetupSocketIOProps) {
// Socket.IO认证中间件
io.use(async (socket: SocketWithUser) => {
try {
const token = socket.handshake.query.get('socket_token');
if (!token) {
log(`未提供token拒绝连接: ${socket.id}`);
throw new Error('未授权')
}
const userData = await auth.verifyToken(token);
if (!userData) {
log(`无效token拒绝连接: ${socket.id}`);
throw new Error('无效凭证')
}
socket.user = userData;
log(`认证成功: ${socket.id} 用户: ${userData.username}`);
} catch (error) {
log(`认证错误: ${socket.id}`, error);
}
});
io.on("connection", (socket: SocketWithUser) => {
if (!socket.user) {
socket.disconnect(true);
return;
}
console.log(`socket ${socket.id} 已连接,用户: ${socket.user.username}`);
socket.on("disconnect", (reason) => {
console.log(`socket ${socket.id} 断开连接,原因: ${reason}`);
});
const context: Variables = {
socket,
auth,
apiClient,
user: socket.user,
}
// 初始化消息路由
setupMessageEvents(context);
});
}

View File

@@ -0,0 +1,324 @@
import { SocketWithUser , Variables} from './router_io.ts';
import { MessageType, MessageStatus } from '../client/share/types.ts'
import { APIClient } from "@d8d-appcontainer/api";
interface MessageSendData {
title: string;
content: string;
type: MessageType;
receiver_ids: number[];
}
interface MessageListData {
page?: number;
pageSize?: number;
type?: MessageType;
status?: MessageStatus;
}
export function setupMessageEvents({ socket , apiClient }:Variables) {
// 订阅频道
socket.on('message:subscribe', (channel: string) => {
try {
socket.join(channel);
socket.emit('message:subscribed', {
message: `成功订阅频道: ${channel}`,
channel
});
} catch (error) {
console.error('订阅频道失败:', error);
socket.emit('error', '订阅频道失败');
}
});
// 取消订阅
socket.on('message:unsubscribe', (channel: string) => {
try {
socket.leave(channel);
socket.emit('message:unsubscribed', {
message: `已取消订阅频道: ${channel}`,
channel
});
} catch (error) {
console.error('取消订阅失败:', error);
socket.emit('error', '取消订阅失败');
}
});
// 广播消息
socket.on('message:broadcast', async (data: {
channel?: string;
title: string;
content: string;
type: MessageType;
}) => {
try {
const { channel, title, content, type } = data;
const user = socket.user;
if (!user) {
socket.emit('error', '未授权访问');
return;
}
// 创建广播消息
const [messageId] = await apiClient.database.table('messages').insert({
title,
content,
type,
sender_id: user.id,
sender_name: user.username,
is_broadcast: 1,
created_at: apiClient.database.fn.now(),
updated_at: apiClient.database.fn.now()
});
// 广播到所有客户端或特定频道
const broadcastTarget = channel ? socket.to(channel) : socket.broadcast;
broadcastTarget.emit('message:broadcasted', {
id: messageId,
title,
content,
type,
sender_id: user.id,
sender_name: user.username,
created_at: new Date().toISOString()
});
socket.emit('message:broadcasted', {
message: '广播消息发送成功',
data: { id: messageId }
});
} catch (error) {
console.error('广播消息失败:', error);
socket.emit('error', '广播消息失败');
}
});
// 发送消息
socket.on('message:send', async (data: MessageSendData) => {
try {
const { title, content, type, receiver_ids } = data;
if (!title || !content || !type || !receiver_ids?.length) {
socket.emit('error', '缺少必要参数');
return;
}
const user = socket.user;
if (!user) {
socket.emit('error', '未授权访问');
return;
}
// 创建消息
const [messageId] = await apiClient.database.table('messages').insert({
title,
content,
type,
sender_id: user.id,
sender_name: user.username,
created_at: apiClient.database.fn.now(),
updated_at: apiClient.database.fn.now()
});
// 关联用户消息
const userMessages = receiver_ids.map((userId: number) => ({
user_id: userId,
message_id: messageId,
status: MessageStatus.UNREAD,
created_at: apiClient.database.fn.now(),
updated_at: apiClient.database.fn.now()
}));
await apiClient.database.table('user_messages').insert(userMessages);
// 根据消息类型推送到不同频道
const messageData = {
id: messageId,
title,
content,
type,
sender_id: user.id,
sender_name: user.username,
created_at: new Date().toISOString()
};
if (type === MessageType.SYSTEM) {
socket.to('system').emit('message:received', messageData);
} else if (type === MessageType.ANNOUNCE) {
socket.to('announce').emit('message:received', messageData);
} else if (type === MessageType.PRIVATE) {
receiver_ids.forEach(userId => {
socket.to(`user_${userId}`).emit('message:received', messageData);
});
}
socket.emit('message:sent', {
message: '消息发送成功',
data: { id: messageId }
});
} catch (error) {
console.error('发送消息失败:', error);
socket.emit('error', '发送消息失败');
}
});
// 获取消息列表
socket.on('message:list', async (data: MessageListData) => {
try {
const { page = 1, pageSize = 20, type, status } = data;
const user = socket.user;
if (!user) {
socket.emit('error', '未授权访问');
return;
}
const query = apiClient.database.table('user_messages as um')
.select('m.*', 'um.status as user_status', 'um.read_at', 'um.id as user_message_id')
.leftJoin('messages as m', 'um.message_id', 'm.id')
.where('um.user_id', user.id)
.where('um.is_deleted', 0)
.orderBy('m.created_at', 'desc')
.limit(pageSize)
.offset((page - 1) * pageSize);
if (type) query.where('m.type', type);
if (status) query.where('um.status', status);
const countQuery = query.clone();
const messages = await query;
// 获取总数用于分页
const total = await countQuery.count();
const totalCount = Number(total);
const totalPages = Math.ceil(totalCount / pageSize);
socket.emit('message:list', {
data: messages,
pagination: {
total: totalCount,
current: page,
pageSize,
totalPages
}
});
} catch (error) {
console.error('获取消息列表失败:', error);
socket.emit('error', '获取消息列表失败');
}
});
// 获取消息详情
socket.on('message:detail', async (messageId: number) => {
try {
const user = socket.user;
if (!user) {
socket.emit('error', '未授权访问');
return;
}
const message = await apiClient.database.table('user_messages as um')
.select('m.*', 'um.status as user_status', 'um.read_at')
.leftJoin('messages as m', 'um.message_id', 'm.id')
.where('um.user_id', user.id)
.where('um.message_id', messageId)
.first();
if (!message) {
socket.emit('error', '消息不存在或无权访问');
return;
}
// 标记为已读
if (message.user_status === MessageStatus.UNREAD) {
await apiClient.database.table('user_messages')
.where('user_id', user.id)
.where('message_id', messageId)
.update({
status: MessageStatus.READ,
read_at: apiClient.database.fn.now(),
updated_at: apiClient.database.fn.now()
});
}
socket.emit('message:detail', {
message: '获取消息成功',
data: message
});
} catch (error) {
console.error('获取消息详情失败:', error);
socket.emit('error', '获取消息详情失败');
}
});
// 删除消息
socket.on('message:delete', async (messageId: number) => {
try {
const user = socket.user;
if (!user) {
socket.emit('error', '未授权访问');
return;
}
await apiClient.database.table('user_messages')
.where('user_id', user.id)
.where('message_id', messageId)
.update({
is_deleted: 1,
updated_at: apiClient.database.fn.now()
});
socket.emit('message:deleted', { message: '消息已删除' });
} catch (error) {
console.error('删除消息失败:', error);
socket.emit('error', '删除消息失败');
}
});
// 获取未读消息数
socket.on('message:count', async () => {
try {
const user = socket.user;
if (!user) {
socket.emit('error', '未授权访问');
return;
}
const count = await apiClient.database.table('user_messages')
.where('user_id', user.id)
.where('status', MessageStatus.UNREAD)
.where('is_deleted', 0)
.count();
socket.emit('message:count', { count: Number(count) });
} catch (error) {
console.error('获取未读消息数失败:', error);
socket.emit('error', '获取未读消息数失败');
}
});
// 标记消息为已读
socket.on('message:read', async (messageId: number) => {
try {
const user = socket.user;
if (!user) {
socket.emit('error', '未授权访问');
return;
}
await apiClient.database.table('user_messages')
.where('user_id', user.id)
.where('message_id', messageId)
.update({
status: MessageStatus.READ,
read_at: apiClient.database.fn.now(),
updated_at: apiClient.database.fn.now()
});
socket.emit('message:read', { message: '消息已标记为已读' });
} catch (error) {
console.error('标记消息为已读失败:', error);
socket.emit('error', '标记消息为已读失败');
}
});
}

View File

@@ -1,8 +1,12 @@
// 导入所需模块
import { Hono } from 'hono'
import { APIClient } from '@d8d-appcontainer/api'
import { Auth } from '@d8d-appcontainer/auth';
import debug from "debug"
import { cors } from 'hono/cors'
import { Server } from "socket.io"
import httpServer from './app.tsx'
import { setupSocketIO } from './router_io.ts'
// 初始化debug实例
const log = {
@@ -36,6 +40,46 @@ const getApiClient = async (workspaceKey: string, serverUrl?: string) => {
throw error
}
}
// 初始化Auth实例
const initAuth = async (apiClient: APIClient) => {
try {
log.auth('正在初始化Auth实例')
const auth = new Auth(apiClient as any, {
jwtSecret: Deno.env.get("JWT_SECRET") || 'your-jwt-secret-key',
initialUsers: [],
storagePrefix: '',
userTable: 'users',
fieldNames: {
id: 'id',
username: 'username',
password: 'password',
phone: 'phone',
email: 'email',
is_disabled: 'is_disabled',
is_deleted: 'is_deleted'
},
tokenExpiry: 24 * 60 * 60,
refreshTokenExpiry: 7 * 24 * 60 * 60
})
log.auth('Auth实例初始化完成')
return auth
} catch (error) {
log.auth('Auth初始化失败:', error)
throw error
}
}
// 初始化API Client
// 注意WORKSPACE_KEY 需要在 多八多(www.d8d.fun) 平台注册并开通工作空间后获取
const workspaceKey = Deno.env.get('WORKSPACE_KEY') || ''
if (!workspaceKey) {
console.warn('未设置WORKSPACE_KEY请前往 多八多(www.d8d.fun) 注册并开通工作空间以获取密钥')
}
const apiClient = await getApiClient(workspaceKey)
const auth = await initAuth(apiClient);
// 创建Hono应用实例
const app = new Hono()
@@ -43,39 +87,43 @@ const app = new Hono()
// 注册CORS中间件
app.use('/*', cors())
// 创建Socket.IO实例
const io = new Server({
cors: {
origin: "*",
methods: ["GET", "POST"],
credentials: true
}
})
setupSocketIO({io, auth, apiClient});
// 动态加载并运行模板
const runTemplate = async () => {
const runTemplate = () => {
try {
// 创建基础app实例
const moduleApp = new Hono()
// 初始化API Client
// 注意WORKSPACE_KEY 需要在 多八多(www.d8d.fun) 平台注册并开通工作空间后获取
const workspaceKey = Deno.env.get('WORKSPACE_KEY') || ''
if (!workspaceKey) {
console.warn('未设置WORKSPACE_KEY请前往 多八多(www.d8d.fun) 注册并开通工作空间以获取密钥')
}
const apiClient = await getApiClient(workspaceKey)
// 导入模板主模块
const templateModule = await import('./app.tsx')
if (templateModule.default) {
// 传入必要参数并初始化应用
const appInstance = templateModule.default({
apiClient: apiClient,
app: moduleApp,
moduleDir: './'
})
// 启动服务器
Deno.serve({ port: 8080 }, appInstance.fetch)
console.log('应用已启动,监听端口: 8080')
}
// 传入必要参数并初始化应用
const appInstance = httpServer({
apiClient: apiClient,
app: moduleApp,
moduleDir: './',
auth
})
// 启动服务器
Deno.serve({
handler: io.handler(async (req) => {
return await appInstance.fetch(req) || new Response(null, { status: 404 });
}),
port: 8080
})
console.log('应用已启动,监听端口: 8080')
} catch (error) {
console.error('模板加载失败:', error)
}
}
// 执行模板
runTemplate()
runTemplate()