Compare commits
66 Commits
d62a75842f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa5802ef2 | ||
|
|
dfd4aacf1c | ||
|
|
56a55ac5ae | ||
|
|
8184994b2c | ||
|
|
2000b474a0 | ||
|
|
07efc4e468 | ||
|
|
f305a900f4 | ||
|
|
6a92ee7a2d | ||
|
|
6467e6dea8 | ||
|
|
08884b7e4b | ||
|
|
9be5eb00f5 | ||
|
|
2332f05cef | ||
| 10874917f2 | |||
| 99141a926e | |||
| 61a809ecd7 | |||
| 66a19139b7 | |||
| 027cbecab6 | |||
| 48425c6120 | |||
| 91eaad04d7 | |||
| 38d31a9fb5 | |||
| efb30708eb | |||
| e68b77f871 | |||
| 2b5d3250a4 | |||
| 69422f3e3f | |||
| fb58d91e50 | |||
| 53204291ce | |||
| b31f2840b9 | |||
| 42957af179 | |||
| 2518f6bba3 | |||
| bbdf9f087d | |||
| aaedcb881b | |||
| d2913dd32d | |||
| bb4096ce7e | |||
| 120303961c | |||
| 75ab160509 | |||
| a48cc48589 | |||
| 1ae4c979dc | |||
| 999a75c76b | |||
| 0084f9878b | |||
| 79e07d6689 | |||
| 4b8f47cea8 | |||
| 6b5164e845 | |||
| d50f5ed2af | |||
| 71c238f953 | |||
| 672208ab6b | |||
| 77273bcfeb | |||
| 366a21d621 | |||
| 1782a9ef19 | |||
| 0d73941127 | |||
| 7088d025c9 | |||
| 577b6bfaa4 | |||
| 9cc48821b1 | |||
| 9d39e1cd46 | |||
| 6c611dcf78 | |||
| 8c6d57d228 | |||
| 84e7a4158d | |||
| 9961efd91a | |||
| bb8ce3338d | |||
| 3cca0720c1 | |||
| ab0ba939d4 | |||
| e51cacd7a2 | |||
| 6bf8d86c10 | |||
| 0be7627bd1 | |||
| 885e04e301 | |||
| 0bd634faf2 | |||
| 7dfa96d165 |
@@ -1,150 +0,0 @@
|
||||
---
|
||||
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);
|
||||
```
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -2,11 +2,8 @@ node_modules
|
||||
|
||||
dist
|
||||
|
||||
app.config.json5
|
||||
|
||||
apps.config.json
|
||||
|
||||
deploy.tar.gz
|
||||
cache-file
|
||||
|
||||
/apps
|
||||
@@ -22,6 +19,7 @@ release/*
|
||||
!.env.example
|
||||
|
||||
pack-dist
|
||||
app.config.json5.envision
|
||||
|
||||
/pages
|
||||
/pages
|
||||
|
||||
storage
|
||||
2
.npmrc
2
.npmrc
@@ -1,2 +0,0 @@
|
||||
@abearxiong:registry=https://npm.pkg.github.com
|
||||
ignore-workspace-root-check=true
|
||||
217
.opencode/skills/create-routes/SKILL.md
Normal file
217
.opencode/skills/create-routes/SKILL.md
Normal file
@@ -0,0 +1,217 @@
|
||||
---
|
||||
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';
|
||||
import { z } from 'zod';
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'prompts',
|
||||
key: 'list',
|
||||
middleware: ['auth'],
|
||||
description: '获取提示词列表',
|
||||
metadata: {
|
||||
args: {
|
||||
page: z.number().optional().default(1).describe('页码'),
|
||||
pageSize: z.number().optional().default(20).describe('每页数量'),
|
||||
search: z.string().optional().describe('搜索关键词'),
|
||||
sort: z.enum(['ASC', 'DESC']).optional().default('DESC').describe('排序方式'),
|
||||
},
|
||||
},
|
||||
})
|
||||
.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);
|
||||
app
|
||||
.route({
|
||||
path: 'prompts',
|
||||
key: 'create',
|
||||
middleware: ['auth'],
|
||||
description: '创建提示词',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z
|
||||
.object({
|
||||
title: z.string().describe('提示词标题'),
|
||||
description: z.string().optional().describe('描述'),
|
||||
summary: z.string().optional().describe('摘要'),
|
||||
tags: z.array(z.string()).optional().describe('标签'),
|
||||
link: z.string().optional().describe('链接'),
|
||||
data: z.record(z.string(), z.any()).optional().describe('数据对象'),
|
||||
parents: z.array(z.string()).optional().describe('父级ID数组'),
|
||||
})
|
||||
.describe('提示词对象'),
|
||||
},
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { title, description, summary, tags, link, data, parents } = ctx.query.data || {};
|
||||
if (!title) {
|
||||
ctx.throw(400, 'title 参数缺失');
|
||||
}
|
||||
const newPrompt = await db
|
||||
.insert(schema.prompts)
|
||||
.values({
|
||||
title,
|
||||
description,
|
||||
summary,
|
||||
tags,
|
||||
link,
|
||||
data,
|
||||
parents,
|
||||
uid: tokenUser.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
ctx.body = newPrompt;
|
||||
})
|
||||
.addTo(app);
|
||||
app
|
||||
.route({
|
||||
path: 'prompts',
|
||||
key: 'update',
|
||||
middleware: ['auth'],
|
||||
description: '更新提示词',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z
|
||||
.object({
|
||||
id: z.string().optional().describe('提示词ID, 不填表示创建'),
|
||||
title: z.string().describe('提示词标题'),
|
||||
description: z.string().optional().describe('描述'),
|
||||
summary: z.string().optional().describe('摘要'),
|
||||
tags: z.array(z.string()).optional().describe('标签'),
|
||||
link: z.string().optional().describe('链接'),
|
||||
data: z.record(z.string(), z.any()).optional().describe('数据对象'),
|
||||
parents: z.array(z.string()).optional().describe('父级ID数组'),
|
||||
})
|
||||
.describe('提示词对象'),
|
||||
},
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { id, uid, updatedAt, ...rest } = ctx.query.data || {};
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
let prompt;
|
||||
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, '没有权限更新该提示词');
|
||||
}
|
||||
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',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('提示词ID'),
|
||||
},
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { id } = ctx.query || {};
|
||||
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',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('提示词ID'),
|
||||
},
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { id } = ctx.query || {};
|
||||
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);
|
||||
```
|
||||
29
.opencode/skills/pnpm-deploy/SKILL.md
Normal file
29
.opencode/skills/pnpm-deploy/SKILL.md
Normal file
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: pnpm-deploy
|
||||
description: 使用pnpm部署应用到测试或生成环境
|
||||
---
|
||||
|
||||
# pnpm-deploy 部署技能
|
||||
|
||||
部署应用到测试或生成环境。
|
||||
|
||||
## 部署环境
|
||||
|
||||
| 环境 | 命令 |
|
||||
|------|------|
|
||||
| 测试环境 | `pnpm pub:me` |
|
||||
| 生成环境 | `pnpm pub:kevisual` |
|
||||
|
||||
## 使用方法
|
||||
|
||||
在项目目录下执行部署命令:
|
||||
|
||||
### 部署到测试环境
|
||||
```bash
|
||||
pnpm pub:me
|
||||
```
|
||||
|
||||
### 部署到生成环境
|
||||
```bash
|
||||
pnpm pub:kevisual
|
||||
```
|
||||
66
AGENTS.md
Normal file
66
AGENTS.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Agents
|
||||
|
||||
## src/aura
|
||||
AI语音识别相关模块,包含ASR功能、配置和库文件
|
||||
|
||||
## src/auth
|
||||
认证授权模块,包含drizzle ORM集成、数据模型和OAuth认证
|
||||
|
||||
## src/db
|
||||
数据库核心模块,包含drizzle配置和数据库schema定义
|
||||
|
||||
## src/models
|
||||
数据模型定义,包含组织和用户相关模型
|
||||
|
||||
## src/modules
|
||||
核心功能模块集合:
|
||||
- auth.ts: 认证模块
|
||||
- config.ts: 配置管理
|
||||
- db.ts: 数据库连接
|
||||
- domain.ts: 域管理
|
||||
- fm-manager/: 文件管理器
|
||||
- html/: HTML处理
|
||||
- jwks/: JWT密钥集
|
||||
- logger.ts: 日志记录
|
||||
- off/: 离线功能
|
||||
- redis.ts: Redis缓存
|
||||
- s3.ts: S3存储
|
||||
- self-restart.ts: 自重启
|
||||
- user-app/: 用户应用
|
||||
- v1-ws-proxy/: V1 WebSocket代理
|
||||
- v3/: V3版本功能
|
||||
|
||||
## src/realtime
|
||||
实时通信模块,包含flowme实时功能
|
||||
|
||||
## src/routes
|
||||
API路由集合,按功能分类:
|
||||
- ai/: AI相关路由
|
||||
- app-manager/: 应用管理
|
||||
- config/: 配置管理
|
||||
- file/: 文件操作
|
||||
- file-listener/: 文件监听
|
||||
- flowme/: Flowme功能
|
||||
- light-code/: 轻量级代码
|
||||
- mark/: 标记功能
|
||||
- micro-app/: 微应用
|
||||
- prompts/: 提示词
|
||||
- query-views/: 查询视图
|
||||
- user/: 用户管理
|
||||
- views/: 视图管理
|
||||
|
||||
## src/routes-simple
|
||||
简化路由模块,包含页面代理和路由器
|
||||
|
||||
## src/scripts
|
||||
实用脚本集合,包含数据库操作、用户管理、S3统计、密钥管理等维护脚本
|
||||
|
||||
## src/test
|
||||
测试相关文件
|
||||
|
||||
## src/utils
|
||||
通用工具函数:
|
||||
- filter.ts: 过滤工具
|
||||
- get-content-type.ts: 内容类型获取
|
||||
- get-engine.ts: 引擎获取
|
||||
- sleep.ts: 延迟工具
|
||||
@@ -1,10 +1,9 @@
|
||||
// @ts-check
|
||||
import { resolvePath } from '@kevisual/use-config';
|
||||
import { execSync } from 'node:child_process';
|
||||
|
||||
const entry = 'src/index.ts';
|
||||
const naming = 'app';
|
||||
const external = ['sequelize', 'pg', 'sqlite3', 'ioredis', 'pm2', 'bun'];
|
||||
const external = ['pg', 'sqlite3', 'ioredis', 'pm2'];
|
||||
/**
|
||||
* @type {import('bun').BuildConfig}
|
||||
*/
|
||||
@@ -17,21 +16,25 @@ await Bun.build({
|
||||
entry: `${naming}.js`,
|
||||
},
|
||||
external,
|
||||
env: 'KEVISUAL_*',
|
||||
// 启用模块转换和优化
|
||||
minify: false,
|
||||
splitting: false,
|
||||
// sourcemap: 'external',
|
||||
// 处理 CommonJS 到 ESM 的转换
|
||||
plugins: [{
|
||||
name: 'transform-requires',
|
||||
setup(build) {
|
||||
// 转换内置模块为 node: 前缀
|
||||
build.onResolve({ filter: /^(path|fs|module|url|util|crypto|stream|buffer|events|http|https|net|os|querystring|zlib|cluster|child_process|worker_threads|perf_hooks|inspector|dgram|dns|tls|readline|repl|process|assert|vm|timers|constants|string_decoder|punycode|v8)$/ }, args => {
|
||||
return {
|
||||
path: `node:${args.path}`,
|
||||
external: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
// const cmd = `dts -i src/index.ts -o app.d.ts`;
|
||||
// const cmd = `dts -i ${entry} -o ${naming}.d.ts`;
|
||||
// execSync(cmd, { stdio: 'inherit' });
|
||||
|
||||
await Bun.build({
|
||||
target: 'node',
|
||||
format: 'esm',
|
||||
entrypoints: [resolvePath('./src/run.ts', { meta: import.meta })],
|
||||
outdir: resolvePath('./dist', { meta: import.meta }),
|
||||
naming: {
|
||||
entry: `${'run'}.js`,
|
||||
},
|
||||
external,
|
||||
env: 'KEVISUAL_*',
|
||||
});
|
||||
|
||||
73
package.json
73
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kevisual/code-center",
|
||||
"version": "0.0.12",
|
||||
"version": "0.0.13",
|
||||
"description": "code center",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
@@ -12,9 +12,7 @@
|
||||
"runtime": [
|
||||
"client"
|
||||
],
|
||||
"pm2Options": {
|
||||
"interpreter": "bun"
|
||||
}
|
||||
"engine": "bun"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "bun run --watch --hot src/index.ts",
|
||||
@@ -31,83 +29,80 @@
|
||||
"pub:kevisual": "npm run build && npm run deploy:kevisual && npm run reload:kevisual",
|
||||
"start": "pm2 start dist/app.js --name code-center",
|
||||
"client:start": "pm2 start apps/code-center/dist/app.js --name code-center",
|
||||
"ssl": "ssh -L 5432:localhost:5432 light",
|
||||
"ssl:redis": "ssh -L 6379:localhost:6379 light",
|
||||
"ssl:minio": "ssh -L 9000:localhost:9000 light",
|
||||
"import-data": "bun run scripts/import-data.ts",
|
||||
"studio": "npx drizzle-kit studio",
|
||||
"drizzle:migrate": "npx drizzle-kit migrate",
|
||||
"drizzle:push": "npx drizzle-kit push",
|
||||
"pub": "envision pack -p -u -c"
|
||||
},
|
||||
"keywords": [],
|
||||
"types": "types/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@kevisual/ai": "^0.0.24",
|
||||
"@kevisual/ai": "^0.0.28",
|
||||
"@kevisual/auth": "^2.0.3",
|
||||
"@kevisual/js-filter": "^0.0.5",
|
||||
"@kevisual/query": "^0.0.39",
|
||||
"@kevisual/js-filter": "^0.0.6",
|
||||
"@kevisual/query": "^0.0.55",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"@types/send": "^1.2.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"bullmq": "^5.67.3",
|
||||
"bullmq": "^5.71.0",
|
||||
"busboy": "^1.6.0",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"ioredis": "^5.9.2",
|
||||
"pg": "^8.18.0",
|
||||
"pm2": "^6.0.14",
|
||||
"send": "^1.2.1",
|
||||
"sequelize": "^6.37.7",
|
||||
"ws": "npm:@kevisual/ws",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@aws-sdk/client-s3": "^3.984.0",
|
||||
"@kevisual/api": "^0.0.47",
|
||||
"@kevisual/code-center-module": "0.0.24",
|
||||
"@kevisual/context": "^0.0.4",
|
||||
"@kevisual/file-listener": "^0.0.2",
|
||||
"@ai-sdk/openai-compatible": "^2.0.37",
|
||||
"@aws-sdk/client-s3": "^3.1014.0",
|
||||
"@kevisual/api": "^0.0.65",
|
||||
"@kevisual/cnb": "^0.0.59",
|
||||
"@kevisual/context": "^0.0.8",
|
||||
"@kevisual/local-app-manager": "0.1.32",
|
||||
"@kevisual/logger": "^0.0.4",
|
||||
"@kevisual/oss": "0.0.19",
|
||||
"@kevisual/oss": "0.0.20",
|
||||
"@kevisual/permission": "^0.0.4",
|
||||
"@kevisual/router": "0.0.70",
|
||||
"@kevisual/router": "0.2.2",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@kevisual/use-config": "^1.0.30",
|
||||
"@types/archiver": "^7.0.0",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/bun": "^1.3.11",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.2.1",
|
||||
"@types/pg": "^8.16.0",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"@types/xml2js": "^0.4.14",
|
||||
"ai": "^6.0.134",
|
||||
"archiver": "^7.0.1",
|
||||
"convex": "^1.34.0",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^17.2.4",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"ioredis": "^5.9.2",
|
||||
"dayjs": "^1.11.20",
|
||||
"dotenv": "^17.3.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"es-toolkit": "^1.45.1",
|
||||
"ioredis": "^5.10.1",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"minio": "^8.0.6",
|
||||
"nanoid": "^5.1.6",
|
||||
"nodemon": "^3.1.11",
|
||||
"lunar": "^2.0.0",
|
||||
"nanoid": "^5.1.7",
|
||||
"p-queue": "^9.1.0",
|
||||
"pg": "^8.18.0",
|
||||
"pg": "^8.20.0",
|
||||
"pm2": "^6.0.14",
|
||||
"semver": "^7.7.4",
|
||||
"sequelize": "^6.37.7",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"resolutions": {
|
||||
"inflight": "latest",
|
||||
"picomatch": "^4.0.2"
|
||||
"picomatch": "^4.0.2",
|
||||
"ioredis": "^5.10.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.2"
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"workspaces": [
|
||||
"wxmsg"
|
||||
]
|
||||
}
|
||||
3147
pnpm-lock.yaml
generated
3147
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
78
scripts/import-data.ts
Normal file
78
scripts/import-data.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 导入 short-link JSON 数据到数据库
|
||||
* 运行: bun run scripts/import-data.ts
|
||||
*/
|
||||
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { shortLink } from '@/db/schemas/n-code-schema.ts';
|
||||
import { useConfig } from '@kevisual/use-config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const config = useConfig() as any;
|
||||
const DATABASE_URL = config.DATABASE_URL || process.env.DATABASE_URL || '';
|
||||
if (!DATABASE_URL) {
|
||||
console.error('缺少 DATABASE_URL 配置');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = drizzle(DATABASE_URL);
|
||||
|
||||
// 读取 JSON 数据
|
||||
const jsonPath = resolve(import.meta.dir, 'ncode-list.json');
|
||||
const rawData = JSON.parse(readFileSync(jsonPath, 'utf-8')) as Array<{
|
||||
code: string;
|
||||
data: Record<string, any>;
|
||||
description: string;
|
||||
slug: string;
|
||||
tags: string[];
|
||||
title: string;
|
||||
type: string;
|
||||
userId: string;
|
||||
version: string;
|
||||
}>;
|
||||
|
||||
async function importData() {
|
||||
console.log(`准备导入 ${rawData.length} 条 short-link 数据...`);
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
for (const item of rawData) {
|
||||
const userId = item.userId && uuidRegex.test(item.userId) ? item.userId : null;
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(shortLink)
|
||||
.values({
|
||||
slug: item.slug,
|
||||
code: item.code,
|
||||
type: item.type || 'link',
|
||||
version: item.version || '1.0',
|
||||
title: item.title || '',
|
||||
description: item.description || '',
|
||||
tags: item.tags ?? [],
|
||||
data: item.data ?? {},
|
||||
userId: userId as any,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
console.log(` ✓ 导入: slug=${item.slug}, code=${item.code}, title=${item.title}`);
|
||||
inserted++;
|
||||
} catch (err: any) {
|
||||
const cause = err.cause || err;
|
||||
console.warn(` ✗ 跳过: slug=${item.slug}, code=${item.code} — ${cause.message || err.message}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n完成: 成功 ${inserted} 条,跳过 ${skipped} 条`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
importData().catch((err) => {
|
||||
console.error('导入失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
83
scripts/import-life.ts
Normal file
83
scripts/import-life.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 导入 life JSON 数据到数据库
|
||||
* 运行: bun run scripts/import-life.ts
|
||||
*/
|
||||
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { life } from '@/db/schemas/life-schema.ts';
|
||||
import { useConfig } from '@kevisual/use-config';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
const config = useConfig() as any;
|
||||
const DATABASE_URL = config.DATABASE_URL || process.env.DATABASE_URL || '';
|
||||
if (!DATABASE_URL) {
|
||||
console.error('缺少 DATABASE_URL 配置');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const db = drizzle(DATABASE_URL);
|
||||
|
||||
// 读取 JSON 数据
|
||||
const jsonPath = resolve(import.meta.dir, 'life-list.json');
|
||||
const rawData = JSON.parse(readFileSync(jsonPath, 'utf-8')) as Array<{
|
||||
data: Record<string, any>;
|
||||
description: string;
|
||||
effectiveAt: string;
|
||||
link: string;
|
||||
prompt: string;
|
||||
summary: string;
|
||||
tags: string[];
|
||||
taskResult: Record<string, any>;
|
||||
taskType: string;
|
||||
title: string;
|
||||
type: string;
|
||||
updatedAt: string;
|
||||
userId: string;
|
||||
}>;
|
||||
|
||||
async function importData() {
|
||||
console.log(`准备导入 ${rawData.length} 条 flowme-life 数据...`);
|
||||
|
||||
let inserted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const item of rawData) {
|
||||
const uid = item.userId;
|
||||
|
||||
try {
|
||||
await db
|
||||
.insert(life)
|
||||
.values({
|
||||
title: item.title || '',
|
||||
summary: item.summary || '',
|
||||
description: item.description || '',
|
||||
tags: item.tags ?? [],
|
||||
link: item.link || '',
|
||||
data: item.data ?? {},
|
||||
effectiveAt: item.effectiveAt || '',
|
||||
type: item.type || '',
|
||||
prompt: item.prompt || '',
|
||||
taskType: item.taskType || '',
|
||||
taskResult: item.taskResult ?? {},
|
||||
uid: uid as any,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
console.log(` ✓ 导入: title=${item.title}, type=${item.type}`);
|
||||
inserted++;
|
||||
} catch (err: any) {
|
||||
const cause = err.cause || err;
|
||||
console.warn(` ✗ 跳过: title=${item.title} — ${cause.message || err.message}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n完成: 成功 ${inserted} 条,跳过 ${skipped} 条`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
importData().catch((err) => {
|
||||
console.error('导入失败:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
104
scripts/ncode-list.json
Normal file
104
scripts/ncode-list.json
Normal file
@@ -0,0 +1,104 @@
|
||||
[
|
||||
{
|
||||
"code": "anwjgg",
|
||||
"data": {
|
||||
"link": "https://kevisual.cn/root/name-card/"
|
||||
},
|
||||
"description": "这是一个测试码",
|
||||
"slug": "pmzsq4gp4g",
|
||||
"tags": [],
|
||||
"title": "测试码",
|
||||
"type": "link",
|
||||
"userId": "0e700dc8-90dd-41b7-91dd-336ea51de3d2",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"code": "0gmn12",
|
||||
"data": {
|
||||
"link": "https://kevisual.cn/root/v1/ha-api?path=ha&key=open-balcony-light",
|
||||
"permission": {
|
||||
"share": "public"
|
||||
},
|
||||
"useOwnerToken": true
|
||||
},
|
||||
"description": "test 阳台灯",
|
||||
"slug": "3z1nbdogew",
|
||||
"tags": [],
|
||||
"title": "阳台灯",
|
||||
"type": "link",
|
||||
"userId": "0e700dc8-90dd-41b7-91dd-336ea51de3d2",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"code": "abc111",
|
||||
"data": {
|
||||
"link": "https://kevisual.cn/root/nfc/",
|
||||
"permission": {
|
||||
"share": "public"
|
||||
}
|
||||
},
|
||||
"description": "nfc link",
|
||||
"slug": "0000000001",
|
||||
"tags": [],
|
||||
"title": "nfc link",
|
||||
"type": "link",
|
||||
"userId": "",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"code": "ej73jm",
|
||||
"data": {
|
||||
"link": "https://kevisual.cn/root/nfc/",
|
||||
"permission": {
|
||||
"share": "public"
|
||||
}
|
||||
},
|
||||
"description": "nfc link",
|
||||
"slug": "001",
|
||||
"tags": [],
|
||||
"title": "nfc link",
|
||||
"type": "link",
|
||||
"userId": "",
|
||||
"version": "1.0"
|
||||
},
|
||||
{
|
||||
"code": "09dd42",
|
||||
"data": {
|
||||
"details": [
|
||||
{
|
||||
"description": "算法基于七卡瓦,自建",
|
||||
"title": "beads(kevisual.cn)",
|
||||
"url": "https://kevisual.cn/root/beads/"
|
||||
},
|
||||
{
|
||||
"description": "算法很不错,图片转图纸很好",
|
||||
"title": "拼豆七卡瓦(zippland.com)",
|
||||
"url": "https://perlerbeads.zippland.com/"
|
||||
},
|
||||
{
|
||||
"description": "功能偏向PS,画板类,像素类",
|
||||
"title": "拼豆像素格子(zwpyyds.com)",
|
||||
"url": "https://www.zwpyyds.com/"
|
||||
},
|
||||
{
|
||||
"description": "编辑不错,拼豆社区",
|
||||
"title": "我嘞个豆(ohmybead.cn)",
|
||||
"url": "https://ohmybead.cn/"
|
||||
}
|
||||
],
|
||||
"link": "https://kevisual.cn/root/nfc/way/",
|
||||
"permission": {
|
||||
"share": "public"
|
||||
},
|
||||
"title": "拼豆图纸自取方案",
|
||||
"type": "html-render"
|
||||
},
|
||||
"description": "Pindou",
|
||||
"slug": "pindou",
|
||||
"tags": [],
|
||||
"title": "Pindou",
|
||||
"type": "link",
|
||||
"userId": "",
|
||||
"version": "1.0"
|
||||
}
|
||||
]
|
||||
29
src/app.ts
29
src/app.ts
@@ -1,6 +1,5 @@
|
||||
import { App } from '@kevisual/router';
|
||||
import * as redisLib from './modules/redis.ts';
|
||||
import * as sequelizeLib from './modules/sequelize.ts';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { SimpleRouter } from '@kevisual/router/simple';
|
||||
import { s3Client, oss as s3Oss } from './modules/s3.ts';
|
||||
@@ -8,6 +7,9 @@ import { BailianProvider } from '@kevisual/ai';
|
||||
import * as schema from './db/schema.ts';
|
||||
import { config } from './modules/config.ts'
|
||||
import { db } from './modules/db.ts'
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
|
||||
|
||||
export const router = useContextKey('router', () => new SimpleRouter());
|
||||
export const runtime = useContextKey('runtime', () => {
|
||||
return {
|
||||
@@ -22,7 +24,6 @@ export const oss = useContextKey(
|
||||
export { s3Client }
|
||||
export const redis = useContextKey('redis', () => redisLib.redis);
|
||||
export const subscriber = useContextKey('subscriber', () => redisLib.subscriber);
|
||||
export const sequelize = useContextKey('sequelize', () => sequelizeLib.sequelize);
|
||||
export { db };
|
||||
const init = () => {
|
||||
return new App({
|
||||
@@ -43,4 +44,26 @@ export const ai = useContextKey('ai', () => {
|
||||
});
|
||||
});
|
||||
|
||||
export { schema };
|
||||
export { schema };
|
||||
|
||||
export const bailian = createOpenAICompatible({
|
||||
baseURL: 'https://coding.dashscope.aliyuncs.com/v1',
|
||||
name: 'custom-bailian',
|
||||
apiKey: process.env.BAILIAN_CODE_API_KEY!,
|
||||
});
|
||||
|
||||
export const cnb = createOpenAICompatible({
|
||||
baseURL: 'https://api.cnb.cool/kevisual/kevisual/-/ai/',
|
||||
name: 'custom-cnb',
|
||||
apiKey: process.env.CNB_API_KEY!,
|
||||
});
|
||||
|
||||
export const models = {
|
||||
'doubao-ark-code-latest': 'doubao-ark-code-latest',
|
||||
'GLM-4.7': 'GLM-4.7',
|
||||
'MiniMax-M2.1': 'MiniMax-M2.1',
|
||||
'qwen3-coder-plus': 'qwen3-coder-plus',
|
||||
'hunyuan-a13b': 'hunyuan-a13b',
|
||||
'qwen-plus': 'qwen-plus',
|
||||
'auto': 'auto',
|
||||
}
|
||||
@@ -1,20 +1,109 @@
|
||||
import { app } from '@/app.ts'
|
||||
import { app, oss } from '@/app.ts'
|
||||
import { asr } from './modules/index.ts'
|
||||
import z from 'zod'
|
||||
import { baseURL } from '@/modules/domain.ts'
|
||||
import { getObjectByPathname } from '@/modules/fm-manager/index.ts'
|
||||
export const createAsr = async (opts: { base64Data: string }) => {
|
||||
const { base64Data } = opts
|
||||
const result = await asr.getText({
|
||||
audio: {
|
||||
data: base64Data,
|
||||
format: 'wav' as any,
|
||||
rate: 16000,
|
||||
channel: 1
|
||||
},
|
||||
request: {
|
||||
enable_words: true,
|
||||
enable_sentence_info: true,
|
||||
enable_utterance_info: true,
|
||||
enable_punctuation_prediction: true,
|
||||
enable_inverse_text_normalization: true
|
||||
}
|
||||
})
|
||||
return {
|
||||
text: result.result?.text || '',
|
||||
result
|
||||
};
|
||||
}
|
||||
app.route({
|
||||
path: 'asr',
|
||||
key: 'text',
|
||||
middleware: ['auth'],
|
||||
description: '语音转文字,将base64的音频数据转换为文字, 参数: base64Audio, 为base64编码的音频数据',
|
||||
}).define(async (ctx) => {
|
||||
const base64Audio = ctx.query.base64Audio as string
|
||||
if (!base64Audio) {
|
||||
ctx.throw('Missing base64Audio parameter')
|
||||
}
|
||||
const result = await asr.getText({
|
||||
audio: {
|
||||
data: base64Audio
|
||||
description: '语音转文字,将base64的音频数据转换为文字, 参数: base64Data 为base64编码的音频数据',
|
||||
metadata: {
|
||||
args: {
|
||||
base64Data: z.string().describe('base64编码的音频数据').nonempty('base64Data参数不能为空'),
|
||||
}
|
||||
})
|
||||
ctx.body = result
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const base64Data = ctx.query.base64Data as string
|
||||
if (!base64Data) {
|
||||
ctx.throw(400, 'base64Data参数不能为空')
|
||||
}
|
||||
const result = await createAsr({ base64Data })
|
||||
ctx.body = {
|
||||
text: result.text
|
||||
}
|
||||
})
|
||||
.addTo(app)
|
||||
.addTo(app)
|
||||
|
||||
app.route({
|
||||
path: 'asr',
|
||||
key: 'link',
|
||||
middleware: ['auth'],
|
||||
description: '语音转文字,将音频链接的音频数据转换为文字, 参数: url 为音频链接',
|
||||
metadata: {
|
||||
args: {
|
||||
url: z.string().describe('音频链接').nonempty('url参数不能为空'),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const url = ctx.query.url as string
|
||||
if (!url) {
|
||||
ctx.throw(400, 'url参数不能为空')
|
||||
}
|
||||
let base64Data: string = null;
|
||||
if (url.startsWith(baseURL) || url.startsWith('/')) {
|
||||
const pathname = new URL(url, baseURL).pathname;
|
||||
const [username] = pathname.split('/').filter(Boolean)
|
||||
if (username !== tokenUser.username) {
|
||||
ctx.throw(403, '没有权限访问该音频链接')
|
||||
}
|
||||
let data: Awaited<ReturnType<typeof oss.getObject>>;
|
||||
try {
|
||||
console.log('fetch audio from minio with objectName', pathname.slice(1))
|
||||
const objectName = getObjectByPathname({ pathname })
|
||||
data = await oss.getObject(objectName.objectName)
|
||||
} catch (e: any) {
|
||||
if (e?.name === 'NoSuchKey' || e?.$metadata?.httpStatusCode === 404) {
|
||||
ctx.throw(404, '音频文件不存在')
|
||||
}
|
||||
}
|
||||
if (!data.Body) {
|
||||
ctx.throw(404, '音频文件内容为空')
|
||||
}
|
||||
const bytes = await data.Body.transformToByteArray()
|
||||
base64Data = Buffer.from(bytes).toString('base64')
|
||||
} else if (url.startsWith('http')) {
|
||||
base64Data = await fetchAudioAsBase64(url)
|
||||
} else {
|
||||
ctx.throw(400, 'url参数必须是有效的链接')
|
||||
}
|
||||
// 这里需要将音频链接转换为base64数据,可以使用fetch获取音频数据并转换为base64
|
||||
const result = await createAsr({ base64Data })
|
||||
ctx.body = {
|
||||
text: result.text
|
||||
}
|
||||
})
|
||||
.addTo(app)
|
||||
|
||||
const fetchAudioAsBase64 = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch audio from URL: ${response.statusText}`);
|
||||
}
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const base64String = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||
return base64String;
|
||||
};
|
||||
@@ -4,4 +4,5 @@ import { auraConfig } from '../../config.ts'
|
||||
export const asr = new Asr({
|
||||
appid: auraConfig.VOLCENGINE_AUC_APPID,
|
||||
token: auraConfig.VOLCENGINE_AUC_TOKEN,
|
||||
type: 'flash'
|
||||
})
|
||||
@@ -1,10 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { users } from './user.ts';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
const db = drizzle(process.env.DATABASE_URL!);
|
||||
|
||||
const one = await db.select().from(users).limit(1);
|
||||
|
||||
console.log(one);
|
||||
@@ -1,42 +0,0 @@
|
||||
import { pgTable, serial, text, varchar, uuid, boolean, jsonb, timestamp } from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel, InferInsertModel } from "drizzle-orm";
|
||||
|
||||
export const users = pgTable('cf_user', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
username: text('username').notNull().unique(),
|
||||
nickname: text('nickname'),
|
||||
password: text('password'),
|
||||
email: text('email'),
|
||||
avatar: text('avatar'),
|
||||
salt: text('salt'),
|
||||
description: text('description'),
|
||||
type: text('type').notNull().default('user'),
|
||||
owner: uuid('owner'),
|
||||
orgId: uuid('orgId'),
|
||||
needChangePassword: boolean('needChangePassword').notNull().default(false),
|
||||
data: jsonb('data').notNull().default({}),
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
||||
deletedAt: timestamp('deletedAt'),
|
||||
});
|
||||
|
||||
// 类型推断
|
||||
export type User = InferSelectModel<typeof users>;
|
||||
export type NewUser = InferInsertModel<typeof users>;
|
||||
|
||||
// 用户数据类型
|
||||
export type UserData = {
|
||||
orgs?: string[];
|
||||
wxUnionId?: string;
|
||||
phone?: string;
|
||||
};
|
||||
|
||||
// 用户类型枚举
|
||||
export enum UserTypes {
|
||||
user = 'user',
|
||||
org = 'org',
|
||||
visitor = 'visitor',
|
||||
}
|
||||
// export class User {
|
||||
|
||||
// }
|
||||
@@ -1,3 +1,3 @@
|
||||
export { User, UserInit, UserServices, UserModel } from './user.ts';
|
||||
export { UserSecretInit, UserSecret } from './user-secret.ts';
|
||||
export { OrgInit, Org } from './org.ts';
|
||||
export { User, UserServices, UserModel, initializeUser, createDemoUser } from './user.ts';
|
||||
export { UserSecret, UserSecretModel } from './user-secret.ts';
|
||||
export { Org, OrgModel, OrgRole } from './org.ts';
|
||||
130
src/auth/models/jwks-manager.ts
Normal file
130
src/auth/models/jwks-manager.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { signJWT, decodeJWT, type JWTPayload, verifyJWT } from '@kevisual/auth'
|
||||
import { generate } from '@kevisual/auth'
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
export const getPath = async (dir: string) => {
|
||||
const JWKS_PATH = path.join(dir, 'jwks.json');
|
||||
const PRIVATE_JWK_PATH = path.join(dir, 'privateKey.json');
|
||||
const PRIVATE_KEY_PATH = path.join(dir, 'privateKey.txt');
|
||||
const PUBLIC_KEY_PATH = path.join(dir, 'publicKey.txt');
|
||||
return {
|
||||
JWKS_PATH,
|
||||
PRIVATE_JWK_PATH,
|
||||
PRIVATE_KEY_PATH,
|
||||
PUBLIC_KEY_PATH,
|
||||
}
|
||||
}
|
||||
|
||||
export const jwksGenerate = async (opts: { dir: string }) => {
|
||||
const dir = path.isAbsolute(opts.dir) ? opts.dir : path.join(process.cwd(), opts.dir);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
const { JWKS_PATH, PRIVATE_JWK_PATH, PRIVATE_KEY_PATH, PUBLIC_KEY_PATH } = await getPath(dir);
|
||||
const { jwks, privateJWK, privatePEM, publicPEM } = await generate();
|
||||
fs.writeFileSync(PUBLIC_KEY_PATH, publicPEM);
|
||||
fs.writeFileSync(PRIVATE_KEY_PATH, privatePEM);
|
||||
fs.writeFileSync(PRIVATE_JWK_PATH, JSON.stringify(privateJWK, null, 2));
|
||||
fs.writeFileSync(JWKS_PATH, JSON.stringify(jwks, null, 2));
|
||||
console.log(`Keys 已保存到目录: ${dir}`);
|
||||
}
|
||||
|
||||
|
||||
interface JWKSPaths {
|
||||
JWKS_PATH: string
|
||||
PRIVATE_JWK_PATH: string
|
||||
PRIVATE_KEY_PATH: string
|
||||
PUBLIC_KEY_PATH: string
|
||||
}
|
||||
|
||||
interface JWKSContent {
|
||||
jwks: string
|
||||
privateJWK: string
|
||||
privateKey: string
|
||||
publicKey: string
|
||||
}
|
||||
|
||||
export class JWKSManager {
|
||||
private paths: JWKSPaths | null = null
|
||||
private content: JWKSContent | null = null
|
||||
|
||||
constructor(private basePath?: string) {
|
||||
this.basePath = basePath || path.join(process.cwd(), 'storage/jwks')
|
||||
}
|
||||
|
||||
async init() {
|
||||
// 确保目录存在
|
||||
if (!fs.existsSync(this.basePath!)) {
|
||||
fs.mkdirSync(this.basePath!, { recursive: true })
|
||||
}
|
||||
|
||||
// 获取所有路径
|
||||
this.paths = await getPath(this.basePath!)
|
||||
|
||||
// 如果 JWKS 文件不存在,则生成
|
||||
if (!fs.existsSync(this.paths.JWKS_PATH)) {
|
||||
await jwksGenerate({ dir: this.basePath! })
|
||||
console.log(`JWKS 创建成功,路径: ${this.paths.JWKS_PATH}`)
|
||||
}
|
||||
|
||||
// 加载所有内容到内存
|
||||
await this.loadContent()
|
||||
|
||||
return this
|
||||
}
|
||||
async checkInit() {
|
||||
if (!this.content) {
|
||||
await this.init()
|
||||
}
|
||||
}
|
||||
private async loadContent() {
|
||||
if (!this.paths) {
|
||||
await this.init()
|
||||
}
|
||||
|
||||
this.content = {
|
||||
jwks: fs.readFileSync(this.paths.JWKS_PATH, 'utf-8'),
|
||||
privateJWK: fs.readFileSync(this.paths.PRIVATE_JWK_PATH, 'utf-8'),
|
||||
privateKey: fs.readFileSync(this.paths.PRIVATE_KEY_PATH, 'utf-8'),
|
||||
publicKey: fs.readFileSync(this.paths.PUBLIC_KEY_PATH, 'utf-8')
|
||||
}
|
||||
}
|
||||
|
||||
async sign(payload: JWTPayload): Promise<string> {
|
||||
await this.checkInit()
|
||||
return signJWT(payload, this.content.privateKey)
|
||||
}
|
||||
async verify(token: string) {
|
||||
await this.checkInit()
|
||||
return verifyJWT(token, this.content.publicKey)
|
||||
}
|
||||
async decode(token: string) {
|
||||
await this.checkInit()
|
||||
return decodeJWT(token)
|
||||
}
|
||||
async getJWKS() {
|
||||
await this.checkInit()
|
||||
return JSON.parse(this.content.jwks)
|
||||
}
|
||||
|
||||
async getPrivateJWK() {
|
||||
await this.checkInit()
|
||||
return JSON.parse(this.content.privateJWK)
|
||||
}
|
||||
|
||||
async getPublicKey() {
|
||||
await this.checkInit()
|
||||
return this.content.publicKey
|
||||
}
|
||||
|
||||
async getPrivateKey() {
|
||||
await this.checkInit()
|
||||
return this.content.privateKey
|
||||
}
|
||||
|
||||
getPaths() {
|
||||
return this.paths
|
||||
}
|
||||
}
|
||||
|
||||
export const manager = new JWKSManager()
|
||||
@@ -1,20 +1,35 @@
|
||||
import { DataTypes, Model, Op, Sequelize } from 'sequelize';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { SyncOpts, User } from './user.ts';
|
||||
import { User } from './user.ts';
|
||||
import { db } from '../../modules/db.ts';
|
||||
import { cfOrgs, cfUser } from '../../db/drizzle/schema.ts';
|
||||
import { eq, inArray, sql, InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
||||
|
||||
const orgsTable = cfOrgs;
|
||||
const usersTable = cfUser;
|
||||
|
||||
type AddUserOpts = {
|
||||
role: string;
|
||||
};
|
||||
export enum OrgRole {
|
||||
admin = 'admin',
|
||||
member = 'member',
|
||||
owner = 'owner',
|
||||
}
|
||||
export class Org extends Model {
|
||||
declare id: string;
|
||||
declare username: string;
|
||||
declare description: string;
|
||||
declare users: { role: string; uid: string }[];
|
||||
|
||||
export type OrgUser = {
|
||||
role: string;
|
||||
uid: string;
|
||||
};
|
||||
|
||||
export type OrgSelect = InferSelectModel<typeof cfOrgs>;
|
||||
export type OrgInsert = InferInsertModel<typeof cfOrgs>;
|
||||
|
||||
export class Org {
|
||||
id: string;
|
||||
username: string;
|
||||
description: string;
|
||||
users: OrgUser[];
|
||||
|
||||
constructor(data: OrgSelect) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
/**
|
||||
* operateId 是真实操作者的id
|
||||
* @param user
|
||||
@@ -67,8 +82,8 @@ export class Org extends Model {
|
||||
} else {
|
||||
users.push({ role: opts?.role || 'member', uid: user.id });
|
||||
}
|
||||
await Org.update({ users }, { where: { id: this.id } });
|
||||
|
||||
await db.update(orgsTable).set({ users }).where(eq(orgsTable.id, this.id));
|
||||
this.users = users;
|
||||
}
|
||||
/**
|
||||
* operateId 是真实操作者的id
|
||||
@@ -89,7 +104,8 @@ export class Org extends Model {
|
||||
}
|
||||
await user.expireOrgs();
|
||||
const users = this.users.filter((u) => u.uid !== user.id || u.role === 'owner');
|
||||
await Org.update({ users }, { where: { id: this.id } });
|
||||
await db.update(orgsTable).set({ users }).where(eq(orgsTable.id, this.id));
|
||||
this.users = users;
|
||||
}
|
||||
/**
|
||||
* operateId 是真实操作者的id
|
||||
@@ -112,13 +128,7 @@ export class Org extends Model {
|
||||
}
|
||||
}
|
||||
}
|
||||
const _users = await User.findAll({
|
||||
where: {
|
||||
id: {
|
||||
[Op.in]: usersIds,
|
||||
},
|
||||
},
|
||||
});
|
||||
const _users = await db.select().from(usersTable).where(inArray(usersTable.id, usersIds));
|
||||
|
||||
const users = _users.map((u) => {
|
||||
const role = orgUser.find((r) => r.uid === u.id)?.role;
|
||||
@@ -139,46 +149,54 @@ export class Org extends Model {
|
||||
const user = this.users.find((u) => u.uid === userId && u.role === role);
|
||||
return !!user;
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 组织模型,在sequelize之后初始化
|
||||
*/
|
||||
export const OrgInit = async (newSequelize?: any, tableName?: string, sync?: SyncOpts) => {
|
||||
const sequelize = useContextKey<Sequelize>('sequelize');
|
||||
Org.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
users: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
defaultValue: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize: newSequelize || sequelize,
|
||||
modelName: tableName || 'cf_org',
|
||||
paranoid: true,
|
||||
},
|
||||
);
|
||||
if (sync) {
|
||||
await Org.sync({ alter: true, logging: false, ...sync }).catch((e) => {
|
||||
console.error('Org sync', e);
|
||||
});
|
||||
return Org;
|
||||
|
||||
/**
|
||||
* 根据主键查找
|
||||
*/
|
||||
static async findByPk(id: string): Promise<Org | null> {
|
||||
const orgs = await db.select().from(orgsTable).where(eq(orgsTable.id, id)).limit(1);
|
||||
return orgs.length > 0 ? new Org(orgs[0]) : null;
|
||||
}
|
||||
return Org;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据条件查找一个
|
||||
*/
|
||||
static async findOne(where: { username?: string; id?: string }): Promise<Org | null> {
|
||||
let query = db.select().from(orgsTable);
|
||||
|
||||
if (where.username) {
|
||||
query = query.where(eq(orgsTable.username, where.username)) as any;
|
||||
} else if (where.id) {
|
||||
query = query.where(eq(orgsTable.id, where.id)) as any;
|
||||
}
|
||||
|
||||
const orgs = await query.limit(1);
|
||||
return orgs.length > 0 ? new Org(orgs[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建组织
|
||||
*/
|
||||
static async create(data: { username: string; description?: string; users: OrgUser[] }): Promise<Org> {
|
||||
const insertData: any = {
|
||||
username: data.username,
|
||||
users: data.users,
|
||||
};
|
||||
|
||||
if (data.description !== undefined && data.description !== null) {
|
||||
insertData.description = data.description;
|
||||
}
|
||||
|
||||
const inserted = await db.insert(orgsTable).values(insertData).returning();
|
||||
return new Org(inserted[0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新组织
|
||||
*/
|
||||
static async update(data: Partial<OrgInsert>, where: { id: string }) {
|
||||
await db.update(orgsTable).set(data).where(eq(orgsTable.id, where.id));
|
||||
}
|
||||
}
|
||||
|
||||
export const OrgModel = useContextKey('OrgModel', () => Org);
|
||||
|
||||
@@ -1,10 +1,25 @@
|
||||
import { DataTypes, Model, Sequelize } from 'sequelize';
|
||||
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { Redis } from 'ioredis';
|
||||
import { SyncOpts, User } from './user.ts';
|
||||
import { oauth } from '../oauth/auth.ts';
|
||||
import { User } from './user.ts';
|
||||
import { oauth, jwksManager } from '../oauth/auth.ts';
|
||||
import { OauthUser } from '../oauth/oauth.ts';
|
||||
import { db } from '../../modules/db.ts';
|
||||
import { cfUserSecrets, cfUser } from '../../db/drizzle/schema.ts';
|
||||
import { eq, InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
||||
|
||||
const userSecretsTable = cfUserSecrets;
|
||||
const usersTable = cfUser;
|
||||
|
||||
export type UserSecretData = {
|
||||
[key: string]: any;
|
||||
wxOpenid?: string;
|
||||
wxUnionid?: string;
|
||||
wxmpOpenid?: string;
|
||||
};
|
||||
|
||||
export type UserSecretSelect = InferSelectModel<typeof cfUserSecrets>;
|
||||
export type UserSecretInsert = InferInsertModel<typeof cfUserSecrets>;
|
||||
|
||||
export const redis = useContextKey<Redis>('redis');
|
||||
|
||||
const UserSecretStatus = ['active', 'inactive', 'expired'] as const;
|
||||
@@ -16,39 +31,56 @@ const randomString = (length: number) => {
|
||||
}
|
||||
return result;
|
||||
};
|
||||
type Data = {
|
||||
[key: string]: any;
|
||||
/**
|
||||
* 微信开放平台的某一个应用的openid
|
||||
*/
|
||||
wxOpenid?: string;
|
||||
/**
|
||||
* 微信开放平台的unionid:主要
|
||||
*/
|
||||
wxUnionid?: string;
|
||||
/**
|
||||
* 微信公众号的openid:次要
|
||||
*/
|
||||
wxmpOpenid?: string;
|
||||
|
||||
}
|
||||
export class UserSecret extends Model {
|
||||
export class UserSecret {
|
||||
static oauth = oauth;
|
||||
declare id: string;
|
||||
declare token: string;
|
||||
declare userId: string;
|
||||
declare orgId: string;
|
||||
declare title: string;
|
||||
declare description: string;
|
||||
declare status: (typeof UserSecretStatus)[number];
|
||||
declare expiredTime: Date;
|
||||
declare data: Data;
|
||||
id: string;
|
||||
token: string;
|
||||
userId: string;
|
||||
orgId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: (typeof UserSecretStatus)[number];
|
||||
expiredTime: Date;
|
||||
data: UserSecretData;
|
||||
|
||||
constructor(data: UserSecretSelect) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
/**
|
||||
* 验证token
|
||||
* @param token
|
||||
* @returns
|
||||
*/
|
||||
static async verifyToken(token: string) {
|
||||
if (oauth.getTokenType(token) === 'jwks') {
|
||||
// 先尝试作为jwt token验证,如果验证成功则直接返回用户信息
|
||||
console.log('[jwksManager] 验证token');
|
||||
try {
|
||||
|
||||
const verified = await jwksManager.verify(token);
|
||||
if (verified) {
|
||||
const sub = verified.sub;
|
||||
const userId = sub.split(':')[1];
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
console.warn(`[jwksManager] 验证token成功,但用户不存在,userId: ${userId}`);
|
||||
return null;
|
||||
}
|
||||
const oauthUser = oauth.getOauthUser({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
type: user.type,
|
||||
});
|
||||
return oauthUser;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[jwksManager] 验证token失败', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!oauth.isSecretKey(token)) {
|
||||
return await oauth.verifyToken(token);
|
||||
}
|
||||
@@ -57,12 +89,13 @@ export class UserSecret extends Model {
|
||||
return secretToken;
|
||||
}
|
||||
console.log('verifyToken: try to verify as secret key');
|
||||
const userSecret = await UserSecret.findOne({
|
||||
where: { token },
|
||||
});
|
||||
if (!userSecret) {
|
||||
const userSecrets = await db.select().from(userSecretsTable).where(eq(userSecretsTable.token, token)).limit(1);
|
||||
|
||||
if (userSecrets.length === 0) {
|
||||
return null; // 如果没有找到对应的用户密钥,则返回null
|
||||
}
|
||||
|
||||
const userSecret = new UserSecret(userSecrets[0]);
|
||||
if (userSecret.isExpired()) {
|
||||
return null; // 如果用户密钥已过期,则返回null
|
||||
}
|
||||
@@ -78,19 +111,49 @@ export class UserSecret extends Model {
|
||||
// 存储到oauth中的token store中
|
||||
return oauthUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据主键查找
|
||||
*/
|
||||
static async findByPk(id: string): Promise<UserSecret | null> {
|
||||
const secrets = await db.select().from(userSecretsTable).where(eq(userSecretsTable.id, id)).limit(1);
|
||||
return secrets.length > 0 ? new UserSecret(secrets[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据条件查找一个
|
||||
*/
|
||||
static async findOne(where: { token?: string; id?: string }): Promise<UserSecret | null> {
|
||||
let query = db.select().from(userSecretsTable);
|
||||
|
||||
if (where.token) {
|
||||
query = query.where(eq(userSecretsTable.token, where.token)) as any;
|
||||
} else if (where.id) {
|
||||
query = query.where(eq(userSecretsTable.id, where.id)) as any;
|
||||
}
|
||||
|
||||
const secrets = await query.limit(1);
|
||||
return secrets.length > 0 ? new UserSecret(secrets[0]) : null;
|
||||
}
|
||||
/**
|
||||
* owner 组织用户的 oauthUser
|
||||
* @returns
|
||||
*/
|
||||
async getOauthUser(opts?: { wx?: boolean }) {
|
||||
const user = await User.findOne({
|
||||
where: { id: this.userId },
|
||||
attributes: ['id', 'username', 'type', 'owner', 'data'],
|
||||
});
|
||||
let org: User = null;
|
||||
if (!user) {
|
||||
const users = await db.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
type: usersTable.type,
|
||||
owner: usersTable.owner,
|
||||
data: usersTable.data,
|
||||
}).from(usersTable).where(eq(usersTable.id, this.userId)).limit(1);
|
||||
|
||||
let org: any = null;
|
||||
if (users.length === 0) {
|
||||
return null; // 如果没有找到对应的用户,则返回null
|
||||
}
|
||||
|
||||
const user = users[0];
|
||||
const expiredTime = this.expiredTime ? new Date(this.expiredTime).getTime() : null;
|
||||
const oauthUser: Partial<OauthUser> = {
|
||||
id: user.id,
|
||||
@@ -101,11 +164,15 @@ export class UserSecret extends Model {
|
||||
},
|
||||
};
|
||||
if (this.orgId) {
|
||||
org = await User.findOne({
|
||||
where: { id: this.orgId },
|
||||
attributes: ['id', 'username', 'type', 'owner'],
|
||||
});
|
||||
if (org) {
|
||||
const orgUsers = await db.select({
|
||||
id: usersTable.id,
|
||||
username: usersTable.username,
|
||||
type: usersTable.type,
|
||||
owner: usersTable.owner,
|
||||
}).from(usersTable).where(eq(usersTable.id, this.orgId)).limit(1);
|
||||
|
||||
if (orgUsers.length > 0) {
|
||||
org = orgUsers[0];
|
||||
oauthUser.id = org.id;
|
||||
oauthUser.username = org.username;
|
||||
oauthUser.type = 'org';
|
||||
@@ -125,6 +192,7 @@ export class UserSecret extends Model {
|
||||
const expiredTime = new Date(this.expiredTime);
|
||||
return now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否过期,如果过期则更新状态为expired
|
||||
*
|
||||
@@ -137,7 +205,6 @@ export class UserSecret extends Model {
|
||||
}
|
||||
}
|
||||
try {
|
||||
|
||||
const now = Date.now();
|
||||
const expiredTime = new Date(this.expiredTime);
|
||||
const isExpired = now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期
|
||||
@@ -145,11 +212,11 @@ export class UserSecret extends Model {
|
||||
this.status = 'active';
|
||||
const expireTime = UserSecret.getExpiredTime();
|
||||
this.expiredTime = expireTime;
|
||||
await this.save()
|
||||
await this.save();
|
||||
}
|
||||
if (this.status !== 'active') {
|
||||
this.status = 'active';
|
||||
await this.save()
|
||||
await this.save();
|
||||
}
|
||||
return {
|
||||
code: 200
|
||||
@@ -163,6 +230,20 @@ export class UserSecret extends Model {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async save() {
|
||||
await db.update(userSecretsTable).set({
|
||||
token: this.token,
|
||||
userId: this.userId,
|
||||
orgId: this.orgId,
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
status: this.status,
|
||||
expiredTime: this.expiredTime ? this.expiredTime.toISOString() : null,
|
||||
data: this.data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(userSecretsTable.id, this.id));
|
||||
}
|
||||
async createNewToken() {
|
||||
if (this.token) {
|
||||
await oauth.delToken(this.token);
|
||||
@@ -172,14 +253,16 @@ export class UserSecret extends Model {
|
||||
await this.save();
|
||||
return token;
|
||||
}
|
||||
|
||||
static async createToken() {
|
||||
let token = oauth.generateSecretKey();
|
||||
// 确保生成的token是唯一的
|
||||
while (await UserSecret.findOne({ where: { token } })) {
|
||||
while (await UserSecret.findOne({ token })) {
|
||||
token = oauth.generateSecretKey();
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 unionid 生成redis的key
|
||||
* `wxmp:unionid:token:${unionid}`
|
||||
@@ -189,28 +272,36 @@ export class UserSecret extends Model {
|
||||
static wxRedisKey(unionid: string) {
|
||||
return `wxmp:unionid:token:${unionid}`;
|
||||
}
|
||||
|
||||
static getExpiredTime(expireDays?: number) {
|
||||
const defaultExpireDays = expireDays || 365;
|
||||
const expireTime = defaultExpireDays * 24 * 60 * 60 * 1000;
|
||||
return new Date(Date.now() + expireTime)
|
||||
return new Date(Date.now() + expireTime);
|
||||
}
|
||||
|
||||
static async createSecret(tokenUser: { id: string; uid?: string, title?: string }, expireDays = 365) {
|
||||
const token = await UserSecret.createToken();
|
||||
let userId = tokenUser.id;
|
||||
let orgId: string = null;
|
||||
let orgId: string | null = null;
|
||||
if (tokenUser.uid) {
|
||||
userId = tokenUser.uid;
|
||||
orgId = tokenUser.id; // 如果是组织用户,则uid是组织ID
|
||||
orgId = tokenUser.id;
|
||||
}
|
||||
const userSecret = await UserSecret.create({
|
||||
|
||||
const insertData: Partial<typeof userSecretsTable.$inferInsert> = {
|
||||
userId,
|
||||
orgId,
|
||||
token,
|
||||
title: tokenUser.title || randomString(6),
|
||||
expiredTime: UserSecret.getExpiredTime(expireDays),
|
||||
});
|
||||
expiredTime: UserSecret.getExpiredTime(expireDays).toISOString(),
|
||||
};
|
||||
|
||||
return userSecret;
|
||||
if (orgId !== null && orgId !== undefined) {
|
||||
insertData.orgId = orgId;
|
||||
}
|
||||
|
||||
const inserted = await db.insert(userSecretsTable).values(insertData).returning();
|
||||
|
||||
return new UserSecret(inserted[0]);
|
||||
}
|
||||
|
||||
async getPermission(opts: { id: string; uid?: string }) {
|
||||
@@ -242,8 +333,8 @@ export class UserSecret extends Model {
|
||||
};
|
||||
}
|
||||
if (this.orgId) {
|
||||
const orgUser = await User.findByPk(this.orgId);
|
||||
if (orgUser && orgUser.owner === userId) {
|
||||
const orgUsers = await db.select().from(usersTable).where(eq(usersTable.id, this.orgId)).limit(1);
|
||||
if (orgUsers.length > 0 && orgUsers[0].owner === userId) {
|
||||
isAdmin = true;
|
||||
hasPermission = true;
|
||||
}
|
||||
@@ -255,68 +346,5 @@ export class UserSecret extends Model {
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 组织模型,在sequelize之后初始化
|
||||
*/
|
||||
export const UserSecretInit = async (newSequelize?: any, tableName?: string, sync?: SyncOpts) => {
|
||||
const sequelize = useContextKey<Sequelize>('sequelize');
|
||||
UserSecret.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
defaultValue: 'active',
|
||||
comment: '状态',
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
expiredTime: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
},
|
||||
token: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: '用户密钥',
|
||||
defaultValue: '',
|
||||
},
|
||||
userId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
},
|
||||
data: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
defaultValue: {},
|
||||
},
|
||||
orgId: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
comment: '组织ID',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize: newSequelize || sequelize,
|
||||
modelName: tableName || 'cf_user_secret',
|
||||
},
|
||||
);
|
||||
if (sync) {
|
||||
await UserSecret.sync({ alter: true, logging: false, ...sync }).catch((e) => {
|
||||
console.error('UserSecret sync', e);
|
||||
});
|
||||
return UserSecret;
|
||||
}
|
||||
return UserSecret;
|
||||
};
|
||||
|
||||
export const UserSecretModel = useContextKey('UserSecretModel', () => UserSecret);
|
||||
|
||||
@@ -1,45 +1,79 @@
|
||||
import { DataTypes, Model, Op, Sequelize } from 'sequelize';
|
||||
import { nanoid, customAlphabet } from 'nanoid';
|
||||
import { CustomError } from '@kevisual/router';
|
||||
import { Org } from './org.ts';
|
||||
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { Redis } from 'ioredis';
|
||||
import { oauth } from '../oauth/auth.ts';
|
||||
import { oauth, jwksManager } from '../oauth/auth.ts';
|
||||
import { cryptPwd } from '../oauth/salt.ts';
|
||||
import { OauthUser } from '../oauth/oauth.ts';
|
||||
export const redis = useContextKey<Redis>('redis');
|
||||
import { db } from '../../modules/db.ts';
|
||||
import { Org } from './org.ts';
|
||||
import { UserSecret } from './user-secret.ts';
|
||||
type UserData = {
|
||||
import { cfUser, cfOrgs, cfUserSecrets } from '../../db/drizzle/schema.ts';
|
||||
import { eq, sql, InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
||||
|
||||
// 类型定义
|
||||
export type UserData = {
|
||||
orgs?: string[];
|
||||
wxUnionId?: string;
|
||||
phone?: string;
|
||||
canChangeUsername?: boolean;
|
||||
cnbId?: string;
|
||||
};
|
||||
|
||||
export enum UserTypes {
|
||||
'user' = 'user',
|
||||
'org' = 'org',
|
||||
'visitor' = 'visitor',
|
||||
user = 'user',
|
||||
org = 'org',
|
||||
visitor = 'visitor',
|
||||
}
|
||||
|
||||
export type UserSelect = InferSelectModel<typeof cfUser>;
|
||||
export type UserInsert = InferInsertModel<typeof cfUser>;
|
||||
export type OrgSelect = InferSelectModel<typeof cfOrgs>;
|
||||
|
||||
const usersTable = cfUser;
|
||||
const orgsTable = cfOrgs;
|
||||
const userSecretsTable = cfUserSecrets;
|
||||
|
||||
// 常量定义
|
||||
const JWKS_TOKEN_EXPIRY = 2 * 3600; // 2 hours in seconds
|
||||
|
||||
export const redis = useContextKey<Redis>('redis');
|
||||
|
||||
type TokenOptions = {
|
||||
expire?: number; // 过期时间,单位秒
|
||||
ip?: string; // 用户ID,默认为当前用户ID
|
||||
browser?: string; // 浏览器信息
|
||||
host?: string; // 主机信息
|
||||
wx?: any;
|
||||
loginWith?: string; // 登录方式,如 'cli', 'web', 'plugin' 等
|
||||
hasRefreshToken?: boolean; // 是否需要 refresh token,默认为 false
|
||||
}
|
||||
/**
|
||||
* 用户模型,在sequelize和Org之后初始化
|
||||
* 用户模型,使用 Drizzle ORM
|
||||
*/
|
||||
export class User extends Model {
|
||||
export class User {
|
||||
static oauth = oauth;
|
||||
declare id: string;
|
||||
declare username: string;
|
||||
declare nickname: string; // 昵称
|
||||
declare password: string;
|
||||
declare salt: string;
|
||||
declare needChangePassword: boolean;
|
||||
declare description: string;
|
||||
declare data: UserData;
|
||||
declare type: string; // user | org | visitor
|
||||
declare owner: string;
|
||||
declare orgId: string;
|
||||
declare email: string;
|
||||
declare avatar: string;
|
||||
id: string;
|
||||
username: string;
|
||||
nickname: string;
|
||||
password: string;
|
||||
salt: string;
|
||||
needChangePassword: boolean;
|
||||
description: string;
|
||||
data: UserData;
|
||||
type: string;
|
||||
owner: string;
|
||||
orgId: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
tokenUser: any;
|
||||
|
||||
constructor(data?: UserSelect) {
|
||||
if (data) {
|
||||
Object.assign(this, data);
|
||||
}
|
||||
}
|
||||
|
||||
setTokenUser(tokenUser: any) {
|
||||
this.tokenUser = tokenUser;
|
||||
}
|
||||
@@ -49,8 +83,38 @@ export class User extends Model {
|
||||
* @param uid
|
||||
* @returns
|
||||
*/
|
||||
async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year' | 'week', expand: any = {}) {
|
||||
/**
|
||||
* 创建JWKS token的通用方法
|
||||
*/
|
||||
static async createJwksTokenResponse(user: { id: string; username: string }, opts: { expire?: number, hasRefreshToken?: boolean } = {}) {
|
||||
const expiresIn = opts?.expire ?? JWKS_TOKEN_EXPIRY;
|
||||
const hasRefreshToken = opts?.hasRefreshToken ?? true;
|
||||
const accessToken = await jwksManager.sign({
|
||||
sub: 'user:' + user.id,
|
||||
name: user.username,
|
||||
exp: Math.floor(Date.now() / 1000) + expiresIn,
|
||||
});
|
||||
if (hasRefreshToken) {
|
||||
await oauth.setJwksToken(accessToken, { id: user.id, expire: expiresIn });
|
||||
}
|
||||
|
||||
const token = {
|
||||
accessToken,
|
||||
refreshToken: accessToken,
|
||||
token: accessToken,
|
||||
refreshTokenExpiresIn: expiresIn,
|
||||
accessTokenExpiresIn: expiresIn,
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'jwks',
|
||||
...token,
|
||||
};
|
||||
}
|
||||
|
||||
async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year' | 'week' | 'jwks', opts: TokenOptions = {}) {
|
||||
const { id, username, type } = this;
|
||||
const hasRefreshToken = opts.hasRefreshToken ?? true;
|
||||
const oauthUser: OauthUser = {
|
||||
id,
|
||||
username,
|
||||
@@ -61,13 +125,13 @@ export class User extends Model {
|
||||
if (uid) {
|
||||
oauthUser.orgId = id;
|
||||
}
|
||||
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand });
|
||||
if (loginType === 'jwks') {
|
||||
return await User.createJwksTokenResponse(this, opts);
|
||||
}
|
||||
const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken, ...opts });
|
||||
return {
|
||||
accessToken: token.accessToken,
|
||||
refreshToken: token.refreshToken,
|
||||
token: token.accessToken,
|
||||
refreshTokenExpiresIn: token.refreshTokenExpiresIn,
|
||||
accessTokenExpiresIn: token.accessTokenExpiresIn,
|
||||
type: 'default',
|
||||
...token,
|
||||
};
|
||||
}
|
||||
/**
|
||||
@@ -78,14 +142,94 @@ export class User extends Model {
|
||||
static async verifyToken(token: string) {
|
||||
return await UserSecret.verifyToken(token);
|
||||
}
|
||||
static async checkJwksValid(token: string) {
|
||||
const verified = await User.verifyToken(token);
|
||||
let isValid = false;
|
||||
if (verified) {
|
||||
isValid = true;
|
||||
}
|
||||
const jwksToken = await oauth.getJwksToken(token);
|
||||
if (!isValid && !jwksToken) {
|
||||
throw new CustomError('Invalid refresh token');
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 刷新token
|
||||
* @param refreshToken
|
||||
* @returns
|
||||
*/
|
||||
static async refreshToken(refreshToken: string) {
|
||||
static async refreshToken(opts: { refreshToken?: string, accessToken?: string }) {
|
||||
const { refreshToken, accessToken } = opts;
|
||||
let jwsRefreshToken = accessToken || refreshToken;
|
||||
if (oauth.getTokenType(jwsRefreshToken) === 'jwks') {
|
||||
// 可能是 jwks token
|
||||
await User.checkJwksValid(jwsRefreshToken);
|
||||
const decoded = await jwksManager.decode(jwsRefreshToken);
|
||||
return await User.createJwksTokenResponse({
|
||||
id: decoded.sub.replace('user:', ''),
|
||||
username: decoded.name
|
||||
});
|
||||
}
|
||||
if (!refreshToken && !accessToken) {
|
||||
throw new CustomError('Refresh Token or Access Token 必须提供一个');
|
||||
}
|
||||
if (accessToken) {
|
||||
try {
|
||||
const token = await User.refreshTokenByAccessToken(accessToken);
|
||||
return token;
|
||||
} catch (e) {
|
||||
// access token 无效,继续使用 refresh token 刷新
|
||||
}
|
||||
}
|
||||
const token = await User.refreshTokenByRefreshToken(refreshToken);
|
||||
return {
|
||||
type: 'default',
|
||||
...token,
|
||||
};
|
||||
}
|
||||
static async refreshTokenByAccessToken(accessToken: string) {
|
||||
const accessUser = await User.verifyToken(accessToken);
|
||||
if (!accessUser) {
|
||||
throw new CustomError('Invalid access token');
|
||||
}
|
||||
const refreshToken = accessUser.oauthExpand?.refreshToken;
|
||||
if (refreshToken) {
|
||||
return await User.refreshTokenByRefreshToken(refreshToken);
|
||||
} else {
|
||||
await User.oauth.delToken(accessToken);
|
||||
const token = await User.oauth.generateToken(accessUser, {
|
||||
...accessUser.oauthExpand,
|
||||
hasRefreshToken: true,
|
||||
});
|
||||
return {
|
||||
type: 'default',
|
||||
...token,
|
||||
};
|
||||
}
|
||||
}
|
||||
static async refreshTokenByRefreshToken(refreshToken: string) {
|
||||
const token = await oauth.refreshToken(refreshToken);
|
||||
return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken };
|
||||
return {
|
||||
type: 'default',
|
||||
...token
|
||||
};
|
||||
}
|
||||
/**
|
||||
* 重置token,立即过期token
|
||||
* @param token
|
||||
* @returns
|
||||
*/
|
||||
static async resetToken(refreshToken: string, expand?: Record<string, any>) {
|
||||
if (oauth.getTokenType(refreshToken) === 'jwks') {
|
||||
// 可能是 jwks token
|
||||
await User.checkJwksValid(refreshToken);
|
||||
const decoded = await jwksManager.decode(refreshToken);
|
||||
return await User.createJwksTokenResponse({
|
||||
id: decoded.sub.replace('user:', ''),
|
||||
username: decoded.name
|
||||
});
|
||||
}
|
||||
return await oauth.resetToken(refreshToken, expand);
|
||||
}
|
||||
static async getOauthUser(token: string) {
|
||||
return await UserSecret.verifyToken(token);
|
||||
@@ -111,6 +255,9 @@ export class User extends Model {
|
||||
}
|
||||
const userId = oauthUser?.uid || oauthUser.id;
|
||||
const user = await User.findByPk(userId);
|
||||
if (!user) {
|
||||
throw new CustomError('User not found');
|
||||
}
|
||||
user.setTokenUser(oauthUser);
|
||||
return user;
|
||||
}
|
||||
@@ -130,8 +277,63 @@ export class User extends Model {
|
||||
}
|
||||
return allUsers.includes(username);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据主键查找用户
|
||||
*/
|
||||
static async findByPk(id: string): Promise<User | null> {
|
||||
const users = await db.select().from(usersTable).where(eq(usersTable.id, id)).limit(1);
|
||||
return users.length > 0 ? new User(users[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据微信 UnionId 查找用户
|
||||
*/
|
||||
static async findByUnionId(unionId: string): Promise<User | null> {
|
||||
const users = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.data}->>'wxUnionId' = ${unionId}`)
|
||||
.limit(1);
|
||||
return users.length > 0 ? new User(users[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 CNB ID 查找用户
|
||||
* @param cnbId
|
||||
* @returns
|
||||
*/
|
||||
static async findByCnbId(cnbId: string): Promise<User | null> {
|
||||
const users = await db
|
||||
.select()
|
||||
.from(usersTable)
|
||||
.where(sql`${usersTable.data}->>'cnbId' = ${cnbId}`)
|
||||
.limit(1);
|
||||
return users.length > 0 ? new User(users[0]) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据条件查找一个用户
|
||||
*/
|
||||
static async findOne(where: { username?: string; id?: string; email?: string }): Promise<User | null> {
|
||||
let query = db.select().from(usersTable);
|
||||
|
||||
if (where.username) {
|
||||
query = query.where(eq(usersTable.username, where.username)) as any;
|
||||
} else if (where.id) {
|
||||
query = query.where(eq(usersTable.id, where.id)) as any;
|
||||
} else if (where.email) {
|
||||
query = query.where(eq(usersTable.email, where.email)) as any;
|
||||
}
|
||||
|
||||
const users = await query.limit(1);
|
||||
return users.length > 0 ? new User(users[0]) : null;
|
||||
}
|
||||
/**
|
||||
* 创建新用户
|
||||
*/
|
||||
static async createUser(username: string, password?: string, description?: string) {
|
||||
const user = await User.findOne({ where: { username } });
|
||||
const user = await User.findOne({ username });
|
||||
if (user) {
|
||||
throw new CustomError('User already exists');
|
||||
}
|
||||
@@ -139,10 +341,33 @@ export class User extends Model {
|
||||
let needChangePassword = !password;
|
||||
password = password || '123456';
|
||||
const cPassword = cryptPwd(password, salt);
|
||||
return await User.create({ username, password: cPassword, description, salt, needChangePassword });
|
||||
|
||||
const insertData: any = {
|
||||
username,
|
||||
password: cPassword,
|
||||
salt,
|
||||
};
|
||||
|
||||
// 只在需要时才设置非默认值
|
||||
if (needChangePassword) {
|
||||
insertData.needChangePassword = true;
|
||||
}
|
||||
|
||||
if (description !== undefined && description !== null) {
|
||||
insertData.description = description;
|
||||
}
|
||||
try {
|
||||
const inserted = await db.insert(usersTable).values(insertData).returning();
|
||||
return new User(inserted[0]);
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
throw e
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static async createOrg(username: string, owner: string, description?: string) {
|
||||
const user = await User.findOne({ where: { username } });
|
||||
const user = await User.findOne({ username });
|
||||
if (user) {
|
||||
throw new CustomError('User already exists');
|
||||
}
|
||||
@@ -153,24 +378,69 @@ export class User extends Model {
|
||||
if (me.type !== 'user') {
|
||||
throw new CustomError('Owner type is not user');
|
||||
}
|
||||
|
||||
const org = await Org.create({ username, description, users: [{ uid: owner, role: 'owner' }] });
|
||||
const newUser = await User.create({ username, password: '', description, type: 'org', owner, orgId: org.id });
|
||||
|
||||
const insertData: any = {
|
||||
username,
|
||||
password: '',
|
||||
type: 'org',
|
||||
owner,
|
||||
orgId: org.id,
|
||||
};
|
||||
|
||||
if (description !== undefined && description !== null) {
|
||||
insertData.description = description;
|
||||
}
|
||||
|
||||
const inserted = await db.insert(usersTable).values(insertData).returning();
|
||||
|
||||
// owner add
|
||||
await redis.del(`user:${me.id}:orgs`);
|
||||
return newUser;
|
||||
return new User(inserted[0]);
|
||||
}
|
||||
async createPassword(password: string) {
|
||||
const salt = this.salt;
|
||||
const cPassword = cryptPwd(password, salt);
|
||||
this.password = cPassword;
|
||||
await this.update({ password: cPassword });
|
||||
await db.update(usersTable).set({ password: cPassword }).where(eq(usersTable.id, this.id));
|
||||
return cPassword;
|
||||
}
|
||||
|
||||
checkPassword(password: string) {
|
||||
const salt = this.salt;
|
||||
const cPassword = cryptPwd(password, salt);
|
||||
return this.password === cPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户
|
||||
*/
|
||||
async update(data: Partial<UserInsert>) {
|
||||
await db.update(usersTable).set(data).where(eq(usersTable.id, this.id));
|
||||
Object.assign(this, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户
|
||||
*/
|
||||
async save() {
|
||||
await db.update(usersTable).set({
|
||||
username: this.username,
|
||||
nickname: this.nickname,
|
||||
password: this.password,
|
||||
email: this.email,
|
||||
avatar: this.avatar,
|
||||
salt: this.salt,
|
||||
description: this.description,
|
||||
type: this.type,
|
||||
owner: this.owner,
|
||||
orgId: this.orgId,
|
||||
needChangePassword: this.needChangePassword,
|
||||
data: this.data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).where(eq(usersTable.id, this.id));
|
||||
}
|
||||
/**
|
||||
* 获取用户信息, 需要先设置 tokenUser 或者设置 uid
|
||||
* @param uid 如果存在,则表示是组织,其中uid为真实用户
|
||||
@@ -210,25 +480,21 @@ export class User extends Model {
|
||||
if (this.tokenUser && this.tokenUser.uid) {
|
||||
id = this.tokenUser.uid;
|
||||
} else {
|
||||
throw new CustomError(400, 'Permission denied');
|
||||
throw new CustomError('Permission denied', { code: 400 });
|
||||
}
|
||||
}
|
||||
const cache = await redis.get(`user:${id}:orgs`);
|
||||
if (cache) {
|
||||
return JSON.parse(cache) as string[];
|
||||
}
|
||||
const orgs = await Org.findAll({
|
||||
order: [['updatedAt', 'DESC']],
|
||||
where: {
|
||||
users: {
|
||||
[Op.contains]: [
|
||||
{
|
||||
uid: id,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 使用 Drizzle 的 SQL 查询来检查 JSONB 数组
|
||||
const orgs = await db
|
||||
.select()
|
||||
.from(orgsTable)
|
||||
.where(sql`${orgsTable.users} @> ${JSON.stringify([{ uid: id }])}::jsonb`)
|
||||
.orderBy(sql`${orgsTable.updatedAt} DESC`);
|
||||
|
||||
const orgNames = orgs.map((org) => org.username);
|
||||
if (orgNames.length > 0) {
|
||||
await redis.set(`user:${id}:orgs`, JSON.stringify(orgNames), 'EX', 60 * 60); // 1 hour
|
||||
@@ -249,93 +515,27 @@ export class User extends Model {
|
||||
}
|
||||
return user?.username;
|
||||
}
|
||||
}
|
||||
export type SyncOpts = {
|
||||
alter?: boolean;
|
||||
logging?: any;
|
||||
force?: boolean;
|
||||
};
|
||||
export const UserInit = async (newSequelize?: any, tableName?: string, sync?: SyncOpts) => {
|
||||
const sequelize = useContextKey<Sequelize>('sequelize');
|
||||
User.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
// 用户名或者手机号
|
||||
// 创建后避免修改的字段,当注册用户后,用户名注册则默认不能用手机号
|
||||
},
|
||||
nickname: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
|
||||
password: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
avatar: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
salt: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
},
|
||||
type: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'user',
|
||||
},
|
||||
owner: {
|
||||
type: DataTypes.UUID,
|
||||
},
|
||||
orgId: {
|
||||
type: DataTypes.UUID,
|
||||
},
|
||||
needChangePassword: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
},
|
||||
data: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {},
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize: newSequelize || sequelize,
|
||||
tableName: tableName || 'cf_user', // codeflow user
|
||||
paranoid: true,
|
||||
},
|
||||
);
|
||||
if (sync) {
|
||||
await User.sync({ alter: true, logging: true, ...sync })
|
||||
.then((res) => {
|
||||
initializeUser();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Sync User error', err);
|
||||
});
|
||||
return User;
|
||||
/**
|
||||
* 查找所有符合条件的用户
|
||||
*/
|
||||
static async findAll(options: { where?: any; attributes?: string[] }) {
|
||||
let query = db.select().from(usersTable);
|
||||
|
||||
if (options.where?.id?.in) {
|
||||
query = query.where(sql`${usersTable.id} = ANY(${options.where.id.in})`) as any;
|
||||
}
|
||||
|
||||
const users = await query;
|
||||
return users.map(u => new User(u));
|
||||
}
|
||||
return User;
|
||||
};
|
||||
}
|
||||
|
||||
const letter = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const custom = customAlphabet(letter, 6);
|
||||
|
||||
export const initializeUser = async (pwd = custom()) => {
|
||||
const w = await User.findOne({ where: { username: 'root' }, logging: false });
|
||||
const w = await User.findOne({ username: 'root' });
|
||||
if (!w) {
|
||||
const root = await User.createUser('root', pwd, '系统管理员');
|
||||
const org = await User.createOrg('admin', root.id, '管理员');
|
||||
@@ -354,8 +554,9 @@ export const initializeUser = async (pwd = custom()) => {
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const createDemoUser = async (username = 'demo', pwd = custom()) => {
|
||||
const u = await User.findOne({ where: { username }, logging: false });
|
||||
const u = await User.findOne({ username });
|
||||
if (!u) {
|
||||
const user = await User.createUser(username, pwd, 'demo');
|
||||
console.info('new Users name', user.username, pwd);
|
||||
@@ -371,11 +572,10 @@ export const createDemoUser = async (username = 'demo', pwd = custom()) => {
|
||||
};
|
||||
}
|
||||
};
|
||||
// initializeUser();
|
||||
|
||||
export class UserServices extends User {
|
||||
static async loginByPhone(phone: string) {
|
||||
let user = await User.findOne({ where: { username: phone } });
|
||||
let user = await User.findOne({ username: phone });
|
||||
let isNew = false;
|
||||
if (!user) {
|
||||
user = await User.createUser(phone, phone.slice(-6));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { OAuth, RedisTokenStore } from './oauth.ts';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { Redis } from 'ioredis';
|
||||
import { manager } from '../models/jwks-manager.ts';
|
||||
|
||||
export const oauth = useContextKey('oauth', () => {
|
||||
const redis = useContextKey<Redis>('redis');
|
||||
@@ -16,3 +17,7 @@ export const oauth = useContextKey('oauth', () => {
|
||||
const oauth = new OAuth(store);
|
||||
return oauth;
|
||||
});
|
||||
|
||||
export const jwksManager = useContextKey('jwksManager', () => manager);
|
||||
|
||||
await manager.init()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './oauth.ts';
|
||||
export * from './salt.ts';
|
||||
export * from './salt.ts';
|
||||
export * from './auth.ts';
|
||||
@@ -61,6 +61,8 @@ type StoreSetOpts = {
|
||||
loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year' | 'week' | 'day'; // 登陆类型 'default' | 'plugin' | 'month' | 'season' | 'year'
|
||||
expire?: number; // 过期时间,单位为秒
|
||||
hasRefreshToken?: boolean;
|
||||
// refreshToken的过期时间比accessToken多多少天,默认是1天
|
||||
expireDay?: number;
|
||||
[key: string]: any;
|
||||
};
|
||||
interface Store<T> {
|
||||
@@ -70,11 +72,11 @@ interface Store<T> {
|
||||
expire: (key: string, ttl?: number) => Promise<void>;
|
||||
delObject: (value?: T) => Promise<void>;
|
||||
keys: (key?: string) => Promise<string[]>;
|
||||
setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise<TokenData>;
|
||||
setToken: (value: { accessToken: string; refreshToken: string; value?: T, day?: number }, opts?: StoreSetOpts) => Promise<TokenData>;
|
||||
delKeys: (keys: string[]) => Promise<number>;
|
||||
}
|
||||
|
||||
type TokenData = {
|
||||
export type TokenData = {
|
||||
accessToken: string;
|
||||
accessTokenExpiresIn?: number;
|
||||
refreshToken?: string;
|
||||
@@ -138,15 +140,18 @@ export class RedisTokenStore implements Store<OauthUser> {
|
||||
await this.del(userPrefix + ':token:' + accessToken);
|
||||
}
|
||||
}
|
||||
async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser }, opts?: StoreSetOpts): Promise<TokenData> {
|
||||
async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser, day?: number }, opts?: StoreSetOpts): Promise<TokenData> {
|
||||
const { accessToken, refreshToken, value } = data;
|
||||
let userPrefix = 'user:' + value?.id;
|
||||
const expireDay = data?.day || 1;
|
||||
|
||||
if (value?.orgId) {
|
||||
userPrefix = 'org:' + value?.orgId + ':user:' + value?.id;
|
||||
}
|
||||
// 计算过期时间,根据opts.expire 和 opts.loginType
|
||||
// 如果expire存在,则使用expire,否则使用opts.loginType 进行计算;
|
||||
let expire = opts?.expire;
|
||||
const day = 24 * 60 * 60; // 一天的秒数
|
||||
if (!expire) {
|
||||
switch (opts.loginType) {
|
||||
case 'day':
|
||||
@@ -155,27 +160,18 @@ export class RedisTokenStore implements Store<OauthUser> {
|
||||
case 'week':
|
||||
expire = 7 * 24 * 60 * 60;
|
||||
break;
|
||||
case 'month':
|
||||
expire = 30 * 24 * 60 * 60;
|
||||
break;
|
||||
case 'season':
|
||||
expire = 90 * 24 * 60 * 60;
|
||||
break;
|
||||
default:
|
||||
expire = 7 * 24 * 60 * 60; // 默认过期时间为7天
|
||||
}
|
||||
} else {
|
||||
expire = Math.min(expire, 60 * 60 * 24 * 30, 60 * 60 * 24 * 90); // 默认的过期时间最大为90天
|
||||
expire = Math.min(expire, 60 * 60 * 24 * 30); // 默认的过期时间最大为30天
|
||||
}
|
||||
|
||||
await this.set(accessToken, JSON.stringify(value), expire);
|
||||
await this.set(userPrefix + ':token:' + accessToken, accessToken, expire);
|
||||
let refreshTokenExpiresIn = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年
|
||||
// refreshToken的过期时间比accessToken多expireDay天,确保在accessToken过期后,refreshToken仍然有效
|
||||
let refreshTokenExpiresIn = expire + expireDay * day;
|
||||
if (refreshToken) {
|
||||
// 小于7天, 则设置为7天
|
||||
if (refreshTokenExpiresIn < 60 * 60 * 24 * 7) {
|
||||
refreshTokenExpiresIn = 60 * 60 * 24 * 7;
|
||||
}
|
||||
await this.set(refreshToken, JSON.stringify(value), refreshTokenExpiresIn);
|
||||
await this.set(userPrefix + ':refreshToken:' + refreshToken, refreshToken, refreshTokenExpiresIn);
|
||||
}
|
||||
@@ -237,7 +233,7 @@ export class OAuth<T extends OauthUser> {
|
||||
user.oauthExpand.refreshToken = refreshToken;
|
||||
}
|
||||
}
|
||||
const tokenData = await this.store.setToken({ accessToken, refreshToken, value: user }, expandOpts);
|
||||
const tokenData = await this.store.setToken({ accessToken, refreshToken, value: user, day: expandOpts?.day }, expandOpts);
|
||||
|
||||
return tokenData;
|
||||
}
|
||||
@@ -251,7 +247,7 @@ export class OAuth<T extends OauthUser> {
|
||||
createTime: new Date().getTime(), // 创建时间
|
||||
};
|
||||
await this.store.setToken(
|
||||
{ accessToken: secretKey, refreshToken: '', value: oauthUser },
|
||||
{ accessToken: secretKey, refreshToken: '', value: oauthUser, day: opts?.day },
|
||||
{
|
||||
...opts,
|
||||
hasRefreshToken: false,
|
||||
@@ -296,6 +292,28 @@ export class OAuth<T extends OauthUser> {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* 获取token类型:jwks, secretKey, accessToken, refreshToken
|
||||
* @param token 要检查的token
|
||||
* @returns token类型或null
|
||||
*/
|
||||
getTokenType(token: string) {
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
if (token.includes('.')) {
|
||||
return 'jwks';
|
||||
}
|
||||
if (token.startsWith('sk_')) {
|
||||
return 'secretKey';
|
||||
}
|
||||
if (token.startsWith('st_')) {
|
||||
return 'accessToken';
|
||||
}
|
||||
if (token.startsWith('rk_')) {
|
||||
return 'refreshToken';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 刷新token
|
||||
* @param refreshToken
|
||||
@@ -314,6 +332,7 @@ export class OAuth<T extends OauthUser> {
|
||||
{
|
||||
...user.oauthExpand,
|
||||
hasRefreshToken: true,
|
||||
day: user.oauthExpand?.day,
|
||||
},
|
||||
);
|
||||
console.log('resetToken token', await this.store.keys());
|
||||
@@ -346,6 +365,7 @@ export class OAuth<T extends OauthUser> {
|
||||
{
|
||||
...user.oauthExpand,
|
||||
hasRefreshToken: true,
|
||||
day: user.oauthExpand?.day,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -399,4 +419,51 @@ export class OAuth<T extends OauthUser> {
|
||||
const tokens = await this.store.keys('*');
|
||||
await this.store.delKeys(tokens);
|
||||
}
|
||||
/**
|
||||
* 设置 jwks token, 用于jwt的验证, 过期时间为2小时
|
||||
*/
|
||||
async setJwksToken(token: string, opts: { id: string; expire: number }) {
|
||||
const expire = opts.expire ?? 2 * 3600; // 2 hours
|
||||
const id = opts.id || '-';
|
||||
// jwks token的过期时间比accessToken多2天,确保2天内可以用来refresh token
|
||||
const addExpire = 2 * 24 * 3600;
|
||||
await this.store.redis.set('user:jwks:' + token, id, 'EX', expire + addExpire);
|
||||
}
|
||||
async deleteJwksToken(token: string) {
|
||||
await this.store.redis.expire('user:jwks:' + token, 0);
|
||||
}
|
||||
/**
|
||||
* 获取后就删除jwks token,确保token只能使用一次。
|
||||
* @param token
|
||||
* @returns
|
||||
*/
|
||||
async getJwksToken(token: string) {
|
||||
const id = await this.store.redis.get('user:jwks:' + token);
|
||||
if (id) {
|
||||
this.deleteJwksToken(token);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 长度为8位的时间的十六进制字符串,单位为秒,使用createHexTime创建,使用parseHexTime解析
|
||||
* @param date
|
||||
* @returns
|
||||
*/
|
||||
export const createHexTime = (date: Date) => {
|
||||
const timestamp = Math.floor(date.getTime() / 1000);
|
||||
const hex = timestamp.toString(16);
|
||||
return hex;
|
||||
};
|
||||
|
||||
/**
|
||||
* 解析createHexTime创建的十六进制时间字符串,返回Date对象
|
||||
* @param hex
|
||||
* @returns
|
||||
*/
|
||||
export const parseHexTime = (hex: string) => {
|
||||
const timestamp = parseInt(hex, 16);
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date;
|
||||
};
|
||||
505
src/db/drizzle/0002_loving_lyja.sql
Normal file
505
src/db/drizzle/0002_loving_lyja.sql
Normal file
@@ -0,0 +1,505 @@
|
||||
CREATE TYPE "public"."enum_cf_router_code_type" AS ENUM('route', 'middleware');--> statement-breakpoint
|
||||
CREATE TABLE "ai_agent" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"type" varchar(255) NOT NULL,
|
||||
"baseUrl" varchar(255) NOT NULL,
|
||||
"apiKey" varchar(255) NOT NULL,
|
||||
"temperature" double precision,
|
||||
"cache" varchar(255),
|
||||
"cacheName" varchar(255),
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"model" varchar(255) NOT NULL,
|
||||
"data" json DEFAULT '{}'::json,
|
||||
"status" varchar(255) DEFAULT 'open',
|
||||
"key" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"deletedAt" timestamp with time zone,
|
||||
CONSTRAINT "ai_agent_key_key" UNIQUE("key")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "apps_trades" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"out_trade_no" varchar(255) NOT NULL,
|
||||
"money" integer NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"status" varchar(255) DEFAULT 'WAIT_BUYER_PAY' NOT NULL,
|
||||
"type" varchar(255) DEFAULT 'alipay' NOT NULL,
|
||||
"data" jsonb DEFAULT '{"list":[]}'::jsonb,
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone,
|
||||
CONSTRAINT "apps_trades_out_trade_no_key" UNIQUE("out_trade_no")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cf_orgs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" varchar(255) NOT NULL,
|
||||
"users" jsonb DEFAULT '[]'::jsonb,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"description" varchar(255),
|
||||
CONSTRAINT "cf_orgs_username_key" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cf_router_code" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"path" varchar(255) NOT NULL,
|
||||
"key" varchar(255) NOT NULL,
|
||||
"active" boolean DEFAULT false,
|
||||
"project" varchar(255) DEFAULT 'default',
|
||||
"code" text DEFAULT '',
|
||||
"type" "enum_cf_router_code_type" DEFAULT 'route',
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"middleware" varchar(255)[] DEFAULT '{"RRAY[]::character varying[])::character varying(25"}',
|
||||
"next" varchar(255) DEFAULT '',
|
||||
"exec" text DEFAULT '',
|
||||
"data" json DEFAULT '{}'::json,
|
||||
"validator" json DEFAULT '{}'::json,
|
||||
"deletedAt" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cf_user" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" varchar(255) NOT NULL,
|
||||
"password" varchar(255),
|
||||
"salt" varchar(255),
|
||||
"needChangePassword" boolean DEFAULT false,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"description" text,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"type" varchar(255) DEFAULT 'user',
|
||||
"owner" uuid,
|
||||
"orgId" uuid,
|
||||
"email" varchar(255),
|
||||
"avatar" text,
|
||||
"nickname" text,
|
||||
CONSTRAINT "cf_user_username_key" UNIQUE("username")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "cf_user_secrets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"description" text,
|
||||
"status" varchar(255) DEFAULT 'active',
|
||||
"title" text,
|
||||
"expiredTime" timestamp with time zone,
|
||||
"token" varchar(255) DEFAULT '' NOT NULL,
|
||||
"userId" uuid,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"orgId" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_histories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"data" json,
|
||||
"chatId" uuid,
|
||||
"chatPromptId" uuid,
|
||||
"root" boolean DEFAULT false,
|
||||
"show" boolean DEFAULT true,
|
||||
"uid" varchar(255),
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"role" varchar(255) DEFAULT 'user'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_prompts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" varchar(255) NOT NULL,
|
||||
"description" text,
|
||||
"data" json,
|
||||
"key" varchar(255) DEFAULT '' NOT NULL,
|
||||
"uid" varchar(255),
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "chat_sessions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"data" json DEFAULT '{}'::json,
|
||||
"chatPromptId" uuid,
|
||||
"type" varchar(255) DEFAULT 'production',
|
||||
"uid" varchar(255),
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"title" varchar(255) DEFAULT '',
|
||||
"key" varchar(255)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "file_sync" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(255),
|
||||
"hash" varchar(255),
|
||||
"stat" jsonb DEFAULT '{}'::jsonb,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"checkedAt" timestamp with time zone,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_ai_chat_history" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"username" varchar(255) DEFAULT '' NOT NULL,
|
||||
"model" varchar(255) DEFAULT '' NOT NULL,
|
||||
"group" varchar(255) DEFAULT '' NOT NULL,
|
||||
"title" varchar(255) DEFAULT '' NOT NULL,
|
||||
"messages" jsonb DEFAULT '[]'::jsonb NOT NULL,
|
||||
"prompt_tokens" integer DEFAULT 0,
|
||||
"total_tokens" integer DEFAULT 0,
|
||||
"completion_tokens" integer DEFAULT 0,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"version" integer DEFAULT 0,
|
||||
"type" varchar(255) DEFAULT 'keep' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_app" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"version" varchar(255) DEFAULT '',
|
||||
"key" varchar(255),
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"title" varchar(255) DEFAULT '',
|
||||
"description" varchar(255) DEFAULT '',
|
||||
"user" varchar(255),
|
||||
"status" varchar(255) DEFAULT 'running',
|
||||
"pid" uuid,
|
||||
"proxy" boolean DEFAULT false,
|
||||
CONSTRAINT "key_uid_unique" UNIQUE("key","uid")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_app_domain" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"domain" varchar(255) NOT NULL,
|
||||
"appId" varchar(255),
|
||||
"uid" varchar(255),
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"data" jsonb,
|
||||
"status" varchar(255) DEFAULT 'running' NOT NULL,
|
||||
CONSTRAINT "kv_app_domain_domain_key" UNIQUE("domain")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_app_list" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"data" json DEFAULT '{}'::json,
|
||||
"version" varchar(255) DEFAULT '',
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"key" varchar(255),
|
||||
"status" varchar(255) DEFAULT 'running'
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_config" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text DEFAULT '',
|
||||
"key" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"hash" text DEFAULT ''
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_light_code" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"type" text DEFAULT 'render-js',
|
||||
"code" text DEFAULT '',
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL,
|
||||
"uid" uuid,
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"hash" text DEFAULT ''
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_github" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"title" varchar(255) DEFAULT '',
|
||||
"githubToken" varchar(255) DEFAULT '',
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_packages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"publish" jsonb DEFAULT '{}'::jsonb,
|
||||
"expand" jsonb DEFAULT '{}'::jsonb,
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_page" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" varchar(255) DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"type" varchar(255) DEFAULT '',
|
||||
"data" json DEFAULT '{}'::json,
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"publish" json DEFAULT '{}'::json
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_resource" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"name" varchar(255) DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"source" varchar(255) DEFAULT '',
|
||||
"sourceId" varchar(255) DEFAULT '',
|
||||
"version" varchar(255) DEFAULT '0.0.0',
|
||||
"data" json DEFAULT '{}'::json,
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "kv_vip" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"level" varchar(255) DEFAULT 'free',
|
||||
"category" varchar(255) NOT NULL,
|
||||
"startDate" timestamp with time zone,
|
||||
"endDate" timestamp with time zone,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"title" text DEFAULT '' NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "micro_apps_upload" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" varchar(255) DEFAULT '',
|
||||
"description" varchar(255) DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"type" varchar(255) DEFAULT '',
|
||||
"source" varchar(255) DEFAULT '',
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"share" boolean DEFAULT false,
|
||||
"uname" varchar(255) DEFAULT '',
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "micro_mark" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"uname" varchar(255) DEFAULT '',
|
||||
"uid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"cover" text DEFAULT '',
|
||||
"thumbnail" text DEFAULT '',
|
||||
"link" text DEFAULT '',
|
||||
"summary" text DEFAULT '',
|
||||
"markType" text DEFAULT 'md',
|
||||
"config" jsonb DEFAULT '{}'::jsonb,
|
||||
"puid" uuid,
|
||||
"deletedAt" timestamp with time zone,
|
||||
"version" integer DEFAULT 1,
|
||||
"fileList" jsonb DEFAULT '[]'::jsonb,
|
||||
"key" text DEFAULT ''
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "query_views" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"uid" uuid,
|
||||
"title" text DEFAULT '',
|
||||
"summary" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"link" text DEFAULT '',
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "router_views" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"uid" uuid,
|
||||
"title" text DEFAULT '',
|
||||
"summary" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"link" text DEFAULT '',
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"views" jsonb DEFAULT '[]'::jsonb,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "work_share_mark" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text DEFAULT '',
|
||||
"key" text DEFAULT '',
|
||||
"markType" text DEFAULT 'md',
|
||||
"description" text DEFAULT '',
|
||||
"cover" text DEFAULT '',
|
||||
"link" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"summary" text DEFAULT '',
|
||||
"config" jsonb DEFAULT '{}'::jsonb,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"fileList" jsonb DEFAULT '[]'::jsonb,
|
||||
"uname" varchar(255) DEFAULT '',
|
||||
"version" integer DEFAULT 1,
|
||||
"markedAt" timestamp with time zone,
|
||||
"uid" uuid,
|
||||
"puid" uuid,
|
||||
"createdAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"deletedAt" timestamp with time zone
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "n_code_make" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"resources" jsonb DEFAULT '[]'::jsonb,
|
||||
"userId" uuid,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "n_code_shop" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"tags" jsonb,
|
||||
"link" text,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"data" jsonb,
|
||||
"platform" text NOT NULL,
|
||||
"userinfo" text,
|
||||
"orderLink" text NOT NULL,
|
||||
"userId" uuid,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "n_code_short_link" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"code" text DEFAULT '' NOT NULL,
|
||||
"type" text DEFAULT 'link' NOT NULL,
|
||||
"version" text DEFAULT '1.0.0' NOT NULL,
|
||||
"title" text DEFAULT '' NOT NULL,
|
||||
"description" text DEFAULT '' NOT NULL,
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"userId" uuid,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "flowme" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"uid" uuid,
|
||||
"title" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"summary" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"link" text DEFAULT '',
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"channelId" uuid,
|
||||
"type" text DEFAULT '',
|
||||
"source" text DEFAULT '',
|
||||
"importance" integer DEFAULT 0,
|
||||
"isArchived" boolean DEFAULT false,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "flowme_channels" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"uid" uuid,
|
||||
"title" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"summary" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"link" text DEFAULT '',
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"key" text DEFAULT '',
|
||||
"color" text DEFAULT '#007bff',
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "flowme_life" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"uid" uuid,
|
||||
"title" text DEFAULT '',
|
||||
"tags" jsonb DEFAULT '[]'::jsonb,
|
||||
"summary" text DEFAULT '',
|
||||
"description" text DEFAULT '',
|
||||
"link" text DEFAULT '',
|
||||
"data" jsonb DEFAULT '{}'::jsonb,
|
||||
"effectiveAt" timestamp with time zone,
|
||||
"type" text DEFAULT '',
|
||||
"prompt" text DEFAULT '',
|
||||
"taskType" text DEFAULT '',
|
||||
"taskResult" jsonb DEFAULT '{}'::jsonb,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "cf_prompts" ALTER COLUMN "parents" SET DATA TYPE text[];--> statement-breakpoint
|
||||
ALTER TABLE "cf_prompts" ALTER COLUMN "parents" SET DEFAULT '{}';--> statement-breakpoint
|
||||
ALTER TABLE "flowme" ADD CONSTRAINT "flowme_channelId_flowme_channels_id_fk" FOREIGN KEY ("channelId") REFERENCES "public"."flowme_channels"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "file_sync_name_idx" ON "file_sync" USING btree ("name");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "kv_app_key_uid" ON "kv_app" USING btree ("key","uid");--> statement-breakpoint
|
||||
CREATE INDEX "query_views_uid_idx" ON "query_views" USING btree ("uid");--> statement-breakpoint
|
||||
CREATE INDEX "query_title_idx" ON "query_views" USING btree ("title");--> statement-breakpoint
|
||||
CREATE INDEX "router_views_uid_idx" ON "router_views" USING btree ("uid");--> statement-breakpoint
|
||||
CREATE INDEX "router_title_idx" ON "router_views" USING btree ("title");--> statement-breakpoint
|
||||
CREATE INDEX "router_views_views_idx" ON "router_views" USING gin ("views");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "n_code_make_idx_slug" ON "n_code_make" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "n_code_shop_idx_slug" ON "n_code_shop" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "n_code_short_idx_slug" ON "n_code_short_link" USING btree ("slug");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "n_code_short_idx_code" ON "n_code_short_link" USING btree ("code");--> statement-breakpoint
|
||||
CREATE INDEX "flowme_uid_idx" ON "flowme" USING btree ("uid");--> statement-breakpoint
|
||||
CREATE INDEX "flowme_title_idx" ON "flowme" USING btree ("title");--> statement-breakpoint
|
||||
CREATE INDEX "flowme_channel_id_idx" ON "flowme" USING btree ("channelId");--> statement-breakpoint
|
||||
CREATE INDEX "flowme_channels_uid_idx" ON "flowme_channels" USING btree ("uid");--> statement-breakpoint
|
||||
CREATE INDEX "flowme_channels_key_idx" ON "flowme_channels" USING btree ("key");--> statement-breakpoint
|
||||
CREATE INDEX "flowme_channels_title_idx" ON "flowme_channels" USING btree ("title");--> statement-breakpoint
|
||||
CREATE INDEX "life_uid_idx" ON "flowme_life" USING btree ("uid");--> statement-breakpoint
|
||||
CREATE INDEX "life_title_idx" ON "flowme_life" USING btree ("title");--> statement-breakpoint
|
||||
CREATE INDEX "life_effective_at_idx" ON "flowme_life" USING btree ("effectiveAt");--> statement-breakpoint
|
||||
CREATE INDEX "life_summary_idx" ON "flowme_life" USING btree ("summary");--> statement-breakpoint
|
||||
CREATE INDEX "prompts_parents_idx" ON "cf_prompts" USING gin ("parents");
|
||||
3479
src/db/drizzle/meta/0002_snapshot.json
Normal file
3479
src/db/drizzle/meta/0002_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,13 @@
|
||||
"when": 1767070768620,
|
||||
"tag": "0001_solid_nocturne",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1773148571509,
|
||||
"tag": "0002_loving_lyja",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,19 +3,8 @@ import { sql, sum } from "drizzle-orm"
|
||||
|
||||
export const enumCfRouterCodeType = pgEnum("enum_cf_router_code_type", ['route', 'middleware'])
|
||||
|
||||
|
||||
export const testPromptTools = pgTable("TestPromptTools", {
|
||||
id: serial().primaryKey().notNull(),
|
||||
template: text().notNull(),
|
||||
args: jsonb().notNull(),
|
||||
process: jsonb().notNull(),
|
||||
type: varchar({ length: 255 }).notNull(),
|
||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
export const aiAgent = pgTable("ai_agent", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
type: varchar({ length: 255 }).notNull(),
|
||||
baseUrl: varchar({ length: 255 }).notNull(),
|
||||
apiKey: varchar({ length: 255 }).notNull(),
|
||||
@@ -35,7 +24,7 @@ export const aiAgent = pgTable("ai_agent", {
|
||||
]);
|
||||
|
||||
export const appsTrades = pgTable("apps_trades", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
outTradeNo: varchar("out_trade_no", { length: 255 }).notNull(),
|
||||
money: integer().notNull(),
|
||||
subject: text().notNull(),
|
||||
@@ -51,7 +40,7 @@ export const appsTrades = pgTable("apps_trades", {
|
||||
]);
|
||||
|
||||
export const cfOrgs = pgTable("cf_orgs", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().notNull().defaultRandom(),
|
||||
username: varchar({ length: 255 }).notNull(),
|
||||
users: jsonb().default([]),
|
||||
createdAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||
@@ -63,7 +52,7 @@ export const cfOrgs = pgTable("cf_orgs", {
|
||||
]);
|
||||
|
||||
export const cfRouterCode = pgTable("cf_router_code", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
path: varchar({ length: 255 }).notNull(),
|
||||
key: varchar({ length: 255 }).notNull(),
|
||||
active: boolean().default(false),
|
||||
@@ -81,7 +70,7 @@ export const cfRouterCode = pgTable("cf_router_code", {
|
||||
});
|
||||
|
||||
export const cfUser = pgTable("cf_user", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().notNull().defaultRandom(),
|
||||
username: varchar({ length: 255 }).notNull(),
|
||||
password: varchar({ length: 255 }),
|
||||
salt: varchar({ length: 255 }),
|
||||
@@ -102,7 +91,7 @@ export const cfUser = pgTable("cf_user", {
|
||||
]);
|
||||
|
||||
export const cfUserSecrets = pgTable("cf_user_secrets", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().notNull().defaultRandom(),
|
||||
description: text(),
|
||||
status: varchar({ length: 255 }).default('active'),
|
||||
title: text(),
|
||||
@@ -116,7 +105,7 @@ export const cfUserSecrets = pgTable("cf_user_secrets", {
|
||||
});
|
||||
|
||||
export const chatHistories = pgTable("chat_histories", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
data: json(),
|
||||
chatId: uuid(),
|
||||
chatPromptId: uuid(),
|
||||
@@ -129,7 +118,7 @@ export const chatHistories = pgTable("chat_histories", {
|
||||
});
|
||||
|
||||
export const chatPrompts = pgTable("chat_prompts", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
title: varchar({ length: 255 }).notNull(),
|
||||
description: text(),
|
||||
data: json(),
|
||||
@@ -141,7 +130,7 @@ export const chatPrompts = pgTable("chat_prompts", {
|
||||
});
|
||||
|
||||
export const chatSessions = pgTable("chat_sessions", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
data: json().default({}),
|
||||
chatPromptId: uuid(),
|
||||
type: varchar({ length: 255 }).default('production'),
|
||||
@@ -153,7 +142,7 @@ export const chatSessions = pgTable("chat_sessions", {
|
||||
});
|
||||
|
||||
export const fileSync = pgTable("file_sync", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
name: varchar({ length: 255 }),
|
||||
hash: varchar({ length: 255 }),
|
||||
stat: jsonb().default({}),
|
||||
@@ -166,7 +155,7 @@ export const fileSync = pgTable("file_sync", {
|
||||
]);
|
||||
|
||||
export const kvAiChatHistory = pgTable("kv_ai_chat_history", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
username: varchar({ length: 255 }).default('').notNull(),
|
||||
model: varchar({ length: 255 }).default('').notNull(),
|
||||
group: varchar({ length: 255 }).default('').notNull(),
|
||||
@@ -184,7 +173,7 @@ export const kvAiChatHistory = pgTable("kv_ai_chat_history", {
|
||||
});
|
||||
|
||||
export const kvApp = pgTable("kv_app", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
data: jsonb().default({}),
|
||||
version: varchar({ length: 255 }).default(''),
|
||||
key: varchar({ length: 255 }),
|
||||
@@ -204,7 +193,7 @@ export const kvApp = pgTable("kv_app", {
|
||||
]);
|
||||
|
||||
export const kvAppDomain = pgTable("kv_app_domain", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
domain: varchar({ length: 255 }).notNull(),
|
||||
appId: varchar({ length: 255 }),
|
||||
uid: varchar({ length: 255 }),
|
||||
@@ -218,7 +207,7 @@ export const kvAppDomain = pgTable("kv_app_domain", {
|
||||
]);
|
||||
|
||||
export const kvAppList = pgTable("kv_app_list", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
data: json().default({}),
|
||||
version: varchar({ length: 255 }).default(''),
|
||||
uid: uuid(),
|
||||
@@ -230,7 +219,7 @@ export const kvAppList = pgTable("kv_app_list", {
|
||||
});
|
||||
|
||||
export const kvConfig = pgTable("kv_config", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
title: text().default(''),
|
||||
key: text().default(''),
|
||||
description: text().default(''),
|
||||
@@ -268,7 +257,7 @@ export const kvGithub = pgTable("kv_github", {
|
||||
});
|
||||
|
||||
export const kvPackages = pgTable("kv_packages", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
title: text().default(''),
|
||||
description: text().default(''),
|
||||
tags: jsonb().default([]),
|
||||
@@ -282,7 +271,7 @@ export const kvPackages = pgTable("kv_packages", {
|
||||
});
|
||||
|
||||
export const kvPage = pgTable("kv_page", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
title: varchar({ length: 255 }).default(''),
|
||||
description: text().default(''),
|
||||
type: varchar({ length: 255 }).default(''),
|
||||
@@ -295,7 +284,7 @@ export const kvPage = pgTable("kv_page", {
|
||||
});
|
||||
|
||||
export const kvResource = pgTable("kv_resource", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
name: varchar({ length: 255 }).default(''),
|
||||
description: text().default(''),
|
||||
source: varchar({ length: 255 }).default(''),
|
||||
@@ -309,7 +298,7 @@ export const kvResource = pgTable("kv_resource", {
|
||||
});
|
||||
|
||||
export const kvVip = pgTable("kv_vip", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
userId: uuid().notNull(),
|
||||
level: varchar({ length: 255 }).default('free'),
|
||||
category: varchar({ length: 255 }).notNull(),
|
||||
@@ -324,7 +313,7 @@ export const kvVip = pgTable("kv_vip", {
|
||||
});
|
||||
|
||||
export const microAppsUpload = pgTable("micro_apps_upload", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
title: varchar({ length: 255 }).default(''),
|
||||
description: varchar({ length: 255 }).default(''),
|
||||
tags: jsonb().default([]),
|
||||
@@ -339,10 +328,13 @@ export const microAppsUpload = pgTable("micro_apps_upload", {
|
||||
});
|
||||
|
||||
export const microMark = pgTable("micro_mark", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
title: text().default(''),
|
||||
description: text().default(''),
|
||||
tags: jsonb().default([]),
|
||||
link: text().default(''),
|
||||
summary: text().default(''),
|
||||
description: text().default(''),
|
||||
|
||||
data: jsonb().default({}),
|
||||
uname: varchar({ length: 255 }).default(''),
|
||||
uid: uuid(),
|
||||
@@ -350,8 +342,7 @@ export const microMark = pgTable("micro_mark", {
|
||||
updatedAt: timestamp({ withTimezone: true, mode: 'string' }).notNull().defaultNow(),
|
||||
cover: text().default(''),
|
||||
thumbnail: text().default(''),
|
||||
link: text().default(''),
|
||||
summary: text().default(''),
|
||||
|
||||
markType: text().default('md'),
|
||||
config: jsonb().default({}),
|
||||
puid: uuid(),
|
||||
@@ -362,7 +353,7 @@ export const microMark = pgTable("micro_mark", {
|
||||
});
|
||||
|
||||
export const workShareMark = pgTable("work_share_mark", {
|
||||
id: uuid().primaryKey().notNull(),
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
title: text().default(''),
|
||||
key: text().default(''),
|
||||
markType: text().default('md'),
|
||||
@@ -468,7 +459,7 @@ export const routerViews = pgTable("router_views", {
|
||||
|
||||
views: jsonb().default([]).$type<Array<RouterViewQuery>>(),
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
}, (table) => [
|
||||
index('router_views_uid_idx').using('btree', table.uid.asc().nullsLast()),
|
||||
index('router_title_idx').using('btree', table.title.asc().nullsLast()),
|
||||
@@ -488,51 +479,9 @@ export const queryViews = pgTable("query_views", {
|
||||
data: jsonb().default({}),
|
||||
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
}, (table) => [
|
||||
index('query_views_uid_idx').using('btree', table.uid.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,3 +1,7 @@
|
||||
import { pgTable, uuid, jsonb, timestamp, text } from "drizzle-orm/pg-core";
|
||||
import { InferSelectModel, InferInsertModel, desc } from "drizzle-orm";
|
||||
export * from './drizzle/schema.ts';
|
||||
export * from './drizzle/schema.ts';
|
||||
|
||||
export * from './schemas/n-code-schema.ts'
|
||||
|
||||
export * from './schemas/life-schema.ts'
|
||||
81
src/db/schemas/life-schema.ts
Normal file
81
src/db/schemas/life-schema.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { pgTable, serial, text, jsonb, varchar, timestamp, unique, uuid, doublePrecision, json, integer, boolean, index, uniqueIndex, pgEnum } from "drizzle-orm/pg-core"
|
||||
import { sql, sum } from "drizzle-orm"
|
||||
|
||||
export const life = pgTable("flowme_life", {
|
||||
id: uuid().primaryKey().notNull().defaultRandom(),
|
||||
uid: uuid(),
|
||||
|
||||
title: text('title').default(''),
|
||||
tags: jsonb().default([]),
|
||||
summary: text('summary').default(''),
|
||||
description: text('description').default(''),
|
||||
link: text('link').default(''),
|
||||
data: jsonb().default({}),
|
||||
|
||||
effectiveAt: timestamp('effectiveAt', { withTimezone: true }),
|
||||
/**
|
||||
* 智能,
|
||||
* 每年农历
|
||||
* 备忘
|
||||
* 归档
|
||||
*/
|
||||
type: text('type').default(''),
|
||||
prompt: text('prompt').default(''),
|
||||
taskType: text('taskType').default(''),
|
||||
taskResult: jsonb('taskResult').default({}),
|
||||
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
}, (table) => [
|
||||
index('life_uid_idx').using('btree', table.uid.asc().nullsLast()),
|
||||
index('life_title_idx').using('btree', table.title.asc().nullsLast()),
|
||||
index('life_effective_at_idx').using('btree', table.effectiveAt.asc().nullsLast()),
|
||||
index('life_summary_idx').using('btree', table.summary.asc().nullsLast()),
|
||||
]);
|
||||
|
||||
export const flowme = pgTable("flowme", {
|
||||
id: uuid().primaryKey().notNull().defaultRandom(),
|
||||
uid: uuid(),
|
||||
|
||||
title: text('title').default(''),
|
||||
tags: jsonb().default([]),
|
||||
summary: text('summary').default(''),
|
||||
description: text('description').default(''),
|
||||
link: text('link').default(''),
|
||||
data: jsonb().default({}),
|
||||
|
||||
channelId: uuid().references(() => flowmeChannels.id, { onDelete: 'set null' }),
|
||||
type: text('type').default(''), // muse,
|
||||
source: text('source').default(''),
|
||||
importance: integer('importance').default(0), // 重要性等级
|
||||
isArchived: boolean('isArchived').default(false), // 是否归档
|
||||
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
|
||||
}, (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(''),
|
||||
tags: jsonb().default([]),
|
||||
summary: text('summary').default(''),
|
||||
description: text('description').default(''),
|
||||
link: text('link').default(''),
|
||||
data: jsonb().default({}),
|
||||
|
||||
key: text('key').default(''),
|
||||
color: text('color').default('#007bff'),
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
}, (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()),
|
||||
]);
|
||||
57
src/db/schemas/n-code-schema.ts
Normal file
57
src/db/schemas/n-code-schema.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { pgTable, serial, text, jsonb, varchar, timestamp, unique, uuid, doublePrecision, json, integer, boolean, index, uniqueIndex, pgEnum } from "drizzle-orm/pg-core"
|
||||
import { desc, sql, sum } from "drizzle-orm"
|
||||
|
||||
export const shortLink = pgTable("n_code_short_link", {
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
// 对外暴露的唯一业务 ID,nanoid 生成
|
||||
slug: text("slug").notNull(),
|
||||
// 协作码,管理员才能编辑, 6-12 位随机字符串,唯一
|
||||
code: text("code").notNull().default(''),
|
||||
// 码的类型,link, agent,默认值为 link
|
||||
type: text("type").notNull().default("link"),
|
||||
version: text("version").notNull().default('1.0.0'),
|
||||
|
||||
title: text("title").notNull().default(''),
|
||||
description: text("description").notNull().default(''),
|
||||
tags: jsonb().default([]),
|
||||
data: jsonb().default({}),
|
||||
|
||||
userId: uuid(),
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
|
||||
}, (table) => [
|
||||
uniqueIndex("n_code_short_idx_slug").on(table.slug),
|
||||
uniqueIndex("n_code_short_idx_code").on(table.code)
|
||||
]);
|
||||
|
||||
export const n5Make = pgTable("n_code_make", {
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
slug: text("slug").notNull(),
|
||||
resources: jsonb().default([]),
|
||||
|
||||
userId: uuid(),
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
}, (table) => [
|
||||
uniqueIndex("n_code_make_idx_slug").on(table.slug),
|
||||
]);
|
||||
|
||||
export const n5Shop = pgTable("n_code_shop", {
|
||||
id: uuid().primaryKey().defaultRandom(),
|
||||
slug: text("slug").notNull(),
|
||||
title: text("title").notNull(), // 商品标题
|
||||
tags: jsonb(), // 商品标签
|
||||
link: text("link"), // 商品链接
|
||||
description: text("description").notNull().default(''), // 商品描述
|
||||
data: jsonb(), // 其他商品发货信息等
|
||||
platform: text("platform").notNull(), // 商品平台,如 "淘宝", "抖音", "小红书" 等
|
||||
userinfo: text("userinfo"), // 卖家信息,如 "店铺名称", "联系方式" 等
|
||||
orderLink: text("orderLink").notNull(), // 订单链接
|
||||
|
||||
userId: uuid(),
|
||||
createdAt: timestamp('createdAt').notNull().defaultNow(),
|
||||
updatedAt: timestamp('updatedAt').notNull().defaultNow().$onUpdate(() => new Date()),
|
||||
}, (table) => [
|
||||
uniqueIndex("n_code_shop_idx_slug").on(table.slug),
|
||||
]);
|
||||
@@ -1,8 +1,8 @@
|
||||
import { app } from './app.ts';
|
||||
import './route.ts';
|
||||
import { handleRequest } from './routes-simple/handle-request.ts';
|
||||
import { handleRequest } from './routes-simple/index.ts';
|
||||
import { port } from './modules/config.ts';
|
||||
import { wssFun } from './modules/ws-proxy/index.ts';
|
||||
import { wssFun } from './modules/v1-ws-proxy/index.ts';
|
||||
import { WebSocketListenerFun, HttpListenerFun } from '@kevisual/router/src/server/server-type.js';
|
||||
console.log('Starting server...', port);
|
||||
app.listen(port, '0.0.0.0', () => {
|
||||
|
||||
@@ -1,18 +1,3 @@
|
||||
import { User, UserInit, UserServices } from '../auth/models/index.ts';
|
||||
import { UserSecretInit, UserSecret } from '../auth/models/index.ts';
|
||||
import { OrgInit } from '../auth/models/index.ts';
|
||||
export { User, UserInit, UserServices, UserSecret };
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
const init = async () => {
|
||||
await OrgInit(null, null).catch((e) => {
|
||||
console.error('Org sync', e);
|
||||
});
|
||||
await UserInit(null, null).catch((e) => {
|
||||
console.error('User sync', e);
|
||||
});
|
||||
await UserSecretInit(null, null).catch((e) => {
|
||||
console.error('UserSecret sync', e);
|
||||
});
|
||||
useContextKey('models-synced', true);
|
||||
};
|
||||
init();
|
||||
import { User, UserServices } from '../auth/models/index.ts';
|
||||
import { UserSecret } from '../auth/models/index.ts';
|
||||
export { User, UserServices, UserSecret };
|
||||
65
src/modules/ai/agent-run.ts
Normal file
65
src/modules/ai/agent-run.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { type QueryRouterServer, type App, type RouteInfo } from '@kevisual/router'
|
||||
import { generateText, tool, type ModelMessage, type LanguageModel, type GenerateTextResult } from 'ai';
|
||||
import z from 'zod';
|
||||
import { filter } from '@kevisual/js-filter'
|
||||
export const createTool = async (app: QueryRouterServer | App, message: { path: string, key: string, token?: string }) => {
|
||||
const route = app.findRoute({ path: message.path, key: message.key });
|
||||
if (!route) {
|
||||
console.error(`未找到路径 ${message.path} 和 key ${message.key} 的路由`);
|
||||
return null;
|
||||
}
|
||||
const _tool = tool({
|
||||
description: route?.metadata?.summary || route?.description || '无描述',
|
||||
inputSchema: z.object({
|
||||
...route.metadata?.args
|
||||
}), // 这里可以根据实际需要定义输入参数的 schema
|
||||
execute: async (args: any) => {
|
||||
const res = await app.run({ path: message.path, key: message.key, payload: args, token: message.token });
|
||||
return res;
|
||||
}
|
||||
});
|
||||
return _tool;
|
||||
}
|
||||
|
||||
export const createTools = async (opts: { app: QueryRouterServer | App, token?: string }) => {
|
||||
const { app, token } = opts;
|
||||
const tools: Record<string, any> = {};
|
||||
for (const route of app.routes) {
|
||||
const id = route.id!;
|
||||
const _tool = await createTool(app, { path: route.path!, key: route.key!, token });
|
||||
if (_tool && id) {
|
||||
tools[id] = _tool;
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
type Route = Partial<RouteInfo>
|
||||
type AgentResult = {
|
||||
result: GenerateTextResult<Record<string, any>, any>,
|
||||
messages: ModelMessage[],
|
||||
}
|
||||
export const reCallAgent = async (opts: { messages?: ModelMessage[], tools?: Record<string, any>, languageModel: LanguageModel }): Promise<AgentResult> => {
|
||||
const { messages = [], tools = {}, languageModel } = opts;
|
||||
const result = await generateText({
|
||||
model: languageModel,
|
||||
messages,
|
||||
tools,
|
||||
});
|
||||
const step = result.steps[0]!;
|
||||
if (step.finishReason === 'tool-calls') {
|
||||
messages.push(...result.response.messages);
|
||||
return reCallAgent({ messages, tools, languageModel });
|
||||
}
|
||||
return { result, messages };
|
||||
}
|
||||
export const runAgent = async (opts: { app: QueryRouterServer | App, messages?: ModelMessage[], routes?: Route[], query?: string, languageModel: LanguageModel, token: string }) => {
|
||||
const { app, languageModel } = opts;
|
||||
let messages = opts.messages || [];
|
||||
|
||||
let routes = opts?.routes || app.routes;
|
||||
if (opts.query) {
|
||||
routes = filter(routes, opts.query);
|
||||
};
|
||||
const tools = await createTools({ app, token: opts.token });
|
||||
return await reCallAgent({ messages, tools, languageModel });
|
||||
}
|
||||
@@ -1,19 +1,12 @@
|
||||
import { useConfig } from '@kevisual/use-config';
|
||||
import { useFileStore } from '@kevisual/use-config';
|
||||
import { minioResources } from './s3.ts';
|
||||
import { proxyDomain } from './domain.ts';
|
||||
|
||||
export const config = useConfig() as any;
|
||||
export const port = config.PORT ? Number(config.PORT) : 4005;
|
||||
export const fileStore = useFileStore('pages');
|
||||
type ConfigType = {
|
||||
api: {
|
||||
/**
|
||||
* API host address
|
||||
*/
|
||||
host: string;
|
||||
path?: string;
|
||||
port?: number;
|
||||
};
|
||||
apiList: {
|
||||
path: string;
|
||||
/**
|
||||
@@ -29,7 +22,11 @@ type ConfigType = {
|
||||
/**
|
||||
* self domain kevisual.xiongxiao.me
|
||||
*/
|
||||
domain: string;
|
||||
domain?: string;
|
||||
/**
|
||||
* self ip
|
||||
*/
|
||||
ip?: string;
|
||||
/**
|
||||
* resources path
|
||||
* https://minio.xiongxiao.me/resources
|
||||
@@ -41,24 +38,8 @@ type ConfigType = {
|
||||
*/
|
||||
allowedOrigin: string[];
|
||||
};
|
||||
stat: {
|
||||
/**
|
||||
* 统计网站ID
|
||||
*/
|
||||
websiteId: string;
|
||||
};
|
||||
redis?: {
|
||||
host: string;
|
||||
port: number;
|
||||
password?: string;
|
||||
};
|
||||
};
|
||||
export const myConfig: ConfigType = {
|
||||
api: {
|
||||
host: config.API_HOST,
|
||||
path: config.API_PATH,
|
||||
port: config.PROXY_PORT,
|
||||
},
|
||||
apiList: [
|
||||
// {
|
||||
// path: '/api',
|
||||
@@ -66,20 +47,12 @@ export const myConfig: ConfigType = {
|
||||
// },
|
||||
{
|
||||
path: '/client',
|
||||
target: config.API_CLIENT_HOST || 'http://localhost:51015',
|
||||
target: config.API_CLIENT_HOST || 'http://localhost:51515',
|
||||
},
|
||||
],
|
||||
proxy: {
|
||||
domain: config.PROXY_DOMAIN,
|
||||
domain: proxyDomain as string,
|
||||
resources: minioResources,
|
||||
allowedOrigin: (config.PROXY_ALLOWED_ORIGINS as string)?.split(',') || [],
|
||||
},
|
||||
redis: {
|
||||
host: config.REDIS_HOST,
|
||||
port: config.REDIS_PORT,
|
||||
password: config.REDIS_PASSWORD,
|
||||
},
|
||||
stat: {
|
||||
websiteId: config.DATA_WEBSITE_ID,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
|
||||
import { useKey } from "@kevisual/use-config";
|
||||
|
||||
/**
|
||||
* 用来放cookie的域名
|
||||
*/
|
||||
export const domain = process.env.DOMAIN || ''; // 请在这里填写你的域名
|
||||
export const proxyDomain = useKey('PROXY_DOMAIN') || ''; // 请在这里填写你的域名
|
||||
|
||||
export const baseProxyUrl = proxyDomain ? `https://${proxyDomain}` : 'https://kevisual.cn';
|
||||
|
||||
|
||||
export const baseURL = baseProxyUrl;
|
||||
@@ -109,7 +109,7 @@ export const pipeProxyReq = async (req: http.IncomingMessage, proxyReq: http.Cli
|
||||
const bunRequest = req.bun.request;
|
||||
const contentType = req.headers['content-type'] || '';
|
||||
if (contentType.includes('multipart/form-data')) {
|
||||
console.log('Processing multipart/form-data');
|
||||
// console.log('Processing multipart/form-data');
|
||||
const arrayBuffer = await bunRequest.arrayBuffer();
|
||||
|
||||
// 设置请求头(在写入数据之前)
|
||||
@@ -123,7 +123,6 @@ export const pipeProxyReq = async (req: http.IncomingMessage, proxyReq: http.Cli
|
||||
proxyReq.end();
|
||||
return;
|
||||
}
|
||||
console.log('Bun pipeProxyReq content-type', contentType);
|
||||
// @ts-ignore
|
||||
const bodyString = req.body;
|
||||
bodyString && proxyReq.write(bodyString);
|
||||
|
||||
@@ -27,12 +27,10 @@ type FileList = {
|
||||
export const getFileList = async (list: any, opts?: { objectName: string; app: string; host?: string }) => {
|
||||
const { app, host } = opts || {};
|
||||
const objectName = opts?.objectName || '';
|
||||
let newObjectName = objectName;
|
||||
const [user] = objectName.split('/');
|
||||
let replaceUser = user + '/';
|
||||
if (app === 'resources') {
|
||||
replaceUser = `${user}/resources/`;
|
||||
newObjectName = objectName.replace(`${user}/`, replaceUser);
|
||||
}
|
||||
return list.map((item: FileList) => {
|
||||
if (item.name) {
|
||||
@@ -70,12 +68,20 @@ const getAiProxy = async (req: IncomingMessage, res: ServerResponse, opts: Proxy
|
||||
const password = params.get('p');
|
||||
const hash = params.get('hash');
|
||||
let dir = !!params.get('dir');
|
||||
const edit = !!params.get('edit');
|
||||
const recursive = !!params.get('recursive');
|
||||
const showStat = !!params.get('stat');
|
||||
const { objectName, app, owner, loginUser, isOwner } = await getObjectName(req);
|
||||
if (!dir && _u.pathname.endsWith('/')) {
|
||||
dir = true; // 如果是目录请求,强制设置为true
|
||||
}
|
||||
if (edit) {
|
||||
// 重定向root/codepod/#folder=路径
|
||||
const redirectUrl = `/root/codepod/#folder=${_u.pathname}`;
|
||||
res.writeHead(302, { Location: redirectUrl });
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
logger.debug(`proxy request: ${objectName}`, dir);
|
||||
try {
|
||||
if (dir) {
|
||||
@@ -201,6 +207,8 @@ export const getObjectByPathname = (opts: {
|
||||
prefix = `${user}/`; // root/resources
|
||||
}
|
||||
let objectName = opts.pathname.replace(replaceKey, prefix);
|
||||
// 解码decodeURIComponent编码的路径
|
||||
objectName = decodeURIComponent(objectName);
|
||||
return { prefix, replaceKey, objectName, user, app };
|
||||
}
|
||||
export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?: boolean }) => {
|
||||
@@ -217,6 +225,8 @@ export const getObjectName = async (req: IncomingMessage, opts?: { checkOwner?:
|
||||
} else {
|
||||
objectName = pathname.replace(`/${user}/${app}/`, `${user}/`); // root/resources
|
||||
}
|
||||
// 解码decodeURIComponent编码的路径
|
||||
objectName = decodeURIComponent(objectName);
|
||||
owner = user;
|
||||
let isOwner = undefined;
|
||||
let loginUser: Awaited<ReturnType<typeof getLoginUser>> = null;
|
||||
@@ -287,7 +297,7 @@ export const renameProxy = async (req: IncomingMessage, res: ServerResponse, opt
|
||||
}
|
||||
const newUrl = new URL(newName, 'http://localhost');
|
||||
const version = _u.searchParams.get('version') || '1.0.0';
|
||||
const newNamePath = newUrl.pathname;
|
||||
const newNamePath = decodeURIComponent(newUrl.pathname);
|
||||
// 确保 newName 有正确的前缀路径
|
||||
|
||||
const newObject = getObjectByPathname({ pathname: newNamePath, version });
|
||||
@@ -314,7 +324,6 @@ export const renameProxy = async (req: IncomingMessage, res: ServerResponse, opt
|
||||
await oss.deleteObject(obj.name);
|
||||
}
|
||||
} else {
|
||||
// 重命名文件
|
||||
await oss.copyObject(objectName, newObjectName);
|
||||
await oss.deleteObject(objectName);
|
||||
copiedCount = 1;
|
||||
|
||||
@@ -125,7 +125,7 @@ export const httpProxy = async (
|
||||
const params = _u.searchParams;
|
||||
const isDownload = params.get('download') === 'true';
|
||||
if (proxyUrl.startsWith(minioResources)) {
|
||||
console.log('isMinio', proxyUrl)
|
||||
// console.log('isMinio', proxyUrl)
|
||||
const isOk = await minioProxy(req, res, { ...opts, isDownload });
|
||||
if (!isOk) {
|
||||
userApp.clearCacheData();
|
||||
|
||||
6
src/modules/html/render-server-html.ts
Normal file
6
src/modules/html/render-server-html.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const renderServerHtml = (data: any, html: string) => {
|
||||
if (html.includes('<body>')) {
|
||||
return html.replace('<body>', `<body><script>window.__SERVER_DATA__ = ${JSON.stringify(data)}</script>`);
|
||||
}
|
||||
return html;
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
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 +0,0 @@
|
||||
export { sequelize } from './sequelize.ts';
|
||||
|
||||
79
src/modules/n5/index.ts
Normal file
79
src/modules/n5/index.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { User } from '@/models/user.ts';
|
||||
import { omit } from 'es-toolkit';
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { renderServerHtml } from '../html/render-server-html.ts';
|
||||
import { baseURL } from '../domain.ts';
|
||||
import { app } from '@/app.ts'
|
||||
import { N5Service } from '@/routes/n5-link/modules/n5.services.ts';
|
||||
type ProxyOptions = {
|
||||
createNotFoundPage: (msg?: string) => any;
|
||||
};
|
||||
// /n5/:slug/
|
||||
export const N5Proxy = async (req: IncomingMessage, res: ServerResponse, opts?: ProxyOptions) => {
|
||||
const { url } = req;
|
||||
const _url = new URL(url || '', baseURL);
|
||||
const { pathname, searchParams } = _url;
|
||||
let [user, app, userAppKey] = pathname.split('/').slice(1);
|
||||
if (!app) {
|
||||
opts?.createNotFoundPage?.('应用未找到');
|
||||
return false;
|
||||
}
|
||||
let n5Data = null;
|
||||
let data = null;
|
||||
let link = '';
|
||||
try {
|
||||
const convexResult = await N5Service.getBySlug(app);
|
||||
if (!convexResult || convexResult.length === 0) {
|
||||
opts?.createNotFoundPage?.('应用未找到');
|
||||
return false;
|
||||
}
|
||||
n5Data = convexResult[0];
|
||||
data = n5Data.data ?? {};
|
||||
link = data.link;
|
||||
if (!link) {
|
||||
opts?.createNotFoundPage?.('不存在对应的跳转的链接');
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching N5 data:', e);
|
||||
opts?.createNotFoundPage?.('应用数据异常');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (data?.useOwnerToken) {
|
||||
const userId = n5Data.userId;
|
||||
if (!userId) {
|
||||
opts?.createNotFoundPage?.('未绑定账号');
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const user = await User.findByPk(userId);
|
||||
const token = await User.createJwksTokenResponse({ id: userId, username: user?.username || '' }, { hasRefreshToken: true });
|
||||
const urlObj = new URL(link);
|
||||
urlObj.searchParams.set('token', token.accessToken);
|
||||
const resultLink = await fetch(urlObj.toString(), { method: 'GET' }).then(res => res.json())
|
||||
res.end(JSON.stringify(resultLink));
|
||||
} catch (e) {
|
||||
console.error('Error fetching the link:', e);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: '请求服务区失败' }));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const type = data?.type;
|
||||
if (type === 'html-render') {
|
||||
const createJson = omit(data, ['useOwnerToken', 'permission']);
|
||||
const html = await fetch(link, { method: 'GET' }).then(res => res.text());
|
||||
const renderedHtml = await renderServerHtml({
|
||||
data: createJson,
|
||||
pathname: _url.toString(),
|
||||
}, html);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(renderedHtml);
|
||||
} else {
|
||||
res.writeHead(302, {
|
||||
Location: link,
|
||||
});
|
||||
}
|
||||
res.end();
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import childProcess from 'child_process';
|
||||
|
||||
export const selfRestart = async () => {
|
||||
const appName = 'code-center';
|
||||
const appName = 'root/code-center';
|
||||
// 检测 pm2 是否安装和是否有 appName 这个应用
|
||||
try {
|
||||
const res = childProcess.execSync(`pm2 list`);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { Sequelize } from 'sequelize';
|
||||
import { config } from './config.ts';
|
||||
import { log } from './logger.ts';
|
||||
export type PostgresConfig = {
|
||||
postgres: {
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
port: number;
|
||||
database: string;
|
||||
};
|
||||
};
|
||||
if (!config.POSTGRES_PASSWORD || !config.POSTGRES_USER) {
|
||||
log.error('postgres config is required password and user');
|
||||
log.error('config', config);
|
||||
process.exit(1);
|
||||
}
|
||||
const postgresConfig = {
|
||||
username: config.POSTGRES_USER,
|
||||
password: config.POSTGRES_PASSWORD,
|
||||
host: config.POSTGRES_HOST || 'localhost',
|
||||
port: parseInt(config.POSTGRES_PORT || '5432'),
|
||||
database: config.POSTGRES_DB || 'postgres',
|
||||
};
|
||||
// connect to db
|
||||
export const sequelize = new Sequelize({
|
||||
dialect: 'postgres',
|
||||
...postgresConfig,
|
||||
// logging: false,
|
||||
});
|
||||
|
||||
sequelize
|
||||
.authenticate({ logging: false })
|
||||
.then(() => {
|
||||
log.info('Database connected');
|
||||
})
|
||||
.catch((err) => {
|
||||
log.error('Database connection failed', { err, config: postgresConfig });
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -110,7 +110,6 @@ export class UserApp {
|
||||
const key = 'user:app:set:' + app + ':' + user;
|
||||
const value = await redis.hget(key, appFileUrl);
|
||||
// const values = await redis.hgetall(key);
|
||||
// console.log('getFile', values);
|
||||
return value;
|
||||
}
|
||||
static async getDomainApp(domain: string) {
|
||||
|
||||
@@ -10,11 +10,11 @@ import { getUserConfig } from '@/modules/fm-manager/index.ts';
|
||||
export const rediretHome = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
const user = await getLoginUser(req);
|
||||
if (!user?.token) {
|
||||
res.writeHead(302, { Location: '/root/home/' });
|
||||
res.writeHead(302, { Location: '/root/center/' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
let redirectURL = '/root/home/';
|
||||
let redirectURL = '/root/center/';
|
||||
try {
|
||||
const token = user.token;
|
||||
const resConfig = await getUserConfig(token);
|
||||
|
||||
52
src/modules/v1-ws-proxy/index.ts
Normal file
52
src/modules/v1-ws-proxy/index.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { WsProxyManager } from './manager.ts';
|
||||
import { getLoginUserByToken } from '@/modules/auth.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
export const wsProxyManager = new WsProxyManager();
|
||||
import { WebSocketListenerFun } from '@kevisual/router/src/server/server-type.ts'
|
||||
// 生成一个随机六位字符串作为注册 ID
|
||||
const generateRegistryId = () => {
|
||||
return Math.random().toString(36).substring(2, 8);
|
||||
}
|
||||
export const wssFun: WebSocketListenerFun = async (req, res) => {
|
||||
// do nothing, just to enable ws upgrade event
|
||||
const { id, ws, token, data, emitter } = req;
|
||||
// console.log('req', req)
|
||||
const { type } = data || {};
|
||||
|
||||
if (type === 'registryClient') {
|
||||
const loginUser = await getLoginUserByToken(token);
|
||||
let isLogin = false;
|
||||
let user = '';
|
||||
if (loginUser?.tokenUser) {
|
||||
isLogin = true;
|
||||
user = loginUser?.tokenUser?.username;
|
||||
} else {
|
||||
logger.debug('未登录,请求等待用户验证', data);
|
||||
user = data?.username || '';
|
||||
}
|
||||
if (!user) {
|
||||
logger.debug('未提供用户名,无法注册 ws 连接');
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
let userApp = user + '--' + id;
|
||||
// TODO: 如果存在, 而且之前的那个关闭了,不需要验证,直接覆盖和复用.
|
||||
let wsConnect = await wsProxyManager.createNewConnection({ ws, user, userApp, isLogin });
|
||||
if (wsConnect.isNew) {
|
||||
logger.debug('新连接注册成功', userApp);
|
||||
}
|
||||
// @ts-ignore
|
||||
ws.data.userApp = wsConnect.id;
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
const userApp = ws.data.userApp;
|
||||
logger.debug('message', data, ' userApp=', userApp);
|
||||
const wsMessage = wsProxyManager.get(userApp);
|
||||
if (wsMessage) {
|
||||
wsMessage.sendResponse(data);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
logger.debug('账号应用未注册,无法处理消息。未授权?', ws.data);
|
||||
}
|
||||
}
|
||||
263
src/modules/v1-ws-proxy/manager.ts
Normal file
263
src/modules/v1-ws-proxy/manager.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import { WebSocket } from 'ws';
|
||||
import { logger } from '../logger.ts';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const nanoid = customAlphabet(letters, 10);
|
||||
|
||||
class WsMessage {
|
||||
ws: WebSocket;
|
||||
user?: string;
|
||||
emitter: EventEmitter;
|
||||
private pingTimer?: NodeJS.Timeout;
|
||||
private readonly PING_INTERVAL = 30000; // 30 秒发送一次 ping
|
||||
id?: string;
|
||||
status?: 'waiting' | 'connected' | 'closed';
|
||||
manager: WsProxyManager;
|
||||
constructor({ ws, user, id, isLogin, manager }: WssMessageOptions) {
|
||||
this.ws = ws;
|
||||
this.user = user;
|
||||
this.id = id;
|
||||
this.emitter = new EventEmitter();
|
||||
this.manager = manager;
|
||||
this.status = isLogin ? 'connected' : 'waiting';
|
||||
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();
|
||||
}
|
||||
isClosed() {
|
||||
return this.ws.readyState === WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
async sendResponse(data: any) {
|
||||
if (data.id) {
|
||||
this.emitter.emit(data.id, data?.data);
|
||||
}
|
||||
}
|
||||
async sendConnected() {
|
||||
const id = this.id;
|
||||
const user = this.user;
|
||||
const data = { type: 'verified', user, id };
|
||||
if (this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
this.status = 'connected';
|
||||
}
|
||||
if (id.includes('-registry-')) {
|
||||
const newId = id.split('-registry-')[0];
|
||||
this.manager.changeId(id, newId);
|
||||
const ws = this.ws;
|
||||
// @ts-ignore
|
||||
if (this.ws?.data) {
|
||||
// @ts-ignore
|
||||
this.ws.data.userApp = newId;
|
||||
}
|
||||
}
|
||||
}
|
||||
getInfo() {
|
||||
const shortAppId = this.id ? this.id.split('--')[1] : '';
|
||||
return {
|
||||
user: this.user,
|
||||
id: this.id,
|
||||
status: this.status,
|
||||
shortAppId,
|
||||
pathname: this.id ? `/${this.user}/v1/${shortAppId}` : '',
|
||||
};
|
||||
}
|
||||
async sendData(data: any, context?: any, opts?: { timeout?: number }) {
|
||||
if (this.ws.readyState !== WebSocket.OPEN) {
|
||||
return { code: 500, message: 'WebSocket is not open' };
|
||||
}
|
||||
const timeout = opts?.timeout || 10 * 6 * 1000; // 10 minutes
|
||||
const id = nanoid();
|
||||
const message = JSON.stringify({
|
||||
id,
|
||||
type: 'proxy',
|
||||
data: {
|
||||
message: data,
|
||||
context: context || {},
|
||||
},
|
||||
});
|
||||
logger.info('ws-proxy sendData', message);
|
||||
this.ws.send(message);
|
||||
const msg = { path: data?.path, key: data?.key, id: data?.id };
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
console.log('ws-proxy sendData timeout', msg);
|
||||
resolve({
|
||||
code: 500,
|
||||
message: `运行超时,执行的id: ${id},参数是${JSON.stringify(msg)}`,
|
||||
});
|
||||
}, timeout);
|
||||
this.emitter.once(id, (data: any) => {
|
||||
resolve(data);
|
||||
clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
type WssMessageOptions = {
|
||||
ws: WebSocket;
|
||||
user?: string;
|
||||
id?: string;
|
||||
realId?: string;
|
||||
isLogin?: boolean;
|
||||
manager: WsProxyManager;
|
||||
};
|
||||
|
||||
export class WsProxyManager {
|
||||
wssMap: Map<string, WsMessage> = new Map();
|
||||
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, id?: string, isLogin: boolean }) {
|
||||
if (this.wssMap.has(id)) {
|
||||
const value = this.wssMap.get(id);
|
||||
if (value) {
|
||||
value.ws.close();
|
||||
value.destroy();
|
||||
}
|
||||
}
|
||||
const [username, appId] = id.split('--');
|
||||
const url = new URL(`/${username}/v1/${appId}`, 'https://kevisual.cn/');
|
||||
console.log('WsProxyManager register', id, '访问地址', url.toString());
|
||||
const value = new WsMessage({ ...opts, manager: this } as WssMessageOptions);
|
||||
this.wssMap.set(id, value);
|
||||
return value;
|
||||
}
|
||||
changeId(oldId: string, newId: string) {
|
||||
const value = this.wssMap.get(oldId);
|
||||
const originalValue = this.wssMap.get(newId);
|
||||
if (originalValue) {
|
||||
logger.debug(`WsProxyManager changeId: ${newId} already exists, close old connection`);
|
||||
originalValue.ws.close();
|
||||
originalValue.destroy();
|
||||
}
|
||||
if (value) {
|
||||
this.wssMap.delete(oldId);
|
||||
this.wssMap.set(newId, value);
|
||||
value.id = newId;
|
||||
// @ts-ignore
|
||||
if (value.ws?.data) {
|
||||
// @ts-ignore
|
||||
value.ws.data.userApp = newId;
|
||||
}
|
||||
logger.debug(`WsProxyManager changeId: ${oldId} -> ${newId}`);
|
||||
}
|
||||
}
|
||||
unregister(id: string) {
|
||||
const value = this.wssMap.get(id);
|
||||
if (value) {
|
||||
value.ws.close();
|
||||
value.destroy();
|
||||
}
|
||||
this.wssMap.delete(id);
|
||||
}
|
||||
getIds(beginWith?: string) {
|
||||
if (beginWith) {
|
||||
return Array.from(this.wssMap.keys()).filter(key => key.startsWith(beginWith));
|
||||
}
|
||||
return Array.from(this.wssMap.keys());
|
||||
}
|
||||
getIdsInfo(beginWith?: string) {
|
||||
const ids = this.getIds(beginWith);
|
||||
const infoList = this.getInfoList(ids);
|
||||
return {
|
||||
ids,
|
||||
infoList,
|
||||
};
|
||||
}
|
||||
getInfoList(ids: string[]) {
|
||||
return ids.map(id => {
|
||||
const value = this.wssMap.get(id);
|
||||
if (value) {
|
||||
return value.getInfo();
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
get(id: string) {
|
||||
return this.wssMap.get(id);
|
||||
}
|
||||
createId(id: string) {
|
||||
if (!this.wssMap.has(id)) {
|
||||
return id;
|
||||
}
|
||||
const newId = id + '-' + nanoid(6);
|
||||
return newId;
|
||||
}
|
||||
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);
|
||||
}
|
||||
async createNewConnection(opts: { ws: any; user: string, userApp: string, isLogin?: boolean }) {
|
||||
let id = opts.userApp;
|
||||
let realId: string = id;
|
||||
const isLogin = opts.isLogin || false;
|
||||
const has = this.wssMap.has(id);
|
||||
let registryId = '-registry-' + generateRegistryId(); // 生成一个随机六位字符串作为注册 ID
|
||||
let isNeedVerify = !isLogin;
|
||||
if (has) {
|
||||
const value = this.wssMap.get(id);
|
||||
if (value) {
|
||||
if (value.isClosed()) {
|
||||
// 短时间内还在, 等于简单重启了一下应用,不需要重新注册.
|
||||
logger.debug('之前的连接已关闭,复用注册 ID 连接 ws', id);
|
||||
this.unregister(id);
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
const wsMessage = this.register(id, { ws: opts.ws, user: opts.user, id, isLogin });
|
||||
wsMessage.sendConnected();
|
||||
return { wsMessage, isNew: false, id: id };
|
||||
} else {
|
||||
// 没有关闭 生成新的 id 连接.
|
||||
id = id + '-mult-' + generateRegistryId(4);
|
||||
realId = id;
|
||||
logger.debug('之前的连接未关闭,使用新的 ID 连接 ws', id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 没有连接, 直接注册新的连接.
|
||||
if (isNeedVerify) {
|
||||
realId = id + registryId;
|
||||
logger.debug('未登录用户,使用临时注册 ID 连接 ws', realId);
|
||||
}
|
||||
const wsMessage = this.register(realId, { ws: opts.ws, user: opts.user, id: realId, isLogin });
|
||||
return { wsMessage, isNew: true, id: realId };
|
||||
}
|
||||
}
|
||||
// 生成一个随机六位字符串作为注册 ID
|
||||
const generateRegistryId = (len = 6) => {
|
||||
return Math.random().toString(36).substring(2, 2 + len);
|
||||
}
|
||||
158
src/modules/v1-ws-proxy/proxy.ts
Normal file
158
src/modules/v1-ws-proxy/proxy.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { wsProxyManager } from './index.ts';
|
||||
|
||||
import { App } from '@kevisual/router';
|
||||
import { logger } from '../logger.ts';
|
||||
import { getLoginUser } from '@/modules/auth.ts';
|
||||
import { omit } from 'es-toolkit';
|
||||
import { baseProxyUrl, proxyDomain } from '../domain.ts';
|
||||
import { renderServerHtml } from '../html/render-server-html.ts';
|
||||
|
||||
type ProxyOptions = {
|
||||
createNotFoundPage: (msg?: string) => any;
|
||||
};
|
||||
export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opts?: ProxyOptions) => {
|
||||
const { url, method } = req;
|
||||
const _url = new URL(url || '', `http://localhost`);
|
||||
const { pathname, searchParams } = _url;
|
||||
const isGet = method === 'GET';
|
||||
|
||||
let [user, app, userAppKey] = pathname.split('/').slice(1);
|
||||
if (!user || !app) {
|
||||
opts?.createNotFoundPage?.('应用未找到');
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await App.handleRequest(req, res);
|
||||
const loginUser = await getLoginUser(req);
|
||||
if (!loginUser) {
|
||||
opts?.createNotFoundPage?.('没有登录');
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAdmin = loginUser.tokenUser?.username === user
|
||||
|
||||
if (!userAppKey) {
|
||||
if (isAdmin) {
|
||||
return handleRequest(req, res, { user, app, userAppKey, isAdmin });
|
||||
} else {
|
||||
opts?.createNotFoundPage?.('应用访问失败');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (!userAppKey.includes('--')) {
|
||||
userAppKey = user + '--' + userAppKey;
|
||||
}
|
||||
|
||||
// TODO: 如果不是管理员,是否需要添加其他人可以访问的逻辑?
|
||||
if (!isAdmin) {
|
||||
opts?.createNotFoundPage?.('没有访问应用权限');
|
||||
return false;
|
||||
}
|
||||
if (!userAppKey.startsWith(user + '--')) {
|
||||
userAppKey = user + '--' + userAppKey;
|
||||
}
|
||||
logger.debug('data', data);
|
||||
const client = wsProxyManager.get(userAppKey);
|
||||
const { ids, infoList } = wsProxyManager.getIdsInfo(user + '--');
|
||||
if (!client) {
|
||||
if (isGet) {
|
||||
if (isAdmin) {
|
||||
// const html = createStudioAppListHtml({ user, appIds: ids, userAppKey, infoList });
|
||||
// res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
// res.end(html);
|
||||
// https://kevisual.cn/root/v1-manager/
|
||||
const html = await fetch(`${baseProxyUrl}/root/v1-manager/index.html`).then(res => res.text());
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(renderServerHtml({ user, appIds: ids, userAppKey, infoList }, html));
|
||||
return true;
|
||||
} else {
|
||||
opts?.createNotFoundPage?.('应用访问失败');
|
||||
}
|
||||
} else {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: '应用访问失败' }));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const path = searchParams.get('path');
|
||||
if (!path) {
|
||||
// 显示前端页面
|
||||
const html = await fetch(`${baseProxyUrl}/root/router-studio/index.html`).then(res => res.text());
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(html);
|
||||
return true;
|
||||
}
|
||||
let message: any = data;
|
||||
if (!isAdmin) {
|
||||
message = omit(data, ['token', 'cookies']);
|
||||
}
|
||||
if (client.status === 'waiting') {
|
||||
res.writeHead(603, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ message: '应用没有鉴权' }));
|
||||
return true;
|
||||
}
|
||||
const value = await client.sendData(message, {
|
||||
state: { tokenUser: omit(loginUser.tokenUser, ['oauthExpand']) },
|
||||
});
|
||||
if (value) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(value));
|
||||
return true;
|
||||
}
|
||||
opts?.createNotFoundPage?.('应用未启动');
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse, opts?: { user?: string, app?: string, userAppKey?: string, isAdmin?: boolean }) => {
|
||||
const { user, userAppKey } = opts || {};
|
||||
const isGet = req.method === 'GET';
|
||||
// 获取所有的管理员的应用列表
|
||||
const { ids, infoList } = wsProxyManager.getIdsInfo(user + '--');
|
||||
if (isGet) {
|
||||
const html = await fetch(`${baseProxyUrl}/root/v1-manager/index.html`).then(res => res.text());
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(html);
|
||||
return;
|
||||
} else {
|
||||
const url = new URL(req.url || '', 'http://localhost');
|
||||
const path = url.searchParams.get('path');
|
||||
if (path) {
|
||||
const appId = url.searchParams.get('appId') || '';
|
||||
const client = wsProxyManager.get(appId!)!;
|
||||
if (!client) {
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 404, message: '应用未找到' }));
|
||||
return;
|
||||
}
|
||||
if (path === 'connected') {
|
||||
client.sendConnected();
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 200, message: '应用已连接' }));
|
||||
return;
|
||||
}
|
||||
if (path === 'rename') {
|
||||
const newId = url.searchParams.get('newId') || '';
|
||||
if (!newId) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 400, message: 'newId 参数缺失' }));
|
||||
return;
|
||||
}
|
||||
const realNewId = user + '--' + newId;
|
||||
const wsMessage = wsProxyManager.get(realNewId!)!;
|
||||
if (wsMessage) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 400, message: 'newId 已存在' }));
|
||||
return;
|
||||
}
|
||||
wsProxyManager.changeId(appId, realNewId);
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 200, message: '应用重命名成功' }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ code: 200, data: { ids, infoList } }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { WsProxyManager } from './manager.ts';
|
||||
import { getLoginUserByToken } from '@/modules/auth.ts';
|
||||
import { logger } from '../logger.ts';
|
||||
export const wsProxyManager = new WsProxyManager();
|
||||
import { WebSocketListenerFun } from '@kevisual/router/src/server/server-type.ts'
|
||||
export const wssFun: WebSocketListenerFun = async (req, res) => {
|
||||
// do nothing, just to enable ws upgrade event
|
||||
const { id, ws, token, data, emitter } = req;
|
||||
logger.debug('ws proxy connected, id=', id, ' token=', token, ' data=', data);
|
||||
// console.log('req', req)
|
||||
const { type } = data || {};
|
||||
if (type === 'registryClient') {
|
||||
const loginUser = await getLoginUserByToken(token);
|
||||
if (!loginUser?.tokenUser) {
|
||||
logger.debug('未登录,断开连接');
|
||||
ws.send(JSON.stringify({ code: 401, message: '未登录' }));
|
||||
setTimeout(() => {
|
||||
ws.close(401, 'Unauthorized');
|
||||
}, 1000);
|
||||
return;
|
||||
}
|
||||
const user = loginUser?.tokenUser?.username;
|
||||
const userApp = user + '--' + id;
|
||||
logger.debug('注册 ws 连接', userApp);
|
||||
const wsMessage = wsProxyManager.get(userApp);
|
||||
if (wsMessage) {
|
||||
logger.debug('ws 连接已存在,关闭旧连接', userApp);
|
||||
wsMessage.ws.close();
|
||||
wsProxyManager.unregister(userApp);
|
||||
}
|
||||
// @ts-ignore
|
||||
wsProxyManager.register(userApp, { user, ws });
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'connected',
|
||||
user: user,
|
||||
id,
|
||||
}),
|
||||
);
|
||||
emitter.once('close--' + id, () => {
|
||||
logger.debug('ws emitter closed');
|
||||
wsProxyManager.unregister(userApp);
|
||||
});
|
||||
// @ts-ignore
|
||||
ws.data.userApp = userApp;
|
||||
return;
|
||||
}
|
||||
// @ts-ignore
|
||||
const userApp = ws.data.userApp;
|
||||
logger.debug('message', data, ' userApp=', userApp);
|
||||
const wsMessage = wsProxyManager.get(userApp);
|
||||
if (wsMessage) {
|
||||
wsMessage.sendResponse(data);
|
||||
} else {
|
||||
// @ts-ignore
|
||||
logger.debug('账号应用未注册,无法处理消息。未授权?', ws.data);
|
||||
}
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
import { nanoid } from 'nanoid';
|
||||
import { WebSocket } from 'ws';
|
||||
import { logger } from '../logger.ts';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { set } from 'zod';
|
||||
|
||||
class WsMessage {
|
||||
ws: WebSocket;
|
||||
user?: string;
|
||||
emitter: EventEmitter;
|
||||
private pingTimer?: NodeJS.Timeout;
|
||||
private readonly PING_INTERVAL = 30000; // 30 秒发送一次 ping
|
||||
|
||||
constructor({ ws, user }: WssMessageOptions) {
|
||||
this.ws = ws;
|
||||
this.user = user;
|
||||
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) {
|
||||
if (data.id) {
|
||||
this.emitter.emit(data.id, data?.data);
|
||||
}
|
||||
}
|
||||
async sendData(data: any, context?: any, opts?: { timeout?: number }) {
|
||||
if (this.ws.readyState !== WebSocket.OPEN) {
|
||||
return { code: 500, message: 'WebSocket is not open' };
|
||||
}
|
||||
const timeout = opts?.timeout || 10 * 6 * 1000; // 10 minutes
|
||||
const id = nanoid();
|
||||
const message = JSON.stringify({
|
||||
id,
|
||||
type: 'proxy',
|
||||
data: {
|
||||
message: data,
|
||||
context: context || {},
|
||||
},
|
||||
});
|
||||
logger.info('ws-proxy sendData', message);
|
||||
this.ws.send(message);
|
||||
const msg = { path: data?.path, key: data?.key, id: data?.id };
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
resolve({
|
||||
code: 500,
|
||||
message: `运行超时,执行的id: ${id},参数是${JSON.stringify(msg)}`,
|
||||
});
|
||||
}, timeout);
|
||||
this.emitter.once(id, (data: any) => {
|
||||
resolve(data);
|
||||
clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
type WssMessageOptions = {
|
||||
ws: WebSocket;
|
||||
user?: string;
|
||||
};
|
||||
export class WsProxyManager {
|
||||
wssMap: Map<string, WsMessage> = new Map();
|
||||
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 }) {
|
||||
if (this.wssMap.has(id)) {
|
||||
const value = this.wssMap.get(id);
|
||||
if (value) {
|
||||
value.ws.close();
|
||||
value.destroy();
|
||||
}
|
||||
}
|
||||
const [username, appId] = id.split('--');
|
||||
const url = new URL(`/${username}/v1/${appId}`, 'https://kevisual.cn/');
|
||||
console.log('WsProxyManager register', id, '访问地址', url.toString());
|
||||
const value = new WsMessage({ ws: opts?.ws, user: opts?.user });
|
||||
this.wssMap.set(id, value);
|
||||
}
|
||||
unregister(id: string) {
|
||||
const value = this.wssMap.get(id);
|
||||
if (value) {
|
||||
value.ws.close();
|
||||
value.destroy();
|
||||
}
|
||||
this.wssMap.delete(id);
|
||||
}
|
||||
getIds(beginWith?: string) {
|
||||
if (beginWith) {
|
||||
return Array.from(this.wssMap.keys()).filter(key => key.startsWith(beginWith));
|
||||
}
|
||||
return Array.from(this.wssMap.keys());
|
||||
}
|
||||
get(id: string) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
import { IncomingMessage, ServerResponse } from 'http';
|
||||
import { wsProxyManager } from './index.ts';
|
||||
|
||||
import { App } from '@kevisual/router';
|
||||
import { logger } from '../logger.ts';
|
||||
import { getLoginUser } from '@/modules/auth.ts';
|
||||
import { createStudioAppListHtml } from '../html/studio-app-list/index.ts';
|
||||
import { omit } from 'es-toolkit';
|
||||
|
||||
type ProxyOptions = {
|
||||
createNotFoundPage: (msg?: string) => any;
|
||||
};
|
||||
export const UserV1Proxy = async (req: IncomingMessage, res: ServerResponse, opts?: ProxyOptions) => {
|
||||
const { url } = req;
|
||||
const _url = new URL(url || '', `http://localhost`);
|
||||
const { pathname, searchParams } = _url;
|
||||
let [user, app, userAppKey] = pathname.split('/').slice(1);
|
||||
if (!user || !app) {
|
||||
opts?.createNotFoundPage?.('应用未找到');
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await App.handleRequest(req, res);
|
||||
const loginUser = await getLoginUser(req);
|
||||
if (!loginUser) {
|
||||
opts?.createNotFoundPage?.('没有登录');
|
||||
return false;
|
||||
}
|
||||
|
||||
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: 如果不是管理员,是否需要添加其他人可以访问的逻辑?
|
||||
if (!isAdmin) {
|
||||
opts?.createNotFoundPage?.('没有访问应用权限');
|
||||
return false;
|
||||
}
|
||||
if (!userAppKey.startsWith(user + '--')) {
|
||||
userAppKey = user + '--' + userAppKey;
|
||||
}
|
||||
logger.debug('data', data);
|
||||
const client = wsProxyManager.get(userAppKey);
|
||||
const ids = wsProxyManager.getIds(user + '--');
|
||||
if (!client) {
|
||||
if (isAdmin) {
|
||||
const html = createStudioAppListHtml({ user, appIds: ids, userAppKey });
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(html);
|
||||
} else {
|
||||
opts?.createNotFoundPage?.('应用访问失败');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const path = searchParams.get('path');
|
||||
if (!path) {
|
||||
// 显示前端页面
|
||||
const html = fetch('https://kevisual.cn/root/router-studio/index.html').then(res => res.text());
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(await html);
|
||||
return true;
|
||||
}
|
||||
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) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(value));
|
||||
return true;
|
||||
}
|
||||
opts?.createNotFoundPage?.('应用未启动');
|
||||
return true;
|
||||
};
|
||||
87
src/route.ts
87
src/route.ts
@@ -4,6 +4,25 @@ import { app } from './app.ts';
|
||||
import type { App } from '@kevisual/router';
|
||||
import { User } from './models/user.ts';
|
||||
import { createCookie, getSomeInfoFromReq } from './routes/user/me.ts';
|
||||
import { toJSONSchema } from '@kevisual/router';
|
||||
import { pick } from 'es-toolkit';
|
||||
/**
|
||||
* 验证上下文中的 App ID 是否与指定的 App ID 匹配
|
||||
* @param {any} ctx - 上下文对象,可能包含 appId 属性
|
||||
* @param {string} appId - 需要验证的目标 App ID
|
||||
* @returns {boolean} 如果 ctx 中包含 appId 且匹配则返回 true,否则返回 false
|
||||
* @throws {Error} 如果 ctx 中包含 appId 但不匹配,则抛出 403 错误
|
||||
*/
|
||||
const checkAppId = (ctx: any, appId: string) => {
|
||||
const _appId = ctx?.app?.appId;
|
||||
if (_appId) {
|
||||
if (_appId !== appId) {
|
||||
ctx.throw(403, 'Invalid App ID');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加auth中间件, 用于验证token
|
||||
@@ -16,11 +35,17 @@ export const addAuth = (app: App) => {
|
||||
app
|
||||
.route({
|
||||
path: 'auth',
|
||||
id: 'auth',
|
||||
rid: 'auth',
|
||||
description: '验证token,必须成功, 错误返回401,正确赋值到ctx.state.tokenUser',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const token = ctx.query.token;
|
||||
// if (checkAppId(ctx, app.appId)) {
|
||||
// ctx.state.tokenUser = {
|
||||
// username: 'default',
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
// 已经有用户信息则直接返回,不需要重复验证
|
||||
if (ctx.state.tokenUser) {
|
||||
return;
|
||||
@@ -46,10 +71,16 @@ export const addAuth = (app: App) => {
|
||||
.route({
|
||||
path: 'auth',
|
||||
key: 'can',
|
||||
id: 'auth-can',
|
||||
rid: 'auth-can',
|
||||
description: '验证token,可以不成功,错误不返回401,正确赋值到ctx.state.tokenUser,失败赋值null',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
// if (checkAppId(ctx, app.appId)) {
|
||||
// ctx.state.tokenUser = {
|
||||
// username: 'default',
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
// 已经有用户信息则直接返回,不需要重复验证
|
||||
if (ctx.state.tokenUser) {
|
||||
return;
|
||||
@@ -76,12 +107,18 @@ app
|
||||
.route({
|
||||
path: 'auth',
|
||||
key: 'admin',
|
||||
id: 'auth-admin',
|
||||
rid: 'auth-admin',
|
||||
isDebug: true,
|
||||
middleware: ['auth'],
|
||||
description: '验证token,必须是admin用户, 错误返回403,正确赋值到ctx.state.tokenAdmin',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
// if (checkAppId(ctx, app.appId)) {
|
||||
// ctx.state.tokenUser = {
|
||||
// username: 'default',
|
||||
// }
|
||||
// return;
|
||||
// }
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
if (!tokenUser) {
|
||||
ctx.throw(401, 'No User For authorized');
|
||||
@@ -92,9 +129,7 @@ app
|
||||
}
|
||||
try {
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
id: tokenUser.id,
|
||||
},
|
||||
id: tokenUser.id,
|
||||
});
|
||||
if (!user) {
|
||||
ctx.throw(404, 'user not found');
|
||||
@@ -119,7 +154,7 @@ app
|
||||
.route({
|
||||
path: 'auth-check',
|
||||
key: 'admin',
|
||||
id: 'check-auth-admin',
|
||||
rid: 'check-auth-admin',
|
||||
middleware: ['auth'],
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
@@ -133,9 +168,7 @@ app
|
||||
|
||||
try {
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
id: tokenUser.id,
|
||||
},
|
||||
id: tokenUser.id,
|
||||
});
|
||||
if (!user) {
|
||||
ctx.throw(404, 'user not found');
|
||||
@@ -163,37 +196,9 @@ app
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'router',
|
||||
key: 'list',
|
||||
description: '列出所有的当前的可请求的路由信息',
|
||||
middleware: ['auth-can']
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
let isUser = !!tokenUser;
|
||||
ctx.body = {
|
||||
list: app.router.routes.filter(item => {
|
||||
if (item.id === 'auth' || item.id === 'auth-can' || item.id === 'check-auth-admin' || item.id === 'auth-admin') {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((item) => {
|
||||
return {
|
||||
id: item.id,
|
||||
path: item.path,
|
||||
key: item.key,
|
||||
description: item.description,
|
||||
middeleware: item.middleware,
|
||||
metadata: item.metadata,
|
||||
};
|
||||
}),
|
||||
isUser
|
||||
}
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app.createRouteList({
|
||||
middleware: ['auth-can']
|
||||
})
|
||||
|
||||
app.route({
|
||||
path: 'system',
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import http from 'node:http';
|
||||
import { router } from './router.ts';
|
||||
import './index.ts';
|
||||
import { handleRequest as PageProxy } from './page-proxy.ts';
|
||||
|
||||
const simpleAppsPrefixs = [
|
||||
"/api/wxmsg"
|
||||
];
|
||||
|
||||
|
||||
export const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
if (req.url?.startsWith('/api/router')) {
|
||||
// router自己管理
|
||||
return;
|
||||
}
|
||||
// if (req.url === '/MP_verify_NGWvli5lGpEkByyt.txt') {
|
||||
// res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
// res.end('NGWvli5lGpEkByyt');
|
||||
// return;
|
||||
// }
|
||||
if (req.url && simpleAppsPrefixs.some(prefix => req.url!.startsWith(prefix))) {
|
||||
// 简单应用路由处理
|
||||
// 设置跨域
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
return router.parse(req, res);
|
||||
}
|
||||
// 其他请求交给页面代理处理
|
||||
return PageProxy(req, res);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import http from 'node:http';
|
||||
import { router } from './router.ts';
|
||||
import { handleRequest as PageProxy } from './page-proxy.ts';
|
||||
|
||||
import './routes/jwks.ts'
|
||||
import './routes/ai/openai.ts'
|
||||
|
||||
const simpleAppsPrefixs = [
|
||||
"/api/wxmsg",
|
||||
"/api/convex/",
|
||||
"/api/chat/completions"
|
||||
];
|
||||
|
||||
|
||||
export const handleRequest = async (req: http.IncomingMessage, res: http.ServerResponse) => {
|
||||
if (req.url?.startsWith('/api/router')) {
|
||||
// router自己管理
|
||||
return;
|
||||
}
|
||||
// if (req.url === '/MP_verify_NGWvli5lGpEkByyt.txt') {
|
||||
// res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
// res.end('NGWvli5lGpEkByyt');
|
||||
// return;
|
||||
// }
|
||||
if (req.url && simpleAppsPrefixs.some(prefix => req.url!.startsWith(prefix))) {
|
||||
// 简单应用路由处理
|
||||
// 设置跨域
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
|
||||
return router.parse(req, res);
|
||||
}
|
||||
// 其他请求交给页面代理处理
|
||||
return PageProxy(req, res);
|
||||
};
|
||||
|
||||
@@ -12,11 +12,12 @@ import { UserPermission } from '@kevisual/permission';
|
||||
import { getLoginUser } from '../modules/auth.ts';
|
||||
import { rediretHome } from '../modules/user-app/index.ts';
|
||||
import { logger } from '../modules/logger.ts';
|
||||
import { UserV1Proxy } from '../modules/ws-proxy/proxy.ts';
|
||||
import { UserV1Proxy } from '../modules/v1-ws-proxy/proxy.ts';
|
||||
import { UserV3Proxy } from '@/modules/v3/index.ts';
|
||||
import { hasBadUser, userIsBanned, appIsBanned, userPathIsBanned } from '@/modules/off/index.ts';
|
||||
import { robotsTxt } from '@/modules/html/index.ts';
|
||||
import { isBun } from '@/utils/get-engine.ts';
|
||||
import { N5Proxy } from '@/modules/n5/index.ts';
|
||||
const domain = config?.proxy?.domain;
|
||||
const allowedOrigins = config?.proxy?.allowedOrigin || [];
|
||||
|
||||
@@ -149,6 +150,11 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
const isDev = isLocalhost(dns?.hostName);
|
||||
if (isDev) {
|
||||
console.debug('开发环境访问:', req.url, 'Host:', dns.hostName);
|
||||
if (req.url === '/') {
|
||||
res.writeHead(302, { Location: '/root/router-studio/' });
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (isIpv4OrIpv6(dns.hostName)) {
|
||||
// 打印出 req.url 和错误信息
|
||||
@@ -183,7 +189,7 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
/**
|
||||
* url是pathname的路径
|
||||
*/
|
||||
const url = pathname;
|
||||
const url = pathname || '';
|
||||
if (!domainApp && noProxyUrl.includes(url)) {
|
||||
if (url === '/') {
|
||||
rediretHome(req, res);
|
||||
@@ -255,6 +261,11 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
createNotFoundPage,
|
||||
});
|
||||
}
|
||||
if (user === 'n5') {
|
||||
return N5Proxy(req, res, {
|
||||
createNotFoundPage,
|
||||
});
|
||||
}
|
||||
if (user !== 'api' && app === 'v3') {
|
||||
return UserV3Proxy(req, res, {
|
||||
createNotFoundPage,
|
||||
@@ -306,13 +317,14 @@ export const handleRequest = async (req: http.IncomingMessage, res: http.ServerR
|
||||
const indexFile = isExist.indexFilePath; // 已经必定存在了
|
||||
try {
|
||||
let appFileUrl: string;
|
||||
if (domainApp) {
|
||||
appFileUrl = (url + '').replace(`/`, '');
|
||||
} else {
|
||||
appFileUrl = (url + '').replace(`/${user}/${app}/`, '');
|
||||
}
|
||||
|
||||
appFileUrl = url.replace(`/${user}/${app}/`, '');
|
||||
appFileUrl = decodeURIComponent(appFileUrl); // Decode URL components
|
||||
let appFile = await userApp.getFile(appFileUrl);
|
||||
if (!appFile && domainApp) {
|
||||
const domainAppFileUrl = url.replace(`/`, '');
|
||||
appFile = await userApp.getFile(domainAppFileUrl);
|
||||
}
|
||||
if (!appFile && url.endsWith('/')) {
|
||||
appFile = await userApp.getFile(appFileUrl + 'index.html');
|
||||
} else if (!appFile && !url.endsWith('/')) {
|
||||
|
||||
51
src/routes-simple/routes/ai/anthropic.ts
Normal file
51
src/routes-simple/routes/ai/anthropic.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { router } from '@/app.ts';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { pipeProxyReq, pipeProxyRes } from '@/modules/fm-manager/index.ts';
|
||||
import { useKey } from '@kevisual/context';
|
||||
|
||||
/**
|
||||
* TODO: 由于目前没有找到合适的开源 Anthropic 兼容实现,暂时先把 /v1/messages 请求代理到配置的 OpenAI 兼容目标地址,等后续有了合适的 Anthropic 兼容实现再改回来
|
||||
* 代理 /v1/messages 请求到配置的 OpenAI 兼容目标地址
|
||||
* 配置项: config.OPENAI_BASE_URL,例如 http://localhost:11434/v1
|
||||
*
|
||||
*/
|
||||
router.all("/v1/messages", async (req, res) => {
|
||||
const targetUrl = new URL('https://api.cnb.cool/kevisual/kevisual/-/ai/chat/messages');
|
||||
const token = useKey('CNB_API_KEY');
|
||||
// 收集并转发请求头(排除 host 和 authorization,用自己的 token 替换)
|
||||
const headers: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'authorization') {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
headers['authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const isHttps = targetUrl.protocol === 'https:';
|
||||
const protocol = isHttps ? https : http;
|
||||
|
||||
const options: http.RequestOptions = {
|
||||
hostname: targetUrl.hostname,
|
||||
port: targetUrl.port || (isHttps ? 443 : 80),
|
||||
path: targetUrl.pathname + (targetUrl.search || ''),
|
||||
method: req.method,
|
||||
headers,
|
||||
...(isHttps ? { rejectUnauthorized: false } : {}),
|
||||
};
|
||||
|
||||
const proxyReq = protocol.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
||||
pipeProxyRes(proxyRes, res);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error('[anthropic proxy] error:', err.message);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
});
|
||||
|
||||
pipeProxyReq(req, proxyReq, res);
|
||||
});
|
||||
50
src/routes-simple/routes/ai/openai.ts
Normal file
50
src/routes-simple/routes/ai/openai.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { router } from '@/app.ts';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { pipeProxyReq, pipeProxyRes } from '@/modules/fm-manager/index.ts';
|
||||
import { useKey } from '@kevisual/context';
|
||||
|
||||
/**
|
||||
* 代理 /api/chat/completions 请求到配置的 OpenAI 兼容目标地址
|
||||
* 配置项: config.OPENAI_BASE_URL,例如 http://localhost:11434/v1
|
||||
*/
|
||||
router.all("/api/chat/completions", async (req, res) => {
|
||||
const targetUrl = new URL('https://api.cnb.cool/kevisual/kevisual/-/ai/chat/completions');
|
||||
|
||||
const token = useKey('CNB_API_KEY');
|
||||
// 收集并转发请求头(排除 host 和 authorization,用自己的 token 替换)
|
||||
const headers: Record<string, any> = {};
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (key.toLowerCase() !== 'host' && key.toLowerCase() !== 'authorization') {
|
||||
headers[key] = value;
|
||||
}
|
||||
}
|
||||
if (token) {
|
||||
headers['authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const isHttps = targetUrl.protocol === 'https:';
|
||||
const protocol = isHttps ? https : http;
|
||||
|
||||
const options: http.RequestOptions = {
|
||||
hostname: targetUrl.hostname,
|
||||
port: targetUrl.port || (isHttps ? 443 : 80),
|
||||
path: targetUrl.pathname + (targetUrl.search || ''),
|
||||
method: req.method,
|
||||
headers,
|
||||
...(isHttps ? { rejectUnauthorized: false } : {}),
|
||||
};
|
||||
|
||||
const proxyReq = protocol.request(options, (proxyRes) => {
|
||||
res.writeHead(proxyRes.statusCode || 200, proxyRes.headers);
|
||||
pipeProxyRes(proxyRes, res);
|
||||
});
|
||||
|
||||
proxyReq.on('error', (err) => {
|
||||
console.error('[openai proxy] error:', err.message);
|
||||
res.writeHead(502, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: err.message }));
|
||||
});
|
||||
|
||||
pipeProxyReq(req, proxyReq, res);
|
||||
});
|
||||
18
src/routes-simple/routes/jwks.ts
Normal file
18
src/routes-simple/routes/jwks.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { router } from '@/app.ts'
|
||||
import { manager } from '@/auth/models/jwks-manager.ts'
|
||||
router.all('/api/convex/jwks.json', async (req, res) => {
|
||||
const jwks = await manager.getJWKS()
|
||||
res.setHeader('Content-Type', 'application/json');
|
||||
res.end(JSON.stringify(jwks));
|
||||
})
|
||||
|
||||
// rsync -avz kevisual:/root/kevisual/assistant-app/storage/jwks/ ./storage/jwks
|
||||
|
||||
// router.all('/api/convex/sign', async (req, res) => {
|
||||
// const payload = {
|
||||
// sub: 'abc'
|
||||
// };
|
||||
// const token = await manager.sign(payload);
|
||||
// res.setHeader('Content-Type', 'application/json');
|
||||
// res.end(JSON.stringify({ token }));
|
||||
// });
|
||||
@@ -1,12 +1,11 @@
|
||||
import { AppModel } from '../module/index.ts';
|
||||
import { db, schema } from '@/app.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
export const mvAppFromUserAToUserB = async (userA: string, userB: string) => {
|
||||
const appList = await AppModel.findAll({
|
||||
where: {
|
||||
user: userA,
|
||||
},
|
||||
});
|
||||
const appList = await db.select().from(schema.kvApp).where(eq(schema.kvApp.user, userA));
|
||||
for (const app of appList) {
|
||||
app.user = userB;
|
||||
await app.save();
|
||||
await db.update(schema.kvApp)
|
||||
.set({ user: userB, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.kvApp.id, app.id));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,25 +1,34 @@
|
||||
import { app } from '@/app.ts';
|
||||
import { AppModel } from '../module/app.ts';
|
||||
import { AppDomainModel } from '../module/app-domain.ts';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
import { AppDomain, AppDomainHelper } from '../module/app-domain-drizzle.ts';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app',
|
||||
key: 'getDomainApp',
|
||||
description: '根据域名获取应用信息',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
domain: z.string().describe('域名'),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { domain } = ctx.query.data;
|
||||
// const query = {
|
||||
// }
|
||||
const domainInfo = await AppDomainModel.findOne({ where: { domain } });
|
||||
const { domain } = ctx.args.data;
|
||||
const domainInfos = await db.select().from(schema.kvAppDomain).where(eq(schema.kvAppDomain.domain, domain)).limit(1);
|
||||
const domainInfo = domainInfos[0];
|
||||
if (!domainInfo || !domainInfo.appId) {
|
||||
ctx.throw(404, 'app not found');
|
||||
}
|
||||
const app = await AppModel.findByPk(domainInfo.appId);
|
||||
if (!app) {
|
||||
const apps = await db.select().from(schema.kvApp).where(eq(schema.kvApp.id, domainInfo.appId)).limit(1);
|
||||
const appFound = apps[0];
|
||||
if (!appFound) {
|
||||
ctx.throw(404, 'app not found');
|
||||
}
|
||||
ctx.body = app;
|
||||
ctx.body = appFound;
|
||||
return ctx;
|
||||
})
|
||||
.addTo(app);
|
||||
@@ -29,15 +38,25 @@ app
|
||||
path: 'app-domain',
|
||||
key: 'create',
|
||||
middleware: ['auth'],
|
||||
description: '创建应用域名绑定',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
domain: z.string().describe('域名'),
|
||||
appId: z.string().describe('应用ID'),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const uid = tokenUser.uid;
|
||||
const { domain, appId } = ctx.query.data || {};
|
||||
const { domain, appId } = ctx.args.data || {};
|
||||
if (!domain || !appId) {
|
||||
ctx.throw(400, 'domain and appId are required');
|
||||
}
|
||||
const domainInfo = await AppDomainModel.create({ domain, appId, uid });
|
||||
const newDomains = await db.insert(schema.kvAppDomain).values({ domain, appId, uid }).returning();
|
||||
const domainInfo = newDomains[0];
|
||||
ctx.body = domainInfo;
|
||||
return ctx;
|
||||
})
|
||||
@@ -48,23 +67,38 @@ app
|
||||
path: 'app-domain',
|
||||
key: 'update',
|
||||
middleware: ['auth'],
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().optional().describe('域名ID'),
|
||||
domain: z.string().optional().describe('域名'),
|
||||
appId: z.string().optional().describe('应用ID'),
|
||||
status: z.string().describe('状态'),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const uid = tokenUser.uid;
|
||||
const { id, domain, appId, status } = ctx.query.data || {};
|
||||
const { id, domain, appId, status } = ctx.args.data || {};
|
||||
if (!domain && !id) {
|
||||
ctx.throw(400, 'domain and id are required at least one');
|
||||
}
|
||||
if (!status) {
|
||||
ctx.throw(400, 'status is required');
|
||||
}
|
||||
let domainInfo: AppDomainModel | null = null;
|
||||
let domainInfo: AppDomain | undefined;
|
||||
if (id) {
|
||||
domainInfo = await AppDomainModel.findByPk(id);
|
||||
const domains = await db.select().from(schema.kvAppDomain).where(eq(schema.kvAppDomain.id, id)).limit(1);
|
||||
domainInfo = domains[0];
|
||||
}
|
||||
if (!domainInfo && domain) {
|
||||
domainInfo = await AppDomainModel.findOne({ where: { domain, appId } });
|
||||
const domains = await db.select().from(schema.kvAppDomain).where(and(
|
||||
eq(schema.kvAppDomain.domain, domain),
|
||||
eq(schema.kvAppDomain.appId, appId)
|
||||
)).limit(1);
|
||||
domainInfo = domains[0];
|
||||
}
|
||||
if (!domainInfo) {
|
||||
ctx.throw(404, 'domain not found');
|
||||
@@ -72,19 +106,23 @@ app
|
||||
if (domainInfo.uid !== uid) {
|
||||
ctx.throw(403, 'domain must be owned by the user');
|
||||
}
|
||||
if (!domainInfo.checkCanUpdateStatus(status)) {
|
||||
if (!AppDomainHelper.checkCanUpdateStatus(domainInfo.status!, status as any)) {
|
||||
ctx.throw(400, 'domain status can not be updated');
|
||||
}
|
||||
const updateData: any = {};
|
||||
if (status) {
|
||||
domainInfo.status = status;
|
||||
updateData.status = status;
|
||||
}
|
||||
|
||||
if (appId) {
|
||||
domainInfo.appId = appId;
|
||||
updateData.appId = appId;
|
||||
}
|
||||
await domainInfo.save({ fields: ['status', 'appId'] });
|
||||
|
||||
ctx.body = domainInfo;
|
||||
updateData.updatedAt = new Date().toISOString();
|
||||
const updateResult = await db.update(schema.kvAppDomain)
|
||||
.set(updateData)
|
||||
.where(eq(schema.kvAppDomain.id, domainInfo.id))
|
||||
.returning();
|
||||
const updatedDomain = updateResult[0];
|
||||
ctx.body = updatedDomain;
|
||||
return ctx;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
import { app } from '@/app.ts';
|
||||
import { AppDomainModel } from '../module/app-domain.ts';
|
||||
import { AppModel } from '../module/app.ts';
|
||||
import { CustomError } from '@kevisual/router';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
import { AppDomain, AppDomainHelper } from '../module/app-domain-drizzle.ts';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app.domain.manager',
|
||||
path: 'app_domain_manager',
|
||||
key: 'list',
|
||||
description: '获取域名列表,支持分页',
|
||||
middleware: ['auth-admin'],
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
page: z.number().optional(),
|
||||
pageSize: z.number().optional(),
|
||||
}).optional()
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { page = 1, pageSize = 999 } = ctx.query.data || {};
|
||||
const { count, rows } = await AppDomainModel.findAndCountAll({
|
||||
offset: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
});
|
||||
const offset = (page - 1) * pageSize;
|
||||
const rows = await db.select().from(schema.kvAppDomain)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
const countResult = await db.select().from(schema.kvAppDomain);
|
||||
const count = countResult.length;
|
||||
ctx.body = { count, list: rows, pagination: { page, pageSize } };
|
||||
return ctx;
|
||||
})
|
||||
@@ -22,20 +33,31 @@ app
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app.domain.manager',
|
||||
path: 'app_domain_manager',
|
||||
key: 'update',
|
||||
description: '更新一个域名的信息',
|
||||
middleware: ['auth-admin'],
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().optional(),
|
||||
domain: z.string(),
|
||||
appId: z.string().optional(),
|
||||
status: z.enum(['active', 'inactive']).optional(),
|
||||
data: z.record(z.string(), z.any()).optional(),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { domain, data, id, ...rest } = ctx.query.data || {};
|
||||
if (!domain) {
|
||||
ctx.throw(400, 'domain is required');
|
||||
}
|
||||
let domainInfo: AppDomainModel;
|
||||
let domainInfo: AppDomain | undefined;
|
||||
if (id) {
|
||||
domainInfo = await AppDomainModel.findByPk(id);
|
||||
} else {
|
||||
domainInfo = await AppDomainModel.create({ domain });
|
||||
const domains = await db.select().from(schema.kvAppDomain).where(eq(schema.kvAppDomain.id, id)).limit(1);
|
||||
domainInfo = domains[0];
|
||||
}
|
||||
const checkAppId = async () => {
|
||||
const isUUID = (id: string) => {
|
||||
@@ -45,7 +67,8 @@ app
|
||||
if (!isUUID(rest.appId)) {
|
||||
ctx.throw(400, 'appId is not valid');
|
||||
}
|
||||
const appInfo = await AppModel.findByPk(rest.appId);
|
||||
const apps = await db.select().from(schema.kvApp).where(eq(schema.kvApp.id, rest.appId)).limit(1);
|
||||
const appInfo = apps[0];
|
||||
if (!appInfo) {
|
||||
ctx.throw(400, 'appId is not exist');
|
||||
}
|
||||
@@ -53,24 +76,31 @@ app
|
||||
};
|
||||
try {
|
||||
if (!domainInfo) {
|
||||
domainInfo = await AppDomainModel.create({ domain, data: {}, ...rest });
|
||||
await checkAppId();
|
||||
const newDomains = await db.insert(schema.kvAppDomain).values({ domain, data: {}, ...rest }).returning();
|
||||
domainInfo = newDomains[0];
|
||||
} else {
|
||||
if (rest.status && domainInfo.status !== rest.status) {
|
||||
await domainInfo.clearCache();
|
||||
await AppDomainHelper.clearCache(domainInfo.domain!);
|
||||
}
|
||||
await checkAppId();
|
||||
await domainInfo.update({
|
||||
domain,
|
||||
data: {
|
||||
...domainInfo.data,
|
||||
...data,
|
||||
},
|
||||
...rest,
|
||||
});
|
||||
const domainData = domainInfo.data as any;
|
||||
const updateResult = await db.update(schema.kvAppDomain)
|
||||
.set({
|
||||
domain,
|
||||
data: {
|
||||
...domainData,
|
||||
...data,
|
||||
},
|
||||
...rest,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
.where(eq(schema.kvAppDomain.id, domainInfo.id))
|
||||
.returning();
|
||||
domainInfo = updateResult[0];
|
||||
}
|
||||
ctx.body = domainInfo;
|
||||
} catch (error) {
|
||||
} catch (error: any) {
|
||||
if (error.code) {
|
||||
ctx.throw(error.code, error.message);
|
||||
}
|
||||
@@ -84,9 +114,18 @@ app
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app.domain.manager',
|
||||
path: 'app_domain_manager',
|
||||
key: 'delete',
|
||||
description: '删除一个域名',
|
||||
middleware: ['auth-admin'],
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { id, domain } = ctx.query.data || {};
|
||||
@@ -94,9 +133,9 @@ app
|
||||
ctx.throw(400, 'id or domain is required');
|
||||
}
|
||||
if (id) {
|
||||
await AppDomainModel.destroy({ where: { id }, force: true });
|
||||
await db.delete(schema.kvAppDomain).where(eq(schema.kvAppDomain.id, id));
|
||||
} else {
|
||||
await AppDomainModel.destroy({ where: { domain }, force: true });
|
||||
await db.delete(schema.kvAppDomain).where(eq(schema.kvAppDomain.domain, domain));
|
||||
}
|
||||
|
||||
ctx.body = { message: 'delete domain success' };
|
||||
@@ -106,16 +145,26 @@ app
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'app.domain.manager',
|
||||
path: 'app_domain_manager',
|
||||
key: 'get',
|
||||
description: '获取域名信息,可以通过id或者domain进行查询',
|
||||
middleware: ['auth-admin'],
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().optional(),
|
||||
domain: z.string().optional(),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { id, domain } = ctx.query.data || {};
|
||||
if (!id && !domain) {
|
||||
ctx.throw(400, 'id or domain is required');
|
||||
}
|
||||
const domainInfo = await AppDomainModel.findOne({ where: { id } });
|
||||
const domains = await db.select().from(schema.kvAppDomain).where(eq(schema.kvAppDomain.id, id)).limit(1);
|
||||
const domainInfo = domains[0];
|
||||
if (!domainInfo) {
|
||||
ctx.throw(404, 'domain not found');
|
||||
}
|
||||
|
||||
@@ -1,33 +1,41 @@
|
||||
import { App, CustomError } from '@kevisual/router';
|
||||
import { AppModel, AppListModel } from './module/index.ts';
|
||||
import { app, redis } from '@/app.ts';
|
||||
import { App as AppType, AppList, AppData } from './module/app-drizzle.ts';
|
||||
import { app, db, oss, schema } from '@/app.ts';
|
||||
import { uniqBy } from 'es-toolkit';
|
||||
import { getUidByUsername, prefixFix } from './util.ts';
|
||||
import { deleteFiles, getMinioListAndSetToAppList } from '../file/index.ts';
|
||||
import { deleteFiles, getMinioList, getMinioListAndSetToAppList } from '../file/index.ts';
|
||||
import { setExpire } from './revoke.ts';
|
||||
import { User } from '@/models/user.ts';
|
||||
import { callDetectAppVersion } from './export.ts';
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { logger } from '@/modules/logger.ts';
|
||||
app
|
||||
.route({
|
||||
path: 'app',
|
||||
key: 'list',
|
||||
middleware: ['auth'],
|
||||
description: '获取应用列表,根据key进行过滤',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
key: z.string().describe('应用的唯一标识')
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const data = ctx.query.data || {};
|
||||
if (!data.key) {
|
||||
throw new CustomError('key is required');
|
||||
ctx.throw('key is required');
|
||||
}
|
||||
const list = await AppListModel.findAll({
|
||||
order: [['updatedAt', 'DESC']],
|
||||
where: {
|
||||
uid: tokenUser.id,
|
||||
key: data.key,
|
||||
},
|
||||
logging: false,
|
||||
});
|
||||
const list = await db.select()
|
||||
.from(schema.kvAppList)
|
||||
.where(and(
|
||||
eq(schema.kvAppList.uid, tokenUser.id),
|
||||
eq(schema.kvAppList.key, data.key)
|
||||
))
|
||||
.orderBy(desc(schema.kvAppList.updatedAt));
|
||||
ctx.body = list.map((item) => prefixFix(item, tokenUser.username));
|
||||
return ctx;
|
||||
})
|
||||
@@ -39,6 +47,16 @@ app
|
||||
key: 'get',
|
||||
middleware: ['auth'],
|
||||
description: '获取应用详情,可以通过id,或者key+version来获取',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
create: z.boolean().optional().describe('如果应用版本不存在,是否创建应用版本记录,默认false'),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
console.log('get app manager called');
|
||||
@@ -46,35 +64,35 @@ app
|
||||
const id = ctx.query.id;
|
||||
const { key, version, create = false } = ctx.query?.data || {};
|
||||
if (!id && (!key || !version)) {
|
||||
throw new CustomError('id is required');
|
||||
ctx.throw('id is required');
|
||||
}
|
||||
let appListModel: AppListModel;
|
||||
let appListModel: AppList | undefined;
|
||||
if (id) {
|
||||
appListModel = await AppListModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvAppList).where(eq(schema.kvAppList.id, id)).limit(1);
|
||||
appListModel = apps[0];
|
||||
} else if (key && version) {
|
||||
appListModel = await AppListModel.findOne({
|
||||
where: {
|
||||
key,
|
||||
version,
|
||||
uid: tokenUser.id,
|
||||
},
|
||||
});
|
||||
const apps = await db.select().from(schema.kvAppList).where(and(
|
||||
eq(schema.kvAppList.key, key),
|
||||
eq(schema.kvAppList.version, version),
|
||||
eq(schema.kvAppList.uid, tokenUser.id)
|
||||
)).limit(1);
|
||||
appListModel = apps[0];
|
||||
}
|
||||
if (!appListModel && create) {
|
||||
appListModel = await AppListModel.create({
|
||||
const newApps = await db.insert(schema.kvAppList).values({
|
||||
key,
|
||||
version,
|
||||
uid: tokenUser.id,
|
||||
data: {},
|
||||
});
|
||||
const appModel = await AppModel.findOne({
|
||||
where: {
|
||||
key,
|
||||
uid: tokenUser.id,
|
||||
},
|
||||
});
|
||||
}).returning();
|
||||
appListModel = newApps[0];
|
||||
const appModels = await db.select().from(schema.kvApp).where(and(
|
||||
eq(schema.kvApp.key, key),
|
||||
eq(schema.kvApp.uid, tokenUser.id)
|
||||
)).limit(1);
|
||||
const appModel = appModels[0];
|
||||
if (!appModel) {
|
||||
await AppModel.create({
|
||||
await db.insert(schema.kvApp).values({
|
||||
key,
|
||||
uid: tokenUser.id,
|
||||
user: tokenUser.username,
|
||||
@@ -88,18 +106,17 @@ app
|
||||
if (res.code !== 200) {
|
||||
ctx.throw(res.message || 'detect version list error');
|
||||
}
|
||||
appListModel = await AppListModel.findOne({
|
||||
where: {
|
||||
key,
|
||||
version,
|
||||
uid: tokenUser.id,
|
||||
},
|
||||
});
|
||||
const apps2 = await db.select().from(schema.kvAppList).where(and(
|
||||
eq(schema.kvAppList.key, key),
|
||||
eq(schema.kvAppList.version, version),
|
||||
eq(schema.kvAppList.uid, tokenUser.id)
|
||||
)).limit(1);
|
||||
appListModel = apps2[0];
|
||||
}
|
||||
if (!appListModel) {
|
||||
ctx.throw('app not found');
|
||||
}
|
||||
console.log('get app', appListModel.id, appListModel.key, appListModel.version);
|
||||
logger.debug('get app', appListModel.id, appListModel.key, appListModel.version);
|
||||
ctx.body = prefixFix(appListModel, tokenUser.username);
|
||||
})
|
||||
.addTo(app);
|
||||
@@ -115,23 +132,29 @@ app
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { data, id, ...rest } = ctx.query.data;
|
||||
if (id) {
|
||||
const app = await AppListModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvAppList).where(eq(schema.kvAppList.id, id)).limit(1);
|
||||
const app = apps[0];
|
||||
if (app) {
|
||||
const newData = { ...app.data, ...data };
|
||||
const newApp = await app.update({ data: newData, ...rest });
|
||||
const appData = app.data as AppData;
|
||||
const newData = { ...appData, ...data };
|
||||
const updateResult = await db.update(schema.kvAppList)
|
||||
.set({ data: newData, ...rest, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.kvAppList.id, id))
|
||||
.returning();
|
||||
const newApp = updateResult[0];
|
||||
ctx.body = newApp;
|
||||
setExpire(newApp.id, 'test');
|
||||
} else {
|
||||
throw new CustomError('app not found');
|
||||
ctx.throw('app not found');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rest.key) {
|
||||
throw new CustomError('key is required');
|
||||
ctx.throw('key is required');
|
||||
}
|
||||
const app = await AppListModel.create({ data, ...rest, uid: tokenUser.id });
|
||||
ctx.body = app;
|
||||
const newApps = await db.insert(schema.kvAppList).values({ data, ...rest, uid: tokenUser.id }).returning();
|
||||
ctx.body = newApps[0];
|
||||
return ctx;
|
||||
})
|
||||
.addTo(app);
|
||||
@@ -147,26 +170,30 @@ app
|
||||
const id = ctx.query.id;
|
||||
const deleteFile = !!ctx.query.deleteFile; // 是否删除文件, 默认不删除
|
||||
if (!id) {
|
||||
throw new CustomError('id is required');
|
||||
ctx.throw('id is required');
|
||||
}
|
||||
const app = await AppListModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvAppList).where(eq(schema.kvAppList.id, id)).limit(1);
|
||||
const app = apps[0];
|
||||
if (!app) {
|
||||
throw new CustomError('app not found');
|
||||
ctx.throw('app not found');
|
||||
}
|
||||
const am = await AppModel.findOne({ where: { key: app.key, uid: app.uid } });
|
||||
const ams = await db.select().from(schema.kvApp).where(and(
|
||||
eq(schema.kvApp.key, app.key),
|
||||
eq(schema.kvApp.uid, app.uid)
|
||||
)).limit(1);
|
||||
const am = ams[0];
|
||||
if (!am) {
|
||||
throw new CustomError('app not found');
|
||||
ctx.throw('app not found');
|
||||
}
|
||||
if (am.version === app.version) {
|
||||
throw new CustomError('app is published');
|
||||
ctx.throw('app is published');
|
||||
}
|
||||
const files = app.data.files || [];
|
||||
const appData = app.data as AppData;
|
||||
const files = appData.files || [];
|
||||
if (deleteFile && files.length > 0) {
|
||||
await deleteFiles(files.map((item) => item.path));
|
||||
}
|
||||
await app.destroy({
|
||||
force: true,
|
||||
});
|
||||
await db.delete(schema.kvAppList).where(eq(schema.kvAppList.id, id));
|
||||
ctx.body = 'success';
|
||||
return ctx;
|
||||
})
|
||||
@@ -185,10 +212,10 @@ app
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { appKey, files, version, username, description } = ctx.query.data;
|
||||
if (!appKey) {
|
||||
throw new CustomError('appKey is required');
|
||||
ctx.throw('appKey is required');
|
||||
}
|
||||
if (!files || !files.length) {
|
||||
throw new CustomError('files is required');
|
||||
ctx.throw('files is required');
|
||||
}
|
||||
let uid = tokenUser.id;
|
||||
let userPrefix = tokenUser.username;
|
||||
@@ -196,20 +223,24 @@ app
|
||||
try {
|
||||
const _user = await User.getUserByToken(ctx.query.token);
|
||||
if (_user.hasUser(username)) {
|
||||
const upUser = await User.findOne({ where: { username } });
|
||||
const upUser = await User.findOne({ username });
|
||||
uid = upUser.id;
|
||||
userPrefix = username;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('getUserByToken error', e);
|
||||
throw new CustomError('user not found');
|
||||
ctx.throw('user not found');
|
||||
}
|
||||
}
|
||||
let am = await AppModel.findOne({ where: { key: appKey, uid } });
|
||||
const ams = await db.select().from(schema.kvApp).where(and(
|
||||
eq(schema.kvApp.key, appKey),
|
||||
eq(schema.kvApp.uid, uid)
|
||||
)).limit(1);
|
||||
let am = ams[0];
|
||||
let appIsNew = false;
|
||||
if (!am) {
|
||||
appIsNew = true;
|
||||
am = await AppModel.create({
|
||||
const newAms = await db.insert(schema.kvApp).values({
|
||||
user: userPrefix,
|
||||
key: appKey,
|
||||
uid,
|
||||
@@ -220,30 +251,45 @@ app
|
||||
data: {
|
||||
files: files || [],
|
||||
},
|
||||
});
|
||||
}).returning();
|
||||
am = newAms[0];
|
||||
}
|
||||
let app = await AppListModel.findOne({ where: { version: version, key: appKey, uid: uid } });
|
||||
const apps = await db.select().from(schema.kvAppList).where(and(
|
||||
eq(schema.kvAppList.version, version),
|
||||
eq(schema.kvAppList.key, appKey),
|
||||
eq(schema.kvAppList.uid, uid)
|
||||
)).limit(1);
|
||||
let app = apps[0];
|
||||
if (!app) {
|
||||
app = await AppListModel.create({
|
||||
const newApps = await db.insert(schema.kvAppList).values({
|
||||
key: appKey,
|
||||
version,
|
||||
uid: uid,
|
||||
data: {
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
}).returning();
|
||||
app = newApps[0];
|
||||
}
|
||||
const dataFiles = app.data.files || [];
|
||||
const appData = app.data as AppData;
|
||||
const dataFiles = appData.files || [];
|
||||
const newFiles = uniqBy([...dataFiles, ...files], (item) => item.name);
|
||||
const res = await app.update({ data: { ...app.data, files: newFiles } });
|
||||
const updateResult = await db.update(schema.kvAppList)
|
||||
.set({ data: { ...appData, files: newFiles }, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.kvAppList.id, app.id))
|
||||
.returning();
|
||||
const res = updateResult[0];
|
||||
if (version === am.version && !appIsNew) {
|
||||
await am.update({ data: { ...am.data, files: newFiles } });
|
||||
const amData = am.data as AppData;
|
||||
await db.update(schema.kvApp)
|
||||
.set({ data: { ...amData, files: newFiles }, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.kvApp.id, am.id));
|
||||
}
|
||||
setExpire(app.id, 'test');
|
||||
ctx.body = prefixFix(res, userPrefix);
|
||||
} catch (e) {
|
||||
console.log('update error', e);
|
||||
throw new CustomError(e.message);
|
||||
ctx.throw(e.message);
|
||||
}
|
||||
})
|
||||
.addTo(app);
|
||||
@@ -254,18 +300,30 @@ app
|
||||
key: 'publish',
|
||||
middleware: ['auth'],
|
||||
description: '发布应用,将某个版本的应用设置为当前应用的版本',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().optional().describe('应用版本记录id'),
|
||||
username: z.string().optional().describe('用户名,默认为当前用户'),
|
||||
appKey: z.string().optional().describe('应用的唯一标识'),
|
||||
version: z.string().describe('应用版本'),
|
||||
detect: z.boolean().optional().describe('是否自动检测版本列表,默认false'),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { id, username, appKey, version, detect } = ctx.query.data;
|
||||
if (!id && !appKey) {
|
||||
throw new CustomError('id or appKey is required');
|
||||
ctx.throw('id or appKey is required');
|
||||
}
|
||||
|
||||
const uid = await getUidByUsername(app, ctx, username);
|
||||
let appList: AppListModel | null = null;
|
||||
let appList: AppList | undefined = undefined;
|
||||
if (id) {
|
||||
appList = await AppListModel.findByPk(id);
|
||||
const appLists = await db.select().from(schema.kvAppList).where(eq(schema.kvAppList.id, id)).limit(1);
|
||||
appList = appLists[0];
|
||||
if (appList?.uid !== uid) {
|
||||
ctx.throw('no permission');
|
||||
}
|
||||
@@ -274,11 +332,17 @@ app
|
||||
if (!version) {
|
||||
ctx.throw('version is required');
|
||||
}
|
||||
appList = await AppListModel.findOne({ where: { key: appKey, version, uid } });
|
||||
const appLists = await db.select().from(schema.kvAppList).where(and(
|
||||
eq(schema.kvAppList.key, appKey),
|
||||
eq(schema.kvAppList.version, version),
|
||||
eq(schema.kvAppList.uid, uid)
|
||||
)).limit(1);
|
||||
appList = appLists[0];
|
||||
}
|
||||
if (!appList) {
|
||||
ctx.throw('app 未发现');
|
||||
}
|
||||
let isDetect = false;
|
||||
if (detect) {
|
||||
const appKey = appList.key;
|
||||
const version = appList.version;
|
||||
@@ -287,18 +351,31 @@ app
|
||||
if (res.code !== 200) {
|
||||
ctx.throw(res.message || '检测版本列表失败');
|
||||
}
|
||||
appList = await AppListModel.findByPk(appList.id);
|
||||
const appLists2 = await db.select().from(schema.kvAppList).where(eq(schema.kvAppList.id, appList.id)).limit(1);
|
||||
appList = appLists2[0];
|
||||
isDetect = true;
|
||||
}
|
||||
if (!appList) {
|
||||
ctx.throw('app 未发现');
|
||||
}
|
||||
|
||||
const files = appList.data.files || [];
|
||||
const am = await AppModel.findOne({ where: { key: appList.key, uid: uid } });
|
||||
const appListData = appList.data as AppData;
|
||||
const files = appListData.files || [];
|
||||
const ams = await db.select().from(schema.kvApp).where(and(
|
||||
eq(schema.kvApp.key, appList.key),
|
||||
eq(schema.kvApp.uid, uid)
|
||||
)).limit(1);
|
||||
const am = ams[0];
|
||||
if (!am) {
|
||||
ctx.throw('app 未发现');
|
||||
}
|
||||
await am.update({ data: { ...am.data, files }, version: appList.version });
|
||||
const amData = am.data as AppData;
|
||||
if (version !== am.version) {
|
||||
// 发布版本和当前版本不一致
|
||||
await db.update(schema.kvApp)
|
||||
.set({ data: { ...amData, files }, version: appList.version, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.kvApp.id, am.id));
|
||||
}
|
||||
|
||||
setExpire(appList.key, am.user);
|
||||
ctx.body = {
|
||||
key: appList.key,
|
||||
@@ -309,24 +386,106 @@ app
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'app',
|
||||
key: 'publishDirectory',
|
||||
middleware: ['auth'],
|
||||
description: '发布应用目录,将某个版本的应用目录设置为当前应用的版本',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
key: z.string().describe('应用的唯一标识'),
|
||||
version: z.string().describe('应用版本'),
|
||||
directory: z.string().describe('应用目录'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { key, version, directory } = ctx.query.data;
|
||||
if (!key || !version || !directory) {
|
||||
ctx.throw('key, version and directory are required');
|
||||
}
|
||||
const username = tokenUser.username;
|
||||
const chunks = directory.split('/').filter((item) => item);
|
||||
const [_username, appWay, ...rest] = chunks;
|
||||
if (username !== _username) {
|
||||
ctx.throw('没有权限');
|
||||
}
|
||||
let newChunks = [];
|
||||
if (appWay === 'resources') {
|
||||
newChunks = [username, ...rest];
|
||||
} else if (appWay === 'ai') {
|
||||
newChunks = [username, 'ai', '1.0.0', ...rest];
|
||||
}
|
||||
const [_, originAppKey, originVersion] = newChunks.filter((item) => item);
|
||||
if (!originAppKey || !originVersion) {
|
||||
ctx.throw('目录不合法');
|
||||
}
|
||||
const pub = async () => {
|
||||
return await app.run({
|
||||
path: 'app',
|
||||
key: 'publish',
|
||||
payload: {
|
||||
data: {
|
||||
appKey: key,
|
||||
version,
|
||||
detect: true,
|
||||
},
|
||||
token: ctx.query.token,
|
||||
},
|
||||
})
|
||||
}
|
||||
// 如果发布的版本和当前版本不一致,则将目录下的文件复制到新的目录下
|
||||
if (originAppKey !== key || originVersion !== version) {
|
||||
const oldPrefix = newChunks.join('/') + '/';
|
||||
const newPrefix = `${username}/${key}/${version}/`;
|
||||
const listSource = await getMinioList<true>({ prefix: oldPrefix, recursive: true });
|
||||
for (const item of listSource) {
|
||||
const newName = item.name.slice(oldPrefix.length);
|
||||
await oss.copyObject(item.name, `${newPrefix}${newName}`);
|
||||
}
|
||||
}
|
||||
const appRes = await app.run({ path: 'app', key: 'get', payload: { data: { key, version, create: true }, token: ctx.query.token } });
|
||||
if (appRes.code !== 200) {
|
||||
ctx.throw(appRes.message || '获取应用信息失败');
|
||||
}
|
||||
const res = await pub();
|
||||
ctx.forward(res);
|
||||
|
||||
}).addTo(app);
|
||||
app
|
||||
.route({
|
||||
path: 'app',
|
||||
key: 'getApp',
|
||||
description: '获取应用信息,可以通过id,或者key+version来获取, 参数在data中传入',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { user, key, id } = ctx.query.data;
|
||||
let app;
|
||||
let app: AppType | undefined;
|
||||
if (id) {
|
||||
app = await AppModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvApp).where(eq(schema.kvApp.id, id)).limit(1);
|
||||
app = apps[0];
|
||||
} else if (user && key) {
|
||||
app = await AppModel.findOne({ where: { user, key } });
|
||||
const apps = await db.select().from(schema.kvApp).where(and(
|
||||
eq(schema.kvApp.user, user),
|
||||
eq(schema.kvApp.key, key)
|
||||
)).limit(1);
|
||||
app = apps[0];
|
||||
} else {
|
||||
throw new CustomError('user or key is required');
|
||||
ctx.throw('user or key is required');
|
||||
}
|
||||
if (!app) {
|
||||
throw new CustomError('app not found');
|
||||
ctx.throw('app not found');
|
||||
}
|
||||
ctx.body = app;
|
||||
})
|
||||
@@ -343,7 +502,7 @@ app
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { key, version } = ctx.query?.data || {};
|
||||
if (!key || !version) {
|
||||
throw new CustomError('key and version are required');
|
||||
ctx.throw('key and version are required');
|
||||
}
|
||||
const files = await getMinioListAndSetToAppList({ username: tokenUser.username, appKey: key, version });
|
||||
ctx.body = files;
|
||||
@@ -354,26 +513,41 @@ app
|
||||
.route({
|
||||
path: 'app',
|
||||
key: 'detectVersionList',
|
||||
description: '检测版本列表,minio中的数据自己上传后,根据版本信息,进行替换',
|
||||
description: '检测版本列表, 对存储内容的网关暴露对应的的模块',
|
||||
middleware: ['auth'],
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
appKey: z.string().describe('应用的唯一标识'),
|
||||
version: z.string().describe('应用版本'),
|
||||
username: z.string().optional().describe('用户名,默认为当前用户'),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
let { appKey, version, username } = ctx.query?.data || {};
|
||||
if (!appKey || !version) {
|
||||
throw new CustomError('appKey and version are required');
|
||||
ctx.throw('appKey and version are required');
|
||||
}
|
||||
const uid = await getUidByUsername(app, ctx, username);
|
||||
let appList = await AppListModel.findOne({ where: { key: appKey, version, uid } });
|
||||
const appLists = await db.select().from(schema.kvAppList).where(and(
|
||||
eq(schema.kvAppList.key, appKey),
|
||||
eq(schema.kvAppList.version, version),
|
||||
eq(schema.kvAppList.uid, uid)
|
||||
)).limit(1);
|
||||
let appList = appLists[0];
|
||||
if (!appList) {
|
||||
appList = await AppListModel.create({
|
||||
const newAppLists = await db.insert(schema.kvAppList).values({
|
||||
key: appKey,
|
||||
version,
|
||||
uid,
|
||||
data: {
|
||||
files: [],
|
||||
},
|
||||
});
|
||||
}).returning();
|
||||
appList = newAppLists[0];
|
||||
}
|
||||
const checkUsername = username || tokenUser.username;
|
||||
const files = await getMinioListAndSetToAppList({ username: checkUsername, appKey, version });
|
||||
@@ -383,7 +557,8 @@ app
|
||||
path: item.name,
|
||||
};
|
||||
});
|
||||
let appListFiles = appList.data?.files || [];
|
||||
const appListData = appList.data as AppData;
|
||||
let appListFiles = appListData?.files || [];
|
||||
const needAddFiles = newFiles.map((item) => {
|
||||
const findFile = appListFiles.find((appListFile) => appListFile.name === item.name);
|
||||
if (findFile && findFile.name === item.name) {
|
||||
@@ -391,23 +566,42 @@ app
|
||||
}
|
||||
return item;
|
||||
});
|
||||
await appList.update({ data: { files: needAddFiles } });
|
||||
const updateResult = await db.update(schema.kvAppList)
|
||||
.set({ data: { files: needAddFiles }, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.kvAppList.id, appList.id))
|
||||
.returning();
|
||||
appList = updateResult[0];
|
||||
setExpire(appList.id, 'test');
|
||||
let am = await AppModel.findOne({ where: { key: appKey, uid } });
|
||||
const ams = await db.select().from(schema.kvApp).where(and(
|
||||
eq(schema.kvApp.key, appKey),
|
||||
eq(schema.kvApp.uid, uid)
|
||||
)).limit(1);
|
||||
let am = ams[0];
|
||||
if (!am) {
|
||||
am = await AppModel.create({
|
||||
// 如果应用不存在,则创建应用记录,版本为0.0.1
|
||||
const newAms = await db.insert(schema.kvApp).values({
|
||||
title: appKey,
|
||||
key: appKey,
|
||||
version: version || '0.0.1',
|
||||
user: checkUsername,
|
||||
uid,
|
||||
data: { files: needAddFiles },
|
||||
proxy: appKey.includes('center') ? false : true,
|
||||
});
|
||||
proxy: true,
|
||||
}).returning();
|
||||
am = newAms[0];
|
||||
} else {
|
||||
const appModel = await AppModel.findOne({ where: { key: appKey, version, uid } });
|
||||
// 如果应用存在,并且版本相同,则更新应用记录的文件列表
|
||||
const appModels = await db.select().from(schema.kvApp).where(and(
|
||||
eq(schema.kvApp.key, appKey),
|
||||
eq(schema.kvApp.version, version),
|
||||
eq(schema.kvApp.uid, uid)
|
||||
)).limit(1);
|
||||
const appModel = appModels[0];
|
||||
if (appModel) {
|
||||
await appModel.update({ data: { files: needAddFiles } });
|
||||
const data = appModel.data as AppData;
|
||||
await db.update(schema.kvApp)
|
||||
.set({ data: { ...data, files: needAddFiles }, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.kvApp.id, appModel.id));
|
||||
setExpire(appModel.key, appModel.user);
|
||||
}
|
||||
}
|
||||
|
||||
46
src/routes/app-manager/module/app-domain-drizzle.ts
Normal file
46
src/routes/app-manager/module/app-domain-drizzle.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
||||
import { kvAppDomain } from '@/db/drizzle/schema.ts';
|
||||
import { redis } from '@/modules/redis.ts';
|
||||
|
||||
// 审核,通过,驳回
|
||||
const appDomainStatus = ['audit', 'auditReject', 'auditPending', 'running', 'stop'] as const;
|
||||
|
||||
export type AppDomainStatus = (typeof appDomainStatus)[number];
|
||||
|
||||
// 类型定义
|
||||
export type AppDomain = InferSelectModel<typeof kvAppDomain>;
|
||||
export type NewAppDomain = InferInsertModel<typeof kvAppDomain>;
|
||||
export type DomainList = AppDomain;
|
||||
|
||||
/**
|
||||
* AppDomain 辅助函数
|
||||
*/
|
||||
export class AppDomainHelper {
|
||||
/**
|
||||
* 检查是否可以更新状态
|
||||
*/
|
||||
static checkCanUpdateStatus(currentStatus: string, newStatus: AppDomainStatus): boolean {
|
||||
// 原本是运行中,可以改为停止,原本是停止,可以改为运行。
|
||||
if (currentStatus === 'running' || currentStatus === 'stop') {
|
||||
return true;
|
||||
}
|
||||
// 原本是审核状态,不能修改。
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除域名缓存
|
||||
*/
|
||||
static async clearCache(domain: string): Promise<void> {
|
||||
// 清除缓存
|
||||
const cacheKey = `domain:${domain}`;
|
||||
const checkHas = async () => {
|
||||
const has = await redis.get(cacheKey);
|
||||
return has;
|
||||
};
|
||||
const has = await checkHas();
|
||||
if (has) {
|
||||
await redis.set(cacheKey, '', 'EX', 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { sequelize } from '../../../modules/sequelize.ts';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
export type DomainList = Partial<InstanceType<typeof AppDomainModel>>;
|
||||
import { redis } from '../../../modules/redis.ts';
|
||||
|
||||
// 审核,通过,驳回
|
||||
const appDomainStatus = ['audit', 'auditReject', 'auditPending', 'running', 'stop'] as const;
|
||||
|
||||
type AppDomainStatus = (typeof appDomainStatus)[number];
|
||||
/**
|
||||
* 应用域名管理
|
||||
*/
|
||||
export class AppDomainModel extends Model {
|
||||
declare id: string;
|
||||
declare domain: string;
|
||||
declare appId: string;
|
||||
// 状态,
|
||||
declare status: AppDomainStatus;
|
||||
declare uid: string;
|
||||
declare data: Record<string, any>;
|
||||
|
||||
declare createdAt: Date;
|
||||
declare updatedAt: Date;
|
||||
|
||||
checkCanUpdateStatus(newStatus: AppDomainStatus) {
|
||||
// 原本是运行中,可以改为停止,原本是停止,可以改为运行。
|
||||
if (this.status === 'running' || this.status === 'stop') {
|
||||
return true;
|
||||
}
|
||||
// 原本是审核状态,不能修改。
|
||||
return false;
|
||||
}
|
||||
async clearCache() {
|
||||
// 清除缓存
|
||||
const cacheKey = `domain:${this.domain}`;
|
||||
const checkHas = async () => {
|
||||
const has = await redis.get(cacheKey);
|
||||
return has;
|
||||
};
|
||||
const has = await checkHas();
|
||||
if (has) {
|
||||
await redis.set(cacheKey, '', 'EX', 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppDomainModel.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
comment: 'id',
|
||||
},
|
||||
domain: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
},
|
||||
data: {
|
||||
type: DataTypes.JSONB,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
defaultValue: 'running',
|
||||
},
|
||||
appId: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
uid: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'kv_app_domain',
|
||||
paranoid: true,
|
||||
},
|
||||
);
|
||||
80
src/routes/app-manager/module/app-drizzle.ts
Normal file
80
src/routes/app-manager/module/app-drizzle.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { InferSelectModel, InferInsertModel } from 'drizzle-orm';
|
||||
import { kvApp, kvAppList } from '@/db/drizzle/schema.ts';
|
||||
|
||||
type AppPermissionType = 'public' | 'private' | 'protected';
|
||||
|
||||
/**
|
||||
* 共享设置
|
||||
* 1. 设置公共可以直接访问
|
||||
* 2. 设置受保护需要登录后访问
|
||||
* 3. 设置私有只有自己可以访问。\n
|
||||
* 受保护可以设置密码,设置访问的用户名。切换共享状态后,需要重新设置密码和用户名。
|
||||
*/
|
||||
export interface AppData {
|
||||
files: { name: string; path: string }[];
|
||||
permission?: {
|
||||
// 访问权限, 字段和minio的权限配置一致
|
||||
share: AppPermissionType; // public, private(Only Self), protected(protected, 通过配置访问)
|
||||
usernames?: string; // 受保护的访问用户名,多个用逗号分隔
|
||||
password?: string; // 受保护的访问密码
|
||||
'expiration-time'?: string; // 受保护的访问过期时间
|
||||
};
|
||||
// 运行环境,browser, node, 或者其他,是数组
|
||||
runtime?: string[];
|
||||
}
|
||||
|
||||
export enum AppStatus {
|
||||
running = 'running',
|
||||
stop = 'stop',
|
||||
}
|
||||
|
||||
// 类型定义
|
||||
export type App = InferSelectModel<typeof kvApp>;
|
||||
export type NewApp = InferInsertModel<typeof kvApp>;
|
||||
export type AppList = InferSelectModel<typeof kvAppList>;
|
||||
export type NewAppList = InferInsertModel<typeof kvAppList>;
|
||||
|
||||
/**
|
||||
* App 辅助函数
|
||||
*/
|
||||
export class AppHelper {
|
||||
/**
|
||||
* 移动应用到新用户
|
||||
*/
|
||||
static async getNewFiles(
|
||||
files: { name: string; path: string }[] = [],
|
||||
opts: { oldUser: string; newUser: string } = { oldUser: '', newUser: '' }
|
||||
) {
|
||||
const { oldUser, newUser } = opts;
|
||||
const _ = files.map((item) => {
|
||||
if (item.path.startsWith('http')) {
|
||||
return item;
|
||||
}
|
||||
if (oldUser && item.path.startsWith(oldUser)) {
|
||||
return item;
|
||||
}
|
||||
const paths = item.path.split('/');
|
||||
return {
|
||||
...item,
|
||||
path: newUser + '/' + paths.slice(1).join('/'),
|
||||
};
|
||||
});
|
||||
return _;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取公开信息(删除敏感数据)
|
||||
*/
|
||||
static getPublic(app: App) {
|
||||
const value = { ...app };
|
||||
// 删除不需要的字段
|
||||
const data = value.data as AppData;
|
||||
if (data && data.permission) {
|
||||
delete data.permission.usernames;
|
||||
delete data.permission.password;
|
||||
delete data.permission['expiration-time'];
|
||||
}
|
||||
value.data = data;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { sequelize } from '../../../modules/sequelize.ts';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
import { AppData } from './app.ts';
|
||||
|
||||
export type AppList = Partial<InstanceType<typeof AppListModel>>;
|
||||
|
||||
/**
|
||||
* APP List 管理 历史版本管理
|
||||
*/
|
||||
export class AppListModel extends Model {
|
||||
declare id: string;
|
||||
declare data: AppData;
|
||||
declare version: string;
|
||||
declare key: string;
|
||||
declare uid: string;
|
||||
declare status: string;
|
||||
}
|
||||
|
||||
AppListModel.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
comment: 'id',
|
||||
},
|
||||
data: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: {},
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: '',
|
||||
},
|
||||
key: {
|
||||
type: DataTypes.STRING,
|
||||
},
|
||||
uid: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'running',
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'kv_app_list',
|
||||
paranoid: true,
|
||||
},
|
||||
);
|
||||
|
||||
// AppListModel.sync({ alter: true, logging: false }).catch((e) => {
|
||||
// console.error('AppListModel sync', e);
|
||||
// });
|
||||
@@ -1,160 +0,0 @@
|
||||
import { sequelize } from '../../../modules/sequelize.ts';
|
||||
import { DataTypes, Model } from 'sequelize';
|
||||
|
||||
type AppPermissionType = 'public' | 'private' | 'protected';
|
||||
/**
|
||||
* 共享设置
|
||||
* 1. 设置公共可以直接访问
|
||||
* 2. 设置受保护需要登录后访问
|
||||
* 3. 设置私有只有自己可以访问。\n
|
||||
* 受保护可以设置密码,设置访问的用户名。切换共享状态后,需要重新设置密码和用户名。
|
||||
*/
|
||||
export interface AppData {
|
||||
files: { name: string; path: string }[];
|
||||
permission?: {
|
||||
// 访问权限, 字段和minio的权限配置一致
|
||||
share: AppPermissionType; // public, private(Only Self), protected(protected, 通过配置访问)
|
||||
usernames?: string; // 受保护的访问用户名,多个用逗号分隔
|
||||
password?: string; // 受保护的访问密码
|
||||
'expiration-time'?: string; // 受保护的访问过期时间
|
||||
};
|
||||
// 运行环境,browser, node, 或者其他,是数组
|
||||
runtime?: string[];
|
||||
}
|
||||
export enum AppStatus {
|
||||
running = 'running',
|
||||
stop = 'stop',
|
||||
}
|
||||
export type App = Partial<InstanceType<typeof AppModel>>;
|
||||
|
||||
/**
|
||||
* APP 管理
|
||||
*/
|
||||
export class AppModel extends Model {
|
||||
declare id: string;
|
||||
declare data: AppData;
|
||||
declare title: string;
|
||||
declare description: string;
|
||||
declare version: string;
|
||||
declare key: string;
|
||||
declare uid: string;
|
||||
declare pid: string;
|
||||
// 是否是history路由代理模式。静态的直接转minio,而不需要缓存下来。
|
||||
declare proxy: boolean;
|
||||
declare user: string;
|
||||
declare status: string;
|
||||
static async moveToNewUser(oldUserName: string, newUserName: string) {
|
||||
const appIds = await AppModel.findAll({
|
||||
where: {
|
||||
user: oldUserName,
|
||||
},
|
||||
attributes: ['id'],
|
||||
});
|
||||
for (const app of appIds) {
|
||||
const appData = await AppModel.findByPk(app.id);
|
||||
appData.user = newUserName;
|
||||
const data = appData.data;
|
||||
data.files = await AppModel.getNewFiles(data.files, {
|
||||
oldUser: oldUserName,
|
||||
newUser: newUserName,
|
||||
});
|
||||
appData.data = { ...data };
|
||||
await appData.save({ fields: ['data', 'user'] });
|
||||
}
|
||||
}
|
||||
static async getNewFiles(files: { name: string; path: string }[] = [], opts: { oldUser: string; newUser: string } = { oldUser: '', newUser: '' }) {
|
||||
const { oldUser, newUser } = opts;
|
||||
const _ = files.map((item) => {
|
||||
if (item.path.startsWith('http')) {
|
||||
return item;
|
||||
}
|
||||
if (oldUser && item.path.startsWith(oldUser)) {
|
||||
return item;
|
||||
}
|
||||
const paths = item.path.split('/');
|
||||
return {
|
||||
...item,
|
||||
path: newUser + '/' + paths.slice(1).join('/'),
|
||||
};
|
||||
});
|
||||
return _;
|
||||
}
|
||||
|
||||
async getPublic() {
|
||||
const value = this.toJSON();
|
||||
// 删除不需要的字段
|
||||
const data = value.data;
|
||||
if (data && data.permission) {
|
||||
delete data.permission.usernames;
|
||||
delete data.permission.password;
|
||||
delete data.permission['expiration-time'];
|
||||
}
|
||||
value.data = data;
|
||||
return value;
|
||||
}
|
||||
}
|
||||
AppModel.init(
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
primaryKey: true,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
comment: 'id',
|
||||
},
|
||||
title: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: '',
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: '',
|
||||
},
|
||||
data: {
|
||||
type: DataTypes.JSONB,
|
||||
defaultValue: {},
|
||||
},
|
||||
version: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: '',
|
||||
},
|
||||
key: {
|
||||
type: DataTypes.STRING,
|
||||
// 和 uid 组合唯一
|
||||
},
|
||||
uid: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
},
|
||||
pid: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: true,
|
||||
},
|
||||
proxy: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
},
|
||||
user: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'running', // stop, running
|
||||
},
|
||||
},
|
||||
{
|
||||
sequelize,
|
||||
tableName: 'kv_app',
|
||||
paranoid: true,
|
||||
indexes: [
|
||||
{
|
||||
unique: true,
|
||||
fields: ['key', 'uid'],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
// AppModel.sync({ alter: true, logging: false }).catch((e) => {
|
||||
// console.error('AppModel sync', e);
|
||||
// });
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from './app-list.ts';
|
||||
export * from './app.ts';
|
||||
// Drizzle 模型(推荐使用)
|
||||
export * from './app-domain-drizzle.ts'
|
||||
export * from './app-drizzle.ts'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { app } from '@/app.ts';
|
||||
import { AppModel } from '../module/index.ts';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
import { ConfigPermission } from '@kevisual/permission';
|
||||
import { eq, desc, asc } from 'drizzle-orm';
|
||||
|
||||
// curl http://localhost:4005/api/router?path=app&key=public-list
|
||||
// TODO:
|
||||
@@ -11,23 +11,20 @@ app
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { username = 'root', status = 'running', page = 1, pageSize = 100, order = 'DESC' } = ctx.query.data || {};
|
||||
const { rows, count } = await AppModel.findAndCountAll({
|
||||
where: {
|
||||
status,
|
||||
user: username,
|
||||
},
|
||||
attributes: {
|
||||
exclude: [],
|
||||
},
|
||||
order: [['updatedAt', order]],
|
||||
limit: pageSize,
|
||||
offset: (page - 1) * pageSize,
|
||||
distinct: true,
|
||||
logging: false,
|
||||
});
|
||||
const offset = (page - 1) * pageSize;
|
||||
const apps = await db.select().from(schema.kvApp)
|
||||
.where(eq(schema.kvApp.user, username))
|
||||
.orderBy(order === 'DESC' ? desc(schema.kvApp.updatedAt) : asc(schema.kvApp.updatedAt))
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
// Note: Drizzle doesn't have a direct equivalent to findAndCountAll
|
||||
// We need to do a separate count query
|
||||
const countResult = await db.select({ count: schema.kvApp.id }).from(schema.kvApp)
|
||||
.where(eq(schema.kvApp.user, username));
|
||||
const count = countResult.length;
|
||||
ctx.body = {
|
||||
list: rows.map((item) => {
|
||||
return ConfigPermission.getDataPublicPermission(item.toJSON());
|
||||
list: apps.map((item) => {
|
||||
return ConfigPermission.getDataPublicPermission(item);
|
||||
}),
|
||||
pagination: {
|
||||
total: count,
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { app } from '@/app.ts';
|
||||
import { AppModel } from '../module/index.ts';
|
||||
import { AppListModel } from '../module/index.ts';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
import { oss } from '@/app.ts';
|
||||
import { User } from '@/models/user.ts';
|
||||
import { permission } from 'process';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -12,9 +9,7 @@ const number = '0123456789';
|
||||
const randomId = customAlphabet(letter + number, 16);
|
||||
const getShareUser = async () => {
|
||||
const shareUser = await User.findOne({
|
||||
where: {
|
||||
username: 'share',
|
||||
},
|
||||
username: 'share',
|
||||
});
|
||||
return shareUser?.id || '';
|
||||
};
|
||||
@@ -65,7 +60,7 @@ app
|
||||
path: urlPath,
|
||||
},
|
||||
];
|
||||
const appModel = await AppModel.create({
|
||||
const appModels = await db.insert(schema.kvApp).values({
|
||||
title,
|
||||
description,
|
||||
version,
|
||||
@@ -80,15 +75,17 @@ app
|
||||
},
|
||||
files: files,
|
||||
},
|
||||
});
|
||||
const appVersionModel = await AppListModel.create({
|
||||
}).returning();
|
||||
const appModel = appModels[0];
|
||||
const appVersionModels = await db.insert(schema.kvAppList).values({
|
||||
data: {
|
||||
files: files,
|
||||
},
|
||||
version: appModel.version,
|
||||
key: appModel.key,
|
||||
uid: appModel.uid,
|
||||
});
|
||||
}).returning();
|
||||
const appVersionModel = appVersionModels[0];
|
||||
|
||||
ctx.body = {
|
||||
url: `/${username}/${key}/`,
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { AppModel, AppListModel } from './module/index.ts';
|
||||
import { app } from '@/app.ts';
|
||||
import { App, AppData, AppHelper } from './module/app-drizzle.ts';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
import { setExpire } from './revoke.ts';
|
||||
import { deleteFileByPrefix } from '../file/index.ts';
|
||||
import { eq, and, desc, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
app
|
||||
.route({
|
||||
@@ -9,19 +11,30 @@ app
|
||||
key: 'list',
|
||||
middleware: ['auth'],
|
||||
description: '获取用户应用列表',
|
||||
metadata: {
|
||||
args: {}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const list = await AppModel.findAll({
|
||||
order: [['updatedAt', 'DESC']],
|
||||
where: {
|
||||
uid: tokenUser.id,
|
||||
},
|
||||
attributes: {
|
||||
exclude: ['data'],
|
||||
},
|
||||
});
|
||||
ctx.body = list;
|
||||
const list = await db.select({
|
||||
id: schema.kvApp.id,
|
||||
title: schema.kvApp.title,
|
||||
description: schema.kvApp.description,
|
||||
version: schema.kvApp.version,
|
||||
key: schema.kvApp.key,
|
||||
uid: schema.kvApp.uid,
|
||||
pid: schema.kvApp.pid,
|
||||
user: schema.kvApp.user,
|
||||
status: schema.kvApp.status,
|
||||
createdAt: schema.kvApp.createdAt,
|
||||
updatedAt: schema.kvApp.updatedAt,
|
||||
permission: sql<AppData['permission']>`${schema.kvApp.data}->'permission'`
|
||||
})
|
||||
.from(schema.kvApp)
|
||||
.where(eq(schema.kvApp.uid, tokenUser.id))
|
||||
.orderBy(desc(schema.kvApp.updatedAt));
|
||||
ctx.body = { list };
|
||||
return ctx;
|
||||
})
|
||||
.addTo(app);
|
||||
@@ -32,6 +45,14 @@ app
|
||||
key: 'get',
|
||||
middleware: ['auth'],
|
||||
description: '获取用户应用,可以指定id或者key',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().optional(),
|
||||
data: z.object({
|
||||
key: z.string().optional(),
|
||||
}).optional(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
@@ -40,14 +61,18 @@ app
|
||||
if (!id && !key) {
|
||||
ctx.throw(500, 'id is required');
|
||||
}
|
||||
let am: AppModel;
|
||||
let am: App | undefined;
|
||||
if (id) {
|
||||
am = await AppModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvApp).where(eq(schema.kvApp.id, id)).limit(1);
|
||||
am = apps[0];
|
||||
if (!am) {
|
||||
ctx.throw(500, 'app not found');
|
||||
}
|
||||
} else {
|
||||
am = await AppModel.findOne({ where: { key, uid: tokenUser.id } });
|
||||
const apps = await db.select().from(schema.kvApp)
|
||||
.where(and(eq(schema.kvApp.key, key), eq(schema.kvApp.uid, tokenUser.id)))
|
||||
.limit(1);
|
||||
am = apps[0];
|
||||
if (!am) {
|
||||
ctx.throw(500, 'app not found');
|
||||
}
|
||||
@@ -65,27 +90,47 @@ app
|
||||
key: 'update',
|
||||
middleware: ['auth'],
|
||||
description: '创建或更新用户应用,参数在data中传入',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().optional(),
|
||||
key: z.string().optional(),
|
||||
title: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
proxy: z.boolean().optional(),
|
||||
share: z.boolean().optional(),
|
||||
status: z.enum(['running', 'stopped']).optional(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
|
||||
const { data, id, user, ...rest } = ctx.query.data;
|
||||
if (id) {
|
||||
const app = await AppModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvApp).where(eq(schema.kvApp.id, id)).limit(1);
|
||||
const app = apps[0];
|
||||
if (app) {
|
||||
const newData = { ...app.data, ...data };
|
||||
const appData = app.data as AppData;
|
||||
const newData = { ...appData, ...data };
|
||||
if (app.user !== tokenUser.username) {
|
||||
rest.user = tokenUser.username;
|
||||
let files = newData?.files || [];
|
||||
if (files.length > 0) {
|
||||
files = await AppModel.getNewFiles(files, { oldUser: app.user, newUser: tokenUser.username });
|
||||
files = await AppHelper.getNewFiles(files, { oldUser: app.user!, newUser: tokenUser.username });
|
||||
}
|
||||
newData.files = files;
|
||||
}
|
||||
const newApp = await app.update({ data: newData, ...rest });
|
||||
const updateResult = await db.update(schema.kvApp)
|
||||
.set({ data: newData, ...rest, updatedAt: new Date().toISOString() })
|
||||
.where(eq(schema.kvApp.id, id))
|
||||
.returning();
|
||||
const newApp = updateResult[0];
|
||||
ctx.body = newApp;
|
||||
if (app.status !== 'running' || data?.share || rest?.status) {
|
||||
setExpire(newApp.key, app.user);
|
||||
setExpire(newApp.key!, app.user!);
|
||||
}
|
||||
} else {
|
||||
ctx.throw(500, 'app not found');
|
||||
@@ -95,17 +140,19 @@ app
|
||||
if (!rest.key) {
|
||||
ctx.throw(500, 'key is required');
|
||||
}
|
||||
const findApp = await AppModel.findOne({ where: { key: rest.key, uid: tokenUser.id } });
|
||||
if (findApp) {
|
||||
const findApps = await db.select().from(schema.kvApp)
|
||||
.where(and(eq(schema.kvApp.key, rest.key), eq(schema.kvApp.uid, tokenUser.id)))
|
||||
.limit(1);
|
||||
if (findApps.length > 0) {
|
||||
ctx.throw(500, 'key already exists');
|
||||
}
|
||||
const app = await AppModel.create({
|
||||
const newApps = await db.insert(schema.kvApp).values({
|
||||
data: { files: [] },
|
||||
...rest,
|
||||
uid: tokenUser.id,
|
||||
user: tokenUser.username,
|
||||
});
|
||||
ctx.body = app;
|
||||
}).returning();
|
||||
ctx.body = newApps[0];
|
||||
return ctx;
|
||||
})
|
||||
.addTo(app);
|
||||
@@ -116,6 +163,12 @@ app
|
||||
key: 'delete',
|
||||
middleware: ['auth'],
|
||||
description: '删除用户应用,可以指定id,参数:deleteFile表示是否删除文件,默认不删除',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().optional().describe('应用id'),
|
||||
deleteFile: z.boolean().optional().describe('是否删除文件, 默认不删除'),
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
@@ -124,16 +177,18 @@ app
|
||||
if (!id) {
|
||||
ctx.throw(500, 'id is required');
|
||||
}
|
||||
const am = await AppModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvApp).where(eq(schema.kvApp.id, id)).limit(1);
|
||||
const am = apps[0];
|
||||
if (!am) {
|
||||
ctx.throw(500, 'app not found');
|
||||
}
|
||||
if (am.uid !== tokenUser.id) {
|
||||
ctx.throw(500, 'app not found');
|
||||
}
|
||||
const list = await AppListModel.findAll({ where: { key: am.key, uid: tokenUser.id } });
|
||||
await am.destroy({ force: true });
|
||||
await Promise.all(list.map((item) => item.destroy({ force: true })));
|
||||
const list = await db.select().from(schema.kvAppList)
|
||||
.where(and(eq(schema.kvAppList.key, am.key!), eq(schema.kvAppList.uid, tokenUser.id)));
|
||||
await db.delete(schema.kvApp).where(eq(schema.kvApp.id, id));
|
||||
await Promise.all(list.map((item) => db.delete(schema.kvAppList).where(eq(schema.kvAppList.id, item.id))));
|
||||
if (deleteFile) {
|
||||
const username = tokenUser.username;
|
||||
await deleteFileByPrefix(`${username}/${am.key}`);
|
||||
@@ -148,19 +203,24 @@ app
|
||||
path: 'user-app',
|
||||
key: 'test',
|
||||
description: '对user-app的数据进行测试, 获取版本的信息',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('应用id'),
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const id = ctx.query.id;
|
||||
if (!id) {
|
||||
ctx.throw(500, 'id is required');
|
||||
}
|
||||
const am = await AppListModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvAppList).where(eq(schema.kvAppList.id, id)).limit(1);
|
||||
const am = apps[0];
|
||||
if (!am) {
|
||||
ctx.throw(500, 'app not found');
|
||||
}
|
||||
const amJson = am.toJSON();
|
||||
ctx.body = {
|
||||
...amJson,
|
||||
...am,
|
||||
proxy: true,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -29,7 +29,7 @@ app
|
||||
path: 'config',
|
||||
key: 'update',
|
||||
middleware: ['auth'],
|
||||
description: '创建或更新用户配置,参数在data中传入',
|
||||
description: '创建或更新用户配置,参数在data中传入'
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokernUser = ctx.state.tokenUser;
|
||||
@@ -90,16 +90,21 @@ app
|
||||
config = updated[0];
|
||||
ctx.body = config;
|
||||
} else {
|
||||
// 根据key创建一个配置
|
||||
const inserted = await db.insert(schema.kvConfig).values({
|
||||
id: nanoid(),
|
||||
key,
|
||||
...rest,
|
||||
data: data,
|
||||
uid: tuid,
|
||||
}).returning();
|
||||
config = inserted[0];
|
||||
ctx.body = config;
|
||||
try {
|
||||
|
||||
// 根据key创建一个配置
|
||||
const inserted = await db.insert(schema.kvConfig).values({
|
||||
key,
|
||||
...rest,
|
||||
data: data || {},
|
||||
uid: tuid,
|
||||
}).returning();
|
||||
config = inserted[0];
|
||||
ctx.body = config;
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
const key = config?.key;
|
||||
|
||||
@@ -28,7 +28,7 @@ export const defaultKeys = [
|
||||
{
|
||||
key: 'user.json',
|
||||
description: '用户配置',
|
||||
data: { key: 'user', version: '1.0.0', redirectURL: '/root/home/' },
|
||||
data: { key: 'user', version: '1.0.0', redirectURL: '/root/center/' },
|
||||
},
|
||||
{
|
||||
key: 'life.json',
|
||||
|
||||
@@ -18,14 +18,14 @@ export class ShareConfigService {
|
||||
shareCacheConfig = JSON.parse(shareCacheConfigString);
|
||||
} catch (e) {
|
||||
await redis.set(`config:share:${username}:${key}`, '', 'EX', 0); // 删除缓存
|
||||
throw new CustomError(400, 'config parse error');
|
||||
throw new CustomError(400, { message: 'config parse error' });
|
||||
}
|
||||
const owner = username;
|
||||
if (shareCacheConfig) {
|
||||
const permission = new UserPermission({ permission: (shareCacheConfig?.data as any)?.permission, owner });
|
||||
const result = permission.checkPermissionSuccess(options);
|
||||
if (!result.success) {
|
||||
throw new CustomError(403, 'no permission');
|
||||
throw new CustomError(403, { message: 'no permission' });
|
||||
}
|
||||
return shareCacheConfig;
|
||||
}
|
||||
@@ -35,7 +35,7 @@ export class ShareConfigService {
|
||||
.limit(1);
|
||||
const user = users[0];
|
||||
if (!user) {
|
||||
throw new CustomError(404, 'user not found');
|
||||
throw new CustomError(404, { message: 'user not found' });
|
||||
}
|
||||
const configs = await db.select()
|
||||
.from(schema.kvConfig)
|
||||
@@ -43,12 +43,12 @@ export class ShareConfigService {
|
||||
.limit(1);
|
||||
const config = configs[0];
|
||||
if (!config) {
|
||||
throw new CustomError(404, 'config not found');
|
||||
throw new CustomError(404, { message: 'config not found' });
|
||||
}
|
||||
const permission = new UserPermission({ permission: (config?.data as any)?.permission, owner });
|
||||
const result = permission.checkPermissionSuccess(options);
|
||||
if (!result.success) {
|
||||
throw new CustomError(403, 'no permission');
|
||||
throw new CustomError(403, { message: 'no permission' });
|
||||
}
|
||||
await redis.set(`config:share:${username}:${key}`, JSON.stringify(config), 'EX', 60 * 60 * 24 * 7); // 7天
|
||||
return config;
|
||||
|
||||
@@ -1 +1 @@
|
||||
import './list.ts';
|
||||
// import './list.ts';
|
||||
|
||||
@@ -1,107 +1,106 @@
|
||||
import { Op } from 'sequelize';
|
||||
import { app } from '@/app.ts';
|
||||
import { FileSyncModel } from './model.ts';
|
||||
app
|
||||
.route({
|
||||
path: 'file-listener',
|
||||
key: 'list',
|
||||
middleware: ['auth'],
|
||||
description: '获取用户的某一个文件夹下的所有的列表的数据',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const username = tokenUser.username;
|
||||
const { page = 1, pageSize = 20, sort = 'DESC' } = ctx.query;
|
||||
let { prefix } = ctx.query;
|
||||
if (prefix) {
|
||||
if (typeof prefix !== 'string') {
|
||||
ctx.throw(400, 'prefix must be a string');
|
||||
}
|
||||
if (prefix.startsWith('/')) {
|
||||
prefix = prefix.slice(1); // Remove leading slash if present
|
||||
}
|
||||
if (!prefix.startsWith(username + '/')) {
|
||||
ctx.throw(400, 'prefix must start with the your username:', username);
|
||||
}
|
||||
}
|
||||
const searchWhere = prefix
|
||||
? {
|
||||
[Op.or]: [{ name: { [Op.like]: `${prefix}%` } }],
|
||||
}
|
||||
: {};
|
||||
// import { app } from '@/app.ts';
|
||||
// import { FileSyncModel } from './model.ts';
|
||||
// app
|
||||
// .route({
|
||||
// path: 'file-listener',
|
||||
// key: 'list',
|
||||
// middleware: ['auth'],
|
||||
// description: '获取用户的某一个文件夹下的所有的列表的数据',
|
||||
// })
|
||||
// .define(async (ctx) => {
|
||||
// const tokenUser = ctx.state.tokenUser;
|
||||
// const username = tokenUser.username;
|
||||
// const { page = 1, pageSize = 20, sort = 'DESC' } = ctx.query;
|
||||
// let { prefix } = ctx.query;
|
||||
// if (prefix) {
|
||||
// if (typeof prefix !== 'string') {
|
||||
// ctx.throw(400, 'prefix must be a string');
|
||||
// }
|
||||
// if (prefix.startsWith('/')) {
|
||||
// prefix = prefix.slice(1); // Remove leading slash if present
|
||||
// }
|
||||
// if (!prefix.startsWith(username + '/')) {
|
||||
// ctx.throw(400, 'prefix must start with the your username:', username);
|
||||
// }
|
||||
// }
|
||||
// const searchWhere = prefix
|
||||
// ? {
|
||||
// [Op.or]: [{ name: { [Op.like]: `${prefix}%` } }],
|
||||
// }
|
||||
// : {};
|
||||
|
||||
const { rows: files, count } = await FileSyncModel.findAndCountAll({
|
||||
where: {
|
||||
...searchWhere,
|
||||
},
|
||||
offset: (page - 1) * pageSize,
|
||||
limit: pageSize,
|
||||
order: [['updatedAt', sort]],
|
||||
});
|
||||
const getPublicFiles = (files: FileSyncModel[]) => {
|
||||
return files.map((file) => {
|
||||
const value = file.toJSON();
|
||||
const stat = value.stat || {};
|
||||
delete stat.password;
|
||||
return {
|
||||
...value,
|
||||
stat: stat,
|
||||
};
|
||||
});
|
||||
};
|
||||
// const { rows: files, count } = await FileSyncModel.findAndCountAll({
|
||||
// where: {
|
||||
// ...searchWhere,
|
||||
// },
|
||||
// offset: (page - 1) * pageSize,
|
||||
// limit: pageSize,
|
||||
// order: [['updatedAt', sort]],
|
||||
// });
|
||||
// const getPublicFiles = (files: FileSyncModel[]) => {
|
||||
// return files.map((file) => {
|
||||
// const value = file.toJSON();
|
||||
// const stat = value.stat || {};
|
||||
// delete stat.password;
|
||||
// return {
|
||||
// ...value,
|
||||
// stat: stat,
|
||||
// };
|
||||
// });
|
||||
// };
|
||||
|
||||
ctx.body = {
|
||||
list: getPublicFiles(files),
|
||||
pagination: {
|
||||
page,
|
||||
current: page,
|
||||
pageSize,
|
||||
total: count,
|
||||
},
|
||||
};
|
||||
})
|
||||
.addTo(app);
|
||||
// ctx.body = {
|
||||
// list: getPublicFiles(files),
|
||||
// pagination: {
|
||||
// page,
|
||||
// current: page,
|
||||
// pageSize,
|
||||
// total: count,
|
||||
// },
|
||||
// };
|
||||
// })
|
||||
// .addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'file-listener',
|
||||
key: 'get',
|
||||
middleware: ['auth'],
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const username = tokenUser.username;
|
||||
const { id, name, hash } = ctx.query.data || {};
|
||||
// app
|
||||
// .route({
|
||||
// path: 'file-listener',
|
||||
// key: 'get',
|
||||
// middleware: ['auth'],
|
||||
// })
|
||||
// .define(async (ctx) => {
|
||||
// const tokenUser = ctx.state.tokenUser;
|
||||
// const username = tokenUser.username;
|
||||
// const { id, name, hash } = ctx.query.data || {};
|
||||
|
||||
if (!id && !name && !hash) {
|
||||
ctx.throw(400, 'id, name or hash is required');
|
||||
}
|
||||
let fileSync: FileSyncModel | null = null;
|
||||
if (id) {
|
||||
fileSync = await FileSyncModel.findByPk(id);
|
||||
}
|
||||
if (name && !fileSync) {
|
||||
fileSync = await FileSyncModel.findOne({
|
||||
where: {
|
||||
name,
|
||||
hash,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!fileSync && hash) {
|
||||
fileSync = await FileSyncModel.findOne({
|
||||
where: {
|
||||
name: {
|
||||
[Op.like]: `${username}/%`,
|
||||
},
|
||||
hash,
|
||||
},
|
||||
});
|
||||
}
|
||||
// if (!id && !name && !hash) {
|
||||
// ctx.throw(400, 'id, name or hash is required');
|
||||
// }
|
||||
// let fileSync: FileSyncModel | null = null;
|
||||
// if (id) {
|
||||
// fileSync = await FileSyncModel.findByPk(id);
|
||||
// }
|
||||
// if (name && !fileSync) {
|
||||
// fileSync = await FileSyncModel.findOne({
|
||||
// where: {
|
||||
// name,
|
||||
// hash,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// if (!fileSync && hash) {
|
||||
// fileSync = await FileSyncModel.findOne({
|
||||
// where: {
|
||||
// name: {
|
||||
// [Op.like]: `${username}/%`,
|
||||
// },
|
||||
// hash,
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
if (!fileSync || !fileSync.name.startsWith(`${username}/`)) {
|
||||
ctx.throw(404, 'NotFoundFile');
|
||||
}
|
||||
ctx.body = fileSync;
|
||||
})
|
||||
.addTo(app);
|
||||
// if (!fileSync || !fileSync.name.startsWith(`${username}/`)) {
|
||||
// ctx.throw(404, 'NotFoundFile');
|
||||
// }
|
||||
// ctx.body = fileSync;
|
||||
// })
|
||||
// .addTo(app);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import { FileSyncModel } from '@kevisual/file-listener/src/file-sync/model.ts';
|
||||
import type { FileSyncModelType } from '@kevisual/file-listener/src/file-sync/model.ts';
|
||||
export { FileSyncModel, FileSyncModelType };
|
||||
// import { FileSyncModel } from '@kevisual/file-listener/src/file-sync/model.ts';
|
||||
// import type { FileSyncModelType } from '@kevisual/file-listener/src/file-sync/model.ts';
|
||||
// export { FileSyncModel, FileSyncModelType };
|
||||
|
||||
46
src/routes/flowme-life/chat.ts
Normal file
46
src/routes/flowme-life/chat.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
|
||||
import { schema, app, cnb, models } from '@/app.ts'
|
||||
import z from 'zod';
|
||||
import { runAgent } from '@kevisual/ai/agent'
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
app.route({
|
||||
path: 'flowme-life',
|
||||
key: 'chat',
|
||||
description: `聊天接口, 对自己的数据进行操作,参数是 question或messages,question是用户的提问,messages是对话消息列表,优先级高于 question`,
|
||||
middleware: ['auth']
|
||||
, metadata: {
|
||||
args: {
|
||||
question: z.string().describe('用户的提问'),
|
||||
messages: z.any().optional().describe('对话消息列表,优先级高于 question'),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const question = ctx.query.question || '';
|
||||
const _messages = ctx.query.messages;
|
||||
const token = ctx.query.token || '';
|
||||
if (!question && !_messages) {
|
||||
ctx.throw(400, '缺少参数 question 或 messages');
|
||||
}
|
||||
const routes = ctx.app.getList().filter(r => r.path.startsWith('flowme-life') && r.key !== 'chat');
|
||||
const currentTime = dayjs().toISOString();
|
||||
const messages = _messages || [
|
||||
{
|
||||
"role": "system" as const,
|
||||
"content": `你是我的智能助手,协助我操作我的数据, 请根据我的提问选择合适的接口进行调用。当前时间是 ${currentTime}。`
|
||||
},
|
||||
{
|
||||
"role": "user" as const,
|
||||
"content": question
|
||||
}
|
||||
]
|
||||
const res = await runAgent({
|
||||
app: app,
|
||||
messages: messages,
|
||||
languageModel: cnb(models['auto']),
|
||||
// query: 'WHERE path LIKE 'flowme-life%',
|
||||
routes,
|
||||
token,
|
||||
});
|
||||
ctx.body = res
|
||||
}).addTo(app);
|
||||
3
src/routes/flowme-life/index.ts
Normal file
3
src/routes/flowme-life/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import './list.ts'
|
||||
import './today.ts'
|
||||
import './chat.ts'
|
||||
38
src/routes/flowme-life/life.services.ts
Normal file
38
src/routes/flowme-life/life.services.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { schema, db } from '@/app.ts';
|
||||
|
||||
export type LifeItem = typeof schema.life.$inferSelect;
|
||||
|
||||
/**
|
||||
* 根据 id 获取 life 记录
|
||||
*/
|
||||
export async function getLifeItem(id: string): Promise<{ code: number; data?: LifeItem; message?: string }> {
|
||||
try {
|
||||
const result = await db.select().from(schema.life).where(eq(schema.life.id, id)).limit(1);
|
||||
if (result.length === 0) {
|
||||
return { code: 404, message: `记录 ${id} 不存在` };
|
||||
}
|
||||
return { code: 200, data: result[0] };
|
||||
} catch (e) {
|
||||
return { code: 500, message: `获取记录 ${id} 失败: ${e?.message || e}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 life 记录的 effectiveAt(下次执行时间)
|
||||
*/
|
||||
export async function updateLifeEffectiveAt(id: string, effectiveAt: string | Date): Promise<{ code: number; data?: LifeItem; message?: string }> {
|
||||
try {
|
||||
const result = await db
|
||||
.update(schema.life)
|
||||
.set({ effectiveAt: new Date(effectiveAt) })
|
||||
.where(eq(schema.life.id, id))
|
||||
.returning();
|
||||
if (result.length === 0) {
|
||||
return { code: 404, message: `记录 ${id} 不存在` };
|
||||
}
|
||||
return { code: 200, data: result[0] };
|
||||
} catch (e) {
|
||||
return { code: 500, message: `更新记录 ${id} 失败: ${e?.message || e}` };
|
||||
}
|
||||
}
|
||||
216
src/routes/flowme-life/list.ts
Normal file
216
src/routes/flowme-life/list.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { desc, eq, count, or, like, and } from 'drizzle-orm';
|
||||
import { schema, app, db } from '@/app.ts'
|
||||
import z from 'zod';
|
||||
import dayjs from 'dayjs';
|
||||
app.route({
|
||||
path: 'flowme-life',
|
||||
key: 'list',
|
||||
middleware: ['auth'],
|
||||
description: '获取 flowme-life 列表',
|
||||
metadata: {
|
||||
args: {
|
||||
page: z.number().describe('页码, 默认为 1').optional(),
|
||||
pageSize: z.number().describe('每页数量, 默认为 20').optional(),
|
||||
search: z.string().describe('搜索关键词').optional(),
|
||||
sort: z.enum(['ASC', 'DESC']).describe('排序方式,ASC 或 DESC,默认为 DESC').optional(),
|
||||
}
|
||||
}
|
||||
}).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.life.updatedAt : desc(schema.life.updatedAt);
|
||||
|
||||
let whereCondition = eq(schema.life.uid, uid);
|
||||
if (search) {
|
||||
whereCondition = and(
|
||||
eq(schema.life.uid, uid),
|
||||
or(
|
||||
like(schema.life.title, `%${search}%`),
|
||||
like(schema.life.summary, `%${search}%`)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const [list, totalCount] = await Promise.all([
|
||||
db.select()
|
||||
.from(schema.life)
|
||||
.where(whereCondition)
|
||||
.limit(pageSize)
|
||||
.offset(offset)
|
||||
.orderBy(orderByField),
|
||||
db.select({ count: count() })
|
||||
.from(schema.life)
|
||||
.where(whereCondition)
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
list,
|
||||
pagination: {
|
||||
page,
|
||||
current: page,
|
||||
pageSize,
|
||||
total: totalCount[0]?.count || 0,
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'flowme-life',
|
||||
key: 'create',
|
||||
middleware: ['auth'],
|
||||
description: '创建一个 flowme-life',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
title: z.string().describe('标题').optional(),
|
||||
summary: z.string().describe('摘要').optional(),
|
||||
description: z.string().describe('描述').optional(),
|
||||
tags: z.array(z.string()).describe('标签').optional(),
|
||||
link: z.string().describe('链接').optional(),
|
||||
data: z.record(z.string(), z.any()).describe('数据').optional(),
|
||||
effectiveAt: z.string().describe('生效日期, 格式为 YYYY-MM-DD HH:mm:ss').optional(),
|
||||
type: z.string().describe('类型: 智能, 每年农历, 备忘, 归档等.默认智能').optional(),
|
||||
prompt: z.string().describe('提示词').optional(),
|
||||
taskType: z.string().describe('任务类型').optional(),
|
||||
taskResult: z.record(z.string(), z.any()).describe('任务结果').optional(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const { uid, updatedAt, createdAt, ...rest } = ctx.query.data || {};
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
if (rest.effectiveAt && isNaN(Date.parse(rest.effectiveAt))) {
|
||||
rest.effectiveAt = null;
|
||||
} else if (rest.effectiveAt) {
|
||||
rest.effectiveAt = dayjs(rest.effectiveAt)
|
||||
}
|
||||
const lifeItem = await db.insert(schema.life).values({
|
||||
title: rest.title || '',
|
||||
summary: rest.summary || '',
|
||||
description: rest.description || '',
|
||||
tags: rest.tags || [],
|
||||
link: rest.link || '',
|
||||
data: rest.data || {},
|
||||
effectiveAt: rest.effectiveAt || '',
|
||||
type: rest.type || '智能',
|
||||
prompt: rest.prompt || '',
|
||||
taskType: rest.taskType || '',
|
||||
taskResult: rest.taskResult || {},
|
||||
uid: tokenUser.id,
|
||||
}).returning();
|
||||
ctx.body = lifeItem;
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'flowme-life',
|
||||
key: 'update',
|
||||
middleware: ['auth'],
|
||||
description: '更新一个 flowme-life 的数据',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().describe('ID'),
|
||||
title: z.string().describe('标题').optional(),
|
||||
summary: z.string().describe('摘要').optional(),
|
||||
description: z.string().describe('描述').optional(),
|
||||
tags: z.array(z.string()).describe('标签').optional(),
|
||||
link: z.string().describe('链接').optional(),
|
||||
data: z.record(z.string(), z.any()).describe('数据').optional(),
|
||||
effectiveAt: z.string().describe('生效日期').optional(),
|
||||
type: z.string().describe('类型').optional(),
|
||||
prompt: z.string().describe('提示词').optional(),
|
||||
taskType: z.string().describe('任务类型').optional(),
|
||||
taskResult: z.record(z.string(), z.any()).describe('任务结果').optional(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const { id, uid, updatedAt, createdAt, ...rest } = ctx.query.data || {};
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
if (!id) {
|
||||
ctx.throw(400, 'id 参数缺失');
|
||||
}
|
||||
const existing = await db.select().from(schema.life).where(eq(schema.life.id, id)).limit(1);
|
||||
if (existing.length === 0) {
|
||||
ctx.throw(404, '没有找到对应的 flowme-life');
|
||||
}
|
||||
if (existing[0].uid !== tokenUser.id) {
|
||||
ctx.throw(403, '没有权限更新该 flowme-life');
|
||||
}
|
||||
if (rest.effectiveAt && isNaN(Date.parse(rest.effectiveAt))) {
|
||||
rest.effectiveAt = null;
|
||||
} else if (rest.effectiveAt) {
|
||||
rest.effectiveAt = dayjs(rest.effectiveAt)
|
||||
}
|
||||
const lifeItem = await db.update(schema.life).set({
|
||||
title: rest.title,
|
||||
summary: rest.summary,
|
||||
description: rest.description,
|
||||
tags: rest.tags,
|
||||
link: rest.link,
|
||||
data: rest.data,
|
||||
effectiveAt: rest.effectiveAt,
|
||||
type: rest.type,
|
||||
prompt: rest.prompt,
|
||||
taskType: rest.taskType,
|
||||
taskResult: rest.taskResult,
|
||||
}).where(eq(schema.life.id, id)).returning();
|
||||
ctx.body = lifeItem;
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'flowme-life',
|
||||
key: 'delete',
|
||||
middleware: ['auth'],
|
||||
description: '删除单个 flowme-life, 参数: id 必填',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('ID'),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { id } = ctx.query || {};
|
||||
if (!id) {
|
||||
ctx.throw(400, 'id 参数缺失');
|
||||
}
|
||||
const existing = await db.select().from(schema.life).where(eq(schema.life.id, id)).limit(1);
|
||||
if (existing.length === 0) {
|
||||
ctx.throw(404, '没有找到对应的 flowme-life');
|
||||
}
|
||||
if (existing[0].uid !== tokenUser.id) {
|
||||
ctx.throw(403, '没有权限删除该 flowme-life');
|
||||
}
|
||||
await db.delete(schema.life).where(eq(schema.life.id, id));
|
||||
ctx.body = { success: true };
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'flowme-life',
|
||||
key: 'get',
|
||||
middleware: ['auth'],
|
||||
description: '获取单个 flowme-life, 参数: id 必填',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('ID'),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { id } = ctx.query || {};
|
||||
if (!id) {
|
||||
ctx.throw(400, 'id 参数缺失');
|
||||
}
|
||||
const existing = await db.select().from(schema.life).where(eq(schema.life.id, id)).limit(1);
|
||||
if (existing.length === 0) {
|
||||
ctx.throw(404, '没有找到对应的 flowme-life');
|
||||
}
|
||||
if (existing[0].uid !== tokenUser.id) {
|
||||
ctx.throw(403, '没有权限查看该 flowme-life');
|
||||
}
|
||||
ctx.body = existing[0];
|
||||
}).addTo(app);
|
||||
174
src/routes/flowme-life/today.ts
Normal file
174
src/routes/flowme-life/today.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { desc, eq, count, like, and, lt } from 'drizzle-orm';
|
||||
import { schema, app, db } from '@/app.ts'
|
||||
import z from 'zod';
|
||||
import dayjs from 'dayjs';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { createLunarDate, toGregorian } from 'lunar';
|
||||
import { getLifeItem, updateLifeEffectiveAt } from './life.services.ts';
|
||||
app.route({
|
||||
path: 'flowme-life',
|
||||
key: 'today',
|
||||
description: `获取今天需要做的事情列表`,
|
||||
middleware: ['auth'],
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const uid = tokenUser.id;
|
||||
|
||||
const tomorrow = dayjs().add(1, 'day').startOf('day').toDate();
|
||||
|
||||
let whereCondition = eq(schema.life.uid, uid);
|
||||
whereCondition = and(
|
||||
eq(schema.life.uid, uid),
|
||||
eq(schema.life.taskType, '运行中'),
|
||||
lt(schema.life.effectiveAt, tomorrow)
|
||||
);
|
||||
|
||||
const list = await db.select()
|
||||
.from(schema.life)
|
||||
.where(whereCondition)
|
||||
.orderBy(desc(schema.life.effectiveAt));
|
||||
|
||||
console.log('today res', list.map(i => i['title']));
|
||||
if (list.length > 0) {
|
||||
ctx.body = {
|
||||
list,
|
||||
content: list.map(item => {
|
||||
return `任务id:[${item['id']}]\n标题: ${item['title']}。\n启动时间: ${dayjs(item['effectiveAt']).format('YYYY-MM-DD HH:mm:ss')}。标签: ${item['tags'] || '无'} \n总结: ${item['summary'] || '无'}`;
|
||||
}).join('\n')
|
||||
};
|
||||
} else {
|
||||
ctx.body = {
|
||||
list,
|
||||
content: '今天没有需要做的事情了,休息一下吧'
|
||||
}
|
||||
}
|
||||
}).addTo(app);
|
||||
|
||||
|
||||
app.route({
|
||||
path: 'flowme-life',
|
||||
key: 'done',
|
||||
description: `完成某件事情,然后判断下一次运行时间。参数是id(string),数据类型是string。如果多个存在,则是ids的string数组`,
|
||||
middleware: ['auth'],
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().optional().describe('记录id'),
|
||||
ids: z.array(z.string()).optional().describe('记录id数组'),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const id = ctx.query.id;
|
||||
const ids: string[] = ctx.query.ids || [];
|
||||
if (!id && ids.length === 0) {
|
||||
ctx.throw(400, '缺少参数 id');
|
||||
}
|
||||
if (ids.length === 0 && id) {
|
||||
ids.push(String(id));
|
||||
}
|
||||
console.log('id', id, ids);
|
||||
const messages = [];
|
||||
const changeItem = async (id: string) => {
|
||||
// 获取记录详情
|
||||
const recordRes = await getLifeItem(id);
|
||||
if (recordRes.code !== 200) {
|
||||
messages.push({
|
||||
id,
|
||||
content: `获取记录 ${id} 详情失败`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const record = recordRes.data;
|
||||
|
||||
// 检查启动时间是否大于今天
|
||||
const startTime = record.effectiveAt;
|
||||
const today = dayjs().startOf('day');
|
||||
const startDate = dayjs(startTime).startOf('day');
|
||||
|
||||
if (startDate.isAfter(today)) {
|
||||
messages.push({
|
||||
id,
|
||||
content: `记录 ${id} 的启动时间是 ${dayjs(startTime).format('YYYY-MM-DD HH:mm:ss')},还没到今天呢,到时候再做吧`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 计算下一次运行时间
|
||||
// 1. 知道当前时间
|
||||
// 2. 知道任务类型,如果是每日,则加一天;如果是每周,则加七天;如果是每月,则加一个月,如果是每年农历,需要转为新的,如果是其他,需要智能判断
|
||||
// 3. 更新记录
|
||||
const strTime = (time: string | Date) => {
|
||||
return dayjs(time).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
const currentTime = strTime(new Date().toISOString());
|
||||
const title = record.title || '无标题';
|
||||
const isLuar = record.type?.includes?.('农历') || title.includes('农历');
|
||||
let summay = record.summary || '无';
|
||||
if (summay.length > 200) {
|
||||
summay = summay.substring(0, 200) + '...';
|
||||
}
|
||||
const prompt = record.prompt || '';
|
||||
const type = record.type || '';
|
||||
const content = `上一次执行的时间是${strTime(startTime)},当前时间是${currentTime},请帮我计算下一次的运行时间,如果时间不存在,默认在8点启动。
|
||||
${prompt ? `这是我给你的提示词,帮你更好地理解我的需求:${prompt}` : ''}
|
||||
|
||||
相关资料是
|
||||
任务:${record.title}
|
||||
总结:${summay}
|
||||
类型: ${type}
|
||||
`
|
||||
const ai = useContextKey('ai');
|
||||
await ai.chat([
|
||||
{ role: 'system', content: `你是一个时间计算专家,擅长根据任务类型和时间计算下一次运行时间。只返回我对应的日期的结果,格式是:YYYY-MM-DD HH:mm:ss。如果类型是每日,则加一天;如果是每周,则加七天;如果是每月,则加一个月,如果是每年农历,需要转为新的,如果是其他,需要智能判断` },
|
||||
{ role: 'user', content }
|
||||
])
|
||||
let nextTime = ai.responseText?.trim();
|
||||
try {
|
||||
// 判断返回的时间是否可以格式化
|
||||
if (nextTime && dayjs(nextTime).isValid()) {
|
||||
const time = dayjs(nextTime);
|
||||
if (isLuar) {
|
||||
const festival = createLunarDate({ year: time.year(), month: time.month() + 1, day: time.date() });
|
||||
const { date } = toGregorian(festival);
|
||||
nextTime = dayjs(date).toISOString();
|
||||
} else {
|
||||
nextTime = time.toISOString();
|
||||
}
|
||||
} else {
|
||||
messages.push({
|
||||
id,
|
||||
content: `记录 ${id} 的任务 "${record.title}",AI 返回的时间格式无效,无法格式化,返回内容是:${ai.responseText}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
messages.push({
|
||||
id,
|
||||
content: `记录 ${id} 的任务 "${record.title}",AI 返回结果解析失败,返回内容是:${ai.responseText}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const update = await updateLifeEffectiveAt(id, nextTime);
|
||||
if (update.code !== 200) {
|
||||
messages.push({
|
||||
id,
|
||||
content: `记录 ${id} 的任务 "${record.title}",更新记录失败`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const msg = {
|
||||
id,
|
||||
nextTime,
|
||||
showCNTime: dayjs(nextTime).format('YYYY-MM-DD HH:mm:ss'),
|
||||
content: `任务 "${record.title}" 已标记为完成。下一次运行时间是 ${dayjs(nextTime).format('YYYY-MM-DD HH:mm:ss')}`
|
||||
};
|
||||
messages.push(msg);
|
||||
}
|
||||
|
||||
for (const _id of ids) {
|
||||
await changeItem(String(_id));
|
||||
}
|
||||
ctx.body = {
|
||||
content: messages.map(m => m.content).join('\n'),
|
||||
list: messages
|
||||
};
|
||||
|
||||
}).addTo(app);
|
||||
@@ -2,4 +2,4 @@ import './list.ts'
|
||||
|
||||
// flowme channel 相关路由
|
||||
|
||||
import './flowme-channel/list.ts'
|
||||
import '../flowme-channel/list.ts'
|
||||
@@ -1,5 +1,6 @@
|
||||
import { desc, eq, count, or, like, and } from 'drizzle-orm';
|
||||
import { desc, eq, or, like, and, gte, lte } from 'drizzle-orm';
|
||||
import { schema, app, db } from '@/app.ts'
|
||||
import z from 'zod';
|
||||
|
||||
// 获取 flowme 列表
|
||||
app.route({
|
||||
@@ -7,10 +8,24 @@ app.route({
|
||||
key: 'list',
|
||||
middleware: ['auth'],
|
||||
description: '获取 flowme 列表',
|
||||
metadata: {
|
||||
args: {
|
||||
page: z.number().describe('页码, 默认为 1').optional(),
|
||||
pageSize: z.number().describe('每页数量, 默认为 100').optional(),
|
||||
search: z.string().describe('搜索关键词').optional(),
|
||||
channelId: z.string().describe('频道ID').optional(),
|
||||
type: z.string().describe('类型').optional(),
|
||||
sort: z.enum(['ASC', 'DESC']).describe('排序方式,ASC 或 DESC,默认为 DESC').optional(),
|
||||
timeRange: z.object({
|
||||
from: z.string().describe('开始时间,ISO 格式').optional(),
|
||||
to: z.string().describe('结束时间,ISO 格式').optional(),
|
||||
}).describe('时间范围过滤').optional(),
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const uid = tokenUser.id;
|
||||
const { page = 1, pageSize = 20, search, channelId, sort = 'DESC' } = ctx.query || {};
|
||||
const { page = 1, pageSize = 100, search, channelId, type, sort = 'DESC', timeRange } = ctx.query || {};
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const orderByField = sort === 'ASC' ? schema.flowme.updatedAt : desc(schema.flowme.updatedAt);
|
||||
@@ -31,18 +46,37 @@ app.route({
|
||||
eq(schema.flowme.channelId, channelId)
|
||||
);
|
||||
}
|
||||
if (type) {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
eq(schema.flowme.type, type)
|
||||
);
|
||||
}
|
||||
if (timeRange) {
|
||||
const { from, to } = timeRange;
|
||||
if (from) {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
gte(schema.flowme.updatedAt, new Date(from))
|
||||
);
|
||||
}
|
||||
if (to) {
|
||||
whereCondition = and(
|
||||
whereCondition,
|
||||
lte(schema.flowme.updatedAt, new Date(to))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
]);
|
||||
const rows = await db.select()
|
||||
.from(schema.flowme)
|
||||
.where(whereCondition)
|
||||
.limit(pageSize + 1)
|
||||
.offset(offset)
|
||||
.orderBy(orderByField);
|
||||
|
||||
const hasMore = rows.length > pageSize;
|
||||
const list = hasMore ? rows.slice(0, pageSize) : rows;
|
||||
|
||||
ctx.body = {
|
||||
list,
|
||||
@@ -50,67 +84,99 @@ app.route({
|
||||
page,
|
||||
current: page,
|
||||
pageSize,
|
||||
total: totalCount[0]?.count || 0,
|
||||
hasMore,
|
||||
},
|
||||
};
|
||||
return ctx;
|
||||
}).addTo(app);
|
||||
|
||||
// 创建或更新 flowme
|
||||
const flowmeUpdate = `创建或更新一个 flowme, 参数定义:
|
||||
title: 标题, 必填
|
||||
description: 描述, 选填
|
||||
tags: 标签, 数组, 选填
|
||||
link: 链接, 选填
|
||||
data: 数据, 对象, 选填
|
||||
channelId: 频道ID, 选填
|
||||
type: 类型, 选填
|
||||
source: 来源, 选填
|
||||
importance: 重要性等级, 数字, 选填
|
||||
`;
|
||||
// 创建 flowme
|
||||
app.route({
|
||||
path: 'flowme',
|
||||
key: 'create',
|
||||
middleware: ['auth'],
|
||||
description: '创建一个 flowme',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
title: z.string().describe('标题').optional(),
|
||||
description: z.string().describe('描述').optional(),
|
||||
tags: z.array(z.string()).describe('标签').optional(),
|
||||
link: z.string().describe('链接').optional(),
|
||||
data: z.record(z.string(), z.any()).describe('数据').optional(),
|
||||
channelId: z.string().describe('频道ID').optional(),
|
||||
type: z.string().describe('类型').optional(),
|
||||
source: z.string().describe('来源').optional(),
|
||||
importance: z.number().describe('重要性等级').optional(),
|
||||
isArchived: z.boolean().describe('是否归档').optional(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const { uid, updatedAt, createdAt, ...rest } = ctx.query.data || {};
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const 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();
|
||||
ctx.body = flowmeItem;
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'flowme',
|
||||
key: 'update',
|
||||
middleware: ['auth'],
|
||||
description: flowmeUpdate,
|
||||
description: '更新一个 flowme',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().describe('ID'),
|
||||
title: z.string().describe('标题').optional(),
|
||||
description: z.string().describe('描述').optional(),
|
||||
tags: z.array(z.string()).describe('标签').optional(),
|
||||
link: z.string().describe('链接').optional(),
|
||||
data: z.record(z.string(), z.any()).describe('数据').optional(),
|
||||
channelId: z.string().describe('频道ID').optional(),
|
||||
type: z.string().describe('类型').optional(),
|
||||
source: z.string().describe('来源').optional(),
|
||||
importance: z.number().describe('重要性等级').optional(),
|
||||
isArchived: z.boolean().describe('是否归档').optional(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}).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.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');
|
||||
}
|
||||
const 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);
|
||||
|
||||
@@ -119,7 +185,14 @@ app.route({
|
||||
path: 'flowme',
|
||||
key: 'delete',
|
||||
middleware: ['auth'],
|
||||
description: '删除 flowme, 参数: data.id 必填',
|
||||
description: '删除 flowme ',
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().describe('ID'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { id } = ctx.query.data || {};
|
||||
|
||||
@@ -8,8 +8,6 @@ import './micro-app/index.ts';
|
||||
|
||||
import './config/index.ts';
|
||||
|
||||
// import './file-listener/index.ts';
|
||||
|
||||
import './mark/index.ts';
|
||||
|
||||
import './light-code/index.ts';
|
||||
@@ -22,4 +20,8 @@ import './views/index.ts';
|
||||
|
||||
import './query-views/index.ts';
|
||||
|
||||
import './flowme/index.ts'
|
||||
import './flowme/index.ts'
|
||||
|
||||
import './n5-link/index.ts'
|
||||
|
||||
import './flowme-life/index.ts'
|
||||
@@ -1,6 +1,5 @@
|
||||
import { eq, desc, and, like, or } from 'drizzle-orm';
|
||||
import { app, db, schema } from '../../app.ts';
|
||||
import { CustomError } from '@kevisual/router';
|
||||
import { filter } from '@kevisual/js-filter'
|
||||
import { z } from 'zod';
|
||||
app
|
||||
@@ -77,7 +76,7 @@ app
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const id = ctx.query.id;
|
||||
if (!id) {
|
||||
throw new CustomError('id is required');
|
||||
ctx.throw(400, 'id is required');
|
||||
}
|
||||
const result = await db
|
||||
.select()
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
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';
|
||||
import z from 'zod';
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'mark',
|
||||
key: 'list',
|
||||
description: 'mark list.',
|
||||
description: '获取mark列表',
|
||||
middleware: ['auth'],
|
||||
metadata: {
|
||||
args: {
|
||||
page: z.number().optional().describe('页码'),
|
||||
pageSize: z.number().optional().describe('每页数量'),
|
||||
search: z.string().optional().describe('搜索关键词'),
|
||||
markType: z.string().optional().describe('mark类型,simple,wallnote,md,draw等'),
|
||||
sort: z.enum(['DESC', 'ASC']).default('DESC').describe('排序字段'),
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
@@ -25,7 +33,13 @@ app
|
||||
.route({
|
||||
path: 'mark',
|
||||
key: 'getVersion',
|
||||
description: '获取mark版本信息',
|
||||
middleware: ['auth'],
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('mark id'),
|
||||
}
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
@@ -47,26 +61,6 @@ app
|
||||
};
|
||||
} 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);
|
||||
@@ -76,6 +70,12 @@ app
|
||||
path: 'mark',
|
||||
key: 'get',
|
||||
middleware: ['auth'],
|
||||
description: '获取mark详情',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('mark id'),
|
||||
}
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
@@ -92,24 +92,6 @@ app
|
||||
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);
|
||||
@@ -119,7 +101,20 @@ app
|
||||
path: 'mark',
|
||||
key: 'update',
|
||||
middleware: ['auth'],
|
||||
description: '更新mark内容',
|
||||
isDebug: true,
|
||||
metadata: {
|
||||
args: {
|
||||
data: z.object({
|
||||
id: z.string().describe('mark id'),
|
||||
title: z.string().default(''),
|
||||
tags: z.any().default([]),
|
||||
link: z.string().default(''),
|
||||
summary: z.string().default(''),
|
||||
description: z.string().default(''),
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
@@ -150,7 +145,6 @@ app
|
||||
markModel = updated[0];
|
||||
} else {
|
||||
const inserted = await db.insert(schema.microMark).values({
|
||||
id: nanoid(),
|
||||
data: data || {},
|
||||
...rest,
|
||||
uname: tokenUser.username,
|
||||
@@ -162,17 +156,31 @@ app
|
||||
ctx.body = markModel;
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'mark',
|
||||
key: 'updateNode',
|
||||
middleware: ['auth'],
|
||||
description: '更新mark节点,支持更新和删除操作',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('mark id'),
|
||||
operate: z.enum(['update', 'delete']).default('update').describe('节点操作类型,update或delete'),
|
||||
data: z.object({
|
||||
id: z.string().describe('节点id'),
|
||||
node: z.any().describe('要更新的节点数据'),
|
||||
}).describe('要更新的节点数据'),
|
||||
}
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const operate = ctx.query.operate || 'update';
|
||||
const markId = ctx.query.id;
|
||||
const { id, node } = ctx.query.data || {};
|
||||
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
|
||||
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, markId)).limit(1);
|
||||
const markModel = marks[0];
|
||||
if (!markModel) {
|
||||
ctx.throw(404, 'mark not found');
|
||||
@@ -184,7 +192,7 @@ app
|
||||
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);
|
||||
@@ -194,7 +202,7 @@ app
|
||||
} else {
|
||||
updatedNodes = [...nodes, node];
|
||||
}
|
||||
|
||||
|
||||
const version = Number(markModel.version) + 1;
|
||||
const updated = await db.update(schema.microMark)
|
||||
.set({
|
||||
@@ -202,7 +210,7 @@ app
|
||||
version,
|
||||
updatedAt: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(schema.microMark.id, id))
|
||||
.where(eq(schema.microMark.id, markId))
|
||||
.returning();
|
||||
ctx.body = updated[0];
|
||||
})
|
||||
@@ -212,10 +220,20 @@ app
|
||||
path: 'mark',
|
||||
key: 'updateNodes',
|
||||
middleware: ['auth'],
|
||||
description: '批量更新mark节点,支持更新和删除操作',
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('mark id'),
|
||||
nodeOperateList: z.array(z.object({
|
||||
operate: z.enum(['update', 'delete']).default('update').describe('节点操作类型,update或delete'),
|
||||
node: z.any().describe('要更新的节点数据'),
|
||||
})).describe('要更新的节点列表'),
|
||||
}
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { id, nodeOperateList } = ctx.query.data || {};
|
||||
const { id, nodeOperateList } = ctx.query || {};
|
||||
const marks = await db.select().from(schema.microMark).where(eq(schema.microMark.id, id)).limit(1);
|
||||
const markModel = marks[0];
|
||||
if (!markModel) {
|
||||
@@ -230,15 +248,15 @@ app
|
||||
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) {
|
||||
@@ -247,7 +265,7 @@ app
|
||||
nodes.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const version = Number(markModel.version) + 1;
|
||||
const updated = await db.update(schema.microMark)
|
||||
.set({
|
||||
@@ -266,6 +284,11 @@ app
|
||||
path: 'mark',
|
||||
key: 'delete',
|
||||
middleware: ['auth'],
|
||||
metadata: {
|
||||
args: {
|
||||
id: z.string().describe('mark id'),
|
||||
}
|
||||
},
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
@@ -284,7 +307,51 @@ app
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({ path: 'mark', key: 'getMenu', description: '获取菜单', middleware: ['auth'] })
|
||||
.route({
|
||||
path: 'mark',
|
||||
key: 'create',
|
||||
description: '创建一个新的mark.',
|
||||
middleware: ['auth'],
|
||||
metadata: {
|
||||
args: {
|
||||
title: z.string().default('').describe('标题'),
|
||||
tags: z.any().default([]).describe('标签'),
|
||||
link: z.string().default('').describe('链接'),
|
||||
summary: z.string().default('').describe('摘要'),
|
||||
description: z.string().default('').describe('描述'),
|
||||
markType: z.string().default('md').describe('mark类型'),
|
||||
config: z.any().default({}).describe('配置'),
|
||||
data: z.any().default({}).describe('数据')
|
||||
}
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { title, tags, link, summary, description, markType, config, data } = ctx.query;
|
||||
const inserted = await db.insert(schema.microMark).values({
|
||||
title,
|
||||
tags: tags || [],
|
||||
link: link || '',
|
||||
summary: summary || '',
|
||||
description: description || '',
|
||||
markType: markType || 'md',
|
||||
config: config || {},
|
||||
data: data || {},
|
||||
uname: tokenUser.username,
|
||||
uid: tokenUser.id,
|
||||
puid: tokenUser.uid,
|
||||
}).returning();
|
||||
ctx.body = inserted[0];
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'mark',
|
||||
key: 'getMenu',
|
||||
description: '获取mark菜单',
|
||||
middleware: ['auth']
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const [rows, totalResult] = await Promise.all([
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { app } from '@/app.ts';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
import { appPathCheck, installApp } from './module/install-app.ts';
|
||||
import { manager } from './manager-app.ts';
|
||||
import { selfRestart } from '@/modules/self-restart.ts';
|
||||
import { AppListModel } from '../app-manager/module/index.ts';
|
||||
import { AppList } from '../app-manager/module/index.ts';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
// curl http://localhost:4002/api/router?path=micro-app&key=deploy
|
||||
// 把对应的应用安装到系统的apps目录下,并解压,然后把配置项写入数据库配置
|
||||
// key 是应用的唯一标识,和package.json中的key一致,绑定关系
|
||||
@@ -26,17 +27,17 @@ app
|
||||
if (data.username && username === 'admin') {
|
||||
username = data.username;
|
||||
}
|
||||
let microApp: AppListModel;
|
||||
let microApp: AppList | undefined;
|
||||
if (!microApp && id) {
|
||||
microApp = await AppListModel.findByPk(id);
|
||||
const apps = await db.select().from(schema.kvAppList).where(eq(schema.kvAppList.id, id)).limit(1);
|
||||
microApp = apps[0];
|
||||
}
|
||||
if (!microApp && postAppKey) {
|
||||
microApp = await AppListModel.findOne({
|
||||
where: {
|
||||
key: postAppKey,
|
||||
version: postVersion,
|
||||
},
|
||||
});
|
||||
const apps = await db.select().from(schema.kvAppList).where(and(
|
||||
eq(schema.kvAppList.key, postAppKey),
|
||||
eq(schema.kvAppList.version, postVersion)
|
||||
)).limit(1);
|
||||
microApp = apps[0];
|
||||
}
|
||||
|
||||
if (!microApp) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import path from 'path';
|
||||
const assistantAppsConfig = path.join(process.cwd(), 'assistant-apps-config.json');
|
||||
const isExist = fileIsExist(assistantAppsConfig);
|
||||
export const existDenpend = [
|
||||
'sequelize', // commonjs
|
||||
'pg', // commonjs
|
||||
'@kevisual/router', // 共享模块
|
||||
'ioredis', // commonjs
|
||||
|
||||
1
src/routes/n5-link/index.ts
Normal file
1
src/routes/n5-link/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
import './list.ts';
|
||||
3
src/routes/n5-link/list.ts
Normal file
3
src/routes/n5-link/list.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import './short-link.ts';
|
||||
import './n5-make/index.ts';
|
||||
import './n5-shop/index.ts';
|
||||
7
src/routes/n5-link/modules/n5.services.ts
Normal file
7
src/routes/n5-link/modules/n5.services.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { desc, eq, count, or, like, and } from 'drizzle-orm';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
export class N5Service {
|
||||
static async getBySlug(slug: string) {
|
||||
return db.select().from(schema.shortLink).where(eq(schema.shortLink.slug, slug)).limit(1);
|
||||
}
|
||||
}
|
||||
24
src/routes/n5-link/n5-make/create.ts
Normal file
24
src/routes/n5-link/n5-make/create.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
|
||||
app.route({
|
||||
path: 'n5-make',
|
||||
key: 'create',
|
||||
middleware: ['auth'],
|
||||
description: `创建 Make, 参数:
|
||||
slug: 唯一业务ID, 必填
|
||||
resources: 资源列表(数组), 选填
|
||||
`,
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const { userId, createdAt, updatedAt, id: _id, ...rest } = ctx.query.data || {};
|
||||
if (!rest.slug) {
|
||||
ctx.throw(400, 'slug 参数缺失');
|
||||
}
|
||||
const result = await db.insert(schema.n5Make).values({
|
||||
...rest,
|
||||
userId: tokenUser.id,
|
||||
}).returning();
|
||||
ctx.body = result[0];
|
||||
return ctx;
|
||||
}).addTo(app);
|
||||
25
src/routes/n5-link/n5-make/delete.ts
Normal file
25
src/routes/n5-link/n5-make/delete.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
|
||||
app.route({
|
||||
path: 'n5-make',
|
||||
key: 'delete',
|
||||
middleware: ['auth'],
|
||||
description: '删除 Make, 参数: id Make 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.n5Make).where(eq(schema.n5Make.id, id)).limit(1);
|
||||
if (existing.length === 0) {
|
||||
ctx.throw(404, 'Make 不存在');
|
||||
}
|
||||
if (existing[0].userId !== tokenUser.id) {
|
||||
ctx.throw(403, '没有权限删除该 Make');
|
||||
}
|
||||
await db.delete(schema.n5Make).where(eq(schema.n5Make.id, id));
|
||||
ctx.body = { success: true };
|
||||
return ctx;
|
||||
}).addTo(app);
|
||||
4
src/routes/n5-link/n5-make/index.ts
Normal file
4
src/routes/n5-link/n5-make/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import './list.ts';
|
||||
import './create.ts';
|
||||
import './update.ts';
|
||||
import './delete.ts';
|
||||
48
src/routes/n5-link/n5-make/list.ts
Normal file
48
src/routes/n5-link/n5-make/list.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { desc, eq, count } from 'drizzle-orm';
|
||||
import { app, db, schema } from '@/app.ts';
|
||||
|
||||
// 列表
|
||||
app.route({
|
||||
path: 'n5-make',
|
||||
key: 'list',
|
||||
middleware: ['auth'],
|
||||
description: '获取 Make 列表, 参数: page, pageSize, sort',
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state.tokenUser;
|
||||
const uid = tokenUser.id;
|
||||
const { page = 1, pageSize = 20, sort = 'DESC' } = ctx.query || {};
|
||||
|
||||
const offset = (page - 1) * pageSize;
|
||||
const orderByField = sort === 'ASC' ? schema.n5Make.updatedAt : desc(schema.n5Make.updatedAt);
|
||||
|
||||
const whereCondition = eq(schema.n5Make.userId, uid);
|
||||
const [list, totalCount] = await Promise.all([
|
||||
db.select().from(schema.n5Make).where(whereCondition).limit(pageSize).offset(offset).orderBy(orderByField),
|
||||
db.select({ count: count() }).from(schema.n5Make).where(whereCondition),
|
||||
]);
|
||||
|
||||
ctx.body = {
|
||||
list,
|
||||
pagination: { page, current: page, pageSize, total: totalCount[0]?.count || 0 },
|
||||
};
|
||||
return ctx;
|
||||
}).addTo(app);
|
||||
|
||||
// 获取单个
|
||||
app.route({
|
||||
path: 'n5-make',
|
||||
key: 'get',
|
||||
description: '获取单个 Make, 参数: id 或 slug',
|
||||
}).define(async (ctx) => {
|
||||
const { id, slug } = ctx.query || {};
|
||||
if (!id && !slug) {
|
||||
ctx.throw(400, 'id 或 slug 参数缺失');
|
||||
}
|
||||
const condition = id ? eq(schema.n5Make.id, id) : eq(schema.n5Make.slug, slug);
|
||||
const existing = await db.select().from(schema.n5Make).where(condition).limit(1);
|
||||
if (existing.length === 0) {
|
||||
ctx.throw(404, 'Make 不存在');
|
||||
}
|
||||
ctx.body = existing[0];
|
||||
return ctx;
|
||||
}).addTo(app);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user