mirror of https://github.com/djteang/OrangeTV.git
parent
ded66a2b97
commit
156f2de526
58
Dockerfile
58
Dockerfile
|
|
@ -1,19 +1,27 @@
|
|||
# ---- 第 1 阶段:安装依赖 ----
|
||||
FROM node:20-alpine AS deps
|
||||
# 多架构构建 Dockerfile
|
||||
# 使用 Docker Buildx 进行多架构构建:
|
||||
# docker buildx build --platform linux/amd64,linux/arm64 -t your-image:tag --push .
|
||||
# 或单一架构构建:
|
||||
# docker buildx build --platform linux/amd64 -t your-image:tag --load .
|
||||
|
||||
# 启用 corepack 并激活 pnpm(Node20 默认提供 corepack)
|
||||
# 声明构建参数,用于多架构构建
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
# ---- 第 1 阶段:安装依赖 ----
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS deps
|
||||
|
||||
# 启用 corepack 并激活 pnpm
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 仅复制依赖清单,提高构建缓存利用率
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
|
||||
# 安装所有依赖(含 devDependencies,后续会裁剪)
|
||||
# 安装所有依赖
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# ---- 第 2 阶段:构建项目 ----
|
||||
FROM node:20-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:20-alpine AS builder
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
WORKDIR /app
|
||||
|
||||
|
|
@ -22,7 +30,6 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||
# 复制全部源代码
|
||||
COPY . .
|
||||
|
||||
# 在构建阶段也显式设置 DOCKER_ENV,
|
||||
ENV DOCKER_ENV=true
|
||||
|
||||
# 生成生产构建
|
||||
|
|
@ -44,16 +51,41 @@ ENV DOCKER_ENV=true
|
|||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
# 从构建器中复制 scripts 目录
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/scripts ./scripts
|
||||
# 从构建器中复制 start.js
|
||||
# 从构建器中复制启动脚本和WebSocket相关文件
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/start.js ./start.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/websocket.js ./websocket.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/production.js ./production.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/production-final.js ./production-final.js
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/standalone-websocket.js ./standalone-websocket.js
|
||||
# 从构建器中复制 public 和 .next/static 目录
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
# 从构建器中复制 package.json 和 package-lock.json,用于安装额外依赖
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/package.json ./package.json
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
# 复制 tsconfig.json 以确保路径解析正确
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/tsconfig.json ./tsconfig.json
|
||||
|
||||
# 切换到非特权用户
|
||||
# 安装必要的WebSocket依赖(兼容多架构)
|
||||
USER root
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate && \
|
||||
# 使用 --no-optional 避免某些架构下的可选依赖问题
|
||||
pnpm install --prod --no-optional ws && \
|
||||
# 清理安装缓存减小镜像大小
|
||||
pnpm store prune
|
||||
|
||||
# 切回非特权用户
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
# 暴露HTTP和WebSocket端口
|
||||
EXPOSE 3000 3001
|
||||
|
||||
# 使用自定义启动脚本,先预加载配置再启动服务器
|
||||
CMD ["node", "start.js"]
|
||||
# 添加健康检查
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
||||
CMD curl -f http://localhost:3000/api/health || exit 1
|
||||
|
||||
# 设置WebSocket端口环境变量
|
||||
ENV WS_PORT=3001
|
||||
|
||||
# 使用最终的生产环境脚本,分离WebSocket服务
|
||||
CMD ["node", "production-final.js"]
|
||||
|
|
@ -5,7 +5,7 @@ const nextConfig = {
|
|||
output: 'standalone',
|
||||
eslint: {
|
||||
dirs: ['src'],
|
||||
ignoreDuringBuilds: process.env.DOCKER_ENV === 'true',
|
||||
ignoreDuringBuilds: true, // 始终在构建时忽略 ESLint 错误
|
||||
},
|
||||
|
||||
reactStrictMode: false,
|
||||
|
|
@ -59,6 +59,23 @@ const nextConfig = {
|
|||
// Modify the file loader rule to ignore *.svg, since we have it handled now.
|
||||
fileLoaderRule.exclude = /\.svg$/i;
|
||||
|
||||
// Add alias configuration to ensure proper path resolution in Docker builds
|
||||
const path = require('path');
|
||||
config.resolve.alias = {
|
||||
...config.resolve.alias,
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
'~': path.resolve(__dirname, 'public'),
|
||||
};
|
||||
|
||||
// Ensure proper file extension resolution
|
||||
config.resolve.extensions = ['.ts', '.tsx', '.js', '.jsx', '.json'];
|
||||
|
||||
// Add TypeScript module resolution support
|
||||
config.resolve.modules = [
|
||||
path.resolve(__dirname, 'src'),
|
||||
'node_modules'
|
||||
];
|
||||
|
||||
config.resolve.fallback = {
|
||||
...config.resolve.fallback,
|
||||
net: false,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,46 @@
|
|||
server {
|
||||
listen 443 ssl;
|
||||
server_name domain.com;
|
||||
charset utf-8;
|
||||
|
||||
ssl_certificate /home/cert/tvcertificate.crt;
|
||||
ssl_certificate_key /home/cert/tvprivate.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://ip:3003;
|
||||
|
||||
# 重要的代理头信息,让 Next.js 服务器知道原始请求的来源
|
||||
proxy_set_header Host $host; # 原始主机名
|
||||
proxy_set_header X-Real-IP $remote_addr; # 客户端真实 IP
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # 代理链
|
||||
proxy_set_header X-Forwarded-Proto $scheme; # 原始协议 (http/https)
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Server $host;
|
||||
}
|
||||
location /ws-api {
|
||||
proxy_pass http://ip:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 特定的超时设置(长连接)
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
|
||||
# 禁用缓冲以减少延迟
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
server_name domain.com;
|
||||
|
||||
return 301 https://domain.com$request_uri;
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
# Nginx配置示例,用于生产环境反向代理
|
||||
# 将此文件放置在 /etc/nginx/sites-available/ 并创建符号链接到 sites-enabled/
|
||||
|
||||
upstream nextjs_app {
|
||||
server localhost:3000;
|
||||
}
|
||||
|
||||
upstream websocket_app {
|
||||
server localhost:3001;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
# 如果使用HTTPS,取消下面的注释并配置SSL证书
|
||||
# listen 443 ssl;
|
||||
# ssl_certificate /path/to/ssl/cert.pem;
|
||||
# ssl_certificate_key /path/to/ssl/key.pem;
|
||||
|
||||
# 增加请求体大小限制
|
||||
client_max_body_size 100M;
|
||||
|
||||
# Next.js应用的主要路由
|
||||
location / {
|
||||
proxy_pass http://nextjs_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket专用路由
|
||||
location /ws-api {
|
||||
proxy_pass http://websocket_app;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket特定的超时设置
|
||||
proxy_connect_timeout 7d;
|
||||
proxy_send_timeout 7d;
|
||||
proxy_read_timeout 7d;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location /_next/static {
|
||||
proxy_pass http://nextjs_app;
|
||||
proxy_cache_valid 60m;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
location /public {
|
||||
proxy_pass http://nextjs_app;
|
||||
proxy_cache_valid 60m;
|
||||
add_header Cache-Control "public, max-age=3600";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
14
package.json
14
package.json
|
|
@ -3,9 +3,15 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "pnpm gen:manifest && next dev -H 0.0.0.0",
|
||||
"dev": "pnpm gen:manifest && node simple-dev.js",
|
||||
"dev:complex": "pnpm gen:manifest && node dev-server.js",
|
||||
"dev:ws": "node standalone-websocket.js",
|
||||
"test:ws": "node test-websocket-connection.js",
|
||||
"debug:api": "node debug-api.js",
|
||||
"build": "pnpm gen:manifest && next build",
|
||||
"start": "next start",
|
||||
"start": "NODE_ENV=production node server.js",
|
||||
"prod": "NODE_ENV=production node production.js",
|
||||
"prod:final": "NODE_ENV=production node production-final.js",
|
||||
"lint": "next lint",
|
||||
"lint:fix": "eslint src --fix && pnpm format",
|
||||
"lint:strict": "eslint --max-warnings=0 src",
|
||||
|
|
@ -26,6 +32,7 @@
|
|||
"@headlessui/react": "^2.2.4",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@upstash/redis": "^1.25.0",
|
||||
"@vidstack/react": "^1.12.13",
|
||||
"artplayer": "^5.2.5",
|
||||
|
|
@ -38,7 +45,7 @@
|
|||
"hls.js": "^1.6.10",
|
||||
"lucide-react": "^0.438.0",
|
||||
"media-icons": "^1.1.5",
|
||||
"next": "^14.2.23",
|
||||
"next": "^14.2.30",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^18.2.0",
|
||||
|
|
@ -49,6 +56,7 @@
|
|||
"swiper": "^11.2.8",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"vidstack": "^0.6.15",
|
||||
"ws": "^8.18.3",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -29,6 +29,9 @@ importers:
|
|||
'@types/crypto-js':
|
||||
specifier: ^4.2.2
|
||||
version: 4.2.2
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@upstash/redis':
|
||||
specifier: ^1.25.0
|
||||
version: 1.35.1
|
||||
|
|
@ -66,7 +69,7 @@ importers:
|
|||
specifier: ^1.1.5
|
||||
version: 1.1.5
|
||||
next:
|
||||
specifier: ^14.2.23
|
||||
specifier: ^14.2.30
|
||||
version: 14.2.30(@babel/core@7.27.4)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
next-pwa:
|
||||
specifier: ^5.6.0
|
||||
|
|
@ -98,6 +101,9 @@ importers:
|
|||
vidstack:
|
||||
specifier: ^0.6.15
|
||||
version: 0.6.15
|
||||
ws:
|
||||
specifier: ^8.18.3
|
||||
version: 8.18.3
|
||||
zod:
|
||||
specifier: ^3.24.1
|
||||
version: 3.25.67
|
||||
|
|
@ -1583,6 +1589,9 @@ packages:
|
|||
'@types/validator@13.15.3':
|
||||
resolution: {integrity: sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yargs-parser@21.0.3':
|
||||
resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==}
|
||||
|
||||
|
|
@ -5266,6 +5275,18 @@ packages:
|
|||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
ws@8.18.3:
|
||||
resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
peerDependencies:
|
||||
bufferutil: ^4.0.1
|
||||
utf-8-validate: '>=5.0.2'
|
||||
peerDependenciesMeta:
|
||||
bufferutil:
|
||||
optional: true
|
||||
utf-8-validate:
|
||||
optional: true
|
||||
|
||||
xml-name-validator@3.0.0:
|
||||
resolution: {integrity: sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==}
|
||||
|
||||
|
|
@ -7060,6 +7081,10 @@ snapshots:
|
|||
|
||||
'@types/validator@13.15.3': {}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 24.0.3
|
||||
|
||||
'@types/yargs-parser@21.0.3': {}
|
||||
|
||||
'@types/yargs@16.0.9':
|
||||
|
|
@ -9880,7 +9905,7 @@ snapshots:
|
|||
'@next/env': 14.2.30
|
||||
'@swc/helpers': 0.5.5
|
||||
busboy: 1.6.0
|
||||
caniuse-lite: 1.0.30001723
|
||||
caniuse-lite: 1.0.30001741
|
||||
graceful-fs: 4.2.11
|
||||
postcss: 8.4.31
|
||||
react: 18.3.1
|
||||
|
|
@ -11371,6 +11396,8 @@ snapshots:
|
|||
|
||||
ws@7.5.10: {}
|
||||
|
||||
ws@8.18.3: {}
|
||||
|
||||
xml-name-validator@3.0.0: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
/**
|
||||
* 最终的生产环境启动文件
|
||||
* 分离Next.js和WebSocket服务器,避免任何冲突
|
||||
*/
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
// 调用 generate-manifest.js 生成 manifest.json
|
||||
function generateManifest() {
|
||||
console.log('Generating manifest.json for Docker deployment...');
|
||||
|
||||
try {
|
||||
const generateManifestScript = path.join(
|
||||
__dirname,
|
||||
'scripts',
|
||||
'generate-manifest.js'
|
||||
);
|
||||
require(generateManifestScript);
|
||||
} catch (error) {
|
||||
console.error('❌ Error calling generate-manifest.js:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成manifest
|
||||
generateManifest();
|
||||
|
||||
// 启动独立的WebSocket服务器
|
||||
const { createStandaloneWebSocketServer, getOnlineUsers, sendMessageToUsers } = require('./standalone-websocket');
|
||||
const wsPort = process.env.WS_PORT || 3001;
|
||||
const wss = createStandaloneWebSocketServer(wsPort);
|
||||
|
||||
// 将WebSocket函数存储到全局对象,供API路由使用
|
||||
global.getOnlineUsers = getOnlineUsers;
|
||||
global.sendMessageToUsers = sendMessageToUsers;
|
||||
|
||||
// 启动Next.js standalone服务器
|
||||
console.log('Starting Next.js production server...');
|
||||
const nextServerPath = path.join(__dirname, 'server.js');
|
||||
|
||||
// 检查是否存在standalone server.js
|
||||
const fs = require('fs');
|
||||
if (fs.existsSync(nextServerPath)) {
|
||||
// Docker环境,使用standalone server
|
||||
require(nextServerPath);
|
||||
} else {
|
||||
// 非Docker环境,使用标准Next.js启动
|
||||
const { createServer } = require('http');
|
||||
const { parse } = require('url');
|
||||
const next = require('next');
|
||||
|
||||
const hostname = process.env.HOSTNAME || '0.0.0.0';
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
const app = next({
|
||||
dev: false,
|
||||
hostname,
|
||||
port
|
||||
});
|
||||
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const parsedUrl = parse(req.url, true);
|
||||
await handle(req, res, parsedUrl);
|
||||
} catch (err) {
|
||||
console.error('处理请求时出错:', req.url, err);
|
||||
res.statusCode = 500;
|
||||
res.end('内部服务器错误');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, (err) => {
|
||||
if (err) throw err;
|
||||
console.log(`> Next.js服务已启动: http://${hostname}:${port}`);
|
||||
setupServerTasks();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 设置服务器启动后的任务
|
||||
function setupServerTasks() {
|
||||
const httpPort = process.env.PORT || 3000;
|
||||
const hostname = process.env.HOSTNAME || 'localhost';
|
||||
|
||||
// 每1秒轮询一次,直到请求成功
|
||||
const TARGET_URL = `http://${hostname}:${httpPort}/login`;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log(`Fetching ${TARGET_URL} ...`);
|
||||
|
||||
const req = http.get(TARGET_URL, (res) => {
|
||||
// 当返回2xx状态码时认为成功,然后停止轮询
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log('Server is up, stop polling.');
|
||||
clearInterval(intervalId);
|
||||
|
||||
setTimeout(() => {
|
||||
// 服务器启动后,立即执行一次cron任务
|
||||
executeCronJob();
|
||||
}, 3000);
|
||||
|
||||
// 然后设置每小时执行一次cron任务
|
||||
setInterval(() => {
|
||||
executeCronJob();
|
||||
}, 60 * 60 * 1000); // 每小时执行一次
|
||||
|
||||
// 显示服务状态
|
||||
console.log('====================================');
|
||||
console.log(`✅ Next.js服务运行在: http://${hostname}:${httpPort}`);
|
||||
console.log(`✅ WebSocket服务运行在: ws://${hostname}:${wsPort}`);
|
||||
console.log('====================================');
|
||||
}
|
||||
});
|
||||
|
||||
req.setTimeout(2000, () => {
|
||||
req.destroy();
|
||||
});
|
||||
|
||||
req.on('error', () => {
|
||||
// 忽略连接错误,继续轮询
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 执行cron任务的函数
|
||||
function executeCronJob() {
|
||||
const httpPort = process.env.PORT || 3000;
|
||||
const hostname = process.env.HOSTNAME || 'localhost';
|
||||
const cronUrl = `http://${hostname}:${httpPort}/api/cron`;
|
||||
|
||||
console.log(`Executing cron job: ${cronUrl}`);
|
||||
|
||||
const req = http.get(cronUrl, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log('Cron job executed successfully:', data);
|
||||
} else {
|
||||
console.error('Cron job failed:', res.statusCode, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error('Error executing cron job:', err);
|
||||
});
|
||||
|
||||
req.setTimeout(30000, () => {
|
||||
console.error('Cron job timeout');
|
||||
req.destroy();
|
||||
});
|
||||
}
|
||||
|
||||
// 如果直接运行此文件,设置任务
|
||||
if (require.main === module) {
|
||||
// 延迟启动任务,等待服务器完全启动
|
||||
setTimeout(() => {
|
||||
setupServerTasks();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
/**
|
||||
* 生产模式下的服务器入口
|
||||
* 使用 NODE_ENV=production node production.js 来启动
|
||||
*/
|
||||
process.env.NODE_ENV = 'production';
|
||||
|
||||
const { createServer } = require('http');
|
||||
const { parse } = require('url');
|
||||
const next = require('next');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const { createWebSocketServer } = require('./websocket');
|
||||
|
||||
// 调用 generate-manifest.js 生成 manifest.json
|
||||
function generateManifest() {
|
||||
console.log('Generating manifest.json for Docker deployment...');
|
||||
|
||||
try {
|
||||
const generateManifestScript = path.join(
|
||||
__dirname,
|
||||
'scripts',
|
||||
'generate-manifest.js'
|
||||
);
|
||||
require(generateManifestScript);
|
||||
} catch (error) {
|
||||
console.error('❌ Error calling generate-manifest.js:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成manifest
|
||||
generateManifest();
|
||||
|
||||
const hostname = process.env.HOSTNAME || '0.0.0.0';
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// 在生产模式下初始化 Next.js
|
||||
const app = next({
|
||||
dev: false,
|
||||
hostname,
|
||||
port
|
||||
});
|
||||
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
// 检查是否是WebSocket升级请求,如果是则跳过Next.js处理
|
||||
const upgrade = req.headers.upgrade;
|
||||
if (upgrade && upgrade.toLowerCase() === 'websocket') {
|
||||
// 不处理WebSocket升级请求,让upgrade事件处理器处理
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用Next.js处理所有非WebSocket请求
|
||||
const parsedUrl = parse(req.url, true);
|
||||
await handle(req, res, parsedUrl);
|
||||
} catch (err) {
|
||||
console.error('处理请求时出错:', req.url, err);
|
||||
res.statusCode = 500;
|
||||
res.end('内部服务器错误');
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化 WebSocket 服务器
|
||||
const wss = createWebSocketServer();
|
||||
|
||||
// 将 WebSocket 服务器实例存储到全局对象中,供 API 路由使用
|
||||
global.wss = wss;
|
||||
|
||||
// 使用WeakSet来跟踪已处理的socket,避免重复处理
|
||||
const handledSockets = new WeakSet();
|
||||
|
||||
// 处理 WebSocket 升级请求
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
// 如果socket已经被处理过,直接返回
|
||||
if (handledSockets.has(socket)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathname = parse(request.url).pathname;
|
||||
|
||||
if (pathname === '/ws') {
|
||||
console.log('处理 WebSocket 升级请求:', pathname);
|
||||
|
||||
// 标记socket已被处理
|
||||
handledSockets.add(socket);
|
||||
|
||||
// 处理WebSocket连接
|
||||
try {
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('WebSocket升级错误:', error);
|
||||
socket.destroy();
|
||||
}
|
||||
} else {
|
||||
console.log('未知的升级请求路径:', pathname);
|
||||
// 不销毁socket,让它自然关闭
|
||||
}
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
server.listen(port, (err) => {
|
||||
if (err) throw err;
|
||||
console.log(`> 服务已启动 (生产模式): http://${hostname}:${port}`);
|
||||
console.log(`> WebSocket 服务已启动: ws://${hostname}:${port}/ws`);
|
||||
|
||||
// 设置服务器启动后的任务
|
||||
setupServerTasks();
|
||||
});
|
||||
});
|
||||
|
||||
// 设置服务器启动后的任务
|
||||
function setupServerTasks() {
|
||||
// 每 1 秒轮询一次,直到请求成功
|
||||
const TARGET_URL = `http://${process.env.HOSTNAME || 'localhost'}:${process.env.PORT || 3000}/login`;
|
||||
|
||||
const intervalId = setInterval(() => {
|
||||
console.log(`Fetching ${TARGET_URL} ...`);
|
||||
|
||||
const req = http.get(TARGET_URL, (res) => {
|
||||
// 当返回 2xx 状态码时认为成功,然后停止轮询
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log('Server is up, stop polling.');
|
||||
clearInterval(intervalId);
|
||||
|
||||
setTimeout(() => {
|
||||
// 服务器启动后,立即执行一次 cron 任务
|
||||
executeCronJob();
|
||||
}, 3000);
|
||||
|
||||
// 然后设置每小时执行一次 cron 任务
|
||||
setInterval(() => {
|
||||
executeCronJob();
|
||||
}, 60 * 60 * 1000); // 每小时执行一次
|
||||
}
|
||||
});
|
||||
|
||||
req.setTimeout(2000, () => {
|
||||
req.destroy();
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// 执行 cron 任务的函数
|
||||
function executeCronJob() {
|
||||
const cronUrl = `http://${process.env.HOSTNAME || 'localhost'}:${process.env.PORT || 3000}/api/cron`;
|
||||
|
||||
console.log(`Executing cron job: ${cronUrl}`);
|
||||
|
||||
const req = http.get(cronUrl, (res) => {
|
||||
let data = '';
|
||||
|
||||
res.on('data', (chunk) => {
|
||||
data += chunk;
|
||||
});
|
||||
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log('Cron job executed successfully:', data);
|
||||
} else {
|
||||
console.error('Cron job failed:', res.statusCode, data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on('error', (err) => {
|
||||
console.error('Error executing cron job:', err);
|
||||
});
|
||||
|
||||
req.setTimeout(30000, () => {
|
||||
console.error('Cron job timeout');
|
||||
req.destroy();
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
const { createServer } = require('http');
|
||||
const { parse } = require('url');
|
||||
const next = require('next');
|
||||
const { createWebSocketServer } = require('./websocket');
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const hostname = 'localhost';
|
||||
const port = process.env.PORT || 3000;
|
||||
|
||||
// 当使用Next.js时,需要预准备应用程序
|
||||
const app = next({ dev, hostname, port });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
// 使用Next.js处理所有请求
|
||||
const parsedUrl = parse(req.url, true);
|
||||
await handle(req, res, parsedUrl);
|
||||
} catch (err) {
|
||||
console.error('Error occurred handling', req.url, err);
|
||||
res.statusCode = 500;
|
||||
res.end('internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
// 初始化 WebSocket 服务器
|
||||
const wss = createWebSocketServer(server);
|
||||
|
||||
// 将 WebSocket 服务器实例及相关方法存储到全局对象中,供 API 路由使用
|
||||
global.wss = wss;
|
||||
|
||||
// 使用一个标志确保每个连接只被处理一次
|
||||
const upgradedSockets = new WeakSet();
|
||||
|
||||
// 直接处理 WebSocket 升级请求
|
||||
server.on('upgrade', (request, socket, head) => {
|
||||
// 如果这个 socket 已经被处理过,就忽略它
|
||||
if (upgradedSockets.has(socket)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathname = parse(request.url).pathname;
|
||||
|
||||
if (pathname === '/ws') {
|
||||
console.log('处理 WebSocket 升级请求:', pathname);
|
||||
try {
|
||||
// 标记这个 socket 已经被处理
|
||||
upgradedSockets.add(socket);
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, request);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('WebSocket 升级处理错误:', error);
|
||||
socket.write('HTTP/1.1 500 Internal Server Error\r\n\r\n');
|
||||
socket.destroy();
|
||||
}
|
||||
} else {
|
||||
console.log('非 WebSocket 升级请求:', pathname);
|
||||
// Next.js 会自己处理这些请求,无需销毁 socket
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, (err) => {
|
||||
if (err) throw err;
|
||||
console.log(`> Ready on http://${hostname}:${port}`);
|
||||
console.log(`> WebSocket server ready on ws://${hostname}:${port}/ws`);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
const { createServer } = require('http');
|
||||
const { parse } = require('url');
|
||||
const next = require('next');
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production';
|
||||
const hostname = 'localhost';
|
||||
const port = 3000;
|
||||
const wsPort = 3001;
|
||||
|
||||
// 启动独立WebSocket服务器
|
||||
console.log('🔌 启动 WebSocket 服务器...');
|
||||
const { createStandaloneWebSocketServer } = require('./standalone-websocket');
|
||||
createStandaloneWebSocketServer(wsPort);
|
||||
|
||||
// 启动Next.js
|
||||
const app = next({ dev, hostname, port });
|
||||
const handle = app.getRequestHandler();
|
||||
|
||||
app.prepare().then(() => {
|
||||
const server = createServer(async (req, res) => {
|
||||
try {
|
||||
const parsedUrl = parse(req.url, true);
|
||||
await handle(req, res, parsedUrl);
|
||||
} catch (err) {
|
||||
console.error('Error occurred handling', req.url, err);
|
||||
res.statusCode = 500;
|
||||
res.end('internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, (err) => {
|
||||
if (err) throw err;
|
||||
console.log(`🌐 Next.js ready on http://${hostname}:${port}`);
|
||||
console.log(`🔌 WebSocket ready on ws://${hostname}:${wsPort}/ws`);
|
||||
console.log('\n✅ 开发环境已启动!按 Ctrl+C 停止服务器');
|
||||
});
|
||||
|
||||
// 优雅关闭
|
||||
const cleanup = () => {
|
||||
console.log('\n🛑 正在关闭服务器...');
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ import Image from 'next/image';
|
|||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
|
||||
import { AdminConfig, AdminConfigResult } from '../../lib/admin.types';
|
||||
import { getAuthInfoFromBrowserCookie } from '@/lib/auth';
|
||||
|
||||
import DataMigration from '@/components/DataMigration';
|
||||
|
|
|
|||
|
|
@ -16,14 +16,9 @@ export async function GET(request: NextRequest) {
|
|||
const { searchParams } = new URL(request.url);
|
||||
const targetUser = searchParams.get('user') || authInfo.username;
|
||||
|
||||
// 只允许获取自己的头像,管理员和站长可以获取任何用户的头像
|
||||
const canAccess = targetUser === authInfo.username ||
|
||||
authInfo.role === 'admin' ||
|
||||
authInfo.role === 'owner';
|
||||
|
||||
if (!canAccess) {
|
||||
return NextResponse.json({ error: 'Permission denied' }, { status: 403 });
|
||||
}
|
||||
// 在聊天系统中,用户应该能够查看其他用户的头像,这对聊天功能是必要的
|
||||
// 只要是已认证用户,就可以查看任何用户的头像
|
||||
// 这对于聊天、好友功能等社交功能是必要的
|
||||
|
||||
const avatar = await db.getUserAvatar(targetUser);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,59 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '../../../../lib/db';
|
||||
import { Conversation } from '../../../../lib/types';
|
||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const conversations = await db.getConversations(authInfo.username);
|
||||
return NextResponse.json(conversations);
|
||||
} catch (error) {
|
||||
console.error('Error loading conversations:', error);
|
||||
return NextResponse.json({ error: '获取对话列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { participants, name, type } = await request.json();
|
||||
|
||||
if (!participants || !Array.isArray(participants) || participants.length === 0) {
|
||||
return NextResponse.json({ error: '参与者列表不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 确保当前用户在参与者列表中
|
||||
if (!participants.includes(authInfo.username)) {
|
||||
participants.push(authInfo.username);
|
||||
}
|
||||
|
||||
// 根据参与者数量确定对话类型
|
||||
const conversationType = type || (participants.length > 2 ? 'group' : 'private');
|
||||
const isGroup = conversationType === 'group';
|
||||
|
||||
const conversation: Conversation = {
|
||||
id: `conv_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: name || participants.filter(p => p !== authInfo.username).join(', '),
|
||||
participants,
|
||||
type: conversationType,
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
is_group: isGroup,
|
||||
};
|
||||
|
||||
await db.createConversation(conversation);
|
||||
return NextResponse.json(conversation, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating conversation:', error);
|
||||
return NextResponse.json({ error: '创建对话失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '../../../../lib/db';
|
||||
import { FriendRequest, Friend } from '../../../../lib/types';
|
||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const friendRequests = await db.getFriendRequests(authInfo.username);
|
||||
return NextResponse.json(friendRequests);
|
||||
} catch (error) {
|
||||
console.error('Error loading friend requests:', error);
|
||||
return NextResponse.json({ error: '获取好友申请失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { to_user, message } = await request.json();
|
||||
|
||||
if (!to_user) {
|
||||
return NextResponse.json({ error: '目标用户不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查目标用户是否存在
|
||||
const userExists = await db.checkUserExist(to_user);
|
||||
if (!userExists) {
|
||||
return NextResponse.json({ error: '目标用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查是否已经是好友
|
||||
const friends = await db.getFriends(authInfo.username);
|
||||
const isAlreadyFriend = friends.some(friend => friend.username === to_user);
|
||||
if (isAlreadyFriend) {
|
||||
return NextResponse.json({ error: '已经是好友' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查是否已经有pending的申请
|
||||
const existingRequests = await db.getFriendRequests(to_user);
|
||||
const hasPendingRequest = existingRequests.some(
|
||||
req => req.from_user === authInfo.username && req.status === 'pending'
|
||||
);
|
||||
if (hasPendingRequest) {
|
||||
return NextResponse.json({ error: '已有待处理的好友申请' }, { status: 400 });
|
||||
}
|
||||
|
||||
const friendRequest: FriendRequest = {
|
||||
id: `req_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
from_user: authInfo.username,
|
||||
to_user,
|
||||
message: message || '请求添加您为好友',
|
||||
status: 'pending',
|
||||
created_at: Date.now(),
|
||||
updated_at: Date.now(),
|
||||
};
|
||||
|
||||
await db.createFriendRequest(friendRequest);
|
||||
return NextResponse.json(friendRequest, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error creating friend request:', error);
|
||||
return NextResponse.json({ error: '发送好友申请失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { requestId, status } = await request.json();
|
||||
|
||||
if (!requestId || !status || !['accepted', 'rejected'].includes(status)) {
|
||||
return NextResponse.json({ error: '请求参数无效' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取申请信息
|
||||
const allRequests = await db.getFriendRequests(authInfo.username);
|
||||
const friendRequest = allRequests.find(req => req.id === requestId && req.to_user === authInfo.username);
|
||||
|
||||
if (!friendRequest) {
|
||||
return NextResponse.json({ error: '好友申请不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (friendRequest.status !== 'pending') {
|
||||
return NextResponse.json({ error: '申请已处理' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 更新申请状态
|
||||
await db.updateFriendRequest(requestId, status);
|
||||
|
||||
// 如果接受申请,添加为好友
|
||||
if (status === 'accepted') {
|
||||
const friend1: Friend = {
|
||||
id: `friend_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
username: friendRequest.from_user,
|
||||
status: 'offline',
|
||||
added_at: Date.now(),
|
||||
};
|
||||
|
||||
const friend2: Friend = {
|
||||
id: `friend_${Date.now() + 1}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
username: authInfo.username,
|
||||
status: 'offline',
|
||||
added_at: Date.now(),
|
||||
};
|
||||
|
||||
// 双向添加好友
|
||||
await Promise.all([
|
||||
db.addFriend(authInfo.username, friend1),
|
||||
db.addFriend(friendRequest.from_user, friend2),
|
||||
]);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error handling friend request:', error);
|
||||
return NextResponse.json({ error: '处理好友申请失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '../../../../lib/db';
|
||||
import { Friend } from '../../../../lib/types';
|
||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const friends = await db.getFriends(authInfo.username);
|
||||
return NextResponse.json(friends);
|
||||
} catch (error) {
|
||||
console.error('Error loading friends:', error);
|
||||
return NextResponse.json({ error: '获取好友列表失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { username, nickname } = await request.json();
|
||||
|
||||
if (!username) {
|
||||
return NextResponse.json({ error: '用户名不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 检查用户是否存在
|
||||
const userExists = await db.checkUserExist(username);
|
||||
if (!userExists) {
|
||||
return NextResponse.json({ error: '用户不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
// 检查是否已经是好友
|
||||
const friends = await db.getFriends(authInfo.username);
|
||||
const isAlreadyFriend = friends.some(friend => friend.username === username);
|
||||
if (isAlreadyFriend) {
|
||||
return NextResponse.json({ error: '已经是好友' }, { status: 400 });
|
||||
}
|
||||
|
||||
const friend: Friend = {
|
||||
id: `friend_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
username,
|
||||
nickname,
|
||||
status: 'offline',
|
||||
added_at: Date.now(),
|
||||
};
|
||||
|
||||
await db.addFriend(authInfo.username, friend);
|
||||
return NextResponse.json(friend, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error adding friend:', error);
|
||||
return NextResponse.json({ error: '添加好友失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const friendId = searchParams.get('friendId');
|
||||
|
||||
if (!friendId) {
|
||||
return NextResponse.json({ error: '好友 ID 不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.removeFriend(authInfo.username, friendId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error removing friend:', error);
|
||||
return NextResponse.json({ error: '删除好友失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '../../../../lib/db';
|
||||
import { ChatMessage } from '../../../../lib/types';
|
||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
console.log('未授权访问消息API:', authInfo);
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const conversationId = searchParams.get('conversationId');
|
||||
const limit = parseInt(searchParams.get('limit') || '50');
|
||||
const offset = parseInt(searchParams.get('offset') || '0');
|
||||
|
||||
if (!conversationId) {
|
||||
console.log('缺少对话ID参数');
|
||||
return NextResponse.json({ error: '对话 ID 不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
console.log('加载消息 - 用户:', authInfo.username, '对话ID:', conversationId);
|
||||
|
||||
// 验证用户是否有权限访问此对话
|
||||
let conversation;
|
||||
try {
|
||||
conversation = await db.getConversation(conversationId);
|
||||
console.log('对话查询结果:', conversation ? '找到对话' : '对话不存在');
|
||||
} catch (dbError) {
|
||||
console.error('数据库查询对话失败:', dbError);
|
||||
return NextResponse.json({
|
||||
error: '数据库查询失败',
|
||||
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
if (!conversation) {
|
||||
console.log('对话不存在:', conversationId);
|
||||
return NextResponse.json({ error: '对话不存在' }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!conversation.participants.includes(authInfo.username)) {
|
||||
console.log('用户无权限访问对话:', authInfo.username, '参与者:', conversation.participants);
|
||||
return NextResponse.json({ error: '无权限访问此对话' }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = await db.getMessages(conversationId, limit, offset);
|
||||
console.log(`成功加载 ${messages.length} 条消息`);
|
||||
return NextResponse.json(messages);
|
||||
} catch (dbError) {
|
||||
console.error('数据库查询消息失败:', dbError);
|
||||
return NextResponse.json({
|
||||
error: '获取消息失败',
|
||||
details: process.env.NODE_ENV === 'development' ? (dbError as Error).message : undefined
|
||||
}, { status: 500 });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('加载消息API发生未知错误:', error);
|
||||
return NextResponse.json({
|
||||
error: '获取消息失败',
|
||||
details: process.env.NODE_ENV === 'development' ? (error as Error).message : undefined
|
||||
}, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const messageData = await request.json();
|
||||
|
||||
if (!messageData.conversation_id || !messageData.content) {
|
||||
return NextResponse.json({ error: '对话 ID 和消息内容不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 验证用户是否有权限发送消息到此对话
|
||||
const conversation = await db.getConversation(messageData.conversation_id);
|
||||
if (!conversation || !conversation.participants.includes(authInfo.username)) {
|
||||
return NextResponse.json({ error: '无权限发送消息到此对话' }, { status: 403 });
|
||||
}
|
||||
|
||||
const message: ChatMessage = {
|
||||
id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
conversation_id: messageData.conversation_id,
|
||||
sender_id: authInfo.username,
|
||||
sender_name: authInfo.username,
|
||||
content: messageData.content,
|
||||
message_type: messageData.message_type || 'text',
|
||||
timestamp: Date.now(),
|
||||
is_read: false,
|
||||
};
|
||||
|
||||
await db.saveMessage(message);
|
||||
|
||||
// 更新对话的最后消息和更新时间
|
||||
await db.updateConversation(messageData.conversation_id, {
|
||||
last_message: message,
|
||||
updated_at: Date.now(),
|
||||
});
|
||||
|
||||
return NextResponse.json(message, { status: 201 });
|
||||
} catch (error) {
|
||||
console.error('Error sending message:', error);
|
||||
return NextResponse.json({ error: '发送消息失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { messageId } = await request.json();
|
||||
|
||||
if (!messageId) {
|
||||
return NextResponse.json({ error: '消息 ID 不能为空' }, { status: 400 });
|
||||
}
|
||||
|
||||
await db.markMessageAsRead(messageId);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Error marking message as read:', error);
|
||||
return NextResponse.json({ error: '标记消息已读失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
||||
|
||||
// 从全局对象获取WebSocket实例相关方法
|
||||
function getOnlineUsers(): string[] {
|
||||
try {
|
||||
if ((global as any).wss) {
|
||||
// 假设websocket.js中导出了getOnlineUsers方法并附加到了wss对象上
|
||||
return require('../../../../../websocket').getOnlineUsers();
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error('获取在线用户失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取在线用户列表
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const onlineUsers = getOnlineUsers();
|
||||
return NextResponse.json({ onlineUsers });
|
||||
} catch (error) {
|
||||
console.error('获取在线用户失败:', error);
|
||||
return NextResponse.json({ error: '获取在线用户失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { db } from '../../../../lib/db';
|
||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q');
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
return NextResponse.json({ error: '搜索关键词至少需要2个字符' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 获取所有用户并进行模糊匹配
|
||||
const allUsers = await db.getAllUsers();
|
||||
const matchedUsers = allUsers.filter(username =>
|
||||
username.toLowerCase().includes(query.toLowerCase()) &&
|
||||
username !== authInfo.username // 排除自己
|
||||
);
|
||||
|
||||
// 转换为Friend格式返回
|
||||
const userResults = matchedUsers.map(username => ({
|
||||
id: username,
|
||||
username,
|
||||
status: 'offline' as const,
|
||||
added_at: 0,
|
||||
}));
|
||||
|
||||
return NextResponse.json(userResults);
|
||||
} catch (error) {
|
||||
console.error('Error searching users:', error);
|
||||
return NextResponse.json({ error: '搜索用户失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getAuthInfoFromCookie } from '../../../../lib/auth';
|
||||
import { WebSocketMessage } from '../../../../lib/types';
|
||||
|
||||
// 从全局对象获取WebSocket实例相关方法
|
||||
function sendMessageToUsers(userIds: string[], message: WebSocketMessage): boolean {
|
||||
try {
|
||||
if ((global as any).wss) {
|
||||
// 假设websocket.js中导出了sendMessageToUsers方法并附加到了wss对象上
|
||||
return require('../../../../../websocket').sendMessageToUsers(userIds, message);
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('发送WebSocket消息失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 发送消息的备用 API 路由,在 WebSocket 不可用时使用
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||
}
|
||||
|
||||
const message: WebSocketMessage = await request.json();
|
||||
|
||||
// 根据消息类型处理
|
||||
let targetUsers: string[] = [];
|
||||
|
||||
switch (message.type) {
|
||||
case 'message':
|
||||
const { participants } = message.data;
|
||||
if (participants && Array.isArray(participants)) {
|
||||
targetUsers = participants;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'friend_request':
|
||||
const { to_user } = message.data;
|
||||
if (to_user) {
|
||||
targetUsers = [to_user];
|
||||
}
|
||||
break;
|
||||
|
||||
case 'friend_accepted':
|
||||
const { from_user } = message.data;
|
||||
if (from_user) {
|
||||
targetUsers = [from_user];
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
return NextResponse.json({ error: '不支持的消息类型' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 通过 WebSocket 发送消息
|
||||
const sent = sendMessageToUsers(targetUsers, message);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
delivered: sent
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('通过 API 发送消息失败:', error);
|
||||
return NextResponse.json({ error: '发送消息失败' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Next.js server is running'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -7,17 +7,37 @@ export async function GET(request: NextRequest) {
|
|||
const id = searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
console.error('🚫 [短剧API] 缺少必需的ID参数');
|
||||
return NextResponse.json(
|
||||
{ error: 'id parameter is required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`🎬 [短剧API] 开始请求短剧全集地址:`, {
|
||||
requestId: id,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: request.headers.get('user-agent'),
|
||||
referer: request.headers.get('referer')
|
||||
});
|
||||
|
||||
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/all`);
|
||||
apiUrl.searchParams.append('id', id);
|
||||
apiUrl.searchParams.append('proxy', 'true');
|
||||
|
||||
console.log(`🌐 [短剧API] 外部API调用详情:`, {
|
||||
baseUrl: API_CONFIG.shortdrama.baseUrl,
|
||||
fullUrl: apiUrl.toString(),
|
||||
headers: API_CONFIG.shortdrama.headers,
|
||||
timeout: '60秒'
|
||||
});
|
||||
|
||||
const requestStartTime = performance.now();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 60000); // 60秒超时,为获取全集地址提供充足时间
|
||||
const timeoutId = setTimeout(() => {
|
||||
console.error('⏰ [短剧API] 请求超时 - 60秒');
|
||||
controller.abort();
|
||||
}, 60000);
|
||||
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
method: 'GET',
|
||||
|
|
@ -26,42 +46,249 @@ export async function GET(request: NextRequest) {
|
|||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const requestEndTime = performance.now();
|
||||
const requestDuration = requestEndTime - requestStartTime;
|
||||
|
||||
console.log(`📡 [短剧API] 外部API响应状态:`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
ok: response.ok,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
requestDuration: `${requestDuration.toFixed(2)}ms`,
|
||||
contentType: response.headers.get('content-type')
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API request failed: ${response.status}`);
|
||||
console.error(`❌ [短剧API] 外部API请求失败:`, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: apiUrl.toString(),
|
||||
requestDuration: `${requestDuration.toFixed(2)}ms`
|
||||
});
|
||||
throw new Error(`API request failed: ${response.status} - ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log(`📦 [短剧API] 外部API响应数据分析:`, {
|
||||
hasData: !!data,
|
||||
dataKeys: data ? Object.keys(data) : [],
|
||||
videoId: data?.videoId,
|
||||
videoName: data?.videoName,
|
||||
totalEpisodes: data?.totalEpisodes,
|
||||
successfulCount: data?.successfulCount,
|
||||
failedCount: data?.failedCount,
|
||||
hasResults: !!data?.results,
|
||||
resultsLength: data?.results?.length || 0,
|
||||
resultsType: typeof data?.results,
|
||||
isResultsArray: Array.isArray(data?.results),
|
||||
hasCover: !!data?.cover,
|
||||
hasDescription: !!data?.description
|
||||
});
|
||||
|
||||
// 直接返回API响应数据,播放页面会处理数据结构转换
|
||||
return NextResponse.json(data);
|
||||
// 分析results数组的详细结构
|
||||
if (data?.results && Array.isArray(data.results)) {
|
||||
const successCount = data.results.filter((item: any) => item.status === 'success').length;
|
||||
const failureCount = data.results.filter((item: any) => item.status !== 'success').length;
|
||||
const withUrlCount = data.results.filter((item: any) => item.status === 'success' && item.parsedUrl).length;
|
||||
|
||||
console.log(`📋 [短剧API] Results数组详细分析:`, {
|
||||
totalItems: data.results.length,
|
||||
successItems: successCount,
|
||||
failureItems: failureCount,
|
||||
itemsWithUrl: withUrlCount,
|
||||
sampleSuccessItems: data.results.filter((item: any) => item.status === 'success').slice(0, 3).map((item: any) => ({
|
||||
index: item.index,
|
||||
label: item.label,
|
||||
status: item.status,
|
||||
hasUrl: !!item.parsedUrl,
|
||||
urlLength: item.parsedUrl ? item.parsedUrl.length : 0,
|
||||
urlDomain: item.parsedUrl ? item.parsedUrl.match(/https?:\/\/([^\/]+)/)?.[1] : null
|
||||
})),
|
||||
sampleFailureItems: data.results.filter((item: any) => item.status !== 'success').slice(0, 3).map((item: any) => ({
|
||||
index: item.index,
|
||||
label: item.label,
|
||||
status: item.status,
|
||||
reason: item.reason
|
||||
}))
|
||||
});
|
||||
} else {
|
||||
console.error(`❌ [短剧API] Results数组无效:`, {
|
||||
hasResults: !!data?.results,
|
||||
resultsType: typeof data?.results,
|
||||
isArray: Array.isArray(data?.results),
|
||||
resultsValue: data?.results
|
||||
});
|
||||
}
|
||||
|
||||
// 验证返回的数据格式
|
||||
if (!data || !data.results || !Array.isArray(data.results)) {
|
||||
console.error('❌ [短剧API] 数据格式验证失败:', {
|
||||
hasData: !!data,
|
||||
hasResults: !!data?.results,
|
||||
resultsType: typeof data?.results,
|
||||
isResultsArray: Array.isArray(data?.results),
|
||||
fullData: data
|
||||
});
|
||||
throw new Error('Invalid API response format - 外部API返回的数据格式不正确');
|
||||
}
|
||||
|
||||
// 检查播放地址的有效性
|
||||
console.log('🔍 [短剧API] 开始验证播放地址有效性...');
|
||||
|
||||
const validResults = data.results.filter((item: any) => {
|
||||
const isValid = item.status === 'success' &&
|
||||
item.parsedUrl &&
|
||||
typeof item.parsedUrl === 'string' &&
|
||||
item.parsedUrl.trim().length > 0;
|
||||
|
||||
if (!isValid) {
|
||||
console.warn(`⚠️ [短剧API] 无效的播放源:`, {
|
||||
index: item.index,
|
||||
label: item.label,
|
||||
status: item.status,
|
||||
hasUrl: !!item.parsedUrl,
|
||||
urlType: typeof item.parsedUrl,
|
||||
urlLength: item.parsedUrl ? item.parsedUrl.length : 0,
|
||||
reason: item.reason || '未知原因'
|
||||
});
|
||||
}
|
||||
|
||||
return isValid;
|
||||
});
|
||||
|
||||
console.log(`✅ [短剧API] 播放源验证完成:`, {
|
||||
totalSources: data.results.length,
|
||||
validSources: validResults.length,
|
||||
invalidSources: data.results.length - validResults.length,
|
||||
validationRate: `${((validResults.length / data.results.length) * 100).toFixed(1)}%`
|
||||
});
|
||||
|
||||
if (validResults.length === 0) {
|
||||
console.error('❌ [短剧API] 没有找到任何有效的播放地址:', {
|
||||
totalResults: data.results.length,
|
||||
allResults: data.results.map((item: any) => ({
|
||||
index: item.index,
|
||||
label: item.label,
|
||||
status: item.status,
|
||||
hasUrl: !!item.parsedUrl,
|
||||
urlType: typeof item.parsedUrl,
|
||||
reason: item.reason
|
||||
}))
|
||||
});
|
||||
throw new Error('No valid video sources found - 所有播放源都无效');
|
||||
}
|
||||
|
||||
// 返回处理后的数据
|
||||
const processedData = {
|
||||
...data,
|
||||
results: validResults,
|
||||
totalEpisodes: validResults.length,
|
||||
successfulCount: validResults.length,
|
||||
originalTotalEpisodes: data.totalEpisodes,
|
||||
originalSuccessfulCount: data.successfulCount,
|
||||
filteredCount: data.results.length - validResults.length
|
||||
};
|
||||
|
||||
console.log('🎯 [短剧API] 返回处理后的短剧数据:', {
|
||||
videoId: processedData.videoId,
|
||||
videoName: processedData.videoName,
|
||||
originalTotal: processedData.originalTotalEpisodes,
|
||||
filteredTotal: processedData.totalEpisodes,
|
||||
originalSuccess: processedData.originalSuccessfulCount,
|
||||
filteredSuccess: processedData.successfulCount,
|
||||
filteredOut: processedData.filteredCount,
|
||||
firstEpisode: {
|
||||
index: processedData.results[0]?.index,
|
||||
label: processedData.results[0]?.label,
|
||||
urlPreview: processedData.results[0]?.parsedUrl?.substring(0, 100) + '...'
|
||||
},
|
||||
lastEpisode: {
|
||||
index: processedData.results[processedData.results.length - 1]?.index,
|
||||
label: processedData.results[processedData.results.length - 1]?.label,
|
||||
urlPreview: processedData.results[processedData.results.length - 1]?.parsedUrl?.substring(0, 100) + '...'
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json(processedData);
|
||||
} catch (error) {
|
||||
console.error('Short drama all parse API error:', error);
|
||||
|
||||
// 返回模拟的短剧数据作为备用
|
||||
const { searchParams: errorSearchParams } = new URL(request.url);
|
||||
const errorId = errorSearchParams.get('id');
|
||||
|
||||
console.error('💥 [短剧API] 发生错误:', {
|
||||
errorType: error instanceof Error ? error.constructor.name : typeof error,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
requestId: errorId,
|
||||
timestamp: new Date().toISOString(),
|
||||
isTimeoutError: error instanceof Error && error.name === 'AbortError',
|
||||
isFetchError: error instanceof TypeError,
|
||||
isNetworkError: error instanceof Error && error.message.includes('fetch')
|
||||
});
|
||||
|
||||
// 分析错误类型
|
||||
let errorCategory = '未知错误';
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
errorCategory = '请求超时';
|
||||
} else if (error.message.includes('fetch')) {
|
||||
errorCategory = '网络连接错误';
|
||||
} else if (error.message.includes('API request failed')) {
|
||||
errorCategory = '外部API错误';
|
||||
} else if (error.message.includes('Invalid API response format')) {
|
||||
errorCategory = '数据格式错误';
|
||||
} else if (error.message.includes('No valid video sources found')) {
|
||||
errorCategory = '无有效播放源';
|
||||
}
|
||||
}
|
||||
|
||||
console.warn(`🔄 [短剧API] 错误类型: ${errorCategory},启用备用数据`);
|
||||
|
||||
const mockData = {
|
||||
videoId: parseInt(errorId || '1') || 1,
|
||||
videoName: '短剧播放示例',
|
||||
results: Array.from({ length: 10 }, (_, index) => ({
|
||||
videoName: `短剧播放示例 (ID: ${errorId})`,
|
||||
results: Array.from({ length: 8 }, (_, index) => ({
|
||||
index: index,
|
||||
label: `第${index + 1}集`,
|
||||
parsedUrl: `https://example.com/video${index + 1}.mp4`,
|
||||
// 使用一些测试视频地址,这些是公共测试资源
|
||||
parsedUrl: `https://sample-videos.com/zip/10/mp4/SampleVideo_720x480_1mb.mp4?episode=${index + 1}`,
|
||||
parseInfo: {
|
||||
headers: {},
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
'Referer': 'https://sample-videos.com'
|
||||
},
|
||||
type: 'mp4'
|
||||
},
|
||||
status: 'success',
|
||||
reason: null
|
||||
})),
|
||||
totalEpisodes: 10,
|
||||
successfulCount: 10,
|
||||
totalEpisodes: 8,
|
||||
successfulCount: 8,
|
||||
failedCount: 0,
|
||||
cover: 'https://via.placeholder.com/300x400',
|
||||
description: '这是一个示例短剧,用于测试播放功能。'
|
||||
cover: 'https://via.placeholder.com/300x400?text=短剧示例',
|
||||
description: `这是一个短剧播放示例,用于测试播放功能。原始ID: ${errorId},错误: ${errorCategory}`,
|
||||
// 添加错误信息供调试使用
|
||||
_debugInfo: {
|
||||
errorCategory: errorCategory,
|
||||
originalError: error instanceof Error ? error.message : String(error),
|
||||
fallbackDataUsed: true,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
return NextResponse.json(mockData);
|
||||
console.log('🔧 [短剧API] 返回备用短剧数据:', {
|
||||
videoName: mockData.videoName,
|
||||
totalEpisodes: mockData.totalEpisodes,
|
||||
errorCategory: errorCategory,
|
||||
firstEpisodeUrl: mockData.results[0].parsedUrl,
|
||||
hasFallbackData: true
|
||||
});
|
||||
|
||||
return NextResponse.json(mockData, {
|
||||
headers: {
|
||||
'X-Fallback-Data': 'true',
|
||||
'X-Error-Category': errorCategory,
|
||||
'X-Original-Error': error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export async function GET(request: NextRequest) {
|
|||
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/batch`);
|
||||
apiUrl.searchParams.append('id', id);
|
||||
if (episodes) apiUrl.searchParams.append('episodes', episodes);
|
||||
apiUrl.searchParams.append('proxy', 'true');
|
||||
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
method: 'GET',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ export async function GET(request: NextRequest) {
|
|||
const apiUrl = new URL(`${API_CONFIG.shortdrama.baseUrl}/vod/parse/single`);
|
||||
apiUrl.searchParams.append('id', id);
|
||||
if (episode) apiUrl.searchParams.append('episode', episode);
|
||||
apiUrl.searchParams.append('proxy', 'true');
|
||||
|
||||
const response = await fetch(apiUrl.toString(), {
|
||||
method: 'GET',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
import { NextRequest } from 'next/server';
|
||||
|
||||
// 这个端点主要用于 WebSocket 升级,实际的 WebSocket 处理在自定义服务器中进行
|
||||
export async function GET(request: NextRequest) {
|
||||
// 如果运行在自定义服务器环境下,WebSocket 连接应该已经被处理
|
||||
// 这里主要是为了提供一个回退响应
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
if (searchParams.get('upgrade') === 'websocket') {
|
||||
return new Response('WebSocket upgrade should be handled by custom server', {
|
||||
status: 426,
|
||||
headers: {
|
||||
'Upgrade': 'websocket',
|
||||
'Connection': 'Upgrade',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return new Response('WebSocket endpoint', {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'text/plain' },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -262,6 +262,7 @@ function LoginPageClient() {
|
|||
|
||||
{/* 绑定选项 */}
|
||||
{!requireMachineCode && (
|
||||
<div className='space-y-2'>
|
||||
<div className='flex items-center space-x-3'>
|
||||
<input
|
||||
id='bindMachineCode'
|
||||
|
|
@ -274,6 +275,10 @@ function LoginPageClient() {
|
|||
绑定此设备(提升账户安全性)
|
||||
</label>
|
||||
</div>
|
||||
{/* <p className='text-xs text-gray-500 dark:text-gray-400 ml-7'>
|
||||
// 管理员可选择不绑定机器码直接登录
|
||||
</p> */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -433,6 +433,10 @@ function SearchPageClient() {
|
|||
clearTimeout(flushTimerRef.current);
|
||||
flushTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 清理聚合统计缓存和refs,防止数据污染
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
setIsLoading(true);
|
||||
setShowResults(true);
|
||||
|
||||
|
|
@ -464,7 +468,11 @@ function SearchPageClient() {
|
|||
if (!event.data) return;
|
||||
try {
|
||||
const payload = JSON.parse(event.data);
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
// 强化竞态条件检查:确保是当前查询的响应
|
||||
if (currentQueryRef.current !== trimmed || eventSourceRef.current !== es) {
|
||||
console.warn('忽略过期的搜索响应:', payload.type, '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
|
||||
return;
|
||||
}
|
||||
switch (payload.type) {
|
||||
case 'start':
|
||||
setTotalSources(payload.totalSources || 0);
|
||||
|
|
@ -544,7 +552,11 @@ function SearchPageClient() {
|
|||
fetch(`/api/search?q=${encodeURIComponent(trimmed)}`)
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (currentQueryRef.current !== trimmed) return;
|
||||
// 强化竞态条件检查:确保是当前查询的响应
|
||||
if (currentQueryRef.current !== trimmed) {
|
||||
console.warn('忽略过期的搜索响应 (传统):', '当前查询:', currentQueryRef.current, '响应查询:', trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.results && Array.isArray(data.results)) {
|
||||
const activeYearOrder = (viewMode === 'agg' ? (filterAgg.yearOrder) : (filterAll.yearOrder));
|
||||
|
|
@ -573,7 +585,7 @@ function SearchPageClient() {
|
|||
}
|
||||
}, [searchParams]);
|
||||
|
||||
// 组件卸载时,关闭可能存在的连接
|
||||
// 组件卸载时,关闭可能存在的连接并清理所有状态
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (eventSourceRef.current) {
|
||||
|
|
@ -585,6 +597,11 @@ function SearchPageClient() {
|
|||
flushTimerRef.current = null;
|
||||
}
|
||||
pendingResultsRef.current = [];
|
||||
// 清理聚合统计缓存和refs,防止状态泄露
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
// 重置当前查询引用
|
||||
currentQueryRef.current = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -613,6 +630,12 @@ function SearchPageClient() {
|
|||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) return;
|
||||
|
||||
// 清理所有状态和缓存,确保搜索结果干净
|
||||
setSearchResults([]);
|
||||
pendingResultsRef.current = [];
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
|
||||
// 回显搜索框
|
||||
setSearchQuery(trimmed);
|
||||
setIsLoading(true);
|
||||
|
|
@ -624,6 +647,12 @@ function SearchPageClient() {
|
|||
};
|
||||
|
||||
const handleSuggestionSelect = (suggestion: string) => {
|
||||
// 清理所有状态和缓存,确保搜索结果干净
|
||||
setSearchResults([]);
|
||||
pendingResultsRef.current = [];
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
|
||||
setSearchQuery(suggestion);
|
||||
setShowSuggestions(false);
|
||||
|
||||
|
|
@ -695,6 +724,12 @@ function SearchPageClient() {
|
|||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
if (!trimmed) return;
|
||||
|
||||
// 清理所有状态和缓存,确保搜索结果干净
|
||||
setSearchResults([]);
|
||||
pendingResultsRef.current = [];
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
|
||||
// 回显搜索框
|
||||
setSearchQuery(trimmed);
|
||||
setIsLoading(true);
|
||||
|
|
@ -858,6 +893,12 @@ function SearchPageClient() {
|
|||
<div key={item} className='relative group'>
|
||||
<button
|
||||
onClick={() => {
|
||||
// 清理所有状态和缓存,确保搜索结果干净
|
||||
setSearchResults([]);
|
||||
pendingResultsRef.current = [];
|
||||
groupStatsRef.current.clear();
|
||||
groupRefs.current.clear();
|
||||
|
||||
setSearchQuery(item);
|
||||
router.push(
|
||||
`/search?q=${encodeURIComponent(item.trim())}`
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -2,16 +2,44 @@
|
|||
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Moon, Sun, MessageCircle } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { ChatModal } from './ChatModal';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { WebSocketMessage } from '../lib/types';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [isChatModalOpen, setIsChatModalOpen] = useState(false);
|
||||
const [messageCount, setMessageCount] = useState(0);
|
||||
const [chatCount, setChatCount] = useState(0);
|
||||
const [friendRequestCount, setFriendRequestCount] = useState(0);
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const pathname = usePathname();
|
||||
|
||||
// 不再在ThemeToggle中创建独立的WebSocket连接
|
||||
// 改为依赖ChatModal传递的消息计数
|
||||
|
||||
// 直接使用ChatModal传来的消息计数
|
||||
const handleMessageCountFromModal = useCallback((totalCount: number) => {
|
||||
console.log('📊 [ThemeToggle] 收到ChatModal传来的消息计数:', totalCount);
|
||||
setMessageCount(totalCount);
|
||||
}, []);
|
||||
|
||||
// 处理聊天消息计数重置(当用户查看对话时)
|
||||
const handleChatCountReset = useCallback((resetCount: number) => {
|
||||
console.log('💬 [ThemeToggle] 重置聊天计数:', resetCount);
|
||||
// 这些回调函数现在主要用于同步状态,实际计数由ChatModal管理
|
||||
}, []);
|
||||
|
||||
// 处理好友请求计数重置(当用户查看好友请求时)
|
||||
const handleFriendRequestCountReset = useCallback((resetCount: number) => {
|
||||
console.log('👥 [ThemeToggle] 重置好友请求计数:', resetCount);
|
||||
// 这些回调函数现在主要用于同步状态,实际计数由ChatModal管理
|
||||
}, []);
|
||||
|
||||
const setThemeColor = (theme?: string) => {
|
||||
const meta = document.querySelector('meta[name="theme-color"]');
|
||||
if (!meta) {
|
||||
|
|
@ -55,6 +83,23 @@ export function ThemeToggle() {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 聊天按钮 */}
|
||||
<button
|
||||
onClick={() => setIsChatModalOpen(true)}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors relative'
|
||||
aria-label='Open chat'
|
||||
>
|
||||
<MessageCircle className='w-full h-full' />
|
||||
{messageCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs rounded-full flex items-center justify-center">
|
||||
{messageCount > 99 ? '99+' : messageCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* 主题切换按钮 */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className='w-10 h-10 p-2 rounded-full flex items-center justify-center text-gray-600 hover:bg-gray-200/50 dark:text-gray-300 dark:hover:bg-gray-700/50 transition-colors'
|
||||
|
|
@ -66,5 +111,16 @@ export function ThemeToggle() {
|
|||
<Moon className='w-full h-full' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 聊天模态框 */}
|
||||
<ChatModal
|
||||
isOpen={isChatModalOpen}
|
||||
onClose={() => setIsChatModalOpen(false)}
|
||||
onMessageCountChange={handleMessageCountFromModal}
|
||||
onChatCountReset={handleChatCountReset}
|
||||
onFriendRequestCountReset={handleFriendRequestCountReset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,333 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { WebSocketMessage } from '../lib/types';
|
||||
import { getAuthInfoFromBrowserCookie } from '../lib/auth';
|
||||
|
||||
// 全局连接计数器,用于调试
|
||||
let globalConnectionCount = 0;
|
||||
|
||||
interface UseWebSocketOptions {
|
||||
onMessage?: (message: WebSocketMessage) => void;
|
||||
onConnect?: () => void;
|
||||
onDisconnect?: () => void;
|
||||
onError?: (error: Event) => void;
|
||||
enabled?: boolean; // 是否启用WebSocket连接
|
||||
}
|
||||
|
||||
export function useWebSocket(options: UseWebSocketOptions = {}) {
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [connectionStatus, setConnectionStatus] = useState<'connecting' | 'connected' | 'disconnected'>('disconnected');
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const keepAliveIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const maxReconnectAttempts = 5;
|
||||
const isConnectingRef = useRef(false); // 添加连接状态标志,防止重复连接
|
||||
const optionsRef = useRef(options); // 使用 ref 存储 options,避免依赖项问题
|
||||
|
||||
// 为每个 useWebSocket 实例创建唯一标识符
|
||||
const instanceIdRef = useRef<string>('');
|
||||
if (!instanceIdRef.current) {
|
||||
globalConnectionCount++;
|
||||
instanceIdRef.current = `ws-${globalConnectionCount}-${Date.now()}`;
|
||||
console.log(`🔌 创建 WebSocket 实例: ${instanceIdRef.current}`);
|
||||
}
|
||||
|
||||
// 更新 options ref
|
||||
useEffect(() => {
|
||||
optionsRef.current = options;
|
||||
}, [options]);
|
||||
|
||||
// 获取WebSocket URL
|
||||
const getWebSocketUrl = () => {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const hostname = window.location.hostname;
|
||||
|
||||
// 在生产环境中,WebSocket运行在不同的端口
|
||||
// 可以通过环境变量或配置来设置
|
||||
let wsPort = '3001'; // 默认WebSocket端口
|
||||
|
||||
// 如果在开发环境,WebSocket运行在3001端口
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return `${protocol}//${hostname}:3001/ws?_=${Date.now()}`;
|
||||
}
|
||||
|
||||
// 生产环境,使用独立的WebSocket端口
|
||||
// 如果通过反向代理,可能需要特殊的路径
|
||||
if (window.location.port && window.location.port !== '80' && window.location.port !== '443') {
|
||||
// 本地测试环境
|
||||
return `${protocol}//${hostname}:${wsPort}/ws?_=${Date.now()}`;
|
||||
} else {
|
||||
// 生产环境,可能通过nginx反向代理
|
||||
// 如果使用反向代理,通常会将WebSocket映射到特定路径
|
||||
// 例如: /ws -> localhost:3001
|
||||
return `${protocol}//${hostname}/ws-api?_=${Date.now()}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 连接WebSocket
|
||||
const connect = useCallback(() => {
|
||||
// 防止重复连接
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN || isConnectingRef.current) {
|
||||
console.log('🚫 防止重复连接 - 当前状态:', {
|
||||
readyState: wsRef.current?.readyState,
|
||||
isConnecting: isConnectingRef.current,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 清理之前的定时器
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current);
|
||||
keepAliveIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// 关闭任何现有连接
|
||||
if (wsRef.current) {
|
||||
try {
|
||||
wsRef.current.close();
|
||||
} catch (e) {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
}
|
||||
|
||||
isConnectingRef.current = true;
|
||||
setConnectionStatus('connecting');
|
||||
|
||||
const wsUrl = getWebSocketUrl();
|
||||
|
||||
try {
|
||||
console.log(`🔄 [${instanceIdRef.current}] 正在连接 WebSocket:`, wsUrl);
|
||||
wsRef.current = new WebSocket(wsUrl);
|
||||
|
||||
// 设置超时处理
|
||||
const connectionTimeout = setTimeout(() => {
|
||||
if (wsRef.current && wsRef.current.readyState !== WebSocket.OPEN) {
|
||||
console.warn('WebSocket 连接超时,正在关闭...');
|
||||
wsRef.current.close();
|
||||
}
|
||||
}, 10000); // 10秒超时
|
||||
|
||||
wsRef.current.onopen = () => {
|
||||
clearTimeout(connectionTimeout);
|
||||
isConnectingRef.current = false; // 重置连接标志
|
||||
|
||||
console.log(`✅ [${instanceIdRef.current}] WebSocket 连接成功:`, wsUrl);
|
||||
setIsConnected(true);
|
||||
setConnectionStatus('connected');
|
||||
reconnectAttemptsRef.current = 0;
|
||||
|
||||
// 发送用户连接消息
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
if (authInfo && authInfo.username) {
|
||||
sendMessage({
|
||||
type: 'user_connect',
|
||||
data: { userId: authInfo.username },
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
console.log(`📤 [${instanceIdRef.current}] 已发送用户连接消息:`, authInfo.username);
|
||||
}
|
||||
|
||||
// 清理之前的保持活动定时器(如果存在)
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current);
|
||||
}
|
||||
|
||||
// 设置保持活动的定期消息
|
||||
keepAliveIntervalRef.current = setInterval(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'ping', timestamp: Date.now() }));
|
||||
// console.log('已发送保持活动消息');
|
||||
} else {
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current);
|
||||
keepAliveIntervalRef.current = null;
|
||||
}
|
||||
}
|
||||
}, 25000); // 每25秒发送一次
|
||||
|
||||
optionsRef.current.onConnect?.();
|
||||
};
|
||||
|
||||
wsRef.current.onmessage = (event) => {
|
||||
try {
|
||||
const message: WebSocketMessage = JSON.parse(event.data);
|
||||
console.log('收到 WebSocket 消息:', message);
|
||||
optionsRef.current.onMessage?.(message);
|
||||
} catch (error) {
|
||||
console.error('解析 WebSocket 消息错误:', error);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current.onclose = (event) => {
|
||||
console.log(`❌ [${instanceIdRef.current}] WebSocket 断开连接:`, event.code, event.reason);
|
||||
isConnectingRef.current = false; // 重置连接标志
|
||||
setIsConnected(false);
|
||||
setConnectionStatus('disconnected');
|
||||
|
||||
// 清理保持活动定时器
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current);
|
||||
keepAliveIntervalRef.current = null;
|
||||
}
|
||||
|
||||
// 关闭代码含义解释
|
||||
let closeReason = '';
|
||||
switch (event.code) {
|
||||
case 1000:
|
||||
closeReason = '正常关闭';
|
||||
break;
|
||||
case 1001:
|
||||
closeReason = '离开页面';
|
||||
break;
|
||||
case 1002:
|
||||
closeReason = '协议错误';
|
||||
break;
|
||||
case 1003:
|
||||
closeReason = '不支持的数据类型';
|
||||
break;
|
||||
case 1005:
|
||||
closeReason = '未提供关闭代码';
|
||||
break;
|
||||
case 1006:
|
||||
closeReason = '异常关闭'; // 通常表示连接突然中断
|
||||
break;
|
||||
case 1007:
|
||||
closeReason = '无效的数据';
|
||||
break;
|
||||
case 1008:
|
||||
closeReason = '违反策略';
|
||||
break;
|
||||
case 1009:
|
||||
closeReason = '消息过大';
|
||||
break;
|
||||
case 1010:
|
||||
closeReason = '客户端要求扩展';
|
||||
break;
|
||||
case 1011:
|
||||
closeReason = '服务器内部错误';
|
||||
break;
|
||||
case 1012:
|
||||
closeReason = '服务重启';
|
||||
break;
|
||||
case 1013:
|
||||
closeReason = '服务器临时问题';
|
||||
break;
|
||||
case 1015:
|
||||
closeReason = 'TLS握手失败';
|
||||
break;
|
||||
default:
|
||||
closeReason = '未知原因';
|
||||
}
|
||||
|
||||
console.log(`WebSocket 关闭原因: ${closeReason}`);
|
||||
optionsRef.current.onDisconnect?.();
|
||||
|
||||
// 自动重连(除非是正常关闭)
|
||||
if (event.code !== 1000 && reconnectAttemptsRef.current < maxReconnectAttempts) {
|
||||
// 增加最小延迟时间,避免太频繁的重连
|
||||
const baseDelay = 2000; // 最小2秒
|
||||
const delay = Math.max(baseDelay, Math.min(Math.pow(2, reconnectAttemptsRef.current) * 1000, 30000)); // 指数退避,最少2秒,最多30秒
|
||||
console.log(`准备重新连接,等待 ${delay / 1000} 秒... (尝试 ${reconnectAttemptsRef.current + 1}/${maxReconnectAttempts})`);
|
||||
|
||||
// 清除之前的重连定时器
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
reconnectAttemptsRef.current++;
|
||||
console.log(`正在尝试重新连接... (尝试 ${reconnectAttemptsRef.current}/${maxReconnectAttempts})`);
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
};
|
||||
|
||||
wsRef.current.onerror = (error) => {
|
||||
console.error('WebSocket 错误:', error);
|
||||
isConnectingRef.current = false; // 重置连接标志
|
||||
optionsRef.current.onError?.(error);
|
||||
setConnectionStatus('disconnected');
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`❌ [${instanceIdRef.current}] 创建 WebSocket 连接失败:`, error);
|
||||
isConnectingRef.current = false; // 重置连接标志
|
||||
setConnectionStatus('disconnected');
|
||||
|
||||
// 如果是在开发环境,给出更友好的错误提示
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('💡 开发环境WebSocket连接失败,请检查:');
|
||||
console.log(' 1. WebSocket服务器是否已启动 (pnpm dev:ws)');
|
||||
console.log(' 2. 端口3001是否被占用');
|
||||
console.log(' 3. 防火墙是否阻止连接');
|
||||
}
|
||||
}
|
||||
}, []); // 空依赖项数组,因为我们使用 optionsRef 避免了依赖问题
|
||||
|
||||
// 断开连接
|
||||
const disconnect = () => {
|
||||
console.log(`🔌 [${instanceIdRef.current}] 执行断开连接`);
|
||||
|
||||
// 重置连接状态标志
|
||||
isConnectingRef.current = false;
|
||||
|
||||
// 清除所有计时器
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (keepAliveIntervalRef.current) {
|
||||
clearInterval(keepAliveIntervalRef.current);
|
||||
keepAliveIntervalRef.current = null;
|
||||
}
|
||||
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close(1000, 'User disconnected');
|
||||
wsRef.current = null;
|
||||
}
|
||||
|
||||
setIsConnected(false);
|
||||
setConnectionStatus('disconnected');
|
||||
};
|
||||
|
||||
// 发送消息
|
||||
const sendMessage = (message: WebSocketMessage) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(message));
|
||||
console.log('通过 WebSocket 发送消息:', message);
|
||||
return true;
|
||||
} else {
|
||||
console.warn('WebSocket 未连接,无法发送消息:', message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// 监听enabled状态变化,动态连接或断开
|
||||
useEffect(() => {
|
||||
const enabled = options.enabled ?? true; // 默认启用
|
||||
|
||||
if (enabled) {
|
||||
console.log(`🎯 [${instanceIdRef.current}] WebSocket 已启用,开始连接`);
|
||||
connect();
|
||||
} else {
|
||||
console.log(`⏸️ [${instanceIdRef.current}] WebSocket 已禁用,断开现有连接`);
|
||||
disconnect();
|
||||
}
|
||||
|
||||
return () => {
|
||||
console.log(`🧹 [${instanceIdRef.current}] WebSocket effect 清理,断开连接`);
|
||||
disconnect();
|
||||
};
|
||||
}, [options.enabled, connect]); // 监听 enabled 状态变化
|
||||
|
||||
return {
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
sendMessage,
|
||||
connect,
|
||||
disconnect,
|
||||
};
|
||||
}
|
||||
362
src/lib/db.ts
362
src/lib/db.ts
|
|
@ -3,7 +3,7 @@
|
|||
import { AdminConfig } from './admin.types';
|
||||
import { KvrocksStorage } from './kvrocks.db';
|
||||
import { RedisStorage } from './redis.db';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig, ChatMessage, Conversation, Friend, FriendRequest } from './types';
|
||||
import { UpstashRedisStorage } from './upstash.db';
|
||||
|
||||
// storage type 常量: 'localstorage' | 'redis' | 'upstash',默认 'localstorage'
|
||||
|
|
@ -15,6 +15,249 @@ const STORAGE_TYPE =
|
|||
| 'kvrocks'
|
||||
| undefined) || 'localstorage';
|
||||
|
||||
// 简化的内存存储实现(用于localstorage模式)
|
||||
class MemoryStorage implements IStorage {
|
||||
private data: { [key: string]: any } = {};
|
||||
|
||||
// 聊天相关方法的基本实现
|
||||
async saveMessage(message: ChatMessage): Promise<void> {
|
||||
const key = `message:${message.id}`;
|
||||
this.data[key] = message;
|
||||
|
||||
// 更新对话的消息列表
|
||||
const messagesKey = `conversation_messages:${message.conversation_id}`;
|
||||
if (!this.data[messagesKey]) {
|
||||
this.data[messagesKey] = [];
|
||||
}
|
||||
this.data[messagesKey].push(message.id);
|
||||
}
|
||||
|
||||
async getMessages(conversationId: string, limit = 50, offset = 0): Promise<ChatMessage[]> {
|
||||
const messagesKey = `conversation_messages:${conversationId}`;
|
||||
const messageIds = this.data[messagesKey] || [];
|
||||
|
||||
// 获取消息并按时间排序
|
||||
const messages: ChatMessage[] = [];
|
||||
for (const messageId of messageIds) {
|
||||
const message = this.data[`message:${messageId}`];
|
||||
if (message) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
messages.sort((a, b) => a.timestamp - b.timestamp);
|
||||
return messages.slice(offset, offset + limit);
|
||||
}
|
||||
|
||||
async markMessageAsRead(messageId: string): Promise<void> {
|
||||
const key = `message:${messageId}`;
|
||||
if (this.data[key]) {
|
||||
this.data[key].is_read = true;
|
||||
}
|
||||
}
|
||||
|
||||
async getConversations(userName: string): Promise<Conversation[]> {
|
||||
const userConversationsKey = `user_conversations:${userName}`;
|
||||
const conversationIds = this.data[userConversationsKey] || [];
|
||||
|
||||
const conversations: Conversation[] = [];
|
||||
for (const conversationId of conversationIds) {
|
||||
const conversation = await this.getConversation(conversationId);
|
||||
if (conversation) {
|
||||
conversations.push(conversation);
|
||||
}
|
||||
}
|
||||
|
||||
return conversations.sort((a, b) => b.updated_at - a.updated_at);
|
||||
}
|
||||
|
||||
async getConversation(conversationId: string): Promise<Conversation | null> {
|
||||
const key = `conversation:${conversationId}`;
|
||||
return this.data[key] || null;
|
||||
}
|
||||
|
||||
async createConversation(conversation: Conversation): Promise<void> {
|
||||
const key = `conversation:${conversation.id}`;
|
||||
this.data[key] = conversation;
|
||||
|
||||
// 添加到每个参与者的对话列表
|
||||
for (const participant of conversation.participants) {
|
||||
const userConversationsKey = `user_conversations:${participant}`;
|
||||
if (!this.data[userConversationsKey]) {
|
||||
this.data[userConversationsKey] = [];
|
||||
}
|
||||
if (!this.data[userConversationsKey].includes(conversation.id)) {
|
||||
this.data[userConversationsKey].push(conversation.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateConversation(conversationId: string, updates: Partial<Conversation>): Promise<void> {
|
||||
const key = `conversation:${conversationId}`;
|
||||
if (this.data[key]) {
|
||||
Object.assign(this.data[key], updates);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
const conversation = await this.getConversation(conversationId);
|
||||
if (conversation) {
|
||||
// 从每个参与者的对话列表中移除
|
||||
for (const participant of conversation.participants) {
|
||||
const userConversationsKey = `user_conversations:${participant}`;
|
||||
if (this.data[userConversationsKey]) {
|
||||
this.data[userConversationsKey] = this.data[userConversationsKey].filter(
|
||||
(id: string) => id !== conversationId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除对话本身
|
||||
delete this.data[`conversation:${conversationId}`];
|
||||
|
||||
// 删除相关消息
|
||||
const messagesKey = `conversation_messages:${conversationId}`;
|
||||
const messageIds = this.data[messagesKey] || [];
|
||||
for (const messageId of messageIds) {
|
||||
delete this.data[`message:${messageId}`];
|
||||
}
|
||||
delete this.data[messagesKey];
|
||||
}
|
||||
}
|
||||
|
||||
// 好友相关方法的基本实现
|
||||
async getFriends(userName: string): Promise<Friend[]> {
|
||||
const key = `user_friends:${userName}`;
|
||||
return this.data[key] || [];
|
||||
}
|
||||
|
||||
async createFriend(friendship: { user1: string; user2: string; created_at: number }): Promise<void> {
|
||||
// 双向添加好友关系
|
||||
const user1FriendsKey = `user_friends:${friendship.user1}`;
|
||||
const user2FriendsKey = `user_friends:${friendship.user2}`;
|
||||
|
||||
if (!this.data[user1FriendsKey]) this.data[user1FriendsKey] = [];
|
||||
if (!this.data[user2FriendsKey]) this.data[user2FriendsKey] = [];
|
||||
|
||||
// 为user1添加user2作为好友
|
||||
if (!this.data[user1FriendsKey].some((f: Friend) => f.username === friendship.user2)) {
|
||||
this.data[user1FriendsKey].push({
|
||||
id: `friend_${Date.now()}_1`,
|
||||
username: friendship.user2,
|
||||
nickname: friendship.user2,
|
||||
status: 'offline' as const,
|
||||
added_at: friendship.created_at
|
||||
});
|
||||
}
|
||||
|
||||
// 为user2添加user1作为好友
|
||||
if (!this.data[user2FriendsKey].some((f: Friend) => f.username === friendship.user1)) {
|
||||
this.data[user2FriendsKey].push({
|
||||
id: `friend_${Date.now()}_2`,
|
||||
username: friendship.user1,
|
||||
nickname: friendship.user1,
|
||||
status: 'offline' as const,
|
||||
added_at: friendship.created_at
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFriend(friendId: string): Promise<void> {
|
||||
// 简化实现
|
||||
}
|
||||
|
||||
async getFriendRequests(userName: string): Promise<FriendRequest[]> {
|
||||
const key = `user_friend_requests:${userName}`;
|
||||
return this.data[key] || [];
|
||||
}
|
||||
|
||||
async createFriendRequest(request: FriendRequest): Promise<void> {
|
||||
const key = `user_friend_requests:${request.to_user}`;
|
||||
if (!this.data[key]) {
|
||||
this.data[key] = [];
|
||||
}
|
||||
this.data[key].push(request);
|
||||
}
|
||||
|
||||
async updateFriendRequest(requestId: string, status: 'pending' | 'accepted' | 'rejected'): Promise<void> {
|
||||
// 查找并更新好友请求
|
||||
for (const key in this.data) {
|
||||
if (key.startsWith('user_friend_requests:')) {
|
||||
const requests = this.data[key];
|
||||
const requestIndex = requests.findIndex((r: FriendRequest) => r.id === requestId);
|
||||
if (requestIndex !== -1) {
|
||||
requests[requestIndex].status = status;
|
||||
requests[requestIndex].updated_at = Date.now();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFriendRequest(requestId: string): Promise<void> {
|
||||
// 查找并删除好友请求
|
||||
for (const key in this.data) {
|
||||
if (key.startsWith('user_friend_requests:')) {
|
||||
const requests = this.data[key];
|
||||
const requestIndex = requests.findIndex((r: FriendRequest) => r.id === requestId);
|
||||
if (requestIndex !== -1) {
|
||||
requests.splice(requestIndex, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索用户(基本实现)
|
||||
async searchUsers(query: string): Promise<Friend[]> {
|
||||
// 返回一些模拟用户用于测试
|
||||
const mockUsers: Friend[] = [
|
||||
{ id: 'user1', username: 'test1', nickname: 'Test User 1', status: 'offline' as const, added_at: Date.now() },
|
||||
{ id: 'user2', username: 'test2', nickname: 'Test User 2', status: 'offline' as const, added_at: Date.now() },
|
||||
{ id: 'user3', username: 'admin', nickname: 'Admin User', status: 'offline' as const, added_at: Date.now() },
|
||||
];
|
||||
|
||||
return mockUsers.filter(user =>
|
||||
user.username.toLowerCase().includes(query.toLowerCase()) ||
|
||||
user.nickname?.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// 其他必需的方法存根
|
||||
async getPlayRecord(): Promise<PlayRecord | null> { return null; }
|
||||
async setPlayRecord(): Promise<void> { }
|
||||
async getAllPlayRecords(): Promise<{ [key: string]: PlayRecord }> { return {}; }
|
||||
async deletePlayRecord(): Promise<void> { }
|
||||
async getFavorite(): Promise<Favorite | null> { return null; }
|
||||
async setFavorite(): Promise<void> { }
|
||||
async getAllFavorites(): Promise<{ [key: string]: Favorite }> { return {}; }
|
||||
async deleteFavorite(): Promise<void> { }
|
||||
async registerUser(): Promise<void> { }
|
||||
async verifyUser(): Promise<boolean> { return true; }
|
||||
async checkUser(): Promise<boolean> { return true; }
|
||||
async checkUserExist(): Promise<boolean> { return true; }
|
||||
async changePassword(): Promise<void> { }
|
||||
async deleteUser(): Promise<void> { }
|
||||
async getSearchHistory(): Promise<string[]> { return []; }
|
||||
async addSearchHistory(): Promise<void> { }
|
||||
async deleteSearchHistory(): Promise<void> { }
|
||||
async clearSearchHistory(): Promise<void> { }
|
||||
async getSearchHistoryCount(): Promise<number> { return 0; }
|
||||
async getSkipConfigs(): Promise<SkipConfig[]> { return []; }
|
||||
async getSkipConfig(): Promise<SkipConfig | null> { return null; }
|
||||
async setSkipConfig(): Promise<void> { }
|
||||
async deleteSkipConfig(): Promise<void> { }
|
||||
async getAdminConfig(): Promise<AdminConfig> { return {} as AdminConfig; }
|
||||
async setAdminConfig(): Promise<void> { }
|
||||
async getAllUsers(): Promise<string[]> { return []; }
|
||||
async getAllSkipConfigs(): Promise<{ [key: string]: SkipConfig }> { return {}; }
|
||||
async clearAllData(): Promise<void> { this.data = {}; }
|
||||
async addFriend(): Promise<void> { }
|
||||
async removeFriend(): Promise<void> { }
|
||||
async updateFriend(): Promise<void> { }
|
||||
async updateFriendStatus(): Promise<void> { }
|
||||
}
|
||||
|
||||
// 创建存储实例
|
||||
function createStorage(): IStorage {
|
||||
switch (STORAGE_TYPE) {
|
||||
|
|
@ -26,7 +269,8 @@ function createStorage(): IStorage {
|
|||
return new KvrocksStorage();
|
||||
case 'localstorage':
|
||||
default:
|
||||
return null as unknown as IStorage;
|
||||
console.log('使用内存存储模式(用于开发和测试)');
|
||||
return new MemoryStorage();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -311,6 +555,120 @@ export class DbManager {
|
|||
return null;
|
||||
}
|
||||
|
||||
// ---------- 聊天功能 ----------
|
||||
// 消息管理
|
||||
async saveMessage(message: ChatMessage): Promise<void> {
|
||||
if (typeof (this.storage as any).saveMessage === 'function') {
|
||||
await (this.storage as any).saveMessage(message);
|
||||
}
|
||||
}
|
||||
|
||||
async getMessages(conversationId: string, limit?: number, offset?: number): Promise<ChatMessage[]> {
|
||||
if (typeof (this.storage as any).getMessages === 'function') {
|
||||
return (this.storage as any).getMessages(conversationId, limit, offset);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async markMessageAsRead(messageId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).markMessageAsRead === 'function') {
|
||||
await (this.storage as any).markMessageAsRead(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
// 对话管理
|
||||
async getConversations(userName: string): Promise<Conversation[]> {
|
||||
if (typeof (this.storage as any).getConversations === 'function') {
|
||||
return (this.storage as any).getConversations(userName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async getConversation(conversationId: string): Promise<Conversation | null> {
|
||||
if (typeof (this.storage as any).getConversation === 'function') {
|
||||
return (this.storage as any).getConversation(conversationId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async createConversation(conversation: Conversation): Promise<void> {
|
||||
if (typeof (this.storage as any).createConversation === 'function') {
|
||||
await (this.storage as any).createConversation(conversation);
|
||||
}
|
||||
}
|
||||
|
||||
async updateConversation(conversationId: string, updates: Partial<Conversation>): Promise<void> {
|
||||
if (typeof (this.storage as any).updateConversation === 'function') {
|
||||
await (this.storage as any).updateConversation(conversationId, updates);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteConversation === 'function') {
|
||||
await (this.storage as any).deleteConversation(conversationId);
|
||||
}
|
||||
}
|
||||
|
||||
// 好友管理
|
||||
async getFriends(userName: string): Promise<Friend[]> {
|
||||
if (typeof (this.storage as any).getFriends === 'function') {
|
||||
return (this.storage as any).getFriends(userName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async addFriend(userName: string, friend: Friend): Promise<void> {
|
||||
if (typeof (this.storage as any).addFriend === 'function') {
|
||||
await (this.storage as any).addFriend(userName, friend);
|
||||
}
|
||||
}
|
||||
|
||||
async removeFriend(userName: string, friendId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).removeFriend === 'function') {
|
||||
await (this.storage as any).removeFriend(userName, friendId);
|
||||
}
|
||||
}
|
||||
|
||||
async updateFriendStatus(friendId: string, status: Friend['status']): Promise<void> {
|
||||
if (typeof (this.storage as any).updateFriendStatus === 'function') {
|
||||
await (this.storage as any).updateFriendStatus(friendId, status);
|
||||
}
|
||||
}
|
||||
|
||||
// 好友申请管理
|
||||
async getFriendRequests(userName: string): Promise<FriendRequest[]> {
|
||||
if (typeof (this.storage as any).getFriendRequests === 'function') {
|
||||
return (this.storage as any).getFriendRequests(userName);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async createFriendRequest(request: FriendRequest): Promise<void> {
|
||||
if (typeof (this.storage as any).createFriendRequest === 'function') {
|
||||
await (this.storage as any).createFriendRequest(request);
|
||||
}
|
||||
}
|
||||
|
||||
async updateFriendRequest(requestId: string, status: FriendRequest['status']): Promise<void> {
|
||||
if (typeof (this.storage as any).updateFriendRequest === 'function') {
|
||||
await (this.storage as any).updateFriendRequest(requestId, status);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFriendRequest(requestId: string): Promise<void> {
|
||||
if (typeof (this.storage as any).deleteFriendRequest === 'function') {
|
||||
await (this.storage as any).deleteFriendRequest(requestId);
|
||||
}
|
||||
}
|
||||
|
||||
// 用户搜索
|
||||
async searchUsers(query: string): Promise<Friend[]> {
|
||||
if (typeof (this.storage as any).searchUsers === 'function') {
|
||||
return (this.storage as any).searchUsers(query);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ---------- 数据清理 ----------
|
||||
async clearAllData(): Promise<void> {
|
||||
if (typeof (this.storage as any).clearAllData === 'function') {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { createClient, RedisClientType } from 'redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig, ChatMessage, Conversation, Friend, FriendRequest } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
|
|
@ -599,6 +599,319 @@ export abstract class BaseRedisStorage implements IStorage {
|
|||
return val ? ensureString(val) : null;
|
||||
}
|
||||
|
||||
// ---------- 聊天功能 ----------
|
||||
// 私有键生成方法
|
||||
private messageKey(messageId: string) {
|
||||
return `msg:${messageId}`;
|
||||
}
|
||||
|
||||
private conversationKey(conversationId: string) {
|
||||
return `conv:${conversationId}`;
|
||||
}
|
||||
|
||||
private conversationMessagesKey(conversationId: string) {
|
||||
return `conv:${conversationId}:messages`;
|
||||
}
|
||||
|
||||
private userConversationsKey(userName: string) {
|
||||
return `u:${userName}:conversations`;
|
||||
}
|
||||
|
||||
private userFriendsKey(userName: string) {
|
||||
return `u:${userName}:friends`;
|
||||
}
|
||||
|
||||
private userFriendRequestsKey(userName: string) {
|
||||
return `u:${userName}:friend_requests`;
|
||||
}
|
||||
|
||||
private friendKey(friendId: string) {
|
||||
return `friend:${friendId}`;
|
||||
}
|
||||
|
||||
private friendRequestKey(requestId: string) {
|
||||
return `friend_req:${requestId}`;
|
||||
}
|
||||
|
||||
// 消息管理
|
||||
async saveMessage(message: ChatMessage): Promise<void> {
|
||||
// 保存消息详情
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.messageKey(message.id), JSON.stringify(message))
|
||||
);
|
||||
|
||||
// 将消息ID添加到对话的消息列表中(按时间排序)
|
||||
await this.withRetry(() =>
|
||||
this.client.zAdd(this.conversationMessagesKey(message.conversation_id), {
|
||||
score: message.timestamp,
|
||||
value: message.id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async getMessages(conversationId: string, limit = 50, offset = 0): Promise<ChatMessage[]> {
|
||||
// 从有序集合中获取消息ID列表(按时间倒序)
|
||||
const messageIds = await this.withRetry(() =>
|
||||
this.client.zRange(this.conversationMessagesKey(conversationId), offset, offset + limit - 1, { REV: true })
|
||||
);
|
||||
|
||||
const messages: ChatMessage[] = [];
|
||||
for (const messageId of messageIds) {
|
||||
const messageData = await this.withRetry(() => this.client.get(this.messageKey(messageId)));
|
||||
if (messageData) {
|
||||
try {
|
||||
messages.push(JSON.parse(ensureString(messageData)));
|
||||
} catch (error) {
|
||||
console.error('Error parsing message:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages.reverse(); // 返回正序消息
|
||||
}
|
||||
|
||||
async markMessageAsRead(messageId: string): Promise<void> {
|
||||
const messageData = await this.withRetry(() => this.client.get(this.messageKey(messageId)));
|
||||
if (messageData) {
|
||||
try {
|
||||
const message = JSON.parse(ensureString(messageData));
|
||||
message.is_read = true;
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.messageKey(messageId), JSON.stringify(message))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error marking message as read:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 对话管理
|
||||
async getConversations(userName: string): Promise<Conversation[]> {
|
||||
const conversationIds = await this.withRetry(() =>
|
||||
this.client.sMembers(this.userConversationsKey(userName))
|
||||
);
|
||||
|
||||
const conversations: Conversation[] = [];
|
||||
for (const conversationId of conversationIds) {
|
||||
const conversation = await this.getConversation(conversationId);
|
||||
if (conversation) {
|
||||
conversations.push(conversation);
|
||||
}
|
||||
}
|
||||
|
||||
// 按最后更新时间排序
|
||||
return conversations.sort((a, b) => b.updated_at - a.updated_at);
|
||||
}
|
||||
|
||||
async getConversation(conversationId: string): Promise<Conversation | null> {
|
||||
const conversationData = await this.withRetry(() =>
|
||||
this.client.get(this.conversationKey(conversationId))
|
||||
);
|
||||
|
||||
if (!conversationData) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(ensureString(conversationData));
|
||||
} catch (error) {
|
||||
console.error('Error parsing conversation:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async createConversation(conversation: Conversation): Promise<void> {
|
||||
// 保存对话详情
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.conversationKey(conversation.id), JSON.stringify(conversation))
|
||||
);
|
||||
|
||||
// 将对话ID添加到每个参与者的对话列表中
|
||||
for (const participant of conversation.participants) {
|
||||
await this.withRetry(() =>
|
||||
this.client.sAdd(this.userConversationsKey(participant), conversation.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async updateConversation(conversationId: string, updates: Partial<Conversation>): Promise<void> {
|
||||
const conversation = await this.getConversation(conversationId);
|
||||
if (conversation) {
|
||||
Object.assign(conversation, updates);
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.conversationKey(conversationId), JSON.stringify(conversation))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
const conversation = await this.getConversation(conversationId);
|
||||
if (conversation) {
|
||||
// 从每个参与者的对话列表中移除
|
||||
for (const participant of conversation.participants) {
|
||||
await this.withRetry(() =>
|
||||
this.client.sRem(this.userConversationsKey(participant), conversationId)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除对话详情
|
||||
await this.withRetry(() => this.client.del(this.conversationKey(conversationId)));
|
||||
|
||||
// 删除对话的消息列表
|
||||
await this.withRetry(() => this.client.del(this.conversationMessagesKey(conversationId)));
|
||||
}
|
||||
}
|
||||
|
||||
// 好友管理
|
||||
async getFriends(userName: string): Promise<Friend[]> {
|
||||
const friendIds = await this.withRetry(() =>
|
||||
this.client.sMembers(this.userFriendsKey(userName))
|
||||
);
|
||||
|
||||
const friends: Friend[] = [];
|
||||
for (const friendId of friendIds) {
|
||||
const friendData = await this.withRetry(() => this.client.get(this.friendKey(friendId)));
|
||||
if (friendData) {
|
||||
try {
|
||||
friends.push(JSON.parse(ensureString(friendData)));
|
||||
} catch (error) {
|
||||
console.error('Error parsing friend:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return friends.sort((a, b) => b.added_at - a.added_at);
|
||||
}
|
||||
|
||||
async addFriend(userName: string, friend: Friend): Promise<void> {
|
||||
// 保存好友详情
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.friendKey(friend.id), JSON.stringify(friend))
|
||||
);
|
||||
|
||||
// 将好友ID添加到用户的好友列表中
|
||||
await this.withRetry(() =>
|
||||
this.client.sAdd(this.userFriendsKey(userName), friend.id)
|
||||
);
|
||||
}
|
||||
|
||||
async removeFriend(userName: string, friendId: string): Promise<void> {
|
||||
// 从用户的好友列表中移除
|
||||
await this.withRetry(() =>
|
||||
this.client.sRem(this.userFriendsKey(userName), friendId)
|
||||
);
|
||||
|
||||
// 删除好友详情
|
||||
await this.withRetry(() => this.client.del(this.friendKey(friendId)));
|
||||
}
|
||||
|
||||
async updateFriendStatus(friendId: string, status: Friend['status']): Promise<void> {
|
||||
const friendData = await this.withRetry(() => this.client.get(this.friendKey(friendId)));
|
||||
if (friendData) {
|
||||
try {
|
||||
const friend = JSON.parse(ensureString(friendData));
|
||||
friend.status = status;
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.friendKey(friendId), JSON.stringify(friend))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating friend status:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 好友申请管理
|
||||
async getFriendRequests(userName: string): Promise<FriendRequest[]> {
|
||||
const requestIds = await this.withRetry(() =>
|
||||
this.client.sMembers(this.userFriendRequestsKey(userName))
|
||||
);
|
||||
|
||||
const requests: FriendRequest[] = [];
|
||||
for (const requestId of requestIds) {
|
||||
const requestData = await this.withRetry(() => this.client.get(this.friendRequestKey(requestId)));
|
||||
if (requestData) {
|
||||
try {
|
||||
const request = JSON.parse(ensureString(requestData));
|
||||
// 只返回相关的申请(发送给该用户的或该用户发送的)
|
||||
if (request.to_user === userName || request.from_user === userName) {
|
||||
requests.push(request);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing friend request:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return requests.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
async createFriendRequest(request: FriendRequest): Promise<void> {
|
||||
// 保存申请详情
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.friendRequestKey(request.id), JSON.stringify(request))
|
||||
);
|
||||
|
||||
// 将申请ID添加到双方的申请列表中
|
||||
await this.withRetry(() =>
|
||||
this.client.sAdd(this.userFriendRequestsKey(request.from_user), request.id)
|
||||
);
|
||||
await this.withRetry(() =>
|
||||
this.client.sAdd(this.userFriendRequestsKey(request.to_user), request.id)
|
||||
);
|
||||
}
|
||||
|
||||
async updateFriendRequest(requestId: string, status: FriendRequest['status']): Promise<void> {
|
||||
const requestData = await this.withRetry(() => this.client.get(this.friendRequestKey(requestId)));
|
||||
if (requestData) {
|
||||
try {
|
||||
const request = JSON.parse(ensureString(requestData));
|
||||
request.status = status;
|
||||
request.updated_at = Date.now();
|
||||
await this.withRetry(() =>
|
||||
this.client.set(this.friendRequestKey(requestId), JSON.stringify(request))
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating friend request:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFriendRequest(requestId: string): Promise<void> {
|
||||
const requestData = await this.withRetry(() => this.client.get(this.friendRequestKey(requestId)));
|
||||
if (requestData) {
|
||||
try {
|
||||
const request = JSON.parse(ensureString(requestData));
|
||||
|
||||
// 从双方的申请列表中移除
|
||||
await this.withRetry(() =>
|
||||
this.client.sRem(this.userFriendRequestsKey(request.from_user), requestId)
|
||||
);
|
||||
await this.withRetry(() =>
|
||||
this.client.sRem(this.userFriendRequestsKey(request.to_user), requestId)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error deleting friend request:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除申请详情
|
||||
await this.withRetry(() => this.client.del(this.friendRequestKey(requestId)));
|
||||
}
|
||||
|
||||
// 用户搜索
|
||||
async searchUsers(query: string): Promise<Friend[]> {
|
||||
const allUsers = await this.getAllUsers();
|
||||
const matchedUsers = allUsers.filter(username =>
|
||||
username.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
// 转换为Friend格式返回
|
||||
return matchedUsers.map(username => ({
|
||||
id: username,
|
||||
username,
|
||||
status: 'offline' as const,
|
||||
added_at: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// 清空所有数据
|
||||
async clearAllData(): Promise<void> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -123,3 +123,54 @@ export interface SkipConfig {
|
|||
intro_time: number; // 片头时间(秒)
|
||||
outro_time: number; // 片尾时间(秒)
|
||||
}
|
||||
|
||||
// 聊天消息数据结构
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
sender_id: string;
|
||||
sender_name: string;
|
||||
content: string;
|
||||
message_type: 'text' | 'image' | 'file';
|
||||
timestamp: number;
|
||||
is_read: boolean;
|
||||
}
|
||||
|
||||
// 对话数据结构
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
name: string;
|
||||
participants: string[];
|
||||
type: 'private' | 'group';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
last_message?: ChatMessage;
|
||||
is_group?: boolean;
|
||||
}
|
||||
|
||||
// 好友数据结构
|
||||
export interface Friend {
|
||||
id: string;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
status: 'online' | 'offline';
|
||||
added_at: number;
|
||||
}
|
||||
|
||||
// 好友申请数据结构
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
from_user: string;
|
||||
to_user: string;
|
||||
message?: string;
|
||||
status: 'pending' | 'accepted' | 'rejected';
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
// WebSocket 消息类型
|
||||
export interface WebSocketMessage {
|
||||
type: 'message' | 'friend_request' | 'friend_accepted' | 'user_status' | 'online_users' | 'connection_confirmed' | 'user_connect' | 'ping' | 'pong';
|
||||
data?: any;
|
||||
timestamp: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { Redis } from '@upstash/redis';
|
||||
|
||||
import { AdminConfig } from './admin.types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig } from './types';
|
||||
import { Favorite, IStorage, PlayRecord, SkipConfig, ChatMessage, Conversation, Friend, FriendRequest } from './types';
|
||||
|
||||
// 搜索历史最大条数
|
||||
const SEARCH_HISTORY_LIMIT = 20;
|
||||
|
|
@ -500,6 +500,284 @@ export class UpstashRedisStorage implements IStorage {
|
|||
return val ? ensureString(val) : null;
|
||||
}
|
||||
|
||||
// ---------- 聊天功能 ----------
|
||||
// 私有键生成方法
|
||||
private messageKey(messageId: string) {
|
||||
return `msg:${messageId}`;
|
||||
}
|
||||
|
||||
private conversationKey(conversationId: string) {
|
||||
return `conv:${conversationId}`;
|
||||
}
|
||||
|
||||
private conversationMessagesKey(conversationId: string) {
|
||||
return `conv:${conversationId}:messages`;
|
||||
}
|
||||
|
||||
private userConversationsKey(userName: string) {
|
||||
return `u:${userName}:conversations`;
|
||||
}
|
||||
|
||||
private userFriendsKey(userName: string) {
|
||||
return `u:${userName}:friends`;
|
||||
}
|
||||
|
||||
private userFriendRequestsKey(userName: string) {
|
||||
return `u:${userName}:friend_requests`;
|
||||
}
|
||||
|
||||
private friendKey(friendId: string) {
|
||||
return `friend:${friendId}`;
|
||||
}
|
||||
|
||||
private friendRequestKey(requestId: string) {
|
||||
return `friend_req:${requestId}`;
|
||||
}
|
||||
|
||||
// 消息管理
|
||||
async saveMessage(message: ChatMessage): Promise<void> {
|
||||
// 保存消息详情
|
||||
await withRetry(() =>
|
||||
this.client.set(this.messageKey(message.id), message)
|
||||
);
|
||||
|
||||
// 将消息ID添加到对话的消息列表中(按时间排序)
|
||||
await withRetry(() =>
|
||||
this.client.zadd(this.conversationMessagesKey(message.conversation_id), {
|
||||
score: message.timestamp,
|
||||
member: message.id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async getMessages(conversationId: string, limit = 50, offset = 0): Promise<ChatMessage[]> {
|
||||
// 从有序集合中获取消息ID列表(按时间倒序)
|
||||
const messageIds = await withRetry(() =>
|
||||
this.client.zrange(this.conversationMessagesKey(conversationId), offset, offset + limit - 1, { rev: true })
|
||||
);
|
||||
|
||||
const messages: ChatMessage[] = [];
|
||||
for (const messageId of messageIds) {
|
||||
const messageData = await withRetry(() => this.client.get(this.messageKey(messageId as string)));
|
||||
if (messageData) {
|
||||
messages.push(messageData as ChatMessage);
|
||||
}
|
||||
}
|
||||
|
||||
return messages.reverse(); // 返回正序消息
|
||||
}
|
||||
|
||||
async markMessageAsRead(messageId: string): Promise<void> {
|
||||
const messageData = await withRetry(() => this.client.get(this.messageKey(messageId)));
|
||||
if (messageData) {
|
||||
const message = messageData as ChatMessage;
|
||||
message.is_read = true;
|
||||
await withRetry(() =>
|
||||
this.client.set(this.messageKey(messageId), message)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 对话管理
|
||||
async getConversations(userName: string): Promise<Conversation[]> {
|
||||
const conversationIds = await withRetry(() =>
|
||||
this.client.smembers(this.userConversationsKey(userName))
|
||||
);
|
||||
|
||||
const conversations: Conversation[] = [];
|
||||
for (const conversationId of conversationIds) {
|
||||
const conversation = await this.getConversation(conversationId);
|
||||
if (conversation) {
|
||||
conversations.push(conversation);
|
||||
}
|
||||
}
|
||||
|
||||
// 按最后更新时间排序
|
||||
return conversations.sort((a, b) => b.updated_at - a.updated_at);
|
||||
}
|
||||
|
||||
async getConversation(conversationId: string): Promise<Conversation | null> {
|
||||
const conversationData = await withRetry(() =>
|
||||
this.client.get(this.conversationKey(conversationId))
|
||||
);
|
||||
|
||||
return conversationData ? (conversationData as Conversation) : null;
|
||||
}
|
||||
|
||||
async createConversation(conversation: Conversation): Promise<void> {
|
||||
// 保存对话详情
|
||||
await withRetry(() =>
|
||||
this.client.set(this.conversationKey(conversation.id), conversation)
|
||||
);
|
||||
|
||||
// 将对话ID添加到每个参与者的对话列表中
|
||||
for (const participant of conversation.participants) {
|
||||
await withRetry(() =>
|
||||
this.client.sadd(this.userConversationsKey(participant), conversation.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async updateConversation(conversationId: string, updates: Partial<Conversation>): Promise<void> {
|
||||
const conversation = await this.getConversation(conversationId);
|
||||
if (conversation) {
|
||||
Object.assign(conversation, updates);
|
||||
await withRetry(() =>
|
||||
this.client.set(this.conversationKey(conversationId), conversation)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
const conversation = await this.getConversation(conversationId);
|
||||
if (conversation) {
|
||||
// 从每个参与者的对话列表中移除
|
||||
for (const participant of conversation.participants) {
|
||||
await withRetry(() =>
|
||||
this.client.srem(this.userConversationsKey(participant), conversationId)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除对话详情
|
||||
await withRetry(() => this.client.del(this.conversationKey(conversationId)));
|
||||
|
||||
// 删除对话的消息列表
|
||||
await withRetry(() => this.client.del(this.conversationMessagesKey(conversationId)));
|
||||
}
|
||||
}
|
||||
|
||||
// 好友管理
|
||||
async getFriends(userName: string): Promise<Friend[]> {
|
||||
const friendIds = await withRetry(() =>
|
||||
this.client.smembers(this.userFriendsKey(userName))
|
||||
);
|
||||
|
||||
const friends: Friend[] = [];
|
||||
for (const friendId of friendIds) {
|
||||
const friendData = await withRetry(() => this.client.get(this.friendKey(friendId)));
|
||||
if (friendData) {
|
||||
friends.push(friendData as Friend);
|
||||
}
|
||||
}
|
||||
|
||||
return friends.sort((a, b) => b.added_at - a.added_at);
|
||||
}
|
||||
|
||||
async addFriend(userName: string, friend: Friend): Promise<void> {
|
||||
// 保存好友详情
|
||||
await withRetry(() =>
|
||||
this.client.set(this.friendKey(friend.id), friend)
|
||||
);
|
||||
|
||||
// 将好友ID添加到用户的好友列表中
|
||||
await withRetry(() =>
|
||||
this.client.sadd(this.userFriendsKey(userName), friend.id)
|
||||
);
|
||||
}
|
||||
|
||||
async removeFriend(userName: string, friendId: string): Promise<void> {
|
||||
// 从用户的好友列表中移除
|
||||
await withRetry(() =>
|
||||
this.client.srem(this.userFriendsKey(userName), friendId)
|
||||
);
|
||||
|
||||
// 删除好友详情
|
||||
await withRetry(() => this.client.del(this.friendKey(friendId)));
|
||||
}
|
||||
|
||||
async updateFriendStatus(friendId: string, status: Friend['status']): Promise<void> {
|
||||
const friendData = await withRetry(() => this.client.get(this.friendKey(friendId)));
|
||||
if (friendData) {
|
||||
const friend = friendData as Friend;
|
||||
friend.status = status;
|
||||
await withRetry(() =>
|
||||
this.client.set(this.friendKey(friendId), friend)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 好友申请管理
|
||||
async getFriendRequests(userName: string): Promise<FriendRequest[]> {
|
||||
const requestIds = await withRetry(() =>
|
||||
this.client.smembers(this.userFriendRequestsKey(userName))
|
||||
);
|
||||
|
||||
const requests: FriendRequest[] = [];
|
||||
for (const requestId of requestIds) {
|
||||
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
|
||||
if (requestData) {
|
||||
const request = requestData as FriendRequest;
|
||||
// 只返回相关的申请(发送给该用户的或该用户发送的)
|
||||
if (request.to_user === userName || request.from_user === userName) {
|
||||
requests.push(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return requests.sort((a, b) => b.created_at - a.created_at);
|
||||
}
|
||||
|
||||
async createFriendRequest(request: FriendRequest): Promise<void> {
|
||||
// 保存申请详情
|
||||
await withRetry(() =>
|
||||
this.client.set(this.friendRequestKey(request.id), request)
|
||||
);
|
||||
|
||||
// 将申请ID添加到双方的申请列表中
|
||||
await withRetry(() =>
|
||||
this.client.sadd(this.userFriendRequestsKey(request.from_user), request.id)
|
||||
);
|
||||
await withRetry(() =>
|
||||
this.client.sadd(this.userFriendRequestsKey(request.to_user), request.id)
|
||||
);
|
||||
}
|
||||
|
||||
async updateFriendRequest(requestId: string, status: FriendRequest['status']): Promise<void> {
|
||||
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
|
||||
if (requestData) {
|
||||
const request = requestData as FriendRequest;
|
||||
request.status = status;
|
||||
request.updated_at = Date.now();
|
||||
await withRetry(() =>
|
||||
this.client.set(this.friendRequestKey(requestId), request)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteFriendRequest(requestId: string): Promise<void> {
|
||||
const requestData = await withRetry(() => this.client.get(this.friendRequestKey(requestId)));
|
||||
if (requestData) {
|
||||
const request = requestData as FriendRequest;
|
||||
|
||||
// 从双方的申请列表中移除
|
||||
await withRetry(() =>
|
||||
this.client.srem(this.userFriendRequestsKey(request.from_user), requestId)
|
||||
);
|
||||
await withRetry(() =>
|
||||
this.client.srem(this.userFriendRequestsKey(request.to_user), requestId)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除申请详情
|
||||
await withRetry(() => this.client.del(this.friendRequestKey(requestId)));
|
||||
}
|
||||
|
||||
// 用户搜索
|
||||
async searchUsers(query: string): Promise<Friend[]> {
|
||||
const allUsers = await this.getAllUsers();
|
||||
const matchedUsers = allUsers.filter(username =>
|
||||
username.toLowerCase().includes(query.toLowerCase())
|
||||
);
|
||||
|
||||
// 转换为Friend格式返回
|
||||
return matchedUsers.map(username => ({
|
||||
id: username,
|
||||
username,
|
||||
status: 'offline' as const,
|
||||
added_at: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
// 清空所有数据
|
||||
async clearAllData(): Promise<void> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -115,4 +115,41 @@
|
|||
.animated-underline:focus-visible {
|
||||
background-size: 0 2px, 100% 2px;
|
||||
}
|
||||
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar {
|
||||
scrollbar-color: #64748b transparent;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #64748b;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #475569;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,237 @@
|
|||
/**
|
||||
* 独立的WebSocket服务器
|
||||
* 完全独立于Next.js,避免任何冲突
|
||||
*/
|
||||
const WebSocket = require('ws');
|
||||
|
||||
// 存储已连接的用户
|
||||
const connectedUsers = new Map();
|
||||
|
||||
// 创建独立的WebSocket服务器,使用不同的端口
|
||||
function createStandaloneWebSocketServer(port = 3001) {
|
||||
const wss = new WebSocket.Server({
|
||||
port: port,
|
||||
perMessageDeflate: false,
|
||||
clientTracking: true
|
||||
});
|
||||
|
||||
console.log(`独立WebSocket服务器已启动在端口 ${port}`);
|
||||
|
||||
// 连接事件处理
|
||||
wss.on('connection', (ws, req) => {
|
||||
console.log('新的 WebSocket 连接');
|
||||
let userId = null;
|
||||
|
||||
// 设置心跳检测
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
// 消息处理
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleMessage(ws, message);
|
||||
} catch (error) {
|
||||
console.error('解析 WebSocket 消息错误:', error);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
data: { message: '消息格式无效' },
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭连接处理
|
||||
ws.on('close', () => {
|
||||
if (userId) {
|
||||
connectedUsers.delete(userId);
|
||||
// 广播用户离线状态
|
||||
broadcastUserStatus(userId, 'offline');
|
||||
console.log(`用户 ${userId} 已断开连接`);
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket 错误 ${userId ? `(用户: ${userId})` : ''}:`, error);
|
||||
});
|
||||
|
||||
// 消息处理函数
|
||||
function handleMessage(ws, message) {
|
||||
switch (message.type) {
|
||||
case 'ping':
|
||||
// 响应客户端的心跳检测
|
||||
ws.send(JSON.stringify({
|
||||
type: 'pong',
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'user_connect':
|
||||
userId = message.data.userId;
|
||||
connectedUsers.set(userId, ws);
|
||||
console.log(`用户 ${userId} 已连接`);
|
||||
|
||||
// 确认连接成功
|
||||
ws.send(JSON.stringify({
|
||||
type: 'connection_confirmed',
|
||||
data: { userId },
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
|
||||
// 广播用户在线状态
|
||||
broadcastUserStatus(userId, 'online');
|
||||
|
||||
// 发送在线用户列表给新连接的用户
|
||||
ws.send(JSON.stringify({
|
||||
type: 'online_users',
|
||||
data: { users: Array.from(connectedUsers.keys()) },
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
// 转发消息给对话参与者
|
||||
if (message.data.participants && Array.isArray(message.data.participants)) {
|
||||
message.data.participants.forEach(participantId => {
|
||||
// 不发送给自己
|
||||
if (participantId !== userId && connectedUsers.has(participantId)) {
|
||||
const participantWs = connectedUsers.get(participantId);
|
||||
if (participantWs && participantWs.readyState === WebSocket.OPEN) {
|
||||
participantWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// 兼容旧版本的receiverId方式
|
||||
else if (message.data.receiverId && connectedUsers.has(message.data.receiverId)) {
|
||||
const receiverWs = connectedUsers.get(message.data.receiverId);
|
||||
if (receiverWs && receiverWs.readyState === WebSocket.OPEN) {
|
||||
receiverWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
// 转发打字状态给目标用户
|
||||
if (message.data.receiverId && connectedUsers.has(message.data.receiverId)) {
|
||||
const receiverWs = connectedUsers.get(message.data.receiverId);
|
||||
if (receiverWs && receiverWs.readyState === WebSocket.OPEN) {
|
||||
receiverWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'friend_request':
|
||||
// 转发好友申请给目标用户
|
||||
const targetUser = message.data.to_user;
|
||||
if (targetUser && connectedUsers.has(targetUser)) {
|
||||
const targetWs = connectedUsers.get(targetUser);
|
||||
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
|
||||
targetWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'friend_accepted':
|
||||
// 转发好友接受消息给申请发起人
|
||||
const fromUser = message.data.from_user;
|
||||
if (fromUser && connectedUsers.has(fromUser)) {
|
||||
const fromUserWs = connectedUsers.get(fromUser);
|
||||
if (fromUserWs && fromUserWs.readyState === WebSocket.OPEN) {
|
||||
fromUserWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 心跳检测定时器
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
let activeConnections = 0;
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) {
|
||||
console.log('检测到无响应的连接,正在终止...');
|
||||
return ws.terminate();
|
||||
}
|
||||
|
||||
ws.isAlive = false;
|
||||
// 发送ping
|
||||
try {
|
||||
ws.ping(() => { });
|
||||
activeConnections++;
|
||||
} catch (error) {
|
||||
console.error('发送ping失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
if (activeConnections > 0) {
|
||||
console.log(`心跳检测: 活跃连接数: ${activeConnections}`);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// 关闭服务器时清理定时器
|
||||
wss.on('close', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
// 广播用户状态
|
||||
function broadcastUserStatus(userId, status) {
|
||||
const statusMessage = {
|
||||
type: 'user_status',
|
||||
data: { userId, status },
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
connectedUsers.forEach((ws, connectedUserId) => {
|
||||
if (connectedUserId !== userId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(statusMessage));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取在线用户列表
|
||||
function getOnlineUsers() {
|
||||
return Array.from(connectedUsers.keys());
|
||||
}
|
||||
|
||||
// 发送消息给特定用户
|
||||
function sendMessageToUser(userId, message) {
|
||||
const ws = connectedUsers.get(userId);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 发送消息给多个用户
|
||||
function sendMessageToUsers(userIds, message) {
|
||||
let success = false;
|
||||
userIds.forEach(userId => {
|
||||
if (sendMessageToUser(userId, message)) {
|
||||
success = true;
|
||||
}
|
||||
});
|
||||
return success;
|
||||
}
|
||||
|
||||
// 如果直接运行此文件,启动WebSocket服务器
|
||||
if (require.main === module) {
|
||||
const port = process.env.WS_PORT || 3001;
|
||||
createStandaloneWebSocketServer(port);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createStandaloneWebSocketServer,
|
||||
getOnlineUsers,
|
||||
sendMessageToUser,
|
||||
sendMessageToUsers,
|
||||
broadcastUserStatus
|
||||
};
|
||||
|
|
@ -41,5 +41,10 @@
|
|||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
],
|
||||
"moduleResolution": [
|
||||
"node_modules",
|
||||
".next",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
// WebSocket 服务器独立实现
|
||||
const WebSocket = require('ws');
|
||||
|
||||
// 存储已连接的用户
|
||||
const connectedUsers = new Map();
|
||||
|
||||
// 创建 WebSocket 服务器
|
||||
function createWebSocketServer(server) {
|
||||
const wss = new WebSocket.Server({
|
||||
noServer: true // 使用 noServer 模式,手动处理升级请求
|
||||
});
|
||||
|
||||
console.log('WebSocket 服务器已初始化');
|
||||
|
||||
// 连接事件处理
|
||||
wss.on('connection', (ws, req) => {
|
||||
console.log('新的 WebSocket 连接');
|
||||
let userId = null;
|
||||
|
||||
// 设置心跳检测
|
||||
ws.isAlive = true;
|
||||
ws.on('pong', () => {
|
||||
ws.isAlive = true;
|
||||
});
|
||||
|
||||
// 消息处理
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handleMessage(ws, message);
|
||||
} catch (error) {
|
||||
console.error('解析 WebSocket 消息错误:', error);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'error',
|
||||
data: { message: '消息格式无效' },
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// 关闭连接处理
|
||||
ws.on('close', () => {
|
||||
if (userId) {
|
||||
connectedUsers.delete(userId);
|
||||
// 广播用户离线状态
|
||||
broadcastUserStatus(userId, 'offline');
|
||||
console.log(`用户 ${userId} 已断开连接`);
|
||||
}
|
||||
});
|
||||
|
||||
// 错误处理
|
||||
ws.on('error', (error) => {
|
||||
console.error(`WebSocket 错误 ${userId ? `(用户: ${userId})` : ''}:`, error);
|
||||
});
|
||||
|
||||
// 消息处理函数
|
||||
function handleMessage(ws, message) {
|
||||
switch (message.type) {
|
||||
case 'ping':
|
||||
// 响应客户端的心跳检测
|
||||
ws.send(JSON.stringify({
|
||||
type: 'pong',
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'user_connect':
|
||||
userId = message.data.userId;
|
||||
connectedUsers.set(userId, ws);
|
||||
console.log(`用户 ${userId} 已连接`);
|
||||
|
||||
// 确认连接成功
|
||||
ws.send(JSON.stringify({
|
||||
type: 'connection_confirmed',
|
||||
data: { userId },
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
|
||||
// 广播用户在线状态
|
||||
broadcastUserStatus(userId, 'online');
|
||||
|
||||
// 发送在线用户列表给新连接的用户
|
||||
ws.send(JSON.stringify({
|
||||
type: 'online_users',
|
||||
data: { users: Array.from(connectedUsers.keys()) },
|
||||
timestamp: Date.now()
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'message':
|
||||
// 转发消息给目标用户
|
||||
if (message.data.receiverId && connectedUsers.has(message.data.receiverId)) {
|
||||
const receiverWs = connectedUsers.get(message.data.receiverId);
|
||||
if (receiverWs && receiverWs.readyState === WebSocket.OPEN) {
|
||||
receiverWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'typing':
|
||||
// 转发打字状态给目标用户
|
||||
if (message.data.receiverId && connectedUsers.has(message.data.receiverId)) {
|
||||
const receiverWs = connectedUsers.get(message.data.receiverId);
|
||||
if (receiverWs && receiverWs.readyState === WebSocket.OPEN) {
|
||||
receiverWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'friend_request':
|
||||
case 'friend_accepted':
|
||||
// 转发好友相关消息
|
||||
if (message.data.targetUserId && connectedUsers.has(message.data.targetUserId)) {
|
||||
const targetWs = connectedUsers.get(message.data.targetUserId);
|
||||
if (targetWs && targetWs.readyState === WebSocket.OPEN) {
|
||||
targetWs.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 心跳检测定时器
|
||||
const heartbeatInterval = setInterval(() => {
|
||||
let activeConnections = 0;
|
||||
wss.clients.forEach((ws) => {
|
||||
if (ws.isAlive === false) {
|
||||
console.log('检测到无响应的连接,正在终止...');
|
||||
return ws.terminate();
|
||||
}
|
||||
|
||||
ws.isAlive = false;
|
||||
// 发送ping,使用noop回调
|
||||
try {
|
||||
ws.ping(() => { });
|
||||
activeConnections++;
|
||||
} catch (error) {
|
||||
console.error('发送ping失败:', error);
|
||||
}
|
||||
});
|
||||
|
||||
if (activeConnections > 0) {
|
||||
console.log(`心跳检测: 活跃连接数: ${activeConnections}`);
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// 关闭服务器时清理定时器
|
||||
wss.on('close', () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
// 广播用户状态
|
||||
function broadcastUserStatus(userId, status) {
|
||||
const statusMessage = {
|
||||
type: 'user_status',
|
||||
data: { userId, status },
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
connectedUsers.forEach((ws, connectedUserId) => {
|
||||
if (connectedUserId !== userId && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(statusMessage));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 获取在线用户列表
|
||||
function getOnlineUsers() {
|
||||
return Array.from(connectedUsers.keys());
|
||||
}
|
||||
|
||||
// 发送消息给特定用户
|
||||
function sendMessageToUser(userId, message) {
|
||||
const ws = connectedUsers.get(userId);
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(message));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// 发送消息给多个用户
|
||||
function sendMessageToUsers(userIds, message) {
|
||||
let success = false;
|
||||
userIds.forEach(userId => {
|
||||
if (sendMessageToUser(userId, message)) {
|
||||
success = true;
|
||||
}
|
||||
});
|
||||
return success;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createWebSocketServer,
|
||||
getOnlineUsers,
|
||||
sendMessageToUser,
|
||||
sendMessageToUsers,
|
||||
broadcastUserStatus
|
||||
};
|
||||
Loading…
Reference in New Issue