Compare commits

...

52 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
103 changed files with 9186 additions and 2125 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);
```

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

@@ -16,7 +16,23 @@ 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`;

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,76 +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.44",
"@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.69.3",
"bullmq": "^5.71.0",
"busboy": "^1.6.0",
"drizzle-kit": "^0.31.9",
"drizzle-kit": "^0.31.10",
"drizzle-orm": "^0.45.1",
"drizzle-zod": "^0.8.3",
"eventemitter3": "^5.0.4",
"pg": "^8.18.0",
"pm2": "^6.0.14",
"send": "^1.2.1",
"ws": "npm:@kevisual/ws",
"xml2js": "^0.6.2"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.991.0",
"@kevisual/api": "^0.0.51",
"@kevisual/context": "^0.0.6",
"@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.74",
"@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.9",
"@types/bun": "^1.3.11",
"@types/crypto-js": "^4.2.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^25.2.3",
"@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",
"dayjs": "^1.11.20",
"dotenv": "^17.3.1",
"es-toolkit": "^1.44.0",
"ioredis": "^5.9.3",
"drizzle-zod": "^0.8.3",
"es-toolkit": "^1.45.1",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
"nanoid": "^5.1.6",
"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",
"zod": "^4.3.6"
},
"resolutions": {
"inflight": "latest",
"picomatch": "^4.0.2"
"picomatch": "^4.0.2",
"ioredis": "^5.10.0"
},
"packageManager": "pnpm@10.30.0"
"packageManager": "pnpm@10.32.1",
"workspaces": [
"wxmsg"
]
}

2434
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

@@ -7,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 {
@@ -41,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,7 +1,7 @@
import { useContextKey } from '@kevisual/context';
import { Redis } from 'ioredis';
import { User } from './user.ts';
import { oauth } from '../oauth/auth.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';
@@ -53,6 +53,34 @@ export class UserSecret {
* @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);
}
@@ -62,11 +90,11 @@ export class UserSecret {
}
console.log('verifyToken: try to verify as secret key');
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
@@ -97,13 +125,13 @@ export class UserSecret {
*/
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;
}
@@ -119,12 +147,12 @@ export class UserSecret {
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> = {
@@ -142,7 +170,7 @@ export class UserSecret {
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;
@@ -164,7 +192,7 @@ export class UserSecret {
const expiredTime = new Date(this.expiredTime);
return now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期
}
/**
* 检查是否过期如果过期则更新状态为expired
*
@@ -225,7 +253,7 @@ export class UserSecret {
await this.save();
return token;
}
static async createToken() {
let token = oauth.generateSecretKey();
// 确保生成的token是唯一的
@@ -234,7 +262,7 @@ export class UserSecret {
}
return token;
}
/**
* 根据 unionid 生成redis的key
* `wxmp:unionid:token:${unionid}`
@@ -244,13 +272,13 @@ export class UserSecret {
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);
}
static async createSecret(tokenUser: { id: string; uid?: string, title?: string }, expireDays = 365) {
const token = await UserSecret.createToken();
let userId = tokenUser.id;
@@ -259,18 +287,18 @@ export class UserSecret {
userId = tokenUser.uid;
orgId = tokenUser.id;
}
const insertData: Partial<typeof userSecretsTable.$inferInsert> = {
userId,
token,
title: tokenUser.title || randomString(6),
expiredTime: UserSecret.getExpiredTime(expireDays).toISOString(),
};
if (orgId !== null && orgId !== undefined) {
insertData.orgId = orgId;
}
const inserted = await db.insert(userSecretsTable).values(insertData).returning();
return new UserSecret(inserted[0]);

View File

@@ -2,12 +2,12 @@ import { nanoid, customAlphabet } from 'nanoid';
import { CustomError } from '@kevisual/router';
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';
import { db } from '../../modules/db.ts';
import { Org } from './org.ts';
import { UserSecret } from './user-secret.ts';
import { cfUser, cfOrgs, cfUserSecrets } from '../../db/drizzle/schema.ts';
import { eq, sql, InferSelectModel, InferInsertModel } from 'drizzle-orm';
@@ -17,6 +17,7 @@ export type UserData = {
wxUnionId?: string;
phone?: string;
canChangeUsername?: boolean;
cnbId?: string;
};
export enum UserTypes {
@@ -33,8 +34,20 @@ 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
}
/**
* 用户模型,使用 Drizzle ORM
*/
@@ -55,8 +68,10 @@ export class User {
avatar: string;
tokenUser: any;
constructor(data: UserSelect) {
Object.assign(this, data);
constructor(data?: UserSelect) {
if (data) {
Object.assign(this, data);
}
}
setTokenUser(tokenUser: any) {
@@ -68,8 +83,38 @@ export class User {
* @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,
@@ -80,13 +125,13 @@ export class User {
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,
};
}
/**
@@ -95,20 +140,98 @@ export class User {
* @returns
*/
static async verifyToken(token: string) {
const { UserSecret } = await import('./user-secret.ts');
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) {
const { UserSecret } = await import('./user-secret.ts');
return await UserSecret.verifyToken(token);
}
/**
@@ -126,7 +249,6 @@ export class User {
* @returns
*/
static async getUserByToken(token: string) {
const { UserSecret } = await import('./user-secret.ts');
const oauthUser = await UserSecret.verifyToken(token);
if (!oauthUser) {
throw new CustomError('Token is invalid. get UserByToken');
@@ -176,6 +298,20 @@ export class User {
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;
}
/**
* 根据条件查找一个用户
*/
@@ -193,10 +329,9 @@ export class User {
const users = await query.limit(1);
return users.length > 0 ? new User(users[0]) : null;
}
static findByunionid(){
}
/**
* 创建新用户
*/
static async createUser(username: string, password?: string, description?: string) {
const user = await User.findOne({ username });
if (user) {
@@ -345,7 +480,7 @@ export class User {
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`);

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

@@ -330,8 +330,11 @@ export const microAppsUpload = pgTable("micro_apps_upload", {
export const microMark = pgTable("micro_mark", {
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(),
@@ -339,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(),
@@ -457,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()),
@@ -477,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

@@ -2,7 +2,7 @@ import { app } from './app.ts';
import './route.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

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

@@ -5,3 +5,8 @@ import { useKey } from "@kevisual/use-config";
* 用来放cookie的域名
*/
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>
`;
};

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

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

@@ -6,6 +6,23 @@ 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
@@ -18,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;
@@ -48,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;
@@ -78,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');
@@ -119,7 +154,7 @@ app
.route({
path: 'auth-check',
key: 'admin',
id: 'check-auth-admin',
rid: 'check-auth-admin',
middleware: ['auth'],
})
.define(async (ctx) => {

View File

@@ -3,10 +3,12 @@ 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/convex/",
"/api/chat/completions"
];

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

@@ -1,5 +1,5 @@
import { router } from '@/app.ts'
import { manager } from '@/modules/jwks/index.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');

View File

@@ -1,16 +1,23 @@
import { app, db, schema } from '@/app.ts';
import { App, AppData } from '../module/app-drizzle.ts';
import { AppDomain, AppDomainHelper } from '../module/app-domain-drizzle.ts';
import { eq, and } from 'drizzle-orm';
import { randomUUID } from 'crypto';
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 { 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) {
@@ -31,15 +38,24 @@ 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 newDomains = await db.insert(schema.kvAppDomain).values({ id: randomUUID(), domain, appId, uid }).returning();
const newDomains = await db.insert(schema.kvAppDomain).values({ domain, appId, uid }).returning();
const domainInfo = newDomains[0];
ctx.body = domainInfo;
return ctx;
@@ -51,11 +67,21 @@ 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');
}
@@ -80,7 +106,7 @@ app
if (domainInfo.uid !== uid) {
ctx.throw(403, 'domain must be owned by the user');
}
if (!AppDomainHelper.checkCanUpdateStatus(domainInfo.status!, status)) {
if (!AppDomainHelper.checkCanUpdateStatus(domainInfo.status!, status as any)) {
ctx.throw(400, 'domain status can not be updated');
}
const updateData: any = {};

View File

@@ -1,7 +1,6 @@
import { app, db, schema } from '@/app.ts';
import { AppDomain, AppDomainHelper } from '../module/app-domain-drizzle.ts';
import { eq } from 'drizzle-orm';
import { randomUUID } from 'crypto';
import z from 'zod';
app
@@ -78,7 +77,7 @@ app
try {
if (!domainInfo) {
await checkAppId();
const newDomains = await db.insert(schema.kvAppDomain).values({ id: randomUUID(), domain, data: {}, ...rest }).returning();
const newDomains = await db.insert(schema.kvAppDomain).values({ domain, data: {}, ...rest }).returning();
domainInfo = newDomains[0];
} else {
if (rest.status && domainInfo.status !== rest.status) {

View File

@@ -1,14 +1,14 @@
import { App as AppType, AppList, AppData } from './module/app-drizzle.ts';
import { app, db, schema } from '@/app.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 { randomUUID } from 'crypto';
import { z } from 'zod';
import { logger } from '@/modules/logger.ts';
app
.route({
path: 'app',
@@ -47,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');
@@ -70,7 +80,6 @@ app
}
if (!appListModel && create) {
const newApps = await db.insert(schema.kvAppList).values({
id: randomUUID(),
key,
version,
uid: tokenUser.id,
@@ -84,7 +93,6 @@ app
const appModel = appModels[0];
if (!appModel) {
await db.insert(schema.kvApp).values({
id: randomUUID(),
key,
uid: tokenUser.id,
user: tokenUser.username,
@@ -108,7 +116,7 @@ app
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);
@@ -145,7 +153,7 @@ app
if (!rest.key) {
ctx.throw('key is required');
}
const newApps = await db.insert(schema.kvAppList).values({ id: randomUUID(), data, ...rest, uid: tokenUser.id }).returning();
const newApps = await db.insert(schema.kvAppList).values({ data, ...rest, uid: tokenUser.id }).returning();
ctx.body = newApps[0];
return ctx;
})
@@ -233,7 +241,6 @@ app
if (!am) {
appIsNew = true;
const newAms = await db.insert(schema.kvApp).values({
id: randomUUID(),
user: userPrefix,
key: appKey,
uid,
@@ -255,7 +262,6 @@ app
let app = apps[0];
if (!app) {
const newApps = await db.insert(schema.kvAppList).values({
id: randomUUID(),
key: appKey,
version,
uid: uid,
@@ -294,6 +300,17 @@ 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;
@@ -351,8 +368,9 @@ app
if (!am) {
ctx.throw('app 未发现');
}
if (!isDetect) {
const amData = am.data as AppData;
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));
@@ -368,11 +386,88 @@ 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;
@@ -418,8 +513,17 @@ 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;
@@ -436,7 +540,6 @@ app
let appList = appLists[0];
if (!appList) {
const newAppLists = await db.insert(schema.kvAppList).values({
id: randomUUID(),
key: appKey,
version,
uid,
@@ -475,6 +578,7 @@ app
)).limit(1);
let am = ams[0];
if (!am) {
// 如果应用不存在则创建应用记录版本为0.0.1
const newAms = await db.insert(schema.kvApp).values({
title: appKey,
key: appKey,
@@ -486,6 +590,7 @@ app
}).returning();
am = newAms[0];
} else {
// 如果应用存在,并且版本相同,则更新应用记录的文件列表
const appModels = await db.select().from(schema.kvApp).where(and(
eq(schema.kvApp.key, appKey),
eq(schema.kvApp.version, version),

View File

@@ -1,5 +1,4 @@
import { app, db, schema } from '@/app.ts';
import { randomUUID } from 'crypto';
import { oss } from '@/app.ts';
import { User } from '@/models/user.ts';
import { customAlphabet } from 'nanoid';
@@ -10,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 || '';
};
@@ -64,7 +61,6 @@ app
},
];
const appModels = await db.insert(schema.kvApp).values({
id: randomUUID(),
title,
description,
version,
@@ -82,7 +78,6 @@ app
}).returning();
const appModel = appModels[0];
const appVersionModels = await db.insert(schema.kvAppList).values({
id: randomUUID(),
data: {
files: files,
},

View File

@@ -1,8 +1,9 @@
import { App, AppList, AppData, AppHelper } from './module/app-drizzle.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 } from 'drizzle-orm';
import { eq, and, desc, sql } from 'drizzle-orm';
import z from 'zod';
app
.route({
@@ -10,6 +11,9 @@ app
key: 'list',
middleware: ['auth'],
description: '获取用户应用列表',
metadata: {
args: {}
}
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
@@ -21,17 +25,16 @@ app
key: schema.kvApp.key,
uid: schema.kvApp.uid,
pid: schema.kvApp.pid,
proxy: schema.kvApp.proxy,
user: schema.kvApp.user,
status: schema.kvApp.status,
createdAt: schema.kvApp.createdAt,
updatedAt: schema.kvApp.updatedAt,
deletedAt: schema.kvApp.deletedAt,
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;
ctx.body = { list };
return ctx;
})
.addTo(app);
@@ -42,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;
@@ -79,6 +90,20 @@ 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;
@@ -138,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;
@@ -172,6 +203,11 @@ app
path: 'user-app',
key: 'test',
description: '对user-app的数据进行测试, 获取版本的信息',
metadata: {
args: {
id: z.string().describe('应用id'),
}
}
})
.define(async (ctx) => {
const id = ctx.query.id;

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

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

@@ -20,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;
@@ -161,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');
@@ -183,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);
@@ -193,7 +202,7 @@ app
} else {
updatedNodes = [...nodes, node];
}
const version = Number(markModel.version) + 1;
const updated = await db.update(schema.microMark)
.set({
@@ -201,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];
})
@@ -211,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) {
@@ -229,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) {
@@ -246,7 +265,7 @@ app
nodes.push(node);
}
}
const version = Number(markModel.version) + 1;
const updated = await db.update(schema.microMark)
.set({
@@ -265,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;
@@ -283,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

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

View File

@@ -0,0 +1,29 @@
import { eq } from 'drizzle-orm';
import { app, db, schema } from '@/app.ts';
app.route({
path: 'n5-make',
key: 'update',
middleware: ['auth'],
description: `更新 Make, 参数:
id: Make ID, 必填
slug: 唯一业务ID, 选填
resources: 资源列表(数组), 选填
`,
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id, userId, createdAt, updatedAt, ...rest } = 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');
}
const result = await db.update(schema.n5Make).set({ ...rest }).where(eq(schema.n5Make.id, id)).returning();
ctx.body = result[0];
return ctx;
}).addTo(app);

View File

@@ -0,0 +1,32 @@
import { eq } from 'drizzle-orm';
import { app, db, schema } from '@/app.ts';
app.route({
path: 'n5-shop',
key: 'create',
middleware: ['auth'],
description: `创建商品, 参数:
slug: 唯一业务ID, 必填
title: 商品标题, 必填
platform: 商品平台, 必填
orderLink: 订单链接, 必填
description: 商品描述, 选填
link: 商品链接, 选填
tags: 标签数组, 选填
data: 其他商品信息对象, 选填
userinfo: 卖家信息, 选填
`,
}).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 参数缺失');
if (!rest.title) ctx.throw(400, 'title 参数缺失');
if (!rest.platform) ctx.throw(400, 'platform 参数缺失');
if (!rest.orderLink) ctx.throw(400, 'orderLink 参数缺失');
const result = await db.insert(schema.n5Shop).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-shop',
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.n5Shop).where(eq(schema.n5Shop.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '商品不存在');
}
if (existing[0].userId !== tokenUser.id) {
ctx.throw(403, '没有权限删除该商品');
}
await db.delete(schema.n5Shop).where(eq(schema.n5Shop.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,58 @@
import { desc, eq, count, or, like, and } from 'drizzle-orm';
import { app, db, schema } from '@/app.ts';
// 列表
app.route({
path: 'n5-shop',
key: 'list',
middleware: ['auth'],
description: '获取商品列表, 参数: page, pageSize, search, sort',
}).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.n5Shop.updatedAt : desc(schema.n5Shop.updatedAt);
let whereCondition: any = eq(schema.n5Shop.userId, uid);
if (search) {
whereCondition = and(
eq(schema.n5Shop.userId, uid),
or(
like(schema.n5Shop.title, `%${search}%`),
like(schema.n5Shop.slug, `%${search}%`),
)
);
}
const [list, totalCount] = await Promise.all([
db.select().from(schema.n5Shop).where(whereCondition).limit(pageSize).offset(offset).orderBy(orderByField),
db.select({ count: count() }).from(schema.n5Shop).where(whereCondition),
]);
ctx.body = {
list,
pagination: { page, current: page, pageSize, total: totalCount[0]?.count || 0 },
};
return ctx;
}).addTo(app);
// 获取单个
app.route({
path: 'n5-shop',
key: 'get',
description: '获取单个商品, 参数: id 或 slug',
}).define(async (ctx) => {
const { id, slug } = ctx.query || {};
if (!id && !slug) {
ctx.throw(400, 'id 或 slug 参数缺失');
}
const condition = id ? eq(schema.n5Shop.id, id) : eq(schema.n5Shop.slug, slug);
const existing = await db.select().from(schema.n5Shop).where(condition).limit(1);
if (existing.length === 0) {
ctx.throw(404, '商品不存在');
}
ctx.body = existing[0];
return ctx;
}).addTo(app);

View File

@@ -0,0 +1,35 @@
import { eq } from 'drizzle-orm';
import { app, db, schema } from '@/app.ts';
app.route({
path: 'n5-shop',
key: 'update',
middleware: ['auth'],
description: `更新商品, 参数:
id: 商品ID, 必填
title: 商品标题, 选填
platform: 商品平台, 选填
orderLink: 订单链接, 选填
description: 商品描述, 选填
link: 商品链接, 选填
tags: 标签数组, 选填
data: 其他商品信息对象, 选填
userinfo: 卖家信息, 选填
`,
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id, userId, createdAt, updatedAt, ...rest } = ctx.query.data || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
const existing = await db.select().from(schema.n5Shop).where(eq(schema.n5Shop.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '商品不存在');
}
if (existing[0].userId !== tokenUser.id) {
ctx.throw(403, '没有权限更新该商品');
}
const result = await db.update(schema.n5Shop).set({ ...rest }).where(eq(schema.n5Shop.id, id)).returning();
ctx.body = result[0];
return ctx;
}).addTo(app);

View File

@@ -0,0 +1,157 @@
import { desc, eq, count, or, like, and } from 'drizzle-orm';
import { app, db, schema } from '@/app.ts';
import { nanoid } from 'nanoid';
import z from 'zod';
// 列表
app.route({
path: 'n5-short-link',
key: 'list',
middleware: ['auth'],
description: '获取短链列表, 参数: page, pageSize, search, sort',
}).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.shortLink.updatedAt : desc(schema.shortLink.updatedAt);
let whereCondition: any = eq(schema.shortLink.userId, uid);
if (search) {
whereCondition = and(
eq(schema.shortLink.userId, uid),
or(
like(schema.shortLink.title, `%${search}%`),
like(schema.shortLink.slug, `%${search}%`),
)
);
}
const [list, totalCount] = await Promise.all([
db.select().from(schema.shortLink).where(whereCondition).limit(pageSize).offset(offset).orderBy(orderByField),
db.select({ count: count() }).from(schema.shortLink).where(whereCondition),
]);
ctx.body = {
list,
pagination: { page, current: page, pageSize, total: totalCount[0]?.count || 0 },
};
return ctx;
}).addTo(app);
// 获取单个
app.route({
path: 'n5-short-link',
key: 'get',
description: '获取单个短链, 参数: id 或 slug',
middleware: ['auth'],
}).define(async (ctx) => {
const { id, slug } = ctx.query || {};
if (!id && !slug) {
ctx.throw(400, 'id 或 slug 参数缺失');
}
const condition = id ? eq(schema.shortLink.id, id) : eq(schema.shortLink.slug, slug);
const existing = await db.select().from(schema.shortLink).where(condition).limit(1);
if (existing.length === 0) {
ctx.throw(404, '短链不存在');
}
ctx.body = existing[0];
return ctx;
}).addTo(app);
// 创建
app.route({
path: 'n5-short-link',
key: 'create',
middleware: ['auth'],
description: `创建短链`,
metadata: {
args: {
data: z.object({
slug: z.string().optional().describe('对外唯一业务ID'),
title: z.string().optional().describe('标题'),
description: z.string().optional().describe('描述'),
tags: z.array(z.string()).optional().describe('标签数组'),
data: z.record(z.string(), z.any()).optional().describe('附加数据对象'),
type: z.enum(['link', 'agent']).optional().describe('类型'),
version: z.string().optional().describe('版本号'),
}).describe('创建短链参数对象'),
}
}
}).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 code = rest.code || nanoid(8);
const result = await db.insert(schema.shortLink).values({
...rest,
code,
userId: tokenUser.id,
}).returning();
ctx.body = result[0];
return ctx;
}).addTo(app);
// 更新
app.route({
path: 'n5-short-link',
key: 'update',
middleware: ['auth'],
description: `更新短链`,
metadata: {
args: {
data: z.object({
id: z.string().optional().describe('短链ID'),
title: z.string().optional().describe('标题'),
description: z.string().optional().describe('描述'),
tags: z.array(z.string()).optional().describe('标签数组'),
data: z.record(z.string(), z.any()).optional().describe('附加数据对象'),
type: z.enum(['link', 'agent']).optional().describe('类型'),
version: z.string().optional().describe('版本号'),
}).describe('更新短链参数对象'),
}
}
}).define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
const { id, userId, createdAt, updatedAt, ...rest } = ctx.query.data || {};
if (!id) {
ctx.throw(400, 'id 参数缺失');
}
const existing = await db.select().from(schema.shortLink).where(eq(schema.shortLink.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '短链不存在');
}
if (existing[0].userId !== tokenUser.id) {
ctx.throw(403, '没有权限更新该短链');
}
const result = await db.update(schema.shortLink).set({ ...rest }).where(eq(schema.shortLink.id, id)).returning();
ctx.body = result[0];
return ctx;
}).addTo(app);
// 删除
app.route({
path: 'n5-short-link',
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.shortLink).where(eq(schema.shortLink.id, id)).limit(1);
if (existing.length === 0) {
ctx.throw(404, '短链不存在');
}
if (existing[0].userId !== tokenUser.id) {
ctx.throw(403, '没有权限删除该短链');
}
await db.delete(schema.shortLink).where(eq(schema.shortLink.id, id));
ctx.body = { success: true };
return ctx;
}).addTo(app);

View File

@@ -88,6 +88,7 @@ app.route({
tags: rest.tags,
link: rest.link,
data: rest.data,
updatedAt: new Date()
}).where(eq(schema.queryViews.id, id)).returning();
}
ctx.body = view;

View File

@@ -9,18 +9,18 @@ import { eq } from 'drizzle-orm';
export const checkUsername = (username: string) => {
if (username.length > 30) {
throw new CustomError(400, '用户名不能过长');
throw new CustomError(400, { message: '用户名不能过长' });
}
if (!/^[a-zA-Z0-9_@]+$/.test(username)) {
throw new CustomError(400, '用户名包含非法字符');
throw new CustomError(400, { message: '用户名包含非法字符' });
}
if (username.includes(' ')) {
throw new CustomError(400, '用户名不能包含空格');
throw new CustomError(400, { message: '用户名不能包含空格' });
}
};
export const checkUsernameShort = (username: string) => {
if (username.length <= 3) {
throw new CustomError(400, '用户名不能过短');
throw new CustomError(400, { message: '用户名不能过短' });
}
};
@@ -31,13 +31,13 @@ export const toChangeName = async (opts: { id: string; newName: string; admin?:
}
const user = await User.findByPk(id);
if (!user) {
ctx.throw(404, 'User not found');
ctx.throw(404, { message: 'User not found' });
}
const oldName = user.username;
checkUsername(newName);
const findUserByUsername = await User.findOne({ username: newName });
if (findUserByUsername) {
ctx.throw(400, 'Username already exists');
ctx.throw(400, { message: 'Username already exists' });
}
user.username = newName;
const data = user.data || {};
@@ -65,7 +65,7 @@ export const toChangeName = async (opts: { id: string; newName: string; admin?:
}
} catch (error) {
console.error('迁移文件数据失败', error);
ctx.throw(500, 'Failed to change username');
ctx.throw(500, { message: 'Failed to change username' });
}
return user;
}
@@ -79,13 +79,13 @@ app
const { id, newName } = ctx.query.data || {};
try {
if (!id || !newName) {
ctx.throw(400, '参数错误');
ctx.throw(400, { message: '参数错误' });
}
const user = await toChangeName({ id, newName, admin: true, ctx });
ctx.body = await user?.getInfo?.();
} catch (error) {
console.error('changeName error', error);
ctx.throw(500, 'Failed to change username');
ctx.throw(500, { message: 'Failed to change username' });
}
})
.addTo(app);
@@ -99,7 +99,7 @@ app
.define(async (ctx) => {
const { username } = ctx.query.data || {};
if (!username) {
ctx.throw(400, 'Username is required');
ctx.throw(400, { message: 'Username is required' });
}
checkUsername(username);
const user = await User.findOne({ username });
@@ -121,7 +121,7 @@ app
const { id, password } = ctx.query.data || {};
const user = await User.findByPk(id);
if (!user) {
ctx.throw(404, 'User not found');
ctx.throw(404, { message: 'User not found' });
}
let pwd = password || nanoid(6);
user.createPassword(pwd);
@@ -149,7 +149,7 @@ app
checkUsername(username);
const findUserByUsername = await User.findOne({ username });
if (findUserByUsername) {
ctx.throw(400, 'Username already exists');
ctx.throw(400, { message: 'Username already exists' });
}
let pwd = password || nanoid(6);
const user = await User.createUser(username, pwd, description);
@@ -172,7 +172,7 @@ app
const { id } = ctx.query.data || {};
const user = await User.findByPk(id);
if (!user) {
ctx.throw(404, 'User not found');
ctx.throw(404, { message: 'User not found' });
}
await db.delete(schema.cfUser).where(eq(schema.cfUser.id, user.id));
backupUserA(user.username, user.id);

View File

@@ -0,0 +1,30 @@
import { app, redis } from "@/app.ts";
import z from "zod";
import { CnbServices } from "./modules/cnb-services.ts";
import { createCookie } from "./me.ts";
app
.route({
path: 'user',
key: 'cnb-login',
description: 'cnb登陆, 根据 CNB_TOKEN 获取用户信息',
metadata: {
args: {
data: z.object({
cnbToken: z.string().describe('cnb token'),
}),
}
}
})
.define(async (ctx) => {
const { cnbToken } = ctx.query.data || {};
if (!cnbToken) {
ctx.throw(400, 'CNB Token is required');
}
const cnb = new CnbServices(cnbToken);
const token = await cnb.login();
if (!token) {
ctx.throw(500, '登陆失败');
}
createCookie(token, ctx);
ctx.body = token;
}).addTo(app);

View File

@@ -16,3 +16,7 @@ import './admin/user.ts';
import './secret-key/list.ts';
import './wx-login.ts'
import './cnb-login.ts';
import './jwks.ts';

36
src/routes/user/jwks.ts Normal file
View File

@@ -0,0 +1,36 @@
import { app } from '@/app.ts'
import { UserModel } from '@/auth/index.ts';
import z from 'zod';
app.route({
path: 'user',
key: 'token-create',
description: '根据用户token创建一个新的token主要用于临时访问',
middleware: ['auth'],
metadata: {
args: {
loginType: z.enum(['jwks']).optional().describe('登录类型默认为jwks'),
hasRefreshToken: z.boolean().optional().describe('是否需要refresh token默认为false'),
}
}
}).define(async (ctx) => {
const user = await UserModel.getUserByToken(ctx.query.token);
const loginType = ctx.query?.loginType ?? 'jwks';
const hasRefreshToken = ctx.query?.hasRefreshToken ?? false;
if (!user) {
ctx.throw(404, 'user not found');
}
if (loginType !== 'jwks') {
ctx.throw(400, 'unsupported login type');
}
let expire = ctx.query.expire ?? 24 * 3600;
// 大于24小时的过期时间需要管理员权限
if (expire > 24 * 3600) {
expire = 2 * 3600;
}
const value = await user.createToken(null, loginType, {
expire: expire, // 24小时过期
hasRefreshToken: hasRefreshToken,
})
ctx.body = value
}).addTo(app)

View File

@@ -35,15 +35,15 @@ app
const tokenUser = ctx.state.tokenUser;
const { id, username, password, description } = ctx.query.data || {};
if (!id) {
throw new CustomError(400, 'id is required');
throw new CustomError(400, { message: 'id is required' });
}
const user = await User.findByPk(id);
if (user.id !== tokenUser.id) {
throw new CustomError(403, 'Permission denied');
throw new CustomError(403, { message: 'Permission denied' });
}
if (!user) {
throw new CustomError(500, 'user not found');
throw new CustomError(500, { message: 'user not found' });
}
if (username) {
user.username = username;
@@ -73,12 +73,12 @@ app
.define(async (ctx) => {
const { username, password, description } = ctx.query.data || {};
if (!username) {
throw new CustomError(400, 'username is required');
throw new CustomError(400, { message: 'username is required' });
}
checkUsername(username);
const findUserByUsername = await User.findOne({ username });
if (findUserByUsername) {
throw new CustomError(400, 'username already exists');
throw new CustomError(400, { message: 'username already exists' });
}
const pwd = password || nanoid(6);
const user = await User.createUser(username, pwd, description);

View File

@@ -1,19 +1,29 @@
import { app } from '@/app.ts';
import { Org } from '@/models/org.ts';
import { User } from '@/models/user.ts';
import { proxyDomain as domain } from '@/modules/domain.ts';
import { logger } from '@/modules/logger.ts';
import z from 'zod';
/**
* 当配置了domain后创建cookie当get请求地址的时候会自动带上cookie
* @param token
* @param ctx
* @returns
*/
export const createCookie = (token: any, ctx: any) => {
export const createCookie = (token: { accessToken?: string; token?: string, type?: string; }, ctx: any) => {
if (!domain) {
return;
}
if (!ctx?.req) {
logger.debug('登陆用户没有请求对象不需要创建cookie');
return
}
// if (!token.type || token.type === 'jwks') {
// // 如果是jwks类型的token不创建cookie
// // 因为jwks类型的token自己就能检测是否过期了不需要依赖cookie了
// return;
// }
//TODO, 获取访问的 hostname 如果访问的和 domain 的不一致也创建cookie
const browser = ctx.req.headers['user-agent'];
const browser = ctx?.req?.headers['user-agent'];
const isBrowser = browser.includes('Mozilla'); // 浏览器
if (isBrowser && ctx.res.cookie) {
ctx.res.cookie('token', token.accessToken || token?.token, {
@@ -134,7 +144,7 @@ app
}
if (tokenUser.id === user.id) {
// 自己刷新自己的token
const token = await User.oauth.resetToken(oldToken, {
const token = await User.resetToken(oldToken, {
...tokenUser.oauthExpand,
});
createCookie(token, ctx);
@@ -151,9 +161,7 @@ app
browser: someInfo['user-agent'],
host: someInfo.host,
});
createCookie({
token: token.accessToken
}, ctx);
createCookie(token, ctx);
ctx.body = token;
})
.addTo(app);
@@ -248,6 +256,7 @@ app
.route({
path: 'user',
key: 'switchCheck',
description: '切换用户或切换为用户组获取切换后的token',
middleware: ['auth'],
})
.define(async (ctx) => {
@@ -255,27 +264,13 @@ app
const { username, accessToken } = ctx.query.data || {};
if (accessToken && username) {
const accessUser = await User.verifyToken(accessToken);
const refreshToken = accessUser.oauthExpand?.refreshToken;
if (refreshToken) {
const result = await User.oauth.refreshToken(refreshToken);
createCookie({
token: result.accessToken
}, ctx);
const result = await User.refreshToken({ accessToken });
if (result.accessToken) {
console.log('refreshToken result', result);
createCookie(result, ctx);
ctx.body = result;
return;
} else if (accessUser) {
await User.oauth.delToken(accessToken);
const result = await User.oauth.generateToken(accessUser, {
...accessUser.oauthExpand,
hasRefreshToken: true,
});
createCookie({
token: result.accessToken
}, ctx);
ctx.body = result;
return;
} else {
ctx.throw(500, 'Refresh Token Failed, please login again');
}
} else {
const result = await ctx.call(
@@ -327,18 +322,13 @@ app
const orgsList = [tokenUser.username, user.username, , ...orgs];
if (orgsList.includes(username)) {
if (tokenUsername === username) {
const result = await User.oauth.resetToken(token);
createCookie({
token: result.accessToken,
}, ctx);
await User.oauth.delToken(token);
const result = await User.resetToken(token);
createCookie(result, ctx);
ctx.body = result;
} else {
const user = await User.findOne({ username });
const result = await user.createToken(userId, 'default');
createCookie({
token: result.accessToken,
}, ctx);
createCookie(result, ctx);
ctx.body = result;
}
} else {
@@ -351,19 +341,26 @@ app
.route({
path: 'user',
key: 'refreshToken',
description: '根据refreshToken刷新token',
metadata: {
args: {
data: z.object({
refreshToken: z.string().describe('刷新token'),
accessToken: z.string().optional().describe('使用访问token去刷新token如果提供了访问token优先使用访问token去刷新token刷新失败才会使用refreshToken去刷新'),
}),
}
}
})
.define(async (ctx) => {
const { refreshToken } = ctx.query.data || {};
const { refreshToken, accessToken } = ctx.query.data || {};
try {
if (!refreshToken) {
ctx.throw(400, 'Refresh Token is required');
if (!refreshToken && !accessToken) {
ctx.throw(400, 'Refresh Token or Access Token 必须提供一个');
}
const result = await User.oauth.refreshToken(refreshToken);
if (result) {
const result = await User.refreshToken({ accessToken, refreshToken });
if (result.accessToken) {
console.log('refreshToken result', result);
createCookie({
token: result.accessToken,
}, ctx);
createCookie(result, ctx);
ctx.body = result;
} else {
ctx.throw(500, 'Refresh Token Failed, please login again');

View File

@@ -0,0 +1,37 @@
import { CNB } from '@kevisual/cnb/src/index.ts'
import { UserModel } from '../../../auth/index.ts';
import { CustomError } from '@kevisual/router';
export class CnbServices {
cnb: CNB;
constructor(token?: string) {
this.cnb = new CNB({
token,
});
}
async login(): Promise<ReturnType<typeof UserModel.prototype.createToken>> {
const cnbUserRes = await this.cnb.user.getUser();
if (cnbUserRes.code !== 200) {
throw new CustomError('CNB Token is invalid');
}
const cnbUser = cnbUserRes?.data;
const cnbUserId = cnbUser?.id
if (!cnbUserId) {
throw new CustomError('CNB User ID is missing');
}
let user = await UserModel.findByCnbId(cnbUserId);
if (!user) {
const username = '@cnb-' + cnbUser.username;
// 如果用户不存在,创建一个新用户
user = await UserModel.createUser(username, cnbUserId);
user.data = {
...user.data,
cnbId: cnbUserId,
}
await user.save();
}
const token = await user.createToken();
return token;
}
}

View File

@@ -54,7 +54,7 @@ export class WxServices {
const token = await fetchToken(code, type);
console.log('login token', token);
if (!token.unionid) {
throw new CustomError(400, 'code is invalid, wxdata can not be found');
throw new CustomError(400, { message: 'code is invalid, wxdata can not be found' });
}
this.wxToken = token;
const unionid = token.unionid;
@@ -180,7 +180,7 @@ export class WxServices {
async getUserInfo() {
try {
if (!this.wxToken) {
throw new CustomError(400, 'wxToken is not set');
throw new CustomError(400, { message: 'wxToken is not set' });
}
const openid = this.wxToken.openid;
const access_token = this.wxToken.access_token;

View File

@@ -45,7 +45,7 @@ export const fetchToken = async (code: string, type: 'open' | 'mp' = 'open'): Pr
appSecret = wx.appSecret;
}
if (!appId || !appSecret) {
throw new CustomError(500, 'appId or appSecret is not set');
throw new CustomError(500, { message: 'appId or appSecret is not set' });
}
console.log('fetchToken===', appId, appSecret, code);
const wxUrl = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${code}&grant_type=authorization_code`;

View File

@@ -6,12 +6,21 @@ import jsonwebtoken from 'jsonwebtoken';
import { redis } from '@/app.ts';
import { createCookie, clearCookie } from './me.ts';
import z from 'zod';
app
.route({
path: 'user',
key: 'webLogin',
description: 'web登录接口配合插件使用',
middleware: [authCan],
metadata: {
args: {
loginToken: z.string().describe('web登录令牌服务端生成客户端保持一致'),
sign: z.string().describe('签名,服务端生成,客户端保持一致'),
randomId: z.string().describe('随机字符串,服务端和客户端保持一致'),
}
}
})
.define(async (ctx) => {
const tokenUser = ctx.state.tokenUser;
@@ -39,7 +48,7 @@ app
<script>
const redirect = new URL('${reqUrl}', window.location.origin);
const encodeRedirect = encodeURIComponent(redirect.toString());
const toPage = new URL('/root/home/?user-check=true&redirect='+encodeRedirect, window.location.origin);
const toPage = new URL('/root/center/login/?user-check=true&redirect='+encodeRedirect, window.location.origin);
setTimeout(() => {
window.location.href = toPage.toString();
}, 1000);
@@ -87,7 +96,7 @@ app
await setErrorLoginTokenRedis(loginToken);
ctx.throw(400, 'user not found');
}
const data = await user.createToken(null, 'plugin', { loginWith: 'cli' });
const data = await user.createToken(null, 'jwks', { loginWith: 'cli' });
await redis.set(loginToken, JSON.stringify(data), 'EX', 10 * 60); // 10分钟
ctx.body = 'success';
})
@@ -97,6 +106,7 @@ app
.route({
path: 'user',
key: 'checkLoginStatus',
description: '循环检查登陆状态',
})
.define(async (ctx) => {
const { loginToken } = ctx.query;
@@ -109,9 +119,7 @@ app
const token = JSON.parse(data);
if (token.accessToken) {
ctx.body = token;
createCookie({
token: token.accessToken,
}, ctx);
createCookie(token, ctx);
} else {
ctx.throw(500, 'Checked error Failed, login failed, please login again');
}

View File

@@ -91,6 +91,7 @@ app.route({
link: rest.link,
data: rest.data,
views: rest.views,
updatedAt: new Date()
}).where(eq(schema.routerViews.id, id)).returning();
}
ctx.body = view;

22
src/test/cnb-login.ts Normal file
View File

@@ -0,0 +1,22 @@
import { app, showMore, cnbToken } from './common.ts';
const res = await app.run({
path: 'user',
key: 'cnb-login',
payload: {
data: {
cnbToken
}
}
})
console.log(showMore(res));
const token = res.data.token;
const me = await app.run({
path: 'user',
key: 'me',
payload: {
token
}
})
console.log(showMore(me));

View File

@@ -4,7 +4,7 @@ import { useConfig, useContextKey } from '@kevisual/context';
import { Query } from '@kevisual/query';
import util from 'node:util';
import dotenv from 'dotenv';
import { QueryLoginNode } from '@kevisual/api/login-node'
dotenv.config();
export {
app,
@@ -12,7 +12,7 @@ export {
}
export const config = useConfig();
export const token = config.KEVISUAL_TOKEN || '';
export const cnbToken = config.CNB_TOKEN || '';
export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const showRes = (res, ...args) => {
@@ -36,4 +36,10 @@ export const exit = (code = 0) => {
export const query = new Query({
url: 'https://kevisual.cn/api/router'
// url: 'https://kevisual.xiongxiao.me/api/router'
})
export const queryLogin = new QueryLoginNode({ query });
export const token = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtpZC1rZXktMSJ9.eyJzdWIiOiJ1c2VyOjBlNzAwZGM4LTkwZGQtNDFiNy05MWRkLTMzNmVhNTFkZTNkMiIsIm5hbWUiOiJyb290IiwiZXhwIjoxNzczMjM1NzM0LCJpc3MiOiJodHRwczovL2NvbnZleC5rZXZpc3VhbC5jbiIsImlhdCI6MTc3MzE0OTMzNCwiYXVkIjoiY29udmV4LWFwcCJ9.Zj3bepCCKnVGgXoOnmmdkM-2u0qiT2V-bLhI-0C1a-YX9-ZlcQP2W_1rYN_D2kaaL5BPduvKhoY1hJzM5UwxRYLc-tYr2oBU4fwEyHc3bn-M8p0spX2-Tbie7CN_WbBszZ9KGePNKCveWmx5rCc14YhfUiIvczviU7WP728yFsaHJ29sVu3FJqd3ezMSkdwwPtlwCBtOhuE3nyqPdWP6nRZHkSSbAZDu5jUb_-3TqGjI2cHVZwChfcIVNwdjTeQrj2KMMQ2NdXBim01PZcolr3wqNwpSsm4bN4IVyB5RmwCw7gzHyYSOSZ1bnE8kc53M0KANDSLBFynKUXzNQJ-Wmg'
// console.log('test config', token);

36
src/test/flowme.ts Normal file
View File

@@ -0,0 +1,36 @@
import { queryLogin, app, token, showMore } from './common.ts'
// const rest = await app.run({
// path: 'flowme-life',
// key: 'today',
// // @ts-ignore
// token: token,
// })
// console.log('flowme-life today', rest)
const updateId = '8c63cb7a-ff6d-463b-b210-6311ee12ed46'
// const updateRest = await app.run({
// path: 'flowme-life',
// key: 'done',
// // @ts-ignore
// token: token,
// payload: {
// id: updateId,
// }
// })
// console.log('flowme-life done', updateRest)
const chatRes = await app.run({
path: 'flowme-life',
key: 'chat',
// @ts-ignore
token: token,
payload: {
// question: '帮我查询一下今天的待办事项'
// question: '帮我查询一下今天的待办事项, 然后帮我把键盘充电的待办标记为完成',
}
})
console.log('flowme-life chat', showMore(chatRes))

View File

@@ -1,4 +1,4 @@
import { manager } from '@/modules/jwks/index.ts'
import { manager } from '@/auth/models/jwks-manager.ts'
await manager.init()

Some files were not shown because too many files have changed in this diff Show More