From b1dd68a14c716cedfc264c4d256a8791fe156d6f Mon Sep 17 00:00:00 2001 From: xqzp2026 Date: Thu, 3 Sep 2026 17:57:27 +0930 Subject: [PATCH] feat(admin): implement P4 management console --- xiaoqu-agent-service/.gitignore | 2 + xiaoqu-agent-service/README.md | 14 +- xiaoqu-agent-service/front/index.html | 13 + xiaoqu-agent-service/front/package-lock.json | 1595 +++++++++++++++++ xiaoqu-agent-service/front/package.json | 19 + xiaoqu-agent-service/front/src/App.vue | 406 +++++ xiaoqu-agent-service/front/src/api.js | 26 + xiaoqu-agent-service/front/src/main.js | 7 + xiaoqu-agent-service/front/src/style.css | 62 + xiaoqu-agent-service/front/tests/api.test.mjs | 19 + .../front/tests/source-contract.test.mjs | 26 + xiaoqu-agent-service/front/vite.config.js | 8 + xiaoqu-agent-service/pom.xml | 38 + xiaoqu-agent-service/sql/V1__init.sql | 2 +- xiaoqu-agent-service/sql/V2__p4_admin.sql | 4 + .../iiv/agent/admin/AdminAuthController.java | 83 + .../me/iiv/agent/admin/AdminBootstrap.java | 47 + .../agent/admin/AdminConsoleController.java | 391 ++++ .../iiv/agent/admin/AdminPasswordHasher.java | 47 + .../iiv/agent/admin/AdminSessionFilter.java | 60 + .../me/iiv/agent/admin/AdminTokenService.java | 159 ++ .../iiv/agent/config/SvcDataSourceConfig.java | 7 + .../me/iiv/agent/entity/svc/AdminUser.java | 2 +- .../src/main/resources/application.yml | 14 + .../agent/admin/AdminPasswordHasherTest.java | 23 + .../agent/admin/AdminSessionFilterTest.java | 93 + .../agent/admin/AdminTokenServiceTest.java | 74 + 27 files changed, 3236 insertions(+), 5 deletions(-) create mode 100644 xiaoqu-agent-service/front/index.html create mode 100644 xiaoqu-agent-service/front/package-lock.json create mode 100644 xiaoqu-agent-service/front/package.json create mode 100644 xiaoqu-agent-service/front/src/App.vue create mode 100644 xiaoqu-agent-service/front/src/api.js create mode 100644 xiaoqu-agent-service/front/src/main.js create mode 100644 xiaoqu-agent-service/front/src/style.css create mode 100644 xiaoqu-agent-service/front/tests/api.test.mjs create mode 100644 xiaoqu-agent-service/front/tests/source-contract.test.mjs create mode 100644 xiaoqu-agent-service/front/vite.config.js create mode 100644 xiaoqu-agent-service/sql/V2__p4_admin.sql create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminAuthController.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminBootstrap.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminPasswordHasher.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminSessionFilter.java create mode 100644 xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminPasswordHasherTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminSessionFilterTest.java create mode 100644 xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java diff --git a/xiaoqu-agent-service/.gitignore b/xiaoqu-agent-service/.gitignore index 439421e..f564d33 100644 --- a/xiaoqu-agent-service/.gitignore +++ b/xiaoqu-agent-service/.gitignore @@ -1,4 +1,6 @@ target/ +front/node_modules/ +front/dist/ *.log .idea/ *.iml diff --git a/xiaoqu-agent-service/README.md b/xiaoqu-agent-service/README.md index 0efd93f..17b0caa 100644 --- a/xiaoqu-agent-service/README.md +++ b/xiaoqu-agent-service/README.md @@ -2,7 +2,7 @@ 面向 MCP 客户端(Kimi Code / Claude 等)的小趣业务数据服务:独立 Spring Boot 应用,只读查询现有三库(m0 权限库 / m1 商城库 / m2 业务库),以 MCP(Streamable HTTP)形式对外暴露工具。 -设计方案见 `../xiaoqu-franchisee/doc/agent-mcp-service-design.md`。当前进度:**P3 首批工具完成**(P1 骨架 + P2 权限层 + agent.list.query / property.list.query 完整版,均经 curl 实测)。管理后台为 P4,未实现。 +设计方案见 `../xiaoqu-franchisee/doc/agent-mcp-service-design.md`。当前进度:**P4 管理后台完成**(P1 骨架 + P2 权限层 + P3 首批工具 + P4 Vue 管理后台)。 ## 技术栈与已验证版本组合 @@ -14,6 +14,7 @@ | MyBatis-Plus | 3.5.12(`mybatis-plus-spring-boot3-starter`) | | MySQL 驱动 | mysql-connector-j(Boot 管理) | | Redis | spring-boot-starter-data-redis(db5) | +| 管理后台 | Vue 3 + Element Plus + Vite 7 | > ⚠️ 版本组合说明:Spring Boot 3.5.x + Spring AI 1.0.0 的 streamable 传输存在 NPE([spring-projects/spring-ai#6274](https://github.com/spring-projects/spring-ai/issues/6274)),本工程选用已修复的 **Spring AI 1.1.8**,2026-09-03 实测 STREAMABLE 握手(initialize → tools/list → tools/call)通过。 @@ -25,7 +26,9 @@ xiaoqu-agent-service/ ├── pom.xml ├── sql/ │ ├── V1__init.sql # 自有库 xiaoqu_agent_svc_d 建表(手工执行,未接 flyway) +│ ├── V2__p4_admin.sql # 旧库升级管理员密码哈希字段 │ └── seed-dev.sql # dev 种子数据(运营作用域授权,不含固定 ak) +├── front/ # Vue 3 管理后台,Maven 打包时自动构建进 jar ├── src/main/java/me/iiv/agent/ │ ├── AgentServiceApplication.java │ ├── config/ # M0/M1/M2/SvcDataSourceConfig(独立 SqlSessionFactory + MapperScan)、McpToolConfig @@ -35,7 +38,7 @@ xiaoqu-agent-service/ │ ├── security/ # AkAuthFilter(/mcp ak→401/AuthContext)、AuthService(ak 解析+用户加载)、ToolGuard │ ├── permission/ # PermissionService:行级数据权限引擎(移植 getByIsAgent) │ ├── common/ # AuditService(audit_log 落库) -│ ├── admin/ # P4 占位包 +│ ├── admin/ # 后台登录、会话/CSRF、Token/工具/作用域/审计 API │ ├── entity/m0、m2、svc mapper/m0、m1、m2、svc # codegen 生成物(提交进仓库) ├── src/main/resources/ │ ├── application.yml # MCP server: protocol=STREAMABLE, type=SYNC,端点 /mcp @@ -99,6 +102,10 @@ export M2_DB_PASSWORD= export SVC_DB_USERNAME= export SVC_DB_PASSWORD= export REDIS_PASSWORD= +# 仅首次、且 admin_user 为空时创建管理员;创建完成后可移除这三个变量 +export ADMIN_BOOTSTRAP_ACCOUNT= +export ADMIN_BOOTSTRAP_PASSWORD= +export ADMIN_BOOTSTRAP_NAME= ./mvnw clean package -DskipTests java -jar target/xiaoqu-agent-service.jar @@ -109,7 +116,8 @@ nohup java -jar target/xiaoqu-agent-service.jar > target/app.log 2>&1 & ``` - 不提供默认 profile、数据库账号或密码;缺失配置时不得启动。 -- dev 端口 **8088**,MCP 端点 `http://localhost:8088/mcp`。 +- dev 端口 **8088**,管理后台 `http://localhost:8088/`,MCP 端点 `http://localhost:8088/mcp`。 +- 已存在 V1 数据库时,部署 P4 前先执行 `mysql ... < sql/V2__p4_admin.sql`。 ### 部署脚本(推荐,本机 dev 部署) diff --git a/xiaoqu-agent-service/front/index.html b/xiaoqu-agent-service/front/index.html new file mode 100644 index 0000000..5f69cc1 --- /dev/null +++ b/xiaoqu-agent-service/front/index.html @@ -0,0 +1,13 @@ + + + + + + + 小趣智能体服务 + + +
+ + + diff --git a/xiaoqu-agent-service/front/package-lock.json b/xiaoqu-agent-service/front/package-lock.json new file mode 100644 index 0000000..c229719 --- /dev/null +++ b/xiaoqu-agent-service/front/package-lock.json @@ -0,0 +1,1595 @@ +{ + "name": "xiaoqu-agent-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "xiaoqu-agent-admin", + "version": "0.1.0", + "dependencies": { + "element-plus": "2.11.3", + "vue": "3.5.21" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.1", + "vite": "7.3.6" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz", + "integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz", + "integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.8.0", + "@floating-ui/utils": "^0.2.12" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz", + "integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==", + "license": "MIT" + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.29", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz", + "integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.25.tgz", + "integrity": "sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.16.tgz", + "integrity": "sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz", + "integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.29" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.21.tgz", + "integrity": "sha512-8i+LZ0vf6ZgII5Z9XmUvrCyEzocvWT+TeR2VBUVlzIH6Tyv57E20mPZ1bCS+tbejgUgmjrEh7q/0F0bibskAmw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/shared": "3.5.21", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.21.tgz", + "integrity": "sha512-jNtbu/u97wiyEBJlJ9kmdw7tAr5Vy0Aj5CgQmo+6pxWNQhXZDPsRr1UWPN4v3Zf82s2H3kF51IbzZ4jMWAgPlQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.21.tgz", + "integrity": "sha512-SXlyk6I5eUGBd2v8Ie7tF6ADHE9kCR6mBEuPyH1nUZ0h6Xx6nZI29i12sJKQmzbDyr2tUHMhhTt51Z6blbkTTQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@vue/compiler-core": "3.5.21", + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.18", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.21.tgz", + "integrity": "sha512-vKQ5olH5edFZdf5ZrlEgSO1j1DMA4u23TVK5XR1uMhvwnYvVdDF0nHXJUblL/GvzlShQbjhZZ2uvYmDlAbgo9w==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.21.tgz", + "integrity": "sha512-3ah7sa+Cwr9iiYEERt9JfZKPw4A2UlbY8RbbnH2mGCE8NwHkhmlZt2VsH0oDA3P08X3jJd29ohBDtX+TbD9AsA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.21.tgz", + "integrity": "sha512-+DplQlRS4MXfIf9gfD1BOJpk5RSyGgGXD/R+cumhe8jdjUcq/qlxDawQlSI8hCKupBlvM+3eS1se5xW+SuNAwA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/shared": "3.5.21" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.21.tgz", + "integrity": "sha512-3M2DZsOFwM5qI15wrMmNF5RJe1+ARijt2HM3TbzBbPSuBHOQpoidE+Pa+XEaVN+czbHf81ETRoG1ltztP2em8w==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.21", + "@vue/runtime-core": "3.5.21", + "@vue/shared": "3.5.21", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.21.tgz", + "integrity": "sha512-qr8AqgD3DJPJcGvLcJKQo2tAc8OnXRcfxhOJCPF+fcfn5bBGz7VCcO7t+qETOPxpWK1mgysXvVT/j+xWaHeMWA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "vue": "3.5.21" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.21.tgz", + "integrity": "sha512-+2k1EQpnYuVuu3N7atWyG3/xoFWIVJZq4Mz8XNOdScFI0etES75fbny/oU4lKWk/577P1zmg0ioYvpGEDZ3DLw==", + "license": "MIT" + }, + "node_modules/@vueuse/core": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-9.13.0.tgz", + "integrity": "sha512-pujnclbeHWxxPRqXWmdkKV5OX4Wk4YeK7wusHqRwU0Q7EFusHoqNA/aPhB6KCh9hEqJkLAJo7bb0Lh9b+OIVzw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.16", + "@vueuse/metadata": "9.13.0", + "@vueuse/shared": "9.13.0", + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/core/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/@vueuse/metadata": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-9.13.0.tgz", + "integrity": "sha512-gdU7TKNAUVlXXLbaF+ZCfte8BjRJQWPCa2J55+7/h+yDtzw3vOoGQDRXzI6pyKyo6bXFT5/QoPE4hAknExjRLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "9.13.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-9.13.0.tgz", + "integrity": "sha512-UrnhU+Cnufu4S6JLCPZnkWh0WwZGUp72ktOF2DFptMlOs3TOdVv8xJN53zhHGARmVOsz5KqOls09+J1NR6sBKw==", + "license": "MIT", + "dependencies": { + "vue-demi": "*" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared/node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmjs.org/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, + "node_modules/element-plus": { + "version": "2.11.3", + "resolved": "https://registry.npmjs.org/element-plus/-/element-plus-2.11.3.tgz", + "integrity": "sha512-769xsjLR4B9Vf9cl5PDXnwTEdmFJvMgAkYtthdJKPhjVjU3hdAwTJ+gXKiO+PUyo2KWFwOYKZd4Ywh6PHfkbJg==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^3.4.1", + "@element-plus/icons-vue": "^2.3.1", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.14.182", + "@types/lodash-es": "^4.17.6", + "@vueuse/core": "^9.1.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.13", + "escape-html": "^1.0.3", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "lodash-unified": "^1.0.2", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.21", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.21.tgz", + "integrity": "sha512-xxf9rum9KtOdwdRkiApWL+9hZEMWE90FHh8yS1+KJAiWYh+iGWV1FquPjoO9VUHQ+VIhsCXNNyZ5Sf4++RVZBA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.21", + "@vue/compiler-sfc": "3.5.21", + "@vue/runtime-dom": "3.5.21", + "@vue/server-renderer": "3.5.21", + "@vue/shared": "3.5.21" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/xiaoqu-agent-service/front/package.json b/xiaoqu-agent-service/front/package.json new file mode 100644 index 0000000..675f4e9 --- /dev/null +++ b/xiaoqu-agent-service/front/package.json @@ -0,0 +1,19 @@ +{ + "name": "xiaoqu-agent-admin", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "node --test tests/*.test.mjs" + }, + "dependencies": { + "element-plus": "2.11.3", + "vue": "3.5.21" + }, + "devDependencies": { + "@vitejs/plugin-vue": "6.0.1", + "vite": "7.3.6" + } +} diff --git a/xiaoqu-agent-service/front/src/App.vue b/xiaoqu-agent-service/front/src/App.vue new file mode 100644 index 0000000..635d0b5 --- /dev/null +++ b/xiaoqu-agent-service/front/src/App.vue @@ -0,0 +1,406 @@ + + + diff --git a/xiaoqu-agent-service/front/src/api.js b/xiaoqu-agent-service/front/src/api.js new file mode 100644 index 0000000..c4d90a2 --- /dev/null +++ b/xiaoqu-agent-service/front/src/api.js @@ -0,0 +1,26 @@ +let csrfToken = '' + +export function setCsrf(value) { + csrfToken = value || '' +} + +export async function api(path, options = {}) { + const method = options.method || 'GET' + const response = await fetch(`/api/admin${path}`, { + credentials: 'same-origin', + ...options, + headers: { + ...(options.body ? { 'Content-Type': 'application/json' } : {}), + ...(!['GET', 'HEAD'].includes(method) && csrfToken ? { 'X-CSRF-Token': csrfToken } : {}), + ...options.headers, + }, + }) + if (!response.ok) { + let message = `请求失败(${response.status})` + try { message = (await response.json()).message || message } catch { /* empty response */ } + const error = new Error(message) + error.status = response.status + throw error + } + return response.status === 204 ? null : response.json() +} diff --git a/xiaoqu-agent-service/front/src/main.js b/xiaoqu-agent-service/front/src/main.js new file mode 100644 index 0000000..786ee5b --- /dev/null +++ b/xiaoqu-agent-service/front/src/main.js @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import './style.css' +import App from './App.vue' + +createApp(App).use(ElementPlus).mount('#app') diff --git a/xiaoqu-agent-service/front/src/style.css b/xiaoqu-agent-service/front/src/style.css new file mode 100644 index 0000000..a759b30 --- /dev/null +++ b/xiaoqu-agent-service/front/src/style.css @@ -0,0 +1,62 @@ +:root { font-family: "Helvetica Neue", Helvetica, "PingFang SC", "Microsoft YaHei", Arial, sans-serif; color: #303133; background: #f0f2f5; font-synthesis: none; } +* { box-sizing: border-box; } +body { margin: 0; min-width: 1100px; background: #f0f2f5; } +button, input { font: inherit; } +.login-page { min-height: 100vh; display: grid; place-items: center; background: linear-gradient(135deg, #142f4e, #409eff); } +.login-card { width: 390px; padding: 42px; border-radius: 10px; background: white; box-shadow: 0 20px 50px rgb(0 21 41 / 25%); } +.brand-mark { width: 48px; height: 48px; margin: 0 auto 14px; display: grid; place-items: center; color: white; font-size: 24px; font-weight: 700; background: #409eff; border-radius: 12px; } +.login-card h1 { margin: 0; text-align: center; font-size: 22px; } +.login-card > p { margin: 7px 0 28px; text-align: center; color: #909399; } +.login-card .el-input { margin-bottom: 16px; } +.login-card .el-button { width: 100%; margin-top: 8px; } +.field-label { display: block; margin-bottom: 7px; color: #606266; font-size: 13px; } +.shell { min-height: 100vh; display: flex; } +.sidebar { position: fixed; inset: 0 auto 0 0; width: 220px; color: white; background: #001529; } +.logo { height: 60px; display: flex; align-items: center; justify-content: center; gap: 9px; font-weight: 650; border-bottom: 1px solid #1f3a5f; } +.logo span { width: 28px; height: 28px; display: grid; place-items: center; border-radius: 7px; background: #409eff; } +.menu-item { width: 100%; padding: 14px 24px; display: flex; gap: 10px; border: 0; color: #a6adb4; background: transparent; cursor: pointer; text-align: left; } +.menu-item:hover { color: white; } +.menu-item.active { color: white; background: #409eff; } +.main { width: calc(100% - 220px); margin-left: 220px; } +.header { height: 60px; padding: 0 24px; display: flex; align-items: center; justify-content: space-between; background: white; box-shadow: 0 1px 4px rgb(0 21 41 / 8%); } +.header > div:first-child { display: flex; align-items: center; gap: 12px; } +.header-kicker { padding-right: 12px; color: #909399; border-right: 1px solid #dcdfe6; } +.admin-user { display: flex; align-items: center; gap: 8px; color: #606266; } +.avatar { width: 30px; height: 30px; display: grid; place-items: center; border-radius: 50%; color: #409eff; background: #ecf5ff; } +.content { padding: 20px; } +.card { margin-bottom: 16px; padding: 20px; border-radius: 5px; background: white; box-shadow: 0 1px 2px rgb(0 0 0 / 4%); } +.card-title { min-height: 32px; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 15px; font-weight: 650; } +.card-title small { margin-left: 5px; color: #909399; font-size: 12px; font-weight: 400; } +.toolbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } +.toolbar .el-input { width: 230px; } +.toolbar .el-select { width: 160px; } +.toolbar.compact { flex-wrap: nowrap; } +.toolbar.compact .el-input { width: auto; flex: 1; } +.token-layout { display: grid; grid-template-columns: 340px minmax(680px, 1fr); gap: 16px; } +.token-card { min-width: 0; } +.user-list { max-height: 370px; overflow: auto; border-top: 1px solid #ebeef5; } +.user-row { width: 100%; padding: 12px 9px; display: flex; align-items: center; justify-content: space-between; border: 0; border-bottom: 1px solid #ebeef5; color: #303133; background: white; cursor: pointer; text-align: left; } +.user-row:hover { background: #f5f7fa; } +.user-row.selected { background: #ecf5ff; } +.user-row span:first-child { display: flex; flex-direction: column; gap: 4px; } +.user-row small { color: #909399; } +.permission-card dl { margin: 0; border-top: 1px solid #ebeef5; } +.permission-card dl > div { display: grid; grid-template-columns: 92px 1fr; border-bottom: 1px solid #ebeef5; } +.permission-card dt, .permission-card dd { margin: 0; padding: 10px 6px; } +.permission-card dt { color: #909399; } +.hint { margin: 12px 0 0; color: #909399; font-size: 12px; line-height: 1.6; } +code { font-family: Menlo, Consolas, monospace; font-size: 12px; } +.el-table .el-tag + .el-tag { margin-left: 5px; } +.danger-text { color: #f56c6c; font-size: 12px; } +.audit-toolbar .el-input { width: 150px; } +.audit-toolbar .el-select { width: 140px; } +.audit-toolbar .el-date-editor { width: 150px; } +.tool-groups { display: block; max-height: 520px; overflow: auto; } +.tool-group { margin-bottom: 12px; border: 1px solid #ebeef5; border-radius: 4px; } +.tool-group h3 { margin: 0; padding: 9px 12px; font-size: 14px; background: #fafafa; border-bottom: 1px solid #ebeef5; } +.tool-group .el-checkbox { width: 100%; height: auto; margin: 0; padding: 9px 12px; align-items: flex-start; } +.tool-group .el-checkbox + .el-checkbox { border-top: 1px solid #f2f3f5; } +.tool-group .el-checkbox__label { display: flex; flex-direction: column; white-space: normal; } +.tool-group small { margin-top: 3px; color: #909399; } +.token-secret { display: block; margin-top: 14px; padding: 14px; word-break: break-all; border-radius: 4px; background: #f4f4f5; font-size: 13px; } +@media (max-width: 1200px) { .token-layout { grid-template-columns: 300px minmax(640px, 1fr); } } diff --git a/xiaoqu-agent-service/front/tests/api.test.mjs b/xiaoqu-agent-service/front/tests/api.test.mjs new file mode 100644 index 0000000..5a09e57 --- /dev/null +++ b/xiaoqu-agent-service/front/tests/api.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { api, setCsrf } from '../src/api.js' + +test('mutation sends the session CSRF token and credentials', async () => { + let request + globalThis.fetch = async (url, options) => { + request = { url, options } + return new Response(null, { status: 204 }) + } + setCsrf('csrf-test') + + await api('/tools/1/status', { method: 'PATCH', body: '{"status":0}' }) + + assert.equal(request.url, '/api/admin/tools/1/status') + assert.equal(request.options.credentials, 'same-origin') + assert.equal(request.options.headers['X-CSRF-Token'], 'csrf-test') + assert.equal(request.options.headers['Content-Type'], 'application/json') +}) diff --git a/xiaoqu-agent-service/front/tests/source-contract.test.mjs b/xiaoqu-agent-service/front/tests/source-contract.test.mjs new file mode 100644 index 0000000..197c242 --- /dev/null +++ b/xiaoqu-agent-service/front/tests/source-contract.test.mjs @@ -0,0 +1,26 @@ +import { readFile } from 'node:fs/promises' +import test from 'node:test' +import assert from 'node:assert/strict' + +test('prototype interactions expose stable test ids', async () => { + const source = await readFile(new URL('../src/App.vue', import.meta.url), 'utf8') + const required = [ + 'admin-input-account', + 'admin-input-password', + 'admin-btn-login', + 'admin-menu-tokens', + 'admin-menu-tools', + 'admin-menu-scopes', + 'admin-menu-audit', + 'admin-input-user-search', + 'admin-btn-issue-token', + 'admin-table-tools', + 'admin-btn-new-scope', + 'admin-table-scopes', + 'admin-table-audit', + 'admin-btn-logout' + ] + for (const testId of required) { + assert.match(source, new RegExp(`data-testid=["']${testId}["']`), testId) + } +}) diff --git a/xiaoqu-agent-service/front/vite.config.js b/xiaoqu-agent-service/front/vite.config.js new file mode 100644 index 0000000..c9dc48f --- /dev/null +++ b/xiaoqu-agent-service/front/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + build: { outDir: 'dist', emptyOutDir: true }, + server: { proxy: { '/api': 'http://localhost:8088' } }, +}) diff --git a/xiaoqu-agent-service/pom.xml b/xiaoqu-agent-service/pom.xml index 9c5af43..675c731 100644 --- a/xiaoqu-agent-service/pom.xml +++ b/xiaoqu-agent-service/pom.xml @@ -99,7 +99,45 @@ xiaoqu-agent-service + + + src/main/resources + + + front/dist + static + + + + com.github.eirslett + frontend-maven-plugin + 1.15.1 + + front + target + + + + install-node-and-npm + install-node-and-npm + + v20.20.2 + 10.8.2 + + + + npm-ci + npm + ci + + + npm-build + npm + run build + + + org.springframework.boot spring-boot-maven-plugin diff --git a/xiaoqu-agent-service/sql/V1__init.sql b/xiaoqu-agent-service/sql/V1__init.sql index 7c5594e..f874a83 100644 --- a/xiaoqu-agent-service/sql/V1__init.sql +++ b/xiaoqu-agent-service/sql/V1__init.sql @@ -115,7 +115,7 @@ CREATE TABLE IF NOT EXISTS audit_log ( CREATE TABLE IF NOT EXISTS admin_user ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', account VARCHAR(64) NOT NULL COMMENT '登录账号', - password_hash CHAR(64) NOT NULL COMMENT '密码 SHA-256 hex', + password_hash VARCHAR(255) NOT NULL COMMENT 'PBKDF2 加盐密码哈希', name VARCHAR(64) NULL COMMENT '姓名', status TINYINT NOT NULL DEFAULT 1 COMMENT '1=正常 0=禁用', create_id BIGINT NULL COMMENT '创建人', diff --git a/xiaoqu-agent-service/sql/V2__p4_admin.sql b/xiaoqu-agent-service/sql/V2__p4_admin.sql new file mode 100644 index 0000000..be656ae --- /dev/null +++ b/xiaoqu-agent-service/sql/V2__p4_admin.sql @@ -0,0 +1,4 @@ +USE xiaoqu_agent_svc_d; + +ALTER TABLE admin_user + MODIFY COLUMN password_hash VARCHAR(255) NOT NULL COMMENT 'PBKDF2 加盐密码哈希'; diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminAuthController.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminAuthController.java new file mode 100644 index 0000000..b09a5c2 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminAuthController.java @@ -0,0 +1,83 @@ +package me.iiv.agent.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; +import me.iiv.agent.entity.svc.AdminUser; +import me.iiv.agent.mapper.svc.AdminUserMapper; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.security.SecureRandom; + +@RestController +@RequestMapping("/api/admin/auth") +public class AdminAuthController { + + private static final SecureRandom RANDOM = new SecureRandom(); + private final AdminUserMapper mapper; + private final AdminPasswordHasher hasher; + + public AdminAuthController(AdminUserMapper mapper, AdminPasswordHasher hasher) { + this.mapper = mapper; + this.hasher = hasher; + } + + @PostMapping("/login") + public SessionView login(@RequestBody LoginRequest body, HttpServletRequest request) { + if (body == null || body.account() == null || body.password() == null + || body.account().length() > 64 || body.password().length() > 1024) throw unauthorized(); + AdminUser admin = mapper.selectOne(new LambdaQueryWrapper() + .eq(AdminUser::getAccount, body.account().trim()) + .eq(AdminUser::getStatus, 1).eq(AdminUser::getSoftDelete, 0)); + if (admin == null || !hasher.matches(body.password(), admin.getPasswordHash())) throw unauthorized(); + HttpSession old = request.getSession(false); + if (old != null) old.invalidate(); + HttpSession session = request.getSession(true); + session.setAttribute(AdminSessionFilter.ADMIN_SESSION_ID, admin.getId()); + session.setAttribute(AdminSessionFilter.ADMIN_SESSION_NAME, + admin.getName() == null ? admin.getAccount() : admin.getName()); + String csrf = randomHex(24); + session.setAttribute(AdminSessionFilter.CSRF_SESSION_KEY, csrf); + return new SessionView(admin.getId(), admin.getAccount(), + (String) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_NAME), csrf); + } + + @GetMapping("/me") + public SessionView me(HttpSession session) { + Long id = (Long) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_ID); + AdminUser admin = mapper.selectById(id); + if (admin == null || admin.getStatus() == null || admin.getStatus() != 1 + || admin.getSoftDelete() == null || admin.getSoftDelete() != 0) throw unauthorized(); + return new SessionView(id, admin.getAccount(), + (String) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_NAME), + (String) session.getAttribute(AdminSessionFilter.CSRF_SESSION_KEY)); + } + + @PostMapping("/logout") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void logout(HttpSession session) { + session.invalidate(); + } + + private static ResponseStatusException unauthorized() { + return new ResponseStatusException(HttpStatus.UNAUTHORIZED, "账号或密码错误"); + } + + static String randomHex(int bytes) { + byte[] value = new byte[bytes]; + RANDOM.nextBytes(value); + StringBuilder result = new StringBuilder(bytes * 2); + for (byte b : value) result.append(String.format("%02x", b)); + return result.toString(); + } + + public record LoginRequest(String account, String password) {} + public record SessionView(Long id, String account, String name, String csrfToken) {} +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminBootstrap.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminBootstrap.java new file mode 100644 index 0000000..ec7d948 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminBootstrap.java @@ -0,0 +1,47 @@ +package me.iiv.agent.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.svc.AdminUser; +import me.iiv.agent.mapper.svc.AdminUserMapper; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class AdminBootstrap implements ApplicationRunner { + + private final AdminUserMapper mapper; + private final AdminPasswordHasher hasher; + private final String account; + private final String password; + private final String name; + + public AdminBootstrap(AdminUserMapper mapper, AdminPasswordHasher hasher, + @Value("${app.admin.bootstrap.account:}") String account, + @Value("${app.admin.bootstrap.password:}") String password, + @Value("${app.admin.bootstrap.name:系统管理员}") String name) { + this.mapper = mapper; + this.hasher = hasher; + this.account = account; + this.password = password; + this.name = name; + } + + @Override + public void run(ApplicationArguments args) { + if (account.isBlank() || password.isBlank()) return; + if (account.length() > 64 || password.length() < 12) { + throw new IllegalStateException("管理员账号最多 64 字,初始密码至少 12 字"); + } + Long count = mapper.selectCount(new LambdaQueryWrapper().eq(AdminUser::getSoftDelete, 0)); + if (count != 0) return; + AdminUser admin = new AdminUser(); + admin.setAccount(account.trim()); + admin.setPasswordHash(hasher.hash(password)); + admin.setName(name); + admin.setStatus((byte) 1); + admin.setSoftDelete((byte) 0); + mapper.insert(admin); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java new file mode 100644 index 0000000..50ad2fa --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminConsoleController.java @@ -0,0 +1,391 @@ +package me.iiv.agent.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import jakarta.servlet.http.HttpSession; +import me.iiv.agent.entity.m0.Agent; +import me.iiv.agent.entity.m0.Authority; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.svc.AgentApiKey; +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.entity.svc.AuditLog; +import me.iiv.agent.entity.svc.ScopeTool; +import me.iiv.agent.entity.svc.TokenScope; +import me.iiv.agent.entity.svc.Tool; +import me.iiv.agent.mapper.m0.AgentMapper; +import me.iiv.agent.mapper.m0.AuthorityMapper; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.svc.AgentApiKeyMapper; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.mapper.svc.AuditLogMapper; +import me.iiv.agent.mapper.svc.ScopeToolMapper; +import me.iiv.agent.mapper.svc.ToolMapper; +import me.iiv.agent.mapper.svc.TokenScopeMapper; +import me.iiv.agent.permission.PermissionService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.HttpStatus; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +@RestController +@RequestMapping("/api/admin") +public class AdminConsoleController { + + private final UserMapper userMapper; + private final AuthorityMapper authorityMapper; + private final AgentMapper agentMapper; + private final AgentApiKeyMapper tokenMapper; + private final AgentScopeMapper scopeMapper; + private final ToolMapper toolMapper; + private final ScopeToolMapper scopeToolMapper; + private final TokenScopeMapper tokenScopeMapper; + private final AuditLogMapper auditMapper; + private final PermissionService permissionService; + private final AdminTokenService tokenService; + + public AdminConsoleController(UserMapper userMapper, AuthorityMapper authorityMapper, AgentMapper agentMapper, + AgentApiKeyMapper tokenMapper, AgentScopeMapper scopeMapper, ToolMapper toolMapper, + ScopeToolMapper scopeToolMapper, AuditLogMapper auditMapper, + TokenScopeMapper tokenScopeMapper, PermissionService permissionService, + AdminTokenService tokenService) { + this.userMapper = userMapper; + this.authorityMapper = authorityMapper; + this.agentMapper = agentMapper; + this.tokenMapper = tokenMapper; + this.scopeMapper = scopeMapper; + this.toolMapper = toolMapper; + this.scopeToolMapper = scopeToolMapper; + this.tokenScopeMapper = tokenScopeMapper; + this.auditMapper = auditMapper; + this.permissionService = permissionService; + this.tokenService = tokenService; + } + + @GetMapping("/users") + public List users(@RequestParam(defaultValue = "") String keyword) { + if (keyword.length() > 100) bad("搜索关键词最多 100 字"); + LambdaQueryWrapper query = new LambdaQueryWrapper().eq(User::getSoftDelete, 0); + if (!keyword.isBlank()) query.and(w -> w.like(User::getName, keyword.trim()) + .or().like(User::getMobile, keyword.trim())); + List users = userMapper.selectList(query.orderByDesc(User::getId).last("LIMIT 100")); + Set ids = users.stream().map(User::getId).collect(Collectors.toSet()); + Map counts = ids.isEmpty() ? Map.of() : tokenMapper.selectList( + new LambdaQueryWrapper().in(AgentApiKey::getUserId, ids) + .eq(AgentApiKey::getSoftDelete, 0)) + .stream().collect(Collectors.groupingBy(AgentApiKey::getUserId, Collectors.counting())); + return users.stream().map(user -> new UserView(user.getId(), user.getName(), maskMobile(user.getMobile()), + counts.getOrDefault(user.getId(), 0L))).toList(); + } + + @GetMapping("/users/{id}/permission") + public PermissionView permission(@PathVariable Long id) { + User user = userMapper.selectById(id); + if (user == null || Integer.valueOf(1).equals(user.getSoftDelete())) bad("人员不存在"); + Authority authority = user.getAuthorityId() == null ? null : authorityMapper.selectById(user.getAuthorityId()); + Agent agent = user.getAgentId() == null ? null : agentMapper.selectById(user.getAgentId()); + String range; + try { + List ids = permissionService.visibleAgentIds(user); + range = ids == null ? "全部数据" : "自己、直属下级及代管代理(" + ids.size() + " 家)"; + } catch (IllegalStateException e) { + range = "权限配置异常:" + e.getMessage(); + } + return new PermissionView(authority == null ? "未配置" : authority.getName(), + authority == null ? null : authority.getIsagent(), agent == null ? "未关联" : agent.getName(), + user.getEnterpriseType(), range); + } + + @GetMapping("/users/{id}/tokens") + public List tokens(@PathVariable Long id) { + return tokenService.list(id); + } + + @PostMapping("/users/{id}/tokens") + public AdminTokenService.IssuedToken issue(@PathVariable Long id, @RequestBody TokenRequest body, + HttpSession session) { + if (body == null) bad("请求内容不能为空"); + return tokenService.issue(id, body.memo(), body.scopeIds(), body.expireTime(), adminId(session)); + } + + @PutMapping("/tokens/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updateToken(@PathVariable Long id, @RequestBody TokenRequest body, HttpSession session) { + if (body == null) bad("请求内容不能为空"); + tokenService.update(id, body.memo(), body.scopeIds(), body.expireTime(), adminId(session)); + } + + @PatchMapping("/tokens/{id}/status") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void tokenStatus(@PathVariable Long id, @RequestBody StatusRequest body, HttpSession session) { + if (body == null) bad("请求内容不能为空"); + tokenService.status(id, body.status(), adminId(session)); + } + + @DeleteMapping("/tokens/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void deleteToken(@PathVariable Long id, HttpSession session) { + tokenService.delete(id, adminId(session)); + } + + @GetMapping("/tools") + public List tools(@RequestParam(defaultValue = "") String category, + @RequestParam(defaultValue = "") String kind, + @RequestParam(defaultValue = "") String keyword) { + LambdaQueryWrapper query = new LambdaQueryWrapper().eq(Tool::getSoftDelete, 0); + if (!category.isBlank()) query.eq(Tool::getCategory, category); + if (!kind.isBlank()) query.eq(Tool::getKind, kind); + if (!keyword.isBlank()) query.and(w -> w.like(Tool::getName, keyword.trim()) + .or().like(Tool::getDescription, keyword.trim())); + List tools = toolMapper.selectList(query.orderByAsc(Tool::getCategory, Tool::getName)); + Map scopes = scopeMapper.selectList(new LambdaQueryWrapper() + .eq(AgentScope::getSoftDelete, 0)).stream() + .collect(Collectors.toMap(AgentScope::getId, Function.identity())); + Map> scopeNames = scopeToolMapper.selectList(new LambdaQueryWrapper() + .eq(ScopeTool::getSoftDelete, 0)).stream() + .filter(r -> scopes.containsKey(r.getScopeId())) + .collect(Collectors.groupingBy(ScopeTool::getToolId, + Collectors.mapping(r -> scopes.get(r.getScopeId()).getName(), Collectors.toList()))); + return tools.stream().map(tool -> new ToolView(tool.getId(), tool.getName(), tool.getCategory(), + tool.getDescription(), tool.getKind(), tool.getStatus(), tool.getModifyTime(), + scopeNames.getOrDefault(tool.getId(), List.of()))).toList(); + } + + @PatchMapping("/tools/{id}/status") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void toolStatus(@PathVariable Long id, @RequestBody StatusRequest body, HttpSession session) { + if (body == null) bad("请求内容不能为空"); + validateStatus(body.status()); + Tool tool = toolMapper.selectById(id); + if (tool == null || !Byte.valueOf((byte) 0).equals(tool.getSoftDelete())) bad("工具不存在"); + tool.setStatus(body.status()); + tool.setModifyId(adminId(session)); + toolMapper.updateById(tool); + } + + @GetMapping("/scopes") + public List scopes() { + List scopes = scopeMapper.selectList(new LambdaQueryWrapper() + .eq(AgentScope::getSoftDelete, 0).orderByDesc(AgentScope::getId)); + Map counts = scopeToolMapper.selectList(new LambdaQueryWrapper() + .eq(ScopeTool::getSoftDelete, 0)).stream() + .collect(Collectors.groupingBy(ScopeTool::getScopeId, Collectors.counting())); + return scopes.stream().map(scope -> new ScopeView(scope.getId(), scope.getName(), scope.getCode(), + scope.getMemo(), scope.getStatus(), scope.getModifyTime(), counts.getOrDefault(scope.getId(), 0L))).toList(); + } + + @PostMapping("/scopes") + @Transactional(transactionManager = "svcTransactionManager") + public ScopeView createScope(@RequestBody ScopeRequest body, HttpSession session) { + validateScope(body, null); + AgentScope scope = new AgentScope(); + apply(scope, body, adminId(session)); + scope.setCreateId(adminId(session)); + scope.setStatus((byte) 1); + scope.setSoftDelete((byte) 0); + scopeMapper.insert(scope); + return new ScopeView(scope.getId(), scope.getName(), scope.getCode(), scope.getMemo(), + scope.getStatus(), scope.getModifyTime(), 0); + } + + @PutMapping("/scopes/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void updateScope(@PathVariable Long id, @RequestBody ScopeRequest body, HttpSession session) { + AgentScope scope = activeScope(id); + validateScope(body, id); + apply(scope, body, adminId(session)); + scopeMapper.updateById(scope); + } + + @PatchMapping("/scopes/{id}/status") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void scopeStatus(@PathVariable Long id, @RequestBody StatusRequest body, HttpSession session) { + if (body == null) bad("请求内容不能为空"); + validateStatus(body.status()); + AgentScope scope = activeScope(id); + scope.setStatus(body.status()); + scope.setModifyId(adminId(session)); + scopeMapper.updateById(scope); + } + + @DeleteMapping("/scopes/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional(transactionManager = "svcTransactionManager") + public void deleteScope(@PathVariable Long id, HttpSession session) { + AgentScope scope = activeScope(id); + Long tokenCount = tokenScopeMapper.selectCount(new LambdaQueryWrapper() + .eq(TokenScope::getScopeId, id).eq(TokenScope::getSoftDelete, 0)); + if (tokenCount != null && tokenCount > 0) { + throw new ResponseStatusException(HttpStatus.CONFLICT, "该作用域仍被 Token 使用,请先调整 Token"); + } + scope.setStatus((byte) 0); + scope.setSoftDelete((byte) 1); + scope.setModifyId(adminId(session)); + scopeMapper.updateById(scope); + for (ScopeTool relation : scopeToolMapper.selectList(new LambdaQueryWrapper() + .eq(ScopeTool::getScopeId, id).eq(ScopeTool::getSoftDelete, 0))) { + relation.setSoftDelete((byte) 1); + relation.setModifyId(adminId(session)); + scopeToolMapper.updateById(relation); + } + } + + @GetMapping("/scopes/{id}/tools") + public List scopeTools(@PathVariable Long id) { + activeScope(id); + return scopeToolMapper.selectList(new LambdaQueryWrapper() + .eq(ScopeTool::getScopeId, id).eq(ScopeTool::getSoftDelete, 0)) + .stream().map(ScopeTool::getToolId).toList(); + } + + @PutMapping("/scopes/{id}/tools") + @ResponseStatus(HttpStatus.NO_CONTENT) + @Transactional(transactionManager = "svcTransactionManager") + public void updateScopeTools(@PathVariable Long id, @RequestBody ToolIdsRequest body, HttpSession session) { + activeScope(id); + if (body == null) bad("请求内容不能为空"); + List toolIds = body.toolIds() == null ? List.of() : body.toolIds().stream().distinct().toList(); + if (!toolIds.isEmpty() && toolMapper.selectList(new LambdaQueryWrapper() + .in(Tool::getId, toolIds).eq(Tool::getSoftDelete, 0)).size() != toolIds.size()) bad("工具不存在"); + Long adminId = adminId(session); + List old = scopeToolMapper.selectList(new LambdaQueryWrapper().eq(ScopeTool::getScopeId, id)); + for (ScopeTool relation : old) { + relation.setSoftDelete((byte) 1); + relation.setModifyId(adminId); + scopeToolMapper.updateById(relation); + } + for (Long toolId : toolIds) { + ScopeTool relation = old.stream().filter(r -> Objects.equals(r.getToolId(), toolId)).findFirst().orElse(null); + if (relation == null) { + relation = new ScopeTool(); + relation.setScopeId(id); + relation.setToolId(toolId); + relation.setCreateId(adminId); + } + relation.setSoftDelete((byte) 0); + relation.setModifyId(adminId); + if (relation.getId() == null) scopeToolMapper.insert(relation); else scopeToolMapper.updateById(relation); + } + } + + @GetMapping("/audit") + public List audit(@RequestParam(defaultValue = "") String person, + @RequestParam(defaultValue = "") String tokenPrefix, + @RequestParam(defaultValue = "") String tool, + @RequestParam(defaultValue = "") String result, + @RequestParam(defaultValue = "") String client, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + LambdaQueryWrapper query = new LambdaQueryWrapper().eq(AuditLog::getSoftDelete, 0); + if (person.length() > 100 || tokenPrefix.length() > 16 || tool.length() > 128) bad("审计筛选条件过长"); + if (!person.isBlank()) { + List ids = userMapper.selectList(new LambdaQueryWrapper().eq(User::getSoftDelete, 0) + .and(w -> w.like(User::getName, person.trim()).or().like(User::getMobile, person.trim())) + .select(User::getId).last("LIMIT 1000")) + .stream().map(User::getId).toList(); + if (ids.isEmpty()) return List.of(); + query.in(AuditLog::getUserId, ids); + } + if (!tokenPrefix.isBlank()) { + List ids = tokenMapper.selectList(new LambdaQueryWrapper() + .likeRight(AgentApiKey::getKeyPrefix, tokenPrefix.trim()).select(AgentApiKey::getId) + .last("LIMIT 1000")) + .stream().map(AgentApiKey::getId).toList(); + if (ids.isEmpty()) return List.of(); + query.in(AuditLog::getTokenId, ids); + } + if (!tool.isBlank()) query.like(AuditLog::getToolName, tool.trim()); + if (!result.isBlank()) query.eq(AuditLog::getResultStatus, result); + if (!client.isBlank()) query.eq(AuditLog::getClientType, client); + ZoneId zone = ZoneId.systemDefault(); + if (from != null) query.ge(AuditLog::getCreateTime, Date.from(from.atStartOfDay(zone).toInstant())); + if (to != null) query.lt(AuditLog::getCreateTime, Date.from(to.plusDays(1).atStartOfDay(zone).toInstant())); + List logs = auditMapper.selectList(query.orderByDesc(AuditLog::getCreateTime).last("LIMIT 200")); + Set userIds = logs.stream().map(AuditLog::getUserId).filter(Objects::nonNull).collect(Collectors.toSet()); + Set tokenIds = logs.stream().map(AuditLog::getTokenId).filter(Objects::nonNull).collect(Collectors.toSet()); + Map names = userIds.isEmpty() ? Map.of() : userMapper.selectBatchIds(userIds).stream() + .collect(Collectors.toMap(User::getId, user -> user.getName() == null ? "未命名" : user.getName())); + Map prefixes = tokenIds.isEmpty() ? Map.of() : tokenMapper.selectBatchIds(tokenIds).stream() + .collect(Collectors.toMap(AgentApiKey::getId, AgentApiKey::getKeyPrefix)); + return logs.stream().map(log -> new AuditView(log.getId(), log.getCreateTime(), log.getUserId(), + log.getUserId() == null ? "未知用户" : names.getOrDefault(log.getUserId(), "未知用户"), + log.getTokenId() == null ? null : prefixes.get(log.getTokenId()), log.getToolName(), + log.getParamsDigest(), log.getResultStatus(), log.getResultMessage(), log.getCostMs(), + log.getClientIp(), log.getClientType())).toList(); + } + + private void validateScope(ScopeRequest body, Long currentId) { + if (body == null) bad("请求内容不能为空"); + if (body.name() == null || body.name().isBlank() || body.name().length() > 64) bad("作用域名称不能为空且最多 64 字"); + if (body.code() == null || !body.code().matches("[a-z][a-z0-9_-]{0,63}")) bad("作用域编码格式无效"); + if (body.memo() != null && body.memo().length() > 255) bad("作用域备注最多 255 字"); + AgentScope duplicate = scopeMapper.selectOne(new LambdaQueryWrapper() + .eq(AgentScope::getCode, body.code()).eq(AgentScope::getSoftDelete, 0) + .ne(currentId != null, AgentScope::getId, currentId)); + if (duplicate != null) throw new ResponseStatusException(HttpStatus.CONFLICT, "作用域编码已存在"); + } + + private static void apply(AgentScope scope, ScopeRequest body, Long adminId) { + scope.setName(body.name().trim()); + scope.setCode(body.code().trim()); + scope.setMemo(body.memo() == null ? null : body.memo().trim()); + scope.setModifyId(adminId); + } + + private AgentScope activeScope(Long id) { + AgentScope scope = scopeMapper.selectById(id); + if (scope == null || !Byte.valueOf((byte) 0).equals(scope.getSoftDelete())) bad("作用域不存在"); + return scope; + } + + private static Long adminId(HttpSession session) { + return (Long) session.getAttribute(AdminSessionFilter.ADMIN_SESSION_ID); + } + + private static void validateStatus(byte status) { + if (status != 0 && status != 1) bad("状态无效"); + } + + private static void bad(String message) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + private static String maskMobile(String mobile) { + if (mobile == null || mobile.length() < 7) return mobile; + return mobile.substring(0, 3) + "****" + mobile.substring(mobile.length() - 4); + } + + public record UserView(Long id, String name, String mobile, long tokenCount) {} + public record PermissionView(String authorityName, Integer isAgent, String agentName, + Integer enterpriseType, String visibleRange) {} + public record TokenRequest(String memo, List scopeIds, Date expireTime) {} + public record StatusRequest(byte status) {} + public record ToolView(Long id, String name, String category, String description, String kind, + Byte status, Date modifyTime, List scopeNames) {} + public record ScopeRequest(String name, String code, String memo) {} + public record ScopeView(Long id, String name, String code, String memo, Byte status, + Date modifyTime, long toolCount) {} + public record ToolIdsRequest(List toolIds) {} + public record AuditView(Long id, Date createTime, Long userId, String userName, String tokenPrefix, + String toolName, String paramsDigest, String resultStatus, String resultMessage, + Long costMs, String clientIp, String clientType) {} +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminPasswordHasher.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminPasswordHasher.java new file mode 100644 index 0000000..1f229d0 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminPasswordHasher.java @@ -0,0 +1,47 @@ +package me.iiv.agent.admin; + +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +public class AdminPasswordHasher { + + private static final int ITERATIONS = 210_000; + private static final SecureRandom RANDOM = new SecureRandom(); + + public String hash(String password) { + byte[] salt = new byte[16]; + RANDOM.nextBytes(salt); + return "pbkdf2$" + ITERATIONS + "$" + Base64.getEncoder().encodeToString(salt) + + "$" + Base64.getEncoder().encodeToString(derive(password, salt, ITERATIONS)); + } + + public boolean matches(String password, String encoded) { + if (password == null || encoded == null) return false; + try { + String[] parts = encoded.split("\\$", -1); + if (parts.length != 4 || !"pbkdf2".equals(parts[0])) return false; + int iterations = Integer.parseInt(parts[1]); + if (iterations < 10_000 || iterations > 1_000_000) return false; + byte[] salt = Base64.getDecoder().decode(parts[2]); + byte[] expected = Base64.getDecoder().decode(parts[3]); + return MessageDigest.isEqual(expected, derive(password, salt, iterations)); + } catch (RuntimeException e) { + return false; + } + } + + private static byte[] derive(String password, byte[] salt, int iterations) { + try { + PBEKeySpec spec = new PBEKeySpec(password.toCharArray(), salt, iterations, 256); + return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded(); + } catch (Exception e) { + throw new IllegalStateException("密码哈希失败", e); + } + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminSessionFilter.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminSessionFilter.java new file mode 100644 index 0000000..4ceec4e --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminSessionFilter.java @@ -0,0 +1,60 @@ +package me.iiv.agent.admin; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +@Component +public class AdminSessionFilter extends OncePerRequestFilter { + + static final String ADMIN_SESSION_ID = "adminUserId"; + static final String ADMIN_SESSION_NAME = "adminName"; + static final String CSRF_SESSION_KEY = "adminCsrf"; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + return !request.getRequestURI().startsWith("/api/admin/"); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String uri = request.getRequestURI(); + response.setHeader("Cache-Control", "no-store"); + if ("/api/admin/auth/login".equals(uri)) { + chain.doFilter(request, response); + return; + } + HttpSession session = request.getSession(false); + if (session == null || session.getAttribute(ADMIN_SESSION_ID) == null) { + error(response, 401, "请先登录"); + return; + } + Object csrf = session.getAttribute(CSRF_SESSION_KEY); + if (isMutation(request.getMethod()) + && (csrf == null || !csrf.equals(request.getHeader("X-CSRF-Token")))) { + error(response, 403, "请求校验失败,请刷新后重试"); + return; + } + chain.doFilter(request, response); + } + + private static boolean isMutation(String method) { + return "POST".equals(method) || "PUT".equals(method) + || "PATCH".equals(method) || "DELETE".equals(method); + } + + private static void error(HttpServletResponse response, int status, String message) throws IOException { + response.setStatus(status); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + response.setContentType("application/json"); + response.getWriter().write("{\"message\":\"" + message + "\"}"); + } +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java new file mode 100644 index 0000000..a2230f7 --- /dev/null +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/admin/AdminTokenService.java @@ -0,0 +1,159 @@ +package me.iiv.agent.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.svc.AgentApiKey; +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.entity.svc.TokenScope; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.svc.AgentApiKeyMapper; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.mapper.svc.TokenScopeMapper; +import me.iiv.agent.security.AuthService; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Date; +import java.util.List; + +@Service +public class AdminTokenService { + + private final UserMapper userMapper; + private final AgentApiKeyMapper tokenMapper; + private final TokenScopeMapper relationMapper; + private final AgentScopeMapper scopeMapper; + + public AdminTokenService(UserMapper userMapper, AgentApiKeyMapper tokenMapper, + TokenScopeMapper relationMapper, AgentScopeMapper scopeMapper) { + this.userMapper = userMapper; + this.tokenMapper = tokenMapper; + this.relationMapper = relationMapper; + this.scopeMapper = scopeMapper; + } + + @Transactional(transactionManager = "svcTransactionManager") + public IssuedToken issue(Long userId, String memo, List scopeIds, Date expireTime, Long adminId) { + User user = userMapper.selectById(userId); + if (user == null || Integer.valueOf(1).equals(user.getSoftDelete())) bad("人员不存在"); + validateMemo(memo); + validateScopes(scopeIds); + String plaintext = "xqak_" + AdminAuthController.randomHex(24); + AgentApiKey token = new AgentApiKey(); + token.setUserId(userId); + token.setKeyPrefix(plaintext.substring(0, 13)); + token.setKeyHash(AuthService.sha256Hex(plaintext)); + token.setMemo(trim(memo, 255)); + token.setStatus((byte) 1); + token.setExpireTime(expireTime); + token.setCreateId(adminId); + token.setModifyId(adminId); + token.setSoftDelete((byte) 0); + tokenMapper.insert(token); + replaceScopes(token.getId(), scopeIds, adminId); + return new IssuedToken(plaintext, view(token)); + } + + @Transactional(transactionManager = "svcTransactionManager") + public void update(Long tokenId, String memo, List scopeIds, Date expireTime, Long adminId) { + AgentApiKey current = activeToken(tokenId); + validateMemo(memo); + validateScopes(scopeIds); + current.setMemo(trim(memo, 255)); + current.setExpireTime(expireTime); + current.setModifyId(adminId); + tokenMapper.updateById(current); + replaceScopes(tokenId, scopeIds, adminId); + } + + public void status(Long tokenId, byte status, Long adminId) { + if (status != 0 && status != 1) bad("状态无效"); + AgentApiKey token = activeToken(tokenId); + token.setStatus(status); + token.setModifyId(adminId); + tokenMapper.updateById(token); + } + + public void delete(Long tokenId, Long adminId) { + AgentApiKey token = activeToken(tokenId); + token.setStatus((byte) 0); + token.setSoftDelete((byte) 1); + token.setModifyId(adminId); + tokenMapper.updateById(token); + } + + public List list(Long userId) { + List tokens = tokenMapper.selectList(new LambdaQueryWrapper() + .eq(AgentApiKey::getUserId, userId).eq(AgentApiKey::getSoftDelete, 0) + .orderByDesc(AgentApiKey::getCreateTime)); + return tokens.stream().map(this::view).toList(); + } + + private TokenView view(AgentApiKey token) { + List scopeIds = relationMapper.selectList(new LambdaQueryWrapper() + .eq(TokenScope::getTokenId, token.getId()).eq(TokenScope::getSoftDelete, 0)) + .stream().map(TokenScope::getScopeId).toList(); + List scopeNames = scopeIds.isEmpty() ? List.of() : scopeMapper.selectList( + new LambdaQueryWrapper().in(AgentScope::getId, scopeIds)) + .stream().map(AgentScope::getName).toList(); + return new TokenView(token.getId(), token.getKeyPrefix(), token.getMemo(), token.getStatus(), + token.getExpireTime(), token.getLastUsedTime(), scopeIds, scopeNames); + } + + private void replaceScopes(Long tokenId, List scopeIds, Long adminId) { + List old = relationMapper.selectList(new LambdaQueryWrapper() + .eq(TokenScope::getTokenId, tokenId)); + for (TokenScope relation : old) { + relation.setSoftDelete((byte) 1); + relation.setModifyId(adminId); + relationMapper.updateById(relation); + } + for (Long scopeId : scopeIds) { + TokenScope relation = old.stream().filter(r -> scopeId.equals(r.getScopeId())).findFirst().orElse(null); + if (relation == null) { + relation = new TokenScope(); + relation.setTokenId(tokenId); + relation.setScopeId(scopeId); + relation.setCreateId(adminId); + } + relation.setSoftDelete((byte) 0); + relation.setModifyId(adminId); + if (relation.getId() == null) relationMapper.insert(relation); else relationMapper.updateById(relation); + } + } + + private void validateScopes(List scopeIds) { + if (scopeIds == null || scopeIds.isEmpty()) bad("至少选择一个作用域"); + if (scopeIds.stream().distinct().count() != scopeIds.size()) bad("作用域不能重复"); + long count = scopeMapper.selectList(new LambdaQueryWrapper() + .in(AgentScope::getId, scopeIds).eq(AgentScope::getStatus, 1) + .eq(AgentScope::getSoftDelete, 0)).size(); + if (count != scopeIds.stream().distinct().count()) bad("作用域不存在或已停用"); + } + + private static void validateMemo(String memo) { + if (memo != null && memo.length() > 255) bad("用途备注最多 255 字"); + } + + private AgentApiKey activeToken(Long id) { + AgentApiKey token = tokenMapper.selectById(id); + if (token == null || token.getSoftDelete() == null || token.getSoftDelete() != 0) bad("Token 不存在"); + return token; + } + + private static String trim(String value, int max) { + if (value == null) return null; + String result = value.trim(); + return result.length() <= max ? result : result.substring(0, max); + } + + private static void bad(String message) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); + } + + public record TokenView(Long id, String keyPrefix, String memo, Byte status, Date expireTime, + Date lastUsedTime, List scopeIds, List scopeNames) {} + public record IssuedToken(String plaintext, TokenView token) {} +} diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/SvcDataSourceConfig.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/SvcDataSourceConfig.java index 69614a6..6d9adcb 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/SvcDataSourceConfig.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/config/SvcDataSourceConfig.java @@ -10,6 +10,8 @@ import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.PlatformTransactionManager; import javax.sql.DataSource; @@ -35,4 +37,9 @@ public class SvcDataSourceConfig { bean.setConfiguration(configuration); return bean.getObject(); } + + @Bean(name = "svcTransactionManager") + public PlatformTransactionManager svcTransactionManager(@Qualifier("svcDataSource") DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } } diff --git a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AdminUser.java b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AdminUser.java index f5301be..0b9d2c7 100644 --- a/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AdminUser.java +++ b/xiaoqu-agent-service/src/main/java/me/iiv/agent/entity/svc/AdminUser.java @@ -38,7 +38,7 @@ public class AdminUser implements Serializable { private String account; /** - * 密码 SHA-256 hex + * PBKDF2 加盐密码哈希 */ private String passwordHash; diff --git a/xiaoqu-agent-service/src/main/resources/application.yml b/xiaoqu-agent-service/src/main/resources/application.yml index f063d1d..306047b 100644 --- a/xiaoqu-agent-service/src/main/resources/application.yml +++ b/xiaoqu-agent-service/src/main/resources/application.yml @@ -1,5 +1,19 @@ server: port: 8088 + servlet: + session: + timeout: 30m + cookie: + http-only: true + same-site: strict + secure: ${ADMIN_COOKIE_SECURE:false} + +app: + admin: + bootstrap: + account: ${ADMIN_BOOTSTRAP_ACCOUNT:} + password: ${ADMIN_BOOTSTRAP_PASSWORD:} + name: ${ADMIN_BOOTSTRAP_NAME:系统管理员} spring: application: diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminPasswordHasherTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminPasswordHasherTest.java new file mode 100644 index 0000000..e2df8c6 --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminPasswordHasherTest.java @@ -0,0 +1,23 @@ +package me.iiv.agent.admin; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AdminPasswordHasherTest { + + private final AdminPasswordHasher hasher = new AdminPasswordHasher(); + + @Test + void hashesAreSaltedAndVerifiable() { + String first = hasher.hash("correct horse battery staple"); + String second = hasher.hash("correct horse battery staple"); + + assertTrue(first.startsWith("pbkdf2$")); + assertNotEquals(first, second); + assertTrue(hasher.matches("correct horse battery staple", first)); + assertFalse(hasher.matches("wrong", first)); + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminSessionFilterTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminSessionFilterTest.java new file mode 100644 index 0000000..b8895ab --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminSessionFilterTest.java @@ -0,0 +1,93 @@ +package me.iiv.agent.admin; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.mock.web.MockHttpSession; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +class AdminSessionFilterTest { + + private final AdminSessionFilter filter = new AdminSessionFilter(); + + @Test + void protectedApiRequiresSession() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/tools"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(401, response.getStatus()); + assertNull(chain.getRequest()); + } + + @Test + void loginEndpointIsPublic() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/api/admin/auth/login"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertSame(request, chain.getRequest()); + } + + @Test + void authenticatedReadIsAllowed() throws Exception { + MockHttpSession session = authenticatedSession(); + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/admin/tools"); + request.setSession(session); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertSame(request, chain.getRequest()); + } + + @Test + void mutationRequiresMatchingCsrfToken() throws Exception { + MockHttpSession session = authenticatedSession(); + session.setAttribute(AdminSessionFilter.CSRF_SESSION_KEY, "csrf-token"); + MockHttpServletRequest request = new MockHttpServletRequest("PATCH", "/api/admin/tools/1/status"); + request.setSession(session); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(403, response.getStatus()); + assertNull(chain.getRequest()); + + request.addHeader("X-CSRF-Token", "csrf-token"); + response = new MockHttpServletResponse(); + chain = new MockFilterChain(); + filter.doFilter(request, response, chain); + assertSame(request, chain.getRequest()); + } + + @Test + void missingCsrfSessionValueCannotBeForgedAsNullText() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest("DELETE", "/api/admin/tokens/1"); + request.setSession(authenticatedSession()); + request.addHeader("X-CSRF-Token", "null"); + MockHttpServletResponse response = new MockHttpServletResponse(); + MockFilterChain chain = new MockFilterChain(); + + filter.doFilter(request, response, chain); + + assertEquals(403, response.getStatus()); + assertNull(chain.getRequest()); + } + + private static MockHttpSession authenticatedSession() { + MockHttpSession session = new MockHttpSession(); + session.setAttribute(AdminSessionFilter.ADMIN_SESSION_ID, 1L); + return session; + } +} diff --git a/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java new file mode 100644 index 0000000..494724c --- /dev/null +++ b/xiaoqu-agent-service/src/test/java/me/iiv/agent/admin/AdminTokenServiceTest.java @@ -0,0 +1,74 @@ +package me.iiv.agent.admin; + +import me.iiv.agent.entity.m0.User; +import me.iiv.agent.entity.svc.AgentApiKey; +import me.iiv.agent.entity.svc.AgentScope; +import me.iiv.agent.mapper.m0.UserMapper; +import me.iiv.agent.mapper.svc.AgentApiKeyMapper; +import me.iiv.agent.mapper.svc.AgentScopeMapper; +import me.iiv.agent.mapper.svc.TokenScopeMapper; +import me.iiv.agent.security.AuthService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AdminTokenServiceTest { + + @Mock UserMapper userMapper; + @Mock AgentApiKeyMapper tokenMapper; + @Mock TokenScopeMapper tokenScopeMapper; + @Mock AgentScopeMapper scopeMapper; + + @Test + void issueReturnsPlaintextOnceAndStoresOnlyHash() { + User user = new User(); + user.setId(7L); + user.setSoftDelete(0); + when(userMapper.selectById(7L)).thenReturn(user); + AgentScope scope = new AgentScope(); + scope.setId(3L); + scope.setStatus((byte) 1); + scope.setSoftDelete((byte) 0); + when(scopeMapper.selectList(any())).thenReturn(List.of(scope)); + when(tokenMapper.insert(any(AgentApiKey.class))).thenAnswer(invocation -> { + invocation.getArgument(0).setId(11L); + return 1; + }); + + AdminTokenService service = new AdminTokenService( + userMapper, tokenMapper, tokenScopeMapper, scopeMapper); + AdminTokenService.IssuedToken issued = service.issue(7L, "Kimi", List.of(3L), null, 1L); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AgentApiKey.class); + org.mockito.Mockito.verify(tokenMapper).insert(captor.capture()); + AgentApiKey stored = captor.getValue(); + assertTrue(issued.plaintext().matches("xqak_[0-9a-f]{48}")); + assertEquals(AuthService.sha256Hex(issued.plaintext()), stored.getKeyHash()); + assertNotEquals(issued.plaintext(), stored.getKeyHash()); + assertEquals(11L, issued.token().id()); + } + + @Test + void issueRejectsDuplicateScopesBeforeWriting() { + User user = new User(); + user.setId(7L); + user.setSoftDelete(0); + when(userMapper.selectById(7L)).thenReturn(user); + AdminTokenService service = new AdminTokenService(userMapper, tokenMapper, tokenScopeMapper, scopeMapper); + + assertThrows(RuntimeException.class, + () -> service.issue(7L, "Kimi", List.of(3L, 3L), null, 1L)); + } +}