mirror of
https://github.com/vastxie/99AI.git
synced 2026-04-23 02:34:36 +08:00
v4.3.0
This commit is contained in:
20
service/.dockerignore
Normal file
20
service/.dockerignore
Normal 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
15
service/.editorconfig
Normal 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
30
service/.env.docker
Normal 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
30
service/.env.example
Normal 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
31
service/.gitignore
vendored
Normal 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
18
service/.prettierrc
Normal 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
8
service/.vscode/extensions.json
vendored
Executable file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"EditorConfig.EditorConfig",
|
||||
"mikestead.dotenv",
|
||||
"Vue.volar",
|
||||
"antfu.unocss"
|
||||
]
|
||||
}
|
||||
115
service/.vscode/settings.json
vendored
Executable file
115
service/.vscode/settings.json
vendored
Executable 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
21
service/Dockerfile
Normal 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"]
|
||||
56
service/docker-compose.yml
Normal file
56
service/docker-compose.yml
Normal 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
17
service/nest-cli.json
Normal 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
144
service/package.json
Normal 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
16
service/pm2.conf.json
Normal 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
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
112
service/src/app.module.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
service/src/common/auth/adminAuth.guard.ts
Normal file
19
service/src/common/auth/adminAuth.guard.ts
Normal 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('非法操作、您的权限等级不足、无法执行当前请求!');
|
||||
}
|
||||
}
|
||||
}
|
||||
20
service/src/common/auth/jwt.strategy.ts
Normal file
20
service/src/common/auth/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
84
service/src/common/auth/jwtAuth.guard.ts
Normal file
84
service/src/common/auth/jwtAuth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
19
service/src/common/auth/superAuth.guard.ts
Normal file
19
service/src/common/auth/superAuth.guard.ts
Normal 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('非法操作、非超级管理员无权操作!');
|
||||
}
|
||||
}
|
||||
}
|
||||
32
service/src/common/constants/balance.constant.ts
Normal file
32
service/src/common/constants/balance.constant.ts
Normal 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,
|
||||
};
|
||||
21
service/src/common/constants/errorMessage.constant.ts
Normal file
21
service/src/common/constants/errorMessage.constant.ts
Normal 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]',
|
||||
};
|
||||
23
service/src/common/constants/midjourney.constant.ts
Normal file
23
service/src/common/constants/midjourney.constant.ts
Normal 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,
|
||||
}
|
||||
10
service/src/common/constants/status.constant.ts
Normal file
10
service/src/common/constants/status.constant.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export enum VerificationUseStatusEnum {
|
||||
UNUSED,
|
||||
USED,
|
||||
}
|
||||
|
||||
export const ModelsMapCn = {
|
||||
1: '普通模型',
|
||||
2: '绘画模型',
|
||||
3: '特殊模型',
|
||||
};
|
||||
19
service/src/common/constants/user.constant.ts
Normal file
19
service/src/common/constants/user.constant.ts
Normal 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]: '当前账户已被永久封禁!',
|
||||
};
|
||||
10
service/src/common/constants/verification.constant.ts
Normal file
10
service/src/common/constants/verification.constant.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Registration: 注册账户
|
||||
* PasswordReset: 重置密码
|
||||
* ChangeEmail: 换绑邮箱
|
||||
*/
|
||||
export enum VerificationEnum {
|
||||
Registration,
|
||||
PasswordReset,
|
||||
ChangeEmail,
|
||||
}
|
||||
111
service/src/common/decorators/rate-limit.decorator.ts
Normal file
111
service/src/common/decorators/rate-limit.decorator.ts
Normal 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();
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
40
service/src/common/entity/baseEntity.ts
Normal file
40
service/src/common/entity/baseEntity.ts
Normal 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;
|
||||
}
|
||||
42
service/src/common/filters/allExceptions.filter.ts
Normal file
42
service/src/common/filters/allExceptions.filter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
23
service/src/common/filters/typeOrmQueryFailed.filter.ts
Normal file
23
service/src/common/filters/typeOrmQueryFailed.filter.ts
Normal 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}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
9
service/src/common/guards/roles/roles.guard.ts
Normal file
9
service/src/common/guards/roles/roles.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
13
service/src/common/interceptors/abort.interceptor.ts
Normal file
13
service/src/common/interceptors/abort.interceptor.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
34
service/src/common/interceptors/transform.interceptor.ts
Normal file
34
service/src/common/interceptors/transform.interceptor.ts
Normal 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));
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
149
service/src/common/logger/custom-logger.service.ts
Normal file
149
service/src/common/logger/custom-logger.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
69
service/src/common/middleware/fast-xml-middleware.ts
Normal file
69
service/src/common/middleware/fast-xml-middleware.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
13
service/src/common/middleware/xml.middleware.ts
Normal file
13
service/src/common/middleware/xml.middleware.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
service/src/common/result/index.ts
Normal file
21
service/src/common/result/index.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
service/src/common/swagger/index.ts
Normal file
13
service/src/common/swagger/index.ts
Normal 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);
|
||||
}
|
||||
23
service/src/common/utils/base.ts
Normal file
23
service/src/common/utils/base.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
service/src/common/utils/convertUrlToBase64.ts
Normal file
20
service/src/common/utils/convertUrlToBase64.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
service/src/common/utils/correctApiBaseUrl.ts
Normal file
24
service/src/common/utils/correctApiBaseUrl.ts
Normal 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;
|
||||
}
|
||||
5
service/src/common/utils/createOrderId.ts
Normal file
5
service/src/common/utils/createOrderId.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { v1 as uuidv1 } from 'uuid';
|
||||
|
||||
export function createOrderId(): string {
|
||||
return uuidv1().toString().replace(/-/g, '');
|
||||
}
|
||||
5
service/src/common/utils/createRandomCode.ts
Normal file
5
service/src/common/utils/createRandomCode.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function createRandomCode(): number {
|
||||
const min = 100000;
|
||||
const max = 999999;
|
||||
return Math.floor(Math.random() * (max - min + 1) + min);
|
||||
}
|
||||
12
service/src/common/utils/createRandomInviteCode.ts
Normal file
12
service/src/common/utils/createRandomInviteCode.ts
Normal 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;
|
||||
}
|
||||
8
service/src/common/utils/createRandomNonceStr.ts
Normal file
8
service/src/common/utils/createRandomNonceStr.ts
Normal 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;
|
||||
}
|
||||
6
service/src/common/utils/createRandomUid.ts
Normal file
6
service/src/common/utils/createRandomUid.ts
Normal 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('-', '');
|
||||
}
|
||||
39
service/src/common/utils/date.ts
Normal file
39
service/src/common/utils/date.ts
Normal 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;
|
||||
420
service/src/common/utils/doubaoSignature.ts
Normal file
420
service/src/common/utils/doubaoSignature.ts
Normal 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 };
|
||||
}
|
||||
11
service/src/common/utils/encrypt.ts
Normal file
11
service/src/common/utils/encrypt.ts
Normal 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=',
|
||||
];
|
||||
11
service/src/common/utils/fromatUrl.ts
Normal file
11
service/src/common/utils/fromatUrl.ts
Normal 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;
|
||||
}
|
||||
6
service/src/common/utils/generateCrami.ts
Normal file
6
service/src/common/utils/generateCrami.ts
Normal 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;
|
||||
}
|
||||
26
service/src/common/utils/getClientIp.ts
Normal file
26
service/src/common/utils/getClientIp.ts
Normal 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;
|
||||
}
|
||||
14
service/src/common/utils/getDiffArray.ts
Normal file
14
service/src/common/utils/getDiffArray.ts
Normal 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;
|
||||
}
|
||||
4
service/src/common/utils/getRandomItem.ts
Normal file
4
service/src/common/utils/getRandomItem.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export function getRandomItem<T>(array: T[]): T {
|
||||
const randomIndex = Math.floor(Math.random() * array.length);
|
||||
return array[randomIndex];
|
||||
}
|
||||
7
service/src/common/utils/getRandomItemFromArray.ts
Normal file
7
service/src/common/utils/getRandomItemFromArray.ts
Normal 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];
|
||||
}
|
||||
29
service/src/common/utils/getTokenCount.ts
Normal file
29
service/src/common/utils/getTokenCount.ts
Normal 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;
|
||||
};
|
||||
43
service/src/common/utils/handleError.ts
Normal file
43
service/src/common/utils/handleError.ts
Normal 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;
|
||||
}
|
||||
10
service/src/common/utils/hideString.ts
Normal file
10
service/src/common/utils/hideString.ts
Normal 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}`;
|
||||
}
|
||||
26
service/src/common/utils/index.ts
Normal file
26
service/src/common/utils/index.ts
Normal 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';
|
||||
8
service/src/common/utils/maskCrami.ts
Normal file
8
service/src/common/utils/maskCrami.ts
Normal 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;
|
||||
}
|
||||
11
service/src/common/utils/maskEmail.ts
Normal file
11
service/src/common/utils/maskEmail.ts
Normal 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}`;
|
||||
}
|
||||
6
service/src/common/utils/maskIpAddress.ts
Normal file
6
service/src/common/utils/maskIpAddress.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export function maskIpAddress(ipAddress: string): string {
|
||||
if (!ipAddress) return '';
|
||||
const ipArray = ipAddress.split('.');
|
||||
ipArray[2] = '***';
|
||||
return ipArray.join('.');
|
||||
}
|
||||
3
service/src/common/utils/removeSpecialCharacters.ts
Normal file
3
service/src/common/utils/removeSpecialCharacters.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export function removeSpecialCharacters(inputString) {
|
||||
return inputString.replace(/[^\w\s-]/g, '');
|
||||
}
|
||||
30
service/src/common/utils/removeThinkTags.ts
Normal file
30
service/src/common/utils/removeThinkTags.ts
Normal 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;
|
||||
}
|
||||
6
service/src/common/utils/tools.ts
Normal file
6
service/src/common/utils/tools.ts
Normal 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)');
|
||||
14
service/src/common/utils/utcformatTime.ts
Normal file
14
service/src/common/utils/utcformatTime.ts
Normal 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
179
service/src/main.ts
Normal 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();
|
||||
867
service/src/modules/aiTool/chat/chat.service.ts
Normal file
867
service/src/modules/aiTool/chat/chat.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
237
service/src/modules/aiTool/search/netSearch.service.ts
Normal file
237
service/src/modules/aiTool/search/netSearch.service.ts
Normal 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: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
136
service/src/modules/app/app.controller.ts
Normal file
136
service/src/modules/app/app.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
68
service/src/modules/app/app.entity.ts
Normal file
68
service/src/modules/app/app.entity.ts
Normal 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;
|
||||
}
|
||||
15
service/src/modules/app/app.module.ts
Normal file
15
service/src/modules/app/app.module.ts
Normal 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 {}
|
||||
716
service/src/modules/app/app.service.ts
Normal file
716
service/src/modules/app/app.service.ts
Normal 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; // 出错时默认返回非会员专属
|
||||
}
|
||||
}
|
||||
}
|
||||
20
service/src/modules/app/appCats.entity.ts
Normal file
20
service/src/modules/app/appCats.entity.ts
Normal 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;
|
||||
}
|
||||
8
service/src/modules/app/dto/collectApp.dto.ts
Normal file
8
service/src/modules/app/dto/collectApp.dto.ts
Normal 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;
|
||||
}
|
||||
111
service/src/modules/app/dto/createApp.dto.ts
Normal file
111
service/src/modules/app/dto/createApp.dto.ts
Normal 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;
|
||||
}
|
||||
47
service/src/modules/app/dto/createCats.dto.ts
Normal file
47
service/src/modules/app/dto/createCats.dto.ts
Normal 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;
|
||||
}
|
||||
50
service/src/modules/app/dto/custonApp.dto.ts
Normal file
50
service/src/modules/app/dto/custonApp.dto.ts
Normal 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;
|
||||
}
|
||||
8
service/src/modules/app/dto/deleteApp.dto.ts
Normal file
8
service/src/modules/app/dto/deleteApp.dto.ts
Normal 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;
|
||||
}
|
||||
8
service/src/modules/app/dto/deleteCats.dto.ts
Normal file
8
service/src/modules/app/dto/deleteCats.dto.ts
Normal 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;
|
||||
}
|
||||
40
service/src/modules/app/dto/queryApp.dto.ts
Normal file
40
service/src/modules/app/dto/queryApp.dto.ts
Normal 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;
|
||||
}
|
||||
24
service/src/modules/app/dto/queryCats.dto.ts
Normal file
24
service/src/modules/app/dto/queryCats.dto.ts
Normal 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;
|
||||
}
|
||||
9
service/src/modules/app/dto/updateApp.dto.ts
Normal file
9
service/src/modules/app/dto/updateApp.dto.ts
Normal 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;
|
||||
}
|
||||
9
service/src/modules/app/dto/updateCats.dto.ts
Normal file
9
service/src/modules/app/dto/updateCats.dto.ts
Normal 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;
|
||||
}
|
||||
23
service/src/modules/app/userApps.entity.ts
Normal file
23
service/src/modules/app/userApps.entity.ts
Normal 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;
|
||||
}
|
||||
80
service/src/modules/auth/auth.controller.ts
Normal file
80
service/src/modules/auth/auth.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
64
service/src/modules/auth/auth.module.ts
Normal file
64
service/src/modules/auth/auth.module.ts
Normal 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 {}
|
||||
491
service/src/modules/auth/auth.service.ts
Normal file
491
service/src/modules/auth/auth.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
service/src/modules/auth/dto/adminLogin.dto.ts
Normal file
17
service/src/modules/auth/dto/adminLogin.dto.ts
Normal 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;
|
||||
}
|
||||
25
service/src/modules/auth/dto/authLogin.dto.ts
Normal file
25
service/src/modules/auth/dto/authLogin.dto.ts
Normal 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;
|
||||
}
|
||||
37
service/src/modules/auth/dto/authRegister.dto.ts
Normal file
37
service/src/modules/auth/dto/authRegister.dto.ts
Normal 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;
|
||||
}
|
||||
15
service/src/modules/auth/dto/loginByPhone.dt.ts
Normal file
15
service/src/modules/auth/dto/loginByPhone.dt.ts
Normal 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;
|
||||
}
|
||||
18
service/src/modules/auth/dto/sendPhoneCode.dto.ts
Normal file
18
service/src/modules/auth/dto/sendPhoneCode.dto.ts
Normal 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;
|
||||
}
|
||||
10
service/src/modules/auth/dto/updatePassByOther.dto.ts
Normal file
10
service/src/modules/auth/dto/updatePassByOther.dto.ts
Normal 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;
|
||||
}
|
||||
10
service/src/modules/auth/dto/updatePassword.dto.ts
Normal file
10
service/src/modules/auth/dto/updatePassword.dto.ts
Normal 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;
|
||||
}
|
||||
25
service/src/modules/auth/dto/userRegisterByPhone.dto.ts
Normal file
25
service/src/modules/auth/dto/userRegisterByPhone.dto.ts
Normal 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;
|
||||
}
|
||||
47
service/src/modules/autoReply/autoReply.controller.ts
Normal file
47
service/src/modules/autoReply/autoReply.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
service/src/modules/autoReply/autoReply.entity.ts
Normal file
17
service/src/modules/autoReply/autoReply.entity.ts
Normal 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;
|
||||
}
|
||||
14
service/src/modules/autoReply/autoReply.module.ts
Normal file
14
service/src/modules/autoReply/autoReply.module.ts
Normal 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 {}
|
||||
128
service/src/modules/autoReply/autoReply.service.ts
Normal file
128
service/src/modules/autoReply/autoReply.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
service/src/modules/autoReply/dto/addAutoReply.dto.ts
Normal file
13
service/src/modules/autoReply/dto/addAutoReply.dto.ts
Normal 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
Reference in New Issue
Block a user