Compare commits
164 Commits
24045274ad
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91f68576dd | ||
|
|
f84dc1d44e | ||
|
|
d0b7223b53 | ||
|
|
3a41eb40d0 | ||
|
|
fd06f3bce3 | ||
|
|
de33710423 | ||
|
|
87b440e93f | ||
|
|
fd3b149172 | ||
|
|
ff7576f33d | ||
|
|
053a321ec1 | ||
|
|
6ad88cb6c4 | ||
|
|
e57971488c | ||
|
|
642521035b | ||
|
|
b64f8e35c3 | ||
|
|
a62b749ef2 | ||
|
|
cc55fd819f | ||
|
|
39371861f3 | ||
|
|
c01c699410 | ||
|
|
89eec8afc1 | ||
|
|
8b651e4ae1 | ||
|
|
dd6c10f44b | ||
|
|
797ffeee82 | ||
|
|
4fdc95da75 | ||
|
|
b9f49d2c0e | ||
|
|
7ea963dc9a | ||
|
|
5ab6bc0fd0 | ||
|
|
c9d8aa3b93 | ||
|
|
95838dba04 | ||
|
|
f08b0c39d9 | ||
|
|
de501c0252 | ||
|
|
b6b0a9dcb3 | ||
|
|
d5025c0ebc | ||
|
|
d86f82a4c5 | ||
|
|
b0f0b95c2a | ||
|
|
1daf58a94c | ||
|
|
479e2e209d | ||
|
|
ca746366f6 | ||
|
|
8ed4e0ce3b | ||
|
|
bd7d7b4913 | ||
|
|
26fbf51fc8 | ||
|
|
8a5d5d6474 | ||
|
|
b3f21adecb | ||
|
|
be901ee112 | ||
|
|
48ac9bdf19 | ||
|
|
1a8e0fb51f | ||
|
|
871bd715c4 | ||
|
|
6323987a0d | ||
|
|
81d2797035 | ||
|
|
fffa0ebe50 | ||
|
|
9011b91dba | ||
|
|
3a159e2f5a | ||
|
|
e473b5c147 | ||
|
|
e57c94c19d | ||
|
|
84d34236e8 | ||
|
|
245a210b25 | ||
|
|
8278fdc81c | ||
|
|
4c862ef4b7 | ||
|
|
d292c34579 | ||
|
|
43418d0453 | ||
|
|
e9c76c59ca | ||
|
|
8ab3d73fb1 | ||
|
|
751ce45619 | ||
|
|
c400367d22 | ||
|
|
6f2ccdf783 | ||
|
|
909f11dae5 | ||
|
|
a6e0246418 | ||
|
|
d679639175 | ||
|
|
3ac350eb27 | ||
|
|
5b78189c9c | ||
|
|
1271e4967b | ||
|
|
5b2f14123e | ||
|
|
13e48df63d | ||
|
|
4f489f402a | ||
|
|
914ed81f19 | ||
|
|
d12db03216 | ||
|
|
047651e404 | ||
|
|
ed2f19d257 | ||
|
|
362f397e37 | ||
|
|
cc36d8dcaf | ||
|
|
91540dec0c | ||
|
|
0d2008b35c | ||
|
|
ba806f779e | ||
|
|
d1ae9421c4 | ||
|
|
755a8f041d | ||
|
|
23c3577ef4 | ||
|
|
dc8cd09535 | ||
|
|
5160a73254 | ||
|
|
4d8b8e4ad5 | ||
|
|
9ad7881378 | ||
|
|
702ea05d4d | ||
|
|
fd130f1f60 | ||
|
|
ddd39396f7 | ||
|
|
ef8252e2dd | ||
|
|
5ebc0a640b | ||
|
|
f35d945d9c | ||
|
|
e4715bbabb | ||
|
|
726369f5a9 | ||
|
|
2f9af0b173 | ||
|
|
eb8ea9974f | ||
|
|
16eb8c691e | ||
|
|
48e5830b9e | ||
|
|
451cda7aa6 | ||
|
|
913c6da8f3 | ||
|
|
38e5bdbc04 | ||
|
|
e4652ace1c | ||
|
|
820b1b6f13 | ||
|
|
76a82f6203 | ||
|
|
3bab6af63c | ||
|
|
ff25c2fef7 | ||
|
|
710bf72353 | ||
|
|
8dc9ce73b9 | ||
|
|
700f6a40b8 | ||
|
|
f4af77cb2b | ||
|
|
ee51fcdbeb | ||
|
|
0f0bffe695 | ||
|
|
e37024bdbe | ||
|
|
5e76828afe | ||
|
|
6deea42de1 | ||
|
|
09d65701a3 | ||
|
|
fa5522e32e | ||
|
|
757858a9aa | ||
|
|
aee8af0877 | ||
|
|
b07f133ed6 | ||
|
|
8ff1c57b83 | ||
|
|
bf25bc5a47 | ||
|
|
029ca6e7f6 | ||
|
|
7eb1498412 | ||
|
|
2bf9192fc5 | ||
|
|
df2ab51e70 | ||
|
|
f8564b631d | ||
|
|
bdcb5c6c14 | ||
|
|
6b033da313 | ||
|
|
38ece7e3d3 | ||
|
|
cef3fe4a43 | ||
|
|
646901b5fa | ||
|
|
508406ff10 | ||
|
|
0672e82fc7 | ||
|
|
ac1a6fd0e6 | ||
|
|
e2a91d88e8 | ||
|
|
dc14418963 | ||
|
|
17475bf0d7 | ||
|
|
b7aecad092 | ||
|
|
35d2cb5ea5 | ||
|
|
ab1d022905 | ||
|
|
b9132c421d | ||
|
|
f8048724c4 | ||
|
|
512c7ab095 | ||
|
|
2373e0b5f3 | ||
|
|
40151c3902 | ||
|
|
15a3285ed5 | ||
|
|
31b4b5a281 | ||
|
|
2eb9a99da3 | ||
|
|
c6fdff20e8 | ||
|
|
2c3f2c8c54 | ||
|
|
44fe160880 | ||
|
|
f9e090125c | ||
|
|
a01fc93594 | ||
|
|
f69fb46c59 | ||
|
|
873cd6ef53 | ||
|
|
a280774f50 | ||
|
|
142b8b78fa | ||
|
|
6ff9bfc6b5 | ||
|
|
4b1a580132 | ||
|
|
8092c4b1f8 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
2088
development-environment-startup-manual.md
Normal file
2088
development-environment-startup-manual.md
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 403 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 403 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 403 KiB |
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name:
|
||||
description:
|
||||
license:
|
||||
---
|
||||
|
||||
# Processing Guide
|
||||
|
||||
## Overview
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"description": "Skill template",
|
||||
"config": {}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
# Helper script for this skill
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Process script placeholder")
|
||||
@@ -0,0 +1,3 @@
|
||||
# API Documentation
|
||||
|
||||
Add interface descriptions related to skills here.
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
74
qiming-backend/sql/update-20260602.sql
Normal file
74
qiming-backend/sql/update-20260602.sql
Normal 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;
|
||||
24
qiming-backend/sql/update-20260605.sql
Normal file
24
qiming-backend/sql/update-20260605.sql
Normal 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 = '数字员工客户端同步记录';
|
||||
75
qiming-backend/sql/update-20260606.sql
Normal file
75
qiming-backend/sql/update-20260606.sql
Normal 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;
|
||||
193
qiming-backend/sql/update-20260612.sql
Normal file
193
qiming-backend/sql/update-20260612.sql
Normal 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;
|
||||
276
qiming-file-server/src/utils/build/buildProjectUtils.js
Normal file
276
qiming-file-server/src/utils/build/buildProjectUtils.js
Normal 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 };
|
||||
71
qiming-file-server/src/utils/build/keepAliveDevUtils.js
Normal file
71
qiming-file-server/src/utils/build/keepAliveDevUtils.js
Normal 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 };
|
||||
920
qiming-file-server/src/utils/build/processManager.js
Normal file
920
qiming-file-server/src/utils/build/processManager.js
Normal 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,
|
||||
};
|
||||
129
qiming-file-server/src/utils/build/restartDevUtils.js
Normal file
129
qiming-file-server/src/utils/build/restartDevUtils.js
Normal 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 };
|
||||
150
qiming-file-server/src/utils/build/startDevUtils.js
Normal file
150
qiming-file-server/src/utils/build/startDevUtils.js
Normal 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 };
|
||||
428
qiming-file-server/src/utils/build/stopDevUtils.js
Normal file
428
qiming-file-server/src/utils/build/stopDevUtils.js
Normal 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 };
|
||||
847
qiming-file-server/src/utils/build/syntaxCheckUtils.js
Normal file
847
qiming-file-server/src/utils/build/syntaxCheckUtils.js
Normal 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,
|
||||
};
|
||||
|
||||
6
qiming-rcoder/.cargo/config.toml
Normal file
6
qiming-rcoder/.cargo/config.toml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Cargo 配置文件
|
||||
|
||||
# 注意:由于 Cargo 的限制,无法通过 patch 为 crates.io 的包添加 features
|
||||
# 如果遇到 codex-protocol 的线程安全问题,请:
|
||||
# 1. 使用 --no-default-features 禁用 codex feature
|
||||
# 2. 或者等待上游修复
|
||||
101
qiming-rcoder/.claude/commands/analyze.md
Normal file
101
qiming-rcoder/.claude/commands/analyze.md
Normal 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
|
||||
158
qiming-rcoder/.claude/commands/clarify.md
Normal file
158
qiming-rcoder/.claude/commands/clarify.md
Normal 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 multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
* A one-word / short‑phrase 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 multiple‑choice 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 short‑answer 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
|
||||
73
qiming-rcoder/.claude/commands/constitution.md
Normal file
73
qiming-rcoder/.claude/commands/constitution.md
Normal 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 non‑negotiable 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.
|
||||
56
qiming-rcoder/.claude/commands/implement.md
Normal file
56
qiming-rcoder/.claude/commands/implement.md
Normal 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.
|
||||
43
qiming-rcoder/.claude/commands/plan.md
Normal file
43
qiming-rcoder/.claude/commands/plan.md
Normal 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.
|
||||
21
qiming-rcoder/.claude/commands/specify.md
Normal file
21
qiming-rcoder/.claude/commands/specify.md
Normal 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.
|
||||
62
qiming-rcoder/.claude/commands/tasks.md
Normal file
62
qiming-rcoder/.claude/commands/tasks.md
Normal 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.
|
||||
15
qiming-rcoder/.claude/settings.local.json
Normal file
15
qiming-rcoder/.claude/settings.local.json
Normal 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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
50
qiming-rcoder/.dockerignore
Normal file
50
qiming-rcoder/.dockerignore
Normal 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
24
qiming-rcoder/.gitignore
vendored
Normal 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*
|
||||
360
qiming-rcoder/.kiro/specs/unified-container-stop/design.md
Normal file
360
qiming-rcoder/.kiro/specs/unified-container-stop/design.md
Normal 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
|
||||
@@ -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
|
||||
135
qiming-rcoder/.kiro/specs/unified-container-stop/tasks.md
Normal file
135
qiming-rcoder/.kiro/specs/unified-container-stop/tasks.md
Normal 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
1
qiming-rcoder/.npmrc
Normal file
@@ -0,0 +1 @@
|
||||
registry=https://registry.npmmirror.com/
|
||||
@@ -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
Reference in New Issue
Block a user