diff --git a/package.json b/package.json index 6731776..9f6451c 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "axios": "^1.10.0", + "ioredis": "^5.6.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", "simple-git": "^3.28.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0f0b71..66f192a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ dependencies: axios: specifier: ^1.10.0 version: 1.10.0 + ioredis: + specifier: ^5.6.1 + version: 5.6.1 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -871,6 +874,10 @@ packages: '@types/node': 22.16.5 dev: true + /@ioredis/commands@1.2.0: + resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} + dev: false + /@isaacs/balanced-match@4.0.1: resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} engines: {node: 20 || >=22} @@ -2906,6 +2913,11 @@ packages: engines: {node: '>=0.8'} dev: true + /cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + dev: false + /co@4.6.0: resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} @@ -3146,6 +3158,11 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + /denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dev: false + /depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -4074,6 +4091,23 @@ packages: kind-of: 6.0.3 dev: true + /ioredis@5.6.1: + resolution: {integrity: sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==} + engines: {node: '>=12.22.0'} + dependencies: + '@ioredis/commands': 1.2.0 + cluster-key-slot: 1.1.2 + debug: 4.4.1 + denque: 2.1.0 + lodash.defaults: 4.2.0 + lodash.isarguments: 3.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + dev: false + /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4772,6 +4806,14 @@ packages: p-locate: 5.0.0 dev: true + /lodash.defaults@4.2.0: + resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} + dev: false + + /lodash.isarguments@3.1.0: + resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==} + dev: false + /lodash.memoize@4.1.2: resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} dev: true @@ -5363,6 +5405,18 @@ packages: engines: {node: '>= 14.18.0'} dev: true + /redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + dev: false + + /redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + dependencies: + redis-errors: 1.2.0 + dev: false + /reflect-metadata@0.2.2: resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} @@ -5696,6 +5750,10 @@ packages: escape-string-regexp: 2.0.0 dev: true + /standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + dev: false + /statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} diff --git a/src/app.module.ts b/src/app.module.ts index 0544b93..d98ed87 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,8 +4,20 @@ import { AppConfigModule } from './config/config.module'; import { PathModule } from './core/path/path.module'; import { SystemModule } from './core/system/system.module'; import { ToolsModule } from './core/tools/tools.module'; +import { AutoUpdateModule } from './core/auto-update/auto-update.module'; +import { PersistenceModule } from './core/persistence/persistence.module'; +import { RedisModule } from './core/redis/redis.module'; @Module({ - imports: [RootModule, AppConfigModule, PathModule, SystemModule, ToolsModule], + imports: [ + RootModule, + AppConfigModule, + PathModule, + SystemModule, + ToolsModule, + PersistenceModule, + AutoUpdateModule, + RedisModule, + ], }) export class AppModule {} diff --git a/src/core/auto-update/auto-update.module.ts b/src/core/auto-update/auto-update.module.ts index e69de29..2ef43e8 100644 --- a/src/core/auto-update/auto-update.module.ts +++ b/src/core/auto-update/auto-update.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AutoUpdateService } from './auto-update.service'; +import { PathModule } from '../path/path.module'; + +@Module({ + imports: [PathModule], + providers: [AutoUpdateService], + exports: [AutoUpdateService], +}) +export class AutoUpdateModule {} diff --git a/src/core/files/files.module.ts b/src/core/files/files.module.ts new file mode 100644 index 0000000..cb88f57 --- /dev/null +++ b/src/core/files/files.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PathModule } from '../path/path.module'; +import { FilesService } from './files.service'; + +@Module({ + imports: [PathModule], + providers: [FilesService], + exports: [FilesService], +}) +export class FilesModule {} diff --git a/src/core/files/files.service.ts b/src/core/files/files.service.ts new file mode 100644 index 0000000..639d98b --- /dev/null +++ b/src/core/files/files.service.ts @@ -0,0 +1,33 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import * as path from 'path'; +import { promises as fs } from 'fs'; +import { PathService } from '../path/path.service'; +@Injectable() +export class FilesService { + private readonly logger = new Logger(FilesService.name); + + constructor( + @Inject(PathService) + private readonly paths: PathService, + ) {} + + /** + * 创建目录 + * @param targetPath 目标路径 + * @param includeFile 是否包含文件路径 + */ + async createDir(targetPath = '', includeFile = false): Promise { + const root = this.paths.get('root'); + try { + const dirToCreate = path.isAbsolute(targetPath) + ? includeFile + ? path.dirname(targetPath) + : targetPath + : path.join(root, includeFile ? path.dirname(targetPath) : targetPath); + + await fs.mkdir(dirToCreate, { recursive: true }); + } catch (err) { + this.logger.error(`创建目录失败: ${err}`); + } + } +} diff --git a/src/core/persistence/persistence.module.ts b/src/core/persistence/persistence.module.ts new file mode 100644 index 0000000..0991b60 --- /dev/null +++ b/src/core/persistence/persistence.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { PersistenceService } from './persistence.service'; +import { PathModule } from '../path/path.module'; +import { FilesModule } from '../files/files.module'; + +@Module({ + imports: [PathModule, FilesModule], + providers: [PersistenceService], + exports: [PersistenceService], +}) +export class PersistenceModule {} diff --git a/src/core/persistence/persistence.service.ts b/src/core/persistence/persistence.service.ts new file mode 100644 index 0000000..a0aa509 --- /dev/null +++ b/src/core/persistence/persistence.service.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import { PathService } from '../path/path.service'; +import { FilesService } from '../files/files.service'; + +@Injectable() +export class PersistenceService { + private readonly logger = new Logger(PersistenceService.name); + + private getDataPath(dataName: string, fileName: string): string { + return path.join(this.paths.get('userData'), dataName, `${fileName}.json`); + } + + constructor( + @Inject(PathService) + private readonly paths: PathService, + @Inject(FilesService) + private readonly fileService: FilesService, + ) {} + + private async ensureDataPath(dataName: string): Promise { + const dataPath = path.join(this.paths.get('userData'), dataName); + try { + await this.fileService.createDir(dataPath, false); + } catch (err) { + this.logger.error('目录创建失败:', err); + } + } + + public async writeDataLocal( + dataName: string, + data: T, + fileName: string, + ): Promise { + await this.ensureDataPath(dataName); + const filePath = this.getDataPath(dataName, fileName); + + try { + await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8'); + this.logger.debug(`用户数据已持久化到本地: ${filePath}`); + } catch (err) { + this.logger.error('写入失败:', err); + } + } + + public async readDataLocal( + dataName: string, + fileName: string, + ): Promise { + const filePath = this.getDataPath(dataName, fileName); + + try { + const data = await fs.readFile(filePath, 'utf-8'); + return JSON.parse(data) as T; + } catch (err) { + this.logger.error('读取失败:', err); + return undefined; + } + } +} diff --git a/src/core/redis/redis.module.ts b/src/core/redis/redis.module.ts new file mode 100644 index 0000000..562b5c3 --- /dev/null +++ b/src/core/redis/redis.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { RedisService } from './redis.service'; +import { AppConfigModule } from '../../config/config.module'; +import { ToolsModule } from '../tools/tools.module'; +import { PersistenceModule } from '../persistence/persistence.module'; + +@Module({ + imports: [AppConfigModule, ToolsModule, PersistenceModule], + providers: [RedisService], + exports: [RedisService], +}) +export class RedisModule {} diff --git a/src/core/redis/redis.service.ts b/src/core/redis/redis.service.ts new file mode 100644 index 0000000..8eed121 --- /dev/null +++ b/src/core/redis/redis.service.ts @@ -0,0 +1,150 @@ +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import Redis from 'ioredis'; +import { RedisUtils } from './redis.utils'; +import { AppConfigService } from '../../config/config.service'; +import { ToolsService } from '../tools/tools.service'; +import IUser from '../../types/user'; +import { PersistenceService } from '../persistence/persistence.service'; + +@Injectable() +export class RedisService implements OnModuleInit { + private readonly logger = new Logger(RedisService.name); + private client!: Redis; + private isConnected = false; + + constructor( + @Inject(AppConfigService) + private readonly config: AppConfigService, + @Inject(ToolsService) + private readonly tools: ToolsService, + @Inject(PersistenceService) + private readonly Persistence: PersistenceService, + ) {} + + async onModuleInit() { + await this.connectWithRetry(); + this.setupEventListeners(); + } + + private async connectWithRetry(): Promise { + try { + await this.tools.retry( + async () => { + this.client = new Redis({ + host: this.config.get('RD_ADD'), + port: Number(this.config.get('RD_PORT')), + retryStrategy: (times: number) => Math.min(times * 1000, 5000), + }); + + await this.client.ping(); + this.isConnected = true; + this.logger.log( + `Redis连接成功! 位于 ${this.config.get('RD_ADD')}:${this.config.get('RD_PORT')}`, + ); + }, + { + maxAttempts: 5, + initialDelay: 1000, + }, + ); + } catch (error) { + this.logger.error('Redis连接失败:', error); + throw error; + } + } + + private setupEventListeners(): void { + this.client.on('error', (err) => { + if (!err.message.includes('ECONNREFUSED')) { + this.logger.error('Redis错误:', err); + } + this.isConnected = false; + }); + + this.client.on('ready', () => { + this.isConnected = true; + this.logger.debug('Redis连接就绪!'); + }); + + this.client.on('reconnecting', () => { + this.logger.warn('Redis重新连接中...'); + }); + } + + public async waitUntilReady(): Promise { + if (this.isConnected) return; + return new Promise((resolve) => { + const check = () => + this.isConnected ? resolve() : setTimeout(check, 100); + check(); + }); + } + + public getClient(): Redis { + if (!this.isConnected) { + this.logger.error('Redis未连接'); + } + return this.client; + } + + public async disconnect(): Promise { + await this.client.quit(); + this.isConnected = false; + } + + public async setObject( + key: string, + value: T, + ttl?: number, + ): Promise { + const serialized = RedisUtils.serialize(value); + await this.client.set(key, serialized); + if (ttl) { + await this.client.expire(key, ttl); + } + } + + public async getObject(key: string): Promise { + const serialized = await this.client.get(key); + if (!serialized) return undefined; + const deserialized = RedisUtils.deserialize(serialized); + return RedisUtils.reviveDates(deserialized); + } + + public async update(key: string, updates: T): Promise { + const existing = await this.getObject(key); + if (!existing) { + this.logger.error(`数据${key}不存在`); + } + const updated = { ...existing, ...updates }; + await this.setObject(key, updated); + return updated; + } + + public async fetch(key: string, fileName: string): Promise { + const data = await this.getObject(key); + if (data) return data; + + const fromLocal = await this.Persistence.readDataLocal(key, fileName); + if (fromLocal) { + await this.setObject(key, fromLocal); + return fromLocal; + } + + this.logger.error(`数据${key}不存在`); + } + + public async persistData( + key: string, + data: T, + fileName: string, + ): Promise { + await this.setObject(key, data); + await this.Persistence.writeDataLocal(key, data, fileName); + } + + public async test(): Promise { + const user = await this.fetch('Jerry', 'IUser'); + this.logger.debug('User:', user); + } +} diff --git a/src/core/redis/redis.utils.ts b/src/core/redis/redis.utils.ts new file mode 100644 index 0000000..99940f6 --- /dev/null +++ b/src/core/redis/redis.utils.ts @@ -0,0 +1,30 @@ +import { Logger } from '@nestjs/common'; + +const logger = new Logger('RedisUtils'); + +export class RedisUtils { + static serialize(data: T): string { + return JSON.stringify(data); + } + + static deserialize(jsonString: string): T | undefined { + try { + return JSON.parse(jsonString); + } catch (err) { + logger.error(`redis反序列化失败:${err}`); + return undefined; + } + } + + static reviveDates(obj: T): T { + const dateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z$/; + + const reviver = (_: string, value: any) => { + if (typeof value === 'string' && dateRegex.test(value)) { + return new Date(value); + } + return value; + }; + return JSON.parse(JSON.stringify(obj), reviver); + } +} diff --git a/src/core/system/system.module.ts b/src/core/system/system.module.ts index 5ca334d..e790c7a 100644 --- a/src/core/system/system.module.ts +++ b/src/core/system/system.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { SystemService } from './system.service'; import { PathModule } from '../path/path.module'; +import { AutoUpdateModule } from '../auto-update/auto-update.module'; @Module({ - imports: [PathModule], + imports: [PathModule, AutoUpdateModule], providers: [SystemService], exports: [SystemService], }) diff --git a/src/core/system/system.service.ts b/src/core/system/system.service.ts index 6159e03..59ee3ea 100644 --- a/src/core/system/system.service.ts +++ b/src/core/system/system.service.ts @@ -2,6 +2,9 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import * as path from 'path'; import * as fs from 'fs'; import { PathService } from '../path/path.service'; +import { AutoUpdateModule } from '../auto-update/auto-update.module'; +import { AutoUpdateService } from '../auto-update/auto-update.service'; +import * as process from 'node:process'; @Injectable() export class SystemService { @@ -11,6 +14,8 @@ export class SystemService { constructor( @Inject(PathService) private readonly pathService: PathService, + @Inject(AutoUpdateService) + private readonly autoUpdateService: AutoUpdateService, ) { this.restartFile = path.join( this.pathService.get('temp'), @@ -50,4 +55,12 @@ export class SystemService { await new Promise((resolve) => setTimeout(resolve, 300)); process.exit(0); } + + async checkUpdate(): Promise { + const updated = await this.autoUpdateService.checkForUpdates(); + if (updated) { + this.logger.warn('系统代码已更新,正在重启..'); + process.exit(1); + } + } } diff --git a/src/main.ts b/src/main.ts index 5735f0e..7036821 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,6 +25,9 @@ async function bootstrap() { const document = () => SwaggerModule.createDocument(app, config); SwaggerModule.setup('', app, document); await app.listen(7000); + await systemService.checkUpdate().catch((err) => { + Logger.error(`自动更新失败: ${err?.message}`, '', 'System'); + }); } bootstrap().then(() => { Logger.log(`API服务已启动:http://localhost:7000`); diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 0000000..172a4a0 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,10 @@ +interface IUser { + name: string; + qq: string; + password: string; + isAdmin: boolean; + lastLogin?: Date; + createdAt: Date; +} + +export default IUser;