Compare commits
26 Commits
0e350b1bca
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 266b7b33de | |||
| 5200cf4c38 | |||
| bf436f05e3 | |||
| bd7525efb0 | |||
| f616045625 | |||
| a51d04341e | |||
| 7bbefd8a4a | |||
| db5c5a89b3 | |||
| 86d4c7f75b | |||
| cbc9b54284 | |||
| b1d3ca241c | |||
| 158dd9e85c | |||
| 82e3392b36 | |||
| a0f0f65d20 | |||
| 337abd2bc3 | |||
| 32167faf67 | |||
| 82c9b834e9 | |||
| 7c61bd3ac5 | |||
| eab14b9fe3 | |||
| 296286bdaf | |||
| 6100e9833d | |||
| 08023d6878 | |||
| c3624a59de | |||
| c4e5668b29 | |||
| 27f170ae2b | |||
| 3464bd240b |
150
.claude/skills/create-routes/SKILL.md
Normal file
150
.claude/skills/create-routes/SKILL.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
---
|
||||||
|
name: create-routes
|
||||||
|
description: 创建路由例子模板代码
|
||||||
|
---
|
||||||
|
# 创建路由例子模板代码
|
||||||
|
|
||||||
|
app是自定义@kevisual/router的一个APP
|
||||||
|
|
||||||
|
1. 一般来说,修改path,和对应的schema表,就可以快速创建对应的增删改查接口。
|
||||||
|
2. 根据需要,每一个功能需要添加对应的描述
|
||||||
|
3. 根据需要,对应schema表的字段进行修改代码
|
||||||
|
|
||||||
|
示例:
|
||||||
|
```ts
|
||||||
|
import { desc, eq, count, or, like, and } from 'drizzle-orm';
|
||||||
|
import { schema, app, db } from '@/app.ts'
|
||||||
|
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'prompts',
|
||||||
|
key: 'list',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '获取提示词列表',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const uid = tokenUser.id;
|
||||||
|
const { page = 1, pageSize = 20, search, sort = 'DESC' } = ctx.query || {};
|
||||||
|
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const orderByField = sort === 'ASC' ? schema.prompts.updatedAt : desc(schema.prompts.updatedAt);
|
||||||
|
|
||||||
|
let whereCondition = eq(schema.prompts.uid, uid);
|
||||||
|
if (search) {
|
||||||
|
whereCondition = and(
|
||||||
|
eq(schema.prompts.uid, uid),
|
||||||
|
or(
|
||||||
|
like(schema.prompts.title, `%${search}%`),
|
||||||
|
like(schema.prompts.summary, `%${search}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [list, totalCount] = await Promise.all([
|
||||||
|
db.select()
|
||||||
|
.from(schema.prompts)
|
||||||
|
.where(whereCondition)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset)
|
||||||
|
.orderBy(orderByField),
|
||||||
|
db.select({ count: count() })
|
||||||
|
.from(schema.prompts)
|
||||||
|
.where(whereCondition)
|
||||||
|
]);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
list,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: totalCount[0]?.count || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
const promptUpdate = `创建或更新一个提示词, 参数定义:
|
||||||
|
title: 提示词标题, 必填
|
||||||
|
description: 描述, 选填
|
||||||
|
summary: 摘要, 选填
|
||||||
|
tags: 标签, 数组, 选填
|
||||||
|
link: 链接, 选填
|
||||||
|
data: 数据, 对象, 选填
|
||||||
|
parents: 父级ID数组, 选填
|
||||||
|
`;
|
||||||
|
app.route({
|
||||||
|
path: 'prompts',
|
||||||
|
key: 'update',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: promptUpdate,
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const { id, uid, updatedAt, ...rest } = ctx.query.data || {};
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
let prompt;
|
||||||
|
if (!id) {
|
||||||
|
prompt = await db.insert(schema.prompts).values({
|
||||||
|
title: rest.title,
|
||||||
|
description: rest.description,
|
||||||
|
...rest,
|
||||||
|
uid: tokenUser.id,
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
const existing = await db.select().from(schema.prompts).where(eq(schema.prompts.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的提示词');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限更新该提示词');
|
||||||
|
}
|
||||||
|
prompt = await db.update(schema.prompts).set({
|
||||||
|
...rest,
|
||||||
|
}).where(eq(schema.prompts.id, id)).returning();
|
||||||
|
}
|
||||||
|
ctx.body = prompt;
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'prompts',
|
||||||
|
key: 'delete',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '删除提示词, 参数: id 提示词ID',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query.data || {};
|
||||||
|
if (!id) {
|
||||||
|
ctx.throw(400, 'id 参数缺失');
|
||||||
|
}
|
||||||
|
const existing = await db.select().from(schema.prompts).where(eq(schema.prompts.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的提示词');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限删除该提示词');
|
||||||
|
}
|
||||||
|
await db.delete(schema.prompts).where(eq(schema.prompts.id, id));
|
||||||
|
ctx.body = { success: true };
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'prompts',
|
||||||
|
key: 'get',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '获取单个提示词, 参数: id 提示词ID',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query.data || {};
|
||||||
|
if (!id) {
|
||||||
|
ctx.throw(400, 'id 参数缺失');
|
||||||
|
}
|
||||||
|
const existing = await db.select().from(schema.prompts).where(eq(schema.prompts.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的提示词');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限查看该提示词');
|
||||||
|
}
|
||||||
|
ctx.body = existing[0];
|
||||||
|
}).addTo(app);
|
||||||
|
```
|
||||||
@@ -3,4 +3,11 @@ code center
|
|||||||
|
|
||||||
```
|
```
|
||||||
unzip -x app.config.json5
|
unzip -x app.config.json5
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
"@kevisual/oss": "file:/home/ubuntu/kevisual/dev3/kevisual-oss",
|
||||||
|
```
|
||||||
|
```
|
||||||
|
TODO: /home/ubuntu/kevisual/code-center/src/routes/file/module/get-minio-list.ts
|
||||||
```
|
```
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { useConfig } from '@kevisual/use-config/context';
|
||||||
import type { Config } from 'drizzle-kit';
|
import type { Config } from 'drizzle-kit';
|
||||||
import 'dotenv/config';
|
const config = useConfig();
|
||||||
const url = process.env.DATABASE_URL!;
|
const url = config.DATABASE_URL!;
|
||||||
|
console.log('Drizzle config using database url:', url);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
schema: './src/db/schema.ts',
|
schema: './src/db/schema.ts',
|
||||||
|
|||||||
34
package.json
34
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kevisual/code-center",
|
"name": "@kevisual/code-center",
|
||||||
"version": "0.0.11",
|
"version": "0.0.12",
|
||||||
"description": "code center",
|
"description": "code center",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
@@ -47,22 +47,23 @@
|
|||||||
],
|
],
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kevisual/ai": "^0.0.22",
|
"@kevisual/ai": "^0.0.24",
|
||||||
"@kevisual/auth": "^2.0.3",
|
"@kevisual/auth": "^2.0.3",
|
||||||
"@kevisual/query": "^0.0.38",
|
"@kevisual/js-filter": "^0.0.5",
|
||||||
|
"@kevisual/query": "^0.0.39",
|
||||||
"@types/busboy": "^1.5.4",
|
"@types/busboy": "^1.5.4",
|
||||||
"@types/send": "^1.2.1",
|
"@types/send": "^1.2.1",
|
||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"bullmq": "^5.67.0",
|
"bullmq": "^5.67.2",
|
||||||
"busboy": "^1.6.0",
|
"busboy": "^1.6.0",
|
||||||
"commander": "^14.0.2",
|
"commander": "^14.0.3",
|
||||||
"drizzle-kit": "^0.31.8",
|
"drizzle-kit": "^0.31.8",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"drizzle-zod": "^0.8.3",
|
"drizzle-zod": "^0.8.3",
|
||||||
"eventemitter3": "^5.0.4",
|
"eventemitter3": "^5.0.4",
|
||||||
"ioredis": "^5.9.2",
|
"ioredis": "^5.9.2",
|
||||||
"minio": "^8.0.6",
|
"minio": "^8.0.6",
|
||||||
"pg": "^8.17.2",
|
"pg": "^8.18.0",
|
||||||
"pm2": "^6.0.14",
|
"pm2": "^6.0.14",
|
||||||
"send": "^1.2.1",
|
"send": "^1.2.1",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
@@ -71,26 +72,29 @@
|
|||||||
"zod-to-json-schema": "^3.25.1"
|
"zod-to-json-schema": "^3.25.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.981.0",
|
||||||
|
"@kevisual/api": "^0.0.44",
|
||||||
"@kevisual/code-center-module": "0.0.24",
|
"@kevisual/code-center-module": "0.0.24",
|
||||||
"@kevisual/context": "^0.0.4",
|
"@kevisual/context": "^0.0.4",
|
||||||
"@kevisual/file-listener": "^0.0.2",
|
"@kevisual/file-listener": "^0.0.2",
|
||||||
"@kevisual/local-app-manager": "0.1.32",
|
"@kevisual/local-app-manager": "0.1.32",
|
||||||
"@kevisual/logger": "^0.0.4",
|
"@kevisual/logger": "^0.0.4",
|
||||||
"@kevisual/oss": "0.0.16",
|
"@kevisual/oss": "0.0.19",
|
||||||
"@kevisual/permission": "^0.0.3",
|
"@kevisual/permission": "^0.0.4",
|
||||||
"@kevisual/router": "0.0.60",
|
"@kevisual/router": "0.0.70",
|
||||||
"@kevisual/types": "^0.0.12",
|
"@kevisual/types": "^0.0.12",
|
||||||
"@kevisual/use-config": "^1.0.28",
|
"@kevisual/use-config": "^1.0.30",
|
||||||
"@types/archiver": "^7.0.0",
|
"@types/archiver": "^7.0.0",
|
||||||
"@types/bun": "^1.3.6",
|
"@types/bun": "^1.3.8",
|
||||||
"@types/crypto-js": "^4.2.2",
|
"@types/crypto-js": "^4.2.2",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"@types/node": "^25.0.10",
|
"@types/node": "^25.2.0",
|
||||||
|
"@types/pg": "^8.16.0",
|
||||||
"@types/semver": "^7.7.1",
|
"@types/semver": "^7.7.1",
|
||||||
"@types/xml2js": "^0.4.14",
|
"@types/xml2js": "^0.4.14",
|
||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^12.6.2",
|
"better-sqlite3": "^12.6.2",
|
||||||
"convex": "^1.31.6",
|
"convex": "^1.31.7",
|
||||||
"crypto-js": "^4.2.0",
|
"crypto-js": "^4.2.0",
|
||||||
"dayjs": "^1.11.19",
|
"dayjs": "^1.11.19",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@@ -101,7 +105,7 @@
|
|||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.11",
|
||||||
"p-queue": "^9.1.0",
|
"p-queue": "^9.1.0",
|
||||||
"pg": "^8.17.2",
|
"pg": "^8.18.0",
|
||||||
"pm2": "^6.0.14",
|
"pm2": "^6.0.14",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
@@ -117,5 +121,5 @@
|
|||||||
"better-sqlite3"
|
"better-sqlite3"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.28.1"
|
"packageManager": "pnpm@10.28.2"
|
||||||
}
|
}
|
||||||
1487
pnpm-lock.yaml
generated
1487
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
19
src/app.ts
19
src/app.ts
@@ -1,14 +1,13 @@
|
|||||||
import { App } from '@kevisual/router';
|
import { App } from '@kevisual/router';
|
||||||
import * as redisLib from './modules/redis.ts';
|
import * as redisLib from './modules/redis.ts';
|
||||||
import * as minioLib from './modules/minio.ts';
|
|
||||||
import * as sequelizeLib from './modules/sequelize.ts';
|
import * as sequelizeLib from './modules/sequelize.ts';
|
||||||
import { useContextKey } from '@kevisual/context';
|
import { useContextKey } from '@kevisual/context';
|
||||||
import { SimpleRouter } from '@kevisual/router/simple';
|
import { SimpleRouter } from '@kevisual/router/simple';
|
||||||
import { OssBase } from '@kevisual/oss/services';
|
import { s3Client, oss as s3Oss } from './modules/s3.ts';
|
||||||
import { BailianProvider } from '@kevisual/ai';
|
import { BailianProvider } from '@kevisual/ai';
|
||||||
import * as schema from './db/schema.ts';
|
import * as schema from './db/schema.ts';
|
||||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
|
||||||
import { config } from './modules/config.ts'
|
import { config } from './modules/config.ts'
|
||||||
|
import { db } from './modules/db.ts'
|
||||||
export const router = useContextKey('router', () => new SimpleRouter());
|
export const router = useContextKey('router', () => new SimpleRouter());
|
||||||
export const runtime = useContextKey('runtime', () => {
|
export const runtime = useContextKey('runtime', () => {
|
||||||
return {
|
return {
|
||||||
@@ -18,21 +17,13 @@ export const runtime = useContextKey('runtime', () => {
|
|||||||
});
|
});
|
||||||
export const oss = useContextKey(
|
export const oss = useContextKey(
|
||||||
'oss',
|
'oss',
|
||||||
() =>
|
() => s3Oss,
|
||||||
new OssBase({
|
|
||||||
client: minioLib.minioClient,
|
|
||||||
bucketName: minioLib.bucketName,
|
|
||||||
prefix: '',
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
export { s3Client }
|
||||||
export const redis = useContextKey('redis', () => redisLib.redis);
|
export const redis = useContextKey('redis', () => redisLib.redis);
|
||||||
export const subscriber = useContextKey('subscriber', () => redisLib.subscriber);
|
export const subscriber = useContextKey('subscriber', () => redisLib.subscriber);
|
||||||
export const minioClient = useContextKey('minioClient', () => minioLib.minioClient);
|
|
||||||
export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize);
|
export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize);
|
||||||
export const db = useContextKey('db', () => {
|
export { db };
|
||||||
const db = drizzle(config.DATABASE_URL || '');
|
|
||||||
return db;
|
|
||||||
})
|
|
||||||
const init = () => {
|
const init = () => {
|
||||||
return new App({
|
return new App({
|
||||||
serverOptions: {
|
serverOptions: {
|
||||||
|
|||||||
@@ -62,7 +62,13 @@ export class User extends Model {
|
|||||||
oauthUser.orgId = id;
|
oauthUser.orgId = id;
|
||||||
}
|
}
|
||||||
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand });
|
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand });
|
||||||
return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken };
|
return {
|
||||||
|
accessToken: token.accessToken,
|
||||||
|
refreshToken: token.refreshToken,
|
||||||
|
token: token.accessToken,
|
||||||
|
refreshTokenExpiresIn: token.refreshTokenExpiresIn,
|
||||||
|
accessTokenExpiresIn: token.accessTokenExpiresIn,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* 验证token
|
* 验证token
|
||||||
@@ -183,7 +189,7 @@ export class User extends Model {
|
|||||||
avatar: this.avatar,
|
avatar: this.avatar,
|
||||||
orgs,
|
orgs,
|
||||||
};
|
};
|
||||||
if(this.data?.canChangeUsername) {
|
if (this.data?.canChangeUsername) {
|
||||||
info.canChangeUsername = this.data.canChangeUsername;
|
info.canChangeUsername = this.data.canChangeUsername;
|
||||||
}
|
}
|
||||||
const tokenUser = this.tokenUser;
|
const tokenUser = this.tokenUser;
|
||||||
@@ -232,6 +238,17 @@ export class User extends Model {
|
|||||||
async expireOrgs() {
|
async expireOrgs() {
|
||||||
await redis.del(`user:${this.id}:orgs`);
|
await redis.del(`user:${this.id}:orgs`);
|
||||||
}
|
}
|
||||||
|
static async getUserNameById(id: string) {
|
||||||
|
const redisName = await redis.get(`user:id:${id}:name`);
|
||||||
|
if (redisName) {
|
||||||
|
return redisName;
|
||||||
|
}
|
||||||
|
const user = await User.findByPk(id);
|
||||||
|
if (user?.username) {
|
||||||
|
await redis.set(`user:id:${id}:name`, user.username, 'EX', 60 * 60); // 1 hour
|
||||||
|
}
|
||||||
|
return user?.username;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export type SyncOpts = {
|
export type SyncOpts = {
|
||||||
alter?: boolean;
|
alter?: boolean;
|
||||||
|
|||||||
@@ -70,9 +70,16 @@ interface Store<T> {
|
|||||||
expire: (key: string, ttl?: number) => Promise<void>;
|
expire: (key: string, ttl?: number) => Promise<void>;
|
||||||
delObject: (value?: T) => Promise<void>;
|
delObject: (value?: T) => Promise<void>;
|
||||||
keys: (key?: string) => Promise<string[]>;
|
keys: (key?: string) => Promise<string[]>;
|
||||||
setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise<void>;
|
setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise<TokenData>;
|
||||||
delKeys: (keys: string[]) => Promise<number>;
|
delKeys: (keys: string[]) => Promise<number>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TokenData = {
|
||||||
|
accessToken: string;
|
||||||
|
accessTokenExpiresIn?: number;
|
||||||
|
refreshToken?: string;
|
||||||
|
refreshTokenExpiresIn?: number;
|
||||||
|
}
|
||||||
export class RedisTokenStore implements Store<OauthUser> {
|
export class RedisTokenStore implements Store<OauthUser> {
|
||||||
redis: Redis;
|
redis: Redis;
|
||||||
private prefix: string = 'oauth:';
|
private prefix: string = 'oauth:';
|
||||||
@@ -131,7 +138,7 @@ export class RedisTokenStore implements Store<OauthUser> {
|
|||||||
await this.del(userPrefix + ':token:' + accessToken);
|
await this.del(userPrefix + ':token:' + accessToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser }, opts?: StoreSetOpts) {
|
async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser }, opts?: StoreSetOpts): Promise<TokenData> {
|
||||||
const { accessToken, refreshToken, value } = data;
|
const { accessToken, refreshToken, value } = data;
|
||||||
let userPrefix = 'user:' + value?.id;
|
let userPrefix = 'user:' + value?.id;
|
||||||
if (value?.orgId) {
|
if (value?.orgId) {
|
||||||
@@ -163,14 +170,20 @@ export class RedisTokenStore implements Store<OauthUser> {
|
|||||||
|
|
||||||
await this.set(accessToken, JSON.stringify(value), expire);
|
await this.set(accessToken, JSON.stringify(value), expire);
|
||||||
await this.set(userPrefix + ':token:' + accessToken, accessToken, expire);
|
await this.set(userPrefix + ':token:' + accessToken, accessToken, expire);
|
||||||
|
let refreshTokenExpiresIn = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
let refreshTokenExpire = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年
|
|
||||||
// 小于7天, 则设置为7天
|
// 小于7天, 则设置为7天
|
||||||
if (refreshTokenExpire < 60 * 60 * 24 * 7) {
|
if (refreshTokenExpiresIn < 60 * 60 * 24 * 7) {
|
||||||
refreshTokenExpire = 60 * 60 * 24 * 7;
|
refreshTokenExpiresIn = 60 * 60 * 24 * 7;
|
||||||
}
|
}
|
||||||
await this.set(refreshToken, JSON.stringify(value), refreshTokenExpire);
|
await this.set(refreshToken, JSON.stringify(value), refreshTokenExpiresIn);
|
||||||
await this.set(userPrefix + ':refreshToken:' + refreshToken, refreshToken, refreshTokenExpire);
|
await this.set(userPrefix + ':refreshToken:' + refreshToken, refreshToken, refreshTokenExpiresIn);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
accessTokenExpiresIn: expire,
|
||||||
|
refreshToken,
|
||||||
|
refreshTokenExpiresIn: refreshTokenExpiresIn,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async delKeys(keys: string[]) {
|
async delKeys(keys: string[]) {
|
||||||
@@ -206,10 +219,7 @@ export class OAuth<T extends OauthUser> {
|
|||||||
async generateToken(
|
async generateToken(
|
||||||
user: T,
|
user: T,
|
||||||
expandOpts?: StoreSetOpts,
|
expandOpts?: StoreSetOpts,
|
||||||
): Promise<{
|
): Promise<TokenData> {
|
||||||
accessToken: string;
|
|
||||||
refreshToken?: string;
|
|
||||||
}> {
|
|
||||||
// 拥有refreshToken 为 true 时,accessToken 为 st_ 开头,refreshToken 为 rk_开头
|
// 拥有refreshToken 为 true 时,accessToken 为 st_ 开头,refreshToken 为 rk_开头
|
||||||
// 意思是secretToken 和 secretKey的缩写
|
// 意思是secretToken 和 secretKey的缩写
|
||||||
const accessToken = expandOpts?.hasRefreshToken ? 'st_' + randomId32() : 'sk_' + randomId64();
|
const accessToken = expandOpts?.hasRefreshToken ? 'st_' + randomId32() : 'sk_' + randomId64();
|
||||||
@@ -227,9 +237,9 @@ export class OAuth<T extends OauthUser> {
|
|||||||
user.oauthExpand.refreshToken = refreshToken;
|
user.oauthExpand.refreshToken = refreshToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.store.setToken({ accessToken, refreshToken, value: user }, expandOpts);
|
const tokenData = await this.store.setToken({ accessToken, refreshToken, value: user }, expandOpts);
|
||||||
|
|
||||||
return { accessToken, refreshToken };
|
return tokenData;
|
||||||
}
|
}
|
||||||
async saveSecretKey(oauthUser: T, secretKey: string, opts?: StoreSetOpts) {
|
async saveSecretKey(oauthUser: T, secretKey: string, opts?: StoreSetOpts) {
|
||||||
// 生成一个secretKey
|
// 生成一个secretKey
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ export const testPromptTools = pgTable("TestPromptTools", {
|
|||||||
args: jsonb().notNull(),
|
args: jsonb().notNull(),
|
||||||
process: jsonb().notNull(),
|
process: jsonb().notNull(),
|
||||||
type: varchar({ length: 255 }).notNull(),
|
type: varchar({ length: 255 }).notNull(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const aiAgent = pgTable("ai_agent", {
|
export const aiAgent = pgTable("ai_agent", {
|
||||||
@@ -22,8 +22,8 @@ export const aiAgent = pgTable("ai_agent", {
|
|||||||
temperature: doublePrecision(),
|
temperature: doublePrecision(),
|
||||||
cache: varchar({ length: 255 }),
|
cache: varchar({ length: 255 }),
|
||||||
cacheName: varchar({ length: 255 }),
|
cacheName: varchar({ length: 255 }),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
model: varchar({ length: 255 }).notNull(),
|
model: varchar({ length: 255 }).notNull(),
|
||||||
data: json().default({}),
|
data: json().default({}),
|
||||||
status: varchar({ length: 255 }).default('open'),
|
status: varchar({ length: 255 }).default('open'),
|
||||||
@@ -43,8 +43,8 @@ export const appsTrades = pgTable("apps_trades", {
|
|||||||
type: varchar({ length: 255 }).default('alipay').notNull(),
|
type: varchar({ length: 255 }).default('alipay').notNull(),
|
||||||
data: jsonb().default({ "list": [] }),
|
data: jsonb().default({ "list": [] }),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
unique("apps_trades_out_trade_no_key").on(table.outTradeNo),
|
unique("apps_trades_out_trade_no_key").on(table.outTradeNo),
|
||||||
@@ -54,8 +54,8 @@ export const cfOrgs = pgTable("cf_orgs", {
|
|||||||
id: uuid().primaryKey().notNull(),
|
id: uuid().primaryKey().notNull(),
|
||||||
username: varchar({ length: 255 }).notNull(),
|
username: varchar({ length: 255 }).notNull(),
|
||||||
users: jsonb().default([]),
|
users: jsonb().default([]),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
description: varchar({ length: 255 }),
|
description: varchar({ length: 255 }),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
@@ -70,8 +70,8 @@ export const cfRouterCode = pgTable("cf_router_code", {
|
|||||||
project: varchar({ length: 255 }).default('default'),
|
project: varchar({ length: 255 }).default('default'),
|
||||||
code: text().default(''),
|
code: text().default(''),
|
||||||
type: enumCfRouterCodeType().default('route'),
|
type: enumCfRouterCodeType().default('route'),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
middleware: varchar({ length: 255 }).array().default(["RRAY[]::character varying[])::character varying(25"]),
|
middleware: varchar({ length: 255 }).array().default(["RRAY[]::character varying[])::character varying(25"]),
|
||||||
next: varchar({ length: 255 }).default(''),
|
next: varchar({ length: 255 }).default(''),
|
||||||
exec: text().default(''),
|
exec: text().default(''),
|
||||||
@@ -86,8 +86,8 @@ export const cfUser = pgTable("cf_user", {
|
|||||||
password: varchar({ length: 255 }),
|
password: varchar({ length: 255 }),
|
||||||
salt: varchar({ length: 255 }),
|
salt: varchar({ length: 255 }),
|
||||||
needChangePassword: boolean().default(false),
|
needChangePassword: boolean().default(false),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
description: text(),
|
description: text(),
|
||||||
data: jsonb().default({}),
|
data: jsonb().default({}),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
@@ -111,8 +111,8 @@ export const cfUserSecrets = pgTable("cf_user_secrets", {
|
|||||||
userId: uuid(),
|
userId: uuid(),
|
||||||
data: jsonb().default({}),
|
data: jsonb().default({}),
|
||||||
orgId: uuid(),
|
orgId: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const chatHistories = pgTable("chat_histories", {
|
export const chatHistories = pgTable("chat_histories", {
|
||||||
@@ -123,8 +123,8 @@ export const chatHistories = pgTable("chat_histories", {
|
|||||||
root: boolean().default(false),
|
root: boolean().default(false),
|
||||||
show: boolean().default(true),
|
show: boolean().default(true),
|
||||||
uid: varchar({ length: 255 }),
|
uid: varchar({ length: 255 }),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
role: varchar({ length: 255 }).default('user'),
|
role: varchar({ length: 255 }).default('user'),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,8 +135,8 @@ export const chatPrompts = pgTable("chat_prompts", {
|
|||||||
data: json(),
|
data: json(),
|
||||||
key: varchar({ length: 255 }).default('').notNull(),
|
key: varchar({ length: 255 }).default('').notNull(),
|
||||||
uid: varchar({ length: 255 }),
|
uid: varchar({ length: 255 }),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -146,8 +146,8 @@ export const chatSessions = pgTable("chat_sessions", {
|
|||||||
chatPromptId: uuid(),
|
chatPromptId: uuid(),
|
||||||
type: varchar({ length: 255 }).default('production'),
|
type: varchar({ length: 255 }).default('production'),
|
||||||
uid: varchar({ length: 255 }),
|
uid: varchar({ length: 255 }),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
title: varchar({ length: 255 }).default(''),
|
title: varchar({ length: 255 }).default(''),
|
||||||
key: varchar({ length: 255 }),
|
key: varchar({ length: 255 }),
|
||||||
});
|
});
|
||||||
@@ -159,8 +159,8 @@ export const fileSync = pgTable("file_sync", {
|
|||||||
stat: jsonb().default({}),
|
stat: jsonb().default({}),
|
||||||
data: jsonb().default({}),
|
data: jsonb().default({}),
|
||||||
checkedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
checkedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
}, (table) => [
|
}, (table) => [
|
||||||
index("file_sync_name_idx").using("btree", table.name.asc().nullsLast()),
|
index("file_sync_name_idx").using("btree", table.name.asc().nullsLast()),
|
||||||
]);
|
]);
|
||||||
@@ -177,8 +177,8 @@ export const kvAiChatHistory = pgTable("kv_ai_chat_history", {
|
|||||||
completionTokens: integer("completion_tokens").default(0),
|
completionTokens: integer("completion_tokens").default(0),
|
||||||
data: jsonb().default({}),
|
data: jsonb().default({}),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
version: integer().default(0),
|
version: integer().default(0),
|
||||||
type: varchar({ length: 255 }).default('keep').notNull(),
|
type: varchar({ length: 255 }).default('keep').notNull(),
|
||||||
});
|
});
|
||||||
@@ -189,8 +189,8 @@ export const kvApp = pgTable("kv_app", {
|
|||||||
version: varchar({ length: 255 }).default(''),
|
version: varchar({ length: 255 }).default(''),
|
||||||
key: varchar({ length: 255 }),
|
key: varchar({ length: 255 }),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
title: varchar({ length: 255 }).default(''),
|
title: varchar({ length: 255 }).default(''),
|
||||||
description: varchar({ length: 255 }).default(''),
|
description: varchar({ length: 255 }).default(''),
|
||||||
@@ -208,8 +208,8 @@ export const kvAppDomain = pgTable("kv_app_domain", {
|
|||||||
domain: varchar({ length: 255 }).notNull(),
|
domain: varchar({ length: 255 }).notNull(),
|
||||||
appId: varchar({ length: 255 }),
|
appId: varchar({ length: 255 }),
|
||||||
uid: varchar({ length: 255 }),
|
uid: varchar({ length: 255 }),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
data: jsonb(),
|
data: jsonb(),
|
||||||
status: varchar({ length: 255 }).default('running').notNull(),
|
status: varchar({ length: 255 }).default('running').notNull(),
|
||||||
@@ -222,8 +222,8 @@ export const kvAppList = pgTable("kv_app_list", {
|
|||||||
data: json().default({}),
|
data: json().default({}),
|
||||||
version: varchar({ length: 255 }).default(''),
|
version: varchar({ length: 255 }).default(''),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
key: varchar({ length: 255 }),
|
key: varchar({ length: 255 }),
|
||||||
status: varchar({ length: 255 }).default('running'),
|
status: varchar({ length: 255 }).default('running'),
|
||||||
@@ -237,24 +237,23 @@ export const kvConfig = pgTable("kv_config", {
|
|||||||
tags: jsonb().default([]),
|
tags: jsonb().default([]),
|
||||||
data: jsonb().default({}),
|
data: jsonb().default({}),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
hash: text().default(''),
|
hash: text().default(''),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const kvContainer = pgTable("kv_container", {
|
export const kvContainer = pgTable("kv_light_code", {
|
||||||
id: uuid().primaryKey().notNull(),
|
id: uuid().primaryKey().notNull().defaultRandom(),
|
||||||
title: text().default(''),
|
title: text().default(''),
|
||||||
description: text().default(''),
|
description: text().default(''),
|
||||||
type: varchar({ length: 255 }).default('render-js'),
|
type: text().default('render-js'),
|
||||||
code: text().default(''),
|
code: text().default(''),
|
||||||
data: json().default({}),
|
data: jsonb().default({}),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
tags: json().default([]),
|
tags: jsonb().default([]),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
|
||||||
hash: text().default(''),
|
hash: text().default(''),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,8 +262,8 @@ export const kvGithub = pgTable("kv_github", {
|
|||||||
title: varchar({ length: 255 }).default(''),
|
title: varchar({ length: 255 }).default(''),
|
||||||
githubToken: varchar({ length: 255 }).default(''),
|
githubToken: varchar({ length: 255 }).default(''),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -277,8 +276,8 @@ export const kvPackages = pgTable("kv_packages", {
|
|||||||
publish: jsonb().default({}),
|
publish: jsonb().default({}),
|
||||||
expand: jsonb().default({}),
|
expand: jsonb().default({}),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -289,8 +288,8 @@ export const kvPage = pgTable("kv_page", {
|
|||||||
type: varchar({ length: 255 }).default(''),
|
type: varchar({ length: 255 }).default(''),
|
||||||
data: json().default({}),
|
data: json().default({}),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
publish: json().default({}),
|
publish: json().default({}),
|
||||||
});
|
});
|
||||||
@@ -304,8 +303,8 @@ export const kvResource = pgTable("kv_resource", {
|
|||||||
version: varchar({ length: 255 }).default('0.0.0'),
|
version: varchar({ length: 255 }).default('0.0.0'),
|
||||||
data: json().default({}),
|
data: json().default({}),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -317,8 +316,8 @@ export const kvVip = pgTable("kv_vip", {
|
|||||||
startDate: timestamp({ withTimezone: true, mode: 'string' }),
|
startDate: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
endDate: timestamp({ withTimezone: true, mode: 'string' }),
|
endDate: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
data: jsonb().default({}),
|
data: jsonb().default({}),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
title: text().default('').notNull(),
|
title: text().default('').notNull(),
|
||||||
description: text().default('').notNull(),
|
description: text().default('').notNull(),
|
||||||
@@ -335,8 +334,8 @@ export const microAppsUpload = pgTable("micro_apps_upload", {
|
|||||||
share: boolean().default(false),
|
share: boolean().default(false),
|
||||||
uname: varchar({ length: 255 }).default(''),
|
uname: varchar({ length: 255 }).default(''),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const microMark = pgTable("micro_mark", {
|
export const microMark = pgTable("micro_mark", {
|
||||||
@@ -347,8 +346,8 @@ export const microMark = pgTable("micro_mark", {
|
|||||||
data: jsonb().default({}),
|
data: jsonb().default({}),
|
||||||
uname: varchar({ length: 255 }).default(''),
|
uname: varchar({ length: 255 }).default(''),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
cover: text().default(''),
|
cover: text().default(''),
|
||||||
thumbnail: text().default(''),
|
thumbnail: text().default(''),
|
||||||
link: text().default(''),
|
link: text().default(''),
|
||||||
@@ -380,8 +379,8 @@ export const workShareMark = pgTable("work_share_mark", {
|
|||||||
markedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
markedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
uid: uuid(),
|
uid: uuid(),
|
||||||
puid: uuid(),
|
puid: uuid(),
|
||||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull(),
|
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||||
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
deletedAt: timestamp({ withTimezone: true, mode: 'string' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -493,4 +492,47 @@ export const queryViews = pgTable("query_views", {
|
|||||||
}, (table) => [
|
}, (table) => [
|
||||||
index('query_views_uid_idx').using('btree', table.uid.asc().nullsLast()),
|
index('query_views_uid_idx').using('btree', table.uid.asc().nullsLast()),
|
||||||
index('query_title_idx').using('btree', table.title.asc().nullsLast()),
|
index('query_title_idx').using('btree', table.title.asc().nullsLast()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const flowme = pgTable("flowme", {
|
||||||
|
id: uuid().primaryKey().notNull().defaultRandom(),
|
||||||
|
uid: uuid(),
|
||||||
|
title: text('title').default(''),
|
||||||
|
description: text('description').default(''),
|
||||||
|
tags: jsonb().default([]),
|
||||||
|
link: text('link').default(''),
|
||||||
|
data: jsonb().default({}),
|
||||||
|
|
||||||
|
channelId: uuid().references(() => flowmeChannels.id, { onDelete: 'set null' }),
|
||||||
|
type: text('type').default(''),
|
||||||
|
source: text('source').default(''),
|
||||||
|
importance: integer('importance').default(0), // 重要性等级
|
||||||
|
isArchived: boolean('isArchived').default(false), // 是否归档
|
||||||
|
|
||||||
|
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
||||||
|
|
||||||
|
}, (table) => [
|
||||||
|
index('flowme_uid_idx').using('btree', table.uid.asc().nullsLast()),
|
||||||
|
index('flowme_title_idx').using('btree', table.title.asc().nullsLast()),
|
||||||
|
index('flowme_channel_id_idx').using('btree', table.channelId.asc().nullsLast()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
|
||||||
|
export const flowmeChannels = pgTable("flowme_channels", {
|
||||||
|
id: uuid().primaryKey().notNull().defaultRandom(),
|
||||||
|
uid: uuid(),
|
||||||
|
title: text('title').default(''),
|
||||||
|
key: text('key').default(''),
|
||||||
|
description: text('description').default(''),
|
||||||
|
tags: jsonb().default([]),
|
||||||
|
link: text('link').default(''),
|
||||||
|
data: jsonb().default({}),
|
||||||
|
color: text('color').default('#007bff'),
|
||||||
|
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
||||||
|
}, (table) => [
|
||||||
|
index('flowme_channels_uid_idx').using('btree', table.uid.asc().nullsLast()),
|
||||||
|
index('flowme_channels_key_idx').using('btree', table.key.asc().nullsLast()),
|
||||||
|
index('flowme_channels_title_idx').using('btree', table.title.asc().nullsLast()),
|
||||||
]);
|
]);
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useConfig } from '@kevisual/use-config';
|
import { useConfig } from '@kevisual/use-config';
|
||||||
import { useFileStore } from '@kevisual/use-config';
|
import { useFileStore } from '@kevisual/use-config';
|
||||||
import { minioResources } from './minio.ts';
|
import { minioResources } from './s3.ts';
|
||||||
|
|
||||||
export const config = useConfig() as any;
|
export const config = useConfig() as any;
|
||||||
export const port = config.PORT ? Number(config.PORT) : 4005;
|
export const port = config.PORT ? Number(config.PORT) : 4005;
|
||||||
|
|||||||
8
src/modules/db.ts
Normal file
8
src/modules/db.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||||
|
import { config } from './config.ts'
|
||||||
|
|
||||||
|
export const db = useContextKey('db', () => {
|
||||||
|
const db = drizzle(config.DATABASE_URL || '');
|
||||||
|
return db;
|
||||||
|
})
|
||||||
@@ -11,7 +11,6 @@ export const getTextContentType = (filePath: string, isFilePath = false) => {
|
|||||||
'.env',
|
'.env',
|
||||||
'.example',
|
'.example',
|
||||||
'.log',
|
'.log',
|
||||||
'.mjs',
|
|
||||||
'.map',
|
'.map',
|
||||||
'.json5',
|
'.json5',
|
||||||
'.pem',
|
'.pem',
|
||||||
|
|||||||
239
src/modules/fm-manager/proxy/ai-proxy-chunk/post-proxy.ts
Normal file
239
src/modules/fm-manager/proxy/ai-proxy-chunk/post-proxy.ts
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
|
||||||
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import busboy from 'busboy';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { createWriteStream } from 'fs';
|
||||||
|
import { parseSearchValue } from '@kevisual/router/src/server/parse-body.ts';
|
||||||
|
import { pipeBusboy } from '../../pipe-busboy.ts';
|
||||||
|
import { ProxyOptions, getMetadata, getObjectName } from '../ai-proxy.ts';
|
||||||
|
import { fileIsExist, useFileStore } from '@kevisual/use-config';
|
||||||
|
import { getContentType } from '../../get-content-type.ts';
|
||||||
|
|
||||||
|
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
||||||
|
|
||||||
|
export const postProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||||||
|
const _u = new URL(req.url, 'http://localhost');
|
||||||
|
|
||||||
|
const pathname = _u.pathname;
|
||||||
|
const oss = opts.oss;
|
||||||
|
const params = _u.searchParams;
|
||||||
|
const force = !!params.get('force');
|
||||||
|
const hash = params.get('hash');
|
||||||
|
const _fileSize: string = params.get('size');
|
||||||
|
let fileSize: number | undefined = undefined;
|
||||||
|
if (_fileSize) {
|
||||||
|
fileSize = parseInt(_fileSize, 10);
|
||||||
|
}
|
||||||
|
console.log('postProxy', { hash, force, fileSize });
|
||||||
|
let meta = parseSearchValue(params.get('meta'), { decode: true });
|
||||||
|
if (!hash && !force) {
|
||||||
|
return opts?.createNotFoundPage?.('no hash');
|
||||||
|
}
|
||||||
|
const { objectName, isOwner } = await getObjectName(req);
|
||||||
|
if (!isOwner) {
|
||||||
|
return opts?.createNotFoundPage?.('no permission');
|
||||||
|
}
|
||||||
|
const end = (data: any, message?: string, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: code, data: data, message: message || 'success' }));
|
||||||
|
};
|
||||||
|
let statMeta: any = {};
|
||||||
|
if (!force) {
|
||||||
|
const check = await oss.checkObjectHash(objectName, hash, meta);
|
||||||
|
statMeta = check?.metaData || {};
|
||||||
|
let isNewMeta = false;
|
||||||
|
if (check.success && JSON.stringify(meta) !== '{}' && !check.equalMeta) {
|
||||||
|
meta = { ...statMeta, ...getMetadata(pathname), ...meta };
|
||||||
|
isNewMeta = true;
|
||||||
|
await oss.replaceObject(objectName, { ...meta });
|
||||||
|
}
|
||||||
|
if (check.success) {
|
||||||
|
return end({ success: true, hash, meta, isNewMeta, equalMeta: check.equalMeta }, '文件已存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const bb = busboy({
|
||||||
|
headers: req.headers,
|
||||||
|
limits: {
|
||||||
|
fileSize: 100 * 1024 * 1024, // 100MB
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
defCharset: 'utf-8',
|
||||||
|
});
|
||||||
|
let fileProcessed = false;
|
||||||
|
bb.on('file', async (name, file, info) => {
|
||||||
|
fileProcessed = true;
|
||||||
|
try {
|
||||||
|
await oss.putObject(
|
||||||
|
objectName,
|
||||||
|
file,
|
||||||
|
{
|
||||||
|
...statMeta,
|
||||||
|
...getMetadata(pathname),
|
||||||
|
...meta,
|
||||||
|
},
|
||||||
|
{ check: false, isStream: true },
|
||||||
|
);
|
||||||
|
end({ success: true, name, info, isNew: true, hash, meta: meta?.metaData, statMeta }, '上传成功', 200);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log('postProxy upload error', error);
|
||||||
|
end({ error: error }, '上传失败', 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
bb.on('finish', () => {
|
||||||
|
// 只有当没有文件被处理时才执行end
|
||||||
|
if (!fileProcessed) {
|
||||||
|
end({ success: false }, '没有接收到文件', 400);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bb.on('error', (err) => {
|
||||||
|
console.error('Busboy 错误:', err);
|
||||||
|
end({ error: err }, '文件解析失败', 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
pipeBusboy(req, res, bb);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export const postChunkProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||||||
|
const oss = opts.oss;
|
||||||
|
const { objectName, isOwner } = await getObjectName(req);
|
||||||
|
if (!isOwner) {
|
||||||
|
return opts?.createNotFoundPage?.('no permission');
|
||||||
|
}
|
||||||
|
const _u = new URL(req.url, 'http://localhost');
|
||||||
|
const params = _u.searchParams;
|
||||||
|
const meta = parseSearchValue(params.get('meta'), { decode: true });
|
||||||
|
const end = (data: any, message?: string, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: code, data: data, message: message || 'success' }));
|
||||||
|
};
|
||||||
|
const bb = busboy({
|
||||||
|
headers: req.headers,
|
||||||
|
limits: {
|
||||||
|
fileSize: 100 * 1024 * 1024, // 100MB
|
||||||
|
files: 1,
|
||||||
|
},
|
||||||
|
defCharset: 'utf-8',
|
||||||
|
});
|
||||||
|
const fields: any = {};
|
||||||
|
let filePromise: Promise<void> | null = null;
|
||||||
|
let tempPath = '';
|
||||||
|
let file: any = null;
|
||||||
|
|
||||||
|
bb.on('field', (fieldname, value) => {
|
||||||
|
fields[fieldname] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
bb.on('file', (_fieldname, fileStream, info) => {
|
||||||
|
const { filename, mimeType } = info;
|
||||||
|
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
|
||||||
|
tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
|
||||||
|
const writeStream = createWriteStream(tempPath);
|
||||||
|
|
||||||
|
filePromise = new Promise<void>((resolve, reject) => {
|
||||||
|
fileStream.pipe(writeStream);
|
||||||
|
writeStream.on('finish', () => {
|
||||||
|
file = {
|
||||||
|
filepath: tempPath,
|
||||||
|
originalFilename: decodedFilename,
|
||||||
|
mimetype: mimeType,
|
||||||
|
};
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
writeStream.on('error', reject);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
bb.on('finish', async () => {
|
||||||
|
if (filePromise) {
|
||||||
|
try {
|
||||||
|
await filePromise;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`File write error: ${err.message}`);
|
||||||
|
return end({ error: err }, '文件写入失败', 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const clearFiles = () => {
|
||||||
|
if (tempPath && fs.existsSync(tempPath)) {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
clearFiles();
|
||||||
|
return end({ success: false }, '没有接收到文件', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
let { chunkIndex, totalChunks } = fields;
|
||||||
|
chunkIndex = parseInt(chunkIndex, 10);
|
||||||
|
totalChunks = parseInt(totalChunks, 10);
|
||||||
|
|
||||||
|
if (isNaN(chunkIndex) || isNaN(totalChunks) || totalChunks <= 0) {
|
||||||
|
clearFiles();
|
||||||
|
return end({ success: false }, 'chunkIndex 和 totalChunks 参数无效', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalFilePath = path.join(cacheFilePath, `chunk-${objectName.replace(/[\/\.]/g, '-')}`);
|
||||||
|
const relativePath = file.originalFilename;
|
||||||
|
const writeStream = fs.createWriteStream(finalFilePath, { flags: 'a' });
|
||||||
|
const readStream = fs.createReadStream(tempPath);
|
||||||
|
readStream.pipe(writeStream);
|
||||||
|
|
||||||
|
writeStream.on('finish', async () => {
|
||||||
|
fs.unlinkSync(tempPath);
|
||||||
|
if (chunkIndex + 1 === totalChunks) {
|
||||||
|
try {
|
||||||
|
const metadata = {
|
||||||
|
...getMetadata(relativePath),
|
||||||
|
...meta,
|
||||||
|
'app-source': 'user-app',
|
||||||
|
};
|
||||||
|
if (fileIsExist(finalFilePath)) {
|
||||||
|
console.log('上传到对象存储', { objectName, metadata });
|
||||||
|
const content = fs.readFileSync(finalFilePath);
|
||||||
|
await oss.putObject(objectName, content, metadata);
|
||||||
|
console.log('上传到对象存储完成', { objectName });
|
||||||
|
fs.unlinkSync(finalFilePath);
|
||||||
|
}
|
||||||
|
return end({
|
||||||
|
success: true,
|
||||||
|
name: relativePath,
|
||||||
|
chunkIndex,
|
||||||
|
totalChunks,
|
||||||
|
objectName,
|
||||||
|
}, '分块上传完成', 200);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('postChunkProxy upload error', error);
|
||||||
|
clearFiles();
|
||||||
|
if (fs.existsSync(finalFilePath)) {
|
||||||
|
fs.unlinkSync(finalFilePath);
|
||||||
|
}
|
||||||
|
return end({ error }, '上传失败', 500);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return end({
|
||||||
|
success: true,
|
||||||
|
chunkIndex,
|
||||||
|
totalChunks,
|
||||||
|
progress: ((chunkIndex + 1) / totalChunks) * 100,
|
||||||
|
}, '分块上传成功', 200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
writeStream.on('error', (err) => {
|
||||||
|
console.error('Write stream error', err);
|
||||||
|
clearFiles();
|
||||||
|
return end({ error: err }, '文件写入失败', 500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
bb.on('error', (err) => {
|
||||||
|
console.error('Busboy 错误:', err);
|
||||||
|
end({ error: err }, '文件解析失败', 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
pipeBusboy(req, res, bb);
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { bucketName, minioClient } from '@/modules/minio.ts';
|
import { oss } from '@/app.ts';
|
||||||
import { IncomingMessage, ServerResponse } from 'http';
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
import { filterKeys } from './http-proxy.ts';
|
import { filterKeys } from './http-proxy.ts';
|
||||||
import { getUserFromRequest } from '../utils.ts';
|
import { getUserFromRequest } from '../utils.ts';
|
||||||
@@ -11,7 +11,8 @@ import { parseSearchValue } from '@kevisual/router/src/server/parse-body.ts';
|
|||||||
import { logger } from '@/modules/logger.ts';
|
import { logger } from '@/modules/logger.ts';
|
||||||
import { pipeBusboy } from '../pipe-busboy.ts';
|
import { pipeBusboy } from '../pipe-busboy.ts';
|
||||||
import { pipeMinioStream } from '../pipe.ts';
|
import { pipeMinioStream } from '../pipe.ts';
|
||||||
|
import { Readable } from 'stream';
|
||||||
|
import { postChunkProxy, postProxy } from './ai-proxy-chunk/post-proxy.ts'
|
||||||
type FileList = {
|
type FileList = {
|
||||||
name: string;
|
name: string;
|
||||||
prefix?: string;
|
prefix?: string;
|
||||||
@@ -53,6 +54,14 @@ export const getFileList = async (list: any, opts?: { objectName: string; app: s
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
// import { logger } from '@/module/logger.ts';
|
// import { logger } from '@/module/logger.ts';
|
||||||
|
/**
|
||||||
|
* GET 处理 AI 代理请求
|
||||||
|
* 1. 如果是目录请求,返回目录列表
|
||||||
|
* 2. 如果是文件请求,返回文件流
|
||||||
|
*
|
||||||
|
* 如果是 stat
|
||||||
|
* 只返回对应的 stat 信息
|
||||||
|
*/
|
||||||
const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||||||
const { createNotFoundPage } = opts;
|
const { createNotFoundPage } = opts;
|
||||||
const _u = new URL(req.url, 'http://localhost');
|
const _u = new URL(req.url, 'http://localhost');
|
||||||
@@ -62,6 +71,7 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
|
|||||||
const hash = params.get('hash');
|
const hash = params.get('hash');
|
||||||
let dir = !!params.get('dir');
|
let dir = !!params.get('dir');
|
||||||
const recursive = !!params.get('recursive');
|
const recursive = !!params.get('recursive');
|
||||||
|
const showStat = !!params.get('stat');
|
||||||
const { objectName, app, owner, loginUser, isOwner } = await getObjectName(req);
|
const { objectName, app, owner, loginUser, isOwner } = await getObjectName(req);
|
||||||
if (!dir && _u.pathname.endsWith('/')) {
|
if (!dir && _u.pathname.endsWith('/')) {
|
||||||
dir = true; // 如果是目录请求,强制设置为true
|
dir = true; // 如果是目录请求,强制设置为true
|
||||||
@@ -88,10 +98,19 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const stat = await oss.statObject(objectName);
|
const stat = await oss.statObject(objectName);
|
||||||
if (!stat) {
|
if (!stat && isOwner) {
|
||||||
createNotFoundPage('Invalid proxy url');
|
// createNotFoundPage('文件不存在');
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 404,
|
||||||
|
message: 'object not found',
|
||||||
|
}),
|
||||||
|
);
|
||||||
logger.debug('no stat', objectName, owner, req.url);
|
logger.debug('no stat', objectName, owner, req.url);
|
||||||
return true;
|
return true;
|
||||||
|
} else if (!stat && !isOwner) {
|
||||||
|
return createNotFoundPage('Invalid ai proxy url');
|
||||||
}
|
}
|
||||||
const permissionInstance = new UserPermission({ permission: stat.metaData as Permission, owner: owner });
|
const permissionInstance = new UserPermission({ permission: stat.metaData as Permission, owner: owner });
|
||||||
const checkPermission = permissionInstance.checkPermissionSuccess({
|
const checkPermission = permissionInstance.checkPermissionSuccess({
|
||||||
@@ -102,6 +121,20 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
|
|||||||
logger.info('no permission', checkPermission, loginUser, owner);
|
logger.info('no permission', checkPermission, loginUser, owner);
|
||||||
return createNotFoundPage('no permission');
|
return createNotFoundPage('no permission');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showStat) {
|
||||||
|
res.writeHead(200, { 'content-type': 'application/json' });
|
||||||
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
code: 200,
|
||||||
|
data: {
|
||||||
|
...stat,
|
||||||
|
metaData: filterKeys(stat.metaData, ['etag', 'size']),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (hash && stat.etag === hash) {
|
if (hash && stat.etag === hash) {
|
||||||
res.writeHead(304); // not modified
|
res.writeHead(304); // not modified
|
||||||
res.end('not modified');
|
res.end('not modified');
|
||||||
@@ -112,7 +145,8 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
|
|||||||
const etag = stat.etag;
|
const etag = stat.etag;
|
||||||
const lastModified = stat.lastModified.toISOString();
|
const lastModified = stat.lastModified.toISOString();
|
||||||
|
|
||||||
const objectStream = await minioClient.getObject(bucketName, objectName);
|
const objectStream = await oss.getObject(objectName);
|
||||||
|
// const objectStream = await minioClient.getObject(bucketName, objectName);
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Length': contentLength,
|
'Content-Length': contentLength,
|
||||||
etag,
|
etag,
|
||||||
@@ -124,8 +158,7 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
|
|||||||
...headers,
|
...headers,
|
||||||
});
|
});
|
||||||
// objectStream.pipe(res, { end: true });
|
// objectStream.pipe(res, { end: true });
|
||||||
// @ts-ignore
|
pipeMinioStream(objectStream.Body as Readable, res);
|
||||||
pipeMinioStream(objectStream, res);
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Proxy request error: ${error.message}`);
|
console.error(`Proxy request error: ${error.message}`);
|
||||||
@@ -152,87 +185,24 @@ export const getMetadata = (pathname: string) => {
|
|||||||
return meta;
|
return meta;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const postProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
export const getObjectByPathname = (opts: {
|
||||||
const _u = new URL(req.url, 'http://localhost');
|
pathname: string,
|
||||||
|
version?: string,
|
||||||
const pathname = _u.pathname;
|
}) => {
|
||||||
const oss = opts.oss;
|
const [_, user, app] = opts.pathname.split('/');
|
||||||
const params = _u.searchParams;
|
let prefix = '';
|
||||||
const force = !!params.get('force');
|
let replaceKey = '';
|
||||||
const hash = params.get('hash');
|
if (app === 'ai') {
|
||||||
const _fileSize: string = params.get('size');
|
const version = opts?.version || '1.0.0';
|
||||||
let fileSize: number | undefined = undefined;
|
replaceKey = `/${user}/${app}/`;
|
||||||
if (_fileSize) {
|
prefix = `${user}/${app}/${version}/`;
|
||||||
fileSize = parseInt(_fileSize, 10)
|
} else {
|
||||||
|
replaceKey = `/${user}/${app}/`;
|
||||||
|
prefix = `${user}/`; // root/resources
|
||||||
}
|
}
|
||||||
let meta = parseSearchValue(params.get('meta'), { decode: true });
|
let objectName = opts.pathname.replace(replaceKey, prefix);
|
||||||
if (!hash && !force) {
|
return { prefix, replaceKey, objectName, user, app };
|
||||||
return opts?.createNotFoundPage?.('no hash');
|
}
|
||||||
}
|
|
||||||
const { objectName, isOwner } = await getObjectName(req);
|
|
||||||
if (!isOwner) {
|
|
||||||
return opts?.createNotFoundPage?.('no permission');
|
|
||||||
}
|
|
||||||
const end = (data: any, message?: string, code = 200) => {
|
|
||||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify({ code: code, data: data, message: message || 'success' }));
|
|
||||||
};
|
|
||||||
let statMeta: any = {};
|
|
||||||
if (!force) {
|
|
||||||
const check = await oss.checkObjectHash(objectName, hash, meta);
|
|
||||||
statMeta = check?.metaData || {};
|
|
||||||
let isNewMeta = false;
|
|
||||||
if (check.success && JSON.stringify(meta) !== '{}' && !check.equalMeta) {
|
|
||||||
meta = { ...statMeta, ...getMetadata(pathname), ...meta };
|
|
||||||
isNewMeta = true;
|
|
||||||
await oss.replaceObject(objectName, { ...meta });
|
|
||||||
}
|
|
||||||
if (check.success) {
|
|
||||||
return end({ success: true, hash, meta, isNewMeta, equalMeta: check.equalMeta }, '文件已存在');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const bb = busboy({
|
|
||||||
headers: req.headers,
|
|
||||||
limits: {
|
|
||||||
fileSize: 100 * 1024 * 1024, // 100MB
|
|
||||||
files: 1,
|
|
||||||
},
|
|
||||||
defCharset: 'utf-8',
|
|
||||||
});
|
|
||||||
let fileProcessed = false;
|
|
||||||
bb.on('file', async (name, file, info) => {
|
|
||||||
fileProcessed = true;
|
|
||||||
try {
|
|
||||||
await oss.putObject(
|
|
||||||
objectName,
|
|
||||||
file,
|
|
||||||
{
|
|
||||||
...statMeta,
|
|
||||||
...getMetadata(pathname),
|
|
||||||
...meta,
|
|
||||||
},
|
|
||||||
{ check: false, isStream: true, size: fileSize },
|
|
||||||
);
|
|
||||||
end({ success: true, name, info, isNew: true, hash, meta: meta?.metaData, statMeta }, '上传成功', 200);
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
end({ error: error }, '上传失败', 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
bb.on('finish', () => {
|
|
||||||
// 只有当没有文件被处理时才执行end
|
|
||||||
if (!fileProcessed) {
|
|
||||||
end({ success: false }, '没有接收到文件', 400);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
bb.on('error', (err) => {
|
|
||||||
console.error('Busboy 错误:', err);
|
|
||||||
end({ error: err }, '文件解析失败', 500);
|
|
||||||
});
|
|
||||||
|
|
||||||
pipeBusboy(req, res, bb);
|
|
||||||
};
|
|
||||||
export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?: boolean }) => {
|
export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?: boolean }) => {
|
||||||
const _u = new URL(req.url, 'http://localhost');
|
const _u = new URL(req.url, 'http://localhost');
|
||||||
const pathname = decodeURIComponent(_u.pathname);
|
const pathname = decodeURIComponent(_u.pathname);
|
||||||
@@ -252,7 +222,7 @@ export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?:
|
|||||||
let loginUser: Awaited<ReturnType<typeof getLoginUser>> = null;
|
let loginUser: Awaited<ReturnType<typeof getLoginUser>> = null;
|
||||||
if (checkOwner) {
|
if (checkOwner) {
|
||||||
loginUser = await getLoginUser(req);
|
loginUser = await getLoginUser(req);
|
||||||
logger.debug('getObjectName', loginUser, user, app);
|
logger.debug('getObjectName', user, app);
|
||||||
isOwner = loginUser?.tokenUser?.username === owner;
|
isOwner = loginUser?.tokenUser?.username === owner;
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -270,32 +240,162 @@ export const deleteProxy = async (req: IncomingMessage, res: ServerResponse, opt
|
|||||||
if (!isOwner) {
|
if (!isOwner) {
|
||||||
return opts?.createNotFoundPage?.('no permission');
|
return opts?.createNotFoundPage?.('no permission');
|
||||||
}
|
}
|
||||||
|
const end = (data: any, message?: string, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: code, data: data, message: message || 'success' }));
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
await oss.deleteObject(objectName);
|
// 如果以 / 结尾,删除该前缀下的所有对象(文件夹)
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
if (objectName.endsWith('/')) {
|
||||||
res.end(JSON.stringify({ success: true, message: 'delete success', objectName }));
|
const objects = await oss.listObjects<true>(objectName, { recursive: true });
|
||||||
|
if (objects.length > 0) {
|
||||||
|
for (const obj of objects) {
|
||||||
|
await oss.deleteObject(obj.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end({ success: true, objectName, deletedCount: objects.length }, 'delete success', 200);
|
||||||
|
} else {
|
||||||
|
await oss.deleteObject(objectName);
|
||||||
|
end({ success: true, objectName }, 'delete success', 200);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('deleteProxy error', error);
|
logger.error('deleteProxy error', error);
|
||||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
end({ success: false, error }, 'delete failed', 500);
|
||||||
res.end(JSON.stringify({ success: false, error: error }));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type ProxyOptions = {
|
export const renameProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||||||
|
const { objectName, isOwner, user } = await getObjectName(req);
|
||||||
|
let oss = opts.oss;
|
||||||
|
if (!isOwner) {
|
||||||
|
return opts?.createNotFoundPage?.('no permission');
|
||||||
|
}
|
||||||
|
const end = (data: any, message?: string, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: code, data: data, message: message || 'success' }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const _u = new URL(req.url, 'http://localhost');
|
||||||
|
let newName = _u.searchParams.get('newName');
|
||||||
|
if (!newName) {
|
||||||
|
return end({ success: false }, 'newName parameter required', 400);
|
||||||
|
}
|
||||||
|
// 解码 URL 编码的路径
|
||||||
|
newName = decodeURIComponent(newName);
|
||||||
|
if (!newName.startsWith('/')) {
|
||||||
|
newName = '/' + newName;
|
||||||
|
}
|
||||||
|
const newUrl = new URL(newName, 'http://localhost');
|
||||||
|
const version = _u.searchParams.get('version') || '1.0.0';
|
||||||
|
const newNamePath = newUrl.pathname;
|
||||||
|
// 确保 newName 有正确的前缀路径
|
||||||
|
|
||||||
|
const newObject = getObjectByPathname({ pathname: newNamePath, version });
|
||||||
|
const { user: newUser, objectName: newObjectName } = newObject;
|
||||||
|
if (newUser !== user) {
|
||||||
|
return end({ success: false }, '文件重命名只能在同一用户下进行', 400);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const isDir = objectName.endsWith('/');
|
||||||
|
let copiedCount = 0;
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
// 重命名文件夹:复制所有对象到新前缀,然后删除原对象
|
||||||
|
const objects = await oss.listObjects<true>(objectName, { recursive: true });
|
||||||
|
for (const obj of objects) {
|
||||||
|
const oldKey = obj.name;
|
||||||
|
const newKey = oldKey.replace(objectName, newObjectName);
|
||||||
|
console.log('rename dir object', oldKey, newKey);
|
||||||
|
await oss.copyObject(oldKey, newKey);
|
||||||
|
copiedCount++;
|
||||||
|
}
|
||||||
|
for (const obj of objects) {
|
||||||
|
console.log('deleted object', obj.name);
|
||||||
|
await oss.deleteObject(obj.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 重命名文件
|
||||||
|
await oss.copyObject(objectName, newObjectName);
|
||||||
|
await oss.deleteObject(objectName);
|
||||||
|
copiedCount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
end({ success: true, objectName, newObjectName, copiedCount }, 'rename success', 200);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('renameProxy error', error);
|
||||||
|
end({ success: false, error }, 'rename failed', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateMetadataProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||||||
|
const { objectName, isOwner } = await getObjectName(req);
|
||||||
|
let oss = opts.oss;
|
||||||
|
if (!isOwner) {
|
||||||
|
return opts?.createNotFoundPage?.('no permission');
|
||||||
|
}
|
||||||
|
const end = (data: any, message?: string, code = 200) => {
|
||||||
|
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||||
|
res.end(JSON.stringify({ code: code, data: data, message: message || 'success' }));
|
||||||
|
};
|
||||||
|
const _u = new URL(req.url, 'http://localhost');
|
||||||
|
const params = _u.searchParams;
|
||||||
|
const metaParam = params.get('meta');
|
||||||
|
if (!metaParam) {
|
||||||
|
return end({ success: false }, 'meta parameter required', 400);
|
||||||
|
}
|
||||||
|
const meta = parseSearchValue(metaParam, { decode: true });
|
||||||
|
try {
|
||||||
|
const stat = await oss.statObject(objectName);
|
||||||
|
if (!stat) {
|
||||||
|
return end({ success: false }, 'object not found', 404);
|
||||||
|
}
|
||||||
|
const newMeta = {
|
||||||
|
"app-source": "user-app",
|
||||||
|
...meta,
|
||||||
|
};
|
||||||
|
console.log('update metadata', objectName, newMeta);
|
||||||
|
// 过滤掉包含无效字符的 key(S3 元数据头不支持某些字符)
|
||||||
|
const filteredMeta: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(newMeta)) {
|
||||||
|
if (/^[\w\-]+$/.test(key)) {
|
||||||
|
filteredMeta[key] = String(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await oss.replaceObject(objectName, filteredMeta);
|
||||||
|
end({ success: true, objectName, meta }, 'update metadata success', 200);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('updateMetadataProxy error', error);
|
||||||
|
end({ success: false, error }, 'update metadata failed', 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProxyOptions = {
|
||||||
createNotFoundPage: (msg?: string) => any;
|
createNotFoundPage: (msg?: string) => any;
|
||||||
oss?: OssBase;
|
oss?: OssBase;
|
||||||
};
|
};
|
||||||
export const aiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
export const aiProxy = async (req: IncomingMessage, res: ServerResponse, opts: ProxyOptions) => {
|
||||||
const oss = new OssBase({ bucketName, client: minioClient });
|
|
||||||
if (!opts.oss) {
|
if (!opts.oss) {
|
||||||
opts.oss = oss;
|
opts.oss = oss;
|
||||||
}
|
}
|
||||||
|
const searchParams = new URL(req.url || '', 'http://localhost').searchParams;
|
||||||
if (req.method === 'POST') {
|
if (req.method === 'POST') {
|
||||||
|
const chunk = searchParams.get('chunk');
|
||||||
|
const chunked = searchParams.get('chunked');
|
||||||
|
if (chunk !== null || chunked !== null) {
|
||||||
|
return postChunkProxy(req, res, opts);
|
||||||
|
}
|
||||||
return postProxy(req, res, opts);
|
return postProxy(req, res, opts);
|
||||||
}
|
}
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
return deleteProxy(req, res, opts);
|
return deleteProxy(req, res, opts);
|
||||||
}
|
}
|
||||||
|
if (req.method === 'PUT') {
|
||||||
|
const meta = searchParams.get('meta');
|
||||||
|
if (meta) {
|
||||||
|
return updateMetadataProxy(req, res, opts);
|
||||||
|
}
|
||||||
|
return renameProxy(req, res, opts);
|
||||||
|
}
|
||||||
|
|
||||||
return getAiProxy(req, res, opts);
|
return getAiProxy(req, res, opts);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pipeline, Readable } from 'node:stream';
|
import { Readable } from 'node:stream';
|
||||||
import { promisify } from 'node:util';
|
import { minioResources } from '@/modules/s3.ts';
|
||||||
import { bucketName, minioClient, minioResources } from '@/modules/minio.ts';
|
import { oss } from '@/app.ts';
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import { IncomingMessage, ServerResponse } from 'node:http';
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
@@ -11,14 +11,18 @@ import path from 'path';
|
|||||||
import { getTextContentType } from '@/modules/fm-manager/index.ts';
|
import { getTextContentType } from '@/modules/fm-manager/index.ts';
|
||||||
import { logger } from '@/modules/logger.ts';
|
import { logger } from '@/modules/logger.ts';
|
||||||
import { pipeStream } from '../pipe.ts';
|
import { pipeStream } from '../pipe.ts';
|
||||||
const pipelineAsync = promisify(pipeline);
|
import { GetObjectCommandOutput } from '@aws-sdk/client-s3';
|
||||||
|
|
||||||
export async function downloadFileFromMinio(fileUrl: string, destFile: string) {
|
export async function downloadFileFromMinio(fileUrl: string, destFile: string) {
|
||||||
const objectName = fileUrl.replace(minioResources + '/', '');
|
const objectName = fileUrl.replace(minioResources + '/', '');
|
||||||
const objectStream = await minioClient.getObject(bucketName, objectName);
|
const objectStream = await oss.getObject(objectName) as GetObjectCommandOutput;
|
||||||
const destStream = fs.createWriteStream(destFile);
|
const body = objectStream.Body as Readable;
|
||||||
await pipelineAsync(objectStream, destStream);
|
|
||||||
console.log(`minio File downloaded to ${minioResources}/${objectName} \n ${destFile}`);
|
const chunks: Uint8Array[] = [];
|
||||||
|
for await (const chunk of body) {
|
||||||
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(destFile, Buffer.concat(chunks));
|
||||||
}
|
}
|
||||||
export const filterKeys = (metaData: Record<string, string>, clearKeys: string[] = []) => {
|
export const filterKeys = (metaData: Record<string, string>, clearKeys: string[] = []) => {
|
||||||
const keys = Object.keys(metaData);
|
const keys = Object.keys(metaData);
|
||||||
@@ -43,8 +47,8 @@ export async function minioProxy(
|
|||||||
const { createNotFoundPage, isDownload = false } = opts;
|
const { createNotFoundPage, isDownload = false } = opts;
|
||||||
const objectName = fileUrl.replace(minioResources + '/', '');
|
const objectName = fileUrl.replace(minioResources + '/', '');
|
||||||
try {
|
try {
|
||||||
const stat = await minioClient.statObject(bucketName, objectName);
|
const stat = await oss.statObject(objectName);
|
||||||
if (stat.size === 0) {
|
if (stat?.size === 0) {
|
||||||
createNotFoundPage('Invalid proxy url');
|
createNotFoundPage('Invalid proxy url');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -54,7 +58,8 @@ export async function minioProxy(
|
|||||||
const lastModified = stat.lastModified.toISOString();
|
const lastModified = stat.lastModified.toISOString();
|
||||||
const fileName = objectName.split('/').pop();
|
const fileName = objectName.split('/').pop();
|
||||||
const ext = path.extname(fileName || '');
|
const ext = path.extname(fileName || '');
|
||||||
const objectStream = await minioClient.getObject(bucketName, objectName);
|
const objectStreamOutput = await oss.getObject(objectName);
|
||||||
|
const objectStream = objectStreamOutput.Body as Readable;
|
||||||
const headers = {
|
const headers = {
|
||||||
'Content-Length': contentLength,
|
'Content-Length': contentLength,
|
||||||
etag,
|
etag,
|
||||||
@@ -151,6 +156,7 @@ export const httpProxy = async (
|
|||||||
return createNotFoundPage('Invalid proxy url:' + error.message);
|
return createNotFoundPage('Invalid proxy url:' + error.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
console.log('Proxying file: headers', headers);
|
||||||
res.writeHead(proxyRes.statusCode, {
|
res.writeHead(proxyRes.statusCode, {
|
||||||
...headers,
|
...headers,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import { minioClient } from '@/modules/minio.ts';
|
|
||||||
import { pipeMinioStream } from '../pipe.ts';
|
import { pipeMinioStream } from '../pipe.ts';
|
||||||
|
import { oss } from '@/app.ts';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
type ProxyInfo = {
|
type ProxyInfo = {
|
||||||
path?: string;
|
path?: string;
|
||||||
target: string;
|
target: string;
|
||||||
@@ -15,9 +16,8 @@ export const minioProxyOrigin = async (req: http.IncomingMessage, res: http.Serv
|
|||||||
if (objectName.startsWith(bucketName)) {
|
if (objectName.startsWith(bucketName)) {
|
||||||
objectName = objectName.slice(bucketName.length);
|
objectName = objectName.slice(bucketName.length);
|
||||||
}
|
}
|
||||||
const objectStream = await minioClient.getObject(bucketName, objectName);
|
const objectStream = await oss.getObject(objectName);
|
||||||
// objectStream.pipe(res);
|
pipeMinioStream(objectStream.Body as Readable, res);
|
||||||
pipeMinioStream(objectStream, res);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching object from MinIO:', error);
|
console.error('Error fetching object from MinIO:', error);
|
||||||
res.statusCode = 500;
|
res.statusCode = 500;
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ export const getDNS = (req: http.IncomingMessage) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const isLocalhost = (hostName: string) => {
|
export const isLocalhost = (hostName: string) => {
|
||||||
|
if (!hostName) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
return hostName.includes('localhost') || hostName.includes('192.168');
|
return hostName.includes('localhost') || hostName.includes('192.168');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
344
src/modules/html/studio-app-list/index.ts
Normal file
344
src/modules/html/studio-app-list/index.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
type StudioOpts = { user: string, userAppKey?: string; appIds: string[] }
|
||||||
|
export const createStudioAppListHtml = (opts: StudioOpts) => {
|
||||||
|
const user = opts.user!;
|
||||||
|
const userAppKey = opts?.userAppKey;
|
||||||
|
let showUserAppKey = userAppKey;
|
||||||
|
if (showUserAppKey && showUserAppKey.startsWith(user + '--')) {
|
||||||
|
showUserAppKey = showUserAppKey.replace(user + '--', '');
|
||||||
|
}
|
||||||
|
const pathApps = opts?.appIds?.map(appId => {
|
||||||
|
const shortAppId = appId.replace(opts!.user + '--', '')
|
||||||
|
return {
|
||||||
|
appId,
|
||||||
|
shortAppId,
|
||||||
|
pathname: `/${user}/v1/${shortAppId}`
|
||||||
|
};
|
||||||
|
}) || []
|
||||||
|
|
||||||
|
// 应用列表内容
|
||||||
|
const appListContent = `
|
||||||
|
<div class="header">
|
||||||
|
<h1><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="12" x="2" y="6" rx="2"/><path d="M12 12h.01"/><path d="M17 12h.01"/><path d="M7 12h.01"/></svg> Studio 应用列表</h1>
|
||||||
|
<p class="user-info">用户: <strong>${user}</strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="app-grid">
|
||||||
|
${pathApps.map((app, index) => `
|
||||||
|
<a href="${app.pathname}" class="app-card" style="animation-delay: ${index * 0.1}s">
|
||||||
|
<div class="app-icon"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="M6 16h12"/><path d="M2 8h20"/></svg></div>
|
||||||
|
<div class="app-info">
|
||||||
|
<h3>${app.shortAppId}</h3>
|
||||||
|
<p class="app-path">${app.pathname}</p>
|
||||||
|
</div>
|
||||||
|
<div class="app-arrow">→</div>
|
||||||
|
</a>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${pathApps.length === 0 ? `
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📭</div>
|
||||||
|
<p>暂无应用</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Studio - ${user} 的应用</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #000000;
|
||||||
|
--primary-hover: #333333;
|
||||||
|
--text-color: #111111;
|
||||||
|
--text-secondary: #666666;
|
||||||
|
--bg-color: #ffffff;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--border-color: #e0e0e0;
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.08), 0 2px 4px -1px rgba(0, 0, 0, 0.04);
|
||||||
|
--shadow-hover: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Not Found Styles */
|
||||||
|
.not-found {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 60vh;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #000000;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found code {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'Fira Code', 'Monaco', monospace;
|
||||||
|
color: #000000;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* App List Styles */
|
||||||
|
.header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-color);
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info strong {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.25rem 1.5rem;
|
||||||
|
background-color: var(--card-bg);
|
||||||
|
border-radius: 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
animation: slideIn 0.5s ease-out backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: var(--shadow-hover);
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-right: 1rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-path {
|
||||||
|
margin: 0.25rem 0 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: 'Fira Code', 'Monaco', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-arrow {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card:hover .app-arrow {
|
||||||
|
color: var(--primary-color);
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
margin-top: 3rem;
|
||||||
|
padding-top: 2rem;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--primary-color: #ffffff;
|
||||||
|
--primary-hover: #cccccc;
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--text-secondary: #999999;
|
||||||
|
--bg-color: #000000;
|
||||||
|
--card-bg: #1a1a1a;
|
||||||
|
--border-color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found code {
|
||||||
|
background-color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-info h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-path {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
${showUserAppKey ? `
|
||||||
|
<div class="not-found">
|
||||||
|
<svg class="not-found-icon" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/></svg>
|
||||||
|
<h1>应用不存在</h1>
|
||||||
|
<p>抱歉,您访问的应用 <code>${showUserAppKey || ''}</code> 不存在。</p>
|
||||||
|
<p>请检查应用 Key 是否正确,或联系管理员。</p>
|
||||||
|
<a href="/${user}/v1/" class="back-link">← 返回应用列表</a>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
${appListContent}
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
© ${new Date().getFullYear()} Studio - 应用管理
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
};
|
||||||
@@ -1,38 +1,13 @@
|
|||||||
import { Client, ClientOptions } from 'minio';
|
import { Client, } from 'minio';
|
||||||
import { useConfig } from '@kevisual/use-config';
|
import { useConfig } from '@kevisual/use-config';
|
||||||
const config = useConfig();
|
const config = useConfig();
|
||||||
import { OssBase } from '@kevisual/oss/services';
|
|
||||||
const minioConfig = {
|
const minioConfig = {
|
||||||
endPoint: config.MINIO_ENDPOINT || 'localhost',
|
endPoint: config.MINIO_ENDPOINT || 'localhost',
|
||||||
|
// @ts-ignore
|
||||||
port: parseInt(config.MINIO_PORT || '9000'),
|
port: parseInt(config.MINIO_PORT || '9000'),
|
||||||
useSSL: config.MINIO_USE_SSL === 'true',
|
useSSL: config.MINIO_USE_SSL === 'true',
|
||||||
accessKey: config.MINIO_ACCESS_KEY,
|
accessKey: config.MINIO_ACCESS_KEY,
|
||||||
secretKey: config.MINIO_SECRET_KEY,
|
secretKey: config.MINIO_SECRET_KEY,
|
||||||
};
|
};
|
||||||
const { port, endPoint, useSSL } = minioConfig;
|
|
||||||
// console.log('minioConfig', minioConfig);
|
|
||||||
export const minioClient = new Client(minioConfig);
|
export const minioClient = new Client(minioConfig);
|
||||||
export const bucketName = config.MINIO_BUCKET_NAME || 'resources';
|
|
||||||
|
|
||||||
export const minioUrl = `http${useSSL ? 's' : ''}://${endPoint}:${port || 9000}`;
|
|
||||||
export const minioResources = `${minioUrl}/resources`;
|
|
||||||
|
|
||||||
if (!minioClient) {
|
|
||||||
throw new Error('Minio client not initialized');
|
|
||||||
}
|
|
||||||
// 验证权限
|
|
||||||
(async () => {
|
|
||||||
const bucketExists = await minioClient.bucketExists(bucketName);
|
|
||||||
if (!bucketExists) {
|
|
||||||
await minioClient.makeBucket(bucketName);
|
|
||||||
}
|
|
||||||
console.log('bucketExists', bucketExists);
|
|
||||||
// const res = await minioClient.putObject(bucketName, 'root/test/0.0.1/a.txt', 'test');
|
|
||||||
// console.log('minio putObject', res);
|
|
||||||
})();
|
|
||||||
|
|
||||||
export const oss = new OssBase({
|
|
||||||
client: minioClient,
|
|
||||||
bucketName: bucketName,
|
|
||||||
prefix: '',
|
|
||||||
});
|
|
||||||
|
|||||||
44
src/modules/s3.ts
Normal file
44
src/modules/s3.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { CreateBucketCommand, HeadObjectCommand, S3Client, } from '@aws-sdk/client-s3';
|
||||||
|
import { OssBase } from '@kevisual/oss/s3.ts';
|
||||||
|
import { useConfig } from '@kevisual/use-config';
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
|
export const bucketName = config.S3_BUCKET_NAME || 'resources';
|
||||||
|
|
||||||
|
export const s3Client = new S3Client({
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: config.S3_ACCESS_KEY_ID || '',
|
||||||
|
secretAccessKey: config.S3_SECRET_ACCESS_KEY || '',
|
||||||
|
},
|
||||||
|
region: config.S3_REGION,
|
||||||
|
endpoint: config.S3_ENDPOINT,
|
||||||
|
// minio配置
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 判断 bucketName 是否存在,不存在则创建
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await s3Client.send(new HeadObjectCommand({ Bucket: bucketName, Key: '' }));
|
||||||
|
console.log(`Bucket ${bucketName} exists.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`Bucket ${bucketName} does not exist. Creating...`);
|
||||||
|
if (config.S3_ENDPOINT?.includes?.('9000')) {
|
||||||
|
// 创建 bucket
|
||||||
|
await s3Client.send(new CreateBucketCommand({ Bucket: bucketName }));
|
||||||
|
console.log(`Bucket ${bucketName} created.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!s3Client) {
|
||||||
|
throw new Error('S3 client not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const oss = new OssBase({
|
||||||
|
client: s3Client,
|
||||||
|
bucketName: bucketName,
|
||||||
|
prefix: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
export const minioResources = `${config.S3_ENDPOINT}/${bucketName}`;
|
||||||
@@ -7,7 +7,7 @@ import { nanoid } from 'nanoid';
|
|||||||
import { pipeline } from 'stream';
|
import { pipeline } from 'stream';
|
||||||
import { promisify } from 'util';
|
import { promisify } from 'util';
|
||||||
import { getAppLoadStatus, setAppLoadStatus } from './get-app-status.ts';
|
import { getAppLoadStatus, setAppLoadStatus } from './get-app-status.ts';
|
||||||
import { minioResources } from '../minio.ts';
|
import { minioResources } from '../s3.ts';
|
||||||
import { downloadFileFromMinio, fetchApp, fetchDomain, fetchTest } from '@/modules/fm-manager/index.ts';
|
import { downloadFileFromMinio, fetchApp, fetchDomain, fetchTest } from '@/modules/fm-manager/index.ts';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
export * from './get-app-status.ts';
|
export * from './get-app-status.ts';
|
||||||
|
|||||||
97
src/modules/v3/index.ts
Normal file
97
src/modules/v3/index.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { IncomingMessage, ServerResponse } from 'http';
|
||||||
|
import { App } from '@kevisual/router';
|
||||||
|
import { logger } from '../logger.ts';
|
||||||
|
// import { getLoginUser } from '@/modules/auth.ts';
|
||||||
|
import { SSEManager } from './sse/sse-manager.ts';
|
||||||
|
import { getLoginUser } from '../auth.ts';
|
||||||
|
import { emitter, flowme_insert } from '../../realtime/flowme/index.ts';
|
||||||
|
export const sseManager = new SSEManager();
|
||||||
|
emitter.on(flowme_insert, (data) => {
|
||||||
|
console.log('flowme_insert event received:', data);
|
||||||
|
const uid = data.uid;
|
||||||
|
if (uid) {
|
||||||
|
sseManager.broadcast({ type: 'flowme_insert', data }, { userId: uid });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
type ProxyOptions = {
|
||||||
|
createNotFoundPage: (msg?: string) => any;
|
||||||
|
};
|
||||||
|
export const UserV3Proxy = async (req: IncomingMessage, res: ServerResponse, opts?: ProxyOptions) => {
|
||||||
|
const { url } = req;
|
||||||
|
const _url = new URL(url || '', `http://localhost`);
|
||||||
|
const { pathname, searchParams } = _url;
|
||||||
|
let [user, app, ...rest] = pathname.split('/').slice(1);
|
||||||
|
if (!user || !app) {
|
||||||
|
opts?.createNotFoundPage?.('应用未找到');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const last = rest.slice(-1)[0] || '';
|
||||||
|
const method = req.method || 'GET';
|
||||||
|
console.log('UserV3Proxy request: last', last, rest);
|
||||||
|
if (method === 'GET' && last === 'event') {
|
||||||
|
const info = await getLoginUser(req);
|
||||||
|
if (!info) {
|
||||||
|
opts?.createNotFoundPage?.('没有登录');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
console.log('建立 SSE 连接, user=', info.tokenUser.uid);
|
||||||
|
addEventStream(req, res, info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
res.end(`UserV3Proxy: user=${user}, app=${app}, rest=${rest.join('/')}`);
|
||||||
|
console.log('UserV3Proxy:', { user, app, });
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Opts = {
|
||||||
|
tokenUser: any;
|
||||||
|
token: string;
|
||||||
|
}
|
||||||
|
const addEventStream = (req: IncomingMessage, res: ServerResponse, opts?: Opts) => {
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
'Access-Control-Allow-Origin': '*'
|
||||||
|
});
|
||||||
|
console.log('Client connected for SSE', opts?.tokenUser?.username || 'unknown');
|
||||||
|
const uid = opts?.tokenUser?.id || 'guest';
|
||||||
|
console.log('SSE for userId=', opts?.tokenUser);
|
||||||
|
const connectionInfo = sseManager.createConnection({ userId: uid });
|
||||||
|
const { stream, id: connectionId } = connectionInfo;
|
||||||
|
// 设置心跳
|
||||||
|
connectionInfo.heartbeatInterval = setInterval(() => {
|
||||||
|
sseManager.sendToConnection(connectionId, { type: "heartbeat", timestamp: Date.now() })
|
||||||
|
.catch(() => {
|
||||||
|
// 心跳失败时清理连接
|
||||||
|
sseManager.closeConnection(connectionId);
|
||||||
|
});
|
||||||
|
}, 30000); // 30秒心跳
|
||||||
|
|
||||||
|
const timer = setInterval(async () => {
|
||||||
|
sseManager.broadcast({ type: "time", timestamp: Date.now() });
|
||||||
|
const hasId = sseManager.getConnection(connectionId);
|
||||||
|
if (!hasId) {
|
||||||
|
clearInterval(timer);
|
||||||
|
console.log('清理广播定时器,连接已关闭');
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
res.pipe(stream as any);
|
||||||
|
const bun = (req as any).bun
|
||||||
|
const request = bun?.request as Bun.BunRequest<string>
|
||||||
|
if (request) {
|
||||||
|
if (request.signal) {
|
||||||
|
// 当客户端断开时清理连接
|
||||||
|
request.signal.addEventListener("abort", () => {
|
||||||
|
console.log(`Client ${connectionId} disconnected`);
|
||||||
|
sseManager.closeConnection(connectionId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// console.log('res', req)
|
||||||
|
// res.end('123');
|
||||||
|
}
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
|
||||||
134
src/modules/v3/sse/sse-manager.ts
Normal file
134
src/modules/v3/sse/sse-manager.ts
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import { nanoid } from "nanoid";
|
||||||
|
type ConnectionInfo = {
|
||||||
|
id: string;
|
||||||
|
writer: WritableStreamDefaultWriter;
|
||||||
|
stream: ReadableStream<any>;
|
||||||
|
connectedAt: Date;
|
||||||
|
heartbeatInterval: NodeJS.Timeout | null;
|
||||||
|
userId?: string;
|
||||||
|
};
|
||||||
|
export class SSEManager {
|
||||||
|
private connections: Map<string, ConnectionInfo> = new Map();
|
||||||
|
private userConnections: Map<string, Set<string>> = new Map(); // userId -> connectionIds
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// 初始化逻辑
|
||||||
|
}
|
||||||
|
createConnection(info?: { userId?: string }): ConnectionInfo {
|
||||||
|
const connectionId = nanoid(16);
|
||||||
|
const { readable, writable } = new TransformStream();
|
||||||
|
const writer = writable.getWriter();
|
||||||
|
|
||||||
|
// 存储连接信息
|
||||||
|
const connectionInfo = {
|
||||||
|
id: connectionId,
|
||||||
|
writer,
|
||||||
|
stream: readable,
|
||||||
|
connectedAt: new Date(),
|
||||||
|
heartbeatInterval: null,
|
||||||
|
userId: info?.userId
|
||||||
|
};
|
||||||
|
|
||||||
|
this.connections.set(connectionId, connectionInfo);
|
||||||
|
|
||||||
|
// 添加到用户索引
|
||||||
|
if (info?.userId) {
|
||||||
|
const userSet = this.userConnections.get(info.userId) || new Set();
|
||||||
|
userSet.add(connectionId);
|
||||||
|
this.userConnections.set(info.userId, userSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
return connectionInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendToConnection(connectionId: string, data: any) {
|
||||||
|
const connection = this.connections.get(connectionId);
|
||||||
|
if (connection) {
|
||||||
|
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||||
|
return connection.writer.write(new TextEncoder().encode(message));
|
||||||
|
}
|
||||||
|
throw new Error(`Connection ${connectionId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnection(connectionId: string) {
|
||||||
|
return this.connections.get(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(data: any, opts?: { userId?: string }) {
|
||||||
|
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||||
|
const promises = [];
|
||||||
|
|
||||||
|
// 指定 userId:只发送给目标用户(通过索引快速查找)
|
||||||
|
if (opts?.userId) {
|
||||||
|
const userConnIds = this.userConnections.get(opts.userId);
|
||||||
|
if (userConnIds) {
|
||||||
|
for (const connId of userConnIds) {
|
||||||
|
const conn = this.connections.get(connId);
|
||||||
|
if (conn) {
|
||||||
|
promises.push(
|
||||||
|
conn.writer.write(new TextEncoder().encode(message))
|
||||||
|
.catch(() => {
|
||||||
|
this.closeConnection(connId);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未指定 userId:广播给所有人
|
||||||
|
for (const [id, connection] of this.connections) {
|
||||||
|
promises.push(
|
||||||
|
connection.writer.write(new TextEncoder().encode(message))
|
||||||
|
.catch(() => {
|
||||||
|
this.closeConnection(id);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(promises);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeConnection(connectionId: string) {
|
||||||
|
const connection = this.connections.get(connectionId);
|
||||||
|
if (connection) {
|
||||||
|
// 清理心跳定时器
|
||||||
|
if (connection.heartbeatInterval) {
|
||||||
|
clearInterval(connection.heartbeatInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从用户索引中移除
|
||||||
|
if (connection.userId) {
|
||||||
|
const userSet = this.userConnections.get(connection.userId);
|
||||||
|
if (userSet) {
|
||||||
|
userSet.delete(connectionId);
|
||||||
|
if (userSet.size === 0) {
|
||||||
|
this.userConnections.delete(connection.userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭写入器
|
||||||
|
connection.writer.close().catch(console.error);
|
||||||
|
|
||||||
|
// 从管理器中移除
|
||||||
|
this.connections.delete(connectionId);
|
||||||
|
|
||||||
|
console.log(`Connection ${connectionId} closed`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeAllConnections() {
|
||||||
|
for (const [connectionId, connection] of this.connections) {
|
||||||
|
this.closeConnection(connectionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveConnections() {
|
||||||
|
return Array.from(this.connections.keys());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -20,8 +20,14 @@ export const wssFun: WebSocketListenerFun = async (req, res) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const user = loginUser?.tokenUser?.username;
|
const user = loginUser?.tokenUser?.username;
|
||||||
const userApp = user + '-' + id;
|
const userApp = user + '--' + id;
|
||||||
logger.debug('注册 ws 连接', userApp);
|
logger.debug('注册 ws 连接', userApp);
|
||||||
|
const wsMessage = wsProxyManager.get(userApp);
|
||||||
|
if (wsMessage) {
|
||||||
|
logger.debug('ws 连接已存在,关闭旧连接', userApp);
|
||||||
|
wsMessage.ws.close();
|
||||||
|
wsProxyManager.unregister(userApp);
|
||||||
|
}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
wsProxyManager.register(userApp, { user, ws });
|
wsProxyManager.register(userApp, { user, ws });
|
||||||
ws.send(
|
ws.send(
|
||||||
|
|||||||
@@ -2,21 +2,51 @@ import { nanoid } from 'nanoid';
|
|||||||
import { WebSocket } from 'ws';
|
import { WebSocket } from 'ws';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
import { EventEmitter } from 'eventemitter3';
|
import { EventEmitter } from 'eventemitter3';
|
||||||
|
import { set } from 'zod';
|
||||||
|
|
||||||
class WsMessage {
|
class WsMessage {
|
||||||
ws: WebSocket;
|
ws: WebSocket;
|
||||||
user?: string;
|
user?: string;
|
||||||
emitter: EventEmitter;;
|
emitter: EventEmitter;
|
||||||
|
private pingTimer?: NodeJS.Timeout;
|
||||||
|
private readonly PING_INTERVAL = 30000; // 30 秒发送一次 ping
|
||||||
|
|
||||||
constructor({ ws, user }: WssMessageOptions) {
|
constructor({ ws, user }: WssMessageOptions) {
|
||||||
this.ws = ws;
|
this.ws = ws;
|
||||||
this.user = user;
|
this.user = user;
|
||||||
this.emitter = new EventEmitter();
|
this.emitter = new EventEmitter();
|
||||||
|
this.startPing();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private startPing() {
|
||||||
|
this.stopPing();
|
||||||
|
this.pingTimer = setInterval(() => {
|
||||||
|
if (this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.ping();
|
||||||
|
} else {
|
||||||
|
this.stopPing();
|
||||||
|
}
|
||||||
|
}, this.PING_INTERVAL);
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopPing() {
|
||||||
|
if (this.pingTimer) {
|
||||||
|
clearInterval(this.pingTimer);
|
||||||
|
this.pingTimer = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
this.stopPing();
|
||||||
|
this.emitter.removeAllListeners();
|
||||||
|
}
|
||||||
|
|
||||||
async sendResponse(data: any) {
|
async sendResponse(data: any) {
|
||||||
if (data.id) {
|
if (data.id) {
|
||||||
this.emitter.emit(data.id, data?.data);
|
this.emitter.emit(data.id, data?.data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async sendData(data: any, opts?: { timeout?: number }) {
|
async sendData(data: any, context?: any, opts?: { timeout?: number }) {
|
||||||
if (this.ws.readyState !== WebSocket.OPEN) {
|
if (this.ws.readyState !== WebSocket.OPEN) {
|
||||||
return { code: 500, message: 'WebSocket is not open' };
|
return { code: 500, message: 'WebSocket is not open' };
|
||||||
}
|
}
|
||||||
@@ -25,7 +55,10 @@ class WsMessage {
|
|||||||
const message = JSON.stringify({
|
const message = JSON.stringify({
|
||||||
id,
|
id,
|
||||||
type: 'proxy',
|
type: 'proxy',
|
||||||
data,
|
data: {
|
||||||
|
message: data,
|
||||||
|
context: context || {},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
logger.info('ws-proxy sendData', message);
|
logger.info('ws-proxy sendData', message);
|
||||||
this.ws.send(message);
|
this.ws.send(message);
|
||||||
@@ -50,15 +83,22 @@ type WssMessageOptions = {
|
|||||||
};
|
};
|
||||||
export class WsProxyManager {
|
export class WsProxyManager {
|
||||||
wssMap: Map<string, WsMessage> = new Map();
|
wssMap: Map<string, WsMessage> = new Map();
|
||||||
constructor() { }
|
PING_INTERVAL = 30000; // 30 秒检查一次连接状态
|
||||||
|
constructor(opts?: { pingInterval?: number }) {
|
||||||
|
if (opts?.pingInterval) {
|
||||||
|
this.PING_INTERVAL = opts.pingInterval;
|
||||||
|
}
|
||||||
|
this.checkConnceted();
|
||||||
|
}
|
||||||
register(id: string, opts?: { ws: WebSocket; user: string }) {
|
register(id: string, opts?: { ws: WebSocket; user: string }) {
|
||||||
if (this.wssMap.has(id)) {
|
if (this.wssMap.has(id)) {
|
||||||
const value = this.wssMap.get(id);
|
const value = this.wssMap.get(id);
|
||||||
if (value) {
|
if (value) {
|
||||||
value.ws.close();
|
value.ws.close();
|
||||||
|
value.destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const [username, appId] = id.split('-');
|
const [username, appId] = id.split('--');
|
||||||
const url = new URL(`/${username}/v1/${appId}`, 'https://kevisual.cn/');
|
const url = new URL(`/${username}/v1/${appId}`, 'https://kevisual.cn/');
|
||||||
console.log('WsProxyManager register', id, '访问地址', url.toString());
|
console.log('WsProxyManager register', id, '访问地址', url.toString());
|
||||||
const value = new WsMessage({ ws: opts?.ws, user: opts?.user });
|
const value = new WsMessage({ ws: opts?.ws, user: opts?.user });
|
||||||
@@ -68,13 +108,29 @@ export class WsProxyManager {
|
|||||||
const value = this.wssMap.get(id);
|
const value = this.wssMap.get(id);
|
||||||
if (value) {
|
if (value) {
|
||||||
value.ws.close();
|
value.ws.close();
|
||||||
|
value.destroy();
|
||||||
}
|
}
|
||||||
this.wssMap.delete(id);
|
this.wssMap.delete(id);
|
||||||
}
|
}
|
||||||
getIds() {
|
getIds(beginWith?: string) {
|
||||||
|
if (beginWith) {
|
||||||
|
return Array.from(this.wssMap.keys()).filter(key => key.startsWith(beginWith));
|
||||||
|
}
|
||||||
return Array.from(this.wssMap.keys());
|
return Array.from(this.wssMap.keys());
|
||||||
}
|
}
|
||||||
get(id: string) {
|
get(id: string) {
|
||||||
return this.wssMap.get(id);
|
return this.wssMap.get(id);
|
||||||
}
|
}
|
||||||
|
checkConnceted() {
|
||||||
|
const that = this;
|
||||||
|
setTimeout(() => {
|
||||||
|
that.wssMap.forEach((value, key) => {
|
||||||
|
if (value.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
logger.debug('ws not connected, unregister', key);
|
||||||
|
that.unregister(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
that.checkConnceted();
|
||||||
|
}, this.PING_INTERVAL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { wsProxyManager } from './index.ts';
|
|||||||
import { App } from '@kevisual/router';
|
import { App } from '@kevisual/router';
|
||||||
import { logger } from '../logger.ts';
|
import { logger } from '../logger.ts';
|
||||||
import { getLoginUser } from '@/modules/auth.ts';
|
import { getLoginUser } from '@/modules/auth.ts';
|
||||||
|
import { createStudioAppListHtml } from '../html/studio-app-list/index.ts';
|
||||||
|
import { omit } from 'es-toolkit';
|
||||||
|
|
||||||
type ProxyOptions = {
|
type ProxyOptions = {
|
||||||
createNotFoundPage: (msg?: string) => any;
|
createNotFoundPage: (msg?: string) => any;
|
||||||
@@ -13,13 +15,10 @@ export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opt
|
|||||||
const _url = new URL(url || '', `http://localhost`);
|
const _url = new URL(url || '', `http://localhost`);
|
||||||
const { pathname, searchParams } = _url;
|
const { pathname, searchParams } = _url;
|
||||||
let [user, app, userAppKey] = pathname.split('/').slice(1);
|
let [user, app, userAppKey] = pathname.split('/').slice(1);
|
||||||
if (!user || !app || !userAppKey) {
|
if (!user || !app) {
|
||||||
opts?.createNotFoundPage?.('应用未找到');
|
opts?.createNotFoundPage?.('应用未找到');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!userAppKey.includes('-')) {
|
|
||||||
userAppKey = user + '-' + userAppKey;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await App.handleRequest(req, res);
|
const data = await App.handleRequest(req, res);
|
||||||
const loginUser = await getLoginUser(req);
|
const loginUser = await getLoginUser(req);
|
||||||
@@ -27,21 +26,42 @@ export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opt
|
|||||||
opts?.createNotFoundPage?.('没有登录');
|
opts?.createNotFoundPage?.('没有登录');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAdmin = loginUser.tokenUser?.username === user
|
const isAdmin = loginUser.tokenUser?.username === user
|
||||||
|
|
||||||
|
if (!userAppKey) {
|
||||||
|
if (isAdmin) {
|
||||||
|
// 获取所有的管理员的应用列表
|
||||||
|
const ids = wsProxyManager.getIds(user + '--');
|
||||||
|
const html = createStudioAppListHtml({ user, appIds: ids, userAppKey });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(html);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
opts?.createNotFoundPage?.('没有访问应用权限');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!userAppKey.includes('--')) {
|
||||||
|
userAppKey = user + '--' + userAppKey;
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: 如果不是管理员,是否需要添加其他人可以访问的逻辑?
|
// TODO: 如果不是管理员,是否需要添加其他人可以访问的逻辑?
|
||||||
if (!isAdmin) {
|
if (!isAdmin) {
|
||||||
opts?.createNotFoundPage?.('没有访问应用权限');
|
opts?.createNotFoundPage?.('没有访问应用权限');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!userAppKey.startsWith(user + '-')) {
|
if (!userAppKey.startsWith(user + '--')) {
|
||||||
userAppKey = user + '-' + userAppKey;
|
userAppKey = user + '--' + userAppKey;
|
||||||
}
|
}
|
||||||
logger.debug('data', data);
|
logger.debug('data', data);
|
||||||
const client = wsProxyManager.get(userAppKey);
|
const client = wsProxyManager.get(userAppKey);
|
||||||
const ids = wsProxyManager.getIds();
|
const ids = wsProxyManager.getIds(user + '--');
|
||||||
if (!client) {
|
if (!client) {
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
opts?.createNotFoundPage?.(`未找到应用 [${userAppKey}], 当前应用列表: ${ids.join(',')}`);
|
const html = createStudioAppListHtml({ user, appIds: ids, userAppKey });
|
||||||
|
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||||
|
res.end(html);
|
||||||
} else {
|
} else {
|
||||||
opts?.createNotFoundPage?.('应用访问失败');
|
opts?.createNotFoundPage?.('应用访问失败');
|
||||||
}
|
}
|
||||||
@@ -55,7 +75,13 @@ export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opt
|
|||||||
res.end(await html);
|
res.end(await html);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const value = await client.sendData(data);
|
let message: any = data;
|
||||||
|
if (!isAdmin) {
|
||||||
|
message = omit(data, ['token', 'cookies']);
|
||||||
|
}
|
||||||
|
const value = await client.sendData(message, {
|
||||||
|
state: { tokenUser: omit(loginUser.tokenUser, ['oauthExpand']) },
|
||||||
|
});
|
||||||
if (value) {
|
if (value) {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(value));
|
res.end(JSON.stringify(value));
|
||||||
|
|||||||
1
src/realtime/flowme/common.ts
Normal file
1
src/realtime/flowme/common.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const flowme_insert = 'flowme_insert'
|
||||||
26
src/realtime/flowme/create.ts
Normal file
26
src/realtime/flowme/create.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { db } from '@/modules/db.ts'
|
||||||
|
|
||||||
|
// 创建触发器函数和触发器,用于在 flowme 表插入新记录时发送通知
|
||||||
|
const sql = `CREATE OR REPLACE FUNCTION notify_flowme_insert()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
PERFORM pg_notify('flowme_insert', row_to_json(NEW)::text);
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
CREATE TRIGGER flowme_after_insert
|
||||||
|
AFTER INSERT ON flowme
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION notify_flowme_insert();
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const result = await db.execute(sql)
|
||||||
|
console.log('✅ flowme 插入触发器已创建或更新:', result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// const listFunSql = `SELECT proname FROM pg_proc WHERE proname = 'flowme_after_insert';`
|
||||||
|
|
||||||
|
// const funExists = await db.execute(listFunSql)
|
||||||
|
// console.log('函数是否存在:', funExists)
|
||||||
3
src/realtime/flowme/index.ts
Normal file
3
src/realtime/flowme/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './listener.ts'
|
||||||
|
|
||||||
|
export * from './common.ts'
|
||||||
33
src/realtime/flowme/listener.ts
Normal file
33
src/realtime/flowme/listener.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Client } from 'pg'
|
||||||
|
import { useConfig } from '@kevisual/use-config'
|
||||||
|
import { EventEmitter } from 'eventemitter3'
|
||||||
|
const config = useConfig()
|
||||||
|
let pgClient: Client | null = null
|
||||||
|
export const emitter = new EventEmitter()
|
||||||
|
|
||||||
|
async function startFlowmeListener() {
|
||||||
|
// 使用独立的数据库连接来监听
|
||||||
|
pgClient = new Client({
|
||||||
|
connectionString: config.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/code_center',
|
||||||
|
})
|
||||||
|
console.log('config.DATABASE_URL =', config.DATABASE_URL)
|
||||||
|
|
||||||
|
await pgClient.connect()
|
||||||
|
console.log('🔌 已连接到 PostgreSQL 监听器')
|
||||||
|
// 订阅通知事件
|
||||||
|
pgClient.on('notification', (data) => {
|
||||||
|
if (!data.payload) return
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.payload)
|
||||||
|
emitter.emit('flowme_insert', parsed)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ 解析 flowme 通知失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行 LISTEN 命令订阅通道
|
||||||
|
await pgClient.query('LISTEN flowme_insert')
|
||||||
|
|
||||||
|
console.log('👂 开始监听 flowme_insert 通道...')
|
||||||
|
}
|
||||||
|
startFlowmeListener();
|
||||||
@@ -29,12 +29,11 @@ export const addAuth = (app: App) => {
|
|||||||
ctx.throw(401, 'Token is required');
|
ctx.throw(401, 'Token is required');
|
||||||
}
|
}
|
||||||
const user = await User.getOauthUser(token);
|
const user = await User.getOauthUser(token);
|
||||||
console.log('auth user: exists', !user);
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
ctx.throw(401, 'Token is invalid');
|
ctx.throw(401, 'Token is invalid');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.log(`auth user: ${user.username} (${user.id})`);
|
// console.log(`auth user: ${user.username} (${user.id})`);
|
||||||
const someInfo = getSomeInfoFromReq(ctx);
|
const someInfo = getSomeInfoFromReq(ctx);
|
||||||
if (someInfo.isBrowser && !ctx.req?.cookies?.['token']) {
|
if (someInfo.isBrowser && !ctx.req?.cookies?.['token']) {
|
||||||
createCookie({ accessToken: token }, ctx);
|
createCookie({ accessToken: token }, ctx);
|
||||||
@@ -87,6 +86,7 @@ app
|
|||||||
if (!tokenUser) {
|
if (!tokenUser) {
|
||||||
ctx.throw(401, 'No User For authorized');
|
ctx.throw(401, 'No User For authorized');
|
||||||
}
|
}
|
||||||
|
console.log('auth-admin tokenUser', ctx.state);
|
||||||
if (typeof ctx.state.isAdmin !== 'undefined' && ctx.state.isAdmin === true) {
|
if (typeof ctx.state.isAdmin !== 'undefined' && ctx.state.isAdmin === true) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -114,6 +114,7 @@ app
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.addTo(app);
|
.addTo(app);
|
||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
path: 'auth-check',
|
path: 'auth-check',
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
import Busboy from 'busboy';
|
|
||||||
import { checkAuth } from '../middleware/auth.ts';
|
|
||||||
import { router, clients, writeEvents } from '../router.ts';
|
|
||||||
import { error } from '../middleware/auth.ts';
|
|
||||||
import fs from 'fs';
|
|
||||||
import { useFileStore } from '@kevisual/use-config';
|
|
||||||
import { app, minioClient } from '@/app.ts';
|
|
||||||
import { bucketName } from '@/modules/minio.ts';
|
|
||||||
import { getContentType } from '@/utils/get-content-type.ts';
|
|
||||||
import path from 'path';
|
|
||||||
import { createWriteStream } from 'fs';
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import { pipeBusboy } from '@/modules/fm-manager/index.ts';
|
|
||||||
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
|
||||||
|
|
||||||
router.post('/api/micro-app/upload', async (req, res) => {
|
|
||||||
if (res.headersSent) return; // 如果响应已发送,不再处理
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
const { tokenUser, token } = await checkAuth(req, res);
|
|
||||||
if (!tokenUser) return;
|
|
||||||
|
|
||||||
// 使用 busboy 解析 multipart/form-data
|
|
||||||
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
|
|
||||||
const fields: any = {};
|
|
||||||
let file: any = null;
|
|
||||||
let filePromise: Promise<void> | null = null;
|
|
||||||
let bytesReceived = 0;
|
|
||||||
let bytesExpected = parseInt(req.headers['content-length'] || '0');
|
|
||||||
|
|
||||||
busboy.on('field', (fieldname, value) => {
|
|
||||||
fields[fieldname] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
busboy.on('file', (fieldname, fileStream, info) => {
|
|
||||||
const { filename, encoding, mimeType } = info;
|
|
||||||
// 处理 UTF-8 文件名编码
|
|
||||||
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
|
|
||||||
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
|
|
||||||
const writeStream = createWriteStream(tempPath);
|
|
||||||
const hash = crypto.createHash('md5');
|
|
||||||
let size = 0;
|
|
||||||
|
|
||||||
filePromise = new Promise<void>((resolve, reject) => {
|
|
||||||
fileStream.on('data', (chunk) => {
|
|
||||||
bytesReceived += chunk.length;
|
|
||||||
size += chunk.length;
|
|
||||||
hash.update(chunk);
|
|
||||||
if (bytesExpected > 0) {
|
|
||||||
const progress = (bytesReceived / bytesExpected) * 100;
|
|
||||||
console.log(`Upload progress: ${progress.toFixed(2)}%`);
|
|
||||||
const data = {
|
|
||||||
progress: progress.toFixed(2),
|
|
||||||
message: `Upload progress: ${progress.toFixed(2)}%`,
|
|
||||||
};
|
|
||||||
writeEvents(req, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fileStream.pipe(writeStream);
|
|
||||||
|
|
||||||
writeStream.on('finish', () => {
|
|
||||||
file = {
|
|
||||||
filepath: tempPath,
|
|
||||||
originalFilename: decodedFilename,
|
|
||||||
mimetype: mimeType,
|
|
||||||
hash: hash.digest('hex'),
|
|
||||||
size: size,
|
|
||||||
};
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
writeStream.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
busboy.on('finish', async () => {
|
|
||||||
// 等待文件写入完成
|
|
||||||
if (filePromise) {
|
|
||||||
try {
|
|
||||||
await filePromise;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`File write error: ${err.message}`);
|
|
||||||
res.end(error(`File write error: ${err.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const clearFiles = () => {
|
|
||||||
if (file?.filepath && fs.existsSync(file.filepath)) {
|
|
||||||
fs.unlinkSync(file.filepath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
res.end(error('No file uploaded'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let appKey, collection;
|
|
||||||
const { appKey: _appKey, collection: _collecion } = fields;
|
|
||||||
if (Array.isArray(_appKey)) {
|
|
||||||
appKey = _appKey?.[0];
|
|
||||||
} else {
|
|
||||||
appKey = _appKey;
|
|
||||||
}
|
|
||||||
if (Array.isArray(_collecion)) {
|
|
||||||
collection = _collecion?.[0];
|
|
||||||
} else {
|
|
||||||
collection = _collecion;
|
|
||||||
}
|
|
||||||
collection = parseIfJson(collection);
|
|
||||||
|
|
||||||
appKey = appKey || 'micro-app';
|
|
||||||
console.log('Appkey', appKey);
|
|
||||||
console.log('collection', collection);
|
|
||||||
|
|
||||||
// 处理上传的文件
|
|
||||||
const uploadResults = [];
|
|
||||||
const tempPath = file.filepath; // 文件上传时的临时路径
|
|
||||||
const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构)
|
|
||||||
// 比如 child2/b.txt
|
|
||||||
const minioPath = `private/${tokenUser.username}/${appKey}/${relativePath}`;
|
|
||||||
// 上传到 MinIO 并保留文件夹结构
|
|
||||||
const isHTML = relativePath.endsWith('.html');
|
|
||||||
await minioClient.fPutObject(bucketName, minioPath, tempPath, {
|
|
||||||
'Content-Type': getContentType(relativePath),
|
|
||||||
'app-source': 'user-micro-app',
|
|
||||||
'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年
|
|
||||||
});
|
|
||||||
uploadResults.push({
|
|
||||||
name: relativePath,
|
|
||||||
path: minioPath,
|
|
||||||
hash: file.hash,
|
|
||||||
size: file.size,
|
|
||||||
});
|
|
||||||
fs.unlinkSync(tempPath); // 删除临时文件
|
|
||||||
|
|
||||||
// 受控
|
|
||||||
const r = await app.call({
|
|
||||||
path: 'micro-app',
|
|
||||||
key: 'upload',
|
|
||||||
payload: {
|
|
||||||
token: token,
|
|
||||||
data: {
|
|
||||||
appKey,
|
|
||||||
collection,
|
|
||||||
files: uploadResults,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const data: any = {
|
|
||||||
code: r.code,
|
|
||||||
data: r.body,
|
|
||||||
};
|
|
||||||
if (r.message) {
|
|
||||||
data.message = r.message;
|
|
||||||
}
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
});
|
|
||||||
|
|
||||||
pipeBusboy(req, res, busboy);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
function parseIfJson(collection: any): any {
|
|
||||||
try {
|
|
||||||
return JSON.parse(collection);
|
|
||||||
} catch (e) {
|
|
||||||
return collection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { router, error, checkAuth, clients, getTaskId, writeEvents, deleteOldClients } from './router.ts';
|
|
||||||
|
|
||||||
router.get('/api/events', async (req, res) => {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
});
|
|
||||||
const taskId = getTaskId(req);
|
|
||||||
if (!taskId) {
|
|
||||||
res.end(error('task-id is required'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 将客户端连接推送到 clients 数组
|
|
||||||
clients.set(taskId, { client: res, createTime: Date.now() });
|
|
||||||
// 移除客户端连接
|
|
||||||
req.on('close', () => {
|
|
||||||
clients.delete(taskId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/api/s1/events', async (req, res) => {
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Type': 'text/event-stream',
|
|
||||||
'Cache-Control': 'no-cache',
|
|
||||||
Connection: 'keep-alive',
|
|
||||||
});
|
|
||||||
const taskId = getTaskId(req);
|
|
||||||
if (!taskId) {
|
|
||||||
res.end(error('task-id is required'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 将客户端连接推送到 clients 数组
|
|
||||||
clients.set(taskId, { client: res, createTime: Date.now() });
|
|
||||||
writeEvents(req, { progress: 0, message: 'start' });
|
|
||||||
// 不自动关闭连接
|
|
||||||
// res.end('ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/api/s1/events/close', async (req, res) => {
|
|
||||||
const taskId = getTaskId(req);
|
|
||||||
if (!taskId) {
|
|
||||||
res.end(error('task-id is required'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
deleteOldClients();
|
|
||||||
clients.delete(taskId);
|
|
||||||
res.end('ok');
|
|
||||||
});
|
|
||||||
@@ -1,214 +1,10 @@
|
|||||||
import { useFileStore } from '@kevisual/use-config';
|
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import fs from 'fs';
|
import { router } from './router.ts';
|
||||||
import Busboy from 'busboy';
|
|
||||||
import { app, minioClient } from '@/app.ts';
|
|
||||||
|
|
||||||
import { bucketName } from '@/modules/minio.ts';
|
|
||||||
import { getContentType } from '@/utils/get-content-type.ts';
|
|
||||||
import { User } from '@/models/user.ts';
|
|
||||||
import { router, error, checkAuth, writeEvents } from './router.ts';
|
|
||||||
import './index.ts';
|
import './index.ts';
|
||||||
import { handleRequest as PageProxy } from './page-proxy.ts';
|
import { handleRequest as PageProxy } from './page-proxy.ts';
|
||||||
import path from 'path';
|
|
||||||
import { createWriteStream } from 'fs';
|
|
||||||
import { pipeBusboy } from '@/modules/fm-manager/pipe-busboy.ts';
|
|
||||||
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
|
||||||
|
|
||||||
router.get('/api/app/upload', async (req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('Upload API is ready');
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post('/api/app/upload', async (req, res) => {
|
|
||||||
if (res.headersSent) return; // 如果响应已发送,不再处理
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
const { tokenUser, token } = await checkAuth(req, res);
|
|
||||||
if (!tokenUser) return;
|
|
||||||
|
|
||||||
// 使用 busboy 解析 multipart/form-data
|
|
||||||
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
|
|
||||||
const fields: any = {};
|
|
||||||
const files: any = [];
|
|
||||||
const filePromises: Promise<void>[] = [];
|
|
||||||
let bytesReceived = 0;
|
|
||||||
let bytesExpected = parseInt(req.headers['content-length'] || '0');
|
|
||||||
|
|
||||||
busboy.on('field', (fieldname, value) => {
|
|
||||||
fields[fieldname] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
busboy.on('file', (fieldname, fileStream, info) => {
|
|
||||||
const { filename, encoding, mimeType } = info;
|
|
||||||
// 处理 UTF-8 文件名编码
|
|
||||||
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
|
|
||||||
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
|
|
||||||
const writeStream = createWriteStream(tempPath);
|
|
||||||
|
|
||||||
const filePromise = new Promise<void>((resolve, reject) => {
|
|
||||||
fileStream.on('data', (chunk) => {
|
|
||||||
bytesReceived += chunk.length;
|
|
||||||
if (bytesExpected > 0) {
|
|
||||||
const progress = (bytesReceived / bytesExpected) * 100;
|
|
||||||
console.log(`Upload progress: ${progress.toFixed(2)}%`);
|
|
||||||
const data = {
|
|
||||||
progress: progress.toFixed(2),
|
|
||||||
message: `Upload progress: ${progress.toFixed(2)}%`,
|
|
||||||
};
|
|
||||||
writeEvents(req, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fileStream.pipe(writeStream);
|
|
||||||
|
|
||||||
writeStream.on('finish', () => {
|
|
||||||
files.push({
|
|
||||||
filepath: tempPath,
|
|
||||||
originalFilename: decodedFilename,
|
|
||||||
mimetype: mimeType,
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
writeStream.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
filePromises.push(filePromise);
|
|
||||||
});
|
|
||||||
|
|
||||||
busboy.on('finish', async () => {
|
|
||||||
// 等待所有文件写入完成
|
|
||||||
try {
|
|
||||||
await Promise.all(filePromises);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`File write error: ${err.message}`);
|
|
||||||
res.end(error(`File write error: ${err.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const clearFiles = () => {
|
|
||||||
files.forEach((file: any) => {
|
|
||||||
if (file?.filepath && fs.existsSync(file.filepath)) {
|
|
||||||
fs.unlinkSync(file.filepath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否有文件上传
|
|
||||||
if (files.length === 0) {
|
|
||||||
res.end(error('files is required'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let appKey,
|
|
||||||
version,
|
|
||||||
username = '';
|
|
||||||
const { appKey: _appKey, version: _version, username: _username } = fields;
|
|
||||||
if (Array.isArray(_appKey)) {
|
|
||||||
appKey = _appKey?.[0];
|
|
||||||
} else {
|
|
||||||
appKey = _appKey;
|
|
||||||
}
|
|
||||||
if (Array.isArray(_version)) {
|
|
||||||
version = _version?.[0];
|
|
||||||
} else {
|
|
||||||
version = _version;
|
|
||||||
}
|
|
||||||
if (Array.isArray(_username)) {
|
|
||||||
username = _username?.[0];
|
|
||||||
} else if (_username) {
|
|
||||||
username = _username;
|
|
||||||
}
|
|
||||||
if (username) {
|
|
||||||
const user = await User.getUserByToken(token);
|
|
||||||
const has = await user.hasUser(username, true);
|
|
||||||
if (!has) {
|
|
||||||
res.end(error('username is not found'));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!appKey) {
|
|
||||||
res.end(error('appKey is required'));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!version) {
|
|
||||||
res.end(error('version is required'));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('Appkey', appKey, version);
|
|
||||||
|
|
||||||
// 逐个处理每个上传的文件
|
|
||||||
const uploadResults = [];
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i];
|
|
||||||
const tempPath = file.filepath; // 文件上传时的临时路径
|
|
||||||
const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构)
|
|
||||||
// 比如 child2/b.txt
|
|
||||||
const minioPath = `${username || tokenUser.username}/${appKey}/${version}/${relativePath}`;
|
|
||||||
// 上传到 MinIO 并保留文件夹结构
|
|
||||||
const isHTML = relativePath.endsWith('.html');
|
|
||||||
await minioClient.fPutObject(bucketName, minioPath, tempPath, {
|
|
||||||
'Content-Type': getContentType(relativePath),
|
|
||||||
'app-source': 'user-app',
|
|
||||||
'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年
|
|
||||||
});
|
|
||||||
uploadResults.push({
|
|
||||||
name: relativePath,
|
|
||||||
path: minioPath,
|
|
||||||
});
|
|
||||||
fs.unlinkSync(tempPath); // 删除临时文件
|
|
||||||
}
|
|
||||||
// 受控
|
|
||||||
const r = await app.call({
|
|
||||||
path: 'app',
|
|
||||||
key: 'uploadFiles',
|
|
||||||
payload: {
|
|
||||||
token: token,
|
|
||||||
data: {
|
|
||||||
appKey,
|
|
||||||
version,
|
|
||||||
username,
|
|
||||||
files: uploadResults,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const data: any = {
|
|
||||||
code: r.code,
|
|
||||||
data: r.body,
|
|
||||||
};
|
|
||||||
if (r.message) {
|
|
||||||
data.message = r.message;
|
|
||||||
}
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
});
|
|
||||||
|
|
||||||
pipeBusboy(req, res, busboy);
|
|
||||||
});
|
|
||||||
|
|
||||||
router.all('/api/nocodb-test/router', async (req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
|
|
||||||
const param = await router.getSearch(req);
|
|
||||||
const body = await router.getBody(req);
|
|
||||||
|
|
||||||
const contentType = req.headers['content-type'] || '';
|
|
||||||
console.log('Content-Type:', contentType);
|
|
||||||
console.log('NocoDB test router called.', req.method, param, JSON.stringify(body, null));
|
|
||||||
res.end(JSON.stringify({ message: 'NocoDB test router is working' }));
|
|
||||||
});
|
|
||||||
const simpleAppsPrefixs = [
|
const simpleAppsPrefixs = [
|
||||||
"/api/app/",
|
"/api/wxmsg"
|
||||||
"/api/micro-app/",
|
|
||||||
"/api/events",
|
|
||||||
"/api/s1/",
|
|
||||||
"/api/container/",
|
|
||||||
"/api/resource/",
|
|
||||||
"/api/wxmsg",
|
|
||||||
"/api/nocodb-test/"
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
// import './code/upload.ts';
|
|
||||||
import './event.ts';
|
|
||||||
|
|
||||||
import './resources/upload.ts';
|
|
||||||
import './resources/chunk.ts';
|
|
||||||
// import './resources/get-resources.ts';
|
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import { User } from '@/models/user.ts';
|
|
||||||
import http from 'http';
|
|
||||||
import { parse } from '@kevisual/router/src/server/cookie.ts';
|
|
||||||
export const error = (msg: string, code = 500) => {
|
|
||||||
return JSON.stringify({ code, message: msg });
|
|
||||||
};
|
|
||||||
export const checkAuth = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
|
||||||
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
|
|
||||||
const url = new URL(req.url || '', 'http://localhost');
|
|
||||||
const resNoPermission = () => {
|
|
||||||
res.statusCode = 401;
|
|
||||||
res.end(error('Invalid authorization'));
|
|
||||||
return { tokenUser: null, token: null };
|
|
||||||
};
|
|
||||||
if (!token) {
|
|
||||||
token = url.searchParams.get('token') || '';
|
|
||||||
}
|
|
||||||
if (!token) {
|
|
||||||
const parsedCookies = parse(req.headers.cookie || '');
|
|
||||||
token = parsedCookies.token || '';
|
|
||||||
}
|
|
||||||
if (!token) {
|
|
||||||
return resNoPermission();
|
|
||||||
}
|
|
||||||
if (token) {
|
|
||||||
token = token.replace('Bearer ', '');
|
|
||||||
}
|
|
||||||
let tokenUser;
|
|
||||||
try {
|
|
||||||
tokenUser = await User.verifyToken(token);
|
|
||||||
} catch (e) {
|
|
||||||
console.log('checkAuth error', e);
|
|
||||||
res.statusCode = 401;
|
|
||||||
res.end(error('Invalid token'));
|
|
||||||
return { tokenUser: null, token: null };
|
|
||||||
}
|
|
||||||
return { tokenUser, token };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getLoginUser = async (req: http.IncomingMessage) => {
|
|
||||||
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
|
|
||||||
const url = new URL(req.url || '', 'http://localhost');
|
|
||||||
if (!token) {
|
|
||||||
token = url.searchParams.get('token') || '';
|
|
||||||
}
|
|
||||||
if (!token) {
|
|
||||||
const parsedCookies = parse(req.headers.cookie || '');
|
|
||||||
token = parsedCookies.token || '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
token = token.replace('Bearer ', '');
|
|
||||||
}
|
|
||||||
let tokenUser;
|
|
||||||
try {
|
|
||||||
tokenUser = await User.verifyToken(token);
|
|
||||||
return { tokenUser, token };
|
|
||||||
} catch (e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './auth.ts'
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { minioClient } from '@/app.ts';
|
|
||||||
import { bucketName } from '@/modules/minio.ts';
|
|
||||||
|
|
||||||
import { router } from '../router.ts';
|
|
||||||
|
|
||||||
router.post('/api/minio', async (ctx) => {
|
|
||||||
let { username, appKey } = { username: '', appKey: '' };
|
|
||||||
const path = `${username}/${appKey}`;
|
|
||||||
const res = await minioClient.listObjectsV2(bucketName, path, true);
|
|
||||||
const file = res.filter((item) => item.isFile);
|
|
||||||
const fileList = file.map((item) => {
|
|
||||||
return {
|
|
||||||
name: item.name,
|
|
||||||
size: item.size,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
// ctx.body = fileList;
|
|
||||||
});
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
/**
|
|
||||||
* 更新时间:2025-03-17
|
|
||||||
* 第二次更新:2025-03-22
|
|
||||||
*/
|
|
||||||
import { minioClient } from '@/app.ts';
|
|
||||||
import { IncomingMessage, ServerResponse } from 'http';
|
|
||||||
import { bucketName } from '@/modules/minio.ts';
|
|
||||||
import { getLoginUser } from '../middleware/auth.ts';
|
|
||||||
import { BucketItemStat } from 'minio';
|
|
||||||
import { UserPermission, Permission } from '@kevisual/permission';
|
|
||||||
import { pipeMinioStream } from '@/modules/fm-manager/index.ts';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 过滤 metaData 中的 key, 去除 password, accesskey, secretkey,
|
|
||||||
* 并返回过滤后的 metaData
|
|
||||||
* @param metaData
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
const filterKeys = (metaData: Record<string, string>, clearKeys: string[] = []) => {
|
|
||||||
const keys = Object.keys(metaData);
|
|
||||||
// remove X-Amz- meta data
|
|
||||||
const removeKeys = ['password', 'accesskey', 'secretkey', ...clearKeys];
|
|
||||||
const filteredKeys = keys.filter((key) => !removeKeys.includes(key));
|
|
||||||
return filteredKeys.reduce((acc, key) => {
|
|
||||||
acc[key] = metaData[key];
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, string>);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const NotFoundFile = (res: ServerResponse, msg?: string, code = 404) => {
|
|
||||||
res.writeHead(code, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end(msg || 'Not Found File');
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
export const shareType = ['public', 'private', 'protected'] as const;
|
|
||||||
export type ShareType = (typeof shareType)[number];
|
|
||||||
|
|
||||||
export const authMinio = async (req: IncomingMessage, res: ServerResponse, objectName: string) => {
|
|
||||||
let stat: BucketItemStat;
|
|
||||||
try {
|
|
||||||
stat = await minioClient.statObject(bucketName, objectName);
|
|
||||||
} catch (e) {
|
|
||||||
return NotFoundFile(res);
|
|
||||||
}
|
|
||||||
const [userKey, ...rest] = objectName.split('/');
|
|
||||||
const _url = new URL(req.url || '', 'http://localhost');
|
|
||||||
const password = _url.searchParams.get('p') || '';
|
|
||||||
const isDownload = !!_url.searchParams.get('download');
|
|
||||||
const metaData = stat.metaData || {};
|
|
||||||
const filteredMetaData = filterKeys(metaData, ['size', 'etag', 'last-modified']);
|
|
||||||
if (stat.size === 0) {
|
|
||||||
return NotFoundFile(res);
|
|
||||||
}
|
|
||||||
const { tokenUser } = await getLoginUser(req);
|
|
||||||
const username = tokenUser?.username;
|
|
||||||
const owner = userKey;
|
|
||||||
const permission = new UserPermission({
|
|
||||||
permission: metaData as Permission,
|
|
||||||
owner,
|
|
||||||
});
|
|
||||||
const checkPermissionResult = permission.checkPermissionSuccess({
|
|
||||||
username,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
if (!checkPermissionResult.success) {
|
|
||||||
return NotFoundFile(res, checkPermissionResult.message, checkPermissionResult.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentLength = stat.size;
|
|
||||||
const etag = stat.etag;
|
|
||||||
const lastModified = stat.lastModified.toISOString();
|
|
||||||
const filename = objectName.split('/').pop() || 'no-file-name-download'; // Extract filename from objectName
|
|
||||||
const fileExtension = filename.split('.').pop()?.toLowerCase() || '';
|
|
||||||
const viewableExtensions = [
|
|
||||||
'jpg',
|
|
||||||
'jpeg',
|
|
||||||
'png',
|
|
||||||
'gif',
|
|
||||||
'svg',
|
|
||||||
'webp',
|
|
||||||
'mp4',
|
|
||||||
'webm',
|
|
||||||
'mp3',
|
|
||||||
'wav',
|
|
||||||
'ogg',
|
|
||||||
'pdf',
|
|
||||||
'doc',
|
|
||||||
'docx',
|
|
||||||
'xls',
|
|
||||||
'xlsx',
|
|
||||||
'ppt',
|
|
||||||
'pptx',
|
|
||||||
];
|
|
||||||
const contentDisposition = viewableExtensions.includes(fileExtension) && !isDownload ? 'inline' : `attachment; filename="${filename}"`;
|
|
||||||
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Length': contentLength,
|
|
||||||
etag,
|
|
||||||
'last-modified': lastModified,
|
|
||||||
'Content-Disposition': contentDisposition,
|
|
||||||
...filteredMetaData,
|
|
||||||
});
|
|
||||||
const objectStream = await minioClient.getObject(bucketName, objectName);
|
|
||||||
|
|
||||||
// objectStream.pipe(res, { end: true });
|
|
||||||
pipeMinioStream(objectStream, res);
|
|
||||||
};
|
|
||||||
@@ -13,6 +13,7 @@ import { getLoginUser } from '../modules/auth.ts';
|
|||||||
import { rediretHome } from '../modules/user-app/index.ts';
|
import { rediretHome } from '../modules/user-app/index.ts';
|
||||||
import { logger } from '../modules/logger.ts';
|
import { logger } from '../modules/logger.ts';
|
||||||
import { UserV1Proxy } from '../modules/ws-proxy/proxy.ts';
|
import { UserV1Proxy } from '../modules/ws-proxy/proxy.ts';
|
||||||
|
import { UserV3Proxy } from '@/modules/v3/index.ts';
|
||||||
import { hasBadUser, userIsBanned, appIsBanned, userPathIsBanned } from '@/modules/off/index.ts';
|
import { hasBadUser, userIsBanned, appIsBanned, userPathIsBanned } from '@/modules/off/index.ts';
|
||||||
import { robotsTxt } from '@/modules/html/index.ts';
|
import { robotsTxt } from '@/modules/html/index.ts';
|
||||||
import { isBun } from '@/utils/get-engine.ts';
|
import { isBun } from '@/utils/get-engine.ts';
|
||||||
@@ -145,7 +146,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
|||||||
|
|
||||||
let user, app;
|
let user, app;
|
||||||
let domainApp = false;
|
let domainApp = false;
|
||||||
const isDev = isLocalhost(dns.hostName);
|
const isDev = isLocalhost(dns?.hostName);
|
||||||
if (isDev) {
|
if (isDev) {
|
||||||
console.debug('开发环境访问:', req.url, 'Host:', dns.hostName);
|
console.debug('开发环境访问:', req.url, 'Host:', dns.hostName);
|
||||||
} else {
|
} else {
|
||||||
@@ -194,8 +195,8 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
|||||||
if (!domainApp) {
|
if (!domainApp) {
|
||||||
// 原始url地址
|
// 原始url地址
|
||||||
const urls = url.split('/');
|
const urls = url.split('/');
|
||||||
|
const [_, _user, _app] = urls;
|
||||||
if (urls.length < 3) {
|
if (urls.length < 3) {
|
||||||
const [_, _user] = urls;
|
|
||||||
if (_user === 'robots.txt') {
|
if (_user === 'robots.txt') {
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||||
res.end(robotsTxt);
|
res.end(robotsTxt);
|
||||||
@@ -212,8 +213,12 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
|||||||
forBadUser(req, res);
|
forBadUser(req, res);
|
||||||
}
|
}
|
||||||
return res.end();
|
return res.end();
|
||||||
|
} else {
|
||||||
|
if (userPathIsBanned(_user) || userPathIsBanned(_app)) {
|
||||||
|
logger.warn(`Bad user access from IP: ${dns.ip}, Host: ${dns.hostName}, URL: ${req.url}`);
|
||||||
|
return forBadUser(req, res);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const [_, _user, _app] = urls;
|
|
||||||
if (_app && urls.length === 3) {
|
if (_app && urls.length === 3) {
|
||||||
// 重定向到
|
// 重定向到
|
||||||
res.writeHead(302, { Location: `${url}/` });
|
res.writeHead(302, { Location: `${url}/` });
|
||||||
@@ -250,6 +255,11 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
|||||||
createNotFoundPage,
|
createNotFoundPage,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (user !== 'api' && app === 'v3') {
|
||||||
|
return UserV3Proxy(req, res, {
|
||||||
|
createNotFoundPage,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userApp = new UserApp({ user, app });
|
const userApp = new UserApp({ user, app });
|
||||||
let isExist = await userApp.getExist();
|
let isExist = await userApp.getExist();
|
||||||
@@ -288,6 +298,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
|||||||
username: loginUser?.tokenUser?.username || '',
|
username: loginUser?.tokenUser?.username || '',
|
||||||
password: password,
|
password: password,
|
||||||
});
|
});
|
||||||
|
console.log('checkPermission', checkPermission, 'loginUser:', loginUser, password)
|
||||||
if (!checkPermission.success) {
|
if (!checkPermission.success) {
|
||||||
return createNotFoundPage('no permission');
|
return createNotFoundPage('no permission');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,238 +0,0 @@
|
|||||||
import { useFileStore } from '@kevisual/use-config';
|
|
||||||
import { checkAuth, error, router, writeEvents, getKey, getTaskId } from '../router.ts';
|
|
||||||
import Busboy from 'busboy';
|
|
||||||
import { app, oss } from '@/app.ts';
|
|
||||||
|
|
||||||
import { getContentType } from '@/utils/get-content-type.ts';
|
|
||||||
import { User } from '@/models/user.ts';
|
|
||||||
import fs from 'fs';
|
|
||||||
import { ConfigModel } from '@/routes/config/models/model.ts';
|
|
||||||
import { validateDirectory } from './util.ts';
|
|
||||||
import path from 'path';
|
|
||||||
import { createWriteStream } from 'fs';
|
|
||||||
import { pipeBusboy } from '@/modules/fm-manager/index.ts';
|
|
||||||
|
|
||||||
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
|
||||||
|
|
||||||
router.get('/api/s1/resources/upload/chunk', async (req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('Upload API is ready');
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/s1/resources/upload
|
|
||||||
router.post('/api/s1/resources/upload/chunk', async (req, res) => {
|
|
||||||
const { tokenUser, token } = await checkAuth(req, res);
|
|
||||||
if (!tokenUser) return;
|
|
||||||
const url = new URL(req.url || '', 'http://localhost');
|
|
||||||
const share = !!url.searchParams.get('public');
|
|
||||||
const noCheckAppFiles = !!url.searchParams.get('noCheckAppFiles');
|
|
||||||
|
|
||||||
const taskId = getTaskId(req);
|
|
||||||
const finalFilePath = `${cacheFilePath}/${taskId}`;
|
|
||||||
if (!taskId) {
|
|
||||||
res.end(error('taskId is required'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 busboy 解析 multipart/form-data
|
|
||||||
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
|
|
||||||
const fields: any = {};
|
|
||||||
let file: any = null;
|
|
||||||
let tempPath = '';
|
|
||||||
let filePromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
busboy.on('field', (fieldname, value) => {
|
|
||||||
fields[fieldname] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
busboy.on('file', (fieldname, fileStream, info) => {
|
|
||||||
const { filename, encoding, mimeType } = info;
|
|
||||||
// 处理 UTF-8 文件名编码
|
|
||||||
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
|
|
||||||
tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
|
|
||||||
const writeStream = createWriteStream(tempPath);
|
|
||||||
|
|
||||||
filePromise = new Promise<void>((resolve, reject) => {
|
|
||||||
fileStream.pipe(writeStream);
|
|
||||||
|
|
||||||
writeStream.on('finish', () => {
|
|
||||||
file = {
|
|
||||||
filepath: tempPath,
|
|
||||||
originalFilename: decodedFilename,
|
|
||||||
mimetype: mimeType,
|
|
||||||
};
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
writeStream.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
busboy.on('finish', async () => {
|
|
||||||
// 等待文件写入完成
|
|
||||||
if (filePromise) {
|
|
||||||
try {
|
|
||||||
await filePromise;
|
|
||||||
} catch (err) {
|
|
||||||
console.error(`File write error: ${err.message}`);
|
|
||||||
res.end(error(`File write error: ${err.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const clearFiles = () => {
|
|
||||||
if (tempPath && fs.existsSync(tempPath)) {
|
|
||||||
fs.unlinkSync(tempPath);
|
|
||||||
}
|
|
||||||
if (fs.existsSync(finalFilePath)) {
|
|
||||||
fs.unlinkSync(finalFilePath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!file) {
|
|
||||||
res.end(error('No file uploaded'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle chunked upload logic here
|
|
||||||
let { chunkIndex, totalChunks, appKey, version, username, directory } = getKey(fields, [
|
|
||||||
'chunkIndex',
|
|
||||||
'totalChunks',
|
|
||||||
'appKey',
|
|
||||||
'version',
|
|
||||||
'username',
|
|
||||||
'directory',
|
|
||||||
]);
|
|
||||||
if (!chunkIndex || !totalChunks) {
|
|
||||||
res.end(error('chunkIndex, totalChunks is required'));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const relativePath = file.originalFilename;
|
|
||||||
|
|
||||||
const writeStream = fs.createWriteStream(finalFilePath, { flags: 'a' });
|
|
||||||
const readStream = fs.createReadStream(tempPath);
|
|
||||||
readStream.pipe(writeStream);
|
|
||||||
|
|
||||||
writeStream.on('finish', async () => {
|
|
||||||
fs.unlinkSync(tempPath); // 删除临时文件
|
|
||||||
|
|
||||||
// Write event for progress tracking
|
|
||||||
const progress = ((parseInt(chunkIndex) + 1) / parseInt(totalChunks)) * 100;
|
|
||||||
writeEvents(req, {
|
|
||||||
progress,
|
|
||||||
message: `Upload progress: ${progress}%`,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (parseInt(chunkIndex) + 1 === parseInt(totalChunks)) {
|
|
||||||
let uid = tokenUser.id;
|
|
||||||
if (username) {
|
|
||||||
const user = await User.getUserByToken(token);
|
|
||||||
const has = await user.hasUser(username, true);
|
|
||||||
if (!has) {
|
|
||||||
res.end(error('username is not found'));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const _user = await User.findOne({ where: { username } });
|
|
||||||
uid = _user?.id || '';
|
|
||||||
}
|
|
||||||
if (!appKey || !version) {
|
|
||||||
const config = await ConfigModel.getUploadConfig({ uid });
|
|
||||||
if (config) {
|
|
||||||
appKey = config.config?.data?.key || '';
|
|
||||||
version = config.config?.data?.version || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!appKey || !version) {
|
|
||||||
res.end(error('appKey or version is not found, please check the upload config.'));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { code, message } = validateDirectory(directory);
|
|
||||||
if (code !== 200) {
|
|
||||||
res.end(error(message));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const minioPath = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
|
|
||||||
const metadata: any = {};
|
|
||||||
if (share) {
|
|
||||||
metadata.share = 'public';
|
|
||||||
}
|
|
||||||
const bucketName = oss.bucketName;
|
|
||||||
// All chunks uploaded, now upload to MinIO
|
|
||||||
await oss.client.fPutObject(bucketName, minioPath, finalFilePath, {
|
|
||||||
'Content-Type': getContentType(relativePath),
|
|
||||||
'app-source': 'user-app',
|
|
||||||
'Cache-Control': relativePath.endsWith('.html') ? 'no-cache' : 'max-age=31536000, immutable',
|
|
||||||
...metadata,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up the final file
|
|
||||||
fs.unlinkSync(finalFilePath);
|
|
||||||
const downloadBase = '/api/s1/share';
|
|
||||||
|
|
||||||
const uploadResult = {
|
|
||||||
name: relativePath,
|
|
||||||
path: `${downloadBase}/${minioPath}`,
|
|
||||||
appKey,
|
|
||||||
version,
|
|
||||||
username,
|
|
||||||
};
|
|
||||||
if (!noCheckAppFiles) {
|
|
||||||
// Notify the app
|
|
||||||
const r = await app.call({
|
|
||||||
path: 'app',
|
|
||||||
key: 'detectVersionList',
|
|
||||||
payload: {
|
|
||||||
token: token,
|
|
||||||
data: {
|
|
||||||
appKey,
|
|
||||||
version,
|
|
||||||
username,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const data: any = {
|
|
||||||
code: r.code,
|
|
||||||
data: {
|
|
||||||
app: r.body,
|
|
||||||
upload: [uploadResult],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (r.message) {
|
|
||||||
data.message = r.message;
|
|
||||||
}
|
|
||||||
console.log('upload data', data);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
} else {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 200,
|
|
||||||
message: 'Chunk uploaded successfully',
|
|
||||||
data: { chunkIndex, totalChunks, upload: [uploadResult] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 200,
|
|
||||||
message: 'Chunk uploaded successfully',
|
|
||||||
data: {
|
|
||||||
chunkIndex,
|
|
||||||
totalChunks,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
pipeBusboy(req, res, busboy);
|
|
||||||
});
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { router } from '@/app.ts';
|
|
||||||
|
|
||||||
import { authMinio } from '../minio/get-minio-resource.ts';
|
|
||||||
|
|
||||||
// 功能可以抽离为某一个dns请求的服务
|
|
||||||
|
|
||||||
router.all('/api/s1/share/*splat', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const url = req.url;
|
|
||||||
const _url = new URL(url || '', 'http://localhost');
|
|
||||||
let objectName = _url.pathname.replace('/api/s1/share/', '');
|
|
||||||
objectName = decodeURIComponent(objectName);
|
|
||||||
await authMinio(req, res, objectName);
|
|
||||||
} catch (e) {
|
|
||||||
console.log('get share resource error url', req.url);
|
|
||||||
console.error('get share resource is error.', e.message);
|
|
||||||
res.end('get share resource is error.');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import { useFileStore } from '@kevisual/use-config';
|
|
||||||
import { checkAuth, error, router, writeEvents, getKey } from '../router.ts';
|
|
||||||
import Busboy from 'busboy';
|
|
||||||
import { app, minioClient } from '@/app.ts';
|
|
||||||
|
|
||||||
import { bucketName } from '@/modules/minio.ts';
|
|
||||||
import { getContentType } from '@/utils/get-content-type.ts';
|
|
||||||
import { User } from '@/models/user.ts';
|
|
||||||
import fs from 'fs';
|
|
||||||
import path from 'path';
|
|
||||||
import { createWriteStream } from 'fs';
|
|
||||||
import { pipeBusboy } from '@/modules/fm-manager/pipe-busboy.ts';
|
|
||||||
import { ConfigModel } from '@/routes/config/models/model.ts';
|
|
||||||
import { validateDirectory } from './util.ts';
|
|
||||||
import { pick } from 'es-toolkit';
|
|
||||||
import { getFileStat } from '@/routes/file/index.ts';
|
|
||||||
import { logger } from '@/modules/logger.ts';
|
|
||||||
|
|
||||||
const cacheFilePath = useFileStore('cache-file', { needExists: true });
|
|
||||||
|
|
||||||
router.get('/api/s1/resources/upload', async (req, res) => {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
||||||
res.end('Upload API is ready');
|
|
||||||
});
|
|
||||||
export const parseIfJson = (data = '{}') => {
|
|
||||||
try {
|
|
||||||
const _data = JSON.parse(data);
|
|
||||||
if (typeof _data === 'object') return _data;
|
|
||||||
return {};
|
|
||||||
} catch (error) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
router.post('/api/s1/resources/upload/check', async (req, res) => {
|
|
||||||
const { tokenUser, token } = await checkAuth(req, res);
|
|
||||||
if (!tokenUser) {
|
|
||||||
res.end(error('Token is invalid.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log('data', req.url);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
const data = await router.getBody(req);
|
|
||||||
type Data = {
|
|
||||||
appKey: string;
|
|
||||||
version: string;
|
|
||||||
username: string;
|
|
||||||
directory: string;
|
|
||||||
files: { path: string; hash: string }[];
|
|
||||||
};
|
|
||||||
let { appKey, version, username, directory, files } = pick(data, ['appKey', 'version', 'username', 'directory', 'files']) as Data;
|
|
||||||
let uid = tokenUser.id;
|
|
||||||
if (username) {
|
|
||||||
const user = await User.getUserByToken(token);
|
|
||||||
const has = await user.hasUser(username, true);
|
|
||||||
if (!has) {
|
|
||||||
res.end(error('username is not found'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const _user = await User.findOne({ where: { username } });
|
|
||||||
uid = _user?.id || '';
|
|
||||||
}
|
|
||||||
if (!appKey || !version) {
|
|
||||||
res.end(error('appKey and version is required'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { code, message } = validateDirectory(directory);
|
|
||||||
if (code !== 200) {
|
|
||||||
res.end(error(message));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
type CheckResult = {
|
|
||||||
path: string;
|
|
||||||
stat: any;
|
|
||||||
resourcePath: string;
|
|
||||||
hash: string;
|
|
||||||
uploadHash: string;
|
|
||||||
isUpload?: boolean;
|
|
||||||
};
|
|
||||||
const checkResult: CheckResult[] = [];
|
|
||||||
for (let i = 0; i < files.length; i++) {
|
|
||||||
const file = files[i];
|
|
||||||
const relativePath = file.path;
|
|
||||||
const minioPath = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
|
|
||||||
let stat = await getFileStat(minioPath, true);
|
|
||||||
const statHash = stat?.etag || '';
|
|
||||||
checkResult.push({
|
|
||||||
path: relativePath,
|
|
||||||
uploadHash: file.hash,
|
|
||||||
resourcePath: minioPath,
|
|
||||||
isUpload: statHash === file.hash,
|
|
||||||
stat,
|
|
||||||
hash: statHash,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
res.end(JSON.stringify({ code: 200, data: checkResult }));
|
|
||||||
});
|
|
||||||
|
|
||||||
// /api/s1/resources/upload
|
|
||||||
router.post('/api/s1/resources/upload', async (req, res) => {
|
|
||||||
const { tokenUser, token } = await checkAuth(req, res);
|
|
||||||
if (!tokenUser) {
|
|
||||||
res.end(error('Token is invalid.'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const url = new URL(req.url || '', 'http://localhost');
|
|
||||||
const share = !!url.searchParams.get('public');
|
|
||||||
const meta = parseIfJson(url.searchParams.get('meta'));
|
|
||||||
const noCheckAppFiles = !!url.searchParams.get('noCheckAppFiles');
|
|
||||||
// 使用 busboy 解析 multipart/form-data
|
|
||||||
const busboy = Busboy({ headers: req.headers, preservePath: true, defCharset: 'utf-8' });
|
|
||||||
const fields: any = {};
|
|
||||||
const files: any[] = [];
|
|
||||||
const filePromises: Promise<void>[] = [];
|
|
||||||
let bytesReceived = 0;
|
|
||||||
let bytesExpected = parseInt(req.headers['content-length'] || '0');
|
|
||||||
busboy.on('field', (fieldname, value) => {
|
|
||||||
fields[fieldname] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
busboy.on('file', (fieldname, fileStream, info) => {
|
|
||||||
const { filename, encoding, mimeType } = info;
|
|
||||||
// 处理 UTF-8 文件名编码(busboy 可能返回 Latin-1 编码的缓冲区)
|
|
||||||
const decodedFilename = typeof filename === 'string' ? Buffer.from(filename, 'latin1').toString('utf8') : filename;
|
|
||||||
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
|
|
||||||
const writeStream = createWriteStream(tempPath);
|
|
||||||
const filePromise = new Promise<void>((resolve, reject) => {
|
|
||||||
fileStream.on('data', (chunk) => {
|
|
||||||
bytesReceived += chunk.length;
|
|
||||||
if (bytesExpected > 0) {
|
|
||||||
const progress = (bytesReceived / bytesExpected) * 100;
|
|
||||||
const data = {
|
|
||||||
progress: progress.toFixed(2),
|
|
||||||
message: `Upload progress: ${progress.toFixed(2)}%`,
|
|
||||||
};
|
|
||||||
console.log('progress-upload', data);
|
|
||||||
writeEvents(req, data);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
fileStream.pipe(writeStream);
|
|
||||||
|
|
||||||
writeStream.on('finish', () => {
|
|
||||||
files.push({
|
|
||||||
filepath: tempPath,
|
|
||||||
originalFilename: decodedFilename,
|
|
||||||
mimetype: mimeType,
|
|
||||||
});
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
writeStream.on('error', (err) => {
|
|
||||||
reject(err);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
filePromises.push(filePromise);
|
|
||||||
});
|
|
||||||
|
|
||||||
busboy.on('finish', async () => {
|
|
||||||
// 等待所有文件写入完成
|
|
||||||
try {
|
|
||||||
await Promise.all(filePromises);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`File write error: ${err.message}`);
|
|
||||||
res.end(error(`File write error: ${err.message}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const clearFiles = () => {
|
|
||||||
files.forEach((file) => {
|
|
||||||
if (file?.filepath && fs.existsSync(file.filepath)) {
|
|
||||||
fs.unlinkSync(file.filepath);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查是否有文件上传
|
|
||||||
if (files.length === 0) {
|
|
||||||
res.end(error('files is required'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let { appKey, version, username, directory, description } = getKey(fields, ['appKey', 'version', 'username', 'directory', 'description']);
|
|
||||||
let uid = tokenUser.id;
|
|
||||||
if (username) {
|
|
||||||
const user = await User.getUserByToken(token);
|
|
||||||
const has = await user.hasUser(username, true);
|
|
||||||
if (!has) {
|
|
||||||
res.end(error('username is not found'));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const _user = await User.findOne({ where: { username } });
|
|
||||||
uid = _user?.id || '';
|
|
||||||
}
|
|
||||||
if (!appKey || !version) {
|
|
||||||
const config = await ConfigModel.getUploadConfig({ uid });
|
|
||||||
if (config) {
|
|
||||||
appKey = config.config?.data?.key || '';
|
|
||||||
version = config.config?.data?.version || '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!appKey || !version) {
|
|
||||||
res.end(error('appKey or version is not found, please check the upload config.'));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { code, message } = validateDirectory(directory);
|
|
||||||
if (code !== 200) {
|
|
||||||
res.end(error(message));
|
|
||||||
clearFiles();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 逐个处理每个上传的文件
|
|
||||||
const uploadedFiles = files;
|
|
||||||
logger.info(
|
|
||||||
'upload files',
|
|
||||||
uploadedFiles.map((item) => {
|
|
||||||
return pick(item, ['filepath', 'originalFilename']);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const uploadResults = [];
|
|
||||||
for (let i = 0; i < uploadedFiles.length; i++) {
|
|
||||||
const file = uploadedFiles[i];
|
|
||||||
// @ts-ignore
|
|
||||||
const tempPath = file.filepath; // 文件上传时的临时路径
|
|
||||||
const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构)
|
|
||||||
// 比如 child2/b.txt
|
|
||||||
const minioPath = `${username || tokenUser.username}/${appKey}/${version}${directory ? `/${directory}` : ''}/${relativePath}`;
|
|
||||||
// 上传到 MinIO 并保留文件夹结构
|
|
||||||
const isHTML = relativePath.endsWith('.html');
|
|
||||||
const metadata: any = {};
|
|
||||||
if (share) {
|
|
||||||
metadata.share = 'public';
|
|
||||||
}
|
|
||||||
Object.assign(metadata, meta);
|
|
||||||
await minioClient.fPutObject(bucketName, minioPath, tempPath, {
|
|
||||||
'Content-Type': getContentType(relativePath),
|
|
||||||
'app-source': 'user-app',
|
|
||||||
'Cache-Control': isHTML ? 'no-cache' : 'max-age=31536000, immutable', // 缓存一年
|
|
||||||
...metadata,
|
|
||||||
});
|
|
||||||
uploadResults.push({
|
|
||||||
name: relativePath,
|
|
||||||
path: minioPath,
|
|
||||||
});
|
|
||||||
fs.unlinkSync(tempPath); // 删除临时文件
|
|
||||||
}
|
|
||||||
if (!noCheckAppFiles) {
|
|
||||||
const _data = { appKey, version, username, files: uploadResults, description, }
|
|
||||||
if (_data.description) {
|
|
||||||
delete _data.description;
|
|
||||||
}
|
|
||||||
// 受控
|
|
||||||
const r = await app.call({
|
|
||||||
path: 'app',
|
|
||||||
key: 'uploadFiles',
|
|
||||||
payload: {
|
|
||||||
token: token,
|
|
||||||
data: _data,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const data: any = {
|
|
||||||
code: r.code,
|
|
||||||
data: {
|
|
||||||
app: r.body,
|
|
||||||
upload: uploadResults,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (r.message) {
|
|
||||||
data.message = r.message;
|
|
||||||
}
|
|
||||||
console.log('upload data', data);
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(JSON.stringify(data));
|
|
||||||
} else {
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
code: 200,
|
|
||||||
data: {
|
|
||||||
detect: [],
|
|
||||||
upload: uploadResults,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
pipeBusboy(req, res, busboy);
|
|
||||||
});
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
/**
|
|
||||||
* 校验directory是否合法, 合法返回200, 不合法返回500
|
|
||||||
*
|
|
||||||
* directory 不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
|
|
||||||
* 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
|
|
||||||
* @param directory 目录
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
export const validateDirectory = (directory?: string) => {
|
|
||||||
// 对directory进行校验,不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
|
|
||||||
if (directory && (directory.startsWith('/') || directory.endsWith('/') || directory.startsWith('..') || directory.endsWith('..'))) {
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
message: 'directory is invalid',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
|
|
||||||
// 可以包含.
|
|
||||||
let _directory = directory?.replace(/\//g, '');
|
|
||||||
if (_directory && !/^[a-zA-Z0-9_.-]+$/.test(_directory)) {
|
|
||||||
return {
|
|
||||||
code: 500,
|
|
||||||
message: 'directory is invalid, only number, letter, underline and hyphen are allowed',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
code: 200,
|
|
||||||
message: 'directory is valid',
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
import { router } from '@/app.ts';
|
import { router } from '@/app.ts';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { useContextKey } from '@kevisual/context';
|
import { useContextKey } from '@kevisual/context';
|
||||||
import { checkAuth, error } from './middleware/auth.ts';
|
export { router, };
|
||||||
export { router, checkAuth, error };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 事件客户端
|
* 事件客户端
|
||||||
*/
|
*/
|
||||||
const eventClientsInit = () => {
|
const eventClientsInit = () => {
|
||||||
const clients = new Map<string, { client?: http.ServerResponse; createTime?: number; [key: string]: any }>();
|
const clients = new Map<string, { client?: http.ServerResponse; createTime?: number;[key: string]: any }>();
|
||||||
return clients;
|
return clients;
|
||||||
};
|
};
|
||||||
export const clients = useContextKey('event-clients', () => eventClientsInit());
|
export const clients = useContextKey('event-clients', () => eventClientsInit());
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getUidByUsername, prefixFix } from './util.ts';
|
|||||||
import { deleteFiles, getMinioListAndSetToAppList } from '../file/index.ts';
|
import { deleteFiles, getMinioListAndSetToAppList } from '../file/index.ts';
|
||||||
import { setExpire } from './revoke.ts';
|
import { setExpire } from './revoke.ts';
|
||||||
import { User } from '@/models/user.ts';
|
import { User } from '@/models/user.ts';
|
||||||
|
import { callDetectAppVersion } from './export.ts';
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
path: 'app',
|
path: 'app',
|
||||||
@@ -43,7 +44,7 @@ app
|
|||||||
console.log('get app manager called');
|
console.log('get app manager called');
|
||||||
const tokenUser = ctx.state.tokenUser;
|
const tokenUser = ctx.state.tokenUser;
|
||||||
const id = ctx.query.id;
|
const id = ctx.query.id;
|
||||||
const { key, version } = ctx.query?.data || {};
|
const { key, version, create = false } = ctx.query?.data || {};
|
||||||
if (!id && (!key || !version)) {
|
if (!id && (!key || !version)) {
|
||||||
throw new CustomError('id is required');
|
throw new CustomError('id is required');
|
||||||
}
|
}
|
||||||
@@ -59,8 +60,27 @@ app
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (!am && create) {
|
||||||
|
am = await AppListModel.create({
|
||||||
|
key,
|
||||||
|
version,
|
||||||
|
uid: tokenUser.id,
|
||||||
|
data: {},
|
||||||
|
});
|
||||||
|
const res = await app.run({ path: 'app', key: "detectVersionList", payload: { data: { appKey: key, version, username: tokenUser.username }, token: ctx.query.token } });
|
||||||
|
if (res.code !== 200) {
|
||||||
|
ctx.throw(res.message || 'detect version list error');
|
||||||
|
}
|
||||||
|
am = await AppListModel.findOne({
|
||||||
|
where: {
|
||||||
|
key,
|
||||||
|
version,
|
||||||
|
uid: tokenUser.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
if (!am) {
|
if (!am) {
|
||||||
throw new CustomError('app not found');
|
ctx.throw('app not found');
|
||||||
}
|
}
|
||||||
console.log('get app', am.id, am.key, am.version);
|
console.log('get app', am.id, am.key, am.version);
|
||||||
ctx.body = prefixFix(am, tokenUser.username);
|
ctx.body = prefixFix(am, tokenUser.username);
|
||||||
@@ -239,7 +259,7 @@ app
|
|||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const tokenUser = ctx.state.tokenUser;
|
const tokenUser = ctx.state.tokenUser;
|
||||||
const { id, username, appKey, version } = ctx.query.data;
|
const { id, username, appKey, version, detect } = ctx.query.data;
|
||||||
if (!id && !appKey) {
|
if (!id && !appKey) {
|
||||||
throw new CustomError('id or appKey is required');
|
throw new CustomError('id or appKey is required');
|
||||||
}
|
}
|
||||||
@@ -249,22 +269,33 @@ app
|
|||||||
if (id) {
|
if (id) {
|
||||||
appList = await AppListModel.findByPk(id);
|
appList = await AppListModel.findByPk(id);
|
||||||
if (appList?.uid !== uid) {
|
if (appList?.uid !== uid) {
|
||||||
throw new CustomError('no permission');
|
ctx.throw('no permission');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!appList && appKey) {
|
if (!appList && appKey) {
|
||||||
if (!version) {
|
if (!version) {
|
||||||
throw new CustomError('version is required');
|
ctx.throw('version is required');
|
||||||
}
|
}
|
||||||
appList = await AppListModel.findOne({ where: { key: appKey, version, uid } });
|
appList = await AppListModel.findOne({ where: { key: appKey, version, uid } });
|
||||||
}
|
}
|
||||||
if (!appList) {
|
if (!appList) {
|
||||||
throw new CustomError('app not found');
|
ctx.throw('app 未发现');
|
||||||
}
|
}
|
||||||
|
if (detect) {
|
||||||
|
const appKey = appList.key;
|
||||||
|
const version = appList.version;
|
||||||
|
// 自动检测最新版本
|
||||||
|
const res = await callDetectAppVersion({ appKey, version, username: username || tokenUser.username }, ctx.query.token);
|
||||||
|
if (res.code !== 200) {
|
||||||
|
ctx.throw(res.message || '检测版本列表失败');
|
||||||
|
}
|
||||||
|
appList = await AppListModel.findByPk(appList.id);
|
||||||
|
}
|
||||||
|
|
||||||
const files = appList.data.files || [];
|
const files = appList.data.files || [];
|
||||||
const am = await AppModel.findOne({ where: { key: appList.key, uid: uid } });
|
const am = await AppModel.findOne({ where: { key: appList.key, uid: uid } });
|
||||||
if (!am) {
|
if (!am) {
|
||||||
throw new CustomError('app not found');
|
ctx.throw('app 未发现');
|
||||||
}
|
}
|
||||||
await am.update({ data: { ...am.data, files }, version: appList.version });
|
await am.update({ data: { ...am.data, files }, version: appList.version });
|
||||||
setExpire(appList.key, am.user);
|
setExpire(appList.key, am.user);
|
||||||
@@ -366,7 +397,7 @@ app
|
|||||||
am = await AppModel.create({
|
am = await AppModel.create({
|
||||||
title: appKey,
|
title: appKey,
|
||||||
key: appKey,
|
key: appKey,
|
||||||
version: version || '0.0.0',
|
version: version || '0.0.1',
|
||||||
user: checkUsername,
|
user: checkUsername,
|
||||||
uid,
|
uid,
|
||||||
data: { files: needAddFiles },
|
data: { files: needAddFiles },
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { app, redis } from '@/app.ts';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { fileStore } from '@/modules/config.ts';
|
import { fileStore } from '@/modules/config.ts';
|
||||||
import { getAppLoadStatus } from '@/modules/user-app/index.ts';
|
import { getAppLoadStatus } from '@/modules/user-app/index.ts';
|
||||||
import { getLoginUser } from '@/modules/auth.ts';
|
|
||||||
|
|
||||||
export class CenterUserApp {
|
export class CenterUserApp {
|
||||||
user: string;
|
user: string;
|
||||||
@@ -55,25 +54,25 @@ export class CenterUserApp {
|
|||||||
deleteUserAppFiles(user, app);
|
deleteUserAppFiles(user, app);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app
|
// app
|
||||||
.route({
|
// .route({
|
||||||
path: 'page-proxy-app',
|
// path: 'page-proxy-app',
|
||||||
key: 'auth-admin',
|
// key: 'auth-admin',
|
||||||
id: 'auth-admin',
|
// id: 'auth-admin',
|
||||||
})
|
// })
|
||||||
.define(async (ctx) => {
|
// .define(async (ctx) => {
|
||||||
const { user } = ctx.query;
|
// const { user } = ctx.query;
|
||||||
const loginUser = await getLoginUser(ctx.req);
|
// const loginUser = await getLoginUser(ctx.req);
|
||||||
if (loginUser) {
|
// if (loginUser) {
|
||||||
const root = ['admin', 'root'];
|
// const root = ['admin', 'root'];
|
||||||
if (root.includes(loginUser.tokenUser?.username)) {
|
// if (root.includes(loginUser.tokenUser?.username)) {
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
ctx.throw(401, 'No Proxy App Permission');
|
// ctx.throw(401, 'No Proxy App Permission');
|
||||||
}
|
// }
|
||||||
ctx.throw(401, 'No Login And No Proxy App Permission');
|
// ctx.throw(401, 'No Login And No Proxy App Permission');
|
||||||
})
|
// })
|
||||||
.addTo(app);
|
// .addTo(app);
|
||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
@@ -81,7 +80,6 @@ app
|
|||||||
key: 'list',
|
key: 'list',
|
||||||
middleware: ['auth-admin'],
|
middleware: ['auth-admin'],
|
||||||
description: '获取应用列表',
|
description: '获取应用列表',
|
||||||
isDebug: true,
|
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const keys = await redis.keys('user:app:*');
|
const keys = await redis.keys('user:app:*');
|
||||||
@@ -101,6 +99,7 @@ app
|
|||||||
path: 'page-proxy-app',
|
path: 'page-proxy-app',
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
middleware: ['auth-admin'],
|
middleware: ['auth-admin'],
|
||||||
|
description: '删除应用缓存',
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const { user, app } = ctx.query;
|
const { user, app } = ctx.query;
|
||||||
@@ -119,6 +118,8 @@ app
|
|||||||
.route({
|
.route({
|
||||||
path: 'page-proxy-app',
|
path: 'page-proxy-app',
|
||||||
key: 'deleteAll',
|
key: 'deleteAll',
|
||||||
|
middleware: ['auth-admin'],
|
||||||
|
description: '删除所有应用缓存',
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const keys = await redis.keys('user:app:*');
|
const keys = await redis.keys('user:app:*');
|
||||||
@@ -134,7 +135,9 @@ app
|
|||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
path: 'page-proxy-app',
|
path: 'page-proxy-app',
|
||||||
|
description: '清理所有应用缓存',
|
||||||
key: 'clear',
|
key: 'clear',
|
||||||
|
middleware: ['auth-admin'],
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const keys = await redis.keys('user:app:*');
|
const keys = await redis.keys('user:app:*');
|
||||||
@@ -153,6 +156,7 @@ app
|
|||||||
.route({
|
.route({
|
||||||
path: 'page-proxy-app',
|
path: 'page-proxy-app',
|
||||||
key: 'get',
|
key: 'get',
|
||||||
|
description: '获取应用缓存信息',
|
||||||
middleware: ['auth-admin'],
|
middleware: ['auth-admin'],
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
@@ -178,6 +182,7 @@ app
|
|||||||
.route({
|
.route({
|
||||||
path: 'page-proxy-app',
|
path: 'page-proxy-app',
|
||||||
key: 'status',
|
key: 'status',
|
||||||
|
description: '获取应用加载状态',
|
||||||
middleware: [],
|
middleware: [],
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { app } from '@/app.ts';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { ConfigModel } from './models/model.ts';
|
import { app, db, schema } from '@/app.ts';
|
||||||
import { oss } from '@/app.ts';
|
import { oss } from '@/app.ts';
|
||||||
import { ConfigOssService } from '@kevisual/oss/services';
|
import { ConfigOssService } from '@kevisual/oss/services';
|
||||||
import { Op } from 'sequelize';
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
@@ -20,14 +20,12 @@ app
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const { list, keys, keyEtagMap } = await configOss.getList();
|
const { list, keys, keyEtagMap } = await configOss.getList();
|
||||||
const configList = await ConfigModel.findAll({
|
const configList = await db.select()
|
||||||
where: {
|
.from(schema.kvConfig)
|
||||||
key: {
|
.where(and(
|
||||||
[Op.in]: keys,
|
inArray(schema.kvConfig.key, keys),
|
||||||
},
|
eq(schema.kvConfig.uid, tokenUser.id)
|
||||||
uid: tokenUser.id,
|
));
|
||||||
},
|
|
||||||
});
|
|
||||||
const needUpdateList = list.filter((item) => {
|
const needUpdateList = list.filter((item) => {
|
||||||
const key = item.key;
|
const key = item.key;
|
||||||
const hash = keyEtagMap.get(key);
|
const hash = keyEtagMap.get(key);
|
||||||
@@ -43,30 +41,33 @@ app
|
|||||||
const keyETag = keyEtagMap.get(key);
|
const keyETag = keyEtagMap.get(key);
|
||||||
const configData = keyDataMap.get(key);
|
const configData = keyDataMap.get(key);
|
||||||
if (keyETag && configData) {
|
if (keyETag && configData) {
|
||||||
const [config, created] = await ConfigModel.findOrCreate({
|
const existing = await db.select()
|
||||||
where: {
|
.from(schema.kvConfig)
|
||||||
key,
|
.where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tokenUser.id)))
|
||||||
uid: tokenUser.id,
|
.limit(1);
|
||||||
},
|
|
||||||
defaults: {
|
let config;
|
||||||
|
if (existing.length === 0) {
|
||||||
|
const inserted = await db.insert(schema.kvConfig).values({
|
||||||
|
id: nanoid(),
|
||||||
key,
|
key,
|
||||||
title: key,
|
title: key,
|
||||||
description: `从${key}:${keyETag} 同步而来`,
|
description: `从${key}:${keyETag} 同步而来`,
|
||||||
uid: tokenUser.id,
|
uid: tokenUser.id,
|
||||||
hash: keyETag,
|
hash: keyETag,
|
||||||
data: configData,
|
data: configData,
|
||||||
},
|
}).returning();
|
||||||
});
|
config = inserted[0];
|
||||||
if (!created) {
|
} else {
|
||||||
await config.update(
|
const updated = await db.update(schema.kvConfig)
|
||||||
{
|
.set({
|
||||||
hash: keyETag,
|
hash: keyETag,
|
||||||
data: json,
|
data: json,
|
||||||
},
|
updatedAt: new Date().toISOString(),
|
||||||
{
|
})
|
||||||
fields: ['hash', 'data'],
|
.where(eq(schema.kvConfig.id, existing[0].id))
|
||||||
},
|
.returning();
|
||||||
);
|
config = updated[0];
|
||||||
}
|
}
|
||||||
updateList.push(config);
|
updateList.push(config);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { app } from '@/app.ts';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { ConfigModel } from './models/model.ts';
|
import { app, db, schema } from '@/app.ts';
|
||||||
import { User } from '@/models/user.ts';
|
import { User } from '@/models/user.ts';
|
||||||
import { defaultKeys } from './models/default-keys.ts';
|
import { defaultKeys } from './models/default-keys.ts';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
@@ -27,19 +28,28 @@ app
|
|||||||
}
|
}
|
||||||
const defaultConfig = defaultKeys.find((item) => item.key === configKey);
|
const defaultConfig = defaultKeys.find((item) => item.key === configKey);
|
||||||
|
|
||||||
const [config, created] = await ConfigModel.findOrCreate({
|
const existing = await db.select()
|
||||||
where: {
|
.from(schema.kvConfig)
|
||||||
key: configKey,
|
.where(and(
|
||||||
uid: tokenUser.id,
|
eq(schema.kvConfig.key, configKey),
|
||||||
},
|
eq(schema.kvConfig.uid, tokenUser.id)
|
||||||
defaults: {
|
))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
let config;
|
||||||
|
if (existing.length === 0) {
|
||||||
|
const inserted = await db.insert(schema.kvConfig).values({
|
||||||
|
id: nanoid(),
|
||||||
title: defaultConfig?.key,
|
title: defaultConfig?.key,
|
||||||
description: defaultConfig?.description || '',
|
description: defaultConfig?.description || '',
|
||||||
key: configKey,
|
key: configKey,
|
||||||
uid: tokenUser.id,
|
uid: tokenUser.id,
|
||||||
data: defaultConfig?.data,
|
data: defaultConfig?.data,
|
||||||
},
|
}).returning();
|
||||||
});
|
config = inserted[0];
|
||||||
|
} else {
|
||||||
|
config = existing[0];
|
||||||
|
}
|
||||||
|
|
||||||
ctx.body = config;
|
ctx.body = config;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { app } from '@/app.ts';
|
import { eq, desc, and, inArray } from 'drizzle-orm';
|
||||||
import { ConfigModel } from './models/model.ts';
|
import { app, db, schema } from '@/app.ts';
|
||||||
import { ShareConfigService } from './services/share.ts';
|
import { ShareConfigService } from './services/share.ts';
|
||||||
import { oss } from '@/app.ts';
|
import { oss } from '@/app.ts';
|
||||||
import { ConfigOssService } from '@kevisual/oss/services';
|
import { ConfigOssService } from '@kevisual/oss/services';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
@@ -13,12 +14,10 @@ app
|
|||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const { id } = ctx.state.tokenUser;
|
const { id } = ctx.state.tokenUser;
|
||||||
const config = await ConfigModel.findAll({
|
const config = await db.select()
|
||||||
where: {
|
.from(schema.kvConfig)
|
||||||
uid: id,
|
.where(eq(schema.kvConfig.uid, id))
|
||||||
},
|
.orderBy(desc(schema.kvConfig.updatedAt));
|
||||||
order: [['updatedAt', 'DESC']],
|
|
||||||
});
|
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
list: config,
|
list: config,
|
||||||
};
|
};
|
||||||
@@ -36,9 +35,10 @@ app
|
|||||||
const tokernUser = ctx.state.tokenUser;
|
const tokernUser = ctx.state.tokenUser;
|
||||||
const tuid = tokernUser.id;
|
const tuid = tokernUser.id;
|
||||||
const { id, data, ...rest } = ctx.query?.data || {};
|
const { id, data, ...rest } = ctx.query?.data || {};
|
||||||
let config: ConfigModel;
|
let config: any;
|
||||||
if (id) {
|
if (id) {
|
||||||
config = await ConfigModel.findByPk(id);
|
const configs = await db.select().from(schema.kvConfig).where(eq(schema.kvConfig.id, id)).limit(1);
|
||||||
|
config = configs[0];
|
||||||
let keyIsChange = false;
|
let keyIsChange = false;
|
||||||
if (rest?.key) {
|
if (rest?.key) {
|
||||||
keyIsChange = rest.key !== config?.key;
|
keyIsChange = rest.key !== config?.key;
|
||||||
@@ -48,50 +48,57 @@ app
|
|||||||
}
|
}
|
||||||
if (keyIsChange) {
|
if (keyIsChange) {
|
||||||
const key = rest.key;
|
const key = rest.key;
|
||||||
const keyConfig = await ConfigModel.findOne({
|
const keyConfigs = await db.select()
|
||||||
where: {
|
.from(schema.kvConfig)
|
||||||
key,
|
.where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
|
||||||
uid: tuid,
|
.limit(1);
|
||||||
},
|
const keyConfig = keyConfigs[0];
|
||||||
});
|
|
||||||
if (keyConfig && keyConfig.id !== id) {
|
if (keyConfig && keyConfig.id !== id) {
|
||||||
ctx.throw(403, 'key is already exists');
|
ctx.throw(403, 'key is already exists');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await config.update({
|
const updated = await db.update(schema.kvConfig)
|
||||||
data: {
|
.set({
|
||||||
...config.data,
|
data: data,
|
||||||
...data,
|
...rest,
|
||||||
},
|
updatedAt: new Date().toISOString(),
|
||||||
...rest,
|
})
|
||||||
});
|
.where(eq(schema.kvConfig.id, id))
|
||||||
if (config.data?.permission?.share === 'public') {
|
.returning();
|
||||||
|
config = updated[0];
|
||||||
|
if ((config.data as any)?.permission?.share === 'public') {
|
||||||
await ShareConfigService.expireShareConfig(config.key, tokernUser.username);
|
await ShareConfigService.expireShareConfig(config.key, tokernUser.username);
|
||||||
}
|
}
|
||||||
ctx.body = config;
|
ctx.body = config;
|
||||||
} else if (rest?.key) {
|
} else if (rest?.key) {
|
||||||
// id 不存在,key存在,则属于更新,key不能重复
|
// id 不存在,key存在,则属于更新,key不能重复
|
||||||
const key = rest.key;
|
const key = rest.key;
|
||||||
config = await ConfigModel.findOne({
|
const configs = await db.select()
|
||||||
where: {
|
.from(schema.kvConfig)
|
||||||
key,
|
.where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
|
||||||
uid: tuid,
|
.limit(1);
|
||||||
},
|
config = configs[0];
|
||||||
});
|
|
||||||
if (config) {
|
if (config) {
|
||||||
await config.update({
|
const updated = await db.update(schema.kvConfig)
|
||||||
data: { ...config.data, ...data },
|
.set({
|
||||||
...rest,
|
data: data,
|
||||||
});
|
...rest,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.kvConfig.id, config.id))
|
||||||
|
.returning();
|
||||||
|
config = updated[0];
|
||||||
ctx.body = config;
|
ctx.body = config;
|
||||||
} else {
|
} else {
|
||||||
// 根据key创建一个配置
|
// 根据key创建一个配置
|
||||||
config = await ConfigModel.create({
|
const inserted = await db.insert(schema.kvConfig).values({
|
||||||
|
id: nanoid(),
|
||||||
key,
|
key,
|
||||||
...rest,
|
...rest,
|
||||||
data: data,
|
data: data,
|
||||||
uid: tuid,
|
uid: tuid,
|
||||||
});
|
}).returning();
|
||||||
|
config = inserted[0];
|
||||||
ctx.body = config;
|
ctx.body = config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,22 +113,25 @@ app
|
|||||||
const data = config.data;
|
const data = config.data;
|
||||||
const hash = ossConfig.hash(data);
|
const hash = ossConfig.hash(data);
|
||||||
if (config.hash !== hash) {
|
if (config.hash !== hash) {
|
||||||
config.hash = hash;
|
await db.update(schema.kvConfig)
|
||||||
await config.save({
|
.set({
|
||||||
fields: ['hash'],
|
hash: hash,
|
||||||
});
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.kvConfig.id, config.id));
|
||||||
await ossConfig.putJsonObject(key, data);
|
await ossConfig.putJsonObject(key, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (config) return;
|
if (config) return;
|
||||||
|
|
||||||
// id和key不存在。创建一个新的配置, 而且没有id的
|
// id和key不存在。创建一个新的配置, 而且没有id的
|
||||||
const newConfig = await ConfigModel.create({
|
const newConfig = await db.insert(schema.kvConfig).values({
|
||||||
|
id: nanoid(),
|
||||||
...rest,
|
...rest,
|
||||||
data: data,
|
data: data,
|
||||||
uid: tuid,
|
uid: tuid,
|
||||||
});
|
}).returning();
|
||||||
ctx.body = newConfig;
|
ctx.body = newConfig[0];
|
||||||
})
|
})
|
||||||
.addTo(app);
|
.addTo(app);
|
||||||
|
|
||||||
@@ -139,17 +149,17 @@ app
|
|||||||
if (!id && !key) {
|
if (!id && !key) {
|
||||||
ctx.throw(400, 'id or key is required');
|
ctx.throw(400, 'id or key is required');
|
||||||
}
|
}
|
||||||
let config: ConfigModel;
|
let config: any;
|
||||||
if (id) {
|
if (id) {
|
||||||
config = await ConfigModel.findByPk(id);
|
const configs = await db.select().from(schema.kvConfig).where(eq(schema.kvConfig.id, id)).limit(1);
|
||||||
|
config = configs[0];
|
||||||
}
|
}
|
||||||
if (!config && key) {
|
if (!config && key) {
|
||||||
config = await ConfigModel.findOne({
|
const configs = await db.select()
|
||||||
where: {
|
.from(schema.kvConfig)
|
||||||
key,
|
.where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, tuid)))
|
||||||
uid: tuid,
|
.limit(1);
|
||||||
},
|
config = configs[0];
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (!config) {
|
if (!config) {
|
||||||
ctx.throw(404, 'config not found');
|
ctx.throw(404, 'config not found');
|
||||||
@@ -174,12 +184,9 @@ app
|
|||||||
const tuid = tokernUser.id;
|
const tuid = tokernUser.id;
|
||||||
const { id, key } = ctx.query?.data || {};
|
const { id, key } = ctx.query?.data || {};
|
||||||
if (id || key) {
|
if (id || key) {
|
||||||
const search: any = id ? { id } : { key };
|
const search: any = id ? eq(schema.kvConfig.id, id) : eq(schema.kvConfig.key, key);
|
||||||
const config = await ConfigModel.findOne({
|
const configs = await db.select().from(schema.kvConfig).where(search).limit(1);
|
||||||
where: {
|
const config = configs[0];
|
||||||
...search
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (config && config.uid === tuid) {
|
if (config && config.uid === tuid) {
|
||||||
const key = config.key;
|
const key = config.key;
|
||||||
const ossConfig = ConfigOssService.fromBase({
|
const ossConfig = ConfigOssService.fromBase({
|
||||||
@@ -193,7 +200,7 @@ app
|
|||||||
await ossConfig.deleteObject(key);
|
await ossConfig.deleteObject(key);
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
await config.destroy();
|
await db.delete(schema.kvConfig).where(eq(schema.kvConfig.id, config.id));
|
||||||
} else {
|
} else {
|
||||||
ctx.throw(403, 'no permission');
|
ctx.throw(403, 'no permission');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useContextKey } from '@kevisual/context';
|
import { useContextKey } from '@kevisual/context';
|
||||||
import { sequelize } from '../../../modules/sequelize.ts';
|
|
||||||
import { DataTypes, Model } from 'sequelize';
|
|
||||||
import { Permission } from '@kevisual/permission';
|
import { Permission } from '@kevisual/permission';
|
||||||
|
import { eq, and } from 'drizzle-orm';
|
||||||
|
import { db, schema } from '../../../app.ts';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
export interface ConfigData {
|
export interface ConfigData {
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -9,23 +10,24 @@ export interface ConfigData {
|
|||||||
permission?: Permission;
|
permission?: Permission;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Config = Partial<InstanceType<typeof ConfigModel>>;
|
export type Config = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
description: string | null;
|
||||||
|
tags: unknown;
|
||||||
|
key: string | null;
|
||||||
|
data: unknown;
|
||||||
|
uid: string | null;
|
||||||
|
hash: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
deletedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户配置
|
* 用户配置
|
||||||
*/
|
*/
|
||||||
export class ConfigModel extends Model {
|
export class ConfigModel {
|
||||||
declare id: string;
|
|
||||||
declare title: string;
|
|
||||||
declare description: string;
|
|
||||||
declare tags: string[];
|
|
||||||
/**
|
|
||||||
* @important 配置key, 默认可以为空,如何设置了,必须要唯一。
|
|
||||||
*/
|
|
||||||
declare key: string;
|
|
||||||
declare data: ConfigData; // files
|
|
||||||
declare uid: string;
|
|
||||||
declare hash: string;
|
|
||||||
/**
|
/**
|
||||||
* 获取用户配置
|
* 获取用户配置
|
||||||
* @param key 配置key
|
* @param key 配置key
|
||||||
@@ -35,37 +37,60 @@ export class ConfigModel extends Model {
|
|||||||
* @returns 配置
|
* @returns 配置
|
||||||
*/
|
*/
|
||||||
static async getConfig(key: string, opts: { uid: string; defaultData?: any }) {
|
static async getConfig(key: string, opts: { uid: string; defaultData?: any }) {
|
||||||
const [config, isNew] = await ConfigModel.findOrCreate({
|
const existing = await db.select()
|
||||||
where: { key, uid: opts.uid },
|
.from(schema.kvConfig)
|
||||||
defaults: {
|
.where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, opts.uid)))
|
||||||
key,
|
.limit(1);
|
||||||
title: key,
|
|
||||||
uid: opts.uid,
|
if (existing.length > 0) {
|
||||||
data: opts?.defaultData || {},
|
return {
|
||||||
},
|
config: existing[0],
|
||||||
});
|
isNew: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const inserted = await db.insert(schema.kvConfig).values({
|
||||||
|
id: nanoid(),
|
||||||
|
key,
|
||||||
|
title: key,
|
||||||
|
uid: opts.uid,
|
||||||
|
data: opts?.defaultData || {},
|
||||||
|
}).returning();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
config: config,
|
config: inserted[0],
|
||||||
isNew,
|
isNew: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async setConfig(key: string, opts: { uid: string; data: any }) {
|
static async setConfig(key: string, opts: { uid: string; data: any }) {
|
||||||
let config = await ConfigModel.findOne({
|
const existing = await db.select()
|
||||||
where: { key, uid: opts.uid },
|
.from(schema.kvConfig)
|
||||||
});
|
.where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, opts.uid)))
|
||||||
if (config) {
|
.limit(1);
|
||||||
config.data = { ...config.data, ...opts.data };
|
|
||||||
await config.save();
|
if (existing.length > 0) {
|
||||||
|
const config = existing[0];
|
||||||
|
const updated = await db.update(schema.kvConfig)
|
||||||
|
.set({
|
||||||
|
data: { ...(config.data as any || {}), ...opts.data },
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.kvConfig.id, config.id))
|
||||||
|
.returning();
|
||||||
|
return updated[0];
|
||||||
} else {
|
} else {
|
||||||
config = await ConfigModel.create({
|
const inserted = await db.insert(schema.kvConfig).values({
|
||||||
|
id: nanoid(),
|
||||||
title: key,
|
title: key,
|
||||||
key,
|
key,
|
||||||
uid: opts.uid,
|
uid: opts.uid,
|
||||||
data: opts.data,
|
data: opts.data,
|
||||||
});
|
}).returning();
|
||||||
|
return inserted[0];
|
||||||
}
|
}
|
||||||
return config;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取上传配置
|
* 获取上传配置
|
||||||
* @param key 配置key
|
* @param key 配置key
|
||||||
@@ -82,7 +107,7 @@ export class ConfigModel extends Model {
|
|||||||
uid: opts.uid,
|
uid: opts.uid,
|
||||||
defaultData: defaultConfig,
|
defaultData: defaultConfig,
|
||||||
});
|
});
|
||||||
const data = config.config.data;
|
const data = config.config.data as any;
|
||||||
const prefix = `/${data.key}/${data.version}`;
|
const prefix = `/${data.key}/${data.version}`;
|
||||||
return {
|
return {
|
||||||
config: config.config,
|
config: config.config,
|
||||||
@@ -90,6 +115,7 @@ export class ConfigModel extends Model {
|
|||||||
prefix,
|
prefix,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async setUploadConfig(opts: { uid: string; data: { key?: string; version?: string } }) {
|
static async setUploadConfig(opts: { uid: string; data: { key?: string; version?: string } }) {
|
||||||
const config = await ConfigModel.setConfig('upload.json', {
|
const config = await ConfigModel.setConfig('upload.json', {
|
||||||
uid: opts.uid,
|
uid: opts.uid,
|
||||||
@@ -98,52 +124,5 @@ export class ConfigModel extends Model {
|
|||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ConfigModel.init(
|
|
||||||
{
|
|
||||||
id: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
primaryKey: true,
|
|
||||||
defaultValue: DataTypes.UUIDV4,
|
|
||||||
comment: 'id',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
key: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
tags: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
defaultValue: [],
|
|
||||||
},
|
|
||||||
hash: {
|
|
||||||
type: DataTypes.TEXT,
|
|
||||||
defaultValue: '',
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
type: DataTypes.JSONB,
|
|
||||||
defaultValue: {},
|
|
||||||
},
|
|
||||||
uid: {
|
|
||||||
type: DataTypes.UUID,
|
|
||||||
allowNull: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
sequelize,
|
|
||||||
tableName: 'kv_config',
|
|
||||||
paranoid: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// ConfigModel.sync({ alter: true, logging: false }).catch((e) => {
|
|
||||||
// console.error('ConfigModel sync', e);
|
|
||||||
// });
|
|
||||||
|
|
||||||
useContextKey('ConfigModel', () => ConfigModel);
|
useContextKey('ConfigModel', () => ConfigModel);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { ConfigModel, Config } from '../models/model.ts';
|
import { Config } from '../models/model.ts';
|
||||||
import { CustomError } from '@kevisual/router';
|
import { CustomError } from '@kevisual/router';
|
||||||
import { redis } from '@/app.ts';
|
import { redis, db, schema } from '@/app.ts';
|
||||||
import { User } from '@/models/user.ts';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { UserPermission, UserPermissionOptions } from '@kevisual/permission';
|
import { UserPermission, UserPermissionOptions } from '@kevisual/permission';
|
||||||
|
|
||||||
export class ShareConfigService extends ConfigModel {
|
export class ShareConfigService {
|
||||||
/**
|
/**
|
||||||
* 获取分享的配置
|
* 获取分享的配置
|
||||||
* @param key 配置的key
|
* @param key 配置的key
|
||||||
@@ -22,26 +22,30 @@ export class ShareConfigService extends ConfigModel {
|
|||||||
}
|
}
|
||||||
const owner = username;
|
const owner = username;
|
||||||
if (shareCacheConfig) {
|
if (shareCacheConfig) {
|
||||||
const permission = new UserPermission({ permission: shareCacheConfig?.data?.permission, owner });
|
const permission = new UserPermission({ permission: (shareCacheConfig?.data as any)?.permission, owner });
|
||||||
const result = permission.checkPermissionSuccess(options);
|
const result = permission.checkPermissionSuccess(options);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new CustomError(403, 'no permission');
|
throw new CustomError(403, 'no permission');
|
||||||
}
|
}
|
||||||
return shareCacheConfig;
|
return shareCacheConfig;
|
||||||
}
|
}
|
||||||
const user = await User.findOne({
|
const users = await db.select()
|
||||||
where: { username },
|
.from(schema.cfUser)
|
||||||
});
|
.where(eq(schema.cfUser.username, username))
|
||||||
|
.limit(1);
|
||||||
|
const user = users[0];
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new CustomError(404, 'user not found');
|
throw new CustomError(404, 'user not found');
|
||||||
}
|
}
|
||||||
const config = await ConfigModel.findOne({
|
const configs = await db.select()
|
||||||
where: { key, uid: user.id },
|
.from(schema.kvConfig)
|
||||||
});
|
.where(and(eq(schema.kvConfig.key, key), eq(schema.kvConfig.uid, user.id)))
|
||||||
|
.limit(1);
|
||||||
|
const config = configs[0];
|
||||||
if (!config) {
|
if (!config) {
|
||||||
throw new CustomError(404, 'config not found');
|
throw new CustomError(404, 'config not found');
|
||||||
}
|
}
|
||||||
const permission = new UserPermission({ permission: config?.data?.permission, owner });
|
const permission = new UserPermission({ permission: (config?.data as any)?.permission, owner });
|
||||||
const result = permission.checkPermissionSuccess(options);
|
const result = permission.checkPermissionSuccess(options);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
throw new CustomError(403, 'no permission');
|
throw new CustomError(403, 'no permission');
|
||||||
|
|||||||
@@ -13,8 +13,9 @@ app
|
|||||||
const config = await ConfigModel.getUploadConfig({
|
const config = await ConfigModel.getUploadConfig({
|
||||||
uid: tokenUser.id,
|
uid: tokenUser.id,
|
||||||
});
|
});
|
||||||
const key = config?.config?.data?.key || '';
|
const data: any = config?.config?.data || {};
|
||||||
const version = config?.config?.data?.version || '';
|
const key = data.key || '';
|
||||||
|
const version = data.version || '';
|
||||||
const username = tokenUser.username;
|
const username = tokenUser.username;
|
||||||
const prefix = `${key}/${version}/`;
|
const prefix = `${key}/${version}/`;
|
||||||
ctx.body = {
|
ctx.body = {
|
||||||
@@ -35,7 +36,7 @@ app
|
|||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const { id } = ctx.state.tokenUser;
|
const { id } = ctx.state.tokenUser;
|
||||||
const data = ctx.query.data || {};
|
const data = ctx.query?.data || {};
|
||||||
const { key, version } = data;
|
const { key, version } = data;
|
||||||
if (!key && !version) {
|
if (!key && !version) {
|
||||||
ctx.throw(400, 'key or version is required');
|
ctx.throw(400, 'key or version is required');
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { app } from '@/app.ts';
|
|||||||
import { getFileStat, getMinioList, deleteFile, updateFileStat, deleteFiles } from './module/get-minio-list.ts';
|
import { getFileStat, getMinioList, deleteFile, updateFileStat, deleteFiles } from './module/get-minio-list.ts';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { CustomError } from '@kevisual/router';
|
import { CustomError } from '@kevisual/router';
|
||||||
import { get } from 'http';
|
|
||||||
import { callDetectAppVersion } from '../app-manager/export.ts';
|
import { callDetectAppVersion } from '../app-manager/export.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { minioClient } from '../../../modules/minio.ts';
|
import { oss } from '@/modules/s3.ts';
|
||||||
import { bucketName } from '../../../modules/minio.ts';
|
import { StatObjectResult } from '@kevisual/oss';
|
||||||
import { BucketItemStat, CopyDestinationOptions, CopySourceOptions } from 'minio';
|
|
||||||
type MinioListOpt = {
|
type MinioListOpt = {
|
||||||
prefix: string;
|
prefix: string;
|
||||||
recursive?: boolean;
|
recursive?: boolean;
|
||||||
@@ -20,33 +19,13 @@ export type MinioList = (MinioFile | MinioDirectory)[];
|
|||||||
export const getMinioList = async <IS_FILE extends boolean>(opts: MinioListOpt): Promise<IS_FILE extends true ? MinioFile[] : MinioDirectory[]> => {
|
export const getMinioList = async <IS_FILE extends boolean>(opts: MinioListOpt): Promise<IS_FILE extends true ? MinioFile[] : MinioDirectory[]> => {
|
||||||
const prefix = opts.prefix;
|
const prefix = opts.prefix;
|
||||||
const recursive = opts.recursive ?? false;
|
const recursive = opts.recursive ?? false;
|
||||||
const res = await new Promise((resolve, reject) => {
|
const res = await oss.listObjects(prefix, { recursive });
|
||||||
let res: any[] = [];
|
|
||||||
let hasError = false;
|
|
||||||
minioClient
|
|
||||||
.listObjectsV2(bucketName, prefix, recursive)
|
|
||||||
.on('data', (data) => {
|
|
||||||
res.push(data);
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
console.error('minio error', opts.prefix, err);
|
|
||||||
hasError = true;
|
|
||||||
})
|
|
||||||
.on('end', () => {
|
|
||||||
if (hasError) {
|
|
||||||
reject();
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
resolve(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return res as IS_FILE extends true ? MinioFile[] : MinioDirectory[];
|
return res as IS_FILE extends true ? MinioFile[] : MinioDirectory[];
|
||||||
};
|
};
|
||||||
export const getFileStat = async (prefix: string, isFile?: boolean): Promise<BucketItemStat | null> => {
|
export const getFileStat = async (prefix: string, isFile?: boolean): Promise<StatObjectResult | null> => {
|
||||||
try {
|
try {
|
||||||
const obj = await minioClient.statObject(bucketName, prefix);
|
const obj = await oss.statObject(prefix);
|
||||||
if (isFile && obj.size === 0) {
|
if (isFile && obj?.size === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return obj;
|
return obj;
|
||||||
@@ -69,10 +48,7 @@ export const deleteFile = async (prefix: string): Promise<{ code: number; messag
|
|||||||
message: 'file not found',
|
message: 'file not found',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
await minioClient.removeObject(bucketName, prefix, {
|
await oss.deleteObject(prefix);
|
||||||
versionId: 'null',
|
|
||||||
forceDelete: true, // 强制删除
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
code: 200,
|
code: 200,
|
||||||
message: 'delete success',
|
message: 'delete success',
|
||||||
@@ -89,7 +65,9 @@ export const deleteFile = async (prefix: string): Promise<{ code: number; messag
|
|||||||
// 批量删除文件
|
// 批量删除文件
|
||||||
export const deleteFiles = async (prefixs: string[]): Promise<any> => {
|
export const deleteFiles = async (prefixs: string[]): Promise<any> => {
|
||||||
try {
|
try {
|
||||||
await minioClient.removeObjects(bucketName, prefixs);
|
for (const prefix of prefixs) {
|
||||||
|
await oss.deleteObject(prefix);
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('delete Files Error not handle', e);
|
console.error('delete Files Error not handle', e);
|
||||||
@@ -135,14 +113,9 @@ export const updateFileStat = async (
|
|||||||
message?: string;
|
message?: string;
|
||||||
}> => {
|
}> => {
|
||||||
try {
|
try {
|
||||||
const source = new CopySourceOptions({ Bucket: bucketName, Object: prefix });
|
const copyResult = await oss.replaceObject(prefix, {
|
||||||
const destination = new CopyDestinationOptions({
|
...newMetadata
|
||||||
Bucket: bucketName,
|
|
||||||
Object: prefix,
|
|
||||||
UserMetadata: newMetadata,
|
|
||||||
MetadataDirective: 'REPLACE',
|
|
||||||
});
|
});
|
||||||
const copyResult = await minioClient.copyObject(source, destination);
|
|
||||||
console.log('copyResult', copyResult);
|
console.log('copyResult', copyResult);
|
||||||
console.log(`Metadata for ${prefix} updated successfully.`);
|
console.log(`Metadata for ${prefix} updated successfully.`);
|
||||||
return {
|
return {
|
||||||
@@ -171,25 +144,16 @@ export const mvUserAToUserB = async (usernameA: string, usernameB: string, clear
|
|||||||
const newPrefix = `${usernameB}/`;
|
const newPrefix = `${usernameB}/`;
|
||||||
const listSource = await getMinioList<true>({ prefix: oldPrefix, recursive: true });
|
const listSource = await getMinioList<true>({ prefix: oldPrefix, recursive: true });
|
||||||
for (const item of listSource) {
|
for (const item of listSource) {
|
||||||
const source = new CopySourceOptions({ Bucket: bucketName, Object: item.name });
|
|
||||||
const stat = await getFileStat(item.name);
|
|
||||||
const newName = item.name.slice(oldPrefix.length);
|
const newName = item.name.slice(oldPrefix.length);
|
||||||
// @ts-ignore
|
await oss.copyObject(item.name, `${newPrefix}${newName}`);
|
||||||
const metadata = stat?.userMetadata || stat.metaData;
|
|
||||||
const destination = new CopyDestinationOptions({
|
|
||||||
Bucket: bucketName,
|
|
||||||
Object: `${newPrefix}${newName}`,
|
|
||||||
UserMetadata: metadata,
|
|
||||||
MetadataDirective: 'COPY',
|
|
||||||
});
|
|
||||||
await minioClient.copyObject(source, destination);
|
|
||||||
}
|
}
|
||||||
if (clearOldUser) {
|
if (clearOldUser) {
|
||||||
const files = await getMinioList<true>({ prefix: oldPrefix, recursive: true });
|
const files = await getMinioList<true>({ prefix: oldPrefix, recursive: true });
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await minioClient.removeObject(bucketName, file.name);
|
await oss.deleteObject(file.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log(`移动 ${usernameA} to ${usernameB} success`);
|
||||||
};
|
};
|
||||||
export const backupUserA = async (usernameA: string, id: string, backName?: string) => {
|
export const backupUserA = async (usernameA: string, id: string, backName?: string) => {
|
||||||
const today = backName || dayjs().format('YYYY-MM-DD-HH-mm');
|
const today = backName || dayjs().format('YYYY-MM-DD-HH-mm');
|
||||||
@@ -202,11 +166,12 @@ export const backupUserA = async (usernameA: string, id: string, backName?: stri
|
|||||||
for (const item of deleteBackup) {
|
for (const item of deleteBackup) {
|
||||||
const files = await getMinioList<true>({ prefix: item.prefix, recursive: true });
|
const files = await getMinioList<true>({ prefix: item.prefix, recursive: true });
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
await minioClient.removeObject(bucketName, file.name);
|
await oss.deleteObject(file.name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await mvUserAToUserB(usernameA, backupPrefix, false);
|
await mvUserAToUserB(usernameA, backupPrefix, false);
|
||||||
|
console.log(`Backup user ${usernameA} to ${backupPrefix} success`);
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 删除用户
|
* 删除用户
|
||||||
@@ -215,6 +180,6 @@ export const backupUserA = async (usernameA: string, id: string, backName?: stri
|
|||||||
export const deleteUser = async (username: string) => {
|
export const deleteUser = async (username: string) => {
|
||||||
const list = await getMinioList<true>({ prefix: `${username}/`, recursive: true });
|
const list = await getMinioList<true>({ prefix: `${username}/`, recursive: true });
|
||||||
for (const item of list) {
|
for (const item of list) {
|
||||||
await minioClient.removeObject(bucketName, item.name);
|
await oss.deleteObject(item.name);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
144
src/routes/flowme/flowme-channel/list.ts
Normal file
144
src/routes/flowme/flowme-channel/list.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { desc, eq, count, or, like, and } from 'drizzle-orm';
|
||||||
|
import { schema, app, db } from '@/app.ts'
|
||||||
|
|
||||||
|
// 获取 flowme-channel 列表
|
||||||
|
app.route({
|
||||||
|
path: 'flowme-channel',
|
||||||
|
key: 'list',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '获取 flowme-channel 列表',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const uid = tokenUser.id;
|
||||||
|
const { page = 1, pageSize = 20, search, sort = 'DESC' } = ctx.query || {};
|
||||||
|
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const orderByField = sort === 'ASC' ? schema.flowmeChannels.updatedAt : desc(schema.flowmeChannels.updatedAt);
|
||||||
|
|
||||||
|
let whereCondition = eq(schema.flowmeChannels.uid, uid);
|
||||||
|
if (search) {
|
||||||
|
whereCondition = and(
|
||||||
|
eq(schema.flowmeChannels.uid, uid),
|
||||||
|
or(
|
||||||
|
like(schema.flowmeChannels.title, `%${search}%`),
|
||||||
|
like(schema.flowmeChannels.description, `%${search}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [list, totalCount] = await Promise.all([
|
||||||
|
db.select()
|
||||||
|
.from(schema.flowmeChannels)
|
||||||
|
.where(whereCondition)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset)
|
||||||
|
.orderBy(orderByField),
|
||||||
|
db.select({ count: count() })
|
||||||
|
.from(schema.flowmeChannels)
|
||||||
|
.where(whereCondition)
|
||||||
|
]);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
list,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: totalCount[0]?.count || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 创建或更新 flowme-channel
|
||||||
|
const channelUpdate = `创建或更新一个 flowme-channel, 参数定义:
|
||||||
|
title: 标题, 必填
|
||||||
|
description: 描述, 选填
|
||||||
|
tags: 标签, 数组, 选填
|
||||||
|
link: 链接, 选填
|
||||||
|
data: 数据, 对象, 选填
|
||||||
|
color: 颜色, 选填, 默认 #007bff
|
||||||
|
`;
|
||||||
|
app.route({
|
||||||
|
path: 'flowme-channel',
|
||||||
|
key: 'update',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: channelUpdate,
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const { id, uid, updatedAt, createdAt, ...rest } = ctx.query.data || {};
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
let channel;
|
||||||
|
if (!id) {
|
||||||
|
channel = await db.insert(schema.flowmeChannels).values({
|
||||||
|
title: rest.title || '',
|
||||||
|
description: rest.description || '',
|
||||||
|
tags: rest.tags || [],
|
||||||
|
link: rest.link || '',
|
||||||
|
data: rest.data || {},
|
||||||
|
color: rest.color || '#007bff',
|
||||||
|
uid: tokenUser.id,
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
const existing = await db.select().from(schema.flowmeChannels).where(eq(schema.flowmeChannels.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的 channel');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限更新该 channel');
|
||||||
|
}
|
||||||
|
channel = await db.update(schema.flowmeChannels).set({
|
||||||
|
title: rest.title,
|
||||||
|
description: rest.description,
|
||||||
|
tags: rest.tags,
|
||||||
|
link: rest.link,
|
||||||
|
data: rest.data,
|
||||||
|
color: rest.color,
|
||||||
|
}).where(eq(schema.flowmeChannels.id, id)).returning();
|
||||||
|
}
|
||||||
|
ctx.body = channel;
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 删除 flowme-channel
|
||||||
|
app.route({
|
||||||
|
path: 'flowme-channel',
|
||||||
|
key: 'delete',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '删除 flowme-channel, 参数: data.id 必填',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query.data || {};
|
||||||
|
if (!id) {
|
||||||
|
ctx.throw(400, 'id 参数缺失');
|
||||||
|
}
|
||||||
|
const existing = await db.select().from(schema.flowmeChannels).where(eq(schema.flowmeChannels.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的 channel');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限删除该 channel');
|
||||||
|
}
|
||||||
|
await db.delete(schema.flowmeChannels).where(eq(schema.flowmeChannels.id, id));
|
||||||
|
ctx.body = { success: true };
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 获取单个 flowme-channel
|
||||||
|
app.route({
|
||||||
|
path: 'flowme-channel',
|
||||||
|
key: 'get',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '获取单个 flowme-channel, 参数: data.id 必填',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query.data || {};
|
||||||
|
if (!id) {
|
||||||
|
ctx.throw(400, 'id 参数缺失');
|
||||||
|
}
|
||||||
|
const existing = await db.select().from(schema.flowmeChannels).where(eq(schema.flowmeChannels.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的 channel');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限查看该 channel');
|
||||||
|
}
|
||||||
|
ctx.body = existing[0];
|
||||||
|
}).addTo(app);
|
||||||
5
src/routes/flowme/index.ts
Normal file
5
src/routes/flowme/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import './list.ts'
|
||||||
|
|
||||||
|
// flowme channel 相关路由
|
||||||
|
|
||||||
|
import './flowme-channel/list.ts'
|
||||||
160
src/routes/flowme/list.ts
Normal file
160
src/routes/flowme/list.ts
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { desc, eq, count, or, like, and } from 'drizzle-orm';
|
||||||
|
import { schema, app, db } from '@/app.ts'
|
||||||
|
|
||||||
|
// 获取 flowme 列表
|
||||||
|
app.route({
|
||||||
|
path: 'flowme',
|
||||||
|
key: 'list',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '获取 flowme 列表',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const uid = tokenUser.id;
|
||||||
|
const { page = 1, pageSize = 20, search, channelId, sort = 'DESC' } = ctx.query || {};
|
||||||
|
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const orderByField = sort === 'ASC' ? schema.flowme.updatedAt : desc(schema.flowme.updatedAt);
|
||||||
|
|
||||||
|
let whereCondition = eq(schema.flowme.uid, uid);
|
||||||
|
if (search) {
|
||||||
|
whereCondition = and(
|
||||||
|
eq(schema.flowme.uid, uid),
|
||||||
|
or(
|
||||||
|
like(schema.flowme.title, `%${search}%`),
|
||||||
|
like(schema.flowme.description, `%${search}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (channelId) {
|
||||||
|
whereCondition = and(
|
||||||
|
whereCondition,
|
||||||
|
eq(schema.flowme.channelId, channelId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [list, totalCount] = await Promise.all([
|
||||||
|
db.select()
|
||||||
|
.from(schema.flowme)
|
||||||
|
.where(whereCondition)
|
||||||
|
.limit(pageSize)
|
||||||
|
.offset(offset)
|
||||||
|
.orderBy(orderByField),
|
||||||
|
db.select({ count: count() })
|
||||||
|
.from(schema.flowme)
|
||||||
|
.where(whereCondition)
|
||||||
|
]);
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
list,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: totalCount[0]?.count || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 创建或更新 flowme
|
||||||
|
const flowmeUpdate = `创建或更新一个 flowme, 参数定义:
|
||||||
|
title: 标题, 必填
|
||||||
|
description: 描述, 选填
|
||||||
|
tags: 标签, 数组, 选填
|
||||||
|
link: 链接, 选填
|
||||||
|
data: 数据, 对象, 选填
|
||||||
|
channelId: 频道ID, 选填
|
||||||
|
type: 类型, 选填
|
||||||
|
source: 来源, 选填
|
||||||
|
importance: 重要性等级, 数字, 选填
|
||||||
|
`;
|
||||||
|
app.route({
|
||||||
|
path: 'flowme',
|
||||||
|
key: 'update',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: flowmeUpdate,
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const { id, uid, updatedAt, createdAt, ...rest } = ctx.query.data || {};
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
let flowmeItem;
|
||||||
|
if (!id) {
|
||||||
|
flowmeItem = await db.insert(schema.flowme).values({
|
||||||
|
title: rest.title || '',
|
||||||
|
description: rest.description || '',
|
||||||
|
tags: rest.tags || [],
|
||||||
|
link: rest.link || '',
|
||||||
|
data: rest.data || {},
|
||||||
|
channelId: rest.channelId || null,
|
||||||
|
type: rest.type || '',
|
||||||
|
source: rest.source || '',
|
||||||
|
importance: rest.importance || 0,
|
||||||
|
uid: tokenUser.id,
|
||||||
|
}).returning();
|
||||||
|
} else {
|
||||||
|
const existing = await db.select().from(schema.flowme).where(eq(schema.flowme.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的 flowme');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限更新该 flowme');
|
||||||
|
}
|
||||||
|
flowmeItem = await db.update(schema.flowme).set({
|
||||||
|
title: rest.title,
|
||||||
|
description: rest.description,
|
||||||
|
tags: rest.tags,
|
||||||
|
link: rest.link,
|
||||||
|
data: rest.data,
|
||||||
|
channelId: rest.channelId,
|
||||||
|
type: rest.type,
|
||||||
|
source: rest.source,
|
||||||
|
importance: rest.importance,
|
||||||
|
isArchived: rest.isArchived,
|
||||||
|
}).where(eq(schema.flowme.id, id)).returning();
|
||||||
|
}
|
||||||
|
ctx.body = flowmeItem;
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 删除 flowme
|
||||||
|
app.route({
|
||||||
|
path: 'flowme',
|
||||||
|
key: 'delete',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '删除 flowme, 参数: data.id 必填',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query.data || {};
|
||||||
|
if (!id) {
|
||||||
|
ctx.throw(400, 'id 参数缺失');
|
||||||
|
}
|
||||||
|
const existing = await db.select().from(schema.flowme).where(eq(schema.flowme.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的 flowme');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限删除该 flowme');
|
||||||
|
}
|
||||||
|
await db.delete(schema.flowme).where(eq(schema.flowme.id, id));
|
||||||
|
ctx.body = { success: true };
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
// 获取单个 flowme
|
||||||
|
app.route({
|
||||||
|
path: 'flowme',
|
||||||
|
key: 'get',
|
||||||
|
middleware: ['auth'],
|
||||||
|
description: '获取单个 flowme, 参数: data.id 必填',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query.data || {};
|
||||||
|
if (!id) {
|
||||||
|
ctx.throw(400, 'id 参数缺失');
|
||||||
|
}
|
||||||
|
const existing = await db.select().from(schema.flowme).where(eq(schema.flowme.id, id)).limit(1);
|
||||||
|
if (existing.length === 0) {
|
||||||
|
ctx.throw(404, '没有找到对应的 flowme');
|
||||||
|
}
|
||||||
|
if (existing[0].uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, '没有权限查看该 flowme');
|
||||||
|
}
|
||||||
|
ctx.body = existing[0];
|
||||||
|
}).addTo(app);
|
||||||
47
src/routes/flowme/listener/index.ts
Normal file
47
src/routes/flowme/listener/index.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { schema, app, db } from '@/app.ts'
|
||||||
|
import { Client } from 'pg'
|
||||||
|
|
||||||
|
let pgClient: Client | null = null
|
||||||
|
|
||||||
|
async function startFlowmeListener() {
|
||||||
|
// 使用独立的数据库连接来监听
|
||||||
|
pgClient = new Client({
|
||||||
|
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@localhost:5432/code_center',
|
||||||
|
})
|
||||||
|
|
||||||
|
await pgClient.connect()
|
||||||
|
console.log('🔌 已连接到 PostgreSQL 监听器')
|
||||||
|
|
||||||
|
// 订阅通知事件
|
||||||
|
pgClient.on('notification', (data) => {
|
||||||
|
if (!data.payload) return
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data.payload)
|
||||||
|
console.log('📥 收到新 flowme 创建通知:', parsed)
|
||||||
|
|
||||||
|
// 在这里处理你的业务逻辑
|
||||||
|
handleNewFlowme(parsed)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('❌ 解析 flowme 通知失败:', err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 执行 LISTEN 命令订阅通道
|
||||||
|
await pgClient.query('LISTEN flowme_insert')
|
||||||
|
|
||||||
|
console.log('👂 开始监听 flowme_insert 通道...')
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNewFlowme(data: any) {
|
||||||
|
// 根据新创建的 flowme 数据执行相应操作
|
||||||
|
console.log('处理新 flowme:', data.id, data.title)
|
||||||
|
|
||||||
|
// 示例:可以通过 WebSocket 推送给前端
|
||||||
|
// wsServer.emit('flowme:created', data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动监听器(只启动一次)
|
||||||
|
if (!global.__flowmeListenerStarted) {
|
||||||
|
global.__flowmeListenerStarted = true
|
||||||
|
startFlowmeListener().catch(console.error)
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import './config/index.ts';
|
|||||||
|
|
||||||
// import './file-listener/index.ts';
|
// import './file-listener/index.ts';
|
||||||
|
|
||||||
|
import './mark/index.ts';
|
||||||
|
|
||||||
import './light-code/index.ts';
|
import './light-code/index.ts';
|
||||||
|
|
||||||
import './ai/index.ts';
|
import './ai/index.ts';
|
||||||
@@ -18,4 +20,6 @@ import './prompts/index.ts'
|
|||||||
|
|
||||||
import './views/index.ts';
|
import './views/index.ts';
|
||||||
|
|
||||||
import './query-views/index.ts';
|
import './query-views/index.ts';
|
||||||
|
|
||||||
|
import './flowme/index.ts'
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { eq, desc, and, like, or } from 'drizzle-orm';
|
import { eq, desc, and, like, or } from 'drizzle-orm';
|
||||||
import { CustomError } from '@kevisual/router';
|
|
||||||
import { app, db, schema } from '../../app.ts';
|
import { app, db, schema } from '../../app.ts';
|
||||||
|
import { CustomError } from '@kevisual/router';
|
||||||
|
import { filter } from '@kevisual/js-filter'
|
||||||
|
import { z } from 'zod';
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
path: 'light-code',
|
path: 'light-code',
|
||||||
@@ -9,10 +10,22 @@ app
|
|||||||
description: `获取轻代码列表,参数
|
description: `获取轻代码列表,参数
|
||||||
type: 代码类型light-code, ts`,
|
type: 代码类型light-code, ts`,
|
||||||
middleware: ['auth'],
|
middleware: ['auth'],
|
||||||
|
metadata: {
|
||||||
|
args: {
|
||||||
|
type: z.string().optional().describe('代码类型light-code, ts'),
|
||||||
|
search: z.string().optional().describe('搜索关键词,匹配标题和描述'),
|
||||||
|
filter: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe(
|
||||||
|
'过滤条件,SQL like格式字符串,例如:WHERE tags LIKE \'%tag1%\' AND tags LIKE \'%tag2%\'',
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const tokenUser = ctx.state.tokenUser;
|
const tokenUser = ctx.state.tokenUser;
|
||||||
const { type, search } = ctx.query || {};
|
const { type, search, filter: filterQuery } = ctx.query || {};
|
||||||
const conditions = [eq(schema.kvContainer.uid, tokenUser.id)];
|
const conditions = [eq(schema.kvContainer.uid, tokenUser.id)];
|
||||||
if (type) {
|
if (type) {
|
||||||
conditions.push(eq(schema.kvContainer.type, type as string));
|
conditions.push(eq(schema.kvContainer.type, type as string));
|
||||||
@@ -34,6 +47,7 @@ app
|
|||||||
type: schema.kvContainer.type,
|
type: schema.kvContainer.type,
|
||||||
tags: schema.kvContainer.tags,
|
tags: schema.kvContainer.tags,
|
||||||
data: schema.kvContainer.data,
|
data: schema.kvContainer.data,
|
||||||
|
code: schema.kvContainer.code,
|
||||||
uid: schema.kvContainer.uid,
|
uid: schema.kvContainer.uid,
|
||||||
createdAt: schema.kvContainer.createdAt,
|
createdAt: schema.kvContainer.createdAt,
|
||||||
updatedAt: schema.kvContainer.updatedAt,
|
updatedAt: schema.kvContainer.updatedAt,
|
||||||
@@ -42,7 +56,12 @@ app
|
|||||||
.from(schema.kvContainer)
|
.from(schema.kvContainer)
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
.orderBy(desc(schema.kvContainer.updatedAt));
|
.orderBy(desc(schema.kvContainer.updatedAt));
|
||||||
ctx.body = list;
|
if (filterQuery) {
|
||||||
|
const filteredList = filter(list, filterQuery);
|
||||||
|
ctx.body = { list: filteredList }
|
||||||
|
} else {
|
||||||
|
ctx.body = { list };
|
||||||
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
})
|
})
|
||||||
.addTo(app);
|
.addTo(app);
|
||||||
@@ -81,6 +100,7 @@ app
|
|||||||
path: 'light-code',
|
path: 'light-code',
|
||||||
key: 'update',
|
key: 'update',
|
||||||
middleware: ['auth'],
|
middleware: ['auth'],
|
||||||
|
isDebug: true,
|
||||||
})
|
})
|
||||||
.define(async (ctx) => {
|
.define(async (ctx) => {
|
||||||
const tokenUser = ctx.state.tokenUser;
|
const tokenUser = ctx.state.tokenUser;
|
||||||
@@ -111,22 +131,34 @@ app
|
|||||||
ctx.body = null;
|
ctx.body = null;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const [created] = await db
|
try {
|
||||||
.insert(schema.kvContainer)
|
|
||||||
.values({
|
console.log('created', container, 'userId', tokenUser.id);
|
||||||
...container,
|
const [created] = await db
|
||||||
uid: tokenUser.id,
|
.insert(schema.kvContainer)
|
||||||
})
|
.values({
|
||||||
.returning();
|
title: container.title || '',
|
||||||
ctx.body = created;
|
description: container.description || '',
|
||||||
|
type: container.type || 'light-code',
|
||||||
|
code: container.code || '',
|
||||||
|
data: container.data || {},
|
||||||
|
tags: container.tags || [],
|
||||||
|
hash: container.hash || '',
|
||||||
|
uid: tokenUser.id,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
ctx.body = created;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating container:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return ctx;
|
|
||||||
})
|
})
|
||||||
.addTo(app);
|
.addTo(app);
|
||||||
|
|
||||||
app
|
app
|
||||||
.route({
|
.route({
|
||||||
path: 'container',
|
path: 'light-code',
|
||||||
key: 'delete',
|
key: 'delete',
|
||||||
middleware: ['auth'],
|
middleware: ['auth'],
|
||||||
})
|
})
|
||||||
|
|||||||
1
src/routes/mark/index.ts
Normal file
1
src/routes/mark/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import './list.ts';
|
||||||
308
src/routes/mark/list.ts
Normal file
308
src/routes/mark/list.ts
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
import { eq, desc, and, like, or, count, sql } from 'drizzle-orm';
|
||||||
|
import { app, db, schema } from '../../app.ts';
|
||||||
|
import { MarkServices } from './services/mark.ts';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'mark',
|
||||||
|
key: 'list',
|
||||||
|
description: 'mark list.',
|
||||||
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
ctx.body = await MarkServices.getList({
|
||||||
|
uid: tokenUser.id,
|
||||||
|
query: ctx.query,
|
||||||
|
queryType: 'simple',
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'mark',
|
||||||
|
key: 'getVersion',
|
||||||
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query;
|
||||||
|
if (id) {
|
||||||
|
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
|
||||||
|
const markModel = marks[0];
|
||||||
|
if (!markModel) {
|
||||||
|
ctx.throw(404, 'mark not found');
|
||||||
|
}
|
||||||
|
if (markModel.uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, 'no permission');
|
||||||
|
}
|
||||||
|
ctx.body = {
|
||||||
|
version: Number(markModel.version),
|
||||||
|
updatedAt: markModel.updatedAt,
|
||||||
|
createdAt: markModel.createdAt,
|
||||||
|
id: markModel.id,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
ctx.throw(400, 'id is required');
|
||||||
|
// const [markModel, created] = await MarkModel.findOrCreate({
|
||||||
|
// where: {
|
||||||
|
// uid: tokenUser.id,
|
||||||
|
// puid: tokenUser.uid,
|
||||||
|
// title: dayjs().format('YYYY-MM-DD'),
|
||||||
|
// },
|
||||||
|
// defaults: {
|
||||||
|
// title: dayjs().format('YYYY-MM-DD'),
|
||||||
|
// uid: tokenUser.id,
|
||||||
|
// markType: 'wallnote',
|
||||||
|
// tags: ['daily'],
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// ctx.body = {
|
||||||
|
// version: Number(markModel.version),
|
||||||
|
// updatedAt: markModel.updatedAt,
|
||||||
|
// createdAt: markModel.createdAt,
|
||||||
|
// id: markModel.id,
|
||||||
|
// created: created,
|
||||||
|
// };
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'mark',
|
||||||
|
key: 'get',
|
||||||
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query;
|
||||||
|
if (id) {
|
||||||
|
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
|
||||||
|
const markModel = marks[0];
|
||||||
|
if (!markModel) {
|
||||||
|
ctx.throw(404, 'mark not found');
|
||||||
|
}
|
||||||
|
if (markModel.uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, 'no permission');
|
||||||
|
}
|
||||||
|
ctx.body = markModel;
|
||||||
|
} else {
|
||||||
|
ctx.throw(400, 'id is required');
|
||||||
|
// id 不存在,获取当天的title为 日期的一条数据
|
||||||
|
// const [markModel, created] = await MarkModel.findOrCreate({
|
||||||
|
// where: {
|
||||||
|
// uid: tokenUser.id,
|
||||||
|
// puid: tokenUser.uid,
|
||||||
|
// title: dayjs().format('YYYY-MM-DD'),
|
||||||
|
// },
|
||||||
|
// defaults: {
|
||||||
|
// title: dayjs().format('YYYY-MM-DD'),
|
||||||
|
// uid: tokenUser.id,
|
||||||
|
// markType: 'wallnote',
|
||||||
|
// tags: ['daily'],
|
||||||
|
// uname: tokenUser.username,
|
||||||
|
// puid: tokenUser.uid,
|
||||||
|
// version: 1,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// ctx.body = markModel;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'mark',
|
||||||
|
key: 'update',
|
||||||
|
middleware: ['auth'],
|
||||||
|
isDebug: true,
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id, createdAt, updatedAt, uid: _, puid: _2, uname: _3, data, ...rest } = ctx.query.data || {};
|
||||||
|
let markModel: any;
|
||||||
|
if (id) {
|
||||||
|
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
|
||||||
|
markModel = marks[0];
|
||||||
|
if (!markModel) {
|
||||||
|
ctx.throw(404, 'mark not found');
|
||||||
|
}
|
||||||
|
if (markModel.uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, 'no permission');
|
||||||
|
}
|
||||||
|
const version = Number(markModel.version) + 1;
|
||||||
|
const updated = await db.update(schema.microMark)
|
||||||
|
.set({
|
||||||
|
...rest,
|
||||||
|
data: {
|
||||||
|
...(markModel.data as any || {}),
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
version,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.microMark.id, id))
|
||||||
|
.returning();
|
||||||
|
markModel = updated[0];
|
||||||
|
} else {
|
||||||
|
const inserted = await db.insert(schema.microMark).values({
|
||||||
|
id: nanoid(),
|
||||||
|
data: data || {},
|
||||||
|
...rest,
|
||||||
|
uname: tokenUser.username,
|
||||||
|
uid: tokenUser.id,
|
||||||
|
puid: tokenUser.uid,
|
||||||
|
}).returning();
|
||||||
|
markModel = inserted[0];
|
||||||
|
}
|
||||||
|
ctx.body = markModel;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'mark',
|
||||||
|
key: 'updateNode',
|
||||||
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const operate = ctx.query.operate || 'update';
|
||||||
|
const { id, node } = ctx.query.data || {};
|
||||||
|
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
|
||||||
|
const markModel = marks[0];
|
||||||
|
if (!markModel) {
|
||||||
|
ctx.throw(404, 'mark not found');
|
||||||
|
}
|
||||||
|
if (markModel.uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, 'no permission');
|
||||||
|
}
|
||||||
|
// Update JSON node logic with Drizzle
|
||||||
|
const currentData = markModel.data as any || {};
|
||||||
|
const nodes = currentData.nodes || [];
|
||||||
|
const nodeIndex = nodes.findIndex((n: any) => n.id === node.id);
|
||||||
|
|
||||||
|
let updatedNodes;
|
||||||
|
if (operate === 'delete') {
|
||||||
|
updatedNodes = nodes.filter((n: any) => n.id !== node.id);
|
||||||
|
} else if (nodeIndex >= 0) {
|
||||||
|
updatedNodes = [...nodes];
|
||||||
|
updatedNodes[nodeIndex] = { ...nodes[nodeIndex], ...node };
|
||||||
|
} else {
|
||||||
|
updatedNodes = [...nodes, node];
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = Number(markModel.version) + 1;
|
||||||
|
const updated = await db.update(schema.microMark)
|
||||||
|
.set({
|
||||||
|
data: { ...currentData, nodes: updatedNodes },
|
||||||
|
version,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.microMark.id, id))
|
||||||
|
.returning();
|
||||||
|
ctx.body = updated[0];
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'mark',
|
||||||
|
key: 'updateNodes',
|
||||||
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id, nodeOperateList } = ctx.query.data || {};
|
||||||
|
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
|
||||||
|
const markModel = marks[0];
|
||||||
|
if (!markModel) {
|
||||||
|
ctx.throw(404, 'mark not found');
|
||||||
|
}
|
||||||
|
if (markModel.uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, 'no permission');
|
||||||
|
}
|
||||||
|
if (!nodeOperateList || !Array.isArray(nodeOperateList) || nodeOperateList.length === 0) {
|
||||||
|
ctx.throw(400, 'nodeOperateList is required');
|
||||||
|
}
|
||||||
|
if (nodeOperateList.some((item: any) => !item.node)) {
|
||||||
|
ctx.throw(400, 'nodeOperateList node is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update multiple JSON nodes logic with Drizzle
|
||||||
|
const currentData = markModel.data as any || {};
|
||||||
|
let nodes = currentData.nodes || [];
|
||||||
|
|
||||||
|
for (const item of nodeOperateList) {
|
||||||
|
const { node, operate = 'update' } = item;
|
||||||
|
const nodeIndex = nodes.findIndex((n: any) => n.id === node.id);
|
||||||
|
|
||||||
|
if (operate === 'delete') {
|
||||||
|
nodes = nodes.filter((n: any) => n.id !== node.id);
|
||||||
|
} else if (nodeIndex >= 0) {
|
||||||
|
nodes[nodeIndex] = { ...nodes[nodeIndex], ...node };
|
||||||
|
} else {
|
||||||
|
nodes.push(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const version = Number(markModel.version) + 1;
|
||||||
|
const updated = await db.update(schema.microMark)
|
||||||
|
.set({
|
||||||
|
data: { ...currentData, nodes },
|
||||||
|
version,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
.where(eq(schema.microMark.id, id))
|
||||||
|
.returning();
|
||||||
|
ctx.body = updated[0];
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({
|
||||||
|
path: 'mark',
|
||||||
|
key: 'delete',
|
||||||
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const { id } = ctx.query;
|
||||||
|
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
|
||||||
|
const markModel = marks[0];
|
||||||
|
if (!markModel) {
|
||||||
|
ctx.throw(404, 'mark not found');
|
||||||
|
}
|
||||||
|
if (markModel.uid !== tokenUser.id) {
|
||||||
|
ctx.throw(403, 'no permission');
|
||||||
|
}
|
||||||
|
await db.delete(schema.microMark).where(eq(schema.microMark.id, id));
|
||||||
|
ctx.body = markModel;
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
|
|
||||||
|
app
|
||||||
|
.route({ path: 'mark', key: 'getMenu', description: '获取菜单', middleware: ['auth'] })
|
||||||
|
.define(async (ctx) => {
|
||||||
|
const tokenUser = ctx.state.tokenUser;
|
||||||
|
const [rows, totalResult] = await Promise.all([
|
||||||
|
db.select({
|
||||||
|
id: schema.microMark.id,
|
||||||
|
title: schema.microMark.title,
|
||||||
|
summary: schema.microMark.summary,
|
||||||
|
tags: schema.microMark.tags,
|
||||||
|
thumbnail: schema.microMark.thumbnail,
|
||||||
|
link: schema.microMark.link,
|
||||||
|
createdAt: schema.microMark.createdAt,
|
||||||
|
updatedAt: schema.microMark.updatedAt,
|
||||||
|
}).from(schema.microMark).where(eq(schema.microMark.uid, tokenUser.id)),
|
||||||
|
db.select({ count: count() }).from(schema.microMark).where(eq(schema.microMark.uid, tokenUser.id))
|
||||||
|
]);
|
||||||
|
ctx.body = {
|
||||||
|
list: rows,
|
||||||
|
total: totalResult[0]?.count || 0,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.addTo(app);
|
||||||
327
src/routes/mark/mark-model.ts
Normal file
327
src/routes/mark/mark-model.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import { nanoid, customAlphabet } from 'nanoid';
|
||||||
|
import { DataTypes, Model, ModelAttributes } from 'sequelize';
|
||||||
|
import type { Sequelize } from 'sequelize';
|
||||||
|
export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
||||||
|
export type Mark = Partial<InstanceType<typeof MarkModel>>;
|
||||||
|
export type MarkData = {
|
||||||
|
md?: string; // markdown
|
||||||
|
mdList?: string[]; // markdown list
|
||||||
|
type?: string; // 类型 markdown | json | html | image | video | audio | code | link | file
|
||||||
|
data?: any;
|
||||||
|
key?: string; // 文件的名称, 唯一
|
||||||
|
push?: boolean; // 是否推送到elasticsearch
|
||||||
|
pushTime?: Date; // 推送时间
|
||||||
|
summary?: string; // 摘要
|
||||||
|
nodes?: MarkDataNode[]; // 节点
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
export type MarkFile = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
size: number;
|
||||||
|
type: 'self' | 'data' | 'generate'; // generate为生成文件
|
||||||
|
query: string; // 'data.nodes[id].content';
|
||||||
|
hash: string;
|
||||||
|
fileKey: string; // 文件的名称, 唯一
|
||||||
|
};
|
||||||
|
export type MarkDataNode = {
|
||||||
|
id?: string;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
export type MarkConfig = {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
export type MarkAuth = {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 隐秘内容
|
||||||
|
* auth
|
||||||
|
* config
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
export class MarkModel extends Model {
|
||||||
|
declare id: string;
|
||||||
|
declare title: string; // 标题,可以ai生成
|
||||||
|
declare description: string; // 描述,可以ai生成
|
||||||
|
declare cover: string; // 封面,可以ai生成
|
||||||
|
declare thumbnail: string; // 缩略图
|
||||||
|
declare key: string; // 文件路径
|
||||||
|
declare markType: string; // markdown | json | html | image | video | audio | code | link | file
|
||||||
|
declare link: string; // 访问链接
|
||||||
|
declare tags: string[]; // 标签
|
||||||
|
declare summary: string; // 摘要, description的简化版
|
||||||
|
declare data: MarkData; // 数据
|
||||||
|
|
||||||
|
declare uid: string; // 操作用户的id
|
||||||
|
declare puid: string; // 父级用户的id, 真实用户
|
||||||
|
declare config: MarkConfig; // mark属于一定不会暴露的内容。
|
||||||
|
|
||||||
|
declare fileList: MarkFile[]; // 文件管理
|
||||||
|
declare uname: string; // 用户的名称, 或者着别名
|
||||||
|
|
||||||
|
declare markedAt: Date; // 标记时间
|
||||||
|
declare createdAt: Date;
|
||||||
|
declare updatedAt: Date;
|
||||||
|
declare version: number;
|
||||||
|
/**
|
||||||
|
* 加锁更新data中的node的节点,通过node的id
|
||||||
|
* @param param0
|
||||||
|
*/
|
||||||
|
static async updateJsonNode(id: string, node: MarkDataNode, opts?: { operate?: 'update' | 'delete'; Model?: any; sequelize?: Sequelize }) {
|
||||||
|
const sequelize = opts?.sequelize || (await useContextKey('sequelize'));
|
||||||
|
const transaction = await sequelize.transaction(); // 开启事务
|
||||||
|
const operate = opts.operate || 'update';
|
||||||
|
const isUpdate = operate === 'update';
|
||||||
|
const Model = opts.Model || MarkModel;
|
||||||
|
try {
|
||||||
|
// 1. 获取当前的 JSONB 字段值(加锁)
|
||||||
|
const mark = await Model.findByPk(id, {
|
||||||
|
transaction,
|
||||||
|
lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改
|
||||||
|
});
|
||||||
|
if (!mark) {
|
||||||
|
throw new Error('Mark not found');
|
||||||
|
}
|
||||||
|
// 2. 修改特定的数组元素
|
||||||
|
const data = mark.data as MarkData;
|
||||||
|
const items = data.nodes;
|
||||||
|
if (!node.id) {
|
||||||
|
node.id = random(12);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到要更新的元素
|
||||||
|
const itemIndex = items.findIndex((item) => item.id === node.id);
|
||||||
|
if (itemIndex === -1) {
|
||||||
|
isUpdate && items.push(node);
|
||||||
|
} else {
|
||||||
|
if (isUpdate) {
|
||||||
|
items[itemIndex] = node;
|
||||||
|
} else {
|
||||||
|
items.splice(itemIndex, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const version = Number(mark.version) + 1;
|
||||||
|
// 4. 更新 JSONB 字段
|
||||||
|
const result = await mark.update(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
nodes: items,
|
||||||
|
},
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static async updateJsonNodes(id: string, nodes: { node: MarkDataNode; operate?: 'update' | 'delete' }[], opts?: { Model?: any; sequelize?: Sequelize }) {
|
||||||
|
const sequelize = opts?.sequelize || (await useContextKey('sequelize'));
|
||||||
|
const transaction = await sequelize.transaction(); // 开启事务
|
||||||
|
const Model = opts?.Model || MarkModel;
|
||||||
|
try {
|
||||||
|
const mark = await Model.findByPk(id, {
|
||||||
|
transaction,
|
||||||
|
lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改
|
||||||
|
});
|
||||||
|
if (!mark) {
|
||||||
|
throw new Error('Mark not found');
|
||||||
|
}
|
||||||
|
const data = mark.data as MarkData;
|
||||||
|
const _nodes = data.nodes || [];
|
||||||
|
// 过滤不在nodes中的节点
|
||||||
|
const blankNodes = nodes.filter((node) => !_nodes.find((n) => n.id === node.node.id)).map((node) => node.node);
|
||||||
|
// 更新或删除节点
|
||||||
|
const newNodes = _nodes
|
||||||
|
.map((node) => {
|
||||||
|
const nodeOperate = nodes.find((n) => n.node.id === node.id);
|
||||||
|
if (nodeOperate) {
|
||||||
|
if (nodeOperate.operate === 'delete') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return nodeOperate.node;
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
})
|
||||||
|
.filter((node) => node !== null);
|
||||||
|
const version = Number(mark.version) + 1;
|
||||||
|
const result = await mark.update(
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
nodes: [...blankNodes, ...newNodes],
|
||||||
|
},
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
await transaction.commit();
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
static async updateData(id: string, data: MarkData, opts: { Model?: any; sequelize?: Sequelize }) {
|
||||||
|
const sequelize = opts.sequelize || (await useContextKey('sequelize'));
|
||||||
|
const transaction = await sequelize.transaction(); // 开启事务
|
||||||
|
const Model = opts.Model || MarkModel;
|
||||||
|
const mark = await Model.findByPk(id, {
|
||||||
|
transaction,
|
||||||
|
lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改
|
||||||
|
});
|
||||||
|
if (!mark) {
|
||||||
|
throw new Error('Mark not found');
|
||||||
|
}
|
||||||
|
const version = Number(mark.version) + 1;
|
||||||
|
const result = await mark.update(
|
||||||
|
{
|
||||||
|
...mark.data,
|
||||||
|
...data,
|
||||||
|
data: {
|
||||||
|
...mark.data,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
version,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
await transaction.commit();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
static async createNew(data: any, opts: { Model?: any; sequelize?: Sequelize }) {
|
||||||
|
const sequelize = opts.sequelize || (await useContextKey('sequelize'));
|
||||||
|
const transaction = await sequelize.transaction(); // 开启事务
|
||||||
|
const Model = opts.Model || MarkModel;
|
||||||
|
const result = await Model.create({ ...data, version: 1 }, { transaction });
|
||||||
|
await transaction.commit();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export type MarkInitOpts<T = any> = {
|
||||||
|
tableName: string;
|
||||||
|
sequelize?: Sequelize;
|
||||||
|
callInit?: (attribute: ModelAttributes) => ModelAttributes;
|
||||||
|
Model?: T extends typeof MarkModel ? T : typeof MarkModel;
|
||||||
|
};
|
||||||
|
export type Opts = {
|
||||||
|
sync?: boolean;
|
||||||
|
alter?: boolean;
|
||||||
|
logging?: boolean | ((...args: any) => any);
|
||||||
|
force?: boolean;
|
||||||
|
};
|
||||||
|
export const MarkMInit = async <T = any>(opts: MarkInitOpts<T>, sync?: Opts) => {
|
||||||
|
const sequelize = await useContextKey('sequelize');
|
||||||
|
opts.sequelize = opts.sequelize || sequelize;
|
||||||
|
const { callInit, Model, ...optsRest } = opts;
|
||||||
|
const modelAttribute = {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
primaryKey: true,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
comment: 'id',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
key: {
|
||||||
|
type: DataTypes.TEXT, // 对应的minio的文件路径
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
markType: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: 'md', // markdown | json | html | image | video | audio | code | link | file
|
||||||
|
comment: '类型',
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: '',
|
||||||
|
},
|
||||||
|
cover: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: '',
|
||||||
|
comment: '封面',
|
||||||
|
},
|
||||||
|
thumbnail: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: '',
|
||||||
|
comment: '缩略图',
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: '',
|
||||||
|
comment: '链接',
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
defaultValue: [],
|
||||||
|
},
|
||||||
|
summary: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
defaultValue: '',
|
||||||
|
comment: '摘要',
|
||||||
|
},
|
||||||
|
config: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
defaultValue: {},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
defaultValue: {},
|
||||||
|
},
|
||||||
|
fileList: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
defaultValue: [],
|
||||||
|
},
|
||||||
|
uname: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
defaultValue: '',
|
||||||
|
comment: '用户的名称, 更新后的用户的名称',
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: DataTypes.INTEGER, // 更新刷新版本,多人协作
|
||||||
|
defaultValue: 1,
|
||||||
|
},
|
||||||
|
markedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '标记时间',
|
||||||
|
},
|
||||||
|
uid: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
puid: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const InitModel = Model || MarkModel;
|
||||||
|
InitModel.init(callInit ? callInit(modelAttribute) : modelAttribute, {
|
||||||
|
sequelize,
|
||||||
|
paranoid: true,
|
||||||
|
...optsRest,
|
||||||
|
});
|
||||||
|
if (sync && sync.sync) {
|
||||||
|
const { sync: _, ...rest } = sync;
|
||||||
|
MarkModel.sync({ alter: true, logging: false, ...rest }).catch((e) => {
|
||||||
|
console.error('MarkModel sync', e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const markModelInit = MarkMInit;
|
||||||
|
|
||||||
|
export const syncMarkModel = async (sync?: Opts, tableName = 'micro_mark') => {
|
||||||
|
const sequelize = await useContextKey('sequelize');
|
||||||
|
await MarkMInit({ sequelize, tableName }, sync);
|
||||||
|
};
|
||||||
5
src/routes/mark/model.ts
Normal file
5
src/routes/mark/model.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from '@kevisual/code-center-module/src/mark/mark-model.ts';
|
||||||
|
import { markModelInit, MarkModel, syncMarkModel } from '@kevisual/code-center-module/src/mark/mark-model.ts';
|
||||||
|
export { markModelInit, MarkModel };
|
||||||
|
|
||||||
|
syncMarkModel({ sync: true, alter: true, logging: false });
|
||||||
85
src/routes/mark/services/mark.ts
Normal file
85
src/routes/mark/services/mark.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { eq, desc, asc, and, like, or, count } from 'drizzle-orm';
|
||||||
|
import { app, db, schema } from '../../../app.ts';
|
||||||
|
|
||||||
|
export class MarkServices {
|
||||||
|
static getList = async (opts: {
|
||||||
|
/** 查询用户的 */
|
||||||
|
uid?: string;
|
||||||
|
query?: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
markType?: string;
|
||||||
|
sort?: string;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 查询类型
|
||||||
|
* simple: 简单查询 默认
|
||||||
|
*/
|
||||||
|
queryType?: string;
|
||||||
|
}) => {
|
||||||
|
const { uid, query = {} } = opts;
|
||||||
|
const { page = 1, pageSize = 999, search, sort = 'DESC' } = query;
|
||||||
|
|
||||||
|
const conditions = [];
|
||||||
|
if (uid) {
|
||||||
|
conditions.push(eq(schema.microMark.uid, uid));
|
||||||
|
}
|
||||||
|
if (search) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(schema.microMark.title, `%${search}%`),
|
||||||
|
like(schema.microMark.summary, `%${search}%`)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (opts.query?.markType) {
|
||||||
|
conditions.push(eq(schema.microMark.markType, opts.query.markType));
|
||||||
|
}
|
||||||
|
|
||||||
|
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||||
|
|
||||||
|
const queryType = opts.queryType || 'simple';
|
||||||
|
let selectFields: any = {};
|
||||||
|
|
||||||
|
if (queryType === 'simple') {
|
||||||
|
// Exclude data, config, cover, description
|
||||||
|
selectFields = {
|
||||||
|
id: schema.microMark.id,
|
||||||
|
title: schema.microMark.title,
|
||||||
|
tags: schema.microMark.tags,
|
||||||
|
uname: schema.microMark.uname,
|
||||||
|
uid: schema.microMark.uid,
|
||||||
|
createdAt: schema.microMark.createdAt,
|
||||||
|
updatedAt: schema.microMark.updatedAt,
|
||||||
|
thumbnail: schema.microMark.thumbnail,
|
||||||
|
link: schema.microMark.link,
|
||||||
|
summary: schema.microMark.summary,
|
||||||
|
markType: schema.microMark.markType,
|
||||||
|
puid: schema.microMark.puid,
|
||||||
|
deletedAt: schema.microMark.deletedAt,
|
||||||
|
version: schema.microMark.version,
|
||||||
|
fileList: schema.microMark.fileList,
|
||||||
|
key: schema.microMark.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderByField = sort === 'ASC' ? asc(schema.microMark.updatedAt) : desc(schema.microMark.updatedAt);
|
||||||
|
|
||||||
|
const [rows, totalResult] = await Promise.all([
|
||||||
|
queryType === 'simple'
|
||||||
|
? db.select(selectFields).from(schema.microMark).where(whereClause).orderBy(orderByField).limit(pageSize).offset((page - 1) * pageSize)
|
||||||
|
: db.select().from(schema.microMark).where(whereClause).orderBy(orderByField).limit(pageSize).offset((page - 1) * pageSize),
|
||||||
|
db.select({ count: count() }).from(schema.microMark).where(whereClause)
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pagination: {
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: totalResult[0]?.count || 0,
|
||||||
|
},
|
||||||
|
list: rows,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { minioClient } from '@/app.ts';
|
import { oss } from '@/app.ts';
|
||||||
import { bucketName } from '@/modules/minio.ts';
|
|
||||||
import { fileIsExist } from '@kevisual/use-config';
|
import { fileIsExist } from '@kevisual/use-config';
|
||||||
import { spawn, spawnSync } from 'child_process';
|
import { spawn, spawnSync } from 'child_process';
|
||||||
import { getFileStat, getMinioList, MinioFile } from '@/routes/file/index.ts';
|
import { getFileStat, getMinioList, MinioFile } from '@/routes/file/index.ts';
|
||||||
@@ -8,6 +7,7 @@ import fs from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { appsPath } from '../lib/index.ts';
|
import { appsPath } from '../lib/index.ts';
|
||||||
import { installAppFromKey } from './manager.ts';
|
import { installAppFromKey } from './manager.ts';
|
||||||
|
import { Readable } from 'stream';
|
||||||
export type InstallAppOpts = {
|
export type InstallAppOpts = {
|
||||||
needInstallDeps?: boolean;
|
needInstallDeps?: boolean;
|
||||||
// minio中
|
// minio中
|
||||||
@@ -46,7 +46,7 @@ export const installApp = async (opts: InstallAppOpts) => {
|
|||||||
if (!fileIsExist(dir)) {
|
if (!fileIsExist(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
const fileStream = await minioClient.getObject(bucketName, `${name}`);
|
const fileStream = (await oss.getObject(`${name}`)).Body as Readable;
|
||||||
const writeStream = fs.createWriteStream(outputPath);
|
const writeStream = fs.createWriteStream(outputPath);
|
||||||
fileStream.pipe(writeStream);
|
fileStream.pipe(writeStream);
|
||||||
|
|
||||||
|
|||||||
@@ -153,7 +153,9 @@ app
|
|||||||
browser: someInfo['user-agent'],
|
browser: someInfo['user-agent'],
|
||||||
host: someInfo.host,
|
host: someInfo.host,
|
||||||
});
|
});
|
||||||
createCookie(token, ctx);
|
createCookie({
|
||||||
|
token: token.accessToken
|
||||||
|
}, ctx);
|
||||||
ctx.body = token;
|
ctx.body = token;
|
||||||
})
|
})
|
||||||
.addTo(app);
|
.addTo(app);
|
||||||
@@ -259,7 +261,10 @@ app
|
|||||||
const refreshToken = accessUser.oauthExpand?.refreshToken;
|
const refreshToken = accessUser.oauthExpand?.refreshToken;
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
const result = await User.oauth.refreshToken(refreshToken);
|
const result = await User.oauth.refreshToken(refreshToken);
|
||||||
createCookie(result, ctx);
|
createCookie({
|
||||||
|
token: result.accessToken
|
||||||
|
}, ctx);
|
||||||
|
|
||||||
ctx.body = result;
|
ctx.body = result;
|
||||||
return;
|
return;
|
||||||
} else if (accessUser) {
|
} else if (accessUser) {
|
||||||
@@ -268,7 +273,9 @@ app
|
|||||||
...accessUser.oauthExpand,
|
...accessUser.oauthExpand,
|
||||||
hasRefreshToken: true,
|
hasRefreshToken: true,
|
||||||
});
|
});
|
||||||
createCookie(result, ctx);
|
createCookie({
|
||||||
|
token: result.accessToken
|
||||||
|
}, ctx);
|
||||||
ctx.body = result;
|
ctx.body = result;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -323,13 +330,17 @@ app
|
|||||||
if (orgsList.includes(username)) {
|
if (orgsList.includes(username)) {
|
||||||
if (tokenUsername === username) {
|
if (tokenUsername === username) {
|
||||||
const result = await User.oauth.resetToken(token);
|
const result = await User.oauth.resetToken(token);
|
||||||
createCookie(result, ctx);
|
createCookie({
|
||||||
|
token: result.accessToken,
|
||||||
|
}, ctx);
|
||||||
await User.oauth.delToken(token);
|
await User.oauth.delToken(token);
|
||||||
ctx.body = result;
|
ctx.body = result;
|
||||||
} else {
|
} else {
|
||||||
const user = await User.findOne({ where: { username } });
|
const user = await User.findOne({ where: { username } });
|
||||||
const result = await user.createToken(userId, 'default');
|
const result = await user.createToken(userId, 'default');
|
||||||
createCookie(result, ctx);
|
createCookie({
|
||||||
|
token: result.accessToken,
|
||||||
|
}, ctx);
|
||||||
ctx.body = result;
|
ctx.body = result;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -352,7 +363,9 @@ app
|
|||||||
const result = await User.oauth.refreshToken(refreshToken);
|
const result = await User.oauth.refreshToken(refreshToken);
|
||||||
if (result) {
|
if (result) {
|
||||||
console.log('refreshToken result', result);
|
console.log('refreshToken result', result);
|
||||||
createCookie(result, ctx);
|
createCookie({
|
||||||
|
token: result.accessToken,
|
||||||
|
}, ctx);
|
||||||
ctx.body = result;
|
ctx.body = result;
|
||||||
} else {
|
} else {
|
||||||
ctx.throw(500, 'Refresh Token Failed, please login again');
|
ctx.throw(500, 'Refresh Token Failed, please login again');
|
||||||
|
|||||||
@@ -109,7 +109,9 @@ app
|
|||||||
const token = JSON.parse(data);
|
const token = JSON.parse(data);
|
||||||
if (token.accessToken) {
|
if (token.accessToken) {
|
||||||
ctx.body = token;
|
ctx.body = token;
|
||||||
createCookie(token, ctx);
|
createCookie({
|
||||||
|
token: token.accessToken,
|
||||||
|
}, ctx);
|
||||||
} else {
|
} else {
|
||||||
ctx.throw(500, 'Checked error Failed, login failed, please login again');
|
ctx.throw(500, 'Checked error Failed, login failed, please login again');
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/test/add-demo-light-code.ts
Normal file
44
src/test/add-demo-light-code.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { app, showMore, showRes } from './common.ts';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
export const getStringHash = (str: string) => {
|
||||||
|
return crypto.createHash('md5').update(str).digest('hex');
|
||||||
|
}
|
||||||
|
const code = `// 这是一个示例代码文件
|
||||||
|
import {App} from '@kevisual/router';
|
||||||
|
|
||||||
|
const app = new App();
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'hello',
|
||||||
|
description: 'LightCode 示例路由 2323232323',
|
||||||
|
metadata: {
|
||||||
|
tags: ['light-code', 'example'],
|
||||||
|
},
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
console.log('tokenUser:', ctx.query?.tokenUser);
|
||||||
|
ctx.body = 'Hello from LightCode!';
|
||||||
|
}).addTo(app);
|
||||||
|
|
||||||
|
app.wait();`
|
||||||
|
const code2 = `const a = 1`
|
||||||
|
|
||||||
|
const res = await app.run({
|
||||||
|
path: 'light-code',
|
||||||
|
key: 'update',
|
||||||
|
payload: {
|
||||||
|
data: {
|
||||||
|
title: 'Demo Light Code',
|
||||||
|
description: '这是一个演示用的轻代码项目,包含一个简单的路由示例。',
|
||||||
|
type: 'light-code',
|
||||||
|
tags: ['demo', 'light-code'],
|
||||||
|
data: {},
|
||||||
|
code: code,
|
||||||
|
hash: getStringHash(code),
|
||||||
|
},
|
||||||
|
token: "st_idht7xpffhgu2eeh94zd8ze1t7ew3amy",
|
||||||
|
},
|
||||||
|
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('showMore', showMore(res));
|
||||||
@@ -3,7 +3,9 @@ import '@/route.ts';
|
|||||||
import { useConfig, useContextKey } from '@kevisual/context';
|
import { useConfig, useContextKey } from '@kevisual/context';
|
||||||
import { Query } from '@kevisual/query';
|
import { Query } from '@kevisual/query';
|
||||||
import util from 'node:util';
|
import util from 'node:util';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config();
|
||||||
export {
|
export {
|
||||||
app,
|
app,
|
||||||
useContextKey
|
useContextKey
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { bucketName, minioClient } from '@/modules/minio.ts';
|
|
||||||
import { S3Error } from 'minio';
|
|
||||||
const main = async () => {
|
|
||||||
const res = await new Promise((resolve, reject) => {
|
|
||||||
let res: any[] = [];
|
|
||||||
let hasError = false;
|
|
||||||
minioClient
|
|
||||||
.listObjectsV2(bucketName, 'root/codeflow/0.0.1/')
|
|
||||||
.on('data', (data) => {
|
|
||||||
res.push(data);
|
|
||||||
})
|
|
||||||
.on('error', (err) => {
|
|
||||||
console.error('error', err);
|
|
||||||
hasError = true;
|
|
||||||
})
|
|
||||||
.on('end', () => {
|
|
||||||
if (hasError) {
|
|
||||||
reject();
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
resolve(res);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
console.log(res);
|
|
||||||
};
|
|
||||||
// main();
|
|
||||||
|
|
||||||
const main2 = async () => {
|
|
||||||
try {
|
|
||||||
const obj = await minioClient.statObject(bucketName, 'root/codeflow/0.0.1/README.md');
|
|
||||||
|
|
||||||
console.log(obj);
|
|
||||||
} catch (e) {
|
|
||||||
console.log('', e.message, '\n\r', e.code);
|
|
||||||
// console.error(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
main2();
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
process.env.NODE_ENV = 'development';
|
process.env.NODE_ENV = 'development';
|
||||||
// import { mvUserAToUserB, backupUserA } from '../routes/file/module/get-minio-list.ts';
|
import { mvUserAToUserB, backupUserA } from '../routes/file/module/get-minio-list.ts';
|
||||||
|
|
||||||
|
|
||||||
// mvUserAToUserB('demo', 'demo2');
|
// mvUserAToUserB('demo', 'demo2');
|
||||||
|
|
||||||
// backupUserA('demo', '123', '2025-04-02-16-00');
|
// backupUserA('demo', '123', '2026-01-31-16-00');
|
||||||
// backupUserA('demo', '123', '2025-04-02-16-01');
|
// backupUserA('demo', '123', '2025-04-02-16-01');
|
||||||
// backupUserA('demo', '123', '2025-04-02-16-02');
|
// backupUserA('demo', '123', '2025-04-02-16-02');
|
||||||
// backupUserA('demo', '123', '2025-04-02-16-03');
|
// backupUserA('demo', '123', '2025-04-02-16-03');
|
||||||
|
|||||||
4
src/test/s3-stat.ts
Normal file
4
src/test/s3-stat.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import { oss } from '@/modules/s3.ts'
|
||||||
|
|
||||||
|
const stat = await oss.statObject('root/codepod/0.0.3/index.html');
|
||||||
|
console.log('Object Stat:', stat);
|
||||||
Reference in New Issue
Block a user