From c78df3a6a613b5f9dff1840a862842910310ab3d Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 15 Oct 2026 18:43:19 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=90=8E=E5=8F=B0(=E7=99=BB=E5=BD=95/=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86/=E6=97=A5=E5=BF=97=E6=9F=A5?= =?UTF-8?q?=E7=9C=8B)=20+=20=E8=A1=8C=E4=B8=BA=E6=97=A5=E5=BF=97=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E9=87=87=E9=9B=86(=E8=AF=B7=E6=B1=82=E4=B8=AD?= =?UTF-8?q?=E9=97=B4=E4=BB=B6=20+=20log=5Fevent)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/index.html | 12 + admin/package-lock.json | 1147 +++++++++++++++++++++++++++++ admin/package.json | 18 + admin/src/App.vue | 124 ++++ admin/src/api/admin.js | 46 ++ admin/src/components/UserForm.vue | 101 +++ admin/src/main.js | 5 + admin/src/style.css | 96 +++ admin/src/views/Login.vue | 53 ++ admin/src/views/Logs.vue | 102 +++ admin/src/views/Users.vue | 72 ++ admin/vite.config.js | 15 + sdk/app/auth.py | 35 + sdk/app/config.py | 8 + sdk/app/db.py | 29 + sdk/app/logwriter.py | 154 ++++ sdk/app/main.py | 39 +- sdk/app/routers/admin.py | 149 ++++ sdk/app/services/auth.py | 189 +++++ sdk/app/services/logview.py | 100 +++ 20 files changed, 2493 insertions(+), 1 deletion(-) create mode 100644 admin/index.html create mode 100644 admin/package-lock.json create mode 100644 admin/package.json create mode 100644 admin/src/App.vue create mode 100644 admin/src/api/admin.js create mode 100644 admin/src/components/UserForm.vue create mode 100644 admin/src/main.js create mode 100644 admin/src/style.css create mode 100644 admin/src/views/Login.vue create mode 100644 admin/src/views/Logs.vue create mode 100644 admin/src/views/Users.vue create mode 100644 admin/vite.config.js create mode 100644 sdk/app/auth.py create mode 100644 sdk/app/logwriter.py create mode 100644 sdk/app/routers/admin.py create mode 100644 sdk/app/services/auth.py create mode 100644 sdk/app/services/logview.py diff --git a/admin/index.html b/admin/index.html new file mode 100644 index 0000000..1ddfebe --- /dev/null +++ b/admin/index.html @@ -0,0 +1,12 @@ + + + + + + 蜂群中枢 · PlantAI 管理后台 + + +
+ + + diff --git a/admin/package-lock.json b/admin/package-lock.json new file mode 100644 index 0000000..3c61da4 --- /dev/null +++ b/admin/package-lock.json @@ -0,0 +1,1147 @@ +{ + "name": "plantai-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "plantai-admin", + "version": "0.1.0", + "dependencies": { + "vue": "3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "5.2.1", + "vite": "5.4.11" + } + }, + "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==", + "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==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "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==", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "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==" + }, + "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, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz", + "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz", + "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz", + "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz", + "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz", + "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz", + "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz", + "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz", + "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz", + "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz", + "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz", + "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz", + "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz", + "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz", + "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz", + "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz", + "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz", + "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz", + "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz", + "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz", + "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz", + "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz", + "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz", + "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz", + "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz", + "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==", + "cpu": [ + "x64" + ], + "dev": true, + "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 + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.1.tgz", + "integrity": "sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==", + "dev": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.13.tgz", + "integrity": "sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/shared": "3.5.13", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz", + "integrity": "sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==", + "dependencies": { + "@vue/compiler-core": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz", + "integrity": "sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==", + "dependencies": { + "@babel/parser": "^7.25.3", + "@vue/compiler-core": "3.5.13", + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.11", + "postcss": "^8.4.48", + "source-map-js": "^1.2.0" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz", + "integrity": "sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.13.tgz", + "integrity": "sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==", + "dependencies": { + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.13.tgz", + "integrity": "sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/shared": "3.5.13" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz", + "integrity": "sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==", + "dependencies": { + "@vue/reactivity": "3.5.13", + "@vue/runtime-core": "3.5.13", + "@vue/shared": "3.5.13", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.13.tgz", + "integrity": "sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==", + "dependencies": { + "@vue/compiler-ssr": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "vue": "3.5.13" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", + "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==" + }, + "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==" + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "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==" + }, + "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, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "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==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "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" + } + ], + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.7.tgz", + "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==", + "dev": true, + "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.64.7", + "@rollup/rollup-android-arm64": "4.64.7", + "@rollup/rollup-darwin-arm64": "4.64.7", + "@rollup/rollup-darwin-x64": "4.64.7", + "@rollup/rollup-freebsd-arm64": "4.64.7", + "@rollup/rollup-freebsd-x64": "4.64.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.7", + "@rollup/rollup-linux-arm-musleabihf": "4.64.7", + "@rollup/rollup-linux-arm64-gnu": "4.64.7", + "@rollup/rollup-linux-arm64-musl": "4.64.7", + "@rollup/rollup-linux-loong64-gnu": "4.64.7", + "@rollup/rollup-linux-loong64-musl": "4.64.7", + "@rollup/rollup-linux-ppc64-gnu": "4.64.7", + "@rollup/rollup-linux-ppc64-musl": "4.64.7", + "@rollup/rollup-linux-riscv64-gnu": "4.64.7", + "@rollup/rollup-linux-riscv64-musl": "4.64.7", + "@rollup/rollup-linux-s390x-gnu": "4.64.7", + "@rollup/rollup-linux-x64-gnu": "4.64.7", + "@rollup/rollup-linux-x64-musl": "4.64.7", + "@rollup/rollup-openbsd-x64": "4.64.7", + "@rollup/rollup-openharmony-arm64": "4.64.7", + "@rollup/rollup-win32-arm64-msvc": "4.64.7", + "@rollup/rollup-win32-ia32-msvc": "4.64.7", + "@rollup/rollup-win32-x64-gnu": "4.64.7", + "@rollup/rollup-win32-x64-msvc": "4.64.7", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.11.tgz", + "integrity": "sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==", + "dev": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.13", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.13.tgz", + "integrity": "sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==", + "dependencies": { + "@vue/compiler-dom": "3.5.13", + "@vue/compiler-sfc": "3.5.13", + "@vue/runtime-dom": "3.5.13", + "@vue/server-renderer": "3.5.13", + "@vue/shared": "3.5.13" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + } + } +} diff --git a/admin/package.json b/admin/package.json new file mode 100644 index 0000000..fdf173c --- /dev/null +++ b/admin/package.json @@ -0,0 +1,18 @@ +{ + "name": "plantai-admin", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "3.5.13" + }, + "devDependencies": { + "vite": "5.4.11", + "@vitejs/plugin-vue": "5.2.1" + } +} diff --git a/admin/src/App.vue b/admin/src/App.vue new file mode 100644 index 0000000..8d703ce --- /dev/null +++ b/admin/src/App.vue @@ -0,0 +1,124 @@ + + + + + diff --git a/admin/src/api/admin.js b/admin/src/api/admin.js new file mode 100644 index 0000000..1bbd40c --- /dev/null +++ b/admin/src/api/admin.js @@ -0,0 +1,46 @@ +const TOKEN_KEY = 'pa-admin-token'; +let token = localStorage.getItem(TOKEN_KEY) || ''; + +export function getToken() { return token; } +export function setToken(t) { + token = t || ''; + if (t) localStorage.setItem(TOKEN_KEY, t); + else localStorage.removeItem(TOKEN_KEY); +} + +async function request(path, { method = 'GET', body } = {}) { + const res = await fetch('/api/admin' + path, { + method, + headers: { + ...(body ? { 'Content-Type': 'application/json' } : {}), + ...(token ? { Authorization: 'Bearer ' + token } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + let data = null; + try { data = await res.json(); } catch { data = null; } + if (!res.ok) { + const err = new Error((data && data.error && data.error.message) || ('请求失败 HTTP ' + res.status)); + err.status = res.status; + err.code = data && data.error && data.error.code; + throw err; + } + return data; +} + +export const api = { + login: (username, password) => request('/login', { method: 'POST', body: { username, password } }), + logout: () => request('/logout', { method: 'POST' }), + me: () => request('/me'), + units: () => request('/units'), + users: () => request('/users'), + createUser: (body) => request('/users', { method: 'POST', body }), + updateUser: (id, body) => request('/users/' + id, { method: 'PATCH', body }), + deleteUser: (id) => request('/users/' + id, { method: 'DELETE' }), + logs: (params = {}) => { + const q = new URLSearchParams(); + Object.entries(params).forEach(([k, v]) => { if (v !== undefined && v !== null && v !== '') q.set(k, v); }); + const qs = q.toString(); + return request('/logs' + (qs ? '?' + qs : '')); + }, +}; diff --git a/admin/src/components/UserForm.vue b/admin/src/components/UserForm.vue new file mode 100644 index 0000000..efbd261 --- /dev/null +++ b/admin/src/components/UserForm.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/admin/src/main.js b/admin/src/main.js new file mode 100644 index 0000000..8dd6bc1 --- /dev/null +++ b/admin/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import App from './App.vue'; +import './style.css'; + +createApp(App).mount('#app'); diff --git a/admin/src/style.css b/admin/src/style.css new file mode 100644 index 0000000..d955355 --- /dev/null +++ b/admin/src/style.css @@ -0,0 +1,96 @@ +:root { + --bg: #0c0e14; + --bg-2: #10131b; + --surface: #151924; + --surface-2: #1a1f2c; + --border: #232a38; + --border-2: #2e3648; + --text: #e9edf5; + --text-2: #9aa4b6; + --text-3: #5f6a7d; + --accent: #e8a33d; + --accent-ink: #14100a; + --accent-soft: rgba(232, 163, 61, 0.14); + --green: #3ecf8e; + --green-soft: rgba(62, 207, 142, 0.14); + --red: #ef6b6b; + --red-soft: rgba(239, 107, 107, 0.14); + --blue: #6ea8ff; + --radius: 10px; + --radius-sm: 7px; +} + +* { box-sizing: border-box; } +html, body, #app { margin: 0; height: 100%; } +body { + background: var(--bg); + color: var(--text); + font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Segoe UI", Roboto, sans-serif; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; +} +.mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; } +button { font-family: inherit; cursor: pointer; } + +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 7px; + padding: 9px 15px; font-size: 13.5px; border-radius: var(--radius-sm); + border: 1px solid transparent; transition: filter .15s; background: transparent; +} +.btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +.btn-primary { background: var(--accent); color: var(--accent-ink); font-weight: 600; } +.btn-primary:hover { filter: brightness(1.06); } +.btn-ghost { border-color: var(--border-2); color: var(--text-2); } +.btn-ghost:hover { color: var(--text); border-color: var(--text-3); } +.btn-danger { background: var(--red-soft); color: var(--red); } +.btn-block { width: 100%; } + +.field { margin-bottom: 15px; } +.field label { display: block; font-size: 12px; color: var(--text-2); margin-bottom: 7px; } +.field input, .field select { + width: 100%; padding: 10px 12px; font-size: 14px; color: var(--text); + background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none; +} +.field input:focus, .field select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); } + +.chip { + display: inline-block; padding: 3px 9px; border-radius: 20px; font-size: 11.5px; + border: 1px solid var(--border-2); color: var(--text-2); margin: 1.5px 3px 1.5px 0; +} +.chip.amber { border-color: transparent; background: var(--accent-soft); color: var(--accent); } +.badge { display: inline-block; padding: 2px 8px; border-radius: 5px; font-size: 11.5px; font-family: ui-monospace, Menlo, monospace; } +.badge.green { background: var(--green-soft); color: var(--green); } +.badge.red { background: var(--red-soft); color: var(--red); } +.badge.amber { background: var(--accent-soft); color: var(--accent); } +.badge.muted { background: var(--surface-2); color: var(--text-3); } + +.panel { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); } +table { width: 100%; border-collapse: collapse; } +th { text-align: left; font-size: 11.5px; font-weight: 500; color: var(--text-3); padding: 10px 18px; border-bottom: 1px solid var(--border); } +td { padding: 13px 18px; border-bottom: 1px solid var(--border); font-size: 13.5px; } +tr:last-child td { border-bottom: none; } +tbody tr:hover { background: rgba(255,255,255,0.015); } +.cell-id { color: var(--text-2); } +.row-actions { display: flex; gap: 6px; } +.row-actions .act { color: var(--blue); cursor: pointer; font-size: 13px; padding: 3px 6px; border-radius: 5px; } +.row-actions .act:hover { background: rgba(110,168,255,0.1); } +.row-actions .act.danger { color: var(--red); } +.row-actions .act.danger:hover { background: var(--red-soft); } + +.role { font-weight: 600; } +.role.admin { color: var(--accent); } +.role.researcher { color: var(--blue); } +.role.guest { color: var(--text-2); } + +.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.55); display: flex; align-items: center; justify-content: center; z-index: 50; } +.modal { width: 460px; max-width: calc(100vw - 32px); background: var(--surface); border: 1px solid var(--border-2); border-radius: 14px; } +.modal-head { padding: 18px 22px 0; font-size: 15px; font-weight: 600; } +.modal-body { padding: 18px 22px; } +.modal-foot { padding: 14px 22px 20px; display: flex; justify-content: flex-end; gap: 10px; border-top: 1px solid var(--border); } +.checks { display: grid; grid-template-columns: 1fr 1fr; gap: 9px 14px; margin-top: 8px; } +.check { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); } +.check input { accent-color: var(--accent); width: 15px; height: 15px; } +.seg { display: flex; gap: 6px; } +.seg button { background: transparent; border: 1px solid var(--border-2); color: var(--text-2); border-radius: 7px; padding: 7px 14px; font-size: 13px; } +.seg button.on { background: var(--accent-soft); border-color: transparent; color: var(--accent); } diff --git a/admin/src/views/Login.vue b/admin/src/views/Login.vue new file mode 100644 index 0000000..6868b14 --- /dev/null +++ b/admin/src/views/Login.vue @@ -0,0 +1,53 @@ + + + + + diff --git a/admin/src/views/Logs.vue b/admin/src/views/Logs.vue new file mode 100644 index 0000000..a43c757 --- /dev/null +++ b/admin/src/views/Logs.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/admin/src/views/Users.vue b/admin/src/views/Users.vue new file mode 100644 index 0000000..6abea8c --- /dev/null +++ b/admin/src/views/Users.vue @@ -0,0 +1,72 @@ + + + diff --git a/admin/vite.config.js b/admin/vite.config.js new file mode 100644 index 0000000..21ee8d4 --- /dev/null +++ b/admin/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + base: '/admin/', + plugins: [vue()], + server: { + host: '127.0.0.1', + port: 5181, + proxy: { + '/api': { target: 'http://127.0.0.1:8090', changeOrigin: false }, + }, + }, + build: { outDir: 'dist', target: 'es2020' }, +}); diff --git a/sdk/app/auth.py b/sdk/app/auth.py new file mode 100644 index 0000000..400a670 --- /dev/null +++ b/sdk/app/auth.py @@ -0,0 +1,35 @@ +"""auth.py —— 管理后台鉴权依赖 + 鉴权异常。""" +from __future__ import annotations + +from fastapi import Depends, Request + +from .db import Database, get_db +from .logwriter import log_event +from .services.auth import resolve_token + + +class AuthError(Exception): + def __init__(self, status: int, code: str, message: str) -> None: + super().__init__(message) + self.status = status + self.code = code + self.message = message + + +def _bearer(request: Request) -> str: + header = request.headers.get("Authorization", "") + return header[7:] if header.startswith("Bearer ") else "" + + +async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict: + """校验 Authorization: Bearer ,并要求角色为 admin;失败抛 AuthError 并留痕。""" + token = _bearer(request) + user = await resolve_token(db, token) if token else None + if user is None: + log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"}) + raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期") + if user["role"] != "admin": + log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "not_admin"}) + raise AuthError(403, "FORBIDDEN", "非管理员账号") + request.state.user = user + return user diff --git a/sdk/app/config.py b/sdk/app/config.py index 8a07ef1..f841fe4 100644 --- a/sdk/app/config.py +++ b/sdk/app/config.py @@ -86,6 +86,14 @@ class Settings(BaseSettings): review_service_url: str = "http://127.0.0.1:8100" review_read_timeout: float = 360.0 # presubmit 上传+LLM 长调用读超时(秒) + # ---- 管理后台(admin console + 行为日志)---- + admin_seed_username: str = "admin" # 种子管理员用户名(启动时若无 admin 则创建) + admin_seed_password: str = "admin" # 种子管理员初始密码 + admin_token_ttl_seconds: int = 86400 # 管理员会话凭据有效期(秒) + admin_dist_dir: str = "admin/dist" # 管理后台前端构建产物(BFF 托管 /admin) + log_dir: str = "data/logs" # 行为日志目录(每日一个 JSONL 文件) + log_retention_days: int = 0 # 日志保留天数(0=不清理) + # ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)---- frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist diff --git a/sdk/app/db.py b/sdk/app/db.py index d194a8d..93327c1 100644 --- a/sdk/app/db.py +++ b/sdk/app/db.py @@ -125,6 +125,30 @@ _DOCGEN_MIGRATIONS = ( ) +# 管理后台/鉴权增量表(users + auth_tokens;行为日志走文件,不建表) +_ADMIN_SCHEMA = """ +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + salt TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user', + authorizations TEXT NOT NULL DEFAULT '[]', + created_at TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS auth_tokens ( + token TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); +""" + + def _now_iso() -> str: return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -175,6 +199,11 @@ class Database: ) except Exception: logger.exception("docgen 表迁移失败(docgen 端点将不可用,其余不受影响)") + # 管理后台/鉴权增量表(users + auth_tokens):单独执行;失败只记日志 + try: + await self._conn.executescript(_ADMIN_SCHEMA) + except Exception: + logger.exception("admin 表迁移失败(管理后台将不可用,其余不受影响)") await self._conn.commit() async def close(self) -> None: diff --git a/sdk/app/logwriter.py b/sdk/app/logwriter.py new file mode 100644 index 0000000..6e0607d --- /dev/null +++ b/sdk/app/logwriter.py @@ -0,0 +1,154 @@ +"""logwriter.py —— 行为日志异步文件写入器(JSONL,固定目录,每日一个文件)。 + +设计:请求/业务只入队(不落盘),后台单任务批量 append,避免日志拖慢接口; +文件按日期命名 audit-YYYY-MM-DD.log,跨天自动切文件。写失败仅记 stderr,绝不影响业务。 +""" +from __future__ import annotations + +import asyncio +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + +_writer: "LogWriter | None" = None # 全局单例 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" + + +def _today() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +class LogWriter: + def __init__(self, log_dir: str, retention_days: int = 0) -> None: + self.log_dir = Path(log_dir) + self.retention_days = retention_days + self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=20000) + self._task: asyncio.Task | None = None + self._date = "" + self._fh = None + + async def start(self) -> None: + self.log_dir.mkdir(parents=True, exist_ok=True) + self._task = asyncio.create_task(self._run()) + + async def stop(self) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + self._close_fh() + + def enqueue(self, entry: dict[str, Any]) -> None: + try: + self._queue.put_nowait(entry) + except asyncio.QueueFull: + logger.warning("行为日志队列已满,丢弃一条") + + def _close_fh(self) -> None: + if self._fh is not None: + try: + self._fh.close() + except Exception: + pass + self._fh = None + + def _open_for_date(self, date_str: str) -> None: + self._close_fh() + path = self.log_dir / f"audit-{date_str}.log" + self._fh = open(path, "a", encoding="utf-8") + self._date = date_str + self._cleanup() + + async def _run(self) -> None: + try: + while True: + batch: list[dict[str, Any]] = [] + try: + batch.append(await asyncio.wait_for(self._queue.get(), timeout=1.0)) + except asyncio.TimeoutError: + continue + while len(batch) < 200: + try: + batch.append(self._queue.get_nowait()) + except asyncio.QueueEmpty: + break + await asyncio.to_thread(self._write_sync, batch) + except asyncio.CancelledError: + raise + + def _write_sync(self, entries: list[dict[str, Any]]) -> None: + today = _today() + if self._date != today: + self._open_for_date(today) + for e in entries: + self._fh.write(json.dumps(e, ensure_ascii=False) + "\n") + self._fh.flush() + + def _cleanup(self) -> None: + if not self.retention_days: + return + try: + import time as _time + + cutoff = _time.time() - self.retention_days * 86400 + for p in self.log_dir.glob("audit-*.log"): + try: + if p.stat().st_mtime < cutoff: + p.unlink() + except OSError: + continue + except Exception: + logger.exception("日志清理失败") + + +def init_writer(log_dir: str, retention_days: int = 0) -> None: + global _writer + _writer = LogWriter(log_dir, retention_days) + + +async def start_writer() -> None: + if _writer is not None: + await _writer.start() + + +async def stop_writer() -> None: + global _writer + if _writer is not None: + await _writer.stop() + _writer = None + + +def log_event( + event: str, + *, + user_id: str | None = None, + method: str | None = None, + path: str | None = None, + status: int | None = None, + latency_ms: float | None = None, + detail: dict[str, Any] | None = None, +) -> None: + """通用业务埋点;writer 未初始化时静默 no-op。""" + if _writer is None: + return + entry = { + "ts": _now_iso(), + "user_id": user_id, + "event": event, + "method": method, + "path": path, + "status": status, + "latency_ms": latency_ms, + "detail": detail or {}, + } + _writer.enqueue(entry) diff --git a/sdk/app/main.py b/sdk/app/main.py index 1a5fdcd..51bd543 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -11,6 +11,7 @@ from __future__ import annotations import logging +import time from contextlib import asynccontextmanager from pathlib import Path @@ -27,6 +28,10 @@ from .routers import bioflow_proxy from .routers import paper from .routers import private_docs from .routers import review_proxy +from .routers import admin +from . import logwriter +from .auth import AuthError +from .services.auth import seed_admin from .upstream import close_client logging.basicConfig(level=logging.INFO) @@ -49,6 +54,29 @@ def create_app() -> FastAPI: allow_headers=["*"], ) + # 行为日志请求中间件:自动采集所有 /api/* 请求(user_id 待鉴权上线后补) + @app.middleware("http") + async def _log_requests(request: Request, call_next): + start = time.monotonic() + response = await call_next(request) + if request.url.path.startswith("/api/"): + logwriter.log_event( + "http_request", + method=request.method, + path=request.url.path, + status=response.status_code, + latency_ms=round((time.monotonic() - start) * 1000, 1), + ) + return response + + # 鉴权异常 → 统一错误信封 + @app.exception_handler(AuthError) + async def _auth_error_handler(_: Request, exc: AuthError) -> JSONResponse: + return JSONResponse( + status_code=exc.status, + content={"ok": False, "error": {"code": exc.code, "message": exc.message, "degraded": False}}, + ) + app.include_router(health.router) app.include_router(sessions.router) app.include_router(qa.router) @@ -65,6 +93,7 @@ def create_app() -> FastAPI: app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关) if get_settings().review_enabled: app.include_router(review_proxy.router) # 科研审稿反代(REVIEW_ENABLED 开关) + app.include_router(admin.router) # 管理后台(/api/admin/*) @app.exception_handler(RequestValidationError) async def _validation_handler(_: Request, exc: RequestValidationError) -> JSONResponse: @@ -79,10 +108,14 @@ def create_app() -> FastAPI: @asynccontextmanager async def _lifespan(_app: FastAPI): - await init_db() + _db = await init_db() + logwriter.init_writer(settings.log_dir, settings.log_retention_days) + await logwriter.start_writer() + await seed_admin(_db) try: yield finally: + await logwriter.stop_writer() await close_client() try: from .services.neo4j import close_neo4j # A3 提供;缺失则跳过 @@ -98,6 +131,10 @@ def create_app() -> FastAPI: # 2026-08-25 PM 修复:dist 路径必须基于项目根解析(BFF 工作目录是 sdk/, # 相对路径会解析成 sdk/ui/dist);sync.sh 排除 dist,需单独同步。 project_root = Path(__file__).resolve().parent.parent.parent + # 管理后台静态托管(/admin;须在 "/" 之前挂载) + admin_dist = project_root / settings.admin_dist_dir if not Path(settings.admin_dist_dir).is_absolute() else Path(settings.admin_dist_dir) + if admin_dist.is_dir(): + app.mount("/admin", StaticFiles(directory=str(admin_dist), html=True), name="admin") dist = project_root / settings.frontend_dist_dir if not Path(settings.frontend_dist_dir).is_absolute() else Path(settings.frontend_dist_dir) if dist.is_dir(): app.mount("/", StaticFiles(directory=str(dist), html=True), name="frontend") diff --git a/sdk/app/routers/admin.py b/sdk/app/routers/admin.py new file mode 100644 index 0000000..571562a --- /dev/null +++ b/sdk/app/routers/admin.py @@ -0,0 +1,149 @@ +"""routers/admin.py —— 管理后台端点(登录/用户管理/日志查看)。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from ..auth import require_admin +from ..config import get_settings +from ..db import Database, get_db +from ..logwriter import log_event +from ..services.auth import ( + AUTHORIZATION_UNITS, + verify_login, + issue_token, + revoke_token, + list_users, + get_user_by_username, + create_user, + update_user, + delete_user, +) +from ..services.logview import query_logs +from ..upstream import envelope + +router = APIRouter(prefix="/api/admin", tags=["admin"]) + + +class LoginBody(BaseModel): + username: str + password: str + + +class UserCreate(BaseModel): + username: str + password: str + role: str = "user" + authorizations: list[str] = [] + + +class UserPatch(BaseModel): + role: str | None = None + authorizations: list[str] | None = None + password: str | None = None + + +@router.post("/login") +async def login(body: LoginBody, db: Database = Depends(get_db)): + user = await verify_login(db, body.username, body.password) + if user is None: + log_event("admin_login_fail", detail={"username": body.username}) + content, status = envelope("UNAUTHORIZED", "用户名或密码错误", status=401) + return JSONResponse(status_code=status, content=content) + if user["role"] != "admin": + log_event("admin_login_fail", detail={"username": body.username, "reason": "not_admin"}) + content, status = envelope("FORBIDDEN", "非管理员账号", status=403) + return JSONResponse(status_code=status, content=content) + token = await issue_token(db, user["id"]) + log_event("admin_login_ok", user_id=user["id"], detail={"username": body.username}) + return {"ok": True, "token": token, "user": user} + + +@router.post("/logout") +async def logout(request: Request, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + header = request.headers.get("Authorization", "") + token = header[7:] if header.startswith("Bearer ") else "" + if token: + await revoke_token(db, token) + return {"ok": True} + + +@router.get("/me") +async def me(_user: dict = Depends(require_admin)): + return {"ok": True, "user": _user} + + +@router.get("/units") +async def units(_user: dict = Depends(require_admin)): + return {"ok": True, "units": list(AUTHORIZATION_UNITS)} + + +@router.get("/users") +async def users(db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + return {"ok": True, "users": await list_users(db)} + + +@router.post("/users") +async def create_user_api(body: UserCreate, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + username = body.username.strip() + if not username: + content, _ = envelope("VALIDATION", "用户名不能为空", status=422) + return JSONResponse(status_code=422, content=content) + if not body.password: + content, _ = envelope("VALIDATION", "密码不能为空", status=422) + return JSONResponse(status_code=422, content=content) + if await get_user_by_username(db, username): + return JSONResponse( + status_code=409, + content={"ok": False, "error": {"code": "CONFLICT", "message": "用户名已存在", "degraded": False}}, + ) + user = await create_user(db, username, body.password, body.role, body.authorizations) + return {"ok": True, "user": user} + + +@router.patch("/users/{user_id}") +async def patch_user(user_id: str, body: UserPatch, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + user = await update_user(db, user_id, role=body.role, authorizations=body.authorizations, password=body.password) + if user is None: + content, _ = envelope("NOT_FOUND", status=404) + return JSONResponse(status_code=404, content=content) + return {"ok": True, "user": user} + + +@router.delete("/users/{user_id}") +async def delete_user_api(user_id: str, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + if user_id == _user["id"]: + return JSONResponse( + status_code=422, + content={"ok": False, "error": {"code": "VALIDATION", "message": "不能删除当前登录账号", "degraded": False}}, + ) + await delete_user(db, user_id) + return {"ok": True} + + +@router.get("/logs") +async def logs( + from_date: str | None = Query(None), + to_date: str | None = Query(None), + event: str | None = Query(None), + user_id: str | None = Query(None), + q: str | None = Query(None), + status: str | None = Query(None), + page: int = Query(1, ge=1), + page_size: int = Query(50, ge=1, le=200), + _user: dict = Depends(require_admin), +): + settings = get_settings() + result = await query_logs( + settings.log_dir, + from_date=from_date, + to_date=to_date, + event=event, + user_id=user_id, + q=q, + status=status, + page=page, + page_size=page_size, + ) + return {"ok": True, **result} diff --git a/sdk/app/services/auth.py b/sdk/app/services/auth.py new file mode 100644 index 0000000..0ae01c3 --- /dev/null +++ b/sdk/app/services/auth.py @@ -0,0 +1,189 @@ +"""services/auth.py —— 管理员鉴权:密码哈希、会话凭据、用户 CRUD、种子管理员。 + +密码用 stdlib pbkdf2_hmac + 每用户随机盐(不新增依赖); +会话凭据用不透明 token 存 auth_tokens,逐次查库(吊销/过期即时生效)。 +所有 DB 访问经 `db` 参数注入(与 services/history.py 一致,避免依赖全局单例)。 +""" +from __future__ import annotations + +import hashlib +import logging +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any + +from ..config import get_settings +from ..db import Database, dumps_json, loads_json +from ..logwriter import log_event + +logger = logging.getLogger(__name__) + +# 授权清单功能单元(9 项,与管理后台界面一致;后续可调) +AUTHORIZATION_UNITS = ( + "qa", "kg", "extraction", "docgen", "figures", + "private_docs", "bioflow", "paper", "legacy", +) + +_PBKDF2_ITERATIONS = 200_000 + + +def _now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def hash_password(password: str, salt: str | None = None) -> tuple[str, str]: + salt = salt or secrets.token_hex(16) + digest = hashlib.pbkdf2_hmac( + "sha256", password.encode("utf-8"), salt.encode("utf-8"), _PBKDF2_ITERATIONS + ) + return digest.hex(), salt + + +def verify_password(password: str, salt: str, expected_hex: str) -> bool: + digest, _ = hash_password(password, salt) + return secrets.compare_digest(digest, expected_hex) + + +def _row_to_user(row: Any) -> dict[str, Any]: + return { + "id": row["id"], + "username": row["username"], + "role": row["role"], + "authorizations": loads_json(row["authorizations"], default=[]), + "created_at": row["created_at"], + } + + +# ---- 登录校验 ---- + +async def verify_login(db: Database, username: str, password: str) -> dict[str, Any] | None: + row = await db.fetchone( + "SELECT id, username, password_hash, salt, role, authorizations, created_at " + "FROM users WHERE username = ?", + (username,), + ) + if row is None: + return None + if not verify_password(password, row["salt"], row["password_hash"]): + return None + return _row_to_user(row) + + +# ---- 会话凭据 ---- + +async def issue_token(db: Database, user_id: str) -> str: + settings = get_settings() + token = secrets.token_urlsafe(32) + now = _now_iso() + expires = datetime.now(timezone.utc) + timedelta(seconds=settings.admin_token_ttl_seconds) + await db.execute( + "INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) " + "VALUES (?, ?, ?, ?, 0)", + (token, user_id, expires.strftime("%Y-%m-%dT%H:%M:%SZ"), now), + ) + return token + + +async def resolve_token(db: Database, token: str) -> dict[str, Any] | None: + row = await db.fetchone( + "SELECT t.expires_at, t.revoked, u.id, u.username, u.role, u.authorizations, u.created_at " + "FROM auth_tokens t JOIN users u ON u.id = t.user_id WHERE t.token = ?", + (token,), + ) + if row is None or row["revoked"]: + return None + try: + expires = datetime.strptime(row["expires_at"], "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except ValueError: + return None + if datetime.now(timezone.utc) >= expires: + return None + return _row_to_user(row) + + +async def revoke_token(db: Database, token: str) -> None: + await db.execute("UPDATE auth_tokens SET revoked = 1 WHERE token = ?", (token,)) + + +# ---- 用户 CRUD ---- + +async def list_users(db: Database) -> list[dict[str, Any]]: + rows = await db.fetchall( + "SELECT id, username, role, authorizations, created_at FROM users ORDER BY created_at ASC" + ) + return [_row_to_user(r) for r in rows] + + +async def get_user(db: Database, user_id: str) -> dict[str, Any] | None: + row = await db.fetchone( + "SELECT id, username, role, authorizations, created_at FROM users WHERE id = ?", (user_id,) + ) + return _row_to_user(row) if row else None + + +async def get_user_by_username(db: Database, username: str) -> dict[str, Any] | None: + row = await db.fetchone( + "SELECT id, username, role, authorizations, created_at FROM users WHERE username = ?", (username,) + ) + return _row_to_user(row) if row else None + + +async def create_user( + db: Database, username: str, password: str, role: str, authorizations: list[str] +) -> dict[str, Any]: + user_id = str(uuid.uuid4()) + ph, salt = hash_password(password) + await db.execute( + "INSERT INTO users (id, username, password_hash, salt, role, authorizations, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (user_id, username, ph, salt, role, dumps_json(authorizations), _now_iso()), + ) + log_event("user_created", detail={"target": username, "role": role}) + return {"id": user_id, "username": username, "role": role, "authorizations": authorizations, "created_at": _now_iso()} + + +async def update_user( + db: Database, + user_id: str, + *, + role: str | None = None, + authorizations: list[str] | None = None, + password: str | None = None, +) -> dict[str, Any] | None: + fields: list[str] = [] + params: list[Any] = [] + if role is not None: + fields.append("role = ?") + params.append(role) + if authorizations is not None: + fields.append("authorizations = ?") + params.append(dumps_json(authorizations)) + if password is not None: + ph, salt = hash_password(password) + fields.append("password_hash = ?") + params.append(ph) + fields.append("salt = ?") + params.append(salt) + if fields: + params.append(user_id) + await db.execute(f"UPDATE users SET {', '.join(fields)} WHERE id = ?", params) + log_event("user_updated", detail={"target_id": user_id, "role": role, "password_reset": password is not None}) + return await get_user(db, user_id) + + +async def delete_user(db: Database, user_id: str) -> None: + await db.execute("DELETE FROM users WHERE id = ?", (user_id,)) + log_event("user_deleted", detail={"target_id": user_id}) + + +# ---- 种子管理员 ---- + +async def seed_admin(db: Database) -> None: + """启动时确保存在至少一个 admin 用户(默认 admin/admin)。""" + row = await db.fetchone("SELECT id FROM users WHERE role = 'admin' LIMIT 1") + if row is not None: + return + settings = get_settings() + await create_user(db, settings.admin_seed_username, settings.admin_seed_password, "admin", list(AUTHORIZATION_UNITS)) + logger.info("已创建种子管理员 %s", settings.admin_seed_username) diff --git a/sdk/app/services/logview.py b/sdk/app/services/logview.py new file mode 100644 index 0000000..b1cd8d0 --- /dev/null +++ b/sdk/app/services/logview.py @@ -0,0 +1,100 @@ +"""services/logview.py —— 读取行为日志文件(JSONL)+ 内存聚合,供管理后台日志查看。""" +from __future__ import annotations + +import asyncio +import json +from pathlib import Path +from typing import Any + + +def _select_files(log_dir: Path, from_date: str | None, to_date: str | None) -> list[Path]: + """按日期范围选出 audit-YYYY-MM-DD.log(升序)。""" + if not log_dir.is_dir(): + return [] + out: list[Path] = [] + for p in sorted(log_dir.glob("audit-*.log")): + name = p.name + if not (name.startswith("audit-") and name.endswith(".log")): + continue + stem = name[len("audit-"):-len(".log")] + if from_date and stem < from_date: + continue + if to_date and stem > to_date: + continue + out.append(p) + return out + + +def _read_lines(path: Path) -> list[dict[str, Any]]: + try: + with open(path, "r", encoding="utf-8") as f: + text = f.read() + except (FileNotFoundError, OSError): + return [] + out: list[dict[str, Any]] = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + out.append(json.loads(line)) + except (json.JSONDecodeError, TypeError): + continue + return out + + +def _match(e: dict[str, Any], event, user_id, q, status) -> bool: + if event and e.get("event") != event: + return False + if user_id and e.get("user_id") != user_id: + return False + if status is not None and str(e.get("status")) != str(status): + return False + if q: + q = str(q) + if q not in str(e.get("path") or "") and q not in str(e.get("user_id") or ""): + return False + return True + + +async def query_logs( + log_dir: str, + *, + from_date: str | None = None, + to_date: str | None = None, + event: str | None = None, + user_id: str | None = None, + q: str | None = None, + status: str | None = None, + page: int = 1, + page_size: int = 50, +) -> dict[str, Any]: + files = _select_files(Path(log_dir), from_date, to_date) + # 文件 I/O 放线程池,不阻塞事件循环 + all_lines = await asyncio.to_thread(lambda: [ln for p in files for ln in _read_lines(p)]) + items = [e for e in all_lines if _match(e, event, user_id, q, status)] + items.sort(key=lambda e: str(e.get("ts") or ""), reverse=True) + total = len(items) + + by_status: dict[str, int] = {} + latencies: list[float] = [] + denied = 0 + for e in items: + st = e.get("status") + if st is not None: + key = str(st) + by_status[key] = by_status.get(key, 0) + 1 + if e.get("event") in ("call_denied", "login_fail", "admin_login_fail"): + denied += 1 + lm = e.get("latency_ms") + if isinstance(lm, (int, float)): + latencies.append(float(lm)) + latencies.sort() + start = (page - 1) * page_size + aggregates = { + "by_status": by_status, + "denied": denied, + "avg_latency_ms": round(sum(latencies) / len(latencies), 1) if latencies else None, + "p95_latency_ms": latencies[int(len(latencies) * 0.95)] if latencies else None, + } + return {"items": items[start:start + page_size], "total": total, "aggregates": aggregates} From 1ca2241a97b1b9330dc3f81ba6c4e6e569284216 Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 15 Oct 2026 23:52:17 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(admin):=20=E5=A2=9E=E5=8A=A0=E4=B8=AD?= =?UTF-8?q?=E6=9E=A2=E8=AE=BE=E7=BD=AE(=E8=87=AA=E5=AE=9A=E4=B9=89=20ID=20?= =?UTF-8?q?=E4=B8=8E=E5=90=8D=E7=A7=B0,=E5=8F=B3=E4=B8=8A=E8=A7=92?= =?UTF-8?q?=E8=AE=BE=E7=BD=AE=E5=85=A5=E5=8F=A3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/src/App.vue | 25 ++++++++++-- admin/src/api/admin.js | 2 + admin/src/components/HubSettings.vue | 57 ++++++++++++++++++++++++++++ sdk/app/db.py | 5 +++ sdk/app/routers/admin.py | 16 ++++++++ sdk/app/services/settings.py | 40 +++++++++++++++++++ 6 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 admin/src/components/HubSettings.vue create mode 100644 sdk/app/services/settings.py diff --git a/admin/src/App.vue b/admin/src/App.vue index 8d703ce..27e7dbf 100644 --- a/admin/src/App.vue +++ b/admin/src/App.vue @@ -4,16 +4,28 @@ import { getToken, setToken, api } from './api/admin.js'; import Login from './views/Login.vue'; import Users from './views/Users.vue'; import Logs from './views/Logs.vue'; +import HubSettings from './components/HubSettings.vue'; const authed = ref(false); const page = ref('overview'); const user = ref(null); const stats = ref({ users: 0, total: 0, denied: 0, p95: null }); +const hub = ref({ hub_id: '', hub_name: '蜂群中枢' }); +const settingsOpen = ref(false); const PAGES = { overview: '概览', users: '用户管理', logs: '日志查看' }; function onUnauthorized() { setToken(''); authed.value = false; } +async function loadHub() { + try { + const r = await api.settings(); + hub.value = r.settings; + } catch (e) { + if (e.status === 401) onUnauthorized(); + } +} + async function loadStats() { try { const [u, l] = await Promise.all([api.users(), api.logs({ page: 1, page_size: 1 })]); @@ -32,6 +44,7 @@ async function init() { const r = await api.me(); user.value = r.user; authed.value = true; + loadHub(); loadStats(); } catch (e) { setToken(''); @@ -39,8 +52,9 @@ async function init() { } } -function onLogin(u) { user.value = u; authed.value = true; page.value = 'overview'; loadStats(); } +function onLogin(u) { user.value = u; authed.value = true; page.value = 'overview'; loadHub(); loadStats(); } async function onLogout() { try { await api.logout(); } catch {} setToken(''); authed.value = false; } +function onSettingsSaved(s) { hub.value = s; settingsOpen.value = false; } onMounted(init); @@ -51,7 +65,7 @@ onMounted(init);
-

{{ PAGES[page] }}

中枢
+
+

{{ PAGES[page] }}

+ {{ hub.hub_name }} + +
@@ -81,6 +99,7 @@ onMounted(init);
+ diff --git a/admin/src/api/admin.js b/admin/src/api/admin.js index 1bbd40c..16bfd28 100644 --- a/admin/src/api/admin.js +++ b/admin/src/api/admin.js @@ -43,4 +43,6 @@ export const api = { const qs = q.toString(); return request('/logs' + (qs ? '?' + qs : '')); }, + settings: () => request('/settings'), + updateSettings: (body) => request('/settings', { method: 'PUT', body }), }; diff --git a/admin/src/components/HubSettings.vue b/admin/src/components/HubSettings.vue new file mode 100644 index 0000000..69df7e9 --- /dev/null +++ b/admin/src/components/HubSettings.vue @@ -0,0 +1,57 @@ + + + + + diff --git a/sdk/app/db.py b/sdk/app/db.py index 93327c1..4a894c6 100644 --- a/sdk/app/db.py +++ b/sdk/app/db.py @@ -146,6 +146,11 @@ CREATE TABLE IF NOT EXISTS auth_tokens ( ); CREATE INDEX IF NOT EXISTS idx_auth_tokens_user ON auth_tokens(user_id); + +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); """ diff --git a/sdk/app/routers/admin.py b/sdk/app/routers/admin.py index 571562a..58ea218 100644 --- a/sdk/app/routers/admin.py +++ b/sdk/app/routers/admin.py @@ -21,6 +21,7 @@ from ..services.auth import ( delete_user, ) from ..services.logview import query_logs +from ..services.settings import get_hub_settings, set_hub_settings from ..upstream import envelope router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -44,6 +45,11 @@ class UserPatch(BaseModel): password: str | None = None +class HubSettingsBody(BaseModel): + hub_id: str | None = None + hub_name: str | None = None + + @router.post("/login") async def login(body: LoginBody, db: Database = Depends(get_db)): user = await verify_login(db, body.username, body.password) @@ -79,6 +85,16 @@ async def units(_user: dict = Depends(require_admin)): return {"ok": True, "units": list(AUTHORIZATION_UNITS)} +@router.get("/settings") +async def get_settings_api(db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + return {"ok": True, "settings": await get_hub_settings(db)} + + +@router.put("/settings") +async def put_settings_api(body: HubSettingsBody, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): + return {"ok": True, "settings": await set_hub_settings(db, body.hub_id, body.hub_name)} + + @router.get("/users") async def users(db: Database = Depends(get_db), _user: dict = Depends(require_admin)): return {"ok": True, "users": await list_users(db)} diff --git a/sdk/app/services/settings.py b/sdk/app/services/settings.py new file mode 100644 index 0000000..ba5952e --- /dev/null +++ b/sdk/app/services/settings.py @@ -0,0 +1,40 @@ +"""services/settings.py —— 中枢级键值设置(hub_id / hub_name)。""" +from __future__ import annotations + +import uuid + +from ..db import Database + +_HUB_ID_KEY = "hub_id" +_HUB_NAME_KEY = "hub_name" +DEFAULT_HUB_NAME = "蜂群中枢" + + +async def get_setting(db: Database, key: str, default: str | None = None) -> str | None: + row = await db.fetchone("SELECT value FROM settings WHERE key = ?", (key,)) + return row["value"] if row else default + + +async def set_setting(db: Database, key: str, value: str) -> None: + row = await db.fetchone("SELECT 1 FROM settings WHERE key = ?", (key,)) + if row is not None: + await db.execute("UPDATE settings SET value = ? WHERE key = ?", (value, key)) + else: + await db.execute("INSERT INTO settings (key, value) VALUES (?, ?)", (key, value)) + + +async def get_hub_settings(db: Database) -> dict: + hub_id = await get_setting(db, _HUB_ID_KEY) + if not hub_id: + hub_id = str(uuid.uuid4()) + await set_setting(db, _HUB_ID_KEY, hub_id) + hub_name = (await get_setting(db, _HUB_NAME_KEY, DEFAULT_HUB_NAME)) or DEFAULT_HUB_NAME + return {"hub_id": hub_id, "hub_name": hub_name} + + +async def set_hub_settings(db: Database, hub_id: str | None, hub_name: str | None) -> dict: + if hub_id and hub_id.strip(): + await set_setting(db, _HUB_ID_KEY, hub_id.strip()) + if hub_name and hub_name.strip(): + await set_setting(db, _HUB_NAME_KEY, hub_name.strip()) + return await get_hub_settings(db) From 282415fcb1d8c5c21bf8bb27532e7a4a772d6c3a Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 15 Oct 2026 23:56:42 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(admin):=20=E8=A1=8C=E4=B8=BA=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E8=AE=B0=E5=BD=95=20hub=5Fid(=E4=BE=BF=E4=BA=8E?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E4=B8=8D=E5=90=8C=E4=B8=AD=E6=9E=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/app/logwriter.py | 11 ++++++++--- sdk/app/main.py | 6 ++++-- sdk/app/routers/admin.py | 5 ++++- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/sdk/app/logwriter.py b/sdk/app/logwriter.py index 6e0607d..cab2f17 100644 --- a/sdk/app/logwriter.py +++ b/sdk/app/logwriter.py @@ -26,14 +26,18 @@ def _today() -> str: class LogWriter: - def __init__(self, log_dir: str, retention_days: int = 0) -> None: + def __init__(self, log_dir: str, retention_days: int = 0, hub_id: str = "") -> None: self.log_dir = Path(log_dir) self.retention_days = retention_days + self.hub_id = hub_id self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue(maxsize=20000) self._task: asyncio.Task | None = None self._date = "" self._fh = None + def set_hub_id(self, hub_id: str) -> None: + self.hub_id = hub_id or "" + async def start(self) -> None: self.log_dir.mkdir(parents=True, exist_ok=True) self._task = asyncio.create_task(self._run()) @@ -111,9 +115,9 @@ class LogWriter: logger.exception("日志清理失败") -def init_writer(log_dir: str, retention_days: int = 0) -> None: +def init_writer(log_dir: str, retention_days: int = 0, hub_id: str = "") -> None: global _writer - _writer = LogWriter(log_dir, retention_days) + _writer = LogWriter(log_dir, retention_days, hub_id=hub_id) async def start_writer() -> None: @@ -143,6 +147,7 @@ def log_event( return entry = { "ts": _now_iso(), + "hub_id": _writer.hub_id, "user_id": user_id, "event": event, "method": method, diff --git a/sdk/app/main.py b/sdk/app/main.py index 51bd543..8a69654 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -32,6 +32,7 @@ from .routers import admin from . import logwriter from .auth import AuthError from .services.auth import seed_admin +from .services.settings import get_hub_settings from .upstream import close_client logging.basicConfig(level=logging.INFO) @@ -109,9 +110,10 @@ def create_app() -> FastAPI: @asynccontextmanager async def _lifespan(_app: FastAPI): _db = await init_db() - logwriter.init_writer(settings.log_dir, settings.log_retention_days) - await logwriter.start_writer() await seed_admin(_db) + hub = await get_hub_settings(_db) + logwriter.init_writer(settings.log_dir, settings.log_retention_days, hub_id=hub["hub_id"]) + await logwriter.start_writer() try: yield finally: diff --git a/sdk/app/routers/admin.py b/sdk/app/routers/admin.py index 58ea218..e49a3ae 100644 --- a/sdk/app/routers/admin.py +++ b/sdk/app/routers/admin.py @@ -9,6 +9,7 @@ from ..auth import require_admin from ..config import get_settings from ..db import Database, get_db from ..logwriter import log_event +from .. import logwriter from ..services.auth import ( AUTHORIZATION_UNITS, verify_login, @@ -92,7 +93,9 @@ async def get_settings_api(db: Database = Depends(get_db), _user: dict = Depends @router.put("/settings") async def put_settings_api(body: HubSettingsBody, db: Database = Depends(get_db), _user: dict = Depends(require_admin)): - return {"ok": True, "settings": await set_hub_settings(db, body.hub_id, body.hub_name)} + settings = await set_hub_settings(db, body.hub_id, body.hub_name) + logwriter.set_hub_id(settings["hub_id"]) + return {"ok": True, "settings": settings} @router.get("/users") From d9abd0b86ac52d708b6dda9596dfa0cc30384b33 Mon Sep 17 00:00:00 2001 From: admin Date: Fri, 16 Oct 2026 14:42:23 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(auth):=20=E7=94=A8=E6=88=B7=E7=AB=AF?= =?UTF-8?q?=E9=89=B4=E6=9D=83(=E7=99=BB=E5=BD=95/=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E6=B8=85=E5=8D=95/=E9=80=90=E6=AC=A1=E6=A0=A1=E9=AA=8C/?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E9=97=A8=E7=A6=81=20+=20owner=20=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E9=9A=94=E7=A6=BB)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sdk/app/auth.py | 48 ++++++++++++++++++-- sdk/app/config.py | 4 ++ sdk/app/db.py | 8 ++++ sdk/app/main.py | 32 ++++++++------ sdk/app/routers/auth.py | 52 ++++++++++++++++++++++ sdk/app/routers/bioflow_proxy.py | 6 +++ sdk/app/routers/capabilities.py | 7 ++- sdk/app/routers/docgen.py | 23 +++++++++- sdk/app/routers/paper.py | 26 +++++++++-- sdk/app/routers/qa.py | 28 ++++++++---- sdk/app/routers/sessions.py | 75 ++++++++++++++++++++------------ sdk/app/services/auth.py | 5 ++- 12 files changed, 252 insertions(+), 62 deletions(-) create mode 100644 sdk/app/routers/auth.py diff --git a/sdk/app/auth.py b/sdk/app/auth.py index 400a670..b6cb7e4 100644 --- a/sdk/app/auth.py +++ b/sdk/app/auth.py @@ -1,8 +1,15 @@ -"""auth.py —— 管理后台鉴权依赖 + 鉴权异常。""" +"""auth.py —— 鉴权依赖(管理员 + 用户级)+ 鉴权异常。 + +- `get_current_user`:受 auth_enabled 开关控制;关=匿名(None),开=校验 token(401 失败)。 +- `require_user`:强制登录(不受开关控制),用于 /api/auth/me、/logout 等必须登录的端点。 +- `require_admin`:管理员;复用 require_user + role 校验。 +- `require_authorization(unit)`:功能门禁;关=放行,开=admin 或 unit 在 authorizations 才放行。 +""" from __future__ import annotations from fastapi import Depends, Request +from .config import get_settings from .db import Database, get_db from .logwriter import log_event from .services.auth import resolve_token @@ -21,15 +28,48 @@ def _bearer(request: Request) -> str: return header[7:] if header.startswith("Bearer ") else "" -async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict: - """校验 Authorization: Bearer ,并要求角色为 admin;失败抛 AuthError 并留痕。""" +async def get_current_user(request: Request, db: Database = Depends(get_db)) -> dict | None: + """用户级鉴权;auth_enabled 为假时返回 None(匿名放行)。""" + if not get_settings().auth_enabled: + return None token = _bearer(request) user = await resolve_token(db, token) if token else None if user is None: log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"}) raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期") + request.state.user = user + return user + + +async def require_user(request: Request, db: Database = Depends(get_db)) -> dict: + """强制登录(不受 auth_enabled 开关控制)。""" + token = _bearer(request) + user = await resolve_token(db, token) if token else None + if user is None: + log_event("call_denied", method=request.method, path=request.url.path, detail={"reason": "unauthorized"}) + raise AuthError(401, "UNAUTHORIZED", "未登录或登录已过期") + request.state.user = user + return user + + +async def require_admin(request: Request, db: Database = Depends(get_db)) -> dict: + user = await require_user(request, db) if user["role"] != "admin": log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "not_admin"}) raise AuthError(403, "FORBIDDEN", "非管理员账号") - request.state.user = user return user + + +def require_authorization(unit: str): + """功能门禁依赖工厂;auth_enabled 关=放行,开=admin 或 unit 在 authorizations 放行。""" + + async def _dep(request: Request, db: Database = Depends(get_db)) -> dict | None: + if not get_settings().auth_enabled: + return None + user = await require_user(request, db) + if user["role"] != "admin" and unit not in (user.get("authorizations") or []): + log_event("call_denied", user_id=user["id"], method=request.method, path=request.url.path, detail={"reason": "forbidden", "unit": unit}) + raise AuthError(403, "FORBIDDEN", "无权访问该功能") + return user + + return _dep diff --git a/sdk/app/config.py b/sdk/app/config.py index f841fe4..7159c24 100644 --- a/sdk/app/config.py +++ b/sdk/app/config.py @@ -94,6 +94,10 @@ class Settings(BaseSettings): log_dir: str = "data/logs" # 行为日志目录(每日一个 JSONL 文件) log_retention_days: int = 0 # 日志保留天数(0=不清理) + # ---- 用户端鉴权(普通用户登录;AUTH_ENABLED 为假=匿名放行,兼容浸泡期)---- + auth_enabled: bool = False + user_token_ttl_seconds: int = 86400 # 普通用户会话凭据有效期(秒) + # ---- 生产形态:BFF 静态托管 ui/dist(ARCHITECTURE.md §3,M6 挂载)---- frontend_dist_dir: str = "ui/dist" # 生产形态:静态托管 ui/dist diff --git a/sdk/app/db.py b/sdk/app/db.py index 4a894c6..ee8f465 100644 --- a/sdk/app/db.py +++ b/sdk/app/db.py @@ -209,6 +209,14 @@ class Database: await self._conn.executescript(_ADMIN_SCHEMA) except Exception: logger.exception("admin 表迁移失败(管理后台将不可用,其余不受影响)") + # sessions 加 owner_id(用户级数据隔离,幂等 ALTER) + try: + cur = await self._conn.execute("PRAGMA table_info(sessions)") + cols = {row[1] for row in await cur.fetchall()} + if "owner_id" not in cols: + await self._conn.execute("ALTER TABLE sessions ADD COLUMN owner_id TEXT") + except Exception: + logger.exception("sessions owner_id 迁移失败") await self._conn.commit() async def close(self) -> None: diff --git a/sdk/app/main.py b/sdk/app/main.py index 8a69654..d810dfe 100644 --- a/sdk/app/main.py +++ b/sdk/app/main.py @@ -15,7 +15,7 @@ import time from contextlib import asynccontextmanager from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, Request from fastapi.exceptions import RequestValidationError from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse @@ -29,8 +29,9 @@ from .routers import paper from .routers import private_docs from .routers import review_proxy from .routers import admin +from .routers import auth from . import logwriter -from .auth import AuthError +from .auth import AuthError, get_current_user, require_authorization from .services.auth import seed_admin from .services.settings import get_hub_settings from .upstream import close_client @@ -61,8 +62,10 @@ def create_app() -> FastAPI: start = time.monotonic() response = await call_next(request) if request.url.path.startswith("/api/"): + user = getattr(request.state, "user", None) logwriter.log_event( "http_request", + user_id=user["id"] if user else None, method=request.method, path=request.url.path, status=response.status_code, @@ -79,21 +82,22 @@ def create_app() -> FastAPI: ) app.include_router(health.router) - app.include_router(sessions.router) - app.include_router(qa.router) - app.include_router(capabilities.router) - app.include_router(kg.router) - app.include_router(extraction.router) - app.include_router(figures.router) - app.include_router(legacy.router) - app.include_router(docgen.router) # docgen(P2) - app.include_router(private_docs.router) # 私有资料问答(FR-PRIV-004) + app.include_router(auth.router) # /api/auth/*(login/status 免鉴权,me/logout 需登录) + app.include_router(sessions.router, dependencies=[Depends(get_current_user)]) + app.include_router(qa.router, dependencies=[Depends(require_authorization("qa"))]) + app.include_router(capabilities.router, dependencies=[Depends(get_current_user)]) + app.include_router(kg.router, dependencies=[Depends(require_authorization("kg"))]) + app.include_router(extraction.router, dependencies=[Depends(require_authorization("extraction"))]) + app.include_router(figures.router, dependencies=[Depends(require_authorization("figures"))]) + app.include_router(legacy.router, dependencies=[Depends(require_authorization("legacy"))]) + app.include_router(docgen.router, dependencies=[Depends(require_authorization("docgen"))]) # docgen(P2) + app.include_router(private_docs.router, dependencies=[Depends(require_authorization("private_docs"))]) # 私有资料问答(FR-PRIV-004) if get_settings().bioflow_enabled: - app.include_router(bioflow_proxy.router) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028) + app.include_router(bioflow_proxy.router, dependencies=[Depends(require_authorization("bioflow"))]) # 生信反代(BIOFLOW_ENABLED 开关,FR-BIO-028) if get_settings().paper_writer_enabled: - app.include_router(paper.router) # 论文撰写(PAPER_WRITER_ENABLED 开关) + app.include_router(paper.router, dependencies=[Depends(require_authorization("paper"))]) # 论文撰写(PAPER_WRITER_ENABLED 开关) if get_settings().review_enabled: - app.include_router(review_proxy.router) # 科研审稿反代(REVIEW_ENABLED 开关) + app.include_router(review_proxy.router, dependencies=[Depends(get_current_user)]) # 科研审稿反代(REVIEW_ENABLED 开关) app.include_router(admin.router) # 管理后台(/api/admin/*) @app.exception_handler(RequestValidationError) diff --git a/sdk/app/routers/auth.py b/sdk/app/routers/auth.py new file mode 100644 index 0000000..307cd19 --- /dev/null +++ b/sdk/app/routers/auth.py @@ -0,0 +1,52 @@ +"""routers/auth.py —— 用户端鉴权端点(登录/登出/me/status)。""" +from __future__ import annotations + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel + +from ..auth import require_user +from ..config import get_settings +from ..db import Database, get_db +from ..logwriter import log_event +from ..services.auth import verify_login, issue_token, revoke_token +from ..upstream import envelope + +router = APIRouter(prefix="/api/auth", tags=["auth"]) + + +class LoginBody(BaseModel): + username: str + password: str + + +@router.get("/status") +async def status(): + """免鉴权:告知前端是否开启鉴权(决定是否显示登录页)。""" + return {"ok": True, "auth_enabled": get_settings().auth_enabled} + + +@router.post("/login") +async def login(body: LoginBody, db: Database = Depends(get_db)): + user = await verify_login(db, body.username, body.password) + if user is None: + log_event("login_fail", detail={"username": body.username}) + content, status = envelope("UNAUTHORIZED", "用户名或密码错误", status=401) + return JSONResponse(status_code=status, content=content) + token = await issue_token(db, user["id"], ttl_seconds=get_settings().user_token_ttl_seconds) + log_event("login_ok", user_id=user["id"], detail={"username": body.username}) + return {"ok": True, "token": token, "user": user} + + +@router.post("/logout") +async def logout(request: Request, db: Database = Depends(get_db), _user: dict = Depends(require_user)): + header = request.headers.get("Authorization", "") + token = header[7:] if header.startswith("Bearer ") else "" + if token: + await revoke_token(db, token) + return {"ok": True} + + +@router.get("/me") +async def me(_user: dict = Depends(require_user)): + return {"ok": True, "user": _user} diff --git a/sdk/app/routers/bioflow_proxy.py b/sdk/app/routers/bioflow_proxy.py index d10adf2..639ec90 100644 --- a/sdk/app/routers/bioflow_proxy.py +++ b/sdk/app/routers/bioflow_proxy.py @@ -138,6 +138,12 @@ async def register_report(request: Request, db: Database = Depends(get_db)) -> A import uuid data = payload.get("data", payload) if isinstance(payload, dict) else {} + sid = str(data.get("session_id") or "") + user = getattr(request.state, "user", None) + if user and sid: + owner_row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (sid,)) + if owner_row is None or owner_row["owner_id"] != user["id"]: + return proxied # 归属不符:不落库,仍透传生信响应 artifact_id = uuid.uuid4().hex[:16] await db.execute( """INSERT INTO doc_artifacts (id, session_id, status, title, template_name, diff --git a/sdk/app/routers/capabilities.py b/sdk/app/routers/capabilities.py index 2831f83..f5461fd 100644 --- a/sdk/app/routers/capabilities.py +++ b/sdk/app/routers/capabilities.py @@ -36,7 +36,12 @@ async def list_capabilities( session_caps: Optional[dict] = None if session_id: try: - row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,)) + user = getattr(request.state, "user", None) + owner_id = user["id"] if user else None + if owner_id is None: + row = await db.fetchone("SELECT meta FROM sessions WHERE id = ?", (session_id,)) + else: + row = await db.fetchone("SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id)) if row is not None: meta = loads_json(row["meta"], default={}) if isinstance(meta, dict): diff --git a/sdk/app/routers/docgen.py b/sdk/app/routers/docgen.py index bceea50..0747a36 100644 --- a/sdk/app/routers/docgen.py +++ b/sdk/app/routers/docgen.py @@ -49,7 +49,7 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any -from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile +from fastapi import APIRouter, Depends, File, Form, Query, Request, Response, UploadFile from fastapi.responses import FileResponse, JSONResponse from ..config import get_settings @@ -109,6 +109,13 @@ def _envelope(code: str, message: str | None = None, degraded: bool | None = Non return JSONResponse(status_code=http, content=body) +async def _session_owned(db: Database, session_id: str, owner_id: str | None) -> bool: + if owner_id is None: + return True + row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (session_id,)) + return row is not None and row["owner_id"] == owner_id + + def _validation(message: str) -> JSONResponse: return _envelope("VALIDATION", message=message, status=422) @@ -646,6 +653,7 @@ async def _run_docgen_task( @router.post("/generate") async def generate_doc( payload: DocgenGenerateRequest, + request: Request, db: Database = Depends(get_db), ): """异步生成(缺陷 #52,2026-09-10):快路径校验 → 插任务行 → 后台生成 → 202。 @@ -657,6 +665,9 @@ async def generate_doc( 202 {ok, task_id, status:"queued"}(契约见 docs/acceptance-r3.md 附录 B)。 chat 消息落库由前端走现有 sessions 机制,本端点不碰。 """ + user = getattr(request.state, "user", None) + if user and payload.session_id and not await _session_owned(db, payload.session_id, user["id"]): + return _envelope("NOT_FOUND", message="会话不存在", status=404) bad = _check_description(payload.description) if bad is not None: return bad @@ -767,15 +778,25 @@ async def get_docgen_task(task_id: str, db: Database = Depends(get_db)): @router.get("/tasks") async def list_docgen_tasks( + request: Request, session_id: str = Query("", description="按会话过滤;空=全部(上限 100)"), db: Database = Depends(get_db), ): """会话任务列表(缺陷 #52 契约,附录 B)。""" + user = getattr(request.state, "user", None) + if user and session_id and not await _session_owned(db, session_id, user["id"]): + return _envelope("NOT_FOUND", message="会话不存在", status=404) if session_id: rows = await db.fetchall( "SELECT id FROM docgen_tasks WHERE session_id = ? ORDER BY created_at DESC LIMIT 100", (session_id,), ) + elif user: + rows = await db.fetchall( + "SELECT t.id FROM docgen_tasks t JOIN sessions s ON s.id = t.session_id " + "WHERE s.owner_id = ? ORDER BY t.created_at DESC LIMIT 100", + (user["id"],), + ) else: rows = await db.fetchall( "SELECT id FROM docgen_tasks ORDER BY created_at DESC LIMIT 100" diff --git a/sdk/app/routers/paper.py b/sdk/app/routers/paper.py index ba96997..869b4ec 100644 --- a/sdk/app/routers/paper.py +++ b/sdk/app/routers/paper.py @@ -9,13 +9,15 @@ import asyncio from typing import Any import httpx -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse from pydantic import BaseModel from ..config import get_settings from ..db import Database, get_db from ..services.paper import agent from ..services.paper.mcp_client import PaperMcpClient +from ..upstream import envelope router = APIRouter(prefix="/api/paper", tags=["paper"]) @@ -36,6 +38,13 @@ def _client() -> PaperMcpClient: return PaperMcpClient(s.paper_writer_url, s.paper_writer_read_timeout) +async def _session_owned(db: Database, session_id: str, owner_id: str | None) -> bool: + if owner_id is None: + return True + row = await db.fetchone("SELECT owner_id FROM sessions WHERE id = ?", (session_id,)) + return row is not None and row["owner_id"] == owner_id + + @router.get("/health") async def paper_health() -> dict[str, Any]: """探活反代 8097 /health;离线返回 offline 标记(前端据此隐藏 paper 入口)。""" @@ -50,19 +59,28 @@ async def paper_health() -> dict[str, Any]: @router.post("/turns") -async def paper_turn(payload: PaperTurnRequest, db: Database = Depends(get_db)) -> dict[str, Any]: +async def paper_turn(payload: PaperTurnRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]: + user = getattr(request.state, "user", None) + if user and not await _session_owned(db, payload.session_id, user["id"]): + return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0]) blocks = await agent.handle_turn(db, payload.session_id, payload.text, payload.paper_id, payload.template_key) return {"ok": True, "blocks": blocks} @router.post("/papers/{paper_id}/confirm") -async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]: +async def paper_confirm(paper_id: str, payload: PaperConfirmRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]: + user = getattr(request.state, "user", None) + if user and not await _session_owned(db, payload.session_id, user["id"]): + return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0]) asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id)) return {"ok": True, "paper_id": paper_id} @router.post("/papers/{paper_id}/resume") -async def paper_resume(paper_id: str, payload: PaperConfirmRequest, db: Database = Depends(get_db)) -> dict[str, Any]: +async def paper_resume(paper_id: str, payload: PaperConfirmRequest, request: Request, db: Database = Depends(get_db)) -> dict[str, Any]: + user = getattr(request.state, "user", None) + if user and not await _session_owned(db, payload.session_id, user["id"]): + return JSONResponse(status_code=404, content=envelope("NOT_FOUND", message="会话不存在", status=404)[0]) asyncio.create_task(agent.run_writing_loop(db, paper_id, payload.session_id)) return {"ok": True, "paper_id": paper_id} diff --git a/sdk/app/routers/qa.py b/sdk/app/routers/qa.py index d23f7f1..c1a8358 100644 --- a/sdk/app/routers/qa.py +++ b/sdk/app/routers/qa.py @@ -100,13 +100,14 @@ async def _proxy( retry_invalid_citation: bool, mode: str, normalizer=None, + owner_id: str | None = None, ) -> Any: """非流式上游代理:重试、错误信封、归一化。返回 (JSONResponse)。""" client = sse_relay.get_qa_client() normalizer = normalizer or sse_relay.get_qa_normalizer() url = f"{sse_relay.qa_base_url()}{_upstream_path(endpoint)}" timeout = _read_timeout(read_timeout) - json_body, _humanize = await _inject_history(body) + json_body, _humanize = await _inject_history(body, owner_id=owner_id) for attempt in (1, 2): try: @@ -206,8 +207,10 @@ async def qa_info(): async def qa_chat(request: Request): """非流式问答:上游 /chat + 归一化 + invalid_llm_citation 重试一次。""" body = await request.json() + user = getattr(request.state, "user", None) + owner_id = user["id"] if user else None return await _proxy( - "chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat" + "chat", body, read_timeout=CHAT_READ_TIMEOUT, retry_invalid_citation=True, mode="chat", owner_id=owner_id ) @@ -224,7 +227,9 @@ async def qa_chat_stream(request: Request): except Exception: return _envelope("VALIDATION", 422) evidence_first = bool(body.pop("evidence_first", False)) - body, humanize_on = await _inject_history(body) + user = getattr(request.state, "user", None) + owner_id = user["id"] if user else None + body, humanize_on = await _inject_history(body, owner_id=owner_id) return StreamingResponse( sse_relay.relay_stream(body, evidence_first=evidence_first, humanize_enabled=humanize_on), media_type="text/event-stream", @@ -242,8 +247,10 @@ async def qa_search(request: Request): """ body = await request.json() body = {**body, "use_llm": False, "native_chain_enabled": False} + user = getattr(request.state, "user", None) + owner_id = user["id"] if user else None return await _proxy( - "search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search" + "search", body, read_timeout=SEARCH_READ_TIMEOUT, retry_invalid_citation=False, mode="search", owner_id=owner_id ) @@ -251,7 +258,7 @@ async def qa_search(request: Request): # 多轮记忆(S2):剥离 session_id → 组装 history 注入上游 # --------------------------------------------------------------------------- -async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]: +async def _inject_history(body: Optional[dict], owner_id: str | None = None) -> tuple[dict, bool]: """剥离 BFF 契约字段 session_id,读会话 meta 注入 history/capabilities。 - 不传 session_id / 会话不存在 → 行为与现役一致(history/capabilities 缺省) @@ -270,9 +277,14 @@ async def _inject_history(body: Optional[dict]) -> tuple[dict, bool]: if body.get("history"): return body, False # 显式传入的历史优先(上游直传场景;capabilities 由请求体自带) try: - row = await bff_db.fetchone( - "SELECT meta FROM sessions WHERE id = ?", (session_id,) - ) + if owner_id is None: + row = await bff_db.fetchone( + "SELECT meta FROM sessions WHERE id = ?", (session_id,) + ) + else: + row = await bff_db.fetchone( + "SELECT meta FROM sessions WHERE id = ? AND owner_id = ?", (session_id, owner_id) + ) if row is None: return body, False meta = loads_json(row["meta"], default={}) diff --git a/sdk/app/routers/sessions.py b/sdk/app/routers/sessions.py index a72b1ae..4f66700 100644 --- a/sdk/app/routers/sessions.py +++ b/sdk/app/routers/sessions.py @@ -8,7 +8,8 @@ - GET /api/sessions/{id}/sources —— 全会话来源聚合:按 paper_id 去重(无 paper_id 按 id), 带 count(出现次数)。 -404 → NOT_FOUND 信封;时间 ISO 8601 UTC。 +用户级数据隔离:auth_enabled 开启时按 sessions.owner_id 过滤/校验(get_current_user 注入当前用户; +匿名模式下 user=None 则不过滤,兼容旧形态)。404 → NOT_FOUND 信封;时间 ISO 8601 UTC。 """ from __future__ import annotations @@ -20,6 +21,7 @@ from typing import Any from fastapi import APIRouter, Depends, Response, status as http_status from fastapi.responses import JSONResponse +from ..auth import get_current_user from ..db import Database, dumps_json, get_db, loads_json from ..schemas import ( MessageCreate, @@ -80,10 +82,14 @@ def _row_to_message(row: Any) -> MessageOut: async def _get_session_or_none( - db: Database, session_id: str, *, with_count: bool = True + db: Database, session_id: str, *, with_count: bool = True, owner_id: str | None = None ) -> SessionOut | None: sql = _SESSION_SELECT + " WHERE s.id = ?" - row = await db.fetchone(sql, (session_id,)) + params: list[Any] = [session_id] + if owner_id is not None: + sql += " AND s.owner_id = ?" + params.append(owner_id) + row = await db.fetchone(sql, tuple(params)) return _row_to_session(row) if row else None @@ -101,31 +107,40 @@ async def _get_message_or_none( # --------------------------------------------------------------------------- @router.get("", response_model=list[SessionOut]) -async def list_sessions(db: Database = Depends(get_db)) -> list[SessionOut]: - rows = await db.fetchall(_SESSION_SELECT + " ORDER BY s.updated_at DESC") +async def list_sessions(db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> list[SessionOut]: + owner_id = user["id"] if user else None + sql = _SESSION_SELECT + params: list[Any] = [] + if owner_id is not None: + sql += " WHERE s.owner_id = ?" + params.append(owner_id) + sql += " ORDER BY s.updated_at DESC" + rows = await db.fetchall(sql, tuple(params)) return [_row_to_session(r) for r in rows] @router.post("", response_model=SessionOut, status_code=http_status.HTTP_201_CREATED) async def create_session( - payload: SessionCreate, db: Database = Depends(get_db) + payload: SessionCreate, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user) ) -> SessionOut: now = _db_now() session_id = str(uuid.uuid4()) title = payload.normalized_title() + owner_id = user["id"] if user else None await db.execute( - "INSERT INTO sessions (id, title, status, meta, created_at, updated_at) " - "VALUES (?, ?, 'idle', ?, ?, ?)", - (session_id, title, dumps_json({"title_manual": False}), now, now), + "INSERT INTO sessions (id, title, status, meta, owner_id, created_at, updated_at) " + "VALUES (?, ?, 'idle', ?, ?, ?, ?)", + (session_id, title, dumps_json({"title_manual": False}), owner_id, now, now), ) - out = await _get_session_or_none(db, session_id) + out = await _get_session_or_none(db, session_id, owner_id=owner_id) assert out is not None # 刚插入必存在 return out @router.get("/{session_id}", response_model=SessionDetail) -async def get_session(session_id: str, db: Database = Depends(get_db)): - session = await _get_session_or_none(db, session_id) +async def get_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)): + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found(session_id) rows = await db.fetchall( @@ -136,9 +151,10 @@ async def get_session(session_id: str, db: Database = Depends(get_db)): @router.patch("/{session_id}", response_model=SessionOut) async def patch_session( - session_id: str, payload: SessionPatch, db: Database = Depends(get_db) + session_id: str, payload: SessionPatch, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user) ): - session = await _get_session_or_none(db, session_id) + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found(session_id) @@ -169,14 +185,15 @@ async def patch_session( (dumps_json(meta), _db_now(), session_id), ) - out = await _get_session_or_none(db, session_id) + out = await _get_session_or_none(db, session_id, owner_id=owner_id) assert out is not None return out @router.delete("/{session_id}", status_code=http_status.HTTP_204_NO_CONTENT) -async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Response: - session = await _get_session_or_none(db, session_id) +async def delete_session(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)) -> Response: + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found_response(session_id) await db.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) @@ -189,9 +206,10 @@ async def delete_session(session_id: str, db: Database = Depends(get_db)) -> Res @router.post("/{session_id}/messages", response_model=MessageOut, status_code=201) async def create_message( - session_id: str, payload: MessageCreate, db: Database = Depends(get_db) + session_id: str, payload: MessageCreate, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user) ): - session = await _get_session_or_none(db, session_id) + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found(session_id) @@ -206,7 +224,6 @@ async def create_message( (message_id, session_id, payload.kind, text, now, now), ) else: - # 助手流式骨架:text 置空,blocks 落库初始块 blocks = [b.model_dump() for b in payload.blocks] await db.execute( "INSERT INTO messages (id, session_id, role, kind, status, blocks, raw, text, " @@ -215,7 +232,6 @@ async def create_message( dumps_json(blocks), now, now), ) - # 消息活动 → 会话 updated_at 前移、idle → running await db.execute( "UPDATE sessions SET status = CASE WHEN status = 'idle' THEN 'running' ELSE status END, " "updated_at = ? WHERE id = ?", @@ -233,7 +249,13 @@ async def patch_message( message_id: str, payload: MessagePatch, db: Database = Depends(get_db), + user: dict | None = Depends(get_current_user), ): + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) + if session is None: + return _not_found(session_id) + message = await _get_message_or_none(db, session_id, message_id) if message is None: return _not_found(session_id, message_id) @@ -258,7 +280,6 @@ async def patch_message( await db.execute( f"UPDATE messages SET {', '.join(sets)} WHERE id = ?", params ) - # 同步前移会话 updated_at await db.execute( "UPDATE sessions SET updated_at = ? WHERE id = ?", (_db_now(), session_id), @@ -274,12 +295,10 @@ async def patch_message( # --------------------------------------------------------------------------- @router.get("/{session_id}/sources") -async def get_session_sources(session_id: str, db: Database = Depends(get_db)): - """按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。 - - 来源来自该会话全部助手消息 blocks 里的 sources 块(§2 块联合)。 - """ - session = await _get_session_or_none(db, session_id) +async def get_session_sources(session_id: str, db: Database = Depends(get_db), user: dict | None = Depends(get_current_user)): + """按 paper_id 去重(无 paper_id 时按 id),带 count(出现次数)。""" + owner_id = user["id"] if user else None + session = await _get_session_or_none(db, session_id, owner_id=owner_id) if session is None: return _not_found(session_id) diff --git a/sdk/app/services/auth.py b/sdk/app/services/auth.py index 0ae01c3..59c668b 100644 --- a/sdk/app/services/auth.py +++ b/sdk/app/services/auth.py @@ -72,11 +72,12 @@ async def verify_login(db: Database, username: str, password: str) -> dict[str, # ---- 会话凭据 ---- -async def issue_token(db: Database, user_id: str) -> str: +async def issue_token(db: Database, user_id: str, ttl_seconds: int | None = None) -> str: settings = get_settings() + ttl = ttl_seconds if ttl_seconds is not None else settings.admin_token_ttl_seconds token = secrets.token_urlsafe(32) now = _now_iso() - expires = datetime.now(timezone.utc) + timedelta(seconds=settings.admin_token_ttl_seconds) + expires = datetime.now(timezone.utc) + timedelta(seconds=ttl) await db.execute( "INSERT INTO auth_tokens (token, user_id, expires_at, created_at, revoked) " "VALUES (?, ?, ?, ?, 0)",