Compare commits

..

66 Commits

Author SHA1 Message Date
xiongxiao
afa5802ef2 feat: 更新依赖版本,修复路由 ID 字段,优化获取 mark 详情逻辑 2026-03-23 18:44:52 +08:00
xiongxiao
dfd4aacf1c Refactor code structure for improved readability and maintainability 2026-03-22 03:04:06 +08:00
xiongxiao
56a55ac5ae update 2026-03-21 15:11:47 +08:00
xiongxiao
8184994b2c Refactor code structure for improved readability and maintainability 2026-03-20 18:13:15 +08:00
xiongxiao
2000b474a0 feat: 更新重定向路径,将 '/root/home/' 修改为 '/root/center/' 2026-03-19 16:49:37 +08:00
xiongxiao
07efc4e468 feat: 更新 microMark schema 和 mark 路由,添加 skill 2026-03-19 01:56:47 +08:00
xiongxiao
f305a900f4 feat: 将 metadata 中的参数封装到 args 对象中,以统一结构 2026-03-19 01:17:43 +08:00
xiongxiao
6a92ee7a2d feat: 更新 mark 路由,添加 metadata 验证,移除未使用的代码 2026-03-18 22:55:08 +08:00
xiongxiao
6467e6dea8 feat: 更新 N5Proxy 以支持生成带有刷新令牌的 JWKS 令牌 2026-03-18 22:31:31 +08:00
xiongxiao
08884b7e4b feat: 添加编辑模式支持,重定向到指定文件夹路径 2026-03-18 16:54:38 +08:00
xiongxiao
9be5eb00f5 chore: update dependencies and remove unused convex package
- Removed "@kevisual/convex" dependency from package.json and pnpm-lock.yaml.
- Updated "ioredis" version from "^5.9.3" to "^5.10.0" in package.json and pnpm-lock.yaml.
- Updated "@kevisual/context", "@kevisual/query", and "@kevisual/router" versions in wxmsg/package.json.
- Updated "@types/node" version in wxmsg/package.json.
- Added resolutions for "ioredis" in wxmsg/package.json.
- Commented out the automatic reply functionality in wxmsg/src/wx/index.ts and added a placeholder message.
2026-03-18 03:24:33 +08:00
xiongxiao
2332f05cef Refactor code structure for improved readability and maintainability 2026-03-17 20:52:31 +08:00
10874917f2 feat: 更新语音转文字功能,支持从音频链接获取音频数据并转换为base64;添加错误处理和权限验证 2026-03-12 02:45:46 +08:00
99141a926e feat: 添加 flowme-channel 功能,包括获取、创建、更新和删除接口;更新 flowme 列表接口以支持时间范围和类型过滤 2026-03-12 00:55:42 +08:00
61a809ecd7 feat: 更新聊天接口,添加当前时间到系统助手消息中;修改生效日期描述格式 2026-03-11 02:51:05 +08:00
66a19139b7 feat: implement AI agent for flowme-life interactions
- Add agent-run module to handle AI interactions with tools and messages.
- Create routes for proxying requests to OpenAI and Anthropic APIs.
- Implement flowme-life chat route for user queries and task management.
- Add services for retrieving and updating life records in the database.
- Implement logic for fetching today's tasks and marking tasks as done with next execution time calculation.
- Introduce tests for flowme-life functionalities.
2026-03-11 01:44:29 +08:00
027cbecab6 feat: 添加 flowme-life 功能,包括创建、更新、删除和列表接口,导入 life JSON 数据到数据库 2026-03-10 20:17:49 +08:00
48425c6120 feat: 添加短链管理功能,包括创建、更新、删除和列表接口 2026-03-10 19:46:50 +08:00
91eaad04d7 feat: 更新N5Proxy逻辑,使用baseURL构建请求链接,并优化错误处理与HTML渲染 2026-03-10 18:18:22 +08:00
38d31a9fb5 feat: 注释掉auth中间件中对checkAppId的调用,避免重复验证用户信息 2026-03-10 00:13:00 +08:00
efb30708eb feat: 添加checkAppId函数以验证上下文中的App ID,并在auth中间件中使用 2026-03-09 19:23:51 +08:00
e68b77f871 feat: 删除studio-app-list模块,优化proxy.ts和flowme路由的结构与逻辑 2026-03-09 02:37:29 +08:00
2b5d3250a4 feat: 更新handleRequest逻辑,确保newId参数与用户关联以避免冲突 2026-03-07 19:48:26 +08:00
69422f3e3f update 2026-03-06 23:41:52 +08:00
fb58d91e50 feat: 优化token验证逻辑,添加异常处理以增强稳定性 2026-03-06 13:20:41 +08:00
53204291ce feat: 更新@kevisual/api依赖版本至0.0.62,优化WsProxyManager和请求处理逻辑 2026-03-06 00:05:34 +08:00
b31f2840b9 temp 2026-03-05 13:34:01 +08:00
42957af179 refactor: remove unused file, update timestamps on query views and views
- Deleted the unused file `src/auth/drizzle/one.ts`.
- Modified `updatedAt` field in `src/db/drizzle/schema.ts` to automatically update on record changes.
- Added `updatedAt` field to the response in `src/routes/query-views/list.ts` and `src/routes/views/list.ts` to ensure it reflects the current timestamp.
2026-03-05 13:32:56 +08:00
2518f6bba3 feat: 更新WsProxyManager注册逻辑,添加isLogin参数并优化HTML响应处理 2026-03-05 04:31:45 +08:00
bbdf9f087d feat: enhance WebSocket proxy with user connection management and status reporting
- Updated StudioOpts type to include infoList for user connection status.
- Added rendering of connection info in createStudioAppListHtml.
- Modified self-restart logic to use a specific app path.
- Improved WebSocket connection handling in wsProxyManager, including user registration and ID management.
- Implemented connection status checks and responses in UserV1Proxy.
- Introduced renderServerHtml function to inject server data into HTML responses.
- Refactored page-proxy request handling for better URL management.
2026-03-05 03:58:46 +08:00
aaedcb881b feat: 添加createHexTime和parseHexTime函数,用于处理十六进制时间字符串 2026-03-04 00:36:56 +08:00
d2913dd32d feat: 更新JWKS token创建逻辑,支持refresh token选项 2026-03-03 19:10:57 +08:00
bb4096ce7e feat: 更新N5代理逻辑,支持使用用户JWKS令牌进行身份验证 2026-03-03 18:33:41 +08:00
120303961c feat: integrate Convex API and add N5 proxy functionality
- Added Convex client setup in a new module for handling Convex API interactions.
- Implemented N5Proxy to handle requests for the /n5/:slug route, querying Convex for application links.
- Updated app context to include Convex client and API.
- Adjusted routing to support new Convex API endpoints.
- Enhanced error handling for missing applications in the N5 proxy.
2026-03-03 14:35:46 +08:00
75ab160509 refactor: 优化getFileList函数,移除不必要的newObjectName变量 2026-03-03 02:48:05 +08:00
a48cc48589 fix: decode URI components in object path handling
- Added decodeURIComponent to the objectName in getObjectByPathname and getObjectName functions to ensure proper handling of encoded paths.
- Updated newNamePath in renameProxy to decode the pathname before processing.
- Removed redundant comment regarding file renaming in renameProxy.
2026-03-02 01:48:15 +08:00
1ae4c979dc feat: 更新token验证逻辑,支持jwks类型token并增强用户验证 2026-02-28 04:25:37 +08:00
999a75c76b feat: 修改用户令牌创建逻辑,使用'jwks'作为令牌类型 2026-02-27 04:00:07 +08:00
0084f9878b feat: 添加发布应用目录功能,增强应用版本管理和权限校验 2026-02-26 14:13:16 +08:00
79e07d6689 feat: 更新依赖版本,优化用户模型构造函数和域名管理路由 2026-02-24 01:03:56 +08:00
4b8f47cea8 feat: 添加AGENTS文档,概述项目模块和功能 2026-02-22 03:22:29 +08:00
6b5164e845 feat: 优化token刷新逻辑,支持使用访问token刷新token,增强错误处理 2026-02-21 19:35:06 +08:00
d50f5ed2af feat: 添加token验证日志,优化WebSocket连接管理 2026-02-21 07:28:01 +08:00
71c238f953 feat: 添加JWKS token支持,更新用户和OAuth相关逻辑 2026-02-21 06:29:11 +08:00
672208ab6b chore: 更新 bun.config.mjs 和 package.json,调整依赖版本和配置 2026-02-21 05:38:14 +08:00
77273bcfeb feat: 添加JWKS管理功能,支持基于用户token创建新token 2026-02-21 05:06:25 +08:00
366a21d621 feat: add CNB login functionality and user management
- Introduced `cnb-login` route to handle user login via CNB token.
- Created `CnbServices` class for managing CNB user interactions.
- Added `findByCnbId` method in the User model to retrieve users by CNB ID.
- Updated error handling to provide more structured error messages.
- Enhanced user creation logic to handle CNB users.
- Added tests for the new CNB login functionality.
2026-02-20 23:30:53 +08:00
1782a9ef19 refactor: migrate WebSocket proxy to v1-ws-proxy module
- Updated import paths to use the new v1-ws-proxy module.
- Removed the old ws-proxy module and its associated files.
- Implemented new WebSocket proxy logic in the v1-ws-proxy module.
- Adjusted UserV1Proxy to utilize the new WebSocket proxy manager and methods.
2026-02-20 21:55:48 +08:00
0d73941127 chore: 更新依赖版本,提升 @kevisual/query 和 @kevisual/context 的版本
feat: 在应用管理路由中添加元数据,增强版本检测和发布功能
2026-02-19 03:59:29 +08:00
7088d025c9 fix: 修改user-app测试路由的id参数为必填项 2026-02-19 03:31:22 +08:00
577b6bfaa4 Refactor code structure for improved readability and maintainability 2026-02-18 12:59:51 +08:00
9cc48821b1 chore: 更新 @kevisual/query、@aws-sdk/client-s3 和 @kevisual/router 的版本 2026-02-18 05:00:47 +08:00
9d39e1cd46 chore: 更新依赖版本,移除不必要的路由定义 2026-02-18 03:28:04 +08:00
6c611dcf78 feat: refactor route definitions and enhance metadata for app domain manager
- Updated route definitions in `src/route.ts` to utilize `toJSONSchema` and `pick` for cleaner route listing.
- Enhanced the `app.domain.manager` routes in `src/routes/app-manager/domain/manager.ts`:
  - Changed path format to snake_case.
  - Added descriptions for each route.
  - Introduced Zod schema validation for request arguments in metadata.
2026-02-18 03:17:45 +08:00
8c6d57d228 temp 2026-02-14 19:22:15 +08:00
84e7a4158d fix 2026-02-14 18:57:27 +08:00
9961efd91a 移除创建新标记时的 ID 生成逻辑,优化数据插入流程 2026-02-14 02:33:01 +08:00
bb8ce3338d 重构 JWKS 模块,新增 JWKS 管理器,优化密钥生成与管理逻辑,更新路由处理 2026-02-12 21:11:16 +08:00
3cca0720c1 更新依赖版本,提升兼容性和稳定性 2026-02-10 22:54:42 +08:00
ab0ba939d4 新增 JWKS 生成模块,支持密钥文件的创建与保存 2026-02-10 22:54:12 +08:00
e51cacd7a2 更新依赖版本,提升兼容性和稳定性 2026-02-07 13:11:53 +08:00
6bf8d86c10 重构配置管理,更新域名处理逻辑,简化配置结构 2026-02-07 13:06:28 +08:00
0be7627bd1 优化用户查找逻辑,新增根据微信 UnionId 查找用户的方法,简化数据库查询 2026-02-07 02:36:29 +08:00
885e04e301 Refactor code structure for improved readability and maintainability 2026-02-07 02:05:54 +08:00
0bd634faf2 优化组织和用户创建逻辑,简化插入数据结构并处理可选描述字段 2026-02-07 02:04:17 +08:00
7dfa96d165 Refactor app management to use Drizzle ORM
- Replaced Sequelize models with Drizzle ORM for app and app list management.
- Updated routes in app-manager to utilize new database queries.
- Removed obsolete Sequelize model files for app, app list, and app domain.
- Introduced new helper functions for app and app domain management.
- Enhanced user app management with improved file handling and user migration.
- Adjusted public API routes to align with new database structure.
- Implemented caching mechanisms for domain management.
2026-02-07 01:26:16 +08:00
134 changed files with 10438 additions and 4356 deletions

View File

@@ -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
View File

@@ -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
View File

@@ -1,2 +0,0 @@
@abearxiong:registry=https://npm.pkg.github.com
ignore-workspace-root-check=true

View 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);
```

View 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
View 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: 延迟工具

View File

@@ -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_*',
});

View File

@@ -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

File diff suppressed because it is too large Load Diff

78
scripts/import-data.ts Normal file
View 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
View 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
View 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": "beadskevisual.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"
}
]

View File

@@ -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',
}

View File

@@ -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;
};

View File

@@ -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'
})

View File

@@ -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);

View File

@@ -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 {
// }

View File

@@ -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';

View 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()

View File

@@ -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);

View File

@@ -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);

View File

@@ -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));

View File

@@ -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()

View File

@@ -1,2 +1,3 @@
export * from './oauth.ts';
export * from './salt.ts';
export * from './salt.ts';
export * from './auth.ts';

View File

@@ -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;
};

View 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");

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1767070768620,
"tag": "0001_solid_nocturne",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1773148571509,
"tag": "0002_loving_lyja",
"breakpoints": true
}
]
}

View File

@@ -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()),
]);

View File

@@ -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'

View 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()),
]);

View 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(),
// 对外暴露的唯一业务 IDnanoid 生成
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),
]);

View File

@@ -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', () => {

View File

@@ -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 };

View 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 });
}

View File

@@ -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,
},
}
};

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;

View File

@@ -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();

View 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;
}

View File

@@ -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>
`;
};

View File

@@ -1 +0,0 @@
export { sequelize } from './sequelize.ts';

79
src/modules/n5/index.ts Normal file
View 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();
};

View File

@@ -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`);

View File

@@ -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);
});

View File

@@ -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) {

View File

@@ -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);

View 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);
}
}

View 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);
}

View 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;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
};

View File

@@ -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',

View File

@@ -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);
};

View File

@@ -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);
};

View File

@@ -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('/')) {

View 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);
});

View 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);
});

View 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 }));
// });

View File

@@ -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));
}
};

View File

@@ -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);

View File

@@ -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');
}

View File

@@ -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);
}
}

View 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);
}
}
}

View File

@@ -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,
},
);

View 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;
}
}

View File

@@ -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);
// });

View File

@@ -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);
// });

View File

@@ -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'

View File

@@ -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,

View File

@@ -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}/`,

View File

@@ -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,
};
})

View File

@@ -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;

View File

@@ -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',

View File

@@ -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;

View File

@@ -1 +1 @@
import './list.ts';
// import './list.ts';

View File

@@ -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);

View File

@@ -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 };

View 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或messagesquestion是用户的提问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);

View File

@@ -0,0 +1,3 @@
import './list.ts'
import './today.ts'
import './chat.ts'

View 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}` };
}
}

View 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);

View 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: `完成某件事情然后判断下一次运行时间。参数是idstring数据类型是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);

View File

@@ -2,4 +2,4 @@ import './list.ts'
// flowme channel 相关路由
import './flowme-channel/list.ts'
import '../flowme-channel/list.ts'

View File

@@ -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 || {};

View File

@@ -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'

View File

@@ -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()

View File

@@ -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([

View File

@@ -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) {

View File

@@ -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

View File

@@ -0,0 +1 @@
import './list.ts';

View File

@@ -0,0 +1,3 @@
import './short-link.ts';
import './n5-make/index.ts';
import './n5-shop/index.ts';

View 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);
}
}

View 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);

View 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);

View File

@@ -0,0 +1,4 @@
import './list.ts';
import './create.ts';
import './update.ts';
import './delete.ts';

View 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