From 2a55f2d3ef535006e72cdfe657ee6885982d6847 Mon Sep 17 00:00:00 2001 From: abearxiong Date: Thu, 4 Dec 2025 10:31:37 +0800 Subject: [PATCH] remove mark --- package.json | 7 +- pnpm-lock.yaml | 220 +++++++++++++- src/auth/index.ts | 11 + src/auth/middleware/auth-manual.ts | 81 +++++ src/auth/middleware/auth.ts | 56 ++++ src/auth/models/index.ts | 3 + src/auth/models/org.ts | 184 ++++++++++++ src/auth/models/user-secret.ts | 261 ++++++++++++++++ src/auth/models/user.ts | 370 +++++++++++++++++++++++ src/auth/oauth/auth.ts | 18 ++ src/auth/oauth/index.ts | 2 + src/auth/oauth/oauth.ts | 392 +++++++++++++++++++++++++ src/auth/oauth/salt.ts | 32 ++ src/models/org.ts | 47 +-- src/models/user.ts | 6 +- src/routes/index.ts | 2 +- src/routes/mark/index.ts | 1 - src/routes/mark/list.ts | 239 --------------- src/routes/mark/mark-model.ts | 327 --------------------- src/routes/mark/model.ts | 5 - src/routes/mark/services/mark.ts | 58 ---- src/routes/user/modules/wx-services.ts | 2 +- src/routes/user/modules/wx.ts | 1 - src/routes/user/secret-key/list.ts | 58 +++- src/routes/user/web-login.ts | 2 +- src/scripts/common.ts | 3 +- src/test/sync-user.ts | 2 +- wxmsg/src/index.ts | 2 +- wxmsg/src/modules/config.ts | 10 +- wxmsg/src/queue.ts | 9 + wxmsg/src/wx/index.ts | 20 +- wxmsg/task/worker/bun.config.ts | 15 + wxmsg/task/worker/index.ts | 38 +++ wxmsg/task/worker/package.json | 37 +++ wxmsg/task/worker/redis.ts | 42 +++ 35 files changed, 1837 insertions(+), 726 deletions(-) create mode 100644 src/auth/index.ts create mode 100644 src/auth/middleware/auth-manual.ts create mode 100644 src/auth/middleware/auth.ts create mode 100644 src/auth/models/index.ts create mode 100644 src/auth/models/org.ts create mode 100644 src/auth/models/user-secret.ts create mode 100644 src/auth/models/user.ts create mode 100644 src/auth/oauth/auth.ts create mode 100644 src/auth/oauth/index.ts create mode 100644 src/auth/oauth/oauth.ts create mode 100644 src/auth/oauth/salt.ts delete mode 100644 src/routes/mark/index.ts delete mode 100644 src/routes/mark/list.ts delete mode 100644 src/routes/mark/mark-model.ts delete mode 100644 src/routes/mark/model.ts delete mode 100644 src/routes/mark/services/mark.ts create mode 100644 wxmsg/src/queue.ts create mode 100644 wxmsg/task/worker/bun.config.ts create mode 100644 wxmsg/task/worker/index.ts create mode 100644 wxmsg/task/worker/package.json create mode 100644 wxmsg/task/worker/redis.ts diff --git a/package.json b/package.json index c0cfd69..76da0f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kevisual/code-center", - "version": "0.0.10", + "version": "0.0.11", "description": "code center", "type": "module", "main": "index.js", @@ -47,6 +47,7 @@ "@types/busboy": "^1.5.4", "@types/send": "^1.2.1", "@types/ws": "^8.18.1", + "bullmq": "^5.65.1", "busboy": "^1.6.0", "commander": "^14.0.2", "cookie": "^1.1.1", @@ -94,7 +95,6 @@ "socket.io": "^4.8.1", "strip-ansi": "^7.1.2", "tar": "^7.5.2", - "typescript": "^5.9.3", "ws": "npm:@kevisual/ws", "zod": "^4.1.13" }, @@ -106,6 +106,9 @@ "onlyBuiltDependencies": [ "esbuild", "sqlite3" + ], + "ignoredBuiltDependencies": [ + "msgpackr-extract" ] }, "packageManager": "pnpm@10.24.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb44a62..f0cf890 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 + bullmq: + specifier: ^5.65.1 + version: 5.65.1 busboy: specifier: ^1.6.0 version: 1.6.0 @@ -145,9 +148,6 @@ importers: tar: specifier: ^7.5.2 version: 7.5.2 - typescript: - specifier: ^5.9.3 - version: 5.9.3 ws: specifier: npm:@kevisual/ws version: '@kevisual/ws@8.0.0' @@ -155,6 +155,31 @@ importers: specifier: ^4.1.13 version: 4.1.13 + wxmsg: + dependencies: + '@kevisual/context': + specifier: ^0.0.4 + version: 0.0.4 + '@kevisual/router': + specifier: 0.0.33 + version: 0.0.33 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + devDependencies: + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 + wxmsg/pack-dist: dependencies: '@kevisual/context': @@ -173,6 +198,34 @@ importers: specifier: ^0.6.2 version: 0.6.2 + wxmsg/task/worker: + dependencies: + '@kevisual/context': + specifier: ^0.0.4 + version: 0.0.4 + '@kevisual/router': + specifier: 0.0.33 + version: 0.0.33 + '@types/node': + specifier: ^24.10.1 + version: 24.10.1 + crypto-js: + specifier: ^4.2.0 + version: 4.2.0 + xml2js: + specifier: ^0.6.2 + version: 0.6.2 + devDependencies: + '@types/bun': + specifier: ^1.3.3 + version: 1.3.3 + '@types/crypto-js': + specifier: ^4.2.2 + version: 4.2.2 + '@types/xml2js': + specifier: ^0.4.14 + version: 0.4.14 + packages: '@ioredis/commands@1.4.0': @@ -243,6 +296,36 @@ packages: resolution: {integrity: sha512-jlFxSlXUEz93cFW+UYT5BXv/rFVgiMQnIfqRYZ0gj1hSP8PMGRqMqUoHSLfKvfRRS4jseLSvTTeEKSQpZJtURg==} engines: {node: '>=10.0.0'} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + resolution: {integrity: sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==} + cpu: [arm64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + resolution: {integrity: sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==} + cpu: [x64] + os: [darwin] + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + resolution: {integrity: sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==} + cpu: [arm64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + resolution: {integrity: sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==} + cpu: [arm] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + resolution: {integrity: sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==} + cpu: [x64] + os: [linux] + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + resolution: {integrity: sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==} + cpu: [x64] + os: [win32] + '@noble/hashes@1.8.0': resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} engines: {node: ^14.21.3 || >=16} @@ -294,6 +377,9 @@ packages: '@types/archiver@7.0.0': resolution: {integrity: sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg==} + '@types/bun@1.3.3': + resolution: {integrity: sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g==} + '@types/busboy@1.5.4': resolution: {integrity: sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw==} @@ -348,6 +434,9 @@ packages: '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@types/xml2js@0.4.14': + resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} + '@zxing/text-encoding@0.9.0': resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} @@ -481,6 +570,12 @@ packages: buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + bullmq@5.65.1: + resolution: {integrity: sha512-QgDAzX1G9L5IRy4Orva5CfQTXZT+5K+OfO/kbPrAqN+pmL9LJekCzxijXehlm/u2eXfWPfWvIdJJIqiuz3WJSg==} + + bun-types@1.3.3: + resolution: {integrity: sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ==} + busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -557,6 +652,10 @@ packages: resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} engines: {node: '>= 14'} + cron-parser@4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + croner@4.1.97: resolution: {integrity: sha512-/f6gpQuxDaqXu+1kwQYSckUglPaOrHdbIlBAu0YuW8/Cdb45XwXYNUBXg3r/9Mo6n540Kn/smKcZWko5x99KrQ==} @@ -638,6 +737,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dezalgo@1.0.4: resolution: {integrity: sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==} @@ -1031,6 +1134,10 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + luxon@3.7.2: + resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==} + engines: {node: '>=12'} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -1095,6 +1202,13 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msgpackr-extract@3.0.3: + resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} + hasBin: true + + msgpackr@1.11.5: + resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==} + mute-stream@0.0.8: resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} @@ -1116,10 +1230,17 @@ packages: resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} engines: {node: '>= 0.4.0'} + node-abort-controller@3.1.1: + resolution: {integrity: sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==} + node-forge@1.3.1: resolution: {integrity: sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA==} engines: {node: '>= 6.13.0'} + node-gyp-build-optional-packages@5.2.2: + resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} + hasBin: true + nodemon@3.1.11: resolution: {integrity: sha512-is96t8F/1//UHAjNPHpbsNY46ELPpftGUoSVNXwUfMk/qdjSylYrWSu1XavVTBOn526kFiOR733ATgNBCQyH0g==} engines: {node: '>=10'} @@ -1618,11 +1739,6 @@ packages: tx2@1.0.5: resolution: {integrity: sha512-sJ24w0y03Md/bxzK4FU8J8JveYYUbSs2FViLJ2D/8bytSiyPRbuE3DyL/9UKYXTZlV3yXq0L8GLlhobTnekCVg==} - typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} - engines: {node: '>=14.17'} - hasBin: true - undefsafe@2.0.5: resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} @@ -1639,6 +1755,10 @@ packages: util@0.12.5: resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + uuid@11.1.0: + resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -1892,6 +2012,24 @@ snapshots: '@kevisual/ws@8.0.0': {} + '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3': + optional: true + + '@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3': + optional: true + '@noble/hashes@1.8.0': {} '@nodelib/fs.scandir@2.1.5': @@ -1973,6 +2111,10 @@ snapshots: dependencies: '@types/readdir-glob': 1.1.5 + '@types/bun@1.3.3': + dependencies: + bun-types: 1.3.3 + '@types/busboy@1.5.4': dependencies: '@types/node': 24.10.1 @@ -2032,6 +2174,10 @@ snapshots: dependencies: '@types/node': 24.10.1 + '@types/xml2js@0.4.14': + dependencies: + '@types/node': 24.10.1 + '@zxing/text-encoding@0.9.0': optional: true @@ -2160,6 +2306,22 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + bullmq@5.65.1: + dependencies: + cron-parser: 4.9.0 + ioredis: 5.8.2 + msgpackr: 1.11.5 + node-abort-controller: 3.1.1 + semver: 7.7.3 + tslib: 2.8.1 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + + bun-types@1.3.3: + dependencies: + '@types/node': 24.10.1 + busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -2237,6 +2399,10 @@ snapshots: crc-32: 1.2.2 readable-stream: 4.5.2 + cron-parser@4.9.0: + dependencies: + luxon: 3.7.2 + croner@4.1.97: {} cross-spawn@7.0.3: @@ -2269,6 +2435,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -2293,6 +2463,9 @@ snapshots: depd@2.0.0: {} + detect-libc@2.1.2: + optional: true + dezalgo@1.0.4: dependencies: asap: 2.0.6 @@ -2685,6 +2858,8 @@ snapshots: lru-cache@7.18.3: {} + luxon@3.7.2: {} + merge2@1.4.1: {} micromatch@4.0.8: @@ -2751,6 +2926,22 @@ snapshots: ms@2.1.3: {} + msgpackr-extract@3.0.3: + dependencies: + node-gyp-build-optional-packages: 5.2.2 + optionalDependencies: + '@msgpackr-extract/msgpackr-extract-darwin-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-darwin-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-arm64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-linux-x64': 3.0.3 + '@msgpackr-extract/msgpackr-extract-win32-x64': 3.0.3 + optional: true + + msgpackr@1.11.5: + optionalDependencies: + msgpackr-extract: 3.0.3 + mute-stream@0.0.8: {} nanoid@5.1.6: {} @@ -2767,8 +2958,15 @@ snapshots: netmask@2.0.2: {} + node-abort-controller@3.1.1: {} + node-forge@1.3.1: {} + node-gyp-build-optional-packages@5.2.2: + dependencies: + detect-libc: 2.1.2 + optional: true + nodemon@3.1.11: dependencies: chokidar: 3.6.0 @@ -3100,7 +3298,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.3(supports-color@5.5.0) + debug: 4.4.3 encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -3336,8 +3534,6 @@ snapshots: json-stringify-safe: 5.0.1 optional: true - typescript@5.9.3: {} - undefsafe@2.0.5: {} undici-types@7.16.0: {} @@ -3354,6 +3550,8 @@ snapshots: is-typed-array: 1.1.13 which-typed-array: 1.1.15 + uuid@11.1.0: {} + uuid@8.3.2: {} validator@13.12.0: {} diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 0000000..b304a6b --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,11 @@ + +/** + * 可以不需要user成功, 有则赋值,交给下一个中间件 + */ +export const authCan = 'auth-can'; +/** + * 必须需要user成功 + */ +export const auth = 'auth'; + +export * from './models/index.ts'; diff --git a/src/auth/middleware/auth-manual.ts b/src/auth/middleware/auth-manual.ts new file mode 100644 index 0000000..caa095e --- /dev/null +++ b/src/auth/middleware/auth-manual.ts @@ -0,0 +1,81 @@ +import { User } from '../models/user.ts'; +import http from 'node:http'; +import cookie from 'cookie'; +export const error = (msg: string, code = 500) => { + return JSON.stringify({ code, message: msg }); +}; +type CheckAuthOptions = { + check401?: boolean; // 是否返回权限信息 +}; +/** + * 手动验证token,如果token不存在,则返回401 + * @param req + * @param res + * @returns + */ +export const checkAuth = async (req: http.IncomingMessage, res: http.ServerResponse, opts?: CheckAuthOptions) => { + let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || ''; + const url = new URL(req.url || '', 'http://localhost'); + const check401 = opts?.check401 ?? true; // 是否返回401错误 + const resNoPermission = () => { + res.statusCode = 401; + res.end(error('Invalid authorization')); + return { tokenUser: null, token: null, hasToken: false }; + }; + if (!token) { + token = url.searchParams.get('token') || ''; + } + if (!token) { + const parsedCookies = cookie.parse(req.headers.cookie || ''); + token = parsedCookies.token || ''; + } + if (!token && check401) { + return resNoPermission(); + } + if (token) { + token = token.replace('Bearer ', ''); + } + let tokenUser; + const hasToken = !!token; // 是否有token存在 + + try { + tokenUser = await User.verifyToken(token); + } catch (e) { + console.log('checkAuth error', e); + res.statusCode = 401; + res.end(error('Invalid token')); + return { tokenUser: null, token: null, hasToken: false }; + } + return { tokenUser, token, hasToken }; +}; + +/** + * 获取登录用户,有则获取,无则返回null + * @param req + * @returns + */ +export const getLoginUser = async (req: http.IncomingMessage) => { + let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || ''; + const url = new URL(req.url || '', 'http://localhost'); + if (!token) { + token = url.searchParams.get('token') || ''; + } + if (!token) { + const parsedCookies = cookie.parse(req.headers.cookie || ''); + token = parsedCookies.token || ''; + } + + if (token) { + token = token.replace('Bearer ', ''); + } + if (!token) { + return null; + } + let tokenUser; + try { + tokenUser = await User.verifyToken(token); + return { tokenUser, token }; + } catch (e) { + return null; + } +}; diff --git a/src/auth/middleware/auth.ts b/src/auth/middleware/auth.ts new file mode 100644 index 0000000..d1f6dac --- /dev/null +++ b/src/auth/middleware/auth.ts @@ -0,0 +1,56 @@ +import { User } from '../models/user.ts'; +import type { App } from '@kevisual/router'; + +/** + * 添加auth中间件, 用于验证token + * 添加 id: auth 必须需要user成功 + * 添加 id: auth-can 可以不需要user成功,有则赋值 + * + * @param app + */ +export const addAuth = (app: App) => { + app + .route({ + path: 'auth', + id: 'auth', + }) + .define(async (ctx) => { + const token = ctx.query.token; + if (!token) { + app.throw(401, 'Token is required'); + } + const user = await User.getOauthUser(token); + if (!user) { + app.throw(401, 'Token is invalid'); + } + if (ctx.state) { + ctx.state.tokenUser = user; + } else { + ctx.state = { + tokenUser: user, + }; + } + }) + .addTo(app); + + app + .route({ + path: 'auth', + key: 'can', + id: 'auth-can', + }) + .define(async (ctx) => { + if (ctx.query?.token) { + const token = ctx.query.token; + const user = await User.getOauthUser(token); + if (ctx.state) { + ctx.state.tokenUser = user; + } else { + ctx.state = { + tokenUser: user, + }; + } + } + }) + .addTo(app); +}; diff --git a/src/auth/models/index.ts b/src/auth/models/index.ts new file mode 100644 index 0000000..db38a4b --- /dev/null +++ b/src/auth/models/index.ts @@ -0,0 +1,3 @@ +export { User, UserInit, UserServices, UserModel } from './user.ts'; +export { UserSecretInit, UserSecret } from './user-secret.ts'; +export { OrgInit, Org } from './org.ts'; \ No newline at end of file diff --git a/src/auth/models/org.ts b/src/auth/models/org.ts new file mode 100644 index 0000000..08bd414 --- /dev/null +++ b/src/auth/models/org.ts @@ -0,0 +1,184 @@ +import { DataTypes, Model, Op, Sequelize } from 'sequelize'; +import { useContextKey } from '@kevisual/context'; +import { SyncOpts, User } from './user.ts'; + +type AddUserOpts = { + role: string; +}; +export enum OrgRole { + admin = 'admin', + member = 'member', + owner = 'owner', +} +export class Org extends Model { + declare id: string; + declare username: string; + declare description: string; + declare users: { role: string; uid: string }[]; + /** + * operateId 是真实操作者的id + * @param user + * @param opts + */ + async addUser(user: User, opts?: { operateId?: string; role: string; needPermission?: boolean; isAdmin?: boolean }) { + const hasUser = this.users.find((u) => u.uid === user.id); + if (hasUser) { + return; + } + if (user.type !== 'user') { + throw Error('Only user can be added to org'); + } + if (opts?.needPermission) { + if (opts?.isAdmin) { + } else { + const adminUsers = this.users.filter((u) => u.role === 'admin' || u.role === 'owner'); + const adminIds = adminUsers.map((u) => u.uid); + const hasPermission = adminIds.includes(opts.operateId); + if (!hasPermission) { + throw Error('No permission'); + } + } + } + try { + await user.expireOrgs(); + } catch (e) { + console.error('expireOrgs', e); + } + const users = [...this.users]; + if (opts?.role === 'owner') { + const orgOwner = users.find((u) => u.role === 'owner'); + if (opts.isAdmin) { + } else { + if (!opts.operateId) { + throw Error('operateId is required'); + } + const owner = await User.findByPk(opts?.operateId); + if (!owner) { + throw Error('operateId is not found'); + } + if (orgOwner?.uid !== owner.id) { + throw Error('No permission'); + } + } + if (orgOwner) { + orgOwner.role = 'admin'; + } + users.push({ role: 'owner', uid: user.id }); + } else { + users.push({ role: opts?.role || 'member', uid: user.id }); + } + await Org.update({ users }, { where: { id: this.id } }); + + } + /** + * operateId 是真实操作者的id + * @param user + * @param opts + */ + async removeUser(user: User, opts?: { operateId?: string; needPermission?: boolean; isAdmin?: boolean }) { + if (opts?.needPermission) { + if (opts?.isAdmin) { + } else { + const adminUsers = this.users.filter((u) => u.role === 'admin' || u.role === 'owner'); + const adminIds = adminUsers.map((u) => u.uid); + const hasPermission = adminIds.includes(opts.operateId); + if (!hasPermission) { + throw Error('No permission'); + } + } + } + await user.expireOrgs(); + const users = this.users.filter((u) => u.uid !== user.id || u.role === 'owner'); + await Org.update({ users }, { where: { id: this.id } }); + } + /** + * operateId 是真实操作者的id + * @param user + * @param opts + */ + async getUsers(opts?: { operateId: string; needPermission?: boolean; isAdmin?: boolean }) { + const usersIds = this.users.map((u) => u.uid); + const orgUser = this.users; + if (opts?.needPermission) { + // 不在组织内或者不是管理员,如果需要权限,返回空 + if (opts.isAdmin) { + } else { + const hasPermission = usersIds.includes(opts.operateId); + if (!hasPermission) { + return { + hasPermission: false, + users: [], + }; + } + } + } + const _users = await User.findAll({ + where: { + id: { + [Op.in]: usersIds, + }, + }, + }); + + const users = _users.map((u) => { + const role = orgUser.find((r) => r.uid === u.id)?.role; + return { + id: u.id, + username: u.username, + role: role, + }; + }); + return { users }; + } + /** + * 检测用户是否在组织内,且角色为role + * @param user + * @param opts + */ + async getInRole(userId: string, role = 'admin') { + const user = this.users.find((u) => u.uid === userId && u.role === role); + return !!user; + } +} +/** + * 组织模型,在sequelize之后初始化 + */ +export const OrgInit = async (newSequelize?: any, tableName?: string, sync?: SyncOpts) => { + const sequelize = useContextKey('sequelize'); + Org.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + username: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + }, + description: { + type: DataTypes.STRING, + allowNull: true, + }, + users: { + type: DataTypes.JSONB, + allowNull: true, + defaultValue: [], + }, + }, + { + sequelize: newSequelize || sequelize, + modelName: tableName || 'cf_org', + paranoid: true, + }, + ); + if (sync) { + await Org.sync({ alter: true, logging: false, ...sync }).catch((e) => { + console.error('Org sync', e); + }); + return Org; + } + return Org; +}; +export const OrgModel = useContextKey('OrgModel', () => Org); diff --git a/src/auth/models/user-secret.ts b/src/auth/models/user-secret.ts new file mode 100644 index 0000000..d2d2659 --- /dev/null +++ b/src/auth/models/user-secret.ts @@ -0,0 +1,261 @@ +import { DataTypes, Model, Sequelize } from 'sequelize'; + +import { useContextKey } from '@kevisual/context'; +import { Redis } from 'ioredis'; +import { SyncOpts, User } from './user.ts'; +import { oauth } from '../oauth/auth.ts'; +import { OauthUser } from '../oauth/oauth.ts'; +export const redis = useContextKey('redis'); + +const UserSecretStatus = ['active', 'inactive', 'expired'] as const; + +type Data = { + [key: string]: any; + /** + * 微信开放平台的某一个应用的openid + */ + wxOpenid?: string; + /** + * 微信开放平台的unionid:主要 + */ + wxUnionid?: string; + /** + * 微信公众号的openid:次要 + */ + wxmpOpenid?: string; + +} +export class UserSecret extends Model { + static oauth = oauth; + declare id: string; + declare token: string; + declare userId: string; + declare orgId: string; + declare title: string; + declare description: string; + declare status: (typeof UserSecretStatus)[number]; + declare expiredTime: Date; + declare data: Data; + /** + * 验证token + * @param token + * @returns + */ + static async verifyToken(token: string) { + if (!oauth.isSecretKey(token)) { + return await oauth.verifyToken(token); + } + // const secretToken = await oauth.verifyToken(token); + // if (secretToken) { + // return secretToken; + // } + const userSecret = await UserSecret.findOne({ + where: { token }, + }); + if (!userSecret) { + return null; // 如果没有找到对应的用户密钥,则返回null + } + if (userSecret.isExpired()) { + return null; // 如果用户密钥已过期,则返回null + } + if (userSecret.status !== 'active') { + return null; // 如果用户密钥状态不是active,则返回null + } + // 如果用户密钥未过期,则返回用户信息 + const oauthUser = await userSecret.getOauthUser(); + if (!oauthUser) { + return null; // 如果没有找到对应的oauth用户,则返回null + } + // await oauth.saveSecretKey(oauthUser, userSecret.token); + // 存储到oauth中的token store中 + return oauthUser; + } + /** + * owner 组织用户的 oauthUser + * @returns + */ + async getOauthUser() { + const user = await User.findOne({ + where: { id: this.userId }, + attributes: ['id', 'username', 'type', 'owner'], + }); + let org: User = null; + if (!user) { + return null; // 如果没有找到对应的用户,则返回null + } + const expiredTime = this.expiredTime ? new Date(this.expiredTime).getTime() : null; + const oauthUser: Partial = { + id: user.id, + username: user.username, + type: 'user', + oauthExpand: { + expiredTime: expiredTime, + }, + }; + if (this.orgId) { + org = await User.findOne({ + where: { id: this.orgId }, + attributes: ['id', 'username', 'type', 'owner'], + }); + if (org) { + oauthUser.id = org.id; + oauthUser.username = org.username; + oauthUser.type = 'org'; + oauthUser.uid = user.id; + } else { + console.warn(`getOauthUser: org not found for orgId ${this.orgId}`); + } + } + + return oauth.getOauthUser(oauthUser); + } + isExpired() { + if (!this.expiredTime) { + return false; // 没有设置过期时间 + } + const now = Date.now(); + const expiredTime = new Date(this.expiredTime); + return now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期 + } + async createNewToken() { + if (this.token) { + await oauth.delToken(this.token); + } + const token = await UserSecret.createToken(); + this.token = token; + await this.save(); + return token; + } + static async createToken() { + let token = oauth.generateSecretKey(); + // 确保生成的token是唯一的 + while (await UserSecret.findOne({ where: { token } })) { + token = oauth.generateSecretKey(); + } + return token; + } + static async createSecret(tokenUser: { id: string; uid?: string }, expireDay = 365) { + const expireTime = expireDay * 24 * 60 * 60 * 1000; // 转换为毫秒 + const token = await UserSecret.createToken(); + let userId = tokenUser.id; + let orgId: string = null; + if (tokenUser.uid) { + userId = tokenUser.uid; + orgId = tokenUser.id; // 如果是组织用户,则uid是组织ID + } + const userSecret = await UserSecret.create({ + userId, + orgId, + token, + expiredTime: new Date(Date.now() + expireTime), + }); + + return userSecret; + } + async getPermission(opts: { id: string; uid?: string }) { + const { id, uid } = opts; + let userId: string = id; + let hasPermission = false; + let isUser = false; + let isAdmin: boolean = null; + if (uid) { + userId = uid; + } + if (!id) { + return { + hasPermission, + isUser, + isAdmin, + }; + } + if (this.userId === userId) { + hasPermission = true; + isUser = true; + } + + if (hasPermission) { + return { + hasPermission, + isUser, + isAdmin, + }; + } + if (this.orgId) { + const orgUser = await User.findByPk(this.orgId); + if (orgUser && orgUser.owner === userId) { + isAdmin = true; + hasPermission = true; + } + } + return { + hasPermission, + isUser, + isAdmin, + }; + } +} +/** + * 组织模型,在sequelize之后初始化 + */ +export const UserSecretInit = async (newSequelize?: any, tableName?: string, sync?: SyncOpts) => { + const sequelize = useContextKey('sequelize'); + UserSecret.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + description: { + type: DataTypes.TEXT, + allowNull: true, + }, + status: { + type: DataTypes.STRING, + allowNull: true, + defaultValue: 'active', + comment: '状态', + }, + title: { + type: DataTypes.TEXT, + allowNull: true, + }, + expiredTime: { + type: DataTypes.DATE, + allowNull: true, + }, + token: { + type: DataTypes.STRING, + allowNull: false, + comment: '用户密钥', + defaultValue: '', + }, + userId: { + type: DataTypes.UUID, + allowNull: true, + }, + data: { + type: DataTypes.JSONB, + allowNull: true, + defaultValue: {}, + }, + orgId: { + type: DataTypes.UUID, + allowNull: true, + comment: '组织ID', + }, + }, + { + sequelize: newSequelize || sequelize, + modelName: tableName || 'cf_user_secret', + }, + ); + if (sync) { + await UserSecret.sync({ alter: true, logging: false, ...sync }).catch((e) => { + console.error('UserSecret sync', e); + }); + return UserSecret; + } + return UserSecret; +}; +export const UserSecretModel = useContextKey('UserSecretModel', () => UserSecret); diff --git a/src/auth/models/user.ts b/src/auth/models/user.ts new file mode 100644 index 0000000..c37fea2 --- /dev/null +++ b/src/auth/models/user.ts @@ -0,0 +1,370 @@ +import { DataTypes, Model, Op, Sequelize } from 'sequelize'; +import { nanoid, customAlphabet } from 'nanoid'; +import { CustomError } from '@kevisual/router'; +import { Org } from './org.ts'; + +import { useContextKey } from '@kevisual/context'; +import { Redis } from 'ioredis'; +import { oauth } from '../oauth/auth.ts'; +import { cryptPwd } from '../oauth/salt.ts'; +import { OauthUser } from '../oauth/oauth.ts'; +export const redis = useContextKey('redis'); +import { UserSecret } from './user-secret.ts'; +type UserData = { + orgs?: string[]; + wxUnionId?: string; + phone?: string; +}; +export enum UserTypes { + 'user' = 'user', + 'org' = 'org', + 'visitor' = 'visitor', +} +/** + * 用户模型,在sequelize和Org之后初始化 + */ +export class User extends Model { + static oauth = oauth; + declare id: string; + declare username: string; + declare nickname: string; // 昵称 + declare password: string; + declare salt: string; + declare needChangePassword: boolean; + declare description: string; + declare data: UserData; + declare type: string; // user | org | visitor + declare owner: string; + declare orgId: string; + declare email: string; + declare avatar: string; + tokenUser: any; + setTokenUser(tokenUser: any) { + this.tokenUser = tokenUser; + } + + /** + * uid 是用于 orgId 的用户id, 如果uid存在,则表示是用户是组织,其中uid为真实用户 + * @param uid + * @returns + */ + async createToken(uid?: string, loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year' | 'week', expand: any = {}) { + const { id, username, type } = this; + const oauthUser: OauthUser = { + id, + username, + uid, + userId: uid || id, // 必存在,真实用户id + type: type as 'user' | 'org', + }; + if (uid) { + oauthUser.orgId = id; + } + const token = await oauth.generateToken(oauthUser, { type: loginType, hasRefreshToken: true, ...expand }); + return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken }; + } + /** + * 验证token + * @param token + * @returns + */ + static async verifyToken(token: string) { + return await UserSecret.verifyToken(token); + } + /** + * 刷新token + * @param refreshToken + * @returns + */ + static async refreshToken(refreshToken: string) { + const token = await oauth.refreshToken(refreshToken); + return { accessToken: token.accessToken, refreshToken: token.refreshToken, token: token.accessToken }; + } + static async getOauthUser(token: string) { + return await UserSecret.verifyToken(token); + } + /** + * 清理用户的token,需要重新登陆 + * @param userid + * @param orgid + * @returns + */ + static async clearUserToken(userid: string, type: 'org' | 'user' = 'user') { + return await oauth.expireUserTokens(userid, type); + } + /** + * 获取用户信息, 并设置tokenUser + * @param token + * @returns + */ + static async getUserByToken(token: string) { + const oauthUser = await UserSecret.verifyToken(token); + if (!oauthUser) { + throw new CustomError('Token is invalid. get UserByToken'); + } + const userId = oauthUser?.uid || oauthUser.id; + const user = await User.findByPk(userId); + user.setTokenUser(oauthUser); + return user; + } + /** + * 判断是否在用户列表中, 需要预先设置 tokenUser + * orgs has set curentUser + * @param username + * @param includeMe + * @returns + */ + async hasUser(username: string, includeMe = true) { + const orgs = await this.getOrgs(); + const me = this.username; + const allUsers = [...orgs]; + if (includeMe) { + allUsers.push(me); + } + return allUsers.includes(username); + } + static async createUser(username: string, password?: string, description?: string) { + const user = await User.findOne({ where: { username } }); + if (user) { + throw new CustomError('User already exists'); + } + const salt = nanoid(6); + let needChangePassword = !password; + password = password || '123456'; + const cPassword = cryptPwd(password, salt); + return await User.create({ username, password: cPassword, description, salt, needChangePassword }); + } + static async createOrg(username: string, owner: string, description?: string) { + const user = await User.findOne({ where: { username } }); + if (user) { + throw new CustomError('User already exists'); + } + const me = await User.findByPk(owner); + if (!me) { + throw new CustomError('Owner not found'); + } + if (me.type !== 'user') { + throw new CustomError('Owner type is not user'); + } + const org = await Org.create({ username, description, users: [{ uid: owner, role: 'owner' }] }); + const newUser = await User.create({ username, password: '', description, type: 'org', owner, orgId: org.id }); + // owner add + await redis.del(`user:${me.id}:orgs`); + return newUser; + } + async createPassword(password: string) { + const salt = this.salt; + const cPassword = cryptPwd(password, salt); + this.password = cPassword; + await this.update({ password: cPassword }); + return cPassword; + } + checkPassword(password: string) { + const salt = this.salt; + const cPassword = cryptPwd(password, salt); + return this.password === cPassword; + } + /** + * 获取用户信息, 需要先设置 tokenUser 或者设置 uid + * @param uid 如果存在,则表示是组织,其中uid为真实用户 + * @returns + */ + async getInfo(uid?: string) { + const orgs = await this.getOrgs(); + + const info: Record = { + id: this.id, + username: this.username, + nickname: this.nickname, + description: this.description, + needChangePassword: this.needChangePassword, + type: this.type, + avatar: this.avatar, + orgs, + }; + const tokenUser = this.tokenUser; + if (uid) { + info.uid = uid; + } else if (tokenUser.uid) { + info.uid = tokenUser.uid; + } + return info; + } + /** + * 获取用户组织 + * @returns + */ + async getOrgs() { + let id = this.id; + if (this.type === 'org') { + if (this.tokenUser && this.tokenUser.uid) { + id = this.tokenUser.uid; + } else { + throw new CustomError(400, 'Permission denied'); + } + } + const cache = await redis.get(`user:${id}:orgs`); + if (cache) { + return JSON.parse(cache) as string[]; + } + const orgs = await Org.findAll({ + order: [['updatedAt', 'DESC']], + where: { + users: { + [Op.contains]: [ + { + uid: id, + }, + ], + }, + }, + }); + const orgNames = orgs.map((org) => org.username); + if (orgNames.length > 0) { + await redis.set(`user:${id}:orgs`, JSON.stringify(orgNames), 'EX', 60 * 60); // 1 hour + } + return orgNames; + } + async expireOrgs() { + await redis.del(`user:${this.id}:orgs`); + } +} +export type SyncOpts = { + alter?: boolean; + logging?: any; + force?: boolean; +}; +export const UserInit = async (newSequelize?: any, tableName?: string, sync?: SyncOpts) => { + const sequelize = useContextKey('sequelize'); + User.init( + { + id: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + }, + username: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + // 用户名或者手机号 + // 创建后避免修改的字段,当注册用户后,用户名注册则默认不能用手机号 + }, + nickname: { + type: DataTypes.TEXT, + allowNull: true, + }, + + password: { + type: DataTypes.STRING, + allowNull: true, + }, + email: { + type: DataTypes.STRING, + allowNull: true, + }, + avatar: { + type: DataTypes.TEXT, + allowNull: true, + }, + salt: { + type: DataTypes.STRING, + allowNull: true, + }, + description: { + type: DataTypes.TEXT, + }, + type: { + type: DataTypes.STRING, + defaultValue: 'user', + }, + owner: { + type: DataTypes.UUID, + }, + orgId: { + type: DataTypes.UUID, + }, + needChangePassword: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + data: { + type: DataTypes.JSONB, + defaultValue: {}, + }, + }, + { + sequelize: newSequelize || sequelize, + tableName: tableName || 'cf_user', // codeflow user + paranoid: true, + }, + ); + if (sync) { + await User.sync({ alter: true, logging: true, ...sync }) + .then((res) => { + initializeUser(); + }) + .catch((err) => { + console.error('Sync User error', err); + }); + return User; + } + return User; +}; +const letter = 'abcdefghijklmnopqrstuvwxyz'; +const custom = customAlphabet(letter, 6); +export const initializeUser = async (pwd = custom()) => { + const w = await User.findOne({ where: { username: 'root' }, logging: false }); + if (!w) { + const root = await User.createUser('root', pwd, '系统管理员'); + const org = await User.createOrg('admin', root.id, '管理员'); + console.info(' new Users name', root.username, org.username); + console.info('new Users root password', pwd); + console.info('new Users id', root.id, org.id); + const demo = await createDemoUser(); + return { + code: 200, + data: { root, org, pwd: pwd, demo }, + }; + } else { + return { + code: 500, + message: 'Users has been created', + }; + } +}; +export const createDemoUser = async (username = 'demo', pwd = custom()) => { + const u = await User.findOne({ where: { username }, logging: false }); + if (!u) { + const user = await User.createUser(username, pwd, 'demo'); + console.info('new Users name', user.username, pwd); + return { + code: 200, + data: { user, pwd: pwd }, + }; + } else { + console.info('Users has been created', u.username); + return { + code: 500, + message: 'Users has been created', + }; + } +}; +// initializeUser(); + +export class UserServices extends User { + static async loginByPhone(phone: string) { + let user = await User.findOne({ where: { username: phone } }); + let isNew = false; + if (!user) { + user = await User.createUser(phone, phone.slice(-6)); + isNew = true; + } + const token = await user.createToken(null, 'season'); + return { ...token, isNew }; + } + static initializeUser = initializeUser; + static createDemoUser = createDemoUser; +} + +export const UserModel = useContextKey('UserModel', () => UserServices); diff --git a/src/auth/oauth/auth.ts b/src/auth/oauth/auth.ts new file mode 100644 index 0000000..c99e699 --- /dev/null +++ b/src/auth/oauth/auth.ts @@ -0,0 +1,18 @@ +import { OAuth, RedisTokenStore } from './oauth.ts'; +import { useContextKey } from '@kevisual/use-config/context'; +import { Redis } from 'ioredis'; + +export const oauth = useContextKey('oauth', () => { + const redis = useContextKey('redis'); + const store = new RedisTokenStore(redis); + // redis是promise + if (redis instanceof Promise) { + redis.then((r) => { + store.setRedis(r); + }); + } else if (redis) { + store.setRedis(redis); + } + const oauth = new OAuth(store); + return oauth; +}); diff --git a/src/auth/oauth/index.ts b/src/auth/oauth/index.ts new file mode 100644 index 0000000..3ebdf92 --- /dev/null +++ b/src/auth/oauth/index.ts @@ -0,0 +1,2 @@ +export * from './oauth.ts'; +export * from './salt.ts'; \ No newline at end of file diff --git a/src/auth/oauth/oauth.ts b/src/auth/oauth/oauth.ts new file mode 100644 index 0000000..8ea9e27 --- /dev/null +++ b/src/auth/oauth/oauth.ts @@ -0,0 +1,392 @@ +/** + * 一个生成和验证token的模块,不使用jwt,使用redis缓存, + * token 分为两种,一种是access_token,一种是refresh_token + * + * access_token 用于验证用户是否登录,过期时间为1小时 + * refresh_token 用于刷新access_token,过期时间为7天 + * + * 生成token时,会根据用户信息生成一个access_token和refresh_token,并缓存到redis中 + * 验证token时,会根据token从redis中获取用户信息 + * 刷新token时,会根据refresh_token生成一个新的access_token和refresh_token,并缓存到redis中 + * + * 并删除旧的access_token和refresh_token + * + * 生成token的方法,使用nanoid生成一个随机字符串 + * 验证token的方法,使用redis的get方法验证token是否存在 + * + * 刷新token的方法,使用redis的set方法刷新token + * + * 缓存和获取都可以不使用redis,只是用可拓展的接口。store.get和store.set去实现。 + */ + +import { Redis } from 'ioredis'; +import { customAlphabet } from 'nanoid'; + +export const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; +export const randomId16 = customAlphabet(alphabet, 16); +export const randomId24 = customAlphabet(alphabet, 24); +export const randomId32 = customAlphabet(alphabet, 32); +export const randomId64 = customAlphabet(alphabet, 64); + +export type OauthUser = { + /** + * 真实用户,非org + */ + id: string; + /** + * 组织id,非必须存在 + */ + orgId?: string; + /** + * 必存在,真实用户id + */ + userId: string; + /** + * 当前用户的id,如果是org,则uid为org的id + */ + uid?: string; + username: string; + type?: 'user' | 'org'; // 用户类型,默认是user,token类型是用于token的扩展 + oauthType?: 'user' | 'token'; // 用户类型,默认是user,token类型是用于token的扩展 + oauthExpand?: UserExpand; +}; +export type UserExpand = { + createTime?: number; + accessToken?: string; + refreshToken?: string; + [key: string]: any; +} & StoreSetOpts; + +type StoreSetOpts = { + loginType?: 'default' | 'plugin' | 'month' | 'season' | 'year' | 'week' | 'day'; // 登陆类型 'default' | 'plugin' | 'month' | 'season' | 'year' + expire?: number; // 过期时间,单位为秒 + hasRefreshToken?: boolean; + [key: string]: any; +}; +interface Store { + redis?: Redis; + getObject: (key: string) => Promise; + setObject: (key: string, value: T, opts?: StoreSetOpts) => Promise; + expire: (key: string, ttl?: number) => Promise; + delObject: (value?: T) => Promise; + keys: (key?: string) => Promise; + setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise; + delKeys: (keys: string[]) => Promise; +} +export class RedisTokenStore implements Store { + redis: Redis; + private prefix: string = 'oauth:'; + constructor(redis?: Redis, prefix?: string) { + this.redis = redis; + this.prefix = prefix || this.prefix; + } + async setRedis(redis: Redis) { + this.redis = redis; + } + async set(key: string, value: string, ttl?: number) { + await this.redis.set(this.prefix + key, value, 'EX', ttl); + } + async get(key: string) { + return await this.redis.get(this.prefix + key); + } + async expire(key: string, ttl?: number) { + await this.redis.expire(this.prefix + key, ttl); + } + async keys(key?: string) { + return await this.redis.keys(this.prefix + key); + } + async getObject(key: string) { + try { + const value = await this.get(key); + if (!value) { + return null; + } + return JSON.parse(value); + } catch (error) { + console.log('get key parse error', error); + return null; + } + } + async del(key: string) { + const number = await this.redis.del(this.prefix + key); + return number; + } + async setObject(key: string, value: OauthUser, opts?: StoreSetOpts) { + await this.set(key, JSON.stringify(value), opts?.expire); + } + async delObject(value?: OauthUser) { + const refreshToken = value?.oauthExpand?.refreshToken; + const accessToken = value?.oauthExpand?.accessToken; + // 清理userPerfix + let userPrefix = 'user:' + value?.id; + if (value?.orgId) { + userPrefix = 'org:' + value?.orgId + ':user:' + value?.id; + } + if (refreshToken) { + await this.del(refreshToken); + await this.del(userPrefix + ':refreshToken:' + refreshToken); + } + if (accessToken) { + await this.del(accessToken); + await this.del(userPrefix + ':token:' + accessToken); + } + } + async setToken(data: { accessToken: string; refreshToken: string; value?: OauthUser }, opts?: StoreSetOpts) { + const { accessToken, refreshToken, value } = data; + let userPrefix = 'user:' + value?.id; + if (value?.orgId) { + userPrefix = 'org:' + value?.orgId + ':user:' + value?.id; + } + // 计算过期时间,根据opts.expire 和 opts.loginType + // 如果expire存在,则使用expire,否则使用opts.loginType 进行计算; + let expire = opts?.expire; + if (!expire) { + switch (opts.loginType) { + case 'day': + expire = 24 * 60 * 60; + break; + case 'week': + expire = 7 * 24 * 60 * 60; + break; + case 'month': + expire = 30 * 24 * 60 * 60; + break; + case 'season': + expire = 90 * 24 * 60 * 60; + break; + default: + expire = 7 * 24 * 60 * 60; // 默认过期时间为7天 + } + } else { + expire = Math.min(expire, 60 * 60 * 24 * 30, 60 * 60 * 24 * 90); // 默认的过期时间最大为90天 + } + + await this.set(accessToken, JSON.stringify(value), expire); + await this.set(userPrefix + ':token:' + accessToken, accessToken, expire); + if (refreshToken) { + let refreshTokenExpire = Math.min(expire * 7, 60 * 60 * 24 * 30, 60 * 60 * 24 * 365); // 最大为一年 + // 小于7天, 则设置为7天 + if (refreshTokenExpire < 60 * 60 * 24 * 7) { + refreshTokenExpire = 60 * 60 * 24 * 7; + } + await this.set(refreshToken, JSON.stringify(value), refreshTokenExpire); + await this.set(userPrefix + ':refreshToken:' + refreshToken, refreshToken, refreshTokenExpire); + } + } + async delKeys(keys: string[]) { + const prefix = this.prefix; + const number = await this.redis.del(keys.map((key) => prefix + key)); + return number; + } +} + +export class OAuth { + private store: Store; + + constructor(store: Store) { + this.store = store; + } + generateSecretKey(sk = true) { + if (sk) { + return 'sk_' + randomId64(); + } + return 'st_' + randomId32(); + } + /** + * 生成token + * @param user + * @param user.id 访问者id + * @param user.uid 如果是org,这个是真实用户id,id是orgId + * @param user.userId 真实用户id + * @param user.orgId 组织id,可选 + * @param user.username + * @param user.type + * @returns + */ + async generateToken( + user: T, + expandOpts?: StoreSetOpts, + ): Promise<{ + accessToken: string; + refreshToken?: string; + }> { + // 拥有refreshToken 为 true 时,accessToken 为 st_ 开头,refreshToken 为 rk_开头 + // 意思是secretToken 和 secretKey的缩写 + const accessToken = expandOpts?.hasRefreshToken ? 'st_' + randomId32() : 'sk_' + randomId64(); + const refreshToken = expandOpts?.hasRefreshToken ? 'rk_' + randomId64() : null; + // 初始化 appExpand + user.oauthExpand = user.oauthExpand || {}; + if (expandOpts) { + user.oauthExpand = { + ...user.oauthExpand, + ...expandOpts, + accessToken, + createTime: new Date().getTime(), // + }; + if (expandOpts?.hasRefreshToken) { + user.oauthExpand.refreshToken = refreshToken; + } + } + await this.store.setToken({ accessToken, refreshToken, value: user }, expandOpts); + + return { accessToken, refreshToken }; + } + async saveSecretKey(oauthUser: T, secretKey: string, opts?: StoreSetOpts) { + // 生成一个secretKey + // 设置到store中 + oauthUser.oauthExpand = { + ...oauthUser.oauthExpand, + accessToken: secretKey, + description: 'secretKey', + createTime: new Date().getTime(), // 创建时间 + }; + await this.store.setToken( + { accessToken: secretKey, refreshToken: '', value: oauthUser }, + { + ...opts, + hasRefreshToken: false, + }, + ); + return secretKey; + } + getOauthUser({ id, uid, username, type }: Partial): OauthUser { + const oauthUser: OauthUser = { + id, + username, + uid, + userId: uid || id, // 必存在,真实用户id + type: type as 'user' | 'org', + }; + if (uid) { + oauthUser.orgId = id; + } + return oauthUser; + } + /** + * 验证token,如果token不存在,返回null + * @param token + * @returns + */ + async verifyToken(token: string) { + const res = await this.store.getObject(token); + return res; + } + /** + * 验证token是否是accessToken, sk 开头的为secretKey,没有refreshToken + * @param token + * @returns + */ + isSecretKey(token: string) { + if (!token) { + return false; + } + // 如果是sk_开头,则是secretKey + if (token.startsWith('sk_')) { + return true; + } + return false; + } + /** + * 刷新token + * @param refreshToken + * @returns + */ + async refreshToken(refreshToken: string) { + const user = await this.store.getObject(refreshToken); + if (!user) { + // 过期 + throw new Error('Refresh token not found'); + } + // 删除旧的token + await this.store.delObject({ ...user }); + const token = await this.generateToken( + { ...user }, + { + ...user.oauthExpand, + hasRefreshToken: true, + }, + ); + console.log('resetToken token', await this.store.keys()); + + return token; + } + /** + * 刷新token的过期时间 + * expand 为扩展参数,可以扩展到user.oauthExpand中 + * @param token + * @returns + */ + async resetToken(accessToken: string, expand?: Record) { + const user = await this.store.getObject(accessToken); + if (!user) { + // 过期 + throw new Error('token not found'); + } + user.oauthExpand = user.oauthExpand || {}; + const refreshToken = user.oauthExpand.refreshToken; + if (refreshToken) { + await this.store.delObject(user); + } + user.oauthExpand = { + ...user.oauthExpand, + ...expand, + }; + const token = await this.generateToken( + { ...user }, + { + ...user.oauthExpand, + hasRefreshToken: true, + }, + ); + + return token; + } + /** + * 过期token + * @param token + */ + async delToken(token: string) { + const user = await this.store.getObject(token); + if (!user) { + // 过期 + throw new Error('token not found'); + } + this.store.delObject(user); + } + + /** + * 获取某一个用户的所有token + * @param userId + * @returns + */ + async getUserTokens(userId: string, orgId?: string) { + const userPrefix = orgId ? `org:${orgId}:user:${userId}` : `user:${userId}`; + const tokens = await this.store.keys(`${userPrefix}:token:*`); + return tokens; + } + /** + * 过期某一个用户的所有token + * @param userId + * @param orgId + */ + async expireUserTokens(userId: string, type: 'user' | 'org' = 'user') { + const userPrefix = type === 'org' ? `org:${userId}:user:*:` : `user:${userId}`; + const tokensKeys = await this.store.keys(`${userPrefix}:token:*`); + for (const tokenKey of tokensKeys) { + try { + const token = await this.store.redis.get(tokenKey); + const user = await this.store.getObject(token); + this.store.delObject(user); + } catch (error) { + console.error('expireUserTokens error', userId, type, error); + } + } + } + /** + * 过期所有用户的token, 然后重启服务 + */ + async expireAllTokens() { + const tokens = await this.store.keys('*'); + await this.store.delKeys(tokens); + } +} diff --git a/src/auth/oauth/salt.ts b/src/auth/oauth/salt.ts new file mode 100644 index 0000000..6cb4857 --- /dev/null +++ b/src/auth/oauth/salt.ts @@ -0,0 +1,32 @@ +import MD5 from 'crypto-js/md5.js'; + +/** + * 生成随机盐 + * @returns + */ +export const getRandomSalt = () => { + return Math.random().toString().slice(2, 7); +}; + +/** + * 加密密码 + * @param password + * @param salt + * @returns + */ +export const cryptPwd = (password: string, salt = '') => { + const saltPassword = password + ':' + salt; + const md5 = MD5(saltPassword); + return md5.toString(); +}; + +/** + * Check password + * @param password + * @param salt + * @param md5 + * @returns + */ +export const checkPwd = (password: string, salt: string, md5: string) => { + return cryptPwd(password, salt) === md5; +}; diff --git a/src/models/org.ts b/src/models/org.ts index 8cbf270..ab48865 100644 --- a/src/models/org.ts +++ b/src/models/org.ts @@ -1,46 +1 @@ -// import { DataTypes, Model, Sequelize } from 'sequelize'; -// import { useContextKey } from '@kevisual/context'; -// const sequelize = useContextKey('sequelize'); -// export class Org extends Model { -// declare id: string; -// declare username: string; -// declare description: string; -// declare users: { role: string; uid: string }[]; -// } - -// Org.init( -// { -// id: { -// type: DataTypes.UUID, -// primaryKey: true, -// defaultValue: DataTypes.UUIDV4, -// }, -// username: { -// type: DataTypes.STRING, -// allowNull: false, -// unique: true, -// }, -// description: { -// type: DataTypes.STRING, -// allowNull: true, -// }, -// users: { -// type: DataTypes.JSONB, -// allowNull: true, -// defaultValue: [], -// }, -// }, -// { -// sequelize, -// modelName: 'cf_org', -// paranoid: true, -// }, -// ); - -// Org.sync({ alter: true, logging: false }).catch((e) => { -// console.error('Org sync', e); -// }); - -// useContextKey('OrgModel', () => Org); -import { Org } from '@kevisual/code-center-module/models'; -export { Org }; +export { Org } from '../auth/models/index.ts' \ No newline at end of file diff --git a/src/models/user.ts b/src/models/user.ts index 62272fc..56875ef 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,6 +1,6 @@ -import { User, UserInit, UserServices } from '@kevisual/code-center-module/models'; -import { UserSecretInit, UserSecret } from '@kevisual/code-center-module/models'; -import { OrgInit } from '@kevisual/code-center-module/models'; +import { User, UserInit, UserServices } from '../auth/models/index.ts'; +import { UserSecretInit, UserSecret } from '../auth/models/index.ts'; +import { OrgInit } from '../auth/models/index.ts'; export { User, UserInit, UserServices, UserSecret }; const init = async () => { await OrgInit(null, null, { diff --git a/src/routes/index.ts b/src/routes/index.ts index 6753701..5acbbee 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -12,6 +12,6 @@ import './micro-app/index.ts'; import './config/index.ts'; -import './mark/index.ts'; +// import './mark/index.ts'; import './file-listener/index.ts'; diff --git a/src/routes/mark/index.ts b/src/routes/mark/index.ts deleted file mode 100644 index 366cb31..0000000 --- a/src/routes/mark/index.ts +++ /dev/null @@ -1 +0,0 @@ -import './list.ts'; \ No newline at end of file diff --git a/src/routes/mark/list.ts b/src/routes/mark/list.ts deleted file mode 100644 index ba8a2e1..0000000 --- a/src/routes/mark/list.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { app } from '@/app.ts'; -import { MarkModel } from './model.ts'; -import { MarkServices } from './services/mark.ts'; -import dayjs from 'dayjs'; - -app - .route({ - path: 'mark', - key: 'list', - description: 'mark list.', - middleware: ['auth'], - }) - .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; - ctx.body = await MarkServices.getList({ - uid: tokenUser.id, - query: ctx.query, - queryType: 'simple', - }); - }) - .addTo(app); - -app - .route({ - path: 'mark', - key: 'getVersion', - middleware: ['auth'], - }) - .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; - const { id } = ctx.query; - if (id) { - const markModel = await MarkModel.findByPk(id); - if (!markModel) { - ctx.throw(404, 'mark not found'); - } - if (markModel.uid !== tokenUser.id) { - ctx.throw(403, 'no permission'); - } - ctx.body = { - version: Number(markModel.version), - updatedAt: markModel.updatedAt, - createdAt: markModel.createdAt, - id: markModel.id, - }; - } else { - ctx.throw(400, 'id is required'); - // const [markModel, created] = await MarkModel.findOrCreate({ - // where: { - // uid: tokenUser.id, - // puid: tokenUser.uid, - // title: dayjs().format('YYYY-MM-DD'), - // }, - // defaults: { - // title: dayjs().format('YYYY-MM-DD'), - // uid: tokenUser.id, - // markType: 'wallnote', - // tags: ['daily'], - // }, - // }); - // ctx.body = { - // version: Number(markModel.version), - // updatedAt: markModel.updatedAt, - // createdAt: markModel.createdAt, - // id: markModel.id, - // created: created, - // }; - } - }) - .addTo(app); - -app - .route({ - path: 'mark', - key: 'get', - middleware: ['auth'], - }) - .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; - const { id } = ctx.query; - if (id) { - const markModel = await MarkModel.findByPk(id); - if (!markModel) { - ctx.throw(404, 'mark not found'); - } - if (markModel.uid !== tokenUser.id) { - ctx.throw(403, 'no permission'); - } - ctx.body = markModel; - } else { - ctx.throw(400, 'id is required'); - // id 不存在,获取当天的title为 日期的一条数据 - // const [markModel, created] = await MarkModel.findOrCreate({ - // where: { - // uid: tokenUser.id, - // puid: tokenUser.uid, - // title: dayjs().format('YYYY-MM-DD'), - // }, - // defaults: { - // title: dayjs().format('YYYY-MM-DD'), - // uid: tokenUser.id, - // markType: 'wallnote', - // tags: ['daily'], - // uname: tokenUser.username, - // puid: tokenUser.uid, - // version: 1, - // }, - // }); - // ctx.body = markModel; - } - }) - .addTo(app); - -app - .route({ - path: 'mark', - key: 'update', - middleware: ['auth'], - isDebug: true, - }) - .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; - const { id, createdAt, updatedAt, uid: _, puid: _2, uname: _3, data, ...rest } = ctx.query.data || {}; - let markModel: MarkModel; - if (id) { - markModel = await MarkModel.findByPk(id); - if (!markModel) { - ctx.throw(404, 'mark not found'); - } - if (markModel.uid !== tokenUser.id) { - ctx.throw(403, 'no permission'); - } - const version = Number(markModel.version) + 1; - await markModel.update({ - ...markModel.data, - ...rest, - data: { - ...markModel.data, - ...data, - }, - version, - }); - } else { - markModel = await MarkModel.create({ - data, - ...rest, - uname: tokenUser.username, - uid: tokenUser.id, - puid: tokenUser.uid, - }); - } - ctx.body = markModel; - }) - .addTo(app); -app - .route({ - path: 'mark', - key: 'updateNode', - middleware: ['auth'], - }) - .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; - const operate = ctx.query.operate || 'update'; - const { id, node } = ctx.query.data || {}; - const markModel = await MarkModel.findByPk(id); - if (!markModel) { - ctx.throw(404, 'mark not found'); - } - if (markModel.uid !== tokenUser.id) { - ctx.throw(403, 'no permission'); - } - await MarkModel.updateJsonNode(id, node, { operate }); - ctx.body = markModel; - }) - .addTo(app); -app - .route({ - path: 'mark', - key: 'updateNodes', - middleware: ['auth'], - }) - .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; - const { id, nodeOperateList } = ctx.query.data || {}; - const markModel = await MarkModel.findByPk(id); - if (!markModel) { - ctx.throw(404, 'mark not found'); - } - if (markModel.uid !== tokenUser.id) { - ctx.throw(403, 'no permission'); - } - if (!nodeOperateList || !Array.isArray(nodeOperateList) || nodeOperateList.length === 0) { - ctx.throw(400, 'nodeOperateList is required'); - } - if (nodeOperateList.some((node) => !node.node)) { - ctx.throw(400, 'nodeOperateList node is required'); - } - const newmark = await MarkModel.updateJsonNodes(id, nodeOperateList); - ctx.body = newmark; - }) - .addTo(app); - -app - .route({ - path: 'mark', - key: 'delete', - middleware: ['auth'], - }) - .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; - const { id } = ctx.query; - const markModel = await MarkModel.findByPk(id); - if (!markModel) { - ctx.throw(404, 'mark not found'); - } - if (markModel.uid !== tokenUser.id) { - ctx.throw(403, 'no permission'); - } - await markModel.destroy(); - ctx.body = markModel; - }) - .addTo(app); - -app - .route({ path: 'mark', key: 'getMenu', description: '获取菜单', middleware: ['auth'] }) - .define(async (ctx) => { - const tokenUser = ctx.state.tokenUser; - const { rows, count } = await MarkModel.findAndCountAll({ - where: { - uid: tokenUser.id, - }, - attributes: ['id', 'title', 'summary', 'tags', 'thumbnail', 'link', 'createdAt', 'updatedAt'], - }); - ctx.body = { - list: rows, - total: count, - }; - }) - .addTo(app); diff --git a/src/routes/mark/mark-model.ts b/src/routes/mark/mark-model.ts deleted file mode 100644 index e737479..0000000 --- a/src/routes/mark/mark-model.ts +++ /dev/null @@ -1,327 +0,0 @@ -import { useContextKey } from '@kevisual/context'; -import { nanoid, customAlphabet } from 'nanoid'; -import { DataTypes, Model, ModelAttributes } from 'sequelize'; -import type { Sequelize } from 'sequelize'; -export const random = customAlphabet('1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'); -export type Mark = Partial>; -export type MarkData = { - md?: string; // markdown - mdList?: string[]; // markdown list - type?: string; // 类型 markdown | json | html | image | video | audio | code | link | file - data?: any; - key?: string; // 文件的名称, 唯一 - push?: boolean; // 是否推送到elasticsearch - pushTime?: Date; // 推送时间 - summary?: string; // 摘要 - nodes?: MarkDataNode[]; // 节点 - [key: string]: any; -}; -export type MarkFile = { - id: string; - name: string; - url: string; - size: number; - type: 'self' | 'data' | 'generate'; // generate为生成文件 - query: string; // 'data.nodes[id].content'; - hash: string; - fileKey: string; // 文件的名称, 唯一 -}; -export type MarkDataNode = { - id?: string; - [key: string]: any; -}; -export type MarkConfig = { - [key: string]: any; -}; -export type MarkAuth = { - [key: string]: any; -}; -/** - * 隐秘内容 - * auth - * config - * - */ -export class MarkModel extends Model { - declare id: string; - declare title: string; // 标题,可以ai生成 - declare description: string; // 描述,可以ai生成 - declare cover: string; // 封面,可以ai生成 - declare thumbnail: string; // 缩略图 - declare key: string; // 文件路径 - declare markType: string; // markdown | json | html | image | video | audio | code | link | file - declare link: string; // 访问链接 - declare tags: string[]; // 标签 - declare summary: string; // 摘要, description的简化版 - declare data: MarkData; // 数据 - - declare uid: string; // 操作用户的id - declare puid: string; // 父级用户的id, 真实用户 - declare config: MarkConfig; // mark属于一定不会暴露的内容。 - - declare fileList: MarkFile[]; // 文件管理 - declare uname: string; // 用户的名称, 或者着别名 - - declare markedAt: Date; // 标记时间 - declare createdAt: Date; - declare updatedAt: Date; - declare version: number; - /** - * 加锁更新data中的node的节点,通过node的id - * @param param0 - */ - static async updateJsonNode(id: string, node: MarkDataNode, opts?: { operate?: 'update' | 'delete'; Model?: any; sequelize?: Sequelize }) { - const sequelize = opts?.sequelize || (await useContextKey('sequelize')); - const transaction = await sequelize.transaction(); // 开启事务 - const operate = opts.operate || 'update'; - const isUpdate = operate === 'update'; - const Model = opts.Model || MarkModel; - try { - // 1. 获取当前的 JSONB 字段值(加锁) - const mark = await Model.findByPk(id, { - transaction, - lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改 - }); - if (!mark) { - throw new Error('Mark not found'); - } - // 2. 修改特定的数组元素 - const data = mark.data as MarkData; - const items = data.nodes; - if (!node.id) { - node.id = random(12); - } - - // 找到要更新的元素 - const itemIndex = items.findIndex((item) => item.id === node.id); - if (itemIndex === -1) { - isUpdate && items.push(node); - } else { - if (isUpdate) { - items[itemIndex] = node; - } else { - items.splice(itemIndex, 1); - } - } - const version = Number(mark.version) + 1; - // 4. 更新 JSONB 字段 - const result = await mark.update( - { - data: { - ...data, - nodes: items, - }, - version, - }, - { transaction }, - ); - - await transaction.commit(); - return result; - } catch (error) { - await transaction.rollback(); - throw error; - } - } - static async updateJsonNodes(id: string, nodes: { node: MarkDataNode; operate?: 'update' | 'delete' }[], opts?: { Model?: any; sequelize?: Sequelize }) { - const sequelize = opts?.sequelize || (await useContextKey('sequelize')); - const transaction = await sequelize.transaction(); // 开启事务 - const Model = opts?.Model || MarkModel; - try { - const mark = await Model.findByPk(id, { - transaction, - lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改 - }); - if (!mark) { - throw new Error('Mark not found'); - } - const data = mark.data as MarkData; - const _nodes = data.nodes || []; - // 过滤不在nodes中的节点 - const blankNodes = nodes.filter((node) => !_nodes.find((n) => n.id === node.node.id)).map((node) => node.node); - // 更新或删除节点 - const newNodes = _nodes - .map((node) => { - const nodeOperate = nodes.find((n) => n.node.id === node.id); - if (nodeOperate) { - if (nodeOperate.operate === 'delete') { - return null; - } - return nodeOperate.node; - } - return node; - }) - .filter((node) => node !== null); - const version = Number(mark.version) + 1; - const result = await mark.update( - { - data: { - ...data, - nodes: [...blankNodes, ...newNodes], - }, - version, - }, - { transaction }, - ); - await transaction.commit(); - return result; - } catch (error) { - await transaction.rollback(); - throw error; - } - } - static async updateData(id: string, data: MarkData, opts: { Model?: any; sequelize?: Sequelize }) { - const sequelize = opts.sequelize || (await useContextKey('sequelize')); - const transaction = await sequelize.transaction(); // 开启事务 - const Model = opts.Model || MarkModel; - const mark = await Model.findByPk(id, { - transaction, - lock: transaction.LOCK.UPDATE, // 加锁,防止其他事务同时修改 - }); - if (!mark) { - throw new Error('Mark not found'); - } - const version = Number(mark.version) + 1; - const result = await mark.update( - { - ...mark.data, - ...data, - data: { - ...mark.data, - ...data, - }, - version, - }, - { transaction }, - ); - await transaction.commit(); - return result; - } - static async createNew(data: any, opts: { Model?: any; sequelize?: Sequelize }) { - const sequelize = opts.sequelize || (await useContextKey('sequelize')); - const transaction = await sequelize.transaction(); // 开启事务 - const Model = opts.Model || MarkModel; - const result = await Model.create({ ...data, version: 1 }, { transaction }); - await transaction.commit(); - return result; - } -} -export type MarkInitOpts = { - tableName: string; - sequelize?: Sequelize; - callInit?: (attribute: ModelAttributes) => ModelAttributes; - Model?: T extends typeof MarkModel ? T : typeof MarkModel; -}; -export type Opts = { - sync?: boolean; - alter?: boolean; - logging?: boolean | ((...args: any) => any); - force?: boolean; -}; -export const MarkMInit = async (opts: MarkInitOpts, sync?: Opts) => { - const sequelize = await useContextKey('sequelize'); - opts.sequelize = opts.sequelize || sequelize; - const { callInit, Model, ...optsRest } = opts; - const modelAttribute = { - id: { - type: DataTypes.UUID, - primaryKey: true, - defaultValue: DataTypes.UUIDV4, - comment: 'id', - }, - title: { - type: DataTypes.TEXT, - defaultValue: '', - }, - key: { - type: DataTypes.TEXT, // 对应的minio的文件路径 - defaultValue: '', - }, - markType: { - type: DataTypes.TEXT, - defaultValue: 'md', // markdown | json | html | image | video | audio | code | link | file - comment: '类型', - }, - description: { - type: DataTypes.TEXT, - defaultValue: '', - }, - cover: { - type: DataTypes.TEXT, - defaultValue: '', - comment: '封面', - }, - thumbnail: { - type: DataTypes.TEXT, - defaultValue: '', - comment: '缩略图', - }, - link: { - type: DataTypes.TEXT, - defaultValue: '', - comment: '链接', - }, - tags: { - type: DataTypes.JSONB, - defaultValue: [], - }, - summary: { - type: DataTypes.TEXT, - defaultValue: '', - comment: '摘要', - }, - config: { - type: DataTypes.JSONB, - defaultValue: {}, - }, - data: { - type: DataTypes.JSONB, - defaultValue: {}, - }, - fileList: { - type: DataTypes.JSONB, - defaultValue: [], - }, - uname: { - type: DataTypes.STRING, - defaultValue: '', - comment: '用户的名称, 更新后的用户的名称', - }, - version: { - type: DataTypes.INTEGER, // 更新刷新版本,多人协作 - defaultValue: 1, - }, - markedAt: { - type: DataTypes.DATE, - allowNull: true, - comment: '标记时间', - }, - uid: { - type: DataTypes.UUID, - allowNull: true, - }, - puid: { - type: DataTypes.UUID, - allowNull: true, - }, - }; - const InitModel = Model || MarkModel; - InitModel.init(callInit ? callInit(modelAttribute) : modelAttribute, { - sequelize, - paranoid: true, - ...optsRest, - }); - if (sync && sync.sync) { - const { sync: _, ...rest } = sync; - MarkModel.sync({ alter: true, logging: false, ...rest }).catch((e) => { - console.error('MarkModel sync', e); - }); - } -}; - -export const markModelInit = MarkMInit; - -export const syncMarkModel = async (sync?: Opts, tableName = 'micro_mark') => { - const sequelize = await useContextKey('sequelize'); - await MarkMInit({ sequelize, tableName }, sync); -}; diff --git a/src/routes/mark/model.ts b/src/routes/mark/model.ts deleted file mode 100644 index 7a66721..0000000 --- a/src/routes/mark/model.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from '@kevisual/code-center-module/src/mark/mark-model.ts'; -import { markModelInit, MarkModel, syncMarkModel } from '@kevisual/code-center-module/src/mark/mark-model.ts'; -export { markModelInit, MarkModel }; - -syncMarkModel({ sync: true, alter: true, logging: false }); diff --git a/src/routes/mark/services/mark.ts b/src/routes/mark/services/mark.ts deleted file mode 100644 index 646445a..0000000 --- a/src/routes/mark/services/mark.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { FindAttributeOptions, Op } from 'sequelize'; -import { MarkModel } from '../model.ts'; - -export class MarkServices { - static getList = async (opts: { - /** 查询用户的 */ - uid?: string; - query?: { - page?: number; - pageSize?: number; - search?: string; - markType?: string; - sort?: string; - }; - /** - * 查询类型 - * simple: 简单查询 默认 - */ - queryType?: string; - }) => { - const { uid, query } = opts; - const { page = 1, pageSize = 999, search, sort = 'DESC' } = query; - const searchWhere = search - ? { - [Op.or]: [{ title: { [Op.like]: `%${search}%` } }, { summary: { [Op.like]: `%${search}%` } }], - } - : {}; - if (opts.query?.markType) { - searchWhere['markType'] = opts.query.markType; - } - const attributes: FindAttributeOptions = { - exclude: [], - }; - const queryType = opts.queryType || 'simple'; - if (queryType === 'simple') { - // attributes.include = ['id', 'title', 'link', 'summary', 'thumbnail', 'markType', 'tags', 'uid', 'share', 'uname']; - attributes.exclude = ['data', 'config', 'cover', 'description']; - } - const { rows, count } = await MarkModel.findAndCountAll({ - where: { - uid: uid, - ...searchWhere, - }, - order: [['updatedAt', sort]], - attributes: attributes, - limit: pageSize, - offset: (page - 1) * pageSize, - }); - return { - pagination: { - current: page, - pageSize, - total: count, - }, - list: rows, - }; - }; -} diff --git a/src/routes/user/modules/wx-services.ts b/src/routes/user/modules/wx-services.ts index e842d60..ecff26e 100644 --- a/src/routes/user/modules/wx-services.ts +++ b/src/routes/user/modules/wx-services.ts @@ -1,6 +1,6 @@ import { WxTokenResponse, fetchToken, getUserInfo, getUserInfoByMp, post } from './wx.ts'; import { useContextKey } from '@kevisual/use-config/context'; -import { UserModel } from '@kevisual/code-center-module'; +import { UserModel } from '../../../auth/index.ts'; import { Buffer } from 'buffer'; import { CustomError } from '@kevisual/router'; import { customAlphabet } from 'nanoid'; diff --git a/src/routes/user/modules/wx.ts b/src/routes/user/modules/wx.ts index 0d4c257..f078258 100644 --- a/src/routes/user/modules/wx.ts +++ b/src/routes/user/modules/wx.ts @@ -10,7 +10,6 @@ const wx = { appId: config.WX_MP_APP_ID, appSecret: config.WX_MP_APP_SECRET, } -console.log('wx config', wx, wxOpen); export type WxTokenResponse = { access_token: string; expires_in: number; diff --git a/src/routes/user/secret-key/list.ts b/src/routes/user/secret-key/list.ts index a946d73..f77762a 100644 --- a/src/routes/user/secret-key/list.ts +++ b/src/routes/user/secret-key/list.ts @@ -10,11 +10,11 @@ app }) .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; - const { page = 1, pageSize = 20, search, sort = 'DESC', orgId } = ctx.query; + const { page = 1, pageSize = 100, search, sort = 'DESC', orgId } = ctx.query; const searchWhere: Record = search ? { - [Op.or]: [{ title: { [Op.like]: `%${search}%` } }, { description: { [Op.like]: `%${search}%` } }], - } + [Op.or]: [{ title: { [Op.like]: `%${search}%` } }, { description: { [Op.like]: `%${search}%` } }], + } : {}; if (orgId) { searchWhere.orgId = orgId; @@ -52,7 +52,7 @@ app }) .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; - const { id, updatedAt: _clear, createdAt: _clear2, token, ...rest } = ctx.query.data; + const { id, updatedAt: _clear, title = 'life', createdAt: _clear2, token, ...rest } = ctx.query.data; let secret: UserSecret; let isNew = false; @@ -65,6 +65,13 @@ app if (secret.userId !== tokenUser.userId) { ctx.throw(403, 'No permission'); } + } else if (title) { + secret = await UserSecret.findOne({ + where: { + userId: tokenUser.userId, + title, + }, + }); } else { secret = await UserSecret.createSecret(tokenUser); isNew = true; @@ -87,13 +94,27 @@ app }) .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; - const { id } = ctx.query.data || {}; + const { id, title } = ctx.query.data || {}; - if (!id) { - ctx.throw(400, 'id is required'); + if (!id && !title) { + ctx.throw(400, 'id 或者 title 必须提供一个'); } + let secret: UserSecret; - const secret = await UserSecret.findByPk(id); + if (id) { + secret = await UserSecret.findByPk(id); + } + if (!secret && title) { + secret = await UserSecret.findOne({ + where: { + userId: tokenUser.userId, + title, + }, + }); + if (!secret) { + ctx.throw(404, 'Secret not found'); + } + } if (!secret) { ctx.throw(404, 'Secret not found'); @@ -115,19 +136,30 @@ app }) .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; - const { id } = ctx.query.data || {}; + const { id, title } = ctx.query.data || {}; - if (!id) { - ctx.throw(400, 'id is required'); + if (!id && !title) { + ctx.throw(400, 'id 或者 title 必须提供一个'); } - const secret = await UserSecret.findByPk(id); + let secret: UserSecret | null = null; + + if (id) { + secret = await UserSecret.findByPk(id); + } else if (title) { + secret = await UserSecret.findOne({ + where: { + userId: tokenUser.userId, + title, + }, + }); + } if (!secret) { ctx.throw(404, 'Secret not found'); } - if (secret.userId !== tokenUser.uid) { + if (secret.userId !== tokenUser.userId) { ctx.throw(403, 'No permission'); } diff --git a/src/routes/user/web-login.ts b/src/routes/user/web-login.ts index 35ca0a3..db6c480 100644 --- a/src/routes/user/web-login.ts +++ b/src/routes/user/web-login.ts @@ -1,7 +1,7 @@ import { app } from '@/app.ts'; import { User } from '@/models/user.ts'; import MD5 from 'crypto-js/md5.js'; -import { authCan } from '@kevisual/code-center-module/models'; +import { authCan } from '@/auth/index.ts'; import jsonwebtoken from 'jsonwebtoken'; import { redis } from '@/app.ts'; diff --git a/src/scripts/common.ts b/src/scripts/common.ts index 9da15be..e94c67e 100644 --- a/src/scripts/common.ts +++ b/src/scripts/common.ts @@ -1,8 +1,7 @@ import { config } from '../modules/config.ts'; import { sequelize } from '../modules/sequelize.ts'; export { program, Command } from '../program.ts'; -// import { User, UserInit, OrgInit, Org, UserSecretInit, UserSecret } from '@kevisual/code-center-module/models'; -import { User, UserInit, OrgInit, Org, UserSecretInit, UserSecret } from '@kevisual/code-center-module/src/core-models.ts'; +import { User, UserInit, OrgInit, Org, UserSecretInit, UserSecret } from '../auth/index.ts'; import { Logger } from '@kevisual/logger'; export const close = async () => { process.exit(0); diff --git a/src/test/sync-user.ts b/src/test/sync-user.ts index e8abb3b..d4345ae 100644 --- a/src/test/sync-user.ts +++ b/src/test/sync-user.ts @@ -1,5 +1,5 @@ import { sequelize } from '../modules/sequelize.ts'; -import { User, UserInit, UserServices, Org, OrgInit } from '@kevisual/code-center-module/models'; +import { User, UserInit, UserServices, Org, OrgInit } from '../auth/index.ts'; // User.sync({ alter: true, logging: true }).then(() => { // console.log('sync user done'); diff --git a/wxmsg/src/index.ts b/wxmsg/src/index.ts index a16234b..1920eb0 100644 --- a/wxmsg/src/index.ts +++ b/wxmsg/src/index.ts @@ -5,7 +5,7 @@ import { useContextKey } from '@kevisual/context'; import { Redis } from 'ioredis'; import http from 'node:http'; import { Wx, WxMsgEvent, parseWxMessage } from './wx/index.ts'; -import { config } from './modules/config.ts'; +import { contextConfig as config } from './modules/config.ts'; import { loginByTicket } from './wx/login-by-ticket.ts'; export const simpleRouter: SimpleRouter = await useContextKey('router'); export const redis: Redis = await useContextKey('redis'); diff --git a/wxmsg/src/modules/config.ts b/wxmsg/src/modules/config.ts index 7b93aa8..072577e 100644 --- a/wxmsg/src/modules/config.ts +++ b/wxmsg/src/modules/config.ts @@ -1,7 +1,13 @@ -import { useConfig } from "@kevisual/context"; +import { useConfig as useContextConfig } from "@kevisual/context"; type Config = { WX_MP_APP_ID: string; WX_MP_APP_SECRET: string; } -export const config = useConfig(); +export const contextConfig = useContextConfig(); + +import { useConfig } from '@kevisual/use-config'; + +export const config = useConfig() + +console.log('配置项:', config); \ No newline at end of file diff --git a/wxmsg/src/queue.ts b/wxmsg/src/queue.ts new file mode 100644 index 0000000..ea4126e --- /dev/null +++ b/wxmsg/src/queue.ts @@ -0,0 +1,9 @@ +import { Queue } from 'bullmq'; + +export const wxmsgQueue = new Queue('wxmsg', { + connection: { + host: process.env.REDIS_HOST || 'kevisual.cn', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, + } +}); \ No newline at end of file diff --git a/wxmsg/src/wx/index.ts b/wxmsg/src/wx/index.ts index 592e623..dc395a8 100644 --- a/wxmsg/src/wx/index.ts +++ b/wxmsg/src/wx/index.ts @@ -1,6 +1,8 @@ import { getAccessToken } from './get-access-token.ts'; import { Redis } from 'ioredis'; import { WxCustomServiceMsg, WxMsgText } from './type/custom-service.ts'; +import { Queue } from 'bullmq'; +import { useContextKey } from "@kevisual/context"; export * from './type/custom-service.ts'; export * from './type/send.ts'; @@ -46,18 +48,14 @@ export class Wx { console.log('Analyzed message:', { touser, msgType }); return; } - const txtMsg = msg as WxMsgText; - const content = txtMsg.content; - - console.log('Analyzing user message:', { touser, msgType, content }); - const sendData = { - touser, - msgtype: 'text', - text: { - content: 'Hello World', - }, + const wxmsgQueue = useContextKey('wxmsgQueue'); + if (!wxmsgQueue) { + throw new Error('wxmsgQueue is not available in context.'); } - this.sendUserMessage(sendData); + wxmsgQueue.add('analyzeUserMsg', { + touser, + msg, + }); } /** * 发送客服消息 diff --git a/wxmsg/task/worker/bun.config.ts b/wxmsg/task/worker/bun.config.ts new file mode 100644 index 0000000..a6d9bbb --- /dev/null +++ b/wxmsg/task/worker/bun.config.ts @@ -0,0 +1,15 @@ +import { build } from 'bun'; + +await build({ + entrypoints: ['./index.ts'], + outdir: './dist', + target: 'node', + format: 'esm', + naming: { + entry: 'app.js', + }, + minify: false, + sourcemap: false, +}); + +console.log('✅ Build complete: dist/app.js'); diff --git a/wxmsg/task/worker/index.ts b/wxmsg/task/worker/index.ts new file mode 100644 index 0000000..03c96f4 --- /dev/null +++ b/wxmsg/task/worker/index.ts @@ -0,0 +1,38 @@ +import { Worker } from "bullmq"; +import { redis } from './redis.ts'; + +import { Wx } from "../../src/wx"; + +const worker = new Worker('wxmsg', async job => { + const wx = new Wx({ + appId: process.env.WX_APPID || '', + appSecret: process.env.WX_APPSECRET || '', + redis: redis + }); + if (job.name === 'analyzeUserMsg') { + const { touser, msg } = job.data; + const accessToken = await wx.getAccessToken(); + const sendData = { + touser, + msgtype: 'text', + text: { + content: 'Hello World' + new Date().toISOString(), + }, + }; + await wx.sendUserMessage(sendData); + } else { + throw new Error(`Unknown job name: ${job.name}`); + } +}, { + connection: redis +}); + +worker.on('completed', (job) => { + console.log(`Job ${job.id} has completed!`); +}); + +worker.on('failed', (job, err) => { + console.log(`Job ${job?.id} has failed with error ${err.message}`); +}); + +console.log('Worker is running...'); \ No newline at end of file diff --git a/wxmsg/task/worker/package.json b/wxmsg/task/worker/package.json new file mode 100644 index 0000000..7d69ac8 --- /dev/null +++ b/wxmsg/task/worker/package.json @@ -0,0 +1,37 @@ +{ + "name": "@kevisual/wxmsg-worker", + "version": "0.0.2", + "description": "", + "main": "index.ts", + "basename": "/root/wxmsg-worker", + "app": { + "type": "pm2-system-app", + "entry": "./app.js" + }, + "scripts": { + "dev": "bun index.ts", + "build": "bun run bun.config.ts", + "prepub": "rimraf dist && rimraf pack-dist && pnpm build", + "pub": "envision pack -p -u -c" + }, + "keywords": [], + "files": [ + "dist" + ], + "author": "abearxiong (https://www.xiongxiao.me)", + "license": "MIT", + "packageManager": "pnpm@10.24.0", + "type": "module", + "dependencies": { + "@kevisual/context": "^0.0.4", + "@kevisual/router": "0.0.33", + "@types/node": "^24.10.1", + "crypto-js": "^4.2.0", + "xml2js": "^0.6.2" + }, + "devDependencies": { + "@types/bun": "^1.3.3", + "@types/crypto-js": "^4.2.2", + "@types/xml2js": "^0.4.14" + } +} \ No newline at end of file diff --git a/wxmsg/task/worker/redis.ts b/wxmsg/task/worker/redis.ts new file mode 100644 index 0000000..8fcb17f --- /dev/null +++ b/wxmsg/task/worker/redis.ts @@ -0,0 +1,42 @@ +import Redis from "ioredis"; +import { useConfig } from '@kevisual/use-config'; +import { useContextKey } from "@kevisual/context"; + +export const config = useConfig() + +// 首先从 process.env 读取环境变量 +const redisConfig = { + host: process.env.REDIS_HOST || 'kevisual.cn', + port: parseInt(process.env.REDIS_PORT || '6379'), + password: process.env.REDIS_PASSWORD, +}; + +export const createRedisClient = (options = {}) => { + const redis = new Redis({ + // host: 'localhost', // Redis 服务器的主机名或 IP 地址 + // port: 6379, // Redis 服务器的端口号 + // password: 'your_password', // Redis 的密码 (如果有) + db: 0, // 要使用的 Redis 数据库索引 (0-15) + keyPrefix: '', // key 前缀 + retryStrategy(times) { + // 连接重试策略 + return Math.min(times * 50, 2000); // 每次重试时延迟增加 + }, + maxRetriesPerRequest: null, // 允许请求重试的次数 (如果需要无限次重试) + ...options, + }); + // 监听连接事件 + redis.on('connect', () => { + console.log('Redis 连接成功'); + }); + + redis.on('error', (err) => { + console.error('Redis 连接错误', err); + }); + redis.on('ready', () => { + console.log('Redis 已准备好处理请求'); + }); + return redis; +}; +const redis = useContextKey('redis', createRedisClient(redisConfig)); +export { redis }; \ No newline at end of file