This commit is contained in:
2025-12-04 14:22:04 +08:00
parent 2a55f2d3ef
commit 9e458f4a77
17 changed files with 449 additions and 143 deletions

View File

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

136
pnpm-lock.yaml generated
View File

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

View File

@@ -8,7 +8,14 @@ import { OauthUser } from '../oauth/oauth.ts';
export const redis = useContextKey<Redis>('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;

View File

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

View File

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

View File

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

33
src/test/common.ts Normal file
View File

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

45
src/test/secret-key.ts Normal file
View File

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

View File

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

3
wxmsg/readme.md Normal file
View File

@@ -0,0 +1,3 @@
# 根据 wx 的内容进行改动
如果更新了, 需要重新打包

View File

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

View File

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

View File

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

View File

@@ -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<string> {
@@ -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}`
}

View File

@@ -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<UserInfo> => {
// 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;
};

View File

@@ -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) => {

View File

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