diff --git a/.gitignore b/.gitignore index 359dbeb..8adaed0 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,7 @@ logs/ # Node node_modules/ +.pnpm-store/ npm-debug.log* yarn-debug.log* yarn-error.log* diff --git a/dashboard/backend/.gitignore b/dashboard/backend/.gitignore index 39b9ea3..2d9328d 100644 --- a/dashboard/backend/.gitignore +++ b/dashboard/backend/.gitignore @@ -38,3 +38,7 @@ autoeq/ # Logs *.log logs/ + +# Build +dist/ +drizzle/ diff --git a/dashboard/backend/Dockerfile b/dashboard/backend/Dockerfile index 7d6f8a7..52babb3 100644 --- a/dashboard/backend/Dockerfile +++ b/dashboard/backend/Dockerfile @@ -11,15 +11,22 @@ WORKDIR /app # Create logs directory RUN mkdir -p /app/logs -# Install dependencies +# Install dependencies (including devDependencies for build) COPY package.json pnpm-lock.yaml ./ -RUN corepack enable pnpm && pnpm install --frozen-lockfile --prod +RUN corepack enable pnpm && pnpm install --frozen-lockfile # Copy application code +COPY tsconfig.json drizzle.config.ts ./ COPY src/ ./src/ +# Build TypeScript → dist/ +RUN pnpm build + +# Remove devDependencies after build +RUN pnpm prune --prod + # Expose port EXPOSE 8000 -# Run the application -CMD ["node", "src/app.js"] +# Run the compiled application +CMD ["node", "--conditions", "@zod/source", "dist/app.js"] diff --git a/dashboard/backend/drizzle.config.ts b/dashboard/backend/drizzle.config.ts new file mode 100644 index 0000000..ba8565c --- /dev/null +++ b/dashboard/backend/drizzle.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'drizzle-kit'; + +export default defineConfig({ + schema: './src/schemas/index.ts', + out: './drizzle', + dialect: 'mysql', + dbCredentials: { + host: process.env.DATABASE_HOST || 'localhost', + port: Number(process.env.DATABASE_PORT || 3306), + user: process.env.DATABASE_USER || 'root', + password: process.env.DATABASE_PASSWORD || '', + database: process.env.DATABASE_NAME || 'audio', + }, +}); diff --git a/dashboard/backend/package.json b/dashboard/backend/package.json index 99f8dd0..0153de9 100644 --- a/dashboard/backend/package.json +++ b/dashboard/backend/package.json @@ -3,26 +3,39 @@ "version": "1.0.0", "description": "耳机品牌与型号管理平台后端 API", "packageManager": "pnpm@11.5.2", - "main": "src/app.js", + "type": "module", + "main": "dist/app.js", "scripts": { - "start": "node src/app.js", - "dev": "nodemon src/app.js" + "dev": "tsx watch --conditions @zod/source src/app.ts", + "build": "tsc", + "start": "node --conditions @zod/source dist/app.js", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" }, "dependencies": { - "express": "^4.21", + "express": "^5.2", "cors": "^2.8", - "sequelize": "^6.37", - "mysql2": "^3.11", + "mysql2": "^3.23", + "drizzle-orm": "^0.45", "jsonwebtoken": "^9.0", - "multer": "^1.4", - "axios": "^1.7", - "dotenv": "^16.4", - "zod": "^3.23", - "@aws-sdk/client-s3": "^3.650", - "winston": "^3.14", - "ioredis": "^5.6" + "multer": "^2.2", + "axios": "^1.19", + "dotenv": "^17.4", + "zod": "^3.25", + "@aws-sdk/client-s3": "^3.1098", + "winston": "^3.19", + "ioredis": "^5.11" }, "devDependencies": { - "nodemon": "^3.1" + "tsx": "^4.23", + "typescript": "^7.0", + "drizzle-kit": "^0.31", + "@types/express": "^5.0", + "@types/cors": "^2.8", + "@types/jsonwebtoken": "^9.0", + "@types/multer": "^2.2", + "@types/node": "^22.0" } } diff --git a/dashboard/backend/pnpm-lock.yaml b/dashboard/backend/pnpm-lock.yaml index 813b74c..fc5ab76 100644 --- a/dashboard/backend/pnpm-lock.yaml +++ b/dashboard/backend/pnpm-lock.yaml @@ -9,149 +9,139 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: ^3.650 - version: 3.1063.0 + specifier: ^3.1098 + version: 3.1098.0 axios: - specifier: ^1.7 - version: 1.17.0(supports-color@5.5.0) + specifier: ^1.19 + version: 1.19.0 cors: specifier: ^2.8 version: 2.8.6 dotenv: - specifier: ^16.4 - version: 16.6.1 + specifier: ^17.4 + version: 17.4.2 + drizzle-orm: + specifier: ^0.45 + version: 0.45.2(mysql2@3.23.2(@types/node@22.20.1)) express: - specifier: ^4.21 - version: 4.22.2 + specifier: ^5.2 + version: 5.2.1 ioredis: - specifier: ^5.6 + specifier: ^5.11 version: 5.11.1 jsonwebtoken: specifier: ^9.0 version: 9.0.3 multer: - specifier: ^1.4 - version: 1.4.4 + specifier: ^2.2 + version: 2.2.0 mysql2: - specifier: ^3.11 - version: 3.22.5(@types/node@25.9.2) - sequelize: - specifier: ^6.37 - version: 6.37.8(mysql2@3.22.5(@types/node@25.9.2))(supports-color@5.5.0) + specifier: ^3.23 + version: 3.23.2(@types/node@22.20.1) winston: - specifier: ^3.14 + specifier: ^3.19 version: 3.19.0 zod: - specifier: ^3.23 - version: 3.25.76 + specifier: ^3.25 + version: 3.25.0 devDependencies: - nodemon: - specifier: ^3.1 - version: 3.1.14 + '@types/cors': + specifier: ^2.8 + version: 2.8.19 + '@types/express': + specifier: ^5.0 + version: 5.0.6 + '@types/jsonwebtoken': + specifier: ^9.0 + version: 9.0.10 + '@types/multer': + specifier: ^2.2 + version: 2.2.0 + '@types/node': + specifier: ^22.0 + version: 22.20.1 + drizzle-kit: + specifier: ^0.31 + version: 0.31.10 + tsx: + specifier: ^4.23 + version: 4.23.1 + typescript: + specifier: ^7.0 + version: 7.0.2 packages: - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} - - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} - - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} - - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} - - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} - - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} - - '@aws-sdk/checksums@3.1000.2': - resolution: {integrity: sha512-PIha+kauTbp6IRmOpYktPTrlfrrSqDVixvhO/EUOFOf62DPX81CaJoHJreuA1m9HYpSKyXf99BKjU1dvJPeUfw==} + '@aws-sdk/checksums@3.1000.23': + resolution: {integrity: sha512-Hv4VX5+IdDYmAbOdiwZguz8fLh3WnE4VtyJwVNEHLulA+OYy7Z5L0ETGUlX2T4g8DLO8XxCV8CyWrtVL4uwqkg==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1063.0': - resolution: {integrity: sha512-ETn+vvmZVK1MmOZwVBXmWANpmD5iTbzojIqyEIoZ86qo+8oWy35S8QyQNE/ZDI+WHgMU1dS+VSYbpRl1QkEySg==} + '@aws-sdk/client-s3@3.1098.0': + resolution: {integrity: sha512-6Z53QG2jdujsCt3eHO2cjCpdfvz2Pgs+hP/NINuGONZUJmdrHLLDvWM9F114Yq0NPIGtk+yA/GCjG3XiUhw+gA==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.974.18': - resolution: {integrity: sha512-JDYCPI0j7zGrzXTDFsLB346cxss7J/AxH7+O0MzWlqppJBEyB9Qe6TQXRL6iwLUo/xZkNv9KFmBL2hqElmwW0g==} + '@aws-sdk/core@3.977.3': + resolution: {integrity: sha512-6YZTpF5Zzl0KdSrztM0l6ErKdnXrnnodIDYfayHE9SFfZU8Ubxls7H2XnfWT121jgwiG+WP1z5kyfVDsH5V4qA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.44': - resolution: {integrity: sha512-3hKJVrZ7bqXzDAXCQp+OaQ1ASN+vWstaNuEH418wQVl//cRZhqhfR9Bjk1qIWmgUGe8/D3gdO73PgidRj378EQ==} + '@aws-sdk/credential-provider-env@3.972.64': + resolution: {integrity: sha512-14kkR5aj1c7D+cYCrEcG2W3xw87wMYAEUeB/tMiQ2j0RVKzzdewd4mJw1+Q0JVLr4IFU1JHGDCyj0WqLrtIixg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.46': - resolution: {integrity: sha512-VhwC9pGAZHhiQ2xSViyOPDFqvr9aRxGCAXZtADsUhU3R65nad7y//CwynE6mQnWNR+suRlqE79W36IVayL+m1g==} + '@aws-sdk/credential-provider-http@3.972.66': + resolution: {integrity: sha512-BrZxoXScBZ+nTOYy388zHkz1n5cS8C+uGFMozD4w9OKEWMq39Cu/9l/k385Hb54dtv0VIeh12f8h67o3xDNH9g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.50': - resolution: {integrity: sha512-09Xi6ovxiK42+De/qBGF71sT5F2bWgYM+1fFyDwSOpy1xpsQ5R/naIu7MVDpH6Dic36QNc8dAv4KADtMGK2JYg==} + '@aws-sdk/credential-provider-ini@3.973.9': + resolution: {integrity: sha512-d/mMvS+sQjSWNA6+JCldK1YB/jQmtoWM8Izj10mrOZdRCeWNVb8IYQuuyHHKKKXcpGo5Pd0LIK6onHbLvlhuVQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.49': - resolution: {integrity: sha512-EfJF/1Fh9mI4pZyoheU2RY9xUhTcugIZNkD63+orXMkYj/QXacJNbKVDUK90Yv5hE+aX+rt9J/EZ9Qr3vKOa7g==} + '@aws-sdk/credential-provider-login@3.972.71': + resolution: {integrity: sha512-l93922lXnTs5RjM3QrMCXSmXiEgLFLaQ0Lco9F3tJ08o0mzGdAj6m2nAXDuTMHoUuL0u7RKmrhm+Z8/7GCilyg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.52': - resolution: {integrity: sha512-7QX+PbyiWBEOVipJq8Nke/TqXT6lAPLE7fvTaopa39/IVWuLfS+Fzdy71sZJONf/mLGgmtj6aU17+REw3+aRrw==} + '@aws-sdk/credential-provider-node@3.972.75': + resolution: {integrity: sha512-sdFhR4E3HWZefGL6OsBhxqXdqtFvxGOh8VLfiu9yttPgVNcpEozEDPmrTYmXA3CIf6npUETVLveQPGQ7GBzVhg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.44': - resolution: {integrity: sha512-V+UUhZpRP7QDRhi+qgBDisM9tUBnYmMje8Bk77A6MZsfeGeGdMsQXmaHP1CDYFcept0o/Rz5g2Y0TMeVlG9dzg==} + '@aws-sdk/credential-provider-process@3.972.64': + resolution: {integrity: sha512-Q5noG5vfFo++bUaLGcjj8OHX5tmwa1O/5nDVMeaSSZgjzgvWbW6tgu/gSa+aENwcJXjoXDQh+8TRZR2zHds/aw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.49': - resolution: {integrity: sha512-9QqOYGuh5tZ76OzaT68kwI78AH+5lS/uZGGvkfxb3fc8FzRrIz2jOufNTliEBEeSAwmgK2rWLNsK+IB3zbtNPA==} + '@aws-sdk/credential-provider-sso@3.973.8': + resolution: {integrity: sha512-37okeZyRdVkGrU6nyKBPGqQvyl/7CEiMpUVOb0+BCNCIR0VAuBxNgjMb0mnsR1EXm8dqQFfQZOJ5YW/0sBfa1w==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.49': - resolution: {integrity: sha512-IYx1lN38MnnPXv+NBLpuATu0cZakbZ321TAfjW+aVkw7HIJF38YnEwdeEO55MSl3pl7hIX1IvvnD6EmnAzmAJw==} + '@aws-sdk/credential-provider-web-identity@3.972.70': + resolution: {integrity: sha512-Ps/f9USafScb6CSQTRPNMzdKL5x5XRwDIRgFvnuFWfClGZe7/fI7iCFqXdxKjc9LBxP28E7iUgNloGgfgw406w==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-flexible-checksums@3.974.27': - resolution: {integrity: sha512-bZqezPLdllFC4VAeV/f+EIc/hz56ab3TD/+4zNCgOgmG5ZHAE5dMHrX1gtTwdcQXbPr3KR7x3zTC3zuCTE6+ng==} + '@aws-sdk/middleware-sdk-s3@3.972.69': + resolution: {integrity: sha512-ziSQA8AkB+u4K/5t/SStFbeoiNTG04reUeddCLNCAgmdRJGQ3+MJcpZOXXLdD6uyfCqSK1/R6004gRdXvHbYQA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.48': - resolution: {integrity: sha512-MRTqx8wD/T3REt6LTT3/yN8rrp6+xIHrbUekkDYJTYWVch70mwtdJBovR4qKJz1jIPlbN+9R/Sn6R04BfsglzA==} + '@aws-sdk/nested-clients@3.997.38': + resolution: {integrity: sha512-jQDs+nxnmgo4+WPOmY373HIC8jx4KyYYMYDI74FWrzX3Ba9fYsV0dSA+7PNYNkhRbbRCRLre83tQ31F1ACCHkA==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.17': - resolution: {integrity: sha512-lDRgraoTfKRawUyc176Ow93mrNrOho/x+EoK4C+lKU+vKkHWhNhzvSMVAx0WEJUJoeQxxDN5ZdKMfiGEyNejig==} + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.32': - resolution: {integrity: sha512-llvApLcsWtmRFhG2wT3WIp1CmDeRaIYutqty1ZZXoMzK7TiJ6MOLOimk9eXUS8PwgG4ew4pa4QAbt0lfhn++1w==} + '@aws-sdk/token-providers@3.1098.0': + resolution: {integrity: sha512-6cFAviffeqYGjrXn4FqGbWcTxB3nbpBpQXUm6MvnrWZThJaVBTHHnespHgSUh4yVLfzhipSh9H/XjzkHQd9P4g==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1063.0': - resolution: {integrity: sha512-nYDaWWdzjKiDP5xj8k4oUgcYd4WPgzfAOgdU5vJsaqH/07Dfvm7ffisHCFJ+NEl7kUC9JEIUxh0kznvenbo3NQ==} + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.11': - resolution: {integrity: sha512-YjS0qFuECClRh4qhEyW8XagW0fwEPBeZ1cfsW/gU73Kh/ExFILxbzxOfPCmzF/2DwEvhvsHYt0b0qnvStwKYrg==} + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.6': - resolution: {integrity: sha512-ZfHjfwSzeXj+Lg9AK5ZNmeDkXev6V+w2tn1t4kgDdRtUaRCthepTQiFwbD06EF9oNGH4LaLg+Mb6U16Ypv5bSw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/xml-builder@3.972.28': - resolution: {integrity: sha512-lI/l3c/vPvsxmspzV63NfS3x9q4CkMmdhJy4QiM+NThAufVkDvi/PZZQ6xETnICL0UD7jI808pY83gllf86RFg==} - engines: {node: '>=20.0.0'} - - '@aws/lambda-invoke-store@0.2.4': - resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} '@colors/colors@1.6.0': @@ -161,84 +151,661 @@ packages: '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} + '@drizzle-team/brocli@0.10.2': + resolution: {integrity: sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==} + + '@esbuild-kit/core-utils@3.3.2': + resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild-kit/esm-loader@2.6.5': + resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} + deprecated: 'Merged into tsx: https://tsx.is' + + '@esbuild/aix-ppc64@0.25.4': + resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.18.20': + resolution: {integrity: sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.4': + resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.18.20': + resolution: {integrity: sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.4': + resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.18.20': + resolution: {integrity: sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.4': + resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.18.20': + resolution: {integrity: sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.4': + resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.18.20': + resolution: {integrity: sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.4': + resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.18.20': + resolution: {integrity: sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.4': + resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.18.20': + resolution: {integrity: sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.4': + resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.18.20': + resolution: {integrity: sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.4': + resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.18.20': + resolution: {integrity: sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.4': + resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.18.20': + resolution: {integrity: sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.4': + resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.18.20': + resolution: {integrity: sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.4': + resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.18.20': + resolution: {integrity: sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.4': + resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.18.20': + resolution: {integrity: sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.4': + resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.18.20': + resolution: {integrity: sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.4': + resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.18.20': + resolution: {integrity: sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.4': + resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.18.20': + resolution: {integrity: sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.4': + resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.4': + resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.18.20': + resolution: {integrity: sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.4': + resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.4': + resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.18.20': + resolution: {integrity: sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.4': + resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.18.20': + resolution: {integrity: sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.4': + resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.18.20': + resolution: {integrity: sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.4': + resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.18.20': + resolution: {integrity: sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.4': + resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.18.20': + resolution: {integrity: sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.4': + resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@ioredis/commands@1.10.0': resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} - '@nodable/entities@2.1.1': - resolution: {integrity: sha512-Pig3HxDIoMgjdEH8OCf/dkcTmLFjJRjWuq8jSnklu284/TKOPibSRERmOykiwmyXTtv61mP+44f3GMx0tLAyjg==} - - '@smithy/core@3.24.6': - resolution: {integrity: sha512-wBXDRup6UU97VKyaiRo8AssnfStPtG0oAAfpq/bC0a1YYau8pM86YB4kM6ccoVi1mS8l/UHbn9oDM+7uozr/ug==} + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.3.8': - resolution: {integrity: sha512-5cAM+KZC02sTqDt6NaLXyu50M/GNMd1eTzDVR8Lb0BBsVtu7RWHo47VPPEEv1vt3Yub6uzr+M5FHC+GtoT0USg==} + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.4.6': - resolution: {integrity: sha512-FEwEYJ1jlBKdhe9TPzfghEi1bP55ZeEImlDkEa62bBBYzUcnB6RUCyuiS2mqKt6ZVjUbBgcNhzfIctH+Hevx9g==} + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/node-http-handler@4.7.7': - resolution: {integrity: sha512-ZAFvHXrEk6K180EVhmZVg8GU5pUH5BSFqRs27JW3j1qEFx9YyYwWFx17x/MHcjALYimGAji7qEOlF1++be+G5A==} + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.4.6': - resolution: {integrity: sha512-Ojg4B6oIDlIr1R86xCDJt1zJWnYa0VINmqdjfe9qxWjdRivHalZ3iSlQgVqYbW0MdpFOC5XfHEWsnbmdnpIILQ==} + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.14.3': - resolution: {integrity: sha512-YupL0ZWmFtJexUN2cHzkvvF/b9pKrtAIfT1o7/oY/Ppu8IYeZ+lDPM5vZdQJaSeA132dJCqojjGC9NhXeF71VQ==} + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/express-serve-static-core@5.1.2': + resolution: {integrity: sha512-d3KvEXBSo/lOAMc2u6fkyDHBvetBHeqD7wm/AcXfLpSOQwlmG9D/aQ0SFswVjv05p7ullQS7Mjohj6/VdbZuTg==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + + '@types/jsonwebtoken@9.0.10': + resolution: {integrity: sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==} '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.9.2': - resolution: {integrity: sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==} + '@types/multer@2.2.0': + resolution: {integrity: sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/qs@6.15.1': + resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} '@types/triple-beam@1.3.5': resolution: {integrity: sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==} - '@types/validator@13.15.10': - resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] - accepts@1.3.8: - resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} - agent-base@6.0.2: - resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + agent-base@6.0.0: + resolution: {integrity: sha512-j1Q7cSCqN+AwrmDd+pzgqc0/NpC655x2bUf5ZjRIO77DcNBFmh+OgRNzF6OKdCC9RSCb19fGd99+bhXFdkRNqw==} engines: {node: '>= 6.0.0'} - anymatch@3.1.3: - resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} - engines: {node: '>= 8'} - append-field@1.0.0: resolution: {integrity: sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==} - array-flatten@1.1.1: - resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} - async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} @@ -249,41 +816,25 @@ packages: resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} engines: {node: '>= 6.0.0'} - axios@1.17.0: - resolution: {integrity: sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==} + axios@1.19.0: + resolution: {integrity: sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==} - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - - binary-extensions@2.3.0: - resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} - engines: {node: '>=8'} - - body-parser@1.20.5: - resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} bowser@2.14.1: resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==} - brace-expansion@5.0.6: - resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} - engines: {node: 18 || 20 || >=22} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} - busboy@0.2.14: - resolution: {integrity: sha512-InWFDomvlkEj+xWLBfU3AvnbVYqeTWmQopiW0tWWEy5yehYm2YkGEc59sUmw/4ty5Zj/b0WHGs1LgecuBSBGrg==} - engines: {node: '>=0.8.0'} + busboy@1.6.0: + resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} + engines: {node: '>=10.16.0'} bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} @@ -297,10 +848,6 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} - chokidar@3.6.0: - resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} - engines: {node: '>= 8.10.0'} - cluster-key-slot@1.1.1: resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} engines: {node: '>=0.10.0'} @@ -309,8 +856,8 @@ packages: resolution: {integrity: sha512-fasDH2ont2GqF5HpyO4w0+BcewlhHEZOFn9c1ckZdHpJ56Qb7MHhH/IcJZbBGgvdtwdwNbLvxiBEdg336iA9Sg==} engines: {node: '>=14.6'} - color-name@2.1.0: - resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + color-name@2.1.1: + resolution: {integrity: sha512-p2FdgwVx1a9yWBHP2wI0VgShkDpgN4kZISkxdNipGBJWpa5G6b04OINlVWCyJj0JmfvcPrgqt95E9k8yvaOJFg==} engines: {node: '>=12.20'} color-string@2.1.4: @@ -325,40 +872,34 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} - concat-stream@1.6.2: - resolution: {integrity: sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==} - engines: {'0': node >= 0.8} + concat-stream@2.0.0: + resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==} + engines: {'0': node >= 6.0} - content-disposition@0.5.4: - resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} - engines: {node: '>= 0.6'} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} content-type@1.0.5: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} - cookie-signature@1.0.7: - resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} - cookie@0.7.2: - resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.1: + resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==} engines: {node: '>= 0.6'} - core-util-is@1.0.3: - resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} - debug@2.6.9: - resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -380,21 +921,105 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} - destroy@1.2.0: - resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} - engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} - - dicer@0.2.5: - resolution: {integrity: sha512-FDvbtnq7dzlPz0wyYlOExifDEZcu8h+rErEXgfxqmLfRfC/kJidEFh4+effJRO3P0xmfqyPbSMG0LveNRfTKVg==} - engines: {node: '>=0.8.0'} - - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} - dottie@2.0.7: - resolution: {integrity: sha512-7lAK2A0b3zZr3UC5aE69CPdCFR4RHW1o2Dr74TqFykxkUCBXSRJum/yPc7g8zRHJqWKomPLHwFLLoUnn8PXXRg==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + drizzle-kit@0.31.10: + resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} + hasBin: true + + drizzle-orm@0.45.2: + resolution: {integrity: sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==} + peerDependencies: + '@aws-sdk/client-rds-data': '>=3' + '@cloudflare/workers-types': '>=4' + '@electric-sql/pglite': '>=0.2.0' + '@libsql/client': '>=0.10.0' + '@libsql/client-wasm': '>=0.10.0' + '@neondatabase/serverless': '>=0.10.0' + '@op-engineering/op-sqlite': '>=2' + '@opentelemetry/api': ^1.4.1 + '@planetscale/database': '>=1.13' + '@prisma/client': '*' + '@tidbcloud/serverless': '*' + '@types/better-sqlite3': '*' + '@types/pg': '*' + '@types/sql.js': '*' + '@upstash/redis': '>=1.34.7' + '@vercel/postgres': '>=0.8.0' + '@xata.io/client': '*' + better-sqlite3: '>=7' + bun-types: '*' + expo-sqlite: '>=14.0.0' + gel: '>=2' + knex: '*' + kysely: '*' + mysql2: '>=2' + pg: '>=8' + postgres: '>=3' + prisma: '*' + sql.js: '>=1' + sqlite3: '>=5' + peerDependenciesMeta: + '@aws-sdk/client-rds-data': + optional: true + '@cloudflare/workers-types': + optional: true + '@electric-sql/pglite': + optional: true + '@libsql/client': + optional: true + '@libsql/client-wasm': + optional: true + '@neondatabase/serverless': + optional: true + '@op-engineering/op-sqlite': + optional: true + '@opentelemetry/api': + optional: true + '@planetscale/database': + optional: true + '@prisma/client': + optional: true + '@tidbcloud/serverless': + optional: true + '@types/better-sqlite3': + optional: true + '@types/pg': + optional: true + '@types/sql.js': + optional: true + '@upstash/redis': + optional: true + '@vercel/postgres': + optional: true + '@xata.io/client': + optional: true + better-sqlite3: + optional: true + bun-types: + optional: true + expo-sqlite: + optional: true + gel: + optional: true + knex: + optional: true + kysely: + optional: true + mysql2: + optional: true + pg: + optional: true + postgres: + optional: true + prisma: + optional: true + sql.js: + optional: true + sqlite3: + optional: true dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} @@ -429,6 +1054,21 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} + esbuild@0.18.20: + resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.4: + resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -436,27 +1076,16 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - express@4.22.2: - resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==} - engines: {node: '>= 0.10.0'} - - fast-xml-builder@1.2.0: - resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} - - fast-xml-parser@5.7.3: - resolution: {integrity: sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==} - hasBin: true + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} fecha@4.2.3: resolution: {integrity: sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==} - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - finalhandler@1.3.2: - resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} - engines: {node: '>= 0.8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} fn.name@1.1.0: resolution: {integrity: sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==} @@ -470,17 +1099,17 @@ packages: debug: optional: true - form-data@4.0.5: - resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} - fresh@0.5.2: - resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} - engines: {node: '>= 0.6'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} @@ -501,18 +1130,13 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - has-flag@3.0.0: - resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} - engines: {node: '>=4'} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -533,21 +1157,10 @@ packages: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - iconv-lite@0.4.24: - resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} - iconv-lite@0.7.2: - resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} - engines: {node: '>=0.10.0'} - - ignore-by-default@1.0.1: - resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} - - inflection@1.13.4: - resolution: {integrity: sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==} - engines: {'0': node >= 0.4.0} - inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -559,21 +1172,8 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} - is-binary-path@2.1.0: - resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} - engines: {node: '>=8'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} is-property@1.0.2: resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} @@ -582,12 +1182,6 @@ packages: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} - isarray@0.0.1: - resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} - - isarray@1.0.0: - resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -622,9 +1216,6 @@ packages: lodash.once@4.1.1: resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} - lodash@4.18.1: - resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} - logform@2.7.0: resolution: {integrity: sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ==} engines: {node: '>= 12.0.0'} @@ -644,56 +1235,47 @@ packages: resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} engines: {node: '>= 0.6'} - merge-descriptors@1.0.3: - resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + media-typer@1.1.1: + resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==} + engines: {node: '>= 0.8'} - methods@1.1.2: - resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.40.0: + resolution: {integrity: sha512-jYdeOMPy9vnxEqFRRo6ZvTZ8d9oPb+k18PKoYNYUe2stVEBPPwsln/qWzdbmaIvnhZ9v2P+CuecK+fpUfsV2mA==} engines: {node: '>= 0.6'} mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.24: + resolution: {integrity: sha512-WaFHS3MCl5fapm3oLxU4eYDw77IQM2ACcxQ9RIxfaC3ooc6PFuBMGZZsYpvoXS5D5QTWPieo1jjLdAm3TBP3cQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - mime@1.6.0: - resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} - engines: {node: '>=4'} - hasBin: true - - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} - engines: {node: 18 || 20 || >=22} - - minimist@1.2.8: - resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - - mkdirp@0.5.6: - resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} - hasBin: true - - moment-timezone@0.5.48: - resolution: {integrity: sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==} - - moment@2.30.1: - resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} - - ms@2.0.0: - resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - multer@1.4.4: - resolution: {integrity: sha512-2wY2+xD4udX612aMqMcB8Ws2Voq6NIUPEtD1be6m411T4uDH/VtL9i//xvcyFlTVfRdaBsk7hV5tgrGQqhuBiw==} - engines: {node: '>= 0.10.0'} - deprecated: Multer 1.x is affected by CVE-2022-24434. This is fixed in v1.4.4-lts.1 which drops support for versions of Node.js before 6. Please upgrade to at least Node.js 6 and version 1.4.4-lts.1 of Multer. If you need support for older versions of Node.js, we are open to accepting patches that would fix the CVE on the main 1.x release line, whilst maintaining compatibility with Node.js 0.10. + multer@2.2.0: + resolution: {integrity: sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==} + engines: {node: '>= 10.16.0'} - mysql2@3.22.5: - resolution: {integrity: sha512-95uZ2TrPWAZdwpB3vvvDbmEMcNG8yIeNCyu6GUcr/QnWEE/wXm7+mhOCsdQfWQDTV7qYT/PDUZ4U4UPP4AsXqQ==} + mysql2@3.23.2: + resolution: {integrity: sha512-fxh3HpQ8vJtu/Mmnd4Xsur19jGjHGzRLMxptiDtOkbX7EVBgnafGSGDx1WGGVmJLClVh2LeeBMMo24IFv8wCyQ==} engines: {node: '>= 8.0'} peerDependencies: '@types/node': '>= 8' @@ -702,19 +1284,10 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} - negotiator@0.6.3: - resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} - nodemon@3.1.14: - resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} - engines: {node: '>=10'} - hasBin: true - - normalize-path@3.0.0: - resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} - engines: {node: '>=0.10.0'} - object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -727,6 +1300,9 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + one-time@1.0.0: resolution: {integrity: sha512-5DXOiRKwuSEcQ/l0kGCF6Q3jcADFv5tSmRaJck/OqkVFcOzutB134KRSfF0xDrL39MNnqxbHBbUUcjZIhTgb2g==} @@ -734,22 +1310,8 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - path-expression-matcher@1.5.0: - resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==} - engines: {node: '>=14.0.0'} - - path-to-regexp@0.1.13: - resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==} - - pg-connection-string@2.13.0: - resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} - - picomatch@2.3.2: - resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} - engines: {node: '>=8.6'} - - process-nextick-args@2.0.1: - resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} @@ -759,35 +1321,22 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} - pstree.remy@1.1.8: - resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} - - qs@6.15.2: - resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} engines: {node: '>=0.6'} - range-parser@1.2.1: - resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} engines: {node: '>= 0.6'} - raw-body@2.5.3: - resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==} - engines: {node: '>= 0.8'} - - readable-stream@1.1.14: - resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} - - readable-stream@2.3.8: - resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} - readdirp@3.6.0: - resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} - engines: {node: '>=8.10.0'} - redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -796,11 +1345,12 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} - retry-as-promised@7.1.1: - resolution: {integrity: sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw==} + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - safe-buffer@5.1.2: - resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -812,55 +1362,18 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} - semver@7.8.2: - resolution: {integrity: sha512-c8jsqUZm3omBOI66G90z1Dyw5z622G8oLG+omfsHBJf3CWQTlOcwOjvOG6wtiNfW6anKm/eA39LMwMtMez2TiQ==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} hasBin: true - send@0.19.2: - resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} - engines: {node: '>= 0.8.0'} + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} - sequelize-pool@7.1.0: - resolution: {integrity: sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==} - engines: {node: '>= 10.0.0'} - - sequelize@6.37.8: - resolution: {integrity: sha512-HJ0IQFqcTsTiqbEgiuioYFMSD00TP6Cz7zoTti+zVVBwVe9fEhev9cH6WnM3XU31+ABS356durAb99ZuOthnKw==} - engines: {node: '>=10.0.0'} - peerDependencies: - ibm_db: '*' - mariadb: '*' - mysql2: '*' - oracledb: '*' - pg: '*' - pg-hstore: '*' - snowflake-sdk: '*' - sqlite3: '*' - tedious: '*' - peerDependenciesMeta: - ibm_db: - optional: true - mariadb: - optional: true - mysql2: - optional: true - oracledb: - optional: true - pg: - optional: true - pg-hstore: - optional: true - snowflake-sdk: - optional: true - sqlite3: - optional: true - tedious: - optional: true - - serve-static@1.16.3: - resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} - engines: {node: '>= 0.8.0'} + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -877,20 +1390,23 @@ packages: resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} engines: {node: '>= 0.4'} - side-channel@1.1.0: - resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} engines: {node: '>= 0.4'} - simple-update-notifier@2.0.0: - resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} - engines: {node: '>=10'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} - sql-escaper@1.3.3: - resolution: {integrity: sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==} + source-map@0.6.0: + resolution: {integrity: sha512-mTozplhTX4tLKIHYji92OTZzVyZvi+Z1qRZDeBvQFI2XUB89wrRoj/xXad3c9NZ1GPJXXRvB+k41PQCPTMC+aA==} + engines: {node: '>=0.10.0'} + + sql-escaper@1.5.1: + resolution: {integrity: sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==} engines: {bun: '>=1.0.0', deno: '>=2.0.0', node: '>=12.0.0'} - stack-trace@0.0.10: - resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stack-trace@0.0.1: + resolution: {integrity: sha512-kbtm51VmeqPJDUMJKDAOfmDGKuC1OaLGpKlmXHUj7b0JyNpdxBblyRV9m/6Tdn+Xwh/t5z+5R72HaCvEZrDAYg==} standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} @@ -899,44 +1415,20 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - streamsearch@0.1.2: - resolution: {integrity: sha512-jos8u++JKm0ARcSUTAZXOVC0mSox7Bhn6sBgty73P1f3JGf7yG2clTbBNHUdde/kdvP2FESam+vM6l8jBrNxHA==} - engines: {node: '>=0.8.0'} - - string_decoder@0.10.31: - resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} - - string_decoder@1.1.1: - resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + streamsearch@1.1.0: + resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} + engines: {node: '>=10.0.0'} string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strnum@2.3.0: - resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} - - supports-color@5.5.0: - resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} - engines: {node: '>=4'} - text-hex@1.0.0: resolution: {integrity: sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==} - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - toposort-class@1.0.1: - resolution: {integrity: sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg==} - - touch@3.1.1: - resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} - hasBin: true - triple-beam@1.4.1: resolution: {integrity: sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==} engines: {node: '>= 14.0.0'} @@ -944,18 +1436,29 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typedarray@0.0.6: resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==} - undefsafe@2.0.5: - resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -964,19 +1467,6 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - utils-merge@1.0.1: - resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} - engines: {node: '>= 0.4.0'} - - uuid@8.3.2: - resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). - hasBin: true - - validator@13.15.35: - resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} - engines: {node: '>= 0.10'} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -989,251 +1479,178 @@ packages: resolution: {integrity: sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==} engines: {node: '>= 12.0.0'} - wkx@0.5.0: - resolution: {integrity: sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - xml-naming@0.1.0: - resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==} - engines: {node: '>=16.0.0'} - - xtend@4.0.2: - resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} - engines: {node: '>=0.4'} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@3.25.0: + resolution: {integrity: sha512-ficnZKUW0mlNivqeJkosTEkGbJ6NKCtSaOHGx5aXbtfeWMdRyzXLbAIn19my4C/KB7WPY/p9vlGPt+qpOp6c4Q==} snapshots: - '@aws-crypto/crc32@5.2.0': + '@aws-sdk/checksums@3.1000.23': dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/crc32c@5.2.0': + '@aws-sdk/client-s3@3.1098.0': dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 + '@aws-sdk/checksums': 3.1000.23 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/credential-provider-node': 3.972.75 + '@aws-sdk/middleware-sdk-s3': 3.972.69 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/sha1-browser@5.2.0': + '@aws-sdk/core@3.977.3': dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 - '@aws-sdk/util-locate-window': 3.965.6 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 - '@aws-sdk/util-locate-window': 3.965.6 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-crypto/sha256-js@5.2.0': - dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.11 - tslib: 2.8.1 - - '@aws-crypto/supports-web-crypto@5.2.0': - dependencies: - tslib: 2.8.1 - - '@aws-crypto/util@5.2.0': - dependencies: - '@aws-sdk/types': 3.973.11 - '@smithy/util-utf8': 2.3.0 - tslib: 2.8.1 - - '@aws-sdk/checksums@3.1000.2': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.974.18 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/client-s3@3.1063.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.18 - '@aws-sdk/credential-provider-node': 3.972.52 - '@aws-sdk/middleware-flexible-checksums': 3.974.27 - '@aws-sdk/middleware-sdk-s3': 3.972.48 - '@aws-sdk/signature-v4-multi-region': 3.996.32 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/fetch-http-handler': 5.4.6 - '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@aws-sdk/core@3.974.18': - dependencies: - '@aws-sdk/types': 3.973.11 - '@aws-sdk/xml-builder': 3.972.28 - '@aws/lambda-invoke-store': 0.2.4 - '@smithy/core': 3.24.6 - '@smithy/signature-v4': 5.4.6 - '@smithy/types': 4.14.3 + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.44': + '@aws-sdk/credential-provider-env@3.972.64': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.46': + '@aws-sdk/credential-provider-http@3.972.66': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/fetch-http-handler': 5.4.6 - '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.50': + '@aws-sdk/credential-provider-ini@3.973.9': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/credential-provider-env': 3.972.44 - '@aws-sdk/credential-provider-http': 3.972.46 - '@aws-sdk/credential-provider-login': 3.972.49 - '@aws-sdk/credential-provider-process': 3.972.44 - '@aws-sdk/credential-provider-sso': 3.972.49 - '@aws-sdk/credential-provider-web-identity': 3.972.49 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/credential-provider-imds': 4.3.8 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/credential-provider-env': 3.972.64 + '@aws-sdk/credential-provider-http': 3.972.66 + '@aws-sdk/credential-provider-login': 3.972.71 + '@aws-sdk/credential-provider-process': 3.972.64 + '@aws-sdk/credential-provider-sso': 3.973.8 + '@aws-sdk/credential-provider-web-identity': 3.972.70 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.49': + '@aws-sdk/credential-provider-login@3.972.71': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.52': + '@aws-sdk/credential-provider-node@3.972.75': dependencies: - '@aws-sdk/credential-provider-env': 3.972.44 - '@aws-sdk/credential-provider-http': 3.972.46 - '@aws-sdk/credential-provider-ini': 3.972.50 - '@aws-sdk/credential-provider-process': 3.972.44 - '@aws-sdk/credential-provider-sso': 3.972.49 - '@aws-sdk/credential-provider-web-identity': 3.972.49 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/credential-provider-imds': 4.3.8 - '@smithy/types': 4.14.3 + '@aws-sdk/credential-provider-env': 3.972.64 + '@aws-sdk/credential-provider-http': 3.972.66 + '@aws-sdk/credential-provider-ini': 3.973.9 + '@aws-sdk/credential-provider-process': 3.972.64 + '@aws-sdk/credential-provider-sso': 3.973.8 + '@aws-sdk/credential-provider-web-identity': 3.972.70 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.44': + '@aws-sdk/credential-provider-process@3.972.64': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.49': + '@aws-sdk/credential-provider-sso@3.973.8': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/token-providers': 3.1063.0 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/token-providers': 3.1098.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.49': + '@aws-sdk/credential-provider-web-identity@3.972.70': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-flexible-checksums@3.974.27': + '@aws-sdk/middleware-sdk-s3@3.972.69': dependencies: - '@aws-sdk/checksums': 3.1000.2 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.48': + '@aws-sdk/nested-clients@3.997.38': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/signature-v4-multi-region': 3.996.32 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.17': + '@aws-sdk/signature-v4-multi-region@3.996.43': dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.974.18 - '@aws-sdk/signature-v4-multi-region': 3.996.32 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/fetch-http-handler': 5.4.6 - '@smithy/node-http-handler': 4.7.7 - '@smithy/types': 4.14.3 + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.32': + '@aws-sdk/token-providers@3.1098.0': dependencies: - '@aws-sdk/types': 3.973.11 - '@smithy/signature-v4': 5.4.6 - '@smithy/types': 4.14.3 + '@aws-sdk/core': 3.977.3 + '@aws-sdk/nested-clients': 3.997.38 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1063.0': + '@aws-sdk/types@3.974.2': dependencies: - '@aws-sdk/core': 3.974.18 - '@aws-sdk/nested-clients': 3.997.17 - '@aws-sdk/types': 3.973.11 - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/types@3.973.11': + '@aws-sdk/xml-builder@3.972.37': dependencies: - '@smithy/types': 4.14.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.6': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/xml-builder@3.972.28': - dependencies: - '@smithy/types': 4.14.3 - fast-xml-parser: 5.7.3 - tslib: 2.8.1 - - '@aws/lambda-invoke-store@0.2.4': {} + '@aws/lambda-invoke-store@0.3.0': {} '@colors/colors@1.6.0': {} @@ -1243,56 +1660,270 @@ snapshots: enabled: 2.0.0 kuler: 2.0.0 + '@drizzle-team/brocli@0.10.2': {} + + '@esbuild-kit/core-utils@3.3.2': + dependencies: + esbuild: 0.18.20 + source-map-support: 0.5.21 + + '@esbuild-kit/esm-loader@2.6.5': + dependencies: + '@esbuild-kit/core-utils': 3.3.2 + get-tsconfig: 4.14.0 + + '@esbuild/aix-ppc64@0.25.4': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.18.20': + optional: true + + '@esbuild/android-arm64@0.25.4': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.18.20': + optional: true + + '@esbuild/android-arm@0.25.4': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.18.20': + optional: true + + '@esbuild/android-x64@0.25.4': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.18.20': + optional: true + + '@esbuild/darwin-arm64@0.25.4': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.18.20': + optional: true + + '@esbuild/darwin-x64@0.25.4': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.18.20': + optional: true + + '@esbuild/freebsd-arm64@0.25.4': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.18.20': + optional: true + + '@esbuild/freebsd-x64@0.25.4': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.18.20': + optional: true + + '@esbuild/linux-arm64@0.25.4': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.18.20': + optional: true + + '@esbuild/linux-arm@0.25.4': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.18.20': + optional: true + + '@esbuild/linux-ia32@0.25.4': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.18.20': + optional: true + + '@esbuild/linux-loong64@0.25.4': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.18.20': + optional: true + + '@esbuild/linux-mips64el@0.25.4': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.18.20': + optional: true + + '@esbuild/linux-ppc64@0.25.4': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.18.20': + optional: true + + '@esbuild/linux-riscv64@0.25.4': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.18.20': + optional: true + + '@esbuild/linux-s390x@0.25.4': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.18.20': + optional: true + + '@esbuild/linux-x64@0.25.4': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.4': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.18.20': + optional: true + + '@esbuild/netbsd-x64@0.25.4': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.4': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.18.20': + optional: true + + '@esbuild/openbsd-x64@0.25.4': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.18.20': + optional: true + + '@esbuild/sunos-x64@0.25.4': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.18.20': + optional: true + + '@esbuild/win32-arm64@0.25.4': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.18.20': + optional: true + + '@esbuild/win32-ia32@0.25.4': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.18.20': + optional: true + + '@esbuild/win32-x64@0.25.4': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + '@ioredis/commands@1.10.0': {} - '@nodable/entities@2.1.1': {} - - '@smithy/core@3.24.6': + '@smithy/core@3.31.1': dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.14.3 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/credential-provider-imds@4.3.8': + '@smithy/credential-provider-imds@4.4.16': dependencies: - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.4.6': + '@smithy/fetch-http-handler@5.6.13': dependencies: - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/is-array-buffer@2.2.0': + '@smithy/node-http-handler@4.9.13': dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/node-http-handler@4.7.7': + '@smithy/signature-v4@5.6.12': dependencies: - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/signature-v4@5.4.6': + '@smithy/types@4.16.1': dependencies: - '@smithy/core': 3.24.6 - '@smithy/types': 4.14.3 - tslib: 2.8.1 - - '@smithy/types@4.14.3': - dependencies: - tslib: 2.8.1 - - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-utf8@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 '@so-ric/colorspace@1.1.6': @@ -1300,95 +1931,176 @@ snapshots: color: 5.0.3 text-hex: 1.0.0 - '@types/debug@4.1.13': + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.20.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.20.1 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.20.1 + + '@types/express-serve-static-core@5.1.2': + dependencies: + '@types/node': 22.20.1 + '@types/qs': 6.15.1 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.2 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + + '@types/jsonwebtoken@9.0.10': dependencies: '@types/ms': 2.1.0 + '@types/node': 22.20.1 '@types/ms@2.1.0': {} - '@types/node@25.9.2': + '@types/multer@2.2.0': dependencies: - undici-types: 7.24.6 + '@types/express': 5.0.6 + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/qs@6.15.1': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.20.1 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.20.1 '@types/triple-beam@1.3.5': {} - '@types/validator@13.15.10': {} + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true - accepts@1.3.8: - dependencies: - mime-types: 2.1.35 - negotiator: 0.6.3 + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true - agent-base@6.0.2(supports-color@5.5.0): + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + accepts@2.0.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + mime-types: 3.0.2 + negotiator: 1.0.0 + + agent-base@6.0.0: + dependencies: + debug: 4.4.3 transitivePeerDependencies: - supports-color - anymatch@3.1.3: - dependencies: - normalize-path: 3.0.0 - picomatch: 2.3.2 - append-field@1.0.0: {} - array-flatten@1.1.1: {} - async@3.2.6: {} asynckit@0.4.0: {} aws-ssl-profiles@1.1.2: {} - axios@1.17.0(supports-color@5.5.0): + axios@1.19.0: dependencies: follow-redirects: 1.16.0 - form-data: 4.0.5 - https-proxy-agent: 5.0.1(supports-color@5.5.0) + form-data: 4.0.6 + https-proxy-agent: 5.0.1 proxy-from-env: 2.1.0 transitivePeerDependencies: - debug - supports-color - balanced-match@4.0.4: {} - - binary-extensions@2.3.0: {} - - body-parser@1.20.5: + body-parser@2.3.0: dependencies: bytes: 3.1.2 - content-type: 1.0.5 - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 + content-type: 2.0.0 + debug: 4.4.3 http-errors: 2.0.1 - iconv-lite: 0.4.24 + iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.2 - raw-body: 2.5.3 - type-is: 1.6.18 - unpipe: 1.0.0 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 transitivePeerDependencies: - supports-color bowser@2.14.1: {} - brace-expansion@5.0.6: - dependencies: - balanced-match: 4.0.4 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - buffer-equal-constant-time@1.0.1: {} buffer-from@1.1.2: {} - busboy@0.2.14: + busboy@1.6.0: dependencies: - dicer: 0.2.5 - readable-stream: 1.1.14 + streamsearch: 1.1.0 bytes@3.1.2: {} @@ -1402,29 +2114,17 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 - chokidar@3.6.0: - dependencies: - anymatch: 3.1.3 - braces: 3.0.3 - glob-parent: 5.1.2 - is-binary-path: 2.1.0 - is-glob: 4.0.3 - normalize-path: 3.0.0 - readdirp: 3.6.0 - optionalDependencies: - fsevents: 2.3.3 - cluster-key-slot@1.1.1: {} color-convert@3.1.3: dependencies: - color-name: 2.1.0 + color-name: 2.1.1 - color-name@2.1.0: {} + color-name@2.1.1: {} color-string@2.1.4: dependencies: - color-name: 2.1.0 + color-name: 2.1.1 color@5.0.3: dependencies: @@ -1435,39 +2135,31 @@ snapshots: dependencies: delayed-stream: 1.0.0 - concat-stream@1.6.2: + concat-stream@2.0.0: dependencies: buffer-from: 1.1.2 inherits: 2.0.4 - readable-stream: 2.3.8 + readable-stream: 3.6.2 typedarray: 0.0.6 - content-disposition@0.5.4: - dependencies: - safe-buffer: 5.2.1 + content-disposition@1.1.0: {} content-type@1.0.5: {} - cookie-signature@1.0.7: {} + content-type@2.0.0: {} - cookie@0.7.2: {} + cookie-signature@1.2.2: {} - core-util-is@1.0.3: {} + cookie@0.7.1: {} cors@2.8.6: dependencies: object-assign: 4.1.1 vary: 1.1.2 - debug@2.6.9: - dependencies: - ms: 2.0.0 - - debug@4.4.3(supports-color@5.5.0): + debug@4.4.3: dependencies: ms: 2.1.3 - optionalDependencies: - supports-color: 5.5.0 delayed-stream@1.0.0: {} @@ -1475,16 +2167,18 @@ snapshots: depd@2.0.0: {} - destroy@1.2.0: {} + dotenv@17.4.2: {} - dicer@0.2.5: + drizzle-kit@0.31.10: dependencies: - readable-stream: 1.1.14 - streamsearch: 0.1.2 + '@drizzle-team/brocli': 0.10.2 + '@esbuild-kit/esm-loader': 2.6.5 + esbuild: 0.25.4 + tsx: 4.23.1 - dotenv@16.6.1: {} - - dottie@2.0.7: {} + drizzle-orm@0.45.2(mysql2@3.23.2(@types/node@22.20.1)): + optionalDependencies: + mysql2: 3.23.2(@types/node@22.20.1) dunder-proto@1.0.1: dependencies: @@ -1517,73 +2211,135 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.4 + esbuild@0.18.20: + optionalDependencies: + '@esbuild/android-arm': 0.18.20 + '@esbuild/android-arm64': 0.18.20 + '@esbuild/android-x64': 0.18.20 + '@esbuild/darwin-arm64': 0.18.20 + '@esbuild/darwin-x64': 0.18.20 + '@esbuild/freebsd-arm64': 0.18.20 + '@esbuild/freebsd-x64': 0.18.20 + '@esbuild/linux-arm': 0.18.20 + '@esbuild/linux-arm64': 0.18.20 + '@esbuild/linux-ia32': 0.18.20 + '@esbuild/linux-loong64': 0.18.20 + '@esbuild/linux-mips64el': 0.18.20 + '@esbuild/linux-ppc64': 0.18.20 + '@esbuild/linux-riscv64': 0.18.20 + '@esbuild/linux-s390x': 0.18.20 + '@esbuild/linux-x64': 0.18.20 + '@esbuild/netbsd-x64': 0.18.20 + '@esbuild/openbsd-x64': 0.18.20 + '@esbuild/sunos-x64': 0.18.20 + '@esbuild/win32-arm64': 0.18.20 + '@esbuild/win32-ia32': 0.18.20 + '@esbuild/win32-x64': 0.18.20 + + esbuild@0.25.4: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.4 + '@esbuild/android-arm': 0.25.4 + '@esbuild/android-arm64': 0.25.4 + '@esbuild/android-x64': 0.25.4 + '@esbuild/darwin-arm64': 0.25.4 + '@esbuild/darwin-x64': 0.25.4 + '@esbuild/freebsd-arm64': 0.25.4 + '@esbuild/freebsd-x64': 0.25.4 + '@esbuild/linux-arm': 0.25.4 + '@esbuild/linux-arm64': 0.25.4 + '@esbuild/linux-ia32': 0.25.4 + '@esbuild/linux-loong64': 0.25.4 + '@esbuild/linux-mips64el': 0.25.4 + '@esbuild/linux-ppc64': 0.25.4 + '@esbuild/linux-riscv64': 0.25.4 + '@esbuild/linux-s390x': 0.25.4 + '@esbuild/linux-x64': 0.25.4 + '@esbuild/netbsd-arm64': 0.25.4 + '@esbuild/netbsd-x64': 0.25.4 + '@esbuild/openbsd-arm64': 0.25.4 + '@esbuild/openbsd-x64': 0.25.4 + '@esbuild/sunos-x64': 0.25.4 + '@esbuild/win32-arm64': 0.25.4 + '@esbuild/win32-ia32': 0.25.4 + '@esbuild/win32-x64': 0.25.4 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + escape-html@1.0.3: {} etag@1.8.1: {} - express@4.22.2: + express@5.2.1: dependencies: - accepts: 1.3.8 - array-flatten: 1.1.1 - body-parser: 1.20.5 - content-disposition: 0.5.4 + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 content-type: 1.0.5 - cookie: 0.7.2 - cookie-signature: 1.0.7 - debug: 2.6.9 + cookie: 0.7.1 + cookie-signature: 1.2.2 + debug: 4.4.3 depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 1.3.2 - fresh: 0.5.2 + finalhandler: 2.1.1 + fresh: 2.0.0 http-errors: 2.0.1 - merge-descriptors: 1.0.3 - methods: 1.1.2 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 on-finished: 2.4.1 + once: 1.4.0 parseurl: 1.3.3 - path-to-regexp: 0.1.13 proxy-addr: 2.0.7 - qs: 6.15.2 - range-parser: 1.2.1 - safe-buffer: 5.2.1 - send: 0.19.2 - serve-static: 1.16.3 - setprototypeof: 1.2.0 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 statuses: 2.0.2 - type-is: 1.6.18 - utils-merge: 1.0.1 + type-is: 2.1.0 vary: 1.1.2 transitivePeerDependencies: - supports-color - fast-xml-builder@1.2.0: - dependencies: - path-expression-matcher: 1.5.0 - xml-naming: 0.1.0 - - fast-xml-parser@5.7.3: - dependencies: - '@nodable/entities': 2.1.1 - fast-xml-builder: 1.2.0 - path-expression-matcher: 1.5.0 - strnum: 2.3.0 - fecha@4.2.3: {} - fill-range@7.1.1: + finalhandler@2.1.1: dependencies: - to-regex-range: 5.0.1 - - finalhandler@1.3.2: - dependencies: - debug: 2.6.9 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 parseurl: 1.3.3 statuses: 2.0.2 - unpipe: 1.0.0 transitivePeerDependencies: - supports-color @@ -1591,7 +2347,7 @@ snapshots: follow-redirects@1.16.0: {} - form-data@4.0.5: + form-data@4.0.6: dependencies: asynckit: 0.4.0 combined-stream: 1.0.8 @@ -1601,7 +2357,7 @@ snapshots: forwarded@0.2.0: {} - fresh@0.5.2: {} + fresh@2.0.0: {} fsevents@2.3.3: optional: true @@ -1630,14 +2386,12 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 - glob-parent@5.1.2: + get-tsconfig@4.14.0: dependencies: - is-glob: 4.0.3 + resolve-pkg-maps: 1.0.0 gopd@1.2.0: {} - has-flag@3.0.0: {} - has-symbols@1.1.0: {} has-tostringtag@1.0.2: @@ -1656,32 +2410,24 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 - https-proxy-agent@5.0.1(supports-color@5.5.0): + https-proxy-agent@5.0.1: dependencies: - agent-base: 6.0.2(supports-color@5.5.0) - debug: 4.4.3(supports-color@5.5.0) + agent-base: 6.0.0 + debug: 4.4.3 transitivePeerDependencies: - supports-color - iconv-lite@0.4.24: + iconv-lite@0.7.3: dependencies: safer-buffer: 2.1.2 - iconv-lite@0.7.2: - dependencies: - safer-buffer: 2.1.2 - - ignore-by-default@1.0.1: {} - - inflection@1.13.4: {} - inherits@2.0.4: {} ioredis@5.11.1: dependencies: '@ioredis/commands': 1.10.0 cluster-key-slot: 1.1.1 - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 denque: 2.1.0 redis-errors: 1.2.0 redis-parser: 3.0.0 @@ -1691,26 +2437,12 @@ snapshots: ipaddr.js@1.9.1: {} - is-binary-path@2.1.0: - dependencies: - binary-extensions: 2.3.0 - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} + is-promise@4.0.0: {} is-property@1.0.2: {} is-stream@2.0.1: {} - isarray@0.0.1: {} - - isarray@1.0.0: {} - jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -1722,7 +2454,7 @@ snapshots: lodash.isstring: 4.0.1 lodash.once: 4.1.1 ms: 2.1.3 - semver: 7.8.2 + semver: 7.8.5 jwa@2.0.1: dependencies: @@ -1751,8 +2483,6 @@ snapshots: lodash.once@4.1.1: {} - lodash@4.18.1: {} - logform@2.7.0: dependencies: '@colors/colors': 1.6.0 @@ -1770,81 +2500,54 @@ snapshots: media-typer@0.3.0: {} - merge-descriptors@1.0.3: {} + media-typer@1.1.1: {} - methods@1.1.2: {} + merge-descriptors@2.0.0: {} + + mime-db@1.40.0: {} mime-db@1.52.0: {} + mime-db@1.54.0: {} + + mime-types@2.1.24: + dependencies: + mime-db: 1.40.0 + mime-types@2.1.35: dependencies: mime-db: 1.52.0 - mime@1.6.0: {} - - minimatch@10.2.5: + mime-types@3.0.2: dependencies: - brace-expansion: 5.0.6 - - minimist@1.2.8: {} - - mkdirp@0.5.6: - dependencies: - minimist: 1.2.8 - - moment-timezone@0.5.48: - dependencies: - moment: 2.30.1 - - moment@2.30.1: {} - - ms@2.0.0: {} + mime-db: 1.54.0 ms@2.1.3: {} - multer@1.4.4: + multer@2.2.0: dependencies: append-field: 1.0.0 - busboy: 0.2.14 - concat-stream: 1.6.2 - mkdirp: 0.5.6 - object-assign: 4.1.1 - on-finished: 2.4.1 + busboy: 1.6.0 + concat-stream: 2.0.0 type-is: 1.6.18 - xtend: 4.0.2 - mysql2@3.22.5(@types/node@25.9.2): + mysql2@3.23.2(@types/node@22.20.1): dependencies: - '@types/node': 25.9.2 + '@types/node': 22.20.1 aws-ssl-profiles: 1.1.2 denque: 2.1.0 generate-function: 2.3.1 - iconv-lite: 0.7.2 + iconv-lite: 0.7.3 long: 5.3.2 lru.min: 1.1.4 named-placeholders: 1.1.6 - sql-escaper: 1.3.3 + sql-escaper: 1.5.1 named-placeholders@1.1.6: dependencies: lru.min: 1.1.4 - negotiator@0.6.3: {} - - nodemon@3.1.14: - dependencies: - chokidar: 3.6.0 - debug: 4.4.3(supports-color@5.5.0) - ignore-by-default: 1.0.1 - minimatch: 10.2.5 - pstree.remy: 1.1.8 - semver: 7.8.2 - simple-update-notifier: 2.0.0 - supports-color: 5.5.0 - touch: 3.1.1 - undefsafe: 2.0.5 - - normalize-path@3.0.0: {} + negotiator@1.0.0: {} object-assign@4.1.1: {} @@ -1854,21 +2557,17 @@ snapshots: dependencies: ee-first: 1.1.1 + once@1.4.0: + dependencies: + wrappy: 1.0.2 + one-time@1.0.0: dependencies: fn.name: 1.1.0 parseurl@1.3.3: {} - path-expression-matcher@1.5.0: {} - - path-to-regexp@0.1.13: {} - - pg-connection-string@2.13.0: {} - - picomatch@2.3.2: {} - - process-nextick-args@2.0.1: {} + path-to-regexp@8.4.2: {} proxy-addr@2.0.7: dependencies: @@ -1877,57 +2576,43 @@ snapshots: proxy-from-env@2.1.0: {} - pstree.remy@1.1.8: {} - - qs@6.15.2: + qs@6.15.3: dependencies: - side-channel: 1.1.0 + es-define-property: 1.0.1 + side-channel: 1.1.1 - range-parser@1.2.1: {} + range-parser@1.3.0: {} - raw-body@2.5.3: + raw-body@3.0.2: dependencies: bytes: 3.1.2 http-errors: 2.0.1 - iconv-lite: 0.4.24 + iconv-lite: 0.7.3 unpipe: 1.0.0 - readable-stream@1.1.14: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 0.0.1 - string_decoder: 0.10.31 - - readable-stream@2.3.8: - dependencies: - core-util-is: 1.0.3 - inherits: 2.0.4 - isarray: 1.0.0 - process-nextick-args: 2.0.1 - safe-buffer: 5.1.2 - string_decoder: 1.1.1 - util-deprecate: 1.0.2 - readable-stream@3.6.2: dependencies: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - readdirp@3.6.0: - dependencies: - picomatch: 2.3.2 - redis-errors@1.2.0: {} redis-parser@3.0.0: dependencies: redis-errors: 1.2.0 - retry-as-promised@7.1.1: {} + resolve-pkg-maps@1.0.0: {} - safe-buffer@5.1.2: {} + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color safe-buffer@5.2.1: {} @@ -1935,57 +2620,30 @@ snapshots: safer-buffer@2.1.2: {} - semver@7.8.2: {} + semver@7.8.5: {} - send@0.19.2: + send@1.2.1: dependencies: - debug: 2.6.9 - depd: 2.0.0 - destroy: 1.2.0 + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - fresh: 0.5.2 + fresh: 2.0.0 http-errors: 2.0.1 - mime: 1.6.0 + mime-types: 3.0.2 ms: 2.1.3 on-finished: 2.4.1 - range-parser: 1.2.1 + range-parser: 1.3.0 statuses: 2.0.2 transitivePeerDependencies: - supports-color - sequelize-pool@7.1.0: {} - - sequelize@6.37.8(mysql2@3.22.5(@types/node@25.9.2))(supports-color@5.5.0): - dependencies: - '@types/debug': 4.1.13 - '@types/validator': 13.15.10 - debug: 4.4.3(supports-color@5.5.0) - dottie: 2.0.7 - inflection: 1.13.4 - lodash: 4.18.1 - moment: 2.30.1 - moment-timezone: 0.5.48 - pg-connection-string: 2.13.0 - retry-as-promised: 7.1.1 - semver: 7.8.2 - sequelize-pool: 7.1.0 - toposort-class: 1.0.1 - uuid: 8.3.2 - validator: 13.15.35 - wkx: 0.5.0 - optionalDependencies: - mysql2: 3.22.5(@types/node@25.9.2) - transitivePeerDependencies: - - supports-color - - serve-static@1.16.3: + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 0.19.2 + send: 1.2.1 transitivePeerDependencies: - supports-color @@ -2011,7 +2669,7 @@ snapshots: object-inspect: 1.13.4 side-channel-map: 1.0.1 - side-channel@1.1.0: + side-channel@1.1.1: dependencies: es-errors: 1.3.0 object-inspect: 1.13.4 @@ -2019,73 +2677,83 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 - simple-update-notifier@2.0.0: + source-map-support@0.5.21: dependencies: - semver: 7.8.2 + buffer-from: 1.1.2 + source-map: 0.6.0 - sql-escaper@1.3.3: {} + source-map@0.6.0: {} - stack-trace@0.0.10: {} + sql-escaper@1.5.1: {} + + stack-trace@0.0.1: {} standard-as-callback@2.1.0: {} statuses@2.0.2: {} - streamsearch@0.1.2: {} - - string_decoder@0.10.31: {} - - string_decoder@1.1.1: - dependencies: - safe-buffer: 5.1.2 + streamsearch@1.1.0: {} string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 - strnum@2.3.0: {} - - supports-color@5.5.0: - dependencies: - has-flag: 3.0.0 - text-hex@1.0.0: {} - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - toidentifier@1.0.1: {} - toposort-class@1.0.1: {} - - touch@3.1.1: {} - triple-beam@1.4.1: {} tslib@2.8.1: {} + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + type-is@1.6.18: dependencies: media-typer: 0.3.0 - mime-types: 2.1.35 + mime-types: 2.1.24 + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.1 + mime-types: 3.0.2 typedarray@0.0.6: {} - undefsafe@2.0.5: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 - undici-types@7.24.6: {} + undici-types@6.21.0: {} unpipe@1.0.0: {} util-deprecate@1.0.2: {} - utils-merge@1.0.1: {} - - uuid@8.3.2: {} - - validator@13.15.35: {} - vary@1.1.2: {} winston-transport@4.9.0: @@ -2104,16 +2772,10 @@ snapshots: one-time: 1.0.0 readable-stream: 3.6.2 safe-stable-stringify: 2.5.0 - stack-trace: 0.0.10 + stack-trace: 0.0.1 triple-beam: 1.4.1 winston-transport: 4.9.0 - wkx@0.5.0: - dependencies: - '@types/node': 25.9.2 + wrappy@1.0.2: {} - xml-naming@0.1.0: {} - - xtend@4.0.2: {} - - zod@3.25.76: {} + zod@3.25.0: {} diff --git a/dashboard/backend/pnpm-workspace.yaml b/dashboard/backend/pnpm-workspace.yaml new file mode 100644 index 0000000..0d2286b --- /dev/null +++ b/dashboard/backend/pnpm-workspace.yaml @@ -0,0 +1,25 @@ +confirmModulesPurge: false +allowBuilds: + esbuild: true +minimumReleaseAgeExclude: + - '@aws-sdk/checksums@3.1000.23' + - '@aws-sdk/client-s3@3.1098.0' + - '@aws-sdk/core@3.977.3' + - '@aws-sdk/credential-provider-env@3.972.64' + - '@aws-sdk/credential-provider-http@3.972.66' + - '@aws-sdk/credential-provider-ini@3.973.9' + - '@aws-sdk/credential-provider-login@3.972.71' + - '@aws-sdk/credential-provider-node@3.972.75' + - '@aws-sdk/credential-provider-process@3.972.64' + - '@aws-sdk/credential-provider-sso@3.973.8' + - '@aws-sdk/credential-provider-web-identity@3.972.70' + - '@aws-sdk/middleware-sdk-s3@3.972.69' + - '@aws-sdk/nested-clients@3.997.38' + - '@aws-sdk/signature-v4-multi-region@3.996.43' + - '@aws-sdk/token-providers@3.1098.0' + - '@smithy/core@3.31.1' + - '@smithy/credential-provider-imds@4.4.16' + - '@smithy/fetch-http-handler@5.6.13' + - '@smithy/node-http-handler@4.9.13' + - '@smithy/signature-v4@5.6.12' + - axios@1.19.0 diff --git a/dashboard/backend/restart.sh b/dashboard/backend/restart.sh index 659132d..65f6ef7 100755 --- a/dashboard/backend/restart.sh +++ b/dashboard/backend/restart.sh @@ -1,4 +1,6 @@ #!/bin/bash +set -e + echo "================================================" echo "Audio Dashboard API - 重启服务" echo "================================================" @@ -7,7 +9,7 @@ echo "" # 切换到脚本所在目录 cd "$(dirname "$0")" -echo "[1/3] 停止服务..." +echo "[1/4] 停止服务..." echo "" # 查找占用 8083 端口的进程 @@ -27,11 +29,16 @@ else fi echo "" -echo "[2/3] 等待端口释放..." -sleep 2 +echo "[2/4] 安装依赖..." +pnpm install --silent echo "" -echo "[3/3] 启动服务..." +echo "[3/4] 构建 TypeScript..." +echo "" +pnpm build + +echo "" +echo "[4/4] 启动服务..." echo "" echo "服务运行在 http://localhost:8083" echo "" @@ -39,4 +46,4 @@ echo "按 Ctrl+C 停止服务" echo "================================================" echo "" -node src/app.js +exec node --conditions @zod/source dist/app.js diff --git a/dashboard/backend/src/app.js b/dashboard/backend/src/app.js deleted file mode 100644 index 4a680fd..0000000 --- a/dashboard/backend/src/app.js +++ /dev/null @@ -1,62 +0,0 @@ -/** - * 入口文件 — 对应 Python: main.py - */ -// 统一时区为东八区(服务器可能在其他时区,但业务面向中国用户) -process.env.TZ = 'Asia/Shanghai'; - -require('./config/loadEnv'); - -const express = require('express'); -const cors = require('cors'); -const sequelize = require('./config/database'); -const logger = require('./config/logger'); -const routes = require('./routes'); -const { bodyLimit } = require('./middleware/bodyLimit'); -const { ensureBootstrapSuperAdmin } = require('./services/userBootstrap'); - -const app = express(); - -// 中间件 -app.use(cors({ origin: '*', credentials: true })); -app.use(express.json()); -app.use(express.urlencoded({ extended: true })); -app.use(bodyLimit); - -// 根路径(无需登录) -app.get('/', (req, res) => { - res.json({ - message: '欢迎使用 Audio Dashboard API', - docs: '/docs', - redoc: '/redoc', - }); -}); - -// 健康检查(无需登录) -app.get('/health', (req, res) => { - res.json({ status: 'healthy' }); -}); - -// 业务路由(需登录的路由在各自文件中挂载 authMiddleware) -routes.forEach((r) => app.use(r)); - -// 同步数据库并启动 -const PORT = process.env.PORT || 8083; - -async function start() { - try { - logger.info('Creating database tables...'); - await sequelize.sync(); - await ensureBootstrapSuperAdmin(); - logger.info('Database tables created successfully'); - } catch (e) { - logger.error(`Warning: Could not create tables: ${e.message}`); - logger.error('Continuing without table creation...'); - } - - app.listen(PORT, '0.0.0.0', () => { - logger.info(`Server running on http://localhost:${PORT}`); - logger.info(`API docs: http://localhost:${PORT}/docs`); - }); -} - -start(); diff --git a/dashboard/backend/src/app.ts b/dashboard/backend/src/app.ts new file mode 100644 index 0000000..f832d1e --- /dev/null +++ b/dashboard/backend/src/app.ts @@ -0,0 +1,66 @@ +// 统一时区为东八区(服务器可能在其他时区,但业务面向中国用户) +process.env.TZ = 'Asia/Shanghai'; + +import './config/loadEnv.js'; + +import express from 'express'; +import cors from 'cors'; +import logger from './config/logger.js'; +import routes from './routes/index.js'; +import { bodyLimit } from './middleware/bodyLimit.js'; +import { ensureBootstrapSuperAdmin } from './services/userBootstrap.js'; + +const app = express(); + +// 中间件 +app.use(cors({ origin: '*', credentials: true })); +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(bodyLimit); + +// 根路径(无需登录) +app.get('/', (_req, res) => { + res.json({ + message: '欢迎使用 Audio Dashboard API', + docs: '/docs', + redoc: '/redoc', + }); +}); + +// 健康检查(无需登录) +app.get('/health', (_req, res) => { + res.json({ status: 'healthy' }); +}); + +// 业务路由(需登录的路由在各自文件中挂载 authMiddleware) +routes.forEach((r) => app.use(r)); + +// 启动 +const PORT = Number(process.env.PORT) || 8083; + +function logConnections() { + const mask = (s: string) => (s ? s.slice(0, 2) + '***' : '(empty)'); + logger.info('── Service Connections ──────────────────────────'); + logger.info(` MySQL: ${process.env.DATABASE_USER || 'root'}@${process.env.DATABASE_HOST || 'localhost'}:${process.env.DATABASE_PORT || '3306'}/${process.env.DATABASE_NAME || 'audio'} (pwd: ${mask(process.env.DATABASE_PASSWORD || '')})`); + logger.info(` Meilisearch: ${process.env.MEILISEARCH_URL || 'http://localhost:7700'} (index: ${process.env.MEILISEARCH_INDEX || 'models'}, key: ${mask(process.env.MEILISEARCH_API_KEY || '')})`); + logger.info(` Redis: ${process.env.REDIS_HOST || '127.0.0.1'}:${process.env.REDIS_PORT || '6379'} db=${process.env.REDIS_EQ_DB || '1'} (pwd: ${mask(process.env.REDIS_PASSWORD || '')})`); + logger.info('─────────────────────────────────────────────────'); +} + +async function start() { + logConnections(); + try { + await ensureBootstrapSuperAdmin(); + logger.info('Bootstrap check completed'); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Warning: Bootstrap failed: ${msg}`); + logger.error('Continuing without bootstrap...'); + } + + app.listen(PORT, '0.0.0.0', () => { + logger.info(`Server running on http://localhost:${PORT}`); + }); +} + +start(); diff --git a/dashboard/backend/src/config/database.js b/dashboard/backend/src/config/database.js deleted file mode 100644 index a0e4f85..0000000 --- a/dashboard/backend/src/config/database.js +++ /dev/null @@ -1,23 +0,0 @@ -const { Sequelize } = require('sequelize'); -const { isDevelopment } = require('./env'); - -const sequelize = new Sequelize( - process.env.DATABASE_NAME || 'audio', - process.env.DATABASE_USER || 'root', - process.env.DATABASE_PASSWORD || 'root123', - { - host: process.env.DATABASE_HOST || 'localhost', - port: parseInt(process.env.DATABASE_PORT || '3306', 10), - dialect: 'mysql', - dialectOptions: { - charset: 'utf8mb4', - }, - logging: isDevelopment ? (msg) => console.log(msg) : false, - define: { - timestamps: false, - freezeTableName: true, - }, - } -); - -module.exports = sequelize; diff --git a/dashboard/backend/src/config/database.ts b/dashboard/backend/src/config/database.ts new file mode 100644 index 0000000..6fcf7f3 --- /dev/null +++ b/dashboard/backend/src/config/database.ts @@ -0,0 +1,25 @@ +import { drizzle } from 'drizzle-orm/mysql2'; +import mysql from 'mysql2/promise'; +import * as schema from '../schemas/index.js'; +import { isDevelopment } from './env.js'; + +const pool = mysql.createPool({ + host: process.env.DATABASE_HOST || 'localhost', + port: Number(process.env.DATABASE_PORT || 3306), + user: process.env.DATABASE_USER || 'root', + password: process.env.DATABASE_PASSWORD || '', + database: process.env.DATABASE_NAME || 'audio', + charset: 'utf8mb4', + waitForConnections: true, + connectionLimit: 10, +}); + +export const db = drizzle(pool, { schema, mode: 'default' }); + +if (isDevelopment) { + pool.on('connection', () => { + // 开发环境可打印连接日志 + }); +} + +export default db; diff --git a/dashboard/backend/src/config/env.js b/dashboard/backend/src/config/env.ts similarity index 85% rename from dashboard/backend/src/config/env.js rename to dashboard/backend/src/config/env.ts index ce8cda9..acb2b11 100644 --- a/dashboard/backend/src/config/env.js +++ b/dashboard/backend/src/config/env.ts @@ -9,4 +9,4 @@ const APP_ENV = (process.env.APP_ENV || 'development').trim().toLowerCase(); const isDevelopment = APP_ENV === 'development'; const isProduction = APP_ENV === 'production'; -module.exports = { APP_ENV, isDevelopment, isProduction }; +export { APP_ENV, isDevelopment, isProduction }; diff --git a/dashboard/backend/src/config/loadEnv.js b/dashboard/backend/src/config/loadEnv.ts similarity index 64% rename from dashboard/backend/src/config/loadEnv.js rename to dashboard/backend/src/config/loadEnv.ts index ec58853..01a0824 100644 --- a/dashboard/backend/src/config/loadEnv.js +++ b/dashboard/backend/src/config/loadEnv.ts @@ -3,10 +3,12 @@ * - 本地开发:读取根目录 .env * - Docker:由 compose env_file 注入,容器内无 .env 文件,不覆盖已有环境变量 */ -const path = require('path'); -const fs = require('fs'); -const dotenv = require('dotenv'); +import path from 'node:path'; +import fs from 'node:fs'; +import dotenv from 'dotenv'; +import { fileURLToPath } from 'node:url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const rootEnvPath = path.resolve(__dirname, '../../../.env'); if (fs.existsSync(rootEnvPath)) { diff --git a/dashboard/backend/src/config/logger.js b/dashboard/backend/src/config/logger.ts similarity index 75% rename from dashboard/backend/src/config/logger.js rename to dashboard/backend/src/config/logger.ts index 92833ec..da7c0f7 100644 --- a/dashboard/backend/src/config/logger.js +++ b/dashboard/backend/src/config/logger.ts @@ -1,6 +1,9 @@ -const winston = require('winston'); -const path = require('path'); -const fs = require('fs'); +import winston from 'winston'; +import path from 'node:path'; +import fs from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const logsDir = path.join(__dirname, '..', '..', 'logs'); if (!fs.existsSync(logsDir)) { @@ -20,9 +23,8 @@ const logger = winston.createLogger({ new winston.transports.Console(), new winston.transports.File({ filename: path.join(logsDir, 'app.log'), - encoding: 'utf-8', }), ], }); -module.exports = logger; +export default logger; diff --git a/dashboard/backend/src/config/redis.js b/dashboard/backend/src/config/redis.ts similarity index 67% rename from dashboard/backend/src/config/redis.js rename to dashboard/backend/src/config/redis.ts index 7386ad8..587f4ac 100644 --- a/dashboard/backend/src/config/redis.js +++ b/dashboard/backend/src/config/redis.ts @@ -1,16 +1,17 @@ -const Redis = require('ioredis'); -const logger = require('./logger'); +import Redis from 'ioredis'; +import type { RedisOptions } from 'ioredis'; +import logger from './logger.js'; const REDIS_HOST = process.env.REDIS_HOST || '127.0.0.1'; const REDIS_PORT = parseInt(process.env.REDIS_PORT || '6379', 10); const REDIS_PASSWORD = process.env.REDIS_PASSWORD || ''; const REDIS_EQ_DB = parseInt(process.env.REDIS_EQ_DB || '1', 10); -let eqCacheClient = null; +let eqCacheClient: Redis | null = null; -function getEqCacheRedis() { +function getEqCacheRedis(): Redis { if (!eqCacheClient) { - const options = { + const options: RedisOptions = { host: REDIS_HOST, port: REDIS_PORT, db: REDIS_EQ_DB, @@ -21,11 +22,11 @@ function getEqCacheRedis() { options.password = REDIS_PASSWORD; } eqCacheClient = new Redis(options); - eqCacheClient.on('error', (err) => { + eqCacheClient.on('error', (err: Error) => { logger.error(`Redis EQ cache error: ${err.message}`); }); } return eqCacheClient; } -module.exports = { getEqCacheRedis }; +export { getEqCacheRedis }; diff --git a/dashboard/backend/src/middleware/auth.js b/dashboard/backend/src/middleware/auth.js deleted file mode 100644 index 2df9644..0000000 --- a/dashboard/backend/src/middleware/auth.js +++ /dev/null @@ -1,35 +0,0 @@ -const { decodeToken } = require('../utils/jwt'); - -function authMiddleware(req, res, next) { - const auth = req.headers.authorization; - if (!auth || !auth.startsWith('Bearer ')) { - return res.status(401).json({ detail: '未登录或缺少凭证' }); - } - const token = auth.slice(7); - try { - const payload = decodeToken(token); - if (!payload.sub) { - return res.status(401).json({ detail: '无效凭证' }); - } - req.user = { - id: payload.sub, - username: payload.username || '', - is_super_admin: !!payload.is_super_admin, - }; - next(); - } catch (e) { - if (e.name === 'TokenExpiredError') { - return res.status(401).json({ detail: '登录已过期,请重新登录' }); - } - return res.status(401).json({ detail: '无效凭证' }); - } -} - -function requireSuperAdmin(req, res, next) { - if (!req.user?.is_super_admin) { - return res.status(403).json({ detail: '需要超级管理员权限' }); - } - next(); -} - -module.exports = { authMiddleware, requireSuperAdmin }; diff --git a/dashboard/backend/src/middleware/auth.ts b/dashboard/backend/src/middleware/auth.ts new file mode 100644 index 0000000..3fdfb78 --- /dev/null +++ b/dashboard/backend/src/middleware/auth.ts @@ -0,0 +1,52 @@ +import type { Request, Response, NextFunction } from 'express'; +import { decodeToken } from '../utils/jwt.js'; + +declare global { + namespace Express { + interface Request { + user?: { + id: number; + username: string; + is_super_admin: boolean; + }; + } + } +} + +function authMiddleware(req: Request, res: Response, next: NextFunction): void { + const auth = req.headers.authorization; + if (!auth || !auth.startsWith('Bearer ')) { + res.status(401).json({ detail: '未登录或缺少凭证' }); + return; + } + const token = auth.slice(7); + try { + const payload = decodeToken(token); + if (!payload.sub) { + res.status(401).json({ detail: '无效凭证' }); + return; + } + req.user = { + id: payload.sub, + username: payload.username || '', + is_super_admin: !!payload.is_super_admin, + }; + next(); + } catch (e) { + if (e instanceof Error && e.name === 'TokenExpiredError') { + res.status(401).json({ detail: '登录已过期,请重新登录' }); + return; + } + res.status(401).json({ detail: '无效凭证' }); + } +} + +function requireSuperAdmin(req: Request, res: Response, next: NextFunction): void { + if (!req.user?.is_super_admin) { + res.status(403).json({ detail: '需要超级管理员权限' }); + return; + } + next(); +} + +export { authMiddleware, requireSuperAdmin }; diff --git a/dashboard/backend/src/middleware/bodyLimit.js b/dashboard/backend/src/middleware/bodyLimit.js deleted file mode 100644 index d192280..0000000 --- a/dashboard/backend/src/middleware/bodyLimit.js +++ /dev/null @@ -1,11 +0,0 @@ -const MAX_BODY_SIZE = 8 * 1024 * 1024; // 8MB - -function bodyLimit(req, res, next) { - const contentLength = req.headers['content-length']; - if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) { - return res.status(413).json({ detail: '上传文件大小超过 8MB 限制' }); - } - next(); -} - -module.exports = { bodyLimit }; diff --git a/dashboard/backend/src/middleware/bodyLimit.ts b/dashboard/backend/src/middleware/bodyLimit.ts new file mode 100644 index 0000000..13b1425 --- /dev/null +++ b/dashboard/backend/src/middleware/bodyLimit.ts @@ -0,0 +1,14 @@ +import type { Request, Response, NextFunction } from 'express'; + +const MAX_BODY_SIZE = 8 * 1024 * 1024; // 8MB + +function bodyLimit(req: Request, res: Response, next: NextFunction): void { + const contentLength = req.headers['content-length']; + if (contentLength && parseInt(contentLength, 10) > MAX_BODY_SIZE) { + res.status(413).json({ detail: '上传文件大小超过 8MB 限制' }); + return; + } + next(); +} + +export { bodyLimit }; diff --git a/dashboard/backend/src/models/BlackList.js b/dashboard/backend/src/models/BlackList.js deleted file mode 100644 index 352df39..0000000 --- a/dashboard/backend/src/models/BlackList.js +++ /dev/null @@ -1,36 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); -const Ota = require('./Ota'); - -const BlackList = sequelize.define('BlackList', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: 'ID', - }, - ota_id: { - type: DataTypes.INTEGER, - allowNull: false, - field: 'ota_id', - comment: '关联 OTA 版本 ID', - }, - mac: { - type: DataTypes.STRING(100), - allowNull: false, - comment: 'MAC 地址', - }, - create_at: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '创建时间', - }, -}, { - tableName: 'black_list', - comment: 'OTA 黑名单', -}); - -BlackList.belongsTo(Ota, { foreignKey: 'ota_id', as: 'ota' }); - -module.exports = BlackList; diff --git a/dashboard/backend/src/models/Brand.js b/dashboard/backend/src/models/Brand.js deleted file mode 100644 index a48c813..0000000 --- a/dashboard/backend/src/models/Brand.js +++ /dev/null @@ -1,22 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); - -const Brand = sequelize.define('Brand', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: '品牌 ID', - }, - name: { - type: DataTypes.STRING(100), - unique: true, - allowNull: false, - comment: '品牌名称', - }, -}, { - tableName: 'brand', - comment: '耳机品牌', -}); - -module.exports = Brand; diff --git a/dashboard/backend/src/models/DashboardUser.js b/dashboard/backend/src/models/DashboardUser.js deleted file mode 100644 index 1381c3d..0000000 --- a/dashboard/backend/src/models/DashboardUser.js +++ /dev/null @@ -1,57 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); - -const DashboardUser = sequelize.define('DashboardUser', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: '主键', - }, - username: { - type: DataTypes.STRING(64), - allowNull: false, - unique: true, - comment: '登录账号', - }, - password_hash: { - type: DataTypes.STRING(255), - allowNull: false, - comment: '密码哈希(bcrypt)', - }, - is_super_admin: { - type: DataTypes.TINYINT, - allowNull: false, - defaultValue: 0, - comment: '是否超级管理员:1=是', - }, - status: { - type: DataTypes.TINYINT, - allowNull: false, - defaultValue: 1, - comment: '状态:1=启用,0=禁用', - }, - last_login_at: { - type: DataTypes.DATE, - allowNull: true, - comment: '最近登录时间', - }, - create_at: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '创建时间', - }, - update_at: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '更新时间', - }, -}, { - tableName: 'dashboard_user', - comment: 'Dashboard 后台账号', -}); - -module.exports = DashboardUser; - diff --git a/dashboard/backend/src/models/Model.js b/dashboard/backend/src/models/Model.js deleted file mode 100644 index c3c04b2..0000000 --- a/dashboard/backend/src/models/Model.js +++ /dev/null @@ -1,52 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); - -const Model = sequelize.define('Model', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: '型号 ID', - }, - brand_name: { - type: DataTypes.STRING(100), - allowNull: false, - comment: '品牌名称', - }, - name: { - type: DataTypes.STRING(100), - allowNull: false, - comment: '型号名称', - }, - form: { - type: DataTypes.STRING(100), - allowNull: true, - comment: '形式', - }, - rig: { - type: DataTypes.STRING(100), - allowNull: true, - comment: '阻抗', - }, - source: { - type: DataTypes.STRING(100), - allowNull: true, - comment: '来源', - }, - eq_key: { - type: DataTypes.STRING(255), - allowNull: true, - comment: 'EQ 键', - }, - create_at: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '创建时间', - }, -}, { - tableName: 'model', - comment: '耳机型号', -}); - -module.exports = Model; diff --git a/dashboard/backend/src/models/Ota.js b/dashboard/backend/src/models/Ota.js deleted file mode 100644 index 090ab5b..0000000 --- a/dashboard/backend/src/models/Ota.js +++ /dev/null @@ -1,96 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); - -const Ota = sequelize.define('Ota', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: 'ID', - }, - verCode: { - type: DataTypes.INTEGER, - allowNull: false, - field: 'verCode', - comment: '版本号(整数)', - }, - verName: { - type: DataTypes.STRING(20), - allowNull: false, - field: 'verName', - comment: '版本名称', - }, - url: { - type: DataTypes.STRING(255), - allowNull: false, - comment: '升级包 URL', - }, - md5: { - type: DataTypes.STRING(32), - allowNull: false, - comment: '升级包 MD5', - }, - force: { - type: DataTypes.SMALLINT, - allowNull: false, - defaultValue: 0, - comment: '是否强升;0-否', - }, - desc: { - type: DataTypes.STRING(255), - allowNull: true, - comment: '描述', - }, - model: { - type: DataTypes.STRING(100), - allowNull: true, - comment: '对应的设备型号', - }, - hw: { - type: DataTypes.INTEGER, - allowNull: false, - defaultValue: 0, - comment: '硬件版本号', - }, - target: { - type: DataTypes.SMALLINT, - allowNull: false, - defaultValue: 0, - comment: '是否定向,1-是,0-否', - }, - beta: { - type: DataTypes.SMALLINT, - allowNull: false, - defaultValue: 0, - comment: '是否灰度,1-是,0-否', - }, - startTime: { - type: DataTypes.DATE, - allowNull: true, - field: 'startTime', - comment: '升级开始时间', - }, - endTime: { - type: DataTypes.DATE, - allowNull: true, - field: 'endTime', - comment: '升级结束时间', - }, - status: { - type: DataTypes.SMALLINT, - allowNull: false, - defaultValue: 1, - comment: '是否可用', - }, - create_at: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '创建时间', - }, -}, { - tableName: 'ota', - comment: 'OTA 升级版本', -}); - -module.exports = Ota; diff --git a/dashboard/backend/src/models/OtaTargetDevice.js b/dashboard/backend/src/models/OtaTargetDevice.js deleted file mode 100644 index 5bd969c..0000000 --- a/dashboard/backend/src/models/OtaTargetDevice.js +++ /dev/null @@ -1,37 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); -const Ota = require('./Ota'); - -const OtaTargetDevice = sequelize.define('OtaTargetDevice', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: 'ID', - }, - ota_id: { - type: DataTypes.INTEGER, - allowNull: false, - field: 'ota_id', - comment: '关联 OTA 版本 ID', - }, - mac_addr: { - type: DataTypes.STRING(17), - allowNull: false, - field: 'mac_addr', - comment: 'MAC 地址', - }, - create_at: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '创建时间', - }, -}, { - tableName: 'ota_target_device', - comment: '定向升级设备', -}); - -OtaTargetDevice.belongsTo(Ota, { foreignKey: 'ota_id', as: 'ota' }); - -module.exports = OtaTargetDevice; diff --git a/dashboard/backend/src/models/ShareCodeLog.js b/dashboard/backend/src/models/ShareCodeLog.js deleted file mode 100644 index 77c7f91..0000000 --- a/dashboard/backend/src/models/ShareCodeLog.js +++ /dev/null @@ -1,59 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); - -const ShareCodeLog = sequelize.define('ShareCodeLog', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: '主键', - }, - mac_addr: { - type: DataTypes.STRING(17), - allowNull: false, - comment: '设备 MAC 地址,如 AA:BB:CC:DD:EE:FF', - }, - share_code: { - type: DataTypes.CHAR(5), - allowNull: false, - comment: '分享码(5位字符)', - }, - action: { - type: DataTypes.ENUM('export', 'import'), - allowNull: false, - comment: '操作类型:export=导出分享码,import=导入分享码', - }, - ip_addr: { - type: DataTypes.STRING(45), - allowNull: false, - defaultValue: '', - comment: '用户 IP 地址(支持 IPv4/IPv6)', - }, - eq_data: { - type: DataTypes.JSON, - allowNull: false, - comment: 'EQ 数据快照(JSON)', - }, - expire_at: { - type: DataTypes.DATE, - allowNull: true, - comment: '分享码到期时间(导出时快照,导入可为空)', - }, - create_at: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '操作时间', - }, -}, { - tableName: 'share_code_log', - comment: '分享码流水表', - indexes: [ - { name: 'idx_mac_addr', fields: ['mac_addr'] }, - { name: 'idx_share_code', fields: ['share_code'] }, - { name: 'idx_create_at', fields: ['create_at'] }, - ], -}); - -module.exports = ShareCodeLog; - diff --git a/dashboard/backend/src/models/UserActive.js b/dashboard/backend/src/models/UserActive.js deleted file mode 100644 index 82bc819..0000000 --- a/dashboard/backend/src/models/UserActive.js +++ /dev/null @@ -1,42 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); - -const UserActive = sequelize.define('UserActive', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: '自增主键', - }, - mac_addr: { - type: DataTypes.STRING(30), - allowNull: false, - comment: '设备 MAC 地址', - }, - model: { - type: DataTypes.STRING(50), - allowNull: false, - comment: '设备型号(如 Luxsin-X9、Luxsin-X8)', - }, - active_date: { - type: DataTypes.DATEONLY, - allowNull: false, - comment: '活跃日期', - }, - ip_addr: { - type: DataTypes.STRING(45), - allowNull: false, - comment: '活跃时 IP 地址', - }, - create_at: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '记录创建时间', - }, -}, { - tableName: 'user_active', - comment: '用户设备日活记录(mac_addr + active_date 唯一)', -}); - -module.exports = UserActive; diff --git a/dashboard/backend/src/models/UserDevice.js b/dashboard/backend/src/models/UserDevice.js deleted file mode 100644 index f320960..0000000 --- a/dashboard/backend/src/models/UserDevice.js +++ /dev/null @@ -1,38 +0,0 @@ -const { DataTypes } = require('sequelize'); -const sequelize = require('../config/database'); - -const UserDevice = sequelize.define('UserDevice', { - id: { - type: DataTypes.INTEGER, - primaryKey: true, - autoIncrement: true, - comment: '自增主键', - }, - mac_addr: { - type: DataTypes.STRING(30), - allowNull: false, - unique: true, - comment: '设备 MAC 地址(唯一)', - }, - model: { - type: DataTypes.STRING(50), - allowNull: false, - comment: '设备型号(如 Luxsin-X9、Luxsin-X8)', - }, - add_time: { - type: DataTypes.DATE, - allowNull: false, - defaultValue: DataTypes.NOW, - comment: '首次注册时间', - }, - ver: { - type: DataTypes.STRING(10), - allowNull: true, - comment: '固件版本号', - }, -}, { - tableName: 'user_device', - comment: '用户设备注册记录', -}); - -module.exports = UserDevice; diff --git a/dashboard/backend/src/models/index.js b/dashboard/backend/src/models/index.js deleted file mode 100644 index c1e1052..0000000 --- a/dashboard/backend/src/models/index.js +++ /dev/null @@ -1,9 +0,0 @@ -const Brand = require('./Brand'); -const Model = require('./Model'); -const Ota = require('./Ota'); -const BlackList = require('./BlackList'); -const OtaTargetDevice = require('./OtaTargetDevice'); -const ShareCodeLog = require('./ShareCodeLog'); -const DashboardUser = require('./DashboardUser'); - -module.exports = { Brand, Model, Ota, BlackList, OtaTargetDevice, ShareCodeLog, DashboardUser }; diff --git a/dashboard/backend/src/routes/auth.js b/dashboard/backend/src/routes/auth.js deleted file mode 100644 index 1ab3ab8..0000000 --- a/dashboard/backend/src/routes/auth.js +++ /dev/null @@ -1,111 +0,0 @@ -/** - * 认证路由 - */ -const router = require('express').Router(); -const DashboardUser = require('../models/DashboardUser'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { createAccessToken, getTokenTtlSeconds } = require('../utils/jwt'); -const { verifyPassword, hashPassword } = require('../utils/password'); -const { authMiddleware } = require('../middleware/auth'); - -function userToDict(user) { - return { - id: user.id, - username: user.username, - is_super_admin: !!user.is_super_admin, - status: user.status, - last_login_at: user.last_login_at ? user.last_login_at.toISOString() : null, - create_at: user.create_at ? user.create_at.toISOString() : null, - update_at: user.update_at ? user.update_at.toISOString() : null, - }; -} - -router.post('/api/auth/login', async (req, res) => { - try { - const { username, password } = req.body || {}; - const trimmedUsername = (username || '').trim(); - - if (!trimmedUsername || !password) { - return res.json(ApiResponse.error('用户名或密码不能为空')); - } - - const user = await DashboardUser.findOne({ - where: { username: trimmedUsername }, - }); - - if (!user || user.status !== 1) { - logger.warn(`Login failed for username=${trimmedUsername}`); - return res.json(ApiResponse.error('用户名或密码错误')); - } - - const ok = await verifyPassword(password, user.password_hash); - if (!ok) { - logger.warn(`Login failed for username=${trimmedUsername}`); - return res.json(ApiResponse.error('用户名或密码错误')); - } - - user.last_login_at = new Date(); - await user.save(); - - const token = createAccessToken(user); - logger.info(`User ${trimmedUsername} logged in`); - - return res.json(ApiResponse.success({ - access_token: token, - token_type: 'bearer', - expires_in: getTokenTtlSeconds(), - user: userToDict(user), - })); - } catch (e) { - logger.error(`Login error: ${e.message}`); - return res.json(ApiResponse.error('登录失败')); - } -}); - -router.get('/api/auth/me', authMiddleware, async (req, res) => { - try { - const user = await DashboardUser.findByPk(req.user.id); - if (!user || user.status !== 1) { - return res.status(401).json({ detail: '账号不存在或已禁用' }); - } - return res.json(ApiResponse.success(userToDict(user))); - } catch (e) { - logger.error(`Get current user error: ${e.message}`); - return res.json(ApiResponse.error('获取用户信息失败')); - } -}); - -router.put('/api/auth/password', authMiddleware, async (req, res) => { - try { - const { old_password: oldPassword, new_password: newPassword } = req.body || {}; - - if (!oldPassword || !newPassword) { - return res.json(ApiResponse.error('请填写原密码和新密码')); - } - if (String(newPassword).length < 6) { - return res.json(ApiResponse.error('新密码至少 6 位')); - } - - const user = await DashboardUser.findByPk(req.user.id); - if (!user || user.status !== 1) { - return res.status(401).json({ detail: '账号不存在或已禁用' }); - } - - const ok = await verifyPassword(oldPassword, user.password_hash); - if (!ok) { - return res.json(ApiResponse.error('原密码错误')); - } - - user.password_hash = await hashPassword(newPassword); - await user.save(); - - logger.info(`User ${user.username} changed password`); - return res.json(ApiResponse.success(null, '密码已修改')); - } catch (e) { - logger.error(`Change password error: ${e.message}`); - return res.json(ApiResponse.error('修改密码失败')); - } -}); - -module.exports = router; diff --git a/dashboard/backend/src/routes/auth.ts b/dashboard/backend/src/routes/auth.ts new file mode 100644 index 0000000..72d5e59 --- /dev/null +++ b/dashboard/backend/src/routes/auth.ts @@ -0,0 +1,140 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { dashboardUsers } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { createAccessToken, getTokenTtlSeconds } from '../utils/jwt.js'; +import { verifyPassword, hashPassword } from '../utils/password.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router: RouterType = Router(); + +function userToDict(user: typeof dashboardUsers.$inferSelect) { + return { + id: user.id, + username: user.username, + is_super_admin: !!user.isSuperAdmin, + status: user.status, + last_login_at: user.lastLoginAt ? user.lastLoginAt.toISOString() : null, + create_at: user.createAt ? user.createAt.toISOString() : null, + update_at: user.updateAt ? user.updateAt.toISOString() : null, + }; +} + +router.post('/api/auth/login', async (req: Request, res: Response) => { + try { + const { username, password } = req.body || {}; + const trimmedUsername = (username || '').trim(); + + if (!trimmedUsername || !password) { + res.json(ApiResponse.error('用户名或密码不能为空')); + return; + } + + const [user] = await db + .select() + .from(dashboardUsers) + .where(eq(dashboardUsers.username, trimmedUsername)) + .limit(1); + + if (!user || user.status !== 1) { + logger.warn(`Login failed for username=${trimmedUsername}`); + res.json(ApiResponse.error('用户名或密码错误')); + return; + } + + const ok = await verifyPassword(password, user.passwordHash); + if (!ok) { + logger.warn(`Login failed for username=${trimmedUsername}`); + res.json(ApiResponse.error('用户名或密码错误')); + return; + } + + await db + .update(dashboardUsers) + .set({ lastLoginAt: new Date() }) + .where(eq(dashboardUsers.id, user.id)); + + const token = createAccessToken(user); + logger.info(`User ${trimmedUsername} logged in`); + + res.json(ApiResponse.success({ + access_token: token, + token_type: 'bearer', + expires_in: getTokenTtlSeconds(), + user: userToDict(user), + })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Login error: ${msg}`); + res.json(ApiResponse.error('登录失败')); + } +}); + +router.get('/api/auth/me', authMiddleware, async (req: Request, res: Response) => { + try { + const [user] = await db + .select() + .from(dashboardUsers) + .where(eq(dashboardUsers.id, req.user!.id)) + .limit(1); + + if (!user || user.status !== 1) { + res.status(401).json({ detail: '账号不存在或已禁用' }); + return; + } + res.json(ApiResponse.success(userToDict(user))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Get current user error: ${msg}`); + res.json(ApiResponse.error('获取用户信息失败')); + } +}); + +router.put('/api/auth/password', authMiddleware, async (req: Request, res: Response) => { + try { + const { old_password: oldPassword, new_password: newPassword } = req.body || {}; + + if (!oldPassword || !newPassword) { + res.json(ApiResponse.error('请填写原密码和新密码')); + return; + } + if (String(newPassword).length < 6) { + res.json(ApiResponse.error('新密码至少 6 位')); + return; + } + + const [user] = await db + .select() + .from(dashboardUsers) + .where(eq(dashboardUsers.id, req.user!.id)) + .limit(1); + + if (!user || user.status !== 1) { + res.status(401).json({ detail: '账号不存在或已禁用' }); + return; + } + + const ok = await verifyPassword(oldPassword, user.passwordHash); + if (!ok) { + res.json(ApiResponse.error('原密码错误')); + return; + } + + const newHash = await hashPassword(newPassword); + await db + .update(dashboardUsers) + .set({ passwordHash: newHash }) + .where(eq(dashboardUsers.id, user.id)); + + logger.info(`User ${user.username} changed password`); + res.json(ApiResponse.success(null, '密码已修改')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Change password error: ${msg}`); + res.json(ApiResponse.error('修改密码失败')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/blacklist.js b/dashboard/backend/src/routes/blacklist.js deleted file mode 100644 index cf0b0ff..0000000 --- a/dashboard/backend/src/routes/blacklist.js +++ /dev/null @@ -1,222 +0,0 @@ -/** - * 黑名单路由 - */ -const router = require('express').Router(); -const { Op } = require('sequelize'); -const BlackList = require('../models/BlackList'); -const Ota = require('../models/Ota'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { authMiddleware } = require('../middleware/auth'); -const { BlackListCreateSchema, BlackListUpdateSchema } = require('../validators/blacklist'); - -// 所有接口需登录 -router.use('/api/blacklist', authMiddleware); - -// GET /api/blacklist/ -router.get('/api/blacklist/', async (req, res) => { - try { - const skip = parseInt(req.query.skip || '0', 10); - const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); - const otaId = req.query.ota_id !== undefined ? parseInt(req.query.ota_id, 10) : null; - const model = req.query.model; - const mac = req.query.mac; - - const where = {}; - if (otaId !== null && !isNaN(otaId)) where.ota_id = otaId; - if (mac) where.mac = { [Op.like]: `%${mac}%` }; - - // 如果按 model 筛选,需先查出对应 model 的 ota_id 列表 - if (model) { - const otaIds = await Ota.findAll({ - where: { model: { [Op.like]: `%${model}%` } }, - attributes: ['id'], - }); - const ids = otaIds.map((o) => o.id); - if (ids.length === 0) { - return res.json(ApiResponse.success({ items: [], total: 0, skip, limit })); - } - if (where.ota_id !== undefined) { - where.ota_id = { [Op.and]: [where.ota_id, { [Op.in]: ids }] }; - } else { - where.ota_id = { [Op.in]: ids }; - } - } - - const total = await BlackList.count({ where }); - const rows = await BlackList.findAll({ - where, - include: [{ - model: Ota, - as: 'ota', - attributes: ['id', 'verCode', 'verName', 'model'], - }], - offset: skip, - limit, - order: [['id', 'DESC']], - }); - - logger.info(`Found ${rows.length} BlackList records, total=${total}`); - - if (!rows.length) { - return res.json(ApiResponse.noData('empty')); - } - - const items = rows.map(blacklistToDict); - return res.json(ApiResponse.success({ items, total, skip, limit })); - } catch (e) { - logger.error(`Error getting BlackList: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// GET /api/blacklist/:id -router.get('/api/blacklist/:id', async (req, res) => { - try { - const id = parseInt(req.params.id, 10); - logger.info(`Getting BlackList: id=${id}`); - const item = await BlackList.findByPk(id, { - include: [{ - model: Ota, - as: 'ota', - attributes: ['id', 'verCode', 'verName', 'model'], - }], - }); - if (!item) { - logger.warn(`BlackList not found: id=${id}`); - return res.json(ApiResponse.noData('empty')); - } - return res.json(ApiResponse.success(blacklistToDict(item))); - } catch (e) { - logger.error(`Error getting BlackList ${req.params.id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// POST /api/blacklist/ -router.post('/api/blacklist/', async (req, res) => { - try { - const parsed = BlackListCreateSchema.safeParse(req.body); - if (!parsed.success) { - const errors = parsed.error.errors.map((e) => e.message).join('; '); - return res.json(ApiResponse.error(errors)); - } - - const data = parsed.data; - logger.info(`Creating BlackList: ota_id=${data.ota_id}, mac=${data.mac}`); - - // 校验 OTA 是否存在 - const ota = await Ota.findByPk(data.ota_id); - if (!ota) { - return res.json(ApiResponse.error('OTA 版本不存在')); - } - - const item = await BlackList.create(data); - logger.info(`BlackList created: id=${item.id}`); - - // 重新加载关联数据 - const created = await BlackList.findByPk(item.id, { - include: [{ - model: Ota, - as: 'ota', - attributes: ['id', 'verCode', 'verName', 'model'], - }], - }); - - return res.json(ApiResponse.success(blacklistToDict(created))); - } catch (e) { - logger.error(`Error creating BlackList: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// PUT /api/blacklist/:id -router.put('/api/blacklist/:id', async (req, res) => { - try { - const id = parseInt(req.params.id, 10); - - const parsed = BlackListUpdateSchema.safeParse(req.body); - if (!parsed.success) { - const errors = parsed.error.errors.map((e) => e.message).join('; '); - return res.json(ApiResponse.error(errors)); - } - - const data = parsed.data; - logger.info(`Updating BlackList: id=${id}`); - - const item = await BlackList.findByPk(id); - if (!item) { - logger.warn(`BlackList not found: id=${id}`); - return res.json(ApiResponse.noData('empty')); - } - - // 如果更新 ota_id,校验 OTA 是否存在 - if (data.ota_id !== undefined && data.ota_id !== null && data.ota_id !== item.ota_id) { - const ota = await Ota.findByPk(data.ota_id); - if (!ota) { - return res.json(ApiResponse.error('OTA 版本不存在')); - } - } - - const updateFields = Object.entries(data).filter(([_, v]) => v !== undefined && v !== null); - for (const [field, value] of updateFields) { - item[field] = value; - } - - await item.save(); - logger.info(`BlackList updated: id=${id}`); - - // 重新加载关联数据 - const updated = await BlackList.findByPk(id, { - include: [{ - model: Ota, - as: 'ota', - attributes: ['id', 'verCode', 'verName', 'model'], - }], - }); - - return res.json(ApiResponse.success(blacklistToDict(updated))); - } catch (e) { - logger.error(`Error updating BlackList ${req.params.id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// DELETE /api/blacklist/:id -router.delete('/api/blacklist/:id', async (req, res) => { - try { - const id = parseInt(req.params.id, 10); - logger.info(`Deleting BlackList: id=${id}`); - - const item = await BlackList.findByPk(id); - if (!item) { - logger.warn(`BlackList not found: id=${id}`); - return res.json(ApiResponse.noData('empty')); - } - - await item.destroy(); - logger.info(`BlackList deleted: id=${id}`); - return res.json(ApiResponse.success(null, 'success')); - } catch (e) { - logger.error(`Error deleting BlackList ${req.params.id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// BlackList 对象转字典 -function blacklistToDict(item) { - return { - id: item.id, - ota_id: item.ota_id, - mac: item.mac, - create_at: item.create_at ? item.create_at.toISOString() : null, - ota: item.ota ? { - id: item.ota.id, - verCode: item.ota.verCode, - verName: item.ota.verName, - model: item.ota.model, - } : null, - }; -} - -module.exports = router; diff --git a/dashboard/backend/src/routes/blacklist.ts b/dashboard/backend/src/routes/blacklist.ts new file mode 100644 index 0000000..23b54aa --- /dev/null +++ b/dashboard/backend/src/routes/blacklist.ts @@ -0,0 +1,242 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq, like, ne, and, inArray, desc, count, sql } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { blackLists, otas } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { BlackListCreateSchema, BlackListUpdateSchema } from '../validators/blacklist.js'; + +const router: RouterType = Router(); + +// 所有接口需登录 +router.use('/api/blacklist', authMiddleware); + +async function getBlackListWithOta(id: number) { + const [row] = await db + .select({ + id: blackLists.id, + ota_id: blackLists.otaId, + mac: blackLists.mac, + create_at: blackLists.createAt, + ota_id_2: otas.id, + ota_verCode: otas.verCode, + ota_verName: otas.verName, + ota_model: otas.model, + }) + .from(blackLists) + .leftJoin(otas, eq(blackLists.otaId, otas.id)) + .where(eq(blackLists.id, id)) + .limit(1); + return row ? blacklistToDict(row) : null; +} + +function blacklistToDict(item: Record) { + return { + id: item.id, + ota_id: item.ota_id, + mac: item.mac, + create_at: item.create_at ? (item.create_at as Date).toISOString() : null, + ota: item.ota_id_2 ? { + id: item.ota_id_2, + verCode: item.ota_verCode, + verName: item.ota_verName, + model: item.ota_model, + } : null, + }; +} + +// GET /api/blacklist/ +router.get('/api/blacklist/', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000); + const otaId = req.query.ota_id !== undefined ? parseInt(req.query.ota_id as string, 10) : null; + const model = req.query.model as string | undefined; + const mac = req.query.mac as string | undefined; + + const conditions: ReturnType[] = []; + if (otaId !== null && !isNaN(otaId)) conditions.push(eq(blackLists.otaId, otaId)); + if (mac) conditions.push(like(blackLists.mac, `%${mac}%`)); + + // 如果按 model 筛选,需先查出对应 model 的 ota_id 列表 + if (model) { + const otaRows = await db + .select({ id: otas.id }) + .from(otas) + .where(like(otas.model, `%${model}%`)); + const ids = otaRows.map((o) => o.id); + if (ids.length === 0) { + res.json(ApiResponse.success({ items: [], total: 0, skip, limit })); + return; + } + conditions.push(inArray(blackLists.otaId, ids)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ value: count() }) + .from(blackLists) + .where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db + .select({ + id: blackLists.id, + ota_id: blackLists.otaId, + mac: blackLists.mac, + create_at: blackLists.createAt, + ota_id_2: otas.id, + ota_verCode: otas.verCode, + ota_verName: otas.verName, + ota_model: otas.model, + }) + .from(blackLists) + .leftJoin(otas, eq(blackLists.otaId, otas.id)) + .where(whereClause) + .orderBy(desc(blackLists.id)) + .offset(skip) + .limit(limit); + + logger.info(`Found ${rows.length} BlackList records, total=${total}`); + + if (!rows.length) { + res.json(ApiResponse.noData('empty')); + return; + } + + const items = rows.map((r) => blacklistToDict(r as unknown as Record)); + res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting BlackList: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// GET /api/blacklist/:id +router.get('/api/blacklist/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + logger.info(`Getting BlackList: id=${id}`); + const item = await getBlackListWithOta(id); + if (!item) { + logger.warn(`BlackList not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + res.json(ApiResponse.success(item)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting BlackList: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// POST /api/blacklist/ +router.post('/api/blacklist/', async (req: Request, res: Response) => { + try { + const parsed = BlackListCreateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + res.json(ApiResponse.error(errors)); + return; + } + + const data = parsed.data; + logger.info(`Creating BlackList: ota_id=${data.ota_id}, mac=${data.mac}`); + + // 校验 OTA 是否存在 + const [ota] = await db.select().from(otas).where(eq(otas.id, data.ota_id)).limit(1); + if (!ota) { + res.json(ApiResponse.error('OTA 版本不存在')); + return; + } + + const result = await db.insert(blackLists).values({ otaId: data.ota_id, mac: data.mac }); + const newId = result[0].insertId; + logger.info(`BlackList created: id=${newId}`); + + const created = await getBlackListWithOta(newId); + res.json(ApiResponse.success(created)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error creating BlackList: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/blacklist/:id +router.put('/api/blacklist/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + + const parsed = BlackListUpdateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + res.json(ApiResponse.error(errors)); + return; + } + + const data = parsed.data; + logger.info(`Updating BlackList: id=${id}`); + + const [item] = await db.select().from(blackLists).where(eq(blackLists.id, id)).limit(1); + if (!item) { + logger.warn(`BlackList not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + + // 如果更新 ota_id,校验 OTA 是否存在 + if (data.ota_id !== undefined && data.ota_id !== null && data.ota_id !== item.otaId) { + const [ota] = await db.select().from(otas).where(eq(otas.id, data.ota_id)).limit(1); + if (!ota) { + res.json(ApiResponse.error('OTA 版本不存在')); + return; + } + } + + const updateData: Record = {}; + if (data.ota_id !== undefined && data.ota_id !== null) updateData.otaId = data.ota_id; + if (data.mac !== undefined && data.mac !== null) updateData.mac = data.mac; + + if (Object.keys(updateData).length > 0) { + await db.update(blackLists).set(updateData).where(eq(blackLists.id, id)); + } + logger.info(`BlackList updated: id=${id}`); + + const updated = await getBlackListWithOta(id); + res.json(ApiResponse.success(updated)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error updating BlackList: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/blacklist/:id +router.delete('/api/blacklist/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + logger.info(`Deleting BlackList: id=${id}`); + + const [item] = await db.select().from(blackLists).where(eq(blackLists.id, id)).limit(1); + if (!item) { + logger.warn(`BlackList not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + + await db.delete(blackLists).where(eq(blackLists.id, id)); + logger.info(`BlackList deleted: id=${id}`); + res.json(ApiResponse.success(null, 'success')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error deleting BlackList: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/brands.js b/dashboard/backend/src/routes/brands.js deleted file mode 100644 index 51a5a75..0000000 --- a/dashboard/backend/src/routes/brands.js +++ /dev/null @@ -1,146 +0,0 @@ -/** - * 品牌路由 — 对应 Python: routes/brands.py - */ -const router = require('express').Router(); -const { Op } = require('sequelize'); -const Brand = require('../models/Brand'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { authMiddleware } = require('../middleware/auth'); - -// 所有品牌接口需登录 -router.use(authMiddleware); - -// GET /api/brands/ -router.get('/api/brands/', async (req, res) => { - try { - const skip = parseInt(req.query.skip || '0', 10); - const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); - const name = req.query.name; - - const where = name ? { name: { [Op.like]: `%${name}%` } } : {}; - const { rows, count } = await Brand.findAndCountAll({ - where, - offset: skip, - limit, - }); - - logger.info(`Found ${rows.length} brands, total=${count}`); - - if (!rows.length) { - return res.json(ApiResponse.noData('empty')); - } - - const items = rows.map((b) => ({ id: b.id, name: b.name })); - return res.json(ApiResponse.success({ items, total: count, skip, limit })); - } catch (e) { - logger.error(`Error getting brands: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// GET /api/brands/:brand_id -router.get('/api/brands/:brand_id', async (req, res) => { - try { - const brandId = parseInt(req.params.brand_id, 10); - logger.info(`Getting brand: id=${brandId}`); - const brand = await Brand.findByPk(brandId); - if (!brand) { - logger.warn(`Brand not found: id=${brandId}`); - return res.json(ApiResponse.noData('empty')); - } - return res.json(ApiResponse.success({ id: brand.id, name: brand.name })); - } catch (e) { - logger.error(`Error getting brand ${req.params.brand_id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// POST /api/brands/ -router.post('/api/brands/', async (req, res) => { - try { - const name = (req.body.name || '').trim(); - if (!name) { - return res.json(ApiResponse.error('品牌名称不能为空')); - } - logger.info(`Creating brand: name=${name}`); - - const existing = await Brand.findOne({ where: { name } }); - if (existing) { - logger.warn(`Brand already exists: name=${name}`); - return res.json(ApiResponse.error('品牌名称已存在')); - } - - const brand = await Brand.create({ name }); - logger.info(`Brand created successfully: id=${brand.id}, name=${brand.name}`); - return res.json(ApiResponse.success({ id: brand.id, name: brand.name }, '品牌创建成功')); - } catch (e) { - logger.error(`Error creating brand: ${e.message}`); - return res.json(ApiResponse.error(`创建失败:${e.message}`)); - } -}); - -// PUT /api/brands/:brand_id -router.put('/api/brands/:brand_id', async (req, res) => { - try { - const brandId = parseInt(req.params.brand_id, 10); - logger.info(`Updating brand: id=${brandId}`); - - const dbBrand = await Brand.findByPk(brandId); - if (!dbBrand) { - logger.warn(`Brand not found: id=${brandId}`); - return res.json(ApiResponse.noData('品牌不存在')); - } - - if (req.body.name === undefined || req.body.name === null) { - return res.json(ApiResponse.success({ id: dbBrand.id, name: dbBrand.name }, '品牌更新成功')); - } - - const newName = req.body.name.trim(); - if (!newName) { - return res.json(ApiResponse.error('品牌名称不能为空')); - } - - const currentName = (dbBrand.name || '').trim(); - if (newName !== currentName) { - const existing = await Brand.findOne({ - where: { name: newName, id: { [Op.ne]: brandId } }, - }); - if (existing) { - logger.warn(`Brand name already exists: name=${newName}`); - return res.json(ApiResponse.error('品牌名称已存在')); - } - dbBrand.name = newName; - } - - await dbBrand.save(); - logger.info(`Brand updated successfully: id=${dbBrand.id}`); - return res.json(ApiResponse.success({ id: dbBrand.id, name: dbBrand.name }, '品牌更新成功')); - } catch (e) { - logger.error(`Error updating brand ${req.params.brand_id}: ${e.message}`); - return res.json(ApiResponse.error(`更新失败:${e.message}`)); - } -}); - -// DELETE /api/brands/:brand_id -router.delete('/api/brands/:brand_id', async (req, res) => { - try { - const brandId = parseInt(req.params.brand_id, 10); - logger.info(`Deleting brand: id=${brandId}`); - - const dbBrand = await Brand.findByPk(brandId); - if (!dbBrand) { - logger.warn(`Brand not found: id=${brandId}`); - return res.json(ApiResponse.noData('品牌不存在')); - } - - await dbBrand.destroy(); - logger.info(`Brand deleted successfully: id=${brandId}`); - return res.json(ApiResponse.success(null, '删除成功')); - } catch (e) { - logger.error(`Error deleting brand ${req.params.brand_id}: ${e.message}`); - return res.json(ApiResponse.error(`删除失败:${e.message}`)); - } -}); - -module.exports = router; diff --git a/dashboard/backend/src/routes/brands.ts b/dashboard/backend/src/routes/brands.ts new file mode 100644 index 0000000..9f2e2a5 --- /dev/null +++ b/dashboard/backend/src/routes/brands.ts @@ -0,0 +1,192 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq, like, ne, and, count } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { brands } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router: RouterType = Router(); + +// 所有品牌接口需登录 +router.use(authMiddleware); + +// GET /api/brands/ +router.get('/api/brands/', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000); + const name = req.query.name as string | undefined; + + const whereClause = name ? like(brands.name, `%${name}%`) : undefined; + + const [totalResult] = await db + .select({ value: count() }) + .from(brands) + .where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db + .select() + .from(brands) + .where(whereClause) + .offset(skip) + .limit(limit); + + logger.info(`Found ${rows.length} brands, total=${total}`); + + if (!rows.length) { + res.json(ApiResponse.noData('empty')); + return; + } + + const items = rows.map((b) => ({ id: b.id, name: b.name })); + res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting brands: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// GET /api/brands/:brand_id +router.get('/api/brands/:brand_id', async (req: Request, res: Response) => { + try { + const brandId = parseInt(req.params.brand_id as string, 10); + logger.info(`Getting brand: id=${brandId}`); + + const [brand] = await db + .select() + .from(brands) + .where(eq(brands.id, brandId)) + .limit(1); + + if (!brand) { + logger.warn(`Brand not found: id=${brandId}`); + res.json(ApiResponse.noData('empty')); + return; + } + res.json(ApiResponse.success({ id: brand.id, name: brand.name })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting brand ${req.params.brand_id}: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// POST /api/brands/ +router.post('/api/brands/', async (req: Request, res: Response) => { + try { + const name = (req.body.name || '').trim(); + if (!name) { + res.json(ApiResponse.error('品牌名称不能为空')); + return; + } + logger.info(`Creating brand: name=${name}`); + + const [existing] = await db + .select() + .from(brands) + .where(eq(brands.name, name)) + .limit(1); + + if (existing) { + logger.warn(`Brand already exists: name=${name}`); + res.json(ApiResponse.error('品牌名称已存在')); + return; + } + + const result = await db.insert(brands).values({ name }); + const newId = result[0].insertId; + logger.info(`Brand created successfully: id=${newId}, name=${name}`); + res.json(ApiResponse.success({ id: newId, name }, '品牌创建成功')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error creating brand: ${msg}`); + res.json(ApiResponse.error(`创建失败:${msg}`)); + } +}); + +// PUT /api/brands/:brand_id +router.put('/api/brands/:brand_id', async (req: Request, res: Response) => { + try { + const brandId = parseInt(req.params.brand_id as string, 10); + logger.info(`Updating brand: id=${brandId}`); + + const [dbBrand] = await db + .select() + .from(brands) + .where(eq(brands.id, brandId)) + .limit(1); + + if (!dbBrand) { + logger.warn(`Brand not found: id=${brandId}`); + res.json(ApiResponse.noData('品牌不存在')); + return; + } + + if (req.body.name === undefined || req.body.name === null) { + res.json(ApiResponse.success({ id: dbBrand.id, name: dbBrand.name }, '品牌更新成功')); + return; + } + + const newName = req.body.name.trim(); + if (!newName) { + res.json(ApiResponse.error('品牌名称不能为空')); + return; + } + + const currentName = (dbBrand.name || '').trim(); + if (newName !== currentName) { + const [existing] = await db + .select() + .from(brands) + .where(and(eq(brands.name, newName), ne(brands.id, brandId))) + .limit(1); + + if (existing) { + logger.warn(`Brand name already exists: name=${newName}`); + res.json(ApiResponse.error('品牌名称已存在')); + return; + } + } + + await db.update(brands).set({ name: newName }).where(eq(brands.id, brandId)); + logger.info(`Brand updated successfully: id=${brandId}`); + res.json(ApiResponse.success({ id: brandId, name: newName }, '品牌更新成功')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error updating brand ${req.params.brand_id}: ${msg}`); + res.json(ApiResponse.error(`更新失败:${msg}`)); + } +}); + +// DELETE /api/brands/:brand_id +router.delete('/api/brands/:brand_id', async (req: Request, res: Response) => { + try { + const brandId = parseInt(req.params.brand_id as string, 10); + logger.info(`Deleting brand: id=${brandId}`); + + const [dbBrand] = await db + .select() + .from(brands) + .where(eq(brands.id, brandId)) + .limit(1); + + if (!dbBrand) { + logger.warn(`Brand not found: id=${brandId}`); + res.json(ApiResponse.noData('品牌不存在')); + return; + } + + await db.delete(brands).where(eq(brands.id, brandId)); + logger.info(`Brand deleted successfully: id=${brandId}`); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error deleting brand ${req.params.brand_id}: ${msg}`); + res.json(ApiResponse.error(`删除失败:${msg}`)); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/dashboard.js b/dashboard/backend/src/routes/dashboard.js deleted file mode 100644 index 237c427..0000000 --- a/dashboard/backend/src/routes/dashboard.js +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Dashboard 路由 — 首页统计数据 - */ -const router = require('express').Router(); -const { Op, fn, col } = require('sequelize'); -const Model = require('../models/Model'); -const Ota = require('../models/Ota'); -const UserDevice = require('../models/UserDevice'); -const UserActive = require('../models/UserActive'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { authMiddleware } = require('../middleware/auth'); - -// 所有 dashboard 接口需登录 -router.use(authMiddleware); - -/** - * GET /api/dashboard/today - * 返回今日新增的耳机型号和 OTA 数据 - */ -router.get('/api/dashboard/today', async (req, res) => { - try { - // 今日 00:00:00 (UTC+8) - const now = new Date(); - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - // 今日新增型号 - const newModels = await Model.findAll({ - where: { - create_at: { [Op.gte]: todayStart }, - }, - order: [['id', 'DESC']], - attributes: ['id', 'brand_name', 'name', 'create_at'], - }); - - // 今日新增 OTA - const newOtas = await Ota.findAll({ - where: { - create_at: { [Op.gte]: todayStart }, - }, - order: [['id', 'DESC']], - attributes: ['id', 'verName', 'model', 'create_at'], - }); - - return res.json( - ApiResponse.success({ - models: newModels.map((m) => ({ - id: m.id, - brand_name: m.brand_name, - name: m.name, - create_at: m.create_at ? m.create_at.toISOString() : null, - })), - otas: newOtas.map((o) => ({ - id: o.id, - verName: o.verName, - model: o.model, - create_at: o.create_at ? o.create_at.toISOString() : null, - })), - }) - ); - } catch (e) { - logger.error(`Error getting dashboard today stats: ${e.message}`); - return res.json(ApiResponse.error(`获取统计数据失败:${e.message}`)); - } -}); - -/** - * GET /api/dashboard/device-daily?days=30 - * 返回最近 N 天每天各设备型号的新增数量(堆叠柱状图数据) - * 返回格式:{ dates: ['2026-06-16', ...], series: [{ name: 'Luxsin-X9', data: [5, 3, ...] }, ...], total: 123 } - */ -router.get('/api/dashboard/device-daily', async (req, res) => { - try { - const days = Math.min(Math.max(parseInt(req.query.days || '30', 10) || 30, 1), 365); - const now = new Date(); - - // ===== 今天:按小时分组 ===== - if (days === 1) { - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - const rows = await UserDevice.findAll({ - attributes: [ - [fn('HOUR', col('add_time')), 'hour'], - 'model', - [fn('COUNT', col('id')), 'count'], - ], - where: { add_time: { [Op.gte]: todayStart } }, - group: [fn('HOUR', col('add_time')), 'model'], - order: [[fn('HOUR', col('add_time')), 'ASC']], - raw: true, - }); - - const currentHour = now.getHours(); - const hourList = Array.from({ length: currentHour + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`); - const modelSet = new Set(); - const dataMap = new Map(); - for (const row of rows) { - const hourStr = `${String(parseInt(row.hour, 10)).padStart(2, '0')}:00`; - modelSet.add(row.model); - if (!dataMap.has(hourStr)) dataMap.set(hourStr, {}); - dataMap.get(hourStr)[row.model] = parseInt(row.count, 10); - } - - const models = [...modelSet].sort(); - const series = models.map((m) => ({ - name: m, - data: hourList.map((h) => dataMap.get(h)?.[m] || 0), - })); - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); - - return res.json(ApiResponse.success({ dates: hourList, series, total })); - } - - // ===== 多天:按日期分组 ===== - const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1); - - // 按日期 + 型号分组统计 - const rows = await UserDevice.findAll({ - attributes: [ - [fn('DATE', col('add_time')), 'date'], - 'model', - [fn('COUNT', col('id')), 'count'], - ], - where: { - add_time: { [Op.gte]: startDate }, - }, - group: [fn('DATE', col('add_time')), 'model'], - order: [[fn('DATE', col('add_time')), 'ASC']], - raw: true, - }); - - // 构建日期序列(补全没有数据的日期) - const dateList = []; - const tmp = new Date(startDate); - while (tmp <= now) { - dateList.push(tmp.toISOString().slice(0, 10)); - tmp.setDate(tmp.getDate() + 1); - } - - // 收集所有型号 - const modelSet = new Set(); - const dataMap = new Map(); // date -> { model -> count } - for (const row of rows) { - const dateStr = row.date instanceof Date ? row.date.toISOString().slice(0, 10) : String(row.date).slice(0, 10); - modelSet.add(row.model); - if (!dataMap.has(dateStr)) dataMap.set(dateStr, {}); - dataMap.get(dateStr)[row.model] = parseInt(row.count, 10); - } - - // 构建系列数据 - const models = [...modelSet].sort(); - const series = models.map((m) => ({ - name: m, - data: dateList.map((d) => dataMap.get(d)?.[m] || 0), - })); - - const total = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); - - return res.json( - ApiResponse.success({ - dates: dateList, - series, - total, - }) - ); - } catch (e) { - logger.error(`Error getting device daily stats: ${e.message}`); - return res.json(ApiResponse.error(`获取设备统计数据失败:${e.message}`)); - } -}); - -/** - * GET /api/dashboard/active-daily?days=30 - * 返回最近 N 天每天各设备型号的日活数量(折线图数据) - * 返回格式:{ dates: ['2026-06-16', ...], series: [{ name: 'Luxsin-X9', data: [5, 3, ...] }, ...], latest: { date, total } } - */ -router.get('/api/dashboard/active-daily', async (req, res) => { - try { - const days = Math.min(Math.max(parseInt(req.query.days || '30', 10) || 30, 1), 365); - const now = new Date(); - - // ===== 今天:按小时分组(用 create_at 的时间维度)===== - if (days === 1) { - const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - - const rows = await UserActive.findAll({ - attributes: [ - [fn('HOUR', col('create_at')), 'hour'], - 'model', - [fn('COUNT', col('id')), 'count'], - ], - where: { create_at: { [Op.gte]: todayStart } }, - group: [fn('HOUR', col('create_at')), 'model'], - order: [[fn('HOUR', col('create_at')), 'ASC']], - raw: true, - }); - - const currentHour = now.getHours(); - const hourList = Array.from({ length: currentHour + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`); - const modelSet = new Set(); - const dataMap = new Map(); - for (const row of rows) { - const hourStr = `${String(parseInt(row.hour, 10)).padStart(2, '0')}:00`; - modelSet.add(row.model); - if (!dataMap.has(hourStr)) dataMap.set(hourStr, {}); - dataMap.get(hourStr)[row.model] = parseInt(row.count, 10); - } - - const models = [...modelSet].sort(); - const series = models.map((m) => ({ - name: m, - data: hourList.map((h) => dataMap.get(h)?.[m] || 0), - })); - - const todayTotal = rows.reduce((sum, r) => sum + parseInt(r.count, 10), 0); - const latest = { date: '今日', total: todayTotal }; - - return res.json(ApiResponse.success({ dates: hourList, series, latest })); - } - - // ===== 多天:按日期分组 ===== - const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1); - const startDateStr = startDate.toISOString().slice(0, 10); - - // 按活跃日期 + 型号分组统计(active_date 为 DATE 类型,唯一键保证每设备每天一条) - const rows = await UserActive.findAll({ - attributes: [ - 'active_date', - 'model', - [fn('COUNT', col('id')), 'count'], - ], - where: { - active_date: { [Op.gte]: startDateStr }, - }, - group: ['active_date', 'model'], - order: [['active_date', 'ASC']], - raw: true, - }); - - // 构建日期序列(补全没有数据的日期) - const dateList = []; - const tmp = new Date(startDate); - while (tmp <= now) { - dateList.push(tmp.toISOString().slice(0, 10)); - tmp.setDate(tmp.getDate() + 1); - } - - // 收集所有型号 - const modelSet = new Set(); - const dataMap = new Map(); // date -> { model -> count } - for (const row of rows) { - const dateStr = String(row.active_date).slice(0, 10); - modelSet.add(row.model); - if (!dataMap.has(dateStr)) dataMap.set(dateStr, {}); - dataMap.get(dateStr)[row.model] = parseInt(row.count, 10); - } - - // 构建系列数据 - const models = [...modelSet].sort(); - const series = models.map((m) => ({ - name: m, - data: dateList.map((d) => dataMap.get(d)?.[m] || 0), - })); - - // 计算每天总日活,取最近一个有数据的日子作为「最新一天总日活」 - const dailyTotals = dateList.map((d) => models.reduce((sum, m) => sum + (dataMap.get(d)?.[m] || 0), 0)); - let latestIdx = dailyTotals.length - 1; - while (latestIdx > 0 && dailyTotals[latestIdx] === 0) latestIdx--; - const latest = { date: dateList[latestIdx], total: dailyTotals[latestIdx] }; - - return res.json( - ApiResponse.success({ - dates: dateList, - series, - latest, - }) - ); - } catch (e) { - logger.error(`Error getting active daily stats: ${e.message}`); - return res.json(ApiResponse.error(`获取设备日活统计数据失败:${e.message}`)); - } -}); - -module.exports = router; diff --git a/dashboard/backend/src/routes/dashboard.ts b/dashboard/backend/src/routes/dashboard.ts new file mode 100644 index 0000000..db78692 --- /dev/null +++ b/dashboard/backend/src/routes/dashboard.ts @@ -0,0 +1,263 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { gte, desc, sql } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { models, otas, userDevices, userActives } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router: RouterType = Router(); + +// 所有 dashboard 接口需登录 +router.use(authMiddleware); + +/** + * GET /api/dashboard/today + * 返回今日新增的耳机型号和 OTA 数据 + */ +router.get('/api/dashboard/today', async (_req: Request, res: Response) => { + try { + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + // 今日新增型号 + const newModels = await db + .select({ + id: models.id, + brand_name: models.brandName, + name: models.name, + create_at: models.createAt, + }) + .from(models) + .where(gte(models.createAt, todayStart)) + .orderBy(desc(models.id)); + + // 今日新增 OTA + const newOtas = await db + .select({ + id: otas.id, + verName: otas.verName, + model: otas.model, + create_at: otas.createAt, + }) + .from(otas) + .where(gte(otas.createAt, todayStart)) + .orderBy(desc(otas.id)); + + res.json(ApiResponse.success({ + models: newModels.map((m) => ({ + id: m.id, + brand_name: m.brand_name, + name: m.name, + create_at: m.create_at ? m.create_at.toISOString() : null, + })), + otas: newOtas.map((o) => ({ + id: o.id, + verName: o.verName, + model: o.model, + create_at: o.create_at ? o.create_at.toISOString() : null, + })), + })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting dashboard today stats: ${msg}`); + res.json(ApiResponse.error(`获取统计数据失败:${msg}`)); + } +}); + +interface RawGroupRow { + dateOrHour: string; + model: string; + count: number; +} + +/** + * GET /api/dashboard/device-daily?days=30 + * 返回最近 N 天每天各设备型号的新增数量(堆叠柱状图数据) + */ +router.get('/api/dashboard/device-daily', async (req: Request, res: Response) => { + try { + const days = Math.min(Math.max(parseInt(req.query.days as string || '30', 10) || 30, 1), 365); + const now = new Date(); + + // ===== 今天:按小时分组 ===== + if (days === 1) { + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const rows = await db + .select({ + hour: sql`HOUR(add_time)`.as('hour'), + model: userDevices.model, + count: sql`COUNT(*)`.as('count'), + }) + .from(userDevices) + .where(gte(userDevices.addTime, todayStart)) + .groupBy(sql`HOUR(add_time)`, userDevices.model) + .orderBy(sql`HOUR(add_time)`); + + const currentHour = now.getHours(); + const hourList = Array.from({ length: currentHour + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`); + const modelSet = new Set(); + const dataMap = new Map>(); + + for (const row of rows) { + const hourStr = `${String(parseInt(row.hour, 10)).padStart(2, '0')}:00`; + modelSet.add(row.model); + if (!dataMap.has(hourStr)) dataMap.set(hourStr, {}); + dataMap.get(hourStr)![row.model] = Number(row.count); + } + + const modelNames = [...modelSet].sort(); + const series = modelNames.map((m) => ({ + name: m, + data: hourList.map((h) => dataMap.get(h)?.[m] || 0), + })); + const total = rows.reduce((sum, r) => sum + Number(r.count), 0); + + res.json(ApiResponse.success({ dates: hourList, series, total })); + return; + } + + // ===== 多天:按日期分组 ===== + const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1); + + const rows: RawGroupRow[] = await db + .select({ + dateOrHour: sql`DATE(add_time)`.as('date'), + model: userDevices.model, + count: sql`COUNT(*)`.as('count'), + }) + .from(userDevices) + .where(gte(userDevices.addTime, startDate)) + .groupBy(sql`DATE(add_time)`, userDevices.model) + .orderBy(sql`DATE(add_time)`); + + const dateList: string[] = []; + const tmp = new Date(startDate); + while (tmp <= now) { + dateList.push(tmp.toISOString().slice(0, 10)); + tmp.setDate(tmp.getDate() + 1); + } + + const modelSet = new Set(); + const dataMap = new Map>(); + for (const row of rows) { + const dateStr = String(row.dateOrHour).slice(0, 10); + modelSet.add(row.model); + if (!dataMap.has(dateStr)) dataMap.set(dateStr, {}); + dataMap.get(dateStr)![row.model] = Number(row.count); + } + + const modelNames = [...modelSet].sort(); + const series = modelNames.map((m) => ({ + name: m, + data: dateList.map((d) => dataMap.get(d)?.[m] || 0), + })); + const total = rows.reduce((sum, r) => sum + Number(r.count), 0); + + res.json(ApiResponse.success({ dates: dateList, series, total })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting device daily stats: ${msg}`); + res.json(ApiResponse.error(`获取设备统计数据失败:${msg}`)); + } +}); + +/** + * GET /api/dashboard/active-daily?days=30 + * 返回最近 N 天每天各设备型号的日活数量(折线图数据) + */ +router.get('/api/dashboard/active-daily', async (req: Request, res: Response) => { + try { + const days = Math.min(Math.max(parseInt(req.query.days as string || '30', 10) || 30, 1), 365); + const now = new Date(); + + // ===== 今天:按小时分组 ===== + if (days === 1) { + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + + const rows = await db + .select({ + hour: sql`HOUR(create_at)`.as('hour'), + model: userActives.model, + count: sql`COUNT(*)`.as('count'), + }) + .from(userActives) + .where(gte(userActives.createAt, todayStart)) + .groupBy(sql`HOUR(create_at)`, userActives.model) + .orderBy(sql`HOUR(create_at)`); + + const currentHour = now.getHours(); + const hourList = Array.from({ length: currentHour + 1 }, (_, h) => `${String(h).padStart(2, '0')}:00`); + const modelSet = new Set(); + const dataMap = new Map>(); + + for (const row of rows) { + const hourStr = `${String(parseInt(row.hour, 10)).padStart(2, '0')}:00`; + modelSet.add(row.model); + if (!dataMap.has(hourStr)) dataMap.set(hourStr, {}); + dataMap.get(hourStr)![row.model] = Number(row.count); + } + + const modelNames = [...modelSet].sort(); + const series = modelNames.map((m) => ({ + name: m, + data: hourList.map((h) => dataMap.get(h)?.[m] || 0), + })); + const todayTotal = rows.reduce((sum, r) => sum + Number(r.count), 0); + const latest = { date: '今日', total: todayTotal }; + + res.json(ApiResponse.success({ dates: hourList, series, latest })); + return; + } + + // ===== 多天:按日期分组 ===== + const startDate = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1); + + const rows = await db + .select({ + active_date: userActives.activeDate, + model: userActives.model, + count: sql`COUNT(*)`.as('count'), + }) + .from(userActives) + .where(gte(userActives.activeDate, startDate)) + .groupBy(userActives.activeDate, userActives.model) + .orderBy(userActives.activeDate); + + const dateList: string[] = []; + const tmp = new Date(startDate); + while (tmp <= now) { + dateList.push(tmp.toISOString().slice(0, 10)); + tmp.setDate(tmp.getDate() + 1); + } + + const modelSet = new Set(); + const dataMap = new Map>(); + for (const row of rows) { + const dateStr = String(row.active_date).slice(0, 10); + modelSet.add(row.model); + if (!dataMap.has(dateStr)) dataMap.set(dateStr, {}); + dataMap.get(dateStr)![row.model] = Number(row.count); + } + + const modelNames = [...modelSet].sort(); + const series = modelNames.map((m) => ({ + name: m, + data: dateList.map((d) => dataMap.get(d)?.[m] || 0), + })); + + const dailyTotals = dateList.map((d) => modelNames.reduce((sum, m) => sum + (dataMap.get(d)?.[m] || 0), 0)); + let latestIdx = dailyTotals.length - 1; + while (latestIdx > 0 && dailyTotals[latestIdx] === 0) latestIdx--; + const latest = { date: dateList[latestIdx], total: dailyTotals[latestIdx] }; + + res.json(ApiResponse.success({ dates: dateList, series, latest })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting active daily stats: ${msg}`); + res.json(ApiResponse.error(`获取设备日活统计数据失败:${msg}`)); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/index.js b/dashboard/backend/src/routes/index.js deleted file mode 100644 index 9230159..0000000 --- a/dashboard/backend/src/routes/index.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 路由汇总 — 对应 Python: routes/__init__.py - */ -const authRouter = require('./auth'); -const brandsRouter = require('./brands'); -const modelsRouter = require('./models'); -const otaRouter = require('./ota'); -const blacklistRouter = require('./blacklist'); -const otaTargetDeviceRouter = require('./otaTargetDevice'); -const shareCodeLogsRouter = require('./shareCodeLogs'); -const usersRouter = require('./users'); -const dashboardRouter = require('./dashboard'); - -module.exports = [authRouter, brandsRouter, modelsRouter, otaRouter, blacklistRouter, otaTargetDeviceRouter, shareCodeLogsRouter, usersRouter, dashboardRouter]; diff --git a/dashboard/backend/src/routes/index.ts b/dashboard/backend/src/routes/index.ts new file mode 100644 index 0000000..0127e2f --- /dev/null +++ b/dashboard/backend/src/routes/index.ts @@ -0,0 +1,24 @@ +import type { Router } from 'express'; +import authRouter from './auth.js'; +import brandsRouter from './brands.js'; +import modelsRouter from './models.js'; +import otaRouter from './ota.js'; +import blacklistRouter from './blacklist.js'; +import otaTargetDeviceRouter from './otaTargetDevice.js'; +import shareCodeLogsRouter from './shareCodeLogs.js'; +import usersRouter from './users.js'; +import dashboardRouter from './dashboard.js'; + +const routes: Router[] = [ + authRouter, + brandsRouter, + modelsRouter, + otaRouter, + blacklistRouter, + otaTargetDeviceRouter, + shareCodeLogsRouter, + usersRouter, + dashboardRouter, +]; + +export default routes; diff --git a/dashboard/backend/src/routes/models.js b/dashboard/backend/src/routes/models.js deleted file mode 100644 index f21b9ad..0000000 --- a/dashboard/backend/src/routes/models.js +++ /dev/null @@ -1,763 +0,0 @@ -/** - * 型号路由 — 对应 Python: routes/models.py - */ -const router = require('express').Router(); -const { Op } = require('sequelize'); -const multer = require('multer'); -const path = require('path'); -const axios = require('axios'); -const Model = require('../models/Model'); -const Brand = require('../models/Brand'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { authMiddleware } = require('../middleware/auth'); -const { fetchAndValidateCurve } = require('../services/curveClient'); -const { uploadMeasurementToS3, getMeasurementFromS3, moveMeasurementOnS3 } = require('../services/measurementStorage'); -const { getEqCacheKeys, getEqCacheField } = require('../services/eqCacheStorage'); -const { fetchFromSquigLink, downloadTxtFile, isDirectTxtUrl, fetchDirectTxtUrl } = require('../services/squiglink'); - -/** - * 将 TXT 频响文件内容转换为 CSV 格式 - * 支持两种格式: - * 1. REW 导出格式:以 "* Freq(Hz)" 行作为数据起点,空格分隔 - * 2. 纯数据格式:直接 tab/空格分隔,无前导注释 - * @param {Buffer} buffer - TXT 文件 Buffer - * @returns {string} CSV 内容(含 frequency,raw 表头) - */ -function convertTxtToCsv(buffer) { - const text = buffer.toString('utf-8'); - const lines = text.split(/\r?\n/); - - // 查找 "* Freq(Hz)" 头行索引 - let startIdx = -1; - for (let i = 0; i < lines.length; i++) { - if (lines[i].startsWith('* Freq(Hz)')) { - startIdx = i; - break; - } - } - - // 数据行:头行之后,或无前导注释时从第一行开始 - const dataLines = startIdx >= 0 ? lines.slice(startIdx + 1) : lines; - - const csvRows = ['frequency,raw']; - for (const line of dataLines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('*')) continue; - const parts = trimmed.split(/[;,\s]+/); - if (parts.length >= 2 && /^[-+]?\d/.test(parts[0])) { - csvRows.push(`${parts[0]},${parts[1]}`); - } - } - - return csvRows.join('\n'); -} - -/** - * 处理上传的频响文件: - * - .txt → 转换为 CSV,返回 { buffer, filename } (扩展名改为 .csv) - * - 其他格式 → 原样返回 - */ -function processUploadedFile(buffer, originalname) { - const ext = path.extname(originalname).toLowerCase(); - if (ext === '.txt') { - const csvContent = convertTxtToCsv(buffer); - const csvFilename = originalname.replace(/\.txt$/i, '.csv'); - logger.info(`Converted TXT to CSV: ${originalname} -> ${csvFilename}`); - return { buffer: Buffer.from(csvContent, 'utf-8'), filename: csvFilename }; - } - return { buffer, filename: originalname }; -} - -// 所有型号接口需登录 -router.use(authMiddleware); - -// Multer 配置 — 内存存储 -const upload = multer({ storage: multer.memoryStorage() }); - -// Meilisearch 配置 -const MEILISEARCH_URL = process.env.MEILISEARCH_URL || 'http://localhost:7700'; -const MEILISEARCH_API_KEY = process.env.MEILISEARCH_API_KEY || ''; -const MEILISEARCH_INDEX = process.env.MEILISEARCH_INDEX || 'models'; - -function meilisearchHeaders() { - return { - Authorization: `Bearer ${MEILISEARCH_API_KEY}`, - 'Content-Type': 'application/json', - }; -} - -/** 从 Meilisearch 获取型号文档(文档 id 与数据库 id 一致) */ -async function getModelFromMeilisearch(modelId) { - try { - const response = await axios.get( - `${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents/${modelId}`, - { headers: meilisearchHeaders(), timeout: 30000 } - ); - if (response.status !== 200) { - throw new Error(JSON.stringify(response.data)); - } - return response.data; - } catch (e) { - if (e.response?.status === 404) { - return null; - } - const detail = e.response?.data ? JSON.stringify(e.response.data) : e.message; - throw new Error(detail); - } -} - -/** 从 Meilisearch 删除型号文档(文档 id 与数据库 id 一致) */ -async function deleteModelFromMeilisearch(modelId) { - try { - const response = await axios.delete( - `${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents/${modelId}`, - { headers: meilisearchHeaders(), timeout: 30000 } - ); - if (![200, 202].includes(response.status)) { - throw new Error(JSON.stringify(response.data)); - } - logger.info(`Meilisearch document deleted: id=${modelId}, taskUid=${response.data?.taskUid}`); - return response.data; - } catch (e) { - if (e.response?.status === 404) { - logger.warn(`Meilisearch document not found, skip: id=${modelId}`); - return null; - } - const detail = e.response?.data ? JSON.stringify(e.response.data) : e.message; - throw new Error(detail); - } -} - -// 文件上传配置 -const ALLOWED_EXTENSIONS = ['.csv', '.txt', '.json']; - -// GET /api/models/ -router.get('/api/models/', async (req, res) => { - try { - const skip = parseInt(req.query.skip || '0', 10); - const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); - const brandName = req.query.brand_name; - const name = req.query.name; - const sortBy = req.query.sort_by || 'id'; - const sortOrder = (req.query.sort_order || 'desc').toLowerCase(); - - const where = {}; - if (brandName) where.brand_name = { [Op.like]: `%${brandName}%` }; - if (name) where.name = { [Op.like]: `%${name}%` }; - - const total = await Model.count({ where }); - - const orderDir = sortOrder === 'asc' ? 'ASC' : 'DESC'; - const orderCol = sortBy === 'create_at' ? 'create_at' : 'id'; - - const rows = await Model.findAll({ - where, - offset: skip, - limit, - order: [[orderCol, orderDir]], - }); - - logger.info(`Found ${rows.length} models, total=${total}`); - - if (!rows.length) { - return res.json(ApiResponse.noData('empty')); - } - - const items = rows.map((m) => ({ - id: m.id, - brand_name: m.brand_name, - name: m.name, - form: m.form, - rig: m.rig, - source: m.source, - eq_key: m.eq_key, - create_at: m.create_at ? m.create_at.toISOString() : null, - })); - - return res.json(ApiResponse.success({ items, total, skip, limit })); - } catch (e) { - logger.error(`Error getting models: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// GET /api/models/:model_id/eq-cache — 查看 Redis EQ 缓存 field 列表 -router.get('/api/models/:model_id/eq-cache', async (req, res) => { - try { - const modelId = parseInt(req.params.model_id, 10); - const model = await Model.findByPk(modelId); - if (!model) { - return res.json(ApiResponse.noData('empty')); - } - - const { redis_key, field_keys } = await getEqCacheKeys(model.brand_name, model.name); - return res.json(ApiResponse.success({ - redis_key, - field_keys, - })); - } catch (e) { - logger.error(`Error reading EQ cache for model ${req.params.model_id}: ${e.message}`); - return res.json(ApiResponse.error(e.message || 'error')); - } -}); - -// GET /api/models/:model_id/eq-cache/field?key= — 查看单个 hash field 的 value -router.get('/api/models/:model_id/eq-cache/field', async (req, res) => { - try { - const modelId = parseInt(req.params.model_id, 10); - const fieldKey = (req.query.key || '').trim(); - if (!fieldKey) { - return res.json(ApiResponse.error('缺少 key 参数')); - } - - const model = await Model.findByPk(modelId); - if (!model) { - return res.json(ApiResponse.noData('empty')); - } - - const data = await getEqCacheField(model.brand_name, model.name, fieldKey); - return res.json(ApiResponse.success(data)); - } catch (e) { - logger.error(`Error reading EQ cache field for model ${req.params.model_id}: ${e.message}`); - return res.json(ApiResponse.error(e.message || 'error')); - } -}); - -// GET /api/models/:model_id/meilisearch — 查看 Meilisearch 中的推送数据 -router.get('/api/models/:model_id/meilisearch', async (req, res) => { - try { - const modelId = parseInt(req.params.model_id, 10); - const model = await Model.findByPk(modelId); - if (!model) { - return res.json(ApiResponse.noData('empty')); - } - - const document = await getModelFromMeilisearch(modelId); - if (!document) { - return res.json(ApiResponse.success({ pushed: false, document: null })); - } - - return res.json(ApiResponse.success({ pushed: true, document })); - } catch (e) { - logger.error(`Error reading Meilisearch document for model ${req.params.model_id}: ${e.message}`); - return res.json(ApiResponse.error(`查询 Meilisearch 失败:${e.message}`)); - } -}); - -// GET /api/models/:model_id/measurement — 查看 S3 上的频响 CSV(仅 Eafonyoung) -router.get('/api/models/:model_id/measurement', async (req, res) => { - try { - const modelId = parseInt(req.params.model_id, 10); - const model = await Model.findByPk(modelId); - if (!model) { - return res.json(ApiResponse.noData('empty')); - } - - const source = (model.source || '').trim(); - if (source.toLowerCase() !== 'eafonyoung') { - return res.json(ApiResponse.error('仅支持查看来源为 Eafonyoung 的频响文件')); - } - if (!model.form) { - return res.json(ApiResponse.error('该型号缺少佩戴方式,无法定位 S3 文件')); - } - - const { key, content } = await getMeasurementFromS3({ - source: model.source, - form: model.form, - brandName: model.brand_name, - modelName: model.name, - }); - - return res.json(ApiResponse.success({ - s3_key: key, - content, - })); - } catch (e) { - logger.error(`Error reading measurement for model ${req.params.model_id}: ${e.message}`); - return res.json(ApiResponse.error(e.message || 'error')); - } -}); - -// GET /api/models/:model_id -router.get('/api/models/:model_id', async (req, res) => { - try { - const modelId = parseInt(req.params.model_id, 10); - logger.info(`Getting model: id=${modelId}`); - const model = await Model.findByPk(modelId); - if (!model) { - logger.warn(`Model not found: id=${modelId}`); - return res.json(ApiResponse.noData('empty')); - } - return res.json(ApiResponse.success({ - id: model.id, - brand_name: model.brand_name, - name: model.name, - form: model.form, - rig: model.rig, - source: model.source, - eq_key: model.eq_key, - create_at: model.create_at ? model.create_at.toISOString() : null, - })); - } catch (e) { - logger.error(`Error getting model ${req.params.model_id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// POST /api/models/squiglink-fetch — 从 squig.link share URL 或直接 TXT URL 抓取频响数据 -router.post('/api/models/squiglink-fetch', async (req, res) => { - try { - const { share_url, selected_file } = req.body; - if (!share_url) { - return res.json(ApiResponse.error('缺少 share_url 参数')); - } - - logger.info(`SquigLink fetch: ${share_url}${selected_file ? ' (selected=' + selected_file + ')' : ''}`); - - // 直接 TXT 文件 URL:无需 phone_book 匹配,直接下载解析 - if (!selected_file && isDirectTxtUrl(share_url)) { - const result = await fetchDirectTxtUrl(share_url); - const csvContent = convertTxtToCsv(result.buffer); - return res.json(ApiResponse.success({ - brand_name: result.brandName, - model_name: result.modelName, - form: result.form || null, - csv_content: csvContent, - data_url: result.url, - matches: [], - })); - } - - // 如果用户从候选列表中选择了特定文件,直接下载 - if (selected_file) { - const { parseShareUrl, detectFormFromUrl } = require('../services/squiglink'); - const { baseUrl, pathPrefix } = parseShareUrl(share_url); - const [downloadResult, detectedForm] = await Promise.all([ - downloadTxtFile(baseUrl, selected_file, pathPrefix), - detectFormFromUrl(baseUrl, pathPrefix), - ]); - const csvContent = convertTxtToCsv(downloadResult.buffer); - // 从 file 名解析品牌和型号 - const parts = selected_file.split(' '); - const brandName = parts.length > 1 ? parts[0] : ''; - const modelName = parts.length > 1 ? parts.slice(1).join(' ') : selected_file; - return res.json(ApiResponse.success({ - brand_name: brandName, - model_name: modelName, - form: detectedForm || null, - csv_content: csvContent, - data_url: downloadResult.url, - matches: [], - })); - } - - // 自动抓取 - const result = await fetchFromSquigLink(share_url); - - // 多个候选,返回给前端选择 - if (result.matches.length > 1) { - return res.json(ApiResponse.success({ - brand_name: '', - model_name: '', - form: result.form || null, - csv_content: null, - data_url: null, - matches: result.matches, - })); - } - - // 单个匹配,返回 CSV 内容 - const csvContent = convertTxtToCsv(result.buffer); - return res.json(ApiResponse.success({ - brand_name: result.brandName, - model_name: result.modelName, - form: result.form || null, - csv_content: csvContent, - data_url: result.dataUrl, - matches: [], - })); - } catch (e) { - logger.error(`SquigLink fetch error: ${e.message}`); - return res.json(ApiResponse.error(e.message || '抓取失败')); - } -}); - -// POST /api/models/ (multipart/form-data) -router.post('/api/models/', upload.single('measurement_file'), async (req, res) => { - try { - const { brand_name, name, form, rig, source, eq_key } = req.body; - logger.info(`Creating model: brand_name=${brand_name}, name=${name}, form=${form}, source=${source}`); - - // 检查是否已存在 - const existing = await Model.findOne({ where: { brand_name, name } }); - if (existing) { - logger.warn(`Model already exists: brand_name=${brand_name}, name=${name}`); - return res.json(ApiResponse.error('该品牌下型号名称已存在')); - } - - // 处理文件上传(优先 squiglink_csv,其次文件上传) - const { squiglink_csv } = req.body; - if (squiglink_csv) { - // 从 squig.link 抓取的 CSV 内容直接上传 S3 - const csvBuffer = Buffer.from(squiglink_csv, 'utf-8'); - const s3Key = await uploadMeasurementToS3(csvBuffer, { - source: source || 'Eafonyoung', - form: form || 'in-ear', - brandName: brand_name, - modelName: name, - }); - logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`); - } else if (req.file && req.file.originalname) { - const fileExt = path.extname(req.file.originalname).toLowerCase(); - if (!ALLOWED_EXTENSIONS.includes(fileExt)) { - logger.error(`Unsupported file format: ${fileExt}`); - return res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`)); - } - - const { buffer: fileBuffer } = processUploadedFile(req.file.buffer, req.file.originalname); - const s3Key = await uploadMeasurementToS3(fileBuffer, { - source: source || 'Eafonyoung', - form: form || 'in-ear', - brandName: brand_name, - modelName: name, - }); - logger.info(`Measurement file uploaded to S3: ${s3Key}`); - } - - const dbModel = await Model.create({ - brand_name, - name, - form: form || null, - rig: rig || null, - source: source || null, - eq_key: eq_key || null, - }); - - logger.info(`Model created successfully: id=${dbModel.id}`); - return res.json(ApiResponse.success({ - id: dbModel.id, - brand_name: dbModel.brand_name, - name: dbModel.name, - form: dbModel.form, - rig: dbModel.rig, - source: dbModel.source, - eq_key: dbModel.eq_key, - create_at: dbModel.create_at ? dbModel.create_at.toISOString() : null, - })); - } catch (e) { - logger.error(`Error creating model: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// PUT /api/models/:model_id (multipart/form-data) -router.put('/api/models/:model_id', upload.single('measurement_file'), async (req, res) => { - try { - const modelId = parseInt(req.params.model_id, 10); - const { brand_name, name, form, rig, source, eq_key } = req.body; - - logger.info(`Updating model: id=${modelId}, brand_name=${brand_name}, name=${name}, form=${form}`); - - const dbModel = await Model.findByPk(modelId); - if (!dbModel) { - logger.warn(`Model not found: id=${modelId}`); - return res.json(ApiResponse.noData('empty')); - } - - const newBrandName = brand_name === undefined || brand_name === 'null' ? dbModel.brand_name : brand_name; - const newName = name === undefined || name === 'null' ? dbModel.name : name; - - if (newBrandName !== dbModel.brand_name || newName !== dbModel.name) { - const existing = await Model.findOne({ - where: { - brand_name: newBrandName, - name: newName, - id: { [Op.ne]: modelId }, - }, - }); - if (existing) { - logger.warn(`Model already exists: brand_name=${newBrandName}, name=${newName}`); - return res.json(ApiResponse.error('该品牌下型号名称已存在')); - } - } - - const effSource = source === undefined || source === 'null' ? dbModel.source : source; - const effForm = form === undefined || form === 'null' ? dbModel.form : form; - - // 处理频响文件(优先 squiglink_csv,其次文件上传) - const { squiglink_csv } = req.body; - if (squiglink_csv) { - if (!effSource || !effForm) { - return res.json(ApiResponse.error('上传频响文件需要来源与形式字段')); - } - const csvBuffer = Buffer.from(squiglink_csv, 'utf-8'); - const s3Key = await uploadMeasurementToS3(csvBuffer, { - source: effSource, - form: effForm, - brandName: newBrandName, - modelName: newName, - }); - logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`); - } else if (req.file && req.file.originalname) { - const fileExt = path.extname(req.file.originalname).toLowerCase(); - if (!ALLOWED_EXTENSIONS.includes(fileExt)) { - logger.error(`Unsupported file format: ${fileExt}`); - return res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`)); - } - if (!effSource || !effForm) { - return res.json(ApiResponse.error('上传频响文件需要来源与形式字段')); - } - const { buffer: fileBuffer } = processUploadedFile(req.file.buffer, req.file.originalname); - const s3Key = await uploadMeasurementToS3(fileBuffer, { - source: effSource, - form: effForm, - brandName: newBrandName, - modelName: newName, - }); - logger.info(`Measurement file uploaded to S3: ${s3Key}`); - } - - // 若未上传新文件,且路径相关字段发生变更,则迁移 S3 上的旧 CSV - const hasNewFile = squiglink_csv || (req.file && req.file.originalname); - if (!hasNewFile) { - await moveMeasurementOnS3( - { source: dbModel.source, form: dbModel.form, brandName: dbModel.brand_name, modelName: dbModel.name }, - { source: effSource, form: effForm, brandName: newBrandName, modelName: newName } - ); - } - - if (brand_name !== undefined && brand_name !== 'null') dbModel.brand_name = brand_name; - if (name !== undefined && name !== 'null') dbModel.name = name; - if (form !== undefined && form !== 'null') dbModel.form = form; - if (rig !== undefined && rig !== 'null') dbModel.rig = rig; - if (source !== undefined && source !== 'null') dbModel.source = source; - if (eq_key !== undefined && eq_key !== 'null') dbModel.eq_key = eq_key; - - await dbModel.save(); - logger.info(`Model updated successfully: id=${dbModel.id}`); - - return res.json(ApiResponse.success({ - id: dbModel.id, - brand_name: dbModel.brand_name, - name: dbModel.name, - form: dbModel.form, - rig: dbModel.rig, - source: dbModel.source, - eq_key: dbModel.eq_key, - create_at: dbModel.create_at ? dbModel.create_at.toISOString() : null, - })); - } catch (e) { - logger.error(`Error updating model ${req.params.model_id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// DELETE /api/models/:model_id -router.delete('/api/models/:model_id', async (req, res) => { - try { - const modelId = parseInt(req.params.model_id, 10); - logger.info(`Deleting model: id=${modelId}`); - - const dbModel = await Model.findByPk(modelId); - if (!dbModel) { - logger.warn(`Model not found: id=${modelId}`); - return res.json(ApiResponse.noData('empty')); - } - - try { - await deleteModelFromMeilisearch(modelId); - } catch (e) { - logger.error(`Meilisearch delete failed for model id=${modelId}: ${e.message}`); - return res.json(ApiResponse.error(`从 Meilisearch 删除失败:${e.message}`)); - } - - await dbModel.destroy(); - logger.info(`Model deleted successfully: id=${modelId}`); - return res.json(ApiResponse.success(null, 'success')); - } catch (e) { - logger.error(`Error deleting model ${req.params.model_id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// PATCH /api/models/batch-update — 批量修改型号字段 -router.patch('/api/models/batch-update', async (req, res) => { - try { - const { ids, brand_name, form, source, rig } = req.body || {}; - if (!Array.isArray(ids) || !ids.length) { - return res.json(ApiResponse.error('请选择要修改的型号')); - } - - // 至少指定一个要修改的字段 - const hasUpdate = [brand_name, form, source, rig].some( - (v) => v !== undefined && v !== null - ); - if (!hasUpdate) { - return res.json(ApiResponse.error('请至少指定一个要修改的字段')); - } - - // 若修改品牌名称,确保品牌存在 - if (brand_name) { - const bn = String(brand_name).trim(); - const existing = await Brand.findOne({ where: { name: bn } }); - if (!existing) { - await Brand.create({ name: bn }); - logger.info(`Brand created during batch update: ${bn}`); - } - } - - const models = await Model.findAll({ where: { id: { [Op.in]: ids } } }); - if (!models.length) { - return res.json(ApiResponse.error('未找到选中的型号数据')); - } - - const updatedIds = []; - const errors = []; - - for (const m of models) { - try { - const oldParams = { - source: m.source, - form: m.form, - brandName: m.brand_name, - modelName: m.name, - }; - - if (brand_name !== undefined && brand_name !== null) m.brand_name = String(brand_name).trim(); - if (form !== undefined && form !== null) m.form = form || null; - if (source !== undefined && source !== null) m.source = source || null; - if (rig !== undefined && rig !== null) m.rig = rig || null; - - // S3 CSV 路径迁移(brand_name 或 form 变更时) - const newParams = { - source: m.source, - form: m.form, - brandName: m.brand_name, - modelName: m.name, - }; - await moveMeasurementOnS3(oldParams, newParams); - - await m.save(); - updatedIds.push(m.id); - } catch (e) { - logger.error(`Batch update failed for model id=${m.id}: ${e.message}`); - errors.push({ id: m.id, brand_name: m.brand_name, name: m.name, error: e.message }); - } - } - - logger.info(`Batch update: ${updatedIds.length} succeeded, ${errors.length} failed`); - return res.json(ApiResponse.success({ - updated_count: updatedIds.length, - updated_ids: updatedIds, - errors, - })); - } catch (e) { - logger.error(`Batch update error: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// POST /api/models/push-to-search/validate -router.post('/api/models/push-to-search/validate', async (req, res) => { - try { - const { model_ids } = req.body || {}; - if (!Array.isArray(model_ids) || !model_ids.length) { - return res.json(ApiResponse.error('请选择要推送的型号')); - } - - const models = await Model.findAll({ where: { id: { [Op.in]: model_ids } } }); - if (!models.length) { - return res.json(ApiResponse.error('未找到选中的型号数据')); - } - - const validationErrors = []; - for (const model of models) { - const [ok, reason] = await fetchAndValidateCurve( - model.brand_name, - model.name, - model.form || '' - ); - if (!ok) { - validationErrors.push({ - id: model.id, - brand_name: model.brand_name, - name: model.name, - form: model.form, - reason, - }); - } - } - - if (validationErrors.length) { - const names = validationErrors.slice(0, 5) - .map((e) => `${e.brand_name} ${e.name}`) - .join('、'); - const suffix = validationErrors.length > 5 ? ' 等' : ''; - return res.json({ - code: 0, - msg: `曲线数据校验未通过:${names}${suffix}`, - data: { errors: validationErrors, validated_count: 0 }, - }); - } - - return res.json(ApiResponse.success({ validated_count: models.length })); - } catch (e) { - logger.error(`validate_push_to_search failed: ${e.message}`); - return res.json(ApiResponse.error(`校验失败:${e.message}`)); - } -}); - -// POST /api/models/push-to-search -router.post('/api/models/push-to-search', async (req, res) => { - try { - const { model_ids } = req.body || {}; - if (!Array.isArray(model_ids) || !model_ids.length) { - return res.json(ApiResponse.error('请选择要推送的型号')); - } - - const models = await Model.findAll({ where: { id: { [Op.in]: model_ids } } }); - if (!models.length) { - return res.json(ApiResponse.error('未找到选中的型号数据')); - } - - const pushData = models.map((m) => { - const doc = { - id: m.id, - brand_name: m.brand_name, - name: m.name, - rig: m.rig, - form: m.form, - source: m.source, - }; - // 移除 null 值 - Object.keys(doc).forEach((k) => doc[k] === null && delete doc[k]); - return doc; - }); - - try { - const response = await axios.post( - `${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents`, - pushData, - { headers: meilisearchHeaders(), timeout: 30000 } - ); - - if (![200, 202].includes(response.status)) { - return res.json(ApiResponse.error(`推送到 Meilisearch 失败:${JSON.stringify(response.data)}`)); - } - - return res.json(ApiResponse.success({ - pushed_count: pushData.length, - task_uid: response.data?.taskUid, - models: pushData, - })); - } catch (e) { - return res.json(ApiResponse.error(`连接 Meilisearch 失败:${e.message}`)); - } - } catch (e) { - logger.error(`push_to_search failed: ${e.message}`); - return res.json(ApiResponse.error(`推送失败:${e.message}`)); - } -}); - -module.exports = router; diff --git a/dashboard/backend/src/routes/models.ts b/dashboard/backend/src/routes/models.ts new file mode 100644 index 0000000..cb81966 --- /dev/null +++ b/dashboard/backend/src/routes/models.ts @@ -0,0 +1,487 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq, like, ne, and, inArray, asc, desc, count } from 'drizzle-orm'; +import multer from 'multer'; +import path from 'node:path'; +import axios from 'axios'; +import { db } from '../config/database.js'; +import { models, brands } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { fetchAndValidateCurve } from '../services/curveClient.js'; +import { uploadMeasurementToS3, getMeasurementFromS3, moveMeasurementOnS3 } from '../services/measurementStorage.js'; +import { getEqCacheKeys, getEqCacheField } from '../services/eqCacheStorage.js'; +import { fetchFromSquigLink, downloadTxtFile, isDirectTxtUrl, fetchDirectTxtUrl } from '../services/squiglink.js'; + +const router: RouterType = Router(); + +function convertTxtToCsv(buffer: Buffer): string { + const text = buffer.toString('utf-8'); + const lines = text.split(/\r?\n/); + let startIdx = -1; + for (let i = 0; i < lines.length; i++) { + if (lines[i].startsWith('* Freq(Hz)')) { startIdx = i; break; } + } + const dataLines = startIdx >= 0 ? lines.slice(startIdx + 1) : lines; + const csvRows = ['frequency,raw']; + for (const line of dataLines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('*')) continue; + const parts = trimmed.split(/[;,\s]+/); + if (parts.length >= 2 && /^[-+]?\d/.test(parts[0])) { + csvRows.push(`${parts[0]},${parts[1]}`); + } + } + return csvRows.join('\n'); +} + +function processUploadedFile(buffer: Buffer, originalname: string) { + const ext = path.extname(originalname).toLowerCase(); + if (ext === '.txt') { + const csvContent = convertTxtToCsv(buffer); + const csvFilename = originalname.replace(/\.txt$/i, '.csv'); + logger.info(`Converted TXT to CSV: ${originalname} -> ${csvFilename}`); + return { buffer: Buffer.from(csvContent, 'utf-8'), filename: csvFilename }; + } + return { buffer, filename: originalname }; +} + +// 所有型号接口需登录 +router.use(authMiddleware); + +const upload = multer({ storage: multer.memoryStorage() }); + +const MEILISEARCH_URL = process.env.MEILISEARCH_URL || 'http://localhost:7700'; +const MEILISEARCH_API_KEY = process.env.MEILISEARCH_API_KEY || ''; +const MEILISEARCH_INDEX = process.env.MEILISEARCH_INDEX || 'models'; + +function meilisearchHeaders() { + return { Authorization: `Bearer ${MEILISEARCH_API_KEY}`, 'Content-Type': 'application/json' }; +} + +async function getModelFromMeilisearch(modelId: number) { + try { + const response = await axios.get( + `${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents/${modelId}`, + { headers: meilisearchHeaders(), timeout: 30000 } + ); + if (response.status !== 200) throw new Error(JSON.stringify(response.data)); + return response.data; + } catch (e: any) { + if (e.response?.status === 404) return null; + const detail = e.response?.data ? JSON.stringify(e.response.data) : e.message; + throw new Error(detail); + } +} + +async function deleteModelFromMeilisearch(modelId: number) { + try { + const response = await axios.delete( + `${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents/${modelId}`, + { headers: meilisearchHeaders(), timeout: 30000 } + ); + if (![200, 202].includes(response.status)) throw new Error(JSON.stringify(response.data)); + logger.info(`Meilisearch document deleted: id=${modelId}, taskUid=${response.data?.taskUid}`); + return response.data; + } catch (e: any) { + if (e.response?.status === 404) { logger.warn(`Meilisearch document not found, skip: id=${modelId}`); return null; } + const detail = e.response?.data ? JSON.stringify(e.response.data) : e.message; + throw new Error(detail); + } +} + +const ALLOWED_EXTENSIONS = ['.csv', '.txt', '.json']; + +function modelToDict(m: typeof models.$inferSelect) { + return { + id: m.id, brand_name: m.brandName, name: m.name, form: m.form, + rig: m.rig, source: m.source, eq_key: m.eqKey, + create_at: m.createAt ? m.createAt.toISOString() : null, + }; +} + +// GET /api/models/ +router.get('/api/models/', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000); + const brandName = req.query.brand_name as string | undefined; + const name = req.query.name as string | undefined; + const sortBy = req.query.sort_by as string || 'id'; + const sortOrder = (req.query.sort_order as string || 'desc').toLowerCase(); + + const conditions = []; + if (brandName) conditions.push(like(models.brandName, `%${brandName}%`)); + if (name) conditions.push(like(models.name, `%${name}%`)); + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db.select({ value: count() }).from(models).where(whereClause); + const total = totalResult?.value ?? 0; + + const orderCol = sortBy === 'create_at' ? models.createAt : models.id; + const orderDir = sortOrder === 'asc' ? asc(orderCol) : desc(orderCol); + + const rows = await db.select().from(models).where(whereClause).orderBy(orderDir).offset(skip).limit(limit); + logger.info(`Found ${rows.length} models, total=${total}`); + + if (!rows.length) { res.json(ApiResponse.noData('empty')); return; } + const items = rows.map(modelToDict); + res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting models: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// GET /api/models/:model_id/eq-cache +router.get('/api/models/:model_id/eq-cache', async (req: Request, res: Response) => { + try { + const modelId = parseInt(req.params.model_id as string, 10); + const [model] = await db.select().from(models).where(eq(models.id, modelId)).limit(1); + if (!model) { res.json(ApiResponse.noData('empty')); return; } + const { redis_key, field_keys } = await getEqCacheKeys(model.brandName, model.name); + res.json(ApiResponse.success({ redis_key, field_keys })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error reading EQ cache for model: ${msg}`); + res.json(ApiResponse.error(msg)); + } +}); + +// GET /api/models/:model_id/eq-cache/field?key= +router.get('/api/models/:model_id/eq-cache/field', async (req: Request, res: Response) => { + try { + const modelId = parseInt(req.params.model_id as string, 10); + const fieldKey = (req.query.key as string || '').trim(); + if (!fieldKey) { res.json(ApiResponse.error('缺少 key 参数')); return; } + const [model] = await db.select().from(models).where(eq(models.id, modelId)).limit(1); + if (!model) { res.json(ApiResponse.noData('empty')); return; } + const data = await getEqCacheField(model.brandName, model.name, fieldKey); + res.json(ApiResponse.success(data)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error reading EQ cache field: ${msg}`); + res.json(ApiResponse.error(msg)); + } +}); + +// GET /api/models/:model_id/meilisearch +router.get('/api/models/:model_id/meilisearch', async (req: Request, res: Response) => { + try { + const modelId = parseInt(req.params.model_id as string, 10); + const [model] = await db.select().from(models).where(eq(models.id, modelId)).limit(1); + if (!model) { res.json(ApiResponse.noData('empty')); return; } + const document = await getModelFromMeilisearch(modelId); + if (!document) { res.json(ApiResponse.success({ pushed: false, document: null })); return; } + res.json(ApiResponse.success({ pushed: true, document })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error reading Meilisearch document: ${msg}`); + res.json(ApiResponse.error(`查询 Meilisearch 失败:${msg}`)); + } +}); + +// GET /api/models/:model_id/measurement +router.get('/api/models/:model_id/measurement', async (req: Request, res: Response) => { + try { + const modelId = parseInt(req.params.model_id as string, 10); + const [model] = await db.select().from(models).where(eq(models.id, modelId)).limit(1); + if (!model) { res.json(ApiResponse.noData('empty')); return; } + const source = (model.source || '').trim(); + if (source.toLowerCase() !== 'eafonyoung') { res.json(ApiResponse.error('仅支持查看来源为 Eafonyoung 的频响文件')); return; } + if (!model.form) { res.json(ApiResponse.error('该型号缺少佩戴方式,无法定位 S3 文件')); return; } + const { key, content } = await getMeasurementFromS3({ source: model.source, form: model.form, brandName: model.brandName, modelName: model.name }); + res.json(ApiResponse.success({ s3_key: key, content })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error reading measurement: ${msg}`); + res.json(ApiResponse.error(msg)); + } +}); + +// GET /api/models/:model_id +router.get('/api/models/:model_id', async (req: Request, res: Response) => { + try { + const modelId = parseInt(req.params.model_id as string, 10); + logger.info(`Getting model: id=${modelId}`); + const [model] = await db.select().from(models).where(eq(models.id, modelId)).limit(1); + if (!model) { logger.warn(`Model not found: id=${modelId}`); res.json(ApiResponse.noData('empty')); return; } + res.json(ApiResponse.success(modelToDict(model))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting model: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// POST /api/models/squiglink-fetch +router.post('/api/models/squiglink-fetch', async (req: Request, res: Response) => { + try { + const { share_url, selected_file } = req.body; + if (!share_url) { res.json(ApiResponse.error('缺少 share_url 参数')); return; } + logger.info(`SquigLink fetch: ${share_url}${selected_file ? ' (selected=' + selected_file + ')' : ''}`); + + if (!selected_file && isDirectTxtUrl(share_url)) { + const result = await fetchDirectTxtUrl(share_url); + const csvContent = convertTxtToCsv(result.buffer); + res.json(ApiResponse.success({ brand_name: result.brandName, model_name: result.modelName, form: result.form || null, csv_content: csvContent, data_url: result.url, matches: [] })); + return; + } + + if (selected_file) { + const { parseShareUrl, detectFormFromUrl } = await import('../services/squiglink.js'); + const { baseUrl, pathPrefix } = parseShareUrl(share_url); + const [downloadResult, detectedForm] = await Promise.all([ + downloadTxtFile(baseUrl, selected_file, pathPrefix), + detectFormFromUrl(baseUrl, pathPrefix), + ]); + const csvContent = convertTxtToCsv(downloadResult.buffer); + const parts = selected_file.split(' '); + const brandName = parts.length > 1 ? parts[0] : ''; + const modelName = parts.length > 1 ? parts.slice(1).join(' ') : selected_file; + res.json(ApiResponse.success({ brand_name: brandName, model_name: modelName, form: detectedForm || null, csv_content: csvContent, data_url: downloadResult.url, matches: [] })); + return; + } + + const result = await fetchFromSquigLink(share_url); + if (result.matches.length > 1) { + res.json(ApiResponse.success({ brand_name: '', model_name: '', form: (result as any).form || null, csv_content: null, data_url: null, matches: result.matches })); + return; + } + const csvContent = convertTxtToCsv(result.buffer!); + res.json(ApiResponse.success({ brand_name: result.brandName, model_name: result.modelName, form: (result as any).form || null, csv_content: csvContent, data_url: result.dataUrl, matches: [] })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`SquigLink fetch error: ${msg}`); + res.json(ApiResponse.error(msg || '抓取失败')); + } +}); + +// POST /api/models/ (multipart/form-data) +router.post('/api/models/', upload.single('measurement_file'), async (req: Request, res: Response) => { + try { + const { brand_name, name, form, rig, source, eq_key } = req.body; + logger.info(`Creating model: brand_name=${brand_name}, name=${name}, form=${form}, source=${source}`); + + const [existing] = await db.select().from(models).where(and(eq(models.brandName, brand_name), eq(models.name, name))).limit(1); + if (existing) { logger.warn(`Model already exists: brand_name=${brand_name}, name=${name}`); res.json(ApiResponse.error('该品牌下型号名称已存在')); return; } + + const { squiglink_csv } = req.body; + if (squiglink_csv) { + const csvBuffer = Buffer.from(squiglink_csv, 'utf-8'); + const s3Key = await uploadMeasurementToS3(csvBuffer, { source: source || 'Eafonyoung', form: form || 'in-ear', brandName: brand_name, modelName: name }); + logger.info(`SquigLink CSV uploaded to S3: ${s3Key}`); + } else if (req.file && req.file.originalname) { + const fileExt = path.extname(req.file.originalname).toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(fileExt)) { logger.error(`Unsupported file format: ${fileExt}`); res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`)); return; } + const { buffer: fileBuffer } = processUploadedFile(req.file.buffer, req.file.originalname); + const s3Key = await uploadMeasurementToS3(fileBuffer, { source: source || 'Eafonyoung', form: form || 'in-ear', brandName: brand_name, modelName: name }); + logger.info(`Measurement file uploaded to S3: ${s3Key}`); + } + + const result = await db.insert(models).values({ brandName: brand_name, name, form: form || null, rig: rig || null, source: source || null, eqKey: eq_key || null }); + const [dbModel] = await db.select().from(models).where(eq(models.id, result[0].insertId)).limit(1); + logger.info(`Model created successfully: id=${dbModel.id}`); + res.json(ApiResponse.success(modelToDict(dbModel))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error creating model: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/models/:model_id (multipart/form-data) +router.put('/api/models/:model_id', upload.single('measurement_file'), async (req: Request, res: Response) => { + try { + const modelId = parseInt(req.params.model_id as string, 10); + const { brand_name, name, form, rig, source, eq_key } = req.body; + logger.info(`Updating model: id=${modelId}, brand_name=${brand_name}, name=${name}, form=${form}`); + + const [dbModel] = await db.select().from(models).where(eq(models.id, modelId)).limit(1); + if (!dbModel) { logger.warn(`Model not found: id=${modelId}`); res.json(ApiResponse.noData('empty')); return; } + + const newBrandName = brand_name === undefined || brand_name === 'null' ? dbModel.brandName : brand_name; + const newName = name === undefined || name === 'null' ? dbModel.name : name; + + if (newBrandName !== dbModel.brandName || newName !== dbModel.name) { + const [existing] = await db.select().from(models).where(and(eq(models.brandName, newBrandName), eq(models.name, newName), ne(models.id, modelId))).limit(1); + if (existing) { logger.warn(`Model already exists: brand_name=${newBrandName}, name=${newName}`); res.json(ApiResponse.error('该品牌下型号名称已存在')); return; } + } + + const effSource = source === undefined || source === 'null' ? dbModel.source : source; + const effForm = form === undefined || form === 'null' ? dbModel.form : form; + + const { squiglink_csv } = req.body; + if (squiglink_csv) { + if (!effSource || !effForm) { res.json(ApiResponse.error('上传频响文件需要来源与形式字段')); return; } + const csvBuffer = Buffer.from(squiglink_csv, 'utf-8'); + await uploadMeasurementToS3(csvBuffer, { source: effSource, form: effForm, brandName: newBrandName, modelName: newName }); + } else if (req.file && req.file.originalname) { + const fileExt = path.extname(req.file.originalname).toLowerCase(); + if (!ALLOWED_EXTENSIONS.includes(fileExt)) { logger.error(`Unsupported file format: ${fileExt}`); res.json(ApiResponse.error(`不支持的文件格式:${fileExt}`)); return; } + if (!effSource || !effForm) { res.json(ApiResponse.error('上传频响文件需要来源与形式字段')); return; } + const { buffer: fileBuffer } = processUploadedFile(req.file.buffer, req.file.originalname); + await uploadMeasurementToS3(fileBuffer, { source: effSource, form: effForm, brandName: newBrandName, modelName: newName }); + } + + const hasNewFile = squiglink_csv || (req.file && req.file.originalname); + if (!hasNewFile) { + await moveMeasurementOnS3( + { source: dbModel.source, form: dbModel.form, brandName: dbModel.brandName, modelName: dbModel.name }, + { source: effSource, form: effForm, brandName: newBrandName, modelName: newName } + ); + } + + const updateData: Partial = {}; + if (brand_name !== undefined && brand_name !== 'null') updateData.brandName = brand_name; + if (name !== undefined && name !== 'null') updateData.name = name; + if (form !== undefined && form !== 'null') updateData.form = form; + if (rig !== undefined && rig !== 'null') updateData.rig = rig; + if (source !== undefined && source !== 'null') updateData.source = source; + if (eq_key !== undefined && eq_key !== 'null') updateData.eqKey = eq_key; + + if (Object.keys(updateData).length > 0) { + await db.update(models).set(updateData).where(eq(models.id, modelId)); + } + logger.info(`Model updated successfully: id=${modelId}`); + const [updated] = await db.select().from(models).where(eq(models.id, modelId)).limit(1); + res.json(ApiResponse.success(modelToDict(updated))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error updating model: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/models/:model_id +router.delete('/api/models/:model_id', async (req: Request, res: Response) => { + try { + const modelId = parseInt(req.params.model_id as string, 10); + logger.info(`Deleting model: id=${modelId}`); + const [dbModel] = await db.select().from(models).where(eq(models.id, modelId)).limit(1); + if (!dbModel) { logger.warn(`Model not found: id=${modelId}`); res.json(ApiResponse.noData('empty')); return; } + try { await deleteModelFromMeilisearch(modelId); } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Meilisearch delete failed for model id=${modelId}: ${msg}`); + res.json(ApiResponse.error(`从 Meilisearch 删除失败:${msg}`)); return; + } + await db.delete(models).where(eq(models.id, modelId)); + logger.info(`Model deleted successfully: id=${modelId}`); + res.json(ApiResponse.success(null, 'success')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error deleting model: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// PATCH /api/models/batch-update +router.patch('/api/models/batch-update', async (req: Request, res: Response) => { + try { + const { ids, brand_name, form, source, rig } = req.body || {}; + if (!Array.isArray(ids) || !ids.length) { res.json(ApiResponse.error('请选择要修改的型号')); return; } + const hasUpdate = [brand_name, form, source, rig].some((v: unknown) => v !== undefined && v !== null); + if (!hasUpdate) { res.json(ApiResponse.error('请至少指定一个要修改的字段')); return; } + + if (brand_name) { + const bn = String(brand_name).trim(); + const [existing] = await db.select().from(brands).where(eq(brands.name, bn)).limit(1); + if (!existing) { await db.insert(brands).values({ name: bn }); logger.info(`Brand created during batch update: ${bn}`); } + } + + const selectedModels = await db.select().from(models).where(inArray(models.id, ids)); + if (!selectedModels.length) { res.json(ApiResponse.error('未找到选中的型号数据')); return; } + + const updatedIds: number[] = []; + const errors: { id: number; brand_name: string; name: string; error: string }[] = []; + + for (const m of selectedModels) { + try { + const oldParams = { source: m.source, form: m.form, brandName: m.brandName, modelName: m.name }; + const updateData: Partial = {}; + if (brand_name !== undefined && brand_name !== null) updateData.brandName = String(brand_name).trim(); + if (form !== undefined && form !== null) updateData.form = form || null; + if (source !== undefined && source !== null) updateData.source = source || null; + if (rig !== undefined && rig !== null) updateData.rig = rig || null; + if (Object.keys(updateData).length > 0) { + await db.update(models).set(updateData).where(eq(models.id, m.id)); + } + const [updated] = await db.select().from(models).where(eq(models.id, m.id)).limit(1); + const newParams = { source: updated.source, form: updated.form, brandName: updated.brandName, modelName: updated.name }; + await moveMeasurementOnS3(oldParams, newParams); + updatedIds.push(m.id); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Batch update failed for model id=${m.id}: ${msg}`); + errors.push({ id: m.id, brand_name: m.brandName, name: m.name, error: msg }); + } + } + + logger.info(`Batch update: ${updatedIds.length} succeeded, ${errors.length} failed`); + res.json(ApiResponse.success({ updated_count: updatedIds.length, updated_ids: updatedIds, errors })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Batch update error: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// POST /api/models/push-to-search/validate +router.post('/api/models/push-to-search/validate', async (req: Request, res: Response) => { + try { + const { model_ids } = req.body || {}; + if (!Array.isArray(model_ids) || !model_ids.length) { res.json(ApiResponse.error('请选择要推送的型号')); return; } + const selectedModels = await db.select().from(models).where(inArray(models.id, model_ids)); + if (!selectedModels.length) { res.json(ApiResponse.error('未找到选中的型号数据')); return; } + + const validationErrors: { id: number; brand_name: string; name: string; form: string | null; reason: string }[] = []; + for (const model of selectedModels) { + const [ok, reason] = await fetchAndValidateCurve(model.brandName, model.name, model.form || ''); + if (!ok) validationErrors.push({ id: model.id, brand_name: model.brandName, name: model.name, form: model.form, reason }); + } + + if (validationErrors.length) { + const names = validationErrors.slice(0, 5).map((e) => `${e.brand_name} ${e.name}`).join('、'); + const suffix = validationErrors.length > 5 ? ' 等' : ''; + res.json({ code: 0, msg: `曲线数据校验未通过:${names}${suffix}`, data: { errors: validationErrors, validated_count: 0 } }); + return; + } + res.json(ApiResponse.success({ validated_count: selectedModels.length })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`validate_push_to_search failed: ${msg}`); + res.json(ApiResponse.error(`校验失败:${msg}`)); + } +}); + +// POST /api/models/push-to-search +router.post('/api/models/push-to-search', async (req: Request, res: Response) => { + try { + const { model_ids } = req.body || {}; + if (!Array.isArray(model_ids) || !model_ids.length) { res.json(ApiResponse.error('请选择要推送的型号')); return; } + const selectedModels = await db.select().from(models).where(inArray(models.id, model_ids)); + if (!selectedModels.length) { res.json(ApiResponse.error('未找到选中的型号数据')); return; } + + const pushData = selectedModels.map((m) => { + const doc: Record = { id: m.id, brand_name: m.brandName, name: m.name, rig: m.rig, form: m.form, source: m.source }; + Object.keys(doc).forEach((k) => doc[k] === null && delete doc[k]); + return doc; + }); + + try { + const response = await axios.post(`${MEILISEARCH_URL}/indexes/${MEILISEARCH_INDEX}/documents`, pushData, { headers: meilisearchHeaders(), timeout: 30000 }); + if (![200, 202].includes(response.status)) { res.json(ApiResponse.error(`推送到 Meilisearch 失败:${JSON.stringify(response.data)}`)); return; } + res.json(ApiResponse.success({ pushed_count: pushData.length, task_uid: response.data?.taskUid, models: pushData })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + res.json(ApiResponse.error(`连接 Meilisearch 失败:${msg}`)); + } + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`push_to_search failed: ${msg}`); + res.json(ApiResponse.error(`推送失败:${msg}`)); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/ota.js b/dashboard/backend/src/routes/ota.js deleted file mode 100644 index 917d544..0000000 --- a/dashboard/backend/src/routes/ota.js +++ /dev/null @@ -1,294 +0,0 @@ -/** - * OTA 路由 — 对应 Python: routes/ota.py - */ -const router = require('express').Router(); -const { Op } = require('sequelize'); -const multer = require('multer'); -const Ota = require('../models/Ota'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { authMiddleware } = require('../middleware/auth'); -const { - OTA_MODEL_X8, - OTA_MODEL_X9, - OTA_UPLOAD_MODELS, - readUploadContentAndMd5, - saveX9PackageLocal, - uploadX8PackageToS3, -} = require('../services/otaStorage'); -const { OtaCreateSchema, OtaUpdateSchema } = require('../validators/ota'); - -// OTA 业务接口需登录(/latest/check 除外,单独处理) -const upload = multer({ storage: multer.memoryStorage() }); - -// POST /api/ota/upload-package (需登录) -router.post('/api/ota/upload-package', authMiddleware, upload.single('package_file'), async (req, res) => { - try { - const model = (req.body.model || '').trim(); - if (!OTA_UPLOAD_MODELS.has(model)) { - return res.json(ApiResponse.error(`当前仅支持为 ${OTA_MODEL_X8}、${OTA_MODEL_X9} 上传升级包`)); - } - if (!req.file) { - return res.json(ApiResponse.error('请选择升级包文件')); - } - - logger.info(`Uploading OTA package: model=${model}, filename=${req.file.originalname}`); - - const { content, md5Hex } = await readUploadContentAndMd5(req.file.buffer); - - if (model === OTA_MODEL_X9) { - const { savedName, downloadUrl } = saveX9PackageLocal(content, md5Hex); - return res.json(ApiResponse.success({ - md5: md5Hex, - filename: savedName, - url: downloadUrl, - })); - } - - if (model === OTA_MODEL_X8) { - const { savedName, downloadUrl, s3Key } = await uploadX8PackageToS3(content, md5Hex); - return res.json(ApiResponse.success({ - md5: md5Hex, - filename: savedName, - url: downloadUrl, - s3_key: s3Key, - })); - } - - return res.json(ApiResponse.error('不支持的设备型号')); - } catch (e) { - if (e.message.includes('未配置') || e.message.includes('S3 上传失败')) { - return res.json(ApiResponse.error(e.message)); - } - logger.error(`Error uploading OTA package: ${e.message}`); - return res.json(ApiResponse.error(`上传失败:${e.message}`)); - } -}); - -// GET /api/ota/latest/check (无需登录 — 设备端调用) -router.get('/api/ota/latest/check', async (req, res) => { - try { - const currentVerCode = parseInt(req.query.currentVerCode, 10); - const model = req.query.model; - const hw = req.query.hw !== undefined ? parseInt(req.query.hw, 10) : null; - - logger.info(`Checking latest OTA: currentVerCode=${currentVerCode}, model=${model}, hw=${hw}`); - - const where = { - status: 1, - verCode: { [Op.gt]: currentVerCode }, - model, - }; - if (hw !== null && !isNaN(hw)) { - where.hw = hw; - } - - const latestOta = await Ota.findOne({ - where, - order: [['verCode', 'DESC']], - }); - - if (!latestOta) { - logger.info(`No available OTA found for model=${model}, currentVerCode=${currentVerCode}`); - return res.json(ApiResponse.noData('empty')); - } - - logger.info(`Latest OTA found: verCode=${latestOta.verCode}, verName=${latestOta.verName}`); - return res.json(ApiResponse.success(otaToDict(latestOta))); - } catch (e) { - logger.error(`Error checking latest OTA: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// 以下接口需登录 -router.use('/api/ota', authMiddleware); - -// GET /api/ota/ -router.get('/api/ota/', async (req, res) => { - try { - const skip = parseInt(req.query.skip || '0', 10); - const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); - const verCode = req.query.verCode !== undefined ? parseInt(req.query.verCode, 10) : null; - const verName = req.query.verName; - const model = req.query.model; - const status = req.query.status !== undefined ? parseInt(req.query.status, 10) : null; - const beta = req.query.beta !== undefined ? parseInt(req.query.beta, 10) : null; - - const where = {}; - if (verCode !== null && !isNaN(verCode)) where.verCode = verCode; - if (verName) where.verName = { [Op.like]: `%${verName}%` }; - if (model) where.model = { [Op.like]: `%${model}%` }; - if (status !== null && !isNaN(status)) where.status = status; - if (beta !== null && !isNaN(beta)) where.beta = beta; - - const total = await Ota.count({ where }); - const rows = await Ota.findAll({ - where, - offset: skip, - limit, - order: [['id', 'DESC']], - }); - - logger.info(`Found ${rows.length} OTA records, total=${total}`); - - if (!rows.length) { - return res.json(ApiResponse.noData('empty')); - } - - const items = rows.map(otaToDict); - return res.json(ApiResponse.success({ items, total, skip, limit })); - } catch (e) { - logger.error(`Error getting OTA list: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// GET /api/ota/:ota_id -router.get('/api/ota/:ota_id', async (req, res) => { - try { - const otaId = parseInt(req.params.ota_id, 10); - logger.info(`Getting OTA: id=${otaId}`); - const ota = await Ota.findByPk(otaId); - if (!ota) { - logger.warn(`OTA not found: id=${otaId}`); - return res.json(ApiResponse.noData('empty')); - } - return res.json(ApiResponse.success(otaToDict(ota))); - } catch (e) { - logger.error(`Error getting OTA ${req.params.ota_id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// POST /api/ota/ -router.post('/api/ota/', async (req, res) => { - try { - const parsed = OtaCreateSchema.safeParse(req.body); - if (!parsed.success) { - const errors = parsed.error.errors.map((e) => e.message).join('; '); - return res.json(ApiResponse.error(errors)); - } - - const data = parsed.data; - logger.info(`Creating OTA: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}`); - - // 检查版本号是否已存在(同一 verCode + model + beta 视为唯一) - const existing = await Ota.findOne({ - where: { verCode: data.verCode, model: data.model, beta: data.beta ?? 0 }, - }); - if (existing) { - logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}, beta=${data.beta ?? 0}`); - return res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)')); - } - - // 处理日期字段 - if (data.startTime) data.startTime = new Date(data.startTime); - if (data.endTime) data.endTime = new Date(data.endTime); - - const dbOta = await Ota.create(data); - logger.info(`OTA created successfully: id=${dbOta.id}, verCode=${dbOta.verCode}`); - return res.json(ApiResponse.success(otaToDict(dbOta))); - } catch (e) { - logger.error(`Error creating OTA: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// PUT /api/ota/:ota_id -router.put('/api/ota/:ota_id', async (req, res) => { - try { - const otaId = parseInt(req.params.ota_id, 10); - - const parsed = OtaUpdateSchema.safeParse(req.body); - if (!parsed.success) { - const errors = parsed.error.errors.map((e) => e.message).join('; '); - return res.json(ApiResponse.error(errors)); - } - - const data = parsed.data; - logger.info(`Updating OTA: id=${otaId}`); - - const dbOta = await Ota.findByPk(otaId); - if (!dbOta) { - logger.warn(`OTA not found: id=${otaId}`); - return res.json(ApiResponse.noData('empty')); - } - - // 如果更新版本号或灰度状态,检查是否冲突 - const newVerCode = data.verCode !== undefined && data.verCode !== null ? data.verCode : dbOta.verCode; - const newModel = data.model !== undefined && data.model !== null ? data.model : dbOta.model; - const newBeta = data.beta !== undefined && data.beta !== null ? data.beta : dbOta.beta; - - if (newVerCode !== dbOta.verCode || newModel !== dbOta.model || newBeta !== dbOta.beta) { - const existing = await Ota.findOne({ - where: { verCode: newVerCode, model: newModel, beta: newBeta }, - }); - if (existing) { - logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}, beta=${newBeta}`); - return res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)')); - } - } - - // 更新字段(仅非 null 的字段) - const updateFields = Object.entries(data).filter(([_, v]) => v !== undefined && v !== null); - for (const [field, value] of updateFields) { - if (field === 'startTime' || field === 'endTime') { - dbOta[field] = new Date(value); - } else { - dbOta[field] = value; - } - } - - await dbOta.save(); - logger.info(`OTA updated successfully: id=${dbOta.id}`); - return res.json(ApiResponse.success(otaToDict(dbOta))); - } catch (e) { - logger.error(`Error updating OTA ${req.params.ota_id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// DELETE /api/ota/:ota_id -router.delete('/api/ota/:ota_id', async (req, res) => { - try { - const otaId = parseInt(req.params.ota_id, 10); - logger.info(`Deleting OTA: id=${otaId}`); - - const dbOta = await Ota.findByPk(otaId); - if (!dbOta) { - logger.warn(`OTA not found: id=${otaId}`); - return res.json(ApiResponse.noData('empty')); - } - - await dbOta.destroy(); - logger.info(`OTA deleted successfully: id=${otaId}`); - return res.json(ApiResponse.success(null, 'success')); - } catch (e) { - logger.error(`Error deleting OTA ${req.params.ota_id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// OTA 对象转字典 -function otaToDict(ota) { - return { - id: ota.id, - verCode: ota.verCode, - verName: ota.verName, - url: ota.url, - md5: ota.md5, - force: ota.force, - desc: ota.desc, - model: ota.model, - hw: ota.hw, - target: ota.target, - beta: ota.beta, - startTime: ota.startTime ? ota.startTime.toISOString() : null, - endTime: ota.endTime ? ota.endTime.toISOString() : null, - status: ota.status, - create_at: ota.create_at ? ota.create_at.toISOString() : null, - }; -} - -module.exports = router; diff --git a/dashboard/backend/src/routes/ota.ts b/dashboard/backend/src/routes/ota.ts new file mode 100644 index 0000000..2e97009 --- /dev/null +++ b/dashboard/backend/src/routes/ota.ts @@ -0,0 +1,351 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq, gt, like, and, desc, count } from 'drizzle-orm'; +import multer from 'multer'; +import { db } from '../config/database.js'; +import { otas } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { OtaCreateSchema, OtaUpdateSchema } from '../validators/ota.js'; +import { + OTA_MODEL_X8, + OTA_MODEL_X9, + OTA_UPLOAD_MODELS, + readUploadContentAndMd5, + saveX9PackageLocal, + uploadX8PackageToS3, +} from '../services/otaStorage.js'; + +const router: RouterType = Router(); + +const upload = multer({ storage: multer.memoryStorage() }); + +// POST /api/ota/upload-package (需登录) +router.post('/api/ota/upload-package', authMiddleware, upload.single('package_file'), async (req: Request, res: Response) => { + try { + const model = (req.body.model || '').trim(); + if (!OTA_UPLOAD_MODELS.has(model)) { + res.json(ApiResponse.error(`当前仅支持为 ${OTA_MODEL_X8}、${OTA_MODEL_X9} 上传升级包`)); + return; + } + if (!req.file) { + res.json(ApiResponse.error('请选择升级包文件')); + return; + } + + logger.info(`Uploading OTA package: model=${model}, filename=${req.file.originalname}`); + + const { content, md5Hex } = await readUploadContentAndMd5(req.file.buffer); + + if (model === OTA_MODEL_X9) { + const { savedName, downloadUrl } = saveX9PackageLocal(content, md5Hex); + res.json(ApiResponse.success({ + md5: md5Hex, + filename: savedName, + url: downloadUrl, + })); + return; + } + + if (model === OTA_MODEL_X8) { + const { savedName, downloadUrl, s3Key } = await uploadX8PackageToS3(content, md5Hex); + res.json(ApiResponse.success({ + md5: md5Hex, + filename: savedName, + url: downloadUrl, + s3_key: s3Key, + })); + return; + } + + res.json(ApiResponse.error('不支持的设备型号')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg.includes('未配置') || msg.includes('S3 上传失败')) { + res.json(ApiResponse.error(msg)); + return; + } + logger.error(`Error uploading OTA package: ${msg}`); + res.json(ApiResponse.error(`上传失败:${msg}`)); + } +}); + +// GET /api/ota/latest/check (无需登录 — 设备端调用) +router.get('/api/ota/latest/check', async (req: Request, res: Response) => { + try { + const currentVerCode = parseInt(req.query.currentVerCode as string, 10); + const model = req.query.model as string; + const hw = req.query.hw !== undefined ? parseInt(req.query.hw as string, 10) : null; + + logger.info(`Checking latest OTA: currentVerCode=${currentVerCode}, model=${model}, hw=${hw}`); + + const conditions = [eq(otas.status, 1), gt(otas.verCode, currentVerCode), eq(otas.model, model)]; + if (hw !== null && !isNaN(hw)) { + conditions.push(eq(otas.hw, hw)); + } + + const [latestOta] = await db + .select() + .from(otas) + .where(and(...conditions)) + .orderBy(desc(otas.verCode)) + .limit(1); + + if (!latestOta) { + logger.info(`No available OTA found for model=${model}, currentVerCode=${currentVerCode}`); + res.json(ApiResponse.noData('empty')); + return; + } + + logger.info(`Latest OTA found: verCode=${latestOta.verCode}, verName=${latestOta.verName}`); + res.json(ApiResponse.success(otaToDict(latestOta))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error checking latest OTA: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// 以下接口需登录 +router.use('/api/ota', authMiddleware); + +// GET /api/ota/ +router.get('/api/ota/', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000); + const verCode = req.query.verCode !== undefined ? parseInt(req.query.verCode as string, 10) : null; + const verName = req.query.verName as string | undefined; + const model = req.query.model as string | undefined; + const status = req.query.status !== undefined ? parseInt(req.query.status as string, 10) : null; + const beta = req.query.beta !== undefined ? parseInt(req.query.beta as string, 10) : null; + + const conditions = []; + if (verCode !== null && !isNaN(verCode)) conditions.push(eq(otas.verCode, verCode)); + if (verName) conditions.push(like(otas.verName, `%${verName}%`)); + if (model) conditions.push(like(otas.model, `%${model}%`)); + if (status !== null && !isNaN(status)) conditions.push(eq(otas.status, status)); + if (beta !== null && !isNaN(beta)) conditions.push(eq(otas.beta, beta)); + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ value: count() }) + .from(otas) + .where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db + .select() + .from(otas) + .where(whereClause) + .orderBy(desc(otas.id)) + .offset(skip) + .limit(limit); + + logger.info(`Found ${rows.length} OTA records, total=${total}`); + + if (!rows.length) { + res.json(ApiResponse.noData('empty')); + return; + } + + const items = rows.map(otaToDict); + res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting OTA list: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// GET /api/ota/:ota_id +router.get('/api/ota/:ota_id', async (req: Request, res: Response) => { + try { + const otaId = parseInt(req.params.ota_id as string, 10); + logger.info(`Getting OTA: id=${otaId}`); + + const [ota] = await db.select().from(otas).where(eq(otas.id, otaId)).limit(1); + if (!ota) { + logger.warn(`OTA not found: id=${otaId}`); + res.json(ApiResponse.noData('empty')); + return; + } + res.json(ApiResponse.success(otaToDict(ota))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting OTA: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// POST /api/ota/ +router.post('/api/ota/', async (req: Request, res: Response) => { + try { + const parsed = OtaCreateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + res.json(ApiResponse.error(errors)); + return; + } + + const data = parsed.data; + logger.info(`Creating OTA: verCode=${data.verCode}, verName=${data.verName}, model=${data.model}`); + + // 检查版本号是否已存在 + const [existing] = await db + .select() + .from(otas) + .where(and( + eq(otas.verCode, data.verCode), + eq(otas.model, data.model!), + eq(otas.beta, data.beta ?? 0), + )) + .limit(1); + if (existing) { + logger.warn(`OTA version already exists: verCode=${data.verCode}, model=${data.model}, beta=${data.beta ?? 0}`); + res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)')); + return; + } + + const result = await db.insert(otas).values({ + verCode: data.verCode, + verName: data.verName, + url: data.url, + md5: data.md5, + force: data.force ?? 0, + desc: data.desc ?? null, + model: data.model ?? null, + hw: data.hw ?? 0, + target: data.target ?? 0, + beta: data.beta ?? 0, + startTime: data.startTime ? new Date(data.startTime) : null, + endTime: data.endTime ? new Date(data.endTime) : null, + status: data.status ?? 1, + }); + + const [dbOta] = await db.select().from(otas).where(eq(otas.id, result[0].insertId)).limit(1); + logger.info(`OTA created successfully: id=${dbOta.id}, verCode=${dbOta.verCode}`); + res.json(ApiResponse.success(otaToDict(dbOta))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error creating OTA: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/ota/:ota_id +router.put('/api/ota/:ota_id', async (req: Request, res: Response) => { + try { + const otaId = parseInt(req.params.ota_id as string, 10); + + const parsed = OtaUpdateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + res.json(ApiResponse.error(errors)); + return; + } + + const data = parsed.data; + logger.info(`Updating OTA: id=${otaId}`); + + const [dbOta] = await db.select().from(otas).where(eq(otas.id, otaId)).limit(1); + if (!dbOta) { + logger.warn(`OTA not found: id=${otaId}`); + res.json(ApiResponse.noData('empty')); + return; + } + + // 如果更新版本号或灰度状态,检查是否冲突 + const newVerCode = data.verCode !== undefined && data.verCode !== null ? data.verCode : dbOta.verCode; + const newModel = data.model !== undefined && data.model !== null ? data.model : dbOta.model; + const newBeta = data.beta !== undefined && data.beta !== null ? data.beta : dbOta.beta; + + if (newVerCode !== dbOta.verCode || newModel !== dbOta.model || newBeta !== dbOta.beta) { + const [existing] = await db + .select() + .from(otas) + .where(and(eq(otas.verCode, newVerCode), eq(otas.model, newModel!), eq(otas.beta, newBeta))) + .limit(1); + if (existing) { + logger.warn(`OTA version already exists: verCode=${newVerCode}, model=${newModel}, beta=${newBeta}`); + res.json(ApiResponse.error('该版本已存在(相同版本+灰度状态)')); + return; + } + } + + // 更新字段 + const updateData: Partial = {}; + if (data.verCode !== undefined && data.verCode !== null) updateData.verCode = data.verCode; + if (data.verName !== undefined && data.verName !== null) updateData.verName = data.verName; + if (data.url !== undefined && data.url !== null) updateData.url = data.url; + if (data.md5 !== undefined && data.md5 !== null) updateData.md5 = data.md5; + if (data.force !== undefined && data.force !== null) updateData.force = data.force; + if (data.desc !== undefined) updateData.desc = data.desc; + if (data.model !== undefined) updateData.model = data.model; + if (data.hw !== undefined && data.hw !== null) updateData.hw = data.hw; + if (data.target !== undefined && data.target !== null) updateData.target = data.target; + if (data.beta !== undefined && data.beta !== null) updateData.beta = data.beta; + if (data.startTime !== undefined && data.startTime !== null) updateData.startTime = new Date(data.startTime); + if (data.endTime !== undefined && data.endTime !== null) updateData.endTime = new Date(data.endTime); + if (data.status !== undefined && data.status !== null) updateData.status = data.status; + + if (Object.keys(updateData).length > 0) { + await db.update(otas).set(updateData).where(eq(otas.id, otaId)); + } + + logger.info(`OTA updated successfully: id=${otaId}`); + + const [updated] = await db.select().from(otas).where(eq(otas.id, otaId)).limit(1); + res.json(ApiResponse.success(otaToDict(updated))); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error updating OTA: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/ota/:ota_id +router.delete('/api/ota/:ota_id', async (req: Request, res: Response) => { + try { + const otaId = parseInt(req.params.ota_id as string, 10); + logger.info(`Deleting OTA: id=${otaId}`); + + const [dbOta] = await db.select().from(otas).where(eq(otas.id, otaId)).limit(1); + if (!dbOta) { + logger.warn(`OTA not found: id=${otaId}`); + res.json(ApiResponse.noData('empty')); + return; + } + + await db.delete(otas).where(eq(otas.id, otaId)); + logger.info(`OTA deleted successfully: id=${otaId}`); + res.json(ApiResponse.success(null, 'success')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error deleting OTA: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +function otaToDict(ota: typeof otas.$inferSelect) { + return { + id: ota.id, + verCode: ota.verCode, + verName: ota.verName, + url: ota.url, + md5: ota.md5, + force: ota.force, + desc: ota.desc, + model: ota.model, + hw: ota.hw, + target: ota.target, + beta: ota.beta, + startTime: ota.startTime ? ota.startTime.toISOString() : null, + endTime: ota.endTime ? ota.endTime.toISOString() : null, + status: ota.status, + create_at: ota.createAt ? ota.createAt.toISOString() : null, + }; +} + +export default router; diff --git a/dashboard/backend/src/routes/otaTargetDevice.js b/dashboard/backend/src/routes/otaTargetDevice.js deleted file mode 100644 index 30d69b6..0000000 --- a/dashboard/backend/src/routes/otaTargetDevice.js +++ /dev/null @@ -1,247 +0,0 @@ -/** - * 定向升级路由 - */ -const router = require('express').Router(); -const { Op } = require('sequelize'); -const OtaTargetDevice = require('../models/OtaTargetDevice'); -const Ota = require('../models/Ota'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { authMiddleware } = require('../middleware/auth'); -const { OtaTargetDeviceCreateSchema, OtaTargetDeviceUpdateSchema } = require('../validators/otaTargetDevice'); - -// 所有接口需登录 -router.use('/api/ota-target-device', authMiddleware); - -// GET /api/ota-target-device/ -router.get('/api/ota-target-device/', async (req, res) => { - try { - const skip = parseInt(req.query.skip || '0', 10); - const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); - const otaId = req.query.ota_id !== undefined ? parseInt(req.query.ota_id, 10) : null; - const model = req.query.model; - const macAddr = req.query.mac_addr; - - const where = {}; - if (otaId !== null && !isNaN(otaId)) where.ota_id = otaId; - if (macAddr) where.mac_addr = { [Op.like]: `%${macAddr}%` }; - - // 如果按 model 筛选,需先查出对应 model 的 ota_id 列表 - if (model) { - const otaIds = await Ota.findAll({ - where: { model: { [Op.like]: `%${model}%` } }, - attributes: ['id'], - }); - const ids = otaIds.map((o) => o.id); - if (ids.length === 0) { - return res.json(ApiResponse.success({ items: [], total: 0, skip, limit })); - } - if (where.ota_id !== undefined) { - where.ota_id = { [Op.and]: [where.ota_id, { [Op.in]: ids }] }; - } else { - where.ota_id = { [Op.in]: ids }; - } - } - - const total = await OtaTargetDevice.count({ where }); - const rows = await OtaTargetDevice.findAll({ - where, - include: [{ - model: Ota, - as: 'ota', - attributes: ['id', 'verCode', 'verName', 'model'], - }], - offset: skip, - limit, - order: [['id', 'DESC']], - }); - - logger.info(`Found ${rows.length} OtaTargetDevice records, total=${total}`); - - if (!rows.length) { - return res.json(ApiResponse.noData('empty')); - } - - const items = rows.map(toDict); - return res.json(ApiResponse.success({ items, total, skip, limit })); - } catch (e) { - logger.error(`Error getting OtaTargetDevice: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// GET /api/ota-target-device/:id -router.get('/api/ota-target-device/:id', async (req, res) => { - try { - const id = parseInt(req.params.id, 10); - logger.info(`Getting OtaTargetDevice: id=${id}`); - const item = await OtaTargetDevice.findByPk(id, { - include: [{ - model: Ota, - as: 'ota', - attributes: ['id', 'verCode', 'verName', 'model'], - }], - }); - if (!item) { - logger.warn(`OtaTargetDevice not found: id=${id}`); - return res.json(ApiResponse.noData('empty')); - } - return res.json(ApiResponse.success(toDict(item))); - } catch (e) { - logger.error(`Error getting OtaTargetDevice ${req.params.id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// POST /api/ota-target-device/ -router.post('/api/ota-target-device/', async (req, res) => { - try { - const parsed = OtaTargetDeviceCreateSchema.safeParse(req.body); - if (!parsed.success) { - const errors = parsed.error.errors.map((e) => e.message).join('; '); - return res.json(ApiResponse.error(errors)); - } - - const data = parsed.data; - logger.info(`Creating OtaTargetDevice: ota_id=${data.ota_id}, mac_addr=${data.mac_addr}`); - - // 校验 OTA 是否存在 - const ota = await Ota.findByPk(data.ota_id); - if (!ota) { - return res.json(ApiResponse.error('OTA 版本不存在')); - } - - // 唯一性校验:同一 ota_id 下 mac_addr 不能重复 - const existing = await OtaTargetDevice.findOne({ - where: { ota_id: data.ota_id, mac_addr: data.mac_addr }, - }); - if (existing) { - return res.json(ApiResponse.error('该 MAC 地址已在此 OTA 版本中')); - } - - const item = await OtaTargetDevice.create(data); - logger.info(`OtaTargetDevice created: id=${item.id}`); - - // 重新加载关联数据 - const created = await OtaTargetDevice.findByPk(item.id, { - include: [{ - model: Ota, - as: 'ota', - attributes: ['id', 'verCode', 'verName', 'model'], - }], - }); - - return res.json(ApiResponse.success(toDict(created))); - } catch (e) { - logger.error(`Error creating OtaTargetDevice: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// PUT /api/ota-target-device/:id -router.put('/api/ota-target-device/:id', async (req, res) => { - try { - const id = parseInt(req.params.id, 10); - - const parsed = OtaTargetDeviceUpdateSchema.safeParse(req.body); - if (!parsed.success) { - const errors = parsed.error.errors.map((e) => e.message).join('; '); - return res.json(ApiResponse.error(errors)); - } - - const data = parsed.data; - logger.info(`Updating OtaTargetDevice: id=${id}`); - - const item = await OtaTargetDevice.findByPk(id); - if (!item) { - logger.warn(`OtaTargetDevice not found: id=${id}`); - return res.json(ApiResponse.noData('empty')); - } - - // 如果更新 ota_id,校验 OTA 是否存在 - if (data.ota_id !== undefined && data.ota_id !== null && data.ota_id !== item.ota_id) { - const ota = await Ota.findByPk(data.ota_id); - if (!ota) { - return res.json(ApiResponse.error('OTA 版本不存在')); - } - } - - // 唯一性校验:如果 ota_id 或 mac_addr 有变更 - const newOtaId = data.ota_id !== undefined && data.ota_id !== null ? data.ota_id : item.ota_id; - const newMacAddr = data.mac_addr !== undefined && data.mac_addr !== null ? data.mac_addr : item.mac_addr; - - if (newOtaId !== item.ota_id || newMacAddr !== item.mac_addr) { - const existing = await OtaTargetDevice.findOne({ - where: { - ota_id: newOtaId, - mac_addr: newMacAddr, - id: { [Op.ne]: id }, - }, - }); - if (existing) { - return res.json(ApiResponse.error('该 MAC 地址已在此 OTA 版本中')); - } - } - - const updateFields = Object.entries(data).filter(([_, v]) => v !== undefined && v !== null); - for (const [field, value] of updateFields) { - item[field] = value; - } - - await item.save(); - logger.info(`OtaTargetDevice updated: id=${id}`); - - // 重新加载关联数据 - const updated = await OtaTargetDevice.findByPk(id, { - include: [{ - model: Ota, - as: 'ota', - attributes: ['id', 'verCode', 'verName', 'model'], - }], - }); - - return res.json(ApiResponse.success(toDict(updated))); - } catch (e) { - logger.error(`Error updating OtaTargetDevice ${req.params.id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// DELETE /api/ota-target-device/:id -router.delete('/api/ota-target-device/:id', async (req, res) => { - try { - const id = parseInt(req.params.id, 10); - logger.info(`Deleting OtaTargetDevice: id=${id}`); - - const item = await OtaTargetDevice.findByPk(id); - if (!item) { - logger.warn(`OtaTargetDevice not found: id=${id}`); - return res.json(ApiResponse.noData('empty')); - } - - await item.destroy(); - logger.info(`OtaTargetDevice deleted: id=${id}`); - return res.json(ApiResponse.success(null, 'success')); - } catch (e) { - logger.error(`Error deleting OtaTargetDevice ${req.params.id}: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// OtaTargetDevice 对象转字典 -function toDict(item) { - return { - id: item.id, - ota_id: item.ota_id, - mac_addr: item.mac_addr, - create_at: item.create_at ? item.create_at.toISOString() : null, - ota: item.ota ? { - id: item.ota.id, - verCode: item.ota.verCode, - verName: item.ota.verName, - model: item.ota.model, - } : null, - }; -} - -module.exports = router; diff --git a/dashboard/backend/src/routes/otaTargetDevice.ts b/dashboard/backend/src/routes/otaTargetDevice.ts new file mode 100644 index 0000000..1c0945b --- /dev/null +++ b/dashboard/backend/src/routes/otaTargetDevice.ts @@ -0,0 +1,281 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq, like, ne, and, inArray, desc, count } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { otaTargetDevices, otas } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware } from '../middleware/auth.js'; +import { OtaTargetDeviceCreateSchema, OtaTargetDeviceUpdateSchema } from '../validators/otaTargetDevice.js'; + +const router: RouterType = Router(); + +// 所有接口需登录 +router.use('/api/ota-target-device', authMiddleware); + +async function getDeviceWithOta(id: number) { + const [row] = await db + .select({ + id: otaTargetDevices.id, + ota_id: otaTargetDevices.otaId, + mac_addr: otaTargetDevices.macAddr, + create_at: otaTargetDevices.createAt, + ota_id_2: otas.id, + ota_verCode: otas.verCode, + ota_verName: otas.verName, + ota_model: otas.model, + }) + .from(otaTargetDevices) + .leftJoin(otas, eq(otaTargetDevices.otaId, otas.id)) + .where(eq(otaTargetDevices.id, id)) + .limit(1); + return row ? toDict(row) : null; +} + +function toDict(item: Record) { + return { + id: item.id, + ota_id: item.ota_id, + mac_addr: item.mac_addr, + create_at: item.create_at ? (item.create_at as Date).toISOString() : null, + ota: item.ota_id_2 ? { + id: item.ota_id_2, + verCode: item.ota_verCode, + verName: item.ota_verName, + model: item.ota_model, + } : null, + }; +} + +// GET /api/ota-target-device/ +router.get('/api/ota-target-device/', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000); + const otaId = req.query.ota_id !== undefined ? parseInt(req.query.ota_id as string, 10) : null; + const model = req.query.model as string | undefined; + const macAddr = req.query.mac_addr as string | undefined; + + const conditions: ReturnType[] = []; + if (otaId !== null && !isNaN(otaId)) conditions.push(eq(otaTargetDevices.otaId, otaId)); + if (macAddr) conditions.push(like(otaTargetDevices.macAddr, `%${macAddr}%`)); + + // 如果按 model 筛选,需先查出对应 model 的 ota_id 列表 + if (model) { + const otaRows = await db + .select({ id: otas.id }) + .from(otas) + .where(like(otas.model, `%${model}%`)); + const ids = otaRows.map((o) => o.id); + if (ids.length === 0) { + res.json(ApiResponse.success({ items: [], total: 0, skip, limit })); + return; + } + conditions.push(inArray(otaTargetDevices.otaId, ids)); + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ value: count() }) + .from(otaTargetDevices) + .where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db + .select({ + id: otaTargetDevices.id, + ota_id: otaTargetDevices.otaId, + mac_addr: otaTargetDevices.macAddr, + create_at: otaTargetDevices.createAt, + ota_id_2: otas.id, + ota_verCode: otas.verCode, + ota_verName: otas.verName, + ota_model: otas.model, + }) + .from(otaTargetDevices) + .leftJoin(otas, eq(otaTargetDevices.otaId, otas.id)) + .where(whereClause) + .orderBy(desc(otaTargetDevices.id)) + .offset(skip) + .limit(limit); + + logger.info(`Found ${rows.length} OtaTargetDevice records, total=${total}`); + + if (!rows.length) { + res.json(ApiResponse.noData('empty')); + return; + } + + const items = rows.map((r) => toDict(r as unknown as Record)); + res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting OtaTargetDevice: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// GET /api/ota-target-device/:id +router.get('/api/ota-target-device/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + logger.info(`Getting OtaTargetDevice: id=${id}`); + const item = await getDeviceWithOta(id); + if (!item) { + logger.warn(`OtaTargetDevice not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + res.json(ApiResponse.success(item)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting OtaTargetDevice: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// POST /api/ota-target-device/ +router.post('/api/ota-target-device/', async (req: Request, res: Response) => { + try { + const parsed = OtaTargetDeviceCreateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + res.json(ApiResponse.error(errors)); + return; + } + + const data = parsed.data; + logger.info(`Creating OtaTargetDevice: ota_id=${data.ota_id}, mac_addr=${data.mac_addr}`); + + // 校验 OTA 是否存在 + const [ota] = await db.select().from(otas).where(eq(otas.id, data.ota_id)).limit(1); + if (!ota) { + res.json(ApiResponse.error('OTA 版本不存在')); + return; + } + + // 唯一性校验:同一 ota_id 下 mac_addr 不能重复 + const [existing] = await db + .select() + .from(otaTargetDevices) + .where(and(eq(otaTargetDevices.otaId, data.ota_id), eq(otaTargetDevices.macAddr, data.mac_addr))) + .limit(1); + if (existing) { + res.json(ApiResponse.error('该 MAC 地址已在此 OTA 版本中')); + return; + } + + const result = await db.insert(otaTargetDevices).values({ otaId: data.ota_id, macAddr: data.mac_addr }); + const newId = result[0].insertId; + logger.info(`OtaTargetDevice created: id=${newId}`); + + const created = await getDeviceWithOta(newId); + res.json(ApiResponse.success(created)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error creating OtaTargetDevice: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// PUT /api/ota-target-device/:id +router.put('/api/ota-target-device/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + + const parsed = OtaTargetDeviceUpdateSchema.safeParse(req.body); + if (!parsed.success) { + const errors = parsed.error.errors.map((e) => e.message).join('; '); + res.json(ApiResponse.error(errors)); + return; + } + + const data = parsed.data; + logger.info(`Updating OtaTargetDevice: id=${id}`); + + const [item] = await db + .select() + .from(otaTargetDevices) + .where(eq(otaTargetDevices.id, id)) + .limit(1); + if (!item) { + logger.warn(`OtaTargetDevice not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + + // 如果更新 ota_id,校验 OTA 是否存在 + if (data.ota_id !== undefined && data.ota_id !== null && data.ota_id !== item.otaId) { + const [ota] = await db.select().from(otas).where(eq(otas.id, data.ota_id)).limit(1); + if (!ota) { + res.json(ApiResponse.error('OTA 版本不存在')); + return; + } + } + + // 唯一性校验 + const newOtaId = data.ota_id !== undefined && data.ota_id !== null ? data.ota_id : item.otaId; + const newMacAddr = data.mac_addr !== undefined && data.mac_addr !== null ? data.mac_addr : item.macAddr; + + if (newOtaId !== item.otaId || newMacAddr !== item.macAddr) { + const [existing] = await db + .select() + .from(otaTargetDevices) + .where(and( + eq(otaTargetDevices.otaId, newOtaId), + eq(otaTargetDevices.macAddr, newMacAddr), + ne(otaTargetDevices.id, id), + )) + .limit(1); + if (existing) { + res.json(ApiResponse.error('该 MAC 地址已在此 OTA 版本中')); + return; + } + } + + const updateData: Record = {}; + if (data.ota_id !== undefined && data.ota_id !== null) updateData.otaId = data.ota_id; + if (data.mac_addr !== undefined && data.mac_addr !== null) updateData.macAddr = data.mac_addr; + + if (Object.keys(updateData).length > 0) { + await db.update(otaTargetDevices).set(updateData).where(eq(otaTargetDevices.id, id)); + } + logger.info(`OtaTargetDevice updated: id=${id}`); + + const updated = await getDeviceWithOta(id); + res.json(ApiResponse.success(updated)); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error updating OtaTargetDevice: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// DELETE /api/ota-target-device/:id +router.delete('/api/ota-target-device/:id', async (req: Request, res: Response) => { + try { + const id = parseInt(req.params.id as string, 10); + logger.info(`Deleting OtaTargetDevice: id=${id}`); + + const [item] = await db + .select() + .from(otaTargetDevices) + .where(eq(otaTargetDevices.id, id)) + .limit(1); + if (!item) { + logger.warn(`OtaTargetDevice not found: id=${id}`); + res.json(ApiResponse.noData('empty')); + return; + } + + await db.delete(otaTargetDevices).where(eq(otaTargetDevices.id, id)); + logger.info(`OtaTargetDevice deleted: id=${id}`); + res.json(ApiResponse.success(null, 'success')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error deleting OtaTargetDevice: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/shareCodeLogs.js b/dashboard/backend/src/routes/shareCodeLogs.js deleted file mode 100644 index dc5f676..0000000 --- a/dashboard/backend/src/routes/shareCodeLogs.js +++ /dev/null @@ -1,87 +0,0 @@ -/** - * 分享码日志路由 - */ -const router = require('express').Router(); -const { Op } = require('sequelize'); -const ShareCodeLog = require('../models/ShareCodeLog'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { authMiddleware } = require('../middleware/auth'); - -// 全部接口需登录 -router.use(authMiddleware); - -// GET /api/share-code/logs -router.get('/api/share-code/logs', async (req, res) => { - try { - const skip = parseInt(req.query.skip || '0', 10); - const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); - - const macAddr = (req.query.mac_addr || '').trim(); - const shareCode = (req.query.share_code || '').trim(); - const action = (req.query.action || '').trim(); - const ipAddr = (req.query.ip_addr || '').trim(); - - const startAt = (req.query.start_at || '').trim(); - const endAt = (req.query.end_at || '').trim(); - - const sortBy = (req.query.sort_by || 'id').trim(); - const sortOrder = (req.query.sort_order || 'desc').toLowerCase(); - - const where = {}; - if (macAddr) where.mac_addr = { [Op.like]: `%${macAddr}%` }; - if (shareCode) where.share_code = { [Op.like]: `%${shareCode}%` }; - if (ipAddr) where.ip_addr = { [Op.like]: `%${ipAddr}%` }; - if (action === 'export' || action === 'import') where.action = action; - - if (startAt || endAt) { - const range = {}; - if (startAt) { - const d = new Date(startAt); - if (!Number.isNaN(d.getTime())) range[Op.gte] = d; - } - if (endAt) { - const d = new Date(endAt); - if (!Number.isNaN(d.getTime())) range[Op.lte] = d; - } - if (Object.keys(range).length) where.create_at = range; - } - - const total = await ShareCodeLog.count({ where }); - - const orderDir = sortOrder === 'asc' ? 'ASC' : 'DESC'; - const orderCol = sortBy === 'create_at' ? 'create_at' : 'id'; - - const rows = await ShareCodeLog.findAll({ - where, - offset: skip, - limit, - order: [[orderCol, orderDir]], - }); - - logger.info(`Found ${rows.length} share_code_log rows, total=${total}`); - - if (!rows.length) { - return res.json(ApiResponse.noData('empty')); - } - - const items = rows.map((r) => ({ - id: r.id, - mac_addr: r.mac_addr, - share_code: r.share_code, - action: r.action, - ip_addr: r.ip_addr, - eq_data: r.eq_data, - expire_at: r.expire_at ? r.expire_at.toISOString() : null, - create_at: r.create_at ? r.create_at.toISOString() : null, - })); - - return res.json(ApiResponse.success({ items, total, skip, limit })); - } catch (e) { - logger.error(`Error getting share code logs: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -module.exports = router; - diff --git a/dashboard/backend/src/routes/shareCodeLogs.ts b/dashboard/backend/src/routes/shareCodeLogs.ts new file mode 100644 index 0000000..2a38a21 --- /dev/null +++ b/dashboard/backend/src/routes/shareCodeLogs.ts @@ -0,0 +1,93 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq, like, gte, lte, and, desc, asc, count } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { shareCodeLogs } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware } from '../middleware/auth.js'; + +const router: RouterType = Router(); + +// 全部接口需登录 +router.use(authMiddleware); + +// GET /api/share-code/logs +router.get('/api/share-code/logs', async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000); + + const macAddr = (req.query.mac_addr as string || '').trim(); + const shareCode = (req.query.share_code as string || '').trim(); + const action = (req.query.action as string || '').trim(); + const ipAddr = (req.query.ip_addr as string || '').trim(); + + const startAt = (req.query.start_at as string || '').trim(); + const endAt = (req.query.end_at as string || '').trim(); + + const sortBy = (req.query.sort_by as string || 'id').trim(); + const sortOrder = (req.query.sort_order as string || 'desc').toLowerCase(); + + const conditions = []; + if (macAddr) conditions.push(like(shareCodeLogs.macAddr, `%${macAddr}%`)); + if (shareCode) conditions.push(like(shareCodeLogs.shareCode, `%${shareCode}%`)); + if (ipAddr) conditions.push(like(shareCodeLogs.ipAddr, `%${ipAddr}%`)); + if (action === 'export' || action === 'import') conditions.push(eq(shareCodeLogs.action, action)); + + if (startAt || endAt) { + if (startAt) { + const d = new Date(startAt); + if (!Number.isNaN(d.getTime())) conditions.push(gte(shareCodeLogs.createAt, d)); + } + if (endAt) { + const d = new Date(endAt); + if (!Number.isNaN(d.getTime())) conditions.push(lte(shareCodeLogs.createAt, d)); + } + } + + const whereClause = conditions.length > 0 ? and(...conditions) : undefined; + + const [totalResult] = await db + .select({ value: count() }) + .from(shareCodeLogs) + .where(whereClause); + const total = totalResult?.value ?? 0; + + const orderCol = sortBy === 'create_at' ? shareCodeLogs.createAt : shareCodeLogs.id; + const orderDir = sortOrder === 'asc' ? asc(orderCol) : desc(orderCol); + + const rows = await db + .select() + .from(shareCodeLogs) + .where(whereClause) + .orderBy(orderDir) + .offset(skip) + .limit(limit); + + logger.info(`Found ${rows.length} share_code_log rows, total=${total}`); + + if (!rows.length) { + res.json(ApiResponse.noData('empty')); + return; + } + + const items = rows.map((r) => ({ + id: r.id, + mac_addr: r.macAddr, + share_code: r.shareCode, + action: r.action, + ip_addr: r.ipAddr, + eq_data: r.eqData, + expire_at: r.expireAt ? r.expireAt.toISOString() : null, + create_at: r.createAt ? r.createAt.toISOString() : null, + })); + + res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting share code logs: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +export default router; diff --git a/dashboard/backend/src/routes/users.js b/dashboard/backend/src/routes/users.js deleted file mode 100644 index 8748618..0000000 --- a/dashboard/backend/src/routes/users.js +++ /dev/null @@ -1,182 +0,0 @@ -/** - * 账号管理路由(超级管理员) - */ -const router = require('express').Router(); -const { Op } = require('sequelize'); -const DashboardUser = require('../models/DashboardUser'); -const logger = require('../config/logger'); -const { ApiResponse } = require('../utils/response'); -const { authMiddleware, requireSuperAdmin } = require('../middleware/auth'); -const { hashPassword } = require('../utils/password'); - -router.use(authMiddleware); -// NOTE: requireSuperAdmin is applied per-route, NOT globally, -// because router-level middleware runs for ALL requests entering -// this router, even those that don't match any route here -// (e.g. /api/dashboard/today), causing unintended 403 responses. - -function userToDict(user) { - return { - id: user.id, - username: user.username, - is_super_admin: !!user.is_super_admin, - status: user.status, - last_login_at: user.last_login_at ? user.last_login_at.toISOString() : null, - create_at: user.create_at ? user.create_at.toISOString() : null, - update_at: user.update_at ? user.update_at.toISOString() : null, - }; -} - -async function countSuperAdmins(excludeId = null) { - const where = { is_super_admin: 1, status: 1 }; - if (excludeId != null) { - where.id = { [Op.ne]: excludeId }; - } - return DashboardUser.count({ where }); -} - -// GET /api/users/ -router.get('/api/users/', requireSuperAdmin, async (req, res) => { - try { - const skip = parseInt(req.query.skip || '0', 10); - const limit = Math.min(parseInt(req.query.limit || '100', 10), 1000); - const username = (req.query.username || '').trim(); - - const where = username ? { username: { [Op.like]: `%${username}%` } } : {}; - const total = await DashboardUser.count({ where }); - const rows = await DashboardUser.findAll({ - where, - offset: skip, - limit, - order: [['id', 'ASC']], - }); - - if (!rows.length) { - return res.json(ApiResponse.noData('empty')); - } - - const items = rows.map(userToDict); - return res.json(ApiResponse.success({ items, total, skip, limit })); - } catch (e) { - logger.error(`Error getting users: ${e.message}`); - return res.json(ApiResponse.error('error')); - } -}); - -// POST /api/users/ -router.post('/api/users/', requireSuperAdmin, async (req, res) => { - try { - const username = (req.body.username || '').trim(); - const password = req.body.password || ''; - const isSuperAdmin = !!req.body.is_super_admin; - - if (!username) { - return res.json(ApiResponse.error('用户名不能为空')); - } - if (username.length > 64) { - return res.json(ApiResponse.error('用户名不能超过 64 个字符')); - } - if (!password || password.length < 6) { - return res.json(ApiResponse.error('密码不能少于 6 位')); - } - - const existing = await DashboardUser.findOne({ where: { username } }); - if (existing) { - return res.json(ApiResponse.error('用户名已存在')); - } - - const user = await DashboardUser.create({ - username, - password_hash: await hashPassword(password), - is_super_admin: isSuperAdmin ? 1 : 0, - status: 1, - }); - - logger.info(`User created: id=${user.id}, username=${user.username}, by=${req.user.id}`); - return res.json(ApiResponse.success(userToDict(user), '账号创建成功')); - } catch (e) { - logger.error(`Error creating user: ${e.message}`); - return res.json(ApiResponse.error(`创建失败:${e.message}`)); - } -}); - -// PUT /api/users/:user_id -router.put('/api/users/:user_id', requireSuperAdmin, async (req, res) => { - try { - const userId = parseInt(req.params.user_id, 10); - const dbUser = await DashboardUser.findByPk(userId); - if (!dbUser) { - return res.json(ApiResponse.noData('账号不存在')); - } - - const { status, is_super_admin: isSuperAdmin, password } = req.body || {}; - const nextStatus = status !== undefined && status !== null ? parseInt(status, 10) : dbUser.status; - const nextIsSuperAdmin = isSuperAdmin !== undefined && isSuperAdmin !== null - ? (isSuperAdmin ? 1 : 0) - : dbUser.is_super_admin; - - if (![0, 1].includes(nextStatus)) { - return res.json(ApiResponse.error('状态值无效')); - } - - const wasSuperAdmin = !!dbUser.is_super_admin; - const willBeSuperAdmin = !!nextIsSuperAdmin; - const willBeEnabled = nextStatus === 1; - - if (wasSuperAdmin && (!willBeSuperAdmin || !willBeEnabled)) { - const otherSuperAdmins = await countSuperAdmins(userId); - if (otherSuperAdmins === 0) { - return res.json(ApiResponse.error('不能禁用或降级最后一个超级管理员')); - } - } - - if (password !== undefined && password !== null && String(password).length > 0) { - if (String(password).length < 6) { - return res.json(ApiResponse.error('密码不能少于 6 位')); - } - dbUser.password_hash = await hashPassword(password); - } - - dbUser.status = nextStatus; - dbUser.is_super_admin = nextIsSuperAdmin ? 1 : 0; - await dbUser.save(); - - logger.info(`User updated: id=${userId}, by=${req.user.id}`); - return res.json(ApiResponse.success(userToDict(dbUser), '账号更新成功')); - } catch (e) { - logger.error(`Error updating user ${req.params.user_id}: ${e.message}`); - return res.json(ApiResponse.error(`更新失败:${e.message}`)); - } -}); - -// DELETE /api/users/:user_id -router.delete('/api/users/:user_id', requireSuperAdmin, async (req, res) => { - try { - const userId = parseInt(req.params.user_id, 10); - - if (userId === req.user.id) { - return res.json(ApiResponse.error('不能删除当前登录账号')); - } - - const dbUser = await DashboardUser.findByPk(userId); - if (!dbUser) { - return res.json(ApiResponse.noData('账号不存在')); - } - - if (dbUser.is_super_admin) { - const otherSuperAdmins = await countSuperAdmins(userId); - if (otherSuperAdmins === 0) { - return res.json(ApiResponse.error('不能删除最后一个超级管理员')); - } - } - - await dbUser.destroy(); - logger.info(`User deleted: id=${userId}, by=${req.user.id}`); - return res.json(ApiResponse.success(null, '删除成功')); - } catch (e) { - logger.error(`Error deleting user ${req.params.user_id}: ${e.message}`); - return res.json(ApiResponse.error(`删除失败:${e.message}`)); - } -}); - -module.exports = router; diff --git a/dashboard/backend/src/routes/users.ts b/dashboard/backend/src/routes/users.ts new file mode 100644 index 0000000..b3c5131 --- /dev/null +++ b/dashboard/backend/src/routes/users.ts @@ -0,0 +1,233 @@ +import { Router, type Request, type Response, type Router as RouterType } from 'express'; +import { eq, like, ne, and, asc, count } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { dashboardUsers } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { ApiResponse } from '../utils/response.js'; +import { authMiddleware, requireSuperAdmin } from '../middleware/auth.js'; +import { hashPassword } from '../utils/password.js'; + +const router: RouterType = Router(); + +router.use(authMiddleware); +// NOTE: requireSuperAdmin is applied per-route, NOT globally + +function userToDict(user: typeof dashboardUsers.$inferSelect) { + return { + id: user.id, + username: user.username, + is_super_admin: !!user.isSuperAdmin, + status: user.status, + last_login_at: user.lastLoginAt ? user.lastLoginAt.toISOString() : null, + create_at: user.createAt ? user.createAt.toISOString() : null, + update_at: user.updateAt ? user.updateAt.toISOString() : null, + }; +} + +async function countSuperAdmins(excludeId: number | null = null) { + const conditions = [eq(dashboardUsers.isSuperAdmin, 1), eq(dashboardUsers.status, 1)]; + if (excludeId != null) { + conditions.push(ne(dashboardUsers.id, excludeId)); + } + const [result] = await db + .select({ value: count() }) + .from(dashboardUsers) + .where(and(...conditions)); + return result?.value ?? 0; +} + +// GET /api/users/ +router.get('/api/users/', requireSuperAdmin, async (req: Request, res: Response) => { + try { + const skip = parseInt(req.query.skip as string || '0', 10); + const limit = Math.min(parseInt(req.query.limit as string || '100', 10), 1000); + const username = (req.query.username as string || '').trim(); + + const whereClause = username ? like(dashboardUsers.username, `%${username}%`) : undefined; + + const [totalResult] = await db + .select({ value: count() }) + .from(dashboardUsers) + .where(whereClause); + const total = totalResult?.value ?? 0; + + const rows = await db + .select() + .from(dashboardUsers) + .where(whereClause) + .orderBy(asc(dashboardUsers.id)) + .offset(skip) + .limit(limit); + + if (!rows.length) { + res.json(ApiResponse.noData('empty')); + return; + } + + const items = rows.map(userToDict); + res.json(ApiResponse.success({ items, total, skip, limit })); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error getting users: ${msg}`); + res.json(ApiResponse.error('error')); + } +}); + +// POST /api/users/ +router.post('/api/users/', requireSuperAdmin, async (req: Request, res: Response) => { + try { + const username = (req.body.username || '').trim(); + const password = req.body.password || ''; + const isSuperAdmin = !!req.body.is_super_admin; + + if (!username) { + res.json(ApiResponse.error('用户名不能为空')); + return; + } + if (username.length > 64) { + res.json(ApiResponse.error('用户名不能超过 64 个字符')); + return; + } + if (!password || password.length < 6) { + res.json(ApiResponse.error('密码不能少于 6 位')); + return; + } + + const [existing] = await db + .select() + .from(dashboardUsers) + .where(eq(dashboardUsers.username, username)) + .limit(1); + if (existing) { + res.json(ApiResponse.error('用户名已存在')); + return; + } + + const result = await db.insert(dashboardUsers).values({ + username, + passwordHash: await hashPassword(password), + isSuperAdmin: isSuperAdmin ? 1 : 0, + status: 1, + }); + + const [user] = await db + .select() + .from(dashboardUsers) + .where(eq(dashboardUsers.id, result[0].insertId)) + .limit(1); + + logger.info(`User created: id=${user.id}, username=${user.username}, by=${req.user!.id}`); + res.json(ApiResponse.success(userToDict(user), '账号创建成功')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error creating user: ${msg}`); + res.json(ApiResponse.error(`创建失败:${msg}`)); + } +}); + +// PUT /api/users/:user_id +router.put('/api/users/:user_id', requireSuperAdmin, async (req: Request, res: Response) => { + try { + const userId = parseInt(req.params.user_id as string, 10); + const [dbUser] = await db + .select() + .from(dashboardUsers) + .where(eq(dashboardUsers.id, userId)) + .limit(1); + if (!dbUser) { + res.json(ApiResponse.noData('账号不存在')); + return; + } + + const { status, is_super_admin: isSuperAdmin, password } = req.body || {}; + const nextStatus = status !== undefined && status !== null ? parseInt(status, 10) : dbUser.status; + const nextIsSuperAdmin = isSuperAdmin !== undefined && isSuperAdmin !== null + ? (isSuperAdmin ? 1 : 0) + : dbUser.isSuperAdmin; + + if (![0, 1].includes(nextStatus)) { + res.json(ApiResponse.error('状态值无效')); + return; + } + + const wasSuperAdmin = !!dbUser.isSuperAdmin; + const willBeSuperAdmin = !!nextIsSuperAdmin; + const willBeEnabled = nextStatus === 1; + + if (wasSuperAdmin && (!willBeSuperAdmin || !willBeEnabled)) { + const otherSuperAdmins = await countSuperAdmins(userId); + if (otherSuperAdmins === 0) { + res.json(ApiResponse.error('不能禁用或降级最后一个超级管理员')); + return; + } + } + + const updateData: Partial = {}; + + if (password !== undefined && password !== null && String(password).length > 0) { + if (String(password).length < 6) { + res.json(ApiResponse.error('密码不能少于 6 位')); + return; + } + updateData.passwordHash = await hashPassword(password); + } + + updateData.status = nextStatus; + updateData.isSuperAdmin = nextIsSuperAdmin ? 1 : 0; + + await db.update(dashboardUsers).set(updateData).where(eq(dashboardUsers.id, userId)); + + logger.info(`User updated: id=${userId}, by=${req.user!.id}`); + + const [updated] = await db + .select() + .from(dashboardUsers) + .where(eq(dashboardUsers.id, userId)) + .limit(1); + res.json(ApiResponse.success(userToDict(updated), '账号更新成功')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error updating user: ${msg}`); + res.json(ApiResponse.error(`更新失败:${msg}`)); + } +}); + +// DELETE /api/users/:user_id +router.delete('/api/users/:user_id', requireSuperAdmin, async (req: Request, res: Response) => { + try { + const userId = parseInt(req.params.user_id as string, 10); + + if (userId === req.user!.id) { + res.json(ApiResponse.error('不能删除当前登录账号')); + return; + } + + const [dbUser] = await db + .select() + .from(dashboardUsers) + .where(eq(dashboardUsers.id, userId)) + .limit(1); + if (!dbUser) { + res.json(ApiResponse.noData('账号不存在')); + return; + } + + if (dbUser.isSuperAdmin) { + const otherSuperAdmins = await countSuperAdmins(userId); + if (otherSuperAdmins === 0) { + res.json(ApiResponse.error('不能删除最后一个超级管理员')); + return; + } + } + + await db.delete(dashboardUsers).where(eq(dashboardUsers.id, userId)); + logger.info(`User deleted: id=${userId}, by=${req.user!.id}`); + res.json(ApiResponse.success(null, '删除成功')); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`Error deleting user: ${msg}`); + res.json(ApiResponse.error(`删除失败:${msg}`)); + } +}); + +export default router; diff --git a/dashboard/backend/src/schemas/blackList.ts b/dashboard/backend/src/schemas/blackList.ts new file mode 100644 index 0000000..a3b054c --- /dev/null +++ b/dashboard/backend/src/schemas/blackList.ts @@ -0,0 +1,9 @@ +import { mysqlTable, int, varchar, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const blackLists = mysqlTable('black_list', { + id: int('id').primaryKey().autoincrement(), + otaId: int('ota_id').notNull(), + mac: varchar('mac', { length: 100 }).notNull(), + createAt: datetime('create_at').notNull().default(sql`NOW()`), +}); diff --git a/dashboard/backend/src/schemas/brand.ts b/dashboard/backend/src/schemas/brand.ts new file mode 100644 index 0000000..9b38b0e --- /dev/null +++ b/dashboard/backend/src/schemas/brand.ts @@ -0,0 +1,6 @@ +import { mysqlTable, int, varchar } from 'drizzle-orm/mysql-core'; + +export const brands = mysqlTable('brand', { + id: int('id').primaryKey().autoincrement(), + name: varchar('name', { length: 100 }).notNull().unique(), +}); diff --git a/dashboard/backend/src/schemas/dashboardUser.ts b/dashboard/backend/src/schemas/dashboardUser.ts new file mode 100644 index 0000000..8561cc0 --- /dev/null +++ b/dashboard/backend/src/schemas/dashboardUser.ts @@ -0,0 +1,13 @@ +import { mysqlTable, int, tinyint, varchar, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const dashboardUsers = mysqlTable('dashboard_user', { + id: int('id').primaryKey().autoincrement(), + username: varchar('username', { length: 64 }).notNull().unique(), + passwordHash: varchar('password_hash', { length: 255 }).notNull(), + isSuperAdmin: tinyint('is_super_admin').notNull().default(0), + status: tinyint('status').notNull().default(1), + lastLoginAt: datetime('last_login_at'), + createAt: datetime('create_at').notNull().default(sql`NOW()`), + updateAt: datetime('update_at').notNull().default(sql`NOW()`), +}); diff --git a/dashboard/backend/src/schemas/index.ts b/dashboard/backend/src/schemas/index.ts new file mode 100644 index 0000000..607330d --- /dev/null +++ b/dashboard/backend/src/schemas/index.ts @@ -0,0 +1,12 @@ +/** + * Schemas 统一导出 + */ +export { brands } from './brand.js'; +export { models } from './model.js'; +export { otas } from './ota.js'; +export { otaTargetDevices } from './otaTargetDevice.js'; +export { blackLists } from './blackList.js'; +export { dashboardUsers } from './dashboardUser.js'; +export { shareCodeLogs } from './shareCodeLog.js'; +export { userActives } from './userActive.js'; +export { userDevices } from './userDevice.js'; diff --git a/dashboard/backend/src/schemas/model.ts b/dashboard/backend/src/schemas/model.ts new file mode 100644 index 0000000..df0fffd --- /dev/null +++ b/dashboard/backend/src/schemas/model.ts @@ -0,0 +1,13 @@ +import { mysqlTable, int, varchar, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const models = mysqlTable('model', { + id: int('id').primaryKey().autoincrement(), + brandName: varchar('brand_name', { length: 100 }).notNull(), + name: varchar('name', { length: 100 }).notNull(), + form: varchar('form', { length: 100 }), + rig: varchar('rig', { length: 100 }), + source: varchar('source', { length: 100 }), + eqKey: varchar('eq_key', { length: 255 }), + createAt: datetime('create_at').notNull().default(sql`NOW()`), +}); diff --git a/dashboard/backend/src/schemas/ota.ts b/dashboard/backend/src/schemas/ota.ts new file mode 100644 index 0000000..b00c3f6 --- /dev/null +++ b/dashboard/backend/src/schemas/ota.ts @@ -0,0 +1,20 @@ +import { mysqlTable, int, smallint, varchar, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const otas = mysqlTable('ota', { + id: int('id').primaryKey().autoincrement(), + verCode: int('verCode').notNull(), + verName: varchar('verName', { length: 20 }).notNull(), + url: varchar('url', { length: 255 }).notNull(), + md5: varchar('md5', { length: 32 }).notNull(), + force: smallint('force').notNull().default(0), + desc: varchar('desc', { length: 255 }), + model: varchar('model', { length: 100 }), + hw: int('hw').notNull().default(0), + target: smallint('target').notNull().default(0), + beta: smallint('beta').notNull().default(0), + startTime: datetime('startTime'), + endTime: datetime('endTime'), + status: smallint('status').notNull().default(1), + createAt: datetime('create_at').notNull().default(sql`NOW()`), +}); diff --git a/dashboard/backend/src/schemas/otaTargetDevice.ts b/dashboard/backend/src/schemas/otaTargetDevice.ts new file mode 100644 index 0000000..e8875b8 --- /dev/null +++ b/dashboard/backend/src/schemas/otaTargetDevice.ts @@ -0,0 +1,9 @@ +import { mysqlTable, int, varchar, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const otaTargetDevices = mysqlTable('ota_target_device', { + id: int('id').primaryKey().autoincrement(), + otaId: int('ota_id').notNull(), + macAddr: varchar('mac_addr', { length: 17 }).notNull(), + createAt: datetime('create_at').notNull().default(sql`NOW()`), +}); diff --git a/dashboard/backend/src/schemas/shareCodeLog.ts b/dashboard/backend/src/schemas/shareCodeLog.ts new file mode 100644 index 0000000..8d9ee39 --- /dev/null +++ b/dashboard/backend/src/schemas/shareCodeLog.ts @@ -0,0 +1,17 @@ +import { mysqlTable, int, char, varchar, json, datetime, index } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const shareCodeLogs = mysqlTable('share_code_log', { + id: int('id').primaryKey().autoincrement(), + macAddr: varchar('mac_addr', { length: 17 }).notNull(), + shareCode: char('share_code', { length: 5 }).notNull(), + action: varchar('action', { length: 10, enum: ['export', 'import'] }).notNull(), + ipAddr: varchar('ip_addr', { length: 45 }).notNull().default(''), + eqData: json('eq_data').notNull(), + expireAt: datetime('expire_at'), + createAt: datetime('create_at').notNull().default(sql`NOW()`), +}, (table) => [ + index('idx_mac_addr').on(table.macAddr), + index('idx_share_code').on(table.shareCode), + index('idx_create_at').on(table.createAt), +]); diff --git a/dashboard/backend/src/schemas/userActive.ts b/dashboard/backend/src/schemas/userActive.ts new file mode 100644 index 0000000..9185f8b --- /dev/null +++ b/dashboard/backend/src/schemas/userActive.ts @@ -0,0 +1,11 @@ +import { mysqlTable, int, varchar, date, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const userActives = mysqlTable('user_active', { + id: int('id').primaryKey().autoincrement(), + macAddr: varchar('mac_addr', { length: 30 }).notNull(), + model: varchar('model', { length: 50 }).notNull(), + activeDate: date('active_date').notNull(), + ipAddr: varchar('ip_addr', { length: 45 }).notNull(), + createAt: datetime('create_at').notNull().default(sql`NOW()`), +}); diff --git a/dashboard/backend/src/schemas/userDevice.ts b/dashboard/backend/src/schemas/userDevice.ts new file mode 100644 index 0000000..3cc1bec --- /dev/null +++ b/dashboard/backend/src/schemas/userDevice.ts @@ -0,0 +1,10 @@ +import { mysqlTable, int, varchar, datetime } from 'drizzle-orm/mysql-core'; +import { sql } from 'drizzle-orm'; + +export const userDevices = mysqlTable('user_device', { + id: int('id').primaryKey().autoincrement(), + macAddr: varchar('mac_addr', { length: 30 }).notNull().unique(), + model: varchar('model', { length: 50 }).notNull(), + addTime: datetime('add_time').notNull().default(sql`NOW()`), + ver: varchar('ver', { length: 10 }), +}); diff --git a/dashboard/backend/src/services/curveClient.js b/dashboard/backend/src/services/curveClient.ts similarity index 63% rename from dashboard/backend/src/services/curveClient.js rename to dashboard/backend/src/services/curveClient.ts index 908f81c..f59e216 100644 --- a/dashboard/backend/src/services/curveClient.js +++ b/dashboard/backend/src/services/curveClient.ts @@ -2,8 +2,8 @@ * Luxsin 曲线 API:拉取、自定义 Base64 解码与 parametric_eq 校验。 * 对应 Python: curve_client.py */ -const axios = require('axios'); -const logger = require('../config/logger'); +import axios from 'axios'; +import logger from '../config/logger.js'; const LUXSIN_CURVE_API_BASE = process.env.CURVE_API_BASE_URL || 'https://api.luxsin.com.cn/audio/getCurve'; @@ -13,17 +13,17 @@ const STANDARD_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123 const TARGET_OVER_EAR = 'Harman over-ear 2018'; const TARGET_IN_EAR = 'Harman in-ear 2019'; -const FORM_TARGET_MAP = { +const FORM_TARGET_MAP: Record = { 'over-ear': TARGET_OVER_EAR, 'in-ear': TARGET_IN_EAR, }; -function curveTargetForForm(form) { +export function curveTargetForForm(form: string | null | undefined): string | null { if (!form) return null; return FORM_TARGET_MAP[form.trim().toLowerCase()] || null; } -function customBase64ToString(encoded) { +export function customBase64ToString(encoded: string): string { if (!encoded || typeof encoded !== 'string') { throw new Error('曲线数据为空'); } @@ -35,20 +35,29 @@ function customBase64ToString(encoded) { try { return Buffer.from(standardB64, 'base64').toString('utf-8'); - } catch (e) { - throw new Error(`Base64 解码失败:${e.message}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`Base64 解码失败:${msg}`); } } -function isValidParametricEqPayload(data) { - if (typeof data !== 'object' || data === null) return false; - const peq = data.parametric_eq; - if (typeof peq !== 'object' || peq === null) return false; - return Array.isArray(peq.filters) && peq.filters.length === 10; +interface ParametricEqPayload { + parametric_eq: { + filters: unknown[]; + [key: string]: unknown; + }; } -function extractEncodedPayload(responseData, responseText) { - const text = (responseText || '').trim(); +export function isValidParametricEqPayload(data: unknown): boolean { + if (typeof data !== 'object' || data === null) return false; + const peq = (data as Record).parametric_eq; + if (typeof peq !== 'object' || peq === null) return false; + const filters = (peq as Record).filters; + return Array.isArray(filters) && filters.length === 10; +} + +export function extractEncodedPayload(responseData: unknown, _responseText: string): string { + const text = (_responseText || '').trim(); if (!text) throw new Error('曲线接口响应为空'); // 如果响应是纯字符串 @@ -60,15 +69,18 @@ function extractEncodedPayload(responseData, responseText) { throw new Error('曲线接口响应格式异常'); } + const data = responseData as Record; + for (const key of ['data', 'curve', 'result', 'content', 'body']) { - const val = responseData[key]; + const val = data[key]; if (typeof val === 'string' && val.trim()) return val.trim(); } - const nested = responseData.data; + const nested = data.data; if (typeof nested === 'object' && nested !== null) { + const nestedObj = nested as Record; for (const key of ['curve', 'data', 'content', 'encoded']) { - const val = nested[key]; + const val = nestedObj[key]; if (typeof val === 'string' && val.trim()) return val.trim(); } } @@ -78,7 +90,12 @@ function extractEncodedPayload(responseData, responseText) { throw new Error('曲线接口响应中未找到可解码数据'); } -async function fetchAndValidateCurve(brand, name, form, timeout = 20000) { +export async function fetchAndValidateCurve( + brand: string, + name: string, + form: string, + timeout: number = 20000, +): Promise<[boolean, string]> { brand = (brand || '').trim(); name = (name || '').trim(); if (!brand || !name) return [false, '品牌名称或型号名称为空']; @@ -94,9 +111,10 @@ async function fetchAndValidateCurve(brand, name, form, timeout = 20000) { let resp; try { resp = await axios.get(url, { timeout }); - } catch (e) { - logger.warn(`getCurve request failed: ${url} ${e.message}`); - return [false, `无法连接曲线接口:${e.message}`]; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.warn(`getCurve request failed: ${url} ${msg}`); + return [false, `无法连接曲线接口:${msg}`]; } if (resp.status !== 200) { @@ -108,25 +126,27 @@ async function fetchAndValidateCurve(brand, name, form, timeout = 20000) { logger.info(`getCurve response data type=${typeof resp.data}`); logger.info(`getCurve response data=${typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data).slice(0, 2000)}`); - let encoded; + let encoded: string; try { encoded = extractEncodedPayload(resp.data, typeof resp.data === 'string' ? resp.data : JSON.stringify(resp.data)); - } catch (e) { - logger.error(`extractEncodedPayload failed: ${e.message}, raw data=${typeof resp.data === 'string' ? resp.data.slice(0, 500) : JSON.stringify(resp.data).slice(0, 500)}`); - return [false, e.message]; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`extractEncodedPayload failed: ${msg}, raw data=${typeof resp.data === 'string' ? resp.data.slice(0, 500) : JSON.stringify(resp.data).slice(0, 500)}`); + return [false, msg]; } - let decodedText; + let decodedText: string; try { decodedText = customBase64ToString(encoded); - } catch (e) { - return [false, e.message]; + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + return [false, msg]; } - let payload; + let payload: unknown; try { payload = JSON.parse(decodedText); - } catch (e) { + } catch { return [false, '解码后的数据不是合法 JSON']; } @@ -136,11 +156,3 @@ async function fetchAndValidateCurve(brand, name, form, timeout = 20000) { return [true, '']; } - -module.exports = { - curveTargetForForm, - customBase64ToString, - isValidParametricEqPayload, - extractEncodedPayload, - fetchAndValidateCurve, -}; diff --git a/dashboard/backend/src/services/eqCacheStorage.js b/dashboard/backend/src/services/eqCacheStorage.ts similarity index 57% rename from dashboard/backend/src/services/eqCacheStorage.js rename to dashboard/backend/src/services/eqCacheStorage.ts index da50273..2c9167f 100644 --- a/dashboard/backend/src/services/eqCacheStorage.js +++ b/dashboard/backend/src/services/eqCacheStorage.ts @@ -1,14 +1,14 @@ -const { getEqCacheRedis } = require('../config/redis'); -const logger = require('../config/logger'); +import { getEqCacheRedis } from '../config/redis.js'; +import logger from '../config/logger.js'; /** * EQ 缓存 Redis Key:{品牌名称} {型号名称} */ -function buildEqCacheRedisKey(brandName, modelName) { +export function buildEqCacheRedisKey(brandName: string, modelName: string): string { return `${brandName} ${modelName}`; } -function parseHashValue(raw) { +function parseHashValue(raw: string | null): unknown { if (raw === null || raw === undefined) return null; try { return JSON.parse(raw); @@ -19,9 +19,11 @@ function parseHashValue(raw) { /** * 读取型号 EQ 缓存的 hash field 列表(不含 value,避免响应过大) - * @returns {Promise<{ redis_key: string, field_keys: string[] }>} */ -async function getEqCacheKeys(brandName, modelName) { +export async function getEqCacheKeys( + brandName: string, + modelName: string, +): Promise<{ redis_key: string; field_keys: string[] }> { const redisKey = buildEqCacheRedisKey(brandName, modelName); const redis = getEqCacheRedis(); @@ -35,16 +37,21 @@ async function getEqCacheKeys(brandName, modelName) { const fieldKeys = await redis.hkeys(redisKey); logger.info(`EQ cache loaded: ${redisKey}, fields=${fieldKeys.length}`); return { redis_key: redisKey, field_keys: fieldKeys }; - } catch (e) { - logger.error(`EQ cache read failed for ${redisKey}: ${e.message}`); - throw new Error(`Redis 读取失败:${e.message}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`EQ cache read failed for ${redisKey}: ${msg}`); + throw new Error(`Redis 读取失败:${msg}`); } } /** * 读取单个 hash field 的 value */ -async function getEqCacheField(brandName, modelName, fieldKey) { +export async function getEqCacheField( + brandName: string, + modelName: string, + fieldKey: string, +): Promise<{ redis_key: string; key: string; value: unknown }> { const redisKey = buildEqCacheRedisKey(brandName, modelName); const redis = getEqCacheRedis(); @@ -58,15 +65,10 @@ async function getEqCacheField(brandName, modelName, fieldKey) { key: fieldKey, value: parseHashValue(raw), }; - } catch (e) { - if (e.message === '缓存字段不存在') throw e; - logger.error(`EQ cache field read failed for ${redisKey}/${fieldKey}: ${e.message}`); - throw new Error(`Redis 读取失败:${e.message}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + if (msg === '缓存字段不存在') throw e; + logger.error(`EQ cache field read failed for ${redisKey}/${fieldKey}: ${msg}`); + throw new Error(`Redis 读取失败:${msg}`); } } - -module.exports = { - buildEqCacheRedisKey, - getEqCacheKeys, - getEqCacheField, -}; diff --git a/dashboard/backend/src/services/measurementStorage.js b/dashboard/backend/src/services/measurementStorage.ts similarity index 57% rename from dashboard/backend/src/services/measurementStorage.js rename to dashboard/backend/src/services/measurementStorage.ts index 5a6f7be..42fd243 100644 --- a/dashboard/backend/src/services/measurementStorage.js +++ b/dashboard/backend/src/services/measurementStorage.ts @@ -3,8 +3,14 @@ * 本地开发环境:使用 AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY * 正式环境:通过 IAM 角色访问 S3(无需配置密钥) */ -const { S3Client, PutObjectCommand, GetObjectCommand, CopyObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); -const logger = require('../config/logger'); +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + CopyObjectCommand, + DeleteObjectCommand, +} from '@aws-sdk/client-s3'; +import logger from '../config/logger.js'; const AWS_REGION = process.env.AWS_REGION || 'eu-central-1'; const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || ''; @@ -15,8 +21,8 @@ const S3_BUCKET = process.env.AWS_S3_MEASUREMENT_BUCKET || process.env.AWS_S3_OT * 获取 S3 客户端 * 有显式凭证则用凭证,否则走 IAM 角色 */ -function getS3Client() { - const options = { region: AWS_REGION }; +function getS3Client(): S3Client { + const options: ConstructorParameters[0] = { region: AWS_REGION }; if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) { options.credentials = { accessKeyId: AWS_ACCESS_KEY_ID, @@ -29,23 +35,24 @@ function getS3Client() { /** * 获取品牌首字符(字母则转大写) */ -function getBrandFirstChar(brandName) { +function getBrandFirstChar(brandName: string): string { if (!brandName || !brandName.length) return '_'; const first = brandName[0]; return /[a-zA-Z]/.test(first) ? first.toUpperCase() : first; } +export interface MeasurementKeyParams { + source?: string | null; + form: string | null; + brandName: string; + modelName: string; +} + /** * 构建 S3 Key * 路径:autoeq/measurements/{source}/data/{form}/{brandFirstChar}/{brand model}.csv - * @param {object} params - * @param {string} params.source - 来源,默认 Eafonyoung - * @param {string} params.form - 佩戴方式 (in-ear / over-ear / earbud) - * @param {string} params.brandName - 品牌名称 - * @param {string} params.modelName - 型号名称 - * @returns {string} S3 Key */ -function buildMeasurementKey({ source, form, brandName, modelName }) { +export function buildMeasurementKey({ source, form, brandName, modelName }: MeasurementKeyParams): string { const src = source || 'Eafonyoung'; const brandChar = getBrandFirstChar(brandName); const fileName = `${brandName} ${modelName}.csv`; @@ -54,11 +61,11 @@ function buildMeasurementKey({ source, form, brandName, modelName }) { /** * 上传频响文件到 S3 - * @param {Buffer} buffer - 文件内容(已转换为 CSV) - * @param {object} params - 路径参数 - * @returns {Promise} S3 Key + * @param buffer - 文件内容(已转换为 CSV) + * @param params - 路径参数 + * @returns S3 Key */ -async function uploadMeasurementToS3(buffer, params) { +export async function uploadMeasurementToS3(buffer: Buffer, params: MeasurementKeyParams): Promise { const key = buildMeasurementKey(params); const client = getS3Client(); @@ -69,22 +76,24 @@ async function uploadMeasurementToS3(buffer, params) { Key: key, Body: buffer, ContentType: 'text/csv', - }) + }), ); logger.info(`Measurement uploaded to s3://${S3_BUCKET}/${key}`); return key; - } catch (e) { - logger.error(`S3 measurement upload failed: ${e.message}`); - throw new Error(`S3 上传失败:${e.message}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`S3 measurement upload failed: ${msg}`); + throw new Error(`S3 上传失败:${msg}`); } } /** * 从 S3 读取频响 CSV 文件 - * @param {object} params - 路径参数(同 buildMeasurementKey) - * @returns {Promise<{ key: string, content: string }>} + * @param params - 路径参数(同 buildMeasurementKey) */ -async function getMeasurementFromS3(params) { +export async function getMeasurementFromS3( + params: MeasurementKeyParams, +): Promise<{ key: string; content: string }> { const key = buildMeasurementKey(params); const client = getS3Client(); @@ -93,27 +102,32 @@ async function getMeasurementFromS3(params) { new GetObjectCommand({ Bucket: S3_BUCKET, Key: key, - }) + }), ); - const content = await response.Body.transformToString('utf-8'); + const content = await response.Body!.transformToString('utf-8'); logger.info(`Measurement read from s3://${S3_BUCKET}/${key}`); return { key, content }; - } catch (e) { - if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) { + } catch (e: unknown) { + const err = e as { name?: string; $metadata?: { httpStatusCode?: number }; message?: string }; + if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) { throw new Error('S3 上未找到该型号的频响文件'); } - logger.error(`S3 measurement read failed: ${e.message}`); - throw new Error(`S3 读取失败:${e.message}`); + const msg = err.message || String(e); + logger.error(`S3 measurement read failed: ${msg}`); + throw new Error(`S3 读取失败:${msg}`); } } /** * 在 S3 上迁移频响文件(复制到新路径后删除旧文件) - * @param {object} oldParams - 旧路径参数 { source, form, brandName, modelName } - * @param {object} newParams - 新路径参数 - * @returns {Promise} 新 S3 Key,若旧文件不存在则返回 null + * @param oldParams - 旧路径参数 + * @param newParams - 新路径参数 + * @returns 新 S3 Key,若旧文件不存在则返回 null */ -async function moveMeasurementOnS3(oldParams, newParams) { +export async function moveMeasurementOnS3( + oldParams: MeasurementKeyParams, + newParams: MeasurementKeyParams, +): Promise { const oldKey = buildMeasurementKey(oldParams); const newKey = buildMeasurementKey(newParams); @@ -132,7 +146,7 @@ async function moveMeasurementOnS3(oldParams, newParams) { CopySource: `${S3_BUCKET}/${encodeURIComponent(oldKey).replace(/%2F/g, '/')}`, Key: newKey, ContentType: 'text/csv', - }) + }), ); logger.info(`S3 copied: ${oldKey} -> ${newKey}`); @@ -141,24 +155,19 @@ async function moveMeasurementOnS3(oldParams, newParams) { new DeleteObjectCommand({ Bucket: S3_BUCKET, Key: oldKey, - }) + }), ); logger.info(`S3 deleted old: ${oldKey}`); return newKey; - } catch (e) { - if (e.name === 'NoSuchKey' || e.$metadata?.httpStatusCode === 404) { + } catch (e: unknown) { + const err = e as { name?: string; $metadata?: { httpStatusCode?: number }; message?: string }; + if (err.name === 'NoSuchKey' || err.$metadata?.httpStatusCode === 404) { logger.info(`S3 move skipped: old key not found (${oldKey})`); return null; } - logger.error(`S3 measurement move failed: ${e.message}`); - throw new Error(`S3 迁移失败:${e.message}`); + const msg = err.message || String(e); + logger.error(`S3 measurement move failed: ${msg}`); + throw new Error(`S3 迁移失败:${msg}`); } } - -module.exports = { - uploadMeasurementToS3, - getMeasurementFromS3, - moveMeasurementOnS3, - buildMeasurementKey, -}; diff --git a/dashboard/backend/src/services/otaStorage.js b/dashboard/backend/src/services/otaStorage.ts similarity index 63% rename from dashboard/backend/src/services/otaStorage.js rename to dashboard/backend/src/services/otaStorage.ts index fd949f2..6d494c9 100644 --- a/dashboard/backend/src/services/otaStorage.js +++ b/dashboard/backend/src/services/otaStorage.ts @@ -1,20 +1,21 @@ /** - * OTA 升级包存储:Luxsin-X8 -> S3,Luxsin-X9 -> 本地目录 + * OTA 升级包存储:Luxsin-X8 → S3,Luxsin-X9 → 本地目录 * S3 客户端:有显式凭证用凭证(本地开发),无凭证走 IAM 角色(正式环境) */ -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); -const { S3Client, PutObjectCommand } = require('@aws-sdk/client-s3'); -const logger = require('../config/logger'); -const { isDevelopment } = require('../config/env'); +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import logger from '../config/logger.js'; +import { isDevelopment } from '../config/env.js'; -const OTA_MODEL_X8 = 'Luxsin-X8'; -const OTA_MODEL_X9 = 'Luxsin-X9'; -const OTA_UPLOAD_MODELS = new Set([OTA_MODEL_X8, OTA_MODEL_X9]); +export const OTA_MODEL_X8 = 'Luxsin-X8'; +export const OTA_MODEL_X9 = 'Luxsin-X9'; +export const OTA_UPLOAD_MODELS = new Set([OTA_MODEL_X8, OTA_MODEL_X9]); const OTA_UPLOAD_DIR = process.env.OTA_UPLOAD_DIR - || (isDevelopment ? path.join(require('os').tmpdir(), 'dashboard') : '/data/projects/source'); + || (isDevelopment ? path.join(os.tmpdir(), 'dashboard') : '/data/projects/source'); const AWS_REGION = process.env.AWS_REGION || 'eu-central-1'; const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID || ''; @@ -27,28 +28,31 @@ const OTA_X9_URL_BASE = (process.env.OTA_X9_URL_BASE || 'http://source.luxsin.ne const OTA_FILENAME_X8 = 'LUXSIN_X8.PKG'; const OTA_FILENAME_X9 = 'LUXSIN.PKG'; -function getOtaUploadDir() { +export function getOtaUploadDir(): string { return OTA_UPLOAD_DIR; } -function md5Prefix5(md5Hex) { +function md5Prefix5(md5Hex: string): string { return (md5Hex || '').slice(0, 5); } -function buildX8PublicUrl(s3Key) { +function buildX8PublicUrl(s3Key: string): string { return `${OTA_X8_PUBLIC_BASE}/${s3Key}`; } -function buildX9PublicUrl(ym, prefix) { +function buildX9PublicUrl(ym: string, prefix: string): string { return `${OTA_X9_URL_BASE}/ota/${ym}/x9/${prefix}/${OTA_FILENAME_X9}`; } -async function readUploadContentAndMd5(fileBuffer) { +export async function readUploadContentAndMd5(fileBuffer: Buffer): Promise<{ content: Buffer; md5Hex: string }> { const md5Hash = crypto.createHash('md5').update(fileBuffer).digest('hex'); return { content: fileBuffer, md5Hex: md5Hash }; } -function saveX9PackageLocal(content, md5Hex) { +export function saveX9PackageLocal( + content: Buffer, + md5Hex: string, +): { savedName: string; downloadUrl: string } { const prefix = md5Prefix5(md5Hex); const ym = new Date().toISOString().slice(0, 7).replace('-', ''); @@ -69,12 +73,15 @@ function saveX9PackageLocal(content, md5Hex) { return { savedName: OTA_FILENAME_X9, downloadUrl }; } -async function uploadX8PackageToS3(content, md5Hex) { +export async function uploadX8PackageToS3( + content: Buffer, + md5Hex: string, +): Promise<{ savedName: string; downloadUrl: string; s3Key: string }> { const ym = new Date().toISOString().slice(0, 7).replace('-', ''); const prefix = md5Prefix5(md5Hex); const s3Key = `ota/${ym}/x8/${prefix}/${OTA_FILENAME_X8}`; - const options = { region: AWS_REGION }; + const options: ConstructorParameters[0] = { region: AWS_REGION }; if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY) { options.credentials = { accessKeyId: AWS_ACCESS_KEY_ID, @@ -90,23 +97,15 @@ async function uploadX8PackageToS3(content, md5Hex) { Key: s3Key, Body: content, ContentType: 'application/octet-stream', - }) + }), ); - } catch (e) { - logger.error(`S3 upload failed: ${e.message}`); - throw new Error(`S3 上传失败:${e.message}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.error(`S3 upload failed: ${msg}`); + throw new Error(`S3 上传失败:${msg}`); } const downloadUrl = buildX8PublicUrl(s3Key); logger.info(`OTA X8 package uploaded to s3://${AWS_S3_OTA_BUCKET}/${s3Key}`); return { savedName: OTA_FILENAME_X8, downloadUrl, s3Key }; } - -module.exports = { - OTA_MODEL_X8, - OTA_MODEL_X9, - OTA_UPLOAD_MODELS, - readUploadContentAndMd5, - saveX9PackageLocal, - uploadX8PackageToS3, -}; diff --git a/dashboard/backend/src/services/squiglink.js b/dashboard/backend/src/services/squiglink.ts similarity index 76% rename from dashboard/backend/src/services/squiglink.js rename to dashboard/backend/src/services/squiglink.ts index d305279..29cd07b 100644 --- a/dashboard/backend/src/services/squiglink.js +++ b/dashboard/backend/src/services/squiglink.ts @@ -1,21 +1,21 @@ /** * squig.link 频响数据抓取服务 - * + * * 核心机制: * - 每个 squig.link 实例都有 {baseURL}{pathPrefix}/data/phone_book.json 清单文件 * - 多 db 实例(如 earphonesarchive)通过 URL 路径前缀(/headphones、/iems)区分数据目录 * - share URL 的 share 参数支持逗号分隔多项(目标曲线 + 耳机),下划线→空格后匹配 phone_book.json 中的 file 字段 * - 数据 URL = {baseURL}{pathPrefix}/data/{file值} L.txt */ -const axios = require('axios'); -const logger = require('../config/logger'); +import axios from 'axios'; +import logger from '../config/logger.js'; const FETCH_TIMEOUT = 15000; // 15s const USER_AGENT = 'Dashboard/1.0 (Measurement Fetcher)'; const SQUIGLINK_SITES_URL = 'https://squig.link/squigsites.json'; // squig.link 类型 → 系统佩戴方式映射 -const TYPE_TO_FORM_MAP = { +const TYPE_TO_FORM_MAP: Record = { 'iems': 'in-ear', 'headphones': 'over-ear', 'earbuds': 'earbud', @@ -23,21 +23,59 @@ const TYPE_TO_FORM_MAP = { }; // squigsites.json 内存缓存(TTL 1 小时) -let _squigsitesCache = null; +let _squigsitesCache: SquigSite[] | null = null; let _squigsitesCacheTime = 0; const SQUIGLINK_SITES_TTL = 60 * 60 * 1000; // 1 hour +interface SquigDb { + folder?: string; + type: string; + [key: string]: unknown; +} + +interface SquigSite { + username: string; + dbs?: SquigDb[]; + [key: string]: unknown; +} + +interface PhoneBookPhone { + file?: string | string[]; + name?: string; + [key: string]: unknown; +} + +interface PhoneBookBrand { + name?: string; + phones?: PhoneBookPhone[]; +} + +interface PhoneBookMatch { + fileName: string; + brandName: string; + modelName: string; +} + +interface FetchResult { + buffer: Buffer | null; + fileName: string | null; + dataUrl: string | null; + brandName: string; + modelName: string; + form: string | null; + matches: PhoneBookMatch[]; +} + /** * 获取 squigsites.json(带内存缓存,1 小时内不重复请求) - * @returns {Promise} */ -async function getSquigSites() { +export async function getSquigSites(): Promise { const now = Date.now(); if (_squigsitesCache && (now - _squigsitesCacheTime) < SQUIGLINK_SITES_TTL) { return _squigsitesCache; } logger.info('Fetching squigsites.json (cache miss or expired)'); - const res = await axios.get(SQUIGLINK_SITES_URL, { + const res = await axios.get(SQUIGLINK_SITES_URL, { timeout: FETCH_TIMEOUT, headers: { 'User-Agent': USER_AGENT }, }); @@ -50,14 +88,13 @@ async function getSquigSites() { /** * 解析 share URL,提取 base URL、路径前缀和 share 参数 - * 支持格式: - * https://theaudiostore.squig.link/?share=Nostalgia_Audio_Camelot - * https://squig.link/?share=Moondrop_Chu_2 - * https://earphonesarchive.squig.link/headphones/?share=5128_DF_Target,Asus_ROG_x_Hifiman_Kithara_(center) - * @param {string} shareUrl - * @returns {{ baseUrl: string, pathPrefix: string, shareParam: string, shareItems: string[] }} */ -function parseShareUrl(shareUrl) { +export function parseShareUrl(shareUrl: string): { + baseUrl: string; + pathPrefix: string; + shareParam: string; + shareItems: string[]; +} { const url = new URL(shareUrl.trim()); const shareParam = url.searchParams.get('share'); if (!shareParam) { @@ -69,18 +106,15 @@ function parseShareUrl(shareUrl) { let pathPrefix = url.pathname.replace(/\/+$/, ''); if (pathPrefix === '/') pathPrefix = ''; // share 参数可能为逗号分隔的多项(目标曲线 + 耳机型号) - const shareItems = shareParam.split(',').map(s => s.trim()).filter(Boolean); + const shareItems = shareParam.split(',').map((s) => s.trim()).filter(Boolean); return { baseUrl, pathPrefix, shareParam, shareItems }; } /** * 从 share URL 自动检测佩戴方式 * 通过 squigsites.json 查找实例配置中的 dbs.type 并映射 - * @param {string} baseUrl - 如 https://theaudiostore.squig.link - * @param {string} pathPrefix - URL 路径前缀,如 /headphones - * @returns {Promise} 如 'in-ear' / 'over-ear' / 'earbud' / null */ -async function detectFormFromUrl(baseUrl, pathPrefix = '') { +export async function detectFormFromUrl(baseUrl: string, pathPrefix: string = ''): Promise { try { // 从 URL 提取 username(子域名) const urlObj = new URL(baseUrl); @@ -99,7 +133,7 @@ async function detectFormFromUrl(baseUrl, pathPrefix = '') { // 使用实际 URL 路径匹配 db folder,优先最具体的匹配 const urlPath = pathPrefix ? `${pathPrefix}/` : '/'; - let matchedType = null; + let matchedType: string | null = null; let bestLen = -1; for (const db of site.dbs) { @@ -125,25 +159,22 @@ async function detectFormFromUrl(baseUrl, pathPrefix = '') { logger.info(`Detected form from squigsites.json: ${username} → ${matchedType} → ${form}`); } return form; - } catch (e) { - logger.warn(`Failed to detect form from squigsites.json: ${e.message}`); + } catch (e: unknown) { + const msg = e instanceof Error ? e.message : String(e); + logger.warn(`Failed to detect form from squigsites.json: ${msg}`); return null; } } /** * 获取 squig.link 实例的 phone_book.json - * @param {string} baseUrl - 如 https://theaudiostore.squig.link - * @param {string} pathPrefix - URL 路径前缀,如 /headphones - * @returns {Promise} */ -async function fetchPhoneBook(baseUrl, pathPrefix = '') { +export async function fetchPhoneBook(baseUrl: string, pathPrefix: string = ''): Promise { const url = `${baseUrl}${pathPrefix}/data/phone_book.json`; logger.info(`Fetching phone_book: ${url}`); - const res = await axios.get(url, { + const res = await axios.get(url, { timeout: FETCH_TIMEOUT, headers: { 'User-Agent': USER_AGENT }, - // 忽略缓存参数(有些站点加 ?timestamp 后缀) maxRedirects: 5, }); if (!Array.isArray(res.data)) { @@ -154,11 +185,11 @@ async function fetchPhoneBook(baseUrl, pathPrefix = '') { /** * 在 phone_book 中查找匹配的 file 字段值 - * @param {Array} phoneBook - * @param {string} shareParam - share 参数(下划线形式) - * @returns {{ fileName: string, brandName: string, modelName: string } | null} */ -function findInPhoneBook(phoneBook, shareParam) { +export function findInPhoneBook( + phoneBook: PhoneBookBrand[], + shareParam: string, +): PhoneBookMatch | null { // share 参数转空格形式 const target = shareParam.replace(/_/g, ' ').trim(); const targetLower = target.toLowerCase(); @@ -189,14 +220,14 @@ function findInPhoneBook(phoneBook, shareParam) { /** * 模糊查找:当精确匹配失败时,用 share 参数关键词模糊搜索 - * @param {Array} phoneBook - * @param {string} shareParam - * @returns {Array<{ fileName: string, brandName: string, modelName: string }>} */ -function fuzzyFindInPhoneBook(phoneBook, shareParam) { +export function fuzzyFindInPhoneBook( + phoneBook: PhoneBookBrand[], + shareParam: string, +): PhoneBookMatch[] { const target = shareParam.replace(/_/g, ' ').trim().toLowerCase(); const keywords = target.split(/\s+/); - const results = []; + const results: PhoneBookMatch[] = []; for (const brand of phoneBook) { const brandName = brand.name || ''; @@ -222,19 +253,19 @@ function fuzzyFindInPhoneBook(phoneBook, shareParam) { /** * 下载 TXT 频响文件 * 按优先级尝试多种后缀:L声道 → R声道 → 无后缀 → 带编号样本 - * @param {string} baseUrl - * @param {string} fileName - phone_book 中的 file 值 - * @param {string} pathPrefix - URL 路径前缀,如 /headphones - * @returns {Promise<{ buffer: Buffer, url: string }>} */ -async function downloadTxtFile(baseUrl, fileName, pathPrefix = '') { +export async function downloadTxtFile( + baseUrl: string, + fileName: string, + pathPrefix: string = '', +): Promise<{ buffer: Buffer; url: string }> { // 按优先级尝试的后缀列表 const suffixes = [ ' L.txt', ' R.txt', '.txt', ' L1.txt', ' R1.txt', ' L2.txt', ' R2.txt', ]; - let lastError = null; + let lastError: Error | null = null; for (const suffix of suffixes) { const url = `${baseUrl}${pathPrefix}/data/${encodeURIComponent(fileName + suffix)}`; @@ -251,9 +282,9 @@ async function downloadTxtFile(baseUrl, fileName, pathPrefix = '') { } logger.info(`TXT downloaded: ${url} (${buffer.length} bytes)`); return { buffer, url }; - } catch (e) { - lastError = e; - logger.warn(`TXT download failed (${suffix}): ${e.message}`); + } catch (e: unknown) { + lastError = e instanceof Error ? e : new Error(String(e)); + logger.warn(`TXT download failed (${suffix}): ${(e as Error).message || String(e)}`); } } @@ -262,29 +293,20 @@ async function downloadTxtFile(baseUrl, fileName, pathPrefix = '') { /** * 从 squig.link share URL 抓取频响数据 - * @param {string} shareUrl - 完整的 share URL - * @returns {Promise<{ - * buffer: Buffer, - * fileName: string, - * dataUrl: string, - * brandName: string, - * modelName: string, - * matches: Array<{ fileName: string, brandName: string, modelName: string }> - * }>} */ -async function fetchFromSquigLink(shareUrl) { +export async function fetchFromSquigLink(shareUrl: string): Promise { const { baseUrl, pathPrefix, shareItems } = parseShareUrl(shareUrl); // 并行获取 phone_book 和检测佩戴方式 const [phoneBook, detectedForm] = await Promise.all([ - fetchPhoneBook(baseUrl, pathPrefix).catch((e) => { + fetchPhoneBook(baseUrl, pathPrefix).catch((e: Error) => { throw new Error(`获取 phone_book.json 失败:${e.message}`); }), detectFormFromUrl(baseUrl, pathPrefix), ]); // 逐项匹配 share 参数(逗号分隔的多项中可能包含目标曲线,无匹配则跳过) - const allMatches = []; + const allMatches: PhoneBookMatch[] = []; for (const item of shareItems) { const exact = findInPhoneBook(phoneBook, item); if (exact) { @@ -295,8 +317,8 @@ async function fetchFromSquigLink(shareUrl) { } // 按 fileName 去重 - const matches = []; - const seen = new Set(); + const matches: PhoneBookMatch[] = []; + const seen = new Set(); for (const m of allMatches) { if (!seen.has(m.fileName)) { seen.add(m.fileName); @@ -339,10 +361,8 @@ async function fetchFromSquigLink(shareUrl) { /** * 判断 URL 是否为直接的 TXT 频响文件链接 - * @param {string} inputUrl - * @returns {boolean} */ -function isDirectTxtUrl(inputUrl) { +export function isDirectTxtUrl(inputUrl: string): boolean { try { const url = new URL(inputUrl.trim()); const pathname = decodeURIComponent(url.pathname); @@ -354,10 +374,10 @@ function isDirectTxtUrl(inputUrl) { /** * 直接下载 TXT 频响文件 URL(无需 phone_book 匹配) - * @param {string} inputUrl - 完整的 TXT 文件 URL - * @returns {Promise<{ buffer: Buffer, url: string, fileName: string, brandName: string, modelName: string, form: string|null }>} */ -async function fetchDirectTxtUrl(inputUrl) { +export async function fetchDirectTxtUrl( + inputUrl: string, +): Promise<{ buffer: Buffer; url: string; fileName: string; brandName: string; modelName: string; form: string | null }> { const trimmedUrl = inputUrl.trim(); logger.info(`Direct TXT download: ${trimmedUrl}`); @@ -385,7 +405,7 @@ async function fetchDirectTxtUrl(inputUrl) { const modelName = parts.length > 1 ? parts.slice(1).join(' ') : stem; // 从 URL 路径推断佩戴方式(/headphones/ → over-ear, /iems/ → in-ear 等) - let form = null; + let form: string | null = null; const pathLower = decodedPath.toLowerCase(); for (const [keyword, formType] of Object.entries(TYPE_TO_FORM_MAP)) { if (pathLower.includes(`/${keyword}/`)) { @@ -405,16 +425,3 @@ async function fetchDirectTxtUrl(inputUrl) { return { buffer, url: trimmedUrl, fileName: stem, brandName, modelName, form }; } - -module.exports = { - parseShareUrl, - fetchFromSquigLink, - fetchPhoneBook, - findInPhoneBook, - fuzzyFindInPhoneBook, - downloadTxtFile, - detectFormFromUrl, - getSquigSites, - isDirectTxtUrl, - fetchDirectTxtUrl, -}; diff --git a/dashboard/backend/src/services/userBootstrap.js b/dashboard/backend/src/services/userBootstrap.js deleted file mode 100644 index 60d735b..0000000 --- a/dashboard/backend/src/services/userBootstrap.js +++ /dev/null @@ -1,27 +0,0 @@ -const { DashboardUser } = require('../models'); -const logger = require('../config/logger'); -const { hashPassword } = require('../utils/password'); - -async function ensureBootstrapSuperAdmin() { - const count = await DashboardUser.count(); - if (count > 0) return; - - const username = (process.env.DASHBOARD_ADMIN_USERNAME || 'admin').trim(); - const password = process.env.DASHBOARD_ADMIN_PASSWORD || 'Eafon123'; - - if (!username) { - logger.warn('dashboard_user is empty and DASHBOARD_ADMIN_USERNAME is not set; skip bootstrap'); - return; - } - - await DashboardUser.create({ - username, - password_hash: await hashPassword(password), - is_super_admin: 1, - status: 1, - }); - - logger.info(`Bootstrap super admin created: username=${username}`); -} - -module.exports = { ensureBootstrapSuperAdmin }; diff --git a/dashboard/backend/src/services/userBootstrap.ts b/dashboard/backend/src/services/userBootstrap.ts new file mode 100644 index 0000000..5cfca0b --- /dev/null +++ b/dashboard/backend/src/services/userBootstrap.ts @@ -0,0 +1,30 @@ +import { eq, count } from 'drizzle-orm'; +import { db } from '../config/database.js'; +import { dashboardUsers } from '../schemas/index.js'; +import logger from '../config/logger.js'; +import { hashPassword } from '../utils/password.js'; + +async function ensureBootstrapSuperAdmin(): Promise { + const [result] = await db.select({ value: count() }).from(dashboardUsers); + const total = result?.value ?? 0; + if (total > 0) return; + + const username = (process.env.DASHBOARD_ADMIN_USERNAME || 'admin').trim(); + const password = process.env.DASHBOARD_ADMIN_PASSWORD || 'Eafon123'; + + if (!username) { + logger.warn('dashboard_user is empty and DASHBOARD_ADMIN_USERNAME is not set; skip bootstrap'); + return; + } + + await db.insert(dashboardUsers).values({ + username, + passwordHash: await hashPassword(password), + isSuperAdmin: 1, + status: 1, + }); + + logger.info(`Bootstrap super admin created: username=${username}`); +} + +export { ensureBootstrapSuperAdmin }; diff --git a/dashboard/backend/src/utils/jwt.js b/dashboard/backend/src/utils/jwt.js deleted file mode 100644 index c15e491..0000000 --- a/dashboard/backend/src/utils/jwt.js +++ /dev/null @@ -1,27 +0,0 @@ -const jwt = require('jsonwebtoken'); - -const JWT_SECRET = process.env.JWT_SECRET || 'dev-only-change-me-for-production'; -const JWT_ALGORITHM = 'HS256'; -const TOKEN_TTL_HOURS = 12; - -function createAccessToken(user) { - const now = Math.floor(Date.now() / 1000); - const payload = { - sub: user.id, - username: user.username, - is_super_admin: !!user.is_super_admin, - iat: now, - exp: now + TOKEN_TTL_HOURS * 3600, - }; - return jwt.sign(payload, JWT_SECRET, { algorithm: JWT_ALGORITHM }); -} - -function decodeToken(token) { - return jwt.verify(token, JWT_SECRET, { algorithms: [JWT_ALGORITHM] }); -} - -function getTokenTtlSeconds() { - return TOKEN_TTL_HOURS * 3600; -} - -module.exports = { createAccessToken, decodeToken, getTokenTtlSeconds, TOKEN_TTL_HOURS }; diff --git a/dashboard/backend/src/utils/jwt.ts b/dashboard/backend/src/utils/jwt.ts new file mode 100644 index 0000000..635cb3b --- /dev/null +++ b/dashboard/backend/src/utils/jwt.ts @@ -0,0 +1,35 @@ +import jwt from 'jsonwebtoken'; + +const JWT_SECRET = process.env.JWT_SECRET || 'dev-only-change-me-for-production'; +const JWT_ALGORITHM = 'HS256' as const; +const TOKEN_TTL_HOURS = 12; + +export interface JwtPayload { + sub: number; + username: string; + is_super_admin: boolean; + iat: number; + exp: number; +} + +function createAccessToken(user: { id: number; username: string; is_super_admin?: boolean | number }): string { + const now = Math.floor(Date.now() / 1000); + const payload: JwtPayload = { + sub: user.id, + username: user.username, + is_super_admin: !!user.is_super_admin, + iat: now, + exp: now + TOKEN_TTL_HOURS * 3600, + }; + return jwt.sign(payload, JWT_SECRET, { algorithm: JWT_ALGORITHM }); +} + +function decodeToken(token: string): JwtPayload { + return jwt.verify(token, JWT_SECRET, { algorithms: [JWT_ALGORITHM] }) as unknown as JwtPayload; +} + +function getTokenTtlSeconds(): number { + return TOKEN_TTL_HOURS * 3600; +} + +export { createAccessToken, decodeToken, getTokenTtlSeconds, TOKEN_TTL_HOURS }; diff --git a/dashboard/backend/src/utils/password.js b/dashboard/backend/src/utils/password.ts similarity index 78% rename from dashboard/backend/src/utils/password.js rename to dashboard/backend/src/utils/password.ts index 4242f70..7419e10 100644 --- a/dashboard/backend/src/utils/password.js +++ b/dashboard/backend/src/utils/password.ts @@ -1,5 +1,5 @@ -const crypto = require('crypto'); -const { promisify } = require('util'); +import crypto from 'node:crypto'; +import { promisify } from 'node:util'; const pbkdf2Async = promisify(crypto.pbkdf2); @@ -7,13 +7,13 @@ const ITERATIONS = 310000; const KEYLEN = 32; const DIGEST = 'sha256'; -async function hashPassword(password) { +async function hashPassword(password: string): Promise { const salt = crypto.randomBytes(16).toString('hex'); const hash = await pbkdf2Async(password, salt, ITERATIONS, KEYLEN, DIGEST); return `pbkdf2:${DIGEST}:${ITERATIONS}:${salt}:${hash.toString('hex')}`; } -async function verifyPassword(password, stored) { +async function verifyPassword(password: string, stored: string): Promise { if (!password || !stored || typeof stored !== 'string') return false; const parts = stored.split(':'); @@ -33,4 +33,4 @@ async function verifyPassword(password, stored) { return crypto.timingSafeEqual(expected, actual); } -module.exports = { hashPassword, verifyPassword }; +export { hashPassword, verifyPassword }; diff --git a/dashboard/backend/src/utils/response.js b/dashboard/backend/src/utils/response.js deleted file mode 100644 index 50d3e7a..0000000 --- a/dashboard/backend/src/utils/response.js +++ /dev/null @@ -1,24 +0,0 @@ -const ApiResponse = { - success(data = null, msg = 'success') { - return { code: 1, msg, data }; - }, - - error(msg = 'error', code = 0) { - return { code, msg, data: null }; - }, - - noData(msg = 'no data') { - return { code: 2, msg, data: null }; - }, -}; - -class PageData { - constructor(items, total, skip, limit) { - this.items = items; - this.total = total; - this.skip = skip; - this.limit = limit; - } -} - -module.exports = { ApiResponse, PageData }; diff --git a/dashboard/backend/src/utils/response.ts b/dashboard/backend/src/utils/response.ts new file mode 100644 index 0000000..1dfd298 --- /dev/null +++ b/dashboard/backend/src/utils/response.ts @@ -0,0 +1,28 @@ +export interface ApiResponseData { + code: number; + msg: string; + data: T | null; +} + +export const ApiResponse = { + success(data: T | null = null, msg = 'success'): ApiResponseData { + return { code: 1, msg, data }; + }, + + error(msg = 'error', code = 0): ApiResponseData { + return { code, msg, data: null }; + }, + + noData(msg = 'no data'): ApiResponseData { + return { code: 2, msg, data: null }; + }, +}; + +export class PageData { + constructor( + public items: T[], + public total: number, + public skip: number, + public limit: number + ) {} +} diff --git a/dashboard/backend/src/validators/blacklist.js b/dashboard/backend/src/validators/blacklist.ts similarity index 53% rename from dashboard/backend/src/validators/blacklist.js rename to dashboard/backend/src/validators/blacklist.ts index 797d9cf..cbf1081 100644 --- a/dashboard/backend/src/validators/blacklist.js +++ b/dashboard/backend/src/validators/blacklist.ts @@ -1,13 +1,14 @@ -const { z } = require('zod'); +import { z } from 'zod'; -const BlackListCreateSchema = z.object({ +export const BlackListCreateSchema = z.object({ ota_id: z.number().int({ message: 'ota_id 须为整数' }), mac: z.string().min(1, 'MAC 地址不能为空').max(100, 'MAC 地址最多100字符'), }); -const BlackListUpdateSchema = z.object({ +export const BlackListUpdateSchema = z.object({ ota_id: z.number().int({ message: 'ota_id 须为整数' }).optional().nullable(), mac: z.string().min(1).max(100).optional().nullable(), }); -module.exports = { BlackListCreateSchema, BlackListUpdateSchema }; +export type BlackListCreateInput = z.infer; +export type BlackListUpdateInput = z.infer; diff --git a/dashboard/backend/src/validators/brand.js b/dashboard/backend/src/validators/brand.js deleted file mode 100644 index 223921c..0000000 --- a/dashboard/backend/src/validators/brand.js +++ /dev/null @@ -1,11 +0,0 @@ -const { z } = require('zod'); - -const BrandCreateSchema = z.object({ - name: z.string().min(1).max(100, '品牌名称最多100字符'), -}); - -const BrandUpdateSchema = z.object({ - name: z.string().min(1).max(100, '品牌名称最多100字符').optional(), -}); - -module.exports = { BrandCreateSchema, BrandUpdateSchema }; diff --git a/dashboard/backend/src/validators/brand.ts b/dashboard/backend/src/validators/brand.ts new file mode 100644 index 0000000..9b14400 --- /dev/null +++ b/dashboard/backend/src/validators/brand.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; + +export const BrandCreateSchema = z.object({ + name: z.string().min(1).max(100, '品牌名称最多100字符'), +}); + +export const BrandUpdateSchema = z.object({ + name: z.string().min(1).max(100, '品牌名称最多100字符').optional(), +}); + +export type BrandCreateInput = z.infer; +export type BrandUpdateInput = z.infer; diff --git a/dashboard/backend/src/validators/model.js b/dashboard/backend/src/validators/model.ts similarity index 73% rename from dashboard/backend/src/validators/model.js rename to dashboard/backend/src/validators/model.ts index a17fba7..97612c2 100644 --- a/dashboard/backend/src/validators/model.js +++ b/dashboard/backend/src/validators/model.ts @@ -1,6 +1,6 @@ -const { z } = require('zod'); +import { z } from 'zod'; -const ModelCreateSchema = z.object({ +export const ModelCreateSchema = z.object({ brand_name: z.string().min(1).max(100, '品牌名称最多100字符'), name: z.string().min(1).max(100, '型号名称最多100字符'), form: z.string().max(100).optional().nullable(), @@ -9,7 +9,7 @@ const ModelCreateSchema = z.object({ eq_key: z.string().max(255).optional().nullable(), }); -const ModelUpdateSchema = z.object({ +export const ModelUpdateSchema = z.object({ brand_name: z.string().min(1).max(100).optional().nullable(), name: z.string().min(1).max(100).optional().nullable(), form: z.string().max(100).optional().nullable(), @@ -18,4 +18,5 @@ const ModelUpdateSchema = z.object({ eq_key: z.string().max(255).optional().nullable(), }); -module.exports = { ModelCreateSchema, ModelUpdateSchema }; +export type ModelCreateInput = z.infer; +export type ModelUpdateInput = z.infer; diff --git a/dashboard/backend/src/validators/ota.js b/dashboard/backend/src/validators/ota.ts similarity index 86% rename from dashboard/backend/src/validators/ota.js rename to dashboard/backend/src/validators/ota.ts index d0c1ddf..984ae7b 100644 --- a/dashboard/backend/src/validators/ota.js +++ b/dashboard/backend/src/validators/ota.ts @@ -1,6 +1,6 @@ -const { z } = require('zod'); +import { z } from 'zod'; -const OtaCreateSchema = z.object({ +export const OtaCreateSchema = z.object({ verCode: z.number().int({ message: '版本号须为整数' }), verName: z.string().min(1).max(20, '版本名称最多20字符'), url: z.string().max(255, 'URL最多255字符'), @@ -16,7 +16,7 @@ const OtaCreateSchema = z.object({ status: z.number().int().min(0).max(1).optional().default(1), }); -const OtaUpdateSchema = z.object({ +export const OtaUpdateSchema = z.object({ verCode: z.number().int().optional().nullable(), verName: z.string().min(1).max(20).optional().nullable(), url: z.string().max(255).optional().nullable(), @@ -32,4 +32,5 @@ const OtaUpdateSchema = z.object({ status: z.number().int().min(0).max(1).optional().nullable(), }); -module.exports = { OtaCreateSchema, OtaUpdateSchema }; +export type OtaCreateInput = z.infer; +export type OtaUpdateInput = z.infer; diff --git a/dashboard/backend/src/validators/otaTargetDevice.js b/dashboard/backend/src/validators/otaTargetDevice.ts similarity index 50% rename from dashboard/backend/src/validators/otaTargetDevice.js rename to dashboard/backend/src/validators/otaTargetDevice.ts index 1a829fa..975e961 100644 --- a/dashboard/backend/src/validators/otaTargetDevice.js +++ b/dashboard/backend/src/validators/otaTargetDevice.ts @@ -1,13 +1,14 @@ -const { z } = require('zod'); +import { z } from 'zod'; -const OtaTargetDeviceCreateSchema = z.object({ +export const OtaTargetDeviceCreateSchema = z.object({ ota_id: z.number().int({ message: 'ota_id 须为整数' }), mac_addr: z.string().min(1, 'MAC 地址不能为空').max(17, 'MAC 地址最多17字符'), }); -const OtaTargetDeviceUpdateSchema = z.object({ +export const OtaTargetDeviceUpdateSchema = z.object({ ota_id: z.number().int({ message: 'ota_id 须为整数' }).optional().nullable(), mac_addr: z.string().min(1).max(17).optional().nullable(), }); -module.exports = { OtaTargetDeviceCreateSchema, OtaTargetDeviceUpdateSchema }; +export type OtaTargetDeviceCreateInput = z.infer; +export type OtaTargetDeviceUpdateInput = z.infer; diff --git a/dashboard/backend/start.sh b/dashboard/backend/start.sh index cd1e301..de88a2b 100755 --- a/dashboard/backend/start.sh +++ b/dashboard/backend/start.sh @@ -1,4 +1,23 @@ #!/bin/bash -echo "Starting Audio Dashboard API..." +set -e + cd "$(dirname "$0")" -node src/app.js + +# 判断运行模式:APP_ENV=development 或未构建时使用 tsx 开发模式 +MODE="${APP_ENV:-production}" + +if [ "$MODE" = "development" ]; then + echo "Starting Audio Dashboard API (development)..." + exec pnpm dev +fi + +# 安装依赖(如 node_modules 缺失或不完整则自动修复) +pnpm install --silent + +# 生产模式:构建 TypeScript → 运行编译产物 +echo "Starting Audio Dashboard API (production)..." +echo "Building TypeScript..." +pnpm build + +echo "Starting server..." +exec node --conditions @zod/source dist/app.js diff --git a/dashboard/backend/tsconfig.json b/dashboard/backend/tsconfig.json new file mode 100644 index 0000000..4315b71 --- /dev/null +++ b/dashboard/backend/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "customConditions": ["@zod/source"], + "esModuleInterop": true, + "strict": true, + "outDir": "dist", + "rootDir": "src", + "skipLibCheck": true, + "resolveJsonModule": true, + "paths": { "@/*": ["./src/*"] } + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}