diff --git a/package.json b/package.json index 76da0f9..a32342f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,8 @@ ], "license": "UNLICENSED", "dependencies": { - "@kevisual/ai": "^0.0.12", + "@kevisual/ai": "^0.0.15", + "@kevisual/query": "^0.0.29", "@types/busboy": "^1.5.4", "@types/send": "^1.2.1", "@types/ws": "^8.18.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0cf890..e7806e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,11 @@ importers: .: dependencies: '@kevisual/ai': - specifier: ^0.0.12 - version: 0.0.12 + specifier: ^0.0.15 + version: 0.0.15 + '@kevisual/query': + specifier: ^0.0.29 + version: 0.0.29(@kevisual/ws@8.0.0)(zod@4.1.13) '@types/busboy': specifier: ^1.5.4 version: 1.5.4 @@ -155,36 +158,14 @@ 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': specifier: ^0.0.4 version: 0.0.4 + '@kevisual/query': + specifier: ^0.0.29 + version: 0.0.29(@kevisual/ws@8.0.0)(zod@3.25.67) '@kevisual/router': specifier: 0.0.33 version: 0.0.33 @@ -198,34 +179,6 @@ 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': @@ -239,8 +192,8 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} - '@kevisual/ai@0.0.12': - resolution: {integrity: sha512-c02ozy4B+1utJsjQee4nnQZ2vDKTMDYJxbya5CIpghm+ujs7jSiB05cyIREELAHErSOzJUQXlHC3Dr8rdb5F0A==} + '@kevisual/ai@0.0.15': + resolution: {integrity: sha512-7oX/wHUKJCfvphFJq7fLBGpl4f6ASEJooQVvmgHZ7fZiYBEeVAEYAB28BNqk36iOItEyWlhuOCxq1oQz3wN+XQ==} '@kevisual/auth@1.0.5': resolution: {integrity: sha512-GwsLj7unKXi7lmMiIIgdig4LwwLiDJnOy15HHZR5gMbyK6s5/uJiMY5RXPB2+onGzTNDqFo/hXjsD2wkerHPVg==} @@ -272,6 +225,9 @@ packages: '@kevisual/permission@0.0.3': resolution: {integrity: sha512-8JsA/5O5Ax/z+M+MYpFYdlioHE6jNmWMuFSokBWYs9CCAHNiSKMR01YLkoVDoPvncfH/Y8F5K/IEXRCbptuMNA==} + '@kevisual/query@0.0.29': + resolution: {integrity: sha512-rQZk0J073UuC1QGzuyq+pb4Y0hu8/Qx/xYHs9NbsmslM+RuMnd1zpXmvhXNj7Kn1MdYTH90ng2MlFLBkkQFaIg==} + '@kevisual/router@0.0.21': resolution: {integrity: sha512-XKTxbNO924cT18UOAGplWErZ+hMze8Y53F2jYCk18v4jsdsvjRho5uXXjJb6HSVsuITMtQR4R3rG0IcM3jkDKQ==} @@ -377,9 +333,6 @@ 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==} @@ -434,9 +387,6 @@ 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==} @@ -573,9 +523,6 @@ packages: 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'} @@ -1261,6 +1208,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openai@5.23.2: + resolution: {integrity: sha512-MQBzmTulj+MM5O8SKEk/gL8a7s5mktS9zUtAkU257WjvobGc9nKcBuVwjyEEcb9SI8a8Y2G/mzn3vm9n1Jlleg==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + p-queue@9.0.1: resolution: {integrity: sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==} engines: {node: '>=20'} @@ -1871,9 +1830,10 @@ snapshots: dependencies: minipass: 7.1.2 - '@kevisual/ai@0.0.12': + '@kevisual/ai@0.0.15': dependencies: '@kevisual/logger': 0.0.4 + '@kevisual/permission': 0.0.3 '@kevisual/auth@1.0.5': {} @@ -1977,6 +1937,20 @@ snapshots: '@kevisual/permission@0.0.3': {} + '@kevisual/query@0.0.29(@kevisual/ws@8.0.0)(zod@3.25.67)': + dependencies: + openai: 5.23.2(@kevisual/ws@8.0.0)(zod@3.25.67) + transitivePeerDependencies: + - ws + - zod + + '@kevisual/query@0.0.29(@kevisual/ws@8.0.0)(zod@4.1.13)': + dependencies: + openai: 5.23.2(@kevisual/ws@8.0.0)(zod@4.1.13) + transitivePeerDependencies: + - ws + - zod + '@kevisual/router@0.0.21': dependencies: path-to-regexp: 8.3.0 @@ -2111,10 +2085,6 @@ 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 @@ -2174,10 +2144,6 @@ 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 @@ -2318,10 +2284,6 @@ snapshots: transitivePeerDependencies: - supports-color - bun-types@1.3.3: - dependencies: - '@types/node': 24.10.1 - busboy@1.6.0: dependencies: streamsearch: 1.1.0 @@ -2435,10 +2397,6 @@ 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 @@ -2992,6 +2950,16 @@ snapshots: dependencies: wrappy: 1.0.2 + openai@5.23.2(@kevisual/ws@8.0.0)(zod@3.25.67): + optionalDependencies: + ws: '@kevisual/ws@8.0.0' + zod: 3.25.67 + + openai@5.23.2(@kevisual/ws@8.0.0)(zod@4.1.13): + optionalDependencies: + ws: '@kevisual/ws@8.0.0' + zod: 4.1.13 + p-queue@9.0.1: dependencies: eventemitter3: 5.0.1 @@ -3298,7 +3266,7 @@ snapshots: send@1.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 diff --git a/src/auth/models/user-secret.ts b/src/auth/models/user-secret.ts index d2d2659..1e7e35c 100644 --- a/src/auth/models/user-secret.ts +++ b/src/auth/models/user-secret.ts @@ -8,7 +8,14 @@ import { OauthUser } from '../oauth/oauth.ts'; export const redis = useContextKey('redis'); const UserSecretStatus = ['active', 'inactive', 'expired'] as const; - +const randomString = (length: number) => { + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + let result = ''; + for (let i = 0; i < length; i++) { + result += chars.charAt(Math.floor(Math.random() * chars.length)); + } + return result; +}; type Data = { [key: string]: any; /** @@ -45,10 +52,12 @@ export class UserSecret extends Model { if (!oauth.isSecretKey(token)) { return await oauth.verifyToken(token); } - // const secretToken = await oauth.verifyToken(token); - // if (secretToken) { - // return secretToken; - // } + const secretToken = await oauth.verifyToken(token); + if (secretToken) { + console.log('verifyToken: verified as normal token'); + return secretToken; + } + console.log('verifyToken: try to verify as secret key'); const userSecret = await UserSecret.findOne({ where: { token }, }); @@ -66,7 +75,7 @@ export class UserSecret extends Model { if (!oauthUser) { return null; // 如果没有找到对应的oauth用户,则返回null } - // await oauth.saveSecretKey(oauthUser, userSecret.token); + await oauth.saveSecretKey(oauthUser, userSecret.token); // 存储到oauth中的token store中 return oauthUser; } @@ -74,10 +83,10 @@ export class UserSecret extends Model { * owner 组织用户的 oauthUser * @returns */ - async getOauthUser() { + async getOauthUser(opts?: { wx?: boolean }) { const user = await User.findOne({ where: { id: this.userId }, - attributes: ['id', 'username', 'type', 'owner'], + attributes: ['id', 'username', 'type', 'owner', 'data'], }); let org: User = null; if (!user) { @@ -117,6 +126,44 @@ export class UserSecret extends Model { const expiredTime = new Date(this.expiredTime); return now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期 } + /** + * 检查是否过期,如果过期则更新状态为expired + * + * @returns + */ + async checkOnUse() { + if (!this.expiredTime) { + return { + code: 200 + } + } + try { + + const now = Date.now(); + const expiredTime = new Date(this.expiredTime); + const isExpired = now > expiredTime.getTime(); // 如果当前时间大于过期时间,则认为已过期 + if (isExpired) { + this.status = 'active'; + const expireTime = UserSecret.getExpiredTime(); + this.expiredTime = expireTime; + await this.save() + } + if (this.status !== 'active') { + this.status = 'active'; + await this.save() + } + return { + code: 200 + }; + } + catch (e) { + console.error('checkExpiredAndUpdate error', this.id, this.title); + return { + code: 500, + message: 'checkExpiredAndUpdate error' + } + } + } async createNewToken() { if (this.token) { await oauth.delToken(this.token); @@ -134,8 +181,21 @@ export class UserSecret extends Model { } return token; } - static async createSecret(tokenUser: { id: string; uid?: string }, expireDay = 365) { - const expireTime = expireDay * 24 * 60 * 60 * 1000; // 转换为毫秒 + /** + * 根据 unionid 生成redis的key + * `wxmp:unionid:token:${unionid}` + * @param unionid + * @returns + */ + static wxRedisKey(unionid: string) { + return `wxmp:unionid:token:${unionid}`; + } + static getExpiredTime(expireDays?: number) { + const defaultExpireDays = expireDays || 365; + const expireTime = defaultExpireDays * 24 * 60 * 60 * 1000; + return new Date(Date.now() + expireTime) + } + static async createSecret(tokenUser: { id: string; uid?: string, title?: string }, expireDays = 365) { const token = await UserSecret.createToken(); let userId = tokenUser.id; let orgId: string = null; @@ -147,11 +207,13 @@ export class UserSecret extends Model { userId, orgId, token, - expiredTime: new Date(Date.now() + expireTime), + title: tokenUser.title || randomString(6), + expiredTime: UserSecret.getExpiredTime(expireDays), }); return userSecret; } + async getPermission(opts: { id: string; uid?: string }) { const { id, uid } = opts; let userId: string = id; diff --git a/src/models/user.ts b/src/models/user.ts index 56875ef..83f573f 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -2,6 +2,7 @@ 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 }; +import { useContextKey } from '@kevisual/context'; const init = async () => { await OrgInit(null, null, { alter: true, @@ -21,5 +22,7 @@ const init = async () => { }).catch((e) => { console.error('UserSecret sync', e); }); + console.log('Models synced'); + useContextKey('models-synced', true); }; init(); diff --git a/src/routes/user/modules/wx-services.ts b/src/routes/user/modules/wx-services.ts index ecff26e..e468700 100644 --- a/src/routes/user/modules/wx-services.ts +++ b/src/routes/user/modules/wx-services.ts @@ -68,16 +68,7 @@ export class WxServices { }, }); // @ts-ignore - if (type === 'open' && user && user.data.wxOpenid !== token.openid) { - user.data = { - ...user.data, - // @ts-ignore - wxOpenid: token.openid, - }; - user = await user.update({ data: user.data }); - console.log('mp-user login openid update=============', token.openid, token.unionid); - // @ts-ignore - } else if (type === 'mp' && user && user.data.wxmpOpenid !== token.openid) { + if (type === 'mp' && user && user.data.wxmpOpenid !== token.openid) { user.data = { ...user.data, // @ts-ignore @@ -94,7 +85,7 @@ export class WxServices { canChangeUsername: true, }; user.data = data; - if ((type = 'mp')) { + if (type === 'mp') { // @ts-ignore data.wxmpOpenid = token.openid; } else { diff --git a/src/routes/user/secret-key/list.ts b/src/routes/user/secret-key/list.ts index f77762a..4cc1d6a 100644 --- a/src/routes/user/secret-key/list.ts +++ b/src/routes/user/secret-key/list.ts @@ -1,7 +1,7 @@ import { Op } from 'sequelize'; import { User, UserSecret } from '@/models/user.ts'; import { app } from '@/app.ts'; - +import { redis } from '@/app.ts'; app .route({ path: 'secret', @@ -10,7 +10,7 @@ app }) .define(async (ctx) => { const tokenUser = ctx.state.tokenUser; - const { page = 1, pageSize = 100, search, sort = 'DESC', orgId } = ctx.query; + const { page = 1, pageSize = 100, search, sort = 'DESC', orgId, showToken = false } = ctx.query; const searchWhere: Record = search ? { [Op.or]: [{ title: { [Op.like]: `%${search}%` } }, { description: { [Op.like]: `%${search}%` } }], @@ -18,7 +18,10 @@ app : {}; if (orgId) { searchWhere.orgId = orgId; + } else { + searchWhere.orgId = null; } + const excludeFields = showToken ? [] : ['token']; const { rows: secrets, count } = await UserSecret.findAndCountAll({ where: { userId: tokenUser.userId, @@ -27,7 +30,7 @@ app offset: (page - 1) * pageSize, limit: pageSize, attributes: { - exclude: ['token'], // Exclude sensitive token field + exclude: excludeFields, // Exclude sensitive token field }, order: [['updatedAt', sort]], }); @@ -166,3 +169,52 @@ app ctx.body = secret; }) .addTo(app); + +app.route({ + path: 'secret', + key: 'wxnotify', + description: '为了微信去缓存需要的数据, unionid是公众号下的用户的unionid', +}).define(async (ctx) => { + const { openid, unionid } = ctx.query; + if (!openid && !unionid) { + // ctx.throw(400, '需要提供 openid 或者 unionid 参数'); + ctx.throw(400, '需要提供 unionid 参数'); + } + // 最少20为的openid + if (unionid.length < 20) { + ctx.throw(400, 'unionid 是必填的'); + } + const redisKey = UserSecret.wxRedisKey(unionid); + const token = await redis.get(redisKey); + if (token) { + ctx.body = 'success' + return; + } + const user = await User.findOne({ + where: { + data: { + wxUnionId: unionid + } + } + }) + if (!user) { + ctx.throw(404, '请关注公众号《人生可视化助手》后再操作'); + return + } + let secretKey = await UserSecret.findOne({ + where: { + userId: user.id, + title: 'wxmp-notify-token' + } + }); + if (!secretKey) { + secretKey = await UserSecret.createSecret({ id: user.id, title: 'wxmp-notify-token' }); + } + const check = await secretKey.checkOnUse(); + if (check.code !== 200) { + ctx.throw(check.code, check.message); + } + await redis.set(redisKey, secretKey.token, 'EX', 30 * 24 * 60 * 60); // 30天过期 + ctx.body = 'success' + +}).addTo(app); \ No newline at end of file diff --git a/src/test/common.ts b/src/test/common.ts new file mode 100644 index 0000000..a912fdd --- /dev/null +++ b/src/test/common.ts @@ -0,0 +1,33 @@ +import { app } from '@/app.ts'; +import '@/route.ts'; +import { useConfig, useContextKey } from '@kevisual/context'; +import { Query } from '@kevisual/query'; +import util from 'node:util'; +export { + app, + useContextKey +} +export const config = useConfig(); + +export const token = config.TOKEN || ''; + +export const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +export const showRes = (res, ...args) => { + if (res.code === 200) { + if (args.length === 0) { + console.log(res.code, util.inspect(res.body, { depth: 6, colors: true })); + return; + } + console.log(res.code, ...args); + } else { + console.error(res.code, res.message, ...args); + } +} + +export const exit = (code = 0) => { + process.exit(code); +} + +export const query = new Query({ + url: 'https://kevisual.cn/api/router' +}) \ No newline at end of file diff --git a/src/test/secret-key.ts b/src/test/secret-key.ts new file mode 100644 index 0000000..3c5134b --- /dev/null +++ b/src/test/secret-key.ts @@ -0,0 +1,45 @@ +import { app, token, showRes, sleep, useContextKey, exit, query } from './common.ts' + +// await sleep(4000) +// const token2 = 'sk_6m3gjpkpny2ma9r96ei3bzck3kpg7b7g4oajghw7gmqoqk0vlh3swgxy85e0wnpt' +// await useContextKey('models-synced'); + +// const res = await app.call({ +// path: 'secret', +// key: 'list', +// payload: { +// token, +// showToken: true +// } +// }) +// showRes(res) + + +// const userRes = await app.call({ +// path: 'user', +// key: 'me', +// payload: { +// token: token2, +// } +// }) +// showRes(userRes) + +// const openid = 'omcvy7AHC6bAA0QM4x9_bE0fGD1g' +// const res = await app.call({ +// path: 'secret', +// key: 'wxnotify', +// payload: { +// openid +// } +// }); +// showRes(res) + +const res = await query.post({ + path: 'secret', + key: 'wxnotify', + payload: { + openid: 'omcvy7M5CBAIB8TWDw6gNDHeHGeE' + } +}) +showRes(res) +exit(0); \ No newline at end of file diff --git a/wxmsg/package.json b/wxmsg/package.json index 3ea72df..de94514 100644 --- a/wxmsg/package.json +++ b/wxmsg/package.json @@ -27,6 +27,7 @@ "type": "module", "dependencies": { "@kevisual/context": "^0.0.4", + "@kevisual/query": "^0.0.29", "@kevisual/router": "0.0.33", "@types/node": "^24.10.1", "crypto-js": "^4.2.0", diff --git a/wxmsg/readme.md b/wxmsg/readme.md new file mode 100644 index 0000000..7109b2a --- /dev/null +++ b/wxmsg/readme.md @@ -0,0 +1,3 @@ +# 根据 wx 的内容进行改动 + +如果更新了, 需要重新打包 diff --git a/wxmsg/src/index.ts b/wxmsg/src/index.ts index 1920eb0..f259660 100644 --- a/wxmsg/src/index.ts +++ b/wxmsg/src/index.ts @@ -7,9 +7,14 @@ import http from 'node:http'; import { Wx, WxMsgEvent, parseWxMessage } from './wx/index.ts'; import { contextConfig as config } from './modules/config.ts'; import { loginByTicket } from './wx/login-by-ticket.ts'; +import { Queue } from 'bullmq'; export const simpleRouter: SimpleRouter = await useContextKey('router'); export const redis: Redis = await useContextKey('redis'); - +export const wxmsgQueue = useContextKey('wxmsgQueue', () => { + return new Queue('wxmsg', { + connection: redis + }); +}); simpleRouter.get('/api/wxmsg', async (req: http.IncomingMessage, res: http.ServerResponse) => { console.log('微信检测服务是否可用'); const query = simpleRouter.getSearch(req); diff --git a/wxmsg/src/queue.ts b/wxmsg/src/queue.ts deleted file mode 100644 index ea4126e..0000000 --- a/wxmsg/src/queue.ts +++ /dev/null @@ -1,9 +0,0 @@ -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/test/get-unionid.ts b/wxmsg/src/test/get-unionid.ts new file mode 100644 index 0000000..27e4ded --- /dev/null +++ b/wxmsg/src/test/get-unionid.ts @@ -0,0 +1,38 @@ +import { Redis } from 'ioredis'; +import { useConfig } from '@kevisual/use-config'; +const config = useConfig(); +const redis = new Redis({ + password: config.REDIS_PASSWORD +}); + +/** + * 公众号获取用户信息 + * @param token + * @param openid + * @returns + */ +export const getUserInfoByMp = async (token: string, openid: string) => { + // const phoneUrl = `https://api.weixin.qq.com/sns/userinfo?access_token=${token}&openid=${openid}`; + const phoneUrl = `https://api.weixin.qq.com/cgi-bin/user/info?access_token=${token}&openid=${openid}&lang=zh_CN`; + + const res = await fetch(phoneUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }); + const data = await res.json(); + console.log('userinfo', data); + return data; +}; + +// 大号的程序的服务号的openid +const openid = 'omcvy7AHC6bAA0QM4x9_bE0fGD1g' +const main = async () => { + const appId = config.WX_MP_APP_ID!; + const _accessToken = await redis.get(`wx:access_token:${appId}`); + + const data = await getUserInfoByMp(_accessToken!, openid); + console.log('用户信息:', data); +} +main(); \ No newline at end of file diff --git a/wxmsg/src/wx/index.ts b/wxmsg/src/wx/index.ts index dc395a8..8b137c1 100644 --- a/wxmsg/src/wx/index.ts +++ b/wxmsg/src/wx/index.ts @@ -3,10 +3,12 @@ import { Redis } from 'ioredis'; import { WxCustomServiceMsg, WxMsgText } from './type/custom-service.ts'; import { Queue } from 'bullmq'; import { useContextKey } from "@kevisual/context"; - +import { Query } from '@kevisual/query'; +import { getUserInfoByMp } from './test/get-user-info.ts'; export * from './type/custom-service.ts'; export * from './type/send.ts'; +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); /** * 从 * @param str @@ -21,10 +23,14 @@ export class Wx { private appId: string; private appSecret: string; public redis: Redis | null = null; - constructor({ appId, appSecret, redis }: { appId: string; appSecret: string; redis?: Redis }) { + query: Query = new Query(); + constructor({ appId, appSecret, redis, url }: { appId: string; appSecret: string; redis?: Redis; url?: string }) { this.appId = appId; this.appSecret = appSecret; this.redis = redis! || null; + this.query = new Query({ + url: url || 'http://localhost:3005' + }); } public async getAccessToken(): Promise { @@ -83,4 +89,93 @@ export class Wx { async get(url: string) { return fetch(url).then((res) => res.json()); } + async getUnionid(openid: string) { + const cacheRedisKey = `wxmp:openid:unionid:${openid}`; + const cachedUnionid = await this.redis!.get(cacheRedisKey); + if (cachedUnionid) { + return cachedUnionid; + } + const accessToken = await this.getAccessToken(); + const res = await getUserInfoByMp(accessToken, openid); + if (!res?.unionid) { + throw new Error('无法获取用户的unionid,用户是否未关注公众号?'); + } + await this.redis!.set(cacheRedisKey, res.unionid, 'EX', 7 * 24 * 3600); // 缓存7天 + return res.unionid; + } + async getCenterCodeToken(touser: string) { + const unionid = await this.getUnionid(touser); + // 第一次尝试获取token + let token = await this.redis!.get(getWxRedisKey(unionid)); + if (!token) { + const msg = { + path: 'secret', + key: 'wxnotify', + payload: { unionid: unionid } + } + const res = await this.query.post(msg); + if (res.code !== 200) { + console.error('获取不到用户配置,是否用户未关注公众号?', res, 'openid', touser, "unionid", unionid); + throw new Error('获取不到用户配置,是否用户未关注公众号?'); + } + + await sleep(1000); + } + // 尝试更新后第二次获取token + token = await this.redis!.get(getWxRedisKey(unionid)); + if (!token) { + console.error('这个uid获取不到用户配置', touser, "unionid", unionid); + throw new Error('获取不到用户配置,是否用户未关注公众号?'); + } + return token; + } + + async postCenter(data: any) { + await this.getAccessToken(); + const { touser, msg } = data; + const msgType = msg.msgtype; + const sendUserText = async (text: string) => { + const sendData = { + touser, + msgtype: 'text', + text: { + content: text, + }, + }; + await this.sendUserMessage(sendData); + } + const userToken = await this.getCenterCodeToken(touser); + if (!userToken) { + await sendUserText('服务器错误:无法获取用户绑定的账号信息'); + console.error('用户未绑定账号,无法自动回复', touser); + return; + } + + if (msgType !== 'text') { + await sendUserText('暂不支持该类型消息的自动回复'); + return; + } + const wxMsg = msg as WxMsgText; + const question = wxMsg.content; + const nocoMsg = { + path: 'noco-life', + key: "chat", + payload: { + token: userToken, + question, + } + } + const res = await this.query.post(nocoMsg); + if (res.code !== 200) { + await sendUserText('自动回复失败,请稍后再试:' + res.message); + } + const content = res.data?.content || '' + if (content) { + await sendUserText(content); + } + } } + +const getWxRedisKey = (unionid: string) => { + return `wxmp:unionid:token:${unionid}` +} \ No newline at end of file diff --git a/wxmsg/src/wx/test/get-user-info.ts b/wxmsg/src/wx/test/get-user-info.ts index 228694e..45e8391 100644 --- a/wxmsg/src/wx/test/get-user-info.ts +++ b/wxmsg/src/wx/test/get-user-info.ts @@ -1,12 +1,31 @@ +type UserInfo = { + subscribe: number; + openid: string; + nickname: string; + sex: number; + language: string; + city: string; + province: string; + country: string; + headimgurl: string; + subscribe_time: number; + unionid: string; + remark: string; + groupid: number; + tagid_list: number[]; + subscribe_scene: string; + qr_scene: number; + qr_scene_str: string; +}; /** * 公众号获取用户信息 * @param token * @param openid * @returns */ -export const getUserInfoByMp = async (token: string, openid: string) => { +export const getUserInfoByMp = async (token: string, openid: string): Promise => { // const phoneUrl = `https://api.weixin.qq.com/sns/userinfo?access_token=${token}&openid=${openid}`; const phoneUrl = `https://api.weixin.qq.com/cgi-bin/user/info?access_token=${token}&openid=${openid}&lang=zh_CN`; @@ -17,6 +36,5 @@ export const getUserInfoByMp = async (token: string, openid: string) => { }, }); const data = await res.json(); - console.log('userinfo', data); return data; }; diff --git a/wxmsg/task/worker/index.ts b/wxmsg/task/worker/index.ts index 03c96f4..b4b49aa 100644 --- a/wxmsg/task/worker/index.ts +++ b/wxmsg/task/worker/index.ts @@ -1,30 +1,31 @@ import { Worker } from "bullmq"; -import { redis } from './redis.ts'; +import { redis, config } from './redis.ts'; import { Wx } from "../../src/wx"; + const worker = new Worker('wxmsg', async job => { + const url = 'http://localhost:3005/api/router'; const wx = new Wx({ - appId: process.env.WX_APPID || '', - appSecret: process.env.WX_APPSECRET || '', - redis: redis + appId: config.WX_MP_APP_ID, appSecret: config.WX_MP_APP_SECRET, + redis: redis, + url, }); 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); + await wx.postCenter(job.data); } else { throw new Error(`Unknown job name: ${job.name}`); } }, { - connection: redis + connection: redis, + removeOnComplete: { + age: 3600, // 1 hour + count: 5000, // keep last 5000 jobs + }, + removeOnFail: { + age: 7200, // 2 hours + count: 5000, // keep last 5000 jobs + }, }); worker.on('completed', (job) => { diff --git a/wxmsg/task/worker/redis.ts b/wxmsg/task/worker/redis.ts index 8fcb17f..6bd883a 100644 --- a/wxmsg/task/worker/redis.ts +++ b/wxmsg/task/worker/redis.ts @@ -3,7 +3,6 @@ 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', @@ -27,14 +26,14 @@ export const createRedisClient = (options = {}) => { }); // 监听连接事件 redis.on('connect', () => { - console.log('Redis 连接成功'); + // console.log('Redis 连接成功'); }); redis.on('error', (err) => { - console.error('Redis 连接错误', err); + // console.error('Redis 连接错误', err); }); redis.on('ready', () => { - console.log('Redis 已准备好处理请求'); + // console.log('Redis 已准备好处理请求'); }); return redis; };