Compare commits

..

166 Commits

Author SHA1 Message Date
baiyanyun
91f68576dd 补齐测试配置与上传资源 2026-06-29 20:02:05 +08:00
baiyanyun
f84dc1d44e 吸收数字员工工作台入口重构收口 2026-06-29 19:53:34 +08:00
baiyanyun
d0b7223b53 吸收数字员工高风险缺口安全壳总收口 2026-06-15 23:33:11 +08:00
baiyanyun
3a41eb40d0 吸收数字员工日报物理事实发送记录预览 2026-06-15 17:20:42 +08:00
baiyanyun
fd06f3bce3 吸收数字员工服务技能记忆审计收口 2026-06-15 16:21:46 +08:00
baiyanyun
de33710423 吸收数字员工资产通知日报业务收口 2026-06-15 14:59:29 +08:00
baiyanyun
87b440e93f 吸收数字员工策略治理审批路由收口 2026-06-15 14:40:41 +08:00
baiyanyun
fd3b149172 吸收数字员工管理端治理策略版本预览 2026-06-15 14:01:10 +08:00
baiyanyun
ff7576f33d 吸收数字员工管理端服务恢复安全壳预览 2026-06-15 11:05:02 +08:00
baiyanyun
053a321ec1 吸收数字员工管理端技能安装权限预览 2026-06-15 10:51:48 +08:00
baiyanyun
6ad88cb6c4 吸收数字员工管理端记忆策略预览 2026-06-15 10:40:32 +08:00
baiyanyun
e57971488c 吸收数字员工管理端远程策略编辑安全壳预览 2026-06-15 10:24:41 +08:00
baiyanyun
642521035b 吸收数字员工管理端日报审批裁决回写预览 2026-06-15 09:54:56 +08:00
baiyanyun
b64f8e35c3 吸收数字员工管理端通知订阅策略预览 2026-06-15 09:45:52 +08:00
baiyanyun
a62b749ef2 吸收数字员工管理端诊断修复预览 2026-06-15 09:14:32 +08:00
baiyanyun
cc55fd819f 吸收数字员工跨设备调度派发策略冲突预览 2026-06-14 20:59:41 +08:00
baiyanyun
39371861f3 吸收数字员工跨设备审批策略冲突预览 2026-06-14 20:20:25 +08:00
baiyanyun
c01c699410 吸收数字员工管理端审批策略预览 2026-06-14 19:54:45 +08:00
baiyanyun
89eec8afc1 吸收数字员工管理端审批裁决策略预览 2026-06-14 18:49:08 +08:00
baiyanyun
8b651e4ae1 吸收数字员工管理端审计归档账单预览 2026-06-14 13:21:51 +08:00
baiyanyun
dd6c10f44b 吸收数字员工管理端产物跨设备聚合预览 2026-06-14 11:53:16 +08:00
baiyanyun
797ffeee82 吸收数字员工管理端记忆合并候选预览 2026-06-13 16:36:54 +08:00
baiyanyun
4fdc95da75 吸收数字员工管理端技能版本权限预览 2026-06-13 16:28:17 +08:00
baiyanyun
b9f49d2c0e 吸收数字员工管理端服务恢复运营预览 2026-06-13 16:18:01 +08:00
baiyanyun
7ea963dc9a 吸收数字员工跨设备路由干预冲突预览 2026-06-13 15:58:10 +08:00
baiyanyun
5ab6bc0fd0 吸收数字员工管理端步骤调度预览 2026-06-13 15:38:30 +08:00
baiyanyun
c9d8aa3b93 吸收数字员工管理端协作运营收口 2026-06-13 15:08:14 +08:00
baiyanyun
95838dba04 吸收数字员工管理端治理执行安全壳 2026-06-13 14:56:54 +08:00
baiyanyun
f08b0c39d9 吸收数字员工管理端策略调度预览 2026-06-13 14:42:16 +08:00
baiyanyun
de501c0252 吸收数字员工管理端记忆技能审计预览 2026-06-13 14:21:16 +08:00
baiyanyun
b6b0a9dcb3 吸收数字员工管理端治理命令预览 2026-06-13 14:07:40 +08:00
baiyanyun
d5025c0ebc 吸收数字员工管理端日报运营预览 2026-06-13 14:00:53 +08:00
baiyanyun
d86f82a4c5 吸收数字员工管理端审计诊断预览 2026-06-13 13:54:18 +08:00
baiyanyun
b0f0b95c2a 吸收数字员工管理端产物生命周期预览 2026-06-13 13:42:50 +08:00
baiyanyun
1daf58a94c 吸收数字员工管理端通知运营预览 2026-06-13 00:03:42 +08:00
baiyanyun
479e2e209d 吸收数字员工管理端路由治理预览 2026-06-12 23:47:20 +08:00
baiyanyun
ca746366f6 吸收数字员工管理端服务运营预览 2026-06-12 23:30:31 +08:00
baiyanyun
8ed4e0ce3b 吸收数字员工管理端审批治理预览 2026-06-12 23:03:58 +08:00
baiyanyun
bd7d7b4913 吸收数字员工发送账单审计外壳 2026-06-12 22:48:27 +08:00
baiyanyun
26fbf51fc8 吸收数字员工业务事实物化查询 2026-06-12 22:24:38 +08:00
baiyanyun
8a5d5d6474 吸收数字员工跨设备策略冲突视图 2026-06-12 21:48:33 +08:00
baiyanyun
b3f21adecb 吸收数字员工管理端低风险动作闭环 2026-06-12 21:17:51 +08:00
baiyanyun
be901ee112 吸收数字员工管理端运营台前端 2026-06-12 20:18:30 +08:00
baiyanyun
48ac9bdf19 吸收数字员工管理端运营台后端 2026-06-12 20:07:55 +08:00
baiyanyun
1a8e0fb51f 吸收数字员工运营预览台 2026-06-12 19:57:38 +08:00
baiyanyun
871bd715c4 合并账号菜单与数字员工吸收 2026-06-12 16:33:56 +08:00
baiyanyun
6323987a0d 收口账号菜单侧栏导航 2026-06-12 16:33:18 +08:00
baiyanyun
81d2797035 吸收数字员工审批冲突与产物业务闭环 2026-06-12 16:29:01 +08:00
baiyanyun
fffa0ebe50 吸收数字员工管理端业务查询模型 2026-06-12 11:21:43 +08:00
baiyanyun
9011b91dba 吸收数字员工诊断成本审计 2026-06-12 10:52:35 +08:00
baiyanyun
3a159e2f5a 吸收数字员工治理审计补齐 2026-06-12 10:33:46 +08:00
baiyanyun
e473b5c147 吸收数字员工通知权限引导 2026-06-12 10:16:02 +08:00
baiyanyun
e57c94c19d 吸收数字员工审批跨端冲突可视化 2026-06-11 21:53:08 +08:00
baiyanyun
84d34236e8 吸收数字员工风险策略本地管理 2026-06-11 21:14:29 +08:00
baiyanyun
245a210b25 吸收数字员工系统桌面通知 2026-06-11 20:53:46 +08:00
baiyanyun
8278fdc81c 吸收数字员工通知跨端同步 2026-06-11 20:27:10 +08:00
baiyanyun
4c862ef4b7 吸收数字员工入口路由干预回写 2026-06-11 18:31:41 +08:00
baiyanyun
d292c34579 吸收数字员工入口路由策略下发 2026-06-11 17:44:16 +08:00
baiyanyun
43418d0453 吸收数字员工多步审批跨端回写 2026-06-11 10:44:12 +08:00
baiyanyun
e9c76c59ca 吸收数字员工风险策略远端下发 2026-06-11 09:51:14 +08:00
baiyanyun
8ab3d73fb1 吸收数字员工技能远端策略下发 2026-06-11 09:04:08 +08:00
baiyanyun
751ce45619 吸收数字员工PlanStep管理端强租约 2026-06-10 23:59:54 +08:00
baiyanyun
c400367d22 吸收数字员工PlanStep远端策略拉取 2026-06-10 23:15:51 +08:00
baiyanyun
6f2ccdf783 吸收数字员工PlanStep本地软租约 2026-06-10 22:29:52 +08:00
baiyanyun
909f11dae5 吸收数字员工PlanStep跨任务编排执行 2026-06-10 21:00:59 +08:00
baiyanyun
a6e0246418 吸收数字员工PlanStep阻塞处理入口 2026-06-10 20:37:11 +08:00
baiyanyun
d679639175 吸收数字员工PlanStep依赖图派发策略 2026-06-10 18:41:33 +08:00
baiyanyun
3ac350eb27 吸收数字员工审批历史订阅策略 2026-06-10 18:22:53 +08:00
baiyanyun
5b78189c9c 吸收数字员工通知回复任务输入 2026-06-10 17:49:14 +08:00
baiyanyun
1271e4967b 吸收数字员工管理端通知视图 2026-06-10 17:05:57 +08:00
baiyanyun
5b2f14123e 吸收数字员工通知订阅策略 2026-06-10 15:56:34 +08:00
baiyanyun
13e48df63d 吸收数字员工管理端事件视图 2026-06-10 15:16:23 +08:00
baiyanyun
4f489f402a 吸收数字员工诊断审计观测 2026-06-10 14:49:49 +08:00
baiyanyun
914ed81f19 吸收数字员工入口路由观测 2026-06-10 11:43:48 +08:00
baiyanyun
d12db03216 吸收数字员工步骤自动调度 2026-06-10 11:23:22 +08:00
baiyanyun
047651e404 吸收数字员工审批历史联动 2026-06-10 10:46:28 +08:00
baiyanyun
ed2f19d257 吸收数字员工产物生命周期治理 2026-06-10 10:23:47 +08:00
baiyanyun
362f397e37 吸收数字员工产物访问治理 2026-06-10 10:00:34 +08:00
baiyanyun
cc36d8dcaf 吸收数字员工技能调用审计 2026-06-10 09:24:02 +08:00
baiyanyun
91540dec0c 吸收数字员工安全服务重启入口 2026-06-09 23:10:01 +08:00
baiyanyun
0d2008b35c 吸收数字员工日报产物闭环 2026-06-09 22:16:03 +08:00
baiyanyun
ba806f779e 吸收数字员工业务查询视图 2026-06-09 21:25:03 +08:00
baiyanyun
d1ae9421c4 吸收数字员工诊断审计投影 2026-06-09 19:24:28 +08:00
baiyanyun
755a8f041d 吸收数字员工入口路由动作映射 2026-06-09 19:05:15 +08:00
baiyanyun
23c3577ef4 吸收数字员工通知投影 2026-06-09 18:33:01 +08:00
baiyanyun
dc8cd09535 吸收数字员工审批时间线 2026-06-09 17:33:00 +08:00
baiyanyun
5160a73254 吸收数字员工产物交付状态 2026-06-09 16:47:14 +08:00
baiyanyun
4d8b8e4ad5 吸收数字员工技能策略开关 2026-06-09 16:28:18 +08:00
baiyanyun
9ad7881378 吸收数字员工治理动作入口 2026-06-09 15:54:49 +08:00
baiyanyun
702ea05d4d 吸收数字员工入口路由决策 2026-06-09 15:32:38 +08:00
baiyanyun
fd130f1f60 吸收数字员工步骤依赖调度 2026-06-09 14:18:07 +08:00
baiyanyun
ddd39396f7 吸收数字员工长期记忆闭环 2026-06-09 13:49:11 +08:00
baiyanyun
ef8252e2dd 吸收数字员工步骤治理闭环 2026-06-09 11:31:29 +08:00
baiyanyun
5ebc0a640b 吸收数字员工周期调度闭环 2026-06-09 11:06:25 +08:00
baiyanyun
f35d945d9c 吸收数字员工计划步骤依赖图 2026-06-09 10:21:12 +08:00
baiyanyun
e4715bbabb 梳理数字员工剩余吸收能力 2026-06-09 09:48:12 +08:00
baiyanyun
726369f5a9 沉淀数字员工角色偏好状态 2026-06-09 09:34:39 +08:00
baiyanyun
2f9af0b173 修复数字员工同步业务失败识别 2026-06-09 09:24:53 +08:00
baiyanyun
eb8ea9974f docs(client): refresh digital fallback status 2026-06-09 08:50:46 +08:00
baiyanyun
16eb8c691e chore(client): remove digital demo scenario residue 2026-06-09 08:49:45 +08:00
baiyanyun
48e5830b9e feat(client): remove demo skill fallback 2026-06-09 08:48:11 +08:00
baiyanyun
451cda7aa6 feat(client): materialize restarted plans 2026-06-08 18:58:39 +08:00
baiyanyun
913c6da8f3 feat(client): project local GUI and file tools 2026-06-08 18:44:45 +08:00
baiyanyun
38e5bdbc04 feat(client): project acp skills to digital catalog 2026-06-08 18:31:03 +08:00
baiyanyun
e4652ace1c feat(client): remove demo mission fallback 2026-06-08 18:19:36 +08:00
baiyanyun
820b1b6f13 feat(client): control task agents from digital deck 2026-06-08 18:06:11 +08:00
baiyanyun
76a82f6203 chore(client): add digital favicon 2026-06-08 17:48:38 +08:00
baiyanyun
3bab6af63c feat(client): define digital sync contract 2026-06-08 17:10:38 +08:00
baiyanyun
ff25c2fef7 feat(client): project plan and task agents 2026-06-08 11:57:47 +08:00
baiyanyun
710bf72353 feat(client): project managed services to digital employee 2026-06-08 11:40:03 +08:00
baiyanyun
8dc9ce73b9 chore(client): add digital employee check 2026-06-08 10:36:19 +08:00
baiyanyun
700f6a40b8 feat(client): show digital artifact sources 2026-06-08 10:31:36 +08:00
baiyanyun
f4af77cb2b feat(client): reply digital approvals to acp 2026-06-08 10:23:06 +08:00
baiyanyun
ee51fcdbeb feat(client): enrich digital skill catalog 2026-06-07 20:04:38 +08:00
baiyanyun
0f0bffe695 feat(client): load local digital plan templates 2026-06-07 19:53:24 +08:00
baiyanyun
e37024bdbe feat(client): persist digital governance events 2026-06-07 18:59:59 +08:00
baiyanyun
5e76828afe fix(client): ensure digital employee schema 2026-06-07 18:39:19 +08:00
baiyanyun
6deea42de1 docs(client): note digital employee database location 2026-06-07 18:24:32 +08:00
baiyanyun
09d65701a3 fix(client): migrate digital storage keys 2026-06-07 18:21:24 +08:00
baiyanyun
fa5522e32e fix(client): localize digital fallback missions 2026-06-07 18:09:39 +08:00
baiyanyun
757858a9aa fix(client): limit workday decisions to approvals 2026-06-07 17:56:37 +08:00
baiyanyun
aee8af0877 fix(client): sync digital approval decisions 2026-06-07 17:52:20 +08:00
baiyanyun
b07f133ed6 feat(client): project digital employee runtime into UI 2026-06-07 17:46:07 +08:00
baiyanyun
8ff1c57b83 fix(backend): validate digital sync client key header 2026-06-07 11:33:59 +08:00
baiyanyun
bf25bc5a47 feat: filter digital sync links by device 2026-06-07 11:24:01 +08:00
baiyanyun
029ca6e7f6 feat: mask digital sync client keys 2026-06-07 11:14:16 +08:00
baiyanyun
7eb1498412 feat: summarize digital sync records 2026-06-07 02:25:37 +08:00
baiyanyun
2bf9192fc5 feat(client): export digital sync diagnostics 2026-06-07 02:22:37 +08:00
baiyanyun
df2ab51e70 fix(client): omit client key from sync record links 2026-06-07 02:20:30 +08:00
baiyanyun
f8564b631d test(client): cover digital employee sync retries 2026-06-07 02:16:04 +08:00
baiyanyun
bdcb5c6c14 feat(client): show digital sync credential gaps 2026-06-07 02:05:12 +08:00
baiyanyun
6b033da313 feat: auto open digital sync records 2026-06-07 02:02:21 +08:00
baiyanyun
38ece7e3d3 feat(client): summarize digital sync failures 2026-06-07 01:51:15 +08:00
baiyanyun
cef3fe4a43 feat(client): track digital sync retry history 2026-06-07 01:42:39 +08:00
baiyanyun
646901b5fa feat: deep link digital sync failures 2026-06-07 01:35:08 +08:00
baiyanyun
508406ff10 feat(client): add digital sync retry details 2026-06-07 01:17:07 +08:00
baiyanyun
0672e82fc7 feat(client): show digital sync failure details 2026-06-07 01:05:49 +08:00
baiyanyun
ac1a6fd0e6 feat(client): show digital employee sync errors 2026-06-07 00:53:28 +08:00
baiyanyun
e2a91d88e8 feat: add digital employee sync records page 2026-06-07 00:37:15 +08:00
baiyanyun
dc14418963 feat(backend): expose digital employee sync records 2026-06-05 18:40:44 +08:00
baiyanyun
17475bf0d7 feat(client): add manual digital employee sync 2026-06-05 18:24:04 +08:00
baiyanyun
b7aecad092 feat(client): back off digital employee sync retries 2026-06-05 18:19:32 +08:00
baiyanyun
35d2cb5ea5 feat(client): show digital employee sync status 2026-06-05 17:25:46 +08:00
baiyanyun
ab1d022905 feat: add digital employee management sync endpoint 2026-06-05 17:21:48 +08:00
baiyanyun
b9132c421d feat(client): sync digital employee outbox 2026-06-05 17:11:10 +08:00
baiyanyun
f8048724c4 feat(client): mirror agent events to digital employee 2026-06-05 17:00:44 +08:00
baiyanyun
512c7ab095 feat(client): record computer tasks for digital employee 2026-06-05 16:52:19 +08:00
baiyanyun
2373e0b5f3 feat(client): add digital employee sync schema 2026-06-05 16:43:23 +08:00
baiyanyun
40151c3902 feat(client): persist digital employee snapshots 2026-06-05 16:27:15 +08:00
baiyanyun
15a3285ed5 feat(client): bridge digital employee status 2026-06-05 15:59:48 +08:00
baiyanyun
31b4b5a281 feat(client): add qimingclaw digital adapter 2026-06-05 15:45:55 +08:00
baiyanyun
2eb9a99da3 feat(client): vendor digital employee source 2026-06-05 15:02:42 +08:00
baiyanyun
c6fdff20e8 fix(client): show embedded digital employee directly 2026-06-04 23:08:41 +08:00
baiyanyun
2c3f2c8c54 feat(client): add offline digital employee overview 2026-06-04 19:42:37 +08:00
baiyanyun
44fe160880 fix: use child menu title for work tabs 2026-06-04 16:59:50 +08:00
baiyanyun
f9e090125c feat: improve theme configuration 2026-06-04 16:48:47 +08:00
baiyanyun
a01fc93594 add online monitor and fix admin scopes 2026-06-02 20:50:42 +08:00
baiyanyun
f69fb46c59 fix user group permission scope 2026-06-02 19:51:45 +08:00
baiyanyun
873cd6ef53 产品化改造:完善本地集成与启动体验 2026-06-02 17:55:08 +08:00
baiyanyun
a280774f50 Ignore IDE and log artifacts 2026-06-01 19:31:45 +08:00
Codex
142b8b78fa 添加开发环境启动手册 2026-06-01 19:18:04 +08:00
Codex
6ff9bfc6b5 添加开发环境配置文件 2026-06-01 13:59:01 +08:00
Codex
4b1a580132 添加qiming-rcoder模块 2026-06-01 13:54:52 +08:00
Codex
8092c4b1f8 "添加前端模板和运行代码模块" 2026-06-01 13:43:09 +08:00
Codex
24045274ad 提交qiming-noVNC 2026-06-01 13:15:17 +08:00
Codex
afb3d9f4e6 提交qiming-mcp-proxy 2026-06-01 13:03:20 +08:00
2056 changed files with 523672 additions and 1000 deletions

10
.gitignore vendored
View File

@@ -8,6 +8,12 @@
!/qiming-mobile/
!/qiming-claude-code-acp-ts/
!/qiming-dev-inject/
!/qiming-mcp-proxy/
!/qiming-noVNC/
!/qiming-rcoder/
!/qiming-run_code_rmcp/
!/qiming-vite-plugin-design-mode/
!/qiming-xagi-frontend-templates/
!/qimingclaw/
!/qimingcode/
@@ -19,6 +25,8 @@ node_modules/
# Build outputs and generated artifacts
dist/
build/
!qiming-file-server/src/utils/build/
!qiming-file-server/src/utils/build/**
out/
target/
.umi/
@@ -31,6 +39,8 @@ coverage/
*.log
.DS_Store
Thumbs.db
**/.idea/
**/logs/
# Local environment files
.env

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,9 @@
---
name:
description:
license:
---
# Processing Guide
## Overview

View File

@@ -0,0 +1,4 @@
{
"description": "Skill template",
"config": {}
}

View File

@@ -0,0 +1,5 @@
#!/usr/bin/env python3
# Helper script for this skill
if __name__ == "__main__":
print("Process script placeholder")

View File

@@ -0,0 +1,3 @@
# API Documentation
Add interface descriptions related to skills here.

View File

@@ -104,6 +104,10 @@
<groupId>com.xspaceagi</groupId>
<artifactId>app-platform-bill-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
</dependencies>
<profiles>
<profile>

View File

@@ -216,13 +216,6 @@ public class AuthInterceptor implements HandlerInterceptor {
requestContext.setLangMap(userDto.getLangMap());
requestContext.setLang(userDto.getLang());
log.debug("JWT token OK, userId: {} ", userDto.getId());
if (originalRequestUri.startsWith("/api/system/")) {
//判断是不是管理员
if (userDto.getRole() != User.Role.Admin) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
}
}
String header = request.getHeader("X-Client-Type");
if (header != null || token.startsWith("ticket")) {
authService.renewToken(token);
@@ -358,4 +351,4 @@ public class AuthInterceptor implements HandlerInterceptor {
throw BizException.of(HttpStatusEnum.UNAUTHORIZED, ErrorCodeEnum.UNAUTHORIZED,
BizExceptionCodeEnum.systemUnauthorizedOrSessionExpired);
}
}
}

View File

@@ -0,0 +1,233 @@
spring:
mvc:
pathmatch:
matching-strategy: ANT_PATH_MATCHER
servlet:
multipart:
max-file-size: 100MB
max-request-size: 100MB
# 使用 dynamic-datasource-spring-boot-starter 配置
datasource:
# 动态SQL生成器配置
sql-generator:
# 指定使用的数据库类型mysql 或 doris
type: mysql
dynamic:
# 设置默认的数据源或者数据源组, 默认值即为 master
primary: master
# 设置严格模式,默认 false. 严格模式下未匹配到数据源直接报错, 非严格模式下则使用默认数据源 primary 配置的数据源
strict: false
# p6spy 开关, 默认 false
p6spy: false
# seata 开关, 默认 false
seata: false
# 数据源配置信息
datasource:
# 主数据源 (MySQL)
master:
url: jdbc:mysql://${DB_HOST:localhost}:3306/${DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DB_USERNAME:root}
password: ${DB_PASSWORD:Byyagz@121}
# 以下为 Druid 连接池配置 (如果使用)
druid:
initial-size: 5
min-idle: 5
max-active: 20
max-wait: 60000
time-between-eviction-runs-millis: 60000
min-evictable-idle-time-millis: 300000
validation-query: SELECT 1 FROM DUAL
test-while-idle: true
test-on-borrow: false
test-on-return: false
pool-prepared-statements: true
max-pool-prepared-statement-per-connection-size: 20
filters: stat # 启用 stat filter 用于监控
# Doris 数据源
doris:
url: jdbc:mysql://${DORIS_HOST:localhost}:3306/${DORIS_DB_NAME:agent_platform}?serverTimezone=Asia/Shanghai&characterEncoding=utf-8&allowMultiQueries=true&socketTimeout=300000&useSSL=false
driver-class-name: com.mysql.cj.jdbc.Driver
username: ${DORIS_USERNAME:root}
password: ${DORIS_PASSWORD:Byyagz@121}
# Doris 表配置
table:
replication-num: 1 # 副本数生产环境通常为3开发环境可以为1
bucket-num: 10 # 分桶数
duplicate-key: id # 重复键字段名,默认使用第一个字段
distributed-key: id # 分布键字段名,默认使用第一个字段
# 可选: 如果 Doris 也用 Druid可以添加 druid 配置
druid:
initial-size: 2
min-idle: 2
max-active: 10
max-wait: 60000
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:Byyagz@121}
database: ${REDIS_DB:1}
lettuce:
pool:
max-idle: 16
max-active: 32
min-idle: 8
main:
allow-circular-references: true
log:
level: DEBUG
server:
port: 18081
jwt:
secretKey: ${JWT_SECRET_KEY:qiming-local-dev-jwt-secret-key-32-chars}
file:
uploadFolder: ${FILE_UPLOAD_FOLDER:D:/work/workspace/feitian-base/nvw/.dev/uploads}
baseUrl: ${FILE_BASE_URL:http://localhost:18081/files/}
# Storage configuration
storage:
type: ${STORAGE_TYPE:local} # Storage type: local, cos(Tencent Cloud), oss(Alibaba Cloud), s3(S3 protocol/MinIO)
local:
upload:
folder: ${LOCAL_UPLOAD_FOLDER:D:/work/workspace/feitian-base/nvw/.dev/uploads}
# Tencent Cloud COS configuration (takes effect when storage.type=cos)
cos:
secret-id: ${COS_SECRET_ID:}
secret-key: ${COS_SECRET_KEY:}
region: ${COS_REGION:ap-chengdu}
bucket-name: ${COS_BUCKET_NAME:}
endpoint: ${COS_ENDPOINT:}
# Legacy configuration below
secretId: ${COS_SECRET_ID:}
secretKey: ${COS_SECRET_KEY:}
baseUrl: ${COS_BASE_URL:}
# Alibaba Cloud OSS configuration (takes effect when storage.type=oss)
oss:
endpoint: ${OSS_ENDPOINT:}
access-key-id: ${OSS_ACCESS_KEY_ID:}
access-key-secret: ${OSS_ACCESS_KEY_SECRET:}
region-name: ${OSS_REGION_NAME:cn-chengdu}
bucket-name: ${OSS_BUCKET_NAME:}
# S3 protocol configuration (takes effect when storage.type=s3, supports MinIO etc.)
s3:
endpoint: ${S3_ENDPOINT:}
access-key: ${S3_ACCESS_KEY:}
secret-key: ${S3_SECRET_KEY:}
bucket-name: ${S3_BUCKET_NAME:}
region: ${S3_REGION:us-east-1}
vector-store:
engine: milvus
milvus:
uri: ${MILVUS_URI:http://43.153.147.203:19530/}
user: ${MILVUS_USER:}
password: ${MILVUS_PASSWORD:}
code:
execute:
url: ${CODE_EXECUTE_URL:http://localhost:18020/api/run_code_with_log}
# 日志模块配置
log-module:
log:
rust-service:
# Rust 日志服务的基础 URL
base-url: ${LOG_SERVICE_URL:http://localhost:8097}
# 连接超时时间(秒)
connect-timeout-seconds: 10
# 写入超时时间(秒)
write-timeout-seconds: 30
# 读取超时时间(秒)
read-timeout-seconds: 30
# 是否启用连接池
enable-connection-pool: true
# 最大空闲连接数
max-idle-connections: 5
# 连接保持存活时间(分钟)
keep-alive-duration-minutes: 5
search:
elasticsearch:
url: ${ES_URL:http://43.153.147.203:19200}
username: ${ES_USERNAME:}
password: ${ES_PASSWORD:}
api_key: ${ES_API_KEY:}
# 生态市场模块配置
eco-market:
# 服务端配置
server:
# 远程server端的 ip端口配置
base-url: ${ECO_MARKET_SERVER_URL:https://test-agent-market-api.xspaceagi.com}
client:
# 客户端配置
retry:
#自动注册客户端到 server端, 重试间隔时间,单位:秒;
interval: 300
mcp:
proxy-base-url: ${MCP_PROXY_URL:http://localhost:18020}
knife4j:
# 开启增强配置
enable: true
# 标识是否生产环境true-生产环境关闭文档false-显示文档
production: false
custom-page:
http-proxy:
host: ${CUSTOM_PAGE_PROXY_HOST:0.0.0.0}
port: ${CUSTOM_PAGE_PROXY_PORT:18082}
dev-server-host: ${DEV_SERVER_HOST:http://localhost}
prod-server-host: ${PROD_SERVER_HOST:http://localhost:18099}
build-server:
base-url: ${BUILD_SERVER_URL:http://localhost:16000/api}
ai-agent:
base-url: ${AI_AGENT_URL:http://43.153.147.203:18087}
docker-proxy:
enable: ${DOCKER_PROXY_ENABLE:true}
base-url: ${DOCKER_PROXY_URL:http://43.153.147.203:18088}
computer:
proxy:
host: ${COMPUTER_PROXY_HOST:0.0.0.0}
port: ${COMPUTER_PROXY_PORT:18085}
supportCustomDomain: true
# 内外穿透相关服务配置
reverse:
server:
# 内部服务器配置
inner:
# 内部服务可访问地址,如果分布式部署,可以在每台主机上通过环境变量制定 SERVICE_HOST
service-host: ${SERVICE_HOST:127.0.0.1}
# 绑定主机地址,如果分布式部署,可以在每台主机上通过环境变量制定 BIND_HOST
bind-host: ${BIND_HOST:0.0.0.0}
# 内部服务器端口范围
ports: ${REVERSE_PORTS:30000-40000}
# 外部服务器配置
outer:
# 外部用于与客户端保持连接的服务器主机地址,多个用英文逗号隔开,例如 192.168.1.12,192.168.1.13,留空时使用平台服务器地址
host: ${OUTER_HOST:}
# 外部服务器端口
port: ${OUTER_PORT:11076}
model-api-proxy:
base-api-url: ${MODEL_API_BASE_URL:http://localhost:18086}
enable-model-proxy: ${MODEL_PROXY_ENABLE:true}
port: ${MODEL_PROXY_PORT:18086}
#记录日志
save-log: ${MODEL_PROXY_SAVE_LOG:true}

View File

@@ -48,7 +48,6 @@
<logger name="org.redisson" level="ERROR"/>
<logger name="com.alibaba" level="ERROR"/>
<logger name="com.xspaceagi.system.domain.service.impl.ScheduleTaskDomainServiceImpl" level="ERROR"/>
<!-- 缺省应用日志输出 -->
<root level="${LOG_LEVEL}">
<if condition='"${DEV_MODE}".equals("dev")'>
@@ -68,4 +67,4 @@
</then>
</if>
</root>
</configuration>
</configuration>

View File

@@ -0,0 +1,105 @@
package com.xspaceagi.agent.core.adapter.application;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenchPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordResultDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBillingEstimateDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
import java.util.Date;
import java.util.List;
public interface DigitalEmployeeSyncApplicationService {
DigitalEmployeeSyncResponseDto syncOutbox(DigitalEmployeeSyncRequestDto request);
DigitalEmployeeSyncRecordPageDto queryRecords(
String clientKey,
String deviceId,
String entityType,
String entityId,
String outboxId,
Long pageNo,
Long pageSize
);
DigitalEmployeeBusinessViewPageDto queryBusinessViews(
String entityType,
String clientKey,
String deviceId,
String entityId,
String status,
String syncStatus,
Date updatedFrom,
Date updatedTo,
Long pageNo,
Long pageSize
);
DigitalEmployeePlanBusinessViewDetailDto queryPlanBusinessViewDetail(
String planId,
String clientKey,
String deviceId
);
DigitalEmployeeAdminWorkbenchPageDto queryAdminWorkbench(
String view,
String clientKey,
String deviceId,
String entityType,
String entityId,
String status,
String kind,
String category,
String level,
String priority,
Date updatedFrom,
Date updatedTo,
Long pageNo,
Long pageSize
);
DigitalEmployeePolicySnapshotPageDto queryAdminPolicySnapshots(
String clientKey,
String deviceId,
String status,
String policyKind,
Date updatedFrom,
Date updatedTo,
Long pageNo,
Long pageSize
);
DigitalEmployeeAdminActionResultDto createAdminAction(DigitalEmployeeAdminActionRequestDto request);
DigitalEmployeeAdminActionPageDto queryPendingAdminActions(
String clientKey,
String deviceId,
Long pageNo,
Long pageSize
);
DigitalEmployeeAdminActionResultDto markAdminActionResult(
Long actionId,
String status,
List<String> reasonCodes
);
DigitalEmployeeAdminRecordResultDto recordDailyReportSendResult(DigitalEmployeeAdminRecordRequestDto request);
DigitalEmployeeBillingEstimateDto queryBillingEstimates(
String clientKey,
String deviceId,
Date updatedFrom,
Date updatedTo
);
DigitalEmployeeAdminRecordResultDto recordAuditArchive(DigitalEmployeeAdminRecordRequestDto request);
}

View File

@@ -0,0 +1,33 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class DigitalEmployeeAdminActionDto {
private Long id;
@JsonProperty("client_key_masked")
private String clientKeyMasked;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("entity_type")
private String entityType;
@JsonProperty("entity_id")
private String entityId;
private String action;
private String status;
@JsonProperty("reason_codes")
private List<String> reasonCodes;
@JsonProperty("request_payload")
private Map<String, Object> requestPayload;
@JsonProperty("created_at")
private Date createdAt;
@JsonProperty("processed_at")
private Date processedAt;
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeeAdminActionPageDto {
private Long total;
@JsonProperty("page_no")
private Long pageNo;
@JsonProperty("page_size")
private Long pageSize;
private List<DigitalEmployeeAdminActionDto> records;
}

View File

@@ -0,0 +1,23 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Map;
@Data
public class DigitalEmployeeAdminActionRequestDto {
@JsonProperty("sync_record_id")
private Long syncRecordId;
@JsonProperty("client_key")
private String clientKey;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("entity_type")
private String entityType;
@JsonProperty("entity_id")
private String entityId;
private String action;
@JsonProperty("request_payload")
private Map<String, Object> requestPayload;
}

View File

@@ -0,0 +1,17 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeeAdminActionResultDto {
private Boolean ok;
private String status;
@JsonProperty("reason_codes")
private List<String> reasonCodes;
private DigitalEmployeeAdminActionDto action;
}

View File

@@ -0,0 +1,33 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonAlias;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.util.Map;
@Data
public class DigitalEmployeeAdminRecordRequestDto {
@JsonAlias("sync_record_id")
private Long syncRecordId;
@JsonAlias("client_key")
private String clientKey;
@JsonAlias("device_id")
private String deviceId;
@JsonAlias("entity_type")
private String entityType;
@JsonAlias("entity_id")
private String entityId;
private String status;
private String endpoint;
@JsonAlias("remote_id")
private String remoteId;
@JsonAlias("archive_location")
private String archiveLocation;
@JsonAlias("archive_summary")
private Map<String, Object> archiveSummary;
private String reason;
@JsonProperty("request_payload")
@JsonAlias("requestPayload")
private Map<String, Object> requestPayload;
}

View File

@@ -0,0 +1,17 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeeAdminRecordResultDto {
private Boolean ok;
private String status;
@JsonProperty("reason_codes")
private List<String> reasonCodes;
private DigitalEmployeeAdminActionDto record;
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeeAdminWorkbenchPageDto {
private Long total;
@JsonProperty("page_no")
private Long pageNo;
@JsonProperty("page_size")
private Long pageSize;
private List<DigitalEmployeeAdminWorkbenchRowDto> records;
}

View File

@@ -0,0 +1,364 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class DigitalEmployeeAdminWorkbenchRowDto {
private String view;
@JsonProperty("sync_record_id")
private Long syncRecordId;
@JsonProperty("sync_record_url")
private String syncRecordUrl;
@JsonProperty("entity_type")
private String entityType;
@JsonProperty("entity_id")
private String entityId;
private String operation;
private String title;
private String status;
private String kind;
private String category;
private String level;
private String priority;
private String summary;
@JsonProperty("plan_id")
private String planId;
@JsonProperty("task_id")
private String taskId;
@JsonProperty("run_id")
private String runId;
@JsonProperty("step_id")
private String stepId;
@JsonProperty("dependency_status")
private String dependencyStatus;
@JsonProperty("lease_state")
private String leaseState;
@JsonProperty("lease_source")
private String leaseSource;
@JsonProperty("dispatch_status")
private String dispatchStatus;
@JsonProperty("dispatch_policy_key")
private String dispatchPolicyKey;
@JsonProperty("orchestration_id")
private String orchestrationId;
@JsonProperty("trigger_source")
private String triggerSource;
@JsonProperty("approval_id")
private String approvalId;
@JsonProperty("approval_policy_id")
private String approvalPolicyId;
@JsonProperty("conflict_policy_id")
private String conflictPolicyId;
@JsonProperty("decision_guard_status")
private String decisionGuardStatus;
@JsonProperty("batch_accept_remote_disabled")
private Boolean batchAcceptRemoteDisabled;
@JsonProperty("auto_decision_disabled")
private Boolean autoDecisionDisabled;
@JsonProperty("policy_reason")
private String policyReason;
@JsonProperty("governance_policy_id")
private String governancePolicyId;
@JsonProperty("policy_guard_status")
private String policyGuardStatus;
@JsonProperty("hard_interrupt_disabled")
private Boolean hardInterruptDisabled;
@JsonProperty("artifact_id")
private String artifactId;
@JsonProperty("artifact_graph_id")
private String artifactGraphId;
@JsonProperty("artifact_group_id")
private String artifactGroupId;
@JsonProperty("group_key")
private String groupKey;
@JsonProperty("related_artifact_ids")
private List<String> relatedArtifactIds;
@JsonProperty("artifact_count")
private Long artifactCount;
@JsonProperty("device_count")
private Long deviceCount;
@JsonProperty("version_count")
private Long versionCount;
@JsonProperty("latest_artifact_id")
private String latestArtifactId;
@JsonProperty("version_key")
private String versionKey;
@JsonProperty("cleanup_request_id")
private String cleanupRequestId;
@JsonProperty("cleanup_eligible_count")
private Long cleanupEligibleCount;
@JsonProperty("batch_cleanup_disabled")
private Boolean batchCleanupDisabled;
@JsonProperty("retention_conflict_status")
private String retentionConflictStatus;
@JsonProperty("cleanup_conflict_status")
private String cleanupConflictStatus;
@JsonProperty("remote_delete_disabled")
private Boolean remoteDeleteDisabled;
@JsonProperty("notification_id")
private String notificationId;
@JsonProperty("collaboration_thread_id")
private String collaborationThreadId;
@JsonProperty("subscription_policy_id")
private String subscriptionPolicyId;
@JsonProperty("subscription_conflict_status")
private String subscriptionConflictStatus;
@JsonProperty("notification_policy_id")
private String notificationPolicyId;
@JsonProperty("subscription_status")
private String subscriptionStatus;
@JsonProperty("desktop_enabled")
private Boolean desktopEnabled;
@JsonProperty("minimum_level")
private String minimumLevel;
private String channel;
@JsonProperty("delivery_status")
private String deliveryStatus;
@JsonProperty("delivery_attempt_id")
private String deliveryAttemptId;
@JsonProperty("send_request_id")
private String sendRequestId;
@JsonProperty("real_push_disabled")
private Boolean realPushDisabled;
@JsonProperty("conflict_status")
private String conflictStatus;
@JsonProperty("report_id")
private String reportId;
@JsonProperty("daily_report_fact_id")
private String dailyReportFactId;
@JsonProperty("daily_report_table")
private String dailyReportTable;
@JsonProperty("daily_report_materialized")
private Boolean dailyReportMaterialized;
@JsonProperty("daily_report_physical_fact_status")
private String dailyReportPhysicalFactStatus;
@JsonProperty("send_record_id")
private String sendRecordId;
@JsonProperty("send_channel")
private String sendChannel;
@JsonProperty("send_request_status")
private String sendRequestStatus;
@JsonProperty("send_record_status")
private String sendRecordStatus;
@JsonProperty("formal_daily_report_page_disabled")
private Boolean formalDailyReportPageDisabled;
@JsonProperty("external_send_disabled")
private Boolean externalSendDisabled;
@JsonProperty("third_party_channel_disabled")
private Boolean thirdPartyChannelDisabled;
@JsonProperty("approval_decision_status")
private String approvalDecisionStatus;
@JsonProperty("advanced_query_supported")
private Boolean advancedQuerySupported;
@JsonProperty("aggregation_scope")
private String aggregationScope;
@JsonProperty("materialized_source")
private String materializedSource;
@JsonProperty("daily_report_approval_id")
private String dailyReportApprovalId;
@JsonProperty("daily_report_approval_action")
private String dailyReportApprovalAction;
@JsonProperty("daily_report_approval_actor")
private String dailyReportApprovalActor;
@JsonProperty("daily_report_approval_reason")
private String dailyReportApprovalReason;
@JsonProperty("daily_report_approval_recorded_at")
private String dailyReportApprovalRecordedAt;
@JsonProperty("route_decision_id")
private String routeDecisionId;
@JsonProperty("service_id")
private String serviceId;
@JsonProperty("service_lease_id")
private String serviceLeaseId;
@JsonProperty("lease_holder")
private String leaseHolder;
@JsonProperty("lease_expires_at")
private String leaseExpiresAt;
@JsonProperty("restart_record_id")
private String restartRecordId;
@JsonProperty("restart_count")
private Long restartCount;
@JsonProperty("last_restart_at")
private String lastRestartAt;
@JsonProperty("recovery_policy_id")
private String recoveryPolicyId;
@JsonProperty("auto_recovery_status")
private String autoRecoveryStatus;
@JsonProperty("service_start_disabled")
private Boolean serviceStartDisabled;
@JsonProperty("service_stop_disabled")
private Boolean serviceStopDisabled;
@JsonProperty("lease_takeover_disabled")
private Boolean leaseTakeoverDisabled;
@JsonProperty("auto_recovery_disabled")
private Boolean autoRecoveryDisabled;
@JsonProperty("recovery_action_request_id")
private String recoveryActionRequestId;
@JsonProperty("start_request_id")
private String startRequestId;
@JsonProperty("stop_request_id")
private String stopRequestId;
@JsonProperty("lease_takeover_request_id")
private String leaseTakeoverRequestId;
@JsonProperty("auto_recovery_request_id")
private String autoRecoveryRequestId;
@JsonProperty("start_service_disabled")
private Boolean startServiceDisabled;
@JsonProperty("stop_service_disabled")
private Boolean stopServiceDisabled;
@JsonProperty("memory_id")
private String memoryId;
@JsonProperty("memory_key")
private String memoryKey;
@JsonProperty("governance_status")
private String governanceStatus;
@JsonProperty("merge_candidate_count")
private Long mergeCandidateCount;
@JsonProperty("duplicate_memory_ids")
private List<String> duplicateMemoryIds;
@JsonProperty("latest_memory_id")
private String latestMemoryId;
@JsonProperty("last_governance_action")
private String lastGovernanceAction;
@JsonProperty("last_reviewed_at")
private String lastReviewedAt;
@JsonProperty("memory_policy_id")
private String memoryPolicyId;
@JsonProperty("memory_policy_status")
private String memoryPolicyStatus;
@JsonProperty("merge_policy_id")
private String mergePolicyId;
@JsonProperty("merge_action_disabled")
private Boolean mergeActionDisabled;
@JsonProperty("remote_memory_policy_disabled")
private Boolean remoteMemoryPolicyDisabled;
@JsonProperty("skill_id")
private String skillId;
@JsonProperty("skill_version")
private String skillVersion;
@JsonProperty("install_status")
private String installStatus;
@JsonProperty("permission_scope")
private String permissionScope;
@JsonProperty("policy_source")
private String policySource;
@JsonProperty("policy_status")
private String policyStatus;
@JsonProperty("skill_install_request_id")
private String skillInstallRequestId;
@JsonProperty("skill_upgrade_request_id")
private String skillUpgradeRequestId;
@JsonProperty("permission_policy_id")
private String permissionPolicyId;
@JsonProperty("permission_edit_request_id")
private String permissionEditRequestId;
@JsonProperty("install_action_disabled")
private Boolean installActionDisabled;
@JsonProperty("upgrade_action_disabled")
private Boolean upgradeActionDisabled;
@JsonProperty("permission_edit_disabled")
private Boolean permissionEditDisabled;
@JsonProperty("tool_name")
private String toolName;
@JsonProperty("server_id")
private String serverId;
@JsonProperty("audit_archive_id")
private String auditArchiveId;
@JsonProperty("archive_status")
private String archiveStatus;
@JsonProperty("archive_location")
private String archiveLocation;
@JsonProperty("billing_estimate_id")
private String billingEstimateId;
@JsonProperty("billing_source")
private String billingSource;
private Boolean estimated;
@JsonProperty("diagnostic_id")
private String diagnosticId;
@JsonProperty("diagnostic_kind")
private String diagnosticKind;
@JsonProperty("issue_code")
private String issueCode;
@JsonProperty("repair_action")
private String repairAction;
@JsonProperty("repair_status")
private String repairStatus;
@JsonProperty("repair_reason")
private String repairReason;
@JsonProperty("safe_repair_disabled")
private Boolean safeRepairDisabled;
@JsonProperty("billing_actual_disabled")
private Boolean billingActualDisabled;
@JsonProperty("sandbox_archive_file_disabled")
private Boolean sandboxArchiveFileDisabled;
@JsonProperty("auto_repair_execution_disabled")
private Boolean autoRepairExecutionDisabled;
@JsonProperty("repair_approval_required")
private Boolean repairApprovalRequired;
@JsonProperty("recommended_action")
private String recommendedAction;
@JsonProperty("policy_kind")
private String policyKind;
@JsonProperty("policy_key")
private String policyKey;
@JsonProperty("policy_edit_request_id")
private String policyEditRequestId;
@JsonProperty("policy_edit_status")
private String policyEditStatus;
@JsonProperty("policy_edit_reason")
private String policyEditReason;
@JsonProperty("merge_request_id")
private String mergeRequestId;
@JsonProperty("memory_merge_request_id")
private String memoryMergeRequestId;
@JsonProperty("remote_memory_policy_request_id")
private String remoteMemoryPolicyRequestId;
@JsonProperty("merge_recommendation")
private String mergeRecommendation;
@JsonProperty("remote_policy_edit_disabled")
private Boolean remotePolicyEditDisabled;
@JsonProperty("auto_merge_disabled")
private Boolean autoMergeDisabled;
@JsonProperty("approval_policy_edit_disabled")
private Boolean approvalPolicyEditDisabled;
@JsonProperty("route_policy_edit_disabled")
private Boolean routePolicyEditDisabled;
@JsonProperty("route_recovery_disabled")
private Boolean routeRecoveryDisabled;
@JsonProperty("high_risk_execution_disabled")
private Boolean highRiskExecutionDisabled;
@JsonProperty("schedule_id")
private String scheduleId;
@JsonProperty("job_id")
private String jobId;
@JsonProperty("revision_id")
private String revisionId;
@JsonProperty("revision_fact_id")
private String revisionFactId;
@JsonProperty("revision_status")
private String revisionStatus;
@JsonProperty("revision_materialized")
private Boolean revisionMaterialized;
@JsonProperty("base_revision_id")
private String baseRevisionId;
@JsonProperty("changed_fields")
private List<String> changedFields;
@JsonProperty("occurred_at")
private String occurredAt;
@JsonProperty("synced_at")
private Date syncedAt;
@JsonProperty("client_key_masked")
private String clientKeyMasked;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("business_view")
private Map<String, Object> businessView;
private Map<String, Object> payload;
}

View File

@@ -0,0 +1,47 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class DigitalEmployeeBillingEstimateDto {
@JsonProperty("total_tokens")
private Long totalTokens;
@JsonProperty("input_tokens")
private Long inputTokens;
@JsonProperty("output_tokens")
private Long outputTokens;
@JsonProperty("estimated_cost_usd")
private BigDecimal estimatedCostUsd;
@JsonProperty("request_count")
private Long requestCount;
private Boolean estimated;
@JsonProperty("by_model")
private Map<String, ModelEstimate> byModel;
@JsonProperty("updated_at")
private Date updatedAt;
@JsonProperty("source_records")
private List<Long> sourceRecords;
@Data
@Builder
public static class ModelEstimate {
@JsonProperty("total_tokens")
private Long totalTokens;
@JsonProperty("input_tokens")
private Long inputTokens;
@JsonProperty("output_tokens")
private Long outputTokens;
@JsonProperty("estimated_cost_usd")
private BigDecimal estimatedCostUsd;
@JsonProperty("request_count")
private Long requestCount;
}
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeeBusinessViewPageDto {
private Long total;
@JsonProperty("page_no")
private Long pageNo;
@JsonProperty("page_size")
private Long pageSize;
private List<DigitalEmployeeBusinessViewRowDto> records;
}

View File

@@ -0,0 +1,68 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
import java.util.Map;
@Data
@Builder
public class DigitalEmployeeBusinessViewRowDto {
@JsonProperty("sync_record_id")
private Long syncRecordId;
@JsonProperty("sync_record_url")
private String syncRecordUrl;
@JsonProperty("entity_type")
private String entityType;
@JsonProperty("entity_id")
private String entityId;
private String operation;
private String title;
private String status;
private String summary;
@JsonProperty("plan_id")
private String planId;
@JsonProperty("task_id")
private String taskId;
@JsonProperty("run_id")
private String runId;
private String kind;
@JsonProperty("occurred_at")
private String occurredAt;
@JsonProperty("started_at")
private String startedAt;
@JsonProperty("finished_at")
private String finishedAt;
@JsonProperty("duration_ms")
private Long durationMs;
@JsonProperty("sync_status")
private String syncStatus;
@JsonProperty("client_key_masked")
private String clientKeyMasked;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("synced_at")
private Date syncedAt;
private Date modified;
private Date created;
@JsonProperty("business_view")
private Map<String, Object> businessView;
private Map<String, Object> payload;
@JsonProperty("task_count")
private Integer taskCount;
@JsonProperty("run_count")
private Integer runCount;
@JsonProperty("latest_event_at")
private String latestEventAt;
@JsonProperty("last_event_message")
private String lastEventMessage;
@JsonProperty("advanced_query_supported")
private Boolean advancedQuerySupported;
@JsonProperty("aggregation_scope")
private String aggregationScope;
@JsonProperty("materialized_source")
private String materializedSource;
private Boolean materialized;
}

View File

@@ -0,0 +1,17 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeePlanBusinessViewDetailDto {
private DigitalEmployeeBusinessViewRowDto plan;
private List<DigitalEmployeeBusinessViewRowDto> tasks;
private List<DigitalEmployeeBusinessViewRowDto> runs;
private List<DigitalEmployeeBusinessViewRowDto> events;
private List<DigitalEmployeeBusinessViewRowDto> artifacts;
private List<DigitalEmployeeBusinessViewRowDto> approvals;
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeePolicySnapshotPageDto {
private Long total;
@JsonProperty("page_no")
private Long pageNo;
@JsonProperty("page_size")
private Long pageSize;
private List<DigitalEmployeePolicySnapshotRowDto> records;
}

View File

@@ -0,0 +1,72 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
import java.util.List;
import java.util.Map;
@Data
@Builder
public class DigitalEmployeePolicySnapshotRowDto {
@JsonProperty("policy_kind")
private String policyKind;
@JsonProperty("policy_key")
private String policyKey;
private String status;
private String summary;
@JsonProperty("client_count")
private Integer clientCount;
@JsonProperty("device_count")
private Integer deviceCount;
@JsonProperty("conflict_keys")
private List<String> conflictKeys;
@JsonProperty("latest_synced_at")
private Date latestSyncedAt;
@JsonProperty("recommended_resolution")
private String recommendedResolution;
@JsonProperty("route_decision_count")
private Integer routeDecisionCount;
@JsonProperty("blocked_count")
private Integer blockedCount;
@JsonProperty("approval_required_count")
private Integer approvalRequiredCount;
@JsonProperty("missing_context_count")
private Integer missingContextCount;
@JsonProperty("not_mapped_count")
private Integer notMappedCount;
@JsonProperty("intervention_conflict_status")
private String interventionConflictStatus;
@JsonProperty("approval_policy_id")
private String approvalPolicyId;
@JsonProperty("conflict_policy_id")
private String conflictPolicyId;
@JsonProperty("decision_guard_status")
private String decisionGuardStatus;
@JsonProperty("batch_accept_remote_disabled")
private Boolean batchAcceptRemoteDisabled;
@JsonProperty("auto_decision_disabled")
private Boolean autoDecisionDisabled;
@JsonProperty("policy_reason")
private String policyReason;
@JsonProperty("dispatch_policy_key")
private String dispatchPolicyKey;
@JsonProperty("max_per_sweep")
private Long maxPerSweep;
@JsonProperty("auto_dispatch_ready_steps")
private Boolean autoDispatchReadySteps;
@JsonProperty("catch_up_on_startup")
private Boolean catchUpOnStartup;
@JsonProperty("max_run_history")
private Long maxRunHistory;
@JsonProperty("sync_record_url")
private String syncRecordUrl;
@JsonProperty("client_key_masked")
private String clientKeyMasked;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("business_view")
private Map<String, Object> businessView;
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
@Data
@Builder
public class DigitalEmployeeSyncAckDto {
@JsonProperty("outbox_id")
private String outboxId;
@JsonProperty("entity_type")
private String entityType;
@JsonProperty("entity_id")
private String entityId;
@JsonProperty("remote_id")
private String remoteId;
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonAlias;
import lombok.Data;
import java.util.Map;
@Data
public class DigitalEmployeeSyncItemDto {
@JsonAlias("outbox_id")
private String outboxId;
@JsonAlias("entity_type")
private String entityType;
@JsonAlias("entity_id")
private String entityId;
private String operation;
@JsonAlias("contract_version")
private String contractVersion;
@JsonAlias("entity_summary")
private Map<String, Object> entitySummary;
@JsonAlias("business_view")
private Map<String, Object> businessView;
private Map<String, Object> payload;
}

View File

@@ -0,0 +1,35 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.Date;
@Data
@Builder
public class DigitalEmployeeSyncRecordDto {
private Long id;
@JsonProperty("client_key")
private String clientKey;
@JsonProperty("device_id")
private String deviceId;
@JsonProperty("outbox_id")
private String outboxId;
@JsonProperty("entity_type")
private String entityType;
@JsonProperty("entity_id")
private String entityId;
private String operation;
@JsonProperty("contract_version")
private String contractVersion;
@JsonProperty("entity_summary")
private String entitySummary;
@JsonProperty("business_view")
private String businessView;
private String payload;
@JsonProperty("synced_at")
private Date syncedAt;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,18 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeeSyncRecordPageDto {
private Long total;
@JsonProperty("page_no")
private Long pageNo;
@JsonProperty("page_size")
private Long pageSize;
private List<DigitalEmployeeSyncRecordDto> records;
}

View File

@@ -0,0 +1,15 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import com.fasterxml.jackson.annotation.JsonAlias;
import lombok.Data;
import java.util.List;
@Data
public class DigitalEmployeeSyncRequestDto {
@JsonAlias("client_key")
private String clientKey;
@JsonAlias("device_id")
private String deviceId;
private List<DigitalEmployeeSyncItemDto> items;
}

View File

@@ -0,0 +1,12 @@
package com.xspaceagi.agent.core.adapter.dto.digital;
import lombok.Builder;
import lombok.Data;
import java.util.List;
@Data
@Builder
public class DigitalEmployeeSyncResponseDto {
private List<DigitalEmployeeSyncAckDto> acked;
}

View File

@@ -0,0 +1,16 @@
package com.xspaceagi.agent.core.adapter.repository;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
public interface DigitalEmployeeAdminActionRepository extends IService<DigitalEmployeeAdminAction> {
IPage<DigitalEmployeeAdminAction> queryActions(
Long tenantId,
String clientKey,
String deviceId,
String status,
long pageNo,
long pageSize
);
}

View File

@@ -0,0 +1,8 @@
package com.xspaceagi.agent.core.adapter.repository;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeArtifactLifecycle;
public interface DigitalEmployeeArtifactLifecycleRepository extends IService<DigitalEmployeeArtifactLifecycle> {
void upsertBySyncRecord(DigitalEmployeeArtifactLifecycle lifecycle);
}

View File

@@ -0,0 +1,24 @@
package com.xspaceagi.agent.core.adapter.repository;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeBusinessFact;
import java.util.Date;
public interface DigitalEmployeeBusinessFactRepository extends IService<DigitalEmployeeBusinessFact> {
void upsertBySyncRecord(DigitalEmployeeBusinessFact fact);
IPage<DigitalEmployeeBusinessFact> queryFacts(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
);
}

View File

@@ -0,0 +1,8 @@
package com.xspaceagi.agent.core.adapter.repository;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeDailyReportFact;
public interface DigitalEmployeeDailyReportFactRepository extends IService<DigitalEmployeeDailyReportFact> {
void upsertBySyncRecord(DigitalEmployeeDailyReportFact fact);
}

View File

@@ -0,0 +1,33 @@
package com.xspaceagi.agent.core.adapter.repository;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.service.IService;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
import java.util.Date;
public interface DigitalEmployeeSyncRecordRepository extends IService<DigitalEmployeeSyncRecord> {
IPage<DigitalEmployeeSyncRecord> queryRecords(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
String outboxId,
long pageNo,
long pageSize
);
IPage<DigitalEmployeeSyncRecord> queryBusinessRecords(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
);
}

View File

@@ -0,0 +1,36 @@
package com.xspaceagi.agent.core.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@TableName("digital_employee_admin_action")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DigitalEmployeeAdminAction {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long userId;
private String clientKey;
private String deviceId;
private String entityType;
private String entityId;
private String action;
private String status;
private String reasonCodes;
private String requestPayload;
private Date processedAt;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,41 @@
package com.xspaceagi.agent.core.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@TableName("digital_employee_artifact_lifecycle")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DigitalEmployeeArtifactLifecycle {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long syncRecordId;
private String clientKey;
private String deviceId;
private String artifactId;
private String planId;
private String taskId;
private String runId;
private String status;
private String retentionStatus;
private String cleanupStatus;
private String deletedAt;
private String versionKey;
private String source;
private String businessView;
private Date syncedAt;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,50 @@
package com.xspaceagi.agent.core.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@TableName("digital_employee_business_fact")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DigitalEmployeeBusinessFact {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long userId;
private Long syncRecordId;
private String clientKey;
private String deviceId;
private String outboxId;
private String entityType;
private String entityId;
private String operation;
private String title;
private String status;
private String summary;
private String planId;
private String taskId;
private String runId;
private String kind;
private String category;
private String occurredAt;
private String startedAt;
private String finishedAt;
private Long durationMs;
private String syncStatus;
private String businessView;
private String payload;
private Date syncedAt;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,46 @@
package com.xspaceagi.agent.core.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@TableName("digital_employee_daily_report_fact")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DigitalEmployeeDailyReportFact {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long syncRecordId;
private String clientKey;
private String deviceId;
private String reportId;
private String dailyReportFactId;
private String planId;
private String status;
private String physicalFactStatus;
private String deliveryStatus;
private String approvalStatus;
private String approvalDecisionStatus;
private String confirmedAt;
private String reportDate;
private String sendRecordId;
private String sendChannel;
private String sendRequestStatus;
private String sendRecordStatus;
private String materializedSource;
private String businessView;
private Date syncedAt;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,38 @@
package com.xspaceagi.agent.core.adapter.repository.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@TableName("digital_employee_sync_record")
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class DigitalEmployeeSyncRecord {
@TableId(value = "id", type = IdType.AUTO)
private Long id;
@TableField(value = "_tenant_id")
private Long tenantId;
private Long userId;
private String clientKey;
private String deviceId;
private String outboxId;
private String entityType;
private String entityId;
private String operation;
private String contractVersion;
private String entitySummary;
private String businessView;
private String payload;
private Date syncedAt;
private Date modified;
private Date created;
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.agent.core.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DigitalEmployeeAdminActionMapper extends BaseMapper<DigitalEmployeeAdminAction> {
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.agent.core.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeArtifactLifecycle;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DigitalEmployeeArtifactLifecycleMapper extends BaseMapper<DigitalEmployeeArtifactLifecycle> {
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.agent.core.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeBusinessFact;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DigitalEmployeeBusinessFactMapper extends BaseMapper<DigitalEmployeeBusinessFact> {
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.agent.core.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeDailyReportFact;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DigitalEmployeeDailyReportFactMapper extends BaseMapper<DigitalEmployeeDailyReportFact> {
}

View File

@@ -0,0 +1,9 @@
package com.xspaceagi.agent.core.infra.dao.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface DigitalEmployeeSyncRecordMapper extends BaseMapper<DigitalEmployeeSyncRecord> {
}

View File

@@ -0,0 +1,36 @@
package com.xspaceagi.agent.core.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeAdminActionRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeAdminAction;
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeAdminActionMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
@Service
public class DigitalEmployeeAdminActionRepositoryImpl
extends ServiceImpl<DigitalEmployeeAdminActionMapper, DigitalEmployeeAdminAction>
implements DigitalEmployeeAdminActionRepository {
@Override
public IPage<DigitalEmployeeAdminAction> queryActions(
Long tenantId,
String clientKey,
String deviceId,
String status,
long pageNo,
long pageSize
) {
LambdaQueryWrapper<DigitalEmployeeAdminAction> queryWrapper = new LambdaQueryWrapper<DigitalEmployeeAdminAction>()
.eq(tenantId != null, DigitalEmployeeAdminAction::getTenantId, tenantId)
.eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeAdminAction::getClientKey, clientKey)
.eq(StringUtils.isNotBlank(deviceId), DigitalEmployeeAdminAction::getDeviceId, deviceId)
.eq(StringUtils.isNotBlank(status), DigitalEmployeeAdminAction::getStatus, status)
.orderByAsc(DigitalEmployeeAdminAction::getCreated)
.orderByAsc(DigitalEmployeeAdminAction::getId);
return page(new Page<>(pageNo, pageSize), queryWrapper);
}
}

View File

@@ -0,0 +1,29 @@
package com.xspaceagi.agent.core.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeArtifactLifecycleRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeArtifactLifecycle;
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeArtifactLifecycleMapper;
import org.springframework.stereotype.Service;
@Service
public class DigitalEmployeeArtifactLifecycleRepositoryImpl
extends ServiceImpl<DigitalEmployeeArtifactLifecycleMapper, DigitalEmployeeArtifactLifecycle>
implements DigitalEmployeeArtifactLifecycleRepository {
@Override
public void upsertBySyncRecord(DigitalEmployeeArtifactLifecycle lifecycle) {
if (lifecycle == null || lifecycle.getTenantId() == null || lifecycle.getSyncRecordId() == null) return;
DigitalEmployeeArtifactLifecycle existing = getOne(new LambdaQueryWrapper<DigitalEmployeeArtifactLifecycle>()
.eq(DigitalEmployeeArtifactLifecycle::getTenantId, lifecycle.getTenantId())
.eq(DigitalEmployeeArtifactLifecycle::getSyncRecordId, lifecycle.getSyncRecordId()), false);
if (existing == null) {
save(lifecycle);
} else {
lifecycle.setId(existing.getId());
lifecycle.setCreated(existing.getCreated());
updateById(lifecycle);
}
}
}

View File

@@ -0,0 +1,61 @@
package com.xspaceagi.agent.core.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeBusinessFactRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeBusinessFact;
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeBusinessFactMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class DigitalEmployeeBusinessFactRepositoryImpl
extends ServiceImpl<DigitalEmployeeBusinessFactMapper, DigitalEmployeeBusinessFact>
implements DigitalEmployeeBusinessFactRepository {
@Override
public void upsertBySyncRecord(DigitalEmployeeBusinessFact fact) {
if (fact == null || fact.getTenantId() == null || fact.getSyncRecordId() == null) return;
DigitalEmployeeBusinessFact existing = getOne(new LambdaQueryWrapper<DigitalEmployeeBusinessFact>()
.eq(DigitalEmployeeBusinessFact::getTenantId, fact.getTenantId())
.eq(DigitalEmployeeBusinessFact::getSyncRecordId, fact.getSyncRecordId()), false);
if (existing == null) {
save(fact);
} else {
fact.setId(existing.getId());
fact.setCreated(existing.getCreated());
updateById(fact);
}
}
@Override
public IPage<DigitalEmployeeBusinessFact> queryFacts(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
) {
LambdaQueryWrapper<DigitalEmployeeBusinessFact> queryWrapper = new LambdaQueryWrapper<DigitalEmployeeBusinessFact>()
.eq(tenantId != null, DigitalEmployeeBusinessFact::getTenantId, tenantId)
.eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeBusinessFact::getClientKey, clientKey)
.eq(StringUtils.isNotBlank(deviceId), DigitalEmployeeBusinessFact::getDeviceId, deviceId)
.eq(StringUtils.isNotBlank(entityType), DigitalEmployeeBusinessFact::getEntityType, entityType)
.eq(StringUtils.isNotBlank(entityId), DigitalEmployeeBusinessFact::getEntityId, entityId)
.eq(StringUtils.isNotBlank(syncStatus), DigitalEmployeeBusinessFact::getSyncStatus, syncStatus)
.ge(updatedFrom != null, DigitalEmployeeBusinessFact::getSyncedAt, updatedFrom)
.le(updatedTo != null, DigitalEmployeeBusinessFact::getSyncedAt, updatedTo)
.orderByDesc(DigitalEmployeeBusinessFact::getSyncedAt)
.orderByDesc(DigitalEmployeeBusinessFact::getId);
return page(new Page<>(pageNo, pageSize), queryWrapper);
}
}

View File

@@ -0,0 +1,29 @@
package com.xspaceagi.agent.core.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeDailyReportFactRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeDailyReportFact;
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeDailyReportFactMapper;
import org.springframework.stereotype.Service;
@Service
public class DigitalEmployeeDailyReportFactRepositoryImpl
extends ServiceImpl<DigitalEmployeeDailyReportFactMapper, DigitalEmployeeDailyReportFact>
implements DigitalEmployeeDailyReportFactRepository {
@Override
public void upsertBySyncRecord(DigitalEmployeeDailyReportFact fact) {
if (fact == null || fact.getTenantId() == null || fact.getSyncRecordId() == null) return;
DigitalEmployeeDailyReportFact existing = getOne(new LambdaQueryWrapper<DigitalEmployeeDailyReportFact>()
.eq(DigitalEmployeeDailyReportFact::getTenantId, fact.getTenantId())
.eq(DigitalEmployeeDailyReportFact::getSyncRecordId, fact.getSyncRecordId()), false);
if (existing == null) {
save(fact);
} else {
fact.setId(existing.getId());
fact.setCreated(existing.getCreated());
updateById(fact);
}
}
}

View File

@@ -0,0 +1,68 @@
package com.xspaceagi.agent.core.infra.repository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.xspaceagi.agent.core.adapter.repository.DigitalEmployeeSyncRecordRepository;
import com.xspaceagi.agent.core.adapter.repository.entity.DigitalEmployeeSyncRecord;
import com.xspaceagi.agent.core.infra.dao.mapper.DigitalEmployeeSyncRecordMapper;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
public class DigitalEmployeeSyncRecordRepositoryImpl
extends ServiceImpl<DigitalEmployeeSyncRecordMapper, DigitalEmployeeSyncRecord>
implements DigitalEmployeeSyncRecordRepository {
@Override
public IPage<DigitalEmployeeSyncRecord> queryRecords(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
String outboxId,
long pageNo,
long pageSize
) {
LambdaQueryWrapper<DigitalEmployeeSyncRecord> queryWrapper = new LambdaQueryWrapper<DigitalEmployeeSyncRecord>()
.eq(tenantId != null, DigitalEmployeeSyncRecord::getTenantId, tenantId)
.eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeSyncRecord::getClientKey, clientKey)
.eq(StringUtils.isNotBlank(deviceId), DigitalEmployeeSyncRecord::getDeviceId, deviceId)
.eq(StringUtils.isNotBlank(entityType), DigitalEmployeeSyncRecord::getEntityType, entityType)
.eq(StringUtils.isNotBlank(entityId), DigitalEmployeeSyncRecord::getEntityId, entityId)
.eq(StringUtils.isNotBlank(outboxId), DigitalEmployeeSyncRecord::getOutboxId, outboxId)
.orderByDesc(DigitalEmployeeSyncRecord::getSyncedAt)
.orderByDesc(DigitalEmployeeSyncRecord::getId);
return page(new Page<>(pageNo, pageSize), queryWrapper);
}
@Override
public IPage<DigitalEmployeeSyncRecord> queryBusinessRecords(
Long tenantId,
String clientKey,
String deviceId,
String entityType,
String entityId,
Date updatedFrom,
Date updatedTo,
String syncStatus,
long pageNo,
long pageSize
) {
LambdaQueryWrapper<DigitalEmployeeSyncRecord> queryWrapper = new LambdaQueryWrapper<DigitalEmployeeSyncRecord>()
.eq(tenantId != null, DigitalEmployeeSyncRecord::getTenantId, tenantId)
.eq(StringUtils.isNotBlank(clientKey), DigitalEmployeeSyncRecord::getClientKey, clientKey)
.eq(StringUtils.isNotBlank(deviceId), DigitalEmployeeSyncRecord::getDeviceId, deviceId)
.eq(StringUtils.isNotBlank(entityType), DigitalEmployeeSyncRecord::getEntityType, entityType)
.eq(StringUtils.isNotBlank(entityId), DigitalEmployeeSyncRecord::getEntityId, entityId)
.ge(updatedFrom != null, DigitalEmployeeSyncRecord::getSyncedAt, updatedFrom)
.le(updatedTo != null, DigitalEmployeeSyncRecord::getSyncedAt, updatedTo)
.orderByDesc(DigitalEmployeeSyncRecord::getSyncedAt)
.orderByDesc(DigitalEmployeeSyncRecord::getId);
return page(new Page<>(pageNo, pageSize), queryWrapper);
}
}

View File

@@ -0,0 +1,77 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminActionResultDto;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.dto.ReqResult;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import static com.xspaceagi.system.spec.enums.ResourceEnum.CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST;
@RestController
@RequestMapping("/api/digital-employee/admin/actions")
public class DigitalEmployeeAdminActionController {
private static final String CLIENT_KEY_HEADER = "X-Qiming-Client-Key";
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
@RequestMapping(method = RequestMethod.POST)
public ReqResult<DigitalEmployeeAdminActionResultDto> createAction(
@RequestBody(required = false) DigitalEmployeeAdminActionRequestDto request
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.createAdminAction(request));
}
@RequestMapping(path = "/pending", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeAdminActionPageDto> pendingActions(
@RequestHeader(name = CLIENT_KEY_HEADER, required = false) String headerClientKey,
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
String normalizedClientKey = StringUtils.defaultIfBlank(clientKey, headerClientKey);
if (StringUtils.isBlank(normalizedClientKey)) {
return ReqResult.create("0001", "客户端 Key 缺失", null);
}
return ReqResult.success(digitalEmployeeSyncApplicationService.queryPendingAdminActions(
normalizedClientKey.trim(),
deviceId,
pageNo,
pageSize
));
}
@RequestMapping(path = "/{actionId}/result", method = RequestMethod.POST)
public ReqResult<DigitalEmployeeAdminActionResultDto> markResult(
@PathVariable("actionId") Long actionId,
@RequestBody(required = false) Map<String, Object> body
) {
String status = body == null ? null : String.valueOf(body.get("status"));
List<String> reasonCodes = reasonCodes(body == null ? null : body.get("reason_codes"));
return ReqResult.success(digitalEmployeeSyncApplicationService.markAdminActionResult(actionId, status, reasonCodes));
}
private List<String> reasonCodes(Object value) {
if (value instanceof List<?> list) {
return list.stream().map(String::valueOf).toList();
}
return List.of();
}
}

View File

@@ -0,0 +1,67 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePolicySnapshotPageDto;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.dto.ReqResult;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import static com.xspaceagi.system.spec.enums.ResourceEnum.CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST;
@RestController
@RequestMapping("/api/digital-employee/admin/policies")
public class DigitalEmployeeAdminPolicyController {
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
@RequestMapping(path = "/snapshots", method = RequestMethod.GET)
public ReqResult<DigitalEmployeePolicySnapshotPageDto> querySnapshots(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "policy_kind", required = false) String policyKind,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.queryAdminPolicySnapshots(
clientKey,
deviceId,
status,
policyKind,
parseDate(updatedFrom),
parseDate(updatedTo),
pageNo,
pageSize
));
}
private Date parseDate(String value) {
if (StringUtils.isBlank(value)) return null;
String trimmed = value.trim();
try {
return Date.from(Instant.parse(trimmed));
} catch (Exception ignored) {
try {
LocalDateTime localDateTime = LocalDateTime.parse(trimmed, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
} catch (Exception ignoredAgain) {
return null;
}
}
}
}

View File

@@ -0,0 +1,78 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminRecordResultDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBillingEstimateDto;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.dto.ReqResult;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import static com.xspaceagi.system.spec.enums.ResourceEnum.CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST;
@RestController
@RequestMapping("/api/digital-employee/admin")
public class DigitalEmployeeAdminRecordController {
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
@RequestMapping(path = "/daily-reports/send-records", method = RequestMethod.POST)
public ReqResult<DigitalEmployeeAdminRecordResultDto> recordDailyReportSendResult(
@RequestBody(required = false) DigitalEmployeeAdminRecordRequestDto request
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.recordDailyReportSendResult(request));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
@RequestMapping(path = "/billing/estimates", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBillingEstimateDto> queryBillingEstimates(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.queryBillingEstimates(
clientKey,
deviceId,
parseDate(updatedFrom),
parseDate(updatedTo)
));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
@RequestMapping(path = "/audit-archives", method = RequestMethod.POST)
public ReqResult<DigitalEmployeeAdminRecordResultDto> recordAuditArchive(
@RequestBody(required = false) DigitalEmployeeAdminRecordRequestDto request
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.recordAuditArchive(request));
}
private Date parseDate(String value) {
if (StringUtils.isBlank(value)) return null;
String trimmed = value.trim();
try {
return Date.from(Instant.parse(trimmed));
} catch (Exception ignored) {
try {
LocalDateTime localDateTime = LocalDateTime.parse(trimmed, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
} catch (Exception ignoredAgain) {
return null;
}
}
}
}

View File

@@ -0,0 +1,80 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeAdminWorkbenchPageDto;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.dto.ReqResult;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import static com.xspaceagi.system.spec.enums.ResourceEnum.CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST;
@RestController
@RequestMapping("/api/digital-employee/admin/workbench")
public class DigitalEmployeeAdminWorkbenchController {
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST)
@RequestMapping(path = "/{view}", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeAdminWorkbenchPageDto> queryWorkbench(
@PathVariable("view") String view,
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_type", required = false) String entityType,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "kind", required = false) String kind,
@RequestParam(name = "category", required = false) String category,
@RequestParam(name = "level", required = false) String level,
@RequestParam(name = "priority", required = false) String priority,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.queryAdminWorkbench(
view,
clientKey,
deviceId,
entityType,
entityId,
status,
kind,
category,
level,
priority,
parseDate(updatedFrom),
parseDate(updatedTo),
pageNo,
pageSize
));
}
private Date parseDate(String value) {
if (StringUtils.isBlank(value)) return null;
String trimmed = value.trim();
try {
return Date.from(Instant.parse(trimmed));
} catch (Exception ignored) {
try {
LocalDateTime localDateTime = LocalDateTime.parse(trimmed, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
} catch (Exception ignoredAgain) {
return null;
}
}
}
}

View File

@@ -0,0 +1,177 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeBusinessViewPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeePlanBusinessViewDetailDto;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.dto.ReqResult;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import static com.xspaceagi.system.spec.enums.ResourceEnum.CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST;
@RestController
@RequestMapping("/api/digital-employee")
public class DigitalEmployeeBusinessViewController {
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/plans", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryPlans(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(query("plan", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/plans/{plan_id}", method = RequestMethod.GET)
public ReqResult<DigitalEmployeePlanBusinessViewDetailDto> queryPlanDetail(
@PathVariable("plan_id") String planId,
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.queryPlanBusinessViewDetail(planId, clientKey, deviceId));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/tasks", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryTasks(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(query("task", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/runs", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryRuns(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(query("run", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/events", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryEvents(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(query("event", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/artifacts", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryArtifacts(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(query("artifact", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/approvals", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeBusinessViewPageDto> queryApprovals(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "sync_status", required = false) String syncStatus,
@RequestParam(name = "updated_from", required = false) String updatedFrom,
@RequestParam(name = "updated_to", required = false) String updatedTo,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(query("approval", clientKey, deviceId, entityId, status, syncStatus, updatedFrom, updatedTo, pageNo, pageSize));
}
private DigitalEmployeeBusinessViewPageDto query(
String entityType,
String clientKey,
String deviceId,
String entityId,
String status,
String syncStatus,
String updatedFrom,
String updatedTo,
Long pageNo,
Long pageSize
) {
return digitalEmployeeSyncApplicationService.queryBusinessViews(
entityType,
clientKey,
deviceId,
entityId,
status,
syncStatus,
parseDate(updatedFrom),
parseDate(updatedTo),
pageNo,
pageSize
);
}
private Date parseDate(String value) {
if (StringUtils.isBlank(value)) return null;
String trimmed = value.trim();
try {
return Date.from(Instant.parse(trimmed));
} catch (Exception ignored) {
try {
LocalDateTime localDateTime = LocalDateTime.parse(trimmed, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"));
return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant());
} catch (Exception ignoredAgain) {
return null;
}
}
}
}

View File

@@ -0,0 +1,72 @@
package com.xspaceagi.agent.web.ui.controller;
import com.xspaceagi.agent.core.adapter.application.DigitalEmployeeSyncApplicationService;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRecordPageDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncRequestDto;
import com.xspaceagi.agent.core.adapter.dto.digital.DigitalEmployeeSyncResponseDto;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.dto.ReqResult;
import jakarta.annotation.Resource;
import org.apache.commons.lang3.StringUtils;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import static com.xspaceagi.system.spec.enums.ResourceEnum.CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST;
@RestController
@RequestMapping("/api/digital-employee/sync")
public class DigitalEmployeeSyncController {
private static final String CLIENT_KEY_HEADER = "X-Qiming-Client-Key";
@Resource
private DigitalEmployeeSyncApplicationService digitalEmployeeSyncApplicationService;
@RequestMapping(path = "/outbox", method = RequestMethod.POST)
public ReqResult<DigitalEmployeeSyncResponseDto> syncOutbox(
@RequestHeader(name = CLIENT_KEY_HEADER, required = false) String headerClientKey,
@RequestBody(required = false) DigitalEmployeeSyncRequestDto request
) {
if (StringUtils.isBlank(headerClientKey)) {
return ReqResult.create("0001", "客户端 Key header 缺失", null);
}
if (request == null) {
request = new DigitalEmployeeSyncRequestDto();
}
String normalizedHeaderClientKey = headerClientKey.trim();
if (StringUtils.isBlank(request.getClientKey())) {
request.setClientKey(normalizedHeaderClientKey);
} else if (!StringUtils.equals(request.getClientKey().trim(), normalizedHeaderClientKey)) {
return ReqResult.create("0001", "客户端 Key header 与请求体不一致", null);
} else {
request.setClientKey(request.getClientKey().trim());
}
return ReqResult.success(digitalEmployeeSyncApplicationService.syncOutbox(request));
}
@RequireResource(CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST)
@RequestMapping(path = "/records", method = RequestMethod.GET)
public ReqResult<DigitalEmployeeSyncRecordPageDto> queryRecords(
@RequestParam(name = "client_key", required = false) String clientKey,
@RequestParam(name = "device_id", required = false) String deviceId,
@RequestParam(name = "entity_type", required = false) String entityType,
@RequestParam(name = "entity_id", required = false) String entityId,
@RequestParam(name = "outbox_id", required = false) String outboxId,
@RequestParam(name = "page_no", required = false) Long pageNo,
@RequestParam(name = "page_size", required = false) Long pageSize
) {
return ReqResult.success(digitalEmployeeSyncApplicationService.queryRecords(
clientKey,
deviceId,
entityType,
entityId,
outboxId,
pageNo,
pageSize
));
}
}

View File

@@ -0,0 +1,92 @@
package com.xspaceagi.sandbox.application.dto;
import com.xspaceagi.system.infra.dao.entity.User;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
import java.util.List;
@Schema(description = "在线监控聚合信息")
@Data
public class OnlineMonitorDto {
@Schema(description = "客户端总数")
private long totalClientCount;
@Schema(description = "在线客户端数")
private long onlineClientCount;
@Schema(description = "离线客户端数")
private long offlineClientCount;
@Schema(description = "已停用客户端数")
private long inactiveClientCount;
@Schema(description = "用户总数")
private long totalUserCount;
@Schema(description = "启用用户数")
private long enabledUserCount;
@Schema(description = "禁用用户数")
private long disabledUserCount;
@Schema(description = "用户组监控信息")
private List<GroupMonitorDto> groups;
@Schema(description = "用户监控信息")
private List<UserMonitorDto> users;
@Schema(description = "客户端监控信息")
private List<ClientMonitorDto> clients;
@Schema(description = "用户组监控信息")
@Data
public static class GroupMonitorDto {
private Long groupId;
private String groupName;
private Integer authEnabled;
private Integer status;
private Date expireTime;
private Integer maxUserCount;
private long currentUserCount;
private Integer maxClientCount;
private long currentClientCount;
private long onlineClientCount;
private long offlineClientCount;
private long inactiveClientCount;
}
@Schema(description = "用户监控信息")
@Data
public static class UserMonitorDto {
private Long userId;
private String userName;
private String nickName;
private User.Role role;
private User.Status status;
private List<String> groupNames;
private long clientCount;
private long onlineClientCount;
private long inactiveClientCount;
private Date lastLoginTime;
}
@Schema(description = "客户端监控信息")
@Data
public static class ClientMonitorDto {
private Long id;
private String name;
private Long userId;
private String userName;
private String nickName;
private Long groupId;
private String groupName;
private Boolean isActive;
private boolean online;
private Integer maxAgentCount;
private Date created;
private Date modified;
}
}

View File

@@ -1,18 +1,23 @@
package com.xspaceagi.sandbox.ui.web.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.xspaceagi.sandbox.application.dto.OnlineMonitorDto;
import com.xspaceagi.sandbox.application.dto.SandboxConfigDto;
import com.xspaceagi.sandbox.application.dto.SandboxConfigQueryDto;
import com.xspaceagi.sandbox.application.dto.SandboxGlobalConfigDto;
import com.xspaceagi.sandbox.application.service.SandboxConfigApplicationService;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.service.SysGroupApplicationService;
import com.xspaceagi.system.application.service.SysRoleApplicationService;
import com.xspaceagi.system.infra.dao.entity.SysGroup;
import com.xspaceagi.system.infra.dao.entity.User;
import com.xspaceagi.system.infra.dao.service.UserService;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.enums.GroupEnum;
import com.xspaceagi.system.spec.enums.RoleEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import io.swagger.v3.oas.annotations.Operation;
@@ -21,7 +26,12 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -41,6 +51,12 @@ public class SandboxConfigManageController {
@Resource
private SysGroupApplicationService sysGroupApplicationService;
@Resource
private SysRoleApplicationService sysRoleApplicationService;
@Resource
private UserService userService;
@RequireResource(SANDBOX_CONFIG_QUERY)
@Operation(summary = "分页查询配置列表")
@PostMapping("/page")
@@ -148,6 +164,154 @@ public class SandboxConfigManageController {
return ReqResult.success(globalConfig);
}
@RequireResource(ONLINE_MONITOR_QUERY)
@Operation(summary = "查询在线监控信息")
@GetMapping("/online-monitor")
public ReqResult<OnlineMonitorDto> onlineMonitor() {
boolean platformAdmin = isPlatformAdmin();
List<SysGroup> groups = visibleGroups();
Set<Long> groupIds = groups.stream().map(SysGroup::getId).collect(Collectors.toSet());
List<User> users = visibleUsers(platformAdmin, groupIds);
Set<Long> userIds = users.stream().map(User::getId).collect(Collectors.toSet());
SandboxConfigQueryDto queryDto = new SandboxConfigQueryDto();
queryDto.setPageNum(1);
queryDto.setPageSize(100000);
if (!platformAdmin && !groupIds.isEmpty()) {
queryDto.setGroupIds(new ArrayList<>(groupIds));
} else if (!platformAdmin) {
queryDto.setGroupIds(List.of(-1L));
}
List<SandboxConfigDto> configs = sandboxConfigApplicationService.pageQuery(queryDto).getRecords().stream()
.filter(config -> config.getUserId() != null)
.filter(config -> platformAdmin || userIds.contains(config.getUserId()))
.toList();
return ReqResult.success(buildOnlineMonitor(groups, users, configs));
}
private OnlineMonitorDto buildOnlineMonitor(List<SysGroup> groups, List<User> users, List<SandboxConfigDto> configs) {
Map<Long, SysGroup> groupMap = groups.stream()
.filter(group -> group.getId() != null)
.collect(Collectors.toMap(SysGroup::getId, group -> group, (a, b) -> a));
Map<Long, User> userMap = users.stream()
.filter(user -> user.getId() != null)
.collect(Collectors.toMap(User::getId, user -> user, (a, b) -> a));
Map<Long, List<SysGroup>> userGroupMap = new HashMap<>();
groups.forEach(group -> sysGroupApplicationService.getUserListByGroupId(group.getId())
.forEach(user -> userGroupMap.computeIfAbsent(user.getId(), key -> new ArrayList<>()).add(group)));
OnlineMonitorDto dto = new OnlineMonitorDto();
dto.setTotalClientCount(configs.size());
dto.setOnlineClientCount(configs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && config.isOnline()).count());
dto.setInactiveClientCount(configs.stream().filter(config -> !Boolean.TRUE.equals(config.getIsActive())).count());
dto.setOfflineClientCount(configs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && !config.isOnline()).count());
dto.setTotalUserCount(users.size());
dto.setEnabledUserCount(users.stream().filter(user -> user.getStatus() == User.Status.Enabled).count());
dto.setDisabledUserCount(users.stream().filter(user -> user.getStatus() == User.Status.Disabled).count());
dto.setGroups(groups.stream()
.map(group -> buildGroupMonitor(group, users, configs))
.sorted(Comparator.comparing(OnlineMonitorDto.GroupMonitorDto::getGroupId, Comparator.nullsLast(Long::compareTo)))
.toList());
dto.setUsers(users.stream()
.map(user -> buildUserMonitor(user, userGroupMap, configs))
.sorted(Comparator.comparing(OnlineMonitorDto.UserMonitorDto::getUserId, Comparator.nullsLast(Long::compareTo)))
.toList());
dto.setClients(configs.stream()
.map(config -> buildClientMonitor(config, userMap, groupMap))
.sorted(Comparator.comparing(OnlineMonitorDto.ClientMonitorDto::getId, Comparator.nullsLast(Long::compareTo)))
.toList());
return dto;
}
private OnlineMonitorDto.GroupMonitorDto buildGroupMonitor(SysGroup group, List<User> users, List<SandboxConfigDto> configs) {
Set<Long> groupUserIds = sysGroupApplicationService.getUserListByGroupId(group.getId()).stream()
.map(User::getId)
.collect(Collectors.toSet());
List<SandboxConfigDto> groupConfigs = configs.stream()
.filter(config -> Objects.equals(config.getGroupId(), group.getId()))
.toList();
OnlineMonitorDto.GroupMonitorDto dto = new OnlineMonitorDto.GroupMonitorDto();
dto.setGroupId(group.getId());
dto.setGroupName(group.getName());
dto.setAuthEnabled(group.getAuthEnabled());
dto.setStatus(group.getStatus());
dto.setExpireTime(group.getExpireTime());
dto.setMaxUserCount(group.getMaxUserCount());
dto.setCurrentUserCount(users.stream().filter(user -> groupUserIds.contains(user.getId())).count());
dto.setMaxClientCount(group.getMaxClientCount());
dto.setCurrentClientCount(groupConfigs.size());
dto.setOnlineClientCount(groupConfigs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && config.isOnline()).count());
dto.setInactiveClientCount(groupConfigs.stream().filter(config -> !Boolean.TRUE.equals(config.getIsActive())).count());
dto.setOfflineClientCount(groupConfigs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && !config.isOnline()).count());
return dto;
}
private OnlineMonitorDto.UserMonitorDto buildUserMonitor(User user, Map<Long, List<SysGroup>> userGroupMap, List<SandboxConfigDto> configs) {
List<SandboxConfigDto> userConfigs = configs.stream()
.filter(config -> Objects.equals(config.getUserId(), user.getId()))
.toList();
OnlineMonitorDto.UserMonitorDto dto = new OnlineMonitorDto.UserMonitorDto();
dto.setUserId(user.getId());
dto.setUserName(user.getUserName());
dto.setNickName(user.getNickName());
dto.setRole(user.getRole());
dto.setStatus(user.getStatus());
dto.setGroupNames(userGroupMap.getOrDefault(user.getId(), List.of()).stream().map(SysGroup::getName).toList());
dto.setClientCount(userConfigs.size());
dto.setOnlineClientCount(userConfigs.stream().filter(config -> Boolean.TRUE.equals(config.getIsActive()) && config.isOnline()).count());
dto.setInactiveClientCount(userConfigs.stream().filter(config -> !Boolean.TRUE.equals(config.getIsActive())).count());
dto.setLastLoginTime(user.getLastLoginTime());
return dto;
}
private OnlineMonitorDto.ClientMonitorDto buildClientMonitor(SandboxConfigDto config, Map<Long, User> userMap, Map<Long, SysGroup> groupMap) {
User user = userMap.get(config.getUserId());
SysGroup group = groupMap.get(config.getGroupId());
OnlineMonitorDto.ClientMonitorDto dto = new OnlineMonitorDto.ClientMonitorDto();
dto.setId(config.getId());
dto.setName(config.getName());
dto.setUserId(config.getUserId());
dto.setUserName(user == null ? null : user.getUserName());
dto.setNickName(user == null ? null : user.getNickName());
dto.setGroupId(config.getGroupId());
dto.setGroupName(group == null ? null : group.getName());
dto.setIsActive(config.getIsActive());
dto.setOnline(config.isOnline());
dto.setMaxAgentCount(config.getMaxAgentCount());
dto.setCreated(config.getCreated());
dto.setModified(config.getModified());
return dto;
}
private List<SysGroup> visibleGroups() {
if (isPlatformAdmin()) {
return sysGroupApplicationService.getGroupList(new SysGroup()).stream()
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
.toList();
}
Set<Long> groupIds = currentUserGroupIds();
if (groupIds.isEmpty()) {
return List.of();
}
return sysGroupApplicationService.listGroupsByIds(new ArrayList<>(groupIds));
}
private List<User> visibleUsers(boolean platformAdmin, Set<Long> groupIds) {
if (platformAdmin) {
return userService.list().stream()
.filter(user -> user.getStatus() != User.Status.Deleted)
.toList();
}
return groupIds.stream()
.flatMap(groupId -> sysGroupApplicationService.getUserListByGroupId(groupId).stream())
.filter(user -> user.getStatus() != User.Status.Deleted)
.collect(Collectors.toMap(User::getId, user -> user, (a, b) -> a))
.values().stream().toList();
}
private void applySandboxConfigManageScope(SandboxConfigQueryDto queryDto) {
if (queryDto == null || isPlatformAdmin()) {
return;
@@ -172,13 +336,18 @@ public class SandboxConfigManageController {
}
private Set<Long> currentUserGroupIds() {
return sysGroupApplicationService.getEffectiveGroupListByUserId(getCurrentUserDto().getId()).stream()
return sysGroupApplicationService.getGroupListByUserId(getCurrentUserDto().getId()).stream()
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
.map(SysGroup::getId)
.collect(Collectors.toSet());
}
private boolean isPlatformAdmin() {
return getCurrentUserDto().getRole() == User.Role.Admin;
UserDto currentUser = getCurrentUserDto();
return currentUser.getRole() == User.Role.Admin
&& (currentUserGroupIds().isEmpty()
|| sysRoleApplicationService.getRoleListByUserId(currentUser.getId()).stream()
.anyMatch(role -> RoleEnum.SUPER_ADMIN.getCode().equals(role.getCode())));
}
private UserDto getCurrentUserDto() {

View File

@@ -5,6 +5,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
@Data
public class UserQueryDto implements Serializable {
@@ -29,4 +30,7 @@ public class UserQueryDto implements Serializable {
@Schema(description = "角色")
private User.Role role;
@Schema(description = "用户ID范围后端数据权限过滤使用")
private List<Long> userIds;
}

View File

@@ -3,6 +3,7 @@ package com.xspaceagi.system.application.dto.permission;
import java.io.Serializable;
import java.util.Date;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -28,6 +29,7 @@ public class SysGroupAddDto implements Serializable {
private Integer authEnabled;
@Schema(description = "授权到期时间,空表示不过期")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date expireTime;
@Schema(description = "来源,1:系统内置 2:用户自定义", hidden = true)
@@ -38,4 +40,4 @@ public class SysGroupAddDto implements Serializable {
@Schema(description = "排序")
private Integer sortIndex;
}
}

View File

@@ -1,5 +1,6 @@
package com.xspaceagi.system.application.dto.permission;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -31,6 +32,7 @@ public class SysGroupUpdateDto implements Serializable {
private Integer authEnabled;
@Schema(description = "授权到期时间,空表示不过期")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
private Date expireTime;
@Schema(description = "来源,1:系统内置 2:用户自定义", hidden = true)
@@ -41,4 +43,4 @@ public class SysGroupUpdateDto implements Serializable {
@Schema(description = "排序")
private Integer sortIndex;
}
}

View File

@@ -247,6 +247,12 @@ public class UserApplicationServiceImpl implements UserApplicationService {
UserQueryDto userQueryDto = pageQueryVo.getQueryFilter();
if (userQueryDto != null) {
BeanUtils.copyProperties(userQueryDto, user);
if (userQueryDto.getUserIds() != null) {
if (userQueryDto.getUserIds().isEmpty()) {
return new Page<>(pageQueryVo.getPageNo(), pageQueryVo.getPageSize(), 0);
}
queryWrapper.in(User::getId, userQueryDto.getUserIds());
}
}
if ("".equals(user.getUserName())) {
user.setUserName(null);

View File

@@ -145,13 +145,22 @@
"sortIndex": 16,
"status": 1
},
{
"code": "online_monitor",
"name": "在线监控模块",
"description": "在线监控模块",
"source": 1,
"type": 1,
"sortIndex": 17,
"status": 1
},
{
"code": "category_config",
"name": "分类管理模块",
"description": "分类管理模块",
"source": 1,
"type": 1,
"sortIndex": 17,
"sortIndex": 18,
"status": 1
},
{
@@ -924,6 +933,16 @@
"sortIndex": 1,
"status": 1
},
{
"code": "online_monitor_query",
"name": "查询",
"description": "查询在线监控",
"source": 1,
"type": 2,
"parentCode": "online_monitor",
"sortIndex": 1,
"status": 1
},
{
"code": "subscription_points_query",
"name": "查询",
@@ -2645,6 +2664,18 @@
"sortIndex": 10,
"status": 1
},
{
"parentCode": "system_manage",
"code": "online_monitor",
"name": "在线监控",
"description": "在线监控",
"source": 1,
"path": "/system/online-monitor",
"openType": 1,
"icon": "",
"sortIndex": 11,
"status": 1
},
{
"parentCode": "workspace",
"code": "member_setting",
@@ -2664,7 +2695,7 @@
"source": 1,
"openType": 1,
"icon": "",
"sortIndex": 11,
"sortIndex": 12,
"status": 1
},
{
@@ -2675,7 +2706,7 @@
"source": 1,
"openType": 1,
"icon": "",
"sortIndex": 12,
"sortIndex": 13,
"status": 1
},
{
@@ -3203,6 +3234,11 @@
"resourceCode": "sandbox_config",
"resourceBindType": 1
},
{
"menuCode": "online_monitor",
"resourceCode": "online_monitor",
"resourceBindType": 1
},
{
"menuCode": "category_config",
"resourceCode": "category_config",
@@ -4016,6 +4052,17 @@
}
]
},
{
"roleCode": "super_admin",
"menuCode": "online_monitor",
"menuBindType": 1,
"resourceTree": [
{
"code": "online_monitor",
"resourceBindType": 1
}
]
},
{
"roleCode": "super_admin",
"menuCode": "category_config",
@@ -4354,4 +4401,4 @@
"pageDailyPromptLimit": -1
}
]
}
}

View File

@@ -267,6 +267,11 @@ public enum ResourceEnum {
SANDBOX_CONFIG_DELETE(ResourceTypeEnum.OPERATION, "sandbox_config_delete", "删除", "sandbox_config"),
// SANDBOX_CONFIG_ENABLE(ResourceTypeEnum.OPERATION, "sandbox_config_enable", "启用/禁用", "sandbox_config"),
// ================== 在线监控模块 ==================
ONLINE_MONITOR(ResourceTypeEnum.MODULE, "online_monitor", "在线监控模块", "root"),
ONLINE_MONITOR_QUERY(ResourceTypeEnum.OPERATION, "online_monitor_query", "查询", "online_monitor"),
// ================== 分类管理模块 ==================
CATEGORY_CONFIG(ResourceTypeEnum.MODULE, "category_config", "分类管理模块", "root"),
@@ -412,6 +417,16 @@ public enum ResourceEnum {
CONTENT_SKILL_QUERY_DETAIL(ResourceTypeEnum.OPERATION, "content_skill_query_detail", "查询详情", "content_skill"),
CONTENT_SKILL_DELETE(ResourceTypeEnum.OPERATION, "content_skill_delete", "删除", "content_skill"),
// 2级模块 - 数字员工同步记录
CONTENT_DIGITAL_EMPLOYEE_SYNC(ResourceTypeEnum.MODULE, "content_digital_employee_sync", "数字员工同步记录", "content_manage"),
CONTENT_DIGITAL_EMPLOYEE_SYNC_QUERY_LIST(ResourceTypeEnum.OPERATION, "content_digital_employee_sync_query_list", "查询列表", "content_digital_employee_sync"),
// 2级模块 - 数字员工运营台
CONTENT_DIGITAL_EMPLOYEE_OPS(ResourceTypeEnum.MODULE, "content_digital_employee_ops", "数字员工运营台", "content_manage"),
CONTENT_DIGITAL_EMPLOYEE_OPS_QUERY_LIST(ResourceTypeEnum.OPERATION, "content_digital_employee_ops_query_list", "查询列表", "content_digital_employee_ops"),
// ================== 多语言管理模块 ==================
I18N_LANG_MANAGE(ResourceTypeEnum.MODULE, "i18n_lang_manage", "多语言管理模块", "root"),
I18N_LANG_QUERY(ResourceTypeEnum.OPERATION, "i18n_lang_query", "查询", "i18n_lang_manage"),

View File

@@ -5,15 +5,22 @@ import com.xspaceagi.system.application.dto.SendNotifyMessageDto;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.application.dto.UserQueryDto;
import com.xspaceagi.system.application.service.NotifyMessageApplicationService;
import com.xspaceagi.system.application.service.SysGroupApplicationService;
import com.xspaceagi.system.application.service.SysRoleApplicationService;
import com.xspaceagi.system.application.service.UserApplicationService;
import com.xspaceagi.system.infra.dao.entity.SysGroup;
import com.xspaceagi.system.infra.dao.entity.NotifyMessage;
import com.xspaceagi.system.infra.dao.entity.User;
import com.xspaceagi.system.infra.dao.mapper.UserMapper;
import com.xspaceagi.system.infra.dao.service.UserService;
import com.xspaceagi.system.spec.annotation.RequireResource;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.common.UserContext;
import com.xspaceagi.system.spec.dto.PageQueryVo;
import com.xspaceagi.system.spec.dto.ReqResult;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.enums.GroupEnum;
import com.xspaceagi.system.spec.enums.RoleEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import com.xspaceagi.system.web.dto.UserAddDto;
@@ -28,6 +35,7 @@ import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import static com.xspaceagi.system.spec.enums.ResourceEnum.*;
@@ -40,17 +48,35 @@ public class UserManageController {
@Resource
private UserApplicationService userApplicationService;
@Resource
private SysGroupApplicationService sysGroupApplicationService;
@Resource
private SysRoleApplicationService sysRoleApplicationService;
@Resource
private NotifyMessageApplicationService notifyMessageApplicationService;
@Resource
private UserMapper userMapper;
@Resource
private UserService userService;
@RequireResource(USER_MANAGE_QUERY)
@Operation(summary = "查询用户列表")
@RequestMapping(path = "/list", method = RequestMethod.POST)
public ReqResult<IPage<UserDto>> listQuery(@RequestBody PageQueryVo<UserQueryDto> pageQueryVo) {
checkAdmin();
if (pageQueryVo == null) {
pageQueryVo = new PageQueryVo<>();
}
if (pageQueryVo.getPageNo() == null) {
pageQueryVo.setPageNo(1L);
}
if (pageQueryVo.getPageSize() == null) {
pageQueryVo.setPageSize(10L);
}
applyUserManageScope(pageQueryVo);
return ReqResult.success(userApplicationService.listQuery(pageQueryVo));
}
@@ -58,8 +84,6 @@ public class UserManageController {
@Operation(summary = "获取用户统计")
@RequestMapping(path = "/stats", method = RequestMethod.GET)
public ReqResult<UserStatsDto> getUserStats() {
checkAdmin();
Long totalUserCount = userMapper.countTotalUsers();
Long todayNewUserCount = userMapper.countTodayNewUsers();
@@ -90,13 +114,17 @@ public class UserManageController {
@Operation(summary = "添加用户")
@RequestMapping(path = "/add", method = RequestMethod.POST)
public ReqResult<Void> addUser(@RequestBody UserAddDto userAddDto) {
checkAdmin();
UserDto userDto = new UserDto();
BeanUtils.copyProperties(userAddDto, userDto);
if (StringUtils.isNotBlank(userAddDto.getPassword())) {
userDto.setResetPass(1);
}
userDto.setRole(User.Role.User);
userApplicationService.add(userDto);
List<Long> groupIds = currentUserGroupIds().stream().toList();
if (CollectionUtils.isNotEmpty(groupIds)) {
groupIds.forEach(groupId -> sysGroupApplicationService.groupAddUser(groupId, userDto.getId(), getCurrentUserContext()));
}
return ReqResult.success();
}
@@ -104,7 +132,7 @@ public class UserManageController {
@Operation(summary = "更新用户信息")
@RequestMapping(path = "/updateById/{id}", method = RequestMethod.POST)
public ReqResult<Void> updateUserById(@PathVariable Long id, @RequestBody UserUpdateDto userUpdateDto) {
checkAdmin();
checkCurrentTenantUser(id);
UserDto userUpdate = new UserDto();
userUpdate.setId(id);
userUpdate.setAvatar(userUpdateDto.getAvatar());
@@ -122,7 +150,7 @@ public class UserManageController {
@Operation(summary = "禁用用户")
@RequestMapping(path = "/disable/{id}", method = RequestMethod.POST)
public ReqResult<Void> disableUserById(@PathVariable Long id) {
checkAdmin();
checkCurrentTenantUser(id);
UserDto userUpdate = new UserDto();
userUpdate.setId(id);
userUpdate.setStatus(User.Status.Disabled);
@@ -134,7 +162,7 @@ public class UserManageController {
@Operation(summary = "启用用户")
@RequestMapping(path = "/enable/{id}", method = RequestMethod.POST)
public ReqResult<Void> enableUserById(@PathVariable Long id) {
checkAdmin();
checkCurrentTenantUser(id);
UserDto userUpdate = new UserDto();
userUpdate.setId(id);
userUpdate.setStatus(User.Status.Enabled);
@@ -146,7 +174,7 @@ public class UserManageController {
@Operation(summary = "删除用户")
@RequestMapping(path = "/delete/{id}", method = RequestMethod.POST)
public ReqResult<Void> deleteUserById(@PathVariable Long id) {
checkAdmin();
checkCurrentTenantUser(id);
userApplicationService.logicDelete(id);
return ReqResult.success();
}
@@ -165,12 +193,69 @@ public class UserManageController {
return ReqResult.success();
}
/**
* 当前简单的在商家范围支持普通用户和管理员两种角色;后续再迭代完整的权限角色
*/
private void checkAdmin() {
if (((UserDto) RequestContext.get().getUser()).getRole() != User.Role.Admin) {
private void checkCurrentTenantUser(Long userId) {
User user = userId == null ? null : userService.getById(userId);
if (user == null || !isCurrentTenantUser(user) || (!isPlatformAdmin() && !visibleUserIds().contains(userId))) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
}
}
}
private void applyUserManageScope(PageQueryVo<UserQueryDto> pageQueryVo) {
if (isPlatformAdmin()) {
return;
}
if (pageQueryVo.getQueryFilter() == null) {
pageQueryVo.setQueryFilter(new UserQueryDto());
}
pageQueryVo.getQueryFilter().setUserIds(visibleUserIds().stream().toList());
}
private Set<Long> visibleUserIds() {
return currentUserGroupIds().stream()
.flatMap(groupId -> sysGroupApplicationService.getUserListByGroupId(groupId).stream())
.map(User::getId)
.collect(Collectors.toSet());
}
private Set<Long> currentUserGroupIds() {
return sysGroupApplicationService.getGroupListByUserId(getCurrentUserDto().getId()).stream()
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
.map(SysGroup::getId)
.collect(Collectors.toSet());
}
private boolean isPlatformAdmin() {
UserDto currentUser = getCurrentUserDto();
return currentUser.getRole() == User.Role.Admin
&& (currentUserGroupIds().isEmpty()
|| sysRoleApplicationService.getRoleListByUserId(currentUser.getId()).stream()
.anyMatch(role -> RoleEnum.SUPER_ADMIN.getCode().equals(role.getCode())));
}
private boolean isCurrentTenantUser(User user) {
return user != null && (getCurrentUserDto().getTenantId() == null || getCurrentUserDto().getTenantId().equals(user.getTenantId()));
}
private UserDto getCurrentUserDto() {
UserDto userDto = (UserDto) RequestContext.get().getUser();
if (userDto == null) {
throw BizException.of(ErrorCodeEnum.UNAUTHORIZED, BizExceptionCodeEnum.systemUserNotLoggedInWeb);
}
return userDto;
}
private UserContext getCurrentUserContext() {
UserDto userDto = getCurrentUserDto();
return UserContext.builder()
.userId(userDto.getId())
.userName(userDto.getUserName())
.nickName(userDto.getNickName())
.avatar(userDto.getAvatar())
.email(userDto.getEmail())
.phone(userDto.getPhone())
.status(userDto.getStatus() == User.Status.Enabled ? 1 : -1)
.tenantId(userDto.getTenantId())
.roleType(userDto.getRole() == User.Role.Admin ? 1 : 2)
.build();
}
}

View File

@@ -8,6 +8,7 @@ import com.xspaceagi.system.application.converter.SysDataPermissionConverter;
import com.xspaceagi.system.application.dto.permission.*;
import com.xspaceagi.system.application.service.SysDataPermissionApplicationService;
import com.xspaceagi.system.application.service.SysGroupApplicationService;
import com.xspaceagi.system.application.service.SysRoleApplicationService;
import com.xspaceagi.system.application.service.SysSubjectPermissionApplicationService;
import com.xspaceagi.system.application.dto.UserDto;
import com.xspaceagi.system.domain.model.GroupBindMenuModel;
@@ -52,6 +53,8 @@ public class SysGroupController extends BaseController {
@Resource
private SysGroupApplicationService sysGroupApplicationService;
@Resource
private SysRoleApplicationService sysRoleApplicationService;
@Resource
private SysDataPermissionApplicationService sysDataPermissionApplicationService;
@Resource
private SysSubjectPermissionApplicationService sysSubjectPermissionApplicationService;
@@ -344,8 +347,8 @@ public class SysGroupController extends BaseController {
return ReqResult.error("参数不能为空");
}
checkGroupManageScope(dto.getGroupId());
// 用户组不允许绑定 生态市场/系统管理
checkForbiddenMenuCodes(dto.getMenuTree());
// 用户组不允许绑定生态市场系统管理整棵子树。
checkForbiddenMenuCodes(dto.getMenuTree(), false);
GroupBindMenuModel model = GroupBindMenuModelConverter.convertToModel(dto);
sysGroupApplicationService.bindMenu(model, getUser());
@@ -370,29 +373,34 @@ public class SysGroupController extends BaseController {
return ReqResult.success(menuDtoList);
}
private void checkForbiddenMenuCodes(List<MenuNodeDto> nodes) {
private void checkForbiddenMenuCodes(List<MenuNodeDto> nodes, boolean underForbiddenMenu) {
if (CollectionUtils.isEmpty(nodes)) {
return;
}
for (MenuNodeDto node : nodes) {
boolean currentForbidden = underForbiddenMenu;
if (StringUtils.isNotBlank(node.getCode())) {
if (node.getCode().equals(MenuEnum.ECO_MARKET.getCode()) && node.getMenuBindType() > 0) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemGroupCannotBindForbiddenMenu,
MenuEnum.ECO_MARKET.getName());
}
if (node.getCode().equals(MenuEnum.SYSTEM_MANAGE.getCode()) && node.getMenuBindType() > 0) {
currentForbidden = currentForbidden || node.getCode().equals(MenuEnum.SYSTEM_MANAGE.getCode());
if (currentForbidden && node.getMenuBindType() > 0) {
throw BizException.of(ErrorCodeEnum.INVALID_PARAM, BizExceptionCodeEnum.systemGroupCannotBindForbiddenMenu,
MenuEnum.SYSTEM_MANAGE.getName());
}
}
if (CollectionUtils.isNotEmpty(node.getChildren())) {
checkForbiddenMenuCodes(node.getChildren());
checkForbiddenMenuCodes(node.getChildren(), currentForbidden);
}
}
}
private List<SysGroup> filterGroupManageScope(List<SysGroup> groupList) {
if (isPlatformAdmin() || CollectionUtils.isEmpty(groupList)) {
if (CollectionUtils.isEmpty(groupList)) {
return groupList;
}
if (isPlatformAdmin()) {
return groupList;
}
Set<Long> allowedGroupIds = currentUserGroupIds();
@@ -411,13 +419,18 @@ public class SysGroupController extends BaseController {
}
private Set<Long> currentUserGroupIds() {
return sysGroupApplicationService.getEffectiveGroupListByUserId(getCurrentUserDto().getId()).stream()
return sysGroupApplicationService.getGroupListByUserId(getCurrentUserDto().getId()).stream()
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
.map(SysGroup::getId)
.collect(Collectors.toSet());
}
private boolean isPlatformAdmin() {
return getCurrentUserDto().getRole() == User.Role.Admin;
UserDto currentUser = getCurrentUserDto();
return currentUser.getRole() == User.Role.Admin
&& (currentUserGroupIds().isEmpty()
|| sysRoleApplicationService.getRoleListByUserId(currentUser.getId()).stream()
.anyMatch(role -> RoleEnum.SUPER_ADMIN.getCode().equals(role.getCode())));
}
private UserDto getCurrentUserDto() {

View File

@@ -5,6 +5,7 @@ import com.xspaceagi.system.application.dto.permission.*;
import com.xspaceagi.system.application.service.SysDataPermissionApplicationService;
import com.xspaceagi.system.application.service.SysGroupApplicationService;
import com.xspaceagi.system.application.service.SysRoleApplicationService;
import com.xspaceagi.system.application.service.UserApplicationService;
import com.xspaceagi.system.application.service.impl.SysUserPermissionCacheServiceImpl;
import com.xspaceagi.system.infra.dao.entity.SysGroup;
import com.xspaceagi.system.infra.dao.entity.SysRole;
@@ -15,6 +16,8 @@ import com.xspaceagi.system.spec.annotation.SaasAdmin;
import com.xspaceagi.system.spec.common.RequestContext;
import com.xspaceagi.system.spec.dto.ReqResult;
import com.xspaceagi.system.spec.enums.ErrorCodeEnum;
import com.xspaceagi.system.spec.enums.GroupEnum;
import com.xspaceagi.system.spec.enums.RoleEnum;
import com.xspaceagi.system.spec.exception.BizException;
import com.xspaceagi.system.spec.exception.BizExceptionCodeEnum;
import com.xspaceagi.system.spec.utils.I18nUtil;
@@ -28,6 +31,7 @@ import org.springframework.beans.BeanUtils;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
@@ -47,6 +51,8 @@ public class SysUserController extends BaseController {
private SysDataPermissionApplicationService sysDataPermissionApplicationService;
@Resource
private SysUserPermissionCacheServiceImpl sysUserPermissionCacheService;
@Resource
private UserApplicationService userApplicationService;
@RequireResource(USER_MANAGE_BIND_ROLE)
@Operation(summary = "查询用户绑定的角色列表")
@@ -56,6 +62,7 @@ public class SysUserController extends BaseController {
return ReqResult.error("参数不能为空");
}
checkUserManageScope(userId);
checkRoleAuthorizationTarget(userId);
List<SysRole> roleList = sysRoleApplicationService.getRoleListByUserId(userId);
if (CollectionUtils.isEmpty(roleList)) {
@@ -79,6 +86,7 @@ public class SysUserController extends BaseController {
return ReqResult.error("参数不能为空");
}
checkUserManageScope(dto.getUserId());
checkRoleAuthorizationTarget(dto.getUserId());
checkRoleBindScope(dto.getRoleIds());
sysRoleApplicationService.userBindRole(dto.getUserId(), dto.getRoleIds(), getUser());
return ReqResult.success();
@@ -146,11 +154,16 @@ public class SysUserController extends BaseController {
}
private void checkUserManageScope(Long userId) {
UserDto targetUser = userApplicationService.queryById(userId);
if (targetUser == null || !Objects.equals(targetUser.getTenantId(), RequestContext.get().getTenantId())) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
}
if (isPlatformAdmin()) {
return;
}
Set<Long> allowedGroupIds = currentUserGroupIds();
boolean inScope = sysGroupApplicationService.getEffectiveGroupListByUserId(userId).stream()
boolean inScope = sysGroupApplicationService.getGroupListByUserId(userId).stream()
.map(SysGroup::getId)
.anyMatch(allowedGroupIds::contains);
if (!inScope) {
@@ -159,7 +172,10 @@ public class SysUserController extends BaseController {
}
private void checkGroupBindScope(List<Long> groupIds) {
if (isPlatformAdmin() || CollectionUtils.isEmpty(groupIds)) {
if (CollectionUtils.isEmpty(groupIds)) {
return;
}
if (isPlatformAdmin()) {
return;
}
Set<Long> allowedGroupIds = currentUserGroupIds();
@@ -170,7 +186,10 @@ public class SysUserController extends BaseController {
}
private void checkRoleBindScope(List<Long> roleIds) {
if (isPlatformAdmin() || CollectionUtils.isEmpty(roleIds)) {
if (CollectionUtils.isEmpty(roleIds)) {
return;
}
if (isPlatformAdmin()) {
return;
}
Set<Long> allowedRoleIds = sysRoleApplicationService.getRoleListByUserId(getCurrentUserDto().getId()).stream()
@@ -182,14 +201,26 @@ public class SysUserController extends BaseController {
}
}
private void checkRoleAuthorizationTarget(Long userId) {
UserDto targetUser = userApplicationService.queryById(userId);
if (targetUser == null || targetUser.getRole() != User.Role.Admin) {
throw BizException.of(ErrorCodeEnum.PERMISSION_DENIED, BizExceptionCodeEnum.permissionDenied);
}
}
private Set<Long> currentUserGroupIds() {
return sysGroupApplicationService.getEffectiveGroupListByUserId(getCurrentUserDto().getId()).stream()
return sysGroupApplicationService.getGroupListByUserId(getCurrentUserDto().getId()).stream()
.filter(group -> !GroupEnum.DEFAULT_GROUP.getCode().equals(group.getCode()))
.map(SysGroup::getId)
.collect(Collectors.toSet());
}
private boolean isPlatformAdmin() {
return getCurrentUserDto().getRole() == User.Role.Admin;
UserDto currentUser = getCurrentUserDto();
return currentUser.getRole() == User.Role.Admin
&& (currentUserGroupIds().isEmpty()
|| sysRoleApplicationService.getRoleListByUserId(currentUser.getId()).stream()
.anyMatch(role -> RoleEnum.SUPER_ADMIN.getCode().equals(role.getCode())));
}
private UserDto getCurrentUserDto() {

View File

@@ -0,0 +1,74 @@
-- Add system online monitor menu and resource.
-- Idempotent for existing tenants: creates menu/resource rows, binds menu to resource,
-- and grants super_admin default access. User-group admins can be granted this menu
-- from the database menu permission management UI.
INSERT INTO sys_resource (`parent_id`, `code`, `name`, `description`, `source`, `type`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT 0, 'online_monitor', '在线监控模块', '在线监控模块', 1, 1, 17, 1, t._tenant_id, 0, 'system', 1
FROM (SELECT DISTINCT `_tenant_id` FROM sys_resource) t
WHERE NOT EXISTS (
SELECT 1 FROM sys_resource r
WHERE r.`_tenant_id` = t.`_tenant_id` AND r.`code` = 'online_monitor' AND r.`yn` = 1
);
INSERT INTO sys_resource (`parent_id`, `code`, `name`, `description`, `source`, `type`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT parent.id, 'online_monitor_query', '查询', '查询在线监控', 1, 2, 1, 1, parent.`_tenant_id`, 0, 'system', 1
FROM sys_resource parent
WHERE parent.`code` = 'online_monitor' AND parent.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_resource r
WHERE r.`_tenant_id` = parent.`_tenant_id` AND r.`code` = 'online_monitor_query' AND r.`yn` = 1
);
INSERT INTO sys_menu (`parent_id`, `code`, `name`, `description`, `source`, `path`, `open_type`, `icon`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT parent.id, 'online_monitor', '在线监控', '在线监控', 1, '/system/online-monitor', 1, '', 11, 1, parent.`_tenant_id`, 0, 'system', 1
FROM sys_menu parent
WHERE parent.`code` = 'system_manage' AND parent.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_menu m
WHERE m.`_tenant_id` = parent.`_tenant_id` AND m.`code` = 'online_monitor' AND m.`yn` = 1
);
INSERT INTO sys_menu_resource (`menu_id`, `resource_id`, `resource_bind_type`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT m.id, r.id, 1, m.`_tenant_id`, 0, 'system', 1
FROM sys_menu m
JOIN sys_resource r ON r.`_tenant_id` = m.`_tenant_id` AND r.`code` = 'online_monitor' AND r.`yn` = 1
WHERE m.`code` = 'online_monitor' AND m.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_menu_resource mr
WHERE mr.`_tenant_id` = m.`_tenant_id` AND mr.`menu_id` = m.id AND mr.`resource_id` = r.id AND mr.`yn` = 1
);
UPDATE sys_menu_resource mr
JOIN sys_menu m ON m.id = mr.menu_id AND m.`_tenant_id` = mr.`_tenant_id` AND m.`code` = 'online_monitor' AND m.`yn` = 1
JOIN sys_resource r ON r.id = mr.resource_id AND r.`_tenant_id` = mr.`_tenant_id` AND r.`code` = 'online_monitor' AND r.`yn` = 1
SET mr.`resource_bind_type` = 1,
mr.`modified` = CURRENT_TIMESTAMP
WHERE mr.`yn` = 1;
INSERT INTO sys_role_menu (`role_id`, `menu_id`, `menu_bind_type`, `resource_tree_json`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT role.id,
menu.id,
1,
JSON_ARRAY(JSON_OBJECT('id', resource.id, 'code', resource.`code`, 'resourceBindType', 1)),
menu.`_tenant_id`,
0,
'system',
1
FROM sys_role role
JOIN sys_menu menu ON menu.`_tenant_id` = role.`_tenant_id` AND menu.`code` = 'online_monitor' AND menu.`yn` = 1
JOIN sys_resource resource ON resource.`_tenant_id` = role.`_tenant_id` AND resource.`code` = 'online_monitor' AND resource.`yn` = 1
WHERE role.`code` = 'super_admin' AND role.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm
WHERE rm.`_tenant_id` = role.`_tenant_id` AND rm.`role_id` = role.id AND rm.`menu_id` = menu.id AND rm.`yn` = 1
);
UPDATE sys_role_menu rm
JOIN sys_role role ON role.id = rm.role_id AND role.`_tenant_id` = rm.`_tenant_id` AND role.`code` = 'super_admin' AND role.`yn` = 1
JOIN sys_menu menu ON menu.id = rm.menu_id AND menu.`_tenant_id` = rm.`_tenant_id` AND menu.`code` = 'online_monitor' AND menu.`yn` = 1
JOIN sys_resource resource ON resource.`_tenant_id` = rm.`_tenant_id` AND resource.`code` = 'online_monitor' AND resource.`yn` = 1
SET rm.`menu_bind_type` = 1,
rm.`resource_tree_json` = JSON_ARRAY(JSON_OBJECT('id', resource.id, 'code', resource.`code`, 'resourceBindType', 1)),
rm.`modified` = CURRENT_TIMESTAMP
WHERE rm.`yn` = 1;

View File

@@ -0,0 +1,24 @@
CREATE TABLE `digital_employee_sync_record`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`_tenant_id` BIGINT DEFAULT NULL COMMENT '租户ID',
`user_id` BIGINT DEFAULT NULL COMMENT '用户ID',
`client_key` VARCHAR(128) NOT NULL COMMENT '客户端配置Key',
`device_id` VARCHAR(128) DEFAULT NULL COMMENT '设备ID',
`outbox_id` VARCHAR(255) NOT NULL COMMENT '客户端outbox ID',
`entity_type` VARCHAR(32) NOT NULL COMMENT '实体类型plan/task/run/event/artifact/approval',
`entity_id` VARCHAR(255) NOT NULL COMMENT '客户端实体ID',
`operation` VARCHAR(32) NOT NULL COMMENT '操作类型',
`payload` JSON DEFAULT NULL COMMENT '实体快照',
`synced_at` DATETIME NOT NULL COMMENT '最近同步时间',
`modified` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
`created` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_client_outbox` (`client_key`, `outbox_id`),
KEY `idx_client_entity` (`client_key`, `entity_type`, `entity_id`),
KEY `idx_tenant_client_entity_time` (`_tenant_id`, `client_key`, `entity_type`, `entity_id`, `synced_at`),
KEY `idx_tenant_user_time` (`_tenant_id`, `user_id`, `synced_at`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COLLATE = utf8mb4_unicode_ci
COMMENT = '数字员工客户端同步记录';

View File

@@ -0,0 +1,75 @@
-- Add digital employee sync records menu and resource.
-- Idempotent for existing tenants: creates menu/resource rows, binds menu to resource,
-- and grants super_admin default access. Other roles can be granted this menu
-- from the menu permission management UI.
INSERT INTO sys_resource (`parent_id`, `code`, `name`, `description`, `source`, `type`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT parent.id, 'content_digital_employee_sync', '数字员工同步记录', '数字员工同步记录', 1, 1, 90, 1, parent.`_tenant_id`, 0, 'system', 1
FROM sys_resource parent
WHERE parent.`code` = 'content_manage' AND parent.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_resource r
WHERE r.`_tenant_id` = parent.`_tenant_id` AND r.`code` = 'content_digital_employee_sync' AND r.`yn` = 1
);
INSERT INTO sys_resource (`parent_id`, `code`, `name`, `description`, `source`, `type`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT parent.id, 'content_digital_employee_sync_query_list', '查询列表', '查询数字员工同步记录', 1, 2, 1, 1, parent.`_tenant_id`, 0, 'system', 1
FROM sys_resource parent
WHERE parent.`code` = 'content_digital_employee_sync' AND parent.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_resource r
WHERE r.`_tenant_id` = parent.`_tenant_id` AND r.`code` = 'content_digital_employee_sync_query_list' AND r.`yn` = 1
);
INSERT INTO sys_menu (`parent_id`, `code`, `name`, `description`, `source`, `path`, `open_type`, `icon`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT parent.id, 'content_digital_employee_sync', '数字员工同步记录', '数字员工同步记录', 1, '/system/content/content-digital-employee-sync', 1, '', 90, 1, parent.`_tenant_id`, 0, 'system', 1
FROM sys_menu parent
WHERE parent.`code` = 'content_manage' AND parent.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_menu m
WHERE m.`_tenant_id` = parent.`_tenant_id` AND m.`code` = 'content_digital_employee_sync' AND m.`yn` = 1
);
INSERT INTO sys_menu_resource (`menu_id`, `resource_id`, `resource_bind_type`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT m.id, r.id, 1, m.`_tenant_id`, 0, 'system', 1
FROM sys_menu m
JOIN sys_resource r ON r.`_tenant_id` = m.`_tenant_id` AND r.`code` = 'content_digital_employee_sync' AND r.`yn` = 1
WHERE m.`code` = 'content_digital_employee_sync' AND m.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_menu_resource mr
WHERE mr.`_tenant_id` = m.`_tenant_id` AND mr.`menu_id` = m.id AND mr.`resource_id` = r.id AND mr.`yn` = 1
);
UPDATE sys_menu_resource mr
JOIN sys_menu m ON m.id = mr.menu_id AND m.`_tenant_id` = mr.`_tenant_id` AND m.`code` = 'content_digital_employee_sync' AND m.`yn` = 1
JOIN sys_resource r ON r.id = mr.resource_id AND r.`_tenant_id` = mr.`_tenant_id` AND r.`code` = 'content_digital_employee_sync' AND r.`yn` = 1
SET mr.`resource_bind_type` = 1,
mr.`modified` = CURRENT_TIMESTAMP
WHERE mr.`yn` = 1;
INSERT INTO sys_role_menu (`role_id`, `menu_id`, `menu_bind_type`, `resource_tree_json`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT role.id,
menu.id,
1,
JSON_ARRAY(JSON_OBJECT('id', resource.id, 'code', resource.`code`, 'resourceBindType', 1)),
menu.`_tenant_id`,
0,
'system',
1
FROM sys_role role
JOIN sys_menu menu ON menu.`_tenant_id` = role.`_tenant_id` AND menu.`code` = 'content_digital_employee_sync' AND menu.`yn` = 1
JOIN sys_resource resource ON resource.`_tenant_id` = role.`_tenant_id` AND resource.`code` = 'content_digital_employee_sync' AND resource.`yn` = 1
WHERE role.`code` = 'super_admin' AND role.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm
WHERE rm.`_tenant_id` = role.`_tenant_id` AND rm.`role_id` = role.id AND rm.`menu_id` = menu.id AND rm.`yn` = 1
);
UPDATE sys_role_menu rm
JOIN sys_role role ON role.id = rm.role_id AND role.`_tenant_id` = rm.`_tenant_id` AND role.`code` = 'super_admin' AND role.`yn` = 1
JOIN sys_menu menu ON menu.id = rm.menu_id AND menu.`_tenant_id` = rm.`_tenant_id` AND menu.`code` = 'content_digital_employee_sync' AND menu.`yn` = 1
JOIN sys_resource resource ON resource.`_tenant_id` = rm.`_tenant_id` AND resource.`code` = 'content_digital_employee_sync' AND resource.`yn` = 1
SET rm.`menu_bind_type` = 1,
rm.`resource_tree_json` = JSON_ARRAY(JSON_OBJECT('id', resource.id, 'code', resource.`code`, 'resourceBindType', 1)),
rm.`modified` = CURRENT_TIMESTAMP
WHERE rm.`yn` = 1;

View File

@@ -0,0 +1,193 @@
ALTER TABLE `digital_employee_sync_record`
ADD COLUMN `contract_version` VARCHAR(128) DEFAULT NULL COMMENT '客户端同步契约版本' AFTER `operation`,
ADD COLUMN `entity_summary` JSON DEFAULT NULL COMMENT '客户端实体摘要' AFTER `contract_version`,
ADD COLUMN `business_view` JSON DEFAULT NULL COMMENT '客户端业务视图投影' AFTER `entity_summary`;
CREATE TABLE IF NOT EXISTS `digital_employee_admin_action` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`_tenant_id` BIGINT DEFAULT NULL COMMENT '租户 ID',
`user_id` BIGINT DEFAULT NULL COMMENT '创建用户 ID',
`client_key` VARCHAR(128) DEFAULT NULL COMMENT '客户端 Key',
`device_id` VARCHAR(128) DEFAULT NULL COMMENT '设备 ID',
`entity_type` VARCHAR(64) DEFAULT NULL COMMENT '实体类型',
`entity_id` VARCHAR(128) DEFAULT NULL COMMENT '实体 ID',
`action` VARCHAR(64) DEFAULT NULL COMMENT '管理端动作',
`status` VARCHAR(32) DEFAULT NULL COMMENT '状态 pending/applied/rejected/failed',
`reason_codes` JSON DEFAULT NULL COMMENT '原因码',
`request_payload` JSON DEFAULT NULL COMMENT '请求附加参数',
`processed_at` DATETIME DEFAULT NULL COMMENT '客户端处理时间',
`modified` DATETIME DEFAULT NULL COMMENT '更新时间',
`created` DATETIME DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_de_admin_action_pending` (`_tenant_id`, `client_key`, `device_id`, `status`, `created`),
KEY `idx_de_admin_action_entity` (`_tenant_id`, `entity_type`, `entity_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工管理端远程动作意图';
CREATE TABLE IF NOT EXISTS `digital_employee_business_fact` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`_tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`user_id` BIGINT DEFAULT NULL COMMENT '用户ID',
`sync_record_id` BIGINT NOT NULL COMMENT '同步记录ID',
`client_key` VARCHAR(255) DEFAULT NULL COMMENT '客户端Key',
`device_id` VARCHAR(255) DEFAULT NULL COMMENT '设备ID',
`outbox_id` VARCHAR(255) DEFAULT NULL COMMENT '客户端outbox ID',
`entity_type` VARCHAR(64) NOT NULL COMMENT '实体类型',
`entity_id` VARCHAR(255) NOT NULL COMMENT '实体ID',
`operation` VARCHAR(64) DEFAULT NULL COMMENT '操作',
`title` VARCHAR(512) DEFAULT NULL COMMENT '标题',
`status` VARCHAR(128) DEFAULT NULL COMMENT '业务状态',
`summary` VARCHAR(1024) DEFAULT NULL COMMENT '摘要',
`plan_id` VARCHAR(255) DEFAULT NULL COMMENT '计划ID',
`task_id` VARCHAR(255) DEFAULT NULL COMMENT '任务ID',
`run_id` VARCHAR(255) DEFAULT NULL COMMENT '运行ID',
`kind` VARCHAR(255) DEFAULT NULL COMMENT '类型',
`category` VARCHAR(128) DEFAULT NULL COMMENT '分类',
`occurred_at` VARCHAR(64) DEFAULT NULL COMMENT '发生时间',
`started_at` VARCHAR(64) DEFAULT NULL COMMENT '开始时间',
`finished_at` VARCHAR(64) DEFAULT NULL COMMENT '结束时间',
`duration_ms` BIGINT DEFAULT NULL COMMENT '耗时毫秒',
`sync_status` VARCHAR(128) DEFAULT NULL COMMENT '同步状态',
`business_view` JSON DEFAULT NULL COMMENT '业务视图快照',
`payload` JSON DEFAULT NULL COMMENT '安全payload快照',
`synced_at` DATETIME DEFAULT NULL COMMENT '同步时间',
`modified` DATETIME NOT NULL COMMENT '修改时间',
`created` DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_de_business_fact_record` (`_tenant_id`, `sync_record_id`),
KEY `idx_de_business_fact_entity` (`_tenant_id`, `entity_type`, `entity_id`),
KEY `idx_de_business_fact_plan` (`_tenant_id`, `plan_id`, `entity_type`),
KEY `idx_de_business_fact_client` (`_tenant_id`, `client_key`, `device_id`, `synced_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工业务事实物化表';
CREATE TABLE IF NOT EXISTS `digital_employee_artifact_lifecycle` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`_tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`sync_record_id` BIGINT NOT NULL COMMENT '同步记录ID',
`client_key` VARCHAR(255) DEFAULT NULL COMMENT '客户端Key',
`device_id` VARCHAR(255) DEFAULT NULL COMMENT '设备ID',
`artifact_id` VARCHAR(255) NOT NULL COMMENT '产物ID',
`plan_id` VARCHAR(255) DEFAULT NULL COMMENT '计划ID',
`task_id` VARCHAR(255) DEFAULT NULL COMMENT '任务ID',
`run_id` VARCHAR(255) DEFAULT NULL COMMENT '运行ID',
`status` VARCHAR(128) DEFAULT NULL COMMENT '状态',
`retention_status` VARCHAR(128) DEFAULT NULL COMMENT '保留状态',
`cleanup_status` VARCHAR(128) DEFAULT NULL COMMENT '清理状态',
`deleted_at` VARCHAR(64) DEFAULT NULL COMMENT '删除时间',
`version_key` VARCHAR(255) DEFAULT NULL COMMENT '版本Key',
`source` VARCHAR(255) DEFAULT NULL COMMENT '来源',
`business_view` JSON DEFAULT NULL COMMENT '业务视图快照',
`synced_at` DATETIME DEFAULT NULL COMMENT '同步时间',
`modified` DATETIME NOT NULL COMMENT '修改时间',
`created` DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_de_artifact_lifecycle_record` (`_tenant_id`, `sync_record_id`),
KEY `idx_de_artifact_lifecycle_artifact` (`_tenant_id`, `artifact_id`),
KEY `idx_de_artifact_lifecycle_plan` (`_tenant_id`, `plan_id`, `task_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工产物生命周期物化表';
CREATE TABLE IF NOT EXISTS `digital_employee_daily_report_fact` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`_tenant_id` BIGINT NOT NULL COMMENT '租户ID',
`sync_record_id` BIGINT NOT NULL COMMENT '同步记录ID',
`client_key` VARCHAR(255) DEFAULT NULL COMMENT '客户端Key',
`device_id` VARCHAR(255) DEFAULT NULL COMMENT '设备ID',
`report_id` VARCHAR(255) NOT NULL COMMENT '日报ID',
`daily_report_fact_id` VARCHAR(255) DEFAULT NULL COMMENT '日报事实ID',
`plan_id` VARCHAR(255) DEFAULT NULL COMMENT '计划ID',
`status` VARCHAR(128) DEFAULT NULL COMMENT '状态',
`physical_fact_status` VARCHAR(128) DEFAULT NULL COMMENT '物理事实状态',
`delivery_status` VARCHAR(128) DEFAULT NULL COMMENT '交付状态',
`approval_status` VARCHAR(128) DEFAULT NULL COMMENT '审批状态',
`approval_decision_status` VARCHAR(128) DEFAULT NULL COMMENT '审批裁决状态',
`confirmed_at` VARCHAR(64) DEFAULT NULL COMMENT '确认时间',
`report_date` VARCHAR(64) DEFAULT NULL COMMENT '日报日期',
`send_record_id` VARCHAR(255) DEFAULT NULL COMMENT '发送记录ID',
`send_channel` VARCHAR(128) DEFAULT NULL COMMENT '发送渠道',
`send_request_status` VARCHAR(128) DEFAULT NULL COMMENT '发送请求状态',
`send_record_status` VARCHAR(128) DEFAULT NULL COMMENT '发送记录状态',
`materialized_source` VARCHAR(128) DEFAULT NULL COMMENT '物化来源',
`business_view` JSON DEFAULT NULL COMMENT '业务视图快照',
`synced_at` DATETIME DEFAULT NULL COMMENT '同步时间',
`modified` DATETIME NOT NULL COMMENT '修改时间',
`created` DATETIME NOT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_de_daily_report_fact_record` (`_tenant_id`, `sync_record_id`),
KEY `idx_de_daily_report_fact_report` (`_tenant_id`, `report_id`),
KEY `idx_de_daily_report_fact_client` (`_tenant_id`, `client_key`, `device_id`, `synced_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='数字员工日报事实物化表';
-- Add digital employee admin operations workbench menu and resource.
-- Idempotent for existing tenants: creates menu/resource rows, binds menu to resource,
-- and grants super_admin default access. Other roles can be granted this menu
-- from the menu permission management UI.
INSERT INTO sys_resource (`parent_id`, `code`, `name`, `description`, `source`, `type`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT parent.id, 'content_digital_employee_ops', '数字员工运营台', '数字员工运营台', 1, 1, 91, 1, parent.`_tenant_id`, 0, 'system', 1
FROM sys_resource parent
WHERE parent.`code` = 'content_manage' AND parent.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_resource r
WHERE r.`_tenant_id` = parent.`_tenant_id` AND r.`code` = 'content_digital_employee_ops' AND r.`yn` = 1
);
INSERT INTO sys_resource (`parent_id`, `code`, `name`, `description`, `source`, `type`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT parent.id, 'content_digital_employee_ops_query_list', '查询列表', '查询数字员工运营台', 1, 2, 1, 1, parent.`_tenant_id`, 0, 'system', 1
FROM sys_resource parent
WHERE parent.`code` = 'content_digital_employee_ops' AND parent.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_resource r
WHERE r.`_tenant_id` = parent.`_tenant_id` AND r.`code` = 'content_digital_employee_ops_query_list' AND r.`yn` = 1
);
INSERT INTO sys_menu (`parent_id`, `code`, `name`, `description`, `source`, `path`, `open_type`, `icon`, `sort_index`, `status`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT parent.id, 'content_digital_employee_ops', '数字员工运营台', '数字员工运营台', 1, '/system/content/content-digital-employee-ops', 1, '', 91, 1, parent.`_tenant_id`, 0, 'system', 1
FROM sys_menu parent
WHERE parent.`code` = 'content_manage' AND parent.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_menu m
WHERE m.`_tenant_id` = parent.`_tenant_id` AND m.`code` = 'content_digital_employee_ops' AND m.`yn` = 1
);
INSERT INTO sys_menu_resource (`menu_id`, `resource_id`, `resource_bind_type`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT m.id, r.id, 1, m.`_tenant_id`, 0, 'system', 1
FROM sys_menu m
JOIN sys_resource r ON r.`_tenant_id` = m.`_tenant_id` AND r.`code` = 'content_digital_employee_ops' AND r.`yn` = 1
WHERE m.`code` = 'content_digital_employee_ops' AND m.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_menu_resource mr
WHERE mr.`_tenant_id` = m.`_tenant_id` AND mr.`menu_id` = m.id AND mr.`resource_id` = r.id AND mr.`yn` = 1
);
UPDATE sys_menu_resource mr
JOIN sys_menu m ON m.id = mr.menu_id AND m.`_tenant_id` = mr.`_tenant_id` AND m.`code` = 'content_digital_employee_ops' AND m.`yn` = 1
JOIN sys_resource r ON r.id = mr.resource_id AND r.`_tenant_id` = mr.`_tenant_id` AND r.`code` = 'content_digital_employee_ops' AND r.`yn` = 1
SET mr.`resource_bind_type` = 1,
mr.`modified` = CURRENT_TIMESTAMP
WHERE mr.`yn` = 1;
INSERT INTO sys_role_menu (`role_id`, `menu_id`, `menu_bind_type`, `resource_tree_json`, `_tenant_id`, `creator_id`, `creator`, `yn`)
SELECT role.id,
menu.id,
1,
JSON_ARRAY(JSON_OBJECT('id', resource.id, 'code', resource.`code`, 'resourceBindType', 1)),
menu.`_tenant_id`,
0,
'system',
1
FROM sys_role role
JOIN sys_menu menu ON menu.`_tenant_id` = role.`_tenant_id` AND menu.`code` = 'content_digital_employee_ops' AND menu.`yn` = 1
JOIN sys_resource resource ON resource.`_tenant_id` = role.`_tenant_id` AND resource.`code` = 'content_digital_employee_ops' AND resource.`yn` = 1
WHERE role.`code` = 'super_admin' AND role.`yn` = 1
AND NOT EXISTS (
SELECT 1 FROM sys_role_menu rm
WHERE rm.`_tenant_id` = role.`_tenant_id` AND rm.`role_id` = role.id AND rm.`menu_id` = menu.id AND rm.`yn` = 1
);
UPDATE sys_role_menu rm
JOIN sys_role role ON role.id = rm.role_id AND role.`_tenant_id` = rm.`_tenant_id` AND role.`code` = 'super_admin' AND role.`yn` = 1
JOIN sys_menu menu ON menu.id = rm.menu_id AND menu.`_tenant_id` = rm.`_tenant_id` AND menu.`code` = 'content_digital_employee_ops' AND menu.`yn` = 1
JOIN sys_resource resource ON resource.`_tenant_id` = rm.`_tenant_id` AND resource.`code` = 'content_digital_employee_ops' AND resource.`yn` = 1
SET rm.`menu_bind_type` = 1,
rm.`resource_tree_json` = JSON_ARRAY(JSON_OBJECT('id', resource.id, 'code', resource.`code`, 'resourceBindType', 1)),
rm.`modified` = CURRENT_TIMESTAMP
WHERE rm.`yn` = 1;

View File

@@ -0,0 +1,276 @@
import { exec } from "child_process";
import path from "path";
import fs from "fs";
import { log, logBuild } from "../log/logUtils.js";
import BuildErrorParser from "../error/buildErrorParser.js";
import config from "../../appConfig/index.js";
import {
BusinessError,
SystemError,
FileError,
ResourceError,
} from "../error/errorHandler.js";
import { installDependencies } from "../buildDependency/dependencyManager.js";
import {
extractIsolationContext,
resolveProjectPath,
} from "../common/projectPathUtils.js";
const distTargetDir = config.DIST_TARGET_DIR;
// 构建并发控制(无队列)
const buildingProjects = new Set(); // 正在构建中的项目
let currentBuilds = 0; // 当前并行构建数
// 将dist目录拷贝到指定目录
async function copyBuildOutputToTarget({
req,
projectPath,
projectId,
outStream,
}) {
try {
const sourceDir = path.join(projectPath, "dist");
const targetBase = distTargetDir;
const targetDir = path.join(targetBase, projectId, "dist");
if (!fs.existsSync(sourceDir)) {
const msg = `dist directory not found: ${sourceDir}`;
log(projectId, "WARN", msg, { projectId });
outStream && outStream.write(`${msg}\n`);
return;
}
// 确保目标父目录存在
if (!fs.existsSync(targetBase)) {
fs.mkdirSync(targetBase, { recursive: true });
}
// 先清空旧目录
if (fs.existsSync(targetDir)) {
await fs.promises.rm(targetDir, { recursive: true, force: true });
}
// 复制 dist -> 目标目录
if (fs.promises.cp) {
await fs.promises.cp(sourceDir, targetDir, { recursive: true });
} else {
// Node 版本不支持 fs.promises.cp 时的降级方案
const copyRecursiveSync = (src, dest) => {
const stat = fs.statSync(src);
if (stat.isDirectory()) {
if (!fs.existsSync(dest)) {
fs.mkdirSync(dest, { recursive: true });
}
for (const entry of fs.readdirSync(src)) {
copyRecursiveSync(path.join(src, entry), path.join(dest, entry));
}
} else {
fs.copyFileSync(src, dest);
}
};
copyRecursiveSync(sourceDir, targetDir);
}
const okMsg = `dist directory copied to: ${targetDir}`;
log(projectId, "INFO", okMsg, { projectId });
outStream && outStream.write(`${okMsg}\n`);
} catch (err) {
const errMsg = `Failed to copy dist directory: ${err.message}`;
log(projectId, "ERROR", errMsg, { projectId });
outStream && outStream.write(`${errMsg}\n`);
throw err;
}
}
//执行build脚本
function runBuildScript(projectId, projectPath, scriptName, extraArgs = []) {
return new Promise((resolve, reject) => {
const argsPart =
Array.isArray(extraArgs) && extraArgs.length > 0
? " -- " + extraArgs.map((s) => String(s)).join(" ")
: "";
const command = `cd ${projectPath} && pnpm run ${scriptName}${argsPart}`;
logBuild(projectId, "INFO", "Execute command", { command });
// 同步输出到普通日志也打印一次,便于从统一日志流检索
try {
log(projectId, "INFO", "Execute build script", { command, cwd: projectPath });
} catch (_) {}
exec(
command,
{
env: process.env, // 继承父进程的环境变量,包括 pnpm 配置
maxBuffer: 10 * 1024 * 1024, // 10MB 缓冲区
},
(error, stdout, stderr) => {
if (error) {
logBuild(projectId, "ERROR", "Execution error", {
error: error.message,
stderr,
});
// 使用错误解析器提供用户友好的错误信息
const errorParser = new BuildErrorParser();
const errorMessage = stderr || error.message;
const userFriendlyMessage = errorParser.parseBuildError(
errorMessage,
projectId
);
// 创建包含用户友好信息的构建错误
const buildError = new SystemError(userFriendlyMessage, {
originalError: error.message,
command: command,
});
return reject(buildError);
}
logBuild(projectId, "INFO", "Script execution completed", { stdout });
resolve(stdout);
});
});
}
/**
* 构建项目
* @param {Object} req 请求对象
* @param {string} projectId 项目ID
* @returns {Promise<Object>} 构建结果
*/
async function buildProject(req, projectId) {
const isolationContext = extractIsolationContext(req?.query || {});
const projectPath = resolveProjectPath(projectId, isolationContext);
const jsonFilePath = path.join(projectPath, "package.json");
const exists = fs.existsSync(jsonFilePath);
if (!exists) {
throw new ResourceError("Project missing package.json file", {
projectId,
projectPath,
});
}
let jsonContent;
try {
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
} catch (error) {
throw new FileError("package.json file format error", {
projectId,
jsonFilePath,
originalError: error.message,
});
}
const jsonScripts = jsonContent.scripts;
const buildScript = jsonScripts.build;
if (!buildScript) {
log(projectId, "WARN", "Project missing build script", {
projectId,
requestId: req.requestId,
});
throw new BusinessError("Project missing build script", { projectId });
}
// 项目级互斥:同一项目仅允许一个构建
if (buildingProjects.has(projectId)) {
throw new BusinessError("This project is being built", { projectId });
}
// 全局并发限制
const max = Number.isFinite(config.MAX_BUILD_CONCURRENCY)
? config.MAX_BUILD_CONCURRENCY
: 20;
if (currentBuilds >= max) {
throw new BusinessError("Concurrency is full, please try again later", {
currentBuilds,
maxConcurrency: max,
});
}
// 直接执行同步构建(空闲)
buildingProjects.add(projectId);
currentBuilds += 1;
try {
// 读取并规范化 basePath仅对 Vite 有效)
let basePath = "";
if (req && req.query && typeof req.query.basePath === "string") {
basePath = req.query.basePath;
}
if (basePath) {
if (!basePath.startsWith("/")) basePath = "/" + basePath;
if (!basePath.endsWith("/")) basePath = basePath + "/";
}
const buildExtraArgs = [];
// 若脚本包含 vite则传入 --base
if (
typeof buildScript === "string" &&
buildScript.includes("vite") &&
basePath
) {
buildExtraArgs.push("--base", basePath);
}
// 若使用 Vite 构建,追加 --debug 以输出调试信息
if (typeof buildScript === "string" && buildScript.includes("vite")) {
buildExtraArgs.push("--debug");
}
// 安装依赖
log(projectId, "INFO", "Start installing dependencies", { projectId });
await installDependencies(req, projectId, projectPath);
// 执行构建Vite 直接使用 pnpm exec避免 npm-run 参数分隔影响
if (typeof buildScript === "string" && buildScript.includes("vite")) {
const viteArgs = ["exec", "vite", "build", ...buildExtraArgs, "--debug"];
const command = `cd ${projectPath} && pnpm ${viteArgs.join(" ")}`;
logBuild(projectId, "INFO", "Execute command(direct vite)", { command });
try {
log(projectId, "INFO", "Execute build script(direct vite)", {
command,
cwd: projectPath,
});
} catch (_) {}
await new Promise((resolve, reject) => {
exec(command, (error, stdout, stderr) => {
if (error) {
logBuild(projectId, "ERROR", "Execution error", {
error: error.message,
stderr,
});
const errorParser = new BuildErrorParser();
const errorMessage = stderr || error.message;
const userFriendlyMessage = errorParser.parseBuildError(
errorMessage,
projectId
);
const buildError = new SystemError(userFriendlyMessage, {
originalError: error.message,
command,
});
return reject(buildError);
}
logBuild(projectId, "INFO", "Script execution completed", { stdout });
resolve(stdout);
});
});
} else {
log(projectId, "INFO", "Start synchronously executing build script", { projectId });
await runBuildScript(projectId, projectPath, "build", buildExtraArgs);
}
// 拷贝 dist
await copyBuildOutputToTarget({ req, projectPath, projectId });
return {
success: true,
message: "Build completed",
projectId,
};
} finally {
buildingProjects.delete(projectId);
currentBuilds -= 1;
}
}
export { buildProject, copyBuildOutputToTarget, runBuildScript };

View File

@@ -0,0 +1,71 @@
import { isPortListening } from "../buildArg/portUtils.js";
import { log } from "../log/logUtils.js";
import { deleteRunningProcess, isProcessRunning } from "./processManager.js";
import { isProjectAlive } from "../buildJudge/aliveJudgeUtils.js";
import { restartDevServer } from "./restartDevUtils.js";
import { startDevServer } from "./startDevUtils.js";
import { stopDevServer } from "./stopDevUtils.js";
/**
* 保持开发服务器活跃
* @param {Object} req 请求对象
* @param {string} projectId 项目ID
* @param {number|string} pid 进程ID
* @param {number|string} port 端口号
* @returns {Promise<Object>} 检查结果
*/
async function keepAliveDevServer(req, projectId, pid, port, basePath) {
const pidNum = Number(pid);
const portNum = Number(port);
log(projectId, "INFO", "Start checking development server status", {
projectId,
pid: pidNum,
port: portNum,
requestId: req.requestId,
});
// 检查项目是否存活
const projectAlive = await isProjectAlive(projectId, portNum, basePath);
if (projectAlive) {
log(projectId, "INFO", "Development server is alive, returning success", {
projectId,
pid: pidNum,
port: portNum,
requestId: req.requestId,
});
return {
success: true,
message: "Development server is alive",
projectId,
pid: pidNum,
port: portNum,
};
}
log(projectId, "INFO", "Development server is not alive, restarting", {
projectId,
pid: pidNum,
port: portNum,
requestId: req.requestId,
});
deleteRunningProcess(projectId);
if(pidNum > 0) {
await stopDevServer(req, projectId, pidNum, {
strict: false,
waitForStop: true,
});
}
const result = await startDevServer(req, projectId);
return {
...result,
action: "start",
};
}
export { keepAliveDevServer };

View File

@@ -0,0 +1,920 @@
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import { log, getLogDir, getCSTDateString, getCSTTimestampString } from "../log/logUtils.js";
import logCacheManager from "../log/logCacheManager.js";
import { ProcessError, BusinessError, ValidationError } from "../error/errorHandler.js";
import ERROR_CODES from "../error/errorCodes.js";
import { sanitizeSensitivePaths } from "../common/sensitiveUtils.js";
import config from "../../appConfig/index.js";
import {
waitPortListening,
waitPortFromPid,
waitPortFromLog,
getPidsByPort,
} from "../buildArg/portUtils.js";
import ExtraArgsUtils from "../buildArg/extraArgsUtils.js";
import { ensureDevBinariesExecutable } from "../buildPermission/permissionManager.js";
import { installDependencies } from "../buildDependency/dependencyManager.js";
import portPool from "../buildArg/portPool.js";
import { isProjectAlive } from "../buildJudge/aliveJudgeUtils.js";
/**
* 从日志文件中提取执行命令后的所有行
* 支持多种构建工具和框架:
* - 构建工具: vite, webpack, rollup, parcel, esbuild, tsc, ts-node
* - 框架: next, nuxt, astro, svelte, remix
* - 包管理器: pnpm, npm, yarn, bun
* - 脚本执行: node, 自定义脚本
*
* @param {string} logPath 日志文件路径
* @param {string} projectId 项目ID
* @param {number} pid 进程ID
* @returns {string} 执行命令后的所有输出(已脱敏)
*/
function extractCommandOutputFromLog(logPath, projectId, pid) {
let commandOutput = "开发服务器启动失败";
try {
if (fs.existsSync(logPath)) {
const logContent = fs.readFileSync(logPath, "utf8");
const lines = logContent.split("\n");
// 查找执行命令的行,支持多种构建工具和框架
let commandStartIndex = -1;
// 第一轮:优先查找明确的命令执行行
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (
// 直接命令格式: > vite, > webpack, > next, > nuxt 等
line.match(
/^>\s*(vite|webpack|next|nuxt|rollup|parcel|esbuild|tsc|ts-node|astro|svelte|remix)/
) ||
// 包管理器运行命令: > pnpm run dev, > npm run start 等
line.match(
/^>\s*(pnpm|npm|yarn|bun)\s+(run\s+)?(dev|start|serve|build|watch|start:dev)/
) ||
// 直接执行脚本: > node server.js, > node index.js 等
line.match(/^>\s*node\s+\S+\.(js|ts|mjs)$/) ||
// 自定义脚本执行标记
line.includes("开始执行脚本: dev") ||
line.includes("开始执行脚本: start") ||
line.includes("开始执行脚本: serve") ||
line.includes("开始执行脚本: build")
) {
commandStartIndex = i;
break;
}
}
// 第二轮:如果没有找到命令行,查找构建工具输出标记
if (commandStartIndex === -1) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (
// 构建工具特定输出标记
line.includes("VITE") ||
line.includes("Webpack") ||
line.includes("Next.js") ||
line.includes("Nuxt") ||
line.includes("Rollup") ||
line.includes("Parcel") ||
line.includes("Astro") ||
line.includes("Svelte") ||
line.includes("Remix") ||
// 开发服务器启动标记
line.includes("Local:") ||
line.includes("Network:") ||
line.includes("ready in") ||
line.includes("compiled successfully") ||
line.includes("dev server running") ||
line.includes("server started")
) {
commandStartIndex = i;
break;
}
}
}
// 第三轮:如果仍然没有找到,查找错误输出标记
if (commandStartIndex === -1) {
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (
line.includes("Error") ||
line.includes("error") ||
line.includes("failed") ||
line.includes("Cannot find") ||
line.includes("ERR_") ||
line.includes("Command failed") ||
line.includes("ELIFECYCLE") ||
line.includes("npm ERR!") ||
line.includes("pnpm ERR!")
) {
commandStartIndex = i;
break;
}
}
}
if (commandStartIndex >= 0) {
// 提取命令执行后的所有行(包含错误行本身)
const outputLines = lines.slice(commandStartIndex);
commandOutput = outputLines.join("\n").trim();
// 如果输出为空,返回默认信息
if (!commandOutput) {
commandOutput = "命令已执行,但无输出信息";
}
} else {
// 如果没有找到命令开始标记,返回所有日志内容
commandOutput = logContent.trim();
}
// 脱敏处理:移除敏感路径信息
commandOutput = sanitizeSensitivePaths(commandOutput);
// 如果输出太长,截取最后的部分(保留更多信息)
if (commandOutput.length > 1000) {
commandOutput = commandOutput.substring(commandOutput.length - 1000);
}
}
} catch (readError) {
log(projectId, "WARN", "读取日志文件失败", {
projectId,
pid: pid,
error: readError.message,
});
}
return commandOutput;
}
// 进程注册表维护运行dev的项目
// key: projectId
// value: { pid, logPath, startedAt, port}
const runningDevProcesses = new Map();
// 项目级启动锁,避免并发重复启动同一项目
const startingProjects = new Set();
/**
* 获取运行中的进程信息
* @param {string} projectId 项目ID
* @returns {Object|null} 进程信息
*/
function getRunningProcess(projectId) {
return runningDevProcesses.get(projectId) || null;
}
/**
* 设置运行中的进程信息
* @param {string} projectId 项目ID
* @param {Object} processInfo 进程信息
*/
function setRunningProcess(projectId, processInfo) {
runningDevProcesses.set(projectId, processInfo);
}
/**
* 删除运行中的进程信息
* @param {string} projectId 项目ID
*/
function deleteRunningProcess(projectId) {
runningDevProcesses.delete(projectId);
}
/**
* 检查项目是否正在启动中
* @param {string} projectId 项目ID
* @returns {boolean}
*/
function isProjectStarting(projectId) {
return startingProjects.has(projectId);
}
/**
* 添加项目到启动锁
* @param {string} projectId 项目ID
*/
function addStartingProject(projectId) {
startingProjects.add(projectId);
}
/**
* 从启动锁中移除项目
* @param {string} projectId 项目ID
*/
function removeStartingProject(projectId) {
startingProjects.delete(projectId);
}
/**
* 使用spawn非阻塞启动开发服务器,并把日志写入文件
* @param {Object} options 启动选项
* @param {Object} options.req 请求对象
* @param {string} options.projectId 项目ID
* @param {string} options.projectPath 项目路径
* @param {string} options.devScript dev脚本内容
* @param {Object} options.envExtra 额外的环境变量
* @param {Array} options.extraArgs 额外的参数
* @returns {Object} { pid, logPath, port }
*/
async function startDev_NonBlocking({
req,
projectId,
projectPath,
devScript,
}) {
const lowerScript = (devScript || "").toLowerCase();
const isVite = lowerScript.includes("vite");
const isNext = lowerScript.includes("next");
if (!isVite && !isNext) {
throw new BusinessError("不支持的脚本类型" + devScript + "请使用vite或next脚本", {
projectId,
code: ERROR_CODES.INVALID_SCRIPT_TYPE,
});
}
// 创建日志目录
const logDir = getLogDir(projectId);
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// 主日志文件
const today = getCSTDateString(); // 格式: YYYY-MM-DD (东八区)
const logPath = path.join(logDir, `dev-${today}.log`);
// 临时日志用于端口解析
const tempLogPath = path.join(
logDir,
`dev-temp-${Date.now().toString()}.log`
);
let outStream, tempOutStream, child;
let streamsClosed = false;
let childExited = false;
let allocatedPort = null; // 跟踪分配的端口,用于失败时释放
// 标记缓存是否已失效(避免频繁删除缓存)
let cacheInvalidated = false;
// 创建安全的写入函数
const safeWrite = (stream, data, streamName, flush = false) => {
// 分别检查流的状态,而不是使用共享的 streamsClosed
if (!stream || stream.destroyed) {
// 只有在需要时才输出调试信息,避免日志过多
if (stream && stream.destroyed) {
log(projectId, "DEBUG", `${streamName}流已销毁,跳过写入`, {
destroyed: true,
});
}
return false;
}
// 如果全局标记已关闭,也不写入
if (streamsClosed) {
return false;
}
try {
// 在data前拼接时间戳 [2025/10/12 11:51:09] (东八区)
const timestamp = getCSTTimestampString();
const dataWithTimestamp = `[${timestamp}] ` + data;
const result = stream.write(dataWithTimestamp);
// 如果需要立即刷新(关键日志),使用 cork/uncork 机制强制刷新缓冲区
if (flush && typeof stream.cork === 'function' && typeof stream.uncork === 'function') {
// cork() 暂停写入uncork() 立即刷新所有缓冲数据
// 这里我们先 cork 再 uncork强制刷新当前缓冲区
stream.cork();
// 使用 setImmediate 确保在下一个事件循环刷新
setImmediate(() => {
try {
if (!stream.destroyed) {
stream.uncork();
}
} catch (e) {
// 忽略 uncork 错误
}
});
}
// 如果缓存已启用,标记缓存失效(只标记一次)
// 避免频繁删除缓存,让下次读取时自动重新加载
if (logCacheManager.isEnabled() && !cacheInvalidated) {
logCacheManager.delete(String(projectId));
cacheInvalidated = true;
}
return result;
} catch (err) {
log(projectId, "WARN", `${streamName}写入错误`, {
error: err.message,
code: err.code,
streamName: streamName,
});
// 只有在严重错误时才关闭所有流
// 临时日志的错误不应该影响主日志
if (err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE") {
// 只关闭出错的流,不影响其他流
try {
if (stream && !stream.destroyed) {
stream.end();
}
} catch (closeErr) {
log(projectId, "WARN", `关闭${streamName}流时出错`, { error: closeErr.message });
}
}
return false;
}
};
// 安全关闭流的函数
const safeCloseStreams = () => {
if (streamsClosed) return;
streamsClosed = true;
try {
if (outStream && !outStream.destroyed) {
outStream.end();
}
} catch (err) {
log(projectId, "WARN", "关闭主日志流时出错", { error: err.message });
}
try {
if (tempOutStream && !tempOutStream.destroyed) {
tempOutStream.end();
}
} catch (err) {
log(projectId, "WARN", "关闭临时日志流时出错", { error: err.message });
}
};
// 添加流错误处理
const handleStreamError = (streamName, err) => {
log(projectId, "WARN", `${streamName}流错误`, { error: err.message });
// 如果是写入错误且子进程已退出,则关闭流
if (
(err.code === "ERR_STREAM_WRITE_AFTER_END" || err.code === "EPIPE") &&
childExited
) {
safeCloseStreams();
}
};
try {
// 在启动前确保可执行权限
try {
await ensureDevBinariesExecutable(projectPath);
} catch (_) {}
// 创建日志文件写入流
outStream = fs.createWriteStream(logPath, { flags: "a" });
tempOutStream = fs.createWriteStream(tempLogPath, { flags: "a" });
// 组合为单个子进程内串行执行:先 dev-inject再 vite-plugin-design-mode
// 使用 set +e 忽略错误,确保两个命令都会执行,无论第一个是否成功
// 注意:即使预处理命令失败,也不会阻塞后续依赖安装和 dev 启动,仅做“尽力而为”的处理
const preCmd = "set +e ; pnpm dlx @xagi/dev-inject@latest install --framework ; pnpm dlx @xagi/vite-plugin-design-mode@latest install ; set -e";
safeWrite(outStream, preCmd, "Main log");
safeWrite(tempOutStream, preCmd, "Temp log");
// 在安装依赖之前先执行预处理命令(失败不影响后续流程)
await new Promise((resolve) => {
try {
const preProcess = spawn("bash", ["-lc", preCmd], {
cwd: projectPath,
env: { ...process.env },
stdio: ["ignore", "pipe", "pipe"],
});
preProcess.stdout.on("data", (data) => {
const msg = data.toString();
safeWrite(outStream, msg, "Main log");
safeWrite(tempOutStream, msg, "Temp log");
});
preProcess.stderr.on("data", (data) => {
const msg = data.toString();
safeWrite(outStream, msg, "Main log");
safeWrite(tempOutStream, msg, "Temp log");
});
preProcess.on("error", (err) => {
const errorMessage = `预处理命令执行出错(忽略并继续后续流程): ${err.message}\n`;
safeWrite(outStream, errorMessage, "Main log", true);
safeWrite(tempOutStream, errorMessage, "Temp log", true);
resolve();
});
preProcess.on("close", (code) => {
if (code !== 0) {
const errorMessage = `Preprocessing command exit code is ${code} (ignore and continue subsequent process)\n`;
safeWrite(outStream, errorMessage, "主日志", true);
safeWrite(tempOutStream, errorMessage, "临时日志", true);
}
resolve();
});
} catch (error) {
const errorMessage = `预处理命令启动失败(忽略并继续后续流程): ${error.message}\n`;
safeWrite(outStream, errorMessage, "Main log", true);
safeWrite(tempOutStream, errorMessage, "Temp log", true);
resolve();
}
});
// 安装依赖(日志会同时写入主日志和临时日志)
try {
await installDependencies(req, projectId, projectPath, {
outStream,
tempOutStream,
safeWrite,
});
} catch (error) {
// 安装失败时记录错误并抛出safeWrite 会自动添加时间戳)- 立即刷新到磁盘
const errorMessage = `Dependency installation failed: ${error.message}\n`;
safeWrite(outStream, errorMessage, "Main log", true); // flush = true
safeWrite(tempOutStream, errorMessage, "Temp log", true); // flush = true
throw error;
}
// 在日志文件开头写入开始执行
const startMessage = `Start executing script: dev\n`;
safeWrite(outStream, startMessage, "Main log");
safeWrite(tempOutStream, startMessage, "Temp log");
// 构建 dev 命令参数
const npmArgs = [];
// 额外参数和环境变量(端口将在内部从端口池获取)
const { extraArgs, envExtra, port } = await ExtraArgsUtils.processExtraArgs({
devScript,
projectId,
req,
});
// 记录分配的端口,用于失败时释放
allocatedPort = port;
// 透传额外参数到脚本
if (extraArgs && extraArgs.length > 0) {
npmArgs.push(...extraArgs);
}
const escapeArg = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
const extraArgsEscaped = (npmArgs && npmArgs.length > 0)
? npmArgs.map(escapeArg).join(" ")
: "";
// 记录参数信息以便调试
if (extraArgsEscaped) {
log(projectId, "INFO", "Generated extra arguments", {
extraArgs: extraArgs,
npmArgs: npmArgs,
extraArgsEscaped: extraArgsEscaped,
});
}
// 检测命令是否需要通过 npx 执行
// 如果命令是纯命令名(如 vite, next而不是路径应该使用 npx
const needsNpx = (script) => {
if (!script || typeof script !== "string") return false;
const trimmed = script.trim();
// 如果包含路径分隔符(/ 或 \),说明是路径,不需要 npx
if (trimmed.includes("/") || trimmed.includes("\\")) return false;
// 提取第一个词(命令名)
const firstWord = trimmed.split(/\s+/)[0];
// 如果是常见的构建工具命令名,且不是路径,需要 npx
const commandsNeedingNpx = ["vite", "next", "webpack", "rollup", "parcel", "esbuild", "tsc", "ts-node", "astro", "svelte", "remix", "nuxt"];
return commandsNeedingNpx.includes(firstWord);
};
let fullCommand;
let execCommand = ""; // 用于记录实际执行的命令
if (isVite) {
// 移除脚本中已存在的 --host / --base包含等号或跟随值避免重复冲突
const sanitizeCliFlags = (script, flagsToRemove) => {
if (!script || typeof script !== "string") return script;
let result = script;
for (const flag of flagsToRemove) {
// 1) 移除 --flag=value 形式
const eqPattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}=\\S+`, "g");
result = result.replace(eqPattern, "");
// 2) 移除 --flag <value> 形式,<value> 为非以 - 开头的单词(含路径)
const spacedPattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\s+([^\s-][^\s]*)`, "g");
result = result.replace(spacedPattern, "");
// 3) 移除仅有 --flag无值
const barePattern = new RegExp(`${flag.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&")}\b`, "g");
result = result.replace(barePattern, "");
}
// 归一多余空白
return result.replace(/\s{2,}/g, " ").trim();
};
const cleanedScript = sanitizeCliFlags(devScript, ["--host", "--base"]);
const appended = extraArgsEscaped ? ` ${extraArgsEscaped}` : "";
// 如果需要 npx使用 npx 执行命令
const commandPrefix = needsNpx(cleanedScript) ? "npx" : "";
execCommand = commandPrefix ? `${commandPrefix} ${cleanedScript}${appended}` : `${cleanedScript}${appended}`;
// 使用 exec 替换 shell 进程,确保 child.pid 直接对应 vite 进程
fullCommand = `exec ${execCommand}`;
} else if (isNext) {
const appended = extraArgsEscaped ? ` ${extraArgsEscaped}` : "";
// 如果需要 npx使用 npx 执行命令
const commandPrefix = needsNpx(devScript) ? "npx" : "";
execCommand = commandPrefix ? `${commandPrefix} ${devScript}${appended}` : `${devScript}${appended}`;
fullCommand = `exec ${execCommand}`;
} else {
const extraForNpm = extraArgsEscaped ? ` -- ${extraArgsEscaped}` : "";
execCommand = `pnpm run dev${extraForNpm}`;
fullCommand = `exec ${execCommand}`;
}
// 将实际执行的命令写入日志文件
if (execCommand) {
const commandMessage = `> ${execCommand}\n`;
safeWrite(outStream, commandMessage, "Main log");
safeWrite(tempOutStream, commandMessage, "Temp log");
}
// 打印将要执行的完整命令与上下文,便于排查
try {
log(projectId, "INFO", "Start child process, sequentially execute preprocessing and start dev:", {
command: fullCommand,
cwd: projectPath,
devScript,
extraArgs,
envExtraKeys: Object.keys(envExtra || {}),
});
} catch (_) {}
// 使用 shell 执行,以便在同一子进程中串行执行两步
child = spawn("sh", ["-c", fullCommand], {
cwd: projectPath,
env: {
PATH: process.env.PATH, // 必需:找到命令
//HOME: process.env.HOME, // 用户配置目录
NODE_ENV: 'development', //不能指定production,否则hmr会失效
...envExtra, // 项目特定变量
},
detached: true,
stdio: ["ignore", "pipe", "pipe"],
});
// 添加日志流的错误处理
outStream.on("error", (err) => {
log(projectId, "ERROR", "Main log stream error", {
error: err.message,
code: err.code,
path: logPath,
destroyed: outStream.destroyed,
});
handleStreamError("Main log", err);
});
tempOutStream.on("error", (err) => {
log(projectId, "ERROR", "Temp log stream error", {
error: err.message,
code: err.code,
path: tempLogPath,
destroyed: tempOutStream.destroyed,
});
handleStreamError("Temp log", err);
});
// 安全地重定向子进程输出到双日志文件
if (child.stdout) {
child.stdout.on("data", (data) => {
// 使用安全的写入函数,避免向已关闭的流写入
const mainWriteOk = safeWrite(outStream, data, "Main log");
const tempWriteOk = safeWrite(tempOutStream, data, "Temp log");
// 如果任一写入失败,记录详细信息(但不影响另一个流)
if (!mainWriteOk || !tempWriteOk) {
log(projectId, "DEBUG", "Log writing status", {
mainWriteOk,
tempWriteOk,
mainDestroyed: outStream ? outStream.destroyed : null,
tempDestroyed: tempOutStream ? tempOutStream.destroyed : null,
streamsClosed,
});
}
});
child.stdout.on("error", (err) => {
log(projectId, "WARN", "Child process stdout error", { error: err.message });
});
child.stdout.on("end", () => {
// stdout 流结束时,不立即关闭日志流,等待子进程完全退出
log(projectId, "INFO", "子进程stdout流已结束", { pid: child.pid });
});
}
if (child.stderr) {
child.stderr.on("data", (data) => {
// 使用安全的写入函数,避免向已关闭的流写入
const mainWriteOk = safeWrite(outStream, data, "Main log");
const tempWriteOk = safeWrite(tempOutStream, data, "Temp log");
// 如果任一写入失败,记录详细信息(但不影响另一个流)
if (!mainWriteOk || !tempWriteOk) {
log(projectId, "DEBUG", "Log writing status(stderr)", {
mainWriteOk,
tempWriteOk,
mainDestroyed: outStream ? outStream.destroyed : null,
tempDestroyed: tempOutStream ? tempOutStream.destroyed : null,
streamsClosed,
});
}
});
child.stderr.on("error", (err) => {
log(projectId, "WARN", "Child process stderr error", { error: err.message });
});
child.stderr.on("end", () => {
// stderr 流结束时,不立即关闭日志流,等待子进程完全退出
log(projectId, "INFO", "Child process stderr stream ended", { pid: child.pid });
});
}
// 监听子进程退出事件,安全关闭日志流
child.on("exit", (code, signal) => {
childExited = true;
log(projectId, "INFO", "Child process exited", {
pid: child.pid,
code,
signal,
});
// 延迟关闭流,确保所有数据都已写入
setTimeout(() => {
safeCloseStreams();
}, 200);
});
child.on("error", (err) => {
log(projectId, "WARN", "Child process error", { error: err.message });
childExited = true;
log(projectId, "ERROR", "Child process startup failed", {
pid: child.pid,
error: err.message,
});
safeCloseStreams();
});
runningDevProcesses.set(projectId, {
pid: child.pid,
logPath,
startedAt: Date.now(),
});
child.unref();// 解除子进程与父进程的关联,子进程可以独立运行
// 如果使用了 exec 命令(替换进程),需要等待 exec 完成进程替换
// exec 会在 preCmd 成功后替换 shell 进程为实际服务进程(如 vite
// 这种情况下 child.pid 会直接对应服务进程,但仍需稍等确保替换完成
const usesExec = fullCommand && fullCommand.includes("exec ");
if (usesExec) {
// 等待 200ms 确保 exec 完成进程替换
await new Promise((resolve) => setTimeout(resolve, 200));
}
log(projectId, "INFO", "Using port pool allocated port and current process ID", {
projectId,
pid: child.pid,
port: port
});
// 轮询等待项目对外可访问,最多 30s
const basePathFromReq =
(req && req.query && req.query.basePath) ||
(req && req.body && req.body.basePath) ||
undefined;
const resolvedBasePath = basePathFromReq || "/";
const maxAliveWaitMs = 30000;
const alivePollIntervalMs = 1000;
const aliveCheckTimeoutPerRequest = 1500;
let projectAlive = false;
const aliveStartedAt = Date.now();
// 启动后先等待 1s 再开始轮询,给框架预留初始化时间
await new Promise((resolve) => setTimeout(resolve, alivePollIntervalMs));
while (Date.now() - aliveStartedAt < maxAliveWaitMs) {
projectAlive = await isProjectAlive(projectId, port, basePathFromReq, {
timeoutMs: aliveCheckTimeoutPerRequest,
});
if (projectAlive) {
break;
}
await new Promise((resolve) => setTimeout(resolve, alivePollIntervalMs));
}
if (!projectAlive) {
const waitSeconds = Math.round(maxAliveWaitMs / 1000);
log(projectId, "WARN", "Development server is not accessible within the limited time", {
port,
pid: child.pid,
basePath: resolvedBasePath,
waitSeconds,
});
} else {
log(projectId, "INFO", "Project accessibility verification passed", {
port,
basePath: resolvedBasePath,
elapsedMs: Date.now() - aliveStartedAt,
});
}
// 回填端口和实际进程ID
const info = runningDevProcesses.get(projectId);
if (info) {
info.port = port;
info.pid = child.pid; // 进程组pid
runningDevProcesses.set(projectId, info);
}
log(projectId, "INFO", "Development server startup successfully", {
projectId,
pid: child.pid,
port: port,
});
//要返回进程组pid和端口因为后续需要通过进程组pid来停止进程
return { pid: child.pid, port: port };
} catch (error) {
// 启动失败,释放已分配的端口
if (allocatedPort) {
portPool.release(String(projectId));
log(projectId, "INFO", "Startup failed, port released", {
port: allocatedPort,
error: error.message
});
}
// 重新抛出错误
throw error;
} finally {
// 只有在发生异常或子进程启动失败时才立即清理资源
// 正常情况下,流会在子进程退出时自动关闭
if (childExited || !child || !child.pid) {
safeCloseStreams();
}
}
}
/**
* 停止指定的进程
* @param {string} projectId 项目ID
* @param {number} pid 进程ID
* @returns {Promise<boolean>} 是否成功停止
*/
async function killProcess(projectId, pid) {
const pidNum = Number(pid);
if (!Number.isFinite(pidNum)) {
return false;
}
// 首先检查进程是否存在
if (!isProcessRunning(pidNum)) {
log(projectId, "INFO", "Process does not exist", { pid: pidNum });
runningDevProcesses.delete(projectId);
return true;
}
let killed = false;
let killMethod = "";
try {
// 优先杀进程组detached
process.kill(-pidNum);
killed = true;
killMethod = "Process group";
log(projectId, "INFO", "通过进程组杀死进程", { pid: pidNum });
} catch (e) {
if (e && (e.code === "ESRCH" || e.errno === "ESRCH")) {
// 进程组不存在,不能认为已杀死;继续尝试对单个进程发送信号
killed = false;
killMethod = "Process group(not exist)";
log(projectId, "INFO", "Process group does not exist, back to try single process kill", { pid: pidNum });
} else {
log(projectId, "WARN", "Failed to kill process group", {
pid: pidNum,
error: e.message,
});
}
}
if (!killed) {
try {
process.kill(pidNum);
killed = true;
killMethod = "单个进程";
log(projectId, "INFO", "Kill process through single process", { pid: pidNum });
} catch (e) {
if (e && (e.code === "ESRCH" || e.errno === "ESRCH")) {
killed = true;
killMethod = "Single process(not exist)";
log(projectId, "INFO", "Process does not exist,视为已停止", { pid: pidNum });
} else {
log(projectId, "ERROR", "Failed to kill process", {
pid: pidNum,
error: e.message,
});
}
}
}
// 验证进程是否真的被杀死了
if (killed) {
// 等待一小段时间让进程完全退出
await new Promise((resolve) => setTimeout(resolve, 100));
if (!isProcessRunning(pidNum)) {
log(projectId, "INFO", "Process stopped successfully", {
pid: pidNum,
method: killMethod,
});
runningDevProcesses.delete(projectId);
return true;
} else {
log(projectId, "WARN", "Process still exists, kill may have failed", {
pid: pidNum,
method: killMethod,
});
return false;
}
}
return killed;
}
/**
* 检查进程是否正在运行
* @param {number} pid 进程ID
* @returns {boolean}
*/
function isProcessRunning(pid) {
try {
process.kill(pid, 0); // 发送信号0检查进程是否存在
return true;
} catch (err) {
if (err && (err.code === 'EPERM' || err.code === 'EACCES')) {
// 进程存在,但当前用户无权限发送信号
return true;
}
if (err && err.code === 'ESRCH') {
// 进程不存在
return false;
}
// 其他错误,保守返回 false或根据需要上报日志
return false;
}
}
/**
* 等待进程停止
* @param {string} projectId 项目ID
* @param {number} pid 进程ID
* @returns {Promise<Object>} { stopped: boolean, attempts: number }
*/
async function waitForProcessStop(projectId, pid) {
let attempts = 0;
const maxAttempts = config.DEV_SERVER_STOP_MAX_ATTEMPTS;
while (isProcessRunning(pid) && attempts < maxAttempts) {
await new Promise((resolve) =>
setTimeout(resolve, config.DEV_SERVER_STOP_CHECK_INTERVAL)
);
attempts++;
}
const stopped = !isProcessRunning(pid);
return { stopped, attempts };
}
/**
* 列出所有运行中的进程
* @returns {Array} 进程列表
*/
function listRunningProcesses() {
const list = Array.from(runningDevProcesses.entries()).map(([id, info]) => ({
projectId: id,
pid: info.pid,
type: info.type,
startedAt: info.startedAt,
port: info.port,
}));
return list;
}
export {
getRunningProcess,
setRunningProcess,
deleteRunningProcess,
isProjectStarting,
addStartingProject,
removeStartingProject,
startDev_NonBlocking,
killProcess,
isProcessRunning,
waitForProcessStop,
listRunningProcesses,
};

View File

@@ -0,0 +1,129 @@
import path from "path";
import fs from "fs";
import { log } from "../log/logUtils.js";
import { BusinessError, FileError, ResourceError } from "../error/errorHandler.js";
import {
isProjectStarting,
addStartingProject,
removeStartingProject,
startDev_NonBlocking,
} from "./processManager.js";
import ERROR_CODES from "../error/errorCodes.js";
import { stopDevServer } from "./stopDevUtils.js";
import { removeNodeModules } from "../buildDependency/dependencyManager.js";
import { createPnpmNpmrc } from "../common/npmrcUtils.js";
import {
extractIsolationContext,
resolveProjectPath,
} from "../common/projectPathUtils.js";
// 重启开发服务器
async function restartDevServer(req, projectId) {
log(projectId, "INFO", "Start restarting development server", {
projectId,
requestId: req.requestId,
});
// 检查项目是否存在
const isolationContext = extractIsolationContext(req?.query || {});
const projectPath = resolveProjectPath(projectId, isolationContext);
const jsonFilePath = path.join(projectPath, "package.json");
const exists = fs.existsSync(jsonFilePath);
if (!exists) {
log(projectId, "WARN", "Project missing package.json file", {
projectId,
requestId: req.requestId,
});
throw new ResourceError("Project missing package.json file", {
projectId,
projectPath,
});
}
let jsonContent;
try {
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
} catch (error) {
throw new FileError("package.json file format error", {
projectId,
jsonFilePath,
originalError: error.message,
});
}
const jsonScripts = jsonContent.scripts;
const devScript = jsonScripts.dev;
if (!devScript) {
log(projectId, "WARN", "Project missing dev script", {
projectId,
requestId: req.requestId,
});
throw new BusinessError("Project missing dev script", { projectId });
}
// 如果项目正在启动中,等待完成
// if (isProjectStarting(projectId)) {
// throw new BusinessError("Project is starting, please try again later", {
// projectId,
// code: ERROR_CODES.PROJECT_STARTING,
// });
// }
addStartingProject(projectId);
try {
// 1. 停止现有的开发服务器
const pidFromQuery = req.query.pid;
await stopDevServer(req, projectId, pidFromQuery, {
strict: false, // 非严格模式,停止失败不抛出异常
waitForStop: true, // 等待进程完全停止
});
// 2. 删除node_modules
log(projectId, "INFO", "Start deleting node_modules and lock file", {
projectId,
requestId: req.requestId,
});
await removeNodeModules(projectPath, projectId);
// 3. 创建.npmrc文件
log(projectId, "INFO", "Create .npmrc file", {
projectId,
requestId: req.requestId,
});
await createPnpmNpmrc(projectPath, projectId);
// 4. 启动dev服务器依赖安装会在 startDev_NonBlocking 中执行)
log(projectId, "INFO", "Start starting dev server", {
projectId,
requestId: req.requestId,
});
const { pid, port: actualPort } = await startDev_NonBlocking({
req,
projectId,
projectPath,
devScript,
});
log(projectId, "INFO", "Dev server restart completed", {
projectId,
pid,
port: actualPort,
requestId: req.requestId,
});
return {
success: true,
message: "Development server restart successfully",
projectId,
pid,
port: actualPort,
};
} finally {
removeStartingProject(projectId);
}
}
export { restartDevServer };

View File

@@ -0,0 +1,150 @@
import path from "path";
import fs from "fs";
import { log } from "../log/logUtils.js";
import { BusinessError, FileError, ResourceError } from "../error/errorHandler.js";
import ERROR_CODES from "../error/errorCodes.js";
import {
getRunningProcess,
isProjectStarting,
addStartingProject,
removeStartingProject,
startDev_NonBlocking,
} from "./processManager.js";
import { removeNodeModules } from "../buildDependency/dependencyManager.js";
import {
extractIsolationContext,
resolveProjectPath,
} from "../common/projectPathUtils.js";
// 启动开发服务器
async function startDevServer(req, projectId) {
// if (isProjectStarting(projectId)) {
// throw new BusinessError("该项目正在启动中,请稍后重试", {
// projectId,
// code: ERROR_CODES.PROJECT_STARTING,
// });
// }
addStartingProject(projectId);
try {
log(projectId, "INFO", "Start starting development server", {
projectId,
requestId: req.requestId,
});
const isolationContext = extractIsolationContext(req?.query || {});
const projectPath = resolveProjectPath(projectId, isolationContext);
const jsonFilePath = path.join(projectPath, "package.json");
const exists = fs.existsSync(jsonFilePath);
if (!exists) {
log(projectId, "WARN", "Project missing package.json file", {
projectId,
requestId: req.requestId,
});
throw new ResourceError("Project missing package.json file", {
projectId,
projectPath,
});
}
let jsonContent;
try {
jsonContent = JSON.parse(fs.readFileSync(jsonFilePath, "utf8"));
} catch (error) {
throw new FileError("package.json file format error", {
projectId,
jsonFilePath,
originalError: error.message,
});
}
const jsonScripts = jsonContent.scripts;
const devScript = jsonScripts.dev;
if (!devScript) {
log(projectId, "WARN", "Project missing dev script", {
projectId,
requestId: req.requestId,
});
throw new BusinessError("Project missing dev script, please add dev script in package.json", { projectId });
}
// Linux 环境下:检测 libc 类型与已安装 Rollup 原生包是否匹配,若不匹配则清理依赖
try {
const isLinux = process.platform === "linux";
if (isLinux) {
const report = typeof process.report?.getReport === "function" ? process.report.getReport() : null;
const glibcVersion = report && report.header && report.header.glibcVersionRuntime;
const isMusl = !glibcVersion; // 没有 glibc 版本通常意味着 musl如 Alpine
const pnpmDir = path.join(projectPath, "node_modules", ".pnpm");
if (fs.existsSync(pnpmDir)) {
const entries = await fs.promises.readdir(pnpmDir, { withFileTypes: true });
const hasRollupGnu = entries.some((ent) => ent.isDirectory() && (ent.name || "").includes("@rollup+rollup-linux-x64-gnu"));
const hasRollupMusl = entries.some((ent) => ent.isDirectory() && (ent.name || "").includes("@rollup+rollup-linux-x64-musl"));
// 在 musl 系统上若装了 gnu 变体,或在 glibc 系统上若装了 musl 变体,则清理
const mismatch = (isMusl && hasRollupGnu) || (!isMusl && hasRollupMusl);
if (mismatch) {
log(projectId, "WARN", "Detected Rollup native package does not match libc, clean dependencies and reinstall", {
projectId,
isMusl,
glibcVersion: glibcVersion || null,
});
await removeNodeModules(projectPath, projectId);
}
}
}
} catch (e) {
log(projectId, "WARN", "Linux native package matching detection failed (ignore continue)", {
error: e && e.message,
});
}
// 尝试为后续 dev 进程注入回退环境,优先使用 WASM/JS避免 .node 装载
try {
process.env.ROLLUP_WASM = process.env.ROLLUP_WASM || "1";
process.env.ROLLUP_DISABLE_NATIVE = process.env.ROLLUP_DISABLE_NATIVE || "1";
} catch (_) {}
// 如果已在运行,则直接返回信息
// if (getRunningProcess(projectId)) {
// const p = getRunningProcess(projectId);
// log(projectId, "INFO", "项目已在运行,直接返回信息", {
// projectId,
// requestId: req.requestId,
// });
// return {
// success: true,
// message: "已在运行",
// projectId,
// pid: p.pid,
// port: p.port,
// };
// }
log(projectId, "INFO", "Start executing dev script in non-blocking mode", {
projectId,
requestId: req.requestId,
});
const { pid, port: actualPort } = await startDev_NonBlocking({
req,
projectId,
projectPath,
devScript,
});
return {
success: true,
message: "Development server started",
projectId,
pid,
port: actualPort,
};
} finally {
// 无论成功、失败都清理锁
removeStartingProject(projectId);
}
}
export { startDevServer };

View File

@@ -0,0 +1,428 @@
import { ValidationError, ProcessError } from "../error/errorHandler.js";
import {
killProcess,
getRunningProcess,
waitForProcessStop,
} from "./processManager.js";
import { log, getLogDir } from "../log/logUtils.js";
import logCacheManager from "../log/logCacheManager.js";
import portPool from "../buildArg/portPool.js";
import fs from "fs";
import path from "path";
import { execSync } from "child_process";
function findPidsByProjectId(projectId) {
try {
const cmd = "ps -Ao pid,command -ww";
const output = execSync(cmd, { encoding: "utf8" });
const lines = output.split("\n");
const pids = [];
const preciseNeedle = "/" + String(projectId);
// 匹配包含 /{projectId} 的路径片段,避免误匹配
for (const line of lines) {
if (!line) continue;
if (line.includes(preciseNeedle)) {
const trimmed = line.trim();
const firstSpace = trimmed.indexOf(" ");
const pidStr = firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed;
const pidNum = Number(pidStr);
if (Number.isFinite(pidNum)) {
pids.push(pidNum);
}
}
}
// 回退:使用宽松的包含 projectId 匹配(可能带来噪声)
if (pids.length === 0) {
for (const line of lines) {
if (!line) continue;
if (line.includes(String(projectId))) {
const trimmed = line.trim();
const firstSpace = trimmed.indexOf(" ");
const pidStr = firstSpace > 0 ? trimmed.slice(0, firstSpace) : trimmed;
const pidNum = Number(pidStr);
if (Number.isFinite(pidNum)) {
pids.push(pidNum);
}
}
}
}
return Array.from(new Set(pids));
} catch (_) {
return [];
}
}
/**
* 停止开发服务器
* @param {Object} req 请求对象
* @param {string} projectId 项目ID
* @param {number|string} pid 进程ID可选如果不提供会从运行表中获取
* @param {Object} options 选项
* @param {boolean} options.strict 是否严格模式,严格模式下停止失败会抛出异常
* @param {boolean} options.waitForStop 是否等待进程完全停止
* @returns {Promise<Object>} 停止结果
*
* 功能说明:
* 1. 确定要停止的进程ID
* 2. 停止进程
* 3. 等待进程完全停止(可选)
* 4. 清理项目下的所有临时日志文件dev-temp-*.log
*/
async function stopDevServerByPid(req, projectId, pid, options = {}) {
const { strict = true, waitForStop = false } = options;
log(projectId, "INFO", "Start stopping development server", {
projectId,
pid,
strict,
waitForStop,
requestId: req.requestId,
});
let pidToKill = null;
let existingProcess = null;
// 1. 确定要停止的进程ID
if (pid !== undefined && pid !== null) {
const pidNum = Number(pid);
if (Number.isFinite(pidNum)) {
pidToKill = pidNum;
log(projectId, "INFO", "Stop development server using incoming pid", {
projectId,
pid: pidToKill,
requestId: req.requestId,
});
} else {
log(projectId, "WARN", "Incoming pid is invalid", {
projectId,
invalidPid: pid,
requestId: req.requestId,
});
if (strict) {
throw new ValidationError("Process ID is invalid", { field: "pid", value: pid });
}
}
}
// 如果传入的pid无效或未提供从运行表中获取
if (!pidToKill) {
existingProcess = getRunningProcess(projectId);
if (existingProcess) {
pidToKill = existingProcess.pid;
log(projectId, "INFO", "Get pid to stop development server from running table", {
projectId,
pid: pidToKill,
requestId: req.requestId,
});
} else {
log(projectId, "INFO", "No development server process found to stop", {
projectId,
requestId: req.requestId,
});
if (strict) {
throw new ProcessError("No running development server process found", { projectId });
}
// 即使未找到进程,也尝试释放端口(可能进程已意外退出)
portPool.release(String(projectId));
log(projectId, "INFO", "Port released (process not running)", {
projectId,
requestId: req.requestId,
});
return {
success: true,
message: "No running process found",
projectId,
pid: null,
};
}
}
// 2. 停止进程
const killed = await killProcess(projectId, pidToKill);
if (killed) {
log(projectId, "INFO", "Development server stopped", {
projectId,
pid: pidToKill,
requestId: req.requestId,
});
} else {
log(projectId, "WARN", "Failed to stop development server", {
projectId,
pid: pidToKill,
requestId: req.requestId,
});
if (strict) {
throw new ProcessError("Failed to stop process", { projectId, pid: pidToKill });
}
}
// 3. 等待进程完全停止(可选)
if (waitForStop && killed) {
const { stopped, attempts } = await waitForProcessStop(
projectId,
pidToKill
);
if (!stopped) {
log(projectId, "WARN", "Process stop timeout", {
projectId,
pid: pidToKill,
attempts,
requestId: req.requestId,
});
if (strict) {
throw new ProcessError("Process stop timeout", {
projectId,
pid: pidToKill,
attempts,
});
}
} else {
log(projectId, "INFO", "Process confirmed stopped", {
projectId,
pid: pidToKill,
attempts,
requestId: req.requestId,
});
}
}
// 4. 清理项目下的所有临时日志文件
try {
const logDir = getLogDir(projectId);
if (fs.existsSync(logDir)) {
const files = fs.readdirSync(logDir);
const tempFiles = files.filter(
(file) => file.startsWith("dev-temp-") && file.endsWith(".log")
);
if (tempFiles.length > 0) {
let deletedCount = 0;
for (const tempFile of tempFiles) {
try {
const tempFilePath = path.join(logDir, tempFile);
fs.unlinkSync(tempFilePath);
deletedCount++;
} catch (err) {
log(projectId, "WARN", "Failed to delete temporary log file", {
projectId,
tempFile,
error: err.message,
requestId: req.requestId,
});
}
}
log(projectId, "INFO", "Temporary log file cleanup completed", {
projectId,
deletedCount,
totalTempFiles: tempFiles.length,
requestId: req.requestId,
});
}
}
} catch (err) {
log(projectId, "WARN", "Error cleaning temporary log files", {
projectId,
error: err.message,
requestId: req.requestId,
});
}
// 5. 删除该项目的日志缓存
try {
if (logCacheManager.isEnabled()) {
logCacheManager.delete(projectId);
log(projectId, "INFO", "Log cache cleaned", {
projectId,
requestId: req.requestId,
});
}
} catch (err) {
log(projectId, "WARN", "Error cleaning log cache", {
projectId,
error: err.message,
requestId: req.requestId,
});
}
// 6. 停止成功后释放端口
if (killed) {
portPool.release(String(projectId));
log(projectId, "INFO", "Port released", {
projectId,
requestId: req.requestId,
});
}
return {
success: true,
message: killed ? "Stopped" : "Failed to stop but continue execution",
projectId,
pid: pidToKill,
};
}
async function stopDevServerByProjectId(req, projectId, options = {}) {
const { strict = true, waitForStop = false } = options;
log(projectId, "INFO", "Start stopping development server(stop all by projectId)", {
projectId,
strict,
waitForStop,
requestId: req.requestId,
});
// 忽略传入的 pid按 projectId 通过系统进程检索
const uniquePids = findPidsByProjectId(projectId);
const candidates = uniquePids.map((pid) => ({ pid }));
if (!candidates || candidates.length === 0) {
log(projectId, "INFO", "No development server process found to stop", {
projectId,
requestId: req.requestId,
});
// if (strict) {
// throw new ProcessError("未找到运行中的开发服务器进程", { projectId });
// }
// 即使未找到进程,也尝试释放端口(可能进程已意外退出)
portPool.release(String(projectId));
log(projectId, "INFO", "Port released (process not running)", {
projectId,
requestId: req.requestId,
});
return {
success: true,
message: "No running process found",
projectId,
pid: null,
};
}
const results = [];
for (const thePid of uniquePids) {
const killed = await killProcess(projectId, thePid);
results.push({ pid: thePid, killed });
if (waitForStop && killed) {
const { stopped, attempts } = await waitForProcessStop(projectId, thePid);
log(projectId, stopped ? "INFO" : "WARN", stopped ? "Process confirmed stopped" : "Process stop timeout", {
projectId,
pid: thePid,
attempts,
requestId: req.requestId,
});
if (!stopped && strict) {
throw new ProcessError("Process stop timeout", {
projectId,
pid: thePid,
attempts,
});
}
} else if (!killed && strict) {
throw new ProcessError("Failed to stop process", { projectId, pid: thePid });
}
}
// 清理项目下的所有临时日志文件
try {
const logDir = getLogDir(projectId);
if (fs.existsSync(logDir)) {
const files = fs.readdirSync(logDir);
const tempFiles = files.filter(
(file) => file.startsWith("dev-temp-") && file.endsWith(".log")
);
if (tempFiles.length > 0) {
let deletedCount = 0;
for (const tempFile of tempFiles) {
try {
const tempFilePath = path.join(logDir, tempFile);
fs.unlinkSync(tempFilePath);
deletedCount++;
} catch (err) {
log(projectId, "WARN", "Failed to delete temporary log file", {
projectId,
tempFile,
error: err.message,
requestId: req.requestId,
});
}
}
log(projectId, "INFO", "Temporary log file cleanup completed", {
projectId,
deletedCount,
totalTempFiles: tempFiles.length,
requestId: req.requestId,
});
}
}
} catch (err) {
log(projectId, "WARN", "Error cleaning temporary log files", {
projectId,
error: err.message,
requestId: req.requestId,
});
}
// 删除该项目的日志缓存
try {
if (logCacheManager.isEnabled()) {
logCacheManager.delete(projectId);
log(projectId, "INFO", "Log cache cleaned", {
projectId,
requestId: req.requestId,
});
}
} catch (err) {
log(projectId, "WARN", "Error cleaning log cache", {
projectId,
error: err.message,
requestId: req.requestId,
});
}
const allKilled = results.every((r) => r.killed === true);
// 停止成功后释放端口
if (allKilled && results.length > 0) {
portPool.release(String(projectId));
log(projectId, "INFO", "Port released", {
projectId,
requestId: req.requestId,
});
}
return {
success: true,
message: allKilled ? "Stopped" : "Partially stopped but continue execution",
projectId,
pid: null,
killedPids: results,
};
}
async function stopDevServer(req, projectId, pid, options = {}) {
return await stopDevServerByProjectId(req, projectId, options);
}
export { stopDevServer };

View File

@@ -0,0 +1,847 @@
import { spawn } from "child_process";
import fs from "fs";
import path from "path";
import { log } from "../log/logUtils.js";
/**
* 在启动开发服务器前进行语法检查
* 根据项目配置自动选择检查方式:
* - TypeScript 项目:运行 tsc --noEmit
* - 纯 JavaScript 项目:使用 esbuild 进行快速语法检查
* - HTML 文件:使用 html-validate 进行 HTML 语法检查
*
* @param {string} projectId 项目ID
* @param {string} projectPath 项目路径
* @param {number} timeoutMs 超时时间(毫秒)
* @returns {Promise<Object>} { passed: boolean, error?: string, method?: string }
*/
async function runSyntaxCheck(projectId, projectPath, timeoutMs = 15000) {
try {
// 检查项目类型
const projectType = detectProjectType(projectPath);
log(projectId, "INFO", "Start syntax check", {
projectId,
projectType,
timeoutMs,
});
const results = [];
// 1. 代码检查TypeScript/JavaScript
if (projectType === "typescript") {
const tsResult = await runTypeScriptCheck(projectId, projectPath, timeoutMs);
results.push(tsResult);
// 如果 TS 检查失败,立即返回
if (!tsResult.passed) {
return tsResult;
}
} else if (projectType === "javascript") {
const jsResult = await runJavaScriptCheck(projectId, projectPath, timeoutMs);
results.push(jsResult);
// 如果 JS 检查失败,立即返回
if (!jsResult.passed) {
return jsResult;
}
}
// 2. HTML 文件检查
const htmlResult = await runHtmlCheck(projectId, projectPath, timeoutMs);
results.push(htmlResult);
// 如果 HTML 检查失败,返回失败
if (!htmlResult.passed) {
return htmlResult;
}
// 所有检查都通过
if (results.length === 0) {
log(projectId, "INFO", "Skip syntax check: no source code files detected", {
projectId,
});
return { passed: true, method: "skipped" };
}
// 返回综合结果
const methods = results.map(r => r.method).filter(Boolean).join(", ");
const totalDuration = results.reduce((sum, r) => sum + (r.duration || 0), 0);
log(projectId, "INFO", "All syntax checks passed", {
projectId,
methods,
totalDuration,
});
return {
passed: true,
method: methods,
duration: totalDuration,
};
} catch (error) {
log(projectId, "WARN", "Syntax check execution failed", {
projectId,
error: error.message,
});
// 检查失败不阻止启动
return { passed: true, method: "error", error: error.message };
}
}
/**
* 检测项目类型
* @param {string} projectPath 项目路径
* @returns {string} "typescript" | "javascript" | "unknown"
*/
function detectProjectType(projectPath) {
// 检查是否有 tsconfig.json
const tsconfigPath = path.join(projectPath, "tsconfig.json");
if (fs.existsSync(tsconfigPath)) {
// 检查是否有 .ts 或 .tsx 文件
const hasTsFiles = hasFilesWithExtension(projectPath, [".ts", ".tsx"]);
if (hasTsFiles) {
return "typescript";
}
}
// 检查是否有 .js 或 .jsx 文件
const hasJsFiles = hasFilesWithExtension(projectPath, [".js", ".jsx"]);
if (hasJsFiles) {
return "javascript";
}
return "unknown";
}
/**
* 检查目录下是否存在指定扩展名的文件
* @param {string} dir 目录路径
* @param {Array<string>} extensions 扩展名列表
* @param {number} maxDepth 最大搜索深度
* @returns {boolean}
*/
function hasFilesWithExtension(dir, extensions, maxDepth = 3) {
try {
// 排除的目录
const excludeDirs = ["node_modules", ".git", "dist", "build", ".next", ".nuxt"];
function searchDir(currentDir, depth) {
if (depth > maxDepth) return false;
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (!excludeDirs.includes(entry.name)) {
if (searchDir(fullPath, depth + 1)) {
return true;
}
}
} else if (entry.isFile()) {
const ext = path.extname(entry.name);
if (extensions.includes(ext)) {
return true;
}
}
}
return false;
}
return searchDir(dir, 0);
} catch (error) {
return false;
}
}
/**
* 运行 TypeScript 类型检查
* @param {string} projectId 项目ID
* @param {string} projectPath 项目路径
* @param {number} timeoutMs 超时时间
* @returns {Promise<Object>}
*/
async function runTypeScriptCheck(projectId, projectPath, timeoutMs) {
return new Promise((resolve) => {
const startTime = Date.now();
const tsconfigPath = path.join(projectPath, "tsconfig.json");
const checkConfigPath = path.join(projectPath, "tsconfig.check.json");
let createdCheckConfig = false;
let createdTempTsconfig = false;
// 创建专门用于语法检查的配置
const createCheckConfig = (hasTsconfig) => {
const config = {
// 明确指定要检查的所有文件模式
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.js",
"src/**/*.jsx",
"*.ts",
"*.tsx"
],
"exclude": ["node_modules", "dist", "build", ".next", ".nuxt", "**/*.spec.ts", "**/*.test.ts"]
};
// 如果用户有 tsconfig.json继承它的 compilerOptions
if (hasTsconfig) {
config.extends = "./tsconfig.json";
log(projectId, "INFO", "Check configuration will inherit user's tsconfig.json", {
projectId,
});
} else {
// 如果没有 tsconfig.json需要提供完整的 compilerOptions
config.compilerOptions = {
"target": "ESNext",
"lib": ["ESNext", "DOM"],
"jsx": "react-jsx",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": false,
"skipLibCheck": true,
"noEmit": true,
"allowJs": true,
"checkJs": false
};
}
return config;
};
const hasTsconfig = fs.existsSync(tsconfigPath);
// 始终创建 tsconfig.check.json 用于检查
try {
const checkConfig = createCheckConfig(hasTsconfig);
fs.writeFileSync(checkConfigPath, JSON.stringify(checkConfig, null, 2), "utf8");
createdCheckConfig = true;
log(projectId, "INFO", "Create temporary tsconfig.check.json for syntax check", {
projectId,
hasTsconfig,
extends: checkConfig.extends || "无",
include: checkConfig.include,
});
} catch (error) {
log(projectId, "WARN", "Failed to create tsconfig.check.json", {
projectId,
error: error.message,
});
// 降级方案:如果无法创建检查配置且没有 tsconfig.json创建临时的
if (!hasTsconfig) {
try {
fs.writeFileSync(tsconfigPath, JSON.stringify(createCheckConfig(false), null, 2), "utf8");
createdTempTsconfig = true;
log(projectId, "INFO", "Create temporary tsconfig.json as fallback", {
projectId,
});
} catch (e) {
log(projectId, "ERROR", "Failed to create any TypeScript configuration file", {
projectId,
error: e.message,
});
}
}
}
// 优先使用项目本地的 tsc如果不存在则使用 npx
const localTscPath = path.join(projectPath, "node_modules", ".bin", "tsc");
const usesLocalTsc = fs.existsSync(localTscPath);
const command = usesLocalTsc ? localTscPath : "npx";
// 使用 --project 参数指定检查配置文件
const configToUse = createdCheckConfig ? "tsconfig.check.json" : "tsconfig.json";
const args = usesLocalTsc
? ["--project", configToUse, "--noEmit", "--skipLibCheck"]
: ["--yes", "tsc", "--project", configToUse, "--noEmit", "--skipLibCheck"];
log(projectId, "INFO", "Run TypeScript type check", {
projectId,
command,
args: args.join(" "),
usesLocalTsc,
configFile: configToUse,
});
const child = spawn(command, args, {
cwd: projectPath,
shell: true,
timeout: timeoutMs,
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
// 清理临时文件的辅助函数
const cleanupTempConfig = () => {
// 清理检查配置文件
if (createdCheckConfig) {
try {
if (fs.existsSync(checkConfigPath)) {
fs.unlinkSync(checkConfigPath);
log(projectId, "INFO", "Cleaned temporary tsconfig.check.json", {
projectId,
});
}
} catch (error) {
log(projectId, "WARN", "Failed to clean tsconfig.check.json", {
projectId,
error: error.message,
});
}
}
// 清理临时创建的 tsconfig.json降级方案
if (createdTempTsconfig) {
try {
if (fs.existsSync(tsconfigPath)) {
fs.unlinkSync(tsconfigPath);
log(projectId, "INFO", "Cleaned temporary tsconfig.json", {
projectId,
});
}
} catch (error) {
log(projectId, "WARN", "Failed to clean tsconfig.json", {
projectId,
error: error.message,
});
}
}
};
child.on("close", (code) => {
const duration = Date.now() - startTime;
// 清理临时配置
cleanupTempConfig();
if (code === 0) {
log(projectId, "INFO", "TypeScript type check passed", {
projectId,
duration,
});
resolve({ passed: true, method: "typescript", duration });
} else {
// 提取错误信息
const errorOutput = stderr || stdout;
const errorSummary = extractTypeScriptErrors(errorOutput);
log(projectId, "ERROR", "TypeScript type check failed", {
projectId,
code,
duration,
errorSummary,
});
resolve({
passed: false,
method: "typescript",
error: errorSummary,
fullOutput: errorOutput.substring(0, 2000), // 限制长度
duration,
});
}
});
child.on("error", (error) => {
// 清理临时配置
cleanupTempConfig();
log(projectId, "WARN", "TypeScript check execution failed", {
projectId,
error: error.message,
});
// 执行失败不阻止启动
resolve({ passed: true, method: "typescript-error", error: error.message });
});
// 超时处理
setTimeout(() => {
try {
child.kill();
// 清理临时配置
cleanupTempConfig();
log(projectId, "WARN", "TypeScript check timeout", {
projectId,
timeoutMs,
});
resolve({ passed: true, method: "typescript-timeout" });
} catch (e) {
cleanupTempConfig();
resolve({ passed: true, method: "typescript-timeout-error" });
}
}, timeoutMs);
});
}
/**
* 运行 JavaScript 语法检查(使用 esbuild
* @param {string} projectId 项目ID
* @param {string} projectPath 项目路径
* @param {number} timeoutMs 超时时间
* @returns {Promise<Object>}
*/
async function runJavaScriptCheck(projectId, projectPath, timeoutMs) {
return new Promise((resolve) => {
const startTime = Date.now();
// 查找入口文件
const entryFiles = findEntryFiles(projectPath);
if (entryFiles.length === 0) {
log(projectId, "INFO", "Skip JavaScript syntax check: no entry files found", {
projectId,
});
resolve({ passed: true, method: "javascript-skipped" });
return;
}
// 使用 esbuild 进行快速语法检查(不输出文件)
const args = [
"--yes",
"esbuild",
...entryFiles,
"--bundle",
"--write=false",
"--outdir=/tmp",
"--format=esm",
];
log(projectId, "INFO", "Run JavaScript syntax check", {
projectId,
entryFiles,
});
const child = spawn("npx", args, {
cwd: projectPath,
shell: true,
timeout: timeoutMs,
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
log(projectId, "INFO", "JavaScript syntax check passed", {
projectId,
duration,
});
resolve({ passed: true, method: "javascript", duration });
} else {
const errorOutput = stderr || stdout;
const errorSummary = errorOutput.substring(0, 1000);
log(projectId, "ERROR", "JavaScript syntax check failed", {
projectId,
code,
duration,
errorSummary,
});
resolve({
passed: false,
method: "javascript",
error: errorSummary,
fullOutput: errorOutput.substring(0, 2000),
duration,
});
}
});
child.on("error", (error) => {
log(projectId, "WARN", "JavaScript check execution failed", {
projectId,
error: error.message,
});
resolve({ passed: true, method: "javascript-error", error: error.message });
});
// 超时处理
setTimeout(() => {
try {
child.kill();
log(projectId, "WARN", "JavaScript check timeout", {
projectId,
timeoutMs,
});
resolve({ passed: true, method: "javascript-timeout" });
} catch (e) {
resolve({ passed: true, method: "javascript-timeout-error" });
}
}, timeoutMs);
});
}
/**
* 查找入口文件
* @param {string} projectPath 项目路径
* @returns {Array<string>} 入口文件路径列表
*/
function findEntryFiles(projectPath) {
const possibleEntries = [
"src/main.js",
"src/main.jsx",
"src/main.ts",
"src/main.tsx",
"src/index.js",
"src/index.jsx",
"src/index.ts",
"src/index.tsx",
"src/app.js",
"src/app.jsx",
"src/App.js",
"src/App.jsx",
"index.js",
"index.jsx",
"index.ts",
"index.tsx",
];
const entries = [];
for (const entry of possibleEntries) {
const fullPath = path.join(projectPath, entry);
if (fs.existsSync(fullPath)) {
entries.push(entry);
}
}
return entries;
}
/**
* 确保项目有 HTML 验证配置文件(如果不存在则创建宽松配置)
* @param {string} projectPath 项目路径
* @returns {boolean} 是否创建了新配置
*/
function ensureHtmlValidateConfig(projectPath) {
const configPath = path.join(projectPath, ".htmlvalidate.json");
// 如果已经存在配置文件,不覆盖
if (fs.existsSync(configPath)) {
return false;
}
// 创建宽松的默认配置
const defaultConfig = {
"extends": ["html-validate:recommended"],
"rules": {
// 允许 void elements 不自闭合HTML5 标准)
"void-style": "off",
// 允许不需要 SRI
"require-sri": "off",
// 允许尾部空白
"no-trailing-whitespace": "off",
// 允许内联样式(开发时常用)
"no-inline-style": "off",
// 允许重复的 ID某些框架会动态处理
"no-dup-id": "warn",
// 允许未使用的 disable 指令
"no-unused-disable": "off"
}
};
try {
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2), "utf8");
return true;
} catch (error) {
// 创建失败不影响检查,使用默认规则
return false;
}
}
/**
* 运行 HTML 语法检查
* @param {string} projectId 项目ID
* @param {string} projectPath 项目路径
* @param {number} timeoutMs 超时时间
* @returns {Promise<Object>}
*/
async function runHtmlCheck(projectId, projectPath, timeoutMs) {
return new Promise((resolve) => {
const startTime = Date.now();
// 查找 HTML 文件
const htmlFiles = findHtmlFiles(projectPath);
if (htmlFiles.length === 0) {
log(projectId, "INFO", "Skip HTML syntax check: no HTML files found", {
projectId,
});
resolve({ passed: true, method: "html-skipped" });
return;
}
// 确保有宽松的验证配置
const configCreated = ensureHtmlValidateConfig(projectPath);
if (configCreated) {
log(projectId, "INFO", "Automatically create宽松的 HTML 验证配置", {
projectId,
configPath: ".htmlvalidate.json",
});
}
// 使用 html-validate 进行 HTML 语法检查
const args = [
"--yes",
"html-validate",
...htmlFiles,
"--formatter=text",
];
log(projectId, "INFO", "Run HTML syntax check", {
projectId,
htmlFiles,
fileCount: htmlFiles.length,
});
const child = spawn("npx", args, {
cwd: projectPath,
shell: true,
timeout: timeoutMs,
});
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
const duration = Date.now() - startTime;
if (code === 0) {
log(projectId, "INFO", "HTML syntax check passed", {
projectId,
duration,
fileCount: htmlFiles.length,
});
resolve({ passed: true, method: "html", duration });
} else {
const errorOutput = stdout || stderr;
const errorSummary = extractHtmlErrors(errorOutput);
log(projectId, "ERROR", "HTML syntax check failed", {
projectId,
code,
duration,
errorSummary,
});
resolve({
passed: false,
method: "html",
error: errorSummary,
fullOutput: errorOutput.substring(0, 2000),
duration,
});
}
});
child.on("error", (error) => {
log(projectId, "WARN", "HTML check execution failed", {
projectId,
error: error.message,
});
// 执行失败不阻止启动
resolve({ passed: true, method: "html-error", error: error.message });
});
// 超时处理
setTimeout(() => {
try {
child.kill();
log(projectId, "WARN", "HTML check timeout", {
projectId,
timeoutMs,
});
resolve({ passed: true, method: "html-timeout" });
} catch (e) {
resolve({ passed: true, method: "html-timeout-error" });
}
}, timeoutMs);
});
}
/**
* 查找 HTML 文件
* @param {string} projectPath 项目路径
* @returns {Array<string>} HTML 文件相对路径列表
*/
function findHtmlFiles(projectPath) {
const htmlFiles = [];
// 常见的 HTML 文件位置
const possibleLocations = [
"index.html",
"public/index.html",
"src/index.html",
"dist/index.html",
];
// 检查常见位置
for (const location of possibleLocations) {
const fullPath = path.join(projectPath, location);
if (fs.existsSync(fullPath)) {
htmlFiles.push(location);
}
}
// 如果没找到,递归搜索 public 和 src 目录
if (htmlFiles.length === 0) {
const dirsToSearch = ["public", "src", "."];
for (const dir of dirsToSearch) {
const dirPath = path.join(projectPath, dir);
if (fs.existsSync(dirPath)) {
const found = searchHtmlFilesInDir(dirPath, projectPath, 2);
htmlFiles.push(...found);
}
}
}
// 去重
return [...new Set(htmlFiles)];
}
/**
* 递归搜索目录中的 HTML 文件
* @param {string} dir 要搜索的目录
* @param {string} basePath 基础路径(用于生成相对路径)
* @param {number} maxDepth 最大搜索深度
* @returns {Array<string>} HTML 文件相对路径列表
*/
function searchHtmlFilesInDir(dir, basePath, maxDepth = 2) {
const htmlFiles = [];
const excludeDirs = ["node_modules", ".git", "dist", "build", ".next", ".nuxt"];
function search(currentDir, depth) {
if (depth > maxDepth) return;
try {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.isDirectory()) {
if (!excludeDirs.includes(entry.name)) {
search(fullPath, depth + 1);
}
} else if (entry.isFile() && entry.name.endsWith(".html")) {
// 生成相对路径
const relativePath = path.relative(basePath, fullPath);
htmlFiles.push(relativePath);
}
}
} catch (error) {
// 忽略读取错误
}
}
search(dir, 0);
return htmlFiles;
}
/**
* 从 HTML 错误输出中提取关键错误信息
* @param {string} output 错误输出
* @returns {string} 错误摘要
*/
function extractHtmlErrors(output) {
if (!output) return "Unknown error";
const lines = output.split("\n");
const errorLines = [];
for (const line of lines) {
const trimmed = line.trim();
// 提取 html-validate 的错误行
// 格式: "error: ..." 或 "✖ ..." 或包含文件路径和行号
if (
trimmed.includes("error:") ||
trimmed.includes("✖") ||
/\.html:\d+:\d+/.test(trimmed) ||
trimmed.includes("Element") ||
trimmed.includes("Attribute")
) {
errorLines.push(trimmed);
if (errorLines.length >= 10) break; // 保留前10个错误
}
}
if (errorLines.length > 0) {
return errorLines.join("\n");
}
// 如果没有找到特定格式的错误,返回前几行
return lines.slice(0, 15).join("\n").trim().substring(0, 800);
}
/**
* 从 TypeScript 错误输出中提取关键错误信息
* @param {string} output 错误输出
* @returns {string} 错误摘要
*/
function extractTypeScriptErrors(output) {
if (!output) return "Unknown error";
const lines = output.split("\n");
const errorLines = [];
for (const line of lines) {
// 提取错误行(包含文件路径和错误信息)
if (line.includes("error TS") || line.includes("): error")) {
errorLines.push(line.trim());
if (errorLines.length >= 5) break; // 只保留前5个错误
}
}
if (errorLines.length > 0) {
return errorLines.join("\n");
}
// 如果没有找到特定格式的错误,返回前几行
return lines.slice(0, 10).join("\n").trim().substring(0, 500);
}
module.exports = {
runSyntaxCheck,
detectProjectType,
findHtmlFiles,
ensureHtmlValidateConfig,
};

View File

@@ -0,0 +1,2 @@
[env]
CMAKE_CUDA_ARCHITECTURES = "60-real;61-real;62-real;70-real;75-real;80-real;86-real"

View File

@@ -0,0 +1,38 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/rust
{
"name": "Rust",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye",
"features": {
"ghcr.io/devcontainers/features/go:1": {},
"ghcr.io/devcontainers-community/features/deno:1": {},
"ghcr.io/va-h/devcontainers-features/uv:1": {},
"ghcr.io/devcontainers-extra/features/curl-apt-get:1": {},
"ghcr.io/devcontainers-extra/features/wget-apt-get:1": {}
}
// Use 'mounts' to make the cargo cache persistent in a Docker Volume.
// "mounts": [
// {
// "source": "devcontainer-cargo-cache-${devcontainerId}",
// "target": "/usr/local/cargo",
// "type": "volume"
// }
// ]
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Use 'postCreateCommand' to run commands after the container is created.
// "postCreateCommand": "rustc --version",
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View File

@@ -0,0 +1,74 @@
# Git
.git
.gitignore
.github
# IDE
.vscode
.idea
*.swp
*.swo
*~
# Build artifacts
target/
dist/
*.exe
*.dll
*.so
*.dylib
# Temp files
temp/
tmp/
*.tmp
*.log
logs/
# Cargo
.cargo/
# Docs
*.md
README*
CHANGELOG*
LICENSE*
LICENSE-*
# Config
.env*
!.env.example
# Tests
**/tests/
benches/
examples/
fixtures/
# Misc
.DS_Store
Thumb.db
# Project specific
data/
assets/
scripts/
spec/
rmcp_code/
.cursor/
.devcontainer/
.kiro/
.trae/
# CI/CD
.github/workflows/
# Config files
.pre-commit-config.yaml
_typos.toml
cliff.toml
deny.toml
# Keep essential files
!Cargo.toml
!Cargo.lock

View File

@@ -0,0 +1,479 @@
# This file was autogenerated by dist: https://axodotdev.github.io/cargo-dist
#
# Copyright 2022-2024, axodotdev
# SPDX-License-Identifier: MIT or Apache-2.0
#
# CI that:
#
# * checks for a Git Tag that looks like a release
# * builds artifacts with dist (archives, installers, hashes)
# * uploads those artifacts to temporary workflow zip
# * on success, uploads the artifacts to a GitHub Release
#
# Note that the GitHub Release will be created with a generated
# title/body based on your changelogs.
name: Release
permissions:
"contents": "write"
# This task will run whenever you push a git tag that looks like a version
# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
#
# If PACKAGE_NAME is specified, then the announcement will be for that
# package (erroring out if it doesn't have the given version or isn't dist-able).
#
# If PACKAGE_NAME isn't specified, then the announcement will be for all
# (dist-able) packages in the workspace with that version (this mode is
# intended for workspaces with only one dist-able package, or with all dist-able
# packages versioned/released in lockstep).
#
# If you push multiple tags at once, separate instances of this workflow will
# spin up, creating an independent announcement for each one. However, GitHub
# will hard limit this to 3 tags per commit, as it will assume more tags is a
# mistake.
#
# If there's a prerelease-style suffix to the version, then the release(s)
# will be marked as a prerelease.
on:
pull_request:
push:
tags:
- '**[0-9]+.[0-9]+.[0-9]+*'
jobs:
# Run 'dist plan' (or host) to determine what tasks we need to do
plan:
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.plan.outputs.manifest }}
tag: ${{ !github.event.pull_request && github.ref_name || '' }}
tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
publishing: ${{ !github.event.pull_request }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install dist
# we specify bash to get pipefail; it guards against the `curl` command
# failing. otherwise `sh` won't catch that `curl` returned non-0
shell: bash
run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.30.3/cargo-dist-installer.sh | sh"
- name: Cache dist
uses: actions/upload-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/dist
# sure would be cool if github gave us proper conditionals...
# so here's a doubly-nested ternary-via-truthiness to try to provide the best possible
# functionality based on whether this is a pull_request, and whether it's from a fork.
# (PRs run on the *source* but secrets are usually on the *target* -- that's *good*
# but also really annoying to build CI around when it needs secrets to work right.)
- id: plan
run: |
dist ${{ (!github.event.pull_request && format('host --steps=create --tag={0}', github.ref_name)) || 'plan' }} --output-format=json > plan-dist-manifest.json
echo "dist ran successfully"
cat plan-dist-manifest.json
echo "manifest=$(jq -c "." plan-dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v4
with:
name: artifacts-plan-dist-manifest
path: plan-dist-manifest.json
# Build and packages all the platform-specific things
build-local-artifacts:
name: build-local-artifacts (${{ join(matrix.targets, ', ') }})
# Let the initial task tell us to not run (currently very blunt)
needs:
- plan
if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
strategy:
fail-fast: false
# Target platforms/runners are computed by dist in create-release.
# Each member of the matrix has the following arguments:
#
# - runner: the github runner
# - dist-args: cli flags to pass to dist
# - install-dist: expression to run to install dist on the runner
#
# Typically there will be:
# - 1 "global" task that builds universal installers
# - N "local" tasks that build each platform's binaries and platform-specific installers
matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
runs-on: ${{ matrix.runner }}
container: ${{ matrix.container && matrix.container.image || null }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
steps:
- name: enable windows longpaths
run: |
git config --global core.longpaths true
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install Rust non-interactively if not already installed
if: ${{ matrix.container }}
run: |
if ! command -v cargo > /dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
fi
- name: Install dist
run: ${{ matrix.install_dist.run }}
# Get the dist-manifest
- name: Fetch local artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- name: Install dependencies
run: |
${{ matrix.packages_install }}
- name: Build artifacts
run: |
# Actually do builds and make zips and whatnot
dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
echo "dist ran successfully"
- id: cargo-dist
name: Post-build
# We force bash here just because github makes it really hard to get values up
# to "real" actions without writing to env-vars, and writing to env-vars has
# inconsistent syntax between shell and powershell.
shell: bash
run: |
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
dist print-upload-files-from-manifest --manifest dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v4
with:
name: artifacts-build-local-${{ join(matrix.targets, '_') }}
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Build and package all the platform-agnostic(ish) things
build-global-artifacts:
needs:
- plan
- build-local-artifacts
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_MANIFEST_NAME: target/distrib/global-dist-manifest.json
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Get all the local artifacts for the global tasks to use (for e.g. checksums)
- name: Fetch local artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: cargo-dist
shell: bash
run: |
dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
echo "dist ran successfully"
# Parse out what we just built and upload it to scratch storage
echo "paths<<EOF" >> "$GITHUB_OUTPUT"
jq --raw-output ".upload_files[]" dist-manifest.json >> "$GITHUB_OUTPUT"
echo "EOF" >> "$GITHUB_OUTPUT"
cp dist-manifest.json "$BUILD_MANIFEST_NAME"
- name: "Upload artifacts"
uses: actions/upload-artifact@v4
with:
name: artifacts-build-global
path: |
${{ steps.cargo-dist.outputs.paths }}
${{ env.BUILD_MANIFEST_NAME }}
# Determines if we should publish/announce
host:
needs:
- plan
- build-local-artifacts
- build-global-artifacts
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
runs-on: "ubuntu-22.04"
outputs:
val: ${{ steps.host.outputs.manifest }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive
- name: Install cached dist
uses: actions/download-artifact@v4
with:
name: cargo-dist-cache
path: ~/.cargo/bin/
- run: chmod +x ~/.cargo/bin/dist
# Fetch artifacts from scratch-storage
- name: Fetch artifacts
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: target/distrib/
merge-multiple: true
- id: host
shell: bash
run: |
dist host ${{ needs.plan.outputs.tag-flag }} --steps=upload --steps=release --output-format=json > dist-manifest.json
echo "artifacts uploaded and released successfully"
cat dist-manifest.json
echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
- name: "Upload dist-manifest.json"
uses: actions/upload-artifact@v4
with:
# Overwrite the previous copy
name: artifacts-dist-manifest
path: dist-manifest.json
# Create a GitHub Release while uploading all files to it
- name: "Download GitHub Artifacts"
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: artifacts
merge-multiple: true
- name: Cleanup
run: |
# Remove the granular manifests
rm -f artifacts/*-dist-manifest.json
- name: Create GitHub Release
env:
PRERELEASE_FLAG: "${{ fromJson(steps.host.outputs.manifest).announcement_is_prerelease && '--prerelease' || '' }}"
ANNOUNCEMENT_TITLE: "${{ fromJson(steps.host.outputs.manifest).announcement_title }}"
ANNOUNCEMENT_BODY: "${{ fromJson(steps.host.outputs.manifest).announcement_github_body }}"
RELEASE_COMMIT: "${{ github.sha }}"
run: |
# Write and read notes from a file to avoid quoting breaking things
echo "$ANNOUNCEMENT_BODY" > $RUNNER_TEMP/notes.txt
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
sync-to-oss:
name: Sync release assets to Alibaba Cloud OSS
needs:
- plan
- host
runs-on: "ubuntu-22.04"
# Only run during actual release, not in PR mode
if: ${{ needs.plan.outputs.publishing == 'true' }}
continue-on-error: true
outputs:
oss_upload_success: ${{ steps.verify.outputs.oss_upload_success }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.plan.outputs.tag }}
steps:
- name: Install ossutil v2
run: |
curl -fSL -o /tmp/ossutil.zip \
"https://gosspublic.alicdn.com/ossutil/v2/2.2.0/ossutil-2.2.0-linux-amd64.zip"
unzip -q /tmp/ossutil.zip -d /tmp
sudo mv /tmp/ossutil-2.2.0-linux-amd64/ossutil /usr/local/bin/
ossutil version
- name: Download release assets
run: |
mkdir -p /tmp/release-assets
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir /tmp/release-assets
echo "==> Downloaded assets:"
ls -lh /tmp/release-assets/
- name: Upload assets to OSS
env:
OSS_ACCESS_KEY_ID: ${{ secrets.OSS_ACCESS_KEY_ID }}
OSS_ACCESS_KEY_SECRET: ${{ secrets.OSS_ACCESS_KEY_SECRET }}
run: |
OSS_ENDPOINT="https://oss-rg-china-mainland.aliyuncs.com"
OSS_REGION="rg-china-mainland"
OSS_BUCKET="oss://nuwa-packages"
OSS_PREFIX="mcp-stdio-proxy/${TAG}"
for file in /tmp/release-assets/*.tar.xz /tmp/release-assets/*.zip; do
if [ -f "$file" ]; then
filename=$(basename "$file")
echo "Uploading: ${filename}"
ossutil cp --force "$file" "${OSS_BUCKET}/${OSS_PREFIX}/${filename}" \
--endpoint "$OSS_ENDPOINT" \
--region "$OSS_REGION" \
--access-key-id "$OSS_ACCESS_KEY_ID" \
--access-key-secret "$OSS_ACCESS_KEY_SECRET"
fi
done
- name: Verify OSS upload
id: verify
run: |
VERIFY_URL="https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/mcp-stdio-proxy/${TAG}/mcp-stdio-proxy-x86_64-apple-darwin.tar.xz"
HTTP_STATUS=$(curl -sS -o /tmp/verify.txt -w "%{http_code}" "$VERIFY_URL")
if [ "$HTTP_STATUS" = "200" ]; then
echo "==> OSS verification passed (HTTP ${HTTP_STATUS})"
echo "oss_upload_success=true" >> "$GITHUB_OUTPUT"
else
echo "::warning::OSS verification failed: ${VERIFY_URL} returned HTTP ${HTTP_STATUS}"
echo "oss_upload_success=false" >> "$GITHUB_OUTPUT"
fi
modify-npm-packages:
name: Modify npm package download URLs
needs:
- plan
- sync-to-oss
runs-on: "ubuntu-22.04"
# Only run during publishing
if: ${{ needs.plan.outputs.publishing == 'true' }}
outputs:
modified: ${{ steps.modify.outputs.modified }}
env:
PLAN: ${{ needs.plan.outputs.val }}
TAG: ${{ needs.plan.outputs.tag }}
OSS_SYNC_SUCCESS: ${{ needs.sync-to-oss.result == 'success' && needs.sync-to-oss.outputs.oss_upload_success == 'true' }}
steps:
- name: Fetch npm packages
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: npm/
merge-multiple: true
- id: modify
name: Modify npm package URLs
run: |
GITHUB_URL="https://github.com/${GITHUB_REPOSITORY}/releases/download/${TAG}"
OSS_URL="https://nuwa-packages.oss-rg-china-mainland.aliyuncs.com/mcp-stdio-proxy/${TAG}"
# 检查 OSS 同步是否成功
if [ "$OSS_SYNC_SUCCESS" != "true" ]; then
echo "==> OSS sync failed or was skipped, using original packages"
echo "modified=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "==> OSS sync successful, modifying npm packages to use OSS URLs"
echo "modified=true" >> "$GITHUB_OUTPUT"
# 查找 npm 包并处理
for release in $(echo "$PLAN" | jq --compact-output '.releases[]'); do
# 从 release 对象中提取 artifacts 数组,并查找 npm 包
pkg=$(echo "$release" | jq -r '.artifacts[] | select(. | test("-npm-package\\.tar\\.gz$"))')
if [ -n "$pkg" ]; then
echo "==> Processing: $pkg"
# 创建临时目录
mkdir -p temp-workspace
# 解压 npm 包
tar -xzf "npm/${pkg}" -C temp-workspace
# 修改 package.json 中的 artifactDownloadUrl
sed -i "s|\"artifactDownloadUrl\": \"${GITHUB_URL}\"|\"artifactDownloadUrl\": \"${OSS_URL}\"|g" temp-workspace/package/package.json
# 验证修改
echo "==> Modified artifactDownloadUrl:"
grep -A1 "artifactDownloadUrl" temp-workspace/package/package.json || true
# 重新打包
tar -czf "npm/${pkg}" -C temp-workspace package
rm -rf temp-workspace
fi
done
- name: Upload modified npm packages
if: ${{ steps.modify.outputs.modified == 'true' }}
uses: actions/upload-artifact@v4
with:
name: artifacts-npm-packages
path: npm/*-npm-package.tar.gz
if-no-files-found: error
publish-npm:
needs:
- plan
- host
- modify-npm-packages
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PLAN: ${{ needs.plan.outputs.val }}
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
steps:
# Try to fetch modified npm packages (with OSS URLs)
- name: Fetch modified npm packages
id: fetch-modified
uses: actions/download-artifact@v4
continue-on-error: true
with:
name: artifacts-npm-packages
path: npm/
# If modified packages don't exist, fetch original packages
- name: Fetch original npm packages
if: ${{ steps.fetch-modified.outcome == 'failure' || steps.fetch-modified.outcome == 'skipped' || needs.modify-npm-packages.outputs.modified != 'true' }}
uses: actions/download-artifact@v4
with:
pattern: artifacts-*
path: npm/
merge-multiple: true
- uses: actions/setup-node@v4
with:
node-version: '20.x'
registry-url: 'https://registry.npmjs.org'
- run: |
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith("-npm-package.tar.gz")] | any)'); do
pkg=$(echo "$release" | jq '.artifacts[] | select(endswith("-npm-package.tar.gz"))' --raw-output)
npm publish --access public "./npm/${pkg}"
done
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
announce:
needs:
- plan
- host
- publish-npm
# sync-to-oss 和 modify-npm-packages 不需要等待(允许失败)
# use "always() && ..." to allow us to wait for all publish jobs while
# still allowing individual publish jobs to skip themselves (for prereleases).
# "host" however must run to completion, no skipping allowed!
if: ${{ always() && needs.host.result == 'success' && (needs.publish-npm.result == 'skipped' || needs.publish-npm.result == 'success') }}
runs-on: "ubuntu-22.04"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
submodules: recursive

53
qiming-mcp-proxy/.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
/target
.idea
.vscode
target
.DS_Store
**/target
mcp-proxy/logs
logs
.venv
.claude
/tmp
packages
document-parser/data/*
scripts/temp/*
data/*
temp/*
server.log
:memory:/*
document-parser/:memory:/*
document-parser/temp/*
models/*
dist/
voice-cli/temp/*
voice-cli/cluster_metadata/*
voice-cli/shared-voice-cli/*
voice-cli/:memory:/*
cluster_metadata
*.pid
voice-cli/build/*
voice-cli/data/*
voice-cli/server-config.yml
voice-cli/server_debug.log
voice-cli/test_audio.txt
voice-cli/checkpoints/*
mcp-proxy/tmp/*
fastembed/.fastembed_cache
.fastembed_cache/
voice-cli/models/
# Environment files
.env
.env.local
.env.*.local
# Internal development tools and docs
.qoder/
spec/
mcp-proxy/docs/

View File

@@ -0,0 +1,61 @@
fail_fast: false
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: check-byte-order-marker
- id: check-case-conflict
- id: check-merge-conflict
- id: check-symlinks
- id: check-yaml
- id: end-of-file-fixer
- id: mixed-line-ending
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 22.10.0
hooks:
- id: black
- repo: local
hooks:
- id: cargo-fmt
name: cargo fmt
description: Format files with rustfmt.
entry: bash -c 'cargo fmt -- --check'
language: rust
files: \.rs$
args: []
- id: cargo-deny
name: cargo deny check
description: Check cargo dependencies
entry: bash -c 'cargo deny check -d'
language: rust
files: \.rs$
args: []
- id: typos
name: typos
description: check typo
entry: bash -c 'typos'
language: rust
files: \.*$
pass_filenames: false
- id: cargo-check
name: cargo check
description: Check the package for errors.
entry: bash -c 'cargo check --all'
language: rust
files: \.rs$
pass_filenames: false
- id: cargo-clippy
name: cargo clippy
description: Lint rust sources
entry: bash -c 'cargo clippy --all-targets --all-features --tests --benches -- -D warnings'
language: rust
files: \.rs$
pass_filenames: false
- id: cargo-test
name: cargo test
description: unit test for the project
entry: bash -c 'cargo nextest run --all-features'
language: rust
files: \.rs$
pass_filenames: false

View File

@@ -0,0 +1,175 @@
# cargo-dist 配置完成总结
## ✅ 已完成的配置
### 1. 初始化 cargo-dist
使用官方命令初始化:
```bash
dist init --yes
```
这会自动创建:
- `[profile.dist]``Cargo.toml`(工作区级别)
- `dist-workspace.toml` 配置文件
- `.github/workflows/release.yml` GitHub Actions 工作流
### 2. 配置文件
#### `dist-workspace.toml`
```toml
[workspace]
members = ["cargo:."]
[dist]
cargo-dist-version = "0.30.3"
ci = "github"
installers = ["shell", "powershell"]
targets = [
"aarch64-apple-darwin",
"aarch64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-unknown-linux-gnu",
"x86_64-pc-windows-msvc"
]
```
#### 各子项目的 `Cargo.toml` 添加
```toml
[package]
repository = "https://github.com/nuwax-ai/mcp-proxy"
```
已更新:
-`mcp-proxy/Cargo.toml` (已有 repository)
-`document-parser/Cargo.toml`
-`voice-cli/Cargo.toml`
### 3. GitHub Actions 工作流
文件:`.github/workflows/release.yml`
自动执行:
1. **Plan** - 检测到 git tag 时,计算需要构建的内容
2. **Build** - 为每个目标平台构建二进制文件
3. **Host** - 创建 GitHub Release 并上传所有产物
### 4. 产物格式
每次发布会生成:
#### 每个 二进制文件
- `{name}-{target}.tar.xz` (Linux/macOS)
- `{name}-{target}.zip` (Windows)
- 对应的 SHA256 校验和
#### 全局产物
- `{name}-installer.sh` - Shell 安装脚本 (Linux/macOS)
- `{name}-installer.ps1` - PowerShell 安装脚本 (Windows)
- `sha256.sum` - 所有文件的校验和
- `source.tar.gz` - 源码压缩包
## 🚀 发布流程
### 1. 更新版本号
```bash
# 更新各子项目的 version
vim mcp-proxy/Cargo.toml
vim document-parser/Cargo.toml
vim voice-cli/Cargo.toml
```
### 2. 提交并打标签
```bash
git add .
git commit -m "release: v0.2.0"
git tag v0.2.0
git push
git push --tags
```
### 3. 自动触发 GitHub Actions
推送 tag 后GitHub Actions 自动:
- ✅ 构建所有平台
- ✅ 生成安装脚本
- ✅ 创建 Release
- ✅ 上传产物
## 📥 用户安装方式
### 最简单(推荐)
```bash
# Linux/macOS
curl --proto '=https' --tlsv1.2 -sSf \
https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.sh | sh
# Windows PowerShell
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.ps1 | iex
```
### 从 Releases 页面下载
访问https://github.com/nuwax-ai/mcp-proxy/releases
### cargo install
```bash
cargo install mcp-stdio-proxy
```
### cargo-binstall
```bash
cargo binstall mcp-stdio-proxy
```
## 🔧 本地测试发布
```bash
# 查看发布计划
dist plan --tag=v0.2.0
# 构建全局产物(安装脚本)
dist build --tag=v0.2.0 --artifacts=global
# 查看生成的文件
ls -la target/distrib/
```
## 📝 文档
- `RELEASE.md` - 完整的发布指南
- `README.md` - 已更新安装方式章节
- `dist-workspace.toml` - cargo-dist 配置
- `.github/workflows/release.yml` - 自动化工作流
## 🎯 支持的平台
| 平台 | 目标三元组 |
|------|-----------|
| Linux x86_64 | x86_64-unknown-linux-gnu |
| Linux ARM64 | aarch64-unknown-linux-gnu |
| macOS Intel | x86_64-apple-darwin |
| macOS Apple Silicon | aarch64-apple-darwin |
| Windows x86_64 | x86_64-pc-windows-msvc |
## 📦 发布的二进制
1. **mcp-stdio-proxy** - 主 MCP 代理服务
2. **document-parser** - 文档解析服务
3. **voice-cli** - 语音转文字服务
## 🔐 校验和验证
每个 release 都包含 `sha256.sum` 文件:
```bash
# 下载校验和
curl -L -O https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/sha256.sum
# 验证
sha256sum -c sha256.sum
```
## 📚 参考资源
- [cargo-dist 官方文档](https://axodotdev.github.io/cargo-dist/)
- [cargo-dist 配置参考](https://axodotdev.github.io/cargo-dist/book/reference/config.html)
- [项目 GitHub Releases](https://github.com/nuwax-ai/mcp-proxy/releases)

View File

@@ -0,0 +1,62 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/),
and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.1.52] - 2026-02-15
### Fixed
- **PATH 按段去重**: `ensure_runtime_path` 从整段 `starts_with` 改为按分隔符拆段去重,
解决上层多次前置导致的 `node/bin` 重复条目问题
- **config PATH 覆盖**: 用户在 MCP config env 中指定自定义 PATH 时,
仍然确保应用内置运行时路径(`NUWAX_APP_RUNTIME_PATH`)在最前面
- **env value 泄露**: `log_command_details` 不再打印 env 变量的 value
仅输出 key 列表,避免泄露敏感信息(如 token、secret
### Added
- **`mcp-common::diagnostic` 模块**: 提取子进程启动诊断日志为独立模块,
提供 `log_stdio_spawn_context``format_spawn_error``format_path_summary` 等公共函数,
减少业务代码中的日志侵入
- **启动阶段环境诊断**: `env_init` 结束后输出 PATH 摘要和镜像环境变量最终值
- **spawn 失败上下文**: SSE/Stream 子进程 spawn 失败时输出完整错误上下文
command、args、PATH便于快速定位可执行文件找不到的问题
- **build 失败上下文**: `mcp_start_task` 中 SSE/Stream server build 失败时
通过 `anyhow::Context` 附加 MCP ID 和服务类型
- **`ensure_runtime_path` 单元测试**: 新增 5 个测试覆盖前置、部分去重、
全部已存在、双重重复等场景
### Changed
- **server_builder PATH 逻辑简化**: 两侧 `connect_stdio` 从三分支
(继承/config/缺失)统一为:取基础 PATH → `ensure_runtime_path` → 传递给子进程
- **无镜像提示**: 未配置镜像源时输出提示行,而非静默跳过
## [0.1.51] - 2026-02-14
### Changed
- 版本号更新
## [0.1.49] - 2026-02-13
### Added
- **镜像源配置**: 支持通过 `config.yml` 配置 npm/PyPI 镜像源,
环境变量优先级高于配置文件
- **环境变量初始化**: `env_init` 模块统一管理子进程环境(镜像源 + 内置运行时 PATH
- **`UV_INSECURE_HOST` 支持**: HTTP 类型的 PyPI 镜像自动提取 host 并设置
## [0.1.48] - 2026-02-12
### Added
- **跨平台进程管理**: `process_compat` 模块提供 `wrap_process_v8` / `wrap_process_v9` 宏,
统一 UnixProcessGroup和 WindowsJobObject + CREATE_NO_WINDOW的进程包装
### Fixed
- Windows 平台隐藏控制台窗口配置

365
qiming-mcp-proxy/CLAUDE.md Normal file
View File

@@ -0,0 +1,365 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Building and Testing
```bash
# Build all workspace crates
cargo build
# Build specific crate
cargo build -p mcp-proxy
cargo build -p voice-cli
cargo build -p document-parser
cargo build -p oss-client
# Build in release mode
cargo build --release
# Run tests for all crates
cargo test
# Run tests for specific crate
cargo test -p mcp-proxy
cargo test -p voice-cli
# Run clippy for linting
cargo clippy --all-targets --all-features
# Format code
cargo fmt
# Check formatting
cargo fmt --check
```
### Cross-Platform Building (using Docker)
```bash
# Build document-parser for Linux x86_64
make build-document-parser-x86_64
# Build document-parser for Linux ARM64
make build-document-parser-arm64
# Build voice-cli for Linux x86_64
make build-voice-cli-x86_64
# Build all components for x86_64
make build-all-x86_64
# Build Docker runtime image
make build-image
# Run Docker container
make run
```
### Service-Specific Commands
**Document Parser:**
```bash
# Initialize Python environment
cd document-parser && cargo run --bin document-parser -- uv-init
# Check environment status
cd document-parser && cargo run --bin document-parser -- check
# Start server
cd document-parser && cargo run --bin document-parser -- server
# Troubleshoot issues
cd document-parser && cargo run --bin document-parser -- troubleshoot
```
**Voice CLI:**
```bash
# Initialize server configuration
cd voice-cli && cargo run --bin voice-cli -- server init
# Run voice server
cd voice-cli && cargo run --bin voice-cli -- server run
# List Whisper models
cd voice-cli && cargo run --bin voice-cli -- model list
# Download model
cd voice-cli && cargo run --bin voice-cli -- model download tiny
```
**MCP Proxy:**
```bash
# Start MCP proxy server
cd mcp-proxy && cargo run --bin mcp-proxy
```
## Architecture Overview
This is a Rust workspace implementing an MCP (Model Context Protocol) proxy system with multiple services:
### Core Services
**mcp-proxy**: Main MCP proxy service implementing SSE protocol
- Provides HTTP API for MCP service management
- Handles dynamic plugin loading and configuration
- Implements Server-Sent Events for real-time communication
- Uses `rmcp` crate for MCP protocol implementation
**document-parser**: High-performance document parsing service
- Multi-format support: PDF, Word, Excel, PowerPoint via MinerU/MarkItDown
- Python-based with uv dependency management
- GPU acceleration support with CUDA
- Automatic virtual environment management
**voice-cli**: Speech-to-text service with TTS capabilities
- Whisper model integration for transcription
- Python-based TTS service with uv management
- Apalis-based async task queue
- FFmpeg integration for metadata extraction
**oss-client**: Lightweight Alibaba Cloud OSS client
- Simple interface for object storage operations
- Workspace dependency for other services
### Key Integrations
**Workspace Management**: All dependencies managed through workspace Cargo.toml with centralized versioning. Sub-crates use `{ workspace = true }` for dependency references.
**Async Processing**: Uses `tokio` runtime throughout. Task processing via `apalis` with SQLite persistence for voice transcription and TTS tasks.
**HTTP Framework**: `axum` with `tower` middleware for all web services. OpenAPI documentation via `utoipa`.
**Error Handling**: Consistent error handling with `anyhow` for application code and `thiserror` for library code.
**Logging**: Structured logging with `tracing` and `tracing-subscriber`. Daily log rotation with `tracing-appender`.
**FFmpeg Integration**: Lightweight FFmpeg command execution via `ffmpeg-sidecar` for media metadata extraction. System FFmpeg installation required but provides graceful fallback.
**Python Integration**: Both `document-parser` and `voice-cli` use Python services with `uv` for dependency management and virtual environment handling:
- Automatic virtual environment creation in `./venv/`
- uv package manager for fast Python dependency installation
- CUDA GPU acceleration support (optional)
- Graceful degradation if Python/uv unavailable
**Task Queue & Persistence**: Voice services use `apalis` for background task processing:
- SQLite-based persistence for task state tracking
- Task retry mechanisms with exponential backoff
- Support for task prioritization and status monitoring
- Worker management with resource limits
### Configuration System
All services use hierarchical configuration:
1. Default values in code
2. Configuration files (YAML/JSON/TOML)
3. Environment variables with service prefixes
4. Command-line arguments
### Development Standards
**Code Organization**: Strict workspace structure - no code in root directory. All implementation in sub-crates with clear module boundaries.
**Formatting & Linting**:
- Line length: 100 characters
- 4-space indentation (no tabs)
- Always run `cargo fmt` and `cargo clippy` before commits
- Use `cargo audit` to check for security vulnerabilities
- Use `typos-cli` to check spelling
**Error Handling**:
- Prefer `anyhow` for application code, `thiserror` for libraries
- Avoid `unwrap()` except in tests
- Use `?` operator for error propagation
- Add contextual error messages with `anyhow::Context`
- Never include sensitive data in error messages
**Concurrency**:
- Use `tokio` for async, `Arc<Mutex<T>>` or `Arc<RwLock<T>>` for shared state
- Avoid blocking operations in async contexts
- Use `tokio::spawn` for creating concurrent tasks
**Memory Management**:
- Prefer borrowing over ownership
- **Use `dashmap` for concurrent hashmaps** instead of `Arc<RwLock<HashMap<_, _>>>` (dashmap provides atomic operations and is more efficient)
- Avoid unnecessary `clone()`, consider `Cow<T>` or reference counting
**Testing**:
- Unit tests alongside implementation code
- Integration tests where appropriate
- Use `assert_eq!`, `assert_ne!` for assertions
- Run specific tests: `cargo test <test_name> -p <crate>`
**Documentation**:
- All public APIs must have documentation comments (`///`)
- Include usage examples in complex API documentation
- Keep README.md and other docs updated
## Cursor Rules Summary
**Development Standards**:
- Line length: 100 characters
- 4-space indentation (no tabs)
- Documentation comments for all public APIs
- Use `cargo fmt` and `cargo clippy` before commits
**Error Handling**:
- `anyhow` for application code, `thiserror` for libraries
- Contextual error messages with `anyhow::Context`
- No sensitive data in error messages
**Module Organization**:
- Clear module responsibilities
- `pub(crate)` for internal visibility
- Re-export public APIs in `lib.rs`
**Dependencies**:
- Centralized workspace dependency management
- Specific versions (no `*`)
- Regular security audits with `cargo audit`
## Build System
The project uses a sophisticated Makefile with Docker buildx for cross-platform compilation:
### Docker Build Commands
```bash
# Check Docker buildx availability
make check-buildx
# Setup buildx builder (if needed)
make setup-buildx
# Build document-parser for specific platforms
make build-document-parser-x86_64
make build-document-parser-arm64
make build-document-parser-multi
# Build voice-cli for specific platforms
make build-voice-cli-x86_64
make build-voice-cli-arm64
make build-voice-cli-multi
# Build all components
make build-all-x86_64
make build-all-arm64
make build-all-multi
# Build and run Docker runtime image
make build-image
make run
```
**Build System Features**:
- **Docker-based builds**: All compilation happens in containers for consistency
- **Multi-platform support**: Linux x86_64 and ARM64 targets
- **Export targets**: Separate build and runtime stages
- **Automated dependency installation**: Python and Rust dependencies managed in containers
- **Output directory**: `./dist/` contains all built binaries organized by platform
## Service-Specific Architecture Details
### Document Parser (`document-parser/`)
- **Core Structure**: `app_state.rs`, `config.rs`, `main.rs`, `lib.rs`
- **Submodules**: `handlers/`, `middleware/`, `models/`, `parsers/`, `processors/`, `services/`, `tests/`, `utils/`
- **Python Integration**: MinerU for PDF parsing, MarkItDown for other formats
- **Virtual Environment**: Auto-managed in `./venv/`, activated via `source ./venv/bin/activate`
- **Server**: Axum-based HTTP server with multipart file upload support
- **Configuration**: YAML/JSON/TOML support with environment variable overrides
### Voice CLI (`voice-cli/`)
- **Core Components**:
- `services/`: Model management, transcription engine, TTS service, task queue
- `server/`: HTTP handlers, routes, middleware configuration
- `models/`: Request/response data structures
- **Whisper Integration**: Model download and management via `voice-toolkit`
- **TTS Service**: Python-based with `uv` dependency management
- **FFmpeg**: Metadata extraction via `ffmpeg-sidecar`
- **Apalis**: Async task processing with SQLite persistence
### MCP Proxy (`mcp-proxy/`)
- **Core Structure**: `config.rs`, `lib.rs`, `main.rs`, `mcp_error.rs`
- **Submodules**: `client/`, `model/`, `proxy/`, `server/`, `tests/`
- **SSE Protocol**: Real-time communication via Server-Sent Events
- **Plugin System**: Dynamic MCP service loading and management
- **HTTP API**: REST endpoints for service management and status checks
## Common Patterns
**Service Initialization**: All services follow similar patterns for configuration loading, logging setup, and graceful shutdown.
**HTTP API Design**: Consistent use of axum extractors, middleware configuration, and OpenAPI documentation.
**Async Task Processing**: Voice services use apalis for background task processing with retry mechanisms and SQLite persistence.
**Python Integration**: Both document-parser and voice-cli use Python services with uv for dependency management and virtual environment handling.
**Configuration Management**: Hierarchical configuration with environment variable overrides and command-line argument integration.
## Single Test Execution Examples
```bash
# Run tests for specific crate
cargo test -p mcp-proxy
cargo test -p voice-cli
cargo test -p document-parser
# Run specific test
cargo test test_extract_basic_metadata -p voice-cli
cargo test <test_name> -p mcp-proxy
# Run tests in release mode
cargo test --release -p mcp-proxy
# Run library tests only (excluding integration tests)
cargo test --lib -p voice-cli
# Run tests with output
cargo test -p mcp-proxy -- --nocapture
```
## Python/uv Environment Management
### For Document Parser:
```bash
cd document-parser
# Initialize Python environment (creates ./venv/)
cargo run --bin document-parser -- uv-init
# Check environment status
cargo run --bin document-parser -- check
# Start server
cargo run --bin document-parser -- server
# Troubleshoot issues
cargo run --bin document-parser -- troubleshoot
```
### For Voice CLI TTS:
```bash
cd voice-cli
# Install uv package manager
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install Python dependencies
uv sync
# Run TTS service directly
python3 tts_service.py --help
```
## Dependencies Management
All dependencies are managed centrally in the workspace `Cargo.toml`:
- Sub-crates use `{ workspace = true }` for dependency references
- Specific versions (no `*` wildcards)
- Centralized feature flags
- Regular security audits with `cargo audit`
Key workspace dependencies:
- `rmcp`: MCP protocol implementation with SSE support
- `tokio`: Async runtime
- `axum`: Web framework with tower middleware
- `tracing`: Structured logging
- `apalis`: Async task queue
- `dashmap`: Concurrent hashmap (preferred over `Arc<RwLock<HashMap>>`)

7822
qiming-mcp-proxy/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

108
qiming-mcp-proxy/Cargo.toml Normal file
View File

@@ -0,0 +1,108 @@
[workspace]
# 排除 fastembedort-sys 不支持部分平台)
members = ["document-parser", "mcp-common", "mcp-proxy", "mcp-sse-proxy", "mcp-streamable-proxy", "oss-client", "voice-cli"]
# 默认只构建 mcp-proxy排除 document-parser 等需要特殊环境的包)
default-members = ["mcp-proxy", "mcp-common", "mcp-sse-proxy", "mcp-streamable-proxy"]
resolver = "2"
exclude = ["fastembed"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[workspace.dependencies]
mcp-proxy = { path = "mcp-proxy" }
oss-client = { path = "oss-client" }
# Note: rmcp 依赖由各子项目独立管理mcp-sse-proxy 用 0.10mcp-streamable-proxy 用 0.12
tokio = { version = "1.48", features = ["macros", "net", "rt", "rt-multi-thread"] }
tokio-util = "0.7"
# Logging and tracing
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
tracing-opentelemetry = "0.32"
opentelemetry = { version = "0.31", features = ["trace"] }
opentelemetry-jaeger = { version = "0.22", features = ["rt-tokio"] }
opentelemetry-semantic-conventions = { version = "0.31", features = ["semconv_experimental"] }
opentelemetry_sdk = "0.31"
opentelemetry-otlp = { version = "0.31", features = ["grpc-tonic"] }
tonic = "0.13"
hostname = "0.4"
uuid = { version = "1.19", features = ["v4", "v7"] }
rand = "0.9"
log = "0.4"
anyhow = "1.0"
chrono = { version = "0.4", features = ["serde", "now"] }
thiserror = "2.0"
axum = { version = "0.8", features = [
"http2",
"query",
"tracing",
"ws",
"multipart",
"macros",
] }
tower = { version = "0.5" }
tower-http = { version = "0.6", features = [
"compression-full",
"cors",
"fs",
"trace",
"limit",
] }
axum-extra = { version = "0.12", features = ["typed-header"] }
utoipa = { version = "5.4", features = ["axum_extras", "chrono", "uuid"] }
utoipa-rapidoc = { version = "6", features = ["axum"] }
utoipa-redoc = { version = "6", features = ["axum"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
dashmap = "6.1"
arc-swap = "1.7"
moka = { version = "0.12", features = ["future"] }
criterion = "0.7"
once_cell = "1.21"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
serde_yaml = "=0.9.33"
serde_with = "3.12"
reqwest = { version = "0.13", features = ["json"] }
http = "1.4"
aliyun-oss-rust-sdk = { version = "0.2", default-features = false, features = ["async"] }
clap = { version = "4.5", features = ["derive", "env"] }
futures = "0.3"
run_code_rmcp = "0.0.35"
derive_builder = "0.20"
bytes = "1.0"
base64 = "0.22"
tempfile = "3.23"
dirs = "6.0"
scopeguard = "1.2"
async-trait = "0.1"
# 自己开发的语音相关工具
voice-toolkit = { version = "0.16", features = ["stt", "audio"] }
# Task queue and persistence for async processing
apalis = { version = "0.7", features = ["tracing", "limit"] }
apalis-sql = { version = "0.7", features = ["sqlite"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "sqlite", "chrono", "uuid"] }
sled = "0.34"
# 可执行文件查找
which = "7.0"
# 国际化支持
rust-i18n = "3"
# Audio format detection and processing
symphonia = { version = "0.5", features = ["all"] }
bincode = "2.0"
tokio-stream = "0.1.18"
backtrace = "0.3"
tracing-futures = "0.2.5"
urlencoding = "2.1.3"
# The profile that 'dist' will build with
[profile.dist]
inherits = "release"
lto = "thin"

42
qiming-mcp-proxy/LICENSE Normal file
View File

@@ -0,0 +1,42 @@
# Dual Licensing
This project is dual-licensed under either:
* **MIT License** - See [LICENSE-MIT](LICENSE-MIT) for details
* **Apache License, Version 2.0** - See [LICENSE-APACHE](LICENSE-APACHE) for details
You may choose either license for your use of this project.
## MIT License Summary
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
## Apache 2.0 License Summary
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
---
Copyright (c) 2025 nuwax-ai

View File

@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law ( such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2025 nuwax-ai
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 nuwax-ai
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

357
qiming-mcp-proxy/Makefile Normal file
View File

@@ -0,0 +1,357 @@
# Makefile for cross-platform compilation of document-parser, voice-cli and mcp-proxy
# 默认目标平台
TARGET_PLATFORM ?= linux/amd64
# Docker 镜像名称
IMAGE_NAME = mcp-proxy-builder
# 输出目录
OUTPUT_DIR = ./dist
# 通用构建函数
define build_target
@echo "🚀 构建 $(1) $(2) 版本..."
@git pull
@mkdir -p $(3)
docker buildx build --platform $(4) --target export --output type=local,dest=$(3) -f docker/Dockerfile.document-parser ..
@echo "$(1) $(2) 版本构建完成"
endef
# 默认目标
.PHONY: all
all: build-document-parser-x86_64
# 创建输出目录
$(OUTPUT_DIR):
@mkdir -p $(OUTPUT_DIR)
# ============================================================================
# Document Parser 构建目标
# ============================================================================
# 构建 document-parser Linux x86_64 版本
.PHONY: build-document-parser-x86_64
build-document-parser-x86_64:
$(call build_target,document-parser,Linux x86_64,./dist/document-parser-x86_64,linux/amd64)
# 构建 document-parser Linux ARM64 版本
.PHONY: build-document-parser-arm64
build-document-parser-arm64:
$(call build_target,document-parser,Linux ARM64,./dist/document-parser-arm64,linux/arm64)
# 构建 document-parser 多平台版本
.PHONY: build-document-parser-multi
build-document-parser-multi: build-document-parser-x86_64 build-document-parser-arm64
# ============================================================================
# Voice CLI 构建目标
# ============================================================================
# 构建 voice-cli Linux x86_64 版本
.PHONY: build-voice-cli-x86_64
build-voice-cli-x86_64:
$(call build_target,voice-cli,Linux x86_64,./dist/voice-cli-x86_64,linux/amd64)
# 构建 voice-cli Linux ARM64 版本
.PHONY: build-voice-cli-arm64
build-voice-cli-arm64:
$(call build_target,voice-cli,Linux ARM64,./dist/voice-cli-arm64,linux/arm64)
# 构建 voice-cli 多平台版本
.PHONY: build-voice-cli-multi
build-voice-cli-multi: build-voice-cli-x86_64 build-voice-cli-arm64
# ============================================================================
# MCP Proxy 构建目标
# ============================================================================
# 构建 mcp-proxy按照当前系统架构
.PHONY: build-mcp-proxy
build-mcp-proxy:
@echo "🚀 构建 mcp-proxy当前系统架构..."
@git pull
@mkdir -p ./dist/mcp-proxy
docker buildx build \
--target export \
--output type=local,dest=./dist/mcp-proxy \
-f docker/Dockerfile.mcp-proxy \
..
@echo "✅ mcp-proxy 构建完成"
# ============================================================================
# 所有组件构建目标
# ============================================================================
# 构建所有组件(当前系统架构)
.PHONY: build-all
build-all: build-document-parser-x86_64 build-voice-cli-x86_64 build-mcp-proxy
# 构建 Docker 镜像(用于运行)
.PHONY: build-image
build-image:
@echo "🚀 构建 mcp-proxy Docker 运行镜像..."
docker buildx build \
--platform $(TARGET_PLATFORM) \
--target runtime \
-t mcp-proxy:latest \
-f docker/Dockerfile.mcp-proxy \
$(shell pwd)
@echo "✅ Docker 镜像构建完成: mcp-proxy:latest"
# 构建 Docker 镜像document-parser
.PHONY: build-image-document-parser
build-image-document-parser:
@echo "🚀 构建 document-parser Docker 运行镜像..."
docker buildx build \
--platform $(TARGET_PLATFORM) \
--target runtime \
-t document-parser:latest \
-f docker/Dockerfile.document-parser \
..
@echo "✅ Docker 镜像构建完成: document-parser:latest"
# 运行 Docker 镜像mcp-proxy
.PHONY: run
run: build-image
@echo "🚀 使用 docker-compose 后台启动 mcp-proxy..."
cd docker && docker-compose up -d
@echo "✅ mcp-proxy 已在后台启动"
@echo "📋 查看日志: cd docker && docker-compose logs -f"
@echo "🛑 停止服务: cd docker && docker-compose down"
@echo "📊 查看状态: cd docker && docker-compose ps"
# 运行 Docker 镜像mcp-proxy前台模式
.PHONY: run-fg
run-fg: build-image
@echo "🚀 使用 docker-compose 前台启动 mcp-proxy..."
cd docker && docker-compose up
# 运行 Docker 镜像document-parser
.PHONY: run-document-parser
run-document-parser:
@echo "🚀 运行 document-parser..."
docker run --rm -p 8080:8080 document-parser:latest
# 检查 Docker buildx 是否可用
.PHONY: check-buildx
check-buildx:
@echo "🔍 检查 Docker buildx 状态..."
@docker buildx version || (echo "❌ Docker buildx 不可用,请确保 Docker 版本支持 buildx" && exit 1)
@docker buildx ls
@echo "✅ Docker buildx 可用"
# 创建 buildx builder如果需要
.PHONY: setup-buildx
setup-buildx:
@echo "🔧 设置 Docker buildx builder..."
docker buildx create --name cross-builder --use --bootstrap || true
@echo "✅ Docker buildx builder 设置完成"
# ============================================================================
# MCP 包发布目标
# ============================================================================
# 自动更新所有 MCP 包的版本号(小版本号加一)
.PHONY: mcp-version-update
mcp-version-update:
@echo "🔄 开始更新 MCP 包版本号..."
@echo ""
@# 读取 mcp-common 的当前版本
@COMMON_VERSION=$$(grep '^version = ' mcp-common/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/'); \
COMMON_MAJOR=$$(echo $$COMMON_VERSION | cut -d. -f1); \
COMMON_MINOR=$$(echo $$COMMON_VERSION | cut -d. -f2); \
COMMON_PATCH=$$(echo $$COMMON_VERSION | cut -d. -f3); \
COMMON_NEW_PATCH=$$((COMMON_PATCH + 1)); \
COMMON_NEW_VERSION="$$COMMON_MAJOR.$$COMMON_MINOR.$$COMMON_NEW_PATCH"; \
echo "mcp-common: $$COMMON_VERSION -> $$COMMON_NEW_VERSION"; \
PROXY_VERSION=$$(grep '^version = ' mcp-proxy/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/'); \
PROXY_MAJOR=$$(echo $$PROXY_VERSION | cut -d. -f1); \
PROXY_MINOR=$$(echo $$PROXY_VERSION | cut -d. -f2); \
PROXY_PATCH=$$(echo $$PROXY_VERSION | cut -d. -f3); \
PROXY_NEW_PATCH=$$((PROXY_PATCH + 1)); \
PROXY_NEW_VERSION="$$PROXY_MAJOR.$$PROXY_MINOR.$$PROXY_NEW_PATCH"; \
echo "mcp-stdio-proxy: $$PROXY_VERSION -> $$PROXY_NEW_VERSION"; \
echo ""; \
echo "1⃣ 更新 mcp-common 版本..."; \
sed -i.bak "s/^version = \"$$COMMON_VERSION\"/version = \"$$COMMON_NEW_VERSION\"/" mcp-common/Cargo.toml && rm mcp-common/Cargo.toml.bak; \
echo "2⃣ 更新 mcp-sse-proxy 版本和依赖..."; \
sed -i.bak "s/^version = \"$$COMMON_VERSION\"/version = \"$$COMMON_NEW_VERSION\"/" mcp-sse-proxy/Cargo.toml && rm mcp-sse-proxy/Cargo.toml.bak; \
sed -i.bak "s/mcp-common = { version = \"$$COMMON_VERSION\"/mcp-common = { version = \"$$COMMON_NEW_VERSION\"/" mcp-sse-proxy/Cargo.toml && rm mcp-sse-proxy/Cargo.toml.bak; \
echo "3⃣ 更新 mcp-streamable-proxy 版本和依赖..."; \
sed -i.bak "s/^version = \"$$COMMON_VERSION\"/version = \"$$COMMON_NEW_VERSION\"/" mcp-streamable-proxy/Cargo.toml && rm mcp-streamable-proxy/Cargo.toml.bak; \
sed -i.bak "s/mcp-common = { version = \"$$COMMON_VERSION\"/mcp-common = { version = \"$$COMMON_NEW_VERSION\"/" mcp-streamable-proxy/Cargo.toml && rm mcp-streamable-proxy/Cargo.toml.bak; \
echo "4⃣ 更新 mcp-stdio-proxy 版本和依赖..."; \
sed -i.bak "s/^version = \"$$PROXY_VERSION\"/version = \"$$PROXY_NEW_VERSION\"/" mcp-proxy/Cargo.toml && rm mcp-proxy/Cargo.toml.bak; \
sed -i.bak "s/mcp-common = { version = \"$$COMMON_VERSION\"/mcp-common = { version = \"$$COMMON_NEW_VERSION\"/" mcp-proxy/Cargo.toml && rm mcp-proxy/Cargo.toml.bak; \
sed -i.bak "s/mcp-streamable-proxy = { version = \"$$COMMON_VERSION\"/mcp-streamable-proxy = { version = \"$$COMMON_NEW_VERSION\"/" mcp-proxy/Cargo.toml && rm mcp-proxy/Cargo.toml.bak; \
sed -i.bak "s/mcp-sse-proxy = { version = \"$$COMMON_VERSION\"/mcp-sse-proxy = { version = \"$$COMMON_NEW_VERSION\"/" mcp-proxy/Cargo.toml && rm mcp-proxy/Cargo.toml.bak; \
echo ""; \
echo "✅ 版本号更新完成!"
# 显示当前 MCP 包的版本号
.PHONY: mcp-version-show
mcp-version-show:
@echo "📋 当前 MCP 包版本号:"
@echo ""
@echo " mcp-common: $$(grep '^version = ' mcp-common/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
@echo " mcp-sse-proxy: $$(grep '^version = ' mcp-sse-proxy/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
@echo " mcp-streamable-proxy: $$(grep '^version = ' mcp-streamable-proxy/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
@echo " mcp-stdio-proxy: $$(grep '^version = ' mcp-proxy/Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')"
@echo ""
@echo "📦 依赖版本号检查:"
@echo ""
@echo " mcp-sse-proxy 依赖的 mcp-common: $$(grep 'mcp-common = { version' mcp-sse-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
@echo " mcp-streamable-proxy 依赖的 mcp-common: $$(grep 'mcp-common = { version' mcp-streamable-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
@echo " mcp-stdio-proxy 依赖的 mcp-common: $$(grep 'mcp-common = { version' mcp-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
@echo " mcp-stdio-proxy 依赖的 mcp-sse-proxy: $$(grep 'mcp-sse-proxy = { version' mcp-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
@echo " mcp-stdio-proxy 依赖的 mcp-streamable-proxy: $$(grep 'mcp-streamable-proxy = { version' mcp-proxy/Cargo.toml | sed 's/.*version = "\([^"]*\)".*/\1/')"
# 发布所有 MCP 相关包(按依赖顺序)
.PHONY: mcp-publish
mcp-publish:
@echo "📦 开始发布 MCP 相关包到 crates.io..."
@echo ""
@echo "1⃣ 发布 mcp-common..."
cd mcp-common && cargo publish
@echo "⏳ 等待 10 秒让 crates.io 索引更新..."
@sleep 10
@echo ""
@echo "2⃣ 发布 mcp-sse-proxy..."
cd mcp-sse-proxy && cargo publish
@echo "⏳ 等待 10 秒让 crates.io 索引更新..."
@sleep 10
@echo ""
@echo "3⃣ 发布 mcp-streamable-proxy..."
cd mcp-streamable-proxy && cargo publish
@echo "⏳ 等待 10 秒让 crates.io 索引更新..."
@sleep 10
@echo ""
@echo "4⃣ 发布 mcp-stdio-proxy..."
cd mcp-proxy && cargo publish
@echo ""
@echo "✅ 所有 MCP 包发布成功!"
# 预览将要发布的 MCP 包dry-run
.PHONY: mcp-publish-dry-run
mcp-publish-dry-run:
@echo "🔍 预览将要发布的 MCP 包..."
@echo ""
@echo "1⃣ mcp-common:"
cd mcp-common && cargo publish --dry-run
@echo ""
@echo "2⃣ mcp-sse-proxy:"
cd mcp-sse-proxy && cargo publish --dry-run
@echo ""
@echo "3⃣ mcp-streamable-proxy:"
cd mcp-streamable-proxy && cargo publish --dry-run
@echo ""
@echo "4⃣ mcp-stdio-proxy:"
cd mcp-proxy && cargo publish --dry-run
@echo ""
@echo "✅ 预览完成(未实际发布)"
# 查看将要发布的文件列表
.PHONY: mcp-package-list
mcp-package-list:
@echo "📋 查看各包将包含的文件..."
@echo ""
@echo "1⃣ mcp-common:"
cd mcp-common && cargo package --list
@echo ""
@echo "2⃣ mcp-sse-proxy:"
cd mcp-sse-proxy && cargo package --list
@echo ""
@echo "3⃣ mcp-streamable-proxy:"
cd mcp-streamable-proxy && cargo package --list
@echo ""
@echo "4⃣ mcp-stdio-proxy:"
cd mcp-proxy && cargo package --list
# 清理构建文件
.PHONY: clean
clean:
@echo "🧹 清理构建文件..."
rm -rf $(OUTPUT_DIR)
@echo "✅ 清理完成"
# 清理 Docker 镜像
.PHONY: clean-images
clean-images:
@echo "🧹 清理 Docker 镜像..."
docker rmi $(IMAGE_NAME):latest 2>/dev/null || true
docker builder prune -f
@echo "✅ Docker 镜像清理完成"
# 显示帮助信息
.PHONY: help
help:
@echo "📖 可用的 Make 命令:"
@echo ""
@echo " 📄 Document Parser 构建:"
@echo " make build-document-parser-x86_64 - 构建 document-parser Linux x86_64 版本(默认)"
@echo " make build-document-parser-arm64 - 构建 document-parser Linux ARM64 版本"
@echo " make build-document-parser-multi - 构建 document-parser 多平台版本"
@echo ""
@echo " 🎤 Voice CLI 构建:"
@echo " make build-voice-cli-x86_64 - 构建 voice-cli Linux x86_64 版本"
@echo " make build-voice-cli-arm64 - 构建 voice-cli Linux ARM64 版本"
@echo " make build-voice-cli-multi - 构建 voice-cli 多平台版本"
@echo ""
@echo " 🔌 MCP Proxy 构建:"
@echo " make build-mcp-proxy - 构建 mcp-proxy当前系统架构"
@echo ""
@echo " 🔧 所有组件构建:"
@echo " make build-all - 构建所有组件(当前系统架构)"
@echo ""
@echo " 🐳 Docker 镜像:"
@echo " make build-image - 构建 mcp-proxy Docker 运行镜像"
@echo " make build-image-document-parser - 构建 document-parser Docker 运行镜像"
@echo ""
@echo " 🚀 运行命令:"
@echo " make run - 构建 + 后台启动 mcp-proxydocker-compose -d"
@echo " make run-fg - 构建 + 前台启动 mcp-proxydocker-compose"
@echo " make run-document-parser - 运行 document-parser Docker 镜像"
@echo ""
@echo " 🛠️ 工具命令:"
@echo " make check-buildx - 检查 Docker buildx 状态"
@echo " make setup-buildx - 设置 Docker buildx builder"
@echo ""
@echo " 📦 MCP 发布命令:"
@echo " make mcp-version-show - 显示当前所有 MCP 包的版本号"
@echo " make mcp-version-update - 自动更新版本号(小版本号加一)"
@echo " make mcp-publish - 发布所有 MCP 包到 crates.io按依赖顺序"
@echo " make mcp-publish-dry-run - 预览将要发布的内容(不实际发布)"
@echo " make mcp-package-list - 查看各包将包含的文件列表"
@echo ""
@echo " 🧹 清理命令:"
@echo " make clean - 清理所有构建文件"
@echo " make clean-images - 清理 Docker 镜像"
@echo ""
@echo " ❓ 其他:"
@echo " make help - 显示此帮助信息"
@echo ""
@echo "📝 示例用法:"
@echo " make # 构建 document-parser Linux x86_64 版本"
@echo " make build-mcp-proxy # 构建 mcp-proxy当前架构"
@echo " make build-all # 构建所有组件(当前架构)"
@echo " make build-image # 构建 mcp-proxy Docker 镜像"
@echo " make run # 构建 + 后台启动 mcp-proxy 服务"
@echo " make run-fg # 构建 + 前台启动 mcp-proxy 服务"
@echo " make mcp-version-show # 查看当前版本号"
@echo " make mcp-publish-dry-run # 预览 MCP 发布(建议先运行此命令)"
@echo ""
@echo "📊 输出目录: ./dist/"
@echo " mcp-proxy/ # MCP Proxy 二进制文件(当前架构)"
@echo " document-parser-x86_64/ # Document Parser x86_64 二进制文件"
@echo " document-parser-arm64/ # Document Parser ARM64 二进制文件"
@echo " voice-cli-x86_64/ # Voice CLI x86_64 二进制文件"
@echo " voice-cli-arm64/ # Voice CLI ARM64 二进制文件"
@echo ""
@echo "📁 Docker 目录: ./docker/"
@echo " Dockerfile.mcp-proxy # mcp-proxy Docker 构建文件"
@echo " Dockerfile.document-parser # document-parser/voice-cli Docker 构建文件"
@echo " config.yml # mcp-proxy 默认配置文件"
@echo " docker-compose.yml # Docker Compose 配置文件"

281
qiming-mcp-proxy/README.md Normal file
View File

@@ -0,0 +1,281 @@
# MCP-Proxy Workspace
**[English](README.md)** | **[简体中文](README_zh-CN.md)**
---
# MCP-Proxy Workspace
A comprehensive Rust workspace implementing MCP (Model Context Protocol) proxy system with multiple services including document parsing, voice transcription, and protocol conversion.
[![License: MIT OR Apache-2.0](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-blue.svg)](LICENSE)
[![Rust](https://img.shields.io/badge/rust-1.70%2B-orange.svg)](https://www.rust-lang.org/)
## Workspace Members
| Crate | Version | Description |
|-------|---------|-------------|
| **mcp-common** | 0.1.5 | Shared types and utilities for MCP proxy components |
| **mcp-sse-proxy** | 0.1.5 | SSE (Server-Sent Events) proxy implementation using rmcp 0.10 |
| **mcp-streamable-proxy** | 0.1.5 | Streamable HTTP proxy implementation using rmcp 0.12 |
| **mcp-stdio-proxy** | 0.1.18 | Main MCP proxy server with CLI tool for protocol conversion |
| **document-parser** | 0.1.0 | High-performance multi-format document parsing service |
| **voice-cli** | 0.1.0 | Speech-to-text HTTP service with Whisper model support |
| **oss-client** | 0.1.0 | Lightweight Alibaba Cloud OSS client library |
| **fastembed** | 0.1.0 | Text embedding HTTP service using FastEmbed |
## Quick Start
### Prerequisites
- **Rust**: 1.70 or later (recommended 1.75+)
- **Python**: 3.8+ (for document-parser and voice-cli TTS)
- **uv**: Python package manager (install via `curl -LsSf https://astral.sh/uv/install.sh | sh`)
### Installation
#### Method 1: Pre-built Binaries (Recommended)
**Using installation script (Linux/macOS):**
```bash
# Install mcp-proxy
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.sh | sh
# Install document-parser
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/document-parser-installer.sh | sh
# Install voice-cli
curl --proto '=https' --tlsv1.2 -sSf https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/voice-cli-installer.sh | sh
```
**Using installation script (Windows PowerShell):**
```powershell
# Install mcp-proxy
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/mcp-stdio-proxy-installer.ps1 | iex
# Install document-parser
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/document-parser-installer.ps1 | iex
# Install voice-cli
irm https://github.com/nuwax-ai/mcp-proxy/releases/latest/download/voice-cli-installer.ps1 | iex
```
**Download from GitHub Releases:**
Visit [GitHub Releases](https://github.com/nuwax-ai/mcp-proxy/releases) to download binaries for your platform.
Supported platforms:
- Linux x86_64
- Linux ARM64
- macOS Intel (x86_64)
- macOS Apple Silicon (ARM64)
- Windows x86_64
#### Method 2: cargo install
```bash
cargo install mcp-stdio-proxy
```
#### Method 3: Build from Source
```bash
# Clone repository
git clone https://github.com/nuwax-ai/mcp-proxy.git
cd mcp-proxy
# Build all workspace members
cargo build --release
# Or build specific crates
cargo build -p mcp-proxy
cargo build -p document-parser
cargo build -p voice-cli
```
### MCP Proxy (mcp-stdio-proxy)
The main proxy service that converts SSE/Streamable HTTP to stdio protocol.
```bash
# Install from source
cargo install --path ./mcp-proxy
# Start the proxy server
mcp-proxy
# Convert remote MCP service to stdio
mcp-proxy convert https://example.com/mcp/sse
# Check service status
mcp-proxy check https://example.com/mcp/sse
# Detect protocol type
mcp-proxy detect https://example.com/mcp
```
**See:** [mcp-proxy/README.md](./mcp-proxy/README.md) for detailed documentation.
### Document Parser
High-performance document parsing service supporting PDF, Word, Excel, and PowerPoint.
```bash
cd document-parser
# Initialize Python environment (first time)
document-parser uv-init
# Check environment status
document-parser check
# Start HTTP server
document-parser server
```
**See:** [document-parser/README.md](./document-parser/README.md) for detailed documentation.
### Voice CLI
Speech-to-text HTTP service with Whisper model support.
```bash
cd voice-cli
# Initialize server configuration
voice-cli server init
# Run voice server
voice-cli server run
# List Whisper models
voice-cli model list
# Download model
voice-cli model download tiny
```
**See:** [voice-cli/README.md](./voice-cli/README.md) for detailed documentation.
## Architecture
### Core Services
#### 1. MCP Proxy System
- **mcp-common**: Shared configuration types and utilities
- **mcp-sse-proxy**: SSE protocol support (rmcp 0.10)
- **mcp-streamable-proxy**: Streamable HTTP protocol support (rmcp 0.12)
- **mcp-stdio-proxy**: Main CLI tool for protocol conversion
**Features:**
- Multi-protocol support: SSE, Streamable HTTP, stdio
- Dynamic plugin loading
- Protocol auto-detection and conversion
- OpenTelemetry integration with OTLP
- Background health checks
#### 2. Document Parser
**Features:**
- Multi-format support: PDF (MinerU), Word/Excel/PowerPoint (MarkItDown)
- GPU acceleration via CUDA/sglang (optional)
- Python environment management with uv
- HTTP API with OpenAPI documentation
- OSS integration for cloud storage
#### 3. Voice CLI
**Features:**
- Whisper model integration (tiny/base/small/medium/large)
- Multi-format audio support (MP3, WAV, FLAC, M4A, etc.)
- Apalis-based async task queue with SQLite persistence
- FFmpeg integration for metadata extraction
- **TTS service (TODO - currently has issues)**
#### 4. Utility Libraries
- **oss-client**: Alibaba Cloud OSS client with unified interface
- **fastembed**: Text embedding HTTP service using FastEmbed
## Development
### Build Commands
```bash
# Build all workspace crates
cargo build
# Build specific crate
cargo build -p mcp-proxy
# Build in release mode
cargo build --release
# Run tests for all crates
cargo test
# Run tests for specific crate
cargo test -p mcp-proxy
# Run clippy for linting
cargo clippy --all-targets --all-features
# Format code
cargo fmt
```
### Cross-Platform Building (Docker)
```bash
# Build document-parser for Linux x86_64
make build-document-parser-x86_64
# Build document-parser for Linux ARM64
make build-document-parser-arm64
# Build voice-cli for Linux x86_64
make build-voice-cli-x86_64
# Build all components for x86_64
make build-all-x86_64
# Build Docker runtime image
make build-image
# Run Docker container
make run
```
### Code Style
- Line length: 100 characters
- 4-space indentation (no tabs)
- Use `dashmap` for concurrent hashmaps instead of `Arc<RwLock<HashMap>>`
- Follow KISS and SOLID principles
- "Fail fast" error handling with `anyhow::Context`
## Documentation
- [CLAUDE.md](./CLAUDE.md) - Development guide for contributors
- [mcp-proxy/README.md](./mcp-proxy/README.md) - MCP Proxy documentation
- [document-parser/README.md](./document-parser/README.md) - Document Parser documentation
- [voice-cli/README.md](./voice-cli/README.md) - Voice CLI documentation
- [oss-client/README.md](./oss-client/README.md) - OSS Client documentation
## License
This project is dual-licensed under MIT OR Apache-2.0.
## Contributing
Contributions are welcome! Please feel free to submit issues and pull requests.
- **GitHub Repository**: https://github.com/nuwax-ai/mcp-proxy
- **Issue Tracker**: https://github.com/nuwax-ai/mcp-proxy/issues
- **Discussions**: https://github.com/nuwax-ai/mcp-proxy/discussions
## Related Resources
- [MCP Official Documentation](https://modelcontextprotocol.io/)
- [rmcp - Rust MCP Implementation](https://crates.io/crates/rmcp)
- [MCP Servers List](https://github.com/modelcontextprotocol/servers)

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