Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
# MobileIMSDK TCP 服务端
|
||||
|
||||
官方协议:4 字节大端 body 长度 + UTF-8 JSON `Protocal`。
|
||||
|
||||
- TCP `8901`:客户端登录 / 心跳 / 私聊 / QoS
|
||||
- HTTP `127.0.0.1:8902`:`/health` `/online` `/push`(仅本机,OA 推待办/审批)
|
||||
|
||||
登录:`loginUserId` = OA 员工 id,`loginToken` = OA `accessToken`,服务端调 `GET /auth/me` 校验。
|
||||
|
||||
环境变量见同目录 `.env`,生产由 `fengying-im.service` 读取 API 的 `.env`。
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "fengying-im-server",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"description": "MobileIMSDK TCP server for 风影智慧办公",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* MobileIMSDK TCP 服务端(官方协议):
|
||||
* 4 字节大端长度 + UTF-8 JSON Protocal。
|
||||
* 登录 token 走 OA GET /auth/me。
|
||||
* 同一账号允许多端同时在线(手机 + 网页),重连不再把本机踢掉。
|
||||
*/
|
||||
const net = require('net');
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const TCP_PORT = Number(process.env.IM_TCP_PORT || 8901);
|
||||
const HTTP_PORT = Number(process.env.IM_HTTP_PORT || 8902);
|
||||
const OA_API = (process.env.OA_API_URL || 'http://127.0.0.1:3010/api/v1').replace(/\/$/, '');
|
||||
const INTERNAL = process.env.IM_INTERNAL_TOKEN || '';
|
||||
const MAX_BODY = 6 * 1024;
|
||||
|
||||
const C = {
|
||||
LOGIN: 0,
|
||||
KEEP_ALIVE: 1,
|
||||
COMMON_DATA: 2,
|
||||
LOGOUT: 3,
|
||||
RECIVED: 4,
|
||||
ECHO: 5,
|
||||
};
|
||||
const S = {
|
||||
LOGIN: 50,
|
||||
KEEP_ALIVE: 51,
|
||||
ERROR: 52,
|
||||
ECHO: 53,
|
||||
KICKOUT: 54,
|
||||
};
|
||||
|
||||
/** @type {Map<string, Set<import('net').Socket>>} */
|
||||
const online = new Map();
|
||||
/** @type {WeakMap<import('net').Socket, { userId?: string, buf: Buffer }>} */
|
||||
const sessions = new WeakMap();
|
||||
|
||||
function frame(obj) {
|
||||
const body = Buffer.from(JSON.stringify(obj), 'utf8');
|
||||
const header = Buffer.alloc(4);
|
||||
header.writeUInt32BE(body.length, 0);
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
|
||||
function send(socket, obj) {
|
||||
if (!socket || socket.destroyed) return false;
|
||||
try {
|
||||
socket.write(frame(obj));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function addOnline(userId, socket) {
|
||||
let set = online.get(userId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
online.set(userId, set);
|
||||
}
|
||||
set.add(socket);
|
||||
}
|
||||
|
||||
function removeSocket(userId, socket) {
|
||||
const set = online.get(userId);
|
||||
if (!set) return;
|
||||
set.delete(socket);
|
||||
if (set.size === 0) online.delete(userId);
|
||||
}
|
||||
|
||||
function sendToUser(userId, obj) {
|
||||
const set = online.get(userId);
|
||||
if (!set) return false;
|
||||
let ok = false;
|
||||
for (const s of set) ok = send(s, obj) || ok;
|
||||
return ok;
|
||||
}
|
||||
|
||||
function protocal(type, dataContent, from, to, extra = {}) {
|
||||
return {
|
||||
bridge: false,
|
||||
type,
|
||||
dataContent,
|
||||
from: from || '0',
|
||||
to: to || '0',
|
||||
fp: extra.fp || null,
|
||||
QoS: Boolean(extra.QoS),
|
||||
typeu: extra.typeu ?? -1,
|
||||
sm: extra.sm ?? -1,
|
||||
};
|
||||
}
|
||||
|
||||
async function verifyLogin(userId, token) {
|
||||
if (!userId || !token) return false;
|
||||
try {
|
||||
const res = await fetch(`${OA_API}/auth/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const json = await res.json();
|
||||
const me = json.data || json;
|
||||
return me && me.id === userId && me.status !== 'LEFT';
|
||||
} catch (e) {
|
||||
console.warn('verify login', e.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function ingest(fromUserId, toUserId, body, fingerprint, extra = {}) {
|
||||
if (!INTERNAL) return;
|
||||
try {
|
||||
await fetch(`${OA_API}/im/internal/ingest`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'x-im-token': INTERNAL },
|
||||
body: JSON.stringify({
|
||||
fromUserId,
|
||||
toUserId,
|
||||
kind: extra.kind || 'chat',
|
||||
body,
|
||||
fingerprint,
|
||||
conversationId: extra.conversationId,
|
||||
contentType: extra.contentType,
|
||||
meta: extra.meta,
|
||||
}),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('ingest', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function membersOf(conversationId) {
|
||||
if (!INTERNAL || !conversationId) return [];
|
||||
try {
|
||||
const res = await fetch(`${OA_API}/im/internal/members/${conversationId}`, {
|
||||
headers: { 'x-im-token': INTERNAL },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
const json = await res.json();
|
||||
const data = json.data || json;
|
||||
return data.userIds || [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function handle(socket, p) {
|
||||
const type = Number(p.type);
|
||||
if (type === C.LOGIN) {
|
||||
let info = {};
|
||||
try {
|
||||
info = JSON.parse(p.dataContent || '{}');
|
||||
} catch {
|
||||
info = {};
|
||||
}
|
||||
const userId = info.loginUserId || p.from;
|
||||
const ok = await verifyLogin(userId, info.loginToken);
|
||||
if (!ok) {
|
||||
send(socket, protocal(S.LOGIN, JSON.stringify({ code: 1, firstLoginTime: -1 }), '0', '-1'));
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
const firstLoginTime = Number(info.firstLoginTime) > 0 ? Number(info.firstLoginTime) : Date.now();
|
||||
const sess = sessions.get(socket) || { buf: Buffer.alloc(0) };
|
||||
sess.userId = userId;
|
||||
sessions.set(socket, sess);
|
||||
addOnline(userId, socket);
|
||||
send(socket, protocal(S.LOGIN, JSON.stringify({ code: 0, firstLoginTime }), '0', userId));
|
||||
console.log(`login ok ${userId} sockets=${online.get(userId)?.size || 0} users=${online.size}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const sess = sessions.get(socket);
|
||||
if (!sess?.userId) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
const userId = sess.userId;
|
||||
|
||||
if (type === C.KEEP_ALIVE) {
|
||||
send(socket, protocal(S.KEEP_ALIVE, '{}', '0', userId));
|
||||
return;
|
||||
}
|
||||
if (type === C.LOGOUT) {
|
||||
removeSocket(userId, socket);
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
if (type === C.ECHO) {
|
||||
send(socket, protocal(S.ECHO, p.dataContent || '', '0', userId));
|
||||
return;
|
||||
}
|
||||
if (type === C.RECIVED) {
|
||||
return;
|
||||
}
|
||||
if (type === C.COMMON_DATA) {
|
||||
const fp = p.fp || crypto.randomUUID();
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(p.dataContent || '{}');
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
const convId = parsed.conversationId || '';
|
||||
const typeu = Number(p.typeu ?? -1);
|
||||
const payload = protocal(C.COMMON_DATA, p.dataContent, userId, p.to, {
|
||||
QoS: p.QoS,
|
||||
fp,
|
||||
typeu: p.typeu,
|
||||
sm: Date.now(),
|
||||
});
|
||||
let targets = [];
|
||||
if (typeu === 2 && convId) {
|
||||
targets = (await membersOf(convId)).filter((id) => id !== userId);
|
||||
} else if (p.to) {
|
||||
targets = [p.to];
|
||||
}
|
||||
for (const to of targets) {
|
||||
sendToUser(to, { ...payload, to });
|
||||
}
|
||||
if (p.QoS) {
|
||||
send(socket, protocal(C.RECIVED, fp, p.to || '0', userId));
|
||||
}
|
||||
if (typeu === 3 || parsed.kind === 'signal') return;
|
||||
const text = String(parsed.text || parsed.title || p.dataContent || '');
|
||||
await ingest(userId, p.to, text, fp, {
|
||||
kind: parsed.kind || 'chat',
|
||||
conversationId: convId,
|
||||
contentType: parsed.contentType,
|
||||
meta: parsed.meta,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const tcp = net.createServer((socket) => {
|
||||
sessions.set(socket, { buf: Buffer.alloc(0) });
|
||||
socket.on('data', (chunk) => {
|
||||
const sess = sessions.get(socket);
|
||||
if (!sess) return;
|
||||
sess.buf = Buffer.concat([sess.buf, chunk]);
|
||||
while (sess.buf.length >= 4) {
|
||||
const len = sess.buf.readUInt32BE(0);
|
||||
if (len <= 0 || len > MAX_BODY) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
if (sess.buf.length < 4 + len) break;
|
||||
const body = sess.buf.subarray(4, 4 + len).toString('utf8');
|
||||
sess.buf = sess.buf.subarray(4 + len);
|
||||
try {
|
||||
void handle(socket, JSON.parse(body));
|
||||
} catch (e) {
|
||||
console.warn('bad packet', e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
socket.on('close', () => {
|
||||
const sess = sessions.get(socket);
|
||||
if (sess?.userId) removeSocket(sess.userId, socket);
|
||||
});
|
||||
socket.on('error', () => {});
|
||||
socket.setTimeout(90000, () => socket.end());
|
||||
});
|
||||
|
||||
const httpServer = http.createServer((req, res) => {
|
||||
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
if (req.method === 'GET' && url.pathname === '/health') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, online: online.size, tcp: TCP_PORT }));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'GET' && url.pathname === '/online') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ count: online.size, users: [...online.keys()] }));
|
||||
return;
|
||||
}
|
||||
if (req.method === 'POST' && url.pathname === '/push') {
|
||||
const token = req.headers['x-im-token'];
|
||||
if (!INTERNAL || token !== INTERNAL) {
|
||||
res.writeHead(401);
|
||||
res.end('forbidden');
|
||||
return;
|
||||
}
|
||||
const chunks = [];
|
||||
req.on('data', (c) => chunks.push(c));
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
||||
const to = body.toUserId;
|
||||
let parsed = {};
|
||||
try {
|
||||
parsed = JSON.parse(body.dataContent || '{}');
|
||||
} catch {
|
||||
parsed = {};
|
||||
}
|
||||
const seq = Number(parsed.seq ?? body.seq ?? 0);
|
||||
const delivered = sendToUser(
|
||||
to,
|
||||
protocal(C.COMMON_DATA, body.dataContent || '', '0', to, {
|
||||
QoS: true,
|
||||
fp: parsed.fingerprint || crypto.randomUUID(),
|
||||
typeu: 1,
|
||||
sm: seq > 0 ? seq : Date.now(),
|
||||
}),
|
||||
);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ ok: true, delivered }));
|
||||
} catch (e) {
|
||||
res.writeHead(400);
|
||||
res.end(e.message);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
});
|
||||
|
||||
tcp.listen(TCP_PORT, '0.0.0.0', () => {
|
||||
console.log(`MobileIMSDK TCP ${TCP_PORT}`);
|
||||
});
|
||||
httpServer.listen(HTTP_PORT, '127.0.0.1', () => {
|
||||
console.log(`IM HTTP push ${HTTP_PORT}`);
|
||||
});
|
||||
Reference in New Issue
Block a user