弱网核销功能

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
+26
View File
@@ -7,6 +7,9 @@ import {
allocateBenefitCoupons,
calcBenefitSummary,
orderTabToStatuses,
classifyRedeemClientError,
generateRedeemPendingNo,
REDEEM_WEAKNET_FAIL_THRESHOLD,
} from './index';
describe('calcBenefitAmount', () => {
@@ -120,3 +123,26 @@ describe('calcBenefitSummary', () => {
});
});
});
describe('classifyRedeemClientError', () => {
it('treats 5xx and network messages as NETWORK', () => {
expect(classifyRedeemClientError({ status: 502 })).toBe('NETWORK');
expect(classifyRedeemClientError({ message: 'Failed to fetch' })).toBe('NETWORK');
expect(classifyRedeemClientError({ message: '网络超时' })).toBe('NETWORK');
});
it('treats business messages as BUSINESS', () => {
expect(classifyRedeemClientError({ status: 400, message: '核销码无效或已过期' })).toBe('BUSINESS');
expect(classifyRedeemClientError({ message: '余额不足' })).toBe('BUSINESS');
});
});
describe('generateRedeemPendingNo', () => {
it('uses RP prefix', () => {
expect(generateRedeemPendingNo().startsWith('RP')).toBe(true);
});
it('exports weaknet threshold of 5', () => {
expect(REDEEM_WEAKNET_FAIL_THRESHOLD).toBe(5);
});
});
+30
View File
@@ -111,6 +111,36 @@ export function generateRedeemNo(): string {
return `RD${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
export function generateRedeemPendingNo(): string {
return `RP${Date.now()}${Math.floor(Math.random() * 1000)}`;
}
/** 弱网核销:网络类失败达到该次数后触发兜底 */
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
/** 门店端/服务端共用:区分网络类与业务类核销错误(仅 NETWORK 计入 5 次) */
export function classifyRedeemClientError(input: {
status?: number | null;
message?: string | null;
}): RedeemErrorClass {
const status = input.status;
const message = (input.message ?? '').toLowerCase();
if (status != null && status >= 500) return 'NETWORK';
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
return 'NETWORK';
}
if (
/failed to fetch|network|timeout|timed out|econnreset|econnrefused|etimedout|abort|offline|连接|网络|超时/.test(
message,
)
) {
return 'NETWORK';
}
return 'BUSINESS';
}
export function orderTabToStatuses(tab: string): string[] | undefined {
switch (tab) {
case 'pending_pay':
+4
View File
@@ -180,3 +180,7 @@ export const SMS_CODE_TTL_SECONDS = 180;
export const REDEEM_RESULT_TTL_SECONDS = 360;
/** 手机号核销会话 TTL(查权益 → 选金额 → 确认) */
export const REDEEM_PHONE_SESSION_TTL_SECONDS = 600;
/** 弱网兜底:preview 快照 TTL(token 失效后仍可提交待处理单) */
export const REDEEM_PENDING_SNAPSHOT_TTL_SECONDS = 600;
/** 弱网核销:网络类失败阈值 */
export const REDEEM_WEAKNET_FAIL_THRESHOLD = 5;
+43
View File
@@ -44,3 +44,46 @@ export interface RedeemPhonePrepareDto {
amount: number;
expireInSeconds: number;
}
export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
PENDING: '待处理',
COMPLETED: '已补核销',
REJECTED: '已驳回',
};
export type RedeemErrorClass = 'NETWORK' | 'BUSINESS';
export type RedeemFailureReportResult = {
failCount: number;
thresholdReached: boolean;
threshold: number;
};
export type RedeemPendingSubmitResult = {
pendingId: string;
pendingNo: string;
redeemToken: string;
failCount: number;
};
export type RedeemPendingItem = {
id: string;
pendingNo: string;
redeemToken: string;
amount: number;
redeemType: string;
failCount: number;
status: RedeemPendingStatus;
remark?: string | null;
rejectReason?: string | null;
processedAt?: string | null;
createdAt: string;
updatedAt: string;
store?: { id: string; name: string; cityName?: string } | null;
user?: { id: string; userNo?: string | null; phone?: string | null; nickname?: string | null } | null;
photoUrl?: string | null;
redeemRecordId?: string | null;
redeemRecord?: { id: string; redeemNo: string } | null;
};
+12 -1
View File
@@ -8,7 +8,18 @@ export type StoreLogCategory =
export const STORE_LOG_EVENT_CATEGORIES: Record<StoreLogCategory, readonly string[]> = {
login: ['store_sms_send', 'store_sms_login', 'store_sms_verify_fail', 'store_login_success'],
wechat_auth: ['store_wechat_login', 'store_wechat_bind'],
redeem: ['store_redeem_preview', 'store_redeem_confirm'],
redeem: [
'store_redeem_preview',
'store_redeem_confirm',
'store_redeem_confirm_fail',
'store_redeem_weaknet_threshold',
'store_redeem_pending_submit',
'store_redeem_pending_complete',
'store_redeem_pending_reject',
'store_redeem_phone_lookup_sms',
'store_redeem_phone_balance',
'store_redeem_phone_prepare',
],
payout: ['store_payout_created', 'store_payout_paid'],
store_ops: ['store_status_change'],
};