fix(wechat): use getStableAccessToken to avoid 40001
Switch mini/OA tokens to stable_token and retry shipping upload once on invalid credential. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<WechatUploadShippingInfoResult> {
|
||||
const accessToken = await this.getMiniAccessToken();
|
||||
const orderKey: Record<string, string | number> = {
|
||||
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<string> {
|
||||
const cached = await this.redis.getJson<TokenCache>(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<string> {
|
||||
return this.fetchStableAccessToken({
|
||||
cacheKey: ACCESS_TOKEN_KEY,
|
||||
appId: this.appId,
|
||||
appSecret: this.appSecret,
|
||||
forceRefresh,
|
||||
label: '服务号',
|
||||
});
|
||||
}
|
||||
|
||||
/** 小程序 access_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */
|
||||
private async getMiniAccessToken(): Promise<string> {
|
||||
/** 小程序 access_token(getPhoneNumber / 发货管理等 wxa 接口必须用小程序 AppID) */
|
||||
private async getMiniAccessToken(forceRefresh = false): Promise<string> {
|
||||
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<TokenCache>(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<string> {
|
||||
if (!opts.appId || !opts.appSecret) {
|
||||
throw new InternalServerErrorException(`${opts.label}未配置 AppID/Secret`);
|
||||
}
|
||||
if (!opts.forceRefresh) {
|
||||
const cached = await this.redis.getJson<TokenCache>(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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user