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:
2026-07-29 16:04:02 +08:00
parent a0a6c14758
commit 05dffbfa15
@@ -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_tokengetPhoneNumber 等 wxa 接口必须用小程序 AppID) */
private async getMiniAccessToken(): Promise<string> {
/** 小程序 access_tokengetPhoneNumber / 发货管理等 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,
);