diff --git a/apps/backend/package-lock.json b/apps/backend/package-lock.json index eb301548..a362fbfe 100644 --- a/apps/backend/package-lock.json +++ b/apps/backend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "express": "^5.1.0", + "node-pg-migrate": "^8.0.4", "pg": "^8.16.3" }, "devDependencies": { @@ -462,6 +463,15 @@ "node": ">=18" } }, + "node_modules/@isaacs/cliui": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", + "integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@types/body-parser": { "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", @@ -519,7 +529,7 @@ "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~7.19.0" @@ -529,7 +539,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -585,6 +595,39 @@ "node": ">= 0.6" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/body-parser": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", @@ -609,6 +652,18 @@ "url": "https://opencollective.com/express" } }, + "node_modules/brace-expansion": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -647,6 +702,38 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -687,6 +774,20 @@ "node": ">=6.6.0" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -733,6 +834,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -814,6 +921,15 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -893,6 +1009,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -920,6 +1052,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -957,6 +1098,30 @@ "node": ">= 0.4" } }, + "node_modules/glob": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "BlueOak-1.0.0", + "dependencies": { + "foreground-child": "^3.3.1", + "jackspeak": "^4.1.1", + "minimatch": "^10.1.1", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^2.0.0" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1044,12 +1209,51 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", "license": "MIT" }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", + "integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^9.0.0" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1105,6 +1309,30 @@ "url": "https://opencollective.com/express" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1120,6 +1348,31 @@ "node": ">= 0.6" } }, + "node_modules/node-pg-migrate": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/node-pg-migrate/-/node-pg-migrate-8.0.4.tgz", + "integrity": "sha512-HTlJ6fOT/2xHhAUtsqSN85PGMAqSbfGJNRwQF8+ZwQ1+sVGNUTl/ZGEshPsOI3yV22tPIyHXrKXr3S0JxeYLrg==", + "license": "MIT", + "dependencies": { + "glob": "~11.1.0", + "yargs": "~17.7.0" + }, + "bin": { + "node-pg-migrate": "bin/node-pg-migrate.js" + }, + "engines": { + "node": ">=20.11.0" + }, + "peerDependencies": { + "@types/pg": ">=6.0.0 <9.0.0", + "pg": ">=4.3.0 <9.0.0" + }, + "peerDependenciesMeta": { + "@types/pg": { + "optional": true + } + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1153,6 +1406,12 @@ "wrappy": "1" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -1162,6 +1421,31 @@ "node": ">= 0.8" } }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/path-to-regexp": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", @@ -1368,6 +1652,15 @@ "node": ">= 0.10" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -1441,6 +1734,27 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/side-channel": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", @@ -1513,6 +1827,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1531,6 +1857,32 @@ "node": ">= 0.8" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -1606,7 +1958,7 @@ "version": "7.19.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unpipe": { @@ -1627,6 +1979,38 @@ "node": ">= 0.8" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -1641,6 +2025,42 @@ "engines": { "node": ">=0.4" } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } } } } diff --git a/apps/backend/package.json b/apps/backend/package.json index 79348b72..7e95fb42 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -22,6 +22,7 @@ }, "dependencies": { "express": "^5.1.0", + "node-pg-migrate": "^8.0.4", "pg": "^8.16.3" }, "devDependencies": { diff --git a/apps/backend/src/db/migrate.ts b/apps/backend/src/db/migrate.ts index 57168de7..e229ba68 100644 --- a/apps/backend/src/db/migrate.ts +++ b/apps/backend/src/db/migrate.ts @@ -1,42 +1,122 @@ -import fs from 'node:fs' import path from 'node:path' import process from 'node:process' import { fileURLToPath } from 'node:url' -import { closeDb, query, withTransaction } from './client.js' +import { runner, type RunnerOption } from 'node-pg-migrate' +import type { ClientConfig } from 'pg' + +import { runtimeConfig } from '../config/runtime.js' +import { closeDb, query } from './client.js' const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url)) const MIGRATIONS_DIR = path.join(CURRENT_DIR, 'migrations') +const MIGRATIONS_TABLE = 'schema_migrations' +const SHORT_NUMERIC_PREFIX_MESSAGE = /^Can't determine timestamp for \d+$/ export async function runDatabaseMigrations() { - const files = fs.readdirSync(MIGRATIONS_DIR) - .filter((name) => name.endsWith('.sql')) - .sort() + await ensureNodePgMigrateMetadataCompatibility() + await runner({ + databaseUrl: createMigrationDatabaseConfig(), + dir: MIGRATIONS_DIR, + direction: 'up', + migrationsTable: MIGRATIONS_TABLE, + migrationsSchema: 'public', + schema: 'public', + singleTransaction: true, + checkOrder: true, + logger: { + debug: () => undefined, + info: (message) => { + console.info(`[db:migrate] ${message}`) + }, + warn: (message) => { + console.warn(`[db:migrate] ${message}`) + }, + error: (message) => { + if (SHORT_NUMERIC_PREFIX_MESSAGE.test(message)) { + return + } + console.error(`[db:migrate] ${message}`) + }, + }, + } satisfies RunnerOption) + + await syncLegacyMigrationColumns() +} + +async function ensureNodePgMigrateMetadataCompatibility() { await query(` - CREATE TABLE IF NOT EXISTS schema_migrations ( + CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} ( id BIGSERIAL PRIMARY KEY, - filename TEXT NOT NULL UNIQUE, + name VARCHAR(255), + run_on TIMESTAMPTZ, + filename TEXT, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `) - const appliedRows = await query('SELECT filename FROM schema_migrations') - const applied = new Set(appliedRows.rows.map((row) => String(row.filename))) + await query(` + ALTER TABLE ${MIGRATIONS_TABLE} + ADD COLUMN IF NOT EXISTS name VARCHAR(255), + ADD COLUMN IF NOT EXISTS run_on TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS filename TEXT, + ADD COLUMN IF NOT EXISTS applied_at TIMESTAMPTZ + `) - for (const filename of files) { - if (applied.has(filename)) { - continue - } + await query(` + ALTER TABLE ${MIGRATIONS_TABLE} + ALTER COLUMN filename DROP NOT NULL, + ALTER COLUMN applied_at SET DEFAULT NOW() + `) - const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, filename), 'utf8') - await withTransaction(async (client) => { - await client.query(sql) - await client.query( - 'INSERT INTO schema_migrations (filename, applied_at) VALUES ($1, NOW())', - [filename], - ) - }) + await query(` + UPDATE ${MIGRATIONS_TABLE} + SET + name = COALESCE( + NULLIF(name, ''), + NULLIF(REGEXP_REPLACE(COALESCE(filename, ''), '\\.[^.]+$', ''), ''), + CONCAT('legacy_', id) + ), + run_on = COALESCE(run_on, applied_at, NOW()) + WHERE name IS NULL + OR name = '' + OR run_on IS NULL + `) + + await query(` + ALTER TABLE ${MIGRATIONS_TABLE} + ALTER COLUMN name SET NOT NULL, + ALTER COLUMN run_on SET NOT NULL + `) + + await query(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_schema_migrations_name + ON ${MIGRATIONS_TABLE}(name) + `) +} + +async function syncLegacyMigrationColumns() { + await query(` + UPDATE ${MIGRATIONS_TABLE} + SET + filename = COALESCE(NULLIF(filename, ''), CONCAT(name, '.sql')), + applied_at = COALESCE(applied_at, run_on, NOW()) + WHERE filename IS NULL + OR filename = '' + OR applied_at IS NULL + `) +} + +function createMigrationDatabaseConfig(): ClientConfig { + const connectionString = String(runtimeConfig.database?.url || '').trim() + if (!connectionString) { + throw new Error('DATABASE_URL 未配置,无法执行数据库迁移') + } + + return { + connectionString, + ssl: runtimeConfig.database?.ssl ? { rejectUnauthorized: false } : false, } } @@ -50,6 +130,6 @@ if (process.argv[1] && path.resolve(process.argv[1]) === currentFilePath) { .catch(async (error) => { console.error(error) await closeDb() - process.exit(1) + process.exitCode = 1 }) } diff --git a/apps/backend/src/db/migrations/001_init.sql b/apps/backend/src/db/migrations/001_init.sql index b17e4efb..14457cbc 100644 --- a/apps/backend/src/db/migrations/001_init.sql +++ b/apps/backend/src/db/migrations/001_init.sql @@ -1,5 +1,9 @@ +-- 初始化完整业务库结构。 +-- 本项目当前不维护历史测试库的逐步升级链路;新环境只需要执行这一份 init 迁移。 + CREATE EXTENSION IF NOT EXISTS pgcrypto; +-- 后台账号与审计日志。 CREATE TABLE IF NOT EXISTS admin_users ( id BIGSERIAL PRIMARY KEY, username TEXT NOT NULL UNIQUE, @@ -23,9 +27,16 @@ CREATE TABLE IF NOT EXISTS admin_audit_logs ( created_at TIMESTAMPTZ NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_created_at ON admin_audit_logs(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_target ON admin_audit_logs(target_type, target_id); +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_created_at + ON admin_audit_logs(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_admin_audit_logs_target + ON admin_audit_logs(target_type, target_id); +COMMENT ON TABLE admin_users IS '后台管理用户'; +COMMENT ON TABLE admin_audit_logs IS '后台操作审计日志'; +COMMENT ON COLUMN admin_audit_logs.payload_json IS '审计上下文快照,保留请求关键字段和变更内容'; + +-- 履约档案与履约资源要求。 CREATE TABLE IF NOT EXISTS fulfillment_profiles ( id BIGSERIAL PRIMARY KEY, profile_key TEXT NOT NULL UNIQUE, @@ -52,6 +63,12 @@ CREATE TABLE IF NOT EXISTS fulfillment_profile_requirements ( UNIQUE(profile_id, role_key) ); +COMMENT ON TABLE fulfillment_profiles IS '履约档案,定义不同履约执行器的基础能力'; +COMMENT ON COLUMN fulfillment_profiles.config_json IS '履约执行器配置快照,适合存放低频变化的扩展配置'; +COMMENT ON TABLE fulfillment_profile_requirements IS '履约档案需要的账号、角色或凭证要求'; +COMMENT ON COLUMN fulfillment_profile_requirements.config_json IS '履约资源要求的扩展配置'; + +-- 订单主数据与订单项。 CREATE TABLE IF NOT EXISTS orders ( id BIGSERIAL PRIMARY KEY, provider TEXT NOT NULL, @@ -90,9 +107,18 @@ CREATE TABLE IF NOT EXISTS order_items ( updated_at TIMESTAMPTZ NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); -CREATE INDEX IF NOT EXISTS idx_order_items_sku_code ON order_items(sku_code); +CREATE INDEX IF NOT EXISTS idx_order_items_order_id + ON order_items(order_id); +CREATE INDEX IF NOT EXISTS idx_order_items_sku_code + ON order_items(sku_code); +COMMENT ON TABLE orders IS '外部平台订单主表'; +COMMENT ON COLUMN orders.raw_payload_json IS '外部平台原始订单报文,便于追溯和兼容字段变化'; +COMMENT ON TABLE order_items IS '订单商品明细'; +COMMENT ON COLUMN order_items.spec_json IS '商品规格扩展字段'; +COMMENT ON COLUMN order_items.item_snapshot_json IS '订单项履约规划时的商品、路由和平台配置快照'; + +-- 履约任务、领取令牌和任务事件。 CREATE TABLE IF NOT EXISTS fulfillment_tasks ( id BIGSERIAL PRIMARY KEY, order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE, @@ -126,9 +152,12 @@ CREATE TABLE IF NOT EXISTS fulfillment_tasks ( UNIQUE(order_item_id, unit_index) ); -CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_status ON fulfillment_tasks(task_status, delivery_status); -CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_order ON fulfillment_tasks(order_id, order_item_id); -CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_claim_token ON fulfillment_tasks(claim_token); +CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_status + ON fulfillment_tasks(task_status, delivery_status); +CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_order + ON fulfillment_tasks(order_id, order_item_id); +CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_claim_token + ON fulfillment_tasks(claim_token); CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_platform_order_lookup ON fulfillment_tasks(provider, platform, platform_order_id, id DESC); CREATE INDEX IF NOT EXISTS idx_fulfillment_tasks_created_at_desc @@ -147,7 +176,8 @@ CREATE TABLE IF NOT EXISTS claim_tokens ( updated_at TIMESTAMPTZ NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_claim_tokens_status ON claim_tokens(status, expired_at); +CREATE INDEX IF NOT EXISTS idx_claim_tokens_status + ON claim_tokens(status, expired_at); CREATE TABLE IF NOT EXISTS task_events ( id BIGSERIAL PRIMARY KEY, @@ -157,8 +187,16 @@ CREATE TABLE IF NOT EXISTS task_events ( created_at TIMESTAMPTZ NOT NULL ); -CREATE INDEX IF NOT EXISTS idx_task_events_task_created_at ON task_events(task_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_task_events_task_created_at + ON task_events(task_id, created_at DESC); +COMMENT ON TABLE fulfillment_tasks IS '履约任务,一件商品按数量拆成独立履约单元'; +COMMENT ON COLUMN fulfillment_tasks.context_json IS '任务执行上下文,保存履约过程中的平台返回、链接、角色和中间状态'; +COMMENT ON TABLE claim_tokens IS '用户领取链接令牌'; +COMMENT ON TABLE task_events IS '任务事件流水'; +COMMENT ON COLUMN task_events.payload_json IS '事件 payload 快照'; + +-- 运行时上下文和快手云履约索引态。 CREATE TABLE IF NOT EXISTS task_runtime_contexts ( id BIGSERIAL PRIMARY KEY, task_id BIGINT NOT NULL UNIQUE REFERENCES fulfillment_tasks(id) ON DELETE CASCADE, @@ -200,3 +238,79 @@ CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_consume_status ON kuaishou_cloud_task_states(consume_status); CREATE INDEX IF NOT EXISTS idx_kuaishou_cloud_task_states_updated_at_desc ON kuaishou_cloud_task_states(updated_at DESC); + +COMMENT ON TABLE task_runtime_contexts IS '任务运行时领取和截图上下文'; +COMMENT ON COLUMN task_runtime_contexts.artifacts_json IS '运行时产物集合,如截图、日志或外部资源引用'; +COMMENT ON COLUMN task_runtime_contexts.state_json IS '运行时状态快照'; +COMMENT ON TABLE kuaishou_cloud_task_states IS '从任务上下文同步出来的快手云履约可查询状态'; + +-- 快手行业电子凭证。 +CREATE TABLE IF NOT EXISTS kuaishou_industry_vouchers ( + id BIGSERIAL PRIMARY KEY, + voucher_code TEXT NOT NULL UNIQUE, + oid TEXT NOT NULL, + order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, + task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL, + unit_index INTEGER NOT NULL, + seller_id TEXT NOT NULL DEFAULT '', + token TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'UNUSED', + valid_start_time BIGINT NOT NULL DEFAULT 0, + valid_end_time BIGINT NOT NULL DEFAULT 0, + consume_serial_num TEXT NOT NULL DEFAULT '', + consume_details_json JSONB NOT NULL DEFAULT '[]'::jsonb, + consumed_at TIMESTAMPTZ, + destroyed_at TIMESTAMPTZ, + raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, + send_callback_status TEXT NOT NULL DEFAULT 'success', + send_callback_attempt_count INTEGER NOT NULL DEFAULT 0, + send_callback_last_error TEXT NOT NULL DEFAULT '', + send_callback_response_json JSONB NOT NULL DEFAULT '{}'::jsonb, + send_callback_sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(oid, unit_index) +); + +CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_oid + ON kuaishou_industry_vouchers(oid, unit_index); +CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_order_id + ON kuaishou_industry_vouchers(order_id); +CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_task_id + ON kuaishou_industry_vouchers(task_id); +CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_status + ON kuaishou_industry_vouchers(status); +CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_seller_id + ON kuaishou_industry_vouchers(seller_id); +CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_send_callback_status + ON kuaishou_industry_vouchers(send_callback_status); + +COMMENT ON TABLE kuaishou_industry_vouchers IS '快手行业电子凭证码实例'; +COMMENT ON COLUMN kuaishou_industry_vouchers.consume_details_json IS '快手核销详情列表'; +COMMENT ON COLUMN kuaishou_industry_vouchers.raw_payload_json IS '快手行业电子凭证原始请求或回调报文'; +COMMENT ON COLUMN kuaishou_industry_vouchers.send_callback_response_json IS '发码回调响应内容'; + +-- 短链。 +CREATE TABLE IF NOT EXISTS short_links ( + id BIGSERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + target_url TEXT NOT NULL, + target_url_hash TEXT NOT NULL, + source TEXT NOT NULL DEFAULT '', + task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL, + expires_at TIMESTAMPTZ, + visit_count INTEGER NOT NULL DEFAULT 0, + last_visited_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE(source, task_id, target_url_hash) +); + +CREATE INDEX IF NOT EXISTS idx_short_links_code + ON short_links(code); +CREATE INDEX IF NOT EXISTS idx_short_links_task_id + ON short_links(task_id); +CREATE INDEX IF NOT EXISTS idx_short_links_expires_at + ON short_links(expires_at); + +COMMENT ON TABLE short_links IS '内部短链映射,用于隐藏较长领取链接或外部平台链接'; diff --git a/apps/backend/src/db/migrations/002_kuaishou_industry_vouchers.sql b/apps/backend/src/db/migrations/002_kuaishou_industry_vouchers.sql deleted file mode 100644 index 02a8c65e..00000000 --- a/apps/backend/src/db/migrations/002_kuaishou_industry_vouchers.sql +++ /dev/null @@ -1,29 +0,0 @@ -CREATE TABLE IF NOT EXISTS kuaishou_industry_vouchers ( - id BIGSERIAL PRIMARY KEY, - voucher_code TEXT NOT NULL UNIQUE, - oid TEXT NOT NULL, - order_id BIGINT REFERENCES orders(id) ON DELETE SET NULL, - task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL, - unit_index INTEGER NOT NULL, - token TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'UNUSED', - valid_start_time BIGINT NOT NULL DEFAULT 0, - valid_end_time BIGINT NOT NULL DEFAULT 0, - consume_serial_num TEXT NOT NULL DEFAULT '', - consume_details_json JSONB NOT NULL DEFAULT '[]'::jsonb, - consumed_at TIMESTAMPTZ, - destroyed_at TIMESTAMPTZ, - raw_payload_json JSONB NOT NULL DEFAULT '{}'::jsonb, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE(oid, unit_index) -); - -CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_oid - ON kuaishou_industry_vouchers(oid, unit_index); -CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_order_id - ON kuaishou_industry_vouchers(order_id); -CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_task_id - ON kuaishou_industry_vouchers(task_id); -CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_status - ON kuaishou_industry_vouchers(status); diff --git a/apps/backend/src/db/migrations/003_short_links.sql b/apps/backend/src/db/migrations/003_short_links.sql deleted file mode 100644 index 2cd2b7e1..00000000 --- a/apps/backend/src/db/migrations/003_short_links.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS short_links ( - id BIGSERIAL PRIMARY KEY, - code TEXT NOT NULL UNIQUE, - target_url TEXT NOT NULL, - target_url_hash TEXT NOT NULL, - source TEXT NOT NULL DEFAULT '', - task_id BIGINT REFERENCES fulfillment_tasks(id) ON DELETE SET NULL, - expires_at TIMESTAMPTZ, - visit_count INTEGER NOT NULL DEFAULT 0, - last_visited_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL, - updated_at TIMESTAMPTZ NOT NULL, - UNIQUE(source, task_id, target_url_hash) -); - -CREATE INDEX IF NOT EXISTS idx_short_links_code - ON short_links(code); - -CREATE INDEX IF NOT EXISTS idx_short_links_task_id - ON short_links(task_id); - -CREATE INDEX IF NOT EXISTS idx_short_links_expires_at - ON short_links(expires_at); diff --git a/apps/backend/src/db/migrations/004_kuaishou_industry_voucher_seller_id.sql b/apps/backend/src/db/migrations/004_kuaishou_industry_voucher_seller_id.sql deleted file mode 100644 index f029ff02..00000000 --- a/apps/backend/src/db/migrations/004_kuaishou_industry_voucher_seller_id.sql +++ /dev/null @@ -1,5 +0,0 @@ -ALTER TABLE kuaishou_industry_vouchers - ADD COLUMN IF NOT EXISTS seller_id TEXT NOT NULL DEFAULT ''; - -CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_seller_id - ON kuaishou_industry_vouchers(seller_id); diff --git a/apps/backend/src/db/migrations/005_kuaishou_industry_voucher_send_callback.sql b/apps/backend/src/db/migrations/005_kuaishou_industry_voucher_send_callback.sql deleted file mode 100644 index f5865d8d..00000000 --- a/apps/backend/src/db/migrations/005_kuaishou_industry_voucher_send_callback.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE kuaishou_industry_vouchers - ADD COLUMN IF NOT EXISTS send_callback_status TEXT NOT NULL DEFAULT 'success', - ADD COLUMN IF NOT EXISTS send_callback_attempt_count INTEGER NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS send_callback_last_error TEXT NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS send_callback_response_json JSONB NOT NULL DEFAULT '{}'::jsonb, - ADD COLUMN IF NOT EXISTS send_callback_sent_at TIMESTAMPTZ; - -CREATE INDEX IF NOT EXISTS idx_kuaishou_industry_vouchers_send_callback_status - ON kuaishou_industry_vouchers(send_callback_status); diff --git a/deploy/ubuntu-deploy.sh b/deploy/ubuntu-deploy.sh index 9e367512..b326d90b 100755 --- a/deploy/ubuntu-deploy.sh +++ b/deploy/ubuntu-deploy.sh @@ -2,7 +2,9 @@ set -Eeuo pipefail # Ubuntu 服务器一键部署脚本。 -# 用法:bash deploy/ubuntu-deploy.sh +# 用法: +# bash deploy/ubuntu-deploy.sh +# bash deploy/ubuntu-deploy.sh --reset-db PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV_FILE="${PROJECT_ROOT}/.env" @@ -12,6 +14,8 @@ BACKEND_CONTAINER_UID=1001 BACKEND_CONTAINER_GID=1001 HEALTH_URL="" READY_TIMEOUT="${READY_TIMEOUT:-120}" +RESET_DB=false +RESET_DB_CONFIRM="${RESET_DB_CONFIRM:-}" log() { printf '\033[1;34m[deploy]\033[0m %s\n' "$*" @@ -26,6 +30,44 @@ fail() { exit 1 } +print_usage() { + cat <<'EOF' +用法: + bash deploy/ubuntu-deploy.sh + bash deploy/ubuntu-deploy.sh --reset-db + +参数: + --reset-db 删除 Compose PostgreSQL 数据卷 postgres_data,并用当前 001_init.sql 重建数据库。 + --yes 跳过 --reset-db 的交互确认,也可使用环境变量 RESET_DB_CONFIRM=1。 + -h, --help 显示帮助。 + +注意: + --reset-db 会删除所有 PostgreSQL 数据,包括订单、任务、后台用户、审计日志和迁移记录。 + 它不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。 +EOF +} + +parse_args() { + while (($# > 0)); do + case "$1" in + --reset-db) + RESET_DB=true + ;; + --yes) + RESET_DB_CONFIRM=1 + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + fail "未知参数:$1。执行 bash deploy/ubuntu-deploy.sh --help 查看用法。" + ;; + esac + shift + done +} + read_env_value() { local key="$1" awk -F= -v key="${key}" ' @@ -86,6 +128,40 @@ validate_database_url_for_compose() { fi } +validate_reset_database_target() { + local database_url + database_url="$(read_env_value DATABASE_URL)" + + if [[ -n "${database_url}" && "${database_url}" != *"@postgres:"* ]]; then + fail "--reset-db 只支持重置本脚本管理的 Compose PostgreSQL。当前 DATABASE_URL 未指向 postgres 服务,请手动确认外部数据库后再处理。" + fi +} + +confirm_reset_database() { + if [[ "${RESET_DB}" != "true" ]]; then + return + fi + + validate_reset_database_target + + warn "即将重置 PostgreSQL 数据库:会删除订单、任务、后台用户、审计日志和所有迁移记录。" + warn "不会删除 apps/backend/data 下的平台 JSON 配置,也不会删除 Caddy 证书卷。" + + if [[ "${RESET_DB_CONFIRM}" == "1" ]]; then + warn "检测到 RESET_DB_CONFIRM=1 或 --yes,跳过交互确认。" + return + fi + + if [[ ! -t 0 ]]; then + fail "当前不是交互式终端。若确认要重置数据库,请执行:RESET_DB_CONFIRM=1 bash deploy/ubuntu-deploy.sh --reset-db" + fi + + local answer + printf '请输入 RESET 确认重置数据库:' + read -r answer + [[ "${answer}" == "RESET" ]] || fail "未确认重置数据库,已停止。" +} + ensure_data_dir() { mkdir -p "${BACKEND_DATA_DIR}" @@ -121,6 +197,65 @@ build_images() { docker compose build web || fail "Web 镜像构建失败。" } +resolve_compose_project_name() { + local project_name + project_name="$(read_env_value COMPOSE_PROJECT_NAME || true)" + + if [[ -z "${project_name}" ]]; then + project_name="$(basename "${PROJECT_ROOT}")" + fi + + printf '%s\n' "${project_name}" +} + +resolve_postgres_volume_name() { + local container_id volume_name project_name + + container_id="$(docker compose ps -aq postgres 2>/dev/null | head -n 1 || true)" + if [[ -n "${container_id}" ]]; then + volume_name="$( + docker inspect -f '{{range .Mounts}}{{if eq .Destination "/var/lib/postgresql/data"}}{{if eq .Type "volume"}}{{.Name}}{{end}}{{end}}{{end}}' "${container_id}" 2>/dev/null || true + )" + if [[ -n "${volume_name}" ]]; then + printf '%s\n' "${volume_name}" + return + fi + fi + + volume_name="$( + docker compose volumes 2>/dev/null \ + | awk '$2 ~ /(^|_)postgres_data$/ { print $2; exit }' || true + )" + if [[ -n "${volume_name}" ]]; then + printf '%s\n' "${volume_name}" + return + fi + + project_name="$(resolve_compose_project_name)" + printf '%s_postgres_data\n' "${project_name}" +} + +reset_database() { + if [[ "${RESET_DB}" != "true" ]]; then + return + fi + + local volume_name + volume_name="$(resolve_postgres_volume_name)" + + warn "开始重置数据库,将删除 Docker volume:${volume_name}" + docker compose stop web backend postgres >/dev/null 2>&1 || true + docker compose rm -f -s postgres >/dev/null 2>&1 || true + + if docker volume inspect "${volume_name}" >/dev/null 2>&1; then + docker volume rm "${volume_name}" >/dev/null || fail "删除 PostgreSQL 数据卷失败:${volume_name}" + log "已删除 PostgreSQL 数据卷:${volume_name}" + return + fi + + warn "未找到 PostgreSQL 数据卷:${volume_name},将按空库继续部署。" +} + wait_for_service() { local service="$1" local label="$2" @@ -204,6 +339,7 @@ deploy_stack() { check_compose_config pull_base_images build_images + reset_database start_postgres run_database_migrations restart_backend @@ -257,7 +393,7 @@ print_diagnostics() { if printf '%s\n' "${backend_logs}" | grep -q 'password authentication failed for user "postgres"'; then warn "检测到 PostgreSQL 密码认证失败。通常是 postgres_data 已用旧密码初始化,而 .env 改成了新密码。" - warn "无生产数据时可执行:docker compose down -v && bash deploy/ubuntu-deploy.sh" + warn "无生产数据时可执行:RESET_DB_CONFIRM=1 bash deploy/ubuntu-deploy.sh --reset-db" warn "需要保留数据时,请进入 postgres 容器用当前 .env 的 POSTGRES_PASSWORD 修改数据库用户密码。" fi @@ -268,11 +404,14 @@ print_diagnostics() { } main() { + parse_args "$@" + cd "${PROJECT_ROOT}" ensure_docker ensure_env_file validate_env_file + confirm_reset_database ensure_data_dir deploy_stack wait_for_health