弱网核销功能

This commit is contained in:
2026-07-12 12:00:55 +08:00
parent de01d36cdb
commit 06b1cb22e0
24 changed files with 1434 additions and 42 deletions
+11 -2
View File
@@ -124,10 +124,19 @@ async function rawRequest<T>(
if (authToken) headers.Authorization = `Bearer ${authToken}`;
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
const json = await res.json();
let json: { code: number; message?: string; data?: T };
try {
json = await res.json();
} catch {
const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status}`) as Error & {
status?: number;
};
err.status = res.status;
throw err;
}
if (json.code !== 0) {
const err = new Error(json.message || '请求失败') as Error & { status?: number };
err.status = json.code;
err.status = res.status >= 500 ? res.status : json.code;
throw err;
}
return json.data as T;
+50
View File
@@ -0,0 +1,50 @@
import { REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types';
import type { RedeemErrorClass, RedeemFailureReportResult } from '@dukang/shared-types';
import { request } from './api';
export function isNetworkError(e: unknown): boolean {
const err = e as Error & { status?: number };
const status = err.status;
const message = (err.message ?? String(e)).toLowerCase();
if (status != null && status >= 500) return true;
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
return true;
}
if (/failed to fetch|network|timeout|timed out|abort|offline|连接|网络|超时/.test(message)) {
return true;
}
// 无 status 的 fetch 失败通常是网络类
if (status == null && e instanceof TypeError) return true;
return false;
}
export async function reportRedeemFailure(
token: string,
step: 'preview' | 'confirm',
error: unknown,
): Promise<RedeemFailureReportResult | null> {
const err = error as Error & { status?: number };
const errorClass: RedeemErrorClass = isNetworkError(error) ? 'NETWORK' : 'BUSINESS';
try {
return await request<RedeemFailureReportResult>('SHOP_H5', '/shop/redeem/failures', {
method: 'POST',
body: JSON.stringify({
token,
errorClass,
message: err.message?.slice(0, 200),
step,
}),
});
} catch {
if (errorClass === 'NETWORK') {
return {
failCount: REDEEM_WEAKNET_FAIL_THRESHOLD,
thresholdReached: true,
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
};
}
return null;
}
}
export { REDEEM_WEAKNET_FAIL_THRESHOLD };
+91
View File
@@ -0,0 +1,91 @@
import { apiBase } from './api';
import { getStoreProfile } from './api';
const UPLOAD_TIMEOUT_MS = 120_000;
export type UploadFileResult = {
url: string;
ossKey: string;
bucket: string;
mock: boolean;
};
export type RegisteredResource = {
id: string;
url: string;
};
async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
const formData = new FormData();
formData.append('file', file);
formData.append('bizType', bizType);
formData.append('mediaType', 'IMAGE');
const token = localStorage.getItem('accessToken');
const headers: Record<string, string> = { 'X-Client-App': 'SHOP_H5' };
if (token) headers.Authorization = `Bearer ${token}`;
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
try {
const res = await fetch(`${apiBase}/common/resources/upload`, {
method: 'POST',
headers,
body: formData,
signal: controller.signal,
});
const json = await res.json();
if (json.code === 401) {
localStorage.removeItem('accessToken');
throw new Error('未登录');
}
if (json.code !== 0) {
throw new Error(json.message || '上传失败');
}
return json.data as UploadFileResult;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') {
throw new Error('上传超时,请检查网络后重试');
}
throw e;
} finally {
window.clearTimeout(timeoutId);
}
}
async function registerResource(upload: UploadFileResult, fileName: string): Promise<RegisteredResource> {
const profile = getStoreProfile();
if (!profile?.storeId) throw new Error('门店信息缺失,请重新登录');
const token = localStorage.getItem('accessToken');
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Client-App': 'SHOP_H5',
};
if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(`${apiBase}/common/resources`, {
method: 'POST',
headers,
body: JSON.stringify({
ownerType: 'STORE',
ownerId: profile.storeId,
bizType: 'REDEEM_PENDING_PHOTO',
mediaType: 'IMAGE',
ossKey: upload.ossKey,
url: upload.url,
ossBucket: upload.bucket,
fileName,
}),
});
const json = await res.json();
if (json.code !== 0) throw new Error(json.message || '登记资源失败');
return { id: String(json.data.id), url: String(json.data.url) };
}
/** 上传核销码照片并登记为 CommonResource,返回 resourceId */
export async function uploadRedeemPendingPhoto(file: File): Promise<RegisteredResource> {
const uploaded = await uploadFileToOss(file, 'REDEEM_PENDING_PHOTO');
return registerResource(uploaded, file.name || 'redeem-pending.jpg');
}