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 TokenCache = { accessToken: string; expiresAt: number };
|
||||||
type TicketCache = { ticket: string; expiresAt: number };
|
type TicketCache = { ticket: string; expiresAt: number };
|
||||||
|
|
||||||
const ACCESS_TOKEN_KEY = 'wechat:access_token';
|
/** 使用 getStableAccessToken,与旧 cgi-bin/token 隔离,避免多端抢刷新导致 40001 */
|
||||||
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token';
|
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 JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
||||||
|
const TOKEN_INVALID_ERRCODES = new Set([40001, 40014, 42001]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class WechatApiProvider implements IWechatProvider {
|
export class WechatApiProvider implements IWechatProvider {
|
||||||
@@ -307,7 +309,6 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult> {
|
async uploadShippingInfo(input: WechatUploadShippingInfoInput): Promise<WechatUploadShippingInfoResult> {
|
||||||
const accessToken = await this.getMiniAccessToken();
|
|
||||||
const orderKey: Record<string, string | number> = {
|
const orderKey: Record<string, string | number> = {
|
||||||
order_number_type: input.orderNumberType,
|
order_number_type: input.orderNumberType,
|
||||||
};
|
};
|
||||||
@@ -350,12 +351,24 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
payer: { openid: input.payerOpenId },
|
payer: { openid: input.payerOpenId },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const callOnce = async (accessToken: string) => {
|
||||||
const apiUrl = `https://api.weixin.qq.com/wxa/sec/order/upload_shipping_info?access_token=${accessToken}`;
|
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, {
|
return this.fetchJson<{ errcode?: number; errmsg?: string }>(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(body),
|
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 {
|
return {
|
||||||
errcode: data.errcode ?? -1,
|
errcode: data.errcode ?? -1,
|
||||||
errmsg: data.errmsg ?? 'unknown',
|
errmsg: data.errmsg ?? 'unknown',
|
||||||
@@ -545,31 +558,18 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAccessToken(): Promise<string> {
|
private async getAccessToken(forceRefresh = false): Promise<string> {
|
||||||
const cached = await this.redis.getJson<TokenCache>(ACCESS_TOKEN_KEY);
|
return this.fetchStableAccessToken({
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
cacheKey: ACCESS_TOKEN_KEY,
|
||||||
|
appId: this.appId,
|
||||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
|
appSecret: this.appSecret,
|
||||||
url.searchParams.set('grant_type', 'client_credential');
|
forceRefresh,
|
||||||
url.searchParams.set('appid', this.appId);
|
label: '服务号',
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 小程序 access_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */
|
/** 小程序 access_token(getPhoneNumber / 发货管理等 wxa 接口必须用小程序 AppID) */
|
||||||
private async getMiniAccessToken(): Promise<string> {
|
private async getMiniAccessToken(forceRefresh = false): Promise<string> {
|
||||||
const appId = this.miniAppId;
|
const appId = this.miniAppId;
|
||||||
const appSecret = this.miniAppSecret;
|
const appSecret = this.miniAppSecret;
|
||||||
if (!appId || !appSecret) {
|
if (!appId || !appSecret) {
|
||||||
@@ -577,22 +577,60 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
|
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const cached = await this.redis.getJson<TokenCache>(MINI_ACCESS_TOKEN_KEY);
|
return this.fetchStableAccessToken({
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
cacheKey: MINI_ACCESS_TOKEN_KEY,
|
||||||
|
appId,
|
||||||
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
|
appSecret,
|
||||||
url.searchParams.set('grant_type', 'client_credential');
|
forceRefresh,
|
||||||
url.searchParams.set('appid', appId);
|
label: '小程序',
|
||||||
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);
|
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||||||
await this.redis.setJson(
|
await this.redis.setJson(
|
||||||
MINI_ACCESS_TOKEN_KEY,
|
opts.cacheKey,
|
||||||
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
|
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
|
||||||
ttl,
|
ttl,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user