remove mark

This commit is contained in:
2025-12-04 10:31:37 +08:00
parent 46aa293cce
commit 2a55f2d3ef
35 changed files with 1837 additions and 726 deletions

View File

@@ -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"

220
pnpm-lock.yaml generated
View File

@@ -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: {}

11
src/auth/index.ts Normal file
View File

@@ -0,0 +1,11 @@
/**
* 可以不需要user成功, 有则赋值,交给下一个中间件
*/
export const authCan = 'auth-can';
/**
* 必须需要user成功
*/
export const auth = 'auth';
export * from './models/index.ts';

View File

@@ -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;
}
};

View File

@@ -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);
};

3
src/auth/models/index.ts Normal file
View File

@@ -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';

184
src/auth/models/org.ts Normal file
View File

@@ -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>('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);

View File

@@ -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>('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<OauthUser> = {
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>('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);

370
src/auth/models/user.ts Normal file
View File

@@ -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>('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<string, any> = {
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>('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);

18
src/auth/oauth/auth.ts Normal file
View File

@@ -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>('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;
});

2
src/auth/oauth/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './oauth.ts';
export * from './salt.ts';

392
src/auth/oauth/oauth.ts Normal file
View File

@@ -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'; // 用户类型默认是usertoken类型是用于token的扩展
oauthType?: 'user' | 'token'; // 用户类型默认是usertoken类型是用于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<T> {
redis?: Redis;
getObject: (key: string) => Promise<T>;
setObject: (key: string, value: T, opts?: StoreSetOpts) => Promise<void>;
expire: (key: string, ttl?: number) => Promise<void>;
delObject: (value?: T) => Promise<void>;
keys: (key?: string) => Promise<string[]>;
setToken: (value: { accessToken: string; refreshToken: string; value?: T }, opts?: StoreSetOpts) => Promise<void>;
delKeys: (keys: string[]) => Promise<number>;
}
export class RedisTokenStore implements Store<OauthUser> {
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<T extends OauthUser> {
private store: Store<T>;
constructor(store: Store<T>) {
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这个是真实用户idid是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<T>): 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<string, any>) {
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);
}
}

32
src/auth/oauth/salt.ts Normal file
View File

@@ -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;
};

View File

@@ -1,46 +1 @@
// import { DataTypes, Model, Sequelize } from 'sequelize';
// import { useContextKey } from '@kevisual/context';
// const sequelize = useContextKey<Sequelize>('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'

View File

@@ -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, {

View File

@@ -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';

View File

@@ -1 +0,0 @@
import './list.ts';

View File

@@ -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);

View File

@@ -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<InstanceType<typeof MarkModel>>;
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<T = any> = {
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 <T = any>(opts: MarkInitOpts<T>, 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);
};

View File

@@ -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 });

View File

@@ -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,
};
};
}

View File

@@ -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';

View File

@@ -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;

View File

@@ -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<string, any> = 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');
}

View File

@@ -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';

View File

@@ -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);

View File

@@ -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');

View File

@@ -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');

View File

@@ -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<Config>();
export const contextConfig = useContextConfig<Config>();
import { useConfig } from '@kevisual/use-config';
export const config = useConfig()
console.log('配置项:', config);

9
wxmsg/src/queue.ts Normal file
View File

@@ -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,
}
});

View File

@@ -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<Queue>('wxmsgQueue');
if (!wxmsgQueue) {
throw new Error('wxmsgQueue is not available in context.');
}
this.sendUserMessage(sendData);
wxmsgQueue.add('analyzeUserMsg', {
touser,
msg,
});
}
/**
* 发送客服消息

View File

@@ -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');

View File

@@ -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...');

View File

@@ -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 <xiongxiao@xiongxiao.me> (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"
}
}

View File

@@ -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 };