chore: 初始化项目基础结构和资源文件

- 添加项目图标文件(app-icon.png、各平台图标)
- 配置开发环境文件(.env、.nvmrc、.npmrc)
- 添加静态资源文件(背景图片、字体、音频)
- 初始化Tauri后端结构(build.rs、main.rs、模块文件)
- 配置前端项目结构(TypeScript、Vue组件、样式)
- 添加Node.js API服务基础结构
- 配置构建和开发工具(vite、prettier、gitignore)
This commit is contained in:
2026-03-13 10:03:05 +08:00
commit 78af453fe1
357 changed files with 70605 additions and 0 deletions

51
node_api/.env.development Normal file
View File

@ -0,0 +1,51 @@
# 开发环境配置
NODE_ENV="development"
# 主机地址
HOST="0.0.0.0"
# 端口号
PORT=4001
# Redis 配置
# Redis 主机地址
REDIS_HOST="47.109.17.238"
# Redis 端口号
REDIS_PORT=16379
# Redis 密码
REDIS_PASSWORD="5jdcs445"
# Redis 数据库编号
REDIS_DB=1
# Redis 键前缀
REDIS_KEY_PREFIX=""
# MySQL 配置
# MySQL 主机地址
DB_HOST="47.109.17.238"
# MySQL 端口号
DB_PORT=13306
# MySQL 用户名
DB_USER="user"
# MySQL 密码
DB_PASSWORD="n68792bu!y99r905"
# MySQL 数据库名称
DB_NAME="scs"
# MySQL 连接池最大连接数
DB_CONNECTION_LIMIT=20
DATABASE_URL="mysql://user:n68792bu!y99r905@47.109.17.238:13306/scs"
# 日志级别
LOG_LEVEL="debug"
# Nacos 配置中心
# Nacos 服务器地址
NACOS_SERVER_ADDR="47.109.17.238:18848"
# Nacos 用户名(可选,如果服务器开启了认证)
NACOS_USERNAME="node_user"
# Nacos 密码(可选,如果服务器开启了认证)
NACOS_PASSWORD="zRDmPH5KXYCuDcxtHkKr"
# Nacos 命名空间 ID可选用于隔离环境
NACOS_NAMESPACE_ID=""
# Nacos 配置 Data ID
NACOS_DATA_ID="agora-config.yml"
# Nacos 配置 Group
NACOS_GROUP="DEFAULT_GROUP"

50
node_api/.env.production Normal file
View File

@ -0,0 +1,50 @@
# 生产环境配置
NODE_ENV="production"
# 主机地址
HOST="0.0.0.0"
# 端口号
PORT=4001
# Redis 配置
# Redis 主机地址
REDIS_HOST="127.0.0.1"
# Redis 端口号
REDIS_PORT=16379
# Redis 密码
REDIS_PASSWORD="5jdcs445"
# Redis 数据库编号
REDIS_DB=1
# Redis 键前缀
REDIS_KEY_PREFIX=""
# MySQL 配置
# MySQL 主机地址
DB_HOST="127.0.0.1"
# MySQL 端口号
DB_PORT=13306
# MySQL 用户名
DB_USER="user"
# MySQL 密码
DB_PASSWORD="n68792bu!y99r905"
# MySQL 数据库名称
DB_NAME="scs"
# MySQL 连接池最大连接数
DB_CONNECTION_LIMIT=10
# 日志级别
LOG_LEVEL="debug"
# Nacos 配置中心
# Nacos 服务器地址
NACOS_SERVER_ADDR="127.0.0.1:18848"
# Nacos 用户名(可选,如果服务器开启了认证)
NACOS_USERNAME="node_user"
# Nacos 密码(可选,如果服务器开启了认证)
NACOS_PASSWORD="zRDmPH5KXYCuDcxtHkKr"
# Nacos 命名空间 ID可选用于隔离环境
NACOS_NAMESPACE_ID=""
# Nacos 配置 Data ID
NACOS_DATA_ID="agora-config.yml"
# Nacos 配置 Group
NACOS_GROUP="DEFAULT_GROUP"

63
node_api/.gitignore vendored Normal file
View File

@ -0,0 +1,63 @@
# Logs
logs
*.log
npm-debug.log*
# Runtime data
pids
*.pid
*.seed
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# node-waf configuration
.lock-wscript
# Compiled binary addons (http://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules
dist
*.log
.env
.DS_Store
# Optional npm cache directory
.npm
# Optional REPL history
.node_repl_history
# 0x
profile-*
# mac files
.DS_Store
# vim swap files
*.swp
# webstorm
.idea
# vscode
.vscode
*code-workspace
# clinic
profile*
*clinic*
*flamegraph*
/src/generated/prisma

27
node_api/.oxlintrc.json Normal file
View File

@ -0,0 +1,27 @@
{
"$schema": "https://raw.githubusercontent.com/oxc-project/oxc/main/npm/oxlint/configuration_schema.json",
"rules": {
"typescript/consistent-type-imports": ["error", { "prefer": "type-imports" }],
"typescript": "error",
"unicorn": "warn",
"import": "warn",
"jsdoc": "warn",
"jsx-a11y": "off",
"nextjs": "off",
"react": "off",
"react-perf": "off"
},
"env": {
"node": true,
"es2024": true
},
"globals": {
"NodeJS": "readonly"
},
"settings": {
"no-unused-vars": {
"argsIgnorePattern": "^_"
}
},
"ignorePatterns": ["node_modules", "dist", "*.config.js", "*.config.ts", "pnpm-lock.yaml"]
}

20
node_api/.prettierrc Normal file
View File

@ -0,0 +1,20 @@
{
"printWidth": 160,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"jsxSingleQuote": false,
"trailingComma": "es5",
"bracketSpacing": true,
"bracketSameLine": false,
"arrowParens": "always",
"quoteProps": "preserve",
"requirePragma": false,
"insertPragma": false,
"proseWrap": "preserve",
"endOfLine": "auto",
"embeddedLanguageFormatting": "auto",
"singleAttributePerLine": false,
"vueIndentScriptAndStyle": true
}

824
node_api/eslint.config.js Normal file
View File

@ -0,0 +1,824 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
import jsdoc from 'eslint-plugin-jsdoc';
import globals from 'globals';
import configPrettier from 'eslint-config-prettier';
import { defineConfig } from 'eslint/config';
/** 是否有TS验证规则 */
const tsVerify = true;
export default defineConfig(
eslint.configs.recommended,
configPrettier,
...tseslint.configs.strict,
...tseslint.configs.stylistic,
{
languageOptions: {
globals: {
...globals.node,
__APP_VERSION_TIMESTAMP__: 'readonly', // APP版本号时间戳(在vite.config中配置)
},
},
plugins: {
jsdoc,
},
rules: {
...getBaseRules(),
...getJsDocRules(),
},
},
{
files: ['**/*.ts', '**/*.cts', '**/*.mts', '**/*.ctsx', '**/*.mtsx', '**/*.tsx'],
languageOptions: {
parser: tseslint.parser,
parserOptions: {
sourceType: 'module',
},
},
plugins: {
'@typescript-eslint': tseslint.plugin,
},
rules: {
...getTypescriptRules(),
},
},
{
files: ['**/*.d.ts'],
rules: {
'init-declarations': 'off',
},
},
// NestJS 文件的特殊配置
{
files: ['src/**/*.controller.ts', 'src/**/*.service.ts', 'src/**/*.module.ts', 'src/**/*.dto.ts'],
rules: {
// NestJS 装饰器需要关闭这些规则
'@typescript-eslint/explicit-member-accessibility': 'off',
'@typescript-eslint/consistent-type-imports': 'off',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
// 允许装饰器语法
'@typescript-eslint/no-useless-constructor': 'off',
},
},
{
files: ['**/*.js', '**/*.cjs', '**/*.mjs', '**/*.cjsx', '**/*.mjsx', '**/*.jsx'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
'@typescript-eslint/no-var-requires': 'off',
},
}
);
/**
* 得到eslint的规则
*/
function getBaseRules() {
return {
/** *************************************************** 这些规则与代码中可能的逻辑错误有关 */
// 强制数组方法的回调函数中有 return 语句
'array-callback-return': 'error',
// 强制在子类构造函数中用super()调用父类构造函数,TypeScrip的编译器也会提示
'constructor-super': 'error',
// 强制 “for” 循环中更新子句的计数器朝着正确的方向移动
'for-direction': 'error',
// 强制在 getter 属性中出现一个 return 语句。每个 getter 都期望有返回值。
'getter-return': 'error',
// 禁止使用异步函数作为 Promise executor
'no-async-promise-executor': 'error',
// 不允许await在循环体内使用。
'no-await-in-loop': 'error',
// 禁止修改类声明的变量
'no-class-assign': 'error',
// 针对试图与-0进行比较的代码发出警告,因为这不会按预期工作。也就是说,像x === -0这样的代码将通过+0和-0。作者可能打算 Object.is(x,-0)。
'no-compare-neg-zero': 'error',
// 禁止条件表达式中出现赋值操作符
'no-cond-assign': 'error',
// 禁止修改 const 声明的变量
'no-const-assign': 'error',
// 将始终评估为真或假的比较以及始终短路或从不短路的逻辑表达式 ( ||, &&, ??) 都可能表明程序员错误
'no-constant-binary-expression': 'error',
// [对应 vue/no-constant-condition]禁止在条件中使用常量表达式 [if (false) {} 错] [if (aa===false) {} 对]
'no-constant-condition': 'error',
// 不允许从构造函数返回值
'no-constructor-return': 'error',
// 禁止在正则表达式中使用控制字符 new RegExp("\x1f")
'no-control-regex': 'error',
// 禁用 debugger
'no-debugger': 'error',
// 禁止 function 定义中出现重名参数
'no-dupe-args': 'error',
// 禁止类成员中出现重复的名称
'no-dupe-class-members': 'error',
// 不允许 if-else-if 链中的重复条件
'no-dupe-else-if': 'error',
// 禁止对象字面量中出现重复的 key
'no-dupe-keys': 'error',
// 禁止重复的 case 标签
'no-duplicate-case': 'error',
// 不允许复制模块的进口
'no-duplicate-imports': 'error',
// 禁止在正则表达式中使用空字符集 (/^abc[]/)
'no-empty-character-class': 'error',
// [对应 vue/no-empty-pattern]禁止使用空解构模式no-empty-pattern
'no-empty-pattern': 'error',
// 禁止对 catch 子句的参数重新赋值
'no-ex-assign': 'error',
// 禁止 case 语句落空
'no-fallthrough': 'error',
// 禁止对 function 声明重新赋值
'no-func-assign': 'error',
// 禁止对 function 声明重新赋值
'no-import-assign': 'error',
// 禁止在嵌套的块中出现 function 或 var 声明
'no-inner-declarations': ['error', 'both'],
// 禁止 RegExp 构造函数中无效的正则表达式字符串
'no-invalid-regexp': 'error',
// 禁止在字符串和注释之外不规则的空白
'no-irregular-whitespace': ['error', { skipStrings: true }],
// 不允许丢失精度的数值
'no-loss-of-precision': 'error',
// 不允许在字符类语法中出现由多个代码点组成的字符, 因为Unicode 包括由多个代码点组成的字符。RegExp 字符类语法 (/[abc]/) 不能处理由多个代码点组成的字符
'no-misleading-character-class': 'error',
// 不允许在字符类语法中出现由多个代码点组成的字符, 因为Unicode 包括由多个代码点组成的字符。RegExp 字符类语法 (/[abc]/) 不能处理由多个代码点组成的字符
'no-new-native-nonconstructor': 'error', // 禁止在不能使用new的变量前使用new
// 禁止 Symbol 的构造函数
'no-new-symbol': 'error',
// 禁止把全局对象 (Math 和 JSON) 作为函数调用 错误var math = Math();
'no-obj-calls': 'error',
// 不允许从 Promise 执行器函数返回值
'no-promise-executor-return': 'error',
// 禁止直接使用 Object.prototypes的内置属性 例如,foo.hasOwnProperty("bar") 应该替换为 Object.prototype.hasOwnProperty.call(foo, "bar")
'no-prototype-builtins': 'error',
// 禁止自我赋值
'no-self-assign': 'error',
// 禁止自身比较
'no-self-compare': 'error',
// 虽然从 setter 返回值不会产生错误,但返回的值将被忽略。因此,从 setter 返回值要么是不必要的,要么是可能的错误,因为不能使用返回的值。
'no-setter-return': 'error',
// [对应 vue/no-sparse-arrays]禁用稀疏数组
'no-sparse-arrays': 'error',
// 警告常规字符串包含看起来像模板字面占位符的内容。"Hello ${name}!";
'no-template-curly-in-string': 'error',
// 禁止在构造函数中,在调用 super() 之前使用 this 或 super
'no-this-before-super': 'error',
// 禁用未声明的变量,除非它们在 /*global */ 注释中被提到
'no-undef': tsVerify ? 'off' : 'error',
// 禁止出现令人困惑的多行表达式
'no-unexpected-multiline': 'error',
// 禁用一成不变的循环条件
'no-unmodified-loop-condition': 'error',
// 禁止在return、throw、continue 和 break语句之后出现不可达代码
'no-unreachable': 'error',
// 禁止无法访问的循环
'no-unreachable-loop': 'error',
// 禁止在 finally 语句块中出现控制流语句
'no-unsafe-finally': 'error',
// 禁止否定关系运算符的左操作数
'no-unsafe-negation': ['error', { enforceForOrderingRelations: true }],
// 禁止在不允许使用值的上下文中使用[可选链?.] 如(undefined)
'no-unsafe-optional-chaining': 'error',
// 禁止未使用的私有类成员
'no-unused-private-class-members': 'error',
// 禁止出现未使用过的变量
'no-unused-vars': tsVerify ? 'off' : 'error',
// 不允许在变量定义之前使用它们
'no-use-before-define': ['error', { functions: false, classes: true, variables: true }],
// 禁止在正则表达式中使用无用的反向引用
'no-useless-backreference': 'error',
// 禁止由于 await 或 yield的使用而可能导致出现竞态条件的赋值
'require-atomic-updates': ['error', { allowProperties: true }],
// 不允许比较"NaN"。判断数字是否是NaN,得用isNaN
'use-isnan': 'error',
// 强制 typeof 表达式与有效的字符串进行比较
'valid-typeof': 'error',
/** *************************************************** 这些规则建议了不同的做事方式 */
// 定义对象的set存取器属性时,强制定义get
'accessor-pairs': ['error', { setWithoutGet: true, getWithoutSet: true }],
// 要求箭头函数体使用大括号
'arrow-body-style': ['off', 'as-needed'],
// 强制把变量的使用限制在其定义的作用域范围内
'block-scoped-var': 'error',
// [对应 vue/camelcase] 强制执行驼峰命名约定
camelcase: 'off',
// 注释 大写字母开头,不推荐 注释的代码会报错
'capitalized-comments': 'off',
// 如果一个类方法没有使用this,它有时可以变成一个静态函数。如果将该方法转换为静态函数,那么调用该特定方法的类的实例也必须转换为静态调用
'class-methods-use-this': 'off',
// 限制圈复杂度,也就是类似if else能连续接多少个
complexity: 'off',
// 要求 return 语句要么总是指定返回的值,要么不指定
'consistent-return': 'error',
// 用于指统一在回调函数中指向this的变量名, var that = this; that不能指向其他任何值,this也不能赋值给that以外的其他值
'consistent-this': ['off', 'that'],
// 强制所有控制语句使用一致的括号风格
curly: ['error', 'all'],
// switch 语句强制 default 分支,也可添加 // no default 注释取消此次警告
'default-case': 'error',
// 将 switch 语句中的缺省子句强制为最后一个
'default-case-last': 'error',
// 将默认参数强制放在最后
'default-param-last': tsVerify ? 'off' : 'error',
// [对应 vue/dot-notation]强制使用.号取属性
'dot-notation': 'error',
// [对应 vue/eqeqeq]使用 === 替代 == allow-null允许null和undefined==
eqeqeq: ['error', 'always'],
// 要求函数名称与它们所分配的变量或属性的名称相匹配
'func-name-matching': 'error',
// 强制使用命名的 function 表达式
'func-names': ['error', 'always', { generators: 'as-needed' }],
// 强制一致地使用函数声明或函数表达式,方法定义风格
'func-style': ['error', 'declaration', { allowArrowFunctions: true }],
// 强制如果一个属性有一个 getter 和一个 setter,那么 setter 应该在 getter 之后定义
'grouped-accessor-pairs': ['error', 'getBeforeSet'],
// 要求 for-in 循环中有一个 if 语句
'guard-for-in': 'off',
// 禁止使用指定的标识符
'id-denylist': 'off',
// 强制标识符的最小和最大长度 (变量名长度)
'id-length': 'off',
// 要求标识符匹配一个指定的正则表达式
'id-match': 'off',
// 要求或禁止 var 声明中的初始化(初值)
'init-declarations': tsVerify ? 'off' : ['error', 'always'],
// 要求或禁止逻辑赋值逻辑运算符速记
'logical-assignment-operators': ['error', 'never'],
// 强制实施每个文件的最大类数
'max-classes-per-file': 'off',
// 强制执行嵌套块的最大深度,以降低代码复杂度。"max"(默认为4)
'max-depth': ['off', { max: 6 }],
// 强制文件的最大行数
'max-lines': 'off',
// 强制文件的最大行数
'max-lines-per-function': 'off',
// 强制回调函数最大嵌套深度 5层
'max-nested-callbacks': ['off', { max: 5 }],
// 强制 function 定义中最多允许的参数数量
'max-params': ['off', { max: 12 }],
// 强制 function 块最多允许的的语句数量
'max-statements': 'off',
// 强化多行评论的特定风格。
'multiline-comment-style': 'off',
// 要求构造函数首字母大写 (要求调用 new 操作符时有首字母大小的函数,允许调用首字母大写的函数时没有 new 操作符。)
'new-cap': ['error', { newIsCap: true, capIsNew: false }],
// 禁用 alert、confirm 和 prompt
'no-alert': 'error',
// 禁止使用 Array 构造函数
'no-array-constructor': 'error',
// 禁用按位运算符
'no-bitwise': 'error',
// 禁用 arguments.caller 或 arguments.callee
'no-caller': 'error',
// 不允许在 case 子句中使用词法声明
'no-case-declarations': 'error',
// 禁用 console
'no-console': 'off',
// 禁用 continue 语句
'no-continue': 'error',
// 禁止删除变量
'no-delete-var': 'error',
// 禁止除法操作符显式的出现在正则表达式开始的位置
'no-div-regex': 'error',
// 禁止 if 语句中有 return 之后有 else
'no-else-return': 'off',
// 禁止空语句块
'no-empty': ['error', { allowEmptyCatch: true }],
// 禁止出现空函数. 如果一个函数包含了一条注释,它将不会被认为有问题。
'no-empty-function': tsVerify ? 'off' : 'error',
// 禁止空静态块
'no-empty-static-block': 'error',
// 禁止在没有类型检查操作符的情况下与 null 进行比较
'no-eq-null': 'error',
// 禁用 eval()
'no-eval': 'error',
// 禁止扩展原生类型
'no-extend-native': ['error', { exceptions: ['Object', 'Array'] }],
// 禁止不必要的 .bind() 调用
'no-extra-bind': 'error',
// 禁止不必要的布尔转换
'no-extra-boolean-cast': 'error',
// 禁用不必要的标签
'no-extra-label': 'error',
// 此规则不允许修改只读全局变量。
'no-global-assign': 'error',
// 禁止使用短符号进行类型转换(!!fOO)
'no-implicit-coercion': 'error',
// 禁止在全局范围内使用 var 和命名的 function 声明
'no-implicit-globals': 'error',
// 禁止使用类似 eval() 的方法
'no-implied-eval': 'error',
// 禁止在代码行后使用内联注释
'no-inline-comments': 'off',
// 禁止 this 关键字出现在类和类对象之外
'no-invalid-this': 'error',
// 禁用 __iterator__ 属性
'no-iterator': 'error',
// 不允许标签与变量同名
'no-label-var': 'error',
// 禁用标签语句
'no-labels': 'error',
// 禁用不必要的嵌套块
'no-lone-blocks': 'error',
// 禁止 if 作为唯一的语句出现在 else 语句中
'no-lonely-if': 'off',
// 禁止在循环中出现 function 声明和表达式
'no-loop-func': 'error',
// 禁用魔术数字(3.14什么的用常量代替)
'no-magic-numbers': 'off',
// 不允许在单个语句中使用多个分配。a = b = c = d;
'no-multi-assign': 'error',
// 禁止使用多行字符串,在 JavaScript 中,可以在新行之前使用斜线创建多行字符串
'no-multi-str': 'error',
// 不允许否定的表达式
'no-negated-condition': 'off',
// 不允许使用嵌套的三元表达式 var foo = bar ? baz : qux === quxx ? bing : bam;
'no-nested-ternary': 'off',
// 禁止在非赋值或条件语句中使用 new 操作符
'no-new': 'off',
// 禁止对 Function 对象使用 new 操作符
'no-new-func': 'error',
// 禁止对 String,Number 和 Boolean 使用 new 操作符
'no-new-wrappers': 'error',
// 禁止字符串文本中的序列并转义序列\8\9
'no-nonoctal-decimal-escape': 'error',
// 通常不鼓励使用构造函数来构造新的空对象,而支持对象文字表示法,因为简洁,并且全局可以重新定义。 例外情况是,当构造函数用于有意包装作为参数传递的指定值时
'no-object-constructor': 'error',
// 禁用八进制字面量
'no-octal': 'error',
// 禁止在字符串中使用八进制转义序列
'no-octal-escape': 'error',
// 不允许对 function 的参数进行重新赋值
'no-param-reassign': 'error',
// 禁止使用一元操作符 ++ 和 --
'no-plusplus': 'off',
// 禁用 __proto__ 属性
'no-proto': 'error',
// 禁止使用 var 多次声明同一变量
'no-redeclare': 'error',
// 禁止正则表达式字面量中出现多个空格
'no-regex-spaces': 'error',
// 禁止在导出中指定名称
'no-restricted-exports': 'off',
// 禁止在导出中指定名称 restrictedNamedExports中就是限制导出的名称 禁用特定的全局变量
'no-restricted-globals': ['error', { name: 'event', message: 'event请在方法的参数中定义event' }],
// 禁止加载指定的模块 paths中就是需要禁止加载的模块
'no-restricted-imports': ['off', { paths: ['import1', 'import2'] }],
// 禁止某些对象上的某些属性 如果省略对象名称,则不允许所有对象使用该属性;如果省略属性名称,则不允许访问给定对象的任何属性
'no-restricted-properties': [
'off',
{
object: '对象名称',
property: '对象对象下的属性名称',
message: '提示消息',
},
],
// 禁止使用特定的语法
'no-restricted-syntax': ['off', { selector: '语法', message: '提示消息' }],
// 禁止在返回语句中赋值 (return foo = bar + 2; 错误)
'no-return-assign': 'error',
// 禁止使用 javascript: url
'no-script-url': 'error',
// 禁用逗号操作符
'no-sequences': 'error',
// 禁止变量声明与外层作用域的变量同名
'no-shadow': tsVerify ? 'off' : 'error',
// 禁止覆盖受限制的标识符
'no-shadow-restricted-names': 'error',
// 不允许使用三元操作符
'no-ternary': 'off',
// 禁止抛出非异常字面量
'no-throw-literal': 'error',
// 禁止将变量初始化为undefined
'no-undef-init': 'off',
// 禁止将 undefined 作为标识符
'no-undefined': 'off',
// 禁止标识符中有悬空下划线_bar
'no-underscore-dangle': 'off',
// 禁止在有比三元操作符更简单表达式时使用三元操作符
'no-unneeded-ternary': 'error',
// 禁止出现未使用过的表达式
'no-unused-expressions': ['error', { allowShortCircuit: true, allowTernary: true }],
// 禁用未使用过的标签
'no-unused-labels': 'error',
// 禁止不必要的 .call() 和 .apply()
'no-useless-call': 'error',
// 禁止不必要的 catch 子句
'no-useless-catch': 'error',
// 禁止不必要的计算性能键对象的文字
'no-useless-computed-key': 'error',
// [对应 vue/no-useless-concat]禁止不必要的字符串字面量或模板字面量的连接
'no-useless-concat': 'error',
// ES2015 会提供默认的类构造函数。因此,没有必要提供一个空构造函数或一个简单地委托给它的父类的构造函数,
'no-useless-constructor': 'error',
// 禁用不必要的转义字符
'no-useless-escape': 'error',
// 不允许将导入、导出和解构分配重命名为相同的名称。
'no-useless-rename': 'error',
// 禁止冗余返回语句
'no-useless-return': 'error',
// 要求使用 let 或 const 而不是 var
'no-var': 'error',
// 禁用 void 操作符
'no-void': 'error',
// 禁止在注释中使用特定的警告术语
'no-warning-comments': 'off',
// 禁用 with 语句
'no-with': 'error',
// 要求或禁止对象字面量中方法和属性使用简写语法
'object-shorthand': ['error', 'always'],
// 强制函数中的变量要么一起声明要么分开声明
'one-var': ['error', 'never'],
// 要求或禁止在可能的情况下要求使用简化的赋值操作符
'operator-assignment': ['error', 'always'],
// 要求使用箭头函数作为回调
'prefer-arrow-callback': 'error',
// 要求使用 const 声明那些声明后不再被修改的变量
'prefer-const': 'error',
// 优先使用数组和对象解构
'prefer-destructuring': 'off',
// 禁止使用 有利于运营商的Math.pow()
'prefer-exponentiation-operator': 'error',
// 强制在正则表达式中使用命名捕获组
'prefer-named-capture-group': 'off',
// 禁止调用parseInt()或Number.parseInt()使用两个参数调用:一个字符串; 和2(二进制),8(八进制)或16(十六进制)的基数选项。
'prefer-numeric-literals': 'error',
// 禁止使用Object.prototype.hasOwnProperty.call() 而应该使用Object.hasOwn()
'prefer-object-has-own': 'error',
// 优先使用扩展("...")而不是Object.assign
'prefer-object-spread': 'error',
// 确保承诺只被Error对象拒绝。
'prefer-promise-reject-errors': 'off',
// 不允许使用构造函数创建正则表达式
'prefer-regex-literals': 'off',
// 禁止使用 arguments 而应该使用 ...args
'prefer-rest-params': 'error',
// 要求使用扩展运算符而非 .apply()
'prefer-spread': 'error',
// [对应 vue/prefer-template]要求使用模板字面量而非字符串连接
'prefer-template': 'error',
// 强制在parseInt()使用基数参数
radix: ['error', 'as-needed'],
// 异步函数必须具有await表达式
'require-await': 'off',
// 在正则表达式上强制使用标志
'require-unicode-regexp': 'off',
// 要求generator 函数内有 yield
'require-yield': 'error',
// 强制模块内的 import 排序
'sort-imports': ['error', { ignoreDeclarationSort: true }],
// 所有属性定义并验证所有变量是按字母顺序排序的。
'sort-keys': 'off',
// 要求同一个声明块中的变量按顺序排列
'sort-vars': 'error',
// 要求或禁止使用严格模式指令
strict: ['error', 'global'],
// var foo = Symbol("some description"); 一定要有描述
'symbol-description': 'error',
// 要求所有的 var 声明出现在它们所在的作用域顶部
'vars-on-top': 'error',
// 要求或禁止 “Yoda” 条件
yoda: 'error',
/** *************************************************** 这些规则关心代码的外观,而不是它的执行方式 */
// 强制行注释可以位于代码上方或旁边。该规则有助于团队保持一致的风格。
'line-comment-position': 'off',
// 要求或不允许 Unicode 字节顺序标记
'unicode-bom': ['error', 'never'],
};
}
/**
* typescript使用的规则
*/
function getTypescriptRules() {
return {
// 要求函数重载签名是连续的
'@typescript-eslint/adjacent-overload-signatures': 'error',
// 要求一致地使用或用于数组T[]Array<T>
'@typescript-eslint/array-type': 'off',
// 禁止直接使用await处理同步函数 💭
// '@typescript-eslint/await-thenable': 'error',
// 不允许在指令后添加注释或要求说明
'@typescript-eslint/ban-ts-comment': 'error',
// 禁止使用tslint注释
'@typescript-eslint/ban-tslint-comment': 'error',
// 不允许某些类型 已废弃
// '@typescript-eslint/ban-types': 'error',
// 强制以一致的样式公开类的文本
'@typescript-eslint/class-literal-property-style': 'error',
// 强制类方法使用 .this
'@typescript-eslint/class-methods-use-this': 'off',
// 强制在构造函数调用的类型注释或构造函数名称上指定泛型类型参数
'@typescript-eslint/consistent-generic-constructors': ['error', 'constructor'],
// 需要或禁止 使用Record
'@typescript-eslint/consistent-indexed-object-style': ['error', 'record'],
// 并不是所有函数里的代码都有返回值时,抛出错误 这里关闭,因为 tsconfig.json 中 noImplicitReturns 更好 💭
// '@typescript-eslint/consistent-return': 'error',
// 强制一致地使用类型断言
'@typescript-eslint/consistent-type-assertions': ['error', { assertionStyle: 'as' }], // 强制一致地使用类型断言
// 强制类型定义一致地使用 interface 或 type
'@typescript-eslint/consistent-type-definitions': ['off', 'type'],
// 强制一致地使用类型导出 💭
// '@typescript-eslint/consistent-type-exports': ['error', { fixMixedExportsWithInlineTypeSpecifier: false }],
// 强制一致使用类型导入
'@typescript-eslint/consistent-type-imports': [
'off',
{
prefer: 'type-imports',
disallowTypeAnnotations: false,
fixStyle: 'inline-type-imports',
},
],
// 需要函数和类方法的显式返回类型
'@typescript-eslint/default-param-last': 'error',
// 尽可能强制使用点表示法 💭
// '@typescript-eslint/dot-notation': 'error',
// 需要函数和类方法的显式返回类型
'@typescript-eslint/explicit-function-return-type': 'off',
// 需要对类属性和方法使用显式辅助功能修饰符
'@typescript-eslint/explicit-member-accessibility': 'error',
// 要求对导出的函数和类的公共类方法进行显式返回和参数类型
'@typescript-eslint/explicit-module-boundary-types': 'off',
// 要求或禁止在变量声明中初始化
'@typescript-eslint/init-declarations': ['error', 'always'],
// 在函数定义中强制参数的最大数目
'@typescript-eslint/max-params': 'off',
// 需要一致的成员声明顺序
'@typescript-eslint/member-ordering': 'off',
// 强制使用特定方法签名语法
'@typescript-eslint/method-signature-style': ['error', 'property'],
// 对代码库中的所有内容强制实施命名约定。💭
// '@typescript-eslint/naming-convention': 'off',
// 禁止使用泛型constructor array
'@typescript-eslint/no-array-constructor': 'error',
// 禁止在数组values上使用delete操作符。💭
'@typescript-eslint/no-array-delete': 'off',
// 要求仅在字符串化时提供有用信息的对象上调用.toString() 💭
// '@typescript-eslint/no-base-to-string': 'error',
// 禁止在可能造成混淆的位置使用非空断言
'@typescript-eslint/no-confusing-non-null-assertion': 'error',
// 无混淆空洞表达 💭
// '@typescript-eslint/no-confusing-void-expression': 'error',
// 禁止重复的类成员
// '@typescript-eslint/no-dupe-class-members': 'error', // 此 ESLint 规则检查的代码问题由 TypeScript 编译器自动检查
// 不允许重复的枚举成员值
'@typescript-eslint/no-duplicate-enum-values': 'error',
// 禁止联合或交集类型的重复成分 💭
// '@typescript-eslint/no-duplicate-type-constituents': 'off',
// 禁止在计算键表达式上使用运算符delete
'@typescript-eslint/no-dynamic-delete': 'off',
// 禁止声明空接口
'@typescript-eslint/no-empty-interface': 'error',
/** 禁止使用空函数 */
'@typescript-eslint/no-empty-function': 'off',
// 禁止使用any
'@typescript-eslint/no-explicit-any': ['off', { ignoreRestArgs: true }],
// 不允许额外的非空断言
'@typescript-eslint/no-extra-non-null-assertion': 'error',
// 禁止将类用作命名空间
'@typescript-eslint/no-extraneous-class': 'off',
// 要求正确处理类似 Promise 的语句 💭
// '@typescript-eslint/no-floating-promises': 'off',
// 不允许使用传入循环遍历数组 💭
// '@typescript-eslint/no-for-in-array': 'error',
// 禁止使用类似eval()的方法 💭
// '@typescript-eslint/no-implied-eval': 'off',
// 当导入只有带有内联类型限定符的说明符时,强制使用顶级导入类型限定符
'@typescript-eslint/no-import-type-side-effects': 'error',
// 不允许对初始化为数字、字符串或布尔值的变量或参数进行显式类型声明
'@typescript-eslint/no-inferrable-types': 'error',
// 禁止在类或类类对象this之外使用关键字
'@typescript-eslint/no-invalid-this': 'error',
// 禁止泛型或返回类型之外的void类型
'@typescript-eslint/no-invalid-void-type': 'error',
// 禁止在循环语句中包含不安全引用的函数声明
'@typescript-eslint/no-loop-func': 'error',
// Disallow literal numbers that lose precision
'@typescript-eslint/no-loss-of-precision': 'error',
// 禁用魔术数字(3.14什么的用常量代替)
'@typescript-eslint/no-magic-numbers': 'off',
// 禁止没有无意义的空运算符 💭
// '@typescript-eslint/no-meaningless-void-operator': 'error',
// 强制实施 和 的有效定义newconstructor
'@typescript-eslint/no-misused-new': 'error',
// 禁止在非设计用于处理承诺的地方发布承诺 💭
// '@typescript-eslint/no-misused-promises': 'off',
// 禁止枚举同时具有数字和字符串成员 💭
// '@typescript-eslint/no-mixed-enums':'off',
// 禁止使用命名空间
'@typescript-eslint/no-namespace': 'error',
// 不允许在空合并运算符的左操作数中使用非空断言
'@typescript-eslint/no-non-null-asserted-nullish-coalescing': 'error',
// 不允许在可选链表达式后使用非空断言
'@typescript-eslint/no-non-null-asserted-optional-chain': 'error',
// 禁止使用后缀运算符的非空断言
'@typescript-eslint/no-non-null-assertion': 'off',
// 禁止变量重声明 此 ESLint 规则检查的代码问题由 TypeScript 编译器自动检查。因此,不建议在新的 TypeScript 项目中启用此规则。仅当您更喜欢 ESLint 错误消息而不是 TypeScript 编译器错误消息时,才需要启用此规则。
'@typescript-eslint/no-redeclare': 'off',
// 禁止不执行任何操作或覆盖类型信息的联合和交叉点的成员。 💭
// '@typescript-eslint/no-redundant-type-constituents': 'off',
// 禁止调用require()
'@typescript-eslint/no-require-imports': 'error',
// 禁止通过import加载指定模块
'@typescript-eslint/no-restricted-imports': 'off',
// 有时,禁止在类型批注中使用特定类型会很有用。 例如,项目可能正在从使用一种类型迁移到另一种类型,并希望禁止对旧类型的引用。此规则可以配置为禁止特定类型的列表,并可以建议替代方法。 请注意,它不会禁止使用相应的运行时对象。
'@typescript-eslint/no-restricted-types': 'off',
// 禁止变量声明掩盖在外部作用域中声明的变量
'@typescript-eslint/no-shadow': 'error',
// 禁止混叠this
'@typescript-eslint/no-this-alias': ['error', { allowedNames: ['that'] }],
// 不允许对布尔文本进行不必要的相等比较 💭
// '@typescript-eslint/no-unnecessary-boolean-literal-compare': 'error',
// 不允许类型始终为真实或始终为虚假的条件 💭
// '@typescript-eslint/no-unnecessary-condition': 'off',
// 不允许不必要的命名空间限定符💭
// '@typescript-eslint/no-unnecessary-qualifier': 'error',
// 禁止等于默认值的类型参数💭
// '@typescript-eslint/no-unnecessary-type-arguments': 'error',
// 禁止不更改表达式类型的类型断言💭
// '@typescript-eslint/no-unnecessary-type-assertion': 'error',
// 不允许对泛型类型进行不必要的约束
'@typescript-eslint/no-unnecessary-type-constraint': 'error',
// 禁止调用具有any类型值的函数 💭
// '@typescript-eslint/no-unsafe-argument': 'off',
// 不允许将any类型值分配给变量和属性 💭
//'@typescript-eslint/no-unsafe-assignment': 'off',
// 不允许调用带有any类型的值 💭
// '@typescript-eslint/no-unsafe-call': 'off',
// 禁止不安全声明合并
'@typescript-eslint/no-unsafe-declaration-merging': 'error',
// 禁止将枚举值与非枚举值进行比较 💭
//'@typescript-eslint/no-unsafe-enum-comparison': 'off',
// 禁止使用不安全的内置函数类型。
'@typescript-eslint/no-unsafe-function-type': 'error',
// 禁止成员访问any类型为的值 💭
// '@typescript-eslint/no-unsafe-member-access': 'off',
// 禁止从函数返回带有any类型的值 💭
// '@typescript-eslint/no-unsafe-return': 'off',
// 求一元否定取一个数 💭
// '@typescript-eslint/no-unsafe-unary-minus': 'off',
// 禁止使用未使用的表达式
'@typescript-eslint/no-unused-expressions': ['error', { allowShortCircuit: true, allowTernary: true }],
// 禁止出现未使用过的变量
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', caughtErrors: 'none' }],
// 禁止在定义变量之前使用它们
'@typescript-eslint/no-use-before-define': ['error', { functions: false, classes: true, variables: true }],
// 禁止使用不必要的构造函数
'@typescript-eslint/no-useless-constructor': 'error',
// 禁止不会更改模块文件中的任何内容的空导出
'@typescript-eslint/no-useless-empty-export': 'error',
// 禁止使用不必要的模板字面值 💭
// '@typescript-eslint/no-useless-template-literals': 'off',
// 禁止语句(导入语句require除外)
'@typescript-eslint/no-var-requires': 'error',
// 不允许使用令人困惑的内置基元类包装器。
'@typescript-eslint/no-wrapper-object-types': 'error',
// 对显式类型强制转换强制实施非空断言 💭
// '@typescript-eslint/non-nullable-type-assertion-style': 'error',
// 禁止在 throw 抛出非 new Error() 的值 💭
// '@typescript-eslint/only-throw-error': 'off',
// 在类构造函数中要求或禁止参数属性
'@typescript-eslint/parameter-properties': 'off',
// 强制使用过度文本as const类型
'@typescript-eslint/prefer-as-const': 'error',
// 要求对数组和/或对象进行解构 💭
// '@typescript-eslint/prefer-destructuring': 'off',
// 要求显式初始化每个枚举成员值
'@typescript-eslint/prefer-enum-initializers': 'error',
// 在查找单个结果时强制使用Array.prototype.find()而不是Array.prototype.filter(),后面跟着[0] 💭
// '@typescript-eslint/prefer-find': 'off',
// 尽可能强制使用标准循环for-of for
'@typescript-eslint/prefer-for-of': 'error',
// 强制使用函数类型而不是带有调用签名的接口 (与vue语法有冲突)
'@typescript-eslint/prefer-function-type': 'off',
// 优先使用includes()方法而不是indexOf() 💭
// '@typescript-eslint/prefer-includes': 'off',
// 要求所有枚举成员都是文本值
'@typescript-eslint/prefer-literal-enum-member': 'off',
// 需要使用namespace关键字而不是module关键字来声明自定义 TypeScript 模块
'@typescript-eslint/prefer-namespace-keyword': 'error',
// 强制使用空合并运算符而不是逻辑链接 (如果未启用 strictNullChecks则此规则将无法按预期工作) 💭
// '@typescript-eslint/prefer-nullish-coalescing': 'off',
// 强制使用简洁的可选链表达式,而不是链式逻辑 and、否定逻辑 or 或空对象
'@typescript-eslint/prefer-optional-chain': 'off',
// 要求使用Error对象作为拒绝承诺的原因 💭
// '@typescript-eslint/prefer-promise-reject-errors': 'off',
// 要求将私有成员标记为readonly,从未在构造函数外部修改 💭
// '@typescript-eslint/prefer-readonly': 'error',
// 要求键入函数参数readonly以防止输入意外突变 💭
// '@typescript-eslint/prefer-readonly-parameter-types': 'error',
// 用时强制使用类型参数Array#reduce而不是强制转换💭
// '@typescript-eslint/prefer-reduce-type-parameter': 'off',
// 如果未提供全局 RegExp#exec标志则强制String#match执行 💭
// '@typescript-eslint/prefer-regexp-exec': 'off',
// 强制在仅返回类型时使用this 💭
// '@typescript-eslint/prefer-return-this-type': 'off',
// 强制使用String#startsWith和String#endsWith超过其他等效的方法来检查子字符串 💭
// '@typescript-eslint/prefer-string-starts-ends-with': 'off',
// 强制使用过度@ts-expect-error @ts-ignore
'@typescript-eslint/prefer-ts-expect-error': 'error',
// 要求将返回 Promise 的任何函数或方法标记为异步 💭
// '@typescript-eslint/promise-function-async': 'warn',
// 要求调用始终提供 Array#sort 💭
// '@typescript-eslint/require-array-sort-compare': 'warn',
// 禁止没有await的async函数
'@typescript-eslint/require-await': 'off',
// 要求加法的两个操作数是相同的类型并且是bigint number string 💭
// '@typescript-eslint/restrict-plus-operands': 'error',
// 强制模板文本表达式为类型string 💭
//@typescript-eslint/restrict-template-expressions': 'off',
// 强制等待值的一致返回 💭
'@typescript-eslint/return-await': 'off',
// 强制按字母顺序对类型并集/交集的成分进行排序
'@typescript-eslint/sort-type-constituents': 'error',
// 禁止布尔表达式中的某些类型 💭
// '@typescript-eslint/strict-boolean-expressions': 'off',
// 要求开关大小写语句对联合类型详尽无遗 💭
// '@typescript-eslint/switch-exhaustiveness-check': 'error',
// 禁止某些三斜杠指令以支持 ES6 样式的导入声明
'@typescript-eslint/triple-slash-reference': 'error',
// 要求文字批注周围的间距一致 (强烈建议您不要使用此规则)
// "@typescript-eslint/type-annotation-spacing": "warn",
// 在某些位置需要类型批注
'@typescript-eslint/typedef': 'error',
// 强制调用未绑定方法及其预期范围 💭
// '@typescript-eslint/unbound-method': 'error',
// 不允许两个重载,这两个重载可以通过联合或可选/rest 参数统一为一个
'@typescript-eslint/unified-signatures': 'off',
// 强制回调中的类型参数为.catch() unknown
'@typescript-eslint/use-unknown-in-catch-callback-variable': 'off',
};
}
/**
* 得到eslint的规则
*/
function getJsDocRules() {
return {
'jsdoc/check-access': 'off', // 强制执行有效标记@access
'jsdoc/check-alignment': 'warn', // 强制对齐 JSDoc 块星号
'jsdoc/check-examples': 'off', // 内部 JavaScript 的 Linting@example
'jsdoc/check-indentation': [
'warn',
{
// 允许嵌套内容缩进(如列表、项目符号、多行描述)
allowIndentedSections: true,
// 排除这些标签的缩进检查
excludeTags: ['param', 'returns', 'property', 'example', 'description', 'see', 'throws'],
},
],
'jsdoc/check-line-alignment': 'warn', // 检查 JSDoc 块行的无效对齐方式
'jsdoc/check-param-names': 'off', // 确保 JSDoc 中的参数名称与 中的相应项匹配 函数声明。
'jsdoc/check-property-names': 'warn', // 确保 JSDoc 中的属性名称不会在同一块上重复 并且嵌套属性已定义根
'jsdoc/check-syntax': 'warn', // 针对不鼓励使用该模式的语法的报告(例如Google 关闭 “jsdoc”或“typescript”模式下的编译器)。请注意,此规则不会检查 对于对于给定模式完全无效的类型,如 中所述。valid-types
'jsdoc/check-tag-names': 'warn', // 报告无效的块标记名称
'jsdoc/check-types': 'warn', // 报告无效类型
'jsdoc/check-values': 'warn', // 此规则检查少数标签的值
'jsdoc/empty-tags': 'warn', // 期望某些标记中没有任何内容
'jsdoc/implements-on-classes': 'warn', // 使用 报告任何非构造函数的问题
'jsdoc/informative-docs': 'off', // 报告仅用于重新启动其附加名称的 JSDoc 文本。
'jsdoc/match-description': 'off', // 为标签描述定义可自定义的正则表达式规则
'jsdoc/match-name': 'off', // 报告 JSDoc 标记的名称部分(是否与给定的正则表达式匹配或不匹配)
'jsdoc/multiline-blocks': 'warn', // 控制 jsdoc 块如何以及是否可以表示为单行或多行块
'jsdoc/no-bad-blocks': 'warn', // 此规则检查不符合 jsdoc 块条件的多行样式注释
'jsdoc/no-blank-block-descriptions': 'warn', // 检查重复名称,嵌套的参数名称是否具有根,以及函数声明中的参数名称是否与 jsdoc 参数名称匹配。@param
'jsdoc/no-blank-blocks': 'warn', // 报告并选择性地删除仅带有空格的块
'jsdoc/no-defaults': 'warn', // 此规则报告在 或 的相关部分使用的默认值。它还可以选择报告是否存在 方括号内的可选参数
'jsdoc/no-missing-syntax': 'off', // 通过此规则,您可以报告是否缺少某些始终预期的注释结构。
'jsdoc/no-multi-asterisks': 'warn', // 防止在行首使用多个星号
'jsdoc/no-restricted-syntax': 'off', // 报告存在某些注释结构
'jsdoc/no-types': 'off', // 此规则报告在 @param或 @returns上使用的类型。 该规则旨在防止在标记上指示以下类型 类型信息对于 TypeScript 来说是多余的。
'jsdoc/no-undefined-types': 'off', // 检查 jsdoc 注释中的类型是否已定义。这可用于检查 未导入的类型
'jsdoc/require-asterisk-prefix': 'warn', // 要求每个 JSDoc 行都以*开头
'jsdoc/require-description': 'warn', // 要求所有函数都有说明
'jsdoc/require-description-complete-sentence': 'off', // 要求块描述、显式 和 / 标签描述用完整的句子编写,
'jsdoc/require-example': 'off', // 要求所有函数都有示例
'jsdoc/require-file-overview': 'off', // 将报告给定文件中的重复文件概述标记
'jsdoc/require-hyphen-before-param-description': ['warn', 'always'], // 将报告给定文件中的重复文件概述标记
'jsdoc/require-jsdoc': ['warn', { enableFixer: false }], // 检查是否存在 jsdoc 注释、类声明以及 功能
'jsdoc/require-param': 'off', // 要求记录所有函数参数
'jsdoc/require-param-description': 'warn', // 要求每个标记都有一个值
'jsdoc/require-param-name': 'warn', // 要求所有函数参数都具有名称
'jsdoc/require-param-type': 'off', // 要求每个@param标记都设置类型
'jsdoc/require-property': 'off',
'jsdoc/require-property-description': 'off', // 要求每个@property标记都有一个description值
'jsdoc/require-property-name': 'off', // 要求所有函数标记都具有名称
'jsdoc/require-property-type': 'off', // 要求每个个@property标记都有一个type值
'jsdoc/require-returns': 'off', // 要求有返回值的函数必须使用@returns标志
'jsdoc/require-returns-check': 'warn', // 检查返回
'jsdoc/require-returns-description': 'warn', // R要求标记具有值。错误 如果返回值为 OR或者为 或,则不会报告。
'jsdoc/require-returns-type': 'off', // 要求@returns标记具有type值
'jsdoc/require-throws': 'off', //
'jsdoc/require-yields': 'off', // Recommended
'jsdoc/require-yields-check': 'off', // Recommended
'jsdoc/sort-tags': 'warn', // 根据标签名称按指定顺序对标签进行排序,可以选择在标签组之间添加换行符
'jsdoc/tag-lines': 'warn', // 在标记之间强制执换行
'jsdoc/text-escaping': 'off', // 此规则可以自动转义在块和标记描述中输入的某些字符
'jsdoc/valid-types': 'off', // 要求所有类型/名称路径都是有效的 JSDoc、Closure 编译器或 TypeScript 类型(可在设置中配置)
};
}

20
node_api/nest-cli.json Normal file
View File

@ -0,0 +1,20 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true,
"builder": "webpack",
"plugins": [
{
"name": "@nestjs/swagger/plugin",
"options": {
"classValidatorShim": true,
"introspectComments": true,
"dtoFileNameSuffix": [".dto.ts", ".entity.ts"],
"controllerFileNameSuffix": ".controller.ts"
}
}
]
}
}

77
node_api/package.json Normal file
View File

@ -0,0 +1,77 @@
{
"name": "myproject",
"version": "1.0.0",
"description": "This project was bootstrapped with Fastify-CLI.",
"main": "dist/main.js",
"scripts": {
"prod": "npm run build && npm run start",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"build:swc": "nest build --builder swc",
"migrate": "node -e \"console.error('\\n[禁止] 数据库表结构由 DBA 统一管理,请勿执行 migrate 命令\\n'); process.exit(1)\"",
"lint:ts": "tsc --noEmit --skipLibCheck",
"lint:prettier": "prettier --write \"src/**/*.{js,json,ts,tsx,cjs,mjs,cts,mts,css,scss}\"",
"lint:eslint": "eslint \"src/**/*.{js,ts,tsx}\" --fix",
"安装": "pnpm install",
"更新": "ncu -u",
"删除": "rimraf pnpm-lock.yaml && rimraf package-lock.json && rimraf yarn.lock && rimraf .yarn && rimraf node_modules"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@fastify/static": "^9.0.0",
"@mikro-orm/core": "^7.0.1",
"@mikro-orm/decorators": "^7.0.1",
"@mikro-orm/mysql": "^7.0.1",
"@mikro-orm/nestjs": "^7.0.1",
"@nestjs/common": "^11.1.16",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.1.16",
"@nestjs/platform-fastify": "^11.1.16",
"@nestjs/platform-socket.io": "^11.1.16",
"@nestjs/swagger": "^11.2.6",
"@nestjs/websockets": "^11.1.16",
"address": "^2.0.3",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"crc-32": "^1.2.2",
"cuint": "0.2.2",
"dayjs": "^1.11.20",
"dotenv": "^17.3.1",
"eventemitter3": "^5.0.4",
"fastify": "^5.8.2",
"fastify-plugin": "^5.1.0",
"ioredis": "^5.10.0",
"js-yaml": "^4.1.1",
"nacos": "^2.6.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pino-roll": "^4.0.0",
"socket.io": "^4.8.3"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@nestjs/cli": "^11.0.16",
"@swc/core": "^1.15.18",
"@types/cuint": "^0.2.4",
"@types/ioredis": "^5.0.0",
"@types/js-yaml": "^4.0.9",
"@types/md5": "^2.3.6",
"@types/node": "^25.5.0",
"@types/socket.io": "^3.0.2",
"eslint": "^10.0.3",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-jsdoc": "^62.8.0",
"globals": "^17.4.0",
"prettier": "^3.8.1",
"ts-loader": "^9.5.4",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.57.0",
"webpack": "^5.105.4",
"webpack-cli": "^6.0.1"
}
}

5751
node_api/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@nestjs/core'
- '@scarf/scarf'
- '@swc/core'
- 'esbuild'

View File

@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { LoggerModule } from './plugins/logger/logger.module';
import { RedisModule } from './plugins/redis/redis.module';
import { MikroOrmConfigModule } from './plugins/mikro-orm/mikro-orm.module';
import { NacosConfigModule } from './plugins/nacos/nacos.module';
import { MeetingModule } from './modules/meeting/meeting.module';
import { WebsocketModule } from './modules/websocket/websocket.module';
import { AuthGuard } from './common/guards/auth.guard';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: ['.env.development', '.env.production'],
}),
// 全局基础设施模块
LoggerModule,
RedisModule,
MikroOrmConfigModule,
NacosConfigModule,
// 业务模块
MeetingModule,
WebsocketModule,
],
providers: [{ provide: APP_GUARD, useClass: AuthGuard }],
})
export class AppModule {}

View File

@ -0,0 +1,23 @@
import { ExecutionContext, createParamDecorator } from '@nestjs/common';
export const AuthUser = createParamDecorator((data: string | undefined, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
const user = req.user;
return data ? user?.[data] : user;
});
/**
* 当前用户的uid
*/
export const UserId = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.user?.userId;
});
/**
* 获取当前用户角色
*/
export const UserRole = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const req = ctx.switchToHttp().getRequest();
return req.user?.role;
});

View File

@ -0,0 +1,214 @@
import { type Type, applyDecorators } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, ApiOperation, getSchemaPath } from '@nestjs/swagger';
import { OkResult } from '../dto/result.dto';
type ApiCustomOkResponseOP<T> = {
/** 接口名称 */
summary: string;
/** 接口说明 */
apiDescription?: string;
/** 返回说明 */
resDescription?: string;
/** 默认值 */
default?: unknown;
/**
* 接口实例
* - 传入 DTO 类(如 TokenResponseDto使用该类作为 data 的类型
* - 传入 null/undefineddata 为 null 类型
* - 传入 Boolean/Number/String使用对应的基本类型
* - 传入 'boolean'/'number'/'string':使用对应的基本类型(字符串形式)
* - 传入 [Boolean]/[Number]/[String]:使用对应的数组类型
* - 传入 ['boolean']/['number']/['string']:使用对应的数组类型(字符串形式)
* - 传入 [TokenResponseDto]:使用 DTO 数组类型
* - 传入 [null]:使用 null 数组类型
*/
model: T;
};
/** DTO 类型 */
type DtoType = Type<unknown>;
/** 基本类型映射(构造函数 -> 字符串类型) */
const primitiveTypeMap = new Map<unknown, string>([
[Boolean, 'boolean'],
[Number, 'number'],
[String, 'string'],
]);
/** 字符串基本类型集合(用于直接指定类型) */
const primitiveStringTypes = new Set(['boolean', 'number', 'string']);
/** Schema 类型定义 */
type SchemaType = { type: string; items?: { type: string; $ref?: string }; $ref?: string } | null;
/** 获取 schema 类型 */
function getSchemaType(model: unknown): SchemaType {
// 处理 null 和 undefined
if (model === null || model === undefined) {
return { type: 'null' };
}
// 处理数组类型
if (Array.isArray(model)) {
if (model.length === 0) {
return null;
}
const firstItem = model[0];
// [null] 或 [undefined]
if (firstItem === null || firstItem === undefined) {
return { type: 'array', items: { type: 'null' } };
}
// ['boolean'], ['number'], ['string']
if (typeof firstItem === 'string' && primitiveStringTypes.has(firstItem)) {
return { type: 'array', items: { type: firstItem } };
}
// [Boolean], [Number], [String]
const primitiveType = primitiveTypeMap.get(firstItem);
if (primitiveType) {
return {
type: 'array',
items: { type: primitiveType },
};
}
// [TokenResponseDto], [BlacklistUserDto] 等 DTO 类数组
if (typeof firstItem === 'function') {
return {
type: 'array',
items: {
type: 'object',
$ref: getSchemaPath(firstItem as Type<unknown>),
},
};
}
return null;
}
// 'boolean', 'number', 'string'
if (typeof model === 'string' && primitiveStringTypes.has(model)) {
return { type: model };
}
// Boolean, Number, String
const primitiveType = primitiveTypeMap.get(model);
if (primitiveType) {
return { type: primitiveType };
}
// TokenResponseDto, BlacklistUserDto 等 DTO 类 - 返回 null让 else 分支处理
if (typeof model === 'function') {
return null;
}
return null;
}
/** 基本类型字符串 */
type PrimitiveString = 'boolean' | 'number' | 'string';
/** 基本类型构造函数 */
type PrimitiveCtor = BooleanConstructor | NumberConstructor | StringConstructor;
/** 自定义返回 */
export function ApiCustomOkResponse<T extends Array<DtoType | PrimitiveCtor | PrimitiveString | null> | DtoType | PrimitiveCtor | PrimitiveString | null>(
op: ApiCustomOkResponseOP<T>
) {
/** 额外需要导入的类型 */
const extraModels: Type<unknown>[] = [OkResult];
const arr = [
ApiOperation({
summary: op.summary,
description: op.apiDescription,
}),
];
const schemaType = getSchemaType(op.model);
if (schemaType) {
// 基本类型或数组类型
const isNullType = schemaType.type === 'null';
const { items, ...restSchema } = schemaType;
const dataProps: any = {
...restSchema,
description: op.resDescription || '主体内容',
default: op.default,
example: isNullType ? null : undefined,
};
if (items) {
dataProps.items = items;
}
arr.push(
ApiOkResponse({
schema: {
description: op.resDescription || '主体内容',
allOf: [
{ $ref: getSchemaPath(OkResult) },
{
properties: {
data: dataProps,
},
},
],
},
})
);
} else if (Array.isArray(op.model)) {
// DTO 数组类型 [TokenResponseDto]
const itemModel = op.model[0];
if (itemModel && typeof itemModel === 'function') {
extraModels.push(itemModel);
arr.push(
ApiOkResponse({
schema: {
description: op.resDescription || '主体内容',
allOf: [
{ $ref: getSchemaPath(OkResult) },
{
properties: {
data: {
type: 'array',
items: {
$ref: getSchemaPath(itemModel),
},
description: op.resDescription || '主体内容',
default: op.default,
},
},
},
],
},
})
);
}
} else if (typeof op.model === 'function') {
// 单个 DTO 类 TokenResponseDto
extraModels.push(op.model as Type<unknown>);
arr.push(
ApiOkResponse({
schema: {
description: op.resDescription || '主体内容',
allOf: [
{ $ref: getSchemaPath(OkResult) },
{
properties: {
data: {
type: 'object',
$ref: getSchemaPath(op.model as Type<unknown>),
},
},
},
],
},
})
);
}
arr.push(ApiExtraModels(...extraModels));
return applyDecorators(...arr);
}

View File

@ -0,0 +1,42 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* 通用响应结果 DTO
*/
export class Result<T = any> {
/** 状态码 */
@ApiProperty({ description: '状态码', example: 200 })
code!: number;
/** 返回的数据 */
@ApiProperty({ description: '数据', nullable: true })
data!: T | null;
/** 提示消息 */
@ApiProperty({ description: '消息', example: 'success' })
msg!: string;
}
/**
* 成功响应结果
*/
export class OkResult<T = any> extends Result<T> {
constructor(data: T, msg?: string, code?: number) {
super();
this.code = code ?? 200;
this.data = data;
this.msg = msg ?? 'success';
}
}
/**
* 失败响应结果
*/
export class FailResult<T = any> extends Result<T> {
constructor(msg?: string, data?: T, code?: number) {
super();
this.code = code ?? 500;
this.data = data ?? null;
this.msg = msg ?? 'error';
}
}

View File

@ -0,0 +1,8 @@
import { HttpException } from '@nestjs/common';
/** 自定义异常 */
export class CustomException extends HttpException {
public constructor(msg: string, code = 424) {
super(msg, code);
}
}

View File

@ -0,0 +1,115 @@
import { type ArgumentsHost, Catch, type ExceptionFilter, type HttpException, HttpStatus } from '@nestjs/common';
import type { FastifyReply } from 'fastify';
import { CustomException } from '../exceptions/custom.exception';
import type { LoggerService } from '../../plugins/logger/logger.service';
/**
* 异常捕获过滤器.
*/
@Catch()
export class CatchExceptionFilter implements ExceptionFilter {
public constructor(private readonly logger: LoggerService) {}
/**
* 重写 catch 方法,实现自定义的异常捕获逻辑.
*/
public catch(exception: HttpException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
/**
* 获取 Fastify 响应对象
*/
const response = ctx.getResponse<FastifyReply>();
const errorResponse = {
code: exception?.getStatus?.() || HttpStatus.INTERNAL_SERVER_ERROR,
msg: exception.message,
data: null,
};
if (exception instanceof CustomException) {
errorResponse.msg = exception.message;
} else if (exception.message.includes('Body cannot be empty when content-type is')) {
errorResponse.msg = 'body不能为空';
} else {
// 打印日志
errorResponse.code !== 401 && this.logger.error(exception);
// 判断不同的异常类型并设置中文提示信息
switch (errorResponse.code) {
case 400:
// BadRequestException 异常
errorResponse.msg = '请求格式错误';
break;
case 401:
console.log('exception====', exception);
// UnauthorizedException 异常
errorResponse.msg = '未授权访问';
break;
case 403:
// ForbiddenException 异常
errorResponse.msg = '没有权限';
break;
case 404:
// NotFoundException 异常
errorResponse.msg = '资源未找到';
break;
case 406:
// NotAcceptableException 异常
errorResponse.msg = '请求的格式不被接受';
break;
case 408:
// RequestTimeoutException 异常
errorResponse.msg = '请求超时';
break;
case 409:
// ConflictException 异常
errorResponse.msg = '请求冲突';
break;
case 410:
// GoneException 异常
errorResponse.msg = '资源已不存在';
break;
case 413:
// PayloadTooLargeException 异常
errorResponse.msg = '请求负载过大';
break;
case 415:
// UnsupportedMediaTypeException 异常
errorResponse.msg = '不支持的媒体类型';
break;
case 422:
// UnprocessableException 异常
errorResponse.msg = '参数错误';
break;
case 423:
errorResponse.msg = '参数错误';
break;
case 500:
// InternalServerErrorException 异常
errorResponse.msg = '服务器内部错误';
break;
case 501:
// NotImplementedException 异常
errorResponse.msg = '功能尚未实现';
break;
case 502:
// BadGatewayException 异常
errorResponse.msg = '错误的网关';
break;
case 503:
// ServiceUnavailableException 异常
errorResponse.msg = '服务不可用';
break;
case 504:
// GatewayTimeoutException 异常
errorResponse.msg = '网关超时';
break;
default:
errorResponse.msg = '内部服务器错误,001';
break;
}
}
response.status(HttpStatus.OK).send(errorResponse);
}
}

View File

@ -0,0 +1,36 @@
import { ArgumentsHost, Catch, HttpException } from '@nestjs/common';
import { BaseWsExceptionFilter, WsException } from '@nestjs/websockets';
import type { Socket } from 'socket.io';
import { LoggerService } from '../../plugins/logger/logger.service';
@Catch()
export class WsExceptionFilter extends BaseWsExceptionFilter {
public constructor(private readonly logger: LoggerService) {
super();
}
public catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToWs();
const client = ctx.getClient<Socket>();
const errorResponse = {
type: 'error',
data: { reason: '未知错误', code: 500 },
};
if (exception instanceof WsException) {
errorResponse.data.reason = exception.message;
errorResponse.data.code = 400; // WebSocket 异常默认为 400
} else if (exception instanceof HttpException) {
errorResponse.data.reason = exception.message;
errorResponse.data.code = exception.getStatus();
} else if (exception instanceof Error) {
errorResponse.data.reason = exception.message;
this.logger.error(exception, 'WsExceptionFilter');
} else {
this.logger.error(exception as any, 'WsExceptionFilter');
}
// 发送统一格式的错误消息
client.emit('message', errorResponse);
}
}

View File

@ -0,0 +1,101 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RedisDatabase, RedisService } from '../../plugins/redis/redis.service';
import { LoggerService } from '../../plugins/logger/logger.service';
@Injectable()
export class AuthGuard implements CanActivate {
public constructor(
private readonly redisService: RedisService,
private readonly logger: LoggerService
) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
const http = context.switchToHttp();
const req = http.getRequest<FastifyRequest>();
const url = String((req as any).url || '');
if (url.startsWith('/api-docs')) {
return true;
}
const headers = (req.headers || {}) as Record<string, any>;
// ✅ 兼容大小写
const authorization = String(headers.authorization || headers.Authorization || '');
const customMac = String(headers['custom-mac'] || headers['Custom-Mac'] || '');
const customPlatform = String(headers['custom-platform'] || headers['Custom-Platform'] || '');
const customTimestamp = String(headers['custom-timestamp'] || headers['Custom-Timestamp'] || '');
if (!authorization || !authorization.toLowerCase().startsWith('bearer ')) {
throw new UnauthorizedException('缺少或无效的Authorization');
}
if (!customMac) {
throw new UnauthorizedException('缺少Custom-Mac');
}
if (!customPlatform) {
throw new UnauthorizedException('缺少Custom-Platform');
}
if (!customTimestamp) {
throw new UnauthorizedException('缺少Custom-Timestamp');
}
const token = authorization.slice(7).trim();
const parts = token.split('.');
if (parts.length < 2) {
throw new UnauthorizedException('无效的Token格式');
}
const payload: any = (() => {
try {
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
return JSON.parse(json);
} catch {
throw new UnauthorizedException('无法解析Token');
}
})();
const NAME_ID_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier';
const ROLE_CLAIM = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role';
const userId = String(payload?.[NAME_ID_CLAIM] || '');
const role = Number(payload?.[ROLE_CLAIM] || '-1');
if (!userId || isNaN(role) || role < 0) {
throw new UnauthorizedException('Token非法');
}
const nowSec = Math.floor(Date.now() / 1000);
const exp = Number(payload?.exp || 0);
if (!Number.isFinite(exp) || nowSec >= exp) {
throw new UnauthorizedException('Token已过期');
}
const platform = customPlatform.toUpperCase();
const redisKey = `Auth:${userId}:${platform}`;
try {
const redis = this.redisService.getClient(RedisDatabase.GLOBAL);
const v = await redis.get(redisKey);
if (!v) {
throw new UnauthorizedException('未找到登录态');
}
const redisObj: any = (() => {
try {
return JSON.parse(v);
} catch {
throw new UnauthorizedException('登录态数据异常');
}
})();
const redisToken = redisObj?.Token || redisObj?.token;
if (redisToken !== token) {
throw new UnauthorizedException('登录态已失效');
}
} catch (e) {
if (e instanceof UnauthorizedException) {
throw e;
}
this.logger.error(e, 'AuthGuard');
throw new UnauthorizedException('鉴权失败');
}
(req as any).user = { userId, role };
return true;
}
}

View File

@ -0,0 +1,19 @@
import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
@Injectable()
export class NonEmptyStringPipe implements PipeTransform<string, string> {
public constructor(
private readonly name?: string,
private readonly code: 422 | 423 = 422
) {}
public transform(value: unknown): string {
if (typeof value !== 'string' || value.trim().length === 0) {
const message = `${this.name ?? '参数'}不能为空`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
}
return value.trim();
}
}

View File

@ -0,0 +1,44 @@
import { HttpException, Injectable, type PipeTransform, UnprocessableEntityException } from '@nestjs/common';
@Injectable()
export class Uint32Pipe implements PipeTransform<string, number> {
public constructor(
private readonly name?: string,
private readonly code: 422 | 423 = 422
) {}
public transform(value: unknown): number {
const field = this.name ?? '参数';
if (value === undefined || value === null || value === '') {
const message = `${field}不能为空`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
}
if ((typeof value === 'string' && !/^\d+$/.test(value)) || isNaN(Number(value))) {
const message = `${field}必须是数字`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
}
const num = Number(value);
if (!Number.isSafeInteger(num)) {
const message = `${field}超出安全整数范围`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
} else if (num < 0 || num > 4294967295) {
const message = `${field}必须在0到4294967295之间`;
if (this.code === 422) {
throw new UnprocessableEntityException(message);
}
throw new HttpException(message, 423);
}
return num;
}
}

View File

@ -0,0 +1,42 @@
/**
* 环境变量加载模块
* 根据 NODE_ENV 自动加载对应的 .env 文件
*/
import { config } from 'dotenv';
import { resolve } from 'path';
/** 运行环境类型 */
export type NodeEnv = 'development' | 'production';
/** 获取当前运行环境 */
export const getNodeEnv = (): NodeEnv => {
const env = process.env.NODE_ENV;
if (env === 'production') {
return 'production';
}
return 'development';
};
/** 是否生产环境 */
export const isProduction = (): boolean => getNodeEnv() === 'production';
/** 是否开发环境 */
export const isDevelopment = (): boolean => getNodeEnv() === 'development';
/** 加载环境变量 */
export const loadEnv = (): void => {
const env = getNodeEnv();
const envFile = `.env.${env}`;
// 先加载默认 .env 文件(如果存在)
config({ path: resolve(process.cwd(), '.env') });
// 再加载环境特定的 .env 文件(覆盖默认值)
config({ path: resolve(process.cwd(), envFile) });
// 最后加载本地 .env.local 文件(最高优先级,不提交到版本控制)
config({ path: resolve(process.cwd(), '.env.local'), override: true });
};
// 自动加载环境变量
loadEnv();

View File

@ -0,0 +1,29 @@
import { defineConfig } from '@mikro-orm/mysql';
import { MeetingUser } from '../modules/meeting/entities/meeting-user.entity';
const config = defineConfig({
dbName: process.env.DB_NAME || 'scs',
host: process.env.DB_HOST || '47.109.17.238',
port: Number(process.env.DB_PORT) || 13306,
user: process.env.DB_USER || 'user',
password: process.env.DB_PASSWORD || '',
charset: 'utf8mb4',
timezone: '+08:00',
// 连接池配置 (v7 版本只支持 max 和 idleTimeoutMillis)
pool: {
max: 20, // 最大连接数
idleTimeoutMillis: 30000, // 空闲连接回收 30 秒
},
// 自动加载实体
entities: [MeetingUser],
// 调试模式
debug: process.env.NODE_ENV === 'development',
// 允许全局上下文(适用于 WebSocket 等长连接场景)
allowGlobalContext: true,
});
export default config;

View File

@ -0,0 +1,91 @@
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import type { ParameterObject } from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
import { isObject } from 'class-validator';
/** 注册 Swagger */
export function useSwaggerConfig(app: NestFastifyApplication, ipv4: string, port: number) {
const config = new DocumentBuilder()
.setTitle('API文档')
.setDescription('Test01移动端(小程序/APP/移动网页)文档')
.setVersion('1.0')
.setContact('马小平', '', 'mxp131011@qq.com')
.setLicense('MIT', 'https://opensource.org/licenses/MIT')
.addServer('https://meeting.qyzhjy.com', '生产环境')
.addServer(`http://${ipv4}:${port}`, '测试环境')
// JWT Bearer 认证
.addBearerAuth(
{ type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: '使用 JWT Token 进行鉴权', in: 'header' },
'JWT-auth' // 安全方案名称
)
// 全局启用 JWT 认证(所有接口默认需要认证)
.addSecurityRequirements('JWT-auth');
/** 全局参数 */
const list: Omit<ParameterObject, 'example' | 'examples'>[] = [
{
name: 'Custom-Mac',
in: 'header',
description: '设备id,设备的唯一识别码',
required: true,
schema: { type: 'string', default: '165362158044846585135' },
},
{
name: 'Custom-Timestamp',
in: 'header',
description: '时间戳(毫秒)',
required: true,
schema: { type: 'string', default: 1772768634072 },
},
{
name: 'Custom-Platform',
in: 'header',
description: '设备平台如:ios/android/web',
required: true,
schema: { type: 'string', default: 'WECHAT', enum: ['WECHAT', 'PC', 'WEB', 'WECHAT', 'PC-APP'] },
},
];
config.addGlobalParameters(...list);
// 创建 Swagger 文档
const document = SwaggerModule.createDocument(app, config.build());
/** 设置 Swagger 路径 */
SwaggerModule.setup('api-docs', app, document, {
jsonDocumentUrl: 'api-docs/swagger.json',
/**
* 重写请求时的文档处理逻辑
*/
patchDocumentOnRequest(req: any, _res, docs) {
// 动态更新时间戳参数的 default 值为当前时间戳
if (docs.components?.parameters) {
const timestampParam = docs.components.parameters['Custom-Timestamp'];
// 检查是否为 ParameterObject而非 ReferenceObject
if (timestampParam && 'schema' in timestampParam && timestampParam.schema) {
(timestampParam.schema as Record<string, unknown>).default = String(Date.now());
}
}
// 判断是否是 YAML 文档请求,并且是否存在 paths 属性
if (req.url.includes('api-docs/swagger.json') && isObject(docs.paths)) {
// 遍历 paths 对象的所有属性(即路径)
for (const path of Object.keys(docs.paths)) {
const methods = docs.paths[path]! as Record<string, unknown>;
for (const method of Object.keys(methods)) {
if (isObject(methods[method]) && 'parameters' in methods[method]) {
const parameters = methods[method].parameters;
if (Array.isArray(parameters)) {
methods[method].parameters = parameters.filter((item: Record<string, unknown>) => {
// 提取全局参数的名称
const keys = list.map((item2) => item2.name);
// 过滤掉名称在全局参数名称列表中的参数
return !(isObject(item) && keys.includes(String(item?.name || '')));
});
}
}
}
}
}
return docs;
},
});
}

77
node_api/src/main.ts Normal file
View File

@ -0,0 +1,77 @@
import './config/env';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { ValidationPipe } from '@nestjs/common';
import { ip } from 'address';
import { execSync } from 'child_process';
import { AppModule } from './app.module';
import { CatchExceptionFilter } from './common/filters/catch.exception.filter';
import { LoggerService } from './plugins/logger/logger.service';
import { useSwaggerConfig } from './config/swagger.config';
// 跨平台 UTF-8 编码支持(解决 Windows 中文乱码)
if (process.platform === 'win32') {
// 设置 Node.js 使用 UTF-8 编码
process.env.NODE_SKIP_UTF8_CHECK = 'true';
try {
// 尝试将 Windows 控制台设置为 UTF-8
execSync('chcp 65001 > nul 2>&1', { stdio: 'ignore' });
} catch {
// 忽略错误,继续运行
}
}
/** 入口 */
async function bootstrap() {
/** 得到id */
const ipv4 = ip() || '127.0.0.1';
/** 端口 */
const port = parseInt(process.env.PORT || '4001', 10);
const pinoLogger = new LoggerService();
const fastifyAdapter = new FastifyAdapter({ loggerInstance: pinoLogger.logger });
const app: NestFastifyApplication = await NestFactory.create<NestFastifyApplication>(AppModule, fastifyAdapter, { bufferLogs: true });
// 启用 CORS
app.enableCors({
origin: true, // 允许所有来源(生产环境应该指定具体域名)
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
allowedHeaders: ['*'],
credentials: true,
});
// 启用全局异常过滤器
app.useGlobalFilters(new CatchExceptionFilter(pinoLogger));
// 自动验证传入的请求数据
app.useGlobalPipes(
new ValidationPipe({
transform: true, // 是否自动将请求数据转换为 DTO 类的实例。
whitelist: true, // 是否自动去除 DTO 类中未定义的属性。
forbidNonWhitelisted: true, // 是否禁止请求数据中包含 DTO 类中未定义的属性
errorHttpStatusCode: 422, // 自定义 HTTP 错误码
stopAtFirstError: true, // 当设置为 true 时,给定属性的验证将在遇到第一个错误后停止。默认为 false。
enableDebugMessages: false, // 是否自动将请求数据转换为 DTO 类的实例。
})
);
/** 自定义Logger复用已有实例 */
app.useLogger(pinoLogger);
/** 注册 Swagger */
useSwaggerConfig(app, ipv4, port);
try {
await app.listen(port, '0.0.0.0');
console.log('\x1b[32;1m%s\x1b[0m \x1b[34;4m%s\x1b[0m', 'AIP接口地址: ', `http://${ipv4}:${port}`);
console.log('\x1b[36;1m%s\x1b[0m \x1b[34;4m%s\x1b[0m', 'AIP接口文档 UI 地址: ', `http://${ipv4}:${port}/api-docs`);
console.log('\x1b[36;1m%s\x1b[0m \x1b[34;4m%s\x1b[0m', 'AIP接口文档JSON地址: ', `http://${ipv4}:${port}/api-docs/swagger.json`, '注意会过滤全局header');
} catch (error) {
pinoLogger.error(error, 'bootstrap');
}
}
if (import.meta.env.PROD) {
bootstrap();
}
// Vite 热重载需要导出 viteNodeApp
export const viteNodeApp = bootstrap();

View File

@ -0,0 +1,14 @@
import { Entity, PrimaryKey, Property, Unique } from '@mikro-orm/decorators/legacy';
@Entity({ tableName: 'meeting_user' })
export class MeetingUser {
@PrimaryKey({ type: 'number' })
public id!: number;
@Unique()
@Property({ type: 'bigint', fieldName: 'long_user_Id' })
public longUserId!: number;
@Property({ type: 'date' })
public createdAt: Date = new Date();
}

View File

@ -0,0 +1,92 @@
import { Controller, Delete, Get, HttpCode, HttpStatus, Param } from '@nestjs/common';
import { ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger';
import { FailResult, OkResult } from '../../common/dto/result.dto';
import { MeetingService } from './meeting.service';
import { NonEmptyStringPipe } from '@/common/pipes/non-empty-string.pipe';
import { Uint32Pipe } from '@/common/pipes/uint32.pipe';
import { ApiCustomOkResponse } from '@/common/decorators/swagger.decorator';
import { TokenResponseDto } from './meeting.dto';
import { MeetingRedisService } from '../websocket/meeting-redis.service';
/**
* 黑名单用户 DTO
*/
class BlacklistUserDto {
@ApiProperty({ description: '短 UID', example: 1001 })
shortUid?: number;
@ApiProperty({ description: '用户名称', example: '张三' })
userName?: string;
}
/**
* 会议控制器
* 处理会议相关的 API 请求
*/
@ApiTags('Meeting')
@Controller('meeting')
export class MeetingController {
public constructor(
private readonly meetingService: MeetingService,
private readonly redis: MeetingRedisService
) {}
/**
* 生成声网 RTC Token
*/
@Get('get-token/:channelName/:uid')
@HttpCode(HttpStatus.OK)
@ApiCustomOkResponse({
summary: '声网 RTC Token',
model: TokenResponseDto,
apiDescription: '声网 RTC Token 信息',
resDescription: '声网 RTC Token 信息包含appid',
})
@ApiParam({ name: 'channelName', description: '需要加入的频道名(格式: `n_课程名称`)', example: 'n_1234567890' })
@ApiParam({ name: 'uid', description: '用户的短 UID(0~4294967295)', example: '1001' })
public async getToken(@Param('channelName', new NonEmptyStringPipe('channelName')) channelName: string, @Param('uid', new Uint32Pipe('uid')) uid: number) {
const result = this.meetingService.generateToken(channelName, uid);
if (!result) {
return new FailResult('生成 Token 失败');
}
return new OkResult(result);
}
/**
* 查询房间黑名单(包含短 UID 和名称的数组)
*/
@Get('blacklist/:roomId')
@HttpCode(HttpStatus.OK)
@ApiParam({ name: 'roomId', description: '服务端房间 IDSocket.IO 房间名)' })
@ApiCustomOkResponse({
summary: '房间黑名单',
model: [BlacklistUserDto],
apiDescription: '房间黑名单(包含短 UID 和名称的数组)',
resDescription: '房间黑名单(包含短 UID 和名称的数组)',
})
public async getBlacklist(@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string) {
const list = await this.redis.getBlacklist(roomId);
return new OkResult(list);
}
/**
* 从房间黑名单移除指定用户(短 UID
*/
@Delete('blacklist/:roomId/:shortUid')
@HttpCode(HttpStatus.OK)
@ApiParam({ name: 'roomId', description: '服务端房间 IDSocket.IO 房间名)' })
@ApiParam({ name: 'shortUid', description: '用户短 UID', example: '1001' })
@ApiCustomOkResponse({
summary: '从房间黑名单移除用户',
model: Boolean,
apiDescription: '从房间黑名单移除指定用户(短 UID',
resDescription: '是否成功移除用户',
})
public async removeFromBlacklist(
@Param('roomId', new NonEmptyStringPipe('roomId')) roomId: string,
@Param('shortUid', new Uint32Pipe('shortUid')) shortUid: number
) {
await this.redis.removeFromBlacklist(roomId, shortUid);
return new OkResult(true);
}
}

View File

@ -0,0 +1,15 @@
import { ApiProperty } from '@nestjs/swagger';
/**
* Token 响应数据 DTO
*/
export class TokenResponseDto {
@ApiProperty({ description: '应用 ID', example: '1234567890' })
public appid!: string;
@ApiProperty({ description: '声网RTC的Token', example: '0061234567890abcdef...' })
public rtcToken!: string;
@ApiProperty({ description: '过期时间戳', example: 1704067200000 })
public expiresAt!: number;
}

View File

@ -0,0 +1,12 @@
import { Module, forwardRef } from '@nestjs/common';
import { MeetingController } from './meeting.controller';
import { MeetingService } from './meeting.service';
import { WebsocketModule } from '../websocket/websocket.module';
@Module({
controllers: [MeetingController],
providers: [MeetingService],
imports: [forwardRef(() => WebsocketModule)],
exports: [MeetingService],
})
export class MeetingModule {}

View File

@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { Role, RtcTokenBuilder } from '../../plugins/shengwang/RtcTokenBuilder2';
import { NacosConfigService } from '../../plugins/nacos/nacos-config.service';
import { EntityManager } from '@mikro-orm/core';
import { MeetingUser } from './entities/meeting-user.entity';
import type { TokenResponseDto } from './meeting.dto';
@Injectable()
export class MeetingService {
constructor(
private readonly em: EntityManager,
private readonly nacosConfig: NacosConfigService
) {}
/**
* 通过作业 ID 查询老师的长 ID
* @param homeworkId - 作业 IDJoinRoomData.homeworkId
* @returns 老师长 ID未查询到返回 null
*/
public async getTeacherLongUserIdByHomeworkId(homeworkId: number): Promise<number | null> {
const id = Number(homeworkId);
if (!Number.isFinite(id) || id <= 0) {
return null;
}
const sql = 'SELECT B.TeacherId AS teacherId FROM icr_homework AS A LEFT JOIN scs_studentgroup AS B ON A.StudentGroupId = B.Id WHERE A.Id = ? LIMIT 1';
const rows = (await this.em.getConnection().execute(sql, [id])) as Array<{ teacherId?: number | string }>;
const teacherIdRaw = rows?.[0]?.teacherId;
const teacherId = teacherIdRaw === undefined ? NaN : Number(teacherIdRaw);
if (!Number.isFinite(teacherId) || teacherId <= 0) {
return null;
}
return teacherId;
}
/**
* 通过作业 ID 获取老师的短 UID
* @param homeworkId - 作业 IDJoinRoomData.homeworkId
* @returns 老师短 UID未查询到返回 null
*/
public async getTeacherShortUidByHomeworkId(homeworkId: number): Promise<number | null> {
const teacherLongUserId = await this.getTeacherLongUserIdByHomeworkId(homeworkId);
if (!teacherLongUserId) {
return null;
}
return await this.getOrAssignShortUid(teacherLongUserId);
}
/**
* 获取或分配用户的短 UID
* @param longUserId - 用户的真实 IDbigint
* @returns 分配的短 UID数据库自增 ID
*/
public async getOrAssignShortUid(longUserId: number | string): Promise<number> {
const userId = typeof longUserId === 'string' ? BigInt(longUserId) : BigInt(longUserId);
// 尝试获取已存在的记录
const existingUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) });
if (existingUser) {
// 复用已有的短 UID
console.log(`[MeetingService] 用户 ${longUserId} 复用短 UID: ${existingUser.id}`);
return existingUser.id;
}
// 创建新记录(数据库自增 ID 会自动分配)
try {
const newUser = this.em.create(MeetingUser, { longUserId: Number(userId), createdAt: new Date() });
await this.em.flush();
console.log(`[MeetingService] 为用户 ${longUserId} 分配短 UID: ${newUser.id}`);
return newUser.id;
} catch (error: any) {
// 处理并发导致的唯一索引冲突 (MySQL Error 1062: Duplicate entry)
if (error.code === 'ER_DUP_ENTRY' || error.message?.includes('Duplicate entry')) {
console.warn(`[MeetingService] 检测到并发创建冲突,重新获取用户 ${longUserId}`);
this.em.clear(); // 清除当前上下文,防止缓存干扰
const retryUser = await this.em.findOne(MeetingUser, { longUserId: Number(userId) });
if (retryUser) {
return retryUser.id;
}
}
throw error;
}
}
public generateToken(channelName: string, uid: number): TokenResponseDto | null {
// 从 Nacos 配置获取 appId 和 appCertificate
const agoraConfig = this.nacosConfig.getDefaultAgoraAppConfig();
const now = Date.now();
const role = Role.PUBLISHER;
const tokenExpirationInSecond = 60 * 60 * 24; // 24 小时
const privilegeExpirationInSecond = tokenExpirationInSecond;
const token = RtcTokenBuilder.buildTokenWithUid(
agoraConfig.appId,
agoraConfig.appCertificate,
channelName, // 使用清理后的频道名
uid,
role,
tokenExpirationInSecond,
privilegeExpirationInSecond
);
if (!token) {
return null;
}
const expiresAt = now + tokenExpirationInSecond * 1000;
return { appid: agoraConfig.appId, rtcToken: token, expiresAt };
}
}

View File

@ -0,0 +1,157 @@
/**
* WebSocket 认证守卫
* 用于验证 Socket.IO 连接的用户身份
*/
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import type { Socket } from 'socket.io';
import { WsException } from '@nestjs/websockets';
import { RedisDatabase, RedisService } from '../../plugins/redis/redis.service';
import { LoggerService } from '../../plugins/logger/logger.service';
import { MeetingService } from '../meeting/meeting.service';
import type { MeetingWsUser } from './types';
@Injectable()
export class MeetingAuthGuard implements CanActivate {
public constructor(
private readonly redisService: RedisService,
private readonly logger: LoggerService,
private readonly meetingService: MeetingService
) {}
public async canActivate(context: ExecutionContext): Promise<boolean> {
// 获取 Socket 实例
const socket = context.switchToWs().getClient<Socket>();
// 检查是否已经通过连接认证
if (socket.data.user) {
return true;
}
try {
const user = await this.validateToken(socket);
socket.data.user = user;
return true;
} catch (e) {
if (e instanceof WsException) {
this.logger.error(e.message, 'WsAuthGuard');
throw e;
}
this.logger.error(e, 'WsAuthGuard');
throw new WsException('WebSocket 鉴权失败');
}
}
/**
* 验证 Token 并返回用户信息
* 可供 handleConnection 直接调用
*/
public async validateToken(socket: Socket): Promise<MeetingWsUser> {
// 尝试从握手信息中获取认证头(兼容大小写)
const handshake = socket.handshake;
const authHeader = String(
handshake.auth?.authorization || handshake.auth?.Authorization || handshake.headers?.authorization || handshake.headers?.Authorization || ''
);
const customMac = String(
handshake.auth?.['custom-mac'] || handshake.auth?.['Custom-Mac'] || handshake.headers?.['custom-mac'] || handshake.headers?.['Custom-Mac'] || ''
);
const customPlatform = String(
handshake.auth?.['custom-platform'] ||
handshake.auth?.['Custom-Platform'] ||
handshake.headers?.['custom-platform'] ||
handshake.headers?.['Custom-Platform'] ||
''
);
const customTimestamp = String(
handshake.auth?.['custom-timestamp'] ||
handshake.auth?.['Custom-Timestamp'] ||
handshake.headers?.['custom-timestamp'] ||
handshake.headers?.['Custom-Timestamp'] ||
''
);
// 验证必要的认证信息
if (!authHeader || !authHeader.toLowerCase().startsWith('bearer')) {
throw new WsException('缺少或无效的 Authorization');
}
if (!customMac) {
throw new WsException('缺少 Custom-Mac');
}
if (!customPlatform) {
throw new WsException('缺少 Custom-Platform');
}
if (!customTimestamp) {
throw new WsException('缺少 Custom-Timestamp');
}
// 解析 Token
const token = authHeader.slice(7).trim();
const parts = token.split('.');
if (parts.length < 2) {
throw new WsException('无效的 Token 格式');
}
const payload: any = (() => {
try {
const json = Buffer.from(parts[1], 'base64url').toString('utf8');
return JSON.parse(json);
} catch {
throw new WsException('无法解析 Token');
}
})();
const NAME_ID_CLAIM = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier';
const ROLE_CLAIM = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role';
const userId = String(payload?.[NAME_ID_CLAIM] || '');
const role = Number(payload?.[ROLE_CLAIM] || '-1');
if (!userId || isNaN(role) || role < 0) {
throw new WsException('Token 非法');
}
// 验证 Token 过期时间
const nowSec = Math.floor(Date.now() / 1000);
const exp = Number(payload?.exp || 0);
if (!Number.isFinite(exp) || nowSec >= exp) {
throw new WsException('Token 已过期');
}
const platform = customPlatform.toUpperCase();
// 验证 Redis 中的登录态
const redisKey = `Auth:${userId}:${platform}`;
const redis = this.redisService.getClient(RedisDatabase.GLOBAL);
const v = await redis.get(redisKey);
if (!v) {
throw new WsException('未找到登录态');
}
const redisObj: any = (() => {
try {
return JSON.parse(v);
} catch {
throw new WsException('登录态数据异常');
}
})();
const redisToken = redisObj?.Token || redisObj?.token || '';
if (redisToken !== token) {
throw new WsException('登录态已失效');
}
// 查询数据库获取短 UID 和用户名
const shortUid = await this.meetingService.getOrAssignShortUid(Number(userId));
// 从 Redis 登录态中获取用户名
const userName = redisObj?.userName || redisObj?.UserName || `用户-${userId}`;
console.log(`[WsAuthGuard] 用户 ${userId} 获得短 UID: ${shortUid}, userName: ${userName}`);
return {
userId,
role,
shortUid,
platform,
userName,
};
}
}

View File

@ -0,0 +1,469 @@
/**
* Redis 服务 - 管理会议状态
* 用于维护跨 WebSocket 连接的用户状态,支持用户重新加入时恢复状态
*/
import { Injectable } from '@nestjs/common';
import { RedisService } from '../../plugins/redis/redis.service';
import type { Redis } from 'ioredis';
import type { BlacklistUser, UserPermissionState } from './types';
@Injectable()
export class MeetingRedisService {
/** Redis 客户端(会议数据库 DB 0 */
private redisClient!: Redis;
/**
* Redis Key 前缀配置
*
* 设计原则:
* 1. 使用冒号(:)作为分隔符,符合 Redis Key 命名规范
* 2. 按功能模块分组,便于管理和排查问题
* 3. 所有 Key 都设置了 24 小时过期时间,自动清理
*
* 数据结构选择:
* - Hash适合存储对象类型的数据如用户状态、黑名单
* - Set适合存储需要去重的集合如房间用户列表、Socket ID 列表)
*/
private readonly KEY_PREFIX = {
/**
* 用户状态 Key
* 用途:存储用户在会议中的权限状态(禁麦/禁视频)和 Socket 连接列表
* 数据结构Hash
* Key 格式meeting:user:{roomId}:{shortUid}
* Hash 字段:
* - isAudioMuted: 是否被禁麦('1' 或 '0'
* - isVideoMuted: 是否被禁视频('1' 或 '0'
* - socketIds: JSON 字符串数组,用户的所有 Socket 连接 ID
* 适用场景:用户断线重连、教师禁麦/禁视频后用户重新加入、多设备登录检测
* 过期时间24 小时
*/
USER_STATE: 'meeting:user:',
/**
* 黑名单 Key
* 用途:存储被踢出房间的用户列表,防止用户再次加入
* 数据结构Hash
* Key 格式meeting:blacklist:{roomId}
* Hash 字段:
* - field: shortUid用户短 UID
* - value: JSON 字符串 { shortUid, userName }
* 适用场景:用户被踢出后记录,下次用户尝试加入时检查
* 过期时间24 小时
* 注意:黑名单在课程结束后不会被清理,需要手动调用 clearRoomAll 或单独清理
*/
BLACKLIST: 'meeting:blacklist:',
/**
* 房间状态 Key
* 用途:存储房间的全局状态信息
* 数据结构Hash
* Key 格式meeting:room:{roomId}
* Hash 字段:
* - classStatus: 课堂状态not_started | in_class | finished
* - speakerUid: 当前主讲人短 UID可选
* - teacherUid: 老师短 UID可选
* 适用场景:同步课堂状态、切换主讲人、判断课程是否在进行中
* 过期时间24 小时
*/
ROOM_STATE: 'meeting:room:',
};
constructor(private readonly redisService: RedisService) {
// ✅ 不在构造函数中初始化,改为懒加载
}
/**
* 懒加载获取 Redis 客户端(第一次使用时才初始化)
*/
private getClient(): Redis {
if (!this.redisClient) {
this.redisClient = this.redisService.getMeetingClient();
}
return this.redisClient;
}
/**
* 获取用户状态 Key
*/
private getUserStateKey(roomId: string, shortUid: number): string {
return `${this.KEY_PREFIX.USER_STATE}${roomId}:${shortUid}`;
}
/**
* 设置用户状态Hash 结构)
*/
async setUserState(roomId: string, shortUid: number, state: Partial<UserPermissionState>): Promise<void> {
const key = this.getUserStateKey(roomId, shortUid);
const hm: Record<string, string> = {};
if (typeof state.isAudioMuted === 'boolean') {
hm.isAudioMuted = state.isAudioMuted ? '1' : '0';
}
if (typeof state.isVideoMuted === 'boolean') {
hm.isVideoMuted = state.isVideoMuted ? '1' : '0';
}
if (Object.keys(hm).length > 0) {
await this.getClient().hset(key, hm);
// 设置过期时间24 小时(会议结束后自动清理)
await this.getClient().expire(key, 24 * 60 * 60);
}
}
/**
* 获取用户状态Hash 结构)
*/
async getUserState(roomId: string, shortUid: number): Promise<UserPermissionState> {
const key = this.getUserStateKey(roomId, shortUid);
const map = await this.getClient().hgetall(key);
let socketIds: string[] = [];
if (map?.socketIds) {
try {
socketIds = JSON.parse(map.socketIds);
} catch {
socketIds = [];
}
}
return {
isAudioMuted: map?.isAudioMuted === '1',
isVideoMuted: map?.isVideoMuted === '1',
socketIds,
};
}
/**
* 清理用户状态(下课或离开时调用)
*/
async clearUserState(roomId: string, shortUid: number): Promise<void> {
const key = this.getUserStateKey(roomId, shortUid);
await this.getClient().del(key);
}
// ==================== 黑名单管理 ====================
/**
* 检查用户是否被踢出(黑名单中)
* @param roomId - 房间 ID
* @param shortUid - 短 UID
*/
async isUserKicked(roomId: string, shortUid: number): Promise<boolean> {
const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
const exists = await this.getClient().hexists(key, String(shortUid));
return exists === 1;
}
/**
* 将用户加入黑名单
* @param roomId - 房间 ID
* @param shortUid - 短 UID
* @param userName - 用户名称
*/
async addToBlacklist(roomId: string, shortUid: number, userName: string): Promise<void> {
const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
const userData: BlacklistUser = { shortUid, userName };
await this.getClient().hset(key, String(shortUid), JSON.stringify(userData));
await this.getClient().expire(key, 24 * 60 * 60);
}
/**
* 从黑名单移除用户(按短 UID
*/
async removeFromBlacklist(roomId: string, shortUid: number): Promise<void> {
const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
await this.getClient().hdel(key, String(shortUid));
}
/**
* 获取黑名单中的所有用户
* @returns 黑名单用户列表(包含短 UID 和名称)
*/
async getBlacklist(roomId: string): Promise<BlacklistUser[]> {
const key = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
const values = await this.getClient().hvals(key);
return values
.map((v) => {
try {
return JSON.parse(v) as BlacklistUser;
} catch {
return null;
}
})
.filter((u): u is BlacklistUser => u !== null);
}
// ==================== 房间状态管理 ====================
/**
* 设置课堂状态
* @param roomId - 房间 ID
* @param status - 状态not_started未开始/ in_class上课中/ finished已下课
*/
async setClassStatus(roomId: string, status: 'finished' | 'in_class' | 'not_started'): Promise<void> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
await this.getClient().hset(key, { classStatus: status });
await this.getClient().expire(key, 24 * 60 * 60);
}
/**
* 设置主讲人
* @param roomId - 房间 ID
* @param speakerUid - 主讲人短 UIDnull 表示取消主讲
*/
async setSpeaker(roomId: string, speakerUid: number | null): Promise<void> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
if (speakerUid === null) {
await this.getClient().hdel(key, 'speakerUid');
} else {
await this.getClient().hset(key, { speakerUid: String(speakerUid) });
await this.getClient().expire(key, 24 * 60 * 60);
}
}
/**
* 设置投屏状态
* @param roomId - 房间 ID
* @param screenShareUid - 投屏人短 UIDnull 表示停止投屏
*/
async setScreenSharing(roomId: string, screenShareUid: number | null): Promise<void> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
if (screenShareUid === null) {
await this.getClient().hdel(key, 'screenShareUid');
} else {
await this.getClient().hset(key, { screenShareUid: String(screenShareUid) });
await this.getClient().expire(key, 24 * 60 * 60);
}
}
/**
* 设置老师短 UID
* @param roomId - 房间 ID
* @param teacherUid - 老师短 UID
*/
async setTeacherUid(roomId: string, teacherUid: number): Promise<void> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
await this.getClient().hset(key, { teacherUid: String(teacherUid) });
await this.getClient().expire(key, 24 * 60 * 60);
}
/**
* 获取老师短 UID
* @param roomId - 房间 ID
* @returns 老师短 UID不存在返回 null
*/
async getTeacherUid(roomId: string): Promise<number | null> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
const v = await this.getClient().hget(key, 'teacherUid');
const uid = v ? Number(v) : NaN;
if (!Number.isFinite(uid) || uid <= 0) {
return null;
}
return uid;
}
/**
* 获取房间状态
* @returns 房间状态(包含课堂状态、主讲人、投屏状态)
*/
async getRoomState(roomId: string): Promise<{
classStatus: 'finished' | 'in_class' | 'not_started';
speakerUid?: number;
screenShareUid?: number;
teacherUid?: number;
}> {
const key = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
const map = await this.getClient().hgetall(key);
const status = (map?.classStatus as any) || 'not_started';
const speakerUid = map?.speakerUid ? Number(map.speakerUid) : undefined;
const screenShareUid = map?.screenShareUid ? Number(map.screenShareUid) : undefined;
const teacherUid = map?.teacherUid ? Number(map.teacherUid) : undefined;
return {
classStatus: status,
...(Number.isFinite(speakerUid) ? { speakerUid } : {}),
...(Number.isFinite(screenShareUid) ? { screenShareUid } : {}),
...(Number.isFinite(teacherUid) ? { teacherUid } : {}),
};
}
// ==================== Socket 连接管理 ====================
/**
* 添加用户 Socket 连接(存储在 USER_STATE Hash 的 socketIds 字段中)
* @param roomId - 房间 ID
* @param shortUid - 短 UID
* @param socketId - Socket.IO 连接 ID
*/
async addSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
const key = this.getUserStateKey(roomId, shortUid);
// 获取当前 socketIds
const currentState = await this.getUserState(roomId, shortUid);
const socketIds = currentState.socketIds || [];
// 添加新 socketId如果不存在
if (!socketIds.includes(socketId)) {
socketIds.push(socketId);
}
// 存储到 Redis
await this.getClient().hset(key, { socketIds: JSON.stringify(socketIds) });
await this.getClient().expire(key, 24 * 60 * 60);
}
/**
* 移除用户 Socket 连接
* @param roomId - 房间 ID
* @param shortUid - 短 UID
* @param socketId - Socket.IO 连接 ID
*/
async removeSocket(roomId: string, shortUid: number, socketId: string): Promise<void> {
const key = this.getUserStateKey(roomId, shortUid);
// 获取当前 socketIds
const currentState = await this.getUserState(roomId, shortUid);
const socketIds = currentState.socketIds || [];
// 移除指定的 socketId
const newSocketIds = socketIds.filter((id) => id !== socketId);
if (newSocketIds.length > 0) {
await this.getClient().hset(key, { socketIds: JSON.stringify(newSocketIds) });
} else {
// 如果没有 socketId 了,删除整个 key用户离开
await this.getClient().del(key);
}
}
/**
* 获取用户的所有 Socket 连接 ID
* @param roomId - 房间 ID
* @param shortUid - 短 UID
* @returns Socket 连接 ID 数组
*/
async getSocketIds(roomId: string, shortUid: number): Promise<string[]> {
const state = await this.getUserState(roomId, shortUid);
return state.socketIds || [];
}
// ==================== 房间用户管理 ====================
/**
* 添加用户到房间(通过设置用户状态来标记用户在线)
* @param roomId - 房间 ID
* @param shortUid - 短 UID
*/
async addUserToRoom(roomId: string, shortUid: number): Promise<void> {
// 通过设置用户状态来标记用户在线(设置一个占位状态)
await this.setUserState(roomId, shortUid, {});
}
/**
* 从房间移除用户(清理用户状态)
* @param roomId - 房间 ID
* @param shortUid - 短 UID
*/
async removeUserFromRoom(roomId: string, shortUid: number): Promise<void> {
// 清理用户状态即表示用户离开房间
await this.clearUserState(roomId, shortUid);
}
/**
* 获取房间中的所有用户短 UID 列表
* 通过扫描 USER_STATE 模式来获取房间内所有用户
* @param roomId - 房间 ID
* @returns 短 UID 数组
*/
async getUsersInRoom(roomId: string): Promise<number[]> {
const pattern = `${this.KEY_PREFIX.USER_STATE}${roomId}:*`;
const client = this.getClient();
const userUids: number[] = [];
let cursor = '0';
do {
// eslint-disable-next-line no-await-in-loop
const res = await client.scan(cursor, 'MATCH', pattern, 'COUNT', 200);
cursor = res[0];
const keys = res[1] ?? [];
for (const key of keys) {
// 从 key 中提取 shortUidmeeting:user:{roomId}:{shortUid}
const parts = key.split(':');
const shortUid = Number(parts[parts.length - 1]);
if (Number.isFinite(shortUid)) {
userUids.push(shortUid);
}
}
} while (cursor !== '0');
return userUids;
}
/**
* 检查房间是否为空(没有任何用户连接)
* 通过检查 Redis 中是否有任何用户的 socketIds 来判断
* 注意:这是检查 Redis 状态,不依赖 Socket.IO 的 rooms
* @param roomId - 房间 ID
* @returns true 表示房间为空false 表示还有用户
*/
async isRoomEmpty(roomId: string): Promise<boolean> {
const users = await this.getUsersInRoom(roomId);
// 如果没有用户,直接返回 true
if (users.length === 0) {
return true;
}
// 并行检查所有用户是否还有有效的 socket 连接
const socketChecks = await Promise.all(
users.map(async (shortUid) => {
const socketIds = await this.getSocketIds(roomId, shortUid);
return socketIds.length > 0;
})
);
// 如果任何一个用户还有 socketIds说明房间不为空
return !socketChecks.includes(true);
}
// ==================== 清理 ====================
/**
* 清理课程状态(下课时调用,不清理黑名单)
* 清理:房间状态、用户状态(包含 socketIds
* 注意:用户状态通过 scan 模式匹配清理socketIds 存储在用户状态 Hash 中一起清理
* @param roomId - 房间 ID
*/
async clearClassData(roomId: string): Promise<void> {
const client = this.getClient();
const userStatePattern = `${this.KEY_PREFIX.USER_STATE}${roomId}:*`;
const roomStateKey = `${this.KEY_PREFIX.ROOM_STATE}${roomId}`;
// 使用 SCAN 遍历所有匹配的用户状态 key
let cursor: string | null = '0';
const keysToDelete: string[] = [];
// 遍历直到 cursor 回到 '0' 或者返回 null
while (cursor !== null && cursor !== '0') {
// eslint-disable-next-line no-await-in-loop
const res: [string, string[]] = await client.scan(cursor, 'MATCH', userStatePattern, 'COUNT', 200);
cursor = res[0] === '0' ? null : res[0];
const keys = res[1] ?? [];
if (keys.length > 0) {
keysToDelete.push(...keys);
}
}
// 使用 pipeline 批量删除,减少网络往返
if (keysToDelete.length > 0) {
const pipeline = client.pipeline();
for (const key of keysToDelete) {
pipeline.del(key);
}
await pipeline.exec();
}
// 清理房间状态
await client.del(roomStateKey);
}
/**
* 清理房间所有状态(包括黑名单)
* 用于房间彻底无人时使用
* @param roomId - 房间 ID
*/
async clearRoomAll(roomId: string): Promise<void> {
// 先清理课程数据
await this.clearClassData(roomId);
// 再清理黑名单
const blacklistKey = `${this.KEY_PREFIX.BLACKLIST}${roomId}`;
await this.getClient().del(blacklistKey);
}
}

View File

@ -0,0 +1,588 @@
import {
ConnectedSocket,
MessageBody,
type OnGatewayConnection,
type OnGatewayDisconnect,
type OnGatewayInit,
SubscribeMessage,
WebSocketGateway,
WebSocketServer,
WsException,
} from '@nestjs/websockets';
import { UseFilters, UseGuards } from '@nestjs/common';
import { MeetingRedisService } from './meeting-redis.service';
import { MeetingService } from '../meeting/meeting.service';
import { MeetingAuthGuard } from './meeting-auth.guard';
import { WsExceptionFilter } from '@/common/filters/ws-exception.filter';
import { LoggerService } from '@/plugins/logger/logger.service';
import type {
ClientToServerMessageType,
ErrorMessage,
JoinRoomData,
KickUserData,
MeetingNamespace,
MeetingRemoteSocket,
MeetingSocket,
MuteUserData,
SucceedMessage,
} from './types';
@WebSocketGateway({
// namespace 对应前端连接的 /meeting
namespace: 'meeting',
// 跨域(当前项目允许任意 origin
cors: { origin: '*' },
})
// 对所有 @SubscribeMessage 事件启用鉴权守卫
@UseGuards(MeetingAuthGuard)
// WebSocket 异常统一格式化输出
@UseFilters(WsExceptionFilter)
export class MeetingWebSocketGateway implements OnGatewayConnection, OnGatewayDisconnect, OnGatewayInit {
@WebSocketServer()
// 注入 Socket.IO namespace带泛型确保 server.in().fetchSockets() 等返回强类型)
public server: MeetingNamespace | null = null;
public constructor(
// 房间/用户状态Redis 持久化
private readonly redisService: MeetingRedisService,
// shortUid/token 等业务能力
private readonly meetingService: MeetingService,
// WebSocket 鉴权能力(用于 afterInit 中的 middleware
private readonly wsAuthGuard: MeetingAuthGuard,
// 统一日志服务
private readonly logger: LoggerService
) {}
private log(message: string): void {
// 统一打到 MeetingWebSocket tag便于检索
this.logger.info({}, message, 'MeetingWebSocket');
}
/**
* 查找目标用户的所有 Socket本节点 + 跨节点)
* @param roomId - 服务端房间 IDSocket.IO 房间名)
* @param targetUid - 目标用户声网 shortUid
* @returns 目标用户的所有 Socket 数组
*/
private async findTargetSockets(roomId: string, targetUid: number): Promise<MeetingRemoteSocket[]> {
// namespace@WebSocketServer 注入)可能在启动早期为空,保护性返回
const ns = this.server;
if (!ns) {
return [];
}
// fetchSockets() 会返回本节点 Socket 或跨节点 RemoteSocket包含 socket.data
const sockets = await ns.in(roomId).fetchSockets();
// 通过 socket.data.user.shortUid 精准定位目标用户(同一用户可能多端在线)
return sockets.filter((s) => s.data.user?.shortUid === targetUid);
}
/**
* 清理房间状态(如果已空)
* 使用 Socket.IO 的 fetchSockets() 检查房间内是否有连接
* 注意fetchSockets() 会返回本节点和跨节点的 socket
* @param roomId - 服务端房间 IDSocket.IO 房间名)
*/
private async cleanupRoomIfEmpty(roomId: string): Promise<void> {
const ns = this.server;
if (!ns) {
return;
}
// 使用 fetchSockets() 获取房间内的所有 socket包括跨节点
const sockets = await ns.in(roomId).fetchSockets();
// 如果房间内没有 socket 连接,则清理 Redis 数据
if (sockets.length === 0) {
await this.redisService.clearRoomAll(roomId);
this.log(`[会议] 房间已空,已清理 Redis 状态roomId=${roomId}`);
}
}
/**
* 初始化后,在 namespace 层添加连接鉴权 middleware
* @param server - 注入的 Socket.IO namespace带泛型确保 server.in().fetchSockets() 等返回强类型)
*/
public afterInit(server: MeetingNamespace): void {
// middleware在 namespace 层做连接鉴权(用于 fetchSockets 时也能拿到 data.user
server.use(async (socket, next) => {
try {
// 解析握手信息并校验 Token返回 user 信息
const user = await this.wsAuthGuard.validateToken(socket);
// 写入 socket.data会被 fetchSockets() 带回)
socket.data.user = user;
// 放行连接
next();
} catch (e) {
console.error('鉴权失败====', e);
// 交给 socket.io 处理为 connect_error前端可据此处理 Unauthorized
next(new WsException('Unauthorized'));
}
});
}
public async handleConnection(socket: MeetingSocket): Promise<void> {
// 读取鉴权 middleware 写入的 user
const user = socket.data.user;
if (!user) {
// 理论上不应该发生(有 guard + middleware但仍兜底断开
this.logger.warn({}, `[MeetingWebSocket] WebSocket 认证失败,拒绝连接:${socket.id}`, 'MeetingWebSocket');
socket.disconnect(true);
return;
}
// 连接建立日志(便于排查 shortUid/role 等)
this.log(`新 WebSocket 连接建立:${socket.id} (userId=${user.userId}, shortUid=${user.shortUid}, role=${user.role})`);
}
public async handleDisconnect(socket: MeetingSocket): Promise<void> {
// Socket.IO 会自动将 socket 从房间移除,这里仅做日志
this.log(`WebSocket 连接断开:${socket.id}`);
const roomId = socket.data.roomId;
const shortUid = socket.data.user?.shortUid ? Number(socket.data.user.shortUid) : 0;
const isHost = socket.data.user?.role !== 0;
// 移除 socketId 映射(避免下次加入时误判为设备冲突)
if (roomId && Number.isFinite(shortUid) && shortUid > 0) {
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
}
// 如果是老师(创建者)主动断开连接,发送下课消息给所有人
if (roomId && isHost) {
this.log(`老师创建者断开连接发送下课消息roomId=${roomId}`);
// 设置课堂状态为已结束
await this.redisService.setClassStatus(roomId, 'finished');
// 通知所有人下课
this.server?.to(roomId).emit('message', { type: 'sev_class_ended', data: { fromRoomId: roomId } });
// 清理 Redis 数据
await this.redisService.clearRoomAll(roomId);
}
if (roomId) {
this.cleanupRoomIfEmpty(roomId);
}
}
@SubscribeMessage<ClientToServerMessageType>('client_join_room')
public async handleJoinRoom(@MessageBody() data: JoinRoomData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 基础参数校验
if (!data?.courseRoomId) {
this.logger.warn({}, '[会议] join_room 数据不完整', 'MeetingWebSocket');
// 下发标准错误包(前端统一处理)
socket.emit('message', { type: 'error', data: { reason: '数据不完整' } } satisfies ErrorMessage);
return;
}
// 统一为 string避免 Redis key 不一致
const courseRoomId = String(data.courseRoomId);
// 直接使用 courseRoomId 作为 roomId
const roomId = courseRoomId;
// 从鉴权后的 user 中获取 shortUid鉴权通过就有短 ID
const shortUid = socket.data.user?.shortUid;
const userName = socket.data.user?.userName || data.userName || '用户';
if (!shortUid) {
this.logger.warn({}, '[会议] 用户未认证,无 shortUid', 'MeetingWebSocket');
socket.emit('message', { type: 'error', data: { reason: '认证失败' } } satisfies ErrorMessage);
return;
}
// 黑名单校验(用短 UID 判断;被踢后不允许再次进入)
const isKicked = await this.redisService.isUserKicked(roomId, shortUid);
if (isKicked) {
this.logger.warn({}, `[会议] 用户 ${shortUid} 已被踢出房间 ${roomId},拒绝重新加入`, 'MeetingWebSocket');
socket.emit('message', {
type: 'sev_kick_user',
data: { fromRoomId: roomId, reason: '您已被创建者移出会议,无法重新加入' },
} satisfies SucceedMessage);
return;
}
// 检查该用户是否已在其他设备加入了房间
const existingSocketIds = await this.redisService.getSocketIds(roomId, shortUid);
if (existingSocketIds.length > 0) {
// 过滤掉当前 socket 自己的连接(同一设备刷新等情况)
const otherDeviceSocketIds = existingSocketIds.filter((id) => id !== socket.id);
if (otherDeviceSocketIds.length > 0) {
// 验证旧连接是否真的存在(可能用户已断开但 Redis 未清理)
// 使用 Promise.all 并行验证所有旧连接
const validationResults = await Promise.all(
otherDeviceSocketIds.map(async (oldSocketId) => {
const sockets = await this.server?.in(oldSocketId).fetchSockets();
return { oldSocketId, sockets, isValid: sockets ? sockets.length > 0 : false };
})
);
// 处理有效的旧连接:发送通知并断开
const validSocketIds: string[] = [];
for (const result of validationResults) {
if (result.isValid && result.sockets) {
this.server?.to(result.oldSocketId).emit('message', {
type: 'sev_device_conflict',
data: { fromRoomId: roomId, reason: '您已在其他设备进入课程', targetUid: shortUid },
} satisfies SucceedMessage);
result.sockets.forEach((s) => s.disconnect(true));
validSocketIds.push(result.oldSocketId);
}
}
// 清理所有旧的 socketId无论连接是否还存在
await Promise.all(otherDeviceSocketIds.map((oldSocketId) => this.redisService.removeSocket(roomId, shortUid, oldSocketId).catch(() => {})));
if (validSocketIds.length > 0) {
this.log(`用户 ${userName}(短 UID:${shortUid}) 在其他设备加入,已踢出旧连接,房间 ${roomId}`);
}
} else {
// 当前 socket 已在 Redis 中存在(同一设备刷新等情况),清理旧的并允许加入
this.log(`用户 ${userName}(短 UID:${shortUid}) 同一设备重新加入,房间 ${roomId}`);
}
}
// 恢复用户状态(如果之前被禁麦/禁视频)
const userState = await this.redisService.getUserState(roomId, shortUid);
try {
// 加入 Socket.IO 房间(用于广播/按房间查找)
socket.join(roomId);
socket.data.roomId = roomId;
socket.data.courseRoomId = courseRoomId;
// 注册 socketId 到 Redis用于踢人、禁麦等控制功能
await this.redisService.addSocket(roomId, shortUid, socket.id);
// 添加用户到房间用户列表
await this.redisService.addUserToRoom(roomId, shortUid);
// 获取房间状态
const roomState = await this.redisService.getRoomState(roomId);
const existedTeacherUid = Number.isFinite(roomState.teacherUid) ? Number(roomState.teacherUid) : 0;
let teacherUid = existedTeacherUid > 0 ? existedTeacherUid : 0;
if (teacherUid <= 0) {
const homeworkId = Number(data?.homeworkId);
const resolvedTeacherUid = Number.isFinite(homeworkId) && homeworkId > 0 ? await this.meetingService.getTeacherShortUidByHomeworkId(homeworkId) : null;
teacherUid = resolvedTeacherUid && resolvedTeacherUid > 0 ? resolvedTeacherUid : shortUid;
await this.redisService.setTeacherUid(roomId, teacherUid).catch(() => {});
}
const existedSpeakerUid = Number.isFinite(roomState.speakerUid) ? Number(roomState.speakerUid) : 0;
const speakerUid = existedSpeakerUid > 0 ? existedSpeakerUid : teacherUid;
if (existedSpeakerUid <= 0) {
await this.redisService.setSpeaker(roomId, speakerUid).catch(() => {});
}
// 下发 join 成功包:包含 roomId/shortUid/tokenInfo/恢复状态/房间状态
socket.emit('message', {
type: 'sev_join_room',
data: {
roomId,
shortUid,
tokenInfo: this.meetingService.generateToken(roomId, shortUid)!,
isAudioMuted: userState.isAudioMuted,
isVideoMuted: userState.isVideoMuted,
classStatus: roomState.classStatus,
screenShareUid: roomState.screenShareUid,
speakerUid,
teacherUid,
},
});
// 成功日志
this.log(`用户 ${userName}(短 UID:${shortUid}) 加入房间 ${roomId}`);
} catch (error) {
// 记录错误后抛出,交由 WsExceptionFilter 统一处理
this.logger.error({}, `[会议] 为用户 ${shortUid} 分配短 UID 失败`, 'MeetingWebSocket');
throw error;
}
}
@SubscribeMessage<ClientToServerMessageType>('client_leave_room')
public async handleLeaveRoom(@MessageBody() data: { courseRoomId?: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// Socket.IO 会自动处理离房逻辑,这里仅记录
this.log(`用户离开房间:${socket.id}`);
const roomId: string | undefined = socket.data.roomId;
const shortUid = socket.data.user?.shortUid ? Number(socket.data.user.shortUid) : 0;
if (roomId && Number.isFinite(shortUid) && shortUid > 0) {
// 移除 socketId 映射
await this.redisService.removeSocket(roomId, shortUid, socket.id).catch(() => {});
// 从房间用户列表移除
await this.redisService.removeUserFromRoom(roomId, shortUid).catch(() => {});
// 清理用户状态
await this.redisService.clearUserState(roomId, shortUid).catch(() => {});
}
try {
roomId && socket.leave(roomId);
} catch {}
socket.data.roomId = undefined;
socket.data.courseRoomId = undefined;
if (roomId) {
await this.cleanupRoomIfEmpty(roomId);
}
}
@SubscribeMessage<ClientToServerMessageType>('client_kick_user')
public async handleKickUser(@MessageBody() data: KickUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 基础参数校验
if (!data?.targetUid || !data.roomId) {
this.logger.warn({}, '[会议] kick_user 数据不完整', 'MeetingWebSocket');
return;
}
// 权限校验:仅创建者/管理员可踢人
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试踢人userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
// 根据 Redis 中的 socketId 列表定位连接
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
if (socketIds.length === 0) {
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
return;
}
// 获取用户名用于黑名单
const targetSockets = await this.findTargetSockets(data.roomId, data.targetUid);
const targetUserName = targetSockets[0]?.data?.user?.userName || `用户-${data.targetUid}`;
// 对该用户的所有连接发送踢出通知并断开连接
for (const id of socketIds) {
this.server?.to(id).emit('message', { type: 'sev_kick_user', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
this.server?.in(id).disconnectSockets(true);
}
// 写入黑名单(用短 UID禁止重连
await this.redisService.addToBlacklist(data.roomId, data.targetUid, targetUserName);
// 成功日志
this.log(`用户 shortUid=${data.targetUid} 已被踢出房间 ${data.roomId}`);
}
@SubscribeMessage<ClientToServerMessageType>('client_mute_audio')
public async handleMuteAudio(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 基础参数校验
if (!data?.targetUid || !data.roomId) {
this.logger.warn({}, '[会议] mute_audio 数据不完整', 'MeetingWebSocket');
return;
}
// 权限校验:仅创建者/管理员可禁麦
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试禁麦userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
if (socketIds.length === 0) {
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
return;
}
// 下发禁麦通知(由前端执行 Agora unpublish/setEnabled
for (const id of socketIds) {
this.server?.to(id).emit('message', { type: 'sev_mute_audio', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
}
// 写入 Redis下次加入/重连时恢复禁麦状态
await this.redisService.setUserState(data.roomId, data.targetUid, { isAudioMuted: true });
this.log(`用户 ${data.targetUid} 已被禁麦,房间 ${data.roomId}`);
}
@SubscribeMessage<ClientToServerMessageType>('client_unmute_audio')
public async handleUnmuteAudio(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 基础参数校验
if (!data?.targetUid || !data.roomId) {
this.logger.warn({}, '[会议] unmute_audio 数据不完整', 'MeetingWebSocket');
return;
}
// 权限校验:仅创建者/管理员可解除禁麦
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试解除禁麦userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
if (socketIds.length === 0) {
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
return;
}
// 下发解除禁麦通知
for (const id of socketIds) {
this.server?.to(id).emit('message', { type: 'sev_unmute_audio', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
}
// 写入 Redis下次加入/重连时恢复状态
await this.redisService.setUserState(data.roomId, data.targetUid, { isAudioMuted: false });
this.log(`用户 ${data.targetUid} 已被解除禁麦,房间 ${data.roomId}`);
}
@SubscribeMessage<ClientToServerMessageType>('client_mute_video')
public async handleMuteVideo(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 基础参数校验
if (!data?.targetUid || !data.roomId) {
this.logger.warn({}, '[会议] mute_video 数据不完整', 'MeetingWebSocket');
return;
}
// 权限校验:仅创建者/管理员可禁视频
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试禁视频userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
if (socketIds.length === 0) {
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
return;
}
// 下发禁视频通知(由前端执行 Agora unpublish/setEnabled
for (const id of socketIds) {
this.server?.to(id).emit('message', { type: 'sev_mute_video', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
}
// 写入 Redis下次加入/重连时恢复禁视频状态
await this.redisService.setUserState(data.roomId, data.targetUid, { isVideoMuted: true });
this.log(`用户 ${data.targetUid} 已被禁视频,房间 ${data.roomId}`);
}
@SubscribeMessage<ClientToServerMessageType>('client_unmute_video')
public async handleUnmuteVideo(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 基础参数校验
if (!data?.targetUid || !data.roomId) {
this.logger.warn({}, '[会议] unmute_video 数据不完整', 'MeetingWebSocket');
return;
}
// 权限校验:仅创建者/管理员可解除禁视频
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试解除禁视频userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
const socketIds = await this.redisService.getSocketIds(data.roomId, data.targetUid);
if (socketIds.length === 0) {
this.logger.warn({}, `[会议] 未找到目标用户 uid: ${data.targetUid}`, 'MeetingWebSocket');
socket.emit('message', { type: 'error', data: { reason: '目标用户已离开房间或不存在' } } satisfies ErrorMessage);
return;
}
// 下发解除禁视频通知
for (const id of socketIds) {
this.server?.to(id).emit('message', { type: 'sev_unmute_video', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
}
// 写入 Redis下次加入/重连时恢复状态
await this.redisService.setUserState(data.roomId, data.targetUid, { isVideoMuted: false });
this.log(`用户 ${data.targetUid} 已被解除禁视频,房间 ${data.roomId}`);
}
/**
* 处理设置全员主屏消息(仅创建者)
*/
@SubscribeMessage<ClientToServerMessageType>('client_set_main_video')
public async handleSetMainVideo(@MessageBody() data: MuteUserData, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
// 参数校验
if (!data?.targetUid || !data.roomId) {
this.logger.warn({}, '[会议] set_main_video 数据不完整', 'MeetingWebSocket');
return;
}
// 权限校验
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试设置主屏userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
// 广播给整个房间
const ns = this.server;
if (!ns) {
return;
}
ns.to(data.roomId).emit('message', { type: 'sev_set_main_video', data: { fromRoomId: data.roomId, targetUid: data.targetUid } });
await this.redisService.setSpeaker(data.roomId, Number(data.targetUid)).catch(() => {});
this.log(`已设置全员主屏targetUid=${data.targetUid} 房间 ${data.roomId}`);
}
/**
* 老师上课:允许推流
*/
@SubscribeMessage<ClientToServerMessageType>('client_start_class')
public async handleStartClass(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
if (!data?.roomId) {
return;
}
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
return;
}
await this.redisService.setClassStatus(data.roomId, 'in_class');
// 通知所有人可以开始推流了
this.server?.to(data.roomId).emit('message', { type: 'sev_class_started', data: { fromRoomId: data.roomId } });
this.log(`房间 ${data.roomId} 上课开始`);
}
/**
* 老师下课:停止推流并清理本节课数据(保持 socket 连接)
*/
@SubscribeMessage<ClientToServerMessageType>('client_end_class')
public async handleEndClass(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
if (!data?.roomId) {
return;
}
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
return;
}
await this.redisService.setClassStatus(data.roomId, 'finished');
// 通知所有人结束推流(由前端停止/离开 RTC
this.server?.to(data.roomId).emit('message', { type: 'sev_class_ended', data: { fromRoomId: data.roomId } });
// 清理本节课全部 Redis 数据(不清理黑名单)
await this.redisService.clearClassData(data.roomId);
this.log(`房间 ${data.roomId} 下课并清理 Redis 数据`);
}
/**
* 处理开始投屏消息(仅创建者)
*/
@SubscribeMessage<ClientToServerMessageType>('client_start_screen_share')
public async handleStartScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
if (!data?.roomId) {
return;
}
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试开始投屏userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
const targetUid = socket.data.user?.shortUid ?? 0;
// 保存投屏状态到 Redis
await this.redisService.setScreenSharing(data.roomId, targetUid);
// 广播给整个房间:有人开始投屏
this.server?.to(data.roomId).emit('message', { type: 'sev_start_screen_share', data: { fromRoomId: data.roomId, targetUid } });
this.log(`创建者开始投屏roomId=${data.roomId}, shortUid=${targetUid}`);
}
/**
* 处理停止投屏消息(仅创建者)
*/
@SubscribeMessage<ClientToServerMessageType>('client_stop_screen_share')
public async handleStopScreenShare(@MessageBody() data: { roomId: string }, @ConnectedSocket() socket: MeetingSocket): Promise<void> {
if (!data?.roomId) {
return;
}
const isHost = socket.data.user?.role !== 0;
if (!isHost) {
this.logger.warn({}, `[会议] 非创建者尝试停止投屏userId=${socket.data.user?.userId}`, 'MeetingWebSocket');
return;
}
const targetUid = socket.data.user?.shortUid ?? 0;
// 清除投屏状态
await this.redisService.setScreenSharing(data.roomId, null);
// 广播给整个房间:有人停止投屏
this.server?.to(data.roomId).emit('message', { type: 'sev_stop_screen_share', data: { fromRoomId: data.roomId, targetUid } });
this.log(`创建者停止投屏roomId=${data.roomId}, shortUid=${targetUid}`);
}
}

View File

@ -0,0 +1,333 @@
/**
* meeting WebSocket 模块的“类型总出口”
*
* 目标:
* - 把 socket.io Server/Namespace/Socket 的泛型参数一次性定义清楚
* - 让 meeting.websocket.ts 不再依赖 any/as any 来访问 socket.data.user 或事件名
* - 让 fetchSockets() 返回的 RemoteSocket 拥有正确的 data 类型
*/
import type { Namespace, RemoteSocket, Server, Socket } from 'socket.io';
export interface MeetingWsUser {
/** 用户长 ID来自 Token 中的 nameidentifier claim */
userId: string;
/** 角色0 学生,非 0 代表创建者/管理员(和当前业务一致) */
role: number;
/** 声网短 UID用于加入 RTC 频道) */
shortUid: number;
/** 平台标识(从 Custom-Platform 解析得来) */
platform: string;
/** 用户名称(用于展示和黑名单) */
userName: string;
}
export interface MeetingSocketData {
/**
* 通过 MeetingAuthGuard 注入到 socket.data 中的用户信息
* - 该字段会被包含在 fetchSockets() 返回结果的 data 里
* - 因此这是“跨节点查找用户 / 过滤用户”的关键字段
*/
user?: MeetingWsUser;
/**
* 当前 socket 加入的业务房间 IDSocket.IO 房间名)
* - 由 handleJoinRoom 写入
* - 由 handleLeaveRoom/handleDisconnect 用于判断“房间是否已空”
*/
roomId?: string;
/**
* 课程房间 ID前端路由中的 id
* - 主要用于清理 courseRoomId -> roomId 的映射 key
*/
courseRoomId?: string;
}
export interface MeetingJoinRoomData {
/** 课程房间 ID前端路由参数 */
courseRoomId: string;
/** 用户长 ID数据库中的真实用户 ID用于分配 shortUid */
longUserId: number;
/** 展示用用户名 */
userName: string;
/** 是否创建者(可选;当前后端实际仍以 socket.data.user.role 判断权限) */
isHost?: boolean;
}
export interface MeetingKickUserData {
/** 目标用户声网 shortUid */
targetUid: number;
/** 房间 ID服务端实际 Socket.IO 房间名) */
roomId: string;
}
export interface MeetingLeaveRoomData {
/** 课程房间 ID用于业务侧记录/日志Socket.IO 会自动离开房间) */
courseRoomId: string;
}
export interface MeetingMuteUserData {
/** 目标用户声网 shortUid */
targetUid: number;
/** 房间 ID服务端实际 Socket.IO 房间名) */
roomId: string;
}
/**
* 投屏数据(上行:客户端 -> 服务端)
*/
export interface MeetingScreenShareData {
/** 房间 ID服务端实际 Socket.IO 房间名) */
roomId: string;
}
/**
* 投屏通知数据(下行:服务端 -> 客户端)
*/
export interface MeetingScreenShareNotifyData {
/** 来源房间 */
fromRoomId: string;
/** 投屏用户短 UID */
targetUid: number;
}
export interface MeetingClientToServerEvents {
/** 客户端加入会议房间join + 分配 shortUid + 返回 tokenInfo + 恢复禁用状态) */
client_join_room: (data: MeetingJoinRoomData) => void;
/** 客户端离开会议房间业务事件Socket.IO 会自动处理房间成员移除) */
client_leave_room: (data: MeetingLeaveRoomData) => void;
/** 创建者踢人 */
client_kick_user: (data: MeetingKickUserData) => void;
/** 创建者禁麦 */
client_mute_audio: (data: MeetingMuteUserData) => void;
/** 创建者解除禁麦 */
client_unmute_audio: (data: MeetingMuteUserData) => void;
/** 创建者禁视频 */
client_mute_video: (data: MeetingMuteUserData) => void;
/** 创建者解除禁视频 */
client_unmute_video: (data: MeetingMuteUserData) => void;
/** 创建者设置某个用户为全员主屏 */
client_set_main_video: (data: MeetingMuteUserData) => void;
/** 创建者开始投屏 */
client_start_screen_share: (data: MeetingScreenShareData) => void;
/** 创建者停止投屏 */
client_stop_screen_share: (data: MeetingScreenShareData) => void;
}
export interface MeetingWsMessagePacket<T extends string = string, D = unknown> {
/** 消息类型sev_* / error 等) */
type: T;
/** 业务数据 */
data: D;
}
/**
* Token 响应数据 DTO
*/
export interface TokenResponseDto {
/** 声网appid */
appid: string;
/** 声网token */
rtcToken: string;
/** 过期时间 */
expiresAt: number;
}
export interface MeetingJoinRoomSuccessData {
/** 服务端实际房间 ID用于后续控制指令中的 roomId */
roomId: string;
/** 当前用户声网短 UID */
shortUid: number;
/** 声网 token 信息(当前项目中由 meetingService.generateToken 生成) */
tokenInfo: TokenResponseDto;
/** 恢复状态:是否被禁麦 */
isAudioMuted: boolean;
/** 恢复状态:是否被禁视频 */
isVideoMuted: boolean;
/** 课堂状态not_started未开始/ in_class上课中/ finished已下课 */
classStatus: 'finished' | 'in_class' | 'not_started';
/** 投屏状态:正在投屏的用户短 UIDundefined 表示无人投屏 */
screenShareUid?: number;
/** 上台的短uid默认为老师的uid */
speakerUid: number;
/** 老师的短uid */
teacherUid: number;
}
export interface MeetingKickedData {
/** 来源房间(可选) */
fromRoomId?: string;
/** 提示原因(可选) */
reason?: string;
/** 被踢出的用户短 UID用于前端同步状态 */
targetUid?: number;
}
export interface MeetingControlData {
/** 来源房间(用于前端提示/一致性校验) */
fromRoomId: string;
/** 被控制的用户短 UID用于前端同步状态 */
targetUid?: number;
}
export interface MeetingSetMainVideoData {
/** 来源房间 */
fromRoomId: string;
/** 被设置为主屏的用户短 UID */
targetUid: number;
}
export interface MeetingClassStateData {
/** 来源房间 */
fromRoomId: string;
}
export type MeetingDownlinkPacket =
/** 统一错误包WsExceptionFilter/业务侧主动 emit 的错误) */
| MeetingWsMessagePacket<'error', { reason: string; code?: number }>
/** 下课通知 */
| MeetingWsMessagePacket<'sev_class_ended', MeetingClassStateData>
/** 上课通知 */
| MeetingWsMessagePacket<'sev_class_started', MeetingClassStateData>
/** 设备冲突通知(多设备登录被踢出) */
| MeetingWsMessagePacket<'sev_device_conflict', MeetingKickedData>
/** 加入房间成功响应 */
| MeetingWsMessagePacket<'sev_join_room', MeetingJoinRoomSuccessData>
/** 被踢出通知 */
| MeetingWsMessagePacket<'sev_kick_user', MeetingKickedData>
/** 禁麦通知 */
| MeetingWsMessagePacket<'sev_mute_audio', MeetingControlData>
/** 禁视频通知 */
| MeetingWsMessagePacket<'sev_mute_video', MeetingControlData>
/** 设置主屏通知 */
| MeetingWsMessagePacket<'sev_set_main_video', MeetingSetMainVideoData>
/** 开始投屏通知 */
| MeetingWsMessagePacket<'sev_start_screen_share', MeetingScreenShareNotifyData>
/** 停止投屏通知 */
| MeetingWsMessagePacket<'sev_stop_screen_share', MeetingScreenShareNotifyData>
/** 解除禁麦通知 */
| MeetingWsMessagePacket<'sev_unmute_audio', MeetingControlData>
/** 解除禁视频通知 */
| MeetingWsMessagePacket<'sev_unmute_video', MeetingControlData>;
export interface MeetingServerToClientEvents {
/** 约定:服务端统一通过 message 事件下发业务包packet.type 决定语义) */
message: (packet: MeetingDownlinkPacket) => void;
}
/**
* 客户端 -> 服务端(上发消息)
*/
export type ClientToServerMessageType =
| 'client_end_class' // 结束课程(仅创建者)
| 'client_join_room' // 加入会议房间
| 'client_kick_user' // 踢出指定用户(仅创建者)
| 'client_leave_room' // 离开会议房间
| 'client_mute_audio' // 禁麦指定用户(仅创建者)
| 'client_mute_video' // 禁视频指定用户(仅创建者)
| 'client_set_main_video' // 设置主屏用户
| 'client_start_class' // 开始课程(仅创建者)
| 'client_start_screen_share' // 开始投屏
| 'client_stop_screen_share' // 停止投屏
| 'client_unmute_audio' // 解除禁麦(仅创建者)
| 'client_unmute_video'; // 解除禁视频(仅创建者)
/**
* 服务端 -> 客户端(下发消息)
*/
export type ServerToClientMessageType =
| 'sev_class_ended' // 下课通知
| 'sev_class_started' // 上课通知
| 'sev_device_conflict' // 设备冲突通知(多设备登录被踢出)
| 'sev_join_room' // 加入房间成功响应
| 'sev_kick_user' // 被踢出通知(创建者踢人)
| 'sev_mute_audio' // 禁麦通知(转发给目标用户)
| 'sev_mute_video' // 禁视频通知(转发给目标用户)
| 'sev_set_main_video' // 设置主屏通知
| 'sev_start_screen_share' // 开始投屏通知(广播给所有人)
| 'sev_stop_screen_share' // 停止投屏通知(广播给所有人)
| 'sev_unmute_audio' // 解除禁麦通知(转发给目标用户)
| 'sev_unmute_video'; // 解除禁视频通知(转发给目标用户)
export interface ErrorMessage {
// 固定为 error便于前端统一处理
type: 'error';
// 错误信息载体
data: { reason: string; code?: number };
}
export interface SucceedMessage {
// 下发消息类型sev_*
type: ServerToClientMessageType;
// 最小化返回字段:来源房间 + 可选原因 + 可选目标用户
data: {
reason?: string;
fromRoomId: string;
targetUid?: number;
};
}
/**
* 加入房间数据
*/
export interface JoinRoomData {
// 课程房间 ID前端传入
courseRoomId: string;
/** 作业 ID */
homeworkId: number;
// 用户名(仅用于日志/展示)
userName: string;
}
/**
* 踢人数据
*/
export interface KickUserData {
// 目标用户声网 shortUid
targetUid: number;
// 房间 IDSocket.IO 房间名)
roomId: string;
}
export interface MuteUserData {
// 目标用户声网 shortUid
targetUid: number;
// 房间 IDSocket.IO 房间名)
roomId: string;
}
/**
* 用户状态(用于恢复被禁麦/禁视频状态,以及管理多设备连接)
*/
export interface UserPermissionState {
/** 是否被禁麦 */
isAudioMuted: boolean;
/** 是否被禁视频 */
isVideoMuted: boolean;
/** 用户的所有 Socket 连接 ID支持多设备同时在线 */
socketIds: string[];
}
/**
* 黑名单用户信息
*/
export interface BlacklistUser {
/** 短 UID */
shortUid: number;
/** 用户名称 */
userName: string;
}
/**
* 强类型化的 Server / Namespace / Socket / RemoteSocket
*
* 对应 socket.io v4 的泛型定义:
* Server<ListenEvents, EmitEvents, ServerSideEvents, SocketData>
* - ListenEvents客户端 -> 服务端socket.on / @SubscribeMessage
* - EmitEvents服务端 -> 客户端socket.emit
* - ServerSideEvents服务器之间 serverSideEmit可选
* - SocketDatasocket.data 中可持久化/可被 fetchSockets 取回的数据
*/
export type MeetingServer = Server<MeetingClientToServerEvents, MeetingServerToClientEvents, any, MeetingSocketData>;
export type MeetingNamespace = Namespace<MeetingClientToServerEvents, MeetingServerToClientEvents, any, MeetingSocketData>;
export type MeetingSocket = Socket<MeetingClientToServerEvents, MeetingServerToClientEvents, any, MeetingSocketData>;
export type MeetingRemoteSocket = RemoteSocket<MeetingServerToClientEvents, MeetingSocketData>;

View File

@ -0,0 +1,19 @@
/**
* WebSocket 模块
* 包含 MeetingWebSocketGateway、MeetingAuthGuard、WsExceptionFilter
*/
import { Module, forwardRef } from '@nestjs/common';
import { MeetingWebSocketGateway } from './meeting.websocket';
import { MeetingAuthGuard } from './meeting-auth.guard';
import { WsExceptionFilter } from '@/common/filters/ws-exception.filter';
import { LoggerService } from '@/plugins/logger/logger.service';
import { MeetingModule } from '../meeting/meeting.module';
import { MeetingRedisService } from './meeting-redis.service';
@Module({
providers: [MeetingWebSocketGateway, MeetingAuthGuard, WsExceptionFilter, LoggerService, MeetingRedisService],
imports: [forwardRef(() => MeetingModule)],
exports: [MeetingWebSocketGateway, MeetingRedisService],
})
export class WebsocketModule {}

View File

@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { LoggerService } from './logger.service';
/**
* 日志模块.
*/
@Global()
@Module({
providers: [LoggerService],
exports: [LoggerService],
})
export class LoggerModule {}

View File

@ -0,0 +1,159 @@
import { ConsoleLogger, Injectable, Scope } from '@nestjs/common';
import { join } from 'path';
import pino, { type Logger } from 'pino';
import pinoPretty from 'pino-pretty';
import dayjs from 'dayjs';
import { execSync } from 'child_process';
/**
* 检测终端是否支持 UTF-8 编码
* @returns 是否支持 UTF-8
*/
function isTerminalUTF8(): boolean {
// Windows 系统检查代码页
if (process.platform === 'win32') {
try {
// 检查是否设置了 NODE_SKIP_UTF8_CHECK 环境变量
if (process.env.NODE_SKIP_UTF8_CHECK) {
return false;
}
// 尝试执行 chcp 命令检查代码页
const codePage = execSync('chcp', { encoding: 'utf8' }).toString();
// 65001 是 UTF-8 代码页
return codePage.includes('65001');
} catch {
// 默认 Windows 终端使用 GBK 编码
return false;
}
}
// macOS 和 Linux 通常默认使用 UTF-8
return true;
}
/**
* 设置终端为 UTF-8 编码(仅 Windows
*/
function setTerminalToUTF8(): void {
if (process.platform === 'win32' && !isTerminalUTF8()) {
try {
// 设置 stdout 为 UTF-8
if (process.stdout && typeof process.stdout.setEncoding === 'function') {
process.stdout.setEncoding('utf-8');
}
} catch {
// 忽略错误
}
}
}
/**
* 日志服务.
*/
@Injectable({ scope: Scope.TRANSIENT })
export class LoggerService extends ConsoleLogger {
/** 日志实例 */
public logger: Logger | undefined = undefined;
/** 上下文 */
public override context = '';
constructor(context?: string) {
super(context || '');
// 设置终端编码
setTerminalToUTF8();
if (process.env.NODE_ENV === 'development') {
const prettyStream = pinoPretty({
colorize: true,
colorizeObjects: true,
singleLine: true,
translateTime: 'SYS:yyyy-mm-dd HH:MM:ss.l',
// 根据终端编码设置输出
sync: true,
customPrettifiers: {
/** 自定义 err 的显示 */
err: (err: unknown) => {
return `\x1b[31m${JSON.stringify(err, null, 2)}\x1b[0m`;
},
/** 自定义 err 的显示 */
error: (err: unknown) => {
return `\x1b[31m${JSON.stringify(err, null, 2)}\x1b[0m`;
},
},
});
this.logger = pino(prettyStream);
} else {
this.logger = pino({
// level: 'warn',
/** 处理时间字段 */
timestamp: () => `,"time":"${dayjs().format('YYYY-MM-DD HH:mm:ss.SSS')}"`,
transport: {
target: 'pino-roll',
options: {
file: join('logs', 'log'), // 日志文件的绝对或相对路径
size: '40m', // 日志文件的最大大小
dateFormat: 'yyyy-MM-dd',
frequency: 'daily', // 周期
mkdir: true,
extension: `.log`,
},
},
});
}
}
/**
* 设置日志上下文.
*/
override setContext(context: string) {
this.context = context;
}
/**
* Info级别日志.
*/
info(obj: any, msg?: string, ...args: any[]) {
this.logger!.info(obj, msg, ...args, this.context);
}
/**
* Error级别日志.
*/
override error(obj: any, msg?: string, ...args: any[]) {
this.logger!.error(obj, msg, ...args, this.context);
}
/**
* Warn级别日志.
*/
override warn(obj: any, msg?: string, ...args: any[]) {
this.logger!.warn(obj, msg, ...args, this.context);
}
/**
* Debug级别日志.
*/
override debug(obj: any, msg?: string, ...args: any[]) {
this.logger!.debug(obj, msg, ...args, this.context);
}
/**
* Trace级别日志.
*/
trace(obj: any, msg?: string, ...args: any[]) {
this.logger!.trace(obj, msg, ...args, this.context);
}
/**
* Fatal级别日志
* 由于 'fatal' 级别的消息旨在在退出进程之前记录,因此 fatal 方法将始终同步刷新目标。因此,重要的是不要滥用 fatal因为如果将其用于进程崩溃或退出之前写入最终日志消息之外的任何其他目的则会导致性能开销。.
*/
override fatal(obj: any, msg?: string, ...args: any[]) {
this.logger!.fatal(obj, msg, ...args, this.context);
}
}

View File

@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import config from '../../config/mikro-orm.config';
@Global()
@Module({
imports: [MikroOrmModule.forRoot(config)],
})
export class MikroOrmConfigModule {}

View File

@ -0,0 +1,159 @@
/**
* Nacos 配置模块
* 从 Nacos 配置中心加载和管理配置
*/
import { Injectable } from '@nestjs/common';
import * as nacos from 'nacos';
import * as yaml from 'js-yaml';
import { ConfigService } from '@nestjs/config';
import { LoggerService } from '../logger/logger.service';
/** 声网应用配置接口 */
export interface AgoraAppConfig {
appId: string;
appCertificate: string;
}
/** 声网配置接口 */
export interface AgoraConfig {
apps: Record<string, AgoraAppConfig>;
defaultAppId: string;
}
/**
* Nacos 配置服务
* 提供配置的读取和缓存功能
*/
@Injectable()
export class NacosConfigService {
private readonly logger = new LoggerService();
private agoraConfig: AgoraConfig | null = null;
private configClient!: nacos.NacosConfigClient;
constructor(private readonly configService: ConfigService) {
this.initNacosClient();
this.loadAgoraConfig();
}
/**
* 初始化 Nacos 客户端
*/
private initNacosClient(): void {
const serverAddr = this.configService.get<string>('NACOS_SERVER_ADDR');
const username = this.configService.get<string>('NACOS_USERNAME');
const password = this.configService.get<string>('NACOS_PASSWORD');
const namespace = this.configService.get<string>('NACOS_NAMESPACE_ID');
if (!serverAddr) {
throw new Error('NACOS_SERVER_ADDR 环境变量未配置');
}
try {
// 创建 Nacos 配置客户端
this.configClient = new nacos.NacosConfigClient({
serverAddr,
username: username || undefined,
password: password || undefined,
namespace: namespace || undefined,
});
this.logger.info(`Nacos 客户端初始化成功,服务器地址:${serverAddr}`);
} catch (error) {
this.logger.error('Nacos 客户端初始化失败:', error);
throw new Error(`Nacos 客户端初始化失败:${error.message}`, { cause: error });
}
}
/**
* 从 Nacos 加载声网配置文件
*/
private async loadAgoraConfig(): Promise<void> {
try {
const dataId = this.configService.get<string>('NACOS_DATA_ID');
const group = this.configService.get<string>('NACOS_GROUP') || 'DEFAULT_GROUP';
// 从 Nacos 获取配置内容
const configContent = await this.configClient.getConfig(
this.configService.get<string>('NACOS_DATA_ID')!,
this.configService.get<string>('NACOS_GROUP') || 'DEFAULT_GROUP'
);
if (!configContent) {
throw new Error(`Nacos 中未找到配置dataId=${dataId}, group=${group}`);
}
// 解析 YAML 配置
const config = yaml.load(configContent) as AgoraConfig;
if (!config.apps || !config.defaultAppId) {
throw new Error('Nacos 配置格式错误:必须包含 apps 和 defaultAppId 字段');
}
if (!config.apps[config.defaultAppId]) {
throw new Error(`Nacos 配置错误defaultAppId '${config.defaultAppId}' 在 apps 中不存在`);
}
this.agoraConfig = config;
this.logger.info(`声网配置从 Nacos 加载成功,默认应用:${config.defaultAppId}`);
// 监听配置变化(可选,实现热更新)
try {
// 使用已解析的 dataId 和 group传入对象格式
this.configClient.subscribe({ dataId, group }, (content: string) => {
this.logger.info('检测到 Nacos 配置变更,重新加载...');
try {
const newConfig = yaml.load(content) as AgoraConfig;
this.agoraConfig = newConfig;
this.logger.info(`声网配置已更新,默认应用:${newConfig.defaultAppId}`);
} catch (error) {
this.logger.error('配置更新失败', error);
}
});
} catch {
// 配置监听失败不影响主流程,记录警告日志
this.logger.warn('Nacos 配置监听器设置失败,但配置已成功加载');
}
} catch (error) {
this.logger.error('从 Nacos 加载声网配置失败', error);
throw new Error(`从 Nacos 加载声网配置失败:${error.message}`, { cause: error });
}
}
/**
* 获取声网配置
* @returns {AgoraConfig} 声网配置对象
*/
getAgoraConfig(): AgoraConfig {
if (!this.agoraConfig) {
throw new Error('声网配置未加载,请检查 Nacos 配置是否正确且格式有效');
}
return this.agoraConfig;
}
/**
* 获取指定的声网应用配置
* @param {string} appId - 应用 ID可选默认使用 defaultAppId
* @returns {AgoraAppConfig} 声网应用配置
*/
getAgoraAppConfig(appId?: string): AgoraAppConfig {
const agoraConfig = this.getAgoraConfig();
const targetAppId = appId || agoraConfig.defaultAppId;
const appConfig = agoraConfig.apps[targetAppId];
if (!appConfig) {
const errorMsg = `未找到声网应用配置:${targetAppId}。可用的应用 ID: ${Object.keys(agoraConfig.apps).join(', ')}`;
this.logger.error(errorMsg);
throw new Error(errorMsg);
}
return appConfig;
}
/**
* 获取默认的声网应用配置
* @returns {AgoraAppConfig} 默认声网应用配置
*/
getDefaultAgoraAppConfig(): AgoraAppConfig {
return this.getAgoraAppConfig();
}
}

View File

@ -0,0 +1,14 @@
/**
* Nacos 配置模块
*/
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { NacosConfigService } from './nacos-config.service';
@Global()
@Module({
imports: [ConfigModule],
providers: [NacosConfigService],
exports: [NacosConfigService],
})
export class NacosConfigModule {}

View File

@ -0,0 +1,13 @@
// redis.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisService } from './redis.service';
/**
* Redis模块。.
*/
@Global()
@Module({
providers: [RedisService],
exports: [RedisService],
})
export class RedisModule {}

View File

@ -0,0 +1,144 @@
/**
* Redis 服务 - 统一管理多个 Redis 数据库连接
* 提供全局 Redis 客户端和会议专用 Redis 客户端
*/
import { Injectable, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import Redis from 'ioredis';
import { LoggerService } from '../logger/logger.service';
/**
* Redis 数据库枚举
*/
export enum RedisDatabase {
/** 全局业务数据库DB 1 */
GLOBAL = 1,
/** 会议业务数据库DB 0 */
MEETING = 0,
}
/**
* Redis 配置选项
*/
interface RedisClientOptions {
/** 数据库编号 */
db: RedisDatabase;
/** 是否为生产环境 */
isProduction?: boolean;
}
@Injectable()
export class RedisService implements OnModuleInit, OnModuleDestroy {
/** Redis 客户端实例 Map */
private clients = new Map<RedisDatabase, Redis>();
/** 日志服务实例 */
private readonly logger = new LoggerService();
/**
* 初始化 Redis 连接
*/
async onModuleInit() {
const isProduction = process.env.NODE_ENV === 'production';
// 创建全局业务 Redis 客户端DB 0
await this.createClient({
db: RedisDatabase.GLOBAL,
isProduction,
});
// 创建会议业务 Redis 客户端DB 1
await this.createClient({
db: RedisDatabase.MEETING,
isProduction,
});
}
/**
* 创建并初始化 Redis 客户端
*/
private async createClient(options: RedisClientOptions): Promise<void> {
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: options.db,
retryStrategy: (times: number) => {
if (times > 10) {
this.logger.error(`Redis DB ${options.db} 重连次数过多,放弃重连`, undefined, 'RedisService');
return null;
}
const delay = Math.min(times * 100, 3000);
this.logger.warn(`Redis DB ${options.db} ${delay}ms 后重连...`, 'RedisService');
return delay;
},
};
const client = new Redis(redisConfig);
// 监听事件
client.on('ready', () => {
this.logger.info(`Redis 连接成功,数据库编号为:${options.db}${options.isProduction ? ' (生产环境)' : ' (开发环境)'}`, 'RedisService');
});
client.on('error', (error: Error) => {
this.logger.error(error, `Redis DB ${options.db} 错误`, 'RedisService');
});
// 存储客户端
this.clients.set(options.db, client);
// 等待连接就绪(如果尚未就绪)
if (client.status === 'ready') {
return;
}
await new Promise<void>((resolve, reject) => {
client.once('ready', () => resolve());
client.once('error', reject);
});
}
/**
* 获取指定数据库的 Redis 客户端
* @param db - 数据库编号,默认为全局数据库
*/
getClient(db: RedisDatabase = RedisDatabase.GLOBAL): Redis {
const client = this.clients.get(db);
if (!client) {
throw new Error(`Redis 客户端不存在,数据库编号:${db}`);
}
return client;
}
/**
* 获取全局业务 Redis 客户端DB 0
*/
getGlobalClient(): Redis {
return this.getClient(RedisDatabase.GLOBAL);
}
/**
* 获取会议业务 Redis 客户端DB 1
*/
getMeetingClient(): Redis {
return this.getClient(RedisDatabase.MEETING);
}
/**
* 销毁模块时关闭所有 Redis 连接
*/
async onModuleDestroy(): Promise<void> {
const quitPromises: Array<Promise<void>> = [];
for (const [db, client] of this.clients.entries()) {
quitPromises.push(
client.quit().then(() => {
this.logger.info(`Redis DB ${db} 已关闭连接`, 'RedisService');
})
);
}
await Promise.all(quitPromises);
this.logger.info('所有 Redis 连接已关闭', 'RedisService');
}
}

View File

@ -0,0 +1,280 @@
import crypto from 'node:crypto';
import crc32 from 'crc-32';
import { UINT32 } from 'cuint';
const version = '006';
const randomInt = Math.floor(Math.random() * 0xffffffff);
const VERSION_LENGTH = 3;
const APP_ID_LENGTH = 32;
export const priviledges = {
kJoinChannel: 1,
kPublishAudioStream: 2,
kPublishVideoStream: 3,
kPublishDataStream: 4,
kRtmLogin: 1000,
};
type Messages = Record<number, number>;
interface MessageOptions {
salt: number;
ts: number;
messages: Messages;
pack?: () => Buffer;
}
interface AccessTokenContentOptions {
signature: Buffer | string;
crc_channel: number;
crc_uid: number;
crc_channel_name?: number;
m: Buffer | string;
pack?: () => Buffer;
}
interface ByteBufInterface {
buffer: Buffer;
position: number;
pack: () => Buffer;
putUint16: (v: number) => ByteBufInterface;
putUint32: (v: number) => ByteBufInterface;
putBytes: (bytes: Buffer) => ByteBufInterface;
putString: (str: string) => ByteBufInterface;
putTreeMap: (map?: Record<string, string>) => ByteBufInterface;
putTreeMapUInt32: (map?: Messages) => ByteBufInterface;
}
interface ReadByteBufInterface {
buffer: Buffer;
position: number;
getUint16: () => number;
getUint32: () => number;
getString: () => Buffer;
getTreeMapUInt32: () => Messages;
}
const encodeHMac = (key: string, message: Buffer): Buffer => {
return crypto.createHmac('sha256', key).update(message).digest();
};
const ByteBuf = (): ByteBufInterface => {
const that: ByteBufInterface = {
buffer: Buffer.alloc(1024),
position: 0,
pack() {
const out = Buffer.alloc(that.position);
that.buffer.copy(out, 0, 0, out.length);
return out;
},
putUint16(v: number) {
that.buffer.writeUInt16LE(v, that.position);
that.position += 2;
return that;
},
putUint32(v: number) {
that.buffer.writeUInt32LE(v, that.position);
that.position += 4;
return that;
},
putBytes(bytes: Buffer) {
that.putUint16(bytes.length);
bytes.copy(that.buffer, that.position);
that.position += bytes.length;
return that;
},
putString(str: string) {
return that.putBytes(Buffer.from(str));
},
putTreeMap(map?: Record<string, string>) {
if (!map) {
that.putUint16(0);
return that;
}
that.putUint16(Object.keys(map).length);
for (const key in map) {
that.putUint16(parseInt(key, 10));
that.putString(map[key]);
}
return that;
},
putTreeMapUInt32(map?: Messages) {
if (!map) {
that.putUint16(0);
return that;
}
that.putUint16(Object.keys(map).length);
for (const key in map) {
that.putUint16(parseInt(key, 10));
that.putUint32(map[key]);
}
return that;
},
};
that.buffer.fill(0);
return that;
};
const ReadByteBuf = (bytes: Buffer): ReadByteBufInterface => {
const that: ReadByteBufInterface = {
buffer: bytes,
position: 0,
getUint16() {
const ret = that.buffer.readUInt16LE(that.position);
that.position += 2;
return ret;
},
getUint32() {
const ret = that.buffer.readUInt32LE(that.position);
that.position += 4;
return ret;
},
getString() {
const len = that.getUint16();
const out = Buffer.alloc(len);
that.buffer.copy(out, 0, that.position, that.position + len);
that.position += len;
return out;
},
getTreeMapUInt32() {
const map: Messages = {};
const len = that.getUint16();
for (let i = 0; i < len; i++) {
const key = that.getUint16();
const value = that.getUint32();
map[key] = value;
}
return map;
},
};
return that;
};
const AccessTokenContent = (options: AccessTokenContentOptions): AccessTokenContentOptions => {
options.pack = () => {
const out = ByteBuf();
return out
.putBytes(options.signature as Buffer)
.putUint32(options.crc_channel)
.putUint32(options.crc_uid)
.putBytes(options.m as Buffer)
.pack();
};
return options;
};
const unPackContent = (bytes: Buffer): AccessTokenContentOptions => {
const readbuf = ReadByteBuf(bytes);
return AccessTokenContent({
signature: readbuf.getString(),
crc_channel_name: readbuf.getUint32(),
crc_uid: readbuf.getUint32(),
m: readbuf.getString(),
crc_channel: 0,
});
};
const Message = (options: MessageOptions): MessageOptions => {
options.pack = () => {
const out = ByteBuf();
return out.putUint32(options.salt).putUint32(options.ts).putTreeMapUInt32(options.messages).pack();
};
return options;
};
const unPackMessages = (bytes: Buffer): MessageOptions => {
const readbuf = ReadByteBuf(bytes);
return Message({
salt: readbuf.getUint32(),
ts: readbuf.getUint32(),
messages: readbuf.getTreeMapUInt32(),
});
};
export class AccessToken {
public appID: string;
public appCertificate: string;
public channelName: string;
public uid: string;
public messages: Messages;
public salt: number;
public ts: number;
public constructor(appID: string, appCertificate: string, channelName: string, uid: number | string) {
this.appID = appID;
this.appCertificate = appCertificate;
this.channelName = channelName;
this.messages = {};
this.salt = randomInt;
this.ts = Math.floor(new Date().getTime() / 1000) + 24 * 3600;
if (uid === 0) {
this.uid = '';
} else {
this.uid = `${uid}`;
}
}
public build(): string {
const m = Message({
salt: this.salt,
ts: this.ts,
messages: this.messages,
}).pack!();
const toSign = Buffer.concat([Buffer.from(this.appID, 'utf8'), Buffer.from(this.channelName, 'utf8'), Buffer.from(this.uid, 'utf8'), m]);
const signature = encodeHMac(this.appCertificate, toSign);
const crc_channel = UINT32(crc32.str(this.channelName)).and(UINT32(0xffffffff)).toNumber();
const crc_uid = UINT32(crc32.str(this.uid)).and(UINT32(0xffffffff)).toNumber();
const content = AccessTokenContent({
signature,
crc_channel,
crc_uid,
m,
}).pack!();
return version + this.appID + content.toString('base64');
}
public addPriviledge(priviledge: number, expireTimestamp: number): void {
this.messages[priviledge] = expireTimestamp;
}
public fromString(originToken: string): boolean {
try {
const originVersion = originToken.substr(0, VERSION_LENGTH);
if (originVersion !== version) {
return false;
}
this.appID = originToken.substr(VERSION_LENGTH, APP_ID_LENGTH);
const originContent = originToken.substr(VERSION_LENGTH + APP_ID_LENGTH);
const originContentDecodedBuf = Buffer.from(originContent, 'base64');
const content = unPackContent(originContentDecodedBuf);
const msgs = unPackMessages(content.m as Buffer);
this.salt = msgs.salt;
this.ts = msgs.ts;
this.messages = msgs.messages;
} catch (err) {
console.log(err);
return false;
}
return true;
}
}
export { version };

View File

@ -0,0 +1,447 @@
import crypto from 'node:crypto';
import zlib from 'node:zlib';
interface ByteBufInterface {
buffer: Buffer;
position: number;
pack: () => Buffer;
putUint16: (v: number) => ByteBufInterface;
putUint32: (v: number) => ByteBufInterface;
putInt32: (v: number) => ByteBufInterface;
putInt16: (v: number) => ByteBufInterface;
putBytes: (bytes: Buffer) => ByteBufInterface;
putString: (str: string) => ByteBufInterface;
putTreeMap: (map?: Record<string, string>) => ByteBufInterface;
putTreeMapUInt32: (map?: Privileges) => ByteBufInterface;
}
const VERSION_LENGTH = 3;
const APP_ID_LENGTH = 32;
const encodeHMac = (key: Buffer, message: Buffer | string): Buffer => {
return crypto.createHmac('sha256', key).update(message).digest();
};
const getVersion = () => {
return '007';
};
type Privileges = Record<number, number>;
class ByteBuf implements ByteBufInterface {
public buffer: Buffer;
public position: number;
public constructor() {
this.buffer = Buffer.alloc(1024);
this.position = 0;
this.buffer.fill(0);
}
public pack(): Buffer {
const out = Buffer.alloc(this.position);
this.buffer.copy(out, 0, 0, out.length);
return out;
}
public putUint16(v: number): ByteBufInterface {
this.buffer.writeUInt16LE(v, this.position);
this.position += 2;
return this;
}
public putUint32(v: number): ByteBufInterface {
this.buffer.writeUInt32LE(v, this.position);
this.position += 4;
return this;
}
public putInt32(v: number): ByteBufInterface {
this.buffer.writeInt32LE(v, this.position);
this.position += 4;
return this;
}
public putInt16(v: number): ByteBufInterface {
this.buffer.writeInt16LE(v, this.position);
this.position += 2;
return this;
}
public putBytes(bytes: Buffer): ByteBufInterface {
this.putUint16(bytes.length);
bytes.copy(this.buffer, this.position);
this.position += bytes.length;
return this;
}
public putString(str: string): ByteBufInterface {
return this.putBytes(Buffer.from(str));
}
public putTreeMap(map?: Record<string, string>): ByteBufInterface {
if (!map) {
this.putUint16(0);
return this;
}
this.putUint16(Object.keys(map).length);
for (const key in map) {
this.putUint16(parseInt(key, 10));
this.putString(map[key]);
}
return this;
}
public putTreeMapUInt32(map?: Privileges): ByteBufInterface {
if (!map) {
this.putUint16(0);
return this;
}
this.putUint16(Object.keys(map).length);
for (const key in map) {
this.putUint16(parseInt(key, 10));
this.putUint32(map[key]);
}
return this;
}
}
class ReadByteBuf {
public buffer: Buffer;
public position: number;
public constructor(bytes: Buffer) {
this.buffer = bytes;
this.position = 0;
}
public getUint16(): number {
const ret = this.buffer.readUInt16LE(this.position);
this.position += 2;
return ret;
}
public getUint32(): number {
const ret = this.buffer.readUInt32LE(this.position);
this.position += 4;
return ret;
}
public getInt16(): number {
const ret = this.buffer.readInt16LE(this.position);
this.position += 2;
return ret;
}
public getString(): string {
const len = this.getUint16();
const out = Buffer.alloc(len);
this.buffer.copy(out, 0, this.position, this.position + len);
this.position += len;
return out.toString();
}
public getTreeMapUInt32(): Privileges {
const map: Privileges = {};
const len = this.getUint16();
for (let i = 0; i < len; i++) {
const key = this.getUint16();
const value = this.getUint32();
map[key] = value;
}
return map;
}
public pack(): Buffer {
const length = this.buffer.length;
const out = Buffer.alloc(length);
this.buffer.copy(out, 0, this.position, length);
return out;
}
}
class Service {
protected __type: number;
protected __privileges: Privileges;
public constructor(service_type: number) {
this.__type = service_type;
this.__privileges = {};
}
protected __pack_type(): Buffer {
const buf = new ByteBuf();
buf.putUint16(this.__type);
return buf.pack();
}
protected __pack_privileges(): Buffer {
const buf = new ByteBuf();
buf.putTreeMapUInt32(this.__privileges);
return buf.pack();
}
public service_type(): number {
return this.__type;
}
public add_privilege(privilege: number, expire: number): void {
this.__privileges[privilege] = expire;
}
public pack() {
return Buffer.concat([this.__pack_type(), this.__pack_privileges()]);
}
public unpack(buffer: Buffer): ReadByteBuf {
const bufReader = new ReadByteBuf(buffer);
this.__privileges = bufReader.getTreeMapUInt32();
return bufReader;
}
}
const kRtcServiceType = 1;
export class ServiceRtc extends Service {
protected __channel_name: string;
protected __uid: string;
public static kPrivilegeJoinChannel = 1;
public static kPrivilegePublishAudioStream = 2;
public static kPrivilegePublishVideoStream = 3;
public static kPrivilegePublishDataStream = 4;
public constructor(channel_name: string, uid: number | string) {
super(kRtcServiceType);
this.__channel_name = channel_name;
this.__uid = uid === 0 ? '' : `${uid}`;
}
public pack() {
const buffer = new ByteBuf();
buffer.putString(this.__channel_name).putString(this.__uid);
return Buffer.concat([super.pack(), buffer.pack()]);
}
public unpack(buffer: Buffer): ReadByteBuf {
const bufReader = super.unpack(buffer);
this.__channel_name = bufReader.getString();
this.__uid = bufReader.getString();
return bufReader;
}
}
const kRtmServiceType = 2;
export class ServiceRtm extends Service {
protected __user_id: string;
public static kPrivilegeLogin = 1;
public constructor(user_id?: string) {
super(kRtmServiceType);
this.__user_id = user_id || '';
}
public pack() {
const buffer = new ByteBuf();
buffer.putString(this.__user_id);
return Buffer.concat([super.pack(), buffer.pack()]);
}
public unpack(buffer: Buffer): ReadByteBuf {
const bufReader = super.unpack(buffer);
this.__user_id = bufReader.getString();
return bufReader;
}
}
const kFpaServiceType = 4;
export class ServiceFpa extends Service {
public static kPrivilegeLogin = 1;
public constructor() {
super(kFpaServiceType);
}
public pack() {
return super.pack();
}
public unpack(buffer: Buffer): ReadByteBuf {
const bufReader = super.unpack(buffer);
return bufReader;
}
}
const kChatServiceType = 5;
export class ServiceChat extends Service {
protected __user_id: string;
public static kPrivilegeUser = 1;
public static kPrivilegeApp = 2;
public constructor(user_id?: string) {
super(kChatServiceType);
this.__user_id = user_id || '';
}
public pack() {
const buffer = new ByteBuf();
buffer.putString(this.__user_id);
return Buffer.concat([super.pack(), buffer.pack()]);
}
public unpack(buffer: Buffer): ReadByteBuf {
const bufReader = super.unpack(buffer);
this.__user_id = bufReader.getString();
return bufReader;
}
}
const kApaasServiceType = 7;
export class ServiceApaas extends Service {
protected __room_uuid: string;
protected __user_uuid: string;
protected __role: number;
public static PRIVILEGE_ROOM_USER = 1;
public static PRIVILEGE_USER = 2;
public static PRIVILEGE_APP = 3;
public constructor(roomUuid?: string, userUuid?: string, role?: number) {
super(kApaasServiceType);
this.__room_uuid = roomUuid || '';
this.__user_uuid = userUuid || '';
this.__role = role || -1;
}
public pack() {
const buffer = new ByteBuf();
buffer.putString(this.__room_uuid);
buffer.putString(this.__user_uuid);
buffer.putInt16(this.__role);
return Buffer.concat([super.pack(), buffer.pack()]);
}
public unpack(buffer: Buffer): ReadByteBuf {
const bufReader = super.unpack(buffer);
this.__room_uuid = bufReader.getString();
this.__user_uuid = bufReader.getString();
this.__role = bufReader.getInt16();
return bufReader;
}
}
type Services = Record<number, Service>;
export class AccessToken2 {
public appId: string;
public appCertificate: string;
public issueTs: number;
public expire: number;
public salt: number;
public services: Services;
public static kServices: Record<number, new (...args: any[]) => Service> = {};
public constructor(appId: string, appCertificate: string, issueTs?: number, expire?: number) {
this.appId = appId;
this.appCertificate = appCertificate;
this.issueTs = issueTs || new Date().getTime() / 1000;
this.expire = expire || 0;
// salt ranges in (1, 99999999)
this.salt = Math.floor(Math.random() * 99999999) + 1;
this.services = {};
}
private __signing() {
let signing = encodeHMac(new ByteBuf().putUint32(this.issueTs).pack(), this.appCertificate);
signing = encodeHMac(new ByteBuf().putUint32(this.salt).pack(), signing);
return signing;
}
private __build_check() {
const is_uuid = (data: string): boolean => {
if (data.length !== APP_ID_LENGTH) {
return false;
}
const buf = Buffer.from(data, 'hex');
return Boolean(buf);
};
const { appId, appCertificate, services } = this;
if (!is_uuid(appId) || !is_uuid(appCertificate)) {
return false;
}
if (Object.keys(services).length === 0) {
return false;
}
return true;
}
public add_service(service: Service): void {
this.services[service.service_type()] = service;
}
public build() {
if (!this.__build_check()) {
return '';
}
const signing = this.__signing();
let signing_info = new ByteBuf()
.putString(this.appId)
.putUint32(this.issueTs)
.putUint32(this.expire)
.putUint32(this.salt)
.putUint16(Object.keys(this.services).length)
.pack();
Object.values(this.services).forEach((service) => {
signing_info = Buffer.concat([signing_info, service.pack()]);
});
const signature = encodeHMac(signing, signing_info);
const content = Buffer.concat([new ByteBuf().putBytes(signature).pack(), signing_info]);
const compressed = zlib.deflateSync(content);
return `${getVersion()}${Buffer.from(compressed).toString('base64')}`;
}
public from_string(origin_token: string): boolean {
const origin_version = origin_token.substring(0, VERSION_LENGTH);
if (origin_version !== getVersion()) {
return false;
}
const origin_content = origin_token.substring(VERSION_LENGTH, origin_token.length);
const buffer = zlib.inflateSync(Buffer.from(origin_content, 'base64'));
const bufferReader = new ReadByteBuf(buffer);
this.appId = bufferReader.getString();
this.issueTs = bufferReader.getUint32();
this.expire = bufferReader.getUint32();
this.salt = bufferReader.getUint32();
const service_count = bufferReader.getUint16();
let remainBuf = bufferReader.pack();
for (let i = 0; i < service_count; i++) {
const bufferReaderService = new ReadByteBuf(remainBuf);
const service_type = bufferReaderService.getUint16();
const service = new AccessToken2.kServices[service_type]();
remainBuf = service.unpack(bufferReaderService.pack()).pack();
this.services[service_type] = service;
}
return true;
}
}
// 初始化 kServices
AccessToken2.kServices[kApaasServiceType] = ServiceApaas;
AccessToken2.kServices[kChatServiceType] = ServiceChat;
AccessToken2.kServices[kFpaServiceType] = ServiceFpa;
AccessToken2.kServices[kRtcServiceType] = ServiceRtc;
AccessToken2.kServices[kRtmServiceType] = ServiceRtm;
export { kApaasServiceType, kChatServiceType, kFpaServiceType, kRtcServiceType, kRtmServiceType };

View File

@ -0,0 +1,65 @@
import md5 from 'md5';
import { AccessToken2, ServiceApaas, ServiceChat, ServiceRtm } from './AccessToken2';
export class ApaasTokenBuilder {
/**
* build user room token
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param roomUuid - The room's id, must be unique.
* @param userUuid - The user's id, must be unique.
* @param role - The user's role.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The user room token.
*/
public static buildRoomUserToken(appId: string, appCertificate: string, roomUuid: string, userUuid: string, role: number, expire: number): string {
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
const chatUserId = md5(userUuid);
const apaasService = new ServiceApaas(roomUuid, userUuid, role);
accessToken.add_service(apaasService);
const rtmService = new ServiceRtm(userUuid);
rtmService.add_privilege(ServiceRtm.kPrivilegeLogin, expire);
accessToken.add_service(rtmService);
const chatService = new ServiceChat(chatUserId);
chatService.add_privilege(ServiceChat.kPrivilegeUser, expire);
accessToken.add_service(chatService);
return accessToken.build();
}
/**
* build user token
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param userUuid - The user's id, must be unique.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The user token.
*/
public static buildUserToken(appId: string, appCertificate: string, userUuid: string, expire: number): string {
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
const apaasService = new ServiceApaas('', userUuid);
apaasService.add_privilege(ServiceApaas.PRIVILEGE_USER, expire);
accessToken.add_service(apaasService);
return accessToken.build();
}
/**
* build app token
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The app token.
*/
public static buildAppToken(appId: string, appCertificate: string, expire: number): string {
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
const apaasService = new ServiceApaas();
apaasService.add_privilege(ServiceApaas.PRIVILEGE_APP, expire);
accessToken.add_service(apaasService);
return accessToken.build();
}
}

View File

@ -0,0 +1,34 @@
import { AccessToken2, ServiceChat } from './AccessToken2';
export class ChatTokenBuilder {
/**
* Build the Chat user token.
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param userUuid - The user's id, must be unique.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The Chat User token.
*/
public static buildUserToken(appId: string, appCertificate: string, userUuid: string, expire: number): string {
const token = new AccessToken2(appId, appCertificate, undefined, expire);
const serviceChat = new ServiceChat(userUuid);
serviceChat.add_privilege(ServiceChat.kPrivilegeUser, expire);
token.add_service(serviceChat);
return token.build();
}
/**
* Build the Chat App token.
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The Chat App token.
*/
public static buildAppToken(appId: string, appCertificate: string, expire: number): string {
const token = new AccessToken2(appId, appCertificate, undefined, expire);
const serviceChat = new ServiceChat();
serviceChat.add_privilege(ServiceChat.kPrivilegeApp, expire);
token.add_service(serviceChat);
return token.build();
}
}

View File

@ -0,0 +1,251 @@
import crypto from 'node:crypto';
const version = '005';
export const noUpload = '0';
export const audioVideoUpload = '3';
// Service Type
const MEDIA_CHANNEL_SERVICE = 1;
const RECORDING_SERVICE = 2;
const PUBLIC_SHARING_SERVICE = 3;
const IN_CHANNEL_PERMISSION = 4;
// InChannelPermissionKey
const ALLOW_UPLOAD_IN_CHANNEL = 1;
type ExtraMap = Record<number, string>;
interface MessageOptions {
serviceType: number;
appID: Buffer;
unixTs: number;
salt: number;
channelName: string;
uid: number;
expiredTs: number;
extra?: ExtraMap;
pack?: () => Buffer;
}
interface DynamicKey5ContentOptions {
serviceType: number;
signature: string;
appID: Buffer;
unixTs: number;
salt: number;
expiredTs: number;
extra?: ExtraMap;
pack?: () => Buffer;
}
interface ByteBufInterface {
buffer: Buffer;
position: number;
pack: () => Buffer;
putUint16: (v: number) => ByteBufInterface;
putUint32: (v: number) => ByteBufInterface;
putBytes: (bytes: Buffer) => ByteBufInterface;
putString: (str: string) => ByteBufInterface;
putTreeMap: (map?: ExtraMap) => ByteBufInterface;
}
const ByteBuf = (): ByteBufInterface => {
const that: ByteBufInterface = {
buffer: Buffer.alloc(1024),
position: 0,
pack() {
const out = Buffer.alloc(that.position);
that.buffer.copy(out, 0, 0, out.length);
return out;
},
putUint16(v: number) {
that.buffer.writeUInt16LE(v, that.position);
that.position += 2;
return that;
},
putUint32(v: number) {
that.buffer.writeUInt32LE(v, that.position);
that.position += 4;
return that;
},
putBytes(bytes: Buffer) {
that.putUint16(bytes.length);
bytes.copy(that.buffer, that.position);
that.position += bytes.length;
return that;
},
putString(str: string) {
return that.putBytes(Buffer.from(str));
},
putTreeMap(map?: ExtraMap) {
if (!map) {
that.putUint16(0);
return that;
}
that.putUint16(Object.keys(map).length);
for (const key in map) {
that.putUint16(parseInt(key, 10));
that.putString(map[key]);
}
return that;
},
};
that.buffer.fill(0);
return that;
};
const hexDecode = (str: string): Buffer => {
return Buffer.from(str, 'hex');
};
const encodeHMac = (key: Buffer, message: Buffer): string => {
return crypto.createHmac('sha1', key).update(message).digest('hex').toUpperCase();
};
const Message = (options: MessageOptions): MessageOptions => {
options.pack = () => {
const out = ByteBuf();
return out
.putUint16(options.serviceType)
.putBytes(options.appID)
.putUint32(options.unixTs)
.putUint32(options.salt)
.putString(options.channelName)
.putUint32(options.uid)
.putUint32(options.expiredTs)
.putTreeMap(options.extra)
.pack();
};
return options;
};
const generateSignature5 = (
appCertificate: string,
serviceType: number,
appID: string,
unixTs: number,
randomInt: number,
channelName: string,
uid: number,
expiredTs: number,
extra?: ExtraMap
): string => {
const rawAppID = hexDecode(appID);
const rawAppCertificate = hexDecode(appCertificate);
const m = Message({
serviceType,
appID: rawAppID,
unixTs,
salt: randomInt,
channelName,
uid,
expiredTs,
extra,
});
const toSign = m.pack!();
return encodeHMac(rawAppCertificate, toSign);
};
const DynamicKey5Content = (options: DynamicKey5ContentOptions): DynamicKey5ContentOptions => {
options.pack = () => {
const out = ByteBuf();
return out
.putUint16(options.serviceType)
.putString(options.signature)
.putBytes(options.appID)
.putUint32(options.unixTs)
.putUint32(options.salt)
.putUint32(options.expiredTs)
.putTreeMap(options.extra)
.pack();
};
return options;
};
export const generateDynamicKey = (
appID: string,
appCertificate: string,
channelName: string,
unixTs: number,
randomInt: number,
uid: number,
expiredTs: number,
extra?: ExtraMap,
serviceType: number = MEDIA_CHANNEL_SERVICE
): string => {
const signature = generateSignature5(appCertificate, serviceType, appID, unixTs, randomInt, channelName, uid, expiredTs, extra);
const content = DynamicKey5Content({
serviceType,
signature,
appID: hexDecode(appID),
unixTs,
salt: randomInt,
expiredTs,
extra,
}).pack!();
return version + content.toString('base64');
};
export const generatePublicSharingKey = (
appID: string,
appCertificate: string,
channelName: string,
unixTs: number,
randomInt: number,
uid: number,
expiredTs: number
): string => {
const channelNameStr = channelName.toString();
return generateDynamicKey(appID, appCertificate, channelNameStr, unixTs, randomInt, uid, expiredTs, undefined, PUBLIC_SHARING_SERVICE);
};
export const generateRecordingKey = (
appID: string,
appCertificate: string,
channelName: string,
unixTs: number,
randomInt: number,
uid: number,
expiredTs: number
): string => {
const channelNameStr = channelName.toString();
return generateDynamicKey(appID, appCertificate, channelNameStr, unixTs, randomInt, uid, expiredTs, undefined, RECORDING_SERVICE);
};
export const generateMediaChannelKey = (
appID: string,
appCertificate: string,
channelName: string,
unixTs: number,
randomInt: number,
uid: number,
expiredTs: number
): string => {
const channelNameStr = channelName.toString();
return generateDynamicKey(appID, appCertificate, channelNameStr, unixTs, randomInt, uid, expiredTs, undefined, MEDIA_CHANNEL_SERVICE);
};
export const generateInChannelPermissionKey = (
appID: string,
appCertificate: string,
channelName: string,
unixTs: number,
randomInt: number,
uid: number,
expiredTs: number,
permission: string
): string => {
const extra: ExtraMap = {};
extra[ALLOW_UPLOAD_IN_CHANNEL] = permission;
return generateDynamicKey(appID, appCertificate, channelName, unixTs, randomInt, uid, expiredTs, extra, IN_CHANNEL_PERMISSION);
};
export { version };

View File

@ -0,0 +1,65 @@
import md5 from 'md5';
import { AccessToken2, ServiceApaas, ServiceChat, ServiceRtm } from './AccessToken2';
export class EducationTokenBuilder {
/**
* build user room token
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param roomUuid - The room's id, must be unique.
* @param userUuid - The user's id, must be unique.
* @param role - The user's role.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The user room token.
*/
public static buildRoomUserToken(appId: string, appCertificate: string, roomUuid: string, userUuid: string, role: number, expire: number): string {
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
const chatUserId = md5(userUuid);
const apaasService = new ServiceApaas(roomUuid, userUuid, role);
accessToken.add_service(apaasService);
const rtmService = new ServiceRtm(userUuid);
rtmService.add_privilege(ServiceRtm.kPrivilegeLogin, expire);
accessToken.add_service(rtmService);
const chatService = new ServiceChat(chatUserId);
chatService.add_privilege(ServiceChat.kPrivilegeUser, expire);
accessToken.add_service(chatService);
return accessToken.build();
}
/**
* build user token
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param userUuid - The user's id, must be unique.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The user token.
*/
public static buildUserToken(appId: string, appCertificate: string, userUuid: string, expire: number): string {
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
const apaasService = new ServiceApaas('', userUuid);
apaasService.add_privilege(ServiceApaas.PRIVILEGE_USER, expire);
accessToken.add_service(apaasService);
return accessToken.build();
}
/**
* build app token
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The app token.
*/
public static buildAppToken(appId: string, appCertificate: string, expire: number): string {
const accessToken = new AccessToken2(appId, appCertificate, 0, expire);
const apaasService = new ServiceApaas();
apaasService.add_privilege(ServiceApaas.PRIVILEGE_APP, expire);
accessToken.add_service(apaasService);
return accessToken.build();
}
}

View File

@ -0,0 +1,19 @@
import { AccessToken2, ServiceFpa } from './AccessToken2';
export class FpaTokenBuilder {
/**
* Build the FPA token.
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @returns The FPA token.
*/
public static buildToken(appId: string, appCertificate: string): string {
const token = new AccessToken2(appId, appCertificate, 0, 24 * 3600);
const serviceFpa = new ServiceFpa();
serviceFpa.add_privilege(ServiceFpa.kPrivilegeLogin, 0);
token.add_service(serviceFpa);
return token.build();
}
}

View File

@ -0,0 +1,59 @@
import { AccessToken, priviledges } from './AccessToken';
export enum Role {
// DEPRECATED. Role::ATTENDEE has the same privileges as Role.PUBLISHER.
ATTENDEE = 0,
// RECOMMENDED. Use this role for a voice/video call or a live broadcast
PUBLISHER = 1,
// Only use this role if your scenario require authentication for Co-host
SUBSCRIBER = 2,
// DEPRECATED. Role.ADMIN has the same privileges as Role.PUBLISHER.
ADMIN = 101,
}
export class RtcTokenBuilder {
/**
* Builds an RTC token using an Integer uid.
* @param appID - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param channelName - The unique channel name for the AgoraRTC session in the string format.
* @param uid - User ID. A 32-bit unsigned integer with a value ranging from 1 to (2^32-1).
* @param role - See #userRole.
* @param privilegeExpiredTs - represented by the number of seconds elapsed since 1/1/1970.
* @returns The new Token.
*/
public static buildTokenWithUid(appID: string, appCertificate: string, channelName: string, uid: number, role: Role, privilegeExpiredTs: number): string {
return this.buildTokenWithAccount(appID, appCertificate, channelName, uid, role, privilegeExpiredTs);
}
/**
* Builds an RTC token with account.
* @param appID - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param channelName - The unique channel name for the AgoraRTC session in the string format.
* @param account - The user account.
* @param role - See #userRole.
* @param privilegeExpiredTs - represented by the number of seconds elapsed since 1/1/1970.
* @returns The new Token.
*/
public static buildTokenWithAccount(
appID: string,
appCertificate: string,
channelName: string,
account: number | string,
role: Role,
privilegeExpiredTs: number
): string {
const key = new AccessToken(appID, appCertificate, channelName, account);
key.addPriviledge(priviledges.kJoinChannel, privilegeExpiredTs);
if (role === Role.ATTENDEE || role === Role.PUBLISHER || role === Role.ADMIN) {
key.addPriviledge(priviledges.kPublishAudioStream, privilegeExpiredTs);
key.addPriviledge(priviledges.kPublishVideoStream, privilegeExpiredTs);
key.addPriviledge(priviledges.kPublishDataStream, privilegeExpiredTs);
}
return key.build();
}
}

View File

@ -0,0 +1,234 @@
import { AccessToken2, ServiceRtc, ServiceRtm } from './AccessToken2';
export enum Role {
/**
* 推荐使用。如果您的场景不需要对联合主播进行身份验证,
* 请使用此角色进行语音/视频通话或直播。
*/
PUBLISHER = 1,
/**
* 仅当您的场景需要对联合主播进行身份验证时才使用此角色。
* 为了使此角色生效,请联系我们的支持团队为您启用联合主播身份验证。
* 否则Role_Subscriber 仍然具有与 Role_Publisher 相同的权限。
*/
SUBSCRIBER = 2,
}
export class RtcTokenBuilder {
/**
* 使用 uid 构建 RTC Token
* @param appId - 声网颁发给您的 App ID
* @param appCertificate - 您在声网控制台注册的应用程序证书
* @param channelName - 字符串格式的 AgoraRTC 会话的唯一频道名称
* @param uid - 用户 ID。范围从 1 到 (2^32-1) 的 32 位无符号整数
* @param role - 用户角色
* @param tokenExpire - 从现在开始经过的秒数表示
* @param privilegeExpire - 从现在开始经过的秒数表示
* @returns RTC Token
*/
public static buildTokenWithUid(
appId: string,
appCertificate: string,
channelName: string,
uid: number | string,
role: Role,
tokenExpire: number,
privilegeExpire = 0
): string {
return this.buildTokenWithUserAccount(appId, appCertificate, channelName, uid, role, tokenExpire, privilegeExpire);
}
/**
* 使用账户构建 RTC Token
* @param appId - 声网颁发给您的 App ID
* @param appCertificate - 您在声网控制台注册的应用程序证书
* @param channelName - 字符串格式的 AgoraRTC 会话的唯一频道名称
* @param account - 用户账户
* @param role - 用户角色
* @param tokenExpire - 从现在开始经过的秒数表示
* @param privilegeExpire - 从现在开始经过的秒数表示
* @returns RTC Token
*/
public static buildTokenWithUserAccount(
appId: string,
appCertificate: string,
channelName: string,
account: number | string,
role: Role,
tokenExpire: number,
privilegeExpire = 0
): string {
const token = new AccessToken2(appId, appCertificate, 0, tokenExpire);
const serviceRtc = new ServiceRtc(channelName, account);
serviceRtc.add_privilege(ServiceRtc.kPrivilegeJoinChannel, privilegeExpire);
if (role === Role.PUBLISHER) {
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishAudioStream, privilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishVideoStream, privilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishDataStream, privilegeExpire);
}
token.add_service(serviceRtc);
return token.build();
}
/**
* Generates an RTC token with the specified privilege.
* @param appId - The App ID of your Agora project.
* @param appCertificate - The App Certificate of your Agora project.
* @param channelName - The unique channel name for the Agora RTC session in string format.
* @param uid - The user ID.
* @param tokenExpire - represented by the number of seconds elapsed since now.
* @param joinChannelPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubAudioPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubVideoPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubDataStreamPrivilegeExpire - represented by the number of seconds elapsed since now.
* @returns The RTC Token
*/
public static buildTokenWithUidAndPrivilege(
appId: string,
appCertificate: string,
channelName: string,
uid: number | string,
tokenExpire: number,
joinChannelPrivilegeExpire: number,
pubAudioPrivilegeExpire: number,
pubVideoPrivilegeExpire: number,
pubDataStreamPrivilegeExpire: number
): string {
return this.BuildTokenWithUserAccountAndPrivilege(
appId,
appCertificate,
channelName,
uid,
tokenExpire,
joinChannelPrivilegeExpire,
pubAudioPrivilegeExpire,
pubVideoPrivilegeExpire,
pubDataStreamPrivilegeExpire
);
}
/**
* Generates an RTC token with the specified privilege.
* @param appId - The App ID of your Agora project.
* @param appCertificate - The App Certificate of your Agora project.
* @param channelName - The unique channel name for the Agora RTC session in string format.
* @param userAccount - The user account.
* @param tokenExpire - represented by the number of seconds elapsed since now.
* @param joinChannelPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubAudioPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubVideoPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubDataStreamPrivilegeExpire - represented by the number of seconds elapsed since now.
* @returns The RTC Token.
*/
public static BuildTokenWithUserAccountAndPrivilege(
appId: string,
appCertificate: string,
channelName: string,
account: number | string,
tokenExpire: number,
joinChannelPrivilegeExpire: number,
pubAudioPrivilegeExpire: number,
pubVideoPrivilegeExpire: number,
pubDataStreamPrivilegeExpire: number
): string {
const token = new AccessToken2(appId, appCertificate, 0, tokenExpire);
const serviceRtc = new ServiceRtc(channelName, account);
serviceRtc.add_privilege(ServiceRtc.kPrivilegeJoinChannel, joinChannelPrivilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishAudioStream, pubAudioPrivilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishVideoStream, pubVideoPrivilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishDataStream, pubDataStreamPrivilegeExpire);
token.add_service(serviceRtc);
return token.build();
}
/**
* Build an RTC and RTM token with account.
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param channelName - The unique channel name for the AgoraRTC session in the string format.
* @param account - The user account.
* @param role - See #userRole.
* @param tokenExpire - represented by the number of seconds elapsed since now.
* @param privilegeExpire - represented by the number of seconds elapsed since now.
* @returns The RTC and RTM Token.
*/
public static buildTokenWithRtm(
appId: string,
appCertificate: string,
channelName: string,
account: number | string,
role: Role,
tokenExpire: number,
privilegeExpire = 0
): string {
const token = new AccessToken2(appId, appCertificate, 0, tokenExpire);
const serviceRtc = new ServiceRtc(channelName, account);
serviceRtc.add_privilege(ServiceRtc.kPrivilegeJoinChannel, privilegeExpire);
if (role === Role.PUBLISHER) {
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishAudioStream, privilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishVideoStream, privilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishDataStream, privilegeExpire);
}
token.add_service(serviceRtc);
const serviceRtm = new ServiceRtm(String(account));
serviceRtm.add_privilege(ServiceRtm.kPrivilegeLogin, tokenExpire);
token.add_service(serviceRtm);
return token.build();
}
/**
* Build an RTC and RTM token with account.
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param channelName - The unique channel name for the AgoraRTC session in the string format.
* @param rtcAccount - The RTC user's account, max length is 255 Bytes.
* @param rtcRole - See #userRole.
* @param rtcTokenExpire - represented by the number of seconds elapsed since now.
* @param joinChannelPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubAudioPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubVideoPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param pubDataStreamPrivilegeExpire - represented by the number of seconds elapsed since now.
* @param rtmUserId - The RTM user's account, max length is 255 Bytes.
* @param rtmTokenExpire - represented by the number of seconds elapsed since now.
* @returns The RTC and RTM Token.
*/
public static buildTokenWithRtm2(
appId: string,
appCertificate: string,
channelName: string,
rtcAccount: number | string,
rtcRole: Role,
rtcTokenExpire: number,
joinChannelPrivilegeExpire: number,
pubAudioPrivilegeExpire: number,
pubVideoPrivilegeExpire: number,
pubDataStreamPrivilegeExpire: number,
rtmUserId: string,
rtmTokenExpire: number
): string {
const token = new AccessToken2(appId, appCertificate, 0, rtcTokenExpire);
const serviceRtc = new ServiceRtc(channelName, rtcAccount);
serviceRtc.add_privilege(ServiceRtc.kPrivilegeJoinChannel, joinChannelPrivilegeExpire);
if (rtcRole === Role.PUBLISHER) {
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishAudioStream, pubAudioPrivilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishVideoStream, pubVideoPrivilegeExpire);
serviceRtc.add_privilege(ServiceRtc.kPrivilegePublishDataStream, pubDataStreamPrivilegeExpire);
}
token.add_service(serviceRtc);
const serviceRtm = new ServiceRtm(rtmUserId);
serviceRtm.add_privilege(ServiceRtm.kPrivilegeLogin, rtmTokenExpire);
token.add_service(serviceRtm);
return token.build();
}
}

View File

@ -0,0 +1,22 @@
import { AccessToken, priviledges } from './AccessToken';
export enum Role {
Rtm_User = 1,
}
export class RtmTokenBuilder {
/**
* Build RTM token
* @param appID - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param account - The user account.
* @param role - User role
* @param privilegeExpiredTs - represented by the number of seconds elapsed since 1/1/1970.
* @returns token
*/
public static buildToken(appID: string, appCertificate: string, account: string, role: Role, privilegeExpiredTs: number): string {
const key = new AccessToken(appID, appCertificate, account, '');
key.addPriviledge(priviledges.kRtmLogin, privilegeExpiredTs);
return key.build();
}
}

View File

@ -0,0 +1,21 @@
import { AccessToken2, ServiceRtm } from './AccessToken2';
export class RtmTokenBuilder {
/**
* Build the RTM token.
* @param appId - The App ID issued to you by Agora.
* @param appCertificate - Certificate of the application that you registered in the Agora Dashboard.
* @param userId - The user's account, max length is 64 Bytes.
* @param expire - represented by the number of seconds elapsed since now.
* @returns The RTM token.
*/
public static buildToken(appId: string, appCertificate: string, userId: string, expire: number): string {
const token = new AccessToken2(appId, appCertificate, undefined, expire);
const serviceRtm = new ServiceRtm(userId);
serviceRtm.add_privilege(ServiceRtm.kPrivilegeLogin, expire);
token.add_service(serviceRtm);
return token.build();
}
}

View File

@ -0,0 +1,43 @@
import md5 from 'md5';
export class SignalingToken {
/**
* Get Signaling Token
* @param appid - The App ID
* @param appcertificate - The App Certificate
* @param account - The user account
* @param validTimeInSeconds - Valid time in seconds
* @returns The Signaling Token
*/
public static get(appid: string, appcertificate: string, account: string, validTimeInSeconds: number): string {
const expiredTime = parseInt(String(new Date().getTime() / 1000), 10) + validTimeInSeconds;
const token_items: string[] = [];
// append SDK VERSION
token_items.push('1');
// append appid
token_items.push(appid);
// expired time
token_items.push(String(expiredTime));
// md5 account + appid + appcertificate + expiredtime
token_items.push(md5(account + appid + appcertificate + expiredTime));
return token_items.join(':');
}
/**
* Convenience function to get token valid within 1 day
* @param appid - The App ID
* @param appcertificate - The App Certificate
* @param account - The user account
* @returns The Signaling Token valid for 1 day
*/
public static get1DayToken(appid: string, appcertificate: string, account: string): string {
return SignalingToken.get(appid, appcertificate, account, 3600 * 24);
}
}
export default SignalingToken;

61
node_api/tsconfig.json Normal file
View File

@ -0,0 +1,61 @@
{
"compilerOptions": {
// 基础配置
"target": "ESNext", // 编译目标
"module": "ESNext", // 模块系统
"lib": ["ESNext"], // 引用的库
"moduleResolution": "bundler", // 模块解析策略
// 输出配置
"rootDir": "./src", // 源代码根目录
"outDir": "./dist", // 输出目录
"sourceMap": false, // 不生成 source map (生产环境不需要)
"declaration": false, // 不生成 .d.ts 文件 (生产环境不需要)
"removeComments": true, // 移除注释(减小体积)
"newLine": "lf", // 换行符统一为 LF
// 模块解析
"baseUrl": "./", // 基础路径
"paths": {
"@/*": ["src/*"] // 路径别名
},
"esModuleInterop": true, // ESM 互操作性
"allowSyntheticDefaultImports": true, // 允许合成默认导入
"resolveJsonModule": true, // 允许导入 JSON
"forceConsistentCasingInFileNames": true, // 强制文件名大小写一致
// 类型检查 - 严格模式
"strict": true, // 启用所有严格检查
"noImplicitAny": true, // 禁止隐式 any
"strictNullChecks": true, // 严格空值检查
"strictFunctionTypes": true, // 严格函数类型
"strictBindCallApply": true, // 严格 bind/call/apply
"strictPropertyInitialization": true, // 严格属性初始化
"noImplicitThis": true, // 禁止隐式 this
"alwaysStrict": true, // 始终严格模式
// 额外检查 - 放宽以适配现有代码
"noUnusedLocals": false, // 允许未使用的局部变量(与 oxlint 配合)
"noUnusedParameters": false, // 允许未使用的参数(与 oxlint 配合)
"noImplicitReturns": false, // 允许部分代码路径无返回值
"noFallthroughCasesInSwitch": true, // 禁止 switch 穿透
"noUncheckedIndexedAccess": false, // 放宽索引访问检查
"noImplicitOverride": false, // 放宽 override 修饰符要求
"useUnknownInCatchVariables": false, // catch 变量使用 any
// 装饰器支持 (NestJS 需要)
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
// 关闭 verbatimModuleSyntax 以允许 NestJS 依赖注入需要的运行时类型信息
"verbatimModuleSyntax": false,
"skipLibCheck": true, // 跳过库文件类型检查(加快编译)
"isolatedModules": true, // 每个文件作为独立模块
"allowJs": false, // 不允许 JS 文件(纯 TS 项目)
"checkJs": false, // 不检查 JS 文件
"types": ["node", "vite/client"] // 包含 Node.js 和 Vite 类型
},
// 包含和排除
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*.spec.ts", "**/*.test.ts", "src/src/**/*"]
}

File diff suppressed because one or more lines are too long