diff --git a/wxmsg/src/wx/access-token.ts b/src/routes/user/modules/get-access-token.ts similarity index 63% rename from wxmsg/src/wx/access-token.ts rename to src/routes/user/modules/get-access-token.ts index 79d40fe..bf49a18 100644 --- a/wxmsg/src/wx/access-token.ts +++ b/src/routes/user/modules/get-access-token.ts @@ -4,8 +4,6 @@ const accessURL = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_cre type AccessData = { "access_token": string; "expires_in": number; // 7200, 单位秒 2小时 - "accessToken": string; - "expiredAt": number; // 到期时间戳,单位毫秒 } type ErrorData = { @@ -23,26 +21,16 @@ export const getAccessToken = async (appId: string, appSecret: string): Promise< }> => { const url = getAccessURL(appId, appSecret); 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) { return { code: 500, 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 { 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); -} +} \ No newline at end of file diff --git a/src/routes/user/modules/get-cache-access-token.ts b/src/routes/user/modules/get-cache-access-token.ts new file mode 100644 index 0000000..beb1e92 --- /dev/null +++ b/src/routes/user/modules/get-cache-access-token.ts @@ -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 => { + 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; +} \ No newline at end of file diff --git a/src/routes/user/modules/wx-services.ts b/src/routes/user/modules/wx-services.ts index c30d18a..e842d60 100644 --- a/src/routes/user/modules/wx-services.ts +++ b/src/routes/user/modules/wx-services.ts @@ -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 { UserModel } from '@kevisual/code-center-module'; import { Buffer } from 'buffer'; import { CustomError } from '@kevisual/router'; import { customAlphabet } from 'nanoid'; +import { getCacheAccessToken } from './get-cache-access-token.ts'; +import { redis } from '@/app.ts'; const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 10); const User = useContextKey('UserModel'); export class WxServices { @@ -89,6 +91,7 @@ export class WxServices { let data = { ...user.data, wxUnionId: unionid, + canChangeUsername: true, }; user.data = data; if ((type = 'mp')) { @@ -100,7 +103,7 @@ export class WxServices { } this.user = await user.save({ fields: ['data'] }); - this.getUserInfo(); + await this.getUserInfo(); this.isNew = true; } this.user = user; @@ -122,16 +125,96 @@ export class WxServices { 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() { try { if (!this.wxToken) { 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; this.user.nickname = nickname; + console.log('User info retrieved', userInfo); try { const downloadImgUrl = await this.downloadImg(headimgurl); this.user.avatar = downloadImgUrl; @@ -160,6 +243,23 @@ export class WxServices { return ''; } } + + async getQrCodeTicket(): Promise { + 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 @@ -185,3 +285,73 @@ export const downloadImag = async (url: string) => { 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 => { + 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; +} \ No newline at end of file diff --git a/src/routes/user/modules/wx.ts b/src/routes/user/modules/wx.ts index 0106dfe..0d4c257 100644 --- a/src/routes/user/modules/wx.ts +++ b/src/routes/user/modules/wx.ts @@ -42,7 +42,7 @@ export const fetchToken = async (code: string, type: 'open' | 'mp' = 'open'): Pr appSecret = wx.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); 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 openid * @returns @@ -80,10 +81,34 @@ export const getUserInfo = 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`; + + 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) type AuthRes = { @@ -123,3 +148,12 @@ export const refreshToken = async (refreshToken: string): Promise }; // 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(); +}; \ No newline at end of file diff --git a/src/routes/user/wx-login.ts b/src/routes/user/wx-login.ts index d04c26a..15937ef 100644 --- a/src/routes/user/wx-login.ts +++ b/src/routes/user/wx-login.ts @@ -1,9 +1,11 @@ import { WxServices } from "./modules/wx-services.ts"; import { app, redis } from "@/app.ts"; + app .route({ path: 'wx', key: 'checkLogin', + description: '微信网页登录后获取登录结果(遗弃)', }) .define(async (ctx) => { const state = ctx.query.state; @@ -28,6 +30,7 @@ app .route({ path: 'wx', key: 'mplogin', + description: '微信网页登录后提交code去登录(遗弃)', }) .define(async (ctx) => { const state = ctx.query.state; @@ -50,7 +53,7 @@ app .route({ path: 'wx', key: 'mp-get-openid', - isDebug: true, + description: '微信公众平台获取openid', }) .define(async (ctx) => { const code = ctx.query.code; @@ -68,7 +71,7 @@ app .route({ path: 'wx', key: 'open-login', - isDebug: true, + description: '微信开放平台登录', }) .define(async (ctx) => { const code = ctx.query.code; @@ -89,3 +92,71 @@ 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); \ No newline at end of file diff --git a/wxmsg/src/index.ts b/wxmsg/src/index.ts index 33f0c3c..a16234b 100644 --- a/wxmsg/src/index.ts +++ b/wxmsg/src/index.ts @@ -4,8 +4,9 @@ import xml2js from 'xml2js'; import { useContextKey } from '@kevisual/context'; import { Redis } from 'ioredis'; 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 { loginByTicket } from './wx/login-by-ticket.ts'; export const simpleRouter: SimpleRouter = await useContextKey('router'); 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; res.end('') 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 } if (fromusername) { diff --git a/wxmsg/src/wx/get-access-token.ts b/wxmsg/src/wx/get-access-token.ts new file mode 100644 index 0000000..bf49a18 --- /dev/null +++ b/wxmsg/src/wx/get-access-token.ts @@ -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, + } +} \ No newline at end of file diff --git a/wxmsg/src/wx/index.ts b/wxmsg/src/wx/index.ts index b60f0da..592e623 100644 --- a/wxmsg/src/wx/index.ts +++ b/wxmsg/src/wx/index.ts @@ -1,4 +1,4 @@ -import { getAccessToken } from './access-token'; +import { getAccessToken } from './get-access-token.ts'; import { Redis } from 'ioredis'; import { WxCustomServiceMsg, WxMsgText } from './type/custom-service.ts'; @@ -34,9 +34,9 @@ export class Wx { if (res.code !== 200 || !res.data) { throw new Error(`Failed to get access token: ${res.message || 'unknown error'}`); } - const { accessToken, expires_in } = res.data; - await this.redis?.set(`wx:access_token:${this.appId}`, accessToken, 'EX', expires_in - 200); - return accessToken; + const { access_token, expires_in } = res.data; + await this.redis?.set(`wx:access_token:${this.appId}`, access_token, 'EX', expires_in - 200); + return access_token; } public async analyzeUserMsg(msg: WxCustomServiceMsg) { const touser = msg.fromusername; @@ -59,6 +59,11 @@ export class Wx { } this.sendUserMessage(sendData); } + /** + * 发送客服消息 + * @param data + * @returns + */ public async sendUserMessage(data: any) { const accessToken = await this.getAccessToken(); const url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${accessToken}` @@ -69,10 +74,15 @@ export class Wx { } return res; } - public async post(url: string, data: any) { + + + async post(url: string, data: any) { return fetch(url, { method: 'POST', body: JSON.stringify(data), }).then((res) => res.json()); } -} \ No newline at end of file + async get(url: string) { + return fetch(url).then((res) => res.json()); + } +} diff --git a/wxmsg/src/wx/login-by-ticket.ts b/wxmsg/src/wx/login-by-ticket.ts new file mode 100644 index 0000000..33f1dd6 --- /dev/null +++ b/wxmsg/src/wx/login-by-ticket.ts @@ -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; +} \ 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 new file mode 100644 index 0000000..228694e --- /dev/null +++ b/wxmsg/src/wx/test/get-user-info.ts @@ -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; +}; diff --git a/wxmsg/src/wx/type/custom-service.ts b/wxmsg/src/wx/type/custom-service.ts index d33d152..0a29933 100644 --- a/wxmsg/src/wx/type/custom-service.ts +++ b/wxmsg/src/wx/type/custom-service.ts @@ -12,7 +12,7 @@ export type WxMsgText = WxMsgBase<{ export type WxMsgEvent = WxMsgBase<{ msgtype: 'event'; event: 'subscribe' | 'unsubscribe' | 'click' | 'location' | 'scan'; - eventkey: string; + eventkey: string; // example: subscribe--> qrscene_login scan--> login /** * * 用户同意上报地理位置后,每次进入服务号会话时,都会在进入时上报地理位置,