优化
This commit is contained in:
1
server/packages/sdk/.gitignore
vendored
Normal file
1
server/packages/sdk/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
dist
|
||||
63
server/packages/sdk/package.json
Normal file
63
server/packages/sdk/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "tailchat-server-sdk",
|
||||
"version": "0.0.18",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"bin": {
|
||||
"tailchat-runner": "./dist/runner/cli.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"watch": "tsc --watch",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"prepare": "tsc",
|
||||
"release": "npm version patch && npm publish --registry https://registry.npmjs.com/"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/msgbyte/tailchat.git"
|
||||
},
|
||||
"keywords": [
|
||||
"msgbyte",
|
||||
"moonrailgun",
|
||||
"tailchat"
|
||||
],
|
||||
"author": "moonrailgun <moonrailgun@gmail.com>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/msgbyte/tailchat/issues"
|
||||
},
|
||||
"homepage": "https://github.com/msgbyte/tailchat#readme",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.1",
|
||||
"typescript": "^4.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/busboy": "^1.1.0",
|
||||
"@typegoose/typegoose": "9.3.1",
|
||||
"accept-language": "^3.0.18",
|
||||
"axios": "^1.3.3",
|
||||
"body-parser": "^1.20.1",
|
||||
"crc": "^3.8.0",
|
||||
"dotenv": "^10.0.0",
|
||||
"etag": "^1.8.1",
|
||||
"fastest-validator": "^1.12.0",
|
||||
"fresh": "^0.5.2",
|
||||
"i18next": "^20.3.5",
|
||||
"i18next-fs-backend": "^1.1.1",
|
||||
"ioredis": "^4.27.6",
|
||||
"isstream": "^0.1.2",
|
||||
"kleur": "^4.1.4",
|
||||
"lodash": "^4.17.21",
|
||||
"minio": "^7.1.1",
|
||||
"moleculer": "0.14.23",
|
||||
"moleculer-db": "0.8.19",
|
||||
"moleculer-repl": "^0.7.2",
|
||||
"moment": "^2.29.1",
|
||||
"mongodb": "4.2.1",
|
||||
"mongoose": "6.1.1",
|
||||
"path-to-regexp": "^6.2.1",
|
||||
"ramda-adjunct": "^4.0.0",
|
||||
"tailchat-types": "workspace:*"
|
||||
}
|
||||
}
|
||||
9
server/packages/sdk/src/const.ts
Normal file
9
server/packages/sdk/src/const.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* 系统用户id
|
||||
*/
|
||||
export const SYSTEM_USERID = '000000000000000000000000';
|
||||
|
||||
/**
|
||||
* 配置项
|
||||
*/
|
||||
export const CONFIG_GATEWAY_AFTER_HOOK = '$gatewayAfterHooks';
|
||||
2
server/packages/sdk/src/db/index.ts
Normal file
2
server/packages/sdk/src/db/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './typegoose';
|
||||
export * from './mongoose';
|
||||
1
server/packages/sdk/src/db/mongoose.ts
Normal file
1
server/packages/sdk/src/db/mongoose.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Types, isValidObjectId } from 'mongoose';
|
||||
10
server/packages/sdk/src/db/typegoose.ts
Normal file
10
server/packages/sdk/src/db/typegoose.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
getModelForClass,
|
||||
prop,
|
||||
modelOptions,
|
||||
Severity,
|
||||
index,
|
||||
} from '@typegoose/typegoose';
|
||||
export type { DocumentType, Ref, ReturnModelType } from '@typegoose/typegoose';
|
||||
export { TimeStamps } from '@typegoose/typegoose/lib/defaultClasses';
|
||||
export type { Base } from '@typegoose/typegoose/lib/defaultClasses';
|
||||
68
server/packages/sdk/src/index.ts
Normal file
68
server/packages/sdk/src/index.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
export { defaultBrokerConfig } from './runner/moleculer.config';
|
||||
export { TcService } from './services/base';
|
||||
export { TcBroker } from './services/broker';
|
||||
export type { TcDbService } from './services/mixins/db.mixin';
|
||||
export { TcMinioService } from './services/mixins/minio.mixin';
|
||||
export type {
|
||||
TcContext,
|
||||
TcPureContext,
|
||||
PureContext,
|
||||
UserJWTPayload,
|
||||
GroupBaseInfo,
|
||||
PureServiceSchema,
|
||||
PureService,
|
||||
PanelFeature,
|
||||
} from './services/types';
|
||||
export { parseLanguageFromHead } from './services/lib/i18n/parser';
|
||||
export { t } from './services/lib/i18n';
|
||||
export { ApiGatewayMixin } from './services/lib/moleculer-web';
|
||||
export * as ApiGatewayErrors from './services/lib/moleculer-web/errors';
|
||||
export * from './services/lib/errors';
|
||||
export { PERMISSION, allPermission } from './services/lib/role';
|
||||
export { call } from './services/lib/call';
|
||||
export {
|
||||
config,
|
||||
buildUploadUrl,
|
||||
builtinAuthWhitelist,
|
||||
checkEnvTrusty,
|
||||
} from './services/lib/settings';
|
||||
|
||||
// struct
|
||||
export type {
|
||||
MessageStruct,
|
||||
MessageReactionStruct,
|
||||
MessageMetaStruct,
|
||||
InboxStruct,
|
||||
} from './structs/chat';
|
||||
export type { BuiltinEventMap } from './structs/events';
|
||||
export type {
|
||||
GroupStruct,
|
||||
GroupRoleStruct,
|
||||
GroupPanelStruct,
|
||||
} from './structs/group';
|
||||
export { GroupPanelType } from './structs/group';
|
||||
export { userType } from './structs/user';
|
||||
export type { UserStruct, UserType, UserStructWithToken } from './structs/user';
|
||||
|
||||
// db
|
||||
export * as db from './db';
|
||||
|
||||
// openapi
|
||||
export * from './openapi';
|
||||
|
||||
export * from './const';
|
||||
|
||||
// other
|
||||
export { Utils, Errors } from 'moleculer';
|
||||
export type { BrokerOptions } from 'moleculer';
|
||||
|
||||
/**
|
||||
* 统一处理未捕获的错误, 防止直接把应用打崩
|
||||
* NOTICE: 未经测试
|
||||
*/
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('unhandledRejection', reason);
|
||||
});
|
||||
process.on('uncaughtException', (error, origin) => {
|
||||
console.error('uncaughtException', error);
|
||||
});
|
||||
1
server/packages/sdk/src/openapi/index.ts
Normal file
1
server/packages/sdk/src/openapi/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { OAuthClient } from './oauth';
|
||||
66
server/packages/sdk/src/openapi/oauth.ts
Normal file
66
server/packages/sdk/src/openapi/oauth.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
/**
|
||||
* 用于 Tailchat OAuth 信息集成的实例
|
||||
*/
|
||||
export class OAuthClient {
|
||||
request: AxiosInstance;
|
||||
|
||||
constructor(
|
||||
apiUrl: string,
|
||||
private appId: string,
|
||||
private appSecret: string
|
||||
) {
|
||||
this.request = axios.create({
|
||||
baseURL: apiUrl,
|
||||
transformRequest: [
|
||||
function (data) {
|
||||
let ret = '';
|
||||
for (const it in data) {
|
||||
ret +=
|
||||
encodeURIComponent(it) + '=' + encodeURIComponent(data[it]) + '&';
|
||||
}
|
||||
ret = ret.substring(0, ret.lastIndexOf('&'));
|
||||
return ret;
|
||||
},
|
||||
],
|
||||
headers: {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据获取到的code获取授权码
|
||||
* @param code 从重定向获取到的临时code
|
||||
* @param redirectUrl 重定向的地址
|
||||
*/
|
||||
async getToken(
|
||||
code: string,
|
||||
redirectUrl: string
|
||||
): Promise<{
|
||||
access_token: string;
|
||||
expires_in: string;
|
||||
id_token: string;
|
||||
scope: string;
|
||||
token_type: string;
|
||||
}> {
|
||||
const { data: tokenInfo } = await this.request.post('/open/token', {
|
||||
client_id: this.appId,
|
||||
client_secret: this.appSecret,
|
||||
redirect_uri: redirectUrl,
|
||||
code,
|
||||
grant_type: 'authorization_code',
|
||||
});
|
||||
|
||||
return tokenInfo;
|
||||
}
|
||||
|
||||
async getUserInfo(accessToken: string): Promise<any> {
|
||||
const { data: userInfo } = await this.request.post('/open/me', {
|
||||
access_token: accessToken,
|
||||
});
|
||||
|
||||
return userInfo;
|
||||
}
|
||||
}
|
||||
4
server/packages/sdk/src/runner/cli.ts
Normal file
4
server/packages/sdk/src/runner/cli.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import { Runner } from 'moleculer';
|
||||
|
||||
const runner = new Runner();
|
||||
runner.start(process.argv);
|
||||
55
server/packages/sdk/src/runner/index.ts
Normal file
55
server/packages/sdk/src/runner/index.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Runner } from 'moleculer';
|
||||
import path from 'path';
|
||||
import cluster from 'cluster';
|
||||
import { config } from '../services/lib/settings';
|
||||
|
||||
declare module 'moleculer' {
|
||||
class Runner {
|
||||
flags?: {
|
||||
config?: string;
|
||||
repl?: boolean;
|
||||
hot?: boolean;
|
||||
silent?: boolean;
|
||||
env?: boolean;
|
||||
envfile?: string;
|
||||
instances?: number;
|
||||
mask?: string;
|
||||
};
|
||||
servicePaths: string[];
|
||||
|
||||
start(args: any[]): void;
|
||||
startWorkers(instances: number): void;
|
||||
_run(): void;
|
||||
}
|
||||
}
|
||||
|
||||
interface DevRunnerOptions {
|
||||
config?: string;
|
||||
}
|
||||
|
||||
const isProd = config.env === 'production';
|
||||
|
||||
/**
|
||||
* 开始一个启动器
|
||||
*/
|
||||
export function startDevRunner(options: DevRunnerOptions) {
|
||||
const runner = new Runner();
|
||||
runner.flags = {
|
||||
hot: isProd ? false : true,
|
||||
repl: isProd ? false : process.env.DISABLE_REPL ? false : true,
|
||||
env: true,
|
||||
config: options.config ?? path.resolve(__dirname, './moleculer.config.ts'),
|
||||
};
|
||||
runner.servicePaths = [
|
||||
'services/**/*.service.ts',
|
||||
'services/**/*.service.dev.ts', // load plugins in dev mode
|
||||
'plugins/**/*.service.ts',
|
||||
'plugins/**/*.service.dev.ts', // load plugins in dev mode
|
||||
];
|
||||
|
||||
if (runner.flags.instances !== undefined && cluster.isPrimary) {
|
||||
return runner.startWorkers(runner.flags.instances);
|
||||
}
|
||||
|
||||
return runner._run();
|
||||
}
|
||||
312
server/packages/sdk/src/runner/moleculer.config.ts
Normal file
312
server/packages/sdk/src/runner/moleculer.config.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
'use strict';
|
||||
import type {
|
||||
BrokerOptions,
|
||||
CallingOptions,
|
||||
Errors,
|
||||
ServiceBroker,
|
||||
} from 'moleculer';
|
||||
import type { UserJWTPayload } from '../services/types';
|
||||
import moment from 'moment';
|
||||
import kleur from 'kleur';
|
||||
import { config } from '../services/lib/settings';
|
||||
import 'moleculer-repl';
|
||||
|
||||
/**
|
||||
* Moleculer ServiceBroker configuration file
|
||||
*
|
||||
* More info about options:
|
||||
* https://moleculer.services/docs/0.14/configuration.html
|
||||
*
|
||||
*
|
||||
* Overwriting options in production:
|
||||
* ================================
|
||||
* You can overwrite any option with environment variables.
|
||||
* For example to overwrite the "logLevel" value, use `LOGLEVEL=warn` env var.
|
||||
* To overwrite a nested parameter, e.g. retryPolicy.retries, use `RETRYPOLICY_RETRIES=10` env var.
|
||||
*
|
||||
* To overwrite broker’s deeply nested default options, which are not presented in "moleculer.config.js",
|
||||
* use the `MOL_` prefix and double underscore `__` for nested properties in .env file.
|
||||
* For example, to set the cacher prefix to `MYCACHE`, you should declare an env var as `MOL_CACHER__OPTIONS__PREFIX=mycache`.
|
||||
* It will set this:
|
||||
* {
|
||||
* cacher: {
|
||||
* options: {
|
||||
* prefix: "mycache"
|
||||
* }
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export const defaultBrokerConfig: BrokerOptions = {
|
||||
// Namespace of nodes to segment your nodes on the same network.
|
||||
namespace: 'tailchat',
|
||||
// Unique node identifier. Must be unique in a namespace.
|
||||
nodeID: undefined,
|
||||
// Custom metadata store. Store here what you want. Accessing: `this.broker.metadata`
|
||||
metadata: {},
|
||||
|
||||
// Enable/disable logging or use custom logger. More info: https://moleculer.services/docs/0.14/logging.html
|
||||
// Available logger types: "Console", "File", "Pino", "Winston", "Bunyan", "debug", "Log4js", "Datadog"
|
||||
logger: [
|
||||
{
|
||||
type: 'Console',
|
||||
options: {
|
||||
// Using colors on the output
|
||||
colors: true,
|
||||
// Print module names with different colors (like docker-compose for containers)
|
||||
moduleColors: false,
|
||||
// Line formatter. It can be "json", "short", "simple", "full", a `Function` or a template string like "{timestamp} {level} {nodeID}/{mod}: {msg}"
|
||||
// formatter: 'full',
|
||||
formatter(type, args, bindings, { printArgs }) {
|
||||
return [
|
||||
kleur.grey(`[${moment().format('YYYY-MM-DD HH:mm:ss')}]`),
|
||||
`${this.levelColorStr[type]}`,
|
||||
...printArgs(args),
|
||||
];
|
||||
},
|
||||
// Custom object printer. If not defined, it uses the `util.inspect` method.
|
||||
objectPrinter: null,
|
||||
// Auto-padding the module name in order to messages begin at the same column.
|
||||
autoPadding: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'File',
|
||||
options: {
|
||||
level: {
|
||||
GATEWAY: 'debug',
|
||||
'**': false,
|
||||
},
|
||||
filename: 'gateway-{nodeID}.log',
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'File',
|
||||
options: {
|
||||
level: {
|
||||
GATEWAY: false,
|
||||
'**': 'debug',
|
||||
},
|
||||
filename: '{date}-{nodeID}.log',
|
||||
},
|
||||
},
|
||||
],
|
||||
// Default log level for built-in console logger. It can be overwritten in logger options above.
|
||||
// Available values: trace, debug, info, warn, error, fatal
|
||||
logLevel: 'info',
|
||||
|
||||
// Define transporter.
|
||||
// More info: https://moleculer.services/docs/0.14/networking.html
|
||||
// Note: During the development, you don't need to define it because all services will be loaded locally.
|
||||
// In production you can set it via `TRANSPORTER=nats://localhost:4222` environment variable.
|
||||
transporter: undefined, // "process.env.TRANSPORTER"
|
||||
|
||||
// Define a cacher.
|
||||
// More info: https://moleculer.services/docs/0.14/caching.html
|
||||
cacher: {
|
||||
type: 'Redis',
|
||||
options: {
|
||||
// Prefix for keys
|
||||
prefix: 'TC',
|
||||
// Redis settings
|
||||
redis: config.redisUrl,
|
||||
},
|
||||
},
|
||||
|
||||
// Define a serializer.
|
||||
// Available values: "JSON", "Avro", "ProtoBuf", "MsgPack", "Notepack", "Thrift".
|
||||
// More info: https://moleculer.services/docs/0.14/networking.html#Serialization
|
||||
serializer: 'JSON',
|
||||
|
||||
// Number of milliseconds to wait before reject a request with a RequestTimeout error. Disabled: 0
|
||||
requestTimeout: config.runner.requestTimeout,
|
||||
|
||||
// Retry policy settings. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Retry
|
||||
retryPolicy: {
|
||||
// Enable feature
|
||||
enabled: false,
|
||||
// Count of retries
|
||||
retries: 5,
|
||||
// First delay in milliseconds.
|
||||
delay: 100,
|
||||
// Maximum delay in milliseconds.
|
||||
maxDelay: 1000,
|
||||
// Backoff factor for delay. 2 means exponential backoff.
|
||||
factor: 2,
|
||||
// A function to check failed requests.
|
||||
check: ((err: Errors.MoleculerError) => err && !!err.retryable) as any,
|
||||
},
|
||||
|
||||
// Limit of calling level. If it reaches the limit, broker will throw an MaxCallLevelError error. (Infinite loop protection)
|
||||
maxCallLevel: 100,
|
||||
|
||||
// Number of seconds to send heartbeat packet to other nodes.
|
||||
heartbeatInterval: 10,
|
||||
// Number of seconds to wait before setting node to unavailable status.
|
||||
heartbeatTimeout: 30,
|
||||
|
||||
// Cloning the params of context if enabled. High performance impact, use it with caution!
|
||||
contextParamsCloning: false,
|
||||
|
||||
// Tracking requests and waiting for running requests before shuting down. More info: https://moleculer.services/docs/0.14/context.html#Context-tracking
|
||||
tracking: {
|
||||
// Enable feature
|
||||
enabled: false,
|
||||
// Number of milliseconds to wait before shuting down the process.
|
||||
shutdownTimeout: 5000,
|
||||
},
|
||||
|
||||
// Disable built-in request & emit balancer. (Transporter must support it, as well.). More info: https://moleculer.services/docs/0.14/networking.html#Disabled-balancer
|
||||
disableBalancer: false,
|
||||
|
||||
// Settings of Service Registry. More info: https://moleculer.services/docs/0.14/registry.html
|
||||
registry: {
|
||||
// Define balancing strategy. More info: https://moleculer.services/docs/0.14/balancing.html
|
||||
// Available values: "RoundRobin", "Random", "CpuUsage", "Latency", "Shard"
|
||||
strategy: 'RoundRobin',
|
||||
// Enable local action call preferring. Always call the local action instance if available.
|
||||
preferLocal: true,
|
||||
},
|
||||
|
||||
// Settings of Circuit Breaker. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Circuit-Breaker
|
||||
circuitBreaker: {
|
||||
// Enable feature
|
||||
enabled: false,
|
||||
// Threshold value. 0.5 means that 50% should be failed for tripping.
|
||||
threshold: 0.5,
|
||||
// Minimum request count. Below it, CB does not trip.
|
||||
minRequestCount: 20,
|
||||
// Number of seconds for time window.
|
||||
windowTime: 60,
|
||||
// Number of milliseconds to switch from open to half-open state
|
||||
halfOpenTime: 10 * 1000,
|
||||
// A function to check failed requests.
|
||||
check: ((err: Errors.MoleculerError) => err && err.code >= 500) as any,
|
||||
},
|
||||
|
||||
// Settings of bulkhead feature. More info: https://moleculer.services/docs/0.14/fault-tolerance.html#Bulkhead
|
||||
bulkhead: {
|
||||
// Enable feature.
|
||||
enabled: false,
|
||||
// Maximum concurrent executions.
|
||||
concurrency: 10,
|
||||
// Maximum size of queue
|
||||
maxQueueSize: 100,
|
||||
},
|
||||
|
||||
// Enable action & event parameter validation. More info: https://moleculer.services/docs/0.14/validating.html
|
||||
validator: true,
|
||||
|
||||
errorHandler: undefined,
|
||||
|
||||
// Enable/disable built-in metrics function. More info: https://moleculer.services/docs/0.14/metrics.html
|
||||
metrics: {
|
||||
enabled: config.enablePrometheus,
|
||||
// Available built-in reporters: "Console", "CSV", "Event", "Prometheus", "Datadog", "StatsD"
|
||||
reporter: [
|
||||
{
|
||||
type: 'Prometheus',
|
||||
options: {
|
||||
// HTTP port
|
||||
port: 13030,
|
||||
// HTTP URL path
|
||||
path: '/metrics',
|
||||
// Default labels which are appended to all metrics labels
|
||||
defaultLabels: (registry) => ({
|
||||
namespace: registry.broker.namespace,
|
||||
nodeID: registry.broker.nodeID,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// Enable built-in tracing function. More info: https://moleculer.services/docs/0.14/tracing.html
|
||||
tracing: {
|
||||
enabled: true,
|
||||
// Available built-in exporters: "Console", "Datadog", "Event", "EventLegacy", "Jaeger", "Zipkin"
|
||||
exporter: {
|
||||
type: 'Console', // Console exporter is only for development!
|
||||
options: {
|
||||
// Custom logger
|
||||
logger: null,
|
||||
// Using colors
|
||||
colors: true,
|
||||
// Width of row
|
||||
width: 100,
|
||||
// Gauge width in the row
|
||||
gaugeWidth: 40,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Register custom middlewares
|
||||
middlewares: [],
|
||||
|
||||
// Register custom REPL commands.
|
||||
// Reference: https://moleculer.services/docs/0.14/moleculer-repl.html#Custom-commands
|
||||
replDelimiter: 'tc $',
|
||||
replCommands: [
|
||||
{
|
||||
// NOTICE: 这个方法不要在原始上下文中使用,会造成其他用户使用不正常(登录成功后会拦截所有的call函数)
|
||||
command: 'login',
|
||||
description: 'Auto login or register tailchat user for cli test',
|
||||
options: [{ option: '-u, --username', description: 'Username' }],
|
||||
alias: null,
|
||||
allowUnknownOptions: null,
|
||||
parse: null,
|
||||
action(broker: ServiceBroker, args) {
|
||||
const username = args.options.username ?? 'localtest';
|
||||
const password = 'localtest';
|
||||
console.log(`开始尝试登录测试账号 ${username}`);
|
||||
return broker
|
||||
.call('user.login', { username, password })
|
||||
.catch((err) => {
|
||||
if (err.name === 'EntityError') {
|
||||
// 未注册
|
||||
console.log('正在注册新的测试账号');
|
||||
return broker.call('user.register', { username, password });
|
||||
}
|
||||
|
||||
console.error('未知的错误:', err);
|
||||
})
|
||||
.then((user: any) => {
|
||||
const token = user.token;
|
||||
const userId = user._id;
|
||||
const originCall = broker.call.bind(broker);
|
||||
|
||||
console.log('登录成功');
|
||||
console.log('> userId:', userId);
|
||||
console.log('> token:', token);
|
||||
|
||||
(broker.call as any) = function (
|
||||
actionName: string,
|
||||
params: unknown,
|
||||
opts?: CallingOptions
|
||||
): Promise<unknown> {
|
||||
return originCall(actionName, params, {
|
||||
...opts,
|
||||
meta: {
|
||||
...(opts.meta ?? {}),
|
||||
user: {
|
||||
_id: userId,
|
||||
nickname: user.nickname,
|
||||
email: user.email,
|
||||
avatar: user.avatar,
|
||||
} as UserJWTPayload,
|
||||
token,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
/*
|
||||
// Called after broker created.
|
||||
created : (broker: ServiceBroker): void => {},
|
||||
// Called after broker started.
|
||||
started: async (broker: ServiceBroker): Promise<void> => {},
|
||||
stopped: async (broker: ServiceBroker): Promise<void> => {},
|
||||
*/
|
||||
};
|
||||
582
server/packages/sdk/src/services/base.ts
Normal file
582
server/packages/sdk/src/services/base.ts
Normal file
@@ -0,0 +1,582 @@
|
||||
import {
|
||||
ActionSchema,
|
||||
CallingOptions,
|
||||
Context,
|
||||
LoggerInstance,
|
||||
Service,
|
||||
ServiceBroker,
|
||||
ServiceDependency,
|
||||
ServiceEvent,
|
||||
ServiceHooks,
|
||||
ServiceSchema,
|
||||
WaitForServicesResult,
|
||||
} from 'moleculer';
|
||||
import { once } from 'lodash';
|
||||
import { TcDbService } from './mixins/db.mixin';
|
||||
import type { PanelFeature, PureContext, TcPureContext } from './types';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { t } from './lib/i18n';
|
||||
import type { ValidationRuleObject } from 'fastest-validator';
|
||||
import type { BuiltinEventMap } from '../structs/events';
|
||||
import { CONFIG_GATEWAY_AFTER_HOOK, SYSTEM_USERID } from '../const';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
decodeNoConflictServiceNameKey,
|
||||
encodeNoConflictServiceNameKey,
|
||||
} from '../utils';
|
||||
|
||||
type ServiceActionHandler<T = any> = (
|
||||
ctx: TcPureContext<any>
|
||||
) => Promise<T> | T;
|
||||
|
||||
type ShortValidationRule =
|
||||
| 'any'
|
||||
| 'array'
|
||||
| 'boolean'
|
||||
| 'custom'
|
||||
| 'date'
|
||||
| 'email'
|
||||
| 'enum'
|
||||
| 'forbidden'
|
||||
| 'function'
|
||||
| 'number'
|
||||
| 'object'
|
||||
| 'string'
|
||||
| 'url'
|
||||
| 'uuid';
|
||||
|
||||
type ServiceActionSchema = Pick<
|
||||
ActionSchema,
|
||||
| 'name'
|
||||
| 'rest'
|
||||
| 'visibility'
|
||||
| 'service'
|
||||
| 'cache'
|
||||
| 'tracing'
|
||||
| 'bulkhead'
|
||||
| 'circuitBreaker'
|
||||
| 'retryPolicy'
|
||||
| 'fallback'
|
||||
| 'hooks'
|
||||
> & {
|
||||
params?: Record<
|
||||
string,
|
||||
ValidationRuleObject | ValidationRuleObject[] | ShortValidationRule
|
||||
>;
|
||||
disableSocket?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成AfterHook唯一键
|
||||
*/
|
||||
function generateAfterHookKey(actionName: string, serviceName = '') {
|
||||
if (serviceName) {
|
||||
return encodeNoConflictServiceNameKey(
|
||||
`${CONFIG_GATEWAY_AFTER_HOOK}.${serviceName}.${actionName}`
|
||||
);
|
||||
} else {
|
||||
return encodeNoConflictServiceNameKey(
|
||||
`${CONFIG_GATEWAY_AFTER_HOOK}.${actionName}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface TcServiceBroker extends ServiceBroker {
|
||||
// 事件类型重写
|
||||
emit<K extends string>(
|
||||
eventName: K,
|
||||
data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
|
||||
groups?: string | string[]
|
||||
): Promise<void>;
|
||||
emit(eventName: string): Promise<void>;
|
||||
broadcast<K extends string>(
|
||||
eventName: K,
|
||||
data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
|
||||
groups?: string | string[]
|
||||
): Promise<void>;
|
||||
broadcast(eventName: string): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* TcService 微服务抽象基类
|
||||
*/
|
||||
export interface TcService extends Service {
|
||||
broker: TcServiceBroker;
|
||||
}
|
||||
export abstract class TcService extends Service {
|
||||
/**
|
||||
* 服务名, 全局唯一
|
||||
*/
|
||||
abstract get serviceName(): string;
|
||||
private _mixins: ServiceSchema['mixins'] = [];
|
||||
private _actions: ServiceSchema['actions'] = {};
|
||||
private _methods: ServiceSchema['methods'] = {};
|
||||
private _settings: ServiceSchema['settings'] = {};
|
||||
private _events: ServiceSchema['events'] = {};
|
||||
|
||||
/**
|
||||
* 全局的配置中心
|
||||
*/
|
||||
public globalConfig: Record<string, any> = {};
|
||||
|
||||
private _generateAndParseSchema() {
|
||||
this.parseServiceSchema({
|
||||
name: this.serviceName,
|
||||
mixins: this._mixins,
|
||||
settings: this._settings,
|
||||
actions: this._actions,
|
||||
events: this._events,
|
||||
started: this.onStart,
|
||||
stopped: this.onStop,
|
||||
hooks: this.buildHooks(),
|
||||
});
|
||||
}
|
||||
|
||||
constructor(broker: ServiceBroker) {
|
||||
super(broker); // Skip 父级的 parseServiceSchema 方法
|
||||
|
||||
this.onInit(); // 初始化服务
|
||||
|
||||
this.initBuiltin(); // 初始化内部服务
|
||||
|
||||
this._generateAndParseSchema();
|
||||
|
||||
this.logger = this.buildLoggerWithPrefix(this.logger);
|
||||
|
||||
this.onInited(); // 初始化完毕
|
||||
}
|
||||
|
||||
protected abstract onInit(): void;
|
||||
|
||||
protected onInited() {}
|
||||
|
||||
protected async onStart() {}
|
||||
|
||||
protected async onStop() {}
|
||||
|
||||
protected initBuiltin() {
|
||||
this.registerEventListener('config.updated', (payload) => {
|
||||
this.logger.info('Update global config with:', payload.config);
|
||||
if (payload.config) {
|
||||
this.globalConfig = {
|
||||
...payload.config,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建内部hooks
|
||||
*/
|
||||
protected buildHooks(): ServiceHooks {
|
||||
return {
|
||||
after: _.mapValues(this._actions, (action, name) => {
|
||||
return (ctx: PureContext, res: unknown) => {
|
||||
try {
|
||||
const afterHooks =
|
||||
this.globalConfig[generateAfterHookKey(name, this.serviceName)];
|
||||
|
||||
if (Array.isArray(afterHooks) && afterHooks.length > 0) {
|
||||
for (const action of afterHooks) {
|
||||
// 异步调用, 暂时不修改值
|
||||
ctx.call(String(action), ctx.params, {
|
||||
meta: {
|
||||
...ctx.meta,
|
||||
actionResult: res,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error('Call action after hooks error:', err);
|
||||
}
|
||||
|
||||
return res;
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取服务操作列表
|
||||
*/
|
||||
getActionList() {
|
||||
return Object.entries(this._actions).map(
|
||||
([name, schema]: [string, ServiceActionSchema]) => {
|
||||
return {
|
||||
name,
|
||||
params: _.mapValues(schema.params, (type) => {
|
||||
if (typeof type === 'string') {
|
||||
return { type: type };
|
||||
} else {
|
||||
return type;
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
registerMixin(mixin: Partial<ServiceSchema>): void {
|
||||
this._mixins.push(mixin);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册微服务绑定的数据库
|
||||
* 不能调用多次
|
||||
*/
|
||||
registerLocalDb = once((model) => {
|
||||
this.registerMixin(TcDbService(model));
|
||||
});
|
||||
|
||||
/**
|
||||
* 注册数据表可见字段列表
|
||||
* @param fields 字段列表
|
||||
*/
|
||||
registerDbField(fields: string[]) {
|
||||
this.registerSetting('fields', fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个操作
|
||||
*
|
||||
* 该操作会同时生成http请求和socketio事件的处理
|
||||
* @param name 操作名, 需微服务内唯一
|
||||
* @param handler 处理方法
|
||||
* @returns
|
||||
*/
|
||||
registerAction(
|
||||
name: string,
|
||||
handler: ServiceActionHandler,
|
||||
schema?: ServiceActionSchema
|
||||
) {
|
||||
if (this._actions[name]) {
|
||||
console.warn(`重复注册操作: ${name}。操作被跳过...`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._actions[name] = {
|
||||
...schema,
|
||||
handler(
|
||||
this: Service,
|
||||
ctx: Context<unknown, { language: string; t: TFunction }>
|
||||
) {
|
||||
// 调用时生成t函数
|
||||
ctx.meta.t = (key: string, defaultValue?: string | object) => {
|
||||
if (typeof defaultValue === 'object') {
|
||||
// 如果是参数对象的话
|
||||
return t(key, {
|
||||
...defaultValue,
|
||||
lng: ctx.meta.language,
|
||||
});
|
||||
}
|
||||
|
||||
return t(key, defaultValue, {
|
||||
lng: ctx.meta.language,
|
||||
});
|
||||
};
|
||||
return handler.call(this, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个内部方法
|
||||
*/
|
||||
registerMethod(name: string, method: (...args: any[]) => any) {
|
||||
if (this._methods[name]) {
|
||||
console.warn(`重复注册方法: ${name}。操作被跳过...`);
|
||||
return;
|
||||
}
|
||||
|
||||
this._methods[name] = method;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个配置项
|
||||
*/
|
||||
registerSetting(key: string, value: unknown): void {
|
||||
if (this._settings[key]) {
|
||||
console.warn(`重复注册配置: ${key}。之前的设置将被覆盖...`);
|
||||
}
|
||||
|
||||
this._settings[key] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个事件监听器
|
||||
*/
|
||||
registerEventListener<K extends string>(
|
||||
eventName: K,
|
||||
handler: (
|
||||
payload: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
|
||||
ctx: TcPureContext
|
||||
) => void,
|
||||
options: Omit<ServiceEvent, 'handler'> = {}
|
||||
) {
|
||||
this._events[eventName] = {
|
||||
...options,
|
||||
handler: (ctx: TcPureContext<any>) => {
|
||||
handler(ctx.params, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册跳过token鉴权的路由地址
|
||||
* @param urls 鉴权路由 会自动添加 serviceName 前缀
|
||||
* @example "/login"
|
||||
*/
|
||||
registerAuthWhitelist(urls: string[]) {
|
||||
this.waitForServices('gateway').then(() => {
|
||||
this.broker.broadcast(
|
||||
'gateway.auth.addWhitelists',
|
||||
{
|
||||
urls: urls.map((url) => `/${this.serviceName}${url}`),
|
||||
},
|
||||
'gateway'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册可用的action请求
|
||||
*
|
||||
* 传入检查函数, 函数的返回值作为结果
|
||||
*/
|
||||
registerAvailableAction(checkFn: () => boolean) {
|
||||
this.registerAction('available', checkFn);
|
||||
this.registerAuthWhitelist(['/available']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册面板功能特性,用于在服务端基础设施开放部分能力
|
||||
* @param panelFeature 面板功能
|
||||
*/
|
||||
async setPanelFeature(panelName: string, panelFeatures: PanelFeature[]) {
|
||||
await this.setGlobalConfig(
|
||||
`panelFeature.${encodeNoConflictServiceNameKey(panelName)}`,
|
||||
panelFeatures
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取拥有某些特性的面板列表
|
||||
* @param panelFeature 面板功能
|
||||
*/
|
||||
getPanelNamesWithFeature(panelFeature: PanelFeature) {
|
||||
const map =
|
||||
this.getGlobalConfig<Record<string, PanelFeature[]>>('panelFeature') ??
|
||||
{};
|
||||
|
||||
const matched = Object.entries(map).filter(([name, features]) => {
|
||||
if (Array.isArray(features)) {
|
||||
return features.includes(panelFeature);
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
return matched.map((m) => decodeNoConflictServiceNameKey(m[0]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待微服务启动
|
||||
* @param serviceNames
|
||||
* @param timeout
|
||||
* @param interval
|
||||
* @param logger
|
||||
* @returns
|
||||
*/
|
||||
waitForServices(
|
||||
serviceNames: string | Array<string> | Array<ServiceDependency>,
|
||||
timeout?: number,
|
||||
interval?: number,
|
||||
logger?: LoggerInstance
|
||||
): Promise<WaitForServicesResult> {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
// 测试环境中跳过
|
||||
return Promise.resolve({
|
||||
services: [],
|
||||
statuses: [],
|
||||
});
|
||||
}
|
||||
|
||||
return super.waitForServices(serviceNames, timeout, interval, logger);
|
||||
}
|
||||
|
||||
getGlobalConfig<T = any>(key: string): T {
|
||||
return _.get(this.globalConfig, key);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置全局配置信息
|
||||
*/
|
||||
async setGlobalConfig(key: string, value: any): Promise<void> {
|
||||
await this.waitForServices('config');
|
||||
await this.broker.call('config.set', {
|
||||
key,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册一个触发了action后的回调
|
||||
* @param fullActionName 完整的带servicename的action名
|
||||
* @param callbackAction 当前服务的action名,不需要带servicename
|
||||
*/
|
||||
async registerAfterActionHook(
|
||||
fullActionName: string,
|
||||
callbackAction: string
|
||||
) {
|
||||
await this.waitForServices(['gateway', 'config']);
|
||||
await this.broker.call('config.addToSet', {
|
||||
key: encodeNoConflictServiceNameKey(
|
||||
`${CONFIG_GATEWAY_AFTER_HOOK}.${fullActionName}`
|
||||
),
|
||||
value: `${this.serviceName}.${callbackAction}`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理action缓存
|
||||
* NOTICE: 这里使用Redis作为缓存管理器,因此不需要通知所有的service
|
||||
*/
|
||||
async cleanActionCache(actionName: string, keys: string[] = []) {
|
||||
if (!this.broker.cacher) {
|
||||
console.error('Can not clean cache because no cacher existed.');
|
||||
}
|
||||
|
||||
if (keys.length === 0) {
|
||||
await this.broker.cacher.clean(`${this.serviceName}.${actionName}`);
|
||||
} else {
|
||||
await this.broker.cacher.clean(
|
||||
`${this.serviceName}.${actionName}:${keys.join('|')}**`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一个有命名空间的通知事件名
|
||||
*/
|
||||
protected generateNotifyEventName(eventName: string) {
|
||||
return `notify:${this.serviceName}.${eventName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地调用操作,不经过外部转发
|
||||
* @param actionName 不需要serverName前缀
|
||||
*/
|
||||
protected localCall(
|
||||
actionName: string,
|
||||
params?: {},
|
||||
opts?: CallingOptions
|
||||
): Promise<any> {
|
||||
return this.actions[actionName](params, opts);
|
||||
}
|
||||
|
||||
protected systemCall<T>(
|
||||
ctx: PureContext,
|
||||
actionName: string,
|
||||
params?: {},
|
||||
opts?: CallingOptions
|
||||
): Promise<T> {
|
||||
return ctx.call(actionName, params, {
|
||||
...opts,
|
||||
meta: {
|
||||
userId: SYSTEM_USERID,
|
||||
...(opts?.meta ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private buildLoggerWithPrefix(_originLogger: LoggerInstance) {
|
||||
const prefix = `[${this.serviceName}]`;
|
||||
const originLogger = _originLogger;
|
||||
return {
|
||||
info: (...args: any[]) => {
|
||||
originLogger.info(prefix, ...args);
|
||||
},
|
||||
fatal: (...args: any[]) => {
|
||||
originLogger.fatal(prefix, ...args);
|
||||
},
|
||||
error: (...args: any[]) => {
|
||||
originLogger.error(prefix, ...args);
|
||||
},
|
||||
warn: (...args: any[]) => {
|
||||
originLogger.warn(prefix, ...args);
|
||||
},
|
||||
debug: (...args: any[]) => {
|
||||
originLogger.debug(prefix, ...args);
|
||||
},
|
||||
trace: (...args: any[]) => {
|
||||
originLogger.trace(prefix, ...args);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 单播推送socket事件
|
||||
*/
|
||||
unicastNotify(
|
||||
ctx: TcPureContext,
|
||||
userId: string,
|
||||
eventName: string,
|
||||
eventData: unknown
|
||||
): Promise<void> {
|
||||
return ctx.call('gateway.notify', {
|
||||
type: 'unicast',
|
||||
target: userId,
|
||||
eventName: this.generateNotifyEventName(eventName),
|
||||
eventData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 列播推送socket事件
|
||||
*/
|
||||
listcastNotify(
|
||||
ctx: TcPureContext,
|
||||
userIds: string[],
|
||||
eventName: string,
|
||||
eventData: unknown
|
||||
) {
|
||||
return ctx.call('gateway.notify', {
|
||||
type: 'listcast',
|
||||
target: userIds,
|
||||
eventName: this.generateNotifyEventName(eventName),
|
||||
eventData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 组播推送socket事件
|
||||
*/
|
||||
roomcastNotify(
|
||||
ctx: TcPureContext,
|
||||
roomId: string,
|
||||
eventName: string,
|
||||
eventData: unknown
|
||||
): Promise<void> {
|
||||
return ctx.call('gateway.notify', {
|
||||
type: 'roomcast',
|
||||
target: roomId,
|
||||
eventName: this.generateNotifyEventName(eventName),
|
||||
eventData,
|
||||
});
|
||||
}
|
||||
/**
|
||||
* 群播推送socket事件
|
||||
*/
|
||||
broadcastNotify(
|
||||
ctx: TcPureContext,
|
||||
eventName: string,
|
||||
eventData: unknown
|
||||
): Promise<void> {
|
||||
return ctx.call('gateway.notify', {
|
||||
type: 'broadcast',
|
||||
eventName: this.generateNotifyEventName(eventName),
|
||||
eventData,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
server/packages/sdk/src/services/broker.ts
Normal file
8
server/packages/sdk/src/services/broker.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import Moleculer from 'moleculer';
|
||||
|
||||
/**
|
||||
* 用于不暴露moleculer让外部手动启动一个broker
|
||||
*
|
||||
* 如tailchat-cli
|
||||
*/
|
||||
export class TcBroker extends Moleculer.ServiceBroker {}
|
||||
171
server/packages/sdk/src/services/lib/call.ts
Normal file
171
server/packages/sdk/src/services/lib/call.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
GroupStruct,
|
||||
UserStruct,
|
||||
SYSTEM_USERID,
|
||||
PERMISSION,
|
||||
TcPureContext,
|
||||
} from '../../index';
|
||||
import type { ChatConverseStruct } from '../../structs/chat';
|
||||
|
||||
export function call(ctx: TcPureContext) {
|
||||
return {
|
||||
/**
|
||||
* 加入socketio房间
|
||||
*/
|
||||
async joinSocketIORoom(roomIds: string[], userId?: string) {
|
||||
await ctx.call('gateway.joinRoom', {
|
||||
roomIds,
|
||||
userId,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 离开socketio房间
|
||||
*/
|
||||
async leaveSocketIORoom(roomIds: string[], userId?: string) {
|
||||
await ctx.call('gateway.leaveRoom', {
|
||||
roomIds,
|
||||
userId,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 检查用户是否在线
|
||||
*/
|
||||
async isUserOnline(userIds: string[]): Promise<boolean[]> {
|
||||
return await ctx.call('gateway.checkUserOnline', { userIds });
|
||||
},
|
||||
/**
|
||||
* 发送系统消息
|
||||
* 如果为群组消息则需要增加groupId
|
||||
*/
|
||||
async sendSystemMessage(
|
||||
message: string,
|
||||
converseId: string,
|
||||
groupId?: string
|
||||
) {
|
||||
await ctx.call(
|
||||
'chat.message.sendMessage',
|
||||
{
|
||||
converseId,
|
||||
groupId,
|
||||
content: message,
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
...ctx.meta,
|
||||
userId: SYSTEM_USERID,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
/**
|
||||
* 获取群组大厅会话的id
|
||||
*/
|
||||
async getGroupLobbyConverseId(groupId: string): Promise<string | null> {
|
||||
const lobbyConverseId: string = await ctx.call(
|
||||
'group.getGroupLobbyConverseId',
|
||||
{
|
||||
groupId,
|
||||
}
|
||||
);
|
||||
|
||||
return lobbyConverseId;
|
||||
},
|
||||
/**
|
||||
* 添加群组系统信息
|
||||
*/
|
||||
async addGroupSystemMessage(groupId: string, message: string) {
|
||||
const lobbyConverseId = await call(ctx).getGroupLobbyConverseId(groupId);
|
||||
|
||||
if (!lobbyConverseId) {
|
||||
// 如果没有文本频道则跳过
|
||||
return;
|
||||
}
|
||||
|
||||
await ctx.call(
|
||||
'chat.message.sendMessage',
|
||||
{
|
||||
converseId: lobbyConverseId,
|
||||
groupId: groupId,
|
||||
content: message,
|
||||
},
|
||||
{
|
||||
meta: {
|
||||
...ctx.meta,
|
||||
userId: SYSTEM_USERID,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
async getUserInfo(userId: string): Promise<UserStruct | null> {
|
||||
return await ctx.call('user.getUserInfo', {
|
||||
userId: String(userId),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 获取会话信息
|
||||
*/
|
||||
async getConverseInfo(
|
||||
converseId: string
|
||||
): Promise<ChatConverseStruct | null> {
|
||||
return await ctx.call('chat.converse.findConverseInfo', {
|
||||
converseId,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 获取群组信息
|
||||
*/
|
||||
async getGroupInfo(groupId: string): Promise<GroupStruct | null> {
|
||||
return await ctx.call('group.getGroupInfo', {
|
||||
groupId,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 检查群组成员权限
|
||||
*/
|
||||
async checkUserPermissions(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
permissions: string[]
|
||||
): Promise<boolean[]> {
|
||||
const userAllPermissions: string[] = await ctx.call(
|
||||
'group.getUserAllPermissions',
|
||||
{
|
||||
groupId,
|
||||
userId,
|
||||
}
|
||||
);
|
||||
|
||||
const hasOwnerPermission = userAllPermissions.includes(
|
||||
PERMISSION.core.owner
|
||||
);
|
||||
|
||||
return permissions.map((p) =>
|
||||
hasOwnerPermission
|
||||
? true // 如果有管理员权限。直接返回true
|
||||
: (userAllPermissions ?? []).includes(p)
|
||||
);
|
||||
},
|
||||
/**
|
||||
* 添加到收件箱
|
||||
* @param type 如果是插件则命名规范为包名加信息名,如: plugin:com.msgbyte.topic
|
||||
* @param payload 内容体,相关的逻辑由前端处理
|
||||
* @param userId 如果是添加到当前用户则userId可以不填
|
||||
*/
|
||||
async appendInbox(
|
||||
type: string,
|
||||
payload: any,
|
||||
userId?: string
|
||||
): Promise<boolean> {
|
||||
return await ctx.call('chat.inbox.append', {
|
||||
userId,
|
||||
type,
|
||||
payload,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
62
server/packages/sdk/src/services/lib/errors.ts
Normal file
62
server/packages/sdk/src/services/lib/errors.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import ExtendableError from 'es6-error';
|
||||
|
||||
class TcError extends ExtendableError {
|
||||
public code: number;
|
||||
public type: string;
|
||||
public data: any;
|
||||
public retryable: boolean;
|
||||
|
||||
constructor(message?: string, code?: number, type?: string, data?: unknown) {
|
||||
super(message ?? 'Service Unavailable');
|
||||
this.code = code ?? this.code ?? 500;
|
||||
this.type = type ?? this.type;
|
||||
this.data = data ?? this.data;
|
||||
this.retryable = this.retryable ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
export class DataNotFoundError extends TcError {
|
||||
constructor(message?: string, code?: number, type?: string, data?: unknown) {
|
||||
super(message ?? 'Not found Data', code ?? 404, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class EntityError extends TcError {
|
||||
constructor(
|
||||
message?: string,
|
||||
code?: number,
|
||||
type?: string,
|
||||
data?: { field: string; message: string }[]
|
||||
) {
|
||||
super(message ?? 'Form error', code ?? 442, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class NoPermissionError extends TcError {
|
||||
constructor(message?: string, code?: number, type?: string, data?: unknown) {
|
||||
super(message ?? 'No operate permission', code ?? 403, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
export class BannedError extends TcError {
|
||||
constructor(message?: string, code?: number, type?: string, data?: unknown) {
|
||||
super(
|
||||
message ?? 'You has been banned',
|
||||
code ?? 403,
|
||||
type ?? 'banned',
|
||||
data
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class ServiceUnavailableError extends TcError {
|
||||
constructor(data?: unknown) {
|
||||
super('Service unavailable', 503, 'SERVICE_NOT_AVAILABLE', data);
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends TcError {
|
||||
constructor(data?: unknown) {
|
||||
super('Not found', 404, 'NOT_FOUND', data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { t } from '../index';
|
||||
/**
|
||||
* 休眠一定时间
|
||||
*/
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) =>
|
||||
setTimeout(() => {
|
||||
resolve();
|
||||
}, ms)
|
||||
);
|
||||
}
|
||||
|
||||
describe('i18n', () => {
|
||||
test('should be work', async () => {
|
||||
await sleep(2000); // 等待异步加载完毕
|
||||
|
||||
expect(t('Token不合规')).toBe('Token不合规');
|
||||
expect(
|
||||
t('Token不合规', undefined, {
|
||||
lng: 'en-US',
|
||||
})
|
||||
).toBe('Token Invalid');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { parseLanguageFromHead } from '../parser';
|
||||
|
||||
describe('parseLanguageFromHead', () => {
|
||||
test.each([
|
||||
// zh
|
||||
['zh-CN,zh;q=0.9', 'zh-CN'],
|
||||
['zh-TW,zh;q=0.9', 'zh-CN'],
|
||||
['zh;q=0.9', 'zh-CN'],
|
||||
['zh', 'zh-CN'],
|
||||
|
||||
// en
|
||||
['en-US,en;q=0.8,sv', 'en-US'],
|
||||
['en-GB,en;q=0.8,sv', 'en-US'],
|
||||
['en;q=0.8,sv', 'en-US'],
|
||||
['en', 'en-US'],
|
||||
|
||||
// other
|
||||
['de-CH;q=0.8,sv', 'en-US'],
|
||||
['jp', 'en-US'],
|
||||
])('%s', (input, output) => {
|
||||
expect(parseLanguageFromHead(input)).toBe(output);
|
||||
});
|
||||
});
|
||||
41
server/packages/sdk/src/services/lib/i18n/index.ts
Normal file
41
server/packages/sdk/src/services/lib/i18n/index.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import i18next, { TFunction, TOptionsBase } from 'i18next';
|
||||
import Backend from 'i18next-fs-backend';
|
||||
import path from 'path';
|
||||
import { crc32 } from 'crc';
|
||||
|
||||
i18next.use(Backend).init({
|
||||
// initImmediate: false,
|
||||
lng: 'en-US',
|
||||
fallbackLng: 'en-US',
|
||||
preload: ['zh-CN', 'en-US'],
|
||||
ns: ['translation'],
|
||||
defaultNS: 'translation',
|
||||
backend: {
|
||||
/**
|
||||
* 加载启动目录下的
|
||||
*/
|
||||
loadPath: path.resolve(process.cwd(), './locales/{{lng}}/{{ns}}.json'),
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 国际化翻译
|
||||
*/
|
||||
export const t: TFunction = (
|
||||
key: string,
|
||||
defaultValue?: string,
|
||||
options?: TOptionsBase
|
||||
) => {
|
||||
try {
|
||||
const hashKey = `k${crc32(key).toString(16)}`;
|
||||
let words = i18next.t(hashKey, defaultValue, options);
|
||||
if (words === hashKey) {
|
||||
words = key;
|
||||
console.info(`[i18n] 翻译缺失: [${hashKey}]${key}`);
|
||||
}
|
||||
return words;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return key;
|
||||
}
|
||||
};
|
||||
23
server/packages/sdk/src/services/lib/i18n/parser.ts
Normal file
23
server/packages/sdk/src/services/lib/i18n/parser.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import acceptLanguage from 'accept-language';
|
||||
|
||||
type AllowedLanguage = 'en-US' | 'zh-CN';
|
||||
acceptLanguage.languages(['en', 'en-US', 'zh-CN', 'zh', 'zh-TW']);
|
||||
|
||||
/**
|
||||
* 解析请求头的 Accept-Language
|
||||
*/
|
||||
export function parseLanguageFromHead(
|
||||
headerLanguage = 'en-US'
|
||||
): AllowedLanguage {
|
||||
const language = acceptLanguage.get(headerLanguage);
|
||||
|
||||
if (language === 'zh' || language === 'zh-TW') {
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
if (language === 'en' || language === 'en-US') {
|
||||
return 'en-US';
|
||||
}
|
||||
|
||||
return language as AllowedLanguage;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
fork from `moleculer-db-adapter-mongoose`
|
||||
@@ -0,0 +1,450 @@
|
||||
/*
|
||||
* moleculer-db-adapter-mongoose
|
||||
* Copyright (c) 2019 MoleculerJS (https://github.com/moleculerjs/moleculer-db)
|
||||
* MIT Licensed
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
import _ from 'lodash';
|
||||
import { Errors, Service, ServiceBroker } from 'moleculer';
|
||||
import type { DbAdapter } from 'moleculer-db';
|
||||
import type { Db } from 'mongodb';
|
||||
import mongoose, {
|
||||
ConnectOptions,
|
||||
Model,
|
||||
Schema,
|
||||
Document,
|
||||
Connection,
|
||||
} from 'mongoose';
|
||||
const ServiceSchemaError = Errors.ServiceSchemaError;
|
||||
|
||||
export class MongooseDbAdapter<TDocument extends Document>
|
||||
implements DbAdapter
|
||||
{
|
||||
uri: string;
|
||||
opts?: ConnectOptions;
|
||||
broker: ServiceBroker;
|
||||
service: Service;
|
||||
model: Model<TDocument>;
|
||||
schema?: Schema;
|
||||
modelName?: string;
|
||||
db: Db;
|
||||
conn: Connection;
|
||||
|
||||
/**
|
||||
* Creates an instance of MongooseDbAdapter.
|
||||
* @param {String} uri
|
||||
* @param {Object?} opts
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
constructor(uri: string, opts) {
|
||||
(this.uri = uri), (this.opts = opts);
|
||||
mongoose.Promise = Promise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize adapter
|
||||
*
|
||||
* @param {ServiceBroker} broker
|
||||
* @param {Service} service
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
init(broker, service) {
|
||||
this.broker = broker;
|
||||
this.service = service;
|
||||
|
||||
if (this.service.schema.model) {
|
||||
this.model = this.service.schema.model;
|
||||
} else if (this.service.schema.schema) {
|
||||
if (!this.service.schema.modelName) {
|
||||
throw new ServiceSchemaError(
|
||||
'`modelName` is required when `schema` is given in schema of service!',
|
||||
{}
|
||||
);
|
||||
}
|
||||
this.schema = this.service.schema.schema;
|
||||
this.modelName = this.service.schema.modelName;
|
||||
}
|
||||
|
||||
if (!this.model && !this.schema) {
|
||||
/* istanbul ignore next */
|
||||
throw new ServiceSchemaError(
|
||||
'Missing `model` or `schema` definition in schema of service!',
|
||||
{}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to database
|
||||
*
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
connect() {
|
||||
let conn: Promise<Connection>;
|
||||
|
||||
if (this.model) {
|
||||
/* istanbul ignore next */
|
||||
if (mongoose.connection.readyState == 1) {
|
||||
this.conn = mongoose.connection;
|
||||
this.db = this.conn.db;
|
||||
return Promise.resolve();
|
||||
} else if (mongoose.connection.readyState == 2) {
|
||||
conn = mongoose.connection.asPromise();
|
||||
} else {
|
||||
conn = mongoose.connect(this.uri, this.opts).then((m) => m.connection);
|
||||
}
|
||||
} else if (this.schema) {
|
||||
conn = mongoose
|
||||
.createConnection(this.uri, this.opts)
|
||||
.asPromise()
|
||||
.then((conn) => {
|
||||
this.model = conn.model(this.modelName, this.schema);
|
||||
return conn;
|
||||
});
|
||||
}
|
||||
|
||||
return conn.then((_conn: Connection) => {
|
||||
this.conn = _conn;
|
||||
this.db = _conn.db;
|
||||
|
||||
this.service.logger.info('MongoDB adapter has connected successfully.');
|
||||
|
||||
/* istanbul ignore next */
|
||||
this.conn.on('disconnected', () =>
|
||||
this.service.logger.warn('Mongoose adapter has disconnected.')
|
||||
);
|
||||
this.conn.on('error', (err) =>
|
||||
this.service.logger.error('MongoDB error.', err)
|
||||
);
|
||||
this.conn.on('reconnect', () =>
|
||||
this.service.logger.info('Mongoose adapter has reconnected.')
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from database
|
||||
*
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
disconnect() {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (this.conn && this.conn.close) {
|
||||
this.conn.close(() => {
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
mongoose.connection.close(() => {
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all entities by filters.
|
||||
*
|
||||
* Available filter props:
|
||||
* - limit
|
||||
* - offset
|
||||
* - sort
|
||||
* - search
|
||||
* - searchFields
|
||||
* - query
|
||||
*
|
||||
* @param {any} filters
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
find(filters) {
|
||||
return this.createCursor(filters).exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entity by query
|
||||
*
|
||||
* @param {Object} query
|
||||
* @returns {Promise}
|
||||
* @memberof MemoryDbAdapter
|
||||
*/
|
||||
findOne(query) {
|
||||
return this.model.findOne(query).exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an entities by ID
|
||||
*
|
||||
* @param {any} _id
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
findById(_id) {
|
||||
return this.model.findById(_id).exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find any entities by IDs
|
||||
*
|
||||
* @param {Array} idList
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
findByIds(idList) {
|
||||
return this.model
|
||||
.find({
|
||||
_id: {
|
||||
$in: idList,
|
||||
},
|
||||
})
|
||||
.exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of filtered entites
|
||||
*
|
||||
* Available filter props:
|
||||
* - search
|
||||
* - searchFields
|
||||
* - query
|
||||
*
|
||||
* @param {Object} [filters={}]
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
count(filters = {}) {
|
||||
return this.createCursor(filters).countDocuments().exec();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert an entity
|
||||
*
|
||||
* @param {Object} entity
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
insert(entity): any {
|
||||
const item = new this.model(entity);
|
||||
return item.save();
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert many entities
|
||||
*
|
||||
* @param {Array} entities
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
insertMany(entities): any {
|
||||
return this.model.create(entities);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update many entities by `query` and `update`
|
||||
*
|
||||
* @param {Object} query
|
||||
* @param {Object} update
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
updateMany(query, update) {
|
||||
return this.model
|
||||
.updateMany(query, update, { multi: true, new: true })
|
||||
.then((res) => res.matchedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an entity by ID and `update`
|
||||
*
|
||||
* @param {any} _id
|
||||
* @param {Object} update
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
updateById(_id, update): any {
|
||||
return this.model.findByIdAndUpdate(_id, update, { new: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove entities which are matched by `query`
|
||||
*
|
||||
* @param {Object} query
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
removeMany(query) {
|
||||
return this.model.deleteMany(query).then((res) => res.deletedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an entity by ID
|
||||
*
|
||||
* @param {any} _id
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
removeById(_id): any {
|
||||
return this.model.findByIdAndRemove(_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all entities from collection
|
||||
*
|
||||
* @returns {Promise}
|
||||
*
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
clear(): any {
|
||||
return this.model.deleteMany({}).then((res) => res.deletedCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert DB entity to JSON object
|
||||
*
|
||||
* @param {any} entity
|
||||
* @returns {Object}
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
entityToObject(entity) {
|
||||
const json = entity.toJSON();
|
||||
if (entity._id && entity._id.toHexString) {
|
||||
json._id = entity._id.toHexString();
|
||||
} else if (entity._id && entity._id.toString) {
|
||||
json._id = entity._id.toString();
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a filtered query
|
||||
* Available filters in `params`:
|
||||
* - search
|
||||
* - sort
|
||||
* - limit
|
||||
* - offset
|
||||
* - query
|
||||
*
|
||||
* @param {Object} params
|
||||
* @returns {MongoQuery}
|
||||
*/
|
||||
createCursor(params) {
|
||||
if (params) {
|
||||
const q = this.model.find(params.query);
|
||||
|
||||
// Search
|
||||
if (_.isString(params.search) && params.search !== '') {
|
||||
if (params.searchFields && params.searchFields.length > 0) {
|
||||
q.find({
|
||||
$or: params.searchFields.map((f) => ({
|
||||
[f]: new RegExp(params.search, 'i'),
|
||||
})),
|
||||
});
|
||||
} else {
|
||||
// Full-text search
|
||||
// More info: https://docs.mongodb.com/manual/reference/operator/query/text/
|
||||
(q as any).find({
|
||||
$text: {
|
||||
$search: String(params.search),
|
||||
},
|
||||
});
|
||||
(q as any)._fields = {
|
||||
_score: {
|
||||
$meta: 'textScore',
|
||||
},
|
||||
};
|
||||
q.sort({
|
||||
_score: {
|
||||
$meta: 'textScore',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (_.isString(params.sort)) q.sort(params.sort.replace(/,/, ' '));
|
||||
else if (Array.isArray(params.sort)) q.sort(params.sort.join(' '));
|
||||
|
||||
// Offset
|
||||
if (_.isNumber(params.offset) && params.offset > 0) q.skip(params.offset);
|
||||
|
||||
// Limit
|
||||
if (_.isNumber(params.limit) && params.limit > 0) q.limit(params.limit);
|
||||
|
||||
return q;
|
||||
}
|
||||
return this.model.find();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms 'idField' into MongoDB's '_id'
|
||||
* @param {Object} entity
|
||||
* @param {String} idField
|
||||
* @memberof MongoDbAdapter
|
||||
* @returns {Object} Modified entity
|
||||
*/
|
||||
beforeSaveTransformID(entity, idField) {
|
||||
const newEntity = _.cloneDeep(entity);
|
||||
|
||||
if (idField !== '_id' && entity[idField] !== undefined) {
|
||||
newEntity._id = this.stringToObjectID(newEntity[idField]);
|
||||
delete newEntity[idField];
|
||||
}
|
||||
|
||||
return newEntity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms MongoDB's '_id' into user defined 'idField'
|
||||
* @param {Object} entity
|
||||
* @param {String} idField
|
||||
* @memberof MongoDbAdapter
|
||||
* @returns {Object} Modified entity
|
||||
*/
|
||||
afterRetrieveTransformID(entity, idField) {
|
||||
if (idField !== '_id') {
|
||||
entity[idField] = this.objectIDToString(entity['_id']);
|
||||
delete entity._id;
|
||||
}
|
||||
return entity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex string to ObjectID
|
||||
* @param {String} id
|
||||
* @returns ObjectID}
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
stringToObjectID(id) {
|
||||
if (typeof id == 'string' && mongoose.Types.ObjectId.isValid(id))
|
||||
return new mongoose.Schema.Types.ObjectId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ObjectID to hex string
|
||||
* @param {ObjectID} id
|
||||
* @returns {String}
|
||||
* @memberof MongooseDbAdapter
|
||||
*/
|
||||
objectIDToString(id) {
|
||||
if (id && id.toString) return id.toString();
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
Fork from https://github.com/moleculerjs/moleculer-web
|
||||
|
||||
Hash: f375dbb4f8bff8aa16e95024e5c65463b626fa45
|
||||
312
server/packages/sdk/src/services/lib/moleculer-web/alias.ts
Normal file
312
server/packages/sdk/src/services/lib/moleculer-web/alias.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
/*
|
||||
* moleculer
|
||||
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
|
||||
* MIT Licensed
|
||||
*/
|
||||
|
||||
import { pathToRegexp } from 'path-to-regexp';
|
||||
import Busboy from '@fastify/busboy';
|
||||
import kleur from 'kleur';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { PayloadTooLarge } from './errors';
|
||||
import { Errors } from 'moleculer';
|
||||
const { MoleculerClientError } = Errors;
|
||||
import {
|
||||
removeTrailingSlashes,
|
||||
addSlashes,
|
||||
decodeParam,
|
||||
compose,
|
||||
} from './utils';
|
||||
|
||||
export class Alias {
|
||||
service;
|
||||
route;
|
||||
type = 'call';
|
||||
method = '*';
|
||||
path = null;
|
||||
handler = null;
|
||||
action = null;
|
||||
fullPath;
|
||||
keys;
|
||||
re;
|
||||
busboyConfig;
|
||||
|
||||
/**
|
||||
* Constructor of Alias
|
||||
*
|
||||
* @param {Service} service
|
||||
* @param {Object} route
|
||||
* @param {Object} opts
|
||||
* @param {any} action
|
||||
*/
|
||||
constructor(service, route, opts, action) {
|
||||
this.service = service;
|
||||
this.route = route;
|
||||
|
||||
if (_.isString(opts)) {
|
||||
// Parse alias string
|
||||
if (opts.indexOf(' ') !== -1) {
|
||||
const p = opts.split(/\s+/);
|
||||
this.method = p[0];
|
||||
this.path = p[1];
|
||||
} else {
|
||||
this.path = opts;
|
||||
}
|
||||
} else if (_.isObject(opts)) {
|
||||
Object.assign(this, _.cloneDeep(opts));
|
||||
}
|
||||
|
||||
if (_.isString(action)) {
|
||||
// Parse type from action name
|
||||
if (action.indexOf(':') > 0) {
|
||||
const p = action.split(':');
|
||||
this.type = p[0];
|
||||
this.action = p[1];
|
||||
} else {
|
||||
this.action = action;
|
||||
}
|
||||
} else if (_.isFunction(action)) {
|
||||
this.handler = action;
|
||||
this.action = null;
|
||||
} else if (Array.isArray(action)) {
|
||||
const mws = _.compact(
|
||||
action.map((mw) => {
|
||||
if (_.isString(mw)) this.action = mw;
|
||||
else if (_.isFunction(mw)) return mw;
|
||||
})
|
||||
);
|
||||
this.handler = compose.call(service, ...mws);
|
||||
} else if (action != null) {
|
||||
Object.assign(this, _.cloneDeep(action));
|
||||
}
|
||||
|
||||
this.type = this.type || 'call';
|
||||
|
||||
this.path = removeTrailingSlashes(this.path);
|
||||
|
||||
this.fullPath = this.fullPath || addSlashes(this.route.path) + this.path;
|
||||
if (this.fullPath !== '/' && this.fullPath.endsWith('/')) {
|
||||
this.fullPath = this.fullPath.slice(0, -1);
|
||||
}
|
||||
|
||||
this.keys = [];
|
||||
this.re = pathToRegexp(
|
||||
this.fullPath,
|
||||
this.keys,
|
||||
route.opts.pathToRegexpOptions || {}
|
||||
); // Options: https://github.com/pillarjs/path-to-regexp#usage
|
||||
|
||||
if (this.type == 'multipart') {
|
||||
// Handle file upload in multipart form
|
||||
this.handler = this.multipartHandler.bind(this);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} url
|
||||
*/
|
||||
match(url) {
|
||||
const m = this.re.exec(url);
|
||||
if (!m) return false;
|
||||
|
||||
const params = {};
|
||||
|
||||
let key, param;
|
||||
for (let i = 0; i < this.keys.length; i++) {
|
||||
key = this.keys[i];
|
||||
param = m[i + 1];
|
||||
if (!param) continue;
|
||||
|
||||
params[key.name] = decodeParam(param);
|
||||
|
||||
if (key.repeat) params[key.name] = params[key.name].split(key.delimiter);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} method
|
||||
*/
|
||||
isMethod(method) {
|
||||
return this.method === '*' || this.method === method;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
printPath() {
|
||||
/* istanbul ignore next */
|
||||
return `${this.method} ${this.fullPath}`;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
toString() {
|
||||
return (
|
||||
kleur.magenta(_.padStart(this.method, 6)) +
|
||||
' ' +
|
||||
kleur.cyan(this.fullPath) +
|
||||
kleur.grey(' => ') +
|
||||
(this.handler != null && this.type !== 'multipart'
|
||||
? '<Function>'
|
||||
: this.action)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
multipartHandler(req, res) {
|
||||
const ctx = req.$ctx;
|
||||
ctx.meta.$multipart = {};
|
||||
const promises = [];
|
||||
|
||||
let numOfFiles = 0;
|
||||
let hasField = false;
|
||||
|
||||
const busboyOptions = _.defaultsDeep(
|
||||
{ headers: req.headers },
|
||||
this.busboyConfig,
|
||||
this.route.opts.busboyConfig
|
||||
);
|
||||
const busboy = new Busboy(busboyOptions);
|
||||
busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {
|
||||
file.on('limit', () => {
|
||||
// This file reached the file size limit.
|
||||
if (_.isFunction(busboyOptions.onFileSizeLimit)) {
|
||||
busboyOptions.onFileSizeLimit.call(this.service, file, busboy);
|
||||
}
|
||||
file.destroy(
|
||||
new PayloadTooLarge({ fieldname, filename, encoding, mimetype })
|
||||
);
|
||||
});
|
||||
numOfFiles++;
|
||||
promises.push(
|
||||
ctx
|
||||
.call(
|
||||
this.action,
|
||||
file,
|
||||
_.defaultsDeep({}, this.route.opts.callOptions, {
|
||||
meta: {
|
||||
fieldname: fieldname,
|
||||
filename: filename,
|
||||
encoding: encoding,
|
||||
mimetype: mimetype,
|
||||
$params: req.$params,
|
||||
},
|
||||
})
|
||||
)
|
||||
.catch((err) => {
|
||||
file.resume(); // Drain file stream to continue processing form
|
||||
busboy.emit('error', err);
|
||||
return err;
|
||||
})
|
||||
);
|
||||
});
|
||||
busboy.on('field', (field, value) => {
|
||||
hasField = true;
|
||||
ctx.meta.$multipart[field] = value;
|
||||
});
|
||||
|
||||
busboy.on('finish', async () => {
|
||||
/* istanbul ignore next */
|
||||
if (!busboyOptions.empty && numOfFiles == 0)
|
||||
return this.service.sendError(
|
||||
req,
|
||||
res,
|
||||
new MoleculerClientError('File missing in the request')
|
||||
);
|
||||
|
||||
// Call the action if no files but multipart fields
|
||||
if (numOfFiles == 0 && hasField) {
|
||||
promises.push(
|
||||
ctx.call(
|
||||
this.action,
|
||||
{},
|
||||
_.defaultsDeep({}, this.route.opts.callOptions, {
|
||||
meta: {
|
||||
$params: req.$params,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
let data = await this.service.Promise.all(promises);
|
||||
const fileLimit =
|
||||
busboyOptions.limits && busboyOptions.limits.files != null
|
||||
? busboyOptions.limits.files
|
||||
: null;
|
||||
if (numOfFiles == 1 && fileLimit == 1) {
|
||||
// Remove the array wrapping
|
||||
data = data[0];
|
||||
}
|
||||
if (this.route.onAfterCall)
|
||||
data = await this.route.onAfterCall.call(
|
||||
this,
|
||||
ctx,
|
||||
this.route,
|
||||
req,
|
||||
res,
|
||||
data
|
||||
);
|
||||
|
||||
this.service.sendResponse(req, res, data, {});
|
||||
} catch (err) {
|
||||
/* istanbul ignore next */
|
||||
this.service.sendError(req, res, err);
|
||||
}
|
||||
});
|
||||
|
||||
/* istanbul ignore next */
|
||||
busboy.on('error', (err) => {
|
||||
req.unpipe(req.busboy);
|
||||
req.resume();
|
||||
this.service.sendError(req, res, err);
|
||||
});
|
||||
|
||||
// Add limit event handlers
|
||||
if (_.isFunction(busboyOptions.onPartsLimit)) {
|
||||
busboy.on('partsLimit', () =>
|
||||
busboyOptions.onPartsLimit.call(
|
||||
this.service,
|
||||
busboy,
|
||||
this,
|
||||
this.service
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (_.isFunction(busboyOptions.onFilesLimit)) {
|
||||
busboy.on('filesLimit', () =>
|
||||
busboyOptions.onFilesLimit.call(
|
||||
this.service,
|
||||
busboy,
|
||||
this,
|
||||
this.service
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (_.isFunction(busboyOptions.onFieldsLimit)) {
|
||||
busboy.on('fieldsLimit', () =>
|
||||
busboyOptions.onFieldsLimit.call(
|
||||
this.service,
|
||||
busboy,
|
||||
this,
|
||||
this.service
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
req.pipe(busboy);
|
||||
}
|
||||
}
|
||||
218
server/packages/sdk/src/services/lib/moleculer-web/errors.ts
Normal file
218
server/packages/sdk/src/services/lib/moleculer-web/errors.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* moleculer
|
||||
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
|
||||
* MIT Licensed
|
||||
*/
|
||||
|
||||
import { Errors } from 'moleculer';
|
||||
|
||||
const { MoleculerError, MoleculerClientError } = Errors;
|
||||
|
||||
export { MoleculerError, MoleculerClientError };
|
||||
|
||||
const ERR_NO_TOKEN = 'NO_TOKEN';
|
||||
const ERR_INVALID_TOKEN = 'INVALID_TOKEN';
|
||||
const ERR_UNABLE_DECODE_PARAM = 'UNABLE_DECODE_PARAM';
|
||||
const ERR_ORIGIN_NOT_FOUND = 'ORIGIN_NOT_FOUND';
|
||||
const ERR_ORIGIN_NOT_ALLOWED = 'ORIGIN_NOT_ALLOWED';
|
||||
|
||||
/**
|
||||
* Invalid request body
|
||||
*
|
||||
* @class InvalidRequestBodyError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class InvalidRequestBodyError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of InvalidRequestBodyError.
|
||||
*
|
||||
* @param {any} body
|
||||
* @param {any} error
|
||||
*
|
||||
* @memberOf InvalidRequestBodyError
|
||||
*/
|
||||
constructor(body, error) {
|
||||
super('Invalid request body', 400, 'INVALID_REQUEST_BODY', {
|
||||
body,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalid response type
|
||||
*
|
||||
* @class InvalidResponseTypeError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class InvalidResponseTypeError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of InvalidResponseTypeError.
|
||||
*
|
||||
* @param {String} dataType
|
||||
*
|
||||
* @memberOf InvalidResponseTypeError
|
||||
*/
|
||||
constructor(dataType) {
|
||||
super(`Invalid response type '${dataType}'`, 500, 'INVALID_RESPONSE_TYPE', {
|
||||
dataType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unauthorized HTTP error
|
||||
*
|
||||
* @class UnAuthorizedError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class UnAuthorizedError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of UnAuthorizedError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf UnAuthorizedError
|
||||
*/
|
||||
constructor(type, data) {
|
||||
super('Unauthorized', 401, type || ERR_INVALID_TOKEN, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forbidden HTTP error
|
||||
*
|
||||
* @class ForbiddenError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class ForbiddenError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of ForbiddenError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf ForbiddenError
|
||||
*/
|
||||
constructor(type, data?) {
|
||||
super('Forbidden', 403, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bad request HTTP error
|
||||
*
|
||||
* @class BadRequestError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class BadRequestError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of BadRequestError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf BadRequestError
|
||||
*/
|
||||
constructor(type, data) {
|
||||
super('Bad request', 400, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Not found HTTP error
|
||||
*
|
||||
* @class NotFoundError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class NotFoundError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of NotFoundError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf NotFoundError
|
||||
*/
|
||||
constructor(type?, data?) {
|
||||
super('Not found', 404, type || 'NOT_FOUND', data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload is too large HTTP error
|
||||
*
|
||||
* @class PayloadTooLarge
|
||||
* @extends {Error}
|
||||
*/
|
||||
class PayloadTooLarge extends MoleculerClientError {
|
||||
/**
|
||||
* Creates an instance of PayloadTooLarge.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf PayloadTooLarge
|
||||
*/
|
||||
constructor(data) {
|
||||
super('Payload too large', 413, 'PAYLOAD_TOO_LARGE', data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rate limit exceeded HTTP error
|
||||
*
|
||||
* @class RateLimitExceeded
|
||||
* @extends {Error}
|
||||
*/
|
||||
class RateLimitExceeded extends MoleculerClientError {
|
||||
/**
|
||||
* Creates an instance of RateLimitExceeded.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf RateLimitExceeded
|
||||
*/
|
||||
constructor(type?, data?) {
|
||||
super('Rate limit exceeded', 429, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service unavailable HTTP error
|
||||
*
|
||||
* @class ForbiddenError
|
||||
* @extends {Error}
|
||||
*/
|
||||
class ServiceUnavailableError extends MoleculerError {
|
||||
/**
|
||||
* Creates an instance of ForbiddenError.
|
||||
*
|
||||
* @param {String} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberOf ForbiddenError
|
||||
*/
|
||||
constructor(type?, data?) {
|
||||
super('Service unavailable', 503, type, data);
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
InvalidRequestBodyError,
|
||||
InvalidResponseTypeError,
|
||||
UnAuthorizedError,
|
||||
ForbiddenError,
|
||||
BadRequestError,
|
||||
NotFoundError,
|
||||
PayloadTooLarge,
|
||||
RateLimitExceeded,
|
||||
ServiceUnavailableError,
|
||||
ERR_NO_TOKEN,
|
||||
ERR_INVALID_TOKEN,
|
||||
ERR_UNABLE_DECODE_PARAM,
|
||||
ERR_ORIGIN_NOT_FOUND,
|
||||
ERR_ORIGIN_NOT_ALLOWED,
|
||||
};
|
||||
1934
server/packages/sdk/src/services/lib/moleculer-web/index.ts
Normal file
1934
server/packages/sdk/src/services/lib/moleculer-web/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Memory store for Rate limiter
|
||||
*
|
||||
* Inspired by https://github.com/dotcypress/micro-ratelimit/
|
||||
*
|
||||
* @class MemoryStore
|
||||
*/
|
||||
export class MemoryStore {
|
||||
hits;
|
||||
resetTime;
|
||||
timer;
|
||||
|
||||
/**
|
||||
* Creates an instance of MemoryStore.
|
||||
*
|
||||
* @param {Number} clearPeriod
|
||||
* @memberof MemoryStore
|
||||
*/
|
||||
constructor(clearPeriod) {
|
||||
this.hits = new Map();
|
||||
this.resetTime = Date.now() + clearPeriod;
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.resetTime = Date.now() + clearPeriod;
|
||||
this.reset();
|
||||
}, clearPeriod);
|
||||
|
||||
this.timer.unref();
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment the counter by key
|
||||
*
|
||||
* @param {String} key
|
||||
* @returns {Number}
|
||||
* @memberof MemoryStore
|
||||
*/
|
||||
inc(key) {
|
||||
let counter = this.hits.get(key) || 0;
|
||||
counter++;
|
||||
this.hits.set(key, counter);
|
||||
return counter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all counters
|
||||
*
|
||||
* @memberof MemoryStore
|
||||
*/
|
||||
reset() {
|
||||
this.hits.clear();
|
||||
}
|
||||
}
|
||||
156
server/packages/sdk/src/services/lib/moleculer-web/utils.ts
Normal file
156
server/packages/sdk/src/services/lib/moleculer-web/utils.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
* moleculer
|
||||
* Copyright (c) 2021 MoleculerJS (https://github.com/moleculerjs/moleculer)
|
||||
* MIT Licensed
|
||||
*/
|
||||
|
||||
import _ from 'lodash';
|
||||
import fresh from 'fresh';
|
||||
import etag from 'etag';
|
||||
|
||||
import {
|
||||
BadRequestError,
|
||||
ERR_UNABLE_DECODE_PARAM,
|
||||
MoleculerError,
|
||||
} from './errors';
|
||||
|
||||
/**
|
||||
* Decode URI encoded param
|
||||
* @param {String} param
|
||||
*/
|
||||
function decodeParam(param) {
|
||||
try {
|
||||
return decodeURIComponent(param);
|
||||
} catch (_) {
|
||||
/* istanbul ignore next */
|
||||
throw new BadRequestError(ERR_UNABLE_DECODE_PARAM, { param });
|
||||
}
|
||||
}
|
||||
|
||||
// Remove slashes "/" from the left & right sides and remove double "//" slashes
|
||||
function removeTrailingSlashes(s) {
|
||||
if (s.startsWith('/')) s = s.slice(1);
|
||||
if (s.endsWith('/')) s = s.slice(0, -1);
|
||||
return s; //.replace(/\/\//g, "/");
|
||||
}
|
||||
|
||||
// Add slashes "/" to the left & right sides
|
||||
function addSlashes(s) {
|
||||
return (s.startsWith('/') ? '' : '/') + s + (s.endsWith('/') ? '' : '/');
|
||||
}
|
||||
|
||||
// Normalize URL path (remove multiple slashes //)
|
||||
function normalizePath(s) {
|
||||
return s.replace(/\/{2,}/g, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose middlewares
|
||||
*
|
||||
* @param {...Function} mws
|
||||
*/
|
||||
function compose(...mws) {
|
||||
const self = this as any;
|
||||
return (req, res, done) => {
|
||||
const next = (i, err?) => {
|
||||
if (i >= mws.length) {
|
||||
if (_.isFunction(done)) return done.call(self, err);
|
||||
|
||||
/* istanbul ignore next */
|
||||
return;
|
||||
}
|
||||
|
||||
if (err) {
|
||||
// Call only error middlewares (err, req, res, next)
|
||||
if (mws[i].length == 4)
|
||||
mws[i].call(self, err, req, res, (err) => next(i + 1, err));
|
||||
else next(i + 1, err);
|
||||
} else {
|
||||
if (mws[i].length < 4)
|
||||
mws[i].call(self, req, res, (err) => next(i + 1, err));
|
||||
else next(i + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return next(0);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose middlewares and return Promise
|
||||
* @param {...Function} mws
|
||||
* @returns {Promise}
|
||||
*/
|
||||
function composeThen(req, res, ...mws) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
compose.call(this, ...mws)(req, res, (err) => {
|
||||
if (err) {
|
||||
/* istanbul ignore next */
|
||||
if (err instanceof MoleculerError) return reject(err);
|
||||
|
||||
/* istanbul ignore next */
|
||||
if (err instanceof Error)
|
||||
return reject(
|
||||
new MoleculerError(
|
||||
err.message,
|
||||
(err as any).code || (err as any).status,
|
||||
(err as any).type
|
||||
)
|
||||
); // TODO err.stack
|
||||
|
||||
/* istanbul ignore next */
|
||||
return reject(new MoleculerError(err));
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate ETag from content.
|
||||
*
|
||||
* @param {any} body
|
||||
* @param {Boolean|String|Function?} opt
|
||||
*
|
||||
* @returns {String}
|
||||
*/
|
||||
function generateETag(body, opt) {
|
||||
if (_.isFunction(opt)) return opt.call(this, body);
|
||||
|
||||
const buf = !Buffer.isBuffer(body) ? Buffer.from(body) : body;
|
||||
|
||||
return etag(buf, opt === true || opt === 'weak' ? { weak: true } : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the data freshness.
|
||||
*
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*
|
||||
* @returns {Boolean}
|
||||
*/
|
||||
function isFresh(req, res) {
|
||||
if (
|
||||
(res.statusCode >= 200 && res.statusCode < 300) ||
|
||||
304 === res.statusCode
|
||||
) {
|
||||
return fresh(req.headers, {
|
||||
etag: res.getHeader('ETag'),
|
||||
'last-modified': res.getHeader('Last-Modified'),
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export {
|
||||
removeTrailingSlashes,
|
||||
addSlashes,
|
||||
normalizePath,
|
||||
decodeParam,
|
||||
compose,
|
||||
composeThen,
|
||||
generateETag,
|
||||
isFresh,
|
||||
};
|
||||
22
server/packages/sdk/src/services/lib/role.ts
Normal file
22
server/packages/sdk/src/services/lib/role.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export const PERMISSION = {
|
||||
/**
|
||||
* 非插件的权限点都叫core
|
||||
*/
|
||||
core: {
|
||||
owner: '__group_owner__', // 保留字段, 用于标识群组所有者
|
||||
message: 'core.message',
|
||||
invite: 'core.invite',
|
||||
unlimitedInvite: 'core.unlimitedInvite',
|
||||
editInvite: 'core.editInvite', // 编辑邀请码权限,需要有创建无限制邀请码的权限
|
||||
groupDetail: 'core.groupDetail',
|
||||
groupBaseInfo: 'core.groupBaseInfo',
|
||||
groupConfig: 'core.groupConfig',
|
||||
manageUser: 'core.manageUser',
|
||||
managePanel: 'core.managePanel',
|
||||
manageInvite: 'core.manageInvite',
|
||||
manageRoles: 'core.manageRoles',
|
||||
deleteMessage: 'core.deleteMessage',
|
||||
},
|
||||
};
|
||||
|
||||
export const allPermission = [...Object.values(PERMISSION.core)];
|
||||
106
server/packages/sdk/src/services/lib/settings.ts
Normal file
106
server/packages/sdk/src/services/lib/settings.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import dotenv from 'dotenv';
|
||||
import _ from 'lodash';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
/**
|
||||
* 配置信息
|
||||
*/
|
||||
const port = process.env.PORT ? Number(process.env.PORT) : 11000;
|
||||
const apiUrl = process.env.API_URL || `http://127.0.0.1:${port}`;
|
||||
const staticHost = process.env.STATIC_HOST || '{BACKEND}';
|
||||
const staticUrl = process.env.STATIC_URL || `${staticHost}/static/`;
|
||||
const requestTimeout = process.env.REQUEST_TIMEOUT
|
||||
? Number(process.env.REQUEST_TIMEOUT)
|
||||
: 10 * 1000; // default 0 (unit: milliseconds)
|
||||
|
||||
export const config = {
|
||||
port,
|
||||
secret: process.env.SECRET || 'tailchat',
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
/**
|
||||
* 是否打开socket admin ui
|
||||
*/
|
||||
enableSocketAdmin: !!process.env.ADMIN,
|
||||
redisUrl: process.env.REDIS_URL,
|
||||
mongoUrl: process.env.MONGO_URL,
|
||||
storage: {
|
||||
type: 'minio', // 可选: minio
|
||||
minioUrl: process.env.MINIO_URL,
|
||||
ssl: checkEnvTrusty(process.env.MINIO_SSL) ?? false,
|
||||
user: process.env.MINIO_USER,
|
||||
pass: process.env.MINIO_PASS,
|
||||
bucketName: process.env.MINIO_BUCKET_NAME || 'tailchat',
|
||||
pathStyle: process.env.MINIO_PATH_STYLE === 'VirtualHosted' ? false : true,
|
||||
|
||||
/**
|
||||
* 文件上传限制
|
||||
* 单位byte
|
||||
* 默认 1m
|
||||
*/
|
||||
limit: process.env.FILE_LIMIT
|
||||
? Number(process.env.FILE_LIMIT)
|
||||
: 1 * 1024 * 1024,
|
||||
},
|
||||
apiUrl,
|
||||
staticUrl,
|
||||
enableOpenapi: true, // 是否开始openapi
|
||||
|
||||
emailVerification: checkEnvTrusty(process.env.EMAIL_VERIFY) || false, // 是否在注册后验证邮箱可用性
|
||||
smtp: {
|
||||
senderName: process.env.SMTP_SENDER, // 发邮件者显示名称
|
||||
connectionUrl: process.env.SMTP_URI || '',
|
||||
},
|
||||
enablePrometheus: checkEnvTrusty(process.env.PROMETHEUS),
|
||||
runner: {
|
||||
requestTimeout,
|
||||
},
|
||||
/**
|
||||
* 使用Tianji对网站进行监控
|
||||
*/
|
||||
tianji: {
|
||||
scriptUrl: process.env.TIANJI_SCRIPT_URL,
|
||||
websiteId: process.env.TIANJI_WEBSITE_ID,
|
||||
},
|
||||
feature: {
|
||||
disableMsgpack: checkEnvTrusty(process.env.DISABLE_MESSAGEPACK), // 是否禁用socketio的 messgpack parser
|
||||
disableFileCheck: checkEnvTrusty(process.env.DISABLE_FILE_CHECK),
|
||||
disableLogger: checkEnvTrusty(process.env.DISABLE_LOGGER), // 是否关闭日志
|
||||
disableInfoLog: checkEnvTrusty(process.env.DISABLE_INFO_LOG), // 是否关闭常规日志(仅保留错误日志)
|
||||
disableTracing: checkEnvTrusty(process.env.DISABLE_TRACING), // 是否关闭链路追踪
|
||||
disableUserRegister: checkEnvTrusty(process.env.DISABLE_USER_REGISTER), // 是否关闭用户注册功能
|
||||
disableGuestLogin: checkEnvTrusty(process.env.DISABLE_GUEST_LOGIN), // 是否关闭用户游客登录功能
|
||||
disableCreateGroup: checkEnvTrusty(process.env.DISABLE_CREATE_GROUP), // 是否禁用用户创建群组功能
|
||||
disablePluginStore: checkEnvTrusty(process.env.DISABLE_PLUGIN_STORE), // 是否禁用用户插件中心功能
|
||||
disableAddFriend: checkEnvTrusty(process.env.DISABLE_ADD_FRIEND), // 是否禁用用户添加好友功能
|
||||
disableTelemetry: checkEnvTrusty(process.env.DISABLE_TELEMETRY), // 是否禁用遥测
|
||||
},
|
||||
};
|
||||
|
||||
export const builtinAuthWhitelist = [
|
||||
'/gateway/health',
|
||||
'/debug/hello',
|
||||
'/user/login',
|
||||
'/user/register',
|
||||
'/user/createTemporaryUser',
|
||||
'/user/resolveToken',
|
||||
'/user/getUserInfo',
|
||||
'/user/getUserInfoList',
|
||||
'/user/checkTokenValid',
|
||||
'/group/getGroupBasicInfo',
|
||||
'/group/invite/findInviteByCode',
|
||||
];
|
||||
|
||||
/**
|
||||
* 构建上传地址
|
||||
*/
|
||||
export function buildUploadUrl(objectName: string) {
|
||||
return config.staticUrl + objectName;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断环境变量是否为true
|
||||
*/
|
||||
export function checkEnvTrusty(env: string): boolean {
|
||||
return env === '1' || env === 'true';
|
||||
}
|
||||
99
server/packages/sdk/src/services/mixins/db.mixin.ts
Normal file
99
server/packages/sdk/src/services/mixins/db.mixin.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Context, Errors, ServiceSchema } from 'moleculer';
|
||||
import BaseDBService, { MoleculerDB } from 'moleculer-db';
|
||||
import { MongooseDbAdapter } from '../lib/moleculer-db-adapter-mongoose';
|
||||
import type { Document, FilterQuery, Model } from 'mongoose';
|
||||
import { config } from '../lib/settings';
|
||||
import type { ReturnModelType } from '@typegoose/typegoose';
|
||||
import type {
|
||||
AnyParamConstructor,
|
||||
BeAnObject,
|
||||
} from '@typegoose/typegoose/lib/types';
|
||||
|
||||
type EntityChangedType = 'created' | 'updated';
|
||||
|
||||
// type MoleculerDBMethods = MoleculerDB<MongooseDbAdapter>['methods'];
|
||||
type MoleculerDBMethods = MoleculerDB<any>['methods'];
|
||||
|
||||
// fork from moleculer-db-adapter-mongoose/index.d.ts
|
||||
interface FindFilters<T extends Document> {
|
||||
query?: FilterQuery<T>;
|
||||
search?: string;
|
||||
searchFields?: string[]; // never used???
|
||||
sort?: string | string[];
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// 复写部分 adapter 的方法类型
|
||||
interface TcDbAdapterOverwrite<T extends Document, M extends Model<T>> {
|
||||
model: M;
|
||||
insert(entity: Partial<T>): Promise<T>;
|
||||
find(filters: FindFilters<T>): Promise<T>;
|
||||
findOne(query: FilterQuery<T>): Promise<T | null>;
|
||||
}
|
||||
|
||||
export interface TcDbService<
|
||||
T extends Document = Document,
|
||||
M extends Model<T> = Model<T>
|
||||
> extends MoleculerDBMethods {
|
||||
entityChanged(type: EntityChangedType, json: {}, ctx: Context): Promise<void>;
|
||||
|
||||
adapter: Omit<MongooseDbAdapter<T>, keyof TcDbAdapterOverwrite<T, M>> &
|
||||
TcDbAdapterOverwrite<T, M>;
|
||||
|
||||
/**
|
||||
* 转换fetch出来的文档, 变成一个json
|
||||
*/
|
||||
transformDocuments: MoleculerDB<
|
||||
// @ts-ignore
|
||||
MongooseDbAdapter<T>
|
||||
>['methods']['transformDocuments'];
|
||||
}
|
||||
|
||||
export type TcDbModel = ReturnModelType<AnyParamConstructor<any>, BeAnObject>;
|
||||
|
||||
/**
|
||||
* Tc 数据库mixin
|
||||
* @param model 数据模型
|
||||
*/
|
||||
export function TcDbService(model: TcDbModel): Partial<ServiceSchema> {
|
||||
const actions = {
|
||||
/**
|
||||
* 自动操作全关
|
||||
*/
|
||||
find: false,
|
||||
count: false,
|
||||
list: false,
|
||||
create: false,
|
||||
insert: false,
|
||||
get: false,
|
||||
update: false,
|
||||
remove: false,
|
||||
};
|
||||
|
||||
const methods = {
|
||||
/**
|
||||
* 实体变更时触发事件
|
||||
*/
|
||||
async entityChanged(type, json, ctx) {
|
||||
await this.clearCache();
|
||||
const eventName = `${this.name}.entity.${type}`;
|
||||
this.broker.emit(eventName, { meta: ctx.meta, entity: json });
|
||||
},
|
||||
};
|
||||
|
||||
if (!config.mongoUrl) {
|
||||
throw new Errors.MoleculerClientError('需要环境变量 MONGO_URL');
|
||||
}
|
||||
|
||||
return {
|
||||
mixins: [BaseDBService],
|
||||
adapter: new MongooseDbAdapter(config.mongoUrl, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true,
|
||||
}),
|
||||
model,
|
||||
actions,
|
||||
methods,
|
||||
};
|
||||
}
|
||||
822
server/packages/sdk/src/services/mixins/minio.mixin.ts
Normal file
822
server/packages/sdk/src/services/mixins/minio.mixin.ts
Normal file
@@ -0,0 +1,822 @@
|
||||
import { Client as MinioClient, CopyConditions } from 'minio';
|
||||
import { isString, isUndefined } from 'ramda-adjunct';
|
||||
import { Errors } from 'moleculer';
|
||||
|
||||
class MinioInitializationError extends Errors.MoleculerError {
|
||||
/**
|
||||
* Creates an instance of MinioInitializationError.
|
||||
*
|
||||
* @param {String?} message
|
||||
* @param {Number?} code
|
||||
* @param {String?} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberof MinioInitializationError
|
||||
*/
|
||||
constructor(
|
||||
message = 'Minio can not be initialized',
|
||||
code = 500,
|
||||
type = 'MINIO_INITIALIZATION_ERROR',
|
||||
data = {}
|
||||
) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
class MinioPingError extends Errors.MoleculerRetryableError {
|
||||
/**
|
||||
* Creates an instance of MinioPingError.
|
||||
*
|
||||
* @param {String?} message
|
||||
* @param {Number?} code
|
||||
* @param {String?} type
|
||||
* @param {any} data
|
||||
*
|
||||
* @memberof MinioPingError
|
||||
*/
|
||||
constructor(
|
||||
message = 'Minio Backend not reachable',
|
||||
code = 502,
|
||||
type = 'MINIO_PING_ERROR',
|
||||
data = {}
|
||||
) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.type = type;
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Service mixin for managing files in a Minio S3 backend
|
||||
*
|
||||
* @name moleculer-minio
|
||||
* @module Service
|
||||
*/
|
||||
|
||||
export const TcMinioService = {
|
||||
// Service name
|
||||
name: 'minio',
|
||||
|
||||
// Default settings
|
||||
settings: {
|
||||
/** @type {String} The Hostname minio is running on and available at. Hostname or IP-Address */
|
||||
endPoint: undefined,
|
||||
/** @type {Number} TCP/IP port number minio is listening on. Default value set to 80 for HTTP and 443 for HTTPs.*/
|
||||
port: undefined,
|
||||
/** @type {Boolean?} If set to true, https is used instead of http. Default is true.*/
|
||||
useSSL: true,
|
||||
/** @type {String} The AccessKey to use when connecting to minio */
|
||||
accessKey: undefined,
|
||||
/** @type {String} The SecretKey to use when connecting to minio */
|
||||
secretKey: undefined,
|
||||
/** @type {String?} Set this value to override region cache*/
|
||||
region: undefined,
|
||||
/** @type {String?} Set this value to pass in a custom transport. (Optional)*/
|
||||
transport: undefined,
|
||||
/** @type {String?} Set this value to provide x-amz-security-token (AWS S3 specific). (Optional)*/
|
||||
sessionToken: undefined,
|
||||
/** @type {Number?} This service will perform a periodic healthcheck of Minio. Use this setting to configure the inverval in which the healthcheck is performed. Set to `0` to turn healthcheks of */
|
||||
minioHealthCheckInterval: 5000,
|
||||
|
||||
/**
|
||||
* Path Style: <Schema>://<S3 Endpoint>/<Bucket>/<Object>
|
||||
* Virtual hosted style: <Schema>://<Bucket>.<S3 Endpoint>/<Object>
|
||||
*/
|
||||
pathStyle: true,
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Creates and returns a new Minio client
|
||||
*
|
||||
* @methods
|
||||
*
|
||||
* @returns {Client}
|
||||
*/
|
||||
createMinioClient() {
|
||||
return new MinioClient({
|
||||
endPoint: this.settings.endPoint,
|
||||
port: this.settings.port,
|
||||
useSSL: this.settings.useSSL,
|
||||
accessKey: this.settings.accessKey,
|
||||
secretKey: this.settings.secretKey,
|
||||
region: this.settings.region,
|
||||
transport: this.settings.transport,
|
||||
sessionToken: this.settings.sessionToken,
|
||||
pathStyle: this.settings.pathStyle,
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Pings the configured minio backend
|
||||
*
|
||||
* @param {number} timeout - Amount of miliseconds to wait for a ping response
|
||||
* @returns {PromiseLike<boolean|MinioPingError>}
|
||||
*/
|
||||
ping({ timeout = 5000 } = {}) {
|
||||
return this.Promise.race([
|
||||
this.client.listBuckets().then(() => true),
|
||||
this.Promise.delay(timeout).then(() => {
|
||||
throw new MinioPingError();
|
||||
}),
|
||||
]);
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
/**
|
||||
* Creates a new Bucket
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @param {string} bucketName - The name of the bucket
|
||||
* @param {string} region - The region to create the bucket in. Defaults to "us-east-1"
|
||||
*
|
||||
* @returns {PromiseLike<undefined|Error>}
|
||||
*/
|
||||
makeBucket: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
region: { type: 'string', optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({ bucketName, region = '' }) =>
|
||||
this.client.makeBucket(bucketName, region)
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Lists all buckets.
|
||||
*
|
||||
* @actions
|
||||
*
|
||||
* @returns {PromiseLike<Bucket[]|Error>}
|
||||
*/
|
||||
listBuckets: {
|
||||
handler() {
|
||||
return this.client
|
||||
.listBuckets()
|
||||
.then((buckets) => (isUndefined(buckets) ? [] : buckets));
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Checks if a bucket exists.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket
|
||||
*
|
||||
* @returns {PromiseLike<boolean|Error>}
|
||||
*/
|
||||
bucketExists: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.bucketExists(ctx.params.bucketName);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Removes a bucket.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket
|
||||
*
|
||||
* @returns {PromiseLike<boolean|Error>}
|
||||
*/
|
||||
removeBucket: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.removeBucket(ctx.params.bucketName);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Lists all objects in a bucket.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket
|
||||
* @param {string} prefix - The prefix of the objects that should be listed (optional, default '').
|
||||
* @param {boolean} recursive - `true` indicates recursive style listing and false indicates directory style listing delimited by '/'. (optional, default `false`).
|
||||
*
|
||||
* @returns {PromiseLike<Object[]|Error>}
|
||||
*/
|
||||
listObjects: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
prefix: { type: 'string', optional: true },
|
||||
recursive: { type: 'boolean', optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({ bucketName, prefix = '', recursive = false }) => {
|
||||
return new this.Promise((resolve, reject) => {
|
||||
try {
|
||||
const stream = this.client.listObjects(
|
||||
bucketName,
|
||||
prefix,
|
||||
recursive
|
||||
);
|
||||
const objects = [];
|
||||
stream.on('data', (el) => objects.push(el));
|
||||
stream.on('end', () => resolve(objects));
|
||||
stream.on('error', reject);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Lists all objects in a bucket using S3 listing objects V2 API
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket
|
||||
* @param {string} prefix - The prefix of the objects that should be listed (optional, default '').
|
||||
* @param {boolean} recursive - `true` indicates recursive style listing and false indicates directory style listing delimited by '/'. (optional, default `false`).
|
||||
* @param {string} startAfter - Specifies the object name to start after when listing objects in a bucket. (optional, default '').
|
||||
*
|
||||
* @returns {PromiseLike<Object[]|Error>}
|
||||
*/
|
||||
listObjectsV2: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
prefix: { type: 'string', optional: true },
|
||||
recursive: { type: 'boolean', optional: true },
|
||||
startAfter: { type: 'string', optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({ bucketName, prefix = '', recursive = false, startAfter = '' }) => {
|
||||
return new this.Promise((resolve, reject) => {
|
||||
try {
|
||||
const stream = this.client.listObjectsV2(
|
||||
bucketName,
|
||||
prefix,
|
||||
recursive,
|
||||
startAfter
|
||||
);
|
||||
const objects = [];
|
||||
stream.on('data', (el) => objects.push(el));
|
||||
stream.on('end', () => resolve(objects));
|
||||
stream.on('error', reject);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Lists partially uploaded objects in a bucket.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket
|
||||
* @param {string} prefix - The prefix of the objects that should be listed (optional, default '').
|
||||
* @param {boolean} recursive - `true` indicates recursive style listing and false indicates directory style listing delimited by '/'. (optional, default `false`).
|
||||
*
|
||||
* @returns {PromiseLike<Object[]|Error>}
|
||||
*/
|
||||
listIncompleteUploads: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
prefix: { type: 'string', optional: true },
|
||||
recursive: { type: 'boolean', optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({ bucketName, prefix = '', recursive = false }) => {
|
||||
return new this.Promise((resolve, reject) => {
|
||||
try {
|
||||
const stream = this.client.listIncompleteUploads(
|
||||
bucketName,
|
||||
prefix,
|
||||
recursive
|
||||
);
|
||||
const objects = [];
|
||||
stream.on('data', (el) => objects.push(el));
|
||||
stream.on('end', () => resolve(objects));
|
||||
stream.on('error', reject);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Downloads an object as a stream.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket
|
||||
* @param {string} objectName - Name of the object.
|
||||
*
|
||||
* @returns {PromiseLike<ReadableStream|Error>}
|
||||
*/
|
||||
getObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.getObject(
|
||||
ctx.params.bucketName,
|
||||
ctx.params.objectName
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Downloads the specified range bytes of an object as a stream.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
* @param {number} offset - `offset` of the object from where the stream will start.
|
||||
* @param {number} length - `length` of the object that will be read in the stream (optional, if not specified we read the rest of the file from the offset).
|
||||
*
|
||||
* @returns {PromiseLike<ReadableStream|Error>}
|
||||
*/
|
||||
getPartialObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
offset: { type: 'number' },
|
||||
length: { type: 'number', optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.getPartialObject(
|
||||
ctx.params.bucketName,
|
||||
ctx.params.objectName,
|
||||
ctx.params.offset,
|
||||
ctx.params.length
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Downloads and saves the object as a file in the local filesystem.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
* @param {string} filePath - Path on the local filesystem to which the object data will be written.
|
||||
*
|
||||
* @returns {PromiseLike<undefined|Error>}
|
||||
*/
|
||||
fGetObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
filePath: { type: 'string' },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.fGetObject(
|
||||
ctx.params.bucketName,
|
||||
ctx.params.objectName,
|
||||
ctx.params.filePath
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Uploads an object from a stream/Buffer.
|
||||
*
|
||||
* @actions
|
||||
* @param {ReadableStream} params - Readable stream.
|
||||
*
|
||||
* @meta
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
* @param {number} size - Size of the object (optional).
|
||||
* @param {object} metaData - metaData of the object (optional).
|
||||
*
|
||||
* @returns {PromiseLike<undefined|Error>}
|
||||
*/
|
||||
putObject: {
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve({
|
||||
stream: ctx.params,
|
||||
meta: ctx.meta,
|
||||
}).then(({ stream, meta }) =>
|
||||
this.client.putObject(
|
||||
meta.bucketName,
|
||||
meta.objectName,
|
||||
stream,
|
||||
meta.size,
|
||||
meta.metaData
|
||||
)
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Uploads contents from a file to objectName.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
* @param {string} filePath - Path of the file to be uploaded.
|
||||
* @param {object} metaData - metaData of the object (optional).
|
||||
*
|
||||
* @returns {PromiseLike<undefined|Error>}
|
||||
*/
|
||||
fPutObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
filePath: { type: 'string' },
|
||||
metaData: { type: 'object', optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.fPutObject(
|
||||
ctx.params.bucketName,
|
||||
ctx.params.objectName,
|
||||
ctx.params.filePath,
|
||||
ctx.params.metaData
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Copy a source object into a new object in the specified bucket.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
* @param {string} sourceObject - Path of the file to be copied.
|
||||
* @param {object} conditions - Conditions to be satisfied before allowing object copy.
|
||||
* @param {object} metaData - metaData of the object (optional).
|
||||
*
|
||||
* @returns {PromiseLike<{etag: {string}, lastModified: {string}}|Error>}
|
||||
*/
|
||||
copyObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
sourceObject: { type: 'string' },
|
||||
conditions: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
modified: { type: 'string', optional: true },
|
||||
unmodified: { type: 'string', optional: true },
|
||||
matchETag: { type: 'string', optional: true },
|
||||
matchETagExcept: { type: 'string', optional: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({ bucketName, objectName, sourceObject, conditions }) => {
|
||||
const _conditions = new CopyConditions();
|
||||
if (conditions.modified) {
|
||||
_conditions.setModified(new Date(conditions.modified));
|
||||
}
|
||||
if (conditions.unmodified) {
|
||||
_conditions.setUnmodified(new Date(conditions.unmodified));
|
||||
}
|
||||
if (conditions.matchETag) {
|
||||
_conditions.setMatchETag(conditions.matchETag);
|
||||
}
|
||||
if (conditions.matchETagExcept) {
|
||||
_conditions.setMatchETagExcept(conditions.matchETagExcept);
|
||||
}
|
||||
conditions = _conditions;
|
||||
return this.client.copyObject(
|
||||
bucketName,
|
||||
objectName,
|
||||
sourceObject,
|
||||
conditions
|
||||
);
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Gets metadata of an object.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
*
|
||||
* @returns {PromiseLike<{size: {number}, metaData: {object}, lastModified: {string}, etag: {string}}|Error>}
|
||||
*/
|
||||
statObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.statObject(
|
||||
ctx.params.bucketName,
|
||||
ctx.params.objectName
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Removes an Object
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
*
|
||||
* @returns {PromiseLike<undefined|Error>}
|
||||
*/
|
||||
removeObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.removeObject(
|
||||
ctx.params.bucketName,
|
||||
ctx.params.objectName
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Removes a list of Objects
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string[]} objectNames - Names of the objects.
|
||||
*
|
||||
* @returns {PromiseLike<undefined|Error>}
|
||||
*/
|
||||
removeObjects: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectNames: { type: 'array', items: 'string' },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.client.removeObjects(
|
||||
ctx.params.bucketName,
|
||||
ctx.params.objectNames
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Removes a partially uploaded object.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
*
|
||||
* @returns {PromiseLike<undefined|Error>}
|
||||
*/
|
||||
removeIncompleteUpload: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({ bucketName, objectName }) =>
|
||||
this.client.removeIncompleteUpload(bucketName, objectName)
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Generates a presigned URL for the provided HTTP method, 'httpMethod'. Browsers/Mobile clients may point to this URL to directly download objects even if the bucket is private. This
|
||||
* presigned URL can have an associated expiration time in seconds after which the URL is no longer valid. The default value is 7 days.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} httpMethod - The HTTP-Method (eg. `GET`).
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
* @param {number} expires - Expiry time in seconds. Default value is 7 days. (optional)
|
||||
* @param {object} reqParams - request parameters. (optional)
|
||||
* @param {string} requestDate - An ISO date string, the url will be issued at. Default value is now. (optional)
|
||||
* @returns {PromiseLike<String|Error>}
|
||||
*/
|
||||
presignedUrl: {
|
||||
params: {
|
||||
httpMethod: { type: 'string' },
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
expires: { type: 'number', integer: true, optional: true },
|
||||
reqParams: { type: 'object', optional: true },
|
||||
requestDate: { type: 'string', optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({
|
||||
httpMethod,
|
||||
bucketName,
|
||||
objectName,
|
||||
expires,
|
||||
reqParams,
|
||||
requestDate,
|
||||
}) => {
|
||||
if (isString(requestDate)) {
|
||||
requestDate = new Date(requestDate);
|
||||
}
|
||||
|
||||
return new this.Promise((resolve, reject) => {
|
||||
this.client.presignedUrl(
|
||||
httpMethod,
|
||||
bucketName,
|
||||
objectName,
|
||||
expires,
|
||||
reqParams,
|
||||
requestDate,
|
||||
(error, url) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(url);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Generates a presigned URL for HTTP GET operations. Browsers/Mobile clients may point to this URL to directly download objects even if the bucket is private. This presigned URL can have an
|
||||
* associated expiration time in seconds after which the URL is no longer valid. The default value is 7 days.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
* @param {number} expires - Expiry time in seconds. Default value is 7 days. (optional)
|
||||
* @param {object} reqParams - request parameters. (optional)
|
||||
* @param {string} requestDate - An ISO date string, the url will be issued at. Default value is now. (optional)
|
||||
* @returns {PromiseLike<String|Error>}
|
||||
*/
|
||||
presignedGetObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
expires: { type: 'number', integer: true, optional: true },
|
||||
reqParams: { type: 'object', optional: true },
|
||||
requestDate: { type: 'string', optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({ bucketName, objectName, expires, reqParams, requestDate }) => {
|
||||
if (isString(requestDate)) {
|
||||
requestDate = new Date(requestDate);
|
||||
}
|
||||
|
||||
return new this.Promise((resolve, reject) => {
|
||||
this.client.presignedGetObject(
|
||||
bucketName,
|
||||
objectName,
|
||||
expires,
|
||||
reqParams,
|
||||
requestDate,
|
||||
(error, url) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(url);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Generates a presigned URL for HTTP PUT operations. Browsers/Mobile clients may point to this URL to upload objects directly to a bucket even if it is private. This presigned URL can have
|
||||
* an associated expiration time in seconds after which the URL is no longer valid. The default value is 7 days.
|
||||
*
|
||||
* @actions
|
||||
* @param {string} bucketName - Name of the bucket.
|
||||
* @param {string} objectName - Name of the object.
|
||||
* @param {number} expires - Expiry time in seconds. Default value is 7 days. (optional)
|
||||
* @returns {PromiseLike<String|Error>}
|
||||
*/
|
||||
presignedPutObject: {
|
||||
params: {
|
||||
bucketName: { type: 'string' },
|
||||
objectName: { type: 'string' },
|
||||
expires: { type: 'number', integer: true, optional: true },
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(
|
||||
({ bucketName, objectName, expires }) => {
|
||||
return new this.Promise((resolve, reject) => {
|
||||
this.client.presignedPutObject(
|
||||
bucketName,
|
||||
objectName,
|
||||
expires,
|
||||
(error, url) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
} else {
|
||||
resolve(url);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
);
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Allows setting policy conditions to a presigned URL for POST operations. Policies such as bucket name to receive object uploads, key name prefixes, expiry policy may be set.
|
||||
*
|
||||
* @actions
|
||||
* @param {object} policy - Policy object created by minioClient.newPostPolicy()
|
||||
* @returns {PromiseLike<{postURL: {string}, formData: {object}}|Error>}
|
||||
*/
|
||||
presignedPostPolicy: {
|
||||
params: {
|
||||
policy: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
expires: { type: 'string', optional: true },
|
||||
key: { type: 'string', optional: true },
|
||||
keyStartsWith: { type: 'string', optional: true },
|
||||
bucket: { type: 'string', optional: true },
|
||||
contentType: { type: 'string', optional: true },
|
||||
contentLengthRangeMin: {
|
||||
type: 'number',
|
||||
integer: true,
|
||||
optional: true,
|
||||
},
|
||||
contentLengthRangeMax: {
|
||||
type: 'number',
|
||||
integer: true,
|
||||
optional: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
handler(ctx) {
|
||||
return this.Promise.resolve(ctx.params).then(({ policy }) => {
|
||||
const _policy = this.client.newPostPolicy();
|
||||
if (policy.expires) {
|
||||
_policy.setExpires(new Date(policy.expires));
|
||||
}
|
||||
if (policy.key) {
|
||||
_policy.setKey(policy.key);
|
||||
}
|
||||
if (policy.keyStartsWith) {
|
||||
_policy.setKeyStartsWith(policy.keyStartsWith);
|
||||
}
|
||||
if (policy.bucket) {
|
||||
_policy.setBucket(policy.bucket);
|
||||
}
|
||||
if (policy.contentType) {
|
||||
_policy.setContentType(policy.contentType);
|
||||
}
|
||||
if (policy.contentLengthRangeMin && policy.contentLengthRangeMax) {
|
||||
_policy.setContentLengthRange(
|
||||
policy.contentLengthRangeMin,
|
||||
policy.contentLengthRangeMax
|
||||
);
|
||||
}
|
||||
return this.client.presignedPostPolicy(_policy);
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Service created lifecycle event handler.
|
||||
* Constructs a new minio client entity
|
||||
*/
|
||||
created() {
|
||||
this.client = this.createMinioClient();
|
||||
},
|
||||
/**
|
||||
* Service started lifecycle event handler. Resolves when:
|
||||
* * ping of S3 backend has been successful
|
||||
* * a healthCheck has been registered, given minioHealthCheckInterval > 0
|
||||
* @returns {PromiseLike<undefined|MinioInitializationError>}
|
||||
*/
|
||||
started() {
|
||||
/* istanbul ignore next */
|
||||
return this.Promise.resolve()
|
||||
.then(() => this.ping())
|
||||
.then(() => {
|
||||
this.settings.minioHealthCheckInterval
|
||||
? (this.healthCheckInterval = setInterval(
|
||||
() =>
|
||||
this.ping().catch((e) =>
|
||||
this.logger.error('Minio backend can not be reached', e)
|
||||
),
|
||||
this.settings.minioHealthCheckInterval
|
||||
))
|
||||
: undefined;
|
||||
return undefined;
|
||||
})
|
||||
.catch((e) => {
|
||||
throw new MinioInitializationError(e.message);
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Service stopped lifecycle event handler.
|
||||
* Removes the healthCheckInterval
|
||||
*/
|
||||
stopped() {
|
||||
this.healthCheckInterval && clearInterval(this.healthCheckInterval);
|
||||
},
|
||||
};
|
||||
68
server/packages/sdk/src/services/types.ts
Normal file
68
server/packages/sdk/src/services/types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Context } from 'moleculer';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { GroupStruct } from '../structs/group';
|
||||
import type { BuiltinEventMap } from '../structs/events';
|
||||
|
||||
export type {
|
||||
ServiceSchema as PureServiceSchema,
|
||||
Service as PureService,
|
||||
} from 'moleculer';
|
||||
|
||||
export interface UserJWTPayload {
|
||||
_id: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface TranslationMeta {
|
||||
t: TFunction;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export type PureContext<P = {}, M extends object = {}> = Context<P, M>;
|
||||
|
||||
export interface TcPureContext<P = {}, M = {}>
|
||||
extends Omit<Context<P>, 'emit'> {
|
||||
meta: TranslationMeta & M;
|
||||
|
||||
// 事件类型重写
|
||||
emit<K extends string>(
|
||||
eventName: K,
|
||||
data: K extends keyof BuiltinEventMap ? BuiltinEventMap[K] : unknown,
|
||||
groups?: string | string[]
|
||||
): Promise<void>;
|
||||
emit(eventName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type TcContext<P = {}, M = {}> = TcPureContext<
|
||||
P,
|
||||
{
|
||||
user: UserJWTPayload;
|
||||
token: string;
|
||||
userId: string;
|
||||
|
||||
/**
|
||||
* 仅在 socket.io 的请求中会出现
|
||||
*/
|
||||
socketId?: string;
|
||||
|
||||
/**
|
||||
* 仅在 afterActionHook 请求中会出现
|
||||
*/
|
||||
actionResult?: any;
|
||||
} & M
|
||||
>;
|
||||
|
||||
export type GroupBaseInfo = Pick<
|
||||
GroupStruct,
|
||||
'name' | 'avatar' | 'owner' | 'description'
|
||||
> & {
|
||||
memberCount: number;
|
||||
backgroundImage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 面板能力
|
||||
*/
|
||||
export type PanelFeature = 'subscribe'; // 订阅变更,即用户登录时自动加入面板的房间
|
||||
71
server/packages/sdk/src/structs/chat.ts
Normal file
71
server/packages/sdk/src/structs/chat.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type { InboxItem } from 'tailchat-types';
|
||||
|
||||
export interface MessageReactionStruct {
|
||||
name: string;
|
||||
author?: string;
|
||||
}
|
||||
|
||||
export interface MessageStruct {
|
||||
_id: string;
|
||||
content: string;
|
||||
author: string;
|
||||
groupId?: string;
|
||||
converseId: string;
|
||||
hasRecall: boolean;
|
||||
reactions: MessageReactionStruct[];
|
||||
}
|
||||
|
||||
export interface MessageMetaStruct {
|
||||
mentions?: string[];
|
||||
reply?: {
|
||||
_id: string;
|
||||
author: string;
|
||||
content: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface InboxMessageStruct {
|
||||
/**
|
||||
* 消息所在群组Id
|
||||
*/
|
||||
groupId?: string;
|
||||
|
||||
/**
|
||||
* 消息所在会话Id
|
||||
*/
|
||||
converseId: string;
|
||||
|
||||
/**
|
||||
* 消息ID
|
||||
*/
|
||||
messageId: string;
|
||||
|
||||
/**
|
||||
* 消息片段,用于消息的预览/发送通知
|
||||
*/
|
||||
messageSnippet: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收件箱记录项类型
|
||||
*/
|
||||
export interface BasicInboxItem {
|
||||
_id: string;
|
||||
userId: string;
|
||||
type: string;
|
||||
message?: InboxMessageStruct;
|
||||
|
||||
/**
|
||||
* 信息体,没有类型
|
||||
*/
|
||||
payload?: object;
|
||||
|
||||
/**
|
||||
* 是否已读
|
||||
*/
|
||||
readed: boolean;
|
||||
}
|
||||
|
||||
export type InboxStruct = InboxItem;
|
||||
|
||||
export type { ChatConverse as ChatConverseStruct } from 'tailchat-types';
|
||||
28
server/packages/sdk/src/structs/events.ts
Normal file
28
server/packages/sdk/src/structs/events.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { InboxStruct, MessageMetaStruct } from './chat';
|
||||
|
||||
/**
|
||||
* 默认服务的事件映射
|
||||
*/
|
||||
export interface BuiltinEventMap {
|
||||
'gateway.auth.addWhitelists': { urls: string[] };
|
||||
'chat.message.updateMessage':
|
||||
| {
|
||||
type: 'add';
|
||||
groupId?: string;
|
||||
converseId: string;
|
||||
messageId: string;
|
||||
author: string;
|
||||
content: string;
|
||||
plain?: string;
|
||||
meta: MessageMetaStruct;
|
||||
}
|
||||
| {
|
||||
type: 'recall' | 'delete';
|
||||
groupId?: string;
|
||||
converseId: string;
|
||||
messageId: string;
|
||||
meta: MessageMetaStruct;
|
||||
};
|
||||
'config.updated': { config: Record<string, any> };
|
||||
'chat.inbox.append': InboxStruct;
|
||||
}
|
||||
63
server/packages/sdk/src/structs/group.ts
Normal file
63
server/packages/sdk/src/structs/group.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export enum GroupPanelType {
|
||||
TEXT = 0,
|
||||
GROUP = 1,
|
||||
PLUGIN = 2,
|
||||
}
|
||||
|
||||
// TODO
|
||||
export type GroupPanelMeta = {};
|
||||
|
||||
interface GroupMemberStruct {
|
||||
roles?: string[]; // 角色
|
||||
|
||||
userId: string;
|
||||
|
||||
muteUntil?: string;
|
||||
}
|
||||
|
||||
export interface GroupPanelStruct {
|
||||
id: string; // 在群组中唯一, 可以用任意方式进行生成。这里使用ObjectId, 但不是ObjectId类型
|
||||
|
||||
name: string; // 用于显示的名称
|
||||
|
||||
parentId?: string; // 父节点id
|
||||
|
||||
type: GroupPanelType; // 面板类型: Reference: https://discord.com/developers/docs/resources/channel#channel-object-channel-types
|
||||
|
||||
provider?: string; // 面板提供者,为插件的标识,仅面板类型为插件时有效
|
||||
|
||||
pluginPanelName?: string; // 插件面板名, 如 com.msgbyte.webview/grouppanel
|
||||
|
||||
/**
|
||||
* 面板的其他数据
|
||||
*/
|
||||
meta?: GroupPanelMeta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 群组权限组
|
||||
*/
|
||||
export interface GroupRoleStruct {
|
||||
name: string; // 权限组名
|
||||
permissions: string[]; // 拥有的权限, 是一段字符串
|
||||
}
|
||||
|
||||
export interface GroupStruct {
|
||||
_id: string;
|
||||
|
||||
name: string;
|
||||
|
||||
avatar?: string;
|
||||
|
||||
owner: string;
|
||||
|
||||
description?: string;
|
||||
|
||||
members: GroupMemberStruct[];
|
||||
|
||||
panels: GroupPanelStruct[];
|
||||
|
||||
roles?: GroupRoleStruct[];
|
||||
|
||||
config: Record<string, any>;
|
||||
}
|
||||
9
server/packages/sdk/src/structs/user.ts
Normal file
9
server/packages/sdk/src/structs/user.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import type { UserBaseInfo, UserInfoWithToken, UserType } from 'tailchat-types';
|
||||
import { userType } from 'tailchat-types';
|
||||
|
||||
export {
|
||||
userType,
|
||||
UserType,
|
||||
UserBaseInfo as UserStruct,
|
||||
UserInfoWithToken as UserStructWithToken,
|
||||
};
|
||||
16
server/packages/sdk/src/utils.ts
Normal file
16
server/packages/sdk/src/utils.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const noConflictKey = '__';
|
||||
|
||||
/**
|
||||
* 因为微服务的名称中经常会有 `.` , 而 `.` 在一些场景(如lodash.set) 有特殊含义,因此增加一个工具用于解决这个问题
|
||||
*/
|
||||
export function encodeNoConflictServiceNameKey(key: string): string {
|
||||
return key.replaceAll('.', noConflictKey);
|
||||
}
|
||||
|
||||
export function decodeNoConflictServiceNameKey(key: string): string {
|
||||
if (typeof key !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return key.replaceAll(noConflictKey, '.');
|
||||
}
|
||||
13
server/packages/sdk/tsconfig.json
Normal file
13
server/packages/sdk/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"skipLibCheck": true,
|
||||
"typeRoots": ["./node_modules/@types"],
|
||||
"rootDir": "./src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["./src/**/*"],
|
||||
"exclude": ["node_modules/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user