diff --git a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts index 5a66aab..f8354b4 100644 --- a/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts +++ b/server/dukang-api/src/integrations/wechat/wechat.api.provider.ts @@ -23,9 +23,11 @@ import { type TokenCache = { accessToken: string; expiresAt: number }; type TicketCache = { ticket: string; expiresAt: number }; -const ACCESS_TOKEN_KEY = 'wechat:access_token'; -const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token'; +/** 使用 getStableAccessToken,与旧 cgi-bin/token 隔离,避免多端抢刷新导致 40001 */ +const ACCESS_TOKEN_KEY = 'wechat:stable_access_token'; +const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_stable_access_token'; const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket'; +const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]); @Injectable() export class WechatApiProvider implements IWechatProvider { @@ -307,7 +309,6 @@ export class WechatApiProvider implements IWechatProvider { } async uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise { - const accessToken = await this.getMiniAccessToken(); const orderKey: Record = { order_number_type: input.orderNumberType, }; @@ -350,12 +351,24 @@ export class WechatApiProvider implements IWechatProvider { payer: { openid: input.payerOpenId }, }; - const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=${accessToken}`; - const data = await this.fetchJson<{ errcode?: number; errmsg?: string }>(apiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); + const callOnce = async (accessToken: string) => { + const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=${accessToken}`; + return this.fetchJson<{ errcode?: number; errmsg?: string }>(apiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }; + + let accessToken = await this.getMiniAccessToken(); + let data = await callOnce(accessToken); + if (TOKEN_INVALID_ERRCODES.has(data.errcode ?? -1)) { + this.logger.warn( + `upload_shipping_info token invalid ${data.errcode}, refresh stable token and retry`, + ); + accessToken = await this.getMiniAccessToken(true); + data = await callOnce(accessToken); + } return { errcode: data.errcode ?? -1, errmsg: data.errmsg ?? 'unknown', @@ -545,31 +558,18 @@ export class WechatApiProvider implements IWechatProvider { return raw; } - private async getAccessToken(): Promise { - const cached = await this.redis.getJson(ACCESS_TOKEN_KEY); - if (cached && cached.expiresAt > Date.now()) return cached.accessToken; - - const url = new URL('https://api.weixin.qq.com/cgi-bin/token'); - url.searchParams.set('grant_type', 'client_credential'); - url.searchParams.set('appid', this.appId); - url.searchParams.set('secret', this.appSecret); - const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>( - url.toString(), - ); - if (!data.access_token) { - throw new InternalServerErrorException(data.errmsg || '获取 access_token 失败'); - } - const ttl = Math.max((data.expires_in ?? 7200) - 300, 60); - await this.redis.setJson( - ACCESS_TOKEN_KEY, - { accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 }, - ttl, - ); - return data.access_token; + private async getAccessToken(forceRefresh = false): Promise { + return this.fetchStableAccessToken({ + cacheKey: ACCESS_TOKEN_KEY, + appId: this.appId, + appSecret: this.appSecret, + forceRefresh, + label: '服务号', + }); } - /** 小程序 access_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */ - private async getMiniAccessToken(): Promise { + /** 小程序 access_token(getPhoneNumber / 发货管理等 wxa 接口必须用小程序 AppID) */ + private async getMiniAccessToken(forceRefresh = false): Promise { const appId = this.miniAppId; const appSecret = this.miniAppSecret; if (!appId || !appSecret) { @@ -577,22 +577,60 @@ export class WechatApiProvider implements IWechatProvider { '小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET', ); } - const cached = await this.redis.getJson(MINI_ACCESS_TOKEN_KEY); - if (cached && cached.expiresAt > Date.now()) return cached.accessToken; + return this.fetchStableAccessToken({ + cacheKey: MINI_ACCESS_TOKEN_KEY, + appId, + appSecret, + forceRefresh, + label: '小程序', + }); + } - const url = new URL('https://api.weixin.qq.com/cgi-bin/token'); - url.searchParams.set('grant_type', 'client_credential'); - url.searchParams.set('appid', appId); - url.searchParams.set('secret', appSecret); - const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>( - url.toString(), - ); - if (!data.access_token) { - throw new InternalServerErrorException(data.errmsg || '获取小程序 access_token 失败'); + /** + * @see https://developers.weixin.qq.com/miniprogram/dev/server/API/mp-access-token/api_getstableaccesstoken.html + */ + private async fetchStableAccessToken(opts: { + cacheKey: string; + appId: string; + appSecret: string; + forceRefresh: boolean; + label: string; + }): Promise { + if (!opts.appId || !opts.appSecret) { + throw new InternalServerErrorException(`${opts.label}未配置 AppID/Secret`); } + if (!opts.forceRefresh) { + const cached = await this.redis.getJson(opts.cacheKey); + if (cached && cached.expiresAt > Date.now()) return cached.accessToken; + } else { + await this.redis.del(opts.cacheKey); + } + + const data = await this.fetchJson<{ + access_token?: string; + expires_in?: number; + errcode?: number; + errmsg?: string; + }>('https://api.weixin.qq.com/cgi-bin/stable_token', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'client_credential', + appid: opts.appId, + secret: opts.appSecret, + force_refresh: !!opts.forceRefresh, + }), + }); + if (!data.access_token) { + this.logger.error( + `getStableAccessToken ${opts.label} failed: ${data.errcode} ${data.errmsg}`, + ); + throw new InternalServerErrorException(data.errmsg || `获取${opts.label} access_token 失败`); + } + // 稳定版会提前约 5 分钟轮换;本地再提前 5 分钟过期,避免踩边 const ttl = Math.max((data.expires_in ?? 7200) - 300, 60); await this.redis.setJson( - MINI_ACCESS_TOKEN_KEY, + opts.cacheKey, { accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 }, ttl, );