diff --git a/.env.mac-docker.example b/.env.mac-docker.example index a67833b7..dbdf9ea4 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -23,6 +23,16 @@ LOG_LEVEL=info LOG_INTEGRATION_LEVEL=warn LOG_RETENTION_DAYS=30 +# File Storage (MinIO / S3 compatible) +STORAGE_ENDPOINT=http://minio:9000 +STORAGE_BUCKET=order-site +STORAGE_ACCESS_KEY_ID=minioadmin +STORAGE_SECRET_ACCESS_KEY=minioadmin +STORAGE_REGION=us-east-1 +STORAGE_MAX_UPLOAD_SIZE_MB=10 +MINIO_API_PORT=19000 +MINIO_CONSOLE_PORT=19001 + # Admin ADMIN_SESSION_SECRET=dev-local-session-secret ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"dev-admin-123456","role":"admin"},{"username":"operator","password":"dev-operator-123456","role":"operator"}] diff --git a/.env.server.example b/.env.server.example index ad33beb3..2b1bb2b3 100644 --- a/.env.server.example +++ b/.env.server.example @@ -28,6 +28,14 @@ LOG_LEVEL=info LOG_INTEGRATION_LEVEL=warn LOG_RETENTION_DAYS=30 +# File Storage (MinIO / S3 compatible) +STORAGE_ENDPOINT=http://minio:9000 +STORAGE_BUCKET=order-site +STORAGE_ACCESS_KEY_ID=change-me-storage-access-key +STORAGE_SECRET_ACCESS_KEY=change-me-storage-secret-key +STORAGE_REGION=us-east-1 +STORAGE_MAX_UPLOAD_SIZE_MB=10 + # Admin ADMIN_SESSION_SECRET=change-me-long-random-session-secret ADMIN_DEFAULT_USERS_JSON=[{"username":"admin","password":"change-me-admin-password","role":"admin"},{"username":"operator","password":"change-me-operator-password","role":"operator"}] diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index a362fbfe..918d240e 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -9,11 +9,15 @@ "version": "0.1.0", "dependencies": { "express": "^5.1.0", + "minio": "^8.0.7", + "multer": "^2.2.0", "node-pg-migrate": "^8.0.4", - "pg": "^8.16.3" + "pg": "^8.16.3", + "sharp": "^0.35.3" }, "devDependencies": { "@types/express": "^5.0.6", + "@types/multer": "^2.2.0", "@types/node": "^25.6.0", "@types/pg": "^8.20.0", "prettier": "^3.8.3", @@ -21,6 +25,16 @@ "typescript": "^6.0.2" } }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -463,6 +477,506 @@ "node": ">=18" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.2" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -472,6 +986,18 @@ "node": ">=18" } }, + "node_modules/@nodable/entities": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.0.0.tgz", + "integrity": "sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -525,6 +1051,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -619,6 +1155,30 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anynum": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz", + "integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -628,6 +1188,15 @@ "node": "18 || 20 || >=22" } }, + "node_modules/block-stream2": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz", + "integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==", + "license": "MIT", + "dependencies": { + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -664,6 +1233,38 @@ "node": "18 || 20 || >=22" } }, + "node_modules/browser-or-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz", + "integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==", + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -734,6 +1335,21 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -805,6 +1421,15 @@ } } }, + "node_modules/decode-uri-component": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", + "integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -814,6 +1439,15 @@ "node": ">= 0.8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -945,6 +1579,12 @@ "node": ">= 0.6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -988,6 +1628,54 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-xml-builder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz", + "integrity": "sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.6.2", + "xml-naming": "^0.3.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz", + "integrity": "sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^3.0.0", + "fast-xml-builder": "^1.2.0", + "is-unsafe": "^2.0.0", + "path-expression-matcher": "^1.6.2", + "strnum": "^2.4.1", + "xml-naming": "^0.3.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/filter-obj": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz", + "integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1224,6 +1912,18 @@ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/is-unsafe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.0.tgz", + "integrity": "sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1245,6 +1945,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.2", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", @@ -1324,6 +2030,60 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minio": { + "version": "8.0.7", + "resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz", + "integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==", + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.4", + "block-stream2": "^2.1.0", + "browser-or-node": "^2.1.1", + "buffer-crc32": "^1.0.0", + "eventemitter3": "^5.0.1", + "fast-xml-parser": "^5.3.4", + "ipaddr.js": "^2.0.1", + "lodash": "^4.17.21", + "mime-types": "^2.1.35", + "query-string": "^7.1.3", + "stream-json": "^1.8.0", + "through2": "^4.0.2", + "xml2js": "^0.5.0 || ^0.6.2" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, + "node_modules/minio/node_modules/ipaddr.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/minio/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minio/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minipass": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", @@ -1339,6 +2099,68 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-6rdyFg2kLrMh9Jee7/BMPuV9lEAd7lLW2YUpF9/YxR7njyoUwwQ0ZPh3TaIY50Sw6vlyD2HW3wGOkTS4P79xrQ==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -1421,6 +2243,21 @@ "node": ">= 0.8" } }, + "node_modules/path-expression-matcher": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz", + "integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1628,6 +2465,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/query-string": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz", + "integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==", + "license": "MIT", + "dependencies": { + "decode-uri-component": "^0.2.2", + "filter-obj": "^1.1.0", + "split-on-first": "^1.0.0", + "strict-uri-encode": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -1652,6 +2507,20 @@ "node": ">= 0.10" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1677,12 +2546,53 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sax": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz", + "integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -1734,6 +2644,55 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1839,6 +2798,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/split-on-first": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz", + "integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1857,6 +2825,47 @@ "node": ">= 0.8" } }, + "node_modules/stream-chain": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", + "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", + "license": "BSD-3-Clause" + }, + "node_modules/stream-json": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", + "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", + "license": "BSD-3-Clause", + "dependencies": { + "stream-chain": "^2.2.5" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/strict-uri-encode": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz", + "integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1883,6 +2892,30 @@ "node": ">=8" } }, + "node_modules/strnum": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.1.tgz", + "integrity": "sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "anynum": "^1.0.1" + } + }, + "node_modules/through2": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", + "integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==", + "license": "MIT", + "dependencies": { + "readable-stream": "3" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1892,6 +2925,13 @@ "node": ">=0.6" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, "node_modules/tsx": { "version": "4.22.3", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.3.tgz", @@ -1940,6 +2980,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/typescript": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", @@ -1970,6 +3016,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2017,6 +3069,43 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xml-naming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz", + "integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xml2js": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", + "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/apps/backend/package.json b/apps/backend/package.json index 80dbf35c..51e059aa 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -25,11 +25,15 @@ }, "dependencies": { "express": "^5.1.0", + "minio": "^8.0.7", + "multer": "^2.2.0", "node-pg-migrate": "^8.0.4", - "pg": "^8.16.3" + "pg": "^8.16.3", + "sharp": "^0.35.3" }, "devDependencies": { "@types/express": "^5.0.6", + "@types/multer": "^2.2.0", "@types/node": "^25.6.0", "@types/pg": "^8.20.0", "prettier": "^3.8.3", diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 3ce933a9..dd12519e 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -1,95 +1,87 @@ -import type { NextFunction, Request, Response } from "express"; -import express from "express"; -import process from "node:process"; +import type { NextFunction, Request, Response } from 'express' +import express from 'express' +import process from 'node:process' -import adminRouter from "./routes/admin.js"; -import collectRouter from "./routes/collect.js"; -import claimsRouter from "./routes/claims.js"; -import kuaishouFeifeiRouter from "./routes/kuaishou-feifei.js"; -import kuaishouIndustryRouter from "./routes/kuaishou-industry.js"; -import open91Router from "./routes/open-91.js"; -import shortLinksRouter from "./routes/short-links.js"; -import workerRouter from "./routes/worker.js"; -import { accessLogMiddleware } from "./middleware/access-log.js"; -import { createCorsMiddleware } from "./middleware/cors.js"; -import { buildHealthPayload, type StartupState } from "./startup/state.js"; -import type { RuntimeConfig } from "./types/runtime-config.js"; -import { buildSuccessPayload, sendRouteError } from "./utils/http.js"; +import adminRouter from './routes/admin.js' +import collectRouter from './routes/collect.js' +import filesRouter from './routes/files.js' +import claimsRouter from './routes/claims.js' +import kuaishouFeifeiRouter from './routes/kuaishou-feifei.js' +import kuaishouIndustryRouter from './routes/kuaishou-industry.js' +import open91Router from './routes/open-91.js' +import shortLinksRouter from './routes/short-links.js' +import workerRouter from './routes/worker.js' +import { accessLogMiddleware } from './middleware/access-log.js' +import { createCorsMiddleware } from './middleware/cors.js' +import { buildHealthPayload, type StartupState } from './startup/state.js' +import type { RuntimeConfig } from './types/runtime-config.js' +import { buildSuccessPayload, sendRouteError } from './utils/http.js' type CreateAppOptions = { - startupState: StartupState; - isShutdownStarted: () => boolean; - config: RuntimeConfig; -}; + startupState: StartupState + isShutdownStarted: () => boolean + config: RuntimeConfig +} -export function createApp({ - startupState, - isShutdownStarted, - config, -}: CreateAppOptions) { - const app = express(); +export function createApp({ startupState, isShutdownStarted, config }: CreateAppOptions) { + const app = express() - app.use(accessLogMiddleware); - app.use(createCorsMiddleware(config)); + app.use(accessLogMiddleware) + app.use(createCorsMiddleware(config)) app.use( express.json({ - limit: "2mb", + limit: '2mb', verify: (req, _res, buffer) => { - (req as Request).rawBody = buffer.toString("utf8"); + ;(req as Request).rawBody = buffer.toString('utf8') }, - }) - ); - app.use(express.urlencoded({ extended: true })); + }), + ) + app.use(express.urlencoded({ extended: true })) - app.get("/health", (_req, res) => { + app.get('/health', (_req, res) => { res.json( buildSuccessPayload( buildHealthPayload(startupState, isShutdownStarted()), - startupState.core.ready ? "ready" : "starting" - ) - ); - }); + startupState.core.ready ? 'ready' : 'starting', + ), + ) + }) - app.get("/health/live", (_req, res) => { + app.get('/health/live', (_req, res) => { res.json( buildSuccessPayload({ - status: isShutdownStarted() ? "shutting_down" : "alive", + status: isShutdownStarted() ? 'shutting_down' : 'alive', pid: process.pid, - mode: "kuaishou-lite", - }) - ); - }); + mode: 'kuaishou-lite', + }), + ) + }) - app.get("/health/ready", (_req, res) => { + app.get('/health/ready', (_req, res) => { if (startupState.core.ready) { - res.json( - buildSuccessPayload( - buildHealthPayload(startupState, isShutdownStarted()), - "ready" - ) - ); - return; + res.json(buildSuccessPayload(buildHealthPayload(startupState, isShutdownStarted()), 'ready')) + return } res.status(503).json({ code: 1, - msg: startupState.core.lastError || "服务启动中,请稍后重试", - errorCode: "service_not_ready", + msg: startupState.core.lastError || '服务启动中,请稍后重试', + errorCode: 'service_not_ready', time: Math.floor(Date.now() / 1000), data: buildHealthPayload(startupState, isShutdownStarted()), - }); - }); + }) + }) app.use((req, res, next) => { if (startupState.core.ready) { - next(); - return; + next() + return } res.status(503).json({ code: 1, - msg: startupState.core.lastError || "服务启动中,请稍后重试", - errorCode: "service_not_ready", + msg: startupState.core.lastError || '服务启动中,请稍后重试', + errorCode: 'service_not_ready', time: Math.floor(Date.now() / 1000), data: { startup: { @@ -98,21 +90,22 @@ export function createApp({ lastAttemptAt: startupState.core.lastAttemptAt, }, }, - }); - }); + }) + }) - app.use("/api/v1/open/91", open91Router); - app.use("/api/v1/open/kuaishou-industry", kuaishouIndustryRouter); - app.use("/api/v1/open/kuaishou-feifei", kuaishouFeifeiRouter); - app.use("/api/v1/claim", claimsRouter); - app.use("/api/v1/collect", collectRouter); - app.use("/api/v1/worker", workerRouter); - app.use("/api/v1/admin", adminRouter); - app.use("/s", shortLinksRouter); + app.use('/api/v1/open/91', open91Router) + app.use('/api/v1/open/kuaishou-industry', kuaishouIndustryRouter) + app.use('/api/v1/open/kuaishou-feifei', kuaishouFeifeiRouter) + app.use('/api/v1/claim', claimsRouter) + app.use('/api/v1/collect', collectRouter) + app.use('/api/v1/files', filesRouter) + app.use('/api/v1/worker', workerRouter) + app.use('/api/v1/admin', adminRouter) + app.use('/s', shortLinksRouter) app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { - sendRouteError(res, err, "服务内部错误", "[global]"); - }); + sendRouteError(res, err, '服务内部错误', '[global]') + }) - return app; + return app } diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 1c4931fa..b8879b71 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -1,6 +1,6 @@ -import path from "node:path"; +import path from 'node:path' -import type { RuntimeConfig } from "../types/runtime-config.js"; +import type { RuntimeConfig } from '../types/runtime-config.js' export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { return { @@ -9,99 +9,107 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { }, data: { - root: path.resolve(projectRoot, "data"), + root: path.resolve(projectRoot, 'data'), }, logging: { - level: "info", + level: 'info', // 对接日志默认只保留 warn/error,避免 integration-*.log 被 info 刷爆 - integrationLevel: "warn", + integrationLevel: 'warn', retentionDays: 30, }, database: { - url: "postgres://postgres:postgres@127.0.0.1:5432/order_site", + url: 'postgres://postgres:postgres@127.0.0.1:5432/order_site', ssl: false, maxConnections: 10, }, + storage: { + endpoint: 'http://127.0.0.1:9000', + bucket: 'order-site', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin', + region: 'us-east-1', + maxUploadSizeMb: 10, + }, + orders: { - claimBaseUrl: "http://127.0.0.1:5173/#/claim", + claimBaseUrl: 'http://127.0.0.1:5173/#/claim', tokenTtlHours: 24, }, admin: { - sessionSecret: "", + sessionSecret: '', sessionTtlHours: 12, defaultUsers: [], }, platforms: { ninetyone: { - userId: "", - secret: "", - version: "1.0", - shopId: "91kaquan", - shopName: "91卡券", + userId: '', + secret: '', + version: '1.0', + shopId: '91kaquan', + shopName: '91卡券', timestampToleranceSeconds: 600, - cardsEncoding: "aes-256-ecb-base64", + cardsEncoding: 'aes-256-ecb-base64', }, kuaishouIndustry: { - appKey: "", - appSecret: "", - signSecret: "", - messageSecret: "", - provider: "kuaishou-industry", - platform: "kuaishou", - shopId: "kuaishou-industry", - shopName: "快手行业电子凭证", - version: "1", + appKey: '', + appSecret: '', + signSecret: '', + messageSecret: '', + provider: 'kuaishou-industry', + platform: 'kuaishou', + shopId: 'kuaishou-industry', + shopName: '快手行业电子凭证', + version: '1', }, kuaishouFeifei: { enabled: true, - baseUrl: "http://skin-exchange.yiquyou.icu", - appKey: "", - appSecret: "", + baseUrl: 'http://skin-exchange.yiquyou.icu', + appKey: '', + appSecret: '', timeoutMs: 10000, - notifyUrl: "", + notifyUrl: '', productRules: [], }, cloudtentacles: { - baseUrl: "https://123.207.217.176", + baseUrl: 'https://123.207.217.176', timeoutMs: 5000, - sendSmsPath: "/public/verif_code", - loginPath: "/public/login", - userInfoPath: "/user/info", - assetPath: "/user/get_asset", - permissionPath: "/user/get_permission", - categoriesPath: "/categories/get", - skuListPath: "/sku/list", - skuBuyPath: "/sku/buy", - skuUsePath: "/sku/use", - knapsackPath: "/user/get_knapsack", - vnListPath: "/vn/list", - vnAppointPath: "/vn/appoint", - vnGenerateLoginCodePath: "/vn/generate_login_code", - vnVerifCodePath: "/public/vn_verif_code", - vnVerifyLoginCodePath: "/vn/verify_login_code", - vnBindUrlPath: "/vn/bind_url", - vnBindInfoPath: "/vn/bind_info", - vnBackPath: "/vn/back", + sendSmsPath: '/public/verif_code', + loginPath: '/public/login', + userInfoPath: '/user/info', + assetPath: '/user/get_asset', + permissionPath: '/user/get_permission', + categoriesPath: '/categories/get', + skuListPath: '/sku/list', + skuBuyPath: '/sku/buy', + skuUsePath: '/sku/use', + knapsackPath: '/user/get_knapsack', + vnListPath: '/vn/list', + vnAppointPath: '/vn/appoint', + vnGenerateLoginCodePath: '/vn/generate_login_code', + vnVerifCodePath: '/public/vn_verif_code', + vnVerifyLoginCodePath: '/vn/verify_login_code', + vnBindUrlPath: '/vn/bind_url', + vnBindInfoPath: '/vn/bind_info', + vnBackPath: '/vn/back', bindUrlTtlSeconds: 600, bindUrlProbeIntervalSeconds: 30, bindUrlProbeTimeoutMs: 5000, bindUrlProbeUserAgent: - "Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.211006.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/98.0.4758.102 MQQBrowser/6.2 TBS/046141 Mobile Safari/537.36 V1_AND_SQ_8.8.85_2674_YYB_D QQ/8.8.85.7685 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/108 SimpleUISwitch/0 QQTheme/1000", - bindUrlProbeEndpoint: "https://comm.ams.game.qq.com/ide/", - bindUrlProbeChartId: "323794", - bindUrlProbeSubChartId: "323794", - bindUrlProbeIdeToken: "z90Syo", - bindUrlProbeActivityUrl: - "http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F", - bindUrlProbeReferer: "https://gp.qq.com/", - bindUrlProbeExtraCookie: "", - clientSource: "ct-client", - deviceId: "08bc9d8c-fd15-48ea-bc00-8d754076cafc", + 'Mozilla/5.0 (Linux; Android 12; M2012K11AC Build/SKQ1.211006.001; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/98.0.4758.102 MQQBrowser/6.2 TBS/046141 Mobile Safari/537.36 V1_AND_SQ_8.8.85_2674_YYB_D QQ/8.8.85.7685 NetType/WIFI WebP/0.3.0 Pixel/1080 StatusBarHeight/108 SimpleUISwitch/0 QQTheme/1000', + bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', + bindUrlProbeChartId: '323794', + bindUrlProbeSubChartId: '323794', + bindUrlProbeIdeToken: 'z90Syo', + bindUrlProbeActivityUrl: 'http%3A%2F%2Fgp.qq.com%2Fcp%2Fa20240828cmcc%2F', + bindUrlProbeReferer: 'https://gp.qq.com/', + bindUrlProbeExtraCookie: '', + clientSource: 'ct-client', + deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc', deviceType: 1, publicKeyPem: `-----BEGIN PUBLIC KEY----- MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsSzjk+9st2f8IpYqYaM5 @@ -116,7 +124,7 @@ PfQTPNA++KsXtwRX9M6Re3vkTDRsutIIWKtj8jqhUbbYS3vzS8GJnAWavUFVkR15 }, cors: { - allowedOrigins: ["*"], + allowedOrigins: ['*'], }, - }; + } } diff --git a/apps/backend/src/config/env-documentation.test.ts b/apps/backend/src/config/env-documentation.test.ts index 37f8eedd..471c8a45 100644 --- a/apps/backend/src/config/env-documentation.test.ts +++ b/apps/backend/src/config/env-documentation.test.ts @@ -10,10 +10,7 @@ const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) const BACKEND_ROOT = path.resolve(CURRENT_DIR, '../..') const WORKSPACE_ROOT = path.resolve(BACKEND_ROOT, '../..') -const SPECIAL_RUNTIME_ENV_NAMES = [ - 'APP_BASE_URL', - 'CLAIM_BASE_URL', -] +const SPECIAL_RUNTIME_ENV_NAMES = ['APP_BASE_URL', 'CLAIM_BASE_URL'] const DEPLOYMENT_ONLY_ENV_NAMES = [ 'APP_DOMAIN', @@ -21,6 +18,8 @@ const DEPLOYMENT_ONLY_ENV_NAMES = [ 'CADDY_SITE_ADDR', 'CHOKIDAR_USEPOLLING', 'KUASHOU_INDUSTRY_RATE_LIMIT_MAX', + 'MINIO_ROOT_PASSWORD', + 'MINIO_ROOT_USER', 'NPM_CONFIG_REGISTRY', 'POSTGRES_DB', 'POSTGRES_PASSWORD', diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index 207633f5..f1574005 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -1,352 +1,215 @@ -import path from "node:path"; -import process from "node:process"; +import path from 'node:path' +import process from 'node:process' import type { AdminDefaultUser, KuaishouFeifeiProductRule, RuntimeConfig, -} from "../types/runtime-config.js"; -import { - parseBoolean, - parseInteger, - parseJsonArray, -} from "./runtime-env.js"; -import { deepMerge } from "./runtime-merge.js"; +} from '../types/runtime-config.js' +import { parseBoolean, parseInteger, parseJsonArray } from './runtime-env.js' +import { deepMerge } from './runtime-merge.js' -type RuntimeConfigPath = readonly [string, ...string[]]; +type RuntimeConfigPath = readonly [string, ...string[]] type RuntimeConfigValue = | string | number | boolean | AdminDefaultUser[] | KuaishouFeifeiProductRule[] - | string[]; -type RuntimeEnv = Record; + | string[] +type RuntimeEnv = Record type EnvOverride = { - env: string; - path: RuntimeConfigPath; - read: (rawValue: unknown) => RuntimeConfigValue | null; -}; + env: string + path: RuntimeConfigPath + read: (rawValue: unknown) => RuntimeConfigValue | null +} export const ENV_OVERRIDES: readonly EnvOverride[] = [ - integerEnv("PORT", ["server", "port"]), - stringEnv("DATA_ROOT", ["data", "root"], path.resolve), - stringEnv("LOG_LEVEL", ["logging", "level"]), - stringEnv("LOG_INTEGRATION_LEVEL", ["logging", "integrationLevel"]), - integerEnv("LOG_RETENTION_DAYS", ["logging", "retentionDays"]), - stringEnv("DATABASE_URL", ["database", "url"]), - booleanEnv("DATABASE_SSL", ["database", "ssl"]), - integerEnv("DATABASE_MAX_CONNECTIONS", ["database", "maxConnections"]), - integerEnv("CLAIM_TOKEN_TTL_HOURS", ["orders", "tokenTtlHours"]), - stringEnv("ADMIN_SESSION_SECRET", ["admin", "sessionSecret"]), - integerEnv("ADMIN_SESSION_TTL_HOURS", ["admin", "sessionTtlHours"]), - adminUsersJsonEnv("ADMIN_DEFAULT_USERS_JSON", ["admin", "defaultUsers"]), - stringEnv("KAQUAN91_USER_ID", ["platforms", "ninetyone", "userId"]), - stringEnv("KAQUAN91_SECRET", ["platforms", "ninetyone", "secret"]), - stringEnv("KAQUAN91_VERSION", ["platforms", "ninetyone", "version"]), - stringEnv("KAQUAN91_SHOP_ID", ["platforms", "ninetyone", "shopId"]), - stringEnv("KAQUAN91_SHOP_NAME", ["platforms", "ninetyone", "shopName"]), - integerEnv("KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS", [ - "platforms", - "ninetyone", - "timestampToleranceSeconds", + integerEnv('PORT', ['server', 'port']), + stringEnv('DATA_ROOT', ['data', 'root'], path.resolve), + stringEnv('LOG_LEVEL', ['logging', 'level']), + stringEnv('LOG_INTEGRATION_LEVEL', ['logging', 'integrationLevel']), + integerEnv('LOG_RETENTION_DAYS', ['logging', 'retentionDays']), + stringEnv('DATABASE_URL', ['database', 'url']), + booleanEnv('DATABASE_SSL', ['database', 'ssl']), + integerEnv('DATABASE_MAX_CONNECTIONS', ['database', 'maxConnections']), + stringEnv('STORAGE_ENDPOINT', ['storage', 'endpoint']), + stringEnv('STORAGE_BUCKET', ['storage', 'bucket']), + stringEnv('STORAGE_ACCESS_KEY_ID', ['storage', 'accessKeyId']), + stringEnv('STORAGE_SECRET_ACCESS_KEY', ['storage', 'secretAccessKey']), + stringEnv('STORAGE_REGION', ['storage', 'region']), + integerEnv('STORAGE_MAX_UPLOAD_SIZE_MB', ['storage', 'maxUploadSizeMb']), + integerEnv('CLAIM_TOKEN_TTL_HOURS', ['orders', 'tokenTtlHours']), + stringEnv('ADMIN_SESSION_SECRET', ['admin', 'sessionSecret']), + integerEnv('ADMIN_SESSION_TTL_HOURS', ['admin', 'sessionTtlHours']), + adminUsersJsonEnv('ADMIN_DEFAULT_USERS_JSON', ['admin', 'defaultUsers']), + stringEnv('KAQUAN91_USER_ID', ['platforms', 'ninetyone', 'userId']), + stringEnv('KAQUAN91_SECRET', ['platforms', 'ninetyone', 'secret']), + stringEnv('KAQUAN91_VERSION', ['platforms', 'ninetyone', 'version']), + stringEnv('KAQUAN91_SHOP_ID', ['platforms', 'ninetyone', 'shopId']), + stringEnv('KAQUAN91_SHOP_NAME', ['platforms', 'ninetyone', 'shopName']), + integerEnv('KAQUAN91_TIMESTAMP_TOLERANCE_SECONDS', [ + 'platforms', + 'ninetyone', + 'timestampToleranceSeconds', ]), - stringEnv("KAQUAN91_CARDS_ENCODING", [ - "platforms", - "ninetyone", - "cardsEncoding", + stringEnv('KAQUAN91_CARDS_ENCODING', ['platforms', 'ninetyone', 'cardsEncoding']), + stringEnv('CLOUDTENTACLES_BASE_URL', ['platforms', 'cloudtentacles', 'baseUrl']), + integerEnv('CLOUDTENTACLES_TIMEOUT_MS', ['platforms', 'cloudtentacles', 'timeoutMs']), + stringEnv('CLOUDTENTACLES_SEND_SMS_PATH', ['platforms', 'cloudtentacles', 'sendSmsPath']), + stringEnv('CLOUDTENTACLES_LOGIN_PATH', ['platforms', 'cloudtentacles', 'loginPath']), + stringEnv('CLOUDTENTACLES_USER_INFO_PATH', ['platforms', 'cloudtentacles', 'userInfoPath']), + stringEnv('CLOUDTENTACLES_ASSET_PATH', ['platforms', 'cloudtentacles', 'assetPath']), + stringEnv('CLOUDTENTACLES_PERMISSION_PATH', ['platforms', 'cloudtentacles', 'permissionPath']), + stringEnv('CLOUDTENTACLES_CATEGORIES_PATH', ['platforms', 'cloudtentacles', 'categoriesPath']), + stringEnv('CLOUDTENTACLES_SKU_LIST_PATH', ['platforms', 'cloudtentacles', 'skuListPath']), + stringEnv('CLOUDTENTACLES_SKU_BUY_PATH', ['platforms', 'cloudtentacles', 'skuBuyPath']), + stringEnv('CLOUDTENTACLES_KNAPSACK_PATH', ['platforms', 'cloudtentacles', 'knapsackPath']), + stringEnv('CLOUDTENTACLES_VN_LIST_PATH', ['platforms', 'cloudtentacles', 'vnListPath']), + stringEnv('CLOUDTENTACLES_VN_APPOINT_PATH', ['platforms', 'cloudtentacles', 'vnAppointPath']), + stringEnv('CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH', [ + 'platforms', + 'cloudtentacles', + 'vnGenerateLoginCodePath', ]), - stringEnv("CLOUDTENTACLES_BASE_URL", [ - "platforms", - "cloudtentacles", - "baseUrl", + stringEnv('CLOUDTENTACLES_VN_VERIF_CODE_PATH', [ + 'platforms', + 'cloudtentacles', + 'vnVerifCodePath', ]), - integerEnv("CLOUDTENTACLES_TIMEOUT_MS", [ - "platforms", - "cloudtentacles", - "timeoutMs", + stringEnv('CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH', [ + 'platforms', + 'cloudtentacles', + 'vnVerifyLoginCodePath', ]), - stringEnv("CLOUDTENTACLES_SEND_SMS_PATH", [ - "platforms", - "cloudtentacles", - "sendSmsPath", + stringEnv('CLOUDTENTACLES_VN_BIND_URL_PATH', ['platforms', 'cloudtentacles', 'vnBindUrlPath']), + stringEnv('CLOUDTENTACLES_VN_BIND_INFO_PATH', ['platforms', 'cloudtentacles', 'vnBindInfoPath']), + stringEnv('CLOUDTENTACLES_VN_BACK_PATH', ['platforms', 'cloudtentacles', 'vnBackPath']), + integerEnv('CLOUDTENTACLES_BIND_URL_TTL_SECONDS', [ + 'platforms', + 'cloudtentacles', + 'bindUrlTtlSeconds', ]), - stringEnv("CLOUDTENTACLES_LOGIN_PATH", [ - "platforms", - "cloudtentacles", - "loginPath", + integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeIntervalSeconds', ]), - stringEnv("CLOUDTENTACLES_USER_INFO_PATH", [ - "platforms", - "cloudtentacles", - "userInfoPath", + integerEnv('CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeTimeoutMs', ]), - stringEnv("CLOUDTENTACLES_ASSET_PATH", [ - "platforms", - "cloudtentacles", - "assetPath", + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeUserAgent', ]), - stringEnv("CLOUDTENTACLES_PERMISSION_PATH", [ - "platforms", - "cloudtentacles", - "permissionPath", + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeEndpoint', ]), - stringEnv("CLOUDTENTACLES_CATEGORIES_PATH", [ - "platforms", - "cloudtentacles", - "categoriesPath", + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeChartId', ]), - stringEnv("CLOUDTENTACLES_SKU_LIST_PATH", [ - "platforms", - "cloudtentacles", - "skuListPath", + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeSubChartId', ]), - stringEnv("CLOUDTENTACLES_SKU_BUY_PATH", [ - "platforms", - "cloudtentacles", - "skuBuyPath", + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeIdeToken', ]), - stringEnv("CLOUDTENTACLES_KNAPSACK_PATH", [ - "platforms", - "cloudtentacles", - "knapsackPath", + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeActivityUrl', ]), - stringEnv("CLOUDTENTACLES_VN_LIST_PATH", [ - "platforms", - "cloudtentacles", - "vnListPath", + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_REFERER', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeReferer', ]), - stringEnv("CLOUDTENTACLES_VN_APPOINT_PATH", [ - "platforms", - "cloudtentacles", - "vnAppointPath", + stringEnv('CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE', [ + 'platforms', + 'cloudtentacles', + 'bindUrlProbeExtraCookie', ]), - stringEnv("CLOUDTENTACLES_VN_GENERATE_LOGIN_CODE_PATH", [ - "platforms", - "cloudtentacles", - "vnGenerateLoginCodePath", + stringEnv('CLOUDTENTACLES_PUBLIC_KEY_PEM', ['platforms', 'cloudtentacles', 'publicKeyPem']), + stringEnv('CLOUDTENTACLES_CLIENT_SOURCE', ['platforms', 'cloudtentacles', 'clientSource']), + stringEnv('CLOUDTENTACLES_DEVICE_ID', ['platforms', 'cloudtentacles', 'deviceId']), + integerEnv('CLOUDTENTACLES_DEVICE_TYPE', ['platforms', 'cloudtentacles', 'deviceType']), + stringEnv('KUASHOU_INDUSTRY_APP_KEY', ['platforms', 'kuaishouIndustry', 'appKey']), + stringEnv('KUASHOU_INDUSTRY_APP_SECRET', ['platforms', 'kuaishouIndustry', 'appSecret']), + stringEnv('KUASHOU_INDUSTRY_SIGN_SECRET', ['platforms', 'kuaishouIndustry', 'signSecret']), + stringEnv('KUASHOU_INDUSTRY_MESSAGE_SECRET', ['platforms', 'kuaishouIndustry', 'messageSecret']), + stringEnv('KUASHOU_INDUSTRY_PROVIDER', ['platforms', 'kuaishouIndustry', 'provider']), + stringEnv('KUASHOU_INDUSTRY_SHOP_ID', ['platforms', 'kuaishouIndustry', 'shopId']), + stringEnv('KUASHOU_INDUSTRY_SHOP_NAME', ['platforms', 'kuaishouIndustry', 'shopName']), + stringEnv('KUASHOU_INDUSTRY_VERSION', ['platforms', 'kuaishouIndustry', 'version']), + stringEnv('KUAISHOU_FEIFEI_BASE_URL', ['platforms', 'kuaishouFeifei', 'baseUrl']), + stringEnv('KUAISHOU_FEIFEI_APP_KEY', ['platforms', 'kuaishouFeifei', 'appKey']), + stringEnv('KUAISHOU_FEIFEI_APP_SECRET', ['platforms', 'kuaishouFeifei', 'appSecret']), + integerEnv('KUAISHOU_FEIFEI_TIMEOUT_MS', ['platforms', 'kuaishouFeifei', 'timeoutMs']), + stringEnv('KUAISHOU_FEIFEI_NOTIFY_URL', ['platforms', 'kuaishouFeifei', 'notifyUrl']), + kuaishouFeifeiProductRulesEnv('KUAISHOU_FEIFEI_PRODUCT_RULES_JSON', [ + 'platforms', + 'kuaishouFeifei', + 'productRules', ]), - stringEnv("CLOUDTENTACLES_VN_VERIF_CODE_PATH", [ - "platforms", - "cloudtentacles", - "vnVerifCodePath", - ]), - stringEnv("CLOUDTENTACLES_VN_VERIFY_LOGIN_CODE_PATH", [ - "platforms", - "cloudtentacles", - "vnVerifyLoginCodePath", - ]), - stringEnv("CLOUDTENTACLES_VN_BIND_URL_PATH", [ - "platforms", - "cloudtentacles", - "vnBindUrlPath", - ]), - stringEnv("CLOUDTENTACLES_VN_BIND_INFO_PATH", [ - "platforms", - "cloudtentacles", - "vnBindInfoPath", - ]), - stringEnv("CLOUDTENTACLES_VN_BACK_PATH", [ - "platforms", - "cloudtentacles", - "vnBackPath", - ]), - integerEnv("CLOUDTENTACLES_BIND_URL_TTL_SECONDS", [ - "platforms", - "cloudtentacles", - "bindUrlTtlSeconds", - ]), - integerEnv("CLOUDTENTACLES_BIND_URL_PROBE_INTERVAL_SECONDS", [ - "platforms", - "cloudtentacles", - "bindUrlProbeIntervalSeconds", - ]), - integerEnv("CLOUDTENTACLES_BIND_URL_PROBE_TIMEOUT_MS", [ - "platforms", - "cloudtentacles", - "bindUrlProbeTimeoutMs", - ]), - stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_USER_AGENT", [ - "platforms", - "cloudtentacles", - "bindUrlProbeUserAgent", - ]), - stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_ENDPOINT", [ - "platforms", - "cloudtentacles", - "bindUrlProbeEndpoint", - ]), - stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_CHART_ID", [ - "platforms", - "cloudtentacles", - "bindUrlProbeChartId", - ]), - stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_SUB_CHART_ID", [ - "platforms", - "cloudtentacles", - "bindUrlProbeSubChartId", - ]), - stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_IDE_TOKEN", [ - "platforms", - "cloudtentacles", - "bindUrlProbeIdeToken", - ]), - stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_ACTIVITY_URL", [ - "platforms", - "cloudtentacles", - "bindUrlProbeActivityUrl", - ]), - stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_REFERER", [ - "platforms", - "cloudtentacles", - "bindUrlProbeReferer", - ]), - stringEnv("CLOUDTENTACLES_BIND_URL_PROBE_EXTRA_COOKIE", [ - "platforms", - "cloudtentacles", - "bindUrlProbeExtraCookie", - ]), - stringEnv("CLOUDTENTACLES_PUBLIC_KEY_PEM", [ - "platforms", - "cloudtentacles", - "publicKeyPem", - ]), - stringEnv("CLOUDTENTACLES_CLIENT_SOURCE", [ - "platforms", - "cloudtentacles", - "clientSource", - ]), - stringEnv("CLOUDTENTACLES_DEVICE_ID", [ - "platforms", - "cloudtentacles", - "deviceId", - ]), - integerEnv("CLOUDTENTACLES_DEVICE_TYPE", [ - "platforms", - "cloudtentacles", - "deviceType", - ]), - stringEnv("KUASHOU_INDUSTRY_APP_KEY", [ - "platforms", - "kuaishouIndustry", - "appKey", - ]), - stringEnv("KUASHOU_INDUSTRY_APP_SECRET", [ - "platforms", - "kuaishouIndustry", - "appSecret", - ]), - stringEnv("KUASHOU_INDUSTRY_SIGN_SECRET", [ - "platforms", - "kuaishouIndustry", - "signSecret", - ]), - stringEnv("KUASHOU_INDUSTRY_MESSAGE_SECRET", [ - "platforms", - "kuaishouIndustry", - "messageSecret", - ]), - stringEnv("KUASHOU_INDUSTRY_PROVIDER", [ - "platforms", - "kuaishouIndustry", - "provider", - ]), - stringEnv("KUASHOU_INDUSTRY_SHOP_ID", [ - "platforms", - "kuaishouIndustry", - "shopId", - ]), - stringEnv("KUASHOU_INDUSTRY_SHOP_NAME", [ - "platforms", - "kuaishouIndustry", - "shopName", - ]), - stringEnv("KUASHOU_INDUSTRY_VERSION", [ - "platforms", - "kuaishouIndustry", - "version", - ]), - stringEnv("KUAISHOU_FEIFEI_BASE_URL", [ - "platforms", - "kuaishouFeifei", - "baseUrl", - ]), - stringEnv("KUAISHOU_FEIFEI_APP_KEY", [ - "platforms", - "kuaishouFeifei", - "appKey", - ]), - stringEnv("KUAISHOU_FEIFEI_APP_SECRET", [ - "platforms", - "kuaishouFeifei", - "appSecret", - ]), - integerEnv("KUAISHOU_FEIFEI_TIMEOUT_MS", [ - "platforms", - "kuaishouFeifei", - "timeoutMs", - ]), - stringEnv("KUAISHOU_FEIFEI_NOTIFY_URL", [ - "platforms", - "kuaishouFeifei", - "notifyUrl", - ]), - kuaishouFeifeiProductRulesEnv("KUAISHOU_FEIFEI_PRODUCT_RULES_JSON", [ - "platforms", - "kuaishouFeifei", - "productRules", - ]), - corsOriginsEnv("CORS_ALLOWED_ORIGINS", ["cors", "allowedOrigins"]), -]; + corsOriginsEnv('CORS_ALLOWED_ORIGINS', ['cors', 'allowedOrigins']), +] export function applyEnvOverrides( baseConfig: RuntimeConfig, - env: RuntimeEnv = process.env + env: RuntimeEnv = process.env, ): RuntimeConfig { - const nextConfig = deepMerge(baseConfig, {}); + const nextConfig = deepMerge(baseConfig, {}) - applyClaimBaseUrlOverride(nextConfig, env); + applyClaimBaseUrlOverride(nextConfig, env) for (const override of ENV_OVERRIDES) { - const value = override.read(env[override.env]); + const value = override.read(env[override.env]) if (value !== null) { - setConfigValue(nextConfig, override.path, value); + setConfigValue(nextConfig, override.path, value) } } - return nextConfig; + return nextConfig } -function applyClaimBaseUrlOverride( - config: RuntimeConfig, - env: RuntimeEnv -): void { - const claimBaseUrl = parseString(env.CLAIM_BASE_URL); +function applyClaimBaseUrlOverride(config: RuntimeConfig, env: RuntimeEnv): void { + const claimBaseUrl = parseString(env.CLAIM_BASE_URL) if (claimBaseUrl !== null) { - config.orders.claimBaseUrl = claimBaseUrl; - return; + config.orders.claimBaseUrl = claimBaseUrl + return } - const appBaseUrl = parseString(env.APP_BASE_URL); + const appBaseUrl = parseString(env.APP_BASE_URL) if (appBaseUrl !== null) { - config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, "")}/#/claim`; + config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/#/claim` } } function stringEnv( env: string, configPath: RuntimeConfigPath, - transform: (value: string) => string = (value) => value + transform: (value: string) => string = (value) => value, ): EnvOverride { return { env, path: configPath, read(rawValue) { - const value = parseString(rawValue); - return value === null ? null : transform(value); + const value = parseString(rawValue) + return value === null ? null : transform(value) }, - }; + } } function integerEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { @@ -354,7 +217,7 @@ function integerEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { env, path: configPath, read: parseInteger, - }; + } } function booleanEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { @@ -362,90 +225,74 @@ function booleanEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { env, path: configPath, read: parseBoolean, - }; + } } -function adminUsersJsonEnv( - env: string, - configPath: RuntimeConfigPath -): EnvOverride { +function adminUsersJsonEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read(rawValue) { - return parseJsonArray(rawValue); + return parseJsonArray(rawValue) }, - }; + } } -function kuaishouFeifeiProductRulesEnv( - env: string, - configPath: RuntimeConfigPath -): EnvOverride { +function kuaishouFeifeiProductRulesEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read(rawValue) { - const parsed = parseJsonArray(rawValue); - return parsed === null ? null : parsed; + const parsed = parseJsonArray(rawValue) + return parsed === null ? null : parsed }, - }; + } } -function corsOriginsEnv( - env: string, - configPath: RuntimeConfigPath -): EnvOverride { +function corsOriginsEnv(env: string, configPath: RuntimeConfigPath): EnvOverride { return { env, path: configPath, read(rawValue) { - const value = parseString(rawValue); + const value = parseString(rawValue) if (value === null) { - return null; + return null } - if (value.trim() === "*") { - return ["*"]; + if (value.trim() === '*') { + return ['*'] } return value - .split(",") + .split(',') .map((s) => s.trim()) - .filter(Boolean); + .filter(Boolean) }, - }; + } } function parseString(rawValue: unknown): string | null { - const value = String(rawValue || "").trim(); - return value ? value : null; + const value = String(rawValue || '').trim() + return value ? value : null } function setConfigValue( config: RuntimeConfig, configPath: RuntimeConfigPath, - value: RuntimeConfigValue + value: RuntimeConfigValue, ): void { - let current: Record = config as unknown as Record< - string, - unknown - >; + let current: Record = config as unknown as Record for (const key of configPath.slice(0, -1)) { - const nextValue = current[key]; - if ( - !nextValue || - typeof nextValue !== "object" || - Array.isArray(nextValue) - ) { - current[key] = {}; + const nextValue = current[key] + if (!nextValue || typeof nextValue !== 'object' || Array.isArray(nextValue)) { + current[key] = {} } - current = current[key] as Record; + current = current[key] as Record } - const lastKey = configPath[configPath.length - 1]; + const lastKey = configPath[configPath.length - 1] if (!lastKey) { - return; + return } - current[lastKey] = value; + current[lastKey] = value } diff --git a/apps/backend/src/config/runtime-validation.test.ts b/apps/backend/src/config/runtime-validation.test.ts index e8acb952..fda772ab 100644 --- a/apps/backend/src/config/runtime-validation.test.ts +++ b/apps/backend/src/config/runtime-validation.test.ts @@ -10,54 +10,63 @@ import { deepMerge } from './runtime-merge.js' import type { RuntimeConfig } from '../types/runtime-config.js' test('validateRuntimeConfig allows missing admin credentials outside production', () => { - const issues = validateRuntimeConfig(createRuntimeConfig({ - admin: { - sessionSecret: '', - defaultUsers: [], + const issues = validateRuntimeConfig( + createRuntimeConfig({ + admin: { + sessionSecret: '', + defaultUsers: [], + }, + }), + { + env: { NODE_ENV: 'development' }, }, - }), { - env: { NODE_ENV: 'development' }, - }) + ) assert.deepEqual(issues, []) }) test('validateRuntimeConfig requires admin credentials in production', () => { - const issues = validateRuntimeConfig(createRuntimeConfig({ - admin: { - sessionSecret: '', - defaultUsers: [], + const issues = validateRuntimeConfig( + createRuntimeConfig({ + admin: { + sessionSecret: '', + defaultUsers: [], + }, + }), + { + env: { NODE_ENV: 'production' }, }, - }), { - env: { NODE_ENV: 'production' }, - }) + ) - assert.deepEqual(issues.map((issue) => issue.path), [ - 'admin.sessionSecret', - 'admin.defaultUsers', - ]) + assert.deepEqual( + issues.map((issue) => issue.path), + ['admin.sessionSecret', 'admin.defaultUsers'], + ) }) test('validateRuntimeConfig rejects unsafe production placeholders', () => { - const issues = validateRuntimeConfig(createRuntimeConfig({ - admin: { - sessionSecret: 'change-me-long-random-session-secret', - defaultUsers: [ - { - username: 'admin', - password: 'change-me-admin-password', - role: 'admin', - }, - ], + const issues = validateRuntimeConfig( + createRuntimeConfig({ + admin: { + sessionSecret: 'change-me-long-random-session-secret', + defaultUsers: [ + { + username: 'admin', + password: 'change-me-admin-password', + role: 'admin', + }, + ], + }, + }), + { + env: { NODE_ENV: 'production' }, }, - }), { - env: { NODE_ENV: 'production' }, - }) + ) - assert.deepEqual(issues.map((issue) => issue.path), [ - 'admin.sessionSecret', - 'admin.defaultUsers[0].password', - ]) + assert.deepEqual( + issues.map((issue) => issue.path), + ['admin.sessionSecret', 'admin.defaultUsers[0].password'], + ) }) test('validateRuntimeConfig accepts complete production config', () => { @@ -69,123 +78,140 @@ test('validateRuntimeConfig accepts complete production config', () => { }) test('validateRuntimeConfig catches invalid numeric and url values', () => { - const issues = validateRuntimeConfig(createRuntimeConfig({ - server: { - port: 70000, + const issues = validateRuntimeConfig( + createRuntimeConfig({ + server: { + port: 70000, + }, + database: { + maxConnections: 0, + }, + orders: { + claimBaseUrl: 'not-a-url', + tokenTtlHours: 0, + }, + }), + { + env: { NODE_ENV: 'development' }, }, - database: { - maxConnections: 0, - }, - orders: { - claimBaseUrl: 'not-a-url', - tokenTtlHours: 0, - }, - }), { - env: { NODE_ENV: 'development' }, - }) + ) - assert.deepEqual(issues.map((issue) => issue.path), [ - 'server.port', - 'database.maxConnections', - 'orders.tokenTtlHours', - 'orders.claimBaseUrl', - ]) + assert.deepEqual( + issues.map((issue) => issue.path), + ['server.port', 'database.maxConnections', 'orders.tokenTtlHours', 'orders.claimBaseUrl'], + ) }) test('assertRuntimeConfigValid throws a readable validation error', () => { - assert.throws(() => { - assertRuntimeConfigValid(createRuntimeConfig({ - server: { - port: 0, - }, - })) - }, (error) => { - assert.equal(error instanceof RuntimeConfigValidationError, true) - assert.match((error as Error).message, /运行时配置校验失败/) - assert.match((error as Error).message, /server\.port/) - return true - }) + assert.throws( + () => { + assertRuntimeConfigValid( + createRuntimeConfig({ + server: { + port: 0, + }, + }), + ) + }, + (error) => { + assert.equal(error instanceof RuntimeConfigValidationError, true) + assert.match((error as Error).message, /运行时配置校验失败/) + assert.match((error as Error).message, /server\.port/) + return true + }, + ) }) function createRuntimeConfig(overrides: Record = {}): RuntimeConfig { - return deepMerge({ - server: { - port: 3000, - }, - data: { - root: '/tmp/order-site', - }, - logging: { - level: 'info', - integrationLevel: 'warn', - }, - database: { - url: 'postgres://postgres:postgres@postgres:5432/order_site', - ssl: false, - maxConnections: 10, - }, - orders: { - claimBaseUrl: 'https://order.example.test/#/claim', - tokenTtlHours: 24, - }, - admin: { - sessionSecret: '0123456789abcdef0123456789abcdef', - sessionTtlHours: 12, - defaultUsers: [ - { - username: 'admin', - password: 'Qp7xT2mN9vR4', - role: 'admin', + return deepMerge( + { + server: { + port: 3000, + }, + data: { + root: '/tmp/order-site', + }, + logging: { + level: 'info', + integrationLevel: 'warn', + }, + database: { + url: 'postgres://postgres:postgres@postgres:5432/order_site', + ssl: false, + maxConnections: 10, + }, + storage: { + endpoint: 'http://minio:9000', + bucket: 'order-site', + accessKeyId: 'minioadmin', + secretAccessKey: 'minioadmin', + region: 'us-east-1', + maxUploadSizeMb: 10, + }, + orders: { + claimBaseUrl: 'https://order.example.test/#/claim', + tokenTtlHours: 24, + }, + admin: { + sessionSecret: '0123456789abcdef0123456789abcdef', + sessionTtlHours: 12, + defaultUsers: [ + { + username: 'admin', + password: 'Qp7xT2mN9vR4', + role: 'admin', + }, + ], + }, + platforms: { + ninetyone: { + userId: '', + secret: '', + version: '1.0', + shopId: '91kaquan', + shopName: '91卡券', + timestampToleranceSeconds: 600, + cardsEncoding: 'aes-256-ecb-base64', + }, + cloudtentacles: { + baseUrl: 'https://123.207.217.176', + timeoutMs: 5000, + sendSmsPath: '/public/verif_code', + loginPath: '/public/login', + userInfoPath: '/user/info', + assetPath: '/user/get_asset', + permissionPath: '/user/get_permission', + categoriesPath: '/categories/get', + skuListPath: '/sku/list', + skuBuyPath: '/sku/buy', + skuUsePath: '/sku/use', + knapsackPath: '/user/get_knapsack', + vnListPath: '/vn/list', + vnAppointPath: '/vn/appoint', + vnGenerateLoginCodePath: '/vn/generate_login_code', + vnVerifCodePath: '/public/vn_verif_code', + vnVerifyLoginCodePath: '/vn/verify_login_code', + vnBindUrlPath: '/vn/bind_url', + vnBindInfoPath: '/vn/bind_info', + vnBackPath: '/vn/back', + bindUrlTtlSeconds: 600, + bindUrlProbeIntervalSeconds: 30, + bindUrlProbeTimeoutMs: 5000, + bindUrlProbeUserAgent: '', + bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', + bindUrlProbeChartId: '323794', + bindUrlProbeSubChartId: '323794', + bindUrlProbeIdeToken: 'z90Syo', + bindUrlProbeActivityUrl: '', + bindUrlProbeReferer: 'https://gp.qq.com/', + bindUrlProbeExtraCookie: '', + publicKeyPem: '', + clientSource: 'ct-client', + deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc', + deviceType: 1, }, - ], - }, - platforms: { - ninetyone: { - userId: '', - secret: '', - version: '1.0', - shopId: '91kaquan', - shopName: '91卡券', - timestampToleranceSeconds: 600, - cardsEncoding: 'aes-256-ecb-base64', - }, - cloudtentacles: { - baseUrl: 'https://123.207.217.176', - timeoutMs: 5000, - sendSmsPath: '/public/verif_code', - loginPath: '/public/login', - userInfoPath: '/user/info', - assetPath: '/user/get_asset', - permissionPath: '/user/get_permission', - categoriesPath: '/categories/get', - skuListPath: '/sku/list', - skuBuyPath: '/sku/buy', - skuUsePath: '/sku/use', - knapsackPath: '/user/get_knapsack', - vnListPath: '/vn/list', - vnAppointPath: '/vn/appoint', - vnGenerateLoginCodePath: '/vn/generate_login_code', - vnVerifCodePath: '/public/vn_verif_code', - vnVerifyLoginCodePath: '/vn/verify_login_code', - vnBindUrlPath: '/vn/bind_url', - vnBindInfoPath: '/vn/bind_info', - vnBackPath: '/vn/back', - bindUrlTtlSeconds: 600, - bindUrlProbeIntervalSeconds: 30, - bindUrlProbeTimeoutMs: 5000, - bindUrlProbeUserAgent: '', - bindUrlProbeEndpoint: 'https://comm.ams.game.qq.com/ide/', - bindUrlProbeChartId: '323794', - bindUrlProbeSubChartId: '323794', - bindUrlProbeIdeToken: 'z90Syo', - bindUrlProbeActivityUrl: '', - bindUrlProbeReferer: 'https://gp.qq.com/', - bindUrlProbeExtraCookie: '', - publicKeyPem: '', - clientSource: 'ct-client', - deviceId: '08bc9d8c-fd15-48ea-bc00-8d754076cafc', - deviceType: 1, }, }, - }, overrides) as RuntimeConfig + overrides, + ) as RuntimeConfig } diff --git a/apps/backend/src/config/runtime-validation.ts b/apps/backend/src/config/runtime-validation.ts index 51ea5942..238f90e5 100644 --- a/apps/backend/src/config/runtime-validation.ts +++ b/apps/backend/src/config/runtime-validation.ts @@ -45,13 +45,32 @@ export function validateRuntimeConfig( requireInteger(issues, 'server.port', config.server?.port, { min: 1, max: 65535 }) requireInteger(issues, 'database.maxConnections', config.database?.maxConnections, { min: 1 }) + requireInteger(issues, 'storage.maxUploadSizeMb', config.storage?.maxUploadSizeMb, { + min: 1, + max: 100, + }) requireInteger(issues, 'orders.tokenTtlHours', config.orders?.tokenTtlHours, { min: 1 }) requireInteger(issues, 'admin.sessionTtlHours', config.admin?.sessionTtlHours, { min: 1 }) - requireInteger(issues, 'platforms.cloudtentacles.timeoutMs', config.platforms?.cloudtentacles?.timeoutMs, { min: 1 }) + requireInteger( + issues, + 'platforms.cloudtentacles.timeoutMs', + config.platforms?.cloudtentacles?.timeoutMs, + { min: 1 }, + ) if (config.platforms?.kuaishouFeifei) { - requireInteger(issues, 'platforms.kuaishouFeifei.timeoutMs', config.platforms.kuaishouFeifei.timeoutMs, { min: 1 }) + requireInteger( + issues, + 'platforms.kuaishouFeifei.timeoutMs', + config.platforms.kuaishouFeifei.timeoutMs, + { min: 1 }, + ) } - requireInteger(issues, 'platforms.cloudtentacles.bindUrlTtlSeconds', config.platforms?.cloudtentacles?.bindUrlTtlSeconds, { min: 1 }) + requireInteger( + issues, + 'platforms.cloudtentacles.bindUrlTtlSeconds', + config.platforms?.cloudtentacles?.bindUrlTtlSeconds, + { min: 1 }, + ) requireInteger( issues, 'platforms.cloudtentacles.bindUrlProbeIntervalSeconds', @@ -66,10 +85,18 @@ export function validateRuntimeConfig( ) validateOptionalHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) + validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) validateRequiredString(issues, 'data.root', config.data?.root) if (productionLike) { validateRequiredString(issues, 'database.url', config.database?.url) + validateRequiredString(issues, 'storage.endpoint', config.storage?.endpoint) + validateOptionalStorageEndpoint(issues, 'storage.endpoint', config.storage?.endpoint) + validateRequiredString(issues, 'storage.bucket', config.storage?.bucket) + validateRequiredString(issues, 'storage.accessKeyId', config.storage?.accessKeyId) + validateSecret(issues, 'storage.secretAccessKey', config.storage?.secretAccessKey, { + minLength: 8, + }) validateRequiredHttpUrl(issues, 'orders.claimBaseUrl', config.orders?.claimBaseUrl) validateSecret(issues, 'admin.sessionSecret', config.admin?.sessionSecret, { minLength: 32 }) validateAdminDefaultUsers(issues, config.admin?.defaultUsers) @@ -96,7 +123,9 @@ function validateAdminDefaultUsers( users.forEach((user, index) => { validateRequiredString(issues, `admin.defaultUsers[${index}].username`, user?.username) - validateSecret(issues, `admin.defaultUsers[${index}].password`, user?.password, { minLength: 8 }) + validateSecret(issues, `admin.defaultUsers[${index}].password`, user?.password, { + minLength: 8, + }) validateRequiredString(issues, `admin.defaultUsers[${index}].role`, user?.role) }) } @@ -171,11 +200,34 @@ function validateRequiredHttpUrl( validateOptionalHttpUrl(issues, configPath, value) } +function validateOptionalStorageEndpoint( + issues: RuntimeConfigValidationIssue[], + configPath: string, + value: unknown, +): void { + const text = String(value || '').trim() + if (!text) { + return + } + + if (/^https?:\/\//i.test(text)) { + validateOptionalHttpUrl(issues, configPath, text) + return + } + + if (!/^[a-z0-9.-]+(?::\d{1,5})?$/i.test(text)) { + issues.push({ + path: configPath, + message: '必须是 http/https URL 或 host:port', + }) + } +} + function requireInteger( issues: RuntimeConfigValidationIssue[], configPath: string, value: unknown, - { min, max }: { min?: number, max?: number } = {}, + { min, max }: { min?: number; max?: number } = {}, ): void { const numberValue = Number(value) if (!Number.isInteger(numberValue)) { diff --git a/apps/backend/src/db/migrations/005_file_storage_and_worker_product_rules.sql b/apps/backend/src/db/migrations/005_file_storage_and_worker_product_rules.sql new file mode 100644 index 00000000..0c3b2ca9 --- /dev/null +++ b/apps/backend/src/db/migrations/005_file_storage_and_worker_product_rules.sql @@ -0,0 +1,67 @@ +-- 005_file_storage_and_worker_product_rules.sql —— 统一文件存储与接单物品规则。 + +CREATE TABLE IF NOT EXISTS file_assets ( + id BIGSERIAL PRIMARY KEY, + object_key TEXT NOT NULL UNIQUE, + scene TEXT NOT NULL DEFAULT 'misc', + original_filename TEXT NOT NULL DEFAULT '', + content_type TEXT NOT NULL DEFAULT '', + size_bytes BIGINT NOT NULL DEFAULT 0, + thumbnail_object_key TEXT NOT NULL DEFAULT '', + medium_object_key TEXT NOT NULL DEFAULT '', + url TEXT NOT NULL DEFAULT '', + thumbnail_url TEXT NOT NULL DEFAULT '', + medium_url TEXT NOT NULL DEFAULT '', + uploader_type TEXT NOT NULL DEFAULT '', + uploader_id TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_file_assets_scene_created + ON file_assets(scene, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_file_assets_uploader_created + ON file_assets(uploader_type, uploader_id, created_at DESC); + +CREATE TABLE IF NOT EXISTS work_product_rules ( + id BIGSERIAL PRIMARY KEY, + rule_key TEXT NOT NULL UNIQUE, + provider TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + shop_id TEXT NOT NULL DEFAULT '', + sku_code TEXT NOT NULL DEFAULT '', + product_name TEXT NOT NULL DEFAULT '', + match_type TEXT NOT NULL DEFAULT 'contains', + category_id BIGINT REFERENCES work_categories(id) ON DELETE SET NULL, + enabled BOOLEAN NOT NULL DEFAULT false, + auto_create BOOLEAN NOT NULL DEFAULT false, + reward_amount INTEGER NOT NULL DEFAULT 0, + required_deposit_amount INTEGER NOT NULL DEFAULT 0, + deposit_threshold_amount INTEGER NOT NULL DEFAULT 20000, + requirement_json JSONB NOT NULL DEFAULT '{}'::jsonb, + sort_order INTEGER NOT NULL DEFAULT 100, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_work_product_rules_enabled_sort + ON work_product_rules(enabled, sort_order, id); +CREATE INDEX IF NOT EXISTS idx_work_product_rules_source + ON work_product_rules(provider, platform, shop_id, sku_code); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_work_orders_order_item_unique + ON work_orders(order_item_id) + WHERE order_item_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_work_orders_task_unique + ON work_orders(task_id) + WHERE task_id IS NOT NULL; + +COMMENT ON TABLE file_assets IS '统一文件资产,底层使用 MinIO/S3 兼容对象存储'; +COMMENT ON COLUMN file_assets.object_key IS '对象存储 key'; +COMMENT ON COLUMN file_assets.thumbnail_object_key IS '缩略图对象 key'; +COMMENT ON COLUMN file_assets.medium_object_key IS '中图对象 key'; +COMMENT ON TABLE work_product_rules IS '接单平台物品规则:决定哪些源订单商品生成接单工单'; +COMMENT ON COLUMN work_product_rules.reward_amount IS '后台发布给接单员的单笔金额,单位分'; +COMMENT ON COLUMN work_product_rules.auto_create IS '源订单入库后是否自动创建接单工单'; diff --git a/apps/backend/src/repositories/file-asset-repo.ts b/apps/backend/src/repositories/file-asset-repo.ts new file mode 100644 index 00000000..57563110 --- /dev/null +++ b/apps/backend/src/repositories/file-asset-repo.ts @@ -0,0 +1,96 @@ +import { query } from '../db/client.js' + +export type FileAssetRow = { + id: number + object_key: string + scene: string + original_filename: string + content_type: string + size_bytes: number + thumbnail_object_key: string + medium_object_key: string + url: string + thumbnail_url: string + medium_url: string + uploader_type: string + uploader_id: string + status: string + metadata_json: string | Record + created_at: string + updated_at: string +} + +type CreateFileAssetInput = { + objectKey: string + scene: string + originalFilename: string + contentType: string + sizeBytes: number + thumbnailObjectKey: string + mediumObjectKey: string + url: string + thumbnailUrl: string + mediumUrl: string + uploaderType: string + uploaderId: string + metadataJson: string + now: string +} + +export async function createFileAsset(input: CreateFileAssetInput): Promise { + const result = await query( + ` + INSERT INTO file_assets ( + object_key, scene, original_filename, content_type, size_bytes, + thumbnail_object_key, medium_object_key, url, thumbnail_url, medium_url, + uploader_type, uploader_id, metadata_json, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9, $10, + $11, $12, $13::jsonb, $14, $15 + ) + ON CONFLICT (object_key) DO UPDATE + SET + scene = EXCLUDED.scene, + original_filename = EXCLUDED.original_filename, + content_type = EXCLUDED.content_type, + size_bytes = EXCLUDED.size_bytes, + thumbnail_object_key = EXCLUDED.thumbnail_object_key, + medium_object_key = EXCLUDED.medium_object_key, + url = EXCLUDED.url, + thumbnail_url = EXCLUDED.thumbnail_url, + medium_url = EXCLUDED.medium_url, + uploader_type = EXCLUDED.uploader_type, + uploader_id = EXCLUDED.uploader_id, + metadata_json = EXCLUDED.metadata_json, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + input.objectKey, + input.scene, + input.originalFilename, + input.contentType, + input.sizeBytes, + input.thumbnailObjectKey, + input.mediumObjectKey, + input.url, + input.thumbnailUrl, + input.mediumUrl, + input.uploaderType, + input.uploaderId, + input.metadataJson, + input.now, + input.now, + ], + ) + return result.rows[0] || null +} + +export async function getFileAssetByObjectKey(objectKey: string): Promise { + const result = await query( + 'SELECT * FROM file_assets WHERE object_key = $1 AND status = $2 LIMIT 1', + [String(objectKey || '').trim(), 'active'], + ) + return result.rows[0] || null +} diff --git a/apps/backend/src/repositories/order-repo.ts b/apps/backend/src/repositories/order-repo.ts index dfe67fad..74ef499c 100644 --- a/apps/backend/src/repositories/order-repo.ts +++ b/apps/backend/src/repositories/order-repo.ts @@ -4,11 +4,7 @@ import type { OrderListQueryInput, OrderUpdateInput, } from '../types/repository/inputs.js' -import type { - OrderListQueryResult, - OrderListRow, - OrderRow, -} from '../types/repository/rows.js' +import type { OrderListQueryResult, OrderListRow, OrderRow } from '../types/repository/rows.js' type OrderPlatformLookupInput = { provider?: string @@ -55,6 +51,23 @@ export async function findLatestOrderByPlatformOrderId({ return result.rows[0] || null } +export async function findLatestOrderByAnyPlatformOrderId( + platformOrderId: string, +): Promise { + const result = await query( + ` + SELECT * + FROM orders + WHERE platform_order_id = $1 + ORDER BY id DESC + LIMIT 1 + `, + [String(platformOrderId || '').trim()], + ) + + return result.rows[0] || null +} + export async function createOrder(input: OrderCreateInput): Promise { const result = await query( ` @@ -101,7 +114,10 @@ export async function createOrder(input: OrderCreateInput): Promise { +export async function updateOrder( + orderId: number | string, + input: OrderUpdateInput, +): Promise { const result = await query( ` UPDATE orders @@ -146,7 +162,9 @@ export async function updateOrder(orderId: number | string, input: OrderUpdateIn } export async function getOrderById(orderId: number | string): Promise { - const result = await query('SELECT * FROM orders WHERE id = $1 LIMIT 1', [Number(orderId)]) + const result = await query('SELECT * FROM orders WHERE id = $1 LIMIT 1', [ + Number(orderId), + ]) return result.rows[0] || null } @@ -175,7 +193,9 @@ export async function listOrders({ if (skuCode) { params.push(skuCode) - filters.push(`EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`) + filters.push( + `EXISTS (SELECT 1 FROM order_items oi WHERE oi.order_id = o.id AND oi.sku_code = $${params.length})`, + ) } if (dateFrom) { @@ -189,7 +209,7 @@ export async function listOrders({ } const whereClause = filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '' - const totalResult = await query<{ [column: string]: unknown, total: number }>( + const totalResult = await query<{ [column: string]: unknown; total: number }>( `SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`, params, ) diff --git a/apps/backend/src/repositories/worker-platform-repo.ts b/apps/backend/src/repositories/worker-platform-repo.ts index 6750ee24..659ec7af 100644 --- a/apps/backend/src/repositories/worker-platform-repo.ts +++ b/apps/backend/src/repositories/worker-platform-repo.ts @@ -53,6 +53,28 @@ export type WorkCategoryRow = { updated_at: string } +export type WorkProductRuleRow = { + id: number + rule_key: string + provider: string + platform: string + shop_id: string + sku_code: string + product_name: string + match_type: string + category_id: number | null + enabled: boolean + auto_create: boolean + reward_amount: number + required_deposit_amount: number + deposit_threshold_amount: number + requirement_json: string | Record + sort_order: number + created_at: string + updated_at: string + category_name?: string +} + export type WorkOrderRow = { id: number work_order_no: string @@ -90,6 +112,11 @@ type ListInput = { workerId?: number } +type ProductRuleListInput = { + enabled?: boolean | null + keyword?: string +} + type CreateWorkerInput = { username: string passwordHash: string @@ -141,6 +168,14 @@ const WORK_ORDER_SELECT = ` LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id ` +const WORK_PRODUCT_RULE_SELECT = ` + SELECT + wpr.*, + wc.name AS category_name + FROM work_product_rules wpr + LEFT JOIN work_categories wc ON wc.id = wpr.category_id +` + export async function getWorkerLevelByKey(levelKey: string): Promise { const result = await query( 'SELECT * FROM worker_levels WHERE level_key = $1 LIMIT 1', @@ -150,7 +185,9 @@ export async function getWorkerLevelByKey(levelKey: string): Promise { - const result = await query('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [Number(levelId)]) + const result = await query('SELECT * FROM worker_levels WHERE id = $1 LIMIT 1', [ + Number(levelId), + ]) return result.rows[0] || null } @@ -199,16 +236,19 @@ export async function upsertWorkerLevel(input: { export async function getWorkerUserByUsername(username: string): Promise { const result = await query( `${WORKER_USER_SELECT} WHERE wu.username = $1 LIMIT 1`, - [String(username || '').trim().toLowerCase()], + [ + String(username || '') + .trim() + .toLowerCase(), + ], ) return result.rows[0] || null } export async function getWorkerUserById(workerId: number | string): Promise { - const result = await query( - `${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, - [Number(workerId)], - ) + const result = await query(`${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [ + Number(workerId), + ]) return result.rows[0] || null } @@ -241,7 +281,12 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise>, + patch: Partial< + Pick< + WorkerUserRow, + 'status' | 'review_note' | 'level_id' | 'session_version' | 'updated_at' | 'reviewed_at' + > + >, ): Promise { const current = await getWorkerUserById(workerId) if (!current) return null @@ -296,7 +341,10 @@ export async function listWorkerUsers({ return { items: itemsResult.rows, total: Number(totalResult.rows[0]?.total || 0) } } -export async function ensureWorkerWallet(workerId: number | string, now: string): Promise { +export async function ensureWorkerWallet( + workerId: number | string, + now: string, +): Promise { return withTransaction(async (client) => { await ensureWorkerWalletWithClient(client, Number(workerId), now) return getWorkerWalletWithClient(client, Number(workerId)) @@ -341,7 +389,15 @@ export async function addWorkerWalletCredit(input: { audit_status, note, payload_json, created_at ) VALUES ($1, 'manual_credit', $2, $3, $4, 'approved', $5, $6::jsonb, $7) `, - [input.workerId, input.amount, nextAvailable, nextFrozen, input.note, input.payloadJson, input.now], + [ + input.workerId, + input.amount, + nextAvailable, + nextFrozen, + input.note, + input.payloadJson, + input.now, + ], ) return getWorkerWalletWithClient(client, input.workerId) }) @@ -362,6 +418,13 @@ export async function listWorkCategories(): Promise { return result.rows } +export async function listAllWorkCategories(): Promise { + const result = await query( + `SELECT * FROM work_categories ORDER BY sort_order ASC, id ASC`, + ) + return result.rows +} + export async function upsertWorkCategory(input: { categoryKey: string name: string @@ -382,6 +445,101 @@ export async function upsertWorkCategory(input: { return result.rows[0] || null } +export async function listWorkProductRules({ + enabled = null, + keyword = '', +}: ProductRuleListInput = {}): Promise { + const { whereClause, params } = buildWorkProductRuleWhere({ enabled, keyword }) + const result = await query( + `${WORK_PRODUCT_RULE_SELECT} + ${whereClause} + ORDER BY wpr.sort_order ASC, wpr.id DESC`, + params, + ) + return result.rows +} + +export async function getWorkProductRuleByKey(ruleKey: string): Promise { + const result = await query( + `${WORK_PRODUCT_RULE_SELECT} WHERE wpr.rule_key = $1 LIMIT 1`, + [String(ruleKey || '').trim()], + ) + return result.rows[0] || null +} + +export async function upsertWorkProductRule(input: { + ruleKey: string + provider: string + platform: string + shopId: string + skuCode: string + productName: string + matchType: string + categoryId: number | null + enabled: boolean + autoCreate: boolean + rewardAmount: number + requiredDepositAmount: number + depositThresholdAmount: number + requirementJson: string + sortOrder: number + now: string +}): Promise { + const result = await query( + ` + INSERT INTO work_product_rules ( + rule_key, provider, platform, shop_id, sku_code, product_name, + match_type, category_id, enabled, auto_create, reward_amount, + required_deposit_amount, deposit_threshold_amount, requirement_json, + sort_order, created_at, updated_at + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, $14::jsonb, + $15, $16, $17 + ) + ON CONFLICT (rule_key) DO UPDATE + SET + provider = EXCLUDED.provider, + platform = EXCLUDED.platform, + shop_id = EXCLUDED.shop_id, + sku_code = EXCLUDED.sku_code, + product_name = EXCLUDED.product_name, + match_type = EXCLUDED.match_type, + category_id = EXCLUDED.category_id, + enabled = EXCLUDED.enabled, + auto_create = EXCLUDED.auto_create, + reward_amount = EXCLUDED.reward_amount, + required_deposit_amount = EXCLUDED.required_deposit_amount, + deposit_threshold_amount = EXCLUDED.deposit_threshold_amount, + requirement_json = EXCLUDED.requirement_json, + sort_order = EXCLUDED.sort_order, + updated_at = EXCLUDED.updated_at + RETURNING * + `, + [ + input.ruleKey, + input.provider, + input.platform, + input.shopId, + input.skuCode, + input.productName, + input.matchType, + input.categoryId, + input.enabled, + input.autoCreate, + input.rewardAmount, + input.requiredDepositAmount, + input.depositThresholdAmount, + input.requirementJson, + input.sortOrder, + input.now, + input.now, + ], + ) + return getWorkProductRuleByKey(result.rows[0]?.rule_key || input.ruleKey) +} + export async function createWorkOrder(input: CreateWorkOrderInput): Promise { const result = await query<{ id: number }>( ` @@ -416,14 +574,23 @@ export async function createWorkOrder(input: CreateWorkOrderInput): Promise { +export async function getWorkOrderByOrderItemId( + orderItemId: number | string, +): Promise { const result = await query( - `${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, - [Number(workOrderId)], + `${WORK_ORDER_SELECT} WHERE wo.order_item_id = $1 LIMIT 1`, + [Number(orderItemId)], ) return result.rows[0] || null } +export async function getWorkOrderById(workOrderId: number | string): Promise { + const result = await query(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [ + Number(workOrderId), + ]) + return result.rows[0] || null +} + export async function findPendingMaterialWorkOrderByPlatformOrderId( platformOrderId: string, ): Promise { @@ -467,19 +634,21 @@ export async function listWorkOrders({ export async function updateWorkOrder( workOrderId: number | string, - patch: Partial>, + patch: Partial< + Pick< + WorkOrderRow, + | 'status' + | 'assigned_worker_id' + | 'material_json' + | 'acceptance_json' + | 'problem_note' + | 'published_at' + | 'assigned_at' + | 'submitted_at' + | 'accepted_at' + | 'updated_at' + > + >, ): Promise { const current = await getWorkOrderById(workOrderId) if (!current) return null @@ -568,7 +737,14 @@ export async function grabWorkOrder(input: { related_work_order_id, note, payload_json, created_at ) VALUES ($1, 'deposit_freeze', $2, $3, $4, $5, '抢单冻结押金', '{}'::jsonb, $6) `, - [input.workerId, -input.depositAmount, nextAvailable, nextFrozen, input.workOrderId, input.now], + [ + input.workerId, + -input.depositAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + input.now, + ], ) } @@ -756,7 +932,11 @@ async function createWorkOrderEventWithClient( ) } -async function ensureWorkerWalletWithClient(client: PoolClient, workerId: number, now: string): Promise { +async function ensureWorkerWalletWithClient( + client: PoolClient, + workerId: number, + now: string, +): Promise { if (!workerId) return await client.query( ` @@ -770,7 +950,10 @@ async function ensureWorkerWalletWithClient(client: PoolClient, workerId: number ) } -async function getWorkerWalletWithClient(client: PoolClient, workerId: number): Promise { +async function getWorkerWalletWithClient( + client: PoolClient, + workerId: number, +): Promise { const result = await client.query( 'SELECT * FROM worker_wallets WHERE worker_id = $1 LIMIT 1', [workerId], @@ -778,7 +961,10 @@ async function getWorkerWalletWithClient(client: PoolClient, workerId: number): return result.rows[0] || null } -async function getWorkerUserByIdWithClient(client: PoolClient, workerId: number | string): Promise { +async function getWorkerUserByIdWithClient( + client: PoolClient, + workerId: number | string, +): Promise { const result = await client.query( `${WORKER_USER_SELECT} WHERE wu.id = $1 LIMIT 1`, [Number(workerId)], @@ -786,15 +972,20 @@ async function getWorkerUserByIdWithClient(client: PoolClient, workerId: number return result.rows[0] || null } -async function getWorkOrderByIdWithClient(client: PoolClient, workOrderId: number | string): Promise { - const result = await client.query( - `${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, - [Number(workOrderId)], - ) +async function getWorkOrderByIdWithClient( + client: PoolClient, + workOrderId: number | string, +): Promise { + const result = await client.query(`${WORK_ORDER_SELECT} WHERE wo.id = $1 LIMIT 1`, [ + Number(workOrderId), + ]) return result.rows[0] || null } -function buildWorkerUserWhere({ status = '', keyword = '' }: Pick) { +function buildWorkerUserWhere({ + status = '', + keyword = '', +}: Pick) { const filters: string[] = [] const params: unknown[] = [] if (status) { @@ -803,7 +994,9 @@ function buildWorkerUserWhere({ status = '', keyword = '' }: Pick 0 ? `WHERE ${filters.join(' AND ')}` : '', @@ -820,7 +1013,9 @@ function buildWorkOrderWhere({ status = '', keyword = '', workerId = 0 }: ListIn } if (keyword) { params.push(`%${keyword}%`) - filters.push(`(wo.work_order_no ILIKE $${params.length} OR wo.platform_order_id ILIKE $${params.length} OR wo.product_name ILIKE $${params.length})`) + filters.push( + `(wo.work_order_no ILIKE $${params.length} OR wo.platform_order_id ILIKE $${params.length} OR wo.product_name ILIKE $${params.length})`, + ) } if (workerId) { params.push(workerId) @@ -832,6 +1027,27 @@ function buildWorkOrderWhere({ status = '', keyword = '', workerId = 0 }: ListIn } } +function buildWorkProductRuleWhere({ enabled = null, keyword = '' }: ProductRuleListInput) { + const filters: string[] = [] + const params: unknown[] = [] + if (enabled !== null && enabled !== undefined) { + params.push(Boolean(enabled)) + filters.push(`wpr.enabled = $${params.length}`) + } + if (keyword) { + params.push(`%${keyword}%`) + filters.push(`( + wpr.rule_key ILIKE $${params.length} + OR wpr.sku_code ILIKE $${params.length} + OR wpr.product_name ILIKE $${params.length} + )`) + } + return { + whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '', + params, + } +} + function toJsonString(value: unknown): string { if (typeof value === 'string') return value || '{}' return JSON.stringify(value || {}) diff --git a/apps/backend/src/routes/admin.ts b/apps/backend/src/routes/admin.ts index 9e46cf85..68ac2bc3 100644 --- a/apps/backend/src/routes/admin.ts +++ b/apps/backend/src/routes/admin.ts @@ -1,36 +1,38 @@ -import { Router } from "express"; +import { Router } from 'express' -import authRouter from "./admin/auth.js"; -import auditLogsRouter from "./admin/audit-logs.js"; -import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js"; -import dashboardRouter from "./admin/dashboard.js"; -import devMockRouter from "./admin/dev-mock.js"; -import kuaishouIndustryRouter from "./admin/kuaishou-industry.js"; -import ordersRouter from "./admin/orders.js"; -import platformConfigRouter from "./admin/platform-config.js"; -import { requireAdminSession } from "./admin/session.js"; -import tasksRouter from "./admin/tasks.js"; -import usersRouter from "./admin/users.js"; -import workerPlatformRouter from "./admin/worker-platform.js"; -import { buildNotFoundPayload } from "../utils/http.js"; +import authRouter from './admin/auth.js' +import auditLogsRouter from './admin/audit-logs.js' +import cloudtentaclesRecordsRouter from './admin/cloudtentacles-records.js' +import dashboardRouter from './admin/dashboard.js' +import devMockRouter from './admin/dev-mock.js' +import filesRouter from './admin/files.js' +import kuaishouIndustryRouter from './admin/kuaishou-industry.js' +import ordersRouter from './admin/orders.js' +import platformConfigRouter from './admin/platform-config.js' +import { requireAdminSession } from './admin/session.js' +import tasksRouter from './admin/tasks.js' +import usersRouter from './admin/users.js' +import workerPlatformRouter from './admin/worker-platform.js' +import { buildNotFoundPayload } from '../utils/http.js' -const router = Router(); +const router = Router() -router.use(authRouter); -router.use(requireAdminSession); -router.use(dashboardRouter); -router.use(usersRouter); -router.use(auditLogsRouter); -router.use(platformConfigRouter); -router.use(kuaishouIndustryRouter); -router.use(ordersRouter); -router.use(tasksRouter); -router.use(cloudtentaclesRecordsRouter); -router.use(workerPlatformRouter); -router.use(devMockRouter); +router.use(authRouter) +router.use(requireAdminSession) +router.use(filesRouter) +router.use(dashboardRouter) +router.use(usersRouter) +router.use(auditLogsRouter) +router.use(platformConfigRouter) +router.use(kuaishouIndustryRouter) +router.use(ordersRouter) +router.use(tasksRouter) +router.use(cloudtentaclesRecordsRouter) +router.use(workerPlatformRouter) +router.use(devMockRouter) router.use((req, res) => { - res.status(404).json(buildNotFoundPayload(req)); -}); + res.status(404).json(buildNotFoundPayload(req)) +}) -export default router; +export default router diff --git a/apps/backend/src/routes/admin/files.ts b/apps/backend/src/routes/admin/files.ts new file mode 100644 index 00000000..bcdbd3e2 --- /dev/null +++ b/apps/backend/src/routes/admin/files.ts @@ -0,0 +1,37 @@ +import { Router } from 'express' + +import { uploadSingleFile } from '../file-upload.js' +import { uploadFileAsset } from '../../services/file-storage/file-storage-service.js' +import { createJsonHandler, getRequiredAdminSession, requireAdminRoles } from './session.js' + +const router = Router() + +router.post( + '/files/upload', + requireAdminRoles(['admin', 'operator', 'support']), + uploadSingleFile, + createJsonHandler( + (req) => { + const session = getRequiredAdminSession(req) + return uploadFileAsset({ + file: req.file, + scene: req.body?.scene, + uploaderType: 'admin', + uploaderId: String(session.userId || session.username || ''), + }) + }, + { + successMessage: '文件已上传', + errorMessage: '文件上传失败', + scope: '[admin/files/upload]', + audit: (_req, data) => ({ + action: 'file_uploaded', + targetType: 'file', + targetId: String((data as { file?: { objectKey?: string } })?.file?.objectKey || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + +export default router diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 66e4b802..0d4a5148 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -5,13 +5,19 @@ import { creditAdminWorkerWallet, createAdminMockWorkOrder, getAdminWorkerPlatformSummary, + listAdminWorkCategories, + listAdminWorkProductRules, listAdminWorkerLevels, listAdminWorkerUsers, listAdminWorkOrders, markAdminWorkOrderProblem, publishAdminWorkOrder, reviewAdminWorkerUser, + saveAdminWorkCategory, + saveAdminWorkProductRule, saveAdminWorkerLevel, + submitAdminWorkOrderMaterial, + syncAdminWorkerOrdersFromSource, } from '../../services/worker-platform/worker-platform-service.js' import { createJsonHandler, requireAdminRoles } from './session.js' @@ -47,7 +53,61 @@ router.post( action: 'worker_level_saved', targetType: 'worker_level', targetId: String((data as { level?: { levelKey?: string } })?.level?.levelKey || ''), - data: data && typeof data === 'object' ? data as Record : {}, + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + +router.get( + '/worker-platform/categories', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler(() => listAdminWorkCategories(), { + successMessage: 'ok', + errorMessage: '读取接单分类失败', + scope: '[admin/worker-platform/categories]', + }), +) + +router.post( + '/worker-platform/categories', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => saveAdminWorkCategory(req.body || {}), { + successMessage: '接单分类已保存', + errorMessage: '保存接单分类失败', + scope: '[admin/worker-platform/categories]', + audit: (_req, data) => ({ + action: 'work_category_saved', + targetType: 'work_category', + targetId: String( + (data as { category?: { categoryKey?: string } })?.category?.categoryKey || '', + ), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + +router.get( + '/worker-platform/product-rules', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler((req) => listAdminWorkProductRules(req.query), { + successMessage: 'ok', + errorMessage: '读取接单物品规则失败', + scope: '[admin/worker-platform/product-rules]', + }), +) + +router.post( + '/worker-platform/product-rules', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => saveAdminWorkProductRule(req.body || {}), { + successMessage: '接单物品规则已保存', + errorMessage: '保存接单物品规则失败', + scope: '[admin/worker-platform/product-rules]', + audit: (_req, data) => ({ + action: 'work_product_rule_saved', + targetType: 'work_product_rule', + targetId: String((data as { rule?: { ruleKey?: string } })?.rule?.ruleKey || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, }), }), ) @@ -65,33 +125,39 @@ router.get( router.post( '/worker-platform/workers/:workerId/review', requireAdminRoles(['admin', 'operator']), - createJsonHandler((req) => reviewAdminWorkerUser(String(req.params.workerId || ''), req.body || {}), { - successMessage: '接单员审核状态已更新', - errorMessage: '审核接单员失败', - scope: '[admin/worker-platform/workers/:workerId/review]', - audit: (req, data) => ({ - action: 'worker_reviewed', - targetType: 'worker', - targetId: String(req.params.workerId || ''), - data: data && typeof data === 'object' ? data as Record : {}, - }), - }), + createJsonHandler( + (req) => reviewAdminWorkerUser(String(req.params.workerId || ''), req.body || {}), + { + successMessage: '接单员审核状态已更新', + errorMessage: '审核接单员失败', + scope: '[admin/worker-platform/workers/:workerId/review]', + audit: (req, data) => ({ + action: 'worker_reviewed', + targetType: 'worker', + targetId: String(req.params.workerId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), ) router.post( '/worker-platform/workers/:workerId/wallet-credit', requireAdminRoles(['admin', 'operator']), - createJsonHandler((req) => creditAdminWorkerWallet(String(req.params.workerId || ''), req.body || {}), { - successMessage: '接单员余额已增加', - errorMessage: '人工充值失败', - scope: '[admin/worker-platform/workers/:workerId/wallet-credit]', - audit: (req, data) => ({ - action: 'worker_wallet_credited', - targetType: 'worker', - targetId: String(req.params.workerId || ''), - data: data && typeof data === 'object' ? data as Record : {}, - }), - }), + createJsonHandler( + (req) => creditAdminWorkerWallet(String(req.params.workerId || ''), req.body || {}), + { + successMessage: '接单员余额已增加', + errorMessage: '人工充值失败', + scope: '[admin/worker-platform/workers/:workerId/wallet-credit]', + audit: (req, data) => ({ + action: 'worker_wallet_credited', + targetType: 'worker', + targetId: String(req.params.workerId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), ) router.get( @@ -115,11 +181,46 @@ router.post( action: 'work_order_mock_created', targetType: 'work_order', targetId: String((data as { order?: { workOrderId?: number } })?.order?.workOrderId || ''), - data: data && typeof data === 'object' ? data as Record : {}, + data: data && typeof data === 'object' ? (data as Record) : {}, }), }), ) +router.post( + '/worker-platform/source-orders/sync', + requireAdminRoles(['admin', 'operator']), + createJsonHandler((req) => syncAdminWorkerOrdersFromSource(req.body || {}), { + successMessage: '源订单同步完成', + errorMessage: '同步源订单失败', + scope: '[admin/worker-platform/source-orders/sync]', + audit: (_req, data) => ({ + action: 'work_order_source_synced', + targetType: 'source_order', + targetId: '', + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), +) + +router.post( + '/worker-platform/orders/:workOrderId/material', + requireAdminRoles(['admin', 'operator', 'support']), + createJsonHandler( + (req) => submitAdminWorkOrderMaterial(String(req.params.workOrderId || ''), req.body || {}), + { + successMessage: '订单资料已保存', + errorMessage: '补充订单资料失败', + scope: '[admin/worker-platform/orders/:workOrderId/material]', + audit: (req, data) => ({ + action: 'work_order_material_saved', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.post( '/worker-platform/orders/:workOrderId/publish', requireAdminRoles(['admin', 'operator']), @@ -131,7 +232,7 @@ router.post( action: 'work_order_published', targetType: 'work_order', targetId: String(req.params.workOrderId || ''), - data: data && typeof data === 'object' ? data as Record : {}, + data: data && typeof data === 'object' ? (data as Record) : {}, }), }), ) @@ -139,17 +240,20 @@ router.post( router.post( '/worker-platform/orders/:workOrderId/problem', requireAdminRoles(['admin', 'operator', 'support']), - createJsonHandler((req) => markAdminWorkOrderProblem(String(req.params.workOrderId || ''), req.body || {}), { - successMessage: '订单已标记为问题单', - errorMessage: '标记问题单失败', - scope: '[admin/worker-platform/orders/:workOrderId/problem]', - audit: (req, data) => ({ - action: 'work_order_marked_problem', - targetType: 'work_order', - targetId: String(req.params.workOrderId || ''), - data: data && typeof data === 'object' ? data as Record : {}, - }), - }), + createJsonHandler( + (req) => markAdminWorkOrderProblem(String(req.params.workOrderId || ''), req.body || {}), + { + successMessage: '订单已标记为问题单', + errorMessage: '标记问题单失败', + scope: '[admin/worker-platform/orders/:workOrderId/problem]', + audit: (req, data) => ({ + action: 'work_order_marked_problem', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), ) router.post( @@ -163,7 +267,7 @@ router.post( action: 'work_order_accepted', targetType: 'work_order', targetId: String(req.params.workOrderId || ''), - data: data && typeof data === 'object' ? data as Record : {}, + data: data && typeof data === 'object' ? (data as Record) : {}, }), }), ) diff --git a/apps/backend/src/routes/file-upload.ts b/apps/backend/src/routes/file-upload.ts new file mode 100644 index 00000000..726bc354 --- /dev/null +++ b/apps/backend/src/routes/file-upload.ts @@ -0,0 +1,36 @@ +import type { NextFunction, Request, Response } from 'express' +import multer from 'multer' + +import { getMaxUploadSizeBytes } from '../services/file-storage/file-storage-service.js' +import { createHttpError, sendRouteError } from '../utils/http.js' + +const upload = multer({ + storage: multer.memoryStorage(), + limits: { + fileSize: getMaxUploadSizeBytes(), + files: 1, + }, +}) + +export function uploadSingleFile(req: Request, res: Response, next: NextFunction): void { + upload.single('file')(req, res, (error: unknown) => { + if (!error) { + next() + return + } + + const normalizedError = + error instanceof multer.MulterError && error.code === 'LIMIT_FILE_SIZE' + ? createHttpError('文件超过上传大小限制', { + statusCode: 400, + errorCode: 'upload_file_too_large', + cause: error, + }) + : createHttpError('文件上传失败', { + statusCode: 400, + errorCode: 'upload_file_invalid', + cause: error, + }) + sendRouteError(res, normalizedError, '文件上传失败', '[files/upload]') + }) +} diff --git a/apps/backend/src/routes/files.ts b/apps/backend/src/routes/files.ts new file mode 100644 index 00000000..8bdf44fb --- /dev/null +++ b/apps/backend/src/routes/files.ts @@ -0,0 +1,31 @@ +import { Router } from 'express' + +import { getStoredFileObject } from '../services/file-storage/file-storage-service.js' +import { buildNotFoundPayload, sendRouteError } from '../utils/http.js' + +const router = Router() + +router.get('/object', async (req, res) => { + try { + const object = await getStoredFileObject(req.query.key, req.query.variant) + res.setHeader('Content-Type', object.contentType || 'application/octet-stream') + res.setHeader('Cache-Control', 'private, max-age=300') + res.setHeader('X-Content-Type-Options', 'nosniff') + object.reader.on('error', (error) => { + if (!res.headersSent) { + sendRouteError(res, error, '读取文件失败', '[files/object]') + } else { + res.destroy(error) + } + }) + object.reader.pipe(res) + } catch (error) { + sendRouteError(res, error, '读取文件失败', '[files/object]') + } +}) + +router.use((req, res) => { + res.status(404).json(buildNotFoundPayload(req)) +}) + +export default router diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 4ec755df..3fbe28a7 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -1,6 +1,8 @@ import { Router } from 'express' import { createRateLimitMiddleware, getBodyFieldRateLimitKey } from '../middleware/rate-limit.js' +import { uploadSingleFile } from './file-upload.js' +import { uploadFileAsset } from '../services/file-storage/file-storage-service.js' import { getWorkerProfile, getWorkerSessionSummary, @@ -80,6 +82,28 @@ router.get( }), ) +router.post( + '/files/upload', + requireActiveWorker, + uploadSingleFile, + createRouteHandler( + (req) => { + const session = getRequiredWorkerSession(req) + return uploadFileAsset({ + file: req.file, + scene: req.body?.scene || 'worker-acceptance', + uploaderType: 'worker', + uploaderId: String(session.workerId), + }) + }, + { + successMessage: '文件已上传', + errorMessage: '文件上传失败', + scope: '[worker/files/upload]', + }, + ), +) + router.get( '/hall/orders', requireActiveWorker, @@ -93,11 +117,15 @@ router.get( router.post( '/hall/orders/:workOrderId/grab', requireActiveWorker, - createRouteHandler((req) => grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), { - successMessage: '抢单成功', - errorMessage: '抢单失败', - scope: '[worker/hall/orders/:workOrderId/grab]', - }), + createRouteHandler( + (req) => + grabWorkerHallOrder(String(req.params.workOrderId || ''), getRequiredWorkerSession(req)), + { + successMessage: '抢单成功', + errorMessage: '抢单失败', + scope: '[worker/hall/orders/:workOrderId/grab]', + }, + ), ) router.get( @@ -114,7 +142,12 @@ router.post( '/orders/:workOrderId/submit-acceptance', requireActiveWorker, createRouteHandler( - (req) => submitWorkerOrderAcceptance(String(req.params.workOrderId || ''), req.body || {}, getRequiredWorkerSession(req)), + (req) => + submitWorkerOrderAcceptance( + String(req.params.workOrderId || ''), + req.body || {}, + getRequiredWorkerSession(req), + ), { successMessage: '验收资料已提交', errorMessage: '提交验收资料失败', diff --git a/apps/backend/src/services/file-storage/file-storage-service.ts b/apps/backend/src/services/file-storage/file-storage-service.ts new file mode 100644 index 00000000..52621bd0 --- /dev/null +++ b/apps/backend/src/services/file-storage/file-storage-service.ts @@ -0,0 +1,309 @@ +import crypto from 'node:crypto' +import path from 'node:path' + +import sharp from 'sharp' + +import { runtimeConfig } from '../../config/runtime.js' +import { createFileAsset, type FileAssetRow } from '../../repositories/file-asset-repo.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import { getObjectStorage, normalizeObjectKey } from './object-storage.js' + +const IMAGE_VARIANT_THUMB = 'thumb' +const IMAGE_VARIANT_MEDIUM = 'medium' +const THUMB_MAX_SIDE = 480 +const MEDIUM_MAX_SIDE = 1280 + +const ALLOWED_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'application/pdf']) + +const IMAGE_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']) + +export type UploadedFileDto = { + objectKey: string + url: string + thumbnailUrl: string + mediumUrl: string + filename: string + contentType: string + size: number +} + +type UploadFileAssetInput = { + file: Express.Multer.File | null | undefined + scene: unknown + uploaderType: string + uploaderId: string +} + +export async function uploadFileAsset(input: UploadFileAssetInput) { + const file = input.file + if (!file?.buffer?.length) { + throw createHttpError('请选择要上传的文件', { + statusCode: 400, + errorCode: 'upload_file_required', + }) + } + + const maxUploadSize = getMaxUploadSizeBytes() + if (file.buffer.length > maxUploadSize) { + throw createHttpError(`文件不能超过 ${runtimeConfig.storage.maxUploadSizeMb}MB`, { + statusCode: 400, + errorCode: 'upload_file_too_large', + }) + } + + const contentType = normalizeContentType(file.mimetype, file.buffer) + if (!ALLOWED_CONTENT_TYPES.has(contentType)) { + throw createHttpError('文件不符合规则,仅支持 JPG、PNG、WebP 或 PDF', { + statusCode: 400, + errorCode: 'upload_file_type_invalid', + }) + } + + const scene = normalizeScene(input.scene) + const objectKey = createObjectKey(scene, file.originalname, contentType) + const storage = getObjectStorage() + await storage.putObject({ + key: objectKey, + content: file.buffer, + contentType, + metadata: { + 'original-filename': file.originalname, + }, + }) + + const variants = await generateImageVariants(objectKey, file.buffer, contentType) + let thumbnailObjectKey = '' + let mediumObjectKey = '' + + for (const variant of variants) { + try { + await storage.putObject({ + key: variant.key, + content: variant.content, + contentType: variant.contentType, + metadata: { + 'source-object': objectKey, + }, + }) + if (variant.name === IMAGE_VARIANT_THUMB) { + thumbnailObjectKey = variant.key + } + if (variant.name === IMAGE_VARIANT_MEDIUM) { + mediumObjectKey = variant.key + } + } catch { + // 缩略图失败不阻断原图上传;预览接口会回退到原图。 + } + } + + const now = nowIso() + const fileUrl = buildFileUrl(objectKey) + const thumbnailUrl = thumbnailObjectKey ? buildFileUrl(thumbnailObjectKey) : fileUrl + const mediumUrl = mediumObjectKey ? buildFileUrl(mediumObjectKey) : fileUrl + const asset = await createFileAsset({ + objectKey, + scene, + originalFilename: file.originalname, + contentType, + sizeBytes: file.buffer.length, + thumbnailObjectKey, + mediumObjectKey, + url: fileUrl, + thumbnailUrl, + mediumUrl, + uploaderType: String(input.uploaderType || '').trim(), + uploaderId: String(input.uploaderId || '').trim(), + metadataJson: JSON.stringify({ + originalMimeType: file.mimetype, + originalSize: file.size, + }), + now, + }) + + return { + file: mapUploadedFile(asset, { + objectKey, + fileUrl, + thumbnailUrl, + mediumUrl, + filename: file.originalname, + contentType, + size: file.buffer.length, + }), + } +} + +export async function getStoredFileObject(key: unknown, variant: unknown = '') { + const objectKey = normalizeObjectKey(key) + const variantName = normalizeVariant(variant) + const candidates = variantName ? imageVariantFallbackKeys(objectKey, variantName) : [objectKey] + let lastError: unknown = null + + for (const candidate of candidates) { + try { + return await getObjectStorage().getObject(candidate) + } catch (error) { + lastError = error + } + } + + throw createHttpError('文件不存在或暂不可访问', { + statusCode: 404, + errorCode: 'file_not_found', + cause: lastError, + }) +} + +export function getMaxUploadSizeBytes() { + return Math.max(1, Number(runtimeConfig.storage.maxUploadSizeMb || 10)) * 1024 * 1024 +} + +export function buildFileUrl(objectKey: string) { + return `/api/v1/files/object?key=${encodeURIComponent(objectKey)}` +} + +export function imageVariantKey(objectKey: string, variant: string) { + const ext = path.extname(objectKey) + const base = ext ? objectKey.slice(0, -ext.length) : objectKey + return `${base}.${variant}.jpg` +} + +function mapUploadedFile( + asset: FileAssetRow | null, + fallback: { + objectKey: string + fileUrl: string + thumbnailUrl: string + mediumUrl: string + filename: string + contentType: string + size: number + }, +): UploadedFileDto { + return { + objectKey: asset?.object_key || fallback.objectKey, + url: asset?.url || fallback.fileUrl, + thumbnailUrl: asset?.thumbnail_url || fallback.thumbnailUrl, + mediumUrl: asset?.medium_url || fallback.mediumUrl, + filename: asset?.original_filename || fallback.filename, + contentType: asset?.content_type || fallback.contentType, + size: Number(asset?.size_bytes || fallback.size || 0), + } +} + +function normalizeContentType(mimeType: unknown, data: Buffer) { + const detected = detectContentType(data) + if (detected) { + return detected + } + + return ( + String(mimeType || '') + .trim() + .toLowerCase() + .split(';')[0] || 'application/octet-stream' + ) +} + +function detectContentType(data: Buffer) { + if (data.length >= 4 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) { + return 'image/jpeg' + } + if ( + data.length >= 8 && + data.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) + ) { + return 'image/png' + } + if ( + data.length >= 12 && + data.subarray(0, 4).toString('ascii') === 'RIFF' && + data.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp' + } + if (data.length >= 4 && data.subarray(0, 4).toString('ascii') === '%PDF') { + return 'application/pdf' + } + return '' +} + +function normalizeScene(value: unknown) { + const scene = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') + return scene || 'misc' +} + +function createObjectKey(scene: string, filename: string, contentType: string) { + const now = new Date() + const token = crypto.randomBytes(12).toString('hex') + const ext = extensionForContentType(contentType) || path.extname(filename).toLowerCase() || '.bin' + const month = String(now.getMonth() + 1).padStart(2, '0') + const day = String(now.getDate()).padStart(2, '0') + return `${scene}/${now.getFullYear()}/${month}/${day}/${token}${ext}` +} + +function extensionForContentType(contentType: string) { + if (contentType === 'image/jpeg') return '.jpg' + if (contentType === 'image/png') return '.png' + if (contentType === 'image/webp') return '.webp' + if (contentType === 'application/pdf') return '.pdf' + return '' +} + +async function generateImageVariants(objectKey: string, data: Buffer, contentType: string) { + if (!IMAGE_CONTENT_TYPES.has(contentType)) { + return [] + } + + const configs = [ + { name: IMAGE_VARIANT_THUMB, maxSide: THUMB_MAX_SIDE, quality: 76 }, + { name: IMAGE_VARIANT_MEDIUM, maxSide: MEDIUM_MAX_SIDE, quality: 82 }, + ] + const variants: Array<{ name: string; key: string; content: Buffer; contentType: string }> = [] + + for (const config of configs) { + try { + const content = await sharp(data) + .rotate() + .resize({ + width: config.maxSide, + height: config.maxSide, + fit: 'inside', + withoutEnlargement: true, + }) + .flatten({ background: '#ffffff' }) + .jpeg({ quality: config.quality, mozjpeg: true }) + .toBuffer() + variants.push({ + name: config.name, + key: imageVariantKey(objectKey, config.name), + content, + contentType: 'image/jpeg', + }) + } catch { + return variants + } + } + + return variants +} + +function imageVariantFallbackKeys(objectKey: string, variant: string) { + const keys = [imageVariantKey(objectKey, variant), objectKey] + return [...new Set(keys)] +} + +function normalizeVariant(value: unknown) { + const variant = String(value || '') + .trim() + .toLowerCase() + if (variant === IMAGE_VARIANT_THUMB || variant === IMAGE_VARIANT_MEDIUM) { + return variant + } + return '' +} diff --git a/apps/backend/src/services/file-storage/object-storage.ts b/apps/backend/src/services/file-storage/object-storage.ts new file mode 100644 index 00000000..6b78062f --- /dev/null +++ b/apps/backend/src/services/file-storage/object-storage.ts @@ -0,0 +1,138 @@ +import { Readable } from 'node:stream' + +import * as Minio from 'minio' + +import { runtimeConfig } from '../../config/runtime.js' +import { createHttpError } from '../../utils/http.js' + +type ObjectStorageConfig = { + endpoint: string + bucket: string + accessKeyId: string + secretAccessKey: string + region: string +} + +type StoredObject = { + reader: Readable + contentType: string + size: number +} + +export class ObjectStorage { + private readonly client: Minio.Client + private readonly bucket: string + private readonly region: string + private bucketReady = false + + constructor(config: ObjectStorageConfig) { + const endpoint = normalizeStorageEndpoint(config.endpoint) + this.bucket = String(config.bucket || '').trim() + this.region = String(config.region || 'us-east-1').trim() || 'us-east-1' + this.client = new Minio.Client({ + endPoint: endpoint.endPoint, + port: endpoint.port, + useSSL: endpoint.useSSL, + accessKey: String(config.accessKeyId || '').trim(), + secretKey: String(config.secretAccessKey || '').trim(), + }) + } + + async putObject(input: { + key: string + content: Buffer + contentType: string + metadata?: Record + }): Promise { + await this.ensureBucketReady() + await this.client.putObject(this.bucket, input.key, input.content, input.content.length, { + 'Content-Type': input.contentType, + ...sanitizeObjectMetadata(input.metadata), + }) + } + + async getObject(key: string): Promise { + await this.ensureBucketReady() + const objectKey = normalizeObjectKey(key) + const stat = await this.client.statObject(this.bucket, objectKey) + const reader = await this.client.getObject(this.bucket, objectKey) + const metadata = stat.metaData || {} + const contentType = String( + metadata['content-type'] || + metadata['Content-Type'] || + metadata.contentType || + 'application/octet-stream', + ) + + return { + reader, + contentType, + size: Number(stat.size || 0), + } + } + + private async ensureBucketReady(): Promise { + if (this.bucketReady) { + return + } + + const exists = await this.client.bucketExists(this.bucket) + if (!exists) { + await this.client.makeBucket(this.bucket, this.region) + } + this.bucketReady = true + } +} + +let storageInstance: ObjectStorage | null = null + +export function getObjectStorage(): ObjectStorage { + if (!storageInstance) { + storageInstance = new ObjectStorage({ + endpoint: runtimeConfig.storage.endpoint, + bucket: runtimeConfig.storage.bucket, + accessKeyId: runtimeConfig.storage.accessKeyId, + secretAccessKey: runtimeConfig.storage.secretAccessKey, + region: runtimeConfig.storage.region, + }) + } + + return storageInstance +} + +export function normalizeStorageEndpoint(rawEndpoint: unknown) { + const endpoint = String(rawEndpoint || '').trim() + const parsed = new URL(/^https?:\/\//i.test(endpoint) ? endpoint : `http://${endpoint}`) + const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80 + + return { + endPoint: parsed.hostname, + port, + useSSL: parsed.protocol === 'https:', + } +} + +export function normalizeObjectKey(rawKey: unknown): string { + const key = String(rawKey || '') + .trim() + .replace(/^\/+/, '') + if (!key || key.includes('..') || key.includes('\\')) { + throw createHttpError('文件 key 不正确', { + statusCode: 400, + errorCode: 'file_key_invalid', + }) + } + return key +} + +function sanitizeObjectMetadata(metadata: Record | undefined) { + if (!metadata) { + return {} + } + + return Object.fromEntries( + Object.entries(metadata) + .map(([key, value]) => [String(key || '').trim(), encodeURIComponent(String(value || ''))]) + .filter(([key]) => Boolean(key)), + ) +} diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index 44e96a42..4ec6ce76 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -10,6 +10,7 @@ import { resolveOrderFulfillmentReadiness, syncOrderFulfillmentAttachments, } from '../fulfillment/order-fulfillment-readiness-service.js' +import { syncWorkerOrdersForSourceOrder } from '../worker-platform/worker-platform-service.js' import { nowIso } from '../../utils/time.js' import { logIntegration } from '../../utils/logger.js' import { createHttpError } from '../../utils/http.js' @@ -61,13 +62,15 @@ type UpsertOrderIgnoredResult = { tasks: [] } -type UpsertOrderResult = { - ignored?: false - ignoreReason?: string - order: OrderRow - orderItems: OrderItemRow[] - tasks: TaskRow[] -} | UpsertOrderIgnoredResult +type UpsertOrderResult = + | { + ignored?: false + ignoreReason?: string + order: OrderRow + orderItems: OrderItemRow[] + tasks: TaskRow[] + } + | UpsertOrderIgnoredResult type OrderStateInput = { order_status?: string @@ -108,13 +111,17 @@ export async function upsertOrderFromSource( }) const resolvedItems = await Promise.all( - event.items.map((item) => resolveOrderItemForFulfillment({ - provider: event.provider, - platform: event.platform, - item, - })), + event.items.map((item) => + resolveOrderItemForFulfillment({ + provider: event.provider, + platform: event.platform, + item, + }), + ), + ) + const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter( + (item) => item.isConfigured, ) - const configuredItems = (resolvedItems as FulfillmentOrderItem[]).filter((item) => item.isConfigured) if (configuredItems.length === 0) { logIntegration('[order-service]', `${sourceLabel} 订单已忽略:未命中任何已配置履约商品`, { @@ -156,16 +163,16 @@ export async function upsertOrderFromSource( const order = existing ? await updateOrder(existing.id, { - ...basePayload, - ...mergedPayload, - updatedAt: now, - }) + ...basePayload, + ...mergedPayload, + updatedAt: now, + }) : await createOrder({ - ...basePayload, - ...mergedPayload, - createdAt: now, - updatedAt: now, - }) + ...basePayload, + ...mergedPayload, + createdAt: now, + updatedAt: now, + }) if (!order) { throw createHttpError('订单写入失败', { @@ -189,14 +196,19 @@ export async function upsertOrderFromSource( const readiness = await resolveOrderFulfillmentReadiness(order) if (!readiness.allowed) { - logIntegration('[order-service]', `${sourceLabel} 订单暂停履约:电子凭证发码回调未确认`, { - orderId: order.id, - provider: order.provider, - platform: order.platform, - platformOrderId: order.platform_order_id, - voucherCount: readiness.voucherCount, - reason: readiness.reason, - }, { level: 'warn' }) + logIntegration( + '[order-service]', + `${sourceLabel} 订单暂停履约:电子凭证发码回调未确认`, + { + orderId: order.id, + provider: order.provider, + platform: order.platform, + platformOrderId: order.platform_order_id, + voucherCount: readiness.voucherCount, + reason: readiness.reason, + }, + { level: 'warn' }, + ) return { ignoreReason: readiness.blockReason || 'fulfillment_not_ready', @@ -211,6 +223,10 @@ export async function upsertOrderFromSource( source: `${sourceLabel}_order_upsert`, now, }) + await syncWorkerOrdersForSourceOrder(order, orderItems, { + source: `${sourceLabel}_order_upsert`, + autoOnly: true, + }) logIntegration('[order-service]', `${sourceLabel} 订单 upsert 完成`, { orderId: order.id, @@ -249,17 +265,23 @@ export function mergeSourceOrderState( existing: OrderStateInput | null | undefined, incoming: OrderStateInput | null | undefined, ): OrderStateMergeResult { - const orderStatus = chooseHigherPriorityStatus( - normalizeStateValue(existing?.order_status ?? existing?.orderStatus), - normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status), - ORDER_STATUS_PRIORITY, - ) || normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status) || 'created' + const orderStatus = + chooseHigherPriorityStatus( + normalizeStateValue(existing?.order_status ?? existing?.orderStatus), + normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status), + ORDER_STATUS_PRIORITY, + ) || + normalizeStateValue(incoming?.orderStatus ?? incoming?.order_status) || + 'created' - const payStatus = chooseHigherPriorityStatus( - normalizeStateValue(existing?.pay_status ?? existing?.payStatus), - normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status), - PAY_STATUS_PRIORITY, - ) || normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status) || 'unpaid' + const payStatus = + chooseHigherPriorityStatus( + normalizeStateValue(existing?.pay_status ?? existing?.payStatus), + normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status), + PAY_STATUS_PRIORITY, + ) || + normalizeStateValue(incoming?.payStatus ?? incoming?.pay_status) || + 'unpaid' return { orderStatus, @@ -296,7 +318,9 @@ function resolveStatusPriority(value: string, priorityMap: StatusPriorityMap): n } function normalizeStateValue(value: unknown): string { - return String(value || '').trim().toLowerCase() + return String(value || '') + .trim() + .toLowerCase() } function resolveMergedPaidAt({ diff --git a/apps/backend/src/services/platforms/ninetyone/order-service.ts b/apps/backend/src/services/platforms/ninetyone/order-service.ts index 713cc199..7fc45335 100644 --- a/apps/backend/src/services/platforms/ninetyone/order-service.ts +++ b/apps/backend/src/services/platforms/ninetyone/order-service.ts @@ -1,14 +1,20 @@ import { query } from '../../../db/client.js' -import { createOrder, findOrderByPlatformOrderId, getOrderById, updateOrder } from '../../../repositories/order-repo.js' -import { listOrderItemsByOrderId, replaceOrderItems } from '../../../repositories/order-item-repo.js' +import { + createOrder, + findOrderByPlatformOrderId, + getOrderById, + updateOrder, +} from '../../../repositories/order-repo.js' +import { + listOrderItemsByOrderId, + replaceOrderItems, +} from '../../../repositories/order-item-repo.js' import { listTasksByOrderId } from '../../../repositories/task-repo.js' import { upsertOrderFromSource } from '../../order/order-service.js' +import { syncWorkerOrdersForSourceOrder } from '../../worker-platform/worker-platform-service.js' import { bindKuaishouIndustryVouchersToOrderTasks } from '../kuaishou-industry/voucher-binding-service.js' -import { asJsonObject, type JsonObject } from '../../../types/json.js' -import { - OPEN_91_PLATFORM, - OPEN_91_PROVIDER, -} from '../../open-91/config.js' +import { asJsonObject, type JsonObject } from '../../../types/json.js' +import { OPEN_91_PLATFORM, OPEN_91_PROVIDER } from '../../open-91/config.js' import { normalizeOpen91CreatePayload } from '../../open-91/payload.js' import { buildOpen91OutTradeNo } from '../../open-91/response.js' import { createHttpError } from '../../../utils/http.js' @@ -179,6 +185,10 @@ export async function upsertOpen91PendingOrder(payload: JsonObject = {}, config: source: 'open91_pending_order', now, }) + await syncWorkerOrdersForSourceOrder(order, orderItems, { + source: 'open91_pending_order', + autoOnly: true, + }) return { order, @@ -243,14 +253,15 @@ export async function failOpen91Order(orderId: string | number, reason = '') { } } -export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pending_config' } = {}) { +export async function listOpen91Orders({ + page = 1, + pageSize = 20, + status = 'pending_config', +} = {}) { const normalizedPage = Math.max(1, Number(page) || 1) const normalizedPageSize = Math.min(100, Math.max(1, Number(pageSize) || 20)) const normalizedStatus = String(status || 'pending_config').trim() - const filters = [ - 'o.provider = $1', - 'o.platform = $2', - ] + const filters = ['o.provider = $1', 'o.platform = $2'] const params: Array = [OPEN_91_PROVIDER, OPEN_91_PLATFORM] if (normalizedStatus && normalizedStatus !== 'all') { @@ -259,7 +270,10 @@ export async function listOpen91Orders({ page = 1, pageSize = 20, status = 'pend } const whereClause = `WHERE ${filters.join(' AND ')}` - const totalResult = await query(`SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`, params) + const totalResult = await query( + `SELECT COUNT(*)::int AS total FROM orders o ${whereClause}`, + params, + ) params.push(normalizedPageSize) params.push((normalizedPage - 1) * normalizedPageSize) @@ -320,7 +334,9 @@ function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[] const normalizedItems = Array.isArray(items) ? items : [] const eventItems = normalizedItems.map((item: OrderItemRow) => { const snapshot = parseJsonObject(item.item_snapshot_json) - const productNo = String(snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '').trim() + const productNo = String( + snapshot.productNo || snapshot.externalSkuCode || item.sku_code || '', + ).trim() return { itemId: productNo, @@ -339,9 +355,7 @@ function buildOpen91SourceEventFromOrder(order: OrderRow, items: OrderItemRow[] provider: order.provider, platform: order.platform, shopId: OPEN_91_PROVIDER, - shopName: String(order.shop_id || '').trim() === OPEN_91_PROVIDER - ? order.shop_name - : '91卡券', + shopName: String(order.shop_id || '').trim() === OPEN_91_PROVIDER ? order.shop_name : '91卡券', platformOrderId: order.platform_order_id, orderStatus: 'paid', payStatus: 'paid', @@ -374,7 +388,9 @@ function mapOpen91AdminOrderRow(row: JsonObject) { payStatus: String(row.pay_status || '').trim(), shopId: String(row.shop_id || '').trim(), shopName: String(row.shop_name || '').trim(), - productNo: String(firstSnapshot.productNo || firstSnapshot.externalSkuCode || firstItem.skuCode || '').trim(), + productNo: String( + firstSnapshot.productNo || firstSnapshot.externalSkuCode || firstItem.skuCode || '', + ).trim(), productName: String(firstSnapshot.externalSkuName || firstItem.skuName || '').trim(), buyNum: Number(firstItem.quantity || 0), taskCount: Number(row.task_count || 0), diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.ts b/apps/backend/src/services/worker-platform/worker-platform-service.ts index 9b7ebf8c..b146c5f9 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.ts @@ -2,6 +2,8 @@ import crypto from 'node:crypto' import { runtimeConfig } from '../../config/runtime.js' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' +import { listOrderItemsByOrderId } from '../../repositories/order-item-repo.js' +import { findLatestOrderByAnyPlatformOrderId, getOrderById } from '../../repositories/order-repo.js' import { acceptWorkOrderAndSettle, addWorkerWalletCredit, @@ -12,32 +14,35 @@ import { findPendingMaterialWorkOrderByPlatformOrderId, getWorkCategoryByKey, getWorkOrderById, + getWorkOrderByOrderItemId, getWorkerLevelById, getWorkerLevelByKey, getWorkerUserById, getWorkerUserByUsername, + listAllWorkCategories, grabWorkOrder, listWorkCategories, listWorkOrders, + listWorkProductRules, listWorkerLevels, listWorkerUsers, updateWorkOrder, updateWorkerUser, upsertWorkCategory, + upsertWorkProductRule, upsertWorkerLevel, + type WorkCategoryRow, type WorkOrderRow, + type WorkProductRuleRow, type WorkerLevelRow, type WorkerUserRow, } from '../../repositories/worker-platform-repo.js' import type { JsonObject } from '../../types/json.js' +import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { createHttpError } from '../../utils/http.js' import { randomId } from '../../utils/random.js' import { nowIso, addHours } from '../../utils/time.js' -import { - normalizePage, - normalizePageSize, - safeParseJson, -} from '../admin/admin-query-utils.js' +import { normalizePage, normalizePageSize, safeParseJson } from '../admin/admin-query-utils.js' export type WorkerSession = { sessionId: string @@ -340,7 +345,9 @@ export async function submitWorkerOrderAcceptance( errorCode: 'work_order_owner_required', }) } - if (![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never)) { + if ( + ![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PROBLEM].includes(workOrder.status as never) + ) { throw createHttpError('当前状态不能提交验收', { statusCode: 409, errorCode: 'work_order_submit_status_invalid', @@ -348,9 +355,21 @@ export async function submitWorkerOrderAcceptance( } const now = nowIso() + const files = normalizeUploadedFiles(payload.files) + const imageUrls = [ + ...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean), + ...normalizeStringArray(payload.imageUrls), + ] + if (imageUrls.length === 0) { + throw createHttpError('请上传验收图片', { + statusCode: 400, + errorCode: 'work_order_acceptance_image_required', + }) + } const acceptance = { note: String(payload.note || '').trim(), - imageUrls: normalizeStringArray(payload.imageUrls), + files, + imageUrls: [...new Set(imageUrls)], submittedAt: now, } const updated = await updateWorkOrder(workOrder.id, { @@ -369,7 +388,12 @@ export async function submitWorkerOrderAcceptance( payloadJson: JSON.stringify(acceptance), now, }) - return { order: mapWorkOrderForWorker(updated || workOrder, resolveWorkerPermissions(await getRequiredWorker(session.workerId))) } + return { + order: mapWorkOrderForWorker( + updated || workOrder, + resolveWorkerPermissions(await getRequiredWorker(session.workerId)), + ), + } } export async function collectLookupWorkOrder(payload: JsonObject = {}) { @@ -411,7 +435,9 @@ export async function collectSubmitWorkOrder(payload: JsonObject = {}) { const fields = resolveRequirementFields(workOrder) const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload) - const complete = fields.every((field) => !field.required || String(submittedFields[field.key] || '').trim()) + const complete = fields.every( + (field) => !field.required || String(submittedFields[field.key] || '').trim(), + ) const now = nowIso() const material = { ...safeParseJson(workOrder.material_json), @@ -448,12 +474,37 @@ export async function listAdminWorkerLevels() { return { items: (await listWorkerLevels()).map(mapWorkerLevel) } } +export async function listAdminWorkCategories() { + await ensureWorkerPlatformDefaults() + return { items: (await listAllWorkCategories()).map(mapWorkCategory) } +} + +export async function saveAdminWorkCategory(payload: JsonObject = {}) { + const now = nowIso() + const categoryKey = normalizeSlugKey( + payload.categoryKey || payload.category_key || payload.name, + DEFAULT_CATEGORY_KEY, + ) + const name = String(payload.name || '').trim() || '默认分类' + const category = await upsertWorkCategory({ + categoryKey, + name, + sortOrder: normalizeInteger(payload.sortOrder, 100), + status: normalizeEnabledStatus(payload.status), + now, + }) + return { category: mapWorkCategory(category) } +} + export async function saveAdminWorkerLevel(payload: JsonObject = {}) { const now = nowIso() const levelKey = String(payload.levelKey || payload.level_key || '').trim() || DEFAULT_LEVEL_KEY const name = String(payload.name || '').trim() || '普通接单员' const permissions = { - depositFreeAmount: normalizeAmountFen(payload.depositFreeAmount ?? payload.depositFreeAmountYuan, 0), + depositFreeAmount: normalizeAmountFen( + payload.depositFreeAmount ?? payload.depositFreeAmountYuan, + 0, + ), maxActiveOrders: normalizePositiveInteger(payload.maxActiveOrders, 3), } const level = await upsertWorkerLevel({ @@ -467,6 +518,78 @@ export async function saveAdminWorkerLevel(payload: JsonObject = {}) { return { level: mapWorkerLevel(level) } } +export async function listAdminWorkProductRules(query: JsonObject = {}) { + await ensureWorkerPlatformDefaults() + const enabledValue = String(query.enabled ?? '').trim() + const enabled = enabledValue + ? ['true', '1', 'enabled', 'active'].includes(enabledValue.toLowerCase()) + : null + const items = await listWorkProductRules({ + enabled, + keyword: String(query.keyword || '').trim(), + }) + return { items: items.map(mapWorkProductRule) } +} + +export async function saveAdminWorkProductRule(payload: JsonObject = {}) { + const defaults = await ensureWorkerPlatformDefaults() + const productName = String( + payload.productName || payload.product_name || payload.skuName || '', + ).trim() + const skuCode = String(payload.skuCode || payload.sku_code || '').trim() + if (!productName && !skuCode) { + throw createHttpError('请填写商品名或 SKU', { + statusCode: 400, + errorCode: 'work_product_rule_target_required', + }) + } + + const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0) + if (rewardAmount <= 0) { + throw createHttpError('接单金额必须大于 0', { + statusCode: 400, + errorCode: 'work_product_rule_reward_invalid', + }) + } + + const depositThresholdAmount = normalizeAmountFen( + payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, + DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ) + const requiredDepositAmount = + payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined + ? rewardAmount > depositThresholdAmount + ? rewardAmount + : 0 + : normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0) + const fields = normalizeRequirementFieldsFromPayload(payload) + const rule = await upsertWorkProductRule({ + ruleKey: normalizeSlugKey( + payload.ruleKey || payload.rule_key || skuCode || productName, + `rule-${Date.now()}`, + ), + provider: String(payload.provider || '').trim(), + platform: String(payload.platform || '').trim(), + shopId: String(payload.shopId || payload.shop_id || '').trim(), + skuCode, + productName, + matchType: normalizeMatchType(payload.matchType || payload.match_type), + categoryId: + normalizeOptionalId(payload.categoryId || payload.category_id) || + defaults.category?.id || + null, + enabled: normalizeBoolean(payload.enabled, true), + autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false), + rewardAmount, + requiredDepositAmount, + depositThresholdAmount, + requirementJson: JSON.stringify({ fields }), + sortOrder: normalizeInteger(payload.sortOrder, 100), + now: nowIso(), + }) + return { rule: mapWorkProductRule(rule) } +} + export async function listAdminWorkerUsers(query: JsonObject = {}) { const page = normalizePage(query.page) const pageSize = normalizePageSize(query.pageSize) @@ -485,7 +608,11 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) { export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) { const worker = await getRequiredWorker(workerId) const status = normalizeReviewStatus(payload.status) - const levelId = normalizeOptionalId(payload.levelId) || worker.level_id || (await ensureWorkerPlatformDefaults()).level?.id || null + const levelId = + normalizeOptionalId(payload.levelId) || + worker.level_id || + (await ensureWorkerPlatformDefaults()).level?.id || + null const now = nowIso() const updated = await updateWorkerUser(worker.id, { status, @@ -532,6 +659,66 @@ export async function listAdminWorkOrders(query: JsonObject = {}) { } } +export async function submitAdminWorkOrderMaterial( + workOrderId: number | string, + payload: JsonObject = {}, +) { + const workOrder = await getRequiredWorkOrder(workOrderId) + if ( + ![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED].includes( + workOrder.status as never, + ) + ) { + throw createHttpError('当前状态不能补充资料', { + statusCode: 409, + errorCode: 'work_order_material_status_invalid', + }) + } + + const fields = resolveRequirementFields(workOrder) + const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload) + const complete = fields.every( + (field) => !field.required || String(submittedFields[field.key] || '').trim(), + ) + const now = nowIso() + const material = { + ...safeParseJson(workOrder.material_json), + collect: { + fields: submittedFields, + submittedAt: now, + complete, + source: 'admin', + }, + } + const nextStatus = complete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL + const updated = await updateWorkOrder(workOrder.id, { + status: nextStatus, + material_json: material, + updated_at: now, + }) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'admin', + actorId: '', + eventType: 'material_completed', + fromStatus: workOrder.status, + toStatus: nextStatus, + payloadJson: JSON.stringify({ fields: submittedFields, complete }), + now, + }) + return { order: mapWorkOrderAdmin(updated || workOrder), complete } +} + +export async function syncAdminWorkerOrdersFromSource(payload: JsonObject = {}) { + const order = await resolveSourceOrder(payload) + const orderItems = await listOrderItemsByOrderId(order.id) + const result = await syncWorkerOrdersForSourceOrder(order, orderItems, { + source: 'admin_manual_sync', + autoOnly: false, + }) + return result +} + export async function createAdminMockWorkOrder(payload: JsonObject = {}) { const defaults = await ensureWorkerPlatformDefaults() const now = nowIso() @@ -540,12 +727,17 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) { payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, ) - const requiredDepositAmount = payload.requiredDepositAmount === undefined - ? (rewardAmount > depositThresholdAmount ? rewardAmount : 0) - : normalizeAmountFen(payload.requiredDepositAmount, 0) + const requiredDepositAmount = + payload.requiredDepositAmount === undefined + ? rewardAmount > depositThresholdAmount + ? rewardAmount + : 0 + : normalizeAmountFen(payload.requiredDepositAmount, 0) const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true' const fields = normalizeRequirementFields(payload.fields) - const platformOrderId = String(payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`).trim() + const platformOrderId = String( + payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`, + ).trim() const productName = String(payload.productName || '指挥官秘钥1个').trim() const material = materialComplete ? { @@ -617,9 +809,16 @@ export async function publishAdminWorkOrder(workOrderId: number | string) { return { order: mapWorkOrderAdmin(updated || workOrder) } } -export async function markAdminWorkOrderProblem(workOrderId: number | string, payload: JsonObject = {}) { +export async function markAdminWorkOrderProblem( + workOrderId: number | string, + payload: JsonObject = {}, +) { const workOrder = await getRequiredWorkOrder(workOrderId) - if (![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes(workOrder.status as never)) { + if ( + ![WORK_ORDER_STATUS.IN_PROGRESS, WORK_ORDER_STATUS.PENDING_ACCEPTANCE].includes( + workOrder.status as never, + ) + ) { throw createHttpError('当前状态不能标记问题单', { statusCode: 409, errorCode: 'work_order_problem_status_invalid', @@ -679,6 +878,96 @@ export async function getAdminWorkerPlatformSummary() { } } +export async function syncWorkerOrdersForSourceOrder( + order: OrderRow, + orderItems: OrderItemRow[], + options: { source?: string; autoOnly?: boolean } = {}, +) { + const rules = await listWorkProductRules({ enabled: true }) + const created: WorkOrderRow[] = [] + const skipped: Array<{ orderItemId: number; reason: string }> = [] + + for (const item of orderItems) { + const rule = resolveMatchingProductRule(order, item, rules) + if (!rule) { + skipped.push({ orderItemId: Number(item.id), reason: 'rule_not_matched' }) + continue + } + if (options.autoOnly && !rule.auto_create) { + skipped.push({ orderItemId: Number(item.id), reason: 'auto_create_disabled' }) + continue + } + if (await getWorkOrderByOrderItemId(item.id)) { + skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' }) + continue + } + + const fields = normalizeRequirementFields(safeParseJson(rule.requirement_json).fields) + const rewardAmount = Number(rule.reward_amount || 0) + const depositThresholdAmount = Number( + rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ) + const requiredDepositAmount = + Number(rule.required_deposit_amount || 0) || + (rewardAmount > depositThresholdAmount ? rewardAmount : 0) + const now = nowIso() + const materialComplete = fields.every((field) => !field.required) + const workOrder = await createWorkOrder({ + workOrderNo: randomId('WO'), + orderId: Number(order.id), + orderItemId: Number(item.id), + taskId: null, + platformOrderId: order.platform_order_id, + productName: item.sku_name || item.sku_code, + categoryId: rule.category_id, + status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL, + rewardAmount, + requiredDepositAmount, + depositThresholdAmount, + materialJson: JSON.stringify({ + source: { + orderId: Number(order.id), + orderItemId: Number(item.id), + provider: order.provider, + platform: order.platform, + shopId: order.shop_id, + skuCode: item.sku_code, + skuName: item.sku_name, + quantity: Number(item.quantity || 1), + syncSource: options.source || 'source_order', + }, + }), + requirementJson: JSON.stringify({ fields }), + now, + }) + + if (workOrder) { + created.push(workOrder) + await createWorkOrderEvent({ + workOrderId: workOrder.id, + actorType: 'system', + actorId: options.source || 'source_order', + eventType: 'source_synced', + fromStatus: '', + toStatus: workOrder.status, + payloadJson: JSON.stringify({ + ruleKey: rule.rule_key, + orderId: order.id, + orderItemId: item.id, + }), + now, + }) + } + } + + return { + created: created.map(mapWorkOrderAdmin), + skipped, + createdCount: created.length, + skippedCount: skipped.length, + } +} + function createWorkerSession(worker: WorkerUserRow) { const issuedAt = nowIso() const expiresAt = addHours(issuedAt, Number(runtimeConfig.admin?.sessionTtlHours || 12)) @@ -759,6 +1048,37 @@ async function getRequiredWorkOrder(workOrderId: number | string): Promise { + const orderId = normalizeOptionalId(payload.orderId || payload.sourceOrderId) + if (orderId) { + const order = await getOrderById(orderId) + if (order) { + return order + } + throw createHttpError('源订单不存在', { + statusCode: 404, + errorCode: 'source_order_not_found', + }) + } + + const platformOrderId = String(payload.platformOrderId || payload.orderNo || '').trim() + if (!platformOrderId) { + throw createHttpError('请填写源订单号', { + statusCode: 400, + errorCode: 'source_order_no_required', + }) + } + + const order = await findLatestOrderByAnyPlatformOrderId(platformOrderId) + if (!order) { + throw createHttpError('未找到源订单,请确认订单号是否正确', { + statusCode: 404, + errorCode: 'source_order_not_found', + }) + } + return order +} + function resolveWorkerPermissions(worker: WorkerUserRow) { const permission = safeParseJson(worker.level_permission_json) return { @@ -767,8 +1087,14 @@ function resolveWorkerPermissions(worker: WorkerUserRow) { } } -function resolveFreezeDepositAmount(workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }) { - return Math.max(0, Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0)) +function resolveFreezeDepositAmount( + workOrder: WorkOrderRow, + permissions: { depositFreeAmount: number }, +) { + return Math.max( + 0, + Number(workOrder.required_deposit_amount || 0) - Number(permissions.depositFreeAmount || 0), + ) } function mapWorkerLevel(level: WorkerLevelRow | null | undefined) { @@ -787,6 +1113,49 @@ function mapWorkerLevel(level: WorkerLevelRow | null | undefined) { } } +function mapWorkCategory(category: WorkCategoryRow | null | undefined) { + if (!category) return null + return { + categoryId: Number(category.id), + categoryKey: category.category_key, + name: category.name, + sortOrder: Number(category.sort_order || 100), + status: category.status, + createdAt: category.created_at, + updatedAt: category.updated_at, + } +} + +function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) { + if (!rule) return null + const requirement = safeParseJson(rule.requirement_json) + return { + ruleId: Number(rule.id), + ruleKey: rule.rule_key, + provider: rule.provider || '', + platform: rule.platform || '', + shopId: rule.shop_id || '', + skuCode: rule.sku_code || '', + productName: rule.product_name || '', + matchType: rule.match_type || 'contains', + categoryId: rule.category_id ? Number(rule.category_id) : null, + categoryName: rule.category_name || '', + enabled: Boolean(rule.enabled), + autoCreate: Boolean(rule.auto_create), + rewardAmount: Number(rule.reward_amount || 0), + requiredDepositAmount: Number(rule.required_deposit_amount || 0), + depositThresholdAmount: Number( + rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ), + requirement: { + fields: normalizeRequirementFields(requirement.fields), + }, + sortOrder: Number(rule.sort_order || 100), + createdAt: rule.created_at, + updatedAt: rule.updated_at, + } +} + function mapWorkerUser(worker: WorkerUserRow) { return { workerId: Number(worker.id), @@ -823,7 +1192,9 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) { status: workOrder.status, rewardAmount: Number(workOrder.reward_amount || 0), requiredDepositAmount: Number(workOrder.required_deposit_amount || 0), - depositThresholdAmount: Number(workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT), + depositThresholdAmount: Number( + workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, + ), material: safeParseJson(workOrder.material_json), requirement: safeParseJson(workOrder.requirement_json), acceptance: safeParseJson(workOrder.acceptance_json), @@ -844,7 +1215,10 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) { } } -function mapWorkOrderForWorker(workOrder: WorkOrderRow, permissions: { depositFreeAmount: number }) { +function mapWorkOrderForWorker( + workOrder: WorkOrderRow, + permissions: { depositFreeAmount: number }, +) { return { ...mapWorkOrderAdmin(workOrder), freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions), @@ -878,7 +1252,8 @@ function normalizeRequirementFields(value: unknown) { const rawFields = Array.isArray(value) ? value : [] const fields = rawFields .map((item) => { - const source = item && typeof item === 'object' && !Array.isArray(item) ? item as JsonObject : {} + const source = + item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {} const key = String(source.key || '').trim() if (!key) return null return { @@ -888,7 +1263,9 @@ function normalizeRequirementFields(value: unknown) { mockValue: String(source.mockValue || '').trim(), } }) - .filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => Boolean(item)) + .filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => + Boolean(item), + ) return fields.length > 0 ? fields @@ -899,8 +1276,113 @@ function normalizeRequirementFields(value: unknown) { ] } +function normalizeRequirementFieldsFromPayload(payload: JsonObject) { + const directFields = Array.isArray(payload.fields) ? payload.fields : null + const requirement = + payload.requirement && + typeof payload.requirement === 'object' && + !Array.isArray(payload.requirement) + ? (payload.requirement as JsonObject) + : {} + if (directFields) { + return normalizeRequirementFields(directFields) + } + if (Array.isArray(requirement.fields)) { + return normalizeRequirementFields(requirement.fields) + } + + const textFields = normalizeRequirementFieldsText( + payload.fieldsText || payload.requiredFieldsText, + ) + return textFields.length > 0 ? textFields : normalizeRequirementFields([]) +} + +function normalizeRequirementFieldsText(value: unknown) { + return String(value || '') + .split(/\r?\n/) + .map((line, index) => { + const text = line.trim() + if (!text) return null + const delimiter = text.includes(':') ? ':' : ':' + if (text.includes(delimiter)) { + const [rawKey, ...labelParts] = text.split(delimiter) + const key = String(rawKey || '').trim() + if (!key) return null + return { + key, + label: labelParts.join(delimiter).trim() || key, + required: true, + mockValue: '', + } + } + return { + key: `field${index + 1}`, + label: text, + required: true, + mockValue: '', + } + }) + .filter((item): item is { key: string; label: string; required: boolean; mockValue: string } => + Boolean(item), + ) +} + +function resolveMatchingProductRule( + order: OrderRow, + item: OrderItemRow, + rules: WorkProductRuleRow[], +) { + return ( + rules.find((rule) => { + if (!matchesOptionalText(rule.provider, order.provider)) return false + if (!matchesOptionalText(rule.platform, order.platform)) return false + if (!matchesOptionalText(rule.shop_id, order.shop_id)) return false + + const skuCode = String(item.sku_code || '') + .trim() + .toLowerCase() + const skuName = String(item.sku_name || '') + .trim() + .toLowerCase() + const ruleSkuCode = String(rule.sku_code || '') + .trim() + .toLowerCase() + const ruleProductName = String(rule.product_name || '') + .trim() + .toLowerCase() + + if (ruleSkuCode && ruleSkuCode !== skuCode) { + return false + } + if (!ruleProductName) { + return Boolean(ruleSkuCode) + } + if (rule.match_type === 'exact') { + return ruleProductName === skuName || ruleProductName === skuCode + } + return skuName.includes(ruleProductName) || skuCode.includes(ruleProductName) + }) || null + ) +} + +function matchesOptionalText(ruleValue: unknown, sourceValue: unknown) { + const ruleText = String(ruleValue || '') + .trim() + .toLowerCase() + if (!ruleText) { + return true + } + return ( + ruleText === + String(sourceValue || '') + .trim() + .toLowerCase() + ) +} + function normalizeSubmittedFields(value: unknown) { - const source = value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {} + const source = + value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} return Object.fromEntries( Object.entries(source) .filter(([key]) => !['orderNo', 'platformOrderId', 'fields', 'material'].includes(key)) @@ -913,14 +1395,80 @@ function normalizeStringArray(value: unknown): string[] { return value.map((item) => String(item || '').trim()).filter(Boolean) } +function normalizeUploadedFiles(value: unknown) { + if (!Array.isArray(value)) return [] + return value + .map((item) => { + const source = + item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {} + const objectKey = String(source.objectKey || source.object_key || '').trim() + const url = String(source.url || '').trim() + if (!objectKey && !url) return null + return { + objectKey, + url, + thumbnailUrl: String(source.thumbnailUrl || source.thumbnail_url || url).trim(), + mediumUrl: String(source.mediumUrl || source.medium_url || url).trim(), + filename: String(source.filename || '').trim(), + contentType: String(source.contentType || source.content_type || '').trim(), + size: normalizeInteger(source.size, 0), + } + }) + .filter( + ( + item, + ): item is { + objectKey: string + url: string + thumbnailUrl: string + mediumUrl: string + filename: string + contentType: string + size: number + } => Boolean(item), + ) +} + function normalizeReviewStatus(value: unknown) { const status = String(value || '').trim() if (['active', 'rejected', 'disabled', 'pending_review'].includes(status)) return status return 'pending_review' } +function normalizeEnabledStatus(value: unknown) { + return String(value || 'active').trim() === 'disabled' ? 'disabled' : 'active' +} + +function normalizeMatchType(value: unknown) { + const matchType = String(value || '').trim() + if (matchType === 'exact') return 'exact' + return 'contains' +} + +function normalizeBoolean(value: unknown, fallback: boolean) { + if (typeof value === 'boolean') return value + const text = String(value ?? '') + .trim() + .toLowerCase() + if (!text) return fallback + if (['true', '1', 'yes', 'enabled', 'active', 'on'].includes(text)) return true + if (['false', '0', 'no', 'disabled', 'off'].includes(text)) return false + return fallback +} + +function normalizeSlugKey(value: unknown, fallback: string) { + const key = String(value || '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + return key || fallback +} + function normalizeUsername(value: unknown): string { - return String(value || '').trim().toLowerCase() + return String(value || '') + .trim() + .toLowerCase() } function normalizePassword(value: unknown): string { diff --git a/apps/backend/src/types/runtime-config.ts b/apps/backend/src/types/runtime-config.ts index d27686b4..f9189697 100644 --- a/apps/backend/src/types/runtime-config.ts +++ b/apps/backend/src/types/runtime-config.ts @@ -1,116 +1,124 @@ export type AdminDefaultUser = { - username: string; - password: string; - role: string; - status?: string; -}; + username: string + password: string + role: string + status?: string +} export type KuaishouFeifeiProductRule = { - id?: string; - productName: string; - normalizedProductName?: string; - productCode: string; - skuName?: string; - enabled?: boolean; - notes?: string; -}; + id?: string + productName: string + normalizedProductName?: string + productCode: string + skuName?: string + enabled?: boolean + notes?: string +} export type RuntimeConfig = { server: { - port: number; - }; + port: number + } data: { - root: string; - }; + root: string + } logging: { - level: string; + level: string /** integration 通道(外部对接日志文件)最低级别,默认 warn */ - integrationLevel: string; - retentionDays: number; - }; + integrationLevel: string + retentionDays: number + } database: { - url: string; - ssl: boolean; - maxConnections: number; - }; + url: string + ssl: boolean + maxConnections: number + } + storage: { + endpoint: string + bucket: string + accessKeyId: string + secretAccessKey: string + region: string + maxUploadSizeMb: number + } orders: { - claimBaseUrl: string; - tokenTtlHours: number; - }; + claimBaseUrl: string + tokenTtlHours: number + } admin: { - sessionSecret: string; - sessionTtlHours: number; - defaultUsers: AdminDefaultUser[]; - }; + sessionSecret: string + sessionTtlHours: number + defaultUsers: AdminDefaultUser[] + } platforms: { ninetyone: { - userId: string; - secret: string; - version: string; - shopId: string; - shopName: string; - timestampToleranceSeconds: number; - cardsEncoding: string; - }; + userId: string + secret: string + version: string + shopId: string + shopName: string + timestampToleranceSeconds: number + cardsEncoding: string + } cloudtentacles: { - baseUrl: string; - timeoutMs: number; - sendSmsPath: string; - loginPath: string; - userInfoPath: string; - assetPath: string; - permissionPath: string; - categoriesPath: string; - skuListPath: string; - skuBuyPath: string; - skuUsePath: string; - knapsackPath: string; - vnListPath: string; - vnAppointPath: string; - vnGenerateLoginCodePath: string; - vnVerifCodePath: string; - vnVerifyLoginCodePath: string; - vnBindUrlPath: string; - vnBindInfoPath: string; - vnBackPath: string; - bindUrlTtlSeconds: number; - bindUrlProbeIntervalSeconds: number; - bindUrlProbeTimeoutMs: number; - bindUrlProbeUserAgent: string; - bindUrlProbeEndpoint: string; - bindUrlProbeChartId: string; - bindUrlProbeSubChartId: string; - bindUrlProbeIdeToken: string; - bindUrlProbeActivityUrl: string; - bindUrlProbeReferer: string; - bindUrlProbeExtraCookie: string; - publicKeyPem: string; - clientSource: string; - deviceId: string; - deviceType: number; - }; + baseUrl: string + timeoutMs: number + sendSmsPath: string + loginPath: string + userInfoPath: string + assetPath: string + permissionPath: string + categoriesPath: string + skuListPath: string + skuBuyPath: string + skuUsePath: string + knapsackPath: string + vnListPath: string + vnAppointPath: string + vnGenerateLoginCodePath: string + vnVerifCodePath: string + vnVerifyLoginCodePath: string + vnBindUrlPath: string + vnBindInfoPath: string + vnBackPath: string + bindUrlTtlSeconds: number + bindUrlProbeIntervalSeconds: number + bindUrlProbeTimeoutMs: number + bindUrlProbeUserAgent: string + bindUrlProbeEndpoint: string + bindUrlProbeChartId: string + bindUrlProbeSubChartId: string + bindUrlProbeIdeToken: string + bindUrlProbeActivityUrl: string + bindUrlProbeReferer: string + bindUrlProbeExtraCookie: string + publicKeyPem: string + clientSource: string + deviceId: string + deviceType: number + } kuaishouIndustry: { - appKey: string; - appSecret: string; - signSecret: string; - messageSecret: string; - provider: string; - platform: string; - shopId: string; - shopName: string; - version: string; - }; + appKey: string + appSecret: string + signSecret: string + messageSecret: string + provider: string + platform: string + shopId: string + shopName: string + version: string + } kuaishouFeifei: { - enabled: boolean; - baseUrl: string; - appKey: string; - appSecret: string; - timeoutMs: number; - notifyUrl: string; - productRules: KuaishouFeifeiProductRule[]; - }; - }; + enabled: boolean + baseUrl: string + appKey: string + appSecret: string + timeoutMs: number + notifyUrl: string + productRules: KuaishouFeifeiProductRule[] + } + } cors: { - allowedOrigins: string[]; - }; -}; + allowedOrigins: string[] + } +} diff --git a/apps/frontend/src/components/files/ImagePreviewList.tsx b/apps/frontend/src/components/files/ImagePreviewList.tsx new file mode 100644 index 00000000..76638a59 --- /dev/null +++ b/apps/frontend/src/components/files/ImagePreviewList.tsx @@ -0,0 +1,53 @@ +import { Image, Typography } from 'antd' + +import type { UploadedFile } from '@/types/worker-platform' + +type ImagePreviewListProps = { + files?: UploadedFile[] + imageUrls?: string[] + size?: number +} + +export default function ImagePreviewList({ + files = [], + imageUrls = [], + size = 56, +}: ImagePreviewListProps) { + const items = normalizePreviewItems(files, imageUrls) + if (items.length === 0) { + return - + } + + return ( + +
+ {items.map((item) => ( + + ))} +
+
+ ) +} + +function normalizePreviewItems(files: UploadedFile[], imageUrls: string[]) { + const normalizedFiles = files + .map((file) => ({ + url: file.url || file.mediumUrl || file.thumbnailUrl, + thumbnailUrl: file.thumbnailUrl || file.url, + mediumUrl: file.mediumUrl || file.url, + })) + .filter((item) => Boolean(item.url)) + const fileUrlSet = new Set(normalizedFiles.map((item) => item.url)) + const normalizedUrls = imageUrls + .map((url) => String(url || '').trim()) + .filter((url) => url && !fileUrlSet.has(url)) + .map((url) => ({ url, thumbnailUrl: url, mediumUrl: url })) + return [...normalizedFiles, ...normalizedUrls] +} diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx new file mode 100644 index 00000000..742f6bad --- /dev/null +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -0,0 +1,66 @@ +import { PlusOutlined } from '@ant-design/icons' +import { App, Upload } from 'antd' +import type { UploadFile, UploadProps } from 'antd' + +import { uploadFile } from '@/services/files' +import type { UploadedFile } from '@/types/worker-platform' + +type ImageUploadProps = { + value?: UploadedFile[] + onChange?: (files: UploadedFile[]) => void + scene: string + scope: 'admin' | 'worker' + maxCount?: number +} + +export default function ImageUpload({ + value = [], + onChange, + scene, + scope, + maxCount = 6, +}: ImageUploadProps) { + const { message } = App.useApp() + const fileList: UploadFile[] = value.map((file) => ({ + uid: file.objectKey || file.url, + name: file.filename || '图片', + status: 'done', + url: file.thumbnailUrl || file.url, + thumbUrl: file.thumbnailUrl || file.url, + })) + + const customRequest: UploadProps['customRequest'] = async (options) => { + try { + const uploaded = await uploadFile(options.file as File, scene, scope) + onChange?.([...value, uploaded].slice(-maxCount)) + options.onSuccess?.(uploaded) + } catch (error) { + message.error(error instanceof Error ? error.message : '上传失败') + options.onError?.(error instanceof Error ? error : new Error('上传失败')) + } + } + + return ( + { + onChange?.( + value.filter((item) => (item.objectKey || item.url) !== file.uid), + ) + return true + }} + showUploadList={{ showPreviewIcon: false }} + > + {value.length >= maxCount ? null : ( + + )} + + ) +} diff --git a/apps/frontend/src/lib/http.ts b/apps/frontend/src/lib/http.ts index 484145a0..fc25c853 100644 --- a/apps/frontend/src/lib/http.ts +++ b/apps/frontend/src/lib/http.ts @@ -36,9 +36,13 @@ http.interceptors.response.use( }, (error) => { const responseMessage = - typeof error?.response?.data?.msg === 'string' ? error.response.data.msg.trim() : '' + typeof error?.response?.data?.msg === 'string' + ? error.response.data.msg.trim() + : '' const fallbackMessage = resolveFallbackHttpMessage(error) - const normalizedError = new Error(responseMessage || fallbackMessage) as Error & { + const normalizedError = new Error( + responseMessage || fallbackMessage, + ) as Error & { errorCode?: string status?: number } @@ -51,7 +55,10 @@ http.interceptors.response.use( normalizedError.status = error.response.status } - if (error?.config?.url && String(error.config.url).startsWith('/api/v1/admin')) { + if ( + error?.config?.url && + String(error.config.url).startsWith('/api/v1/admin') + ) { const status = Number(error?.response?.status || 0) const errorCode = String(error?.response?.data?.errorCode || '').trim() @@ -67,7 +74,10 @@ http.interceptors.response.use( } } - if (error?.config?.url && String(error.config.url).startsWith('/api/v1/worker')) { + if ( + error?.config?.url && + String(error.config.url).startsWith('/api/v1/worker') + ) { const status = Number(error?.response?.status || 0) const errorCode = String(error?.response?.data?.errorCode || '').trim() @@ -118,8 +128,13 @@ function resolveFallbackHttpMessage(error: unknown) { return '网络连接错误' } - if (typeof (error as { response?: { statusText?: string } })?.response?.statusText === 'string') { - return String((error as { response: { statusText: string } }).response.statusText).trim() + if ( + typeof (error as { response?: { statusText?: string } })?.response + ?.statusText === 'string' + ) { + return String( + (error as { response: { statusText: string } }).response.statusText, + ).trim() } return '接口请求失败' @@ -145,7 +160,9 @@ function shouldClearWorkerSession(errorCode: string) { ].includes(errorCode) } -function request(config: Parameters>>[0]) { +function request( + config: Parameters>>[0], +) { return http.request, ApiEnvelope>(config) } @@ -163,7 +180,20 @@ export function apiGetBlob(url: string, params?: Record) { } export function apiPost(url: string, data?: unknown) { - return request({ method: 'POST', url, data: data as Record }) + return request({ + method: 'POST', + url, + data: data as Record, + }) +} + +export function apiPostForm(url: string, data: FormData) { + return request({ + method: 'POST', + url, + data, + headers: { 'Content-Type': 'multipart/form-data' }, + }) } export function apiDelete(url: string) { diff --git a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx index 44a423fd..f43794d4 100644 --- a/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx +++ b/apps/frontend/src/pages/admin/AdminWorkerPlatformPage.tsx @@ -1,8 +1,10 @@ import { CheckOutlined, + FormOutlined, PlusOutlined, ReloadOutlined, SendOutlined, + SyncOutlined, WarningOutlined, } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -26,11 +28,14 @@ import { import type { TableColumnsType } from 'antd' import { useState } from 'react' +import ImagePreviewList from '@/components/files/ImagePreviewList' import PageHeader from '@/components/admin/PageHeader' import { acceptAdminWorkOrder, createAdminMockWorkOrder, creditAdminWorkerWallet, + fetchAdminWorkCategories, + fetchAdminWorkProductRules, fetchAdminWorkerLevels, fetchAdminWorkerPlatformSummary, fetchAdminWorkerUsers, @@ -38,19 +43,36 @@ import { markAdminWorkOrderProblem, publishAdminWorkOrder, reviewAdminWorkerUser, + saveAdminWorkCategory, + saveAdminWorkProductRule, saveAdminWorkerLevel, + submitAdminWorkOrderMaterial, + syncAdminWorkerOrdersFromSource, } from '@/services/admin' -import type { WorkOrder, WorkerLevel, WorkerUser } from '@/types/worker-platform' +import type { + CollectField, + UploadedFile, + WorkCategory, + WorkOrder, + WorkProductRule, + WorkerLevel, + WorkerUser, +} from '@/types/worker-platform' export default function AdminWorkerPlatformPage() { return (
- + }, + { key: 'rules', label: '物品规则', children: }, + { key: 'categories', label: '分类', children: }, { key: 'workers', label: '接单员', children: }, { key: 'levels', label: '等级权限', children: }, ]} @@ -69,10 +91,16 @@ function SummaryCards() { return (
- + - + @@ -89,7 +117,9 @@ function WorkOrdersPanel() { const queryClient = useQueryClient() const [status, setStatus] = useState('') const [problemOrder, setProblemOrder] = useState(null) + const [materialOrder, setMaterialOrder] = useState(null) const [problemForm] = Form.useForm() + const [materialForm] = Form.useForm() const ordersQuery = useQuery({ queryKey: ['admin-worker-platform-orders', status], @@ -98,12 +128,19 @@ function WorkOrdersPanel() { async function refreshAll() { await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }), - queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'] }), + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-orders'], + }), + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-summary'], + }), ]) } - async function runAction(action: () => Promise, successMessage: string) { + async function runAction( + action: () => Promise, + successMessage: string, + ) { try { await action() message.success(successMessage) @@ -116,13 +153,47 @@ function WorkOrdersPanel() { async function submitProblem(values: { note?: string }) { if (!problemOrder) return await runAction( - () => markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''), + () => + markAdminWorkOrderProblem(problemOrder.workOrderId, values.note || ''), '已标记问题单', ) setProblemOrder(null) problemForm.resetFields() } + async function submitMaterial(values: { fields?: Record }) { + if (!materialOrder) return + await runAction( + () => + submitAdminWorkOrderMaterial( + materialOrder.workOrderId, + values.fields || {}, + ), + '订单资料已保存', + ) + setMaterialOrder(null) + materialForm.resetFields() + } + + async function submitSourceSync(values: { platformOrderId?: string }) { + try { + const result = await syncAdminWorkerOrdersFromSource({ + platformOrderId: String(values.platformOrderId || '').trim(), + }) + message.success( + `同步完成,新增 ${result.data.createdCount} 个,跳过 ${result.data.skippedCount} 个`, + ) + await refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '同步失败') + } + } + + function openMaterialModal(row: WorkOrder) { + setMaterialOrder(row) + materialForm.setFieldsValue({ fields: getMaterialFields(row) }) + } + const columns: TableColumnsType = [ { title: '订单', @@ -136,35 +207,93 @@ function WorkOrdersPanel() {
), }, - { title: '金额', width: 110, render: (_, row) => formatMoney(row.rewardAmount) }, - { title: '押金', width: 110, render: (_, row) => formatMoney(row.requiredDepositAmount) }, + { + title: '金额', + width: 110, + render: (_, row) => formatMoney(row.rewardAmount), + }, + { + title: '押金', + width: 110, + render: (_, row) => formatMoney(row.requiredDepositAmount), + }, { title: '状态', width: 130, - render: (_, row) => {formatStatus(row.status)}, + render: (_, row) => ( + + {formatStatus(row.status)} + + ), }, { title: '接单员', width: 140, - render: (_, row) => row.worker?.displayName || row.worker?.username || '-', + render: (_, row) => + row.worker?.displayName || row.worker?.username || '-', + }, + { + title: '资料', + width: 160, + render: (_, row) => renderMaterialSummary(row), + }, + { + title: '验收图', + width: 170, + render: (_, row) => ( + + ), }, { title: '操作', - width: 260, + width: 340, render: (_, row) => ( + {row.status === 'pending_material' ? ( + + ) : null} {row.status === 'unassigned' ? ( - ) : null} {row.status === 'pending_acceptance' ? ( - ) : null} {['in_progress', 'pending_acceptance'].includes(row.status) ? ( - ) : null} @@ -175,6 +304,25 @@ function WorkOrdersPanel() { return (
+ +
+ + + + + + +
+
@@ -197,7 +345,11 @@ function WorkOrdersPanel() { ]} onChange={setStatus} /> - @@ -222,11 +374,40 @@ function WorkOrdersPanel() { destroyOnHidden >
- + + + setMaterialOrder(null)} + onOk={() => materialForm.submit()} + destroyOnHidden + > +
+ {getRequirementFields(materialOrder).map((field) => ( + + + + ))} +
+
) } @@ -256,7 +437,11 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { return (
@@ -268,11 +453,20 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { - + - @@ -280,6 +474,332 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise }) { ) } +function ProductRulesPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const rulesQuery = useQuery({ + queryKey: ['admin-worker-platform-product-rules'], + queryFn: () => fetchAdminWorkProductRules(), + }) + const categoriesQuery = useQuery({ + queryKey: ['admin-worker-platform-categories'], + queryFn: () => fetchAdminWorkCategories(), + }) + + async function saveRule(values: { + ruleKey: string + provider?: string + platform?: string + shopId?: string + skuCode?: string + productName?: string + matchType?: string + categoryId?: number + rewardAmount?: number + requiredDepositAmount?: number + depositThresholdAmount?: number + fieldsText?: string + enabled?: boolean + autoCreate?: boolean + }) { + try { + await saveAdminWorkProductRule(values) + message.success('物品规则已保存') + form.resetFields() + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-product-rules'], + }), + queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-summary'], + }), + ]) + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } + } + + const columns: TableColumnsType = [ + { + title: '规则', + minWidth: 260, + render: (_, row) => ( +
+ + {row.productName || row.skuCode} + + {row.ruleKey} +
+ ), + }, + { + title: 'SKU', + dataIndex: 'skuCode', + width: 180, + render: (value) => String(value || '-'), + }, + { title: '分类', width: 140, render: (_, row) => row.categoryName || '-' }, + { + title: '接单金额', + width: 120, + render: (_, row) => formatMoney(row.rewardAmount), + }, + { + title: '押金', + width: 120, + render: (_, row) => formatMoney(row.requiredDepositAmount), + }, + { + title: '匹配', + width: 130, + render: (_, row) => (row.matchType === 'exact' ? '精确' : '包含'), + }, + { + title: '状态', + width: 150, + render: (_, row) => ( + + + {row.enabled ? '启用' : '停用'} + + {row.autoCreate ? 自动创建 : null} + + ), + }, + ] + + return ( +
+ + +
+ + + + + + + + + + + + + + + + + + + + ({ + value: item.categoryId, + label: item.name, + }), + )} + /> + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ } + loading={rulesQuery.isFetching} + onClick={() => rulesQuery.refetch()} + > + 刷新 + + } + bordered={false} + > + + rowKey="ruleId" + loading={rulesQuery.isLoading} + dataSource={rulesQuery.data?.data.items || []} + columns={columns} + pagination={false} + scroll={{ x: 1200 }} + /> + +
+ ) +} + +function CategoriesPanel() { + const { message } = App.useApp() + const queryClient = useQueryClient() + const [form] = Form.useForm() + const categoriesQuery = useQuery({ + queryKey: ['admin-worker-platform-categories'], + queryFn: () => fetchAdminWorkCategories(), + }) + + async function saveCategory(values: { + categoryKey: string + name: string + sortOrder?: number + status?: string + }) { + try { + await saveAdminWorkCategory(values) + message.success('分类已保存') + form.resetFields() + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-categories'], + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '保存失败') + } + } + + const columns: TableColumnsType = [ + { title: '分类', dataIndex: 'name' }, + { title: '标识', dataIndex: 'categoryKey' }, + { title: '排序', dataIndex: 'sortOrder', width: 100 }, + { + title: '状态', + width: 120, + render: (_, row) => ( + + {row.status === 'active' ? '启用' : '停用'} + + ), + }, + ] + + return ( +
+ +
+ + + + + + + + + + + @@ -419,11 +974,18 @@ function LevelsPanel() { }) const [form] = Form.useForm() - async function saveLevel(values: { levelKey: string; name: string; depositFreeAmount?: number; maxActiveOrders?: number }) { + async function saveLevel(values: { + levelKey: string + name: string + depositFreeAmount?: number + maxActiveOrders?: number + }) { try { await saveAdminWorkerLevel(values) message.success('等级已保存') - await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-levels'] }) + await queryClient.invalidateQueries({ + queryKey: ['admin-worker-platform-levels'], + }) } catch (error) { message.error(error instanceof Error ? error.message : '保存失败') } @@ -432,7 +994,10 @@ function LevelsPanel() { const columns: TableColumnsType = [ { title: '等级', dataIndex: 'name' }, { title: '标识', dataIndex: 'levelKey' }, - { title: '免押额度', render: (_, row) => formatMoney(row.depositFreeAmount) }, + { + title: '免押额度', + render: (_, row) => formatMoney(row.depositFreeAmount), + }, { title: '最大同时接单', dataIndex: 'maxActiveOrders' }, { title: '状态', dataIndex: 'status' }, ] @@ -443,13 +1008,26 @@ function LevelsPanel() { - + - + @@ -459,7 +1037,9 @@ function LevelsPanel() { - +
@@ -476,6 +1056,77 @@ function LevelsPanel() { ) } +function getRequirementFields(order: WorkOrder | null): CollectField[] { + if (!order) return [] + const rawFields = Array.isArray(order.requirement?.fields) + ? order.requirement.fields + : [] + return rawFields + .map((item) => { + const source = asRecord(item) + const key = String(source.key || '').trim() + if (!key) return null + return { + key, + label: String(source.label || key).trim(), + required: source.required !== false, + } + }) + .filter((item): item is CollectField => Boolean(item)) +} + +function getMaterialFields(order: WorkOrder): Record { + const material = asRecord(order.material) + const collect = asRecord(material.collect) + const fields = asRecord(collect.fields || material.fields) + return Object.fromEntries( + Object.entries(fields).map(([key, value]) => [key, String(value || '')]), + ) +} + +function renderMaterialSummary(order: WorkOrder) { + const fields = getMaterialFields(order) + const entries = Object.entries(fields).filter(([, value]) => + String(value || '').trim(), + ) + if (entries.length === 0) { + return - + } + + return ( +
+ {entries.slice(0, 2).map(([key, value]) => ( + + {key}: {value} + + ))} + {entries.length > 2 ? ( + + 还有 {entries.length - 2} 项 + + ) : null} +
+ ) +} + +function getAcceptanceFiles(row: WorkOrder): UploadedFile[] { + return Array.isArray(row.acceptance?.files) ? row.acceptance.files : [] +} + +function getAcceptanceImageUrls(row: WorkOrder): string[] { + return Array.isArray(row.acceptance?.imageUrls) + ? row.acceptance.imageUrls + .map((item) => String(item || '').trim()) + .filter(Boolean) + : [] +} + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {} +} + function formatMoney(value: number | undefined) { return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}` } diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 12f4f6a4..e4131d20 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -15,8 +15,10 @@ import { import type { TableColumnsType } from 'antd' import { useState } from 'react' +import ImagePreviewList from '@/components/files/ImagePreviewList' +import ImageUpload from '@/components/files/ImageUpload' import { fetchWorkerMyOrders, submitWorkerAcceptance } from '@/services/worker' -import type { WorkOrder } from '@/types/worker-platform' +import type { UploadedFile, WorkOrder } from '@/types/worker-platform' const STATUS_OPTIONS = [ { value: '', label: '全部' }, @@ -31,6 +33,7 @@ export default function WorkerOrdersPage() { const queryClient = useQueryClient() const [status, setStatus] = useState('') const [submittingOrder, setSubmittingOrder] = useState(null) + const [acceptanceFiles, setAcceptanceFiles] = useState([]) const [form] = Form.useForm() const ordersQuery = useQuery({ @@ -39,18 +42,20 @@ export default function WorkerOrdersPage() { retry: false, }) - async function submitAcceptance(values: { note?: string; imageUrls?: string }) { + async function submitAcceptance(values: { note?: string }) { if (!submittingOrder) return + if (acceptanceFiles.length === 0) { + message.error('请上传验收图片') + return + } try { await submitWorkerAcceptance(submittingOrder.workOrderId, { note: values.note, - imageUrls: String(values.imageUrls || '') - .split('\n') - .map((item) => item.trim()) - .filter(Boolean), + files: acceptanceFiles, }) message.success('已提交验收') setSubmittingOrder(null) + setAcceptanceFiles([]) form.resetFields() await queryClient.invalidateQueries({ queryKey: ['worker-my-orders'] }) } catch (error) { @@ -65,19 +70,40 @@ export default function WorkerOrdersPage() { render: (_, row) => (
{row.productName} - {row.platformOrderId} + + {row.platformOrderId} +
), }, { title: '金额', width: 130, - render: (_, row) => {formatMoney(row.rewardAmount)}, + render: (_, row) => ( + + {formatMoney(row.rewardAmount)} + + ), }, { title: '状态', width: 140, - render: (_, row) => {formatStatus(row.status)}, + render: (_, row) => ( + + {formatStatus(row.status)} + + ), + }, + { + title: '验收图片', + width: 170, + render: (_, row) => ( + + ), }, { title: '问题备注', @@ -90,7 +116,13 @@ export default function WorkerOrdersPage() { width: 140, render: (_, row) => ['in_progress', 'problem'].includes(row.status) ? ( - ) : null, @@ -102,8 +134,17 @@ export default function WorkerOrdersPage() {
我的订单 - + @@ -121,7 +162,10 @@ export default function WorkerOrdersPage() { setSubmittingOrder(null)} + onCancel={() => { + setSubmittingOrder(null) + setAcceptanceFiles([]) + }} onOk={() => form.submit()} destroyOnHidden > @@ -129,12 +173,14 @@ export default function WorkerOrdersPage() { - - + + @@ -142,6 +188,18 @@ export default function WorkerOrdersPage() { ) } +function getAcceptanceFiles(row: WorkOrder): UploadedFile[] { + return Array.isArray(row.acceptance?.files) ? row.acceptance.files : [] +} + +function getAcceptanceImageUrls(row: WorkOrder): string[] { + return Array.isArray(row.acceptance?.imageUrls) + ? row.acceptance.imageUrls + .map((item) => String(item || '').trim()) + .filter(Boolean) + : [] +} + function formatMoney(value: number | undefined) { return `¥${((Number(value || 0) || 0) / 100).toFixed(2)}` } diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 4ac0ec8a..4795172f 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -1,6 +1,8 @@ import { apiGet, apiPost } from '@/lib/http' import type { + WorkCategory, WorkOrder, + WorkProductRule, WorkerLevel, WorkerListResponse, WorkerUser, @@ -16,7 +18,27 @@ export function fetchAdminWorkerPlatformSummary() { } export function fetchAdminWorkerLevels() { - return apiGet<{ items: WorkerLevel[] }>('/api/v1/admin/worker-platform/levels') + return apiGet<{ items: WorkerLevel[] }>( + '/api/v1/admin/worker-platform/levels', + ) +} + +export function fetchAdminWorkCategories() { + return apiGet<{ items: WorkCategory[] }>( + '/api/v1/admin/worker-platform/categories', + ) +} + +export function saveAdminWorkCategory(payload: { + categoryKey: string + name: string + sortOrder?: number + status?: string +}) { + return apiPost<{ category: WorkCategory }>( + '/api/v1/admin/worker-platform/categories', + payload, + ) } export function saveAdminWorkerLevel(payload: { @@ -25,18 +47,57 @@ export function saveAdminWorkerLevel(payload: { depositFreeAmount?: number maxActiveOrders?: number }) { - return apiPost<{ level: WorkerLevel }>('/api/v1/admin/worker-platform/levels', payload) + return apiPost<{ level: WorkerLevel }>( + '/api/v1/admin/worker-platform/levels', + payload, + ) } export function fetchAdminWorkerUsers(params?: Record) { - return apiGet>('/api/v1/admin/worker-platform/workers', params) + return apiGet>( + '/api/v1/admin/worker-platform/workers', + params, + ) +} + +export function fetchAdminWorkProductRules(params?: Record) { + return apiGet<{ items: WorkProductRule[] }>( + '/api/v1/admin/worker-platform/product-rules', + params, + ) +} + +export function saveAdminWorkProductRule(payload: { + ruleKey: string + provider?: string + platform?: string + shopId?: string + skuCode?: string + productName?: string + matchType?: string + categoryId?: number + enabled?: boolean + autoCreate?: boolean + rewardAmount?: number + requiredDepositAmount?: number + depositThresholdAmount?: number + fieldsText?: string + sortOrder?: number +}) { + return apiPost<{ rule: WorkProductRule }>( + '/api/v1/admin/worker-platform/product-rules', + payload, + ) } export function reviewAdminWorkerUser( workerId: number, payload: { status: string; reviewNote?: string; levelId?: number }, ) { - return apiPost<{ worker: WorkerUser }>(`/api/v1/admin/worker-platform/workers/${workerId}/review`, payload) + return apiPost<{ worker: WorkerUser }>( + `/api/v1/admin/worker-platform/workers/${workerId}/review`, + payload, + ) } export function creditAdminWorkerWallet( @@ -50,7 +111,10 @@ export function creditAdminWorkerWallet( } export function fetchAdminWorkOrders(params?: Record) { - return apiGet>('/api/v1/admin/worker-platform/orders', params) + return apiGet>( + '/api/v1/admin/worker-platform/orders', + params, + ) } export function createAdminMockWorkOrder(payload: { @@ -59,17 +123,49 @@ export function createAdminMockWorkOrder(payload: { rewardAmount?: number materialComplete?: boolean }) { - return apiPost<{ order: WorkOrder }>('/api/v1/admin/worker-platform/orders/mock', payload) + return apiPost<{ order: WorkOrder }>( + '/api/v1/admin/worker-platform/orders/mock', + payload, + ) +} + +export function syncAdminWorkerOrdersFromSource(payload: { + platformOrderId?: string + orderId?: number +}) { + return apiPost<{ + created: WorkOrder[] + skipped: Array<{ orderItemId: number; reason: string }> + createdCount: number + skippedCount: number + }>('/api/v1/admin/worker-platform/source-orders/sync', payload) +} + +export function submitAdminWorkOrderMaterial( + workOrderId: number, + fields: Record, +) { + return apiPost<{ order: WorkOrder; complete: boolean }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/material`, + { fields }, + ) } export function publishAdminWorkOrder(workOrderId: number) { - return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/publish`) + return apiPost<{ order: WorkOrder }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/publish`, + ) } export function markAdminWorkOrderProblem(workOrderId: number, note: string) { - return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/problem`, { note }) + return apiPost<{ order: WorkOrder }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/problem`, + { note }, + ) } export function acceptAdminWorkOrder(workOrderId: number) { - return apiPost<{ order: WorkOrder }>(`/api/v1/admin/worker-platform/orders/${workOrderId}/accept`) + return apiPost<{ order: WorkOrder }>( + `/api/v1/admin/worker-platform/orders/${workOrderId}/accept`, + ) } diff --git a/apps/frontend/src/services/files.ts b/apps/frontend/src/services/files.ts new file mode 100644 index 00000000..6c67ec72 --- /dev/null +++ b/apps/frontend/src/services/files.ts @@ -0,0 +1,22 @@ +import { apiPostForm } from '@/lib/http' +import type { UploadedFile } from '@/types/worker-platform' +import { optimizeImageForUpload } from '@/utils/image-upload' + +type UploadScope = 'admin' | 'worker' + +export async function uploadFile( + file: File, + scene: string, + scope: UploadScope, +) { + const uploadTarget = await optimizeImageForUpload(file, scene) + const form = new FormData() + form.append('file', uploadTarget) + form.append('scene', scene) + const endpoint = + scope === 'admin' + ? '/api/v1/admin/files/upload' + : '/api/v1/worker/files/upload' + const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form) + return response.data.file +} diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 244dcc9f..6e9b5807 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -1,6 +1,7 @@ import { apiGet, apiPost } from '@/lib/http' import type { CollectField, + UploadedFile, WorkOrder, WorkerListResponse, WorkerLoginResponse, @@ -13,7 +14,10 @@ export function registerWorker(payload: { displayName?: string phone?: string }) { - return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>('/api/v1/worker/auth/register', payload) + return apiPost<{ worker: WorkerUser; reviewRequired: boolean }>( + '/api/v1/worker/auth/register', + payload, + ) } export function loginWorker(payload: { username: string; password: string }) { @@ -21,7 +25,11 @@ export function loginWorker(payload: { username: string; password: string }) { } export function fetchWorkerSession() { - return apiGet<{ authenticated: boolean; expiresAt: string; worker: WorkerUser }>('/api/v1/worker/auth/session') + return apiGet<{ + authenticated: boolean + expiresAt: string + worker: WorkerUser + }>('/api/v1/worker/auth/session') } export function logoutWorker() { @@ -29,15 +37,22 @@ export function logoutWorker() { } export function fetchWorkerProfile() { - return apiGet<{ worker: WorkerUser; permissions: Record }>('/api/v1/worker/profile') + return apiGet<{ worker: WorkerUser; permissions: Record }>( + '/api/v1/worker/profile', + ) } export function fetchWorkerHallOrders(params?: Record) { - return apiGet>('/api/v1/worker/hall/orders', params) + return apiGet>( + '/api/v1/worker/hall/orders', + params, + ) } export function grabWorkerOrder(workOrderId: number) { - return apiPost<{ order: WorkOrder }>(`/api/v1/worker/hall/orders/${workOrderId}/grab`) + return apiPost<{ order: WorkOrder }>( + `/api/v1/worker/hall/orders/${workOrderId}/grab`, + ) } export function fetchWorkerMyOrders(params?: Record) { @@ -46,14 +61,20 @@ export function fetchWorkerMyOrders(params?: Record) { export function submitWorkerAcceptance( workOrderId: number, - payload: { note?: string; imageUrls?: string[] }, + payload: { note?: string; imageUrls?: string[]; files?: UploadedFile[] }, ) { - return apiPost<{ order: WorkOrder }>(`/api/v1/worker/orders/${workOrderId}/submit-acceptance`, payload) + return apiPost<{ order: WorkOrder }>( + `/api/v1/worker/orders/${workOrderId}/submit-acceptance`, + payload, + ) } export function lookupCollectOrder(orderNo: string) { return apiPost<{ - order: Pick + order: Pick< + WorkOrder, + 'workOrderNo' | 'platformOrderId' | 'productName' | 'status' + > fields: CollectField[] }>('/api/v1/collect/lookup', { orderNo }) } @@ -62,5 +83,8 @@ export function submitCollectOrder(payload: { orderNo: string fields: Record }) { - return apiPost<{ complete: boolean; order: Record }>('/api/v1/collect/submit', payload) + return apiPost<{ complete: boolean; order: Record }>( + '/api/v1/collect/submit', + payload, + ) } diff --git a/apps/frontend/src/styles/main.css b/apps/frontend/src/styles/main.css index b3a0a6c4..3ecfeefe 100644 --- a/apps/frontend/src/styles/main.css +++ b/apps/frontend/src/styles/main.css @@ -657,7 +657,8 @@ select { .payload-text { font-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + monospace; font-size: 12px; color: #4b5563; word-break: break-all; @@ -1272,7 +1273,9 @@ select { } .platform-shop-row { - grid-template-columns: auto auto minmax(120px, 0.9fr) minmax(140px, 1fr) minmax(220px, 1.5fr) auto auto; + grid-template-columns: + auto auto minmax(120px, 0.9fr) minmax(140px, 1fr) + minmax(220px, 1.5fr) auto auto; } .platform-rule-head { @@ -1337,7 +1340,9 @@ select { .cloud-records-filter { display: grid; - grid-template-columns: minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr) auto; + grid-template-columns: + minmax(180px, 0.28fr) minmax(220px, 0.36fr) minmax(360px, 1fr) + auto; gap: 12px; align-items: center; } @@ -1682,6 +1687,47 @@ select { margin-bottom: 0; } +.image-upload-trigger { + width: 100%; + height: 100%; + min-height: 72px; + border: 0; + background: transparent; + color: #64748b; + display: grid; + place-items: center; + gap: 4px; + cursor: pointer; +} + +.image-upload-trigger span { + font-size: 12px; +} + +.image-preview-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + align-items: center; +} + +.image-preview-item { + object-fit: cover; + border-radius: 6px; + border: 1px solid #e5e7eb; + background: #f8fafc; +} + +.worker-rule-form { + width: 100%; +} + +.worker-rule-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 0 14px; +} + @media (max-width: 720px) { .worker-page-head { align-items: stretch; diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index d810422d..f775bce6 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -35,6 +35,50 @@ export type WorkerLevel = { updatedAt: string } +export type WorkCategory = { + categoryId: number + categoryKey: string + name: string + sortOrder: number + status: string + createdAt: string + updatedAt: string +} + +export type WorkProductRule = { + ruleId: number + ruleKey: string + provider: string + platform: string + shopId: string + skuCode: string + productName: string + matchType: 'contains' | 'exact' | string + categoryId: number | null + categoryName: string + enabled: boolean + autoCreate: boolean + rewardAmount: number + requiredDepositAmount: number + depositThresholdAmount: number + requirement: { + fields: CollectField[] + } + sortOrder: number + createdAt: string + updatedAt: string +} + +export type UploadedFile = { + objectKey: string + url: string + thumbnailUrl: string + mediumUrl: string + filename: string + contentType: string + size: number +} + export type WorkOrderStatus = | 'pending_material' | 'unassigned' @@ -59,7 +103,13 @@ export type WorkOrder = { freezeDepositAmount?: number material: Record requirement: Record - acceptance: Record + acceptance: { + note?: string + files?: UploadedFile[] + imageUrls?: string[] + submittedAt?: string + [key: string]: unknown + } problemNote: string worker: null | { workerId: number diff --git a/apps/frontend/src/utils/image-upload.ts b/apps/frontend/src/utils/image-upload.ts new file mode 100644 index 00000000..5bba97d2 --- /dev/null +++ b/apps/frontend/src/utils/image-upload.ts @@ -0,0 +1,86 @@ +const IMAGE_UPLOAD_MAX_SIDE: Record = { + 'worker-acceptance': 1920, + 'collect-material': 1920, + avatar: 512, +} + +const IMAGE_UPLOAD_QUALITY: Record = { + 'worker-acceptance': 0.86, + 'collect-material': 0.86, + avatar: 0.82, +} + +export async function optimizeImageForUpload(file: File, scene: string) { + if (!file.type.startsWith('image/')) return file + if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) + return file + if (typeof document === 'undefined') return file + + const maxSide = IMAGE_UPLOAD_MAX_SIDE[scene] ?? 1600 + const quality = IMAGE_UPLOAD_QUALITY[scene] ?? 0.84 + + try { + const image = await loadImage(file) + const { width, height } = fitSize( + image.naturalWidth, + image.naturalHeight, + maxSide, + ) + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const context = canvas.getContext('2d') + if (!context) return file + context.fillStyle = '#ffffff' + context.fillRect(0, 0, width, height) + context.drawImage(image, 0, 0, width, height) + const blob = await canvasToBlob(canvas, 'image/webp', quality) + if (!blob || blob.size >= file.size) return file + return new File([blob], replaceFileExt(file.name, 'webp'), { + type: 'image/webp', + lastModified: Date.now(), + }) + } catch { + return file + } +} + +function loadImage(file: File) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(file) + const image = new Image() + image.onload = () => { + URL.revokeObjectURL(url) + resolve(image) + } + image.onerror = () => { + URL.revokeObjectURL(url) + reject(new Error('图片读取失败')) + } + image.src = url + }) +} + +function fitSize(width: number, height: number, maxSide: number) { + if (width <= 0 || height <= 0) return { width: 1, height: 1 } + const scale = Math.min(1, maxSide / Math.max(width, height)) + return { + width: Math.max(1, Math.round(width * scale)), + height: Math.max(1, Math.round(height * scale)), + } +} + +function canvasToBlob( + canvas: HTMLCanvasElement, + type: string, + quality: number, +) { + return new Promise((resolve) => { + canvas.toBlob(resolve, type, quality) + }) +} + +function replaceFileExt(filename: string, ext: string) { + const base = filename.replace(/\.[^.]+$/, '') + return `${base || 'image'}.${ext}` +} diff --git a/deploy/mac-dev.sh b/deploy/mac-dev.sh index d0318b14..eb5294e0 100755 --- a/deploy/mac-dev.sh +++ b/deploy/mac-dev.sh @@ -47,7 +47,7 @@ print_usage() { 注意: --reset-db 会删除本机开发库里的订单、任务、后台用户、审计日志和迁移记录。 - 它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 node_modules 缓存 volume。 + 它不会删除 apps/backend/data 下的平台 JSON 配置、MinIO 文件数据和 node_modules 缓存 volume。 EOF } @@ -86,6 +86,18 @@ read_env_value() { ' "${ENV_FILE}" } +read_effective_env_value() { + local key="$1" + local default_value="$2" + local value="${!key:-}" + + if [[ -z "${value}" ]]; then + value="$(read_env_value "${key}" || true)" + fi + + printf '%s\n' "${value:-${default_value}}" +} + ensure_docker() { command -v docker >/dev/null 2>&1 || fail "未找到 docker,请先安装并启动 Docker Desktop for Mac。" docker compose version >/dev/null 2>&1 || fail "未找到 docker compose 插件,请升级 Docker Desktop。" @@ -270,6 +282,7 @@ start_dev_stack() { log "启动 Mac 本机 Docker 开发环境" if "${COMPOSE_CMD[@]}" up -d --build; then wait_for_service postgres "PostgreSQL" + wait_for_service minio "MinIO" wait_for_service backend "后端" wait_for_service frontend "前端" wait_for_service web "Web 入口" @@ -327,6 +340,9 @@ print_diagnostics() { warn "前端最近日志:" "${COMPOSE_CMD[@]}" logs --tail=80 frontend || true + warn "MinIO 最近日志:" + "${COMPOSE_CMD[@]}" logs --tail=80 minio || true + warn "后端 ready 接口:" "${COMPOSE_CMD[@]}" exec -T backend sh -lc 'curl -fsS http://127.0.0.1:3000/health/ready || true' || true @@ -334,10 +350,14 @@ print_diagnostics() { } print_endpoints() { + local minio_console_port + minio_console_port="$(read_effective_env_value MINIO_CONSOLE_PORT 19001)" + log "常用入口" printf '%s\n' " 前端、后台、领取页:http://localhost" printf '%s\n' " 后端健康检查:http://localhost/health" printf '%s\n' " 后端 ready 检查:http://localhost/health/ready" + printf '%s\n' " MinIO 控制台:http://localhost:${minio_console_port}" } main() { diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index d155f4d3..e97d531b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -28,6 +28,21 @@ services: volumes: - postgres_dev_data:/var/lib/postgresql/data + minio: + image: docker.m.daocloud.io/minio/minio:latest + restart: unless-stopped + logging: *json-log-rotation + command: server /data --address ":9000" --console-address ":9001" + environment: + TZ: ${TZ:-Asia/Shanghai} + MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY_ID:-minioadmin} + MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin} + ports: + - "${MINIO_API_PORT:-19000}:9000" + - "${MINIO_CONSOLE_PORT:-19001}:9001" + volumes: + - minio_dev_data:/data + backend: build: context: . @@ -43,6 +58,8 @@ services: depends_on: postgres: condition: service_healthy + minio: + condition: service_started healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/health/ready || exit 1"] @@ -59,6 +76,12 @@ services: DATA_ROOT: /app/data LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} + STORAGE_ENDPOINT: ${STORAGE_ENDPOINT:-http://minio:9000} + STORAGE_BUCKET: ${STORAGE_BUCKET:-order-site} + STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-minioadmin} + STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin} + STORAGE_REGION: ${STORAGE_REGION:-us-east-1} + STORAGE_MAX_UPLOAD_SIZE_MB: ${STORAGE_MAX_UPLOAD_SIZE_MB:-10} # ADMIN_* 仅走 env_file,避免 JSON 经 ${} 注入时被 YAML 破坏 KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120} logging: *json-log-rotation @@ -105,5 +128,6 @@ services: volumes: postgres_dev_data: + minio_dev_data: backend_node_modules: frontend_node_modules: diff --git a/docker-compose.yml b/docker-compose.yml index 305e0e95..84e639f7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -56,6 +56,12 @@ services: DATA_ROOT: /app/data LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30} CLAIM_BASE_URL: ${CLAIM_BASE_URL:-http://localhost/#/claim} + STORAGE_ENDPOINT: ${STORAGE_ENDPOINT:-http://minio:9000} + STORAGE_BUCKET: ${STORAGE_BUCKET:-order-site} + STORAGE_ACCESS_KEY_ID: ${STORAGE_ACCESS_KEY_ID:-minioadmin} + STORAGE_SECRET_ACCESS_KEY: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin} + STORAGE_REGION: ${STORAGE_REGION:-us-east-1} + STORAGE_MAX_UPLOAD_SIZE_MB: ${STORAGE_MAX_UPLOAD_SIZE_MB:-10} # ADMIN_SESSION_SECRET / ADMIN_DEFAULT_USERS_JSON 仅走 env_file。 # 勿在此处用 ${ADMIN_DEFAULT_USERS_JSON} 再注入:JSON 会被 YAML 解析破坏,导致生产校验失败。 KUASHOU_INDUSTRY_RATE_LIMIT_MAX: ${KUASHOU_INDUSTRY_RATE_LIMIT_MAX:-120} @@ -63,6 +69,22 @@ services: volumes: - ./apps/backend/data:/app/data + minio: + image: docker.m.daocloud.io/minio/minio:latest + profiles: ["storage"] + restart: unless-stopped + logging: *json-log-rotation + command: server /data --address ":9000" --console-address ":9001" + environment: + TZ: ${TZ:-Asia/Shanghai} + MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY_ID:-minioadmin} + MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_ACCESS_KEY:-minioadmin} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio_data:/data + web: build: context: . @@ -89,5 +111,6 @@ services: volumes: postgres_data: + minio_data: caddy_data: caddy_config: