This commit is contained in:
vastxie
2025-05-31 02:28:46 +08:00
parent 0f7adc5c65
commit 86e2eecc1f
1808 changed files with 183083 additions and 86701 deletions

20
service/.dockerignore Normal file
View File

@@ -0,0 +1,20 @@
node_modules
data
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### VS Code ###
.vscode/
### Macos ###
.DS_Store
.env
.git
.gitignore
docker
docs
README.md

15
service/.editorconfig Normal file
View File

@@ -0,0 +1,15 @@
# Editor configuration, see http://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
max_line_length = 80
trim_trailing_whitespace = false

30
service/.env.docker Normal file
View File

@@ -0,0 +1,30 @@
# server base
PORT=9520
# mysql
DB_HOST=mysql
DB_PORT=3306
DB_USER=root
DB_PASS=123456
DB_DATABASE=chatgpt
# Redis
REDIS_PORT=6379
REDIS_HOST=redis
REDIS_PASSWORD=
REDIS_USER=
REDIS_DB=0
# 是否测试环境
ISDEV=false
# 自定义微信URL
weChatOpenUrl=https://open.weixin.qq.com
weChatApiUrl=https://api.weixin.qq.com
weChatApiUrlToken=https://api.weixin.qq.com/cgi-bin/token
weChatMpUrl=https://mp.weixin.qq.com
# 自定义后台路径
ADMIN_SERVE_ROOT=/admin
# 机器码及授权码

30
service/.env.example Normal file
View File

@@ -0,0 +1,30 @@
# 端口
PORT=9520
# MySQL
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=root
DB_PASS=
DB_DATABASE=chatgpt
# Redis
REDIS_PORT=6379
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=
REDIS_USER=
REDIS_DB=0
# 是否测试环境
ISDEV=false
# 自定义微信URL
weChatOpenUrl=https://open.weixin.qq.com
weChatApiUrl=https://api.weixin.qq.com
weChatApiUrlToken=https://api.weixin.qq.com/cgi-bin/token
weChatMpUrl=https://mp.weixin.qq.com
# 自定义后台路径
ADMIN_SERVE_ROOT=/admin
# 机器码及授权码

31
service/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# compiled output
/dist
/node_modules
/public
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
.env

18
service/.prettierrc Normal file
View File

@@ -0,0 +1,18 @@
{
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2,
"semi": true,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf",
"overrides": [
{
"files": "*.{ts,js}",
"options": {
"parser": "typescript"
}
}
]
}

8
service/.vscode/extensions.json vendored Executable file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"mikestead.dotenv",
"Vue.volar",
"antfu.unocss"
]
}

115
service/.vscode/settings.json vendored Executable file
View File

@@ -0,0 +1,115 @@
{
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
},
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"markdown"
],
"cSpell.words": [
"aiweb",
"antfu",
"axios",
"Baichuan",
"bumpp",
"Chatbox",
"chatglm",
"chatgpt",
"chatlog",
"chenzhaoyu",
"chevereto",
"cogvideox",
"commitlint",
"crami",
"cref",
"dall",
"dalle",
"davinci",
"deepsearch",
"deepseek",
"dockerhub",
"Doubao",
"duckduckgo",
"Dulu",
"EMAILCODE",
"Epay",
"errcode",
"errmsg",
"esno",
"Flowith",
"getticket",
"GPTAPI",
"gpts",
"highlightjs",
"hljs",
"hunyuan",
"Hupi",
"iconify",
"ISDEV",
"Jsapi",
"katex",
"katexmath",
"langchain",
"lightai",
"linkify",
"logprobs",
"longcontext",
"Ltzf",
"luma",
"mapi",
"Markmap",
"mdhljs",
"mediumtext",
"micromessenger",
"mila",
"Mindmap",
"modelcontextprotocol",
"MODELSMAPLIST",
"MODELTYPELIST",
"modelvalue",
"Mpay",
"newconfig",
"niji",
"Nmessage",
"nodata",
"OPENAI",
"pinia",
"Popconfirm",
"PPTCREATE",
"projectaddress",
"qwen",
"rushstack",
"sdxl",
"seededit",
"seedream",
"Sider",
"sref",
"suno",
"tailwindcss",
"Tavily",
"traptitech",
"tsup",
"Typecheck",
"typeorm",
"unionid",
"unplugin",
"usercenter",
"vastxie",
"VITE",
"vueuse",
"wechat"
],
"vue.codeActions.enabled": false
}

21
service/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
# 使用官方Node.js的基础镜像
FROM node:latest
# 设置时区为上海
ENV TZ="Asia/Shanghai"
# 设置工作目录
WORKDIR /usr/src/app
# 安装pnpm
RUN npm install -g npm pm2 pnpm
# 复制package.json以利用Docker缓存机制
COPY package.json ./
RUN pnpm install
# 暴露应用端口
EXPOSE 9520
# 启动应用
CMD ["pm2-runtime", "start", "pm2.conf.json"]

View File

@@ -0,0 +1,56 @@
version: '3.9'
services:
mysql:
image: mysql:8
command: --mysql-native-password=ON --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
# command: --default-authentication-plugin=caching_sha2_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
restart: always
volumes:
- ./data/mysql/:/var/lib/mysql/
- ./sql/:/docker-entrypoint-initdb.d/ #数据库文件放此目录可自动导入
# ports:
# - "3306:3306"
environment:
TZ: Asia/Shanghai
MYSQL_ROOT_PASSWORD: '123456'
MYSQL_DATABASE: 'chatgpt'
MYSQL_USER: 'chatgpt'
MYSQL_PASSWORD: '123456'
redis:
image: redis
# command: --requirepass "12345678" # redis库密码,不需要密码注释本行
restart: always
# ports:
# - "6379:6379"
environment:
TZ: Asia/Shanghai # 指定时区
volumes:
- ./data/redis/:/data/
99ai:
build:
context: . # Use the current directory as build context
dockerfile: Dockerfile # Specify the Dockerfile name
container_name: 99ai
restart: always
ports:
- '9520:9520'
volumes:
- ./.env.docker:/usr/src/app/.env:rw # Mount with explicit read-write permissions
- ./dist:/usr/src/app/dist:ro # 挂载dist目录只读
- ./public:/usr/src/app/public:ro # 挂载public目录只读
- ./public/file:/usr/src/app/public/file:rw # 挂载public/file目录读写
- ./package.json:/usr/src/app/package.json:ro # 挂载package.json只读
- ./pm2.conf.json:/usr/src/app/pm2.conf.json:ro # 挂载pm2.conf.json只读
- myapp_data:/app/data # Mount the named volume for persistent instance ID
environment:
- TZ=Asia/Shanghai
# depends_on can often be removed if the app handles connection retries
# depends_on:
# - mysql
# - redis
volumes:
myapp_data: # Define the named volume

17
service/nest-cli.json Normal file
View File

@@ -0,0 +1,17 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"packageManager": "pnpm",
"compilerOptions": {
"deleteOutDir": true,
"assets": ["src/common/mailTemplates/**/*", "src/views/**/*", "src/rpc/*"],
"sourceMap": false,
"declaration": false,
"webpack": true,
"tsConfigPath": "tsconfig.build.json"
},
"defaults": {
"path": "modules"
}
}

144
service/package.json Normal file
View File

@@ -0,0 +1,144 @@
{
"name": "99ai",
"version": "4.3.0",
"description": "",
"author": "vastxie",
"private": true,
"license": "Apache-2.0",
"bin": "./dist/main.js",
"scripts": {
"start": "pm2 start pm2.conf.json",
"prebuild": "pnpm run format",
"build": "pnpm format && nest build",
"build:test": "nest build",
"format": "prettier --write 'src/**/*.{vue,ts,tsx,js,jsx,css,scss,less}'",
"encrypt": "node ./encrypt.js",
"start:daemon": "pm2 start pm2.conf.json --no-daemon",
"dev": "nest start --watch --preserveWatchOutput",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"test": "jest",
"test:watch": "jest --watch",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"pkg:win": "pkg . -t node16-win-x64 -o app-win --debug",
"pkg:mac": "pkg . -t node16-mac-x64 -o app-mac --debug",
"pkg:linux": "pkg . -t node16-linux-x64 -o app-linux --debug"
},
"dependencies": {
"@alicloud/pop-core": "^1.8.0",
"@aws-sdk/client-s3": "^3.817.0",
"@google/genai": "^0.10.0",
"@langchain/community": "^0.3.42",
"@langchain/core": "^0.3.55",
"@langchain/langgraph": "^0.2.72",
"@langchain/langgraph-sdk": "^0.0.74",
"@langchain/openai": "^0.5.10",
"@langchain/tavily": "^0.1.1",
"@modelcontextprotocol/sdk": "^1.10.1",
"@nestjs/common": "^10.4.17",
"@nestjs/core": "^10.4.17",
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.17",
"@nestjs/schedule": "^4.1.2",
"@nestjs/serve-static": "^4.0.2",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"@nestjs/websockets": "^10.4.17",
"@types/raw-body": "^2.3.0",
"abort-controller": "^3.0.0",
"ali-oss": "^6.22.0",
"axios": "^1.8.4",
"bcryptjs": "^2.4.3",
"body-parser": "^1.20.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"compression": "^1.8.0",
"cos-nodejs-sdk-v5": "^2.14.7",
"cross-fetch": "3.1.6",
"dayjs": "^1.11.13",
"dotenv": "^16.5.0",
"exceljs": "^4.4.0",
"express": "^4.21.2",
"fast-xml-parser": "^5.2.0",
"form-data": "^4.0.2",
"gpt-tokenizer": "^2.9.0",
"guid-typescript": "^1.0.9",
"https-proxy-agent": "7.0.2",
"iconv-lite": "^0.6.3",
"ioredis": "^5.6.1",
"jschardet": "^3.1.4",
"jsonwebtoken": "^9.0.2",
"lodash": "^4.17.21",
"mammoth": "^1.9.0",
"markdown-table": "^3.0.4",
"mime-types": "^2.1.35",
"mysql2": "^3.14.0",
"node-cron": "^3.0.3",
"node-machine-id": "^1.1.12",
"nodemailer": "^6.10.1",
"openai": "^4.96.0",
"passport": "^0.6.0",
"passport-jwt": "^4.0.1",
"path-to-regexp": "^1.9.0",
"pdf-parse": "^1.1.1",
"pm2": "^6.0.5",
"pptxtojson": "^1.3.1",
"raw-body": "^3.0.0",
"redis": "^4.7.0",
"reflect-metadata": "^0.1.14",
"rxjs": "^7.8.2",
"stream-to-buffer": "^0.1.0",
"tough-cookie": "^4.1.3",
"typeorm": "^0.3.22",
"uuid": "^9.0.1",
"wechatpay-node-v3": "^2.2.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz"
},
"devDependencies": {
"@babel/core": "^7.26.10",
"@nestjs/cli": "^10.4.9",
"@types/express": "^4.17.21",
"@types/node": "18.11.18",
"fs-extra": "^11.3.0",
"javascript-obfuscator": "^4.1.1",
"jest": "29.3.1",
"prettier": "^2.8.8",
"ts-jest": "29.0.3",
"ts-loader": "^9.5.2",
"ts-node": "^10.9.2",
"typescript": "^5.8.3"
},
"pnpm": {
"peerDependencyRules": {
"ignoreMissing": [
"@nestjs/websockets",
"@nestjs/common",
"@nestjs/core"
]
},
"overrides": {
"@nestjs/serve-static>path-to-regexp": "^1.9.0",
"tough-cookie": "^4.1.3"
}
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "src",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": "ts-jest"
},
"collectCoverageFrom": [
"**/*.(t|j)s"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}

16
service/pm2.conf.json Normal file
View File

@@ -0,0 +1,16 @@
{
"apps": {
"name": "99AI",
"script": "./dist/main.js",
"watch": true,
"ignore_watch": ["node_modules", "logs", "public/file"],
"env": {
"TZ": "Asia/Shanghai"
},
"instances": 1,
"error_file": "logs/err.log",
"out_file": "logs/out.log",
"log_date_format": "YYYY-MM-DD HH:mm:ss",
"max_memory_restart": "2000M"
}
}

11434
service/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

112
service/src/app.module.ts Normal file
View File

@@ -0,0 +1,112 @@
import { AbortInterceptor } from '@/common/interceptors/abort.interceptor';
import { CustomLoggerService } from '@/common/logger/custom-logger.service';
// import { LicenseValidatorMiddleware } from '@/common/middleware/license-validator.middleware';
import { RateLimitModule } from '@/modules/rateLimit/rate-limit.module';
import { Global, MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { ServeStaticModule } from '@nestjs/serve-static';
// import * as fetch from 'isomorphic-fetch'; // Disable isomorphic-fetch polyfill
import { join } from 'path';
import { AppModule as ApplicationModule } from './modules/app/app.module';
import { AuthModule } from './modules/auth/auth.module';
import { AutoReplyModule } from './modules/autoReply/autoReply.module';
import { BadWordsModule } from './modules/badWords/badWords.module';
import { ChatModule } from './modules/chat/chat.module';
import { ChatGroupModule } from './modules/chatGroup/chatGroup.module';
import { ChatLogModule } from './modules/chatLog/chatLog.module';
import { CramiModule } from './modules/crami/crami.module';
import { DatabaseModule } from './modules/database/database.module';
import { GlobalConfigModule } from './modules/globalConfig/globalConfig.module';
import { ModelsModule } from './modules/models/models.module';
import { OfficialModule } from './modules/official/official.module';
import { OrderModule } from './modules/order/order.module';
import { PayModule } from './modules/pay/pay.module';
import { PluginModule } from './modules/plugin/plugin.module';
import { RedisCacheModule } from './modules/redisCache/redisCache.module';
import { ShareModule } from './modules/share/share.module';
import { SigninModule } from './modules/signin/signin.module';
import { SpaModule } from './modules/spa/spa.module';
import { StatisticModule } from './modules/statistic/statistic.module';
import { TaskModule } from './modules/task/task.module';
import { UploadModule } from './modules/upload/upload.module';
import { UserModule } from './modules/user/user.module';
import { UserBalanceModule } from './modules/userBalance/userBalance.module';
import { VerificationModule } from './modules/verification/verification.module';
// global.fetch = fetch; // Disable isomorphic-fetch polyfill
@Global()
@Module({
imports: [
DatabaseModule,
RateLimitModule,
ServeStaticModule.forRoot(
{
rootPath: join(__dirname, '..', 'public/admin'),
serveRoot: process.env.ADMIN_SERVE_ROOT || '/admin',
},
{
rootPath: join(__dirname, '..', 'public/file'),
serveRoot: '/file',
serveStaticOptions: {
setHeaders: (_res, _path, _stat) => {
_res.set('Access-Control-Allow-Origin', '*');
},
},
},
{
rootPath: join(__dirname, '..', 'public/chat'),
serveRoot: '/',
serveStaticOptions: {
index: false,
fallthrough: true,
redirect: false,
extensions: ['html', 'htm'],
setHeaders: (_res, _path, _stat) => {
if (_path.endsWith('.js')) {
_res.set('Content-Type', 'application/javascript');
} else if (_path.endsWith('.css')) {
_res.set('Content-Type', 'text/css');
}
},
},
},
),
UserModule,
PluginModule,
AuthModule,
VerificationModule,
ChatModule,
ApplicationModule,
CramiModule,
UserBalanceModule,
ChatLogModule,
UploadModule,
RedisCacheModule,
GlobalConfigModule,
StatisticModule,
BadWordsModule,
AutoReplyModule,
PayModule,
OrderModule,
OfficialModule,
TaskModule,
ChatGroupModule,
SigninModule,
ModelsModule,
ShareModule,
SpaModule,
],
providers: [
{
provide: APP_INTERCEPTOR,
useClass: AbortInterceptor,
},
CustomLoggerService,
],
exports: [CustomLoggerService],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer) {
consumer;
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { JwtAuthGuard } from './jwtAuth.guard';
@Injectable()
export class AdminAuthGuard extends JwtAuthGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
const isAuthorized = await super.canActivate(context);
if (!isAuthorized) {
return false;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (user && ['admin', 'super'].includes(user.role)) {
return true;
} else {
throw new UnauthorizedException('非法操作、您的权限等级不足、无法执行当前请求!');
}
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
// import { RedisService } from './../../redis/redis.service';
import { RedisCacheService } from '@/modules/redisCache/redisCache.service';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(private readonly redisService: RedisCacheService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: redisService.getJwtSecret(),
});
}
/* fromat decode token return */
async validate(payload): Promise<any> {
return payload;
}
}

View File

@@ -0,0 +1,84 @@
import { GlobalConfigService } from '@/modules/globalConfig/globalConfig.service';
import { RedisCacheService } from '@/modules/redisCache/redisCache.service';
import {
HttpException,
HttpStatus,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import * as jwt from 'jsonwebtoken';
import { AuthService } from '../../modules/auth/auth.service';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(
private redisCacheService: RedisCacheService,
private readonly moduleRef: ModuleRef,
private readonly globalConfigService: GlobalConfigService,
private readonly authService: AuthService,
) {
super();
}
async canActivate(context) {
if (!this.redisCacheService) {
this.redisCacheService = this.moduleRef.get(RedisCacheService, {
strict: false,
});
}
const request = context.switchToHttp().getRequest();
// TODO 域名检测
const _domain = request.headers.host;
const token = this.extractToken(request);
request.user = await this.validateToken(token);
await this.redisCacheService.checkTokenAuth(token, request);
return true;
}
private extractToken(request) {
if (!request.headers.authorization) {
if (request.headers.fingerprint) {
let id = request.headers.fingerprint;
/* 超过mysql最大值进行截取 */
if (id > 2147483647) {
id = id.toString().slice(-9);
id = Number(String(Number(id)));
}
const token = this.authService.createTokenFromFingerprint(id);
return token;
}
return null;
}
const parts = request.headers.authorization.split(' ');
if (parts.length !== 2 || parts[0] !== 'Bearer') {
return null;
}
return parts[1];
}
private async validateToken(token) {
try {
const secret = await this.redisCacheService.getJwtSecret();
const decoded = await jwt.verify(token, secret);
return decoded;
} catch (error) {
Logger.debug('用户信息校验失败', 'JwtAuthGuard');
throw new HttpException(
'亲爱的用户,请登录后继续操作,我们正在等您的到来!',
HttpStatus.UNAUTHORIZED,
);
}
}
handleRequest(err, user, info) {
if (err || !user) {
console.log('err: ', err);
throw err || new UnauthorizedException();
}
return user;
}
}

View File

@@ -0,0 +1,19 @@
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { JwtAuthGuard } from './jwtAuth.guard';
@Injectable()
export class SuperAuthGuard extends JwtAuthGuard {
async canActivate(context: ExecutionContext): Promise<boolean> {
const isAuthorized = await super.canActivate(context);
if (!isAuthorized) {
return false;
}
const request = context.switchToHttp().getRequest();
const user = request.user;
if (user && user.role === 'super') {
return true;
} else {
throw new UnauthorizedException('非法操作、非超级管理员无权操作!');
}
}
}

View File

@@ -0,0 +1,32 @@
export const ChatType = {
NORMAL_CHAT: 1, // 普通对话
PAINT: 2, // 绘图
EXTENDED_CHAT: 3, // 拓展性对话
};
/**
* @description: 扣费类型
* @param {type}
* 1: 模型3 模型4 MJ TODO 新版更新已经修改了 TYPE 这里暂不处理
*/
// export const DeductionKey = {
// BALANCE_TYPE: 'balance',
// CHAT_TYPE: 'usesLeft',
// PAINT_TYPE: 'paintCount',
// };
/**
* @description: 账户充值类型
* @param {type}
* 1: 注册赠送 2: 受邀请赠送 3: 邀请人赠送 4: 购买套餐赠送 5: 管理员赠送 6扫码支付 7: 绘画失败退款 8: 签到奖励
*/
export const RechargeType = {
REG_GIFT: 1,
INVITE_GIFT: 2,
REFER_GIFT: 3,
PACKAGE_GIFT: 4,
ADMIN_GIFT: 5,
SCAN_PAY: 6,
DRAW_FAIL_REFUND: 7,
SIGN_IN: 8,
};

View File

@@ -0,0 +1,21 @@
export enum ErrorMessageEnum {
USERNAME_OR_EMAIL_ALREADY_REGISTERED = '用户名或邮箱已注册!',
USER_NOT_FOUND = '用户不存在!',
VERIFICATION_NOT_FOUND = '验证记录不存在!',
VERIFICATION_CODE_EXPIRED = '验证码已过期!',
VERIFICATION_CODE_INVALID = '验证码无效!',
VERIFICATION_CODE_MISMATCH = '验证码不匹配!',
VERIFICATION_CODE_SEND_FAILED = '验证码发送失败!',
VERIFICATION_CODE_SEND_TOO_OFTEN = '验证码发送过于频繁!',
}
export const OpenAiErrorCodeMessage: Record<string, string> = {
400: '[Inter Error] 服务端错误[400]',
401: '[Inter Error] 服务出现错误、请稍后再试一次吧[401]',
403: '[Inter Error] 服务器拒绝访问,请稍后再试 | Server refused to access, please try again later',
429: '[Inter Error] 当前key调用频率过高、请重新对话再试一次吧[429]',
502: '[Inter Error] 错误的网关 | Bad Gateway[502]',
503: '[Inter Error] 服务器繁忙,请稍后再试 | Server is busy, please try again later[503]',
504: '[Inter Error] 网关超时 | Gateway Time-out[504]',
500: '[Inter Error] 服务器繁忙,请稍后再试 | Internal Server Error[500]',
};

View File

@@ -0,0 +1,23 @@
/**
* 任务状态枚举 1: 等待中 2: 绘制中 3: 绘制完成 4: 绘制失败 5: 绘制超时
*/
export enum MidjourneyStatusEnum {
WAITING = 1,
DRAWING = 2,
DRAWED = 3,
DRAWFAIL = 4,
DRAWTIMEOUT = 5,
}
/**
* 绘画动作枚举 1: 绘画 2: 放大 3: 变换 4: 图生图 5: 重新生成 6 无线缩放 7: 单张变化【很大|微小】
*/
export enum MidjourneyActionEnum {
DRAW = 1,
UPSCALE = 2,
VARIATION = 3,
GENERATE = 4,
REGENERATE = 5,
VARY = 6,
ZOOM = 7,
}

View File

@@ -0,0 +1,10 @@
export enum VerificationUseStatusEnum {
UNUSED,
USED,
}
export const ModelsMapCn = {
1: '普通模型',
2: '绘画模型',
3: '特殊模型',
};

View File

@@ -0,0 +1,19 @@
/**
* PENDING: 审核中
* ACTIVE: 正常状态
* LOCKED: 账号锁定
* BLACKLISTED: 黑名单账号
*/
export enum UserStatusEnum {
PENDING,
ACTIVE,
LOCKED,
BLACKLISTED,
}
export const UserStatusErrMsg = {
[UserStatusEnum.PENDING]: '当前账户未激活,请前往邮箱验证或重新发送验证码!',
[UserStatusEnum.ACTIVE]: '当前账户已激活!',
[UserStatusEnum.LOCKED]: '当前账户已锁定,请联系管理员解锁!',
[UserStatusEnum.BLACKLISTED]: '当前账户已被永久封禁!',
};

View File

@@ -0,0 +1,10 @@
/**
* Registration: 注册账户
* PasswordReset: 重置密码
* ChangeEmail: 换绑邮箱
*/
export enum VerificationEnum {
Registration,
PasswordReset,
ChangeEmail,
}

View File

@@ -0,0 +1,111 @@
import { getClientIp } from '@/common/utils/getClientIp';
import { RedisCacheService } from '@/modules/redisCache/redisCache.service';
import {
ExecutionContext,
HttpException,
HttpStatus,
UseInterceptors,
applyDecorators,
createParamDecorator,
} from '@nestjs/common';
// 参数装饰器 - 用于手动检查
export const CheckRateLimit = createParamDecorator(
async (
data: { maxRequests: number; windowMs: number; keyPrefix: string },
ctx: ExecutionContext,
) => {
const { maxRequests = 1000, windowMs = 60000, keyPrefix = 'rate_limit' } = data || {};
const request = ctx.switchToHttp().getRequest();
const redisCacheService = request.app.get(RedisCacheService);
// 获取IP和用户ID
const ip = getClientIp(request);
const userId = request.user?.id || null;
if (!ip && !userId) return;
// 创建唯一标识符
const identifier = userId ? `${ip}_${userId}` : ip;
// Redis键名
const redisKey = `${keyPrefix}:${identifier}`;
// 获取当前计数
const currentCount = await redisCacheService.get({ key: redisKey });
const count = currentCount ? parseInt(currentCount, 10) : 0;
// 检查是否超过限制
if (count >= maxRequests) {
throw new HttpException('请求频率超过限制,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
}
// 更新计数
const windowSeconds = Math.floor(windowMs / 1000);
await redisCacheService.set({ key: redisKey, val: (count + 1).toString() }, windowSeconds);
// 返回当前计数信息
return {
current: count + 1,
limit: maxRequests,
remaining: maxRequests - count - 1,
reset: Date.now() + windowMs,
};
},
);
// 方法装饰器 - 可以直接应用于控制器方法
export function RateLimit(options?: {
maxRequests?: number;
windowMs?: number;
keyPrefix?: string;
}) {
return applyDecorators(
UseInterceptors({
intercept: async (context, next) => {
const { maxRequests = 5, windowMs = 60000, keyPrefix = 'rate_limit' } = options || {};
const request = context.switchToHttp().getRequest();
const redisCacheService = request.app.get(RedisCacheService);
// 获取IP和用户ID
const ip = getClientIp(request);
const userId = request.user?.id || null;
if (!ip && !userId) {
return next.handle();
}
// 创建唯一标识符
const identifier = userId ? `${ip}_${userId}` : ip;
// 获取控制器和方法名称,用于更精确的键名
const controllerName = context.getClass().name;
const handlerName = context.getHandler().name;
// Redis键名
const redisKey = `${keyPrefix}:${controllerName}:${handlerName}:${identifier}`;
// 获取当前计数
const currentCount = await redisCacheService.get({ key: redisKey });
const count = currentCount ? parseInt(currentCount, 10) : 0;
// 检查是否超过限制
if (count >= maxRequests) {
throw new HttpException('请求频率超过限制,请稍后再试', HttpStatus.TOO_MANY_REQUESTS);
}
// 更新计数
const windowSeconds = Math.floor(windowMs / 1000);
await redisCacheService.set({ key: redisKey, val: (count + 1).toString() }, windowSeconds);
// 设置响应头
const response = context.switchToHttp().getResponse();
response.header('X-RateLimit-Limit', maxRequests);
response.header('X-RateLimit-Remaining', maxRequests - count - 1);
response.header('X-RateLimit-Reset', Date.now() + windowMs);
return next.handle();
},
}),
);
}

View File

@@ -0,0 +1,40 @@
import {
Entity,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
DeleteDateColumn,
} from 'typeorm';
@Entity()
export class BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@CreateDateColumn({
type: 'datetime',
length: 0,
nullable: false,
name: 'createdAt',
comment: '创建时间',
})
createdAt: Date;
@UpdateDateColumn({
type: 'datetime',
length: 0,
nullable: false,
name: 'updatedAt',
comment: '更新时间',
})
updatedAt: Date;
@DeleteDateColumn({
type: 'datetime',
length: 0,
nullable: false,
name: 'deletedAt',
comment: '删除时间',
})
deletedAt: Date;
}

View File

@@ -0,0 +1,42 @@
import { Result } from '@/common/result';
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common';
import { Response, Request } from 'express';
@Catch()
export class AllExceptionsFilter<_T> implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const _request = ctx.getRequest<Request>();
// 检查异常是否是 HttpException 类型
if (exception instanceof HttpException) {
const status = exception.getStatus();
const exceptionResponse = exception.getResponse() as any;
// 如果是 ValidationPipe 抛出的异常
if (status === HttpStatus.BAD_REQUEST && Array.isArray(exceptionResponse?.message)) {
response.status(status).json({
code: status,
message: exceptionResponse.message[0],
data: null,
});
return;
}
response.status(status).json({
code: status,
message: exception.message,
data: null,
});
return;
}
// 处理其他类型的异常
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
code: HttpStatus.INTERNAL_SERVER_ERROR,
message: '服务器内部错误',
data: null,
});
}
}

View File

@@ -0,0 +1,23 @@
import { Catch, ArgumentsHost, ExceptionFilter, BadRequestException } from '@nestjs/common';
import { QueryFailedError } from 'typeorm';
@Catch(QueryFailedError)
export class TypeOrmQueryFailedFilter implements ExceptionFilter {
catch(exception: QueryFailedError, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
if ((exception as any).code === 'ER_DUP_ENTRY') {
throw new BadRequestException('该记录已经存在,请勿重复添加!');
} else {
console.log('other query error');
}
response.status(500).json({
statusCode: 500,
timestamp: new Date().toISOString(),
path: request.url,
message: `Database query failed: ${exception.message}`,
});
}
}

View File

@@ -0,0 +1,9 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class RolesGuard implements CanActivate {
canActivate(_context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
return true;
}
}

View File

@@ -0,0 +1,13 @@
import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Request } from 'express';
import { AbortController } from 'abort-controller';
import { Observable } from 'rxjs';
@Injectable()
export class AbortInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest<Request>();
const abortController = new AbortController();
request.abortController = abortController;
return next.handle();
}
}

View File

@@ -0,0 +1,34 @@
import { Result } from '@/common/result';
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { catchError, throwError } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class TransformInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): any {
return next.handle().pipe(
map(data => {
const response = context.switchToHttp().getResponse();
const request = context.switchToHttp().getRequest();
response.statusCode = 200;
/* 微信类支付类通知接口需要原样输出 */
if (request.path.includes('notify')) {
return data;
}
const message = response.status < 400 ? null : response.statusText;
return Result.success(data, message);
}),
catchError(error => {
const statusCode = error.status || 500;
const message = (error.response || 'Internal server error') as string;
return throwError(new HttpException(message, statusCode));
}),
);
}
}

View File

@@ -0,0 +1,149 @@
import { ConsoleLogger, Injectable } from '@nestjs/common';
import util from 'util';
@Injectable()
export class CustomLoggerService extends ConsoleLogger {
private isDev: boolean;
constructor() {
super();
this.isDev = process.env.ISDEV === 'true';
}
/**
* 过滤日志消息中的敏感数据,如 Base64 编码
* @param message 需要过滤的日志消息
* @returns 过滤后的日志消息
*/
private sanitizeLogMessage(message: any): string {
// 处理空值情况
if (message === null || message === undefined) {
return String(message);
}
// 处理对象或数组
if (typeof message === 'object') {
try {
// 使用 util.inspect 确保对象完全序列化,包括循环引用
if (Array.isArray(message)) {
// 数组特殊处理避免JSON.parse可能的问题
const sanitizedArray = [...message];
this.sanitizeDeep(sanitizedArray);
return util.inspect(sanitizedArray, { depth: null, maxArrayLength: null });
} else {
// 对象处理
const clone = JSON.parse(JSON.stringify(message));
this.sanitizeDeep(clone);
return util.inspect(clone, { depth: null, maxArrayLength: null });
}
} catch (e) {
// 如果对象无法序列化(例如循环引用),则转为字符串
try {
return this.sanitizeBase64String(util.inspect(message, { depth: null }));
} catch (err) {
return '[无法序列化的对象]';
}
}
}
// 处理字符串
if (typeof message === 'string') {
return this.sanitizeBase64String(message);
}
// 处理其他类型
return this.sanitizeBase64String(String(message));
}
/**
* 递归处理对象中的所有字符串属性
* @param obj 需要处理的对象
*/
private sanitizeDeep(obj: any): void {
if (!obj || typeof obj !== 'object') return;
if (Array.isArray(obj)) {
// 处理数组
for (let i = 0; i < obj.length; i++) {
const value = obj[i];
if (typeof value === 'string') {
obj[i] = this.sanitizeBase64String(value);
} else if (value && typeof value === 'object') {
this.sanitizeDeep(value);
}
}
return;
}
// 处理对象
for (const key of Object.keys(obj)) {
const value = obj[key];
if (typeof value === 'string') {
obj[key] = this.sanitizeBase64String(value);
} else if (value && typeof value === 'object') {
this.sanitizeDeep(value);
}
}
}
/**
* 过滤字符串中的 Base64 数据
* @param str 需要过滤的字符串
* @returns 过滤后的字符串
*/
private sanitizeBase64String(str: string): string {
if (!str) return str;
// 1. 过滤 data URL 格式的 Base64
str = str.replace(/(data:[^;]+;base64,)[a-zA-Z0-9+/=]{20,}/g, '$1***BASE64_DATA***');
// 2. 过滤常见的 Base64 模式 - 宽松匹配
// 匹配至少有50个连续的Base64字符的字符串
str = str.replace(/([a-zA-Z0-9+/=]{50})[a-zA-Z0-9+/=]{10,}/g, '$1***BASE64_DATA***');
// 3. 特别处理可能在JSON中的Base64引号包围的
str = str.replace(/"([a-zA-Z0-9+/=]{20,})"/g, function (match, p1) {
// 只处理很可能是Base64的长字符串
if (p1.length >= 50 && /^[a-zA-Z0-9+/=]+$/.test(p1)) {
return '"' + p1.substring(0, 20) + '***BASE64_DATA***"';
}
return match;
});
// 4. 特别处理 url 字段中的 Base64 数据
str = str.replace(/("url"\s*:\s*")([a-zA-Z0-9+/=]{20,})(")/g, '$1***BASE64_DATA***$3');
return str;
}
log(message: any, context?: string) {
const sanitized = this.sanitizeLogMessage(message);
super.log(sanitized, context);
}
error(message: any, trace?: string, context?: string) {
const sanitized = this.sanitizeLogMessage(message);
super.error(sanitized, trace, context);
}
warn(message: any, context?: string) {
if (this.isDev) {
const sanitized = this.sanitizeLogMessage(message);
super.warn(sanitized, context);
}
}
debug(message: any, context?: string) {
if (this.isDev) {
const sanitized = this.sanitizeLogMessage(message);
super.debug(sanitized, context);
}
}
verbose(message: any, context?: string) {
if (this.isDev) {
const sanitized = this.sanitizeLogMessage(message);
super.verbose(sanitized, context);
}
}
}

View File

@@ -0,0 +1,69 @@
import { Injectable, Logger, NestMiddleware } from '@nestjs/common';
import { NextFunction, Request, Response } from 'express';
import { XMLParser } from 'fast-xml-parser';
import * as getRawBody from 'raw-body';
@Injectable()
export class FastXmlMiddleware implements NestMiddleware {
private xmlParser: XMLParser;
constructor() {
// 配置XML解析器
this.xmlParser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
allowBooleanAttributes: true,
parseAttributeValue: true,
trimValues: true,
isArray: name => {
// 使所有元素都成为数组与express-xml-bodyparser行为一致
return true;
},
});
}
use(req: Request, res: Response, next: NextFunction) {
// 只处理XML内容类型的请求
const contentType = req.headers['content-type'] || '';
if (!contentType.includes('application/xml') && !contentType.includes('text/xml')) {
return next();
}
Logger.debug(`收到XML请求 - Content-Type: ${contentType}`, 'FastXmlMiddleware');
// 使用Promise处理异步操作
getRawBody(req, {
length: req.headers['content-length'],
limit: '1mb',
encoding: true,
})
.then(rawBody => {
// 记录原始XML内容
Logger.debug(`原始XML内容: ${rawBody}`, 'FastXmlMiddleware');
try {
// 解析XML
const parsedXml = this.xmlParser.parse(rawBody);
// 记录解析后的结构
Logger.debug(`XML解析结果: ${JSON.stringify(parsedXml, null, 2)}`, 'FastXmlMiddleware');
// 修正: 直接将解析后的结构赋值给req.body
// 检查是否有xml属性如果有则直接使用解析后的xml结构否则保持整个结构
req.body = parsedXml.xml ? { xml: parsedXml.xml } : parsedXml;
// 记录最终请求体结构
Logger.debug(`解析后的req.body结构已设置`, 'FastXmlMiddleware');
next();
} catch (parseError) {
Logger.error(`XML解析错误: ${parseError.message}`, 'FastXmlMiddleware');
next(parseError);
}
})
.catch(error => {
Logger.error(`获取请求体错误: ${error.message}`, 'FastXmlMiddleware');
next(error);
});
}
}

View File

@@ -0,0 +1,13 @@
import { Injectable, NestMiddleware } from '@nestjs/common';
import * as bodyParser from 'body-parser';
const bodyParserMiddleware = bodyParser.text({
type: 'application/xml',
});
@Injectable()
export class XMLMiddleware implements NestMiddleware {
use(req: any, res: any, next: () => void) {
bodyParserMiddleware(req, res, next);
}
}

View File

@@ -0,0 +1,21 @@
export class Result<T> {
code: number;
data?: T;
success: boolean;
message?: string;
constructor(code: number, success: boolean, data?: T, message?: string) {
this.code = code;
this.data = data;
this.success = success;
this.message = message;
}
static success<T>(data?: T, message = '请求成功'): Result<T> {
return new Result<T>(200, true, data, message);
}
static fail<T>(code: number, message = '请求失败', data?: T): Result<T> {
return new Result<T>(code, false, data, message);
}
}

View File

@@ -0,0 +1,13 @@
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
const swaggerOptions = new DocumentBuilder()
.setTitle('AIWeb Team api document')
.setDescription('AIWeb Team api document')
.setVersion('1.0.0')
.addBearerAuth()
.build();
export function createSwagger(app) {
const document = SwaggerModule.createDocument(app, swaggerOptions);
SwaggerModule.setup('/swagger/docs', app, document);
}

View File

@@ -0,0 +1,23 @@
import * as crypto from 'crypto';
const encryptionKey = 'bf3c116f2470cb4che9071240917c171';
const initializationVector = '518363fh72eec1v4';
const algorithm = 'aes-256-cbc';
export function encrypt(text: string): string {
const cipher = crypto.createCipheriv(algorithm, encryptionKey, initializationVector);
let encrypted = cipher.update(text, 'utf8', 'base64');
encrypted += cipher.final('base64');
return encrypted;
}
export function decrypt(text: string): string {
try {
const decipher = crypto.createDecipheriv(algorithm, encryptionKey, initializationVector);
let decrypted = decipher.update(text, 'base64', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
process.exit(1);
}
}

View File

@@ -0,0 +1,20 @@
import { Logger } from '@nestjs/common';
import axios from 'axios';
/**
* 将URL转换为Base64
* @param url - 需要转换的URL
* @returns 转换后的Base64字符串
*/
export async function convertUrlToBase64(url: string): Promise<string> {
try {
const response = await axios.get(url, { responseType: 'arraybuffer' });
const buffer = Buffer.from(response.data, 'binary'); // 获取图片的二进制数据
const base64Data = `data:${response.headers['content-type']};base64,${buffer.toString(
'base64',
)}`;
return base64Data;
} catch (error) {
Logger.error('下载图片失败', error);
return url;
}
}

View File

@@ -0,0 +1,24 @@
/**
* 规范化API基础URL
* @param baseUrl - 需要规范化的API基础URL
* @returns 规范化后的URL字符串
*/
export async function correctApiBaseUrl(baseUrl: string) {
if (!baseUrl) return '';
// 去除两端空格
let url = baseUrl.trim();
// 如果URL以斜杠'/'结尾,则移除这个斜杠
if (url.endsWith('/')) {
url = url.slice(0, -1);
}
// 检查URL是否已包含任何版本标记包括常见的模式如/v1, /v1beta, /v1alpha等
if (!/\/v\d+(?:beta|alpha)?/.test(url)) {
// 如果不包含任何版本号,添加 /v1
return `${url}/v1`;
}
return url;
}

View File

@@ -0,0 +1,5 @@
import { v1 as uuidv1 } from 'uuid';
export function createOrderId(): string {
return uuidv1().toString().replace(/-/g, '');
}

View File

@@ -0,0 +1,5 @@
export function createRandomCode(): number {
const min = 100000;
const max = 999999;
return Math.floor(Math.random() * (max - min + 1) + min);
}

View File

@@ -0,0 +1,12 @@
export function generateRandomString(): string {
const length = 10;
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
let result = '';
for (let i = 0; i < length; i++) {
const randomIndex: number = Math.floor(Math.random() * characters.length);
result += characters.charAt(randomIndex);
}
return result;
}

View File

@@ -0,0 +1,8 @@
export function createRandomNonceStr(len: number): string {
const data = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let str = '';
for (let i = 0; i < len; i++) {
str += data.charAt(parseInt((Math.random() * data.length).toFixed(0), 10));
}
return str;
}

View File

@@ -0,0 +1,6 @@
import { Guid } from 'guid-typescript';
export function createRandomUid(): string {
const uuid = Guid.create();
return uuid.toString().substr(0, 10).replace('-', '');
}

View File

@@ -0,0 +1,39 @@
import * as dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import * as b from 'dayjs/plugin/timezone';
import * as a from 'dayjs/plugin/utc';
dayjs.locale('zh-cn');
dayjs.extend(a);
dayjs.extend(b);
dayjs.tz.setDefault('Asia/Shanghai');
export function formatDate(date: string | number | Date, format = 'YYYY-MM-DD HH:mm:ss'): string {
return dayjs(date).format(format);
}
export function formatCreateOrUpdateDate(input, format = 'YYYY-MM-DD HH:mm:ss'): any[] {
if (Array.isArray(input)) {
return input.map((t: any) => {
t.createdAt = t?.createdAt ? dayjs(t.createdAt).format(format) : dayjs().format(format);
t.updatedAt = t?.updatedAt ? dayjs(t.updatedAt).format(format) : dayjs().format(format);
return t;
});
} else {
let obj: any = {};
try {
obj = JSON.parse(JSON.stringify(input));
} catch (error) {}
obj?.createdAt && (obj.createdAt = dayjs(obj.createdAt).format(format));
obj?.updatedAt && (obj.updatedAt = dayjs(obj.updatedAt).format(format));
return obj;
}
}
export function isExpired(createdAt: Date, days: number): boolean {
const expireDate = new Date(createdAt.getTime() + days * 24 * 60 * 60 * 1000);
const now = new Date();
return now > expireDate;
}
export default dayjs;

View File

@@ -0,0 +1,420 @@
import * as crypto from 'crypto';
/**
* 豆包签名配置接口
*/
export interface DoubaoSignatureConfig {
accessKeyId: string;
secretAccessKey: string;
region?: string;
service?: string;
}
/**
* 签名结果接口
*/
export interface SignatureResult {
authorization: string;
xDate: string;
headers: Record<string, string>;
}
/**
* 豆包火山引擎API签名工具类
* 完全按照官方文档的标准HMAC-SHA256签名算法实现
*/
export class DoubaoSignature {
private accessKeyId: string;
private secretAccessKey: string;
private region: string;
private service: string;
constructor(
accessKeyId: string,
secretAccessKey: string,
region: string = 'cn-north-1',
service: string = 'cv',
) {
this.accessKeyId = accessKeyId;
this.secretAccessKey = secretAccessKey;
this.region = region;
this.service = service;
}
/**
* 生成UTC时间字符串
* @returns ISO 8601格式的UTC时间字符串 (YYYYMMDD'T'HHMMSS'Z')
*/
private generateTimestamp(): string {
const now = new Date();
return now
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}/, '');
}
/**
* 获取日期字符串 (YYYYMMDD)
* @param timestamp 时间戳
* @returns 日期字符串
*/
private getDateString(timestamp: string): string {
return timestamp.substring(0, 8);
}
/**
* URI编码按照火山引擎标准
* @param str 要编码的字符串
* @returns 编码后的字符串
*/
private uriEscape(str: string): string {
return encodeURIComponent(str).replace(
/[!'()*]/g,
c => '%' + c.charCodeAt(0).toString(16).toUpperCase(),
);
}
/**
* 计算SHA256哈希
* @param data 数据
* @returns 十六进制哈希值
*/
private sha256(data: string): string {
return crypto.createHash('sha256').update(data, 'utf8').digest('hex');
}
/**
* 计算HMAC-SHA256
* @param key 密钥
* @param data 数据
* @returns Buffer
*/
private hmacSha256(key: string | Buffer, data: string): Buffer {
return crypto
.createHmac('sha256', key as any)
.update(data, 'utf8')
.digest();
}
/**
* 构建规范请求字符串(按照官方示例优化)
* @param method HTTP方法
* @param uri 请求URI
* @param queryParams 查询参数
* @param headers 请求头
* @param payload 请求体
* @returns 规范请求字符串
*/
private buildCanonicalRequest(
method: string,
uri: string,
queryParams: Record<string, any>,
headers: Record<string, string>,
payload: string,
): { canonicalRequest: string; signedHeaders: string } {
// 1. HTTP方法
const httpMethod = method.toUpperCase();
// 2. 规范URI
const canonicalUri = uri || '/';
// 3. 规范查询字符串(按照官方示例格式)
const sortedParams = Object.keys(queryParams)
.sort()
.map(key => `${this.uriEscape(key)}=${this.uriEscape(queryParams[key])}`)
.join('&');
// 4. 计算请求体哈希(用于 x-content-sha256 头部)
const payloadHash = this.sha256(payload);
// 5. 按照官方示例,只包含特定的头部进行签名
// 官方示例只包含: host, x-content-sha256, x-date
const signedHeadersMap: Record<string, string> = {};
// 添加必需的头部
if (headers['host'] || headers['Host']) {
signedHeadersMap['host'] = (headers['host'] || headers['Host']).trim();
}
if (headers['x-date']) {
signedHeadersMap['x-date'] = headers['x-date'].trim();
}
// 添加 x-content-sha256
signedHeadersMap['x-content-sha256'] = payloadHash;
// 6. 按字母顺序排序头部键
const sortedHeaderKeys = Object.keys(signedHeadersMap).sort();
const canonicalHeaders =
sortedHeaderKeys.map(key => `${key}:${signedHeadersMap[key]}`).join('\n') + '\n';
// 7. 签名头部
const signedHeaders = sortedHeaderKeys.join(';');
// 8. 构建规范请求(按照官方格式)
const canonicalRequest = [
httpMethod,
canonicalUri,
sortedParams,
canonicalHeaders,
signedHeaders,
payloadHash,
].join('\n');
// 规范请求构建完成
return { canonicalRequest, signedHeaders };
}
/**
* 构建待签字符串
* @param timestamp 时间戳
* @param canonicalRequest 规范请求
* @returns 待签字符串
*/
private buildStringToSign(timestamp: string, canonicalRequest: string): string {
const algorithm = 'HMAC-SHA256';
const requestDate = timestamp;
const credentialScope = `${this.getDateString(timestamp)}/${this.region}/${
this.service
}/request`;
const hashedCanonicalRequest = this.sha256(canonicalRequest);
const stringToSign = [algorithm, requestDate, credentialScope, hashedCanonicalRequest].join(
'\n',
);
// 待签字符串构建完成
return stringToSign;
}
/**
* 计算签名密钥
* @param timestamp 时间戳
* @returns 签名密钥
*/
private calculateSigningKey(timestamp: string): Buffer {
const dateString = this.getDateString(timestamp);
// kSecret = Your Secret Access Key
const kSecret = this.secretAccessKey;
// kDate = HMAC(kSecret, Date)
const kDate = this.hmacSha256(kSecret, dateString);
// kRegion = HMAC(kDate, Region)
const kRegion = this.hmacSha256(kDate, this.region);
// kService = HMAC(kRegion, Service)
const kService = this.hmacSha256(kRegion, this.service);
// kSigning = HMAC(kService, "request")
const kSigning = this.hmacSha256(kService, 'request');
// 签名密钥计算完成
return kSigning;
}
/**
* 计算签名
* @param signingKey 签名密钥
* @param stringToSign 待签字符串
* @returns 签名
*/
private calculateSignature(signingKey: Buffer, stringToSign: string): string {
const signature = crypto
.createHmac('sha256', signingKey as any)
.update(stringToSign, 'utf8')
.digest('hex');
// 签名计算完成
return signature;
}
/**
* 生成Authorization头部签名按照官方示例优化
* @param method HTTP方法
* @param uri 请求URI
* @param queryParams 查询参数
* @param headers 请求头不包含Authorization
* @param payload 请求体
* @param timestamp 可选的时间戳,不提供则自动生成
* @returns 包含Authorization头部、X-Date和完整头部的对象
*/
public generateHeaderSignature(
method: string,
uri: string,
queryParams: Record<string, any> = {},
headers: Record<string, string> = {},
payload: string = '',
timestamp?: string,
): { authorization: string; xDate: string; headers: Record<string, string> } {
const xDate = timestamp || headers['x-date'] || this.generateTimestamp();
// 确保headers中包含必要的头部
const allHeaders = {
...headers,
'x-date': xDate,
};
// 如果没有host头部添加默认值
if (!allHeaders['host'] && !allHeaders['Host']) {
allHeaders['host'] = 'visual.volcengineapi.com';
}
// 豆包签名请求参数准备完成
// 1. 构建规范请求
const { canonicalRequest, signedHeaders } = this.buildCanonicalRequest(
method,
uri,
queryParams,
allHeaders,
payload,
);
// 2. 构建待签字符串
const stringToSign = this.buildStringToSign(xDate, canonicalRequest);
// 3. 计算签名密钥
const signingKey = this.calculateSigningKey(xDate);
// 4. 计算签名
const signature = this.calculateSignature(signingKey, stringToSign);
// 5. 构建Authorization头部
const credentialScope = `${this.getDateString(xDate)}/${this.region}/${this.service}/request`;
const authorization = `HMAC-SHA256 Credential=${this.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
// 6. 构建完整的请求头部(按照官方示例格式)
const payloadHash = this.sha256(payload);
const finalHeaders = {
Authorization: authorization,
'Content-Type': 'application/json',
Host: allHeaders['host'] || allHeaders['Host'],
'X-Content-Sha256': payloadHash,
'X-Date': xDate,
// 不包含小写的 host避免重复
};
// 豆包签名Authorization头部构建完成
// 豆包签名最终头部构建完成
return {
authorization,
xDate,
headers: finalHeaders,
};
}
/**
* 验证签名是否有效
* @param signature 要验证的签名
* @param method HTTP方法
* @param uri 请求URI
* @param queryParams 查询参数
* @param headers 请求头
* @param payload 请求体
* @param timestamp 时间戳
* @returns 是否有效
*/
public verifySignature(
signature: string,
method: string,
uri: string,
queryParams: Record<string, any> = {},
headers: Record<string, string> = {},
payload: string = '',
timestamp: string,
): boolean {
try {
const { authorization } = this.generateHeaderSignature(
method,
uri,
queryParams,
headers,
payload,
timestamp,
);
const expectedSignature = authorization.split('Signature=')[1];
return signature === expectedSignature;
} catch (error) {
console.error(`[DEBUG] 豆包签名 - 验证签名失败:`, error);
return false;
}
}
}
/**
* 创建豆包签名实例的工厂函数
* @param accessKeyId 访问密钥ID
* @param secretAccessKey 秘密访问密钥
* @param region 区域默认cn-north-1
* @param service 服务名默认cv
* @returns DoubaoSignature实例
*/
export function createDoubaoSignature(
accessKeyId: string,
secretAccessKey: string,
region: string = 'cn-north-1',
service: string = 'cv',
): DoubaoSignature {
return new DoubaoSignature(accessKeyId, secretAccessKey, region, service);
}
/**
* 便捷的签名生成函数
* @param config 签名配置
* @param request 请求信息
* @returns 签名结果
*/
export function generateDoubaoSignature(
config: DoubaoSignatureConfig,
request: {
method: string;
uri: string;
queryParams?: Record<string, any>;
headers?: Record<string, string>;
payload?: string;
host?: string;
},
): SignatureResult {
const signer = createDoubaoSignature(
config.accessKeyId,
config.secretAccessKey,
config.region,
config.service,
);
// 从headers中获取host或使用传入的host或使用默认值
const host =
request.headers?.['host'] ||
request.headers?.['Host'] ||
request.host ||
'visual.volcengineapi.com';
// 确保headers中包含正确的Host
const headersWithHost = {
...request.headers,
host: host,
};
console.log(`[DEBUG] 豆包签名调试 - Host: ${host}`);
console.log(`[DEBUG] 豆包签名调试 - Headers: ${JSON.stringify(headersWithHost)}`);
console.log(`[DEBUG] 豆包签名调试 - Payload: ${request.payload?.substring(0, 100)}...`);
const {
authorization,
xDate,
headers: signedHeaders,
} = signer.generateHeaderSignature(
request.method,
request.uri,
request.queryParams || {},
headersWithHost,
request.payload || '',
);
return { authorization, xDate, headers: signedHeaders };
}

View File

@@ -0,0 +1,11 @@
export function atob(str) {
return Buffer.from(str, 'base64').toString('utf-8');
}
export const copyRightMsg = [
'agxoTstMY8m+DJO89Iwy4zqcFTqlcj/Fa/erMTvn0IexetXaDttr4K/BN2+RbtfouXOeFjPDYnxOfQ+IIpuJ3PmtyHAzmlGFls/HvBDeh6EXAQ3waALbvK9Ue96soAb5/3Tv6VuZE7npISqXiYhI6Vqx4yDVYf6vUUkEO9jvVotWQkLOLkr6M/guLK6sik/ZOgHvSlDYKAv79NFJJ0Tt0WkH2SyN8l+woMiWVTOKkdE=',
'nXdXi8UU7J5av2eDOFjxQWlZDa+3bdASE4UwpqT6B11XSCweKKuzHxmFO2wx45iVlib/V0tt+NbEcOQZtzEWKqHsREkwEb5aqVCUl2Kj4nJeEFId2iyvY6MWEV1lHtCY+htpJoyqwQJc7yeNfpTl2SLBubWk77p4AHei1QFEs1rpOOwyE79lF0RqzY/Cpzhs',
'VjVCGib1VFp7hNynpKGQPUrX+ishpxi2u5a4txHXzk2nyUP1NZfIomEDmGhDTQ7VRJLox+8urtVG1CBBSct1v+4OA2ucAcDUFoy1H1Kl1z+dndVcNU6gz5YGnDppsxY8uGFAVGsWrDl2DIOKxk7kMURaRiQCXCHRF/3sLGyIEmE6KL9Q4kDInB6vuzBScxupFShMXTq2XrOhwRgn2elcig==',
'ZPcz1IaPDMGI3Yn9sm4QOT0qCZo7yZbJl4/c2RTrhUKINkjGB5yb0yN5vAnLtt/o8cmpoOoH3PUSOOWQa9aKD86NWK+1r8wBOVjwXZOpp2gbB1ZJLbWvjRbENvEJxVsLROXnpNDqUXVGxFMaIt+gmEi3Rp0thqC1soXUpvM1zqU4+LkQmunR7UytvzwXEmXBlIfPwz5hv+n/lxDsw526KWixC3jLLpeijw5433Zh7cI=',
'YPo1HNzS6p6190ku4f1PQENUBa/ip+v+6sPuQXVyAn3axo6SLKQBszNr3PAW2EzWhZLy2o+nBgr3o3IOy9OgNit1JHrCklpVp172wbGDKh8sB8HCXyJoRv3BaZVY5UhyhpV5K+4nPoM2RUwvIGONUGFPQfPQv9N8MS8UCL7UnWYcVLzxWo0ZDg+UXFRr7NhXKu7KQ7e1+Wiqm0qE+olfDVowi4pGDRGrYL154wEEJUo=',
];

View File

@@ -0,0 +1,11 @@
export function formatUrl(url: string): string {
// 去除空格
let formattedUrl = url.replace(/\s+/g, '');
// 去除最后一位的 '/',如果有的话
if (formattedUrl.endsWith('/')) {
formattedUrl = formattedUrl.slice(0, -1);
}
return formattedUrl;
}

View File

@@ -0,0 +1,6 @@
import { v4 as uuidv4 } from 'uuid';
export function generateCramiCode(): string {
const code = uuidv4().replace(/-/g, '').slice(0, 16);
return code;
}

View File

@@ -0,0 +1,26 @@
import { Request } from 'express';
function getFirstValidIp(ipString: string): string {
const ips = ipString.split(',').map(ip => ip.trim());
// 可以在这里加入对IP地址有效性的额外验证
return ips.find(ip => isValidIp(ip)) || '';
}
function isValidIp(ip: string): boolean {
return /^\d{1,3}(\.\d{1,3}){3}$/.test(ip) || /^::ffff:\d{1,3}(\.\d{1,3}){3}$/.test(ip);
}
export function getClientIp(req: Request): string {
const forwardedFor = req.header('x-forwarded-for');
let clientIp = forwardedFor ? getFirstValidIp(forwardedFor) : '';
if (!clientIp) {
clientIp = req.connection.remoteAddress || req.socket.remoteAddress || '';
}
if (clientIp.startsWith('::ffff:')) {
clientIp = clientIp.substring(7);
}
return clientIp;
}

View File

@@ -0,0 +1,14 @@
export function getDiffArray(aLength: number, bLength: number, str: string): string[] {
const a = Array.from({ length: aLength }, (_, i) => i + 1);
const b = Array.from({ length: bLength }, (_, i) => i + 1);
const diffArray: string[] = [];
for (let i = 0; i < a.length; i++) {
if (!b.includes(a[i])) {
diffArray.push(`${str}${a[i]}`);
}
}
return diffArray;
}

View File

@@ -0,0 +1,4 @@
export function getRandomItem<T>(array: T[]): T {
const randomIndex = Math.floor(Math.random() * array.length);
return array[randomIndex];
}

View File

@@ -0,0 +1,7 @@
export function getRandomItemFromArray<T>(array: T[]): T | null {
if (array.length === 0) {
return null;
}
const randomIndex = Math.floor(Math.random() * array.length);
return array[randomIndex];
}

View File

@@ -0,0 +1,29 @@
import { encode } from 'gpt-tokenizer';
export const getTokenCount = async (input: any): Promise<number> => {
let text = '';
if (Array.isArray(input)) {
// 如果输入是数组,处理消息数组
text = input.reduce((pre: string, cur: any) => {
if (Array.isArray(cur.content)) {
const contentText = cur.content
.filter((item: { type: string }) => item.type === 'text')
.map((item: { text: any }) => item.text)
.join(' ');
return pre + contentText;
} else {
return pre + (cur.content || '');
}
}, '');
} else if (typeof input === 'string') {
// 如果输入是字符串,直接处理
text = input;
} else if (input) {
// 如果输入是其他类型,将其转换为字符串处理
text = String(input);
}
text = text.replace(/<\|endoftext\|>/g, '');
return encode(text).length;
};

View File

@@ -0,0 +1,43 @@
import axios from 'axios';
export function handleError(error: { response: { status: any }; message: string }) {
let message = '发生未知错误,请稍后再试';
if (axios.isAxiosError(error) && error.response) {
switch (error.response.status) {
case 400:
message = '发生错误400 Bad Request - 请求因格式错误无法被服务器处理。';
break;
case 401:
message = '发生错误401 Unauthorized - 请求要求进行身份验证。';
break;
case 403:
message = '发生错误403 Forbidden - 服务器拒绝执行请求。';
break;
case 404:
message = '发生错误404 Not Found - 请求的资源无法在服务器上找到。';
break;
case 500:
message = '发生错误500 Internal Server Error - 服务器内部错误,无法完成请求。';
break;
case 502:
message =
'发生错误502 Bad Gateway - 作为网关或代理工作的服务器从上游服务器收到无效响应。';
break;
case 503:
message =
'发生错误503 Service Unavailable - 服务器暂时处于超负载或维护状态,无法处理请求。';
break;
// 你可以继续添加其他你认为常见的HTTP错误状态码及其解释
default:
// message = `发生错误:${error.response.status} - ${error.response.statusText}`;
break;
}
} else {
// 处理非Axios错误
message = error.message || message;
}
// 返回处理后的错误信息
return message;
}

View File

@@ -0,0 +1,10 @@
export function hideString(input: string, str?: string): string {
const length = input.length;
const start = input.slice(0, (length - 10) / 2);
const end = input.slice((length + 10) / 2, length);
const hidden = '*'.repeat(10);
if (str) {
return `**********${str}**********`;
}
return `${start}${hidden}${end}`;
}

View File

@@ -0,0 +1,26 @@
export * from './base';
export * from './convertUrlToBase64';
export * from './createOrderId';
export * from './createRandomCode';
export * from './createRandomInviteCode';
export * from './createRandomNonceStr';
export * from './createRandomUid';
export * from './date';
export * from './encrypt';
export * from './fromatUrl';
export * from './generateCrami';
export * from './getClientIp';
export * from './getDiffArray';
export * from './getRandomItem';
export * from './getRandomItemFromArray';
export * from './getTokenCount';
export * from './handleError';
export * from './hideString';
export * from './maskCrami';
export * from './maskEmail';
export * from './maskIpAddress';
export * from './removeSpecialCharacters';
export * from './removeThinkTags';
export * from './tools';
export * from './utcformatTime';
export * from './correctApiBaseUrl';

View File

@@ -0,0 +1,8 @@
export function maskCrami(str: string): string {
if (str.length !== 16) {
throw new Error('Invalid input');
}
const masked = str.substring(0, 6) + '****' + str.substring(10);
return masked;
}

View File

@@ -0,0 +1,11 @@
export function maskEmail(email: string): string {
if (!email) return '';
const atIndex = email.indexOf('@');
if (atIndex <= 1) {
return email;
}
const firstPart = email.substring(0, atIndex - 1);
const lastPart = email.substring(atIndex);
const maskedPart = '*'.repeat(firstPart.length - 1);
return `${firstPart.charAt(0)}${maskedPart}${email.charAt(atIndex - 1)}${lastPart}`;
}

View File

@@ -0,0 +1,6 @@
export function maskIpAddress(ipAddress: string): string {
if (!ipAddress) return '';
const ipArray = ipAddress.split('.');
ipArray[2] = '***';
return ipArray.join('.');
}

View File

@@ -0,0 +1,3 @@
export function removeSpecialCharacters(inputString) {
return inputString.replace(/[^\w\s-]/g, '');
}

View File

@@ -0,0 +1,30 @@
/**
* 删除以 <think> 开头到 </think> 之间的内容
* @param content - 需要处理的内容
* @returns 处理后的内容
*/
export function removeThinkTags(content: any) {
// 如果 content 为 null 或 undefined直接返回原值
if (content === null || content === undefined) {
return content;
}
if (Array.isArray(content)) {
// 如果是数组,遍历其中的每个元素,处理其中的文本内容
return content.map(item => {
if (item && item.type === 'text' && typeof item.text === 'string') {
// 对文本内容进行<think>标签处理
item.text = item.text.replace(/<think>[\s\S]*?<\/think>/g, '');
}
return item;
});
}
// 如果是普通文本,直接删除<think>标签
if (typeof content === 'string') {
return content.replace(/<think>[\s\S]*?<\/think>/g, '');
}
// 如果既不是数组也不是字符串,原样返回
return content;
}

View File

@@ -0,0 +1,6 @@
export function isNotEmptyString(value: any): boolean {
return typeof value === 'string' && value.length > 0;
}
// === await eval('import("module")');
export const importDynamic = new Function('modulePath', 'return import(modulePath)');

View File

@@ -0,0 +1,14 @@
export function utcToShanghaiTime(utcTime: string, format = 'YYYY/MM/DD hh:mm:ss'): string {
const date = new Date(utcTime);
const shanghaiTime = date.getTime() + 8 * 60 * 60 * 1000;
const shanghaiDate = new Date(shanghaiTime);
let result = format.replace('YYYY', shanghaiDate.getFullYear().toString());
result = result.replace('MM', `0${shanghaiDate.getMonth() + 1}`.slice(-2));
result = result.replace('DD', `0${shanghaiDate.getDate()}`.slice(-2));
result = result.replace('hh', `0${shanghaiDate.getHours()}`.slice(-2));
result = result.replace('mm', `0${shanghaiDate.getMinutes()}`.slice(-2));
result = result.replace('ss', `0${shanghaiDate.getSeconds()}`.slice(-2));
return result;
}

179
service/src/main.ts Normal file
View File

@@ -0,0 +1,179 @@
import { TypeOrmQueryFailedFilter } from '@/common/filters/typeOrmQueryFailed.filter';
import { TransformInterceptor } from '@/common/interceptors/transform.interceptor';
import { CustomLoggerService } from '@/common/logger/custom-logger.service';
import { FastXmlMiddleware } from '@/common/middleware/fast-xml-middleware';
import { Logger, RequestMethod, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as compression from 'compression';
import { randomBytes } from 'crypto';
import * as Dotenv from 'dotenv';
import * as fs from 'fs';
import Redis from 'ioredis';
import * as path from 'path';
import 'reflect-metadata';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/allExceptions.filter';
Dotenv.config({ path: '.env' });
/**
* 查找文件的多种可能路径
* @param filename 文件名
* @returns 找到的文件路径或null
*/
function findFilePath(filename: string): string | null {
const possiblePaths = [
path.join(process.cwd(), filename), // 当前工作目录
path.join(__dirname, '..', filename), // 应用根目录
path.join(__dirname, filename), // 与主程序同级
path.resolve(filename), // 绝对路径解析
path.join(process.cwd(), '..', filename), // 上级目录
path.join(process.cwd(), 'dist', filename), // dist目录
];
for (const filePath of possiblePaths) {
if (fs.existsSync(filePath)) {
return filePath;
}
}
return null;
}
async function bootstrap() {
console.log('\n======================================');
console.log(' 99AI 服务启动中... ');
console.log('======================================\n');
const redis = new Redis({
host: process.env.REDIS_HOST,
port: Number(process.env.REDIS_PORT),
password: process.env.REDIS_PASSWORD,
db: Number(process.env.REDIS_DB || 0),
});
// 尝试获取现有的 JWT_SECRET
const existingSecret = await redis.get('JWT_SECRET');
if (!existingSecret) {
// 如果不存在,生成新的 JWT_SECRET
const jwtSecret = randomBytes(256).toString('base64');
Logger.log('Generating and setting new JWT_SECRET');
await redis.set('JWT_SECRET', jwtSecret);
}
// 导入初始化数据库函数
const { initDatabase } = require('./modules/database/initDatabase');
const app = await NestFactory.create(AppModule, {
bufferLogs: true,
logger: ['log', 'error', 'warn', 'debug', 'verbose'],
});
// 在应用配置后,但在监听端口前初始化数据库表结构
try {
Logger.log('正在预初始化数据库结构...', 'Bootstrap');
await initDatabase();
Logger.log('数据库结构预初始化完成', 'Bootstrap');
} catch (dbError) {
Logger.error(`数据库预初始化失败: ${dbError.message}`, 'Bootstrap');
// 即使失败也继续启动应用
}
// 根据环境变量设置全局 Logger
app.useLogger(app.get(CustomLoggerService));
// 使用我们的自定义XML中间件替代express-xml-bodyparser
const xmlMiddleware = new FastXmlMiddleware();
app.use(xmlMiddleware.use.bind(xmlMiddleware));
app.use(
// Re-enable compression
compression({
filter: (req, res) => {
// 对流式响应路由禁用压缩
if (req.path.includes('/api/chatgpt/chat-process')) {
return false;
}
return compression.filter(req, res);
},
}),
);
// 启用并配置 CORS
app.enableCors({
origin: '*', // 或者配置允许的具体域名
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
preflightContinue: false,
optionsSuccessStatus: 204,
});
// app.enableCors();
app.setGlobalPrefix('/api', {
exclude: [{ path: '*', method: RequestMethod.GET }], // 排除GET请求的通配符路由
});
app.useGlobalInterceptors(new TransformInterceptor()); // Re-enable TransformInterceptor
app.useGlobalFilters(new TypeOrmQueryFailedFilter());
app.useGlobalFilters(new AllExceptionsFilter()); // Re-enable AllExceptionsFilter
app.useGlobalPipes(new ValidationPipe());
app.getHttpAdapter().getInstance().set('views', 'templates/pages');
app.getHttpAdapter().getInstance().set('view engine', 'hbs');
// 只在测试环境下启用Swagger
if (process.env.ISDEV === 'true') {
const config = new DocumentBuilder()
.setTitle('99AI API')
.setDescription('99AI服务API文档')
.setVersion('1.0')
.addBearerAuth()
.build();
const document = SwaggerModule.createDocument(app, config);
// 添加全局响应定义
const responseSchema = {
type: 'object',
properties: {
code: { type: 'number', example: 200 },
data: { type: 'object' },
success: { type: 'boolean', example: true },
message: { type: 'string', example: '请求成功' },
},
};
// 为每个路由添加标准响应格式
Object.values(document.paths).forEach(path => {
Object.values(path).forEach(method => {
method.responses = {
...method.responses,
'200': {
description: '成功响应',
content: {
'application/json': {
schema: responseSchema,
},
},
},
};
});
});
SwaggerModule.setup('api-docs', app, document);
Logger.log(
'Swagger API文档已启用: http://localhost:' + (process.env.PORT || 3000) + '/api-docs',
'Main',
);
}
const PORT = process.env.PORT || 3000;
const server = await app.listen(PORT, () => {
console.log('\n======================================');
console.log(` 服务启动成功: http://localhost:${PORT}`);
console.log('======================================\n');
});
server.timeout = 5 * 60 * 1000;
}
bootstrap();

View File

@@ -0,0 +1,867 @@
import { handleError } from '@/common/utils';
import { correctApiBaseUrl } from '@/common/utils/correctApiBaseUrl';
import { Injectable, Logger } from '@nestjs/common';
import OpenAI from 'openai';
import { GlobalConfigService } from '../../globalConfig/globalConfig.service';
import { NetSearchService } from '../search/netSearch.service';
// 引入其他需要的模块或服务
@Injectable()
export class OpenAIChatService {
constructor(
private readonly globalConfigService: GlobalConfigService,
private readonly netSearchService: NetSearchService,
) {}
/**
* 处理深度思考逻辑
* @param messagesHistory 消息历史
* @param inputs 输入参数
* @param result 结果对象
* @returns 是否应该终止请求
*/
private async handleDeepThinking(
messagesHistory: any,
inputs: {
apiKey: any;
model: any;
proxyUrl: any;
timeout: any;
usingDeepThinking?: boolean;
deepThinkingModel?: string;
deepThinkingUrl?: string;
deepThinkingKey?: string;
searchResults?: any[];
deepThinkingType?: any;
abortController: AbortController;
onProgress?: (data: any) => void;
},
result: any,
): Promise<boolean> {
const {
apiKey,
model,
proxyUrl,
timeout,
usingDeepThinking,
searchResults,
abortController,
deepThinkingType,
onProgress,
} = inputs;
const {
openaiBaseUrl,
openaiBaseKey,
openaiBaseModel,
deepThinkingUrl,
deepThinkingKey,
deepThinkingModel,
} = await this.globalConfigService.getConfigs([
'openaiBaseUrl',
'openaiBaseKey',
'openaiBaseModel',
'deepThinkingUrl',
'deepThinkingKey',
'deepThinkingModel',
]);
// 如果不使用深度思考且不是DeepSeek模型直接返回
if (!usingDeepThinking && deepThinkingType !== 2) {
return false;
}
const deepUrl = deepThinkingType === 2 ? proxyUrl : deepThinkingUrl || openaiBaseUrl;
const deepKey = deepThinkingType === 2 ? apiKey : deepThinkingKey || openaiBaseKey;
const deepModel = deepThinkingType === 2 ? model : deepThinkingModel || openaiBaseModel;
let shouldEndThinkStream = false;
let thinkingSourceType = null; // 'reasoning_content' 或 'think_tag'
// 处理所有消息中的imageUrl类型
const processedMessages = JSON.parse(JSON.stringify(messagesHistory)).map((message: any) => {
if (message.role === 'user' && Array.isArray(message.content)) {
// 将带有image_url类型的内容转换为普通文本
message.content = message.content
.filter((item: any) => item.type !== 'image_url')
.map((item: any) => item.text || item)
.join('');
}
return message;
});
// 添加文件向量搜索、图片描述和MCP工具结果到system消息
const systemMessageIndex = processedMessages.findIndex((msg: any) => msg.role === 'system');
let additionalContent = '';
// 如果有网络搜索结果添加到system消息中
if (searchResults && searchResults.length > 0) {
// 将 searchResult 转换为 JSON 字符串
let searchPrompt = JSON.stringify(searchResults, null, 2);
additionalContent += `\n\n以下是网络搜索结果请基于这些信息回答用户问题这些信息比你的训练数据更新\n${searchPrompt}`;
}
// 将额外内容添加到system消息中
if (systemMessageIndex !== -1) {
processedMessages[systemMessageIndex].content += additionalContent;
} else if (additionalContent) {
processedMessages.unshift({
role: 'system',
content: additionalContent,
});
}
const correctedDeepUrl = await correctApiBaseUrl(deepUrl);
const thinkOpenai = new OpenAI({
apiKey: deepKey,
baseURL: correctedDeepUrl,
timeout: timeout * 5,
});
Logger.debug(
`思考流请求 - Messages: ${JSON.stringify(processedMessages)}`,
'OpenAIChatService',
);
// 构建请求配置
const requestConfig: any = {
model: deepModel,
messages: processedMessages,
stream: true,
};
// 如果是 grok-3-mini-latest 模型,添加 reasoning_effort 参数
// if (deepModel === 'grok-3-mini-latest') {
// requestConfig.reasoning_effort = 'high';
// Logger.debug('为grok-3-mini-latest模型添加reasoning_effort=high参数', 'OpenAIChatService');
// }
const stream = await thinkOpenai.chat.completions.create(requestConfig, {
signal: abortController.signal,
});
// @ts-ignore - 忽略TypeScript错误因为我们知道stream是可迭代的
for await (const chunk of stream) {
if (abortController.signal.aborted || shouldEndThinkStream) {
break;
}
const delta = chunk.choices[0]?.delta;
Logger.debug(`思考流delta: ${JSON.stringify(delta)}`, 'OpenAIChatService');
const content = delta?.content;
const reasoning_content = (delta as any)?.reasoning_content || '';
// 根据已确定的思考流来源类型处理数据
if (thinkingSourceType === 'reasoning_content') {
// 已确定使用reasoning_content字段
if (reasoning_content) {
Logger.debug(
`继续接收reasoning_content思考流: ${reasoning_content}`,
'OpenAIChatService',
);
result.reasoning_content = [
{
type: 'text',
text: reasoning_content,
},
];
result.full_reasoning_content += reasoning_content;
onProgress?.({
reasoning_content: result.reasoning_content,
});
} else if (content && !content.includes('<think>')) {
// 如果出现普通content对于非DeepSeek模型终止思考流
// 对于DeepSeek模型将内容作为正常响应处理
Logger.debug(`reasoning_content模式下收到普通content: ${content}`, 'OpenAIChatService');
if (deepThinkingType === 2) {
result.content = [
{
type: 'text',
text: content,
},
];
result.full_content += content;
onProgress?.({
content: result.content,
});
} else {
shouldEndThinkStream = true;
}
}
continue;
} else if (thinkingSourceType === 'think_tag') {
// 已确定使用think标签
if (content) {
if (content.includes('</think>')) {
// 如果包含结束标签,提取剩余思考内容
Logger.debug(`检测到</think>标签,思考流结束`, 'OpenAIChatService');
const regex = /([\s\S]*?)<\/think>([\s\S]*)/;
const matches = content.match(regex);
if (matches) {
const thinkContent = matches[1] || '';
const remainingContent = matches[2] || '';
if (thinkContent) {
result.reasoning_content = [
{
type: 'text',
text: thinkContent,
},
];
result.full_reasoning_content += thinkContent;
onProgress?.({
reasoning_content: result.reasoning_content,
});
}
// 对于DeepSeek模型如果有剩余内容作为正常响应处理
if (deepThinkingType === 2 && remainingContent) {
result.content = [
{
type: 'text',
text: remainingContent,
},
];
result.full_content += remainingContent;
onProgress?.({
content: result.content,
});
}
}
// 对于非DeepSeek模型终止思考流
// 对于DeepSeek模型只标记思考流结束但继续处理后续内容
if (deepThinkingType !== 2) {
shouldEndThinkStream = true;
} else {
thinkingSourceType = 'normal_content';
}
} else {
// 继续接收think标签内的思考内容
Logger.debug(`继续接收think标签思考流: ${content}`, 'OpenAIChatService');
result.reasoning_content = [
{
type: 'text',
text: content,
},
];
result.full_reasoning_content += content;
onProgress?.({
reasoning_content: result.reasoning_content,
});
}
}
continue;
} else if (thinkingSourceType === 'normal_content' && deepThinkingType === 2) {
// DeepSeek模型在思考流结束后的正常内容处理
if (content) {
result.content = [
{
type: 'text',
text: content,
},
];
result.full_content += content;
onProgress?.({
content: result.content,
});
}
continue;
}
// 尚未确定思考流来源类型,进行检测
if (reasoning_content) {
// 确定使用reasoning_content字段作为思考流
Logger.debug(
`首次检测到reasoning_content确定使用reasoning_content思考流方式: ${reasoning_content}`,
'OpenAIChatService',
);
thinkingSourceType = 'reasoning_content';
result.reasoning_content = [
{
type: 'text',
text: reasoning_content,
},
];
result.full_reasoning_content += reasoning_content;
onProgress?.({
reasoning_content: result.reasoning_content,
});
} else if (content) {
if (content.includes('<think>')) {
// 确定使用think标签作为思考流
Logger.debug(`首次检测到<think>标签确定使用think标签思考流方式`, 'OpenAIChatService');
thinkingSourceType = 'think_tag';
// 提取第一个块中的内容
const thinkContent = content.replace(/<think>/, '');
if (thinkContent) {
Logger.debug(`从<think>标签中提取的初始思考内容: ${thinkContent}`, 'OpenAIChatService');
result.reasoning_content = [
{
type: 'text',
text: thinkContent,
},
];
result.full_reasoning_content += thinkContent;
onProgress?.({
reasoning_content: result.reasoning_content,
});
// 如果已经包含了</think>标签,提取思考内容和剩余内容
if (content.includes('</think>')) {
Logger.debug('在首个块中检测到</think>标签', 'OpenAIChatService');
const regex = /<think>([\s\S]*?)<\/think>([\s\S]*)/;
const matches = content.match(regex);
if (matches) {
const fullThinkContent = matches[1] || '';
const remainingContent = matches[2] || '';
// 更新思考内容
result.reasoning_content = [
{
type: 'text',
text: fullThinkContent,
},
];
result.full_reasoning_content = fullThinkContent;
onProgress?.({
reasoning_content: result.reasoning_content,
});
// 对于DeepSeek模型如果有剩余内容作为正常响应处理
if (deepThinkingType === 2 && remainingContent) {
result.content = [
{
type: 'text',
text: remainingContent,
},
];
result.full_content += remainingContent;
onProgress?.({
content: result.content,
});
}
}
// 对于非DeepSeek模型终止思考流
// 对于DeepSeek模型只标记思考流结束继续处理后续内容
if (deepThinkingType !== 2) {
shouldEndThinkStream = true;
} else {
thinkingSourceType = 'normal_content';
}
}
}
} else {
// 没有任何思考流标记,不同模型有不同处理
Logger.debug(`没有检测到思考流标记,处理普通内容: ${content}`, 'OpenAIChatService');
if (deepThinkingType === 2) {
// DeepSeek模型直接处理为正常内容
thinkingSourceType = 'normal_content';
result.content = [
{
type: 'text',
text: content,
},
];
result.full_content += content;
onProgress?.({
content: result.content,
});
} else {
// 非DeepSeek模型终止思考流
shouldEndThinkStream = true;
}
}
}
}
Logger.debug('思考流处理完成', 'OpenAIChatService');
// 如果是DeepSeek模型并且有内容直接返回true表示应该终止请求
return deepThinkingType === 2 && result.full_content.length > 0;
}
/**
* 处理常规响应逻辑
* @param messagesHistory 消息历史
* @param inputs 输入参数
* @param result 结果对象
*/
private async handleRegularResponse(
messagesHistory: any,
inputs: {
apiKey: any;
model: any;
proxyUrl: any;
timeout: any;
temperature: any;
max_tokens?: any;
extraParam?: any;
searchResults?: any[];
images?: string[];
abortController: AbortController;
onProgress?: (data: any) => void;
},
result: any,
): Promise<void> {
const {
apiKey,
model,
proxyUrl,
timeout,
temperature,
max_tokens,
searchResults,
images,
abortController,
onProgress,
} = inputs;
// 步骤1: 准备和增强系统消息
const processedMessages = this.prepareSystemMessage(
messagesHistory,
{
searchResults,
images,
},
result,
);
// 步骤2: 处理OpenAI聊天API调用
await this.handleOpenAIChat(
processedMessages,
{
apiKey,
model,
proxyUrl,
timeout,
temperature,
max_tokens,
abortController,
onProgress,
},
result,
);
}
async chat(
messagesHistory: any,
inputs: {
chatId: any;
maxModelTokens?: any;
max_tokens?: any;
apiKey: any;
model: any;
modelName: any;
temperature: any;
modelType?: any;
prompt?: any;
imageUrl?: any;
isFileUpload: any;
isImageUpload?: any;
fileUrl?: any;
usingNetwork?: boolean;
timeout: any;
proxyUrl: any;
modelAvatar?: any;
usingDeepThinking?: boolean;
usingMcpTool?: boolean;
isMcpTool?: boolean;
extraParam?: any;
deepThinkingType?: any;
onProgress?: (data: {
text?: string;
content?: [];
reasoning_content?: [];
tool_calls?: string;
networkSearchResult?: string;
finishReason?: string;
// full_json?: string; // 编辑模式相关,已注释
}) => void;
onFailure?: (error: any) => void;
onDatabase?: (data: any) => void;
abortController: AbortController;
},
) {
const {
chatId,
maxModelTokens,
max_tokens,
apiKey,
model,
modelName,
temperature,
prompt,
timeout,
proxyUrl,
modelAvatar,
usingDeepThinking,
usingNetwork,
extraParam,
deepThinkingType,
onProgress,
onFailure,
onDatabase,
abortController,
} = inputs;
// 创建原始消息历史的副本
const originalMessagesHistory = JSON.parse(JSON.stringify(messagesHistory));
const result: any = {
chatId,
modelName,
modelAvatar,
model,
status: 2,
full_content: '',
full_reasoning_content: '',
networkSearchResult: '',
fileVectorResult: '',
finishReason: null,
};
try {
// 步骤1: 处理网络搜索 - 使用NetSearchService
const { searchResults, images } = await this.netSearchService.processNetSearch(
prompt || '',
{
usingNetwork,
onProgress,
onDatabase,
},
result,
);
// 步骤5: 处理深度思考
const shouldEndRequest = await this.handleDeepThinking(
messagesHistory,
{
apiKey,
model,
proxyUrl,
timeout,
usingDeepThinking,
searchResults,
abortController,
deepThinkingType,
onProgress,
},
result,
);
// 如果深度思考处理后应该终止请求,则直接返回结果
if (shouldEndRequest) {
result.content = '';
result.reasoning_content = '';
result.finishReason = 'stop';
return result;
}
// 步骤6: 处理常规响应
await this.handleRegularResponse(
originalMessagesHistory,
{
apiKey,
model,
proxyUrl,
timeout,
temperature,
max_tokens,
extraParam,
searchResults,
images,
abortController,
onProgress,
},
result,
);
result.content = [
{
type: 'text',
text: '',
},
];
result.reasoning_content = [
{
type: 'text',
text: '',
},
];
result.finishReason = 'stop';
return result;
} catch (error) {
const errorMessage = handleError(error);
Logger.error(`对话请求失败: ${errorMessage}`, 'OpenAIChatService');
result.errMsg = errorMessage;
onFailure?.(result);
return result;
}
}
async chatFree(prompt: string, systemMessage?: string, messagesHistory?: any[], imageUrl?: any) {
const {
openaiBaseUrl = '',
openaiBaseKey = '',
openaiBaseModel,
} = await this.globalConfigService.getConfigs([
'openaiBaseKey',
'openaiBaseUrl',
'openaiBaseModel',
]);
const key = openaiBaseKey;
const proxyUrl = openaiBaseUrl;
let requestData = [];
if (systemMessage) {
requestData.push({
role: 'system',
content: systemMessage,
});
}
if (messagesHistory && messagesHistory.length > 0) {
requestData = requestData.concat(messagesHistory);
} else {
if (imageUrl) {
requestData.push({
role: 'user',
content: [
{
type: 'text',
text: prompt,
},
{
type: 'image_url',
image_url: {
url: imageUrl,
},
},
],
});
} else {
requestData.push({
role: 'user',
content: prompt,
});
}
}
try {
const openai = new OpenAI({
apiKey: key,
baseURL: await correctApiBaseUrl(proxyUrl),
});
const response = await openai.chat.completions.create(
{
model: openaiBaseModel || 'gpt-4o-mini',
messages: requestData,
},
{
timeout: 30000,
},
);
return response.choices[0].message.content;
} catch (error) {
const errorMessage = handleError(error);
Logger.error(`全局模型调用失败: ${errorMessage}`, 'OpenAIChatService');
return;
}
}
/**
* 准备和增强系统消息
* @param messagesHistory 消息历史
* @param inputs 输入参数
* @param result 结果对象
* @returns 处理后的消息历史
*/
private prepareSystemMessage(
messagesHistory: any,
inputs: {
searchResults?: any[];
images?: string[];
},
result: any,
): any {
const { searchResults, images } = inputs;
// 创建消息历史的副本
const processedMessages = JSON.parse(JSON.stringify(messagesHistory));
// 查找系统消息
const systemMessage = processedMessages?.find((message: any) => message.role === 'system');
if (systemMessage) {
const imageUrlMessages =
processedMessages?.filter((message: any) => message.type === 'image_url') || [];
let updatedContent = '';
// 添加推理思考内容
if (result.full_reasoning_content) {
updatedContent = `\n\n以下是针对这个问题的思考推理思路思路不一定完全正确仅供参考\n${result.full_reasoning_content}`;
}
// 添加网络搜索结果
if (searchResults && searchResults.length > 0) {
// 将 searchResult 转换为 JSON 字符串
let searchPrompt = JSON.stringify(searchResults, null, 2); // 格式化为漂亮的 JSON 字符串
// 处理图片数据
let imagesPrompt = '';
if (images && images.length > 0) {
imagesPrompt = `\n\n以下是搜索到的相关图片链接:\n${images.join('\n')}`;
}
const now = new Date();
const options = {
timeZone: 'Asia/Shanghai', // 设置时区为 'Asia/Shanghai'(北京时间)
year: 'numeric' as const,
month: '2-digit' as const,
day: '2-digit' as const,
hour: '2-digit' as const,
minute: '2-digit' as const,
hour12: false, // 使用24小时制
};
const currentDate = new Intl.DateTimeFormat('zh-CN', options).format(now);
updatedContent += `
\n\n你的任务是根据用户的问题通过下面的搜索结果提供更精确、详细、具体的回答。
请在适当的情况下在对应部分句子末尾标注引用的链接,使用[[序号](链接地址)]格式,同时使用多个链接可连续使用比如[[2](链接地址)][[5](链接地址)],以下是搜索结果:
${searchPrompt}${imagesPrompt}
在回答时,请注意以下几点:
- 现在时间是: ${currentDate}
- 如果结果中包含图片链接可在适当位置使用MarkDown格式插入至少一张图片让回答图文并茂。
- 并非搜索结果的所有内容都与用户的问题密切相关,你需要结合问题,对搜索结果进行甄别、筛选。
- 对于列举类的问题如列举所有航班信息尽量将答案控制在10个要点以内并告诉用户可以查看搜索来源、获得完整信息。优先提供信息完整、最相关的列举项如非必要不要主动告诉用户搜索结果未提供的内容。
- 对于创作类的问题(如写论文),请务必在正文的段落中引用对应的参考编号。你需要解读并概括用户的题目要求,选择合适的格式,充分利用搜索结果并抽取重要信息,生成符合用户要求、极具思想深度、富有创造力与专业性的答案。你的创作篇幅需要尽可能延长,对于每一个要点的论述要推测用户的意图,给出尽可能多角度的回答要点,且务必信息量大、论述详尽。
- 如果回答很长请尽量结构化、分段落总结。如果需要分点作答尽量控制在5个点以内并合并相关的内容。
- 对于客观类的问答,如果问题的答案非常简短,可以适当补充一到两句相关信息,以丰富内容。
- 你需要根据用户要求和回答内容选择合适、美观的回答格式,确保可读性强。
- 你的回答应该综合多个相关网页来回答,不能只重复引用一个网页。
- 除非用户要求,否则你回答的语言需要和用户提问的语言保持一致。
`;
}
// 添加图片URL消息
if (imageUrlMessages && imageUrlMessages.length > 0) {
imageUrlMessages.forEach((imageMessage: any) => {
updatedContent = `${updatedContent}\n${JSON.stringify(imageMessage)}`;
});
}
systemMessage.content += updatedContent;
}
return processedMessages;
}
/**
* 处理OpenAI聊天API调用和流式响应
* @param messagesHistory 处理后的消息历史
* @param inputs 输入参数
* @param result 结果对象
*/
private async handleOpenAIChat(
messagesHistory: any,
inputs: {
apiKey: any;
model: any;
proxyUrl: any;
timeout: any;
temperature: any;
max_tokens?: any;
abortController: AbortController;
onProgress?: (data: any) => void;
},
result: any,
): Promise<void> {
const {
apiKey,
model,
proxyUrl,
timeout,
temperature,
max_tokens,
abortController,
onProgress,
} = inputs;
// 准备请求数据
const streamData = {
model,
messages: messagesHistory,
stream: true,
temperature,
};
// 创建OpenAI实例
const openai = new OpenAI({
apiKey: apiKey,
baseURL: await correctApiBaseUrl(proxyUrl),
timeout: timeout,
});
try {
Logger.debug(
`对话请求 - Messages: ${JSON.stringify(streamData.messages)}`,
'OpenAIChatService',
);
// 发送流式请求
const stream = await openai.chat.completions.create(
{
model: streamData.model,
messages: streamData.messages,
stream: true,
max_tokens: max_tokens,
temperature: streamData.temperature,
},
{
signal: abortController.signal,
},
);
// 处理流式响应
for await (const chunk of stream) {
if (abortController.signal.aborted) {
break;
}
const content = chunk.choices[0]?.delta?.content || '';
if (content) {
// 处理流式内容
result.content = [
{
type: 'text',
text: content,
},
];
result.full_content += content;
onProgress?.({
content: result.content,
});
}
}
} catch (error) {
Logger.error(`OpenAI请求失败: ${handleError(error)}`, 'OpenAIChatService');
throw error;
}
}
}

View File

@@ -0,0 +1,237 @@
import { handleError } from '@/common/utils';
import { Injectable, Logger } from '@nestjs/common';
import { GlobalConfigService } from '../../globalConfig/globalConfig.service';
@Injectable()
export class NetSearchService {
constructor(private readonly globalConfigService: GlobalConfigService) {}
/**
* 处理网络搜索流程
* @param prompt 搜索关键词
* @param inputs 输入参数
* @param result 结果对象
* @returns 搜索结果对象
*/
async processNetSearch(
prompt: string,
inputs: {
usingNetwork?: boolean;
onProgress?: (data: any) => void;
onDatabase?: (data: any) => void;
},
result: any,
): Promise<{ searchResults: any[]; images: string[] }> {
const { usingNetwork, onProgress, onDatabase } = inputs;
let searchResults: any[] = [];
let images: string[] = [];
// 如果不使用网络搜索,直接返回空结果
if (!usingNetwork) {
return { searchResults, images };
}
try {
Logger.log(`[网络搜索] 开始搜索: ${prompt}`, 'NetSearchService');
// 调用网络搜索服务
const searchResponse = await this.webSearchPro(prompt);
searchResults = searchResponse.searchResults;
images = searchResponse.images;
Logger.log(
`[网络搜索] 完成,获取到 ${searchResults.length} 条结果和 ${images.length} 张图片`,
'NetSearchService',
);
// 更新结果对象
result.networkSearchResult = JSON.stringify(searchResults);
onProgress?.({
networkSearchResult: result.networkSearchResult,
});
// 存储数据到数据库
onDatabase?.({
networkSearchResult: JSON.stringify(
searchResults.map((item: { [x: string]: any; content: any }) => {
const { content, ...rest } = item; // 删除 content 部分
return rest; // 返回剩余部分
}),
null,
2,
),
});
return { searchResults, images };
} catch (error) {
Logger.error(`[网络搜索] 失败: ${handleError(error)}`, 'NetSearchService');
// 即时存储错误信息
onDatabase?.({
network_search_error: {
error: handleError(error),
query: prompt,
timestamp: new Date(),
},
});
return { searchResults: [], images: [] };
}
}
async webSearchPro(prompt: string) {
try {
const { pluginUrl, pluginKey } = await this.globalConfigService.getConfigs([
'pluginUrl',
'pluginKey',
]);
if (!pluginUrl || !pluginKey) {
Logger.warn('搜索插件配置缺失');
return { searchResults: [], images: [] };
}
// 如果有多个 key随机选择一个
const keys = pluginKey.split(',').filter(key => key.trim());
const selectedKey = keys[Math.floor(Math.random() * keys.length)];
const isBochaiApi = pluginUrl.includes('bochaai.com');
const isBigModelApi = pluginUrl.includes('bigmodel.cn');
const isTavilyApi = pluginUrl.includes('tavily.com');
Logger.log(
`[搜索] API类型: ${
isBochaiApi ? 'Bochai' : isBigModelApi ? 'BigModel' : isTavilyApi ? 'Tavily' : '未知'
}`,
);
Logger.log(`[搜索] 请求URL: ${pluginUrl}`);
Logger.log(`[搜索] 搜索关键词: ${prompt}`);
const requestBody = isBochaiApi
? {
query: prompt,
// freshness: 'oneWeek',
summary: true,
count: 20,
}
: isTavilyApi
? {
query: prompt,
search_depth: 'basic',
// search_depth: 'advanced',
include_answer: false,
// include_raw_content: true,
include_images: true,
max_results: 10,
}
: {
tool: 'web-search-pro',
stream: false,
messages: [{ role: 'user', content: prompt }],
};
Logger.log(`[搜索] 请求参数: ${JSON.stringify(requestBody, null, 2)}`);
const response = await fetch(pluginUrl, {
method: 'POST',
headers: {
Authorization: selectedKey,
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
});
if (!response.ok) {
Logger.error(`[搜索] 接口返回错误: ${response.status}`);
return { searchResults: [], images: [] };
}
const apiResult = await response.json();
Logger.log(`[搜索] 原始返回数据: ${JSON.stringify(apiResult, null, 2)}`);
let searchResults: any[] = [];
if (isBochaiApi) {
if (apiResult?.code === 200 && apiResult?.data?.webPages?.value) {
searchResults = apiResult.data.webPages.value.map((item: any) => ({
title: item?.name || '',
link: item?.url || '',
content: item?.summary || '',
icon: item?.siteIcon || '',
media: item?.siteName || '',
}));
}
} else if (isBigModelApi) {
if (apiResult?.choices?.[0]?.message?.tool_calls?.length > 0) {
for (const toolCall of apiResult.choices[0].message.tool_calls) {
if (Array.isArray(toolCall.search_result)) {
searchResults = toolCall.search_result.map((item: any) => ({
title: item?.title || '',
link: item?.link || '',
content: item?.content || '',
icon: item?.icon || '',
media: item?.media || '',
}));
break;
}
}
}
} else if (isTavilyApi) {
if (Array.isArray(apiResult?.results)) {
searchResults = apiResult.results.map((item: any) => ({
title: item?.title || '',
link: item?.url || '',
content: item?.raw_content || item?.content || '',
icon: '',
media: '',
}));
}
}
const formattedResult = searchResults.map((item, index) => ({
resultIndex: index + 1,
...item,
}));
// 提取 Tavily API 返回的图片
let images: string[] = [];
if (isTavilyApi && Array.isArray(apiResult?.images)) {
images = apiResult.images;
}
// 处理博查API返回的图片
if (isBochaiApi) {
// 博查API的图片可能在两个不同的路径
if (apiResult?.data?.images?.value && Array.isArray(apiResult.data.images.value)) {
// 从博查API的图片结构中提取contentUrl
images = apiResult.data.images.value
.filter(img => img.contentUrl)
.map(img => img.contentUrl);
}
// else if (
// apiResult?.images?.value &&
// Array.isArray(apiResult.images.value)
// ) {
// // 备选路径
// images = apiResult.images.value
// .filter((img) => img.contentUrl)
// .map((img) => img.contentUrl);
// }
}
Logger.log(`[搜索] 格式化后的结果: ${JSON.stringify(formattedResult, null, 2)}`);
// 同时返回搜索结果和图片数组
return {
searchResults: formattedResult,
images: images,
};
} catch (fetchError) {
Logger.error('[搜索] 调用接口出错:', fetchError);
return {
searchResults: [],
images: [],
};
}
}
}

View File

@@ -0,0 +1,136 @@
import { AdminAuthGuard } from '@/common/auth/adminAuth.guard';
import { JwtAuthGuard } from '@/common/auth/jwtAuth.guard';
import { SuperAuthGuard } from '@/common/auth/superAuth.guard';
import { Body, Controller, Get, Post, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { AppService } from './app.service';
import { CollectAppDto } from './dto/collectApp.dto';
import { CreateAppDto } from './dto/createApp.dto';
import { CreateCatsDto } from './dto/createCats.dto';
import { OperateAppDto } from './dto/deleteApp.dto';
import { DeleteCatsDto } from './dto/deleteCats.dto';
import { QuerAppDto } from './dto/queryApp.dto';
import { QuerCatsDto } from './dto/queryCats.dto';
import { UpdateAppDto } from './dto/updateApp.dto';
import { UpdateCatsDto } from './dto/updateCats.dto';
@ApiTags('app')
@Controller('app')
export class AppController {
constructor(private readonly appService: AppService) {}
@Get('queryAppCats')
@ApiOperation({ summary: '获取App分类列表' })
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
appCatsList(@Query() query: QuerCatsDto, @Req() req: Request) {
return this.appService.appCatsList(query, req);
}
@Get('queryCats')
@ApiOperation({ summary: '用户端获取App分类列表' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
catsList(@Req() req: Request) {
const params: QuerCatsDto = { status: 1, page: 1, size: 1000, name: '' };
return this.appService.appCatsList(params, req);
}
@Get('queryOneCat')
@ApiOperation({ summary: '用户端获取App详情' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
queryOneCats(@Query() query, @Req() req: Request) {
return this.appService.queryOneCat(query, req);
}
@Post('createAppCats')
@ApiOperation({ summary: '添加App分类' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
createAppCat(@Body() body: CreateCatsDto) {
return this.appService.createAppCat(body);
}
@Post('updateAppCats')
@ApiOperation({ summary: '修改App分类' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
updateAppCats(@Body() body: UpdateCatsDto) {
return this.appService.updateAppCats(body);
}
@Post('delAppCats')
@ApiOperation({ summary: '删除App分类' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
delAppCat(@Body() body: DeleteCatsDto) {
return this.appService.delAppCat(body);
}
@Get('queryApp')
@ApiOperation({ summary: '获取App列表' })
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
appList(@Req() req: Request, @Query() query: QuerAppDto) {
return this.appService.appList(req, query);
}
@Get('list')
@ApiOperation({ summary: '客户端获取App' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
list(@Req() req: Request, @Query() query: QuerAppDto) {
return this.appService.frontAppList(req, query);
}
@Post('searchList')
@ApiOperation({ summary: '客户端获取App' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async searchList(@Body() body: any, @Req() req: Request) {
body.userId = req.user.id;
return this.appService.searchAppList(body);
}
@Post('createApp')
@ApiOperation({ summary: '添加App' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
createApp(@Body() body: CreateAppDto) {
return this.appService.createApp(body);
}
@Post('updateApp')
@ApiOperation({ summary: '修改App' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
updateApp(@Body() body: UpdateAppDto) {
return this.appService.updateApp(body);
}
@Post('delApp')
@ApiOperation({ summary: '删除App' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
delApp(@Body() body: OperateAppDto) {
return this.appService.delApp(body);
}
@Post('collect')
@ApiOperation({ summary: '收藏/取消收藏App' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
collect(@Body() body: CollectAppDto, @Req() req: Request) {
return this.appService.collect(body, req);
}
@Get('mineApps')
@ApiOperation({ summary: '我的收藏' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
mineApps(@Req() req: Request) {
return this.appService.mineApps(req);
}
}

View File

@@ -0,0 +1,68 @@
import { BaseEntity } from 'src/common/entity/baseEntity';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'app' })
export class AppEntity extends BaseEntity {
@Column({ unique: true, comment: 'App应用名称' })
name: string;
@Column({ comment: 'App分类Id列表多个分类Id以逗号分隔', type: 'text' })
catId: string;
@Column({ comment: 'App应用描述信息' })
des: string;
@Column({ comment: 'App应用预设场景信息', type: 'text' })
preset: string;
@Column({ comment: 'App应用封面图片', nullable: true, type: 'text' })
coverImg: string;
@Column({ comment: 'App应用排序、数字越大越靠前', default: 100 })
order: number;
@Column({ comment: 'App应用是否启用中 0禁用 1启用', default: 1 })
status: number;
@Column({ comment: 'App示例数据', nullable: true, type: 'text' })
demoData: string;
@Column({ comment: 'App应用角色 system user', default: 'system' })
role: string;
@Column({ comment: 'App应用是否是GPTs', default: '0' })
isGPTs: number;
@Column({ comment: 'App应用是否是固定使用模型', default: '0' })
isFixedModel: number;
@Column({ comment: 'App应用使用的模型', type: 'text' })
appModel: string;
@Column({ comment: 'GPTs 的调用ID', default: '' })
gizmoID: string;
@Column({ comment: 'App是否共享到应用广场', default: false })
public: boolean;
@Column({ comment: '用户Id', nullable: true })
userId: number;
@Column({ comment: '是否使用flowith模型', default: 0 })
isFlowith: number;
@Column({ comment: 'flowith模型ID', nullable: true })
flowithId: string;
@Column({ comment: 'flowith模型名称', nullable: true })
flowithName: string;
@Column({ comment: 'flowith模型Key', nullable: true })
flowithKey: string;
@Column({ comment: 'App背景图', nullable: true, type: 'text' })
backgroundImg: string;
@Column({ comment: 'App提问模版', nullable: true, type: 'text' })
prompt: string;
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserBalanceService } from '../userBalance/userBalance.service';
import { AppController } from './app.controller';
import { AppEntity } from './app.entity';
import { AppService } from './app.service';
import { AppCatsEntity } from './appCats.entity';
import { UserAppsEntity } from './userApps.entity';
@Module({
imports: [TypeOrmModule.forFeature([AppCatsEntity, AppEntity, UserAppsEntity])],
controllers: [AppController],
providers: [AppService, UserBalanceService],
})
export class AppModule {}

View File

@@ -0,0 +1,716 @@
import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Request } from 'express';
import { In, IsNull, Like, MoreThan, Not, Repository } from 'typeorm';
import { UserBalanceService } from '../userBalance/userBalance.service';
import { AppEntity } from './app.entity';
import { AppCatsEntity } from './appCats.entity';
import { CollectAppDto } from './dto/collectApp.dto';
import { CreateAppDto } from './dto/createApp.dto';
import { CreateCatsDto } from './dto/createCats.dto';
import { OperateAppDto } from './dto/deleteApp.dto';
import { DeleteCatsDto } from './dto/deleteCats.dto';
import { QuerAppDto } from './dto/queryApp.dto';
import { QuerCatsDto } from './dto/queryCats.dto';
import { UpdateAppDto } from './dto/updateApp.dto';
import { UpdateCatsDto } from './dto/updateCats.dto';
import { UserAppsEntity } from './userApps.entity';
@Injectable()
export class AppService {
constructor(
@InjectRepository(AppCatsEntity)
private readonly appCatsEntity: Repository<AppCatsEntity>,
@InjectRepository(AppEntity)
private readonly appEntity: Repository<AppEntity>,
@InjectRepository(UserAppsEntity)
private readonly userAppsEntity: Repository<UserAppsEntity>,
private readonly userBalanceService: UserBalanceService,
) {}
async createAppCat(body: CreateCatsDto) {
const { name } = body;
const c = await this.appCatsEntity.findOne({ where: { name } });
if (c) {
throw new HttpException('该分类名称已存在!', HttpStatus.BAD_REQUEST);
}
return await this.appCatsEntity.save(body);
}
async delAppCat(body: DeleteCatsDto) {
const { id } = body;
const c = await this.appCatsEntity.findOne({ where: { id } });
if (!c) {
throw new HttpException('该分类不存在!', HttpStatus.BAD_REQUEST);
}
// 查找所有包含该分类ID的App
const apps = await this.appEntity.find();
const appsWithThisCat = apps.filter(app => {
const catIds = app.catId.split(',');
return catIds.includes(id.toString());
});
if (appsWithThisCat.length > 0) {
throw new HttpException('该分类下存在App不可删除', HttpStatus.BAD_REQUEST);
}
const res = await this.appCatsEntity.delete(id);
if (res.affected > 0) return '删除成功';
throw new HttpException('删除失败!', HttpStatus.BAD_REQUEST);
}
async updateAppCats(body: UpdateCatsDto) {
const { id, name } = body;
const c = await this.appCatsEntity.findOne({
where: { name, id: Not(id) },
});
if (c) {
throw new HttpException('该分类名称已存在!', HttpStatus.BAD_REQUEST);
}
const res = await this.appCatsEntity.update({ id }, body);
if (res.affected > 0) return '修改成功';
throw new HttpException('修改失败!', HttpStatus.BAD_REQUEST);
}
async queryOneCat(params, req?: Request) {
const { id } = params;
if (!id) {
throw new HttpException('缺失必要参数!', HttpStatus.BAD_REQUEST);
}
const app = await this.appEntity.findOne({ where: { id } });
if (!app) {
throw new HttpException('应用不存在!', HttpStatus.BAD_REQUEST);
}
const appData = app as any;
return {
demoData: appData.demoData ? appData.demoData.split('\n') : [],
coverImg: appData.coverImg,
des: appData.des,
name: appData.name,
isGPTs: appData.isGPTs,
isFlowith: appData.isFlowith,
flowithId: appData.flowithId,
flowithName: appData.flowithName,
isFixedModel: appData.isFixedModel,
appModel: appData.appModel,
backgroundImg: appData.backgroundImg,
prompt: appData.prompt,
};
}
async appCatsList(query: QuerCatsDto, req?: Request) {
const { page = 1, size = 10, name, status } = query;
const where: any = {};
name && (where.name = Like(`%${name}%`));
[0, 1, '0', '1'].includes(status) && (where.status = status);
const [rows, count] = await this.appCatsEntity.findAndCount({
where,
order: { order: 'DESC' },
skip: (page - 1) * size,
take: size,
});
// 如果是超级管理员,跳过过滤逻辑
let filteredRows = [...rows];
if (req?.user?.role !== 'super') {
// 获取用户的分类ID列表
const userCatIds = await this.userBalanceService.getUserApps(Number(req.user.id));
const userCatIdsSet = new Set(userCatIds);
// 过滤分类如果分类ID在用户的分类ID列表中则保留否则检查是否需要隐藏
filteredRows = rows.filter(cat => {
// 如果分类ID在用户的分类ID列表中保留它
if (userCatIdsSet.has(cat.id.toString())) {
return true;
}
// 只过滤掉设置了hideFromNonMember的分类不考虑isMember属性
return cat.hideFromNonMember !== 1;
});
}
// 查出所有分类下对应的App数量
const catIds = filteredRows.map(item => item.id);
const apps = await this.appEntity.find();
const appCountMap = {};
// 初始化每个分类的App计数为0
catIds.forEach(id => {
appCountMap[id] = 0;
});
// 统计每个分类下的App数量
apps.forEach(item => {
const appCatIds = item.catId.split(',');
appCatIds.forEach(catId => {
const catIdNum = Number(catId);
if (catIds.includes(catIdNum)) {
appCountMap[catIdNum] = (appCountMap[catIdNum] || 0) + 1;
}
});
});
filteredRows.forEach((item: any) => (item.appCount = appCountMap[item.id] || 0));
return { rows: filteredRows, count: filteredRows.length };
}
async appList(req: Request, query: QuerAppDto, orderKey = 'id') {
const { page = 1, size = 10, name, status, catId, role } = query;
const where: any = {};
name && (where.name = Like(`%${name}%`));
// 如果指定了分类ID则查找包含该分类ID的App
let filteredByCategory = null;
if (catId) {
const apps = await this.appEntity.find();
filteredByCategory = apps
.filter(app => {
const appCatIds = app.catId.split(',');
return appCatIds.includes(catId.toString());
})
.map(app => app.id);
if (filteredByCategory.length === 0) {
return { rows: [], count: 0 };
}
where.id = In(filteredByCategory);
}
role && (where.role = role);
status && (where.status = status);
const [rows, count] = await this.appEntity.findAndCount({
where,
order: { [orderKey]: 'DESC' },
skip: (page - 1) * size,
take: size,
});
// 获取所有分类信息
const allCats = await this.appCatsEntity.find();
const catsMap = {};
allCats.forEach(cat => {
catsMap[cat.id] = cat;
});
// 为每个App添加分类名称
rows.forEach((item: any) => {
const catIds = item.catId.split(',');
const catNames = catIds
.map(id => {
const cat = catsMap[Number(id)];
return cat ? cat.name : '';
})
.filter(name => name);
item.catName = catNames.join(', ');
item.backgroundImg = item.backgroundImg;
item.prompt = item.prompt;
});
if (req?.user?.role !== 'super') {
rows.forEach((item: any) => {
delete item.preset;
});
}
return { rows, count };
}
async frontAppList(req: Request, query: QuerAppDto, orderKey = 'id') {
const { page = 1, size = 1000, catId } = query;
const where: any = [
{
status: In([1, 4]),
userId: IsNull(),
public: false,
},
{ userId: MoreThan(0), public: true },
];
const userCatIds = await this.userBalanceService.getUserApps(Number(req.user.id));
const userCatIdsSet = new Set(userCatIds);
// 如果指定了分类ID则过滤包含该分类ID的App
if (catId) {
const apps = await this.appEntity.find();
const filteredByCategory = apps
.filter(app => {
const appCatIds = app.catId.split(',');
return appCatIds.includes(catId.toString());
})
.map(app => app.id);
if (filteredByCategory.length === 0) {
return { rows: [], count: 0 };
}
// 修改查询条件只查询包含指定分类ID的App
where[0].id = In(filteredByCategory);
where[1].id = In(filteredByCategory);
}
const [rows, count] = await this.appEntity.findAndCount({
where,
order: { order: 'DESC' },
skip: (page - 1) * size,
take: size,
});
// 获取所有分类信息
const allCats = await this.appCatsEntity.find();
const catsMap = {};
allCats.forEach(cat => {
catsMap[cat.id] = cat;
});
// 如果是超级管理员,跳过过滤逻辑
let filteredRows = [...rows];
if (req?.user?.role !== 'super') {
// 过滤应用如果应用的分类ID在用户的 userCatIds 中则保留,否则检查是否需要隐藏
filteredRows = rows.filter(app => {
// 获取应用所属的所有分类
const appCatIds = app.catId.split(',').map(id => Number(id));
// 检查应用是否属于用户拥有的任何分类
for (const catId of appCatIds) {
if (userCatIdsSet.has(catId.toString())) {
return true;
}
}
// 检查应用的分类是否有会员专属且对非会员隐藏的
for (const catId of appCatIds) {
const cat = catsMap[catId];
if (cat && cat.isMember === 1 && cat.hideFromNonMember === 1) {
return false; // 过滤掉这个应用
}
}
return true; // 保留这个应用
});
}
// 为每个App添加分类名称
filteredRows.forEach((item: any) => {
const appCatIds = item.catId.split(',');
const catNames = appCatIds
.map(id => {
const cat = catsMap[Number(id)];
return cat ? cat.name : '';
})
.filter(name => name);
item.catName = catNames.join(',');
item.backgroundImg = item.backgroundImg;
});
// 只有非超级管理员需要删除 preset
if (req?.user?.role !== 'super') {
filteredRows.forEach((item: any) => {
delete item.preset;
});
}
return { rows: filteredRows, count: filteredRows.length };
}
async searchAppList(body: any) {
const { page = 1, size = 1000, keyword, catId, userId, role } = body;
// 基础查询条件
let baseWhere: any = [
{
status: In([1, 4]),
userId: IsNull(),
public: false,
},
{ userId: MoreThan(0), public: true },
];
// 如果存在关键字,修改查询条件以搜索 name
if (keyword) {
baseWhere = baseWhere.map(condition => ({
...condition,
name: Like(`%${keyword}%`),
}));
}
// 如果指定了分类ID则过滤包含该分类ID的App
if (catId && !isNaN(Number(catId))) {
const apps = await this.appEntity.find();
const filteredByCategory = apps
.filter(app => {
const appCatIds = app.catId.split(',');
return appCatIds.includes(catId.toString());
})
.map(app => app.id);
if (filteredByCategory.length === 0) {
return { rows: [], count: 0 };
}
baseWhere = baseWhere.map(condition => ({
...condition,
id: In(filteredByCategory),
}));
}
try {
// 确保 userId 是有效数字
const userIdNum = isNaN(Number(userId)) ? 0 : Number(userId);
// 获取用户的分类ID列表
const userCatIds = await this.userBalanceService.getUserApps(userIdNum);
const userCatIdsSet = new Set(userCatIds);
const [rows, count] = await this.appEntity.findAndCount({
where: baseWhere,
skip: (page - 1) * size,
take: size,
});
// 获取所有分类信息
const allCats = await this.appCatsEntity.find();
const catsMap = {};
allCats.forEach(cat => {
catsMap[cat.id] = cat;
});
// 如果是超级管理员,跳过过滤逻辑
let filteredRows = [...rows];
if (role !== 'super') {
// 过滤应用如果应用的分类在用户的分类ID列表中则保留否则检查是否需要隐藏
filteredRows = rows.filter(app => {
// 获取应用所属的所有分类
const appCatIds = app.catId.split(',').map(id => Number(id));
// 检查应用是否属于用户拥有的任何分类
for (const catId of appCatIds) {
if (userCatIdsSet.has(catId.toString())) {
return true; // 保留这个应用
}
}
// 检查应用的分类是否有会员专属且对非会员隐藏的
for (const catId of appCatIds) {
const cat = catsMap[catId];
if (cat && cat.isMember === 1 && cat.hideFromNonMember === 1) {
return false; // 过滤掉这个应用
}
}
return true; // 保留这个应用
});
}
// 为每个App添加分类名称
filteredRows.forEach((item: any) => {
const appCatIds = item.catId.split(',');
const catNames = appCatIds
.map(id => {
const cat = catsMap[Number(id)];
return cat ? cat.name : '';
})
.filter(name => name);
item.catName = catNames.join(', ');
item.backgroundImg = item.backgroundImg;
item.prompt = item.prompt;
// 只有非超级管理员需要删除 preset
if (role !== 'super') {
delete item.preset;
}
});
return { rows: filteredRows, count: filteredRows.length };
} catch (error) {
throw new HttpException('查询应用列表失败', HttpStatus.INTERNAL_SERVER_ERROR);
}
}
async createApp(body: CreateAppDto) {
const { name, catId } = body;
body.role = 'system';
// 检查应用名称是否已存在
const a = await this.appEntity.findOne({ where: { name } });
if (a) {
throw new HttpException('该应用名称已存在!', HttpStatus.BAD_REQUEST);
}
// 验证所有分类ID是否存在
if (!catId) {
throw new HttpException('缺少分类ID', HttpStatus.BAD_REQUEST);
}
const catIds = catId.split(',');
for (const id of catIds) {
const numId = Number(id);
if (isNaN(numId)) {
throw new HttpException(`分类ID ${id} 不是有效的数字!`, HttpStatus.BAD_REQUEST);
}
const c = await this.appCatsEntity.findOne({ where: { id: numId } });
if (!c) {
throw new HttpException(`分类ID ${id} 不存在!`, HttpStatus.BAD_REQUEST);
}
}
try {
// 添加必要的默认字段
const saveData: any = { ...body };
// 检查ID是否有效如果无效则删除
if (!saveData.id || isNaN(Number(saveData.id))) {
delete saveData.id;
}
saveData.public = false;
// 设置默认值
saveData.appModel = saveData.appModel || '';
saveData.order = isNaN(Number(saveData.order)) ? 100 : saveData.order;
saveData.status = isNaN(Number(saveData.status)) ? 1 : saveData.status;
saveData.isGPTs = isNaN(Number(saveData.isGPTs)) ? 0 : saveData.isGPTs;
saveData.isFlowith = isNaN(Number(saveData.isFlowith)) ? 0 : saveData.isFlowith;
saveData.flowithId = saveData.flowithId || '';
saveData.flowithName = saveData.flowithName || '';
saveData.flowithKey = saveData.flowithKey || '';
saveData.isFixedModel = isNaN(Number(saveData.isFixedModel)) ? 0 : saveData.isFixedModel;
saveData.backgroundImg = saveData.backgroundImg || '';
saveData.prompt = saveData.prompt || '';
// 保存应用
return await this.appEntity.save(saveData);
} catch (error) {
throw new HttpException(`保存应用失败`, HttpStatus.BAD_REQUEST);
}
}
async updateApp(body: UpdateAppDto) {
const { id, name, catId, status } = body;
// 验证ID是否有效
if (id === undefined || id === null || isNaN(Number(id))) {
throw new HttpException('无效的应用ID', HttpStatus.BAD_REQUEST);
}
const a = await this.appEntity.findOne({ where: { name, id: Not(id) } });
if (a) {
throw new HttpException('该应用名称已存在!', HttpStatus.BAD_REQUEST);
}
// 验证所有分类ID是否存在
const catIds = catId.split(',');
for (const id of catIds) {
const c = await this.appCatsEntity.findOne({ where: { id: Number(id) } });
if (!c) {
throw new HttpException(`分类ID ${id} 不存在!`, HttpStatus.BAD_REQUEST);
}
}
// 创建更新数据对象
const updateData = { ...body } as any;
const curApp = await this.appEntity.findOne({ where: { id } });
const curAppData = curApp as any;
// 设置默认值
updateData.appModel = updateData.appModel ?? (curAppData.appModel || '');
updateData.order = isNaN(Number(updateData.order)) ? 100 : updateData.order;
updateData.status = isNaN(Number(updateData.status)) ? 1 : updateData.status;
updateData.isGPTs = isNaN(Number(updateData.isGPTs)) ? 0 : updateData.isGPTs;
updateData.isFlowith = isNaN(Number(updateData.isFlowith)) ? 0 : updateData.isFlowith;
updateData.flowithId = updateData.flowithId ?? (curAppData.flowithId || '');
updateData.flowithName = updateData.flowithName ?? (curAppData.flowithName || '');
updateData.isFixedModel = isNaN(Number(updateData.isFixedModel)) ? 0 : updateData.isFixedModel;
updateData.backgroundImg = updateData.backgroundImg ?? (curAppData.backgroundImg || '');
updateData.prompt = updateData.prompt ?? (curAppData.prompt || '');
if (curAppData.status !== updateData.status) {
await this.userAppsEntity.update({ appId: id }, { status: updateData.status });
}
const res = await this.appEntity.update({ id }, updateData);
if (res.affected > 0) return '修改App信息成功';
throw new HttpException('修改App信息失败', HttpStatus.BAD_REQUEST);
}
async delApp(body: OperateAppDto) {
const { id } = body;
const a = await this.appEntity.findOne({ where: { id } });
if (!a) {
throw new HttpException('该应用不存在!', HttpStatus.BAD_REQUEST);
}
const res = await this.appEntity.delete(id);
if (res.affected > 0) return '删除App成功';
throw new HttpException('删除App失败', HttpStatus.BAD_REQUEST);
}
async collect(body: CollectAppDto, req: Request) {
const { appId } = body;
const { id: userId } = req.user;
// 验证参数
if (appId === undefined || appId === null || isNaN(Number(appId))) {
throw new HttpException('无效的应用ID', HttpStatus.BAD_REQUEST);
}
if (userId === undefined || userId === null || isNaN(Number(userId))) {
throw new HttpException('无效的用户ID', HttpStatus.BAD_REQUEST);
}
const historyApp = await this.userAppsEntity.findOne({
where: { appId, userId },
});
if (historyApp) {
const r = await this.userAppsEntity.delete({ appId, userId });
if (r.affected > 0) {
return '取消收藏成功!';
} else {
throw new HttpException('取消收藏失败!', HttpStatus.BAD_REQUEST);
}
}
const app = await this.appEntity.findOne({ where: { id: appId } });
if (!app) {
throw new HttpException('应用不存在!', HttpStatus.BAD_REQUEST);
}
const { id, role: appRole, catId } = app;
const collectInfo = {
userId,
appId: id,
catId,
appRole,
public: true,
status: 1,
};
await this.userAppsEntity.save(collectInfo);
return '已将应用加入到我的收藏!';
}
async mineApps(req: Request, query = { page: 1, size: 30 }) {
const { id } = req.user;
const { page = 1, size = 30 } = query;
let filteredRows = [];
try {
// 获取用户的分类ID列表
const userCatIds = await this.userBalanceService.getUserApps(Number(id));
const userCatIdsSet = new Set(userCatIds);
const [rows, count] = await this.userAppsEntity.findAndCount({
where: { userId: id, status: In([1, 3, 4, 5]) },
order: { id: 'DESC' },
skip: (page - 1) * size,
take: size,
});
const appIds = rows.map(item => item.appId);
const appsInfo = await this.appEntity.find({ where: { id: In(appIds) } });
// 获取所有分类信息
const allCats = await this.appCatsEntity.find();
const catsMap = {};
allCats.forEach(cat => {
catsMap[cat.id] = cat;
});
// 如果是超级管理员,跳过过滤逻辑
filteredRows = [...rows];
if (req?.user?.role !== 'super') {
filteredRows = rows.filter(item => {
const app = appsInfo.find(c => c.id === item.appId);
if (!app) return false;
// 获取应用所属的所有分类
const appCatIds = app.catId.split(',').map(id => Number(id));
// 检查应用是否属于用户拥有的任何分类
for (const catId of appCatIds) {
if (userCatIdsSet.has(catId.toString())) {
return true;
}
}
// 检查应用的分类是否有会员专属且对非会员隐藏的
for (const catId of appCatIds) {
const cat = catsMap[catId];
if (cat && cat.isMember === 1 && cat.hideFromNonMember === 1) {
return false; // 过滤掉这个应用
}
}
return true; // 保留这个应用
});
}
// 为每个应用添加详细信息
filteredRows.forEach((item: any) => {
const app = appsInfo.find(c => c.id === item.appId);
if (!app) return;
item.appName = app.name || '';
item.appRole = app.role || '';
item.appDes = app.des || '';
item.coverImg = app.coverImg || '';
item.demoData = app.demoData || '';
item.backgroundImg = app.backgroundImg || '';
// 添加分类名称
const appCatIds = app.catId.split(',');
const catNames = appCatIds
.map(id => {
const cat = catsMap[Number(id)];
return cat ? cat.name : '';
})
.filter(name => name);
item.catName = catNames.join(',');
// 处理 preset 字段
item.preset = app.userId === id ? app.preset : '******';
item.prompt = app.prompt || '';
});
} catch (error) {
throw new HttpException('获取用户应用列表失败', HttpStatus.INTERNAL_SERVER_ERROR);
}
return { rows: filteredRows, count: filteredRows.length };
}
/**
* 检查应用是否是会员专属
* @param appId 应用ID
* @returns 返回应用是否是会员专属的布尔值
*/
async checkAppIsMemberOnly(appId: number): Promise<boolean> {
try {
// 查询应用信息
const appInfo = await this.appEntity.findOne({
where: { id: appId },
select: ['catId'],
});
if (!appInfo || !appInfo.catId) {
return false;
}
// 解析分类ID列表
const catIds = appInfo.catId
.split(',')
.map(id => Number(id.trim()))
.filter(id => id > 0);
if (catIds.length === 0) {
return false;
}
// 查询这些分类是否有会员专属的
const cats = await this.appCatsEntity.find({
where: { id: In(catIds) },
select: ['id', 'isMember'],
});
// 检查是否有任何一个分类是会员专属的
return cats.some(cat => cat.isMember === 1);
} catch (error) {
return false; // 出错时默认返回非会员专属
}
}
}

View File

@@ -0,0 +1,20 @@
import { BaseEntity } from 'src/common/entity/baseEntity';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'app_cats' })
export class AppCatsEntity extends BaseEntity {
@Column({ unique: true, comment: 'App分类名称' })
name: string;
@Column({ comment: 'App分类排序、数字越大越靠前', default: 100 })
order: number;
@Column({ comment: 'App分类是否启用中 0禁用 1启用', default: 1 })
status: number;
@Column({ comment: 'App分类是否为会员专属 0否 1是', default: 0 })
isMember: number;
@Column({ comment: '非会员是否隐藏 0否 1是', default: 0 })
hideFromNonMember: number;
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber } from 'class-validator';
export class CollectAppDto {
@ApiProperty({ example: 1, description: '要收藏的appId', required: true })
@IsNumber({}, { message: 'ID必须是Number' })
appId: number;
}

View File

@@ -0,0 +1,111 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDefined, IsIn, IsNumber, IsOptional } from 'class-validator';
export class CreateAppDto {
@ApiProperty({ example: '前端助手', description: 'app名称', required: true })
@IsDefined({ message: 'app名称是必传参数' })
name: string;
@ApiProperty({
example: '1,2,3',
description: 'app分类Id列表多个分类Id以逗号分隔',
required: true,
})
@IsDefined({ message: 'app分类Id必传参数' })
catId: string;
@ApiProperty({
example: '适用于编程编码、期望成为您的编程助手',
description: 'app名称详情描述',
required: false,
})
@IsDefined({ message: 'app名称描述是必传参数' })
des: string;
@ApiProperty({
example: '你现在是一个翻译官。接下来我说的所有话帮我翻译成中文',
description: '预设的prompt',
required: false,
})
@IsOptional()
preset: string;
@ApiProperty({
example: 'GPTs 的调用ID',
description: 'GPTs 使用的 ID',
required: false,
})
@IsOptional()
gizmoID: string;
@ApiProperty({ description: '是否GPTs', required: false })
@IsOptional()
isGPTs: number;
@ApiProperty({
example: 'https://xxxx.png',
description: '套餐封面图片',
required: false,
})
@IsOptional()
coverImg: string;
@ApiProperty({
example: 100,
description: '套餐排序、数字越大越靠前',
required: false,
})
@IsOptional()
order: number;
@ApiProperty({
example: 1,
description: '套餐状态 0禁用 1启用',
required: true,
})
@IsNumber({}, { message: '套餐状态必须是Number' })
@IsIn([0, 1, 3, 4, 5], { message: '套餐状态错误' })
status: number;
@ApiProperty({
example: '这是一句示例数据',
description: 'app示例数据',
required: false,
})
demoData: string;
@ApiProperty({
example: 'system',
description: '创建的角色',
required: false,
})
role: string;
@ApiProperty({
example: 0,
description: '是否使用flowith模型',
required: false,
})
isFlowith: number;
@ApiProperty({
example: 'flowith模型ID',
description: 'flowith模型ID',
required: false,
})
flowithId: string;
@ApiProperty({
example: 'flowith模型名称',
description: 'flowith模型名称',
required: false,
})
flowithName: string;
@ApiProperty({
example: 'flowith模型Key',
description: 'flowith模型Key',
required: false,
})
flowithKey: string;
}

View File

@@ -0,0 +1,47 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDefined, IsIn, IsNumber, IsOptional } from 'class-validator';
export class CreateCatsDto {
@ApiProperty({
example: '编程助手',
description: 'app分类名称',
required: true,
})
@IsDefined({ message: 'app分类名称是必传参数' })
name: string;
@ApiProperty({
example: 100,
description: '分类排序、数字越大越靠前',
required: false,
})
@IsOptional()
order: number;
@ApiProperty({
example: 1,
description: '分类状态 0禁用 1启用',
required: true,
})
@IsNumber({}, { message: '状态必须是Number' })
@IsIn([0, 1, 3, 4, 5], { message: '套餐状态错误' })
status: number;
@ApiProperty({
example: 0,
description: '分类是否为会员专属 0否 1是',
required: true,
})
@IsNumber({}, { message: '分类是否为会员专属必须是Number' })
@IsIn([0, 1], { message: '分类是否为会员专属错误' })
isMember: number;
@ApiProperty({
example: 0,
description: '非会员是否隐藏 0否 1是',
required: true,
})
@IsNumber({}, { message: '非会员是否隐藏必须是Number' })
@IsIn([0, 1], { message: '非会员是否隐藏状态错误' })
hideFromNonMember: number;
}

View File

@@ -0,0 +1,50 @@
import { IsOptional, IsDefined } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class CustomAppDto {
@ApiProperty({ example: '前端助手', description: 'app名称', required: true })
name: string;
@ApiProperty({ example: 1, description: 'app分类Id', required: true })
catId: number;
@ApiProperty({
example: '适用于编程编码、期望成为您的编程助手',
description: 'app名称详情描述',
required: false,
})
@IsDefined({ message: 'app名称描述是必传参数' })
des: string;
@ApiProperty({
example: '你现在是一个翻译官。接下来我说的所有话帮我翻译成中文',
description: '预设的prompt',
required: true,
})
preset: string;
@ApiProperty({
example: 'https://xxxx.png',
description: '套餐封面图片',
required: false,
})
coverImg: string;
@ApiProperty({
example: '这是一句示例数据',
description: 'app示例数据',
required: false,
})
demoData: string;
@ApiProperty({
example: false,
description: '是否共享到所有人',
required: false,
})
public: boolean;
@ApiProperty({ example: 1, description: '应用ID', required: false })
@IsOptional()
appId: number;
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber } from 'class-validator';
export class OperateAppDto {
@ApiProperty({ example: 1, description: '要删除的appId', required: true })
@IsNumber({}, { message: 'ID必须是Number' })
id: number;
}

View File

@@ -0,0 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber } from 'class-validator';
export class DeleteCatsDto {
@ApiProperty({ example: 1, description: '要删除app分类Id', required: true })
@IsNumber({}, { message: 'ID必须是Number' })
id: number;
}

View File

@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional } from 'class-validator';
export class QuerAppDto {
@ApiProperty({ example: 1, description: '查询页数', required: false })
@IsOptional()
page: number;
@ApiProperty({ example: 10, description: '每页数量', required: false })
@IsOptional()
size: number;
@ApiProperty({ example: 'name', description: 'app名称', required: false })
@IsOptional()
name: string;
@ApiProperty({
example: 1,
description: 'app状态 0禁用 1启用 3:审核加入广场中 4已拒绝加入广场',
required: false,
})
@IsOptional()
status: number;
@ApiProperty({ example: 2, description: 'app分类Id', required: false })
@IsOptional()
catId: number;
@ApiProperty({ example: 'role', description: 'app角色', required: false })
@IsOptional()
role: string;
@ApiProperty({
example: '关键词',
description: '搜索关键词',
required: false,
})
@IsOptional()
keyword: string;
}

View File

@@ -0,0 +1,24 @@
import { IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class QuerCatsDto {
@ApiProperty({ example: 1, description: '查询页数', required: false })
@IsOptional()
page: number;
@ApiProperty({ example: 10, description: '每页数量', required: false })
@IsOptional()
size: number;
@ApiProperty({ example: 'name', description: '分类名称', required: false })
@IsOptional()
name: string;
@ApiProperty({
example: 1,
description: '分类状态 0禁用 1启用',
required: false,
})
@IsOptional()
status: number;
}

View File

@@ -0,0 +1,9 @@
import { IsNumber } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { CreateAppDto } from './createApp.dto';
export class UpdateAppDto extends CreateAppDto {
@ApiProperty({ example: 1, description: '要修改的分类Id', required: true })
@IsNumber({}, { message: '分类ID必须是Number' })
id: number;
}

View File

@@ -0,0 +1,9 @@
import { IsNumber } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { CreateCatsDto } from './createCats.dto';
export class UpdateCatsDto extends CreateCatsDto {
@ApiProperty({ example: 1, description: '要修改的分类Id', required: true })
@IsNumber({}, { message: '分类ID必须是Number' })
id: number;
}

View File

@@ -0,0 +1,23 @@
import { BaseEntity } from 'src/common/entity/baseEntity';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'user_apps' })
export class UserAppsEntity extends BaseEntity {
@Column({ comment: '用户ID' })
userId: number;
@Column({ comment: '应用ID' })
appId: number;
@Column({ comment: 'app类型 system/user', default: 'user' })
appType: string;
@Column({ comment: '是否公开到公告菜单', default: false })
public: boolean;
@Column({ comment: 'app状态 1正常 2审核 3违规', default: 1 })
status: number;
@Column({ comment: 'App应用排序、数字越大越靠前', default: 100 })
order: number;
}

View File

@@ -0,0 +1,80 @@
import { JwtAuthGuard } from '@/common/auth/jwtAuth.guard';
import { SuperAuthGuard } from '@/common/auth/superAuth.guard';
import { Body, Controller, Get, Logger, Post, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import { AuthService } from './auth.service';
import { UserLoginDto } from './dto/authLogin.dto';
import { UpdatePassByOtherDto } from './dto/updatePassByOther.dto';
import { UpdatePasswordDto } from './dto/updatePassword.dto';
@ApiTags('auth')
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService) {}
@Post('login')
@ApiOperation({ summary: '用户登录' })
async login(@Body() body: UserLoginDto, @Req() req: Request) {
return this.authService.login(body, req);
}
@Post('updatePassword')
@ApiOperation({ summary: '用户更改密码' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async updatePassword(@Req() req: Request, @Body() body: UpdatePasswordDto) {
return this.authService.updatePassword(req, body);
}
@Post('updatePassByOther')
@ApiOperation({ summary: '管理员更改用户密码' })
@UseGuards(JwtAuthGuard)
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
async updatePassByOther(@Req() req: Request, @Body() body: UpdatePassByOtherDto) {
return this.authService.updatePassByOther(req, body);
}
@Get('getInfo')
@ApiOperation({ summary: '获取用户个人信息' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async getInfo(@Req() req: Request) {
const { id, role } = req.user || {};
const fingerprint = req.headers.fingerprint;
Logger.debug(
`用户信息请求 | ID: ${id} | 角色: ${role} | 指纹: ${fingerprint}`,
'AuthController',
);
return this.authService.getInfo(req);
}
@Post('sendCode')
@ApiOperation({ summary: '发送验证码' })
async sendCode(@Body() parmas: any) {
return this.authService.sendCode(parmas);
}
@Post('sendPhoneCode')
@ApiOperation({ summary: '发送手机验证码' })
async sendPhoneCode(@Body() parmas: any) {
return this.authService.sendPhoneCode(parmas);
}
@Post('verifyIdentity')
@ApiOperation({ summary: '验证身份' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async verifyIdentity(@Req() req: Request, @Body() body: any) {
return this.authService.verifyIdentity(req, body);
}
@Post('verifyPhoneIdentity')
@ApiOperation({ summary: '验证手机号' })
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async verifyPhoneIdentity(@Req() req: Request, @Body() body: any) {
return this.authService.verifyPhoneIdentity(req, body);
}
}

View File

@@ -0,0 +1,64 @@
import { JwtStrategy } from '@/common/auth/jwt.strategy';
import { JwtAuthGuard } from '@/common/auth/jwtAuth.guard';
import { Global, Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ChatGroupEntity } from '../chatGroup/chatGroup.entity';
import { ChatLogEntity } from '../chatLog/chatLog.entity';
import { CramiPackageEntity } from '../crami/cramiPackage.entity';
import { ConfigEntity } from '../globalConfig/config.entity';
import { MailerService } from '../mailer/mailer.service';
import { RedisCacheModule } from '../redisCache/redisCache.module';
import { RedisCacheService } from '../redisCache/redisCache.service';
import { UserEntity } from '../user/user.entity';
import { UserModule } from '../user/user.module';
import { AccountLogEntity } from '../userBalance/accountLog.entity';
import { BalanceEntity } from '../userBalance/balance.entity';
import { FingerprintLogEntity } from '../userBalance/fingerprint.entity';
import { UserBalanceEntity } from '../userBalance/userBalance.entity';
import { UserBalanceService } from '../userBalance/userBalance.service';
import { VerificationEntity } from './../verification/verification.entity';
import { VerificationService } from './../verification/verification.service';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
@Global()
@Module({
imports: [
UserModule,
RedisCacheModule,
PassportModule.register({ defaultStrategy: 'jwt' }),
JwtModule.registerAsync({
inject: [RedisCacheService],
useFactory: async (redisService: RedisCacheService) => ({
secret: await redisService.getJwtSecret(),
signOptions: { expiresIn: '7d' },
}),
}),
TypeOrmModule.forFeature([
VerificationEntity,
BalanceEntity,
AccountLogEntity,
ConfigEntity,
CramiPackageEntity,
UserBalanceEntity,
UserEntity,
FingerprintLogEntity,
ChatLogEntity,
ChatGroupEntity,
]),
],
controllers: [AuthController],
providers: [
AuthService,
JwtStrategy,
JwtAuthGuard,
MailerService,
VerificationService,
UserBalanceService,
RedisCacheService,
],
exports: [AuthService],
})
export class AuthModule {}

View File

@@ -0,0 +1,491 @@
import { UserStatusEnum, UserStatusErrMsg } from '@/common/constants/user.constant';
import { createRandomCode, createRandomUid, getClientIp } from '@/common/utils';
import { GlobalConfigService } from '@/modules/globalConfig/globalConfig.service';
import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { InjectRepository } from '@nestjs/typeorm';
import * as bcrypt from 'bcryptjs';
import { Request } from 'express';
import * as os from 'os';
import { Repository } from 'typeorm';
import { ConfigEntity } from '../globalConfig/config.entity';
import { MailerService } from '../mailer/mailer.service';
import { RedisCacheService } from '../redisCache/redisCache.service';
import { UserService } from '../user/user.service';
import { UserBalanceService } from '../userBalance/userBalance.service';
import { UserEntity } from './../user/user.entity';
import { VerificationService } from './../verification/verification.service';
import { UserLoginDto } from './dto/authLogin.dto';
import { UpdatePassByOtherDto } from './dto/updatePassByOther.dto';
import { UpdatePasswordDto } from './dto/updatePassword.dto';
@Injectable()
export class AuthService {
private ipAddress: string;
constructor(
@InjectRepository(ConfigEntity)
private readonly configEntity: Repository<ConfigEntity>,
private userService: UserService,
private jwtService: JwtService,
private mailerService: MailerService,
private readonly verificationService: VerificationService,
private readonly userBalanceService: UserBalanceService,
private readonly redisCacheService: RedisCacheService,
private readonly globalConfigService: GlobalConfigService, // private readonly userEntity: Repository<UserEntity>
) {}
async onModuleInit() {
this.getIp();
}
async login(user: UserLoginDto, req: Request): Promise<string> {
Logger.debug(`用户登录尝试,用户名: ${user.username}`, 'authService');
// 检查是否是验证码登录
if (user.captchaId) {
Logger.debug(`检测到验证码登录,联系方式: ${user.username}`, 'authService');
return await this.loginWithCaptcha({ contact: user.username, code: user.captchaId }, req);
}
// 密码登录流程
const u: UserEntity = await this.userService.verifyUserCredentials(user);
if (!u) {
Logger.error(`登录失败: 用户凭证无效 - 用户名: ${user.username}`, 'authService');
throw new HttpException('登录失败,用户凭证无效。', HttpStatus.UNAUTHORIZED);
}
const { username, id, email, role, openId, client, phone } = u;
Logger.debug(`用户${username}(ID: ${id})登录成功`, 'authService');
// 保存登录IP
const ip = getClientIp(req);
await this.userService.savaLoginIp(id, ip);
// 生成JWT令牌
const token = await this.jwtService.sign({
username,
id,
email,
role,
openId,
client,
phone,
});
// 保存令牌到Redis
await this.redisCacheService.saveToken(id, token);
Logger.debug(`用户${username}(ID: ${id})登录完成IP: ${ip}`, 'authService');
return token;
}
async loginWithCaptcha(body: any, req: Request): Promise<string> {
const { contact, code } = body;
let email = '',
phone = '';
// 判断 contact 是邮箱还是手机号
const isEmail = /\S+@\S+\.\S+/.test(contact);
const isPhone = /^\d{10,}$/.test(contact); // 根据实际需求调整正则表达式
Logger.debug(`验证码登录 | 联系方式: ${contact}`, 'authService');
if (isEmail) {
email = contact;
} else if (isPhone) {
phone = contact;
} else {
throw new HttpException('请提供有效的邮箱地址或手机号码。', HttpStatus.BAD_REQUEST);
}
// 验证短信或邮箱验证码
const nameSpace = await this.globalConfigService.getNamespace();
const codeKey = `${nameSpace}:CODE:${contact}`;
// 获取验证码
const savedCode = await this.redisCacheService.get({ key: codeKey });
if (savedCode) {
// 验证码存在,检查是否匹配
if (savedCode !== code) {
Logger.log(`验证码错误 | 联系方式: ${contact}`, 'authService');
throw new HttpException('验证码错误', HttpStatus.BAD_REQUEST);
}
Logger.debug(`验证码验证成功`);
// 验证码验证成功后,立即删除缓存中的验证码,避免重复使用
await this.redisCacheService.del({ key: codeKey });
// 处理用户登录
return await this.processUserLogin(email, phone, contact, req);
} else {
Logger.log(`验证码不存在或已过期 | 联系方式: ${contact}`, 'authService');
throw new HttpException('验证码不存在或已过期,请重新获取', HttpStatus.BAD_REQUEST);
}
}
// 抽取用户登录处理逻辑为独立方法
private async processUserLogin(
email: string,
phone: string,
contact: string,
req: Request,
): Promise<string> {
// 检查用户是否存在
let u = await this.userService.getUserByContact({ email, phone });
// 如果用户不存在,创建新用户
if (!u) {
Logger.log(`创建新用户 | 联系方式: ${contact}`, 'authService');
// 创建随机用户名
let username = createRandomUid();
while (true) {
const usernameTaken = await this.userService.verifyUserRegister({
username,
});
if (usernameTaken) {
break;
}
username = createRandomUid();
}
// 创建新用户对象
let newUser: any = {
username,
status: UserStatusEnum.ACTIVE,
};
// 根据联系方式类型添加相应字段
const isEmail = /\S+@\S+\.\S+/.test(contact);
if (isEmail) {
newUser.email = contact;
} else {
// 为手机用户创建一个随机邮箱
newUser.email = `${createRandomUid()}@aiweb.com`;
newUser.phone = contact;
}
// 创建随机密码并加密
const randomPassword = createRandomUid().substring(0, 8);
const hashedPassword = bcrypt.hashSync(randomPassword, 10);
newUser.password = hashedPassword;
// 保存新用户到数据库
u = await this.userService.createUser(newUser);
Logger.log(`用户创建成功 | 用户ID: ${u.id}`, 'authService');
// 为新用户添加初始余额
await this.userBalanceService.addBalanceToNewUser(u.id);
}
if (!u) {
throw new HttpException('登录失败,用户创建失败。', HttpStatus.INTERNAL_SERVER_ERROR);
}
const { username, id, role, openId, client } = u;
// 保存登录IP
const ip = getClientIp(req);
await this.userService.savaLoginIp(id, ip);
// 生成JWT令牌
const token = await this.jwtService.sign({
username,
id,
email,
role,
openId,
client,
phone,
});
// 保存令牌到Redis
await this.redisCacheService.saveToken(id, token);
Logger.log(`用户登录成功 | 用户ID: ${id} | 联系方式: ${contact}`, 'authService');
return token;
}
async loginByOpenId(user: UserEntity, req: Request): Promise<string> {
const { status } = user;
if (status !== UserStatusEnum.ACTIVE) {
throw new HttpException(UserStatusErrMsg[status], HttpStatus.BAD_REQUEST);
}
const { username, id, email, role, openId, client } = user;
const ip = getClientIp(req);
await this.userService.savaLoginIp(id, ip);
const token = await this.jwtService.sign({
username,
id,
email,
role,
openId,
client,
});
await this.redisCacheService.saveToken(id, token);
return token;
}
async getInfo(req: Request) {
const { id, role } = req.user;
Logger.debug(`获取用户信息 | 用户ID: ${id} | 角色: ${role}`, 'AuthService-getInfo');
// 记录请求头中的指纹
if (req.headers.fingerprint) {
Logger.debug(`请求包含指纹头: ${req.headers.fingerprint}`, 'AuthService-getInfo');
}
try {
const result = await this.userService.getUserInfo(id);
Logger.debug(`成功获取用户信息 | 用户ID: ${id}`, 'AuthService-getInfo');
return result;
} catch (error) {
Logger.error(`获取用户信息失败: ${error.message}`, 'AuthService-getInfo');
throw error;
}
}
async updatePassword(req: Request, body: UpdatePasswordDto) {
const { id, client, role } = req.user;
if (client && Number(client) > 0) {
throw new HttpException('无权此操作、请联系管理员!', HttpStatus.BAD_REQUEST);
}
if (role === 'admin') {
throw new HttpException('非法操作、请联系管理员!', HttpStatus.BAD_REQUEST);
}
// const bool = await this.userService.verifyUserPassword(id, body.oldPassword);
// if (!bool) {
// throw new HttpException('旧密码错误、请检查提交', HttpStatus.BAD_REQUEST);
// }
this.userService.updateUserPassword(id, body.password);
return '密码修改成功';
}
async updatePassByOther(req: Request, body: UpdatePassByOtherDto) {
const { id, client } = req.user;
if (!client) {
throw new HttpException('无权此操作!', HttpStatus.BAD_REQUEST);
}
this.userService.updateUserPassword(id, body.password);
return '密码修改成功';
}
getIp() {
let ipAddress: string;
const interfaces = os.networkInterfaces();
Object.keys(interfaces).forEach(interfaceName => {
const interfaceInfo = interfaces[interfaceName];
for (let i = 0; i < interfaceInfo.length; i++) {
const alias = interfaceInfo[i];
if (alias.family === 'IPv4' && alias.address !== '127.0.0.1' && !alias.internal) {
ipAddress = alias.address;
break;
}
}
});
this.ipAddress = ipAddress;
}
/* 发送验证证码 */
async sendCode(body: any) {
const { contact, isLogin } = body;
let email = '',
phone = '';
const code = createRandomCode();
// 判断 contact 是邮箱还是手机号
const isEmail = /\S+@\S+\.\S+/.test(contact);
const isPhone = /^\d{10,}$/.test(contact); // 根据实际需求调整正则表达式
Logger.debug(`发送验证码 | 联系方式: ${contact}`);
if (!isEmail && !isPhone) {
throw new HttpException('请提供有效的邮箱地址或手机号码。', HttpStatus.BAD_REQUEST);
}
// 注册时才检查用户是否已存在
if (!isLogin) {
if (isEmail) {
email = contact;
} else if (isPhone) {
phone = contact;
}
}
const nameSpace = await this.globalConfigService.getNamespace();
const key = `${nameSpace}:CODE:${contact}`;
// 检查Redis中是否已经有验证码且未过期
const ttl = await this.redisCacheService.ttl(key);
if (ttl && ttl > 0 && isPhone) {
throw new HttpException(`${ttl}秒内不得重复发送验证码!`, HttpStatus.BAD_REQUEST);
}
if (isEmail) {
// 检查Redis中是否已经有验证码
const existingCode = await this.redisCacheService.get({ key });
if (existingCode) {
// 如果存在有效的验证码,则直接使用这个验证码,而不生成新的
await this.mailerService.sendMail({
to: email,
context: {
// 这里传入模板中使用的变量和数据
code: existingCode,
},
});
Logger.log(`重发验证码 | 邮箱: ${email}`, 'authService');
return `验证码发送成功、请填写验证码完成${isLogin ? '登录' : '注册'}`;
} else {
// 如果没有现有验证码或验证码已过期,则生成新的验证码
try {
await this.mailerService.sendMail({
to: email,
context: {
// 这里传入模板中使用的变量和数据
code: code,
},
});
Logger.log(`发送新验证码 | 邮箱: ${email}`, 'authService');
} catch (error) {
Logger.error(`邮件发送失败: ${error.message}`, 'authService');
throw new HttpException('验证码发送失败,请稍后重试', HttpStatus.INTERNAL_SERVER_ERROR);
}
await this.redisCacheService.set({ key, val: code }, 10 * 60); // 设置验证码600秒过期
return `验证码发送成功、请填写验证码完成${isLogin ? '登录' : '注册'}`;
}
} else if (isPhone) {
const messageInfo = { phone, code };
await this.verificationService.sendPhoneCode(messageInfo);
await this.redisCacheService.set({ key, val: code }, 10 * 60);
Logger.log(`发送验证码 | 手机号: ${phone}`, 'authService');
return `验证码发送成功、请填写验证码完成${isLogin ? '登录' : '注册'}`;
}
}
/* 发送验证证码 */
async sendPhoneCode(body: any) {
const { phone, isLogin } = body;
// const { id } = req.user;
const code = createRandomCode();
// 判断 contact 是邮箱还是手机号
const isPhone = /^\d{10,}$/.test(phone); // 根据实际需求调整正则表达式
Logger.debug(`发送手机验证码 | 手机号: ${phone}`);
if (!isPhone) {
throw new HttpException('请提供有效的手机号码。', HttpStatus.BAD_REQUEST);
}
// 仅在注册流程且指定登录标记时校验已存在用户
if (isLogin === false) {
const isAvailable = await this.userService.verifyUserRegister({
phone,
});
if (!isAvailable) {
throw new HttpException('当前手机号已注册,请勿重复注册!', HttpStatus.BAD_REQUEST);
}
}
const nameSpace = await this.globalConfigService.getNamespace();
const key = `${nameSpace}:CODE:${phone}`;
// 检查Redis中是否已经有验证码且未过期
const ttl = await this.redisCacheService.ttl(key);
if (ttl && ttl > 0 && isPhone) {
throw new HttpException(`${ttl}秒内不得重复发送验证码!`, HttpStatus.BAD_REQUEST);
}
const messageInfo = { phone, code };
await this.redisCacheService.set({ key, val: code }, 10 * 60);
await this.verificationService.sendPhoneCode(messageInfo);
Logger.log(`发送验证码 | 手机号: ${phone}`, 'authService');
return `验证码发送成功、请填写验证码完成${isLogin === false ? '注册' : '验证/登录'}`;
}
/* create token */
createTokenFromFingerprint(fingerprint) {
const token = this.jwtService.sign({
username: `游客${fingerprint}`,
id: fingerprint,
email: `${fingerprint}@visitor.com`,
role: 'visitor',
openId: null,
client: null,
});
return token;
}
async verifyIdentity(req: Request, body) {
Logger.debug('开始实名认证流程');
const { name, idCard } = body;
const { id } = req.user;
try {
// 调用验证服务进行身份验证
const result = await this.verificationService.verifyIdentity(body);
// 输出验证结果到日志
Logger.debug(`实名认证结果: ${result}`);
// 检查验证结果
if (!result) {
throw new HttpException('身份验证错误,请检查实名信息', HttpStatus.BAD_REQUEST);
}
// 保存用户的实名信息
await this.userService.saveRealNameInfo(id, name, idCard);
return '认证成功';
} catch (error) {
// 处理可能的错误并记录错误信息
Logger.error('验证过程出现错误', error);
throw new HttpException('认证失败,请检查相关信息', HttpStatus.BAD_REQUEST);
}
}
async verifyPhoneIdentity(req: Request, body) {
Logger.debug('开始手机号认证流程');
const { phone, username, password, code } = body;
const { id } = req.user;
// 校验验证码是否过期或错误
const nameSpace = this.globalConfigService.getNamespace();
const key = `${nameSpace}:CODE:${phone}`;
const redisCode = await this.redisCacheService.get({ key });
Logger.debug(`Retrieved redisCode for ${phone}: ${redisCode}`);
if (code === '') {
throw new HttpException('请输入验证码', HttpStatus.BAD_REQUEST);
}
if (!redisCode) {
Logger.log(`验证码过期: ${phone}`, 'authService');
throw new HttpException('验证码已过期,请重新发送!', HttpStatus.BAD_REQUEST);
}
if (code !== redisCode) {
Logger.log(
`验证码错误: ${phone} 输入的验证码: ${code}, 期望的验证码: ${redisCode}`,
'authService',
);
throw new HttpException('验证码填写错误,请重新输入!', HttpStatus.BAD_REQUEST);
}
// 验证用户名是否已存在
if (username) {
const usernameTaken = await this.userService.isUsernameTaken(body.username, id);
if (usernameTaken) {
throw new HttpException('用户名已存在!', HttpStatus.BAD_REQUEST);
}
}
try {
// 保存用户的实名信息
await this.userService.updateUserPhone(id, phone, username, password);
return '认证成功';
} catch (error) {
// 处理可能的错误并记录错误信息
Logger.error('验证过程出现错误', error);
throw new HttpException('身份验证错误,请检查相关信息', HttpStatus.BAD_REQUEST);
}
}
}

View File

@@ -0,0 +1,17 @@
import { IsNotEmpty, MinLength, MaxLength, IsOptional } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class AdminLoginDto {
@ApiProperty({ example: 'super', description: '邮箱' })
@IsNotEmpty({ message: '用户名不能为空!' })
@MinLength(2, { message: '用户名最短是两位数!' })
@MaxLength(30, { message: '用户名最长不得超过30位' })
@IsOptional()
username?: string;
@ApiProperty({ example: '999999', description: '密码' })
@IsNotEmpty({ message: '用户密码不能为空!' })
@MinLength(6, { message: '用户密码最低需要大于6位数' })
@MaxLength(30, { message: '用户密码最长不能超过30位数' })
password: string;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, MaxLength, MinLength } from 'class-validator';
export class UserLoginDto {
@ApiProperty({ example: 'super', description: '邮箱' })
@IsNotEmpty({ message: '用户名不能为空!' })
@MinLength(2, { message: '用户名最短是两位数!' })
@MaxLength(30, { message: '用户名最长不得超过30位' })
@IsOptional()
username?: string;
@ApiProperty({ example: 1, description: '用户ID' })
@IsOptional()
uid?: number;
@ApiProperty({ example: '999999', description: '密码' })
@IsOptional()
@MinLength(6, { message: '用户密码最低需要大于6位数' })
@MaxLength(30, { message: '用户密码最长不能超过30位数' })
password?: string;
@ApiProperty({ example: 'abc123', description: '图形验证码ID' })
@IsOptional()
captchaId?: string;
}

View File

@@ -0,0 +1,37 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, MaxLength, MinLength } from 'class-validator';
export class UserRegisterDto {
@ApiProperty({ example: 'cooper', description: '用户名称' })
// @IsNotEmpty({ message: '用户名不能为空!' })
// @MinLength(2, { message: '用户名最低需要大于2位数' })
// @MaxLength(12, { message: '用户名不得超过12位' })
username?: string;
@ApiProperty({ example: '123456', description: '用户密码' })
@IsNotEmpty({ message: '用户密码不能为空' })
@MinLength(6, { message: '用户密码最低需要大于6位数' })
@MaxLength(30, { message: '用户密码最长不能超过30位数' })
password: string;
@ApiProperty({ example: 'ai@aiweb.com', description: '用户邮箱' })
// @IsEmail({}, { message: '请填写正确格式的邮箱!' })
// @IsNotEmpty({ message: '邮箱不能为空!' })
email: string;
@ApiProperty({
example: '',
description: '用户头像',
required: false,
})
@IsOptional()
avatar: string;
@ApiProperty({
example: 'default',
description: '用户注册来源',
required: false,
})
@IsOptional()
client: string;
}

View File

@@ -0,0 +1,15 @@
import { IsNotEmpty, MinLength, MaxLength, IsPhoneNumber } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class LoginByPhoneDto {
@ApiProperty({ example: '19999999', description: '手机号' })
@IsNotEmpty({ message: '手机号不能为空!' })
@IsPhoneNumber('CN', { message: '手机号格式不正确!' })
phone?: string;
@ApiProperty({ example: '999999', description: '密码' })
@IsNotEmpty({ message: '用户密码不能为空!' })
@MinLength(6, { message: '用户密码最低需要大于6位数' })
@MaxLength(30, { message: '用户密码最长不能超过30位数' })
password: string;
}

View File

@@ -0,0 +1,18 @@
import { IsNotEmpty, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class SendPhoneCodeDto {
@ApiProperty({ example: '199999999', description: '手机号' })
@IsNotEmpty({ message: '手机号不能为空' })
@MinLength(11, { message: '手机号长度为11位' })
@MaxLength(11, { message: '手机号长度为11位' })
phone?: string;
@ApiProperty({ example: '2b4i1b4', description: '图形验证码KEY' })
@IsNotEmpty({ message: '验证码KEY不能为空' })
captchaId?: string;
@ApiProperty({ example: '1g4d', description: '图形验证码' })
@IsNotEmpty({ message: '验证码不能为空' })
captchaCode?: string;
}

View File

@@ -0,0 +1,10 @@
import { IsNotEmpty, MinLength, MaxLength } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
export class UpdatePassByOtherDto {
@ApiProperty({ example: '666666', description: '三方用户更新新密码' })
@IsNotEmpty({ message: '用户密码不能为空!' })
@MinLength(6, { message: '用户密码最低需要大于6位数' })
@MaxLength(30, { message: '用户密码最长不能超过30位数' })
password: string;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, MaxLength, MinLength } from 'class-validator';
export class UpdatePasswordDto {
@ApiProperty({ example: '666666', description: '用户更新新密码' })
@IsNotEmpty({ message: '用户密码不能为空!' })
@MinLength(6, { message: '用户密码最低需要大于6位数' })
@MaxLength(30, { message: '用户密码最长不能超过30位数' })
password: string;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsPhoneNumber, MaxLength, MinLength } from 'class-validator';
export class UserRegisterByPhoneDto {
@ApiProperty({ example: 'cooper', description: '用户名称' })
@IsNotEmpty({ message: '用户名不能为空!' })
@MinLength(2, { message: '用户名最低需要大于2位数' })
@MaxLength(12, { message: '用户名不得超过12位' })
username?: string;
@ApiProperty({ example: '123456', description: '用户密码' })
@IsNotEmpty({ message: '用户密码不能为空' })
@MinLength(6, { message: '用户密码最低需要大于6位数' })
@MaxLength(30, { message: '用户密码最长不能超过30位数' })
password: string;
@ApiProperty({ example: '19999999999', description: '用户手机号码' })
@IsPhoneNumber('CN', { message: '手机号码格式不正确!' })
@IsNotEmpty({ message: '手机号码不能为空!' })
phone: string;
@ApiProperty({ example: '152546', description: '手机验证码' })
@IsNotEmpty({ message: '手机验证码不能为空!' })
phoneCode: string;
}

View File

@@ -0,0 +1,47 @@
import { AdminAuthGuard } from '@/common/auth/adminAuth.guard';
import { SuperAuthGuard } from '@/common/auth/superAuth.guard';
import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AutoReplyService } from './autoReply.service';
import { AddAutoReplyDto } from './dto/addAutoReply.dto';
import { DelAutoReplyDto } from './dto/delBadWords.dto';
import { QueryAutoReplyDto } from './dto/queryAutoReply.dto';
import { UpdateAutoReplyDto } from './dto/updateAutoReply.dto';
@ApiTags('autoReply')
@Controller('autoReply')
export class AutoReplyController {
constructor(private readonly autoReplyService: AutoReplyService) {}
@Get('query')
@ApiOperation({ summary: '查询自动回复' })
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
queryAutoReply(@Query() query: QueryAutoReplyDto) {
return this.autoReplyService.queryAutoReply(query);
}
@Post('add')
@ApiOperation({ summary: '添加自动回复' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
addAutoReply(@Body() body: AddAutoReplyDto) {
return this.autoReplyService.addAutoReply(body);
}
@Post('update')
@ApiOperation({ summary: '修改自动回复' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
updateAutoReply(@Body() body: UpdateAutoReplyDto) {
return this.autoReplyService.updateAutoReply(body);
}
@Post('del')
@ApiOperation({ summary: '删除自动回复' })
@UseGuards(SuperAuthGuard)
@ApiBearerAuth()
delAutoReply(@Body() body: DelAutoReplyDto) {
return this.autoReplyService.delAutoReply(body);
}
}

View File

@@ -0,0 +1,17 @@
import { BaseEntity } from 'src/common/entity/baseEntity';
import { Column, Entity } from 'typeorm';
@Entity({ name: 'auto_reply' })
export class AutoReplyEntity extends BaseEntity {
@Column({ comment: '提问的问题', type: 'text' })
prompt: string;
@Column({ comment: '定义的答案', type: 'text' })
answer: string;
@Column({ default: 1, comment: '是否开启AI回复0关闭 1启用' })
isAIReplyEnabled: number;
@Column({ default: 1, comment: '启用当前自动回复状态, 0关闭 1启用' })
status: number;
}

View File

@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AutoReplyController } from './autoReply.controller';
import { AutoReplyEntity } from './autoReply.entity';
import { AutoReplyService } from './autoReply.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([AutoReplyEntity])],
controllers: [AutoReplyController],
providers: [AutoReplyService],
exports: [AutoReplyService],
})
export class AutoReplyModule {}

View File

@@ -0,0 +1,128 @@
import { HttpException, HttpStatus, Injectable, OnModuleInit } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Like, Repository } from 'typeorm';
import { AutoReplyEntity } from './autoReply.entity';
import { AddAutoReplyDto } from './dto/addAutoReply.dto';
import { DelAutoReplyDto } from './dto/delBadWords.dto';
import { QueryAutoReplyDto } from './dto/queryAutoReply.dto';
import { UpdateAutoReplyDto } from './dto/updateAutoReply.dto';
@Injectable()
export class AutoReplyService implements OnModuleInit {
private autoReplyKes: { prompt: string; keywords: string[] }[] = [];
private autoReplyMap = {};
private autoReplyFuzzyMatch = true;
constructor(
@InjectRepository(AutoReplyEntity)
private readonly autoReplyEntity: Repository<AutoReplyEntity>,
) {}
async onModuleInit() {
await this.loadAutoReplyList();
}
async loadAutoReplyList() {
const res = await this.autoReplyEntity.find({
where: { status: 1 },
select: ['prompt', 'answer', 'isAIReplyEnabled'],
});
this.autoReplyMap = {};
this.autoReplyKes = [];
res.forEach(t => {
this.autoReplyMap[t.prompt] = {
answer: t.answer,
isAIReplyEnabled: t.isAIReplyEnabled,
};
const keywords = t.prompt.split(' ').map(k => k.trim()); // 关键词以空格分词
this.autoReplyKes.push({ prompt: t.prompt, keywords });
});
}
async checkAutoReply(prompt: string) {
const answers = [];
let isAIReplyEnabled = 0;
const seenGroups = new Set<string>();
// Logger.debug('checkAutoReply', prompt);
// Logger.debug('checkAutoReply', this.autoReplyKes);
// Logger.debug('autoReplyMap', this.autoReplyMap);
if (this.autoReplyFuzzyMatch) {
for (const item of this.autoReplyKes) {
if (item.keywords.some(keyword => prompt.includes(keyword))) {
if (!seenGroups.has(item.prompt)) {
answers.push(this.autoReplyMap[item.prompt].answer);
seenGroups.add(item.prompt);
if (this.autoReplyMap[item.prompt].isAIReplyEnabled === 1) {
isAIReplyEnabled = 1;
}
}
}
}
} else {
const matches = this.autoReplyKes.filter(item => item.prompt === prompt);
for (const match of matches) {
if (!seenGroups.has(match.prompt)) {
answers.push(this.autoReplyMap[match.prompt].answer);
seenGroups.add(match.prompt);
if (this.autoReplyMap[match.prompt].isAIReplyEnabled === 1) {
isAIReplyEnabled = 1;
}
}
}
}
return {
answer: answers.join('\n'), // 拼接所有匹配到的答案
isAIReplyEnabled,
};
}
async queryAutoReply(query: QueryAutoReplyDto) {
const { page = 1, size = 10, prompt, status } = query;
const where: any = {};
[0, 1, '0', '1'].includes(status) && (where.status = status);
prompt && (where.prompt = Like(`%${prompt}%`));
const [rows, count] = await this.autoReplyEntity.findAndCount({
where,
skip: (page - 1) * size,
take: size,
order: { id: 'DESC' },
});
return { rows, count };
}
async addAutoReply(body: AddAutoReplyDto) {
// 直接保存新的自动回复
await this.autoReplyEntity.save(body);
// 重新加载自动回复列表
await this.loadAutoReplyList();
return '添加问题成功!';
}
async updateAutoReply(body: UpdateAutoReplyDto) {
const { id } = body;
const res = await this.autoReplyEntity.update({ id }, body);
if (res.affected > 0) {
await this.loadAutoReplyList();
return '更新问题成功';
}
throw new HttpException('更新失败', HttpStatus.BAD_REQUEST);
}
async delAutoReply(body: DelAutoReplyDto) {
const { id } = body;
const z = await this.autoReplyEntity.findOne({ where: { id } });
if (!z) {
throw new HttpException('该问题不存在,请检查您的提交信息', HttpStatus.BAD_REQUEST);
}
const res = await this.autoReplyEntity.delete({ id });
if (res.affected > 0) {
await this.loadAutoReplyList();
return '删除问题成功';
}
throw new HttpException('删除失败', HttpStatus.BAD_REQUEST);
}
}

View File

@@ -0,0 +1,13 @@
import { ApiProperty } from '@nestjs/swagger';
export class AddAutoReplyDto {
@ApiProperty({ example: '你是谁', description: '提问的问题', required: true })
prompt: string;
@ApiProperty({
example: '我是AIWeb提供的Ai服务机器人',
description: '回答的答案',
required: true,
})
answer: string;
}

Some files were not shown because too many files have changed in this diff Show More