This commit is contained in:
2025-11-30 21:32:01 +08:00
parent b7f1095e4a
commit d4ff2862bd
11 changed files with 413 additions and 34 deletions

View File

@@ -4,8 +4,6 @@ const accessURL = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_cre
type AccessData = { type AccessData = {
"access_token": string; "access_token": string;
"expires_in": number; // 7200, 单位秒 2小时 "expires_in": number; // 7200, 单位秒 2小时
"accessToken": string;
"expiredAt": number; // 到期时间戳,单位毫秒
} }
type ErrorData = { type ErrorData = {
@@ -23,26 +21,16 @@ export const getAccessToken = async (appId: string, appSecret: string): Promise<
}> => { }> => {
const url = getAccessURL(appId, appSecret); const url = getAccessURL(appId, appSecret);
const response = await fetch(url); const response = await fetch(url);
const data = await response.json(); const data = await response.json() as AccessData | ErrorData;
console.log('Access token response:', data);
if ((data as ErrorData).errcode) { if ((data as ErrorData).errcode) {
return { return {
code: 500, code: 500,
message: (data as ErrorData).errmsg, message: (data as ErrorData).errmsg,
} }
} }
console.log('access token data', data);
data.accessToken = data.access_token;
data.expiredAt = Date.now() + data.expires_in * 1000;
return { return {
code: 200, code: 200,
data data: data as AccessData,
} }
} }
if(require.main === module) {
// 测试代码
const appId = 'wxff97d569b1db16b6';
const appSecret = '012d84d0d2b914de95f4e9ca84923aed';
const res = await getAccessToken(appId, appSecret);
console.log('getAccessToken res', res);
}

View File

@@ -0,0 +1,26 @@
import { redis } from '@/app.ts'
import { getAccessToken } from './get-access-token.ts';
import { config } from '@/modules/config.ts';
/**
* 公众号获取缓存的 access token和获取的平台的 access token 是分开的
* @param appId
* @returns
*/
export const getCacheAccessToken = async (): Promise<string | null> => {
const appId = config.WX_MP_APP_ID;
const appSecret = config.WX_MP_APP_SECRET;
const cacheKey = `wx:access_token:${appId}`;
let accessToken = await redis.get(cacheKey);
if (!accessToken) {
const { code, data, message } = await getAccessToken(appId, appSecret);
if (code === 200 && data) {
accessToken = data.access_token;
await redis.set(cacheKey, accessToken, 'EX', data.expires_in - 200); // 提前200秒过期
} else {
console.error('Error getting access token:', message);
throw new Error(message || 'Error getting access token');
}
}
return accessToken;
}

View File

@@ -1,9 +1,11 @@
import { WxTokenResponse, fetchToken, getUserInfo } from './wx.ts'; import { WxTokenResponse, fetchToken, getUserInfo, getUserInfoByMp, post } from './wx.ts';
import { useContextKey } from '@kevisual/use-config/context'; import { useContextKey } from '@kevisual/use-config/context';
import { UserModel } from '@kevisual/code-center-module'; import { UserModel } from '@kevisual/code-center-module';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
import { CustomError } from '@kevisual/router'; import { CustomError } from '@kevisual/router';
import { customAlphabet } from 'nanoid'; import { customAlphabet } from 'nanoid';
import { getCacheAccessToken } from './get-cache-access-token.ts';
import { redis } from '@/app.ts';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10); const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10);
const User = useContextKey<typeof UserModel>('UserModel'); const User = useContextKey<typeof UserModel>('UserModel');
export class WxServices { export class WxServices {
@@ -89,6 +91,7 @@ export class WxServices {
let data = { let data = {
...user.data, ...user.data,
wxUnionId: unionid, wxUnionId: unionid,
canChangeUsername: true,
}; };
user.data = data; user.data = data;
if ((type = 'mp')) { if ((type = 'mp')) {
@@ -100,7 +103,7 @@ export class WxServices {
} }
this.user = await user.save({ fields: ['data'] }); this.user = await user.save({ fields: ['data'] });
this.getUserInfo(); await this.getUserInfo();
this.isNew = true; this.isNew = true;
} }
this.user = user; this.user = user;
@@ -122,16 +125,96 @@ export class WxServices {
isNew: this.isNew, isNew: this.isNew,
}; };
} }
/**
* 通过ticket登录
* @param msgInfo
*/
async loginByTicket(msgInfo: { openid: string, ticket: string }) {
const { ticket, openid } = msgInfo;
const key = `wx:mp:login:qrcode:${ticket}`;
const access_token = await getCacheAccessToken();
this.wxToken = {
access_token: access_token,
expires_in: 7200,
refresh_token: '',
openid: openid,
scope: '',
unionid: '',
};
const userInfo = await getUserInfoByMp(access_token, openid);
const { unionid } = userInfo;
let user = await User.findOne({
where: {
data: {
wxUnionId: unionid,
},
},
});
if (!user) {
const username = await this.randomUsername();
user = await User.createUser(username, nanoid(10));
let data = {
...user.data,
wxUnionId: unionid,
canChangeUsername: true,
};
user.data = data;
// @ts-ignore
data.wxmpOpenid = openid;
const fileds = ['data']
const { nickname, headimgurl } = userInfo;
if (nickname) {
this.user.nickname = nickname;
fileds.push('nickname');
}
if (headimgurl) {
const image = await this.downloadImg(headimgurl);
this.user.avatar = image;
fileds.push('avatar');
}
this.isNew = true;
this.user = await user.save({ fields: fileds });
}
this.user = user;
const tokenInfo = await user.createToken(null, 'plugin', {
wx: {
openid: openid,
unionid: unionid,
type: 'mp',
},
});
this.webToken = tokenInfo.accessToken;
async checkHasUser() {} this.accessToken = tokenInfo.accessToken;
this.refreshToken = tokenInfo.refreshToken;
this.user = user;
const newToken = {
accessToken: this.accessToken,
refreshToken: this.refreshToken,
isNew: this.isNew,
};
await redis.set(key, JSON.stringify(newToken), 'EX', 300); // 5分钟过期
return newToken;
}
async checkHasUser() { }
async getUserInfo() { async getUserInfo() {
try { try {
if (!this.wxToken) { if (!this.wxToken) {
throw new CustomError(400, 'wxToken is not set'); throw new CustomError(400, 'wxToken is not set');
} }
const userInfo = await getUserInfo(this.wxToken.access_token, this.wxToken.openid); const openid = this.wxToken.openid;
const access_token = this.wxToken.access_token;
const userInfo = await getUserInfo(access_token, openid);
// @ts-ignore
if (userInfo?.errcode) {
console.error('Failed to get user info: ', userInfo);
throw new Error(`Failed to get user info: ${openid}`,);
}
const { nickname, headimgurl } = userInfo; const { nickname, headimgurl } = userInfo;
this.user.nickname = nickname; this.user.nickname = nickname;
console.log('User info retrieved', userInfo);
try { try {
const downloadImgUrl = await this.downloadImg(headimgurl); const downloadImgUrl = await this.downloadImg(headimgurl);
this.user.avatar = downloadImgUrl; this.user.avatar = downloadImgUrl;
@@ -160,6 +243,23 @@ export class WxServices {
return ''; return '';
} }
} }
async getQrCodeTicket(): Promise<QrCodeRespone | null> {
const res = await createQrcodeTicket();
const ticket = res?.ticket;
if (ticket) {
const imageBlob = await getShowQrCode(ticket);
const buffer = await imageBlob.arrayBuffer();
return {
ticket: res.ticket,
expire_seconds: res.expire_seconds,
originUrl: res.url,
url: `data:image/jpeg;base64,${Buffer.from(buffer).toString('base64')}`,
};
}
return null;
}
} }
// https://thirdwx.qlogo.cn/mmopen/vi_32/WvOPpbDwUKEVJvSst8Z91Y68m7CsBeecMqRGlqey5HejByePD89boYGaVCM8vESsYmokk1jABUDsK08IrfI6JEkibZkDIC2zsb96DGBTEF7E/132 // https://thirdwx.qlogo.cn/mmopen/vi_32/WvOPpbDwUKEVJvSst8Z91Y68m7CsBeecMqRGlqey5HejByePD89boYGaVCM8vESsYmokk1jABUDsK08IrfI6JEkibZkDIC2zsb96DGBTEF7E/132
@@ -185,3 +285,73 @@ export const downloadImag = async (url: string) => {
throw error; throw error;
} }
}; };
// https://juejin.cn/post/7235118809605144631
type QrCodeOpts = {
/**
* 该二维码有效时间,以秒为单位。 最大不超过2592000即30天
*/
expired_seconds: number;
/**
* QR_SCENE为临时,QR_STR_SCENE为临时的字符串参数值,QR_LIMIT_SCENE为永久,QR_LIMIT_STR_SCENE为永久的字符串参数值
*/
scene_str: 'QR_SCENE' | 'QR_STR_SCENE' | 'QR_LIMIT_SCENE' | 'QR_LIMIT_STR_SCENE';
action_info: {
scene: {
/**
* 1-100000
*/
scene_id?: number;
/**
* 1-64字符
*/
scene_str?: string;
}
}
}
type QrCodeRespone = {
ticket: string;
expire_seconds: number;
url: string;
[key: string]: any;
}
/**
* 第二步:创建二维码, 浏览器去获取图片
* @param ticket
* @returns
*/
export const getShowQrCode = async (ticket: string) => {
const url = `https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=${encodeURIComponent(ticket)}`;
return fetch(url).then((res) => res.blob());
}
/**
* 第一步创建二维码服务端去获取ticket
* @param QrCodeOpts
* @returns
*/
export const createQrcodeTicket = async (qrCodeOpts?: QrCodeOpts): Promise<QrCodeRespone | null> => {
const accessToken = await getCacheAccessToken();
const url = `https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=${accessToken}`;
const data = {
expire_seconds: 300, // 5分钟
action_name: "QR_STR_SCENE",
...qrCodeOpts,
action_info: {
scene: {
scene_str: "login",
...qrCodeOpts?.action_info?.scene
}
}
};
const res = await post(url, data);
if (res.errcode) {
console.error('Failed to create QR code:', res);
return null;
}
return res;
}

View File

@@ -42,7 +42,7 @@ export const fetchToken = async (code: string, type: 'open' | 'mp' = 'open'): Pr
appSecret = wx.appSecret; appSecret = wx.appSecret;
} }
if (!appId || !appSecret) { if (!appId || !appSecret) {
throw new CustomError(500, 'appId or appSecret is not set'); throw new CustomError(500, 'appId or appSecret is not set');
} }
console.log('fetchToken===', appId, appSecret, code); console.log('fetchToken===', appId, appSecret, code);
const wxUrl = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${code}&grant_type=authorization_code`; const wxUrl = `https://api.weixin.qq.com/sns/oauth2/access_token?appid=${appId}&secret=${appSecret}&code=${code}&grant_type=authorization_code`;
@@ -65,7 +65,8 @@ type UserInfo = {
}; };
/** /**
* 获取用户信息 * 获取用户信息 通过授权登录
* 例子:微信内部网页,开放平台授权网页,确定登录的时候
* @param token * @param token
* @param openid * @param openid
* @returns * @returns
@@ -80,10 +81,34 @@ export const getUserInfo = async (token: string, openid: string): Promise<UserIn
}, },
}); });
const data = await res.json(); const data = await res.json();
console.log(data);
return data; return data;
}; };
/**
* 公众号获取用户信息,
* 微信公众号扫码,
* 订阅登录,非授权,
* 而是根据你关注了用户才登录
* @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();
return data;
};
// getUserInfo(token.access_token, token.openid) // getUserInfo(token.access_token, token.openid)
type AuthRes = { type AuthRes = {
@@ -123,3 +148,12 @@ export const refreshToken = async (refreshToken: string): Promise<RefreshToken>
}; };
// refreshToken(token.refresh_token) // refreshToken(token.refresh_token)
export const post = async (url: string, data: any) => {
const res = await fetch(url, {
method: 'POST',
body: JSON.stringify(data),
});
return await res.json();
};

View File

@@ -1,9 +1,11 @@
import { WxServices } from "./modules/wx-services.ts"; import { WxServices } from "./modules/wx-services.ts";
import { app, redis } from "@/app.ts"; import { app, redis } from "@/app.ts";
app app
.route({ .route({
path: 'wx', path: 'wx',
key: 'checkLogin', key: 'checkLogin',
description: '微信网页登录后获取登录结果(遗弃)',
}) })
.define(async (ctx) => { .define(async (ctx) => {
const state = ctx.query.state; const state = ctx.query.state;
@@ -28,6 +30,7 @@ app
.route({ .route({
path: 'wx', path: 'wx',
key: 'mplogin', key: 'mplogin',
description: '微信网页登录后提交code去登录遗弃',
}) })
.define(async (ctx) => { .define(async (ctx) => {
const state = ctx.query.state; const state = ctx.query.state;
@@ -50,7 +53,7 @@ app
.route({ .route({
path: 'wx', path: 'wx',
key: 'mp-get-openid', key: 'mp-get-openid',
isDebug: true, description: '微信公众平台获取openid',
}) })
.define(async (ctx) => { .define(async (ctx) => {
const code = ctx.query.code; const code = ctx.query.code;
@@ -68,7 +71,7 @@ app
.route({ .route({
path: 'wx', path: 'wx',
key: 'open-login', key: 'open-login',
isDebug: true, description: '微信开放平台登录',
}) })
.define(async (ctx) => { .define(async (ctx) => {
const code = ctx.query.code; const code = ctx.query.code;
@@ -89,3 +92,71 @@ app
} }
}) })
.addTo(app); .addTo(app);
app.route({
path: 'wx',
key: 'get-qrcode-ticket',
description: '获取微信二维码ticket',
})
.define(async (ctx) => {
const wx = new WxServices();
const res = await wx.getQrCodeTicket();
if (!res) {
ctx.throw(500, 'Get qrcode ticket failed');
return;
}
const key = `wx:mp:login:qrcode:${res.ticket}`;
await redis.set(key, '-', 'EX', 360); // 6分钟过期
ctx.body = res;
})
.addTo(app);
app.route({
path: 'wx',
key: 'check-qrcode-login',
description: '检查微信二维码登录状态',
})
.define(async (ctx) => {
const ticket = ctx.query.ticket;
if (!ticket) {
ctx.throw(400, 'ticket is required');
return;
}
const token = await redis.get(`wx:mp:login:qrcode:${ticket}`);
if (!token) {
ctx.throw(400, 'Invalid ticket');
return;
}
if (token === '-') {
ctx.throw(400, 'Not scanned yet');
return;
}
try {
// remove the token after getting it
await redis.del(`wx:mp:login:qrcode:${ticket}`);
ctx.body = JSON.parse(token);
} catch (error) {
ctx.throw(500, 'Invalid token get');
}
})
.addTo(app);
app.route({
path: 'wx',
key: 'login-by-ticket',
description: '通过ticket登录微信扫码登录回调',
})
.define(async (ctx) => {
const { openid, ticket } = ctx.query || {};
if (!openid || !ticket) {
console.error('openid and ticket are required');
ctx.throw(400, 'openid and ticket are required');
return;
}
const wx = new WxServices();
const result = await wx.loginByTicket({ openid, ticket });
ctx.body = result;
})
.addTo(app);

View File

@@ -4,8 +4,9 @@ import xml2js from 'xml2js';
import { useContextKey } from '@kevisual/context'; import { useContextKey } from '@kevisual/context';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import http from 'node:http'; import http from 'node:http';
import { Wx, parseWxMessage } from './wx/index.ts'; import { Wx, WxMsgEvent, parseWxMessage } from './wx/index.ts';
import { config } from './modules/config.ts'; import { config } from './modules/config.ts';
import { loginByTicket } from './wx/login-by-ticket.ts';
export const simpleRouter: SimpleRouter = await useContextKey('router'); export const simpleRouter: SimpleRouter = await useContextKey('router');
export const redis: Redis = await useContextKey('redis'); export const redis: Redis = await useContextKey('redis');
@@ -74,7 +75,13 @@ simpleRouter.post('/api/wxmsg', async (req: http.IncomingMessage, res: http.Serv
const { fromusername, msgtype } = msg; const { fromusername, msgtype } = msg;
res.end('') res.end('')
if (msgtype === 'event') { if (msgtype === 'event') {
console.log('Received event message'); const wxMsgEvent = msg as WxMsgEvent;
if (wxMsgEvent.eventkey?.includes?.('login')) {
await loginByTicket({
ticket: wxMsgEvent.ticket!,
openid: wxMsgEvent.fromusername,
});
}
return return
} }
if (fromusername) { if (fromusername) {

View File

@@ -0,0 +1,36 @@
// const accessURL = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET'
const accessURL = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential';
type AccessData = {
"access_token": string;
"expires_in": number; // 7200, 单位秒 2小时
}
type ErrorData = {
errcode: number;
errmsg: string;
}
export const getAccessURL = (appId: string, appSecret: string): string => {
return `${accessURL}&appid=${appId}&secret=${appSecret}`;
};
export const getAccessToken = async (appId: string, appSecret: string): Promise<{
code: number;
data?: AccessData;
message?: string;
}> => {
const url = getAccessURL(appId, appSecret);
const response = await fetch(url);
const data = await response.json() as AccessData | ErrorData;
console.log('Access token response:', data);
if ((data as ErrorData).errcode) {
return {
code: 500,
message: (data as ErrorData).errmsg,
}
}
return {
code: 200,
data: data as AccessData,
}
}

View File

@@ -1,4 +1,4 @@
import { getAccessToken } from './access-token'; import { getAccessToken } from './get-access-token.ts';
import { Redis } from 'ioredis'; import { Redis } from 'ioredis';
import { WxCustomServiceMsg, WxMsgText } from './type/custom-service.ts'; import { WxCustomServiceMsg, WxMsgText } from './type/custom-service.ts';
@@ -34,9 +34,9 @@ export class Wx {
if (res.code !== 200 || !res.data) { if (res.code !== 200 || !res.data) {
throw new Error(`Failed to get access token: ${res.message || 'unknown error'}`); throw new Error(`Failed to get access token: ${res.message || 'unknown error'}`);
} }
const { accessToken, expires_in } = res.data; const { access_token, expires_in } = res.data;
await this.redis?.set(`wx:access_token:${this.appId}`, accessToken, 'EX', expires_in - 200); await this.redis?.set(`wx:access_token:${this.appId}`, access_token, 'EX', expires_in - 200);
return accessToken; return access_token;
} }
public async analyzeUserMsg(msg: WxCustomServiceMsg) { public async analyzeUserMsg(msg: WxCustomServiceMsg) {
const touser = msg.fromusername; const touser = msg.fromusername;
@@ -59,6 +59,11 @@ export class Wx {
} }
this.sendUserMessage(sendData); this.sendUserMessage(sendData);
} }
/**
* 发送客服消息
* @param data
* @returns
*/
public async sendUserMessage(data: any) { public async sendUserMessage(data: any) {
const accessToken = await this.getAccessToken(); const accessToken = await this.getAccessToken();
const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${accessToken}` const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${accessToken}`
@@ -69,10 +74,15 @@ export class Wx {
} }
return res; return res;
} }
public async post(url: string, data: any) {
async post(url: string, data: any) {
return fetch(url, { return fetch(url, {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}).then((res) => res.json()); }).then((res) => res.json());
} }
} async get(url: string) {
return fetch(url).then((res) => res.json());
}
}

View File

@@ -0,0 +1,15 @@
import { useContextKey } from '@kevisual/context';
import { App } from '@kevisual/router';
export const loginByTicket = async (msgInfo: { openid: string, ticket: string }) => {
const app: App = useContextKey('app');
const res = await app.call({
path: 'wx',
key: 'login-by-ticket',
payload: {
ticket: msgInfo.ticket,
openid: msgInfo.openid,
},
})
return res;
}

View File

@@ -0,0 +1,22 @@
/**
* 公众号获取用户信息
* @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;
};

View File

@@ -12,7 +12,7 @@ export type WxMsgText = WxMsgBase<{
export type WxMsgEvent = WxMsgBase<{ export type WxMsgEvent = WxMsgBase<{
msgtype: 'event'; msgtype: 'event';
event: 'subscribe' | 'unsubscribe' | 'click' | 'location' | 'scan'; event: 'subscribe' | 'unsubscribe' | 'click' | 'location' | 'scan';
eventkey: string; eventkey: string; // example: subscribe--> qrscene_login scan--> login
/** /**
* *
* 用户同意上报地理位置后,每次进入服务号会话时,都会在进入时上报地理位置, * 用户同意上报地理位置后,每次进入服务号会话时,都会在进入时上报地理位置,