feat:系统模块

This commit is contained in:
Jerry 2025-07-25 18:30:23 +08:00
parent d91f900f49
commit 7c4b933e9d
15 changed files with 417 additions and 2 deletions

View File

@ -22,6 +22,7 @@
"@nestjs/platform-express": "^11.0.1", "@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.2.0", "@nestjs/swagger": "^11.2.0",
"axios": "^1.10.0", "axios": "^1.10.0",
"ioredis": "^5.6.1",
"reflect-metadata": "^0.2.2", "reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"simple-git": "^3.28.0", "simple-git": "^3.28.0",

58
pnpm-lock.yaml generated
View File

@ -26,6 +26,9 @@ dependencies:
axios: axios:
specifier: ^1.10.0 specifier: ^1.10.0
version: 1.10.0 version: 1.10.0
ioredis:
specifier: ^5.6.1
version: 5.6.1
reflect-metadata: reflect-metadata:
specifier: ^0.2.2 specifier: ^0.2.2
version: 0.2.2 version: 0.2.2
@ -871,6 +874,10 @@ packages:
'@types/node': 22.16.5 '@types/node': 22.16.5
dev: true dev: true
/@ioredis/commands@1.2.0:
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
dev: false
/@isaacs/balanced-match@4.0.1: /@isaacs/balanced-match@4.0.1:
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@ -2906,6 +2913,11 @@ packages:
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
dev: true 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: /co@4.6.0:
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==} resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'} engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
@ -3146,6 +3158,11 @@ packages:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'} 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: /depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@ -4074,6 +4091,23 @@ packages:
kind-of: 6.0.3 kind-of: 6.0.3
dev: true 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: /ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@ -4772,6 +4806,14 @@ packages:
p-locate: 5.0.0 p-locate: 5.0.0
dev: true 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: /lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==} resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
dev: true dev: true
@ -5363,6 +5405,18 @@ packages:
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
dev: true 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: /reflect-metadata@0.2.2:
resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==}
@ -5696,6 +5750,10 @@ packages:
escape-string-regexp: 2.0.0 escape-string-regexp: 2.0.0
dev: true dev: true
/standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
dev: false
/statuses@2.0.1: /statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}

View File

@ -4,8 +4,20 @@ import { AppConfigModule } from './config/config.module';
import { PathModule } from './core/path/path.module'; import { PathModule } from './core/path/path.module';
import { SystemModule } from './core/system/system.module'; import { SystemModule } from './core/system/system.module';
import { ToolsModule } from './core/tools/tools.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({ @Module({
imports: [RootModule, AppConfigModule, PathModule, SystemModule, ToolsModule], imports: [
RootModule,
AppConfigModule,
PathModule,
SystemModule,
ToolsModule,
PersistenceModule,
AutoUpdateModule,
RedisModule,
],
}) })
export class AppModule {} export class AppModule {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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<void> {
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}`);
}
}
}

View File

@ -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 {}

View File

@ -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<void> {
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<T>(
dataName: string,
data: T,
fileName: string,
): Promise<void> {
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<T>(
dataName: string,
fileName: string,
): Promise<T | undefined> {
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;
}
}
}

View File

@ -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 {}

View File

@ -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<void> {
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<void> {
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<void> {
await this.client.quit();
this.isConnected = false;
}
public async setObject<T>(
key: string,
value: T,
ttl?: number,
): Promise<void> {
const serialized = RedisUtils.serialize(value);
await this.client.set(key, serialized);
if (ttl) {
await this.client.expire(key, ttl);
}
}
public async getObject<T>(key: string): Promise<T | undefined> {
const serialized = await this.client.get(key);
if (!serialized) return undefined;
const deserialized = RedisUtils.deserialize<T>(serialized);
return RedisUtils.reviveDates(deserialized);
}
public async update<T>(key: string, updates: T): Promise<T> {
const existing = await this.getObject<T>(key);
if (!existing) {
this.logger.error(`数据${key}不存在`);
}
const updated = { ...existing, ...updates };
await this.setObject(key, updated);
return updated;
}
public async fetch<T>(key: string, fileName: string): Promise<T | undefined> {
const data = await this.getObject<T>(key);
if (data) return data;
const fromLocal = await this.Persistence.readDataLocal<T>(key, fileName);
if (fromLocal) {
await this.setObject(key, fromLocal);
return fromLocal;
}
this.logger.error(`数据${key}不存在`);
}
public async persistData<T>(
key: string,
data: T,
fileName: string,
): Promise<void> {
await this.setObject(key, data);
await this.Persistence.writeDataLocal(key, data, fileName);
}
public async test(): Promise<void> {
const user = await this.fetch<IUser>('Jerry', 'IUser');
this.logger.debug('User:', user);
}
}

View File

@ -0,0 +1,30 @@
import { Logger } from '@nestjs/common';
const logger = new Logger('RedisUtils');
export class RedisUtils {
static serialize<T>(data: T): string {
return JSON.stringify(data);
}
static deserialize<T>(jsonString: string): T | undefined {
try {
return JSON.parse(jsonString);
} catch (err) {
logger.error(`redis反序列化失败${err}`);
return undefined;
}
}
static reviveDates<T>(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);
}
}

View File

@ -1,9 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SystemService } from './system.service'; import { SystemService } from './system.service';
import { PathModule } from '../path/path.module'; import { PathModule } from '../path/path.module';
import { AutoUpdateModule } from '../auto-update/auto-update.module';
@Module({ @Module({
imports: [PathModule], imports: [PathModule, AutoUpdateModule],
providers: [SystemService], providers: [SystemService],
exports: [SystemService], exports: [SystemService],
}) })

View File

@ -2,6 +2,9 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import * as path from 'path'; import * as path from 'path';
import * as fs from 'fs'; import * as fs from 'fs';
import { PathService } from '../path/path.service'; 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() @Injectable()
export class SystemService { export class SystemService {
@ -11,6 +14,8 @@ export class SystemService {
constructor( constructor(
@Inject(PathService) @Inject(PathService)
private readonly pathService: PathService, private readonly pathService: PathService,
@Inject(AutoUpdateService)
private readonly autoUpdateService: AutoUpdateService,
) { ) {
this.restartFile = path.join( this.restartFile = path.join(
this.pathService.get('temp'), this.pathService.get('temp'),
@ -50,4 +55,12 @@ export class SystemService {
await new Promise((resolve) => setTimeout(resolve, 300)); await new Promise((resolve) => setTimeout(resolve, 300));
process.exit(0); process.exit(0);
} }
async checkUpdate(): Promise<void> {
const updated = await this.autoUpdateService.checkForUpdates();
if (updated) {
this.logger.warn('系统代码已更新,正在重启..');
process.exit(1);
}
}
} }

View File

@ -25,6 +25,9 @@ async function bootstrap() {
const document = () => SwaggerModule.createDocument(app, config); const document = () => SwaggerModule.createDocument(app, config);
SwaggerModule.setup('', app, document); SwaggerModule.setup('', app, document);
await app.listen(7000); await app.listen(7000);
await systemService.checkUpdate().catch((err) => {
Logger.error(`自动更新失败: ${err?.message}`, '', 'System');
});
} }
bootstrap().then(() => { bootstrap().then(() => {
Logger.log(`API服务已启动http://localhost:7000`); Logger.log(`API服务已启动http://localhost:7000`);

10
src/types/user.ts Normal file
View File

@ -0,0 +1,10 @@
interface IUser {
name: string;
qq: string;
password: string;
isAdmin: boolean;
lastLogin?: Date;
createdAt: Date;
}
export default IUser;