Compare commits

...

164 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
1434 changed files with 345749 additions and 1000 deletions

8
.gitignore vendored
View File

@@ -10,6 +10,10 @@
!/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/
@@ -21,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/
@@ -33,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,6 @@
# Cargo 配置文件
# 注意:由于 Cargo 的限制,无法通过 patch 为 crates.io 的包添加 features
# 如果遇到 codex-protocol 的线程安全问题,请:
# 1. 使用 --no-default-features 禁用 codex feature
# 2. 或者等待上游修复

View File

@@ -0,0 +1,101 @@
---
description: Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation.
---
The user input to you can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty).
User input:
$ARGUMENTS
Goal: Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/tasks` has successfully produced a complete `tasks.md`.
STRICTLY READ-ONLY: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
Constitution Authority: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/analyze`.
Execution steps:
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
- SPEC = FEATURE_DIR/spec.md
- PLAN = FEATURE_DIR/plan.md
- TASKS = FEATURE_DIR/tasks.md
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
2. Load artifacts:
- Parse spec.md sections: Overview/Context, Functional Requirements, Non-Functional Requirements, User Stories, Edge Cases (if present).
- Parse plan.md: Architecture/stack choices, Data Model references, Phases, Technical constraints.
- Parse tasks.md: Task IDs, descriptions, phase grouping, parallel markers [P], referenced file paths.
- Load constitution `.specify/memory/constitution.md` for principle validation.
3. Build internal semantic models:
- Requirements inventory: Each functional + non-functional requirement with a stable key (derive slug based on imperative phrase; e.g., "User can upload file" -> `user-can-upload-file`).
- User story/action inventory.
- Task coverage mapping: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases).
- Constitution rule set: Extract principle names and any MUST/SHOULD normative statements.
4. Detection passes:
A. Duplication detection:
- Identify near-duplicate requirements. Mark lower-quality phrasing for consolidation.
B. Ambiguity detection:
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria.
- Flag unresolved placeholders (TODO, TKTK, ???, <placeholder>, etc.).
C. Underspecification:
- Requirements with verbs but missing object or measurable outcome.
- User stories missing acceptance criteria alignment.
- Tasks referencing files or components not defined in spec/plan.
D. Constitution alignment:
- Any requirement or plan element conflicting with a MUST principle.
- Missing mandated sections or quality gates from constitution.
E. Coverage gaps:
- Requirements with zero associated tasks.
- Tasks with no mapped requirement/story.
- Non-functional requirements not reflected in tasks (e.g., performance, security).
F. Inconsistency:
- Terminology drift (same concept named differently across files).
- Data entities referenced in plan but absent in spec (or vice versa).
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note).
- Conflicting requirements (e.g., one requires to use Next.js while other says to use Vue as the framework).
5. Severity assignment heuristic:
- CRITICAL: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality.
- HIGH: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion.
- MEDIUM: Terminology drift, missing non-functional task coverage, underspecified edge case.
- LOW: Style/wording improvements, minor redundancy not affecting execution order.
6. Produce a Markdown report (no file writes) with sections:
### Specification Analysis Report
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|----|----------|----------|-------------|---------|----------------|
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
(Add one row per finding; generate stable IDs prefixed by category initial.)
Additional subsections:
- Coverage Summary Table:
| Requirement Key | Has Task? | Task IDs | Notes |
- Constitution Alignment Issues (if any)
- Unmapped Tasks (if any)
- Metrics:
* Total Requirements
* Total Tasks
* Coverage % (requirements with >=1 task)
* Ambiguity Count
* Duplication Count
* Critical Issues Count
7. At end of report, output a concise Next Actions block:
- If CRITICAL issues exist: Recommend resolving before `/implement`.
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions.
- Provide explicit command suggestions: e.g., "Run /specify with refinement", "Run /plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'".
8. Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
Behavior rules:
- NEVER modify files.
- NEVER hallucinate missing sections—if absent, report them.
- KEEP findings deterministic: if rerun without changes, produce consistent IDs and counts.
- LIMIT total findings in the main table to 50; aggregate remainder in a summarized overflow note.
- If zero issues found, emit a success report with coverage statistics and proceed recommendation.
Context: $ARGUMENTS

View File

@@ -0,0 +1,158 @@
---
description: Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec.
---
The user input to you can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty).
User input:
$ARGUMENTS
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Execution steps:
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --paths-only` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
- `FEATURE_DIR`
- `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `/specify` or verify feature branch environment.
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
Functional Scope & Behavior:
- Core user goals & success criteria
- Explicit out-of-scope declarations
- User roles / personas differentiation
Domain & Data Model:
- Entities, attributes, relationships
- Identity & uniqueness rules
- Lifecycle/state transitions
- Data volume / scale assumptions
Interaction & UX Flow:
- Critical user journeys / sequences
- Error/empty/loading states
- Accessibility or localization notes
Non-Functional Quality Attributes:
- Performance (latency, throughput targets)
- Scalability (horizontal/vertical, limits)
- Reliability & availability (uptime, recovery expectations)
- Observability (logging, metrics, tracing signals)
- Security & privacy (authN/Z, data protection, threat assumptions)
- Compliance / regulatory constraints (if any)
Integration & External Dependencies:
- External services/APIs and failure modes
- Data import/export formats
- Protocol/versioning assumptions
Edge Cases & Failure Handling:
- Negative scenarios
- Rate limiting / throttling
- Conflict resolution (e.g., concurrent edits)
Constraints & Tradeoffs:
- Technical constraints (language, storage, hosting)
- Explicit tradeoffs or rejected alternatives
Terminology & Consistency:
- Canonical glossary terms
- Avoided synonyms / deprecated terms
Completion Signals:
- Acceptance criteria testability
- Measurable Definition of Done style indicators
Misc / Placeholders:
- TODO markers / unresolved decisions
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
For each category with Partial or Missing status, add a candidate question opportunity unless:
- Clarification would not materially change implementation or validation strategy
- Information is better deferred to planning phase (note internally)
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
- Maximum of 5 total questions across the whole session.
- Each question must be answerable with EITHER:
* A short multiplechoice selection (25 distinct, mutually exclusive options), OR
* A one-word / shortphrase answer (explicitly constrain: "Answer in <=5 words").
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
4. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
- For multiplechoice questions render options as a Markdown table:
| Option | Description |
|--------|-------------|
| A | <Option A description> |
| B | <Option B description> |
| C | <Option C description> | (add D/E as needed up to 5)
| Short | Provide a different short answer (<=5 words) | (Include only if free-form alternative is appropriate)
- For shortanswer style (no meaningful discrete options), output a single line after the question: `Format: Short answer (<=5 words)`.
- After the user answers:
* Validate the answer maps to one option or fits the <=5 word constraint.
* If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
* Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
- Stop asking further questions when:
* All critical ambiguities resolved early (remaining queued items become unnecessary), OR
* User signals completion ("done", "good", "no more"), OR
* You reach 5 asked questions.
- Never reveal future queued questions in advance.
- If no valid questions exist at start, immediately report no critical ambiguities.
5. Integration after EACH accepted answer (incremental update approach):
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
- For the first integrated answer in this session:
* Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
* Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
- Then immediately apply the clarification to the most appropriate section(s):
* Functional ambiguity → Update or add a bullet in Functional Requirements.
* User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
* Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
* Non-functional constraint → Add/modify measurable criteria in Non-Functional / Quality Attributes section (convert vague adjective to metric or explicit target).
* Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
* Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
- Keep each inserted clarification minimal and testable (avoid narrative drift).
6. Validation (performed after EACH write plus final pass):
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
- Total asked (accepted) questions ≤ 5.
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
- Terminology consistency: same canonical term used across all updated sections.
7. Write the updated spec back to `FEATURE_SPEC`.
8. Report completion (after questioning loop ends or early termination):
- Number of questions asked & answered.
- Path to updated spec.
- Sections touched (list names).
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `/plan` or run `/clarify` again later post-plan.
- Suggested next command.
Behavior rules:
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
- If spec file missing, instruct user to run `/specify` first (do not create a new spec here).
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed").
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
Context for prioritization: $ARGUMENTS

View File

@@ -0,0 +1,73 @@
---
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
---
The user input to you can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty).
User input:
$ARGUMENTS
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
Follow this execution flow:
1. Load the existing constitution template at `.specify/memory/constitution.md`.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
2. Collect/derive values for placeholders:
- If user input (conversation) supplies a value, use it.
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
* MAJOR: Backward incompatible governance/principle removals or redefinitions.
* MINOR: New principle/section added or materially expanded guidance.
* PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
- If version bump type ambiguous, propose reasoning before finalizing.
3. Draft the updated constitution content:
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonnegotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
4. Consistency propagation checklist (convert prior checklist into active validations):
- Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- Read `.specify/templates/spec-template.md` for scope/requirements alignment—update if constitution adds/removes mandatory sections or constraints.
- Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- Read each command file in `.specify/templates/commands/*.md` (including this one) to verify no outdated references (agent-specific names like CLAUDE only) remain when generic guidance is required.
- Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old → new
- List of modified principles (old title → new title if renamed)
- Added sections
- Removed sections
- Templates requiring updates (✅ updated / ⚠ pending) with file paths
- Follow-up TODOs if any placeholders intentionally deferred.
6. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
8. Output a final summary to the user with:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
Formatting & Style Requirements:
- Use Markdown headings exactly as in the template (do not demote/promote levels).
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
- Keep a single blank line between sections.
- Avoid trailing whitespace.
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.

View File

@@ -0,0 +1,56 @@
---
description: Execute the implementation plan by processing and executing all tasks defined in tasks.md
---
The user input can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty).
User input:
$ARGUMENTS
1. Run `.specify/scripts/bash/check-prerequisites.sh --json --require-tasks --include-tasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute.
2. Load and analyze the implementation context:
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
3. Parse tasks.md structure and extract:
- **Task phases**: Setup, Tests, Core, Integration, Polish
- **Task dependencies**: Sequential vs parallel execution rules
- **Task details**: ID, description, file paths, parallel markers [P]
- **Execution flow**: Order and dependency requirements
4. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding
5. Implementation execution rules:
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints
- **Integration work**: Database connections, middleware, logging, external services
- **Polish and validation**: Unit tests, performance optimization, documentation
6. Progress tracking and error handling:
- Report progress after each completed task
- Halt execution if any non-parallel task fails
- For parallel tasks [P], continue with successful tasks, report failed ones
- Provide clear error messages with context for debugging
- Suggest next steps if implementation cannot proceed
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
7. Completion validation:
- Verify all required tasks are completed
- Check that implemented features match the original specification
- Validate that tests pass and coverage meets requirements
- Confirm the implementation follows the technical plan
- Report final status with summary of completed work
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/tasks` first to regenerate the task list.

View File

@@ -0,0 +1,43 @@
---
description: Execute the implementation planning workflow using the plan template to generate design artifacts.
---
The user input to you can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty).
User input:
$ARGUMENTS
Given the implementation details provided as an argument, do this:
1. Run `.specify/scripts/bash/setup-plan.sh --json` from the repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. All future file paths must be absolute.
- BEFORE proceeding, inspect FEATURE_SPEC for a `## Clarifications` section with at least one `Session` subheading. If missing or clearly ambiguous areas remain (vague adjectives, unresolved critical choices), PAUSE and instruct the user to run `/clarify` first to reduce rework. Only continue if: (a) Clarifications exist OR (b) an explicit user override is provided (e.g., "proceed without clarification"). Do not attempt to fabricate clarifications yourself.
2. Read and analyze the feature specification to understand:
- The feature requirements and user stories
- Functional and non-functional requirements
- Success criteria and acceptance criteria
- Any technical constraints or dependencies mentioned
3. Read the constitution at `.specify/memory/constitution.md` to understand constitutional requirements.
4. Execute the implementation plan template:
- Load `.specify/templates/plan-template.md` (already copied to IMPL_PLAN path)
- Set Input path to FEATURE_SPEC
- Run the Execution Flow (main) function steps 1-9
- The template is self-contained and executable
- Follow error handling and gate checks as specified
- Let the template guide artifact generation in $SPECS_DIR:
* Phase 0 generates research.md
* Phase 1 generates data-model.md, contracts/, quickstart.md
* Phase 2 generates tasks.md
- Incorporate user-provided details from arguments into Technical Context: $ARGUMENTS
- Update Progress Tracking as you complete each phase
5. Verify execution completed:
- Check Progress Tracking shows all phases complete
- Ensure all required artifacts were generated
- Confirm no ERROR states in execution
6. Report results with branch name, file paths, and generated artifacts.
Use absolute paths with the repository root for all file operations to avoid path issues.

View File

@@ -0,0 +1,21 @@
---
description: Create or update the feature specification from a natural language feature description.
---
The user input to you can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty).
User input:
$ARGUMENTS
The text the user typed after `/specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
Given that feature description, do this:
1. Run the script `.specify/scripts/bash/create-new-feature.sh --json "$ARGUMENTS"` from repo root and parse its JSON output for BRANCH_NAME and SPEC_FILE. All file paths must be absolute.
**IMPORTANT** You must only ever run this script once. The JSON is provided in the terminal as output - always refer to it to get the actual content you're looking for.
2. Load `.specify/templates/spec-template.md` to understand required sections.
3. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
4. Report completion with branch name, spec file path, and readiness for the next phase.
Note: The script creates and checks out the new branch and initializes the spec file before writing.

View File

@@ -0,0 +1,62 @@
---
description: Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts.
---
The user input to you can be provided directly by the agent or as a command argument - you **MUST** consider it before proceeding with the prompt (if not empty).
User input:
$ARGUMENTS
1. Run `.specify/scripts/bash/check-prerequisites.sh --json` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute.
2. Load and analyze available design documents:
- Always read plan.md for tech stack and libraries
- IF EXISTS: Read data-model.md for entities
- IF EXISTS: Read contracts/ for API endpoints
- IF EXISTS: Read research.md for technical decisions
- IF EXISTS: Read quickstart.md for test scenarios
Note: Not all projects have all documents. For example:
- CLI tools might not have contracts/
- Simple libraries might not need data-model.md
- Generate tasks based on what's available
3. Generate tasks following the template:
- Use `.specify/templates/tasks-template.md` as the base
- Replace example tasks with actual tasks based on:
* **Setup tasks**: Project init, dependencies, linting
* **Test tasks [P]**: One per contract, one per integration scenario
* **Core tasks**: One per entity, service, CLI command, endpoint
* **Integration tasks**: DB connections, middleware, logging
* **Polish tasks [P]**: Unit tests, performance, docs
4. Task generation rules:
- Each contract file → contract test task marked [P]
- Each entity in data-model → model creation task marked [P]
- Each endpoint → implementation task (not parallel if shared files)
- Each user story → integration test marked [P]
- Different files = can be parallel [P]
- Same file = sequential (no [P])
5. Order tasks by dependencies:
- Setup before everything
- Tests before implementation (TDD)
- Models before services
- Services before endpoints
- Core before integration
- Everything before polish
6. Include parallel execution examples:
- Group [P] tasks that can run together
- Show actual Task agent commands
7. Create FEATURE_DIR/tasks.md with:
- Correct feature name from implementation plan
- Numbered tasks (T001, T002, etc.)
- Clear file paths for each task
- Dependency notes
- Parallel execution guidance
Context for task generation: $ARGUMENTS
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.

View File

@@ -0,0 +1,15 @@
{
"permissions": {
"allow": [
"Bash(.specify/scripts/bash/setup-plan.sh --help)",
"Bash(.specify/scripts/bash/setup-plan.sh)",
"Bash(.specify/scripts/bash/setup-plan.sh --json)",
"Bash(git add *)",
"Bash(mkdir -p ~/.kube)",
"Bash(sudo cp *)",
"Bash(sudo chmod *)",
"Bash(echo 'export KUBECONFIG=~/.kube/config')",
"Bash(cargo metadata *)"
]
}
}

View File

@@ -0,0 +1,50 @@
# 构建产物(最重要:排除 target 避免传输19GB
target/
# Git 相关
.git
.gitignore
.gitattributes
# IDE 配置
.vscode/
.idea/
*.iml
.DS_Store
# 项目工作空间
project_workspace/
# 日志文件
logs/
*.log
# Docker 相关(排除其他 Dockerfile 和运行时文件)
docker/project_workspace/
docker/logs/
docker/specs/
# docker/rcoder-agent-runner/ # 保留(包含 downloads/ 用于 LibreOffice 预下载)
docker/computer-cache/
docker/computer-project-workspace/
# 测试文件
tests/
test_*.sh
*.rest
# 文档
docs/
CLAUDE.md
IMPLEMENTATION_SUMMARY.md
install.md
README.md
# 测试数据
fixtures/
dist
# 临时文件
*.tmp
*.swp
*.swo
*~

24
qiming-rcoder/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
/target
/tmp
/temp
/other
/dist
.idea
/logs
.classpath
project_workspace
.settings
.checkstyle
.checkstyle_suppressions.xml
.DS_Store
docker/rcoder
.vscode
docker/rcoder-agent-runner/bin/agent_runner
docker/rcoder-agent-runner/downloads
docker/computer-cache
docker/computer-project-workspace
docker/logs
# Stakpak session files
.stakpak/session*

View File

@@ -0,0 +1,360 @@
# Design Document
## Overview
This design document outlines the unified container stopping module that will consolidate all container stopping logic into a single, reusable module within the `docker_manager` crate. The module will provide two distinct stopping strategies optimized for different scenarios: startup cleanup and runtime cleanup.
## Architecture
### Module Location
```
crates/docker_manager/src/
├── lib.rs (existing)
├── manager.rs (existing)
├── types.rs (existing)
├── utils.rs (existing)
└── container_stop.rs (new - unified stopping logic)
```
### High-Level Design
```mermaid
graph TD
A[main.rs startup] -->|startup cleanup| B[container_stop::startup_cleanup]
C[cleanup_task.rs] -->|runtime cleanup| D[container_stop::runtime_cleanup]
E[container_manager.rs] -->|runtime cleanup| D
B --> F[stop_with_409_filter]
D --> G[stop_with_quick_timeout]
F --> H[DockerManager::stop_container_by_id_with_timeout]
G --> H
H --> I[Docker API]
```
## Components and Interfaces
### 1. Container Stop Module (`container_stop.rs`)
#### Public API
```rust
/// 启动时容器清理策略
///
/// 特点:
/// - 使用5秒超时
/// - 过滤409冲突错误容器已在删除中
/// - 不阻塞服务启动
pub async fn startup_cleanup_containers(
docker_manager: &DockerManager,
pattern: &str,
) -> Result<CleanupResult, DockerError>
/// 运行时容器清理策略
///
/// 特点:
/// - 使用3秒优雅停止超时
/// - 立即强制停止
/// - 快速释放资源
pub async fn runtime_cleanup_container(
docker_manager: &DockerManager,
container_id: &str,
) -> Result<(), DockerError>
/// 运行时批量清理容器
pub async fn runtime_cleanup_containers(
docker_manager: &DockerManager,
container_ids: Vec<String>,
) -> Result<CleanupResult, DockerError>
```
#### Internal Helper Functions
```rust
/// 停止单个容器(启动场景)
async fn stop_container_startup_mode(
docker_manager: &DockerManager,
container_id: &str,
) -> Result<(), DockerError>
/// 停止单个容器(运行时场景)
async fn stop_container_runtime_mode(
docker_manager: &DockerManager,
container_id: &str,
) -> Result<(), DockerError>
/// 检查是否为409冲突错误
fn is_409_conflict_error(error: &DockerError) -> bool
/// 创建清理结果统计
fn create_cleanup_result(
total: usize,
successful: usize,
failed: usize,
removed_ids: Vec<String>,
failures: Vec<ContainerRemovalFailure>,
) -> CleanupResult
```
### 2. Configuration Constants
```rust
/// 启动清理超时时间(秒)
const STARTUP_CLEANUP_TIMEOUT_SECONDS: u64 = 5;
/// 运行时清理超时时间(秒)
const RUNTIME_CLEANUP_TIMEOUT_SECONDS: u64 = 3;
/// 容器停止后的等待时间(毫秒)
const POST_STOP_WAIT_MS: u64 = 100;
```
## Data Models
### CleanupResult (existing, reused)
```rust
pub struct CleanupResult {
pub total_found: usize,
pub successfully_removed: usize,
pub failed_removals: usize,
pub removed_container_ids: Vec<String>,
pub failed_removals_details: Vec<ContainerRemovalFailure>,
pub duration_ms: u64,
}
```
### ContainerRemovalFailure (existing, reused)
```rust
pub struct ContainerRemovalFailure {
pub container_id: String,
pub container_name: String,
pub error_message: String,
}
```
## Error Handling
### Startup Cleanup Error Handling
1. **409 Conflict Errors**
- Log at INFO level: "容器已在删除中,跳过"
- Do not count as failure
- Continue processing other containers
2. **Other Errors**
- Log at WARN level with error details
- Count as failure in statistics
- Continue processing other containers
- Do not block service startup
### Runtime Cleanup Error Handling
1. **All Errors**
- Log at WARN level with error details
- Return error to caller
- Caller decides whether to continue
2. **Timeout Handling**
- If graceful stop times out, force stop immediately
- Log timeout at WARN level
- Continue with force stop
## Testing Strategy
### Unit Tests
```rust
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_409_conflict_error() {
// Test 409 error detection
}
#[test]
fn test_cleanup_result_creation() {
// Test result statistics calculation
}
#[tokio::test]
async fn test_startup_cleanup_filters_409() {
// Test that 409 errors are filtered during startup
}
#[tokio::test]
async fn test_runtime_cleanup_quick_timeout() {
// Test that runtime cleanup uses 3-second timeout
}
}
```
### Integration Tests
1. **Startup Cleanup Integration**
- Create test containers
- Simulate 409 conflict scenario
- Verify service continues without error
2. **Runtime Cleanup Integration**
- Create running container
- Verify 3-second timeout behavior
- Verify force stop after timeout
## Implementation Details
### Startup Cleanup Flow
```
1. List all containers matching pattern
2. For each container:
a. Call stop_container_startup_mode
b. Use 5-second timeout
c. If 409 error: log INFO, skip to next
d. If other error: log WARN, count as failure
e. If success: count as success
3. Return CleanupResult with statistics
```
### Runtime Cleanup Flow
```
1. For given container_id:
a. Call stop_container_runtime_mode
b. Use 3-second graceful timeout
c. If timeout: force stop immediately
d. Remove container
e. Log result
2. Return success or error
```
### Integration Points
#### main.rs Changes
```rust
// Before
match startup_cleanup_orphaned_containers(&docker_manager).await {
Ok(cleaned_count) => { /* ... */ }
Err(e) => { /* ... */ }
}
// After
use docker_manager::container_stop;
match container_stop::startup_cleanup_containers(&docker_manager, "rcoder-agent-*").await {
Ok(result) => {
if result.successfully_removed > 0 {
info!("✅ 启动时清理完成,共清理了 {} 个遗留容器", result.successfully_removed);
}
}
Err(e) => {
warn!("⚠️ 启动时容器清理失败: {},但这不影响服务启动", e);
}
}
```
#### cleanup_task.rs Changes
```rust
// In destroy_docker_container method
use docker_manager::container_stop;
// Replace stop_container_by_id_with_timeout call
container_stop::runtime_cleanup_container(&docker_manager, &container_id).await?;
```
#### container_manager.rs Changes
```rust
// If needed for container cleanup
use docker_manager::container_stop;
container_stop::runtime_cleanup_container(&docker_manager, &container_id).await?;
```
## Performance Considerations
1. **Startup Cleanup**
- Parallel processing of containers (existing behavior maintained)
- 5-second timeout prevents long blocking
- 409 filtering reduces unnecessary retries
2. **Runtime Cleanup**
- 3-second timeout balances speed and graceful shutdown
- Immediate force stop after timeout
- Minimal resource holding time
## Security Considerations
1. **Container Isolation**
- Only stop containers matching specific patterns
- Validate container ownership before stopping
2. **Error Information**
- Do not expose sensitive container details in logs
- Use generic error messages for external errors
## Logging Strategy
### Log Levels
- **INFO**: Normal operations, 409 conflicts during startup
- **WARN**: Unexpected errors, timeouts
- **ERROR**: Critical failures (none expected in this module)
- **DEBUG**: Detailed operation flow (for development)
### Log Format
```rust
// Startup cleanup
info!("🧹 [STARTUP_CLEANUP] 开始清理容器: pattern={}", pattern);
info!("✅ [STARTUP_CLEANUP] 容器清理成功: container_id={}", container_id);
info!("🔄 [STARTUP_CLEANUP] 容器已在删除中,跳过: container_id={}", container_id);
warn!("⚠️ [STARTUP_CLEANUP] 容器清理失败: container_id={}, error={}", container_id, error);
// Runtime cleanup
info!("🔥 [RUNTIME_CLEANUP] 开始停止容器: container_id={}", container_id);
info!("✅ [RUNTIME_CLEANUP] 容器停止成功: container_id={}", container_id);
warn!("⏰ [RUNTIME_CLEANUP] 容器停止超时,强制停止: container_id={}", container_id);
warn!("⚠️ [RUNTIME_CLEANUP] 容器停止失败: container_id={}, error={}", container_id, error);
```
## Migration Strategy
### Phase 1: Create New Module
- Implement `container_stop.rs` in docker_manager crate
- Add unit tests
- Export public API from lib.rs
### Phase 2: Update main.rs
- Replace startup cleanup logic
- Test service startup with orphaned containers
- Verify 409 error filtering
### Phase 3: Update cleanup_task.rs
- Replace destroy_docker_container logic
- Test runtime cleanup behavior
- Verify 3-second timeout
### Phase 4: Update container_manager.rs (if needed)
- Replace any container stopping logic
- Test container lifecycle
### Phase 5: Cleanup
- Remove old duplicated code
- Update documentation
- Final integration testing
## Rollback Plan
If issues are discovered:
1. Revert changes to calling code (main.rs, cleanup_task.rs, etc.)
2. Keep new module for future use
3. Restore original inline implementations
4. Document issues for future redesign

View File

@@ -0,0 +1,78 @@
# Requirements Document
## Introduction
This document defines the requirements for unifying container stopping logic across the rcoder codebase. Currently, container stopping logic is scattered across multiple files with inconsistent implementations. We need to consolidate this into two well-defined scenarios with reusable functions.
## Glossary
- **Container**: Docker容器实例
- **Startup Cleanup**: 服务启动时的容器清理流程
- **Runtime Cleanup**: 运行时的容器清理流程
- **409 Conflict Error**: Docker API返回的容器已在删除中的错误
- **Graceful Stop**: 给容器优雅退出时间的停止方式
- **Force Stop**: 立即强制停止容器的方式
- **docker_manager**: 负责Docker容器管理的crate模块
## Requirements
### Requirement 1: 启动时容器清理场景
**User Story:** As a system administrator, I want the rcoder service to clean up orphaned containers during startup without blocking the service initialization, so that the system can start reliably even when containers are already being deleted.
#### Acceptance Criteria
1. WHEN the rcoder service starts, THE Container Stop Module SHALL identify all rcoder-agent-* containers
2. WHEN a container is found during startup cleanup, THE Container Stop Module SHALL attempt to stop it with a 5-second timeout
3. IF a 409 conflict error occurs indicating the container is already being deleted, THEN THE Container Stop Module SHALL log this as informational and continue without error
4. WHEN all containers are processed, THE Container Stop Module SHALL return the count of successfully cleaned containers
5. THE Container Stop Module SHALL NOT block service startup if container cleanup encounters non-409 errors
### Requirement 2: 运行时容器清理场景
**User Story:** As a system operator, I want containers to be stopped quickly during runtime cleanup operations, so that resources are freed promptly without waiting for long graceful shutdown periods.
#### Acceptance Criteria
1. WHEN a container needs to be stopped during runtime, THE Container Stop Module SHALL give the container 3 seconds for graceful shutdown
2. WHEN the 3-second grace period expires, THE Container Stop Module SHALL immediately force-stop the container
3. THE Container Stop Module SHALL remove the container after stopping it
4. THE Container Stop Module SHALL log all stop operations with appropriate detail levels
5. THE Container Stop Module SHALL handle errors gracefully and continue cleanup operations
### Requirement 3: 统一的容器停止接口
**User Story:** As a developer, I want a unified API for stopping containers in different scenarios, so that I can easily invoke the appropriate stopping strategy without duplicating code.
#### Acceptance Criteria
1. THE Container Stop Module SHALL provide a function for startup cleanup scenario
2. THE Container Stop Module SHALL provide a function for runtime cleanup scenario
3. WHEN called from main.rs startup logic, THE Container Stop Module SHALL use the startup cleanup strategy
4. WHEN called from cleanup_task.rs, THE Container Stop Module SHALL use the runtime cleanup strategy
5. WHEN called from container_manager.rs, THE Container Stop Module SHALL use the runtime cleanup strategy
6. THE Container Stop Module SHALL be located in the docker_manager crate for reusability
### Requirement 4: 错误处理和日志记录
**User Story:** As a system operator, I want clear logging of container stop operations, so that I can diagnose issues and understand what happened during cleanup.
#### Acceptance Criteria
1. THE Container Stop Module SHALL log container stop attempts at INFO level
2. THE Container Stop Module SHALL log 409 conflict errors at INFO level during startup cleanup
3. THE Container Stop Module SHALL log other errors at WARN level
4. THE Container Stop Module SHALL log successful stops at INFO level
5. THE Container Stop Module SHALL include container_id and project_id in all log messages
### Requirement 5: 代码复用和维护性
**User Story:** As a developer, I want the container stopping logic centralized in one module, so that future changes only need to be made in one place.
#### Acceptance Criteria
1. THE Container Stop Module SHALL be implemented in a new file under crates/docker_manager/src/
2. WHEN container stopping is needed in main.rs, THE code SHALL call the Container Stop Module
3. WHEN container stopping is needed in cleanup_task.rs, THE code SHALL call the Container Stop Module
4. WHEN container stopping is needed in container_manager.rs, THE code SHALL call the Container Stop Module
5. THE Container Stop Module SHALL eliminate all duplicated container stopping logic across the codebase

View File

@@ -0,0 +1,135 @@
# Implementation Plan
## 任务概述
将分散在多个文件中的容器停止逻辑统一到 docker_manager crate 的新模块中,提供两种清理策略:启动时清理和运行时清理。
## 任务列表
- [x] 1. 创建 container_stop 模块基础结构
-`crates/docker_manager/src/` 创建 `container_stop.rs` 文件
- 定义模块常量(超时时间等)
-`lib.rs` 中导出新模块
- _Requirements: 3.6, 5.1_
- [x] 2. 实现启动时清理功能
- [x] 2.1 实现 startup_cleanup_containers 函数
- 实现容器模式匹配查找
- 实现 5 秒超时停止逻辑
- 实现 409 错误过滤
- 返回 CleanupResult 统计信息
- _Requirements: 1.1, 1.2, 1.3, 1.4_
- [x] 2.2 实现 stop_container_startup_mode 辅助函数
- 调用 DockerManager::stop_container_by_id_with_timeout
- 使用 STARTUP_CLEANUP_TIMEOUT_SECONDS 常量
- 实现错误处理和日志记录
- _Requirements: 1.2, 4.1, 4.2_
- [x] 2.3 实现 is_409_conflict_error 辅助函数
- 检查 DockerError 是否包含 "409" 和 "already in progress"
- 返回布尔值
- _Requirements: 1.3_
- [x] 3. 实现运行时清理功能
- [x] 3.1 实现 runtime_cleanup_container 函数
- 实现单个容器停止逻辑
- 使用 3 秒优雅停止超时
- 超时后立即强制停止
- _Requirements: 2.1, 2.2, 2.3_
- [x] 3.2 实现 runtime_cleanup_containers 批量清理函数
- 批量处理多个容器 ID
- 返回 CleanupResult 统计信息
- _Requirements: 2.4, 2.5_
- [x] 3.3 实现 stop_container_runtime_mode 辅助函数
- 调用 DockerManager::stop_container_by_id_with_timeout
- 使用 RUNTIME_CLEANUP_TIMEOUT_SECONDS 常量
- 实现错误处理和日志记录
- _Requirements: 2.2, 4.1, 4.4_
- [x] 4. 更新 main.rs 使用新的启动清理接口
- [x] 4.1 导入 container_stop 模块
- 添加 `use docker_manager::container_stop;`
- _Requirements: 3.3, 5.2_
- [x] 4.2 替换 startup_cleanup_orphaned_containers 函数
- 调用 `container_stop::startup_cleanup_containers`
- 更新错误处理逻辑
- 更新日志输出
- _Requirements: 3.3, 4.2, 4.3_
- [x] 4.3 删除旧的 startup_cleanup_orphaned_containers 实现
- 删除 `startup_cleanup_orphaned_containers` 函数
- 删除 `find_and_cleanup_orphaned_containers` 函数(如果不再使用)
- _Requirements: 5.5_
- [x] 5. 更新 cleanup_task.rs 使用新的运行时清理接口
- [x] 5.1 导入 container_stop 模块
-`destroy_docker_container` 方法中添加导入
- _Requirements: 3.4, 5.3_
- [x] 5.2 替换容器停止逻辑
-`stop_container_by_id_with_timeout` 调用替换为 `container_stop::runtime_cleanup_container`
- 简化错误处理逻辑
- 更新日志输出
- _Requirements: 3.4, 4.4_
- [x] 5.3 清理 cleanup_single_orphaned_container 方法
- 使用新的 runtime_cleanup_container 接口
- 删除重复的停止逻辑
- _Requirements: 5.5_
- [x] 6. 更新 container_manager.rs如需要
- [x] 6.1 检查是否有容器停止逻辑
- 搜索 `stop_container` 相关调用
- 确定是否需要更新
- _Requirements: 3.5, 5.4_
- [x] 6.2 如有需要,替换为新接口
- 导入 container_stop 模块
- 替换停止逻辑
- _Requirements: 3.5, 5.4_
- [x] 7. 添加文档和注释
- [x] 7.1 为 container_stop.rs 添加模块级文档
- 说明两种清理策略的区别
- 提供使用示例
- _Requirements: 5.5_
- [x] 7.2 为公共函数添加详细文档注释
- 包含参数说明
- 包含返回值说明
- 包含使用示例
- _Requirements: 4.5_
- [x] 8. 验证和测试
- [x] 8.1 编译检查
- 运行 `cargo check` 确保无编译错误
- 运行 `cargo clippy` 检查代码质量
- _Requirements: 5.5_
- [x] 8.2 功能测试
- 测试启动时清理(模拟 409 错误场景)
- 测试运行时清理(验证 3 秒超时)
- 验证日志输出正确
- _Requirements: 1.5, 2.5, 4.1-4.5_

1
qiming-rcoder/.npmrc Normal file
View File

@@ -0,0 +1 @@
registry=https://registry.npmmirror.com/

View File

@@ -0,0 +1,341 @@
# Agent 抽象层重构 - 实施总结
## 项目概述
本次重构成功实现了 RCoder Agent 抽象层的配置化改造,将硬编码的 Agent 配置、MCP 服务器配置和系统提示词迁移到统一的配置管理系统中。
## 完成日期
2024年12月3日
## 实施阶段
### ✅ 阶段 1: 配置系统基础 (已完成)
**目标**: 建立配置化基础设施
**完成内容**:
1. 创建了独立的 `agent_config` crate
- 位置: `crates/agent_config/`
- 提供配置解析、环境变量映射、默认配置生成等功能
2. 实现的核心模块:
- `config.rs`: 数据结构定义
- `AgentServersConfig`: 配置文件根结构
- `AgentConfig`: 单个 Agent 配置
- `SystemPromptConfig`: 系统提示词配置(支持模板变量)
- `McpServerConfig`: MCP 服务器配置
- `env_resolver.rs`: 环境变量解析器
- 支持 `{VARIABLE_NAME}` 占位符解析
- env_overrides 优先级高于 env
- 支持从 ModelProviderConfig 映射环境变量
- `generator.rs`: 默认配置生成器
- 生成与当前硬编码行为一致的默认配置
- 包含 Claude Code Agent 配置
- 包含 context7 和 fetch MCP 服务器配置
- `loader.rs`: 配置加载器
- 支持从文件加载配置
- 自动生成默认配置
- 配置验证功能
- 默认路径: `~/.rcoder/agents.json`
- `error.rs`: 错误类型定义
3. 测试覆盖:
- 所有模块都有单元测试
- **15个测试全部通过**
- 测试覆盖: 配置解析、环境变量解析、模板渲染、文件加载等
**交付物**:
- ✅ agent_config crate (完整实现并测试通过)
- ✅ 配置文件示例和文档
- ✅ 单元测试 (15/15 通过)
### ✅ 阶段 2: 系统提示词模板化 (已完成)
**目标**: 实现系统提示词配置化
**完成内容**:
1. `SystemPromptConfig` 结构设计:
- `template` 字段存储完整的系统提示词内容
- `variables` HashMap 存储模板变量
- `render()` 方法使用 `String::replace` 进行变量替换
2. 模板变量替换逻辑:
- 使用简单的 `String::replace` 确保业务正确性
- 支持 `{VARIABLE_NAME}` 格式的占位符
- 性能优化预留: 未来可引入 MiniJinja/Tera
**设计决策**:
- ✅ 优先保证业务正确性,使用 String::replace
- ✅ 模板内容存储在配置文件中,便于修改
- ✅ 如性能成为瓶颈(>10ms),可后续引入模板引擎
**交付物**:
- ✅ SystemPromptConfig 实现
- ✅ 模板渲染逻辑
- ✅ 单元测试
### ✅ 阶段 3: MCP 服务器配置化 (已完成)
**目标**: 实现 MCP 服务器动态配置
**完成内容**:
1. `McpServerConfig` 数据结构:
- `name`: 服务器名称
- `command`: 启动命令
- `args`: 命令行参数
- `env`: 环境变量配置(支持占位符)
2. 集成到 Agent 启动流程:
- `to_acp_mcp_server()` 方法转换为 ACP 协议类型
- 环境变量解析集成
- 支持多个 MCP 服务器配置
3. 默认配置:
- context7 MCP 服务器
- fetch MCP 服务器
**交付物**:
- ✅ McpServerConfig 实现
- ✅ 与 Agent 启动流程集成
- ✅ 单元测试
### ✅ 阶段 4: Agent 管理简化版 (已完成)
**目标**: 简化的 Agent 管理接口
**完成内容**:
1. 创建 `agent_manager.rs` 模块:
- 位置: `crates/agent_runner/src/agent_manager.rs`
- 封装配置加载和 Agent 启动逻辑
2. `AgentManager` 实现:
- `new()`: 使用默认配置路径创建
- `get_agent_config()`: 获取 Agent 配置
- `start_claude_agent()`: 启动 Claude Agent (保持向后兼容)
- `build_mcp_servers()`: 构建 MCP 服务器列表
- `resolve_env()`: 解析环境变量映射
3. 集成到 agent_runner:
- 添加 agent_config 依赖
- 导出 AgentManager
- 保持现有 API 不变
**交付物**:
- ✅ AgentManager 实现
- ✅ 与 agent_runner 集成
- ✅ 单元测试 (3/3 通过)
### ✅ 阶段 5: 向后兼容与集成测试 (已完成)
**目标**: 确保平滑迁移
**完成内容**:
1. 向后兼容层:
- `AgentManager::start_claude_agent()` 保持与原有函数签名一致
- 内部使用新的配置系统
- 自动生成默认配置
2. 集成测试:
- agent_config 单元测试: 15/15 通过
- agent_runner 单元测试: 16/16 通过
- 完整项目构建: 成功
3. 迁移策略:
- 首次启动自动生成默认配置文件
- 配置内容与当前硬编码行为完全一致
- 用户可选择性修改配置
**交付物**:
- ✅ 向后兼容层实现
- ✅ 端到端测试 (31/31 通过)
- ✅ 构建验证通过
## 技术决策总结
### 1. 配置文件格式
**决策**: JSON
**理由**:
- serde_json 生态成熟
- 序列化简单
- 工具支持好
### 2. 系统提示词模板引擎
**决策**: String::replace (阶段1), 预留 MiniJinja/Tera (性能优化)
**理由**:
- 优先保证业务正确性
- 简单直接,无额外依赖
- 性能可接受(< 10ms)
### 3. 环境变量优先级
**决策**: env_overrides 优先于 env
**理由**:
- 符合配置覆盖语义
- 灵活性高
- 便于调试
### 4. 配置文件路径
**决策**: `~/.rcoder/agents.json``/etc/rcoder/agents.json`
**理由**:
- 用户目录优先
- 支持系统级配置
- 符合 Unix 惯例
### 5. 模块划分
**决策**: 独立的 agent_config crate
**理由**:
- 职责清晰
- 可复用
- 便于测试
## 实施指标
### 代码量统计
- **新增文件**: 8个
- agent_config/src/lib.rs
- agent_config/src/config.rs (237行)
- agent_config/src/env_resolver.rs (253行)
- agent_config/src/error.rs (53行)
- agent_config/src/generator.rs (181行)
- agent_config/src/loader.rs (182行)
- agent_runner/src/agent_manager.rs (170行)
- agent_config/examples/generate_config.rs (28行)
- **修改文件**: 4个
- Cargo.toml (添加 agent_config)
- agent_runner/Cargo.toml (添加依赖)
- agent_runner/src/lib.rs (导出 agent_manager)
- agent_runner/src/proxy_agent/mod.rs (公开 claude_code_agent)
- **总新增代码**: ~1100行
### 测试覆盖
- agent_config 单元测试: 15个全部通过
- agent_runner 单元测试: 16个全部通过
- **总测试数**: 31个
- **通过率**: 100%
### 构建验证
- ✅ agent_config crate 编译通过
- ✅ agent_runner crate 编译通过
- ✅ 完整项目构建成功
- ⚠️ 52个警告(未使用的函数/变量,不影响功能)
## 向后兼容性
### API 兼容性
-`start_claude_code_acp_agent_service()` 接口保持不变
- ✅ 现有调用代码无需修改
- ✅ AgentManager 提供新接口,可选使用
### 行为兼容性
- ✅ 默认配置与硬编码行为完全一致
- ✅ MCP 服务器列表: context7 + fetch
- ✅ 环境变量映射规则保持一致
- ✅ 系统提示词内容保持一致
### 配置兼容性
- ✅ 首次启动自动生成配置
- ✅ 配置文件版本号: 1.0
- ✅ 支持配置升级和迁移
## 未来优化方向
### 性能优化 (P2)
1. 系统提示词模板引擎
- 条件: 渲染时间 > 10ms
- 方案: 引入 MiniJinja 或 Tera
- 预期收益: 10x 性能提升
2. 配置缓存
- 缓存解析后的配置
- 避免重复解析
- 使用文件监控自动刷新
### 功能扩展 (P3)
1. Agent 热重载
- 配置文件变更自动重载
- 无需重启服务
2. MCP 服务器验证器
- lib 级别的验证能力
- 配置验证接口
3. Agent 安装管理器
- 自动安装缺失的 Agent
- 版本管理
### 可观测性增强 (P2)
1. 配置变更审计日志
2. 性能监控指标
3. 配置健康检查 API
## 已知限制
1. **配置文件格式**: 仅支持 JSON
- 影响: 不支持注释
- 缓解: 可使用外部工具转换 JSON5
2. **模板引擎**: 使用简单的 String::replace
- 影响: 复杂模板性能较低
- 缓解: 后续可引入 MiniJinja
3. **Agent 类型**: 目前仅支持 Claude 和 Codex
- 影响: 无法添加自定义 Agent 类型
- 缓解: 后续扩展 AgentType 枚举
## 风险评估
### 高风险项 (已解决)
- ✅ 环境变量借用检查问题: 通过重构解析逻辑解决
- ✅ 配置文件路径问题: 使用 dirs crate 获取用户目录
- ✅ 向后兼容性: 通过默认配置生成器保证
### 中风险项
- ⚠️ 配置文件损坏: 已实现配置验证和错误处理
- ⚠️ MCP 服务器启动失败: 暂时保持当前容错逻辑
### 低风险项
- 📝 性能影响: 测试显示配置加载耗时 < 5ms
- 📝 内存占用: 配置缓存占用 < 1MB
## 部署建议
### 首次部署
1. 确保环境变量已设置 (ANTHROPIC_*)
2. 首次启动会自动生成配置文件
3. 检查日志确认配置加载成功
### 配置迁移
1. 保留当前环境变量配置
2. 首次启动自动生成默认配置
3. 后续可选择性修改配置文件
### 回滚方案
1. 删除 ~/.rcoder/agents.json
2. 重启服务使用硬编码配置
3. 或回退到旧版本代码
## 结论
本次重构成功实现了 Agent 抽象层的配置化改造,达到了设计文档中的所有核心目标:
**配置化系统**: 统一的配置管理,支持 JSON 格式
**环境变量映射**: 标准化的 ModelProviderConfig 映射env_overrides 优先
**系统提示词模板**: 解决硬编码问题,支持模板变量替换
**MCP 配置化**: 动态 MCP 服务器管理
**向后兼容**: 保持现有 API 不变,行为一致
**质量指标**:
- 测试通过率: 100% (31/31)
- 构建状态: 成功
- 向后兼容性: 完全兼容
**技术债务**:
- 52个警告(未使用的函数/变量): 不影响功能,可后续清理
- 性能优化预留: 模板引擎可按需引入
整体而言,本次重构为 RCoder 项目建立了坚实的配置管理基础,为后续功能扩展和优化提供了良好的架构支持。

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